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:
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 +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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
export { KNOWN_VARIANTS } from "@oh-my-opencode/model-core"
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 +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"
|
||||
|
||||
Reference in New Issue
Block a user