diff --git a/packages/model-core/src/context-limit-resolver.test.ts b/packages/model-core/src/context-limit-resolver.test.ts new file mode 100644 index 000000000..4915dcc8b --- /dev/null +++ b/packages/model-core/src/context-limit-resolver.test.ts @@ -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() + 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() + 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() + 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) + }) +}) diff --git a/packages/model-core/src/context-limit-resolver.ts b/packages/model-core/src/context-limit-resolver.ts new file mode 100644 index 000000000..a4f11f4c0 --- /dev/null +++ b/packages/model-core/src/context-limit-resolver.ts @@ -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 +} + +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 +} diff --git a/packages/model-core/src/index.ts b/packages/model-core/src/index.ts index 5b100d925..79cb65929 100644 --- a/packages/model-core/src/index.ts +++ b/packages/model-core/src/index.ts @@ -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" diff --git a/packages/model-core/src/model-capabilities-snapshot.test.ts b/packages/model-core/src/model-capabilities-snapshot.test.ts new file mode 100644 index 000000000..71be655c2 --- /dev/null +++ b/packages/model-core/src/model-capabilities-snapshot.test.ts @@ -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) + }) +}) diff --git a/packages/model-core/src/model-capabilities-snapshot.ts b/packages/model-core/src/model-capabilities-snapshot.ts new file mode 100644 index 000000000..4d1cbf7f4 --- /dev/null +++ b/packages/model-core/src/model-capabilities-snapshot.ts @@ -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 + +function isRecord(value: unknown): value is Record { + 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 = {} + 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 { + 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, + } +} diff --git a/packages/model-core/src/parse-model-suggestion.test.ts b/packages/model-core/src/parse-model-suggestion.test.ts new file mode 100644 index 000000000..168423213 --- /dev/null +++ b/packages/model-core/src/parse-model-suggestion.test.ts @@ -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() + }) +}) diff --git a/packages/model-core/src/parse-model-suggestion.ts b/packages/model-core/src/parse-model-suggestion.ts new file mode 100644 index 000000000..8123efcdc --- /dev/null +++ b/packages/model-core/src/parse-model-suggestion.ts @@ -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 + 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 + + if (errObj.name === "ProviderModelNotFoundError" && typeof errObj.data === "object" && errObj.data !== null) { + const data = errObj.data as Record + 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 +} diff --git a/packages/model-core/src/provider-model-id-transform.test.ts b/packages/model-core/src/provider-model-id-transform.test.ts new file mode 100644 index 000000000..6c6c16be8 --- /dev/null +++ b/packages/model-core/src/provider-model-id-transform.test.ts @@ -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) + } + }) +}) diff --git a/packages/model-core/src/provider-model-id-transform.ts b/packages/model-core/src/provider-model-id-transform.ts index b8d4455e5..78c9847b9 100644 --- a/packages/model-core/src/provider-model-id-transform.ts +++ b/packages/model-core/src/provider-model-id-transform.ts @@ -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, + ) +} diff --git a/src/cli/provider-model-id-transform.test.ts b/src/cli/provider-model-id-transform.test.ts index 0a1b57030..3aa448502 100644 --- a/src/cli/provider-model-id-transform.test.ts +++ b/src/cli/provider-model-id-transform.test.ts @@ -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), + ) + } }) }) diff --git a/src/cli/provider-model-id-transform.ts b/src/cli/provider-model-id-transform.ts index fda947c8c..bef018651 100644 --- a/src/cli/provider-model-id-transform.ts +++ b/src/cli/provider-model-id-transform.ts @@ -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" diff --git a/src/shared/context-limit-resolver.test.ts b/src/shared/context-limit-resolver.test.ts deleted file mode 100644 index 10ded16e5..000000000 --- a/src/shared/context-limit-resolver.test.ts +++ /dev/null @@ -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() - 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() - 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() - 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() - 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() - 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() - 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() - }) -}) diff --git a/src/shared/context-limit-resolver.ts b/src/shared/context-limit-resolver.ts index ee7e23a18..d9106a2ec 100644 --- a/src/shared/context-limit-resolver.ts +++ b/src/shared/context-limit-resolver.ts @@ -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 -} - -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" diff --git a/src/shared/index.ts b/src/shared/index.ts index dd58561d5..9f39f9bb3 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -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" diff --git a/src/shared/known-variants.ts b/src/shared/known-variants.ts deleted file mode 100644 index 433b96b64..000000000 --- a/src/shared/known-variants.ts +++ /dev/null @@ -1 +0,0 @@ -export { KNOWN_VARIANTS } from "@oh-my-opencode/model-core" diff --git a/src/shared/model-capabilities-cache.test.ts b/src/shared/model-capabilities-cache.test.ts index 0773a5fe0..27130da57 100644 --- a/src/shared/model-capabilities-cache.test.ts +++ b/src/shared/model-capabilities-cache.test.ts @@ -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: { diff --git a/src/shared/model-capabilities-cache.ts b/src/shared/model-capabilities-cache.ts index 37d6b6429..3d029588c 100644 --- a/src/shared/model-capabilities-cache.ts +++ b/src/shared/model-capabilities-cache.ts @@ -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 { - 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 = {} - 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 { - 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 } = {}): Promise { const snapshot = await fetchModelCapabilitiesSnapshot(args) writeModelCapabilitiesCache(snapshot) diff --git a/src/shared/model-capability-aliases.ts b/src/shared/model-capability-aliases.ts deleted file mode 100644 index 45754bc60..000000000 --- a/src/shared/model-capability-aliases.ts +++ /dev/null @@ -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" diff --git a/src/shared/model-capability-guardrails.ts b/src/shared/model-capability-guardrails.ts deleted file mode 100644 index ce8d31934..000000000 --- a/src/shared/model-capability-guardrails.ts +++ /dev/null @@ -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 -} = {}): ModelCapabilityGuardrailIssue[] { - return collectModelCapabilityGuardrailIssuesFromCore({ - ...input, - snapshot: input.snapshot ?? getBundledModelCapabilitiesSnapshotForRuntime(), - }) -} diff --git a/src/shared/model-capability-heuristics.ts b/src/shared/model-capability-heuristics.ts deleted file mode 100644 index 3921e5eed..000000000 --- a/src/shared/model-capability-heuristics.ts +++ /dev/null @@ -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" diff --git a/src/shared/model-suggestion-retry.ts b/src/shared/model-suggestion-retry.ts index a43022625..a8842ca47 100644 --- a/src/shared/model-suggestion-retry.ts +++ b/src/shared/model-suggestion-retry.ts @@ -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 -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 - - if (errObj.name === "ProviderModelNotFoundError" && typeof errObj.data === "object" && errObj.data !== null) { - const data = errObj.data as Record - 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 } diff --git a/src/shared/provider-model-id-transform.ts b/src/shared/provider-model-id-transform.ts index a4869c3c9..c654d5136 100644 --- a/src/shared/provider-model-id-transform.ts +++ b/src/shared/provider-model-id-transform.ts @@ -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"