Compare commits

..

3 Commits

Author SHA1 Message Date
feelsodev
4c7b81986a fix: add google provider model transform across all resolution paths
transformModelForProvider only handled github-copilot provider, leaving
google provider models untransformed. This caused ProviderModelNotFoundError
when google/gemini-3-flash was sent to the API (correct ID is
gemini-3-flash-preview).

Changes:
- Add google provider to transformModelForProvider with idempotent regex
  negative lookahead to prevent double -preview suffix
- Fix category-default path in model-resolution-pipeline when
  availableModels is empty but connected provider exists
- Fix getFirstFallbackModel first-run path that constructed raw model IDs
  without transformation
- Fix github-copilot provider gemini transforms to also use idempotent
  regex (was vulnerable to double-transform)
- Extract transformModelForProvider to shared module (single source of
  truth, imported by cli and shared layers)
- Add 20 new test cases: unit tests for both providers, runtime
  integration tests for category-default and fallback-chain paths,
  double-transform prevention for both providers
2026-02-21 02:29:02 +09:00
feelsodev
fec75535ba refactor: move transformModelForProvider to shared for runtime access
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-21 02:29:02 +09:00
once
e5a0ab4034 fix: add google provider model transform for gemini-3-flash/pro preview suffix
transformModelForProvider only handled github-copilot provider, leaving
google provider models untransformed. This caused ProviderModelNotFoundError
when google/gemini-3-flash was sent to the API (correct ID is
gemini-3-flash-preview).

Add google provider block with -preview suffix guard to prevent double
transformation.
2026-02-21 02:29:02 +09:00
11 changed files with 352 additions and 162 deletions

View File

@@ -1,4 +1,5 @@
import { resolveModelPipeline } from "../../shared"
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
export function applyModelResolution(input: {
uiSelectedModel?: string
@@ -20,8 +21,10 @@ export function getFirstFallbackModel(requirement?: {
}) {
const entry = requirement?.fallbackChain?.[0]
if (!entry || entry.providers.length === 0) return undefined
const provider = entry.providers[0]
const transformedModel = transformModelForProvider(provider, entry.model)
return {
model: `${entry.providers[0]}/${entry.model}`,
model: `${provider}/${transformedModel}`,
provenance: "provider-fallback" as const,
variant: entry.variant,
}

View File

@@ -334,48 +334,48 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"model": "opencode/minimax-m2.5-free",
},
"metis": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"momus": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"multimodal-looker": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"prometheus": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
},
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"quick": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
"ultrabrain": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"unspecified-high": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
"unspecified-low": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
},
}
@@ -395,48 +395,48 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
"model": "opencode/minimax-m2.5-free",
},
"metis": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"momus": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"multimodal-looker": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"prometheus": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
},
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"quick": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
"ultrabrain": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"unspecified-high": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
},
"unspecified-low": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
},
}
@@ -468,7 +468,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
"variant": "medium",
},
"multimodal-looker": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "openai/gpt-5.2",
@@ -485,7 +485,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"deep": {
@@ -506,11 +506,11 @@ exports[`generateModelConfig all native providers uses preferred models from fal
"model": "anthropic/claude-sonnet-4-6",
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
},
}
@@ -542,7 +542,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
"variant": "medium",
},
"multimodal-looker": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "openai/gpt-5.2",
@@ -559,7 +559,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"deep": {
@@ -581,11 +581,11 @@ exports[`generateModelConfig all native providers uses preferred models with isM
"model": "anthropic/claude-sonnet-4-6",
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
},
}
@@ -1230,10 +1230,10 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
"variant": "max",
},
"multimodal-looker": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"prometheus": {
@@ -1247,14 +1247,14 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
"ultrabrain": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"unspecified-high": {
@@ -1264,11 +1264,11 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
"model": "anthropic/claude-sonnet-4-6",
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
},
}
@@ -1391,7 +1391,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"deep": {
@@ -1412,11 +1412,11 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
"model": "anthropic/claude-sonnet-4-6",
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
},
}
@@ -1465,7 +1465,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"deep": {
@@ -1487,11 +1487,11 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
"model": "anthropic/claude-sonnet-4-6",
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"model": "google/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
"model": "google/gemini-3-flash-preview",
},
},
}

View File

@@ -0,0 +1,191 @@
import { describe, expect, test } from "bun:test"
import { transformModelForProvider } from "./provider-model-id-transform"
describe("transformModelForProvider", () => {
describe("github-copilot provider", () => {
test("transforms claude-opus-4-6 to claude-opus-4.6", () => {
// #given github-copilot provider and claude-opus-4-6 model
const provider = "github-copilot"
const model = "claude-opus-4-6"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to claude-opus-4.6
expect(result).toBe("claude-opus-4.6")
})
test("transforms claude-sonnet-4-5 to claude-sonnet-4.5", () => {
// #given github-copilot provider and claude-sonnet-4-5 model
const provider = "github-copilot"
const model = "claude-sonnet-4-5"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to claude-sonnet-4.5
expect(result).toBe("claude-sonnet-4.5")
})
test("transforms claude-haiku-4-5 to claude-haiku-4.5", () => {
// #given github-copilot provider and claude-haiku-4-5 model
const provider = "github-copilot"
const model = "claude-haiku-4-5"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to claude-haiku-4.5
expect(result).toBe("claude-haiku-4.5")
})
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
// #given github-copilot provider and gemini-3-pro model
const provider = "github-copilot"
const model = "gemini-3-pro"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to gemini-3-pro-preview
expect(result).toBe("gemini-3-pro-preview")
})
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
// #given github-copilot provider and gemini-3-flash model
const provider = "github-copilot"
const model = "gemini-3-flash"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to gemini-3-flash-preview
expect(result).toBe("gemini-3-flash-preview")
})
test("prevents double transformation of gemini-3-pro-preview", () => {
// #given github-copilot provider and gemini-3-pro-preview model (already transformed)
const provider = "github-copilot"
const model = "gemini-3-pro-preview"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should NOT become gemini-3-pro-preview-preview
expect(result).toBe("gemini-3-pro-preview")
})
test("prevents double transformation of gemini-3-flash-preview", () => {
// #given github-copilot provider and gemini-3-flash-preview model (already transformed)
const provider = "github-copilot"
const model = "gemini-3-flash-preview"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should NOT become gemini-3-flash-preview-preview
expect(result).toBe("gemini-3-flash-preview")
})
})
describe("google provider", () => {
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
// #given google provider and gemini-3-flash model
const provider = "google"
const model = "gemini-3-flash"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to gemini-3-flash-preview
expect(result).toBe("gemini-3-flash-preview")
})
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
// #given google provider and gemini-3-pro model
const provider = "google"
const model = "gemini-3-pro"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to gemini-3-pro-preview
expect(result).toBe("gemini-3-pro-preview")
})
test("passes through other gemini models unchanged", () => {
// #given google provider and gemini-2.5-flash model
const provider = "google"
const model = "gemini-2.5-flash"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should pass through unchanged
expect(result).toBe("gemini-2.5-flash")
})
test("prevents double transformation of gemini-3-flash-preview", () => {
// #given google provider and gemini-3-flash-preview model (already transformed)
const provider = "google"
const model = "gemini-3-flash-preview"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should NOT become gemini-3-flash-preview-preview
expect(result).toBe("gemini-3-flash-preview")
})
test("prevents double transformation of gemini-3-pro-preview", () => {
// #given google provider and gemini-3-pro-preview model (already transformed)
const provider = "google"
const model = "gemini-3-pro-preview"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should NOT become gemini-3-pro-preview-preview
expect(result).toBe("gemini-3-pro-preview")
})
test("does not transform claude models for google provider", () => {
// #given google provider and claude-opus-4-6 model
const provider = "google"
const model = "claude-opus-4-6"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should pass through unchanged (google doesn't use claude)
expect(result).toBe("claude-opus-4-6")
})
})
describe("unknown provider", () => {
test("passes model through unchanged for unknown provider", () => {
// #given unknown provider and any model
const provider = "unknown-provider"
const model = "some-model"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should pass through unchanged
expect(result).toBe("some-model")
})
test("passes gemini-3-flash through unchanged for unknown provider", () => {
// #given unknown provider and gemini-3-flash model
const provider = "unknown-provider"
const model = "gemini-3-flash"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should pass through unchanged (no transformation for unknown provider)
expect(result).toBe("gemini-3-flash")
})
})
})

View File

@@ -1,12 +1 @@
export function transformModelForProvider(provider: string, model: string): string {
if (provider === "github-copilot") {
return model
.replace("claude-opus-4-6", "claude-opus-4.6")
.replace("claude-sonnet-4-6", "claude-sonnet-4.6")
.replace("claude-haiku-4-5", "claude-haiku-4.5")
.replace("claude-sonnet-4", "claude-sonnet-4")
.replace("gemini-3-pro", "gemini-3-pro-preview")
.replace("gemini-3-flash", "gemini-3-flash-preview")
}
return model
}
export { transformModelForProvider } from "../shared/provider-model-id-transform"

View File

@@ -3941,96 +3941,3 @@ describe("BackgroundManager regression fixes - resume and aborted notification",
manager.shutdown()
})
})
describe("BackgroundManager - tool permission spread order", () => {
test("startTask respects explore agent restrictions", async () => {
//#given
let capturedTools: Record<string, unknown> | undefined
const client = {
session: {
get: async () => ({ data: { directory: "/test/dir" } }),
create: async () => ({ data: { id: "session-1" } }),
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
capturedTools = args.body.tools as Record<string, unknown>
return {}
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-1",
status: "pending",
queuedAt: new Date(),
description: "test task",
prompt: "test prompt",
agent: "explore",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
}
//#when
await (manager as unknown as { startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> })
.startTask({ task, input })
//#then
expect(capturedTools).toBeDefined()
expect(capturedTools?.call_omo_agent).toBe(false)
expect(capturedTools?.task).toBe(false)
expect(capturedTools?.write).toBe(false)
expect(capturedTools?.edit).toBe(false)
manager.shutdown()
})
test("resume respects explore agent restrictions", async () => {
//#given
let capturedTools: Record<string, unknown> | undefined
const client = {
session: {
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
capturedTools = args.body.tools as Record<string, unknown>
return {}
},
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-2",
sessionID: "session-2",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
description: "resume task",
prompt: "resume prompt",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getTaskMap(manager).set(task.id, task)
//#when
await manager.resume({
sessionId: "session-2",
prompt: "continue",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
})
//#then
expect(capturedTools).toBeDefined()
expect(capturedTools?.call_omo_agent).toBe(false)
expect(capturedTools?.task).toBe(false)
expect(capturedTools?.write).toBe(false)
expect(capturedTools?.edit).toBe(false)
manager.shutdown()
})
})

View File

@@ -355,10 +355,10 @@ export class BackgroundManager {
system: input.skillContent,
tools: (() => {
const tools = {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent),
}
setSessionTools(sessionID, tools)
return tools
@@ -628,10 +628,10 @@ export class BackgroundManager {
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: (() => {
const tools = {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(existingTask.agent),
}
setSessionTools(existingTask.sessionID!, tools)
return tools

View File

@@ -141,10 +141,10 @@ export async function startTask(
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent),
},
parts: [{ type: "text", text: input.prompt }],
},
@@ -225,10 +225,10 @@ export async function resumeTask(
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
...getAgentToolRestrictions(task.agent),
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(task.agent),
},
parts: [{ type: "text", text: input.prompt }],
},

View File

@@ -2,6 +2,7 @@ import { log } from "./logger"
import * as connectedProvidersCache from "./connected-providers-cache"
import { fuzzyMatchModel } from "./model-availability"
import type { FallbackEntry } from "./model-requirements"
import { transformModelForProvider } from "./provider-model-id-transform"
export type ModelResolutionRequest = {
intent?: {
@@ -85,10 +86,13 @@ export function resolveModelPipeline(
if (parts.length >= 2) {
const provider = parts[0]
if (connectedProviders.includes(provider)) {
const modelName = parts.slice(1).join("/")
const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}`
log("Model resolved via category default (connected provider)", {
model: normalizedCategoryDefault,
model: transformedModel,
original: normalizedCategoryDefault,
})
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
return { model: transformedModel, provenance: "category-default", attempted }
}
}
}
@@ -108,10 +112,11 @@ export function resolveModelPipeline(
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (connectedSet.has(provider)) {
const model = `${provider}/${entry.model}`
const transformedModelId = transformModelForProvider(provider, entry.model)
const model = `${provider}/${transformedModelId}`
log("Model resolved via fallback chain (connected provider)", {
provider,
model: entry.model,
model: transformedModelId,
variant: entry.variant,
})
return {

View File

@@ -543,7 +543,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// then - should use github-copilot (second provider) since google not connected
expect(result!.model).toBe("github-copilot/gemini-3-pro")
// model name is transformed to preview variant for github-copilot provider
expect(result!.model).toBe("github-copilot/gemini-3-pro-preview")
expect(result!.source).toBe("provider-fallback")
cacheSpy.mockRestore()
})
@@ -795,8 +796,82 @@ describe("resolveModelWithFallback", () => {
// when
const result = resolveModelWithFallback(input)
// then - should use categoryDefaultModel since google is connected
expect(result!.model).toBe("google/gemini-3-pro")
// then - should use transformed categoryDefaultModel since google is connected
expect(result!.model).toBe("google/gemini-3-pro-preview")
expect(result!.source).toBe("category-default")
cacheSpy.mockRestore()
})
test("transforms gemini-3-flash in categoryDefaultModel for google connected provider", () => {
// given - google connected, category default uses gemini-3-flash
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3-flash",
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-5",
}
// when
const result = resolveModelWithFallback(input)
// then - gemini-3-flash should be transformed to gemini-3-flash-preview
expect(result!.model).toBe("google/gemini-3-flash-preview")
expect(result!.source).toBe("category-default")
cacheSpy.mockRestore()
})
test("does not double-transform categoryDefaultModel already containing -preview", () => {
// given - category default already has -preview suffix
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3-pro-preview",
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-5",
}
// when
const result = resolveModelWithFallback(input)
// then - should NOT become gemini-3-pro-preview-preview
expect(result!.model).toBe("google/gemini-3-pro-preview")
expect(result!.source).toBe("category-default")
cacheSpy.mockRestore()
})
test("transforms gemini-3-pro in fallback chain for google connected provider", () => {
// given - google connected, fallback chain has gemini-3-pro
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["google", "github-copilot"], model: "gemini-3-pro" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-5",
}
// when
const result = resolveModelWithFallback(input)
// then - should transform to preview variant for google provider
expect(result!.model).toBe("google/gemini-3-pro-preview")
expect(result!.source).toBe("provider-fallback")
cacheSpy.mockRestore()
})
test("passes through non-gemini-3 models for google connected provider", () => {
// given - google connected, category default uses gemini-2.5-flash (no transform needed)
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-2.5-flash",
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-5",
}
// when
const result = resolveModelWithFallback(input)
// then - should pass through unchanged
expect(result!.model).toBe("google/gemini-2.5-flash")
expect(result!.source).toBe("category-default")
cacheSpy.mockRestore()
})

View File

@@ -0,0 +1,18 @@
export function transformModelForProvider(provider: string, model: string): string {
if (provider === "github-copilot") {
return model
.replace("claude-opus-4-6", "claude-opus-4.6")
.replace("claude-sonnet-4-6", "claude-sonnet-4.6")
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
.replace("claude-haiku-4-5", "claude-haiku-4.5")
.replace("claude-sonnet-4", "claude-sonnet-4")
.replace(/gemini-3-pro(?!-)/g, "gemini-3-pro-preview")
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
}
if (provider === "google") {
return model
.replace(/gemini-3-pro(?!-)/g, "gemini-3-pro-preview")
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
}
return model
}

View File

@@ -1,5 +1,6 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import { fuzzyMatchModel } from "../../shared/model-availability"
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
function normalizeModel(model?: string): string | undefined {
const trimmed = model?.trim()
@@ -38,7 +39,8 @@ export function resolveModelForDelegateTask(input: {
const first = fallbackChain[0]
const provider = first?.providers?.[0]
if (provider) {
return { model: `${provider}/${first.model}`, variant: first.variant }
const transformedModelId = transformModelForProvider(provider, first.model)
return { model: `${provider}/${transformedModelId}`, variant: first.variant }
}
} else {
for (const entry of fallbackChain) {