refactor(model-core): host snapshot fetcher, suggestion parser, and context-limit resolver

Move three pure helpers from src/shared/ into @oh-my-opencode/model-core so the package can stand alone without depending on plugin internals:

- buildModelCapabilitiesSnapshotFromModelsDev + fetchModelCapabilitiesSnapshot (models.dev normalization)
- parseModelSuggestion (cross-provider ProviderModelNotFoundError suggestion extraction)
- resolveActualContextLimit (Anthropic GA 1M context override)

Split provider-model-id-transform into two variants exposed by model-core:

- transformModelForProvider keeps the runtime dash to dot Anthropic rewrite used by the SDK
- transformModelForProviderDisplay preserves hyphenated Anthropic IDs so the installer writes registry-compatible model strings, fixing the ProviderModelNotFoundError fresh installs hit when the dotted form leaks into the config

src/shared/* and src/cli/provider-model-id-transform.ts collapse to re-export shims that point at the new core modules. Stale src/shared/{known-variants,model-capability-aliases,model-capability-guardrails,model-capability-heuristics}.ts re-export files plus the duplicated context-limit-resolver test are removed in favor of the canonical model-core copies.

Tests: bun test packages/model-core src/shared/model-capabilities-cache.test.ts src/cli/provider-model-id-transform.test.ts
This commit is contained in:
YeonGyu-Kim
2026-05-21 16:02:41 +09:00
parent 4ea76365cd
commit edaa95fec0
22 changed files with 671 additions and 767 deletions
@@ -0,0 +1,92 @@
import process from "node:process"
import { afterEach, describe, expect, it } from "bun:test"
import { resolveActualContextLimit } from "./context-limit-resolver"
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
const originalAnthropicContextEnv = process.env[ANTHROPIC_CONTEXT_ENV_KEY]
const originalVertexContextEnv = process.env[VERTEX_CONTEXT_ENV_KEY]
function restoreContextLimitEnv(): void {
if (originalAnthropicContextEnv === undefined) {
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
} else {
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = originalAnthropicContextEnv
}
if (originalVertexContextEnv === undefined) {
delete process.env[VERTEX_CONTEXT_ENV_KEY]
} else {
process.env[VERTEX_CONTEXT_ENV_KEY] = originalVertexContextEnv
}
}
describe("resolveActualContextLimit", () => {
afterEach(() => {
restoreContextLimitEnv()
})
it("returns cached limit for non-Anthropic providers", () => {
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("openai/gpt-5", 400_000)
const actualLimit = resolveActualContextLimit("openai", "gpt-5", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
expect(actualLimit).toBe(400_000)
})
it("returns GA 1M for Anthropic 4.6/4.7 models without explicit 1M mode", () => {
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-6", {
anthropicContext1MEnabled: false,
})
expect(actualLimit).toBe(1_000_000)
})
it("uses cached limit for GA Anthropic models when cache exists", () => {
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-opus-4-7", 700_000)
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4-7", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
expect(actualLimit).toBe(700_000)
})
it("returns 1M when ANTHROPIC_1M_CONTEXT=true regardless of model", () => {
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = "true"
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 200_000)
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
expect(actualLimit).toBe(1_000_000)
})
it("returns 1M when VERTEX_ANTHROPIC_1M_CONTEXT=true for Anthropic aliases", () => {
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
process.env[VERTEX_CONTEXT_ENV_KEY] = "true"
const actualLimit = resolveActualContextLimit("google-vertex-anthropic", "claude-sonnet-4-5", {
anthropicContext1MEnabled: false,
})
expect(actualLimit).toBe(1_000_000)
})
})
@@ -0,0 +1,46 @@
import process from "node:process"
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000
const ANTHROPIC_GA_1M_LIMIT = 1_000_000
export type ContextLimitModelCacheState = {
anthropicContext1MEnabled: boolean
modelContextLimitsCache?: Map<string, number>
}
function isAnthropicProvider(providerID: string): boolean {
const normalized = providerID.toLowerCase()
return normalized === "anthropic" || normalized === "google-vertex-anthropic" || normalized === "aws-bedrock-anthropic"
}
function getAnthropicActualLimit(modelCacheState?: ContextLimitModelCacheState): number {
return (modelCacheState?.anthropicContext1MEnabled ?? false) ||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
? ANTHROPIC_GA_1M_LIMIT
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
function hasGA1MContext(modelID: string): boolean {
return /^claude-(opus|sonnet)-4(?:-|\.)(?:6|7)(?:-high)?$/.test(modelID)
}
export function resolveActualContextLimit(
providerID: string,
modelID: string,
modelCacheState?: ContextLimitModelCacheState,
): number | null {
if (isAnthropicProvider(providerID)) {
const explicit1M = getAnthropicActualLimit(modelCacheState)
if (explicit1M === ANTHROPIC_GA_1M_LIMIT) return explicit1M
const cachedLimit = modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`)
if (cachedLimit && hasGA1MContext(modelID)) return cachedLimit
if (hasGA1MContext(modelID)) return ANTHROPIC_GA_1M_LIMIT
return DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
return modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`) ?? null
}
+7 -1
View File
@@ -28,7 +28,10 @@ export {
fuzzyMatchModel,
isModelAvailable,
} from "./model-availability"
export { transformModelForProvider } from "./provider-model-id-transform"
export {
transformModelForProvider,
transformModelForProviderDisplay,
} from "./provider-model-id-transform"
export * from "./fallback-chain-from-models"
export * from "./known-variants"
export {
@@ -42,3 +45,6 @@ export type {
} from "./model-resolution-pipeline"
export * from "./model-error-classifier"
export * from "./model-capabilities"
export * from "./context-limit-resolver"
export * from "./model-capabilities-snapshot"
export * from "./parse-model-suggestion"
@@ -0,0 +1,117 @@
import { describe, expect, test } from "bun:test"
import {
buildModelCapabilitiesSnapshotFromModelsDev,
fetchModelCapabilitiesSnapshot,
} from "./model-capabilities-snapshot"
describe("model-capabilities-snapshot", () => {
test("builds a normalized snapshot from models.dev provider data", () => {
const raw = {
openai: {
models: {
"gpt-5.4": {
id: "gpt-5.4",
family: "gpt",
reasoning: true,
temperature: false,
tool_call: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 1_050_000,
output: 128_000,
},
},
},
},
}
const snapshot = buildModelCapabilitiesSnapshotFromModelsDev(raw)
expect(snapshot.sourceUrl).toBe("https://models.dev/api.json")
expect(snapshot.models["gpt-5.4"]).toEqual({
id: "gpt-5.4",
family: "gpt",
reasoning: true,
temperature: false,
toolCall: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 1_050_000,
output: 128_000,
},
})
})
test("ignores malformed provider entries and missing fields", () => {
const raw = {
invalidProvider: null,
anthropic: {
models: {
"claude-sonnet-4-6": {
reasoning: true,
},
"bad-model": "invalid",
},
},
openai: {
models: {
"gpt-5.4": {
id: "GPT-5.4",
modalities: {
input: ["text", 1],
},
},
},
},
}
const snapshot = buildModelCapabilitiesSnapshotFromModelsDev(raw)
expect(snapshot.models["claude-sonnet-4-6"]).toEqual({
id: "claude-sonnet-4-6",
reasoning: true,
})
expect(snapshot.models["gpt-5.4"]).toEqual({
id: "GPT-5.4",
modalities: {
input: ["text"],
},
})
expect(snapshot.models["bad-model"]).toBeUndefined()
})
test("fetches snapshot using injected fetch implementation", async () => {
const sourceUrl = "https://fixture.local/models.json"
const fetchImpl = async () =>
new Response(
JSON.stringify({
openai: {
models: {
"gpt-5.4": {
id: "gpt-5.4",
limit: {
output: 128_000,
},
},
},
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
const snapshot = await fetchModelCapabilitiesSnapshot({ sourceUrl, fetchImpl })
expect(snapshot.sourceUrl).toBe(sourceUrl)
expect(snapshot.models["gpt-5.4"]?.limit?.output).toBe(128_000)
})
})
@@ -0,0 +1,160 @@
import type {
ModelCapabilitiesSnapshot,
ModelCapabilitiesSnapshotEntry,
} from "./model-capabilities"
export const MODELS_DEV_SOURCE_URL = "https://models.dev/api.json"
type FetchImpl = (input: string) => Promise<Response>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined
}
function readStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const result = value.filter((item): item is string => typeof item === "string")
return result.length > 0 ? result : undefined
}
function normalizeSnapshotEntry(rawModelID: string, rawModel: unknown): ModelCapabilitiesSnapshotEntry | undefined {
if (!isRecord(rawModel)) {
return undefined
}
const id = readString(rawModel.id) ?? rawModelID
const family = readString(rawModel.family)
const reasoning = readBoolean(rawModel.reasoning)
const temperature = readBoolean(rawModel.temperature)
const toolCall = readBoolean(rawModel.tool_call)
const rawModalities = isRecord(rawModel.modalities) ? rawModel.modalities : undefined
const modalitiesInput = readStringArray(rawModalities?.input)
const modalitiesOutput = readStringArray(rawModalities?.output)
const modalities = modalitiesInput || modalitiesOutput
? {
...(modalitiesInput ? { input: modalitiesInput } : {}),
...(modalitiesOutput ? { output: modalitiesOutput } : {}),
}
: undefined
const rawLimit = isRecord(rawModel.limit) ? rawModel.limit : undefined
const limitContext = readNumber(rawLimit?.context)
const limitInput = readNumber(rawLimit?.input)
const limitOutput = readNumber(rawLimit?.output)
const limit = limitContext !== undefined || limitInput !== undefined || limitOutput !== undefined
? {
...(limitContext !== undefined ? { context: limitContext } : {}),
...(limitInput !== undefined ? { input: limitInput } : {}),
...(limitOutput !== undefined ? { output: limitOutput } : {}),
}
: undefined
return {
id,
...(family ? { family } : {}),
...(reasoning !== undefined ? { reasoning } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(toolCall !== undefined ? { toolCall } : {}),
...(modalities ? { modalities } : {}),
...(limit ? { limit } : {}),
}
}
function mergeSnapshotEntries(
existing: ModelCapabilitiesSnapshotEntry | undefined,
incoming: ModelCapabilitiesSnapshotEntry,
): ModelCapabilitiesSnapshotEntry {
if (!existing) {
return incoming
}
const mergedModalities = existing.modalities || incoming.modalities
? {
...existing.modalities,
...incoming.modalities,
}
: undefined
const mergedLimit = existing.limit || incoming.limit
? {
...existing.limit,
...incoming.limit,
}
: undefined
return {
...existing,
...incoming,
...(mergedModalities ? { modalities: mergedModalities } : {}),
...(mergedLimit ? { limit: mergedLimit } : {}),
}
}
export function buildModelCapabilitiesSnapshotFromModelsDev(raw: unknown): ModelCapabilitiesSnapshot {
const models: Record<string, ModelCapabilitiesSnapshotEntry> = {}
const providers = isRecord(raw) ? raw : {}
for (const providerValue of Object.values(providers)) {
if (!isRecord(providerValue)) {
continue
}
const providerModels = providerValue.models
if (!isRecord(providerModels)) {
continue
}
for (const [rawModelID, rawModel] of Object.entries(providerModels)) {
const normalizedEntry = normalizeSnapshotEntry(rawModelID, rawModel)
if (!normalizedEntry) {
continue
}
models[normalizedEntry.id.toLowerCase()] = mergeSnapshotEntries(
models[normalizedEntry.id.toLowerCase()],
normalizedEntry,
)
}
}
return {
generatedAt: new Date().toISOString(),
sourceUrl: MODELS_DEV_SOURCE_URL,
models,
}
}
export async function fetchModelCapabilitiesSnapshot(args: {
sourceUrl?: string
fetchImpl?: FetchImpl
} = {}): Promise<ModelCapabilitiesSnapshot> {
const sourceUrl = args.sourceUrl ?? MODELS_DEV_SOURCE_URL
const fetchImpl = args.fetchImpl ?? fetch
const response = await fetchImpl(sourceUrl)
if (!response.ok) {
throw new Error(`models.dev fetch failed with ${response.status}`)
}
const raw = await response.json()
const snapshot = buildModelCapabilitiesSnapshotFromModelsDev(raw)
return {
...snapshot,
sourceUrl,
}
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "bun:test"
import { parseModelSuggestion } from "./parse-model-suggestion"
describe("parseModelSuggestion", () => {
it("extracts suggestions from structured Anthropic ProviderModelNotFoundError", () => {
const error = {
name: "ProviderModelNotFoundError",
data: {
providerID: "anthropic",
modelID: "claude-sonet-4",
suggestions: ["claude-sonnet-4", "claude-sonnet-4-6"],
},
}
expect(parseModelSuggestion(error)).toEqual({
providerID: "anthropic",
modelID: "claude-sonet-4",
suggestion: "claude-sonnet-4",
})
})
it("extracts suggestions from nested OpenAI errors", () => {
const error = {
data: {
name: "ProviderModelNotFoundError",
data: {
providerID: "openai",
modelID: "gpt-5",
suggestions: ["gpt-5.4"],
},
},
}
expect(parseModelSuggestion(error)).toEqual({
providerID: "openai",
modelID: "gpt-5",
suggestion: "gpt-5.4",
})
})
it("extracts suggestions from Bedrock-style model-not-found messages", () => {
const error = new Error(
"Model not found: aws-bedrock-anthropic/claude-sonet-4. Did you mean: claude-sonnet-4, claude-sonnet-4-6?",
)
expect(parseModelSuggestion(error)).toEqual({
providerID: "aws-bedrock-anthropic",
modelID: "claude-sonet-4",
suggestion: "claude-sonnet-4",
})
})
it("extracts suggestions from plain string message payloads", () => {
const error = "Model not found: openai/gtp-5. Did you mean: gpt-5?"
expect(parseModelSuggestion(error)).toEqual({
providerID: "openai",
modelID: "gtp-5",
suggestion: "gpt-5",
})
})
it("returns null for unrelated errors", () => {
expect(parseModelSuggestion(new Error("Connection timeout"))).toBeNull()
expect(parseModelSuggestion(null)).toBeNull()
})
})
@@ -0,0 +1,65 @@
export interface ModelSuggestionInfo {
providerID: string
modelID: string
suggestion: string
}
function extractMessage(error: unknown): string {
if (typeof error === "string") return error
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null) {
const obj = error as Record<string, unknown>
if (typeof obj.message === "string") return obj.message
try {
return JSON.stringify(error)
} catch {
return ""
}
}
return String(error)
}
export function parseModelSuggestion(error: unknown): ModelSuggestionInfo | null {
if (!error) return null
if (typeof error === "object") {
const errObj = error as Record<string, unknown>
if (errObj.name === "ProviderModelNotFoundError" && typeof errObj.data === "object" && errObj.data !== null) {
const data = errObj.data as Record<string, unknown>
const suggestions = data.suggestions
if (Array.isArray(suggestions) && suggestions.length > 0 && typeof suggestions[0] === "string") {
return {
providerID: String(data.providerID ?? ""),
modelID: String(data.modelID ?? ""),
suggestion: suggestions[0],
}
}
return null
}
for (const key of ["data", "error", "cause"] as const) {
const nested = errObj[key]
if (nested && typeof nested === "object") {
const result = parseModelSuggestion(nested)
if (result) return result
}
}
}
const message = extractMessage(error)
if (!message) return null
const modelMatch = message.match(/model not found:\s*([^/\s]+)\s*\/\s*([^.\s]+)/i)
const suggestionMatch = message.match(/did you mean:\s*([^,?]+)/i)
if (modelMatch && suggestionMatch) {
return {
providerID: modelMatch[1].trim(),
modelID: modelMatch[2].trim(),
suggestion: suggestionMatch[1].trim(),
}
}
return null
}
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test"
import {
transformModelForProvider,
transformModelForProviderDisplay,
} from "./provider-model-id-transform"
describe("provider model ID transforms", () => {
test("keeps separate Anthropic API and display behavior", () => {
// #given an Anthropic model ID in config-display form
const provider = "anthropic"
const model = "claude-opus-4-7"
// #when both model-core transform variants are called
const apiResult = transformModelForProvider(provider, model)
const displayResult = transformModelForProviderDisplay(provider, model)
// #then API calls use dotted Anthropic versions while display keeps hyphens
expect(apiResult).toBe("claude-opus-4.7")
expect(displayResult).toBe("claude-opus-4-7")
})
test("produces identical results for non-Anthropic providers", () => {
// #given non-Anthropic provider/model pairs
const scenarios = [
{ provider: "openai", model: "gpt-4o" },
{ provider: "google", model: "gemini-2.5-pro" },
{ provider: "github-copilot", model: "gemini-3-flash" },
{ provider: "vercel", model: "claude-opus-4-7" },
] as const
for (const scenario of scenarios) {
// #when both transform variants are called
const apiResult = transformModelForProvider(
scenario.provider,
scenario.model,
)
const displayResult = transformModelForProviderDisplay(
scenario.provider,
scenario.model,
)
// #then the variants match outside the direct Anthropic provider branch
expect(displayResult).toBe(apiResult)
}
})
})
@@ -24,7 +24,11 @@ function applyGatewayTransforms(model: string): string {
)
}
export function transformModelForProvider(provider: string, model: string): string {
function transformModelForProviderUsingAnthropicBehavior(
provider: string,
model: string,
directAnthropicTransform: (model: string) => string,
): string {
if (provider === "vercel") {
const slashIndex = model.indexOf("/")
if (slashIndex !== -1) {
@@ -49,7 +53,26 @@ export function transformModelForProvider(provider: string, model: string): stri
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "anthropic") {
return claudeVersionDot(model)
return directAnthropicTransform(model)
}
return model
}
export function transformModelForProvider(provider: string, model: string): string {
return transformModelForProviderUsingAnthropicBehavior(
provider,
model,
claudeVersionDot,
)
}
export function transformModelForProviderDisplay(
provider: string,
model: string,
): string {
return transformModelForProviderUsingAnthropicBehavior(
provider,
model,
(model) => model,
)
}
+21 -7
View File
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"
import { transformModelForProvider } from "./provider-model-id-transform"
import { transformModelForProvider as transformSharedModelForProvider } from "../shared/provider-model-id-transform"
import {
transformModelForProvider as transformRuntimeModelForProvider,
transformModelForProviderDisplay as transformModelForProvider,
} from "@oh-my-opencode/model-core"
describe("transformModelForProvider", () => {
describe("github-copilot provider", () => {
@@ -338,16 +340,28 @@ describe("transformModelForProvider", () => {
})
})
test("uses a CLI-local transform implementation distinct from the shared runtime transform", () => {
// #given the CLI transform (used by the installer) and the shared runtime transform
test("uses separate display and runtime transform implementations", () => {
// #given the display transform (used by the installer) and the runtime transform
const cliResult = transformModelForProvider("anthropic", "claude-opus-4-7")
const sharedResult = transformSharedModelForProvider("anthropic", "claude-opus-4-7")
const runtimeResult = transformRuntimeModelForProvider("anthropic", "claude-opus-4-7")
const nonAnthropicScenarios = [
{ provider: "openai", model: "gpt-4o" },
{ provider: "google", model: "gemini-2.5-pro" },
{ provider: "github-copilot", model: "gemini-3-flash" },
{ provider: "vercel", model: "claude-opus-4-7" },
] as const
// #when both are called with the same anthropic claude input
// #then the CLI preserves hyphenated form for config output,
// the shared runtime transform converts dash→dot for API calls
expect(transformModelForProvider).not.toBe(transformSharedModelForProvider)
expect(transformModelForProvider).not.toBe(transformRuntimeModelForProvider)
expect(cliResult).toBe("claude-opus-4-7")
expect(sharedResult).toBe("claude-opus-4.7")
expect(runtimeResult).toBe("claude-opus-4.7")
for (const scenario of nonAnthropicScenarios) {
expect(transformModelForProvider(scenario.provider, scenario.model)).toBe(
transformRuntimeModelForProvider(scenario.provider, scenario.model),
)
}
})
})
+1 -66
View File
@@ -1,66 +1 @@
function inferSubProvider(model: string): string | undefined {
if (model.startsWith("claude-")) return "anthropic"
if (model.startsWith("gpt-")) return "openai"
if (model.startsWith("gemini-")) return "google"
if (model.startsWith("grok-")) return "xai"
if (model.startsWith("minimax-")) return "minimax"
if (model.startsWith("kimi-")) return "moonshotai"
if (model.startsWith("glm-")) return "zai"
return undefined
}
const CLAUDE_VERSION_DOT = /claude-(\w+)-(\d+)-(\d+)/g
const GEMINI_31_PRO_PREVIEW = /gemini-3\.1-pro(?!-)/g
const GEMINI_3_FLASH_PREVIEW = /gemini-3-flash(?!-)/g
function claudeVersionDot(model: string): string {
return model.replace(CLAUDE_VERSION_DOT, "claude-$1-$2.$3")
}
function applyGatewayTransforms(model: string): string {
return claudeVersionDot(model).replace(
GEMINI_31_PRO_PREVIEW,
"gemini-3.1-pro-preview",
)
}
export function transformModelForProvider(provider: string, model: string): string {
if (provider === "vercel") {
const slashIndex = model.indexOf("/")
if (slashIndex !== -1) {
const subProvider = model.substring(0, slashIndex)
const subModel = model.substring(slashIndex + 1)
return `${subProvider}/${applyGatewayTransforms(subModel)}`
}
const subProvider = inferSubProvider(model)
if (subProvider) {
return `${subProvider}/${applyGatewayTransforms(model)}`
}
return model
}
if (provider === "github-copilot") {
return claudeVersionDot(model)
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "google") {
return model
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "anthropic") {
// Installer writes hyphenated IDs (claude-opus-4-7) to the config. The
// runtime provider-model-id-transform converts dash→dot when calling the
// Anthropic API. Keeping the dotted form in the config breaks fresh
// installs with ProviderModelNotFoundError because Anthropic's provider
// registers models under hyphenated IDs.
return model
}
return model
}
export { transformModelForProviderDisplay as transformModelForProvider } from "@oh-my-opencode/model-core"
-244
View File
@@ -1,244 +0,0 @@
import process from "node:process"
import { afterEach, describe, expect, it } from "bun:test"
import { resolveActualContextLimit } from "./context-limit-resolver"
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
const originalAnthropicContextEnv = process.env[ANTHROPIC_CONTEXT_ENV_KEY]
const originalVertexContextEnv = process.env[VERTEX_CONTEXT_ENV_KEY]
function resetContextLimitEnv(): void {
if (originalAnthropicContextEnv === undefined) {
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
} else {
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = originalAnthropicContextEnv
}
if (originalVertexContextEnv === undefined) {
delete process.env[VERTEX_CONTEXT_ENV_KEY]
} else {
process.env[VERTEX_CONTEXT_ENV_KEY] = originalVertexContextEnv
}
}
describe("resolveActualContextLimit", () => {
afterEach(() => {
resetContextLimitEnv()
})
it("returns cached limit for Anthropic 4.7 models when 1M mode is disabled (GA support)", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-opus-4-7", 1_000_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4-7", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
expect(actualLimit).toBe(1_000_000)
})
it("returns default 200K for older Anthropic models when 1M mode is disabled", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
// then
expect(actualLimit).toBe(200_000)
})
it("returns default 200K for Anthropic models without cached limit and 1M mode disabled", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5", {
anthropicContext1MEnabled: false,
})
// then
expect(actualLimit).toBe(200_000)
})
it("explicit 1M mode takes priority over cached limit", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 200_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5", {
anthropicContext1MEnabled: true,
modelContextLimitsCache,
})
expect(actualLimit).toBe(1_000_000)
})
it("treats Anthropics aliases as Anthropic providers", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
// when
const actualLimit = resolveActualContextLimit(
"aws-bedrock-anthropic",
"claude-sonnet-4-5",
{ anthropicContext1MEnabled: false },
)
// then
expect(actualLimit).toBe(200000)
})
it("supports Anthropic 4.7 dot-version model IDs without explicit 1M mode", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-opus-4.7", 1_000_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4.7", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
// then
expect(actualLimit).toBe(1_000_000)
})
it("supports Anthropic 4.6 high-variant model IDs without widening older models", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-6-high", 500_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-6-high", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
// then
expect(actualLimit).toBe(500_000)
})
it("ignores stale cached limits for older Anthropic models with suffixed IDs", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5-high", 500_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5-high", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
// then
expect(actualLimit).toBe(200_000)
})
it("returns GA 1M for claude-sonnet-4-6 without cached limit (GA context window)", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-6", {
anthropicContext1MEnabled: false,
})
// then
expect(actualLimit).toBe(1_000_000)
})
it("returns GA 1M for claude-opus-4-6 without cached limit (GA context window)", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4-6", {
anthropicContext1MEnabled: false,
})
// then
expect(actualLimit).toBe(1_000_000)
})
it("returns GA 1M for claude-opus-4-7 without cached limit (GA context window)", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4-7", {
anthropicContext1MEnabled: false,
})
// then
expect(actualLimit).toBe(1_000_000)
})
it("returns GA 1M for claude-sonnet-4-6-high without cached limit (GA context window)", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-6-high", {
anthropicContext1MEnabled: false,
})
// then
expect(actualLimit).toBe(1_000_000)
})
it("returns GA 1M for GA models on google-vertex-anthropic without cached limit", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
// when
const actualLimit = resolveActualContextLimit("google-vertex-anthropic", "claude-sonnet-4-6", {
anthropicContext1MEnabled: false,
})
// then
expect(actualLimit).toBe(1_000_000)
})
it("returns null for non-Anthropic providers without a cached limit", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
// when
const actualLimit = resolveActualContextLimit("openai", "gpt-5", {
anthropicContext1MEnabled: false,
})
// then
expect(actualLimit).toBeNull()
})
})
+2 -45
View File
@@ -1,45 +1,2 @@
import process from "node:process"
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000
const ANTHROPIC_GA_1M_LIMIT = 1_000_000
export type ContextLimitModelCacheState = {
anthropicContext1MEnabled: boolean
modelContextLimitsCache?: Map<string, number>
}
function isAnthropicProvider(providerID: string): boolean {
const normalized = providerID.toLowerCase()
return normalized === "anthropic" || normalized === "google-vertex-anthropic" || normalized === "aws-bedrock-anthropic"
}
function getAnthropicActualLimit(modelCacheState?: ContextLimitModelCacheState): number {
return (modelCacheState?.anthropicContext1MEnabled ?? false) ||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
? ANTHROPIC_GA_1M_LIMIT
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
function hasGA1MContext(modelID: string): boolean {
return /^claude-(opus|sonnet)-4(?:-|\.)(?:6|7)(?:-high)?$/.test(modelID)
}
export function resolveActualContextLimit(
providerID: string,
modelID: string,
modelCacheState?: ContextLimitModelCacheState,
): number | null {
if (isAnthropicProvider(providerID)) {
const explicit1M = getAnthropicActualLimit(modelCacheState)
if (explicit1M === ANTHROPIC_GA_1M_LIMIT) return explicit1M
const cachedLimit = modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`)
if (cachedLimit && hasGA1MContext(modelID)) return cachedLimit
if (hasGA1MContext(modelID)) return ANTHROPIC_GA_1M_LIMIT
return DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
return modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`) ?? null
}
export type { ContextLimitModelCacheState } from "@oh-my-opencode/model-core"
export { resolveActualContextLimit } from "@oh-my-opencode/model-core"
-1
View File
@@ -48,7 +48,6 @@ export type {
export * from "./model-availability"
export * from "./model-capabilities"
export * from "./model-capabilities-cache"
export * from "./model-capability-heuristics"
export * from "./model-settings-compatibility"
export * from "./fallback-model-availability"
export * from "./connected-providers-cache"
-1
View File
@@ -1 +0,0 @@
export { KNOWN_VARIANTS } from "@oh-my-opencode/model-core"
+1 -103
View File
@@ -6,9 +6,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
buildModelCapabilitiesSnapshotFromModelsDev,
createModelCapabilitiesCacheStore,
MODELS_DEV_SOURCE_URL,
} from "./model-capabilities-cache"
let fakeUserCacheRoot = ""
@@ -28,106 +26,6 @@ describe("model-capabilities-cache", () => {
testCacheDir = ""
})
test("builds a normalized snapshot from provider-keyed models.dev data", () => {
//#given
const raw = {
openai: {
models: {
"gpt-5.4": {
id: "gpt-5.4",
family: "gpt",
reasoning: true,
temperature: false,
tool_call: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 1_050_000,
output: 128_000,
},
},
},
},
anthropic: {
models: {
"claude-sonnet-4-6": {
family: "claude-sonnet",
reasoning: true,
temperature: true,
limit: {
context: 1_000_000,
output: 64_000,
},
},
},
},
}
//#when
const snapshot = buildModelCapabilitiesSnapshotFromModelsDev(raw)
//#then
expect(snapshot.sourceUrl).toBe(MODELS_DEV_SOURCE_URL)
expect(snapshot.models["gpt-5.4"]).toEqual({
id: "gpt-5.4",
family: "gpt",
reasoning: true,
temperature: false,
toolCall: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 1_050_000,
output: 128_000,
},
})
expect(snapshot.models["claude-sonnet-4-6"]).toEqual({
id: "claude-sonnet-4-6",
family: "claude-sonnet",
reasoning: true,
temperature: true,
limit: {
context: 1_000_000,
output: 64_000,
},
})
})
test("merges repeated snapshot entries without materializing empty optional objects", () => {
const raw = {
openai: {
models: {
"gpt-5.4": {
id: "gpt-5.4",
family: "gpt",
},
},
},
alias: {
models: {
"gpt-5.4-preview": {
id: "gpt-5.4",
reasoning: true,
},
},
},
}
const snapshot = buildModelCapabilitiesSnapshotFromModelsDev(raw)
expect(snapshot.models["gpt-5.4"]).toEqual({
id: "gpt-5.4",
family: "gpt",
reasoning: true,
})
expect(snapshot.models["gpt-5.4"]).not.toHaveProperty("modalities")
expect(snapshot.models["gpt-5.4"]).not.toHaveProperty("limit")
})
test("refresh writes cache and preserves unrelated files in the cache directory", async () => {
//#given
const sentinelPath = join(testCacheDir, "keep-me.json")
@@ -135,7 +33,7 @@ describe("model-capabilities-cache", () => {
mkdirSync(testCacheDir, { recursive: true })
writeFileSync(sentinelPath, JSON.stringify({ keep: true }))
const fetchImpl: typeof fetch = async () =>
const fetchImpl = async () =>
new Response(JSON.stringify({
openai: {
models: {
+13 -155
View File
@@ -1,162 +1,20 @@
import * as dataPath from "./data-path"
import { createJsonFileCacheStore } from "./json-file-cache-store"
import type { ModelCapabilitiesSnapshot, ModelCapabilitiesSnapshotEntry } from "./model-capabilities"
import {
MODELS_DEV_SOURCE_URL,
buildModelCapabilitiesSnapshotFromModelsDev,
fetchModelCapabilitiesSnapshot,
} from "@oh-my-opencode/model-core"
import type { ModelCapabilitiesSnapshot } from "./model-capabilities"
export {
MODELS_DEV_SOURCE_URL,
buildModelCapabilitiesSnapshotFromModelsDev,
fetchModelCapabilitiesSnapshot,
}
export const MODELS_DEV_SOURCE_URL = "https://models.dev/api.json"
const MODEL_CAPABILITIES_CACHE_FILE = "model-capabilities.json"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined
}
function readStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const result = value.filter((item): item is string => typeof item === "string")
return result.length > 0 ? result : undefined
}
function normalizeSnapshotEntry(rawModelID: string, rawModel: unknown): ModelCapabilitiesSnapshotEntry | undefined {
if (!isRecord(rawModel)) {
return undefined
}
const id = readString(rawModel.id) ?? rawModelID
const family = readString(rawModel.family)
const reasoning = readBoolean(rawModel.reasoning)
const temperature = readBoolean(rawModel.temperature)
const toolCall = readBoolean(rawModel.tool_call)
const rawModalities = isRecord(rawModel.modalities) ? rawModel.modalities : undefined
const modalitiesInput = readStringArray(rawModalities?.input)
const modalitiesOutput = readStringArray(rawModalities?.output)
const modalities = modalitiesInput || modalitiesOutput
? {
...(modalitiesInput ? { input: modalitiesInput } : {}),
...(modalitiesOutput ? { output: modalitiesOutput } : {}),
}
: undefined
const rawLimit = isRecord(rawModel.limit) ? rawModel.limit : undefined
const limitContext = readNumber(rawLimit?.context)
const limitInput = readNumber(rawLimit?.input)
const limitOutput = readNumber(rawLimit?.output)
const limit = limitContext !== undefined || limitInput !== undefined || limitOutput !== undefined
? {
...(limitContext !== undefined ? { context: limitContext } : {}),
...(limitInput !== undefined ? { input: limitInput } : {}),
...(limitOutput !== undefined ? { output: limitOutput } : {}),
}
: undefined
return {
id,
...(family ? { family } : {}),
...(reasoning !== undefined ? { reasoning } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(toolCall !== undefined ? { toolCall } : {}),
...(modalities ? { modalities } : {}),
...(limit ? { limit } : {}),
}
}
function mergeSnapshotEntries(
existing: ModelCapabilitiesSnapshotEntry | undefined,
incoming: ModelCapabilitiesSnapshotEntry,
): ModelCapabilitiesSnapshotEntry {
if (!existing) {
return incoming
}
const mergedModalities = existing.modalities || incoming.modalities
? {
...existing.modalities,
...incoming.modalities,
}
: undefined
const mergedLimit = existing.limit || incoming.limit
? {
...existing.limit,
...incoming.limit,
}
: undefined
return {
...existing,
...incoming,
...(mergedModalities ? { modalities: mergedModalities } : {}),
...(mergedLimit ? { limit: mergedLimit } : {}),
}
}
export function buildModelCapabilitiesSnapshotFromModelsDev(raw: unknown): ModelCapabilitiesSnapshot {
const models: Record<string, ModelCapabilitiesSnapshotEntry> = {}
const providers = isRecord(raw) ? raw : {}
for (const providerValue of Object.values(providers)) {
if (!isRecord(providerValue)) {
continue
}
const providerModels = providerValue.models
if (!isRecord(providerModels)) {
continue
}
for (const [rawModelID, rawModel] of Object.entries(providerModels)) {
const normalizedEntry = normalizeSnapshotEntry(rawModelID, rawModel)
if (!normalizedEntry) {
continue
}
models[normalizedEntry.id.toLowerCase()] = mergeSnapshotEntries(
models[normalizedEntry.id.toLowerCase()],
normalizedEntry,
)
}
}
return {
generatedAt: new Date().toISOString(),
sourceUrl: MODELS_DEV_SOURCE_URL,
models,
}
}
export async function fetchModelCapabilitiesSnapshot(args: {
sourceUrl?: string
fetchImpl?: typeof fetch
} = {}): Promise<ModelCapabilitiesSnapshot> {
const sourceUrl = args.sourceUrl ?? MODELS_DEV_SOURCE_URL
const fetchImpl = args.fetchImpl ?? fetch
const response = await fetchImpl(sourceUrl)
if (!response.ok) {
throw new Error(`models.dev fetch failed with ${response.status}`)
}
const raw = await response.json()
const snapshot = buildModelCapabilitiesSnapshotFromModelsDev(raw)
return {
...snapshot,
sourceUrl,
}
}
export function createModelCapabilitiesCacheStore(
getCacheDir: () => string = dataPath.getOmoOpenCodeCacheDir,
) {
@@ -186,7 +44,7 @@ export function createModelCapabilitiesCacheStore(
async function refreshModelCapabilitiesCache(args: {
sourceUrl?: string
fetchImpl?: typeof fetch
fetchImpl?: (input: string) => Promise<Response>
} = {}): Promise<ModelCapabilitiesSnapshot> {
const snapshot = await fetchModelCapabilitiesSnapshot(args)
writeModelCapabilitiesCache(snapshot)
-10
View File
@@ -1,10 +0,0 @@
export type {
ExactAliasRule,
PatternAliasRule,
ModelIDAliasResolution,
} from "@oh-my-opencode/model-core"
export {
resolveModelIDAlias,
getExactModelIDAliasRules,
getPatternModelIDAliasRules,
} from "@oh-my-opencode/model-core"
-22
View File
@@ -1,22 +0,0 @@
import {
collectModelCapabilityGuardrailIssues as collectModelCapabilityGuardrailIssuesFromCore,
getBuiltInRequirementModelIDs,
} from "@oh-my-opencode/model-core"
import type {
ModelCapabilityGuardrailIssue,
ModelCapabilitiesSnapshot,
} from "@oh-my-opencode/model-core"
import { getBundledModelCapabilitiesSnapshotForRuntime } from "./model-capabilities"
export { getBuiltInRequirementModelIDs }
export type { ModelCapabilityGuardrailIssue }
export function collectModelCapabilityGuardrailIssues(input: {
snapshot?: ModelCapabilitiesSnapshot
requirementModelIDs?: Iterable<string>
} = {}): ModelCapabilityGuardrailIssue[] {
return collectModelCapabilityGuardrailIssuesFromCore({
...input,
snapshot: input.snapshot ?? getBundledModelCapabilitiesSnapshotForRuntime(),
})
}
@@ -1,5 +0,0 @@
export type { HeuristicModelFamilyDefinition } from "@oh-my-opencode/model-core"
export {
HEURISTIC_MODEL_FAMILY_REGISTRY,
detectHeuristicModelFamily,
} from "@oh-my-opencode/model-core"
+5 -52
View File
@@ -1,4 +1,5 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import { parseModelSuggestion as parseModelSuggestionFromCore } from "@oh-my-opencode/model-core"
import { log } from "./logger"
import {
createPromptTimeoutContext,
@@ -14,11 +15,8 @@ import { isAmbiguousPostDispatchPromptFailure } from "./prompt-failure-classifie
type Client = ReturnType<typeof createOpencodeClient>
export interface ModelSuggestionInfo {
providerID: string
modelID: string
suggestion: string
}
export type { ModelSuggestionInfo } from "@oh-my-opencode/model-core"
export { parseModelSuggestionFromCore as parseModelSuggestion }
function extractMessage(error: unknown): string {
if (typeof error === "string") return error
@@ -41,52 +39,7 @@ function isAgentResolutionError(error: unknown): boolean {
}
function shouldReleaseReservationAfterFailedAsyncPrompt(error: unknown): boolean {
return parseModelSuggestion(error) !== null || isAgentResolutionError(error)
}
export function parseModelSuggestion(error: unknown): ModelSuggestionInfo | null {
if (!error) return null
if (typeof error === "object") {
const errObj = error as Record<string, unknown>
if (errObj.name === "ProviderModelNotFoundError" && typeof errObj.data === "object" && errObj.data !== null) {
const data = errObj.data as Record<string, unknown>
const suggestions = data.suggestions
if (Array.isArray(suggestions) && suggestions.length > 0 && typeof suggestions[0] === "string") {
return {
providerID: String(data.providerID ?? ""),
modelID: String(data.modelID ?? ""),
suggestion: suggestions[0],
}
}
return null
}
for (const key of ["data", "error", "cause"] as const) {
const nested = errObj[key]
if (nested && typeof nested === "object") {
const result = parseModelSuggestion(nested)
if (result) return result
}
}
}
const message = extractMessage(error)
if (!message) return null
const modelMatch = message.match(/model not found:\s*([^/\s]+)\s*\/\s*([^.\s]+)/i)
const suggestionMatch = message.match(/did you mean:\s*([^,?]+)/i)
if (modelMatch && suggestionMatch) {
return {
providerID: modelMatch[1].trim(),
modelID: modelMatch[2].trim(),
suggestion: suggestionMatch[1].trim(),
}
}
return null
return parseModelSuggestionFromCore(error) !== null || isAgentResolutionError(error)
}
interface PromptBody {
@@ -198,7 +151,7 @@ export async function promptSyncWithModelSuggestionRetry(
timeoutContext.cleanup()
}
} catch (error) {
const suggestion = parseModelSuggestion(error)
const suggestion = parseModelSuggestionFromCore(error)
if (!suggestion || !args.body.model) {
throw error
}
+1 -53
View File
@@ -1,53 +1 @@
function inferSubProvider(model: string): string | undefined {
if (model.startsWith("claude-")) return "anthropic"
if (model.startsWith("gpt-")) return "openai"
if (model.startsWith("gemini-")) return "google"
if (model.startsWith("grok-")) return "xai"
if (model.startsWith("minimax-")) return "minimax"
if (model.startsWith("kimi-")) return "moonshotai"
if (model.startsWith("glm-")) return "zai"
return undefined
}
const CLAUDE_VERSION_DOT = /claude-(\w+)-(\d+)-(\d+)/g
const GEMINI_31_PRO_PREVIEW = /gemini-3\.1-pro(?!-)/g
const GEMINI_3_FLASH_PREVIEW = /gemini-3-flash(?!-)/g
function claudeVersionDot(model: string): string {
return model.replace(CLAUDE_VERSION_DOT, "claude-$1-$2.$3")
}
function applyGatewayTransforms(model: string): string {
return claudeVersionDot(model)
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
}
export function transformModelForProvider(provider: string, model: string): string {
if (provider === "vercel") {
const slashIndex = model.indexOf("/")
if (slashIndex !== -1) {
const subProvider = model.substring(0, slashIndex)
const subModel = model.substring(slashIndex + 1)
return `${subProvider}/${applyGatewayTransforms(subModel)}`
}
const subProvider = inferSubProvider(model)
if (subProvider) {
return `${subProvider}/${applyGatewayTransforms(model)}`
}
return model
}
if (provider === "github-copilot") {
return claudeVersionDot(model)
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "google") {
return model
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "anthropic") {
return claudeVersionDot(model)
}
return model
}
export { transformModelForProvider } from "@oh-my-opencode/model-core"