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,
|
fuzzyMatchModel,
|
||||||
isModelAvailable,
|
isModelAvailable,
|
||||||
} from "./model-availability"
|
} 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 "./fallback-chain-from-models"
|
||||||
export * from "./known-variants"
|
export * from "./known-variants"
|
||||||
export {
|
export {
|
||||||
@@ -42,3 +45,6 @@ export type {
|
|||||||
} from "./model-resolution-pipeline"
|
} from "./model-resolution-pipeline"
|
||||||
export * from "./model-error-classifier"
|
export * from "./model-error-classifier"
|
||||||
export * from "./model-capabilities"
|
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") {
|
if (provider === "vercel") {
|
||||||
const slashIndex = model.indexOf("/")
|
const slashIndex = model.indexOf("/")
|
||||||
if (slashIndex !== -1) {
|
if (slashIndex !== -1) {
|
||||||
@@ -49,7 +53,26 @@ export function transformModelForProvider(provider: string, model: string): stri
|
|||||||
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
|
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
|
||||||
}
|
}
|
||||||
if (provider === "anthropic") {
|
if (provider === "anthropic") {
|
||||||
return claudeVersionDot(model)
|
return directAnthropicTransform(model)
|
||||||
}
|
}
|
||||||
return 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 { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import { transformModelForProvider } from "./provider-model-id-transform"
|
import {
|
||||||
import { transformModelForProvider as transformSharedModelForProvider } from "../shared/provider-model-id-transform"
|
transformModelForProvider as transformRuntimeModelForProvider,
|
||||||
|
transformModelForProviderDisplay as transformModelForProvider,
|
||||||
|
} from "@oh-my-opencode/model-core"
|
||||||
|
|
||||||
describe("transformModelForProvider", () => {
|
describe("transformModelForProvider", () => {
|
||||||
describe("github-copilot provider", () => {
|
describe("github-copilot provider", () => {
|
||||||
@@ -338,16 +340,28 @@ describe("transformModelForProvider", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses a CLI-local transform implementation distinct from the shared runtime transform", () => {
|
test("uses separate display and runtime transform implementations", () => {
|
||||||
// #given the CLI transform (used by the installer) and the shared runtime transform
|
// #given the display transform (used by the installer) and the runtime transform
|
||||||
const cliResult = transformModelForProvider("anthropic", "claude-opus-4-7")
|
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
|
// #when both are called with the same anthropic claude input
|
||||||
// #then the CLI preserves hyphenated form for config output,
|
// #then the CLI preserves hyphenated form for config output,
|
||||||
// the shared runtime transform converts dash→dot for API calls
|
// 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(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 {
|
export { transformModelForProviderDisplay as transformModelForProvider } from "@oh-my-opencode/model-core"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
export type { ContextLimitModelCacheState } from "@oh-my-opencode/model-core"
|
||||||
|
export { resolveActualContextLimit } from "@oh-my-opencode/model-core"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export type {
|
|||||||
export * from "./model-availability"
|
export * from "./model-availability"
|
||||||
export * from "./model-capabilities"
|
export * from "./model-capabilities"
|
||||||
export * from "./model-capabilities-cache"
|
export * from "./model-capabilities-cache"
|
||||||
export * from "./model-capability-heuristics"
|
|
||||||
export * from "./model-settings-compatibility"
|
export * from "./model-settings-compatibility"
|
||||||
export * from "./fallback-model-availability"
|
export * from "./fallback-model-availability"
|
||||||
export * from "./connected-providers-cache"
|
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 { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import {
|
import {
|
||||||
buildModelCapabilitiesSnapshotFromModelsDev,
|
|
||||||
createModelCapabilitiesCacheStore,
|
createModelCapabilitiesCacheStore,
|
||||||
MODELS_DEV_SOURCE_URL,
|
|
||||||
} from "./model-capabilities-cache"
|
} from "./model-capabilities-cache"
|
||||||
|
|
||||||
let fakeUserCacheRoot = ""
|
let fakeUserCacheRoot = ""
|
||||||
@@ -28,106 +26,6 @@ describe("model-capabilities-cache", () => {
|
|||||||
testCacheDir = ""
|
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 () => {
|
test("refresh writes cache and preserves unrelated files in the cache directory", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sentinelPath = join(testCacheDir, "keep-me.json")
|
const sentinelPath = join(testCacheDir, "keep-me.json")
|
||||||
@@ -135,7 +33,7 @@ describe("model-capabilities-cache", () => {
|
|||||||
mkdirSync(testCacheDir, { recursive: true })
|
mkdirSync(testCacheDir, { recursive: true })
|
||||||
writeFileSync(sentinelPath, JSON.stringify({ keep: true }))
|
writeFileSync(sentinelPath, JSON.stringify({ keep: true }))
|
||||||
|
|
||||||
const fetchImpl: typeof fetch = async () =>
|
const fetchImpl = async () =>
|
||||||
new Response(JSON.stringify({
|
new Response(JSON.stringify({
|
||||||
openai: {
|
openai: {
|
||||||
models: {
|
models: {
|
||||||
|
|||||||
@@ -1,162 +1,20 @@
|
|||||||
import * as dataPath from "./data-path"
|
import * as dataPath from "./data-path"
|
||||||
import { createJsonFileCacheStore } from "./json-file-cache-store"
|
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"
|
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(
|
export function createModelCapabilitiesCacheStore(
|
||||||
getCacheDir: () => string = dataPath.getOmoOpenCodeCacheDir,
|
getCacheDir: () => string = dataPath.getOmoOpenCodeCacheDir,
|
||||||
) {
|
) {
|
||||||
@@ -186,7 +44,7 @@ export function createModelCapabilitiesCacheStore(
|
|||||||
|
|
||||||
async function refreshModelCapabilitiesCache(args: {
|
async function refreshModelCapabilitiesCache(args: {
|
||||||
sourceUrl?: string
|
sourceUrl?: string
|
||||||
fetchImpl?: typeof fetch
|
fetchImpl?: (input: string) => Promise<Response>
|
||||||
} = {}): Promise<ModelCapabilitiesSnapshot> {
|
} = {}): Promise<ModelCapabilitiesSnapshot> {
|
||||||
const snapshot = await fetchModelCapabilitiesSnapshot(args)
|
const snapshot = await fetchModelCapabilitiesSnapshot(args)
|
||||||
writeModelCapabilitiesCache(snapshot)
|
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 type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
|
import { parseModelSuggestion as parseModelSuggestionFromCore } from "@oh-my-opencode/model-core"
|
||||||
import { log } from "./logger"
|
import { log } from "./logger"
|
||||||
import {
|
import {
|
||||||
createPromptTimeoutContext,
|
createPromptTimeoutContext,
|
||||||
@@ -14,11 +15,8 @@ import { isAmbiguousPostDispatchPromptFailure } from "./prompt-failure-classifie
|
|||||||
|
|
||||||
type Client = ReturnType<typeof createOpencodeClient>
|
type Client = ReturnType<typeof createOpencodeClient>
|
||||||
|
|
||||||
export interface ModelSuggestionInfo {
|
export type { ModelSuggestionInfo } from "@oh-my-opencode/model-core"
|
||||||
providerID: string
|
export { parseModelSuggestionFromCore as parseModelSuggestion }
|
||||||
modelID: string
|
|
||||||
suggestion: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractMessage(error: unknown): string {
|
function extractMessage(error: unknown): string {
|
||||||
if (typeof error === "string") return error
|
if (typeof error === "string") return error
|
||||||
@@ -41,52 +39,7 @@ function isAgentResolutionError(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldReleaseReservationAfterFailedAsyncPrompt(error: unknown): boolean {
|
function shouldReleaseReservationAfterFailedAsyncPrompt(error: unknown): boolean {
|
||||||
return parseModelSuggestion(error) !== null || isAgentResolutionError(error)
|
return parseModelSuggestionFromCore(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PromptBody {
|
interface PromptBody {
|
||||||
@@ -198,7 +151,7 @@ export async function promptSyncWithModelSuggestionRetry(
|
|||||||
timeoutContext.cleanup()
|
timeoutContext.cleanup()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const suggestion = parseModelSuggestion(error)
|
const suggestion = parseModelSuggestionFromCore(error)
|
||||||
if (!suggestion || !args.body.model) {
|
if (!suggestion || !args.body.model) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1 @@
|
|||||||
function inferSubProvider(model: string): string | undefined {
|
export { transformModelForProvider } from "@oh-my-opencode/model-core"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user