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
This commit is contained in:
feelsodev
2026-02-17 22:18:56 +09:00
committed by YeonGyu-Kim
parent 63994cf2dd
commit 6d3b355762
6 changed files with 122 additions and 16 deletions
+9 -4
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 {
+78 -3
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()
})
+4 -7
View File
@@ -6,16 +6,13 @@ export function transformModelForProvider(provider: string, model: string): stri
.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", "gemini-3-pro-preview")
.replace("gemini-3-flash", "gemini-3-flash-preview")
.replace(/gemini-3-pro(?!-)/g, "gemini-3-pro-preview")
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
}
if (provider === "google") {
if (!model.endsWith("-preview")) {
return model
.replace("gemini-3-pro", "gemini-3-pro-preview")
.replace("gemini-3-flash", "gemini-3-flash-preview")
}
return model
.replace(/gemini-3-pro(?!-)/g, "gemini-3-pro-preview")
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
}
return model
}