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

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

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

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

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

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

Tests: bun test packages/model-core src/shared/model-capabilities-cache.test.ts src/cli/provider-model-id-transform.test.ts
This commit is contained in:
YeonGyu-Kim
2026-05-21 16:02:41 +09:00
parent 4ea76365cd
commit edaa95fec0
22 changed files with 671 additions and 767 deletions
@@ -0,0 +1,92 @@
import process from "node:process"
import { afterEach, describe, expect, it } from "bun:test"
import { resolveActualContextLimit } from "./context-limit-resolver"
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
const originalAnthropicContextEnv = process.env[ANTHROPIC_CONTEXT_ENV_KEY]
const originalVertexContextEnv = process.env[VERTEX_CONTEXT_ENV_KEY]
function restoreContextLimitEnv(): void {
if (originalAnthropicContextEnv === undefined) {
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
} else {
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = originalAnthropicContextEnv
}
if (originalVertexContextEnv === undefined) {
delete process.env[VERTEX_CONTEXT_ENV_KEY]
} else {
process.env[VERTEX_CONTEXT_ENV_KEY] = originalVertexContextEnv
}
}
describe("resolveActualContextLimit", () => {
afterEach(() => {
restoreContextLimitEnv()
})
it("returns cached limit for non-Anthropic providers", () => {
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("openai/gpt-5", 400_000)
const actualLimit = resolveActualContextLimit("openai", "gpt-5", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
expect(actualLimit).toBe(400_000)
})
it("returns GA 1M for Anthropic 4.6/4.7 models without explicit 1M mode", () => {
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-6", {
anthropicContext1MEnabled: false,
})
expect(actualLimit).toBe(1_000_000)
})
it("uses cached limit for GA Anthropic models when cache exists", () => {
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-opus-4-7", 700_000)
const actualLimit = resolveActualContextLimit("anthropic", "claude-opus-4-7", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
expect(actualLimit).toBe(700_000)
})
it("returns 1M when ANTHROPIC_1M_CONTEXT=true regardless of model", () => {
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = "true"
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 200_000)
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
expect(actualLimit).toBe(1_000_000)
})
it("returns 1M when VERTEX_ANTHROPIC_1M_CONTEXT=true for Anthropic aliases", () => {
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
process.env[VERTEX_CONTEXT_ENV_KEY] = "true"
const actualLimit = resolveActualContextLimit("google-vertex-anthropic", "claude-sonnet-4-5", {
anthropicContext1MEnabled: false,
})
expect(actualLimit).toBe(1_000_000)
})
})
@@ -0,0 +1,46 @@
import process from "node:process"
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000
const ANTHROPIC_GA_1M_LIMIT = 1_000_000
export type ContextLimitModelCacheState = {
anthropicContext1MEnabled: boolean
modelContextLimitsCache?: Map<string, number>
}
function isAnthropicProvider(providerID: string): boolean {
const normalized = providerID.toLowerCase()
return normalized === "anthropic" || normalized === "google-vertex-anthropic" || normalized === "aws-bedrock-anthropic"
}
function getAnthropicActualLimit(modelCacheState?: ContextLimitModelCacheState): number {
return (modelCacheState?.anthropicContext1MEnabled ?? false) ||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
? ANTHROPIC_GA_1M_LIMIT
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
function hasGA1MContext(modelID: string): boolean {
return /^claude-(opus|sonnet)-4(?:-|\.)(?:6|7)(?:-high)?$/.test(modelID)
}
export function resolveActualContextLimit(
providerID: string,
modelID: string,
modelCacheState?: ContextLimitModelCacheState,
): number | null {
if (isAnthropicProvider(providerID)) {
const explicit1M = getAnthropicActualLimit(modelCacheState)
if (explicit1M === ANTHROPIC_GA_1M_LIMIT) return explicit1M
const cachedLimit = modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`)
if (cachedLimit && hasGA1MContext(modelID)) return cachedLimit
if (hasGA1MContext(modelID)) return ANTHROPIC_GA_1M_LIMIT
return DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
return modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`) ?? null
}
+7 -1
View File
@@ -28,7 +28,10 @@ export {
fuzzyMatchModel,
isModelAvailable,
} from "./model-availability"
export { transformModelForProvider } from "./provider-model-id-transform"
export {
transformModelForProvider,
transformModelForProviderDisplay,
} from "./provider-model-id-transform"
export * from "./fallback-chain-from-models"
export * from "./known-variants"
export {
@@ -42,3 +45,6 @@ export type {
} from "./model-resolution-pipeline"
export * from "./model-error-classifier"
export * from "./model-capabilities"
export * from "./context-limit-resolver"
export * from "./model-capabilities-snapshot"
export * from "./parse-model-suggestion"
@@ -0,0 +1,117 @@
import { describe, expect, test } from "bun:test"
import {
buildModelCapabilitiesSnapshotFromModelsDev,
fetchModelCapabilitiesSnapshot,
} from "./model-capabilities-snapshot"
describe("model-capabilities-snapshot", () => {
test("builds a normalized snapshot from models.dev provider data", () => {
const raw = {
openai: {
models: {
"gpt-5.4": {
id: "gpt-5.4",
family: "gpt",
reasoning: true,
temperature: false,
tool_call: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 1_050_000,
output: 128_000,
},
},
},
},
}
const snapshot = buildModelCapabilitiesSnapshotFromModelsDev(raw)
expect(snapshot.sourceUrl).toBe("https://models.dev/api.json")
expect(snapshot.models["gpt-5.4"]).toEqual({
id: "gpt-5.4",
family: "gpt",
reasoning: true,
temperature: false,
toolCall: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 1_050_000,
output: 128_000,
},
})
})
test("ignores malformed provider entries and missing fields", () => {
const raw = {
invalidProvider: null,
anthropic: {
models: {
"claude-sonnet-4-6": {
reasoning: true,
},
"bad-model": "invalid",
},
},
openai: {
models: {
"gpt-5.4": {
id: "GPT-5.4",
modalities: {
input: ["text", 1],
},
},
},
},
}
const snapshot = buildModelCapabilitiesSnapshotFromModelsDev(raw)
expect(snapshot.models["claude-sonnet-4-6"]).toEqual({
id: "claude-sonnet-4-6",
reasoning: true,
})
expect(snapshot.models["gpt-5.4"]).toEqual({
id: "GPT-5.4",
modalities: {
input: ["text"],
},
})
expect(snapshot.models["bad-model"]).toBeUndefined()
})
test("fetches snapshot using injected fetch implementation", async () => {
const sourceUrl = "https://fixture.local/models.json"
const fetchImpl = async () =>
new Response(
JSON.stringify({
openai: {
models: {
"gpt-5.4": {
id: "gpt-5.4",
limit: {
output: 128_000,
},
},
},
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
const snapshot = await fetchModelCapabilitiesSnapshot({ sourceUrl, fetchImpl })
expect(snapshot.sourceUrl).toBe(sourceUrl)
expect(snapshot.models["gpt-5.4"]?.limit?.output).toBe(128_000)
})
})
@@ -0,0 +1,160 @@
import type {
ModelCapabilitiesSnapshot,
ModelCapabilitiesSnapshotEntry,
} from "./model-capabilities"
export const MODELS_DEV_SOURCE_URL = "https://models.dev/api.json"
type FetchImpl = (input: string) => Promise<Response>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined
}
function readStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const result = value.filter((item): item is string => typeof item === "string")
return result.length > 0 ? result : undefined
}
function normalizeSnapshotEntry(rawModelID: string, rawModel: unknown): ModelCapabilitiesSnapshotEntry | undefined {
if (!isRecord(rawModel)) {
return undefined
}
const id = readString(rawModel.id) ?? rawModelID
const family = readString(rawModel.family)
const reasoning = readBoolean(rawModel.reasoning)
const temperature = readBoolean(rawModel.temperature)
const toolCall = readBoolean(rawModel.tool_call)
const rawModalities = isRecord(rawModel.modalities) ? rawModel.modalities : undefined
const modalitiesInput = readStringArray(rawModalities?.input)
const modalitiesOutput = readStringArray(rawModalities?.output)
const modalities = modalitiesInput || modalitiesOutput
? {
...(modalitiesInput ? { input: modalitiesInput } : {}),
...(modalitiesOutput ? { output: modalitiesOutput } : {}),
}
: undefined
const rawLimit = isRecord(rawModel.limit) ? rawModel.limit : undefined
const limitContext = readNumber(rawLimit?.context)
const limitInput = readNumber(rawLimit?.input)
const limitOutput = readNumber(rawLimit?.output)
const limit = limitContext !== undefined || limitInput !== undefined || limitOutput !== undefined
? {
...(limitContext !== undefined ? { context: limitContext } : {}),
...(limitInput !== undefined ? { input: limitInput } : {}),
...(limitOutput !== undefined ? { output: limitOutput } : {}),
}
: undefined
return {
id,
...(family ? { family } : {}),
...(reasoning !== undefined ? { reasoning } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(toolCall !== undefined ? { toolCall } : {}),
...(modalities ? { modalities } : {}),
...(limit ? { limit } : {}),
}
}
function mergeSnapshotEntries(
existing: ModelCapabilitiesSnapshotEntry | undefined,
incoming: ModelCapabilitiesSnapshotEntry,
): ModelCapabilitiesSnapshotEntry {
if (!existing) {
return incoming
}
const mergedModalities = existing.modalities || incoming.modalities
? {
...existing.modalities,
...incoming.modalities,
}
: undefined
const mergedLimit = existing.limit || incoming.limit
? {
...existing.limit,
...incoming.limit,
}
: undefined
return {
...existing,
...incoming,
...(mergedModalities ? { modalities: mergedModalities } : {}),
...(mergedLimit ? { limit: mergedLimit } : {}),
}
}
export function buildModelCapabilitiesSnapshotFromModelsDev(raw: unknown): ModelCapabilitiesSnapshot {
const models: Record<string, ModelCapabilitiesSnapshotEntry> = {}
const providers = isRecord(raw) ? raw : {}
for (const providerValue of Object.values(providers)) {
if (!isRecord(providerValue)) {
continue
}
const providerModels = providerValue.models
if (!isRecord(providerModels)) {
continue
}
for (const [rawModelID, rawModel] of Object.entries(providerModels)) {
const normalizedEntry = normalizeSnapshotEntry(rawModelID, rawModel)
if (!normalizedEntry) {
continue
}
models[normalizedEntry.id.toLowerCase()] = mergeSnapshotEntries(
models[normalizedEntry.id.toLowerCase()],
normalizedEntry,
)
}
}
return {
generatedAt: new Date().toISOString(),
sourceUrl: MODELS_DEV_SOURCE_URL,
models,
}
}
export async function fetchModelCapabilitiesSnapshot(args: {
sourceUrl?: string
fetchImpl?: FetchImpl
} = {}): Promise<ModelCapabilitiesSnapshot> {
const sourceUrl = args.sourceUrl ?? MODELS_DEV_SOURCE_URL
const fetchImpl = args.fetchImpl ?? fetch
const response = await fetchImpl(sourceUrl)
if (!response.ok) {
throw new Error(`models.dev fetch failed with ${response.status}`)
}
const raw = await response.json()
const snapshot = buildModelCapabilitiesSnapshotFromModelsDev(raw)
return {
...snapshot,
sourceUrl,
}
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "bun:test"
import { parseModelSuggestion } from "./parse-model-suggestion"
describe("parseModelSuggestion", () => {
it("extracts suggestions from structured Anthropic ProviderModelNotFoundError", () => {
const error = {
name: "ProviderModelNotFoundError",
data: {
providerID: "anthropic",
modelID: "claude-sonet-4",
suggestions: ["claude-sonnet-4", "claude-sonnet-4-6"],
},
}
expect(parseModelSuggestion(error)).toEqual({
providerID: "anthropic",
modelID: "claude-sonet-4",
suggestion: "claude-sonnet-4",
})
})
it("extracts suggestions from nested OpenAI errors", () => {
const error = {
data: {
name: "ProviderModelNotFoundError",
data: {
providerID: "openai",
modelID: "gpt-5",
suggestions: ["gpt-5.4"],
},
},
}
expect(parseModelSuggestion(error)).toEqual({
providerID: "openai",
modelID: "gpt-5",
suggestion: "gpt-5.4",
})
})
it("extracts suggestions from Bedrock-style model-not-found messages", () => {
const error = new Error(
"Model not found: aws-bedrock-anthropic/claude-sonet-4. Did you mean: claude-sonnet-4, claude-sonnet-4-6?",
)
expect(parseModelSuggestion(error)).toEqual({
providerID: "aws-bedrock-anthropic",
modelID: "claude-sonet-4",
suggestion: "claude-sonnet-4",
})
})
it("extracts suggestions from plain string message payloads", () => {
const error = "Model not found: openai/gtp-5. Did you mean: gpt-5?"
expect(parseModelSuggestion(error)).toEqual({
providerID: "openai",
modelID: "gtp-5",
suggestion: "gpt-5",
})
})
it("returns null for unrelated errors", () => {
expect(parseModelSuggestion(new Error("Connection timeout"))).toBeNull()
expect(parseModelSuggestion(null)).toBeNull()
})
})
@@ -0,0 +1,65 @@
export interface ModelSuggestionInfo {
providerID: string
modelID: string
suggestion: string
}
function extractMessage(error: unknown): string {
if (typeof error === "string") return error
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null) {
const obj = error as Record<string, unknown>
if (typeof obj.message === "string") return obj.message
try {
return JSON.stringify(error)
} catch {
return ""
}
}
return String(error)
}
export function parseModelSuggestion(error: unknown): ModelSuggestionInfo | null {
if (!error) return null
if (typeof error === "object") {
const errObj = error as Record<string, unknown>
if (errObj.name === "ProviderModelNotFoundError" && typeof errObj.data === "object" && errObj.data !== null) {
const data = errObj.data as Record<string, unknown>
const suggestions = data.suggestions
if (Array.isArray(suggestions) && suggestions.length > 0 && typeof suggestions[0] === "string") {
return {
providerID: String(data.providerID ?? ""),
modelID: String(data.modelID ?? ""),
suggestion: suggestions[0],
}
}
return null
}
for (const key of ["data", "error", "cause"] as const) {
const nested = errObj[key]
if (nested && typeof nested === "object") {
const result = parseModelSuggestion(nested)
if (result) return result
}
}
}
const message = extractMessage(error)
if (!message) return null
const modelMatch = message.match(/model not found:\s*([^/\s]+)\s*\/\s*([^.\s]+)/i)
const suggestionMatch = message.match(/did you mean:\s*([^,?]+)/i)
if (modelMatch && suggestionMatch) {
return {
providerID: modelMatch[1].trim(),
modelID: modelMatch[2].trim(),
suggestion: suggestionMatch[1].trim(),
}
}
return null
}
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test"
import {
transformModelForProvider,
transformModelForProviderDisplay,
} from "./provider-model-id-transform"
describe("provider model ID transforms", () => {
test("keeps separate Anthropic API and display behavior", () => {
// #given an Anthropic model ID in config-display form
const provider = "anthropic"
const model = "claude-opus-4-7"
// #when both model-core transform variants are called
const apiResult = transformModelForProvider(provider, model)
const displayResult = transformModelForProviderDisplay(provider, model)
// #then API calls use dotted Anthropic versions while display keeps hyphens
expect(apiResult).toBe("claude-opus-4.7")
expect(displayResult).toBe("claude-opus-4-7")
})
test("produces identical results for non-Anthropic providers", () => {
// #given non-Anthropic provider/model pairs
const scenarios = [
{ provider: "openai", model: "gpt-4o" },
{ provider: "google", model: "gemini-2.5-pro" },
{ provider: "github-copilot", model: "gemini-3-flash" },
{ provider: "vercel", model: "claude-opus-4-7" },
] as const
for (const scenario of scenarios) {
// #when both transform variants are called
const apiResult = transformModelForProvider(
scenario.provider,
scenario.model,
)
const displayResult = transformModelForProviderDisplay(
scenario.provider,
scenario.model,
)
// #then the variants match outside the direct Anthropic provider branch
expect(displayResult).toBe(apiResult)
}
})
})
@@ -24,7 +24,11 @@ function applyGatewayTransforms(model: string): string {
)
}
export function transformModelForProvider(provider: string, model: string): string {
function transformModelForProviderUsingAnthropicBehavior(
provider: string,
model: string,
directAnthropicTransform: (model: string) => string,
): string {
if (provider === "vercel") {
const slashIndex = model.indexOf("/")
if (slashIndex !== -1) {
@@ -49,7 +53,26 @@ export function transformModelForProvider(provider: string, model: string): stri
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "anthropic") {
return claudeVersionDot(model)
return directAnthropicTransform(model)
}
return model
}
export function transformModelForProvider(provider: string, model: string): string {
return transformModelForProviderUsingAnthropicBehavior(
provider,
model,
claudeVersionDot,
)
}
export function transformModelForProviderDisplay(
provider: string,
model: string,
): string {
return transformModelForProviderUsingAnthropicBehavior(
provider,
model,
(model) => model,
)
}