refactor(packages): extract model-core package

This commit is contained in:
YeonGyu-Kim
2026-05-21 02:11:37 +09:00
parent f7ceb03efe
commit 2748009ff2
60 changed files with 2155 additions and 1834 deletions
+6 -128
View File
@@ -1,128 +1,6 @@
import type { FallbackEntry } from "./model-requirements"
import type { FallbackModelObject } from "../config/schema/fallback-models"
import { normalizeFallbackModels } from "./model-resolver"
import { KNOWN_VARIANTS } from "./known-variants"
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
if (typeof rawModel !== "string") {
return { modelID: "" }
}
const trimmedModel = rawModel.trim()
if (!trimmedModel) {
return { modelID: "" }
}
const parenthesizedVariant = trimmedModel.match(/^(.*)\(([^()]+)\)\s*$/)
if (parenthesizedVariant) {
const modelID = parenthesizedVariant[1]?.trim() ?? ""
const variant = parenthesizedVariant[2]?.trim()
return variant ? { modelID, variant } : { modelID }
}
const spaceVariant = trimmedModel.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
if (spaceVariant) {
const modelID = spaceVariant[1]?.trim() ?? ""
const variant = spaceVariant[2]?.trim().toLowerCase()
if (variant && KNOWN_VARIANTS.has(variant)) {
return { modelID, variant }
}
}
return { modelID: trimmedModel }
}
export function parseFallbackModelEntry(
model: string,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry | undefined {
if (typeof model !== "string") return undefined
const trimmed = model.trim()
if (!trimmed) return undefined
const parts = trimmed.split("/")
const providerID =
parts.length >= 2 ? parts[0].trim() : (contextProviderID?.trim() || defaultProviderID)
const rawModelID = parts.length >= 2 ? parts.slice(1).join("/").trim() : trimmed
if (!providerID || !rawModelID) return undefined
const parsed = parseVariantFromModel(rawModelID)
if (!parsed.modelID) return undefined
return {
providers: [providerID],
model: parsed.modelID,
variant: parsed.variant,
}
}
export function parseFallbackModelObjectEntry(
obj: FallbackModelObject,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry | undefined {
const base = parseFallbackModelEntry(obj.model, contextProviderID, defaultProviderID)
if (!base) return undefined
return {
...base,
variant: obj.variant ?? base.variant,
reasoningEffort: obj.reasoningEffort,
temperature: obj.temperature,
top_p: obj.top_p,
maxTokens: obj.maxTokens,
thinking: obj.thinking,
}
}
/**
* Find the most specific FallbackEntry whose `provider/model` is a prefix of
* the resolved `provider/modelID`. Longest match wins so that e.g.
* `openai/gpt-5.4-preview` picks the entry for `openai/gpt-5.4-preview` over
* the shorter `openai/gpt-5.4`.
*/
export function findMostSpecificFallbackEntry(
providerID: string,
modelID: string,
chain: FallbackEntry[],
): FallbackEntry | undefined {
const resolved = `${providerID}/${modelID}`.toLowerCase()
// Collect entries whose provider/model is a prefix of the resolved model,
// together with the length of the matching prefix (longest match wins).
const matches: { entry: FallbackEntry; matchLen: number }[] = []
for (const entry of chain) {
for (const p of entry.providers) {
const candidate = `${p}/${entry.model}`.toLowerCase()
if (resolved.startsWith(candidate)) {
matches.push({ entry, matchLen: candidate.length })
break // one match per entry is enough
}
}
}
if (matches.length === 0) return undefined
matches.sort((a, b) => b.matchLen - a.matchLen)
return matches[0].entry
}
export function buildFallbackChainFromModels(
fallbackModels: string | (string | FallbackModelObject)[] | undefined,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry[] | undefined {
const normalized = normalizeFallbackModels(fallbackModels)
if (!normalized || normalized.length === 0) return undefined
const parsed = normalized
.map((entry) => {
if (typeof entry === "string") {
return parseFallbackModelEntry(entry, contextProviderID, defaultProviderID)
}
return parseFallbackModelObjectEntry(entry, contextProviderID, defaultProviderID)
})
.filter((entry): entry is FallbackEntry => entry !== undefined)
if (parsed.length === 0) return undefined
return parsed
}
export {
parseFallbackModelEntry,
parseFallbackModelObjectEntry,
findMostSpecificFallbackEntry,
buildFallbackChainFromModels,
} from "@oh-my-opencode/model-core"
+1 -16
View File
@@ -1,16 +1 @@
/**
* Canonical set of recognised variant / effort tokens.
* Used by parseFallbackModelEntry (space-suffix detection) and
* flattenToFallbackModelStrings (inline-variant stripping).
*/
export const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"minimal",
"none",
"auto",
"thinking",
])
export { KNOWN_VARIANTS } from "@oh-my-opencode/model-core"
@@ -1,34 +0,0 @@
import { describe, expect, test } from "bun:test"
import { getBundledModelCapabilitiesSnapshot, getModelCapabilities } from "./model-capabilities"
describe("bundled model capabilities snapshot", () => {
test("keeps GPT-4.1 OpenAI variants marked as supporting tool calls", () => {
// given
const bundledSnapshot = getBundledModelCapabilitiesSnapshot()
const modelIDs = [
"openai/gpt-4.1",
"openai/gpt-4.1-mini",
"openai/gpt-4.1-nano",
]
// when
const results = modelIDs.map((modelID) =>
getModelCapabilities({
providerID: "openai",
modelID,
bundledSnapshot,
}),
)
// then
for (const result of results) {
expect(result.toolCall).toBe(true)
expect(result.diagnostics).toMatchObject({
resolutionMode: "snapshot-backed",
snapshot: { source: "bundled-snapshot" },
toolCall: { source: "bundled-snapshot" },
})
}
})
})
-427
View File
@@ -1,427 +0,0 @@
import type { ModelCapabilitiesSnapshot } from "./model-capabilities"
import { afterEach, describe, expect, test, spyOn } from "bun:test"
import * as connectedProvidersCache from "./connected-providers-cache"
import { getModelCapabilities, getBundledModelCapabilitiesSnapshot } from "./model-capabilities"
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "./model-requirements"
describe("getModelCapabilities", () => {
let findProviderModelMetadataSpy: ReturnType<typeof spyOn> | undefined
afterEach(() => {
findProviderModelMetadataSpy?.mockRestore()
findProviderModelMetadataSpy = undefined
})
const bundledSnapshot: ModelCapabilitiesSnapshot = {
generatedAt: "2026-03-25T00:00:00.000Z",
sourceUrl: "https://models.dev/api.json",
models: {
"claude-opus-4-7": {
id: "claude-opus-4-7",
family: "claude-opus",
reasoning: true,
temperature: true,
modalities: {
input: ["text", "image", "pdf"],
output: ["text"],
},
limit: {
context: 1_000_000,
output: 128_000,
},
toolCall: true,
},
"gemini-3.1-pro": {
id: "gemini-3.1-pro",
family: "gemini",
reasoning: true,
temperature: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 1_000_000,
output: 65_000,
},
},
"gpt-5.4": {
id: "gpt-5.4",
family: "gpt",
reasoning: true,
temperature: false,
modalities: {
input: ["text", "image", "pdf"],
output: ["text"],
},
limit: {
context: 1_050_000,
output: 128_000,
},
},
"minimax-m2.7": {
id: "minimax-m2.7",
family: "minimax",
reasoning: true,
temperature: true,
},
},
}
test("uses runtime metadata before snapshot data", () => {
findProviderModelMetadataSpy = spyOn(connectedProvidersCache, "findProviderModelMetadata").mockReturnValue(undefined)
const result = getModelCapabilities({
providerID: "anthropic",
modelID: "claude-opus-4-7",
runtimeModel: {
variants: {
low: {},
medium: {},
high: {},
},
},
bundledSnapshot,
})
expect(result).toMatchObject({
canonicalModelID: "claude-opus-4-7",
family: "claude-opus",
variants: ["low", "medium", "high"],
supportsThinking: true,
supportsTemperature: true,
maxOutputTokens: 128_000,
toolCall: true,
})
expect(result.diagnostics).toMatchObject({
resolutionMode: "snapshot-backed",
canonicalization: { source: "canonical" },
snapshot: { source: "bundled-snapshot" },
variants: { source: "runtime" },
})
})
test("reads structured runtime capabilities from the SDK v2 shape", () => {
findProviderModelMetadataSpy = spyOn(connectedProvidersCache, "findProviderModelMetadata").mockReturnValue(undefined)
const result = getModelCapabilities({
providerID: "openai",
modelID: "gpt-5.4",
runtimeModel: {
capabilities: {
reasoning: true,
temperature: false,
toolcall: true,
input: {
text: true,
image: true,
},
output: {
text: true,
},
},
},
bundledSnapshot,
})
expect(result).toMatchObject({
canonicalModelID: "gpt-5.4",
reasoning: true,
supportsThinking: true,
supportsTemperature: false,
toolCall: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
})
expect(result.diagnostics).toMatchObject({
resolutionMode: "snapshot-backed",
reasoning: { source: "runtime" },
supportsThinking: { source: "runtime" },
toolCall: { source: "runtime" },
})
})
test("respects root-level thinking flags when providers do not nest them under capabilities", () => {
findProviderModelMetadataSpy = spyOn(connectedProvidersCache, "findProviderModelMetadata").mockReturnValue(undefined)
const result = getModelCapabilities({
providerID: "custom-proxy",
modelID: "gpt-5.4",
runtimeModel: {
supportsThinking: true,
},
bundledSnapshot,
})
expect(result).toMatchObject({
canonicalModelID: "gpt-5.4",
supportsThinking: true,
})
expect(result.diagnostics).toMatchObject({
supportsThinking: { source: "runtime" },
})
})
test("accepts runtime variant arrays without corrupting them into numeric keys", () => {
findProviderModelMetadataSpy = spyOn(connectedProvidersCache, "findProviderModelMetadata").mockReturnValue(undefined)
const result = getModelCapabilities({
providerID: "openai",
modelID: "gpt-5.4",
runtimeModel: {
variants: ["low", "medium", "high", "xhigh"],
},
bundledSnapshot,
})
expect(result.variants).toEqual(["low", "medium", "high", "xhigh"])
})
test("normalizes the legacy Claude Opus thinking alias before snapshot lookup", () => {
findProviderModelMetadataSpy = spyOn(connectedProvidersCache, "findProviderModelMetadata").mockReturnValue(undefined)
const result = getModelCapabilities({
providerID: "anthropic",
modelID: "claude-opus-4-7-thinking",
bundledSnapshot,
})
expect(result).toMatchObject({
canonicalModelID: "claude-opus-4-7",
family: "claude-opus",
supportsThinking: true,
supportsTemperature: true,
maxOutputTokens: 128_000,
})
expect(result.diagnostics).toMatchObject({
resolutionMode: "alias-backed",
canonicalization: {
source: "pattern-alias",
ruleID: "claude-thinking-legacy-alias",
},
snapshot: { source: "bundled-snapshot" },
})
})
test("maps local gemini aliases to canonical models.dev entries", () => {
findProviderModelMetadataSpy = spyOn(connectedProvidersCache, "findProviderModelMetadata").mockReturnValue(undefined)
const result = getModelCapabilities({
providerID: "google",
modelID: "gemini-3.1-pro-high",
bundledSnapshot,
})
expect(result).toMatchObject({
canonicalModelID: "gemini-3.1-pro",
family: "gemini",
supportsThinking: true,
supportsTemperature: true,
maxOutputTokens: 65_000,
})
expect(result.diagnostics).toMatchObject({
resolutionMode: "alias-backed",
canonicalization: {
source: "pattern-alias",
ruleID: "gemini-3.1-pro-tier-alias",
},
snapshot: { source: "bundled-snapshot" },
})
})
test("canonicalizes provider-prefixed gemini aliases without changing the transport-facing request", () => {
const result = getModelCapabilities({
providerID: "google",
modelID: "google/gemini-3.1-pro-high",
bundledSnapshot,
})
expect(result).toMatchObject({
requestedModelID: "google/gemini-3.1-pro-high",
canonicalModelID: "gemini-3.1-pro",
family: "gemini",
supportsThinking: true,
supportsTemperature: true,
maxOutputTokens: 65_000,
})
expect(result.diagnostics).toMatchObject({
resolutionMode: "alias-backed",
canonicalization: {
source: "pattern-alias",
ruleID: "gemini-3.1-pro-tier-alias",
},
snapshot: { source: "bundled-snapshot" },
})
})
test("canonicalizes provider-prefixed Claude thinking aliases to bare snapshot IDs", () => {
const result = getModelCapabilities({
providerID: "anthropic",
modelID: "anthropic/claude-opus-4-7-thinking",
bundledSnapshot,
})
expect(result).toMatchObject({
requestedModelID: "anthropic/claude-opus-4-7-thinking",
canonicalModelID: "claude-opus-4-7",
family: "claude-opus",
supportsThinking: true,
supportsTemperature: true,
maxOutputTokens: 128_000,
})
expect(result.diagnostics).toMatchObject({
resolutionMode: "alias-backed",
canonicalization: {
source: "pattern-alias",
ruleID: "claude-thinking-legacy-alias",
},
snapshot: { source: "bundled-snapshot" },
})
})
test("prefers runtime models.dev cache over bundled snapshot", () => {
findProviderModelMetadataSpy = spyOn(connectedProvidersCache, "findProviderModelMetadata").mockReturnValue(undefined)
const runtimeSnapshot: ModelCapabilitiesSnapshot = {
...bundledSnapshot,
models: {
...bundledSnapshot.models,
"gpt-5.4": {
...bundledSnapshot.models["gpt-5.4"],
limit: {
context: 1_050_000,
output: 64_000,
},
},
},
}
const result = getModelCapabilities({
providerID: "openai",
modelID: "gpt-5.4",
bundledSnapshot,
runtimeSnapshot,
})
expect(result).toMatchObject({
canonicalModelID: "gpt-5.4",
maxOutputTokens: 64_000,
supportsTemperature: false,
})
expect(result.diagnostics).toMatchObject({
snapshot: { source: "runtime-snapshot" },
maxOutputTokens: { source: "runtime-snapshot" },
supportsTemperature: { source: "runtime-snapshot" },
})
})
test("falls back to heuristic family rules when no snapshot entry exists", () => {
const result = getModelCapabilities({
providerID: "openai",
modelID: "o3-mini",
bundledSnapshot,
})
expect(result).toMatchObject({
canonicalModelID: "o3-mini",
family: "openai-reasoning",
variants: ["low", "medium", "high"],
reasoningEfforts: ["none", "minimal", "low", "medium", "high"],
})
expect(result.diagnostics).toMatchObject({
resolutionMode: "heuristic-backed",
snapshot: { source: "none" },
family: { source: "heuristic" },
reasoningEfforts: { source: "heuristic" },
})
})
test("marks MiniMax M2.7 as not supporting thinking despite snapshot reasoning", () => {
// given
const modelID = "minimax-m2.7"
// when
const result = getModelCapabilities({
providerID: "volcengine",
modelID,
bundledSnapshot,
})
// then
expect(result.supportsThinking).toBe(false)
expect(result.diagnostics.supportsThinking.source).toBe("heuristic")
})
test("marks non-thinking Kimi K2.6 as not supporting thinking", () => {
// given
const modelID = "kimi-k2.6"
// when
const result = getModelCapabilities({
providerID: "volcengine",
modelID,
bundledSnapshot,
})
// then
expect(result.supportsThinking).toBe(false)
expect(result.diagnostics.supportsThinking.source).toBe("heuristic")
})
test("keeps thinking-flavored Kimi K2.6 models as supporting thinking", () => {
// given
const modelID = "kimi-k2.6-thinking"
// when
const result = getModelCapabilities({
providerID: "volcengine",
modelID,
bundledSnapshot,
})
// then
expect(result.supportsThinking).toBe(true)
expect(result.family).toBe("kimi-thinking")
expect(result.diagnostics.supportsThinking.source).toBe("heuristic")
})
test("detects prefixed o-series model IDs through the heuristic fallback", () => {
const result = getModelCapabilities({
providerID: "azure-openai",
modelID: "openai/o3-mini",
bundledSnapshot,
})
expect(result).toMatchObject({
requestedModelID: "openai/o3-mini",
canonicalModelID: "o3-mini",
family: "openai-reasoning",
variants: ["low", "medium", "high"],
reasoningEfforts: ["none", "minimal", "low", "medium", "high"],
})
expect(result.diagnostics).toMatchObject({
resolutionMode: "heuristic-backed",
snapshot: { source: "none" },
family: { source: "heuristic" },
})
})
test("keeps every built-in OmO requirement model snapshot-backed", () => {
const bundledSnapshot = getBundledModelCapabilitiesSnapshot()
const requirementModels = new Set<string>()
for (const requirement of Object.values(AGENT_MODEL_REQUIREMENTS)) {
for (const entry of requirement.fallbackChain) requirementModels.add(entry.model)
}
for (const requirement of Object.values(CATEGORY_MODEL_REQUIREMENTS)) {
for (const entry of requirement.fallbackChain) requirementModels.add(entry.model)
}
for (const modelID of requirementModels) {
const result = getModelCapabilities({
providerID: "test-provider",
modelID,
bundledSnapshot,
})
expect(result.diagnostics.resolutionMode).toBe("snapshot-backed")
expect(result.diagnostics.snapshot.source).toBe("bundled-snapshot")
}
})
})
@@ -1,24 +0,0 @@
import bundledModelCapabilitiesSnapshotJson from "../../generated/model-capabilities.generated.json"
import { SUPPLEMENTAL_MODEL_CAPABILITIES } from "./supplemental-entries"
import type { ModelCapabilitiesSnapshot } from "./types"
function normalizeSnapshot(
snapshot: ModelCapabilitiesSnapshot | typeof bundledModelCapabilitiesSnapshotJson,
): ModelCapabilitiesSnapshot {
return snapshot as ModelCapabilitiesSnapshot
}
const normalizedBundledSnapshot = normalizeSnapshot(bundledModelCapabilitiesSnapshotJson)
const bundledModelCapabilitiesSnapshot: ModelCapabilitiesSnapshot = {
...normalizedBundledSnapshot,
models: {
...normalizedBundledSnapshot.models,
...SUPPLEMENTAL_MODEL_CAPABILITIES,
},
}
export function getBundledModelCapabilitiesSnapshot(): ModelCapabilitiesSnapshot {
return bundledModelCapabilitiesSnapshot
}
@@ -1,140 +0,0 @@
import { findProviderModelMetadata } from "../connected-providers-cache"
import { resolveModelIDAlias } from "../model-capability-aliases"
import { detectHeuristicModelFamily } from "../model-capability-heuristics"
import { getBundledModelCapabilitiesSnapshot } from "./bundled-snapshot"
import {
readRuntimeModel,
readRuntimeModelLimitOutput,
readRuntimeModelModalities,
readRuntimeModelReasoningSupport,
readRuntimeModelTemperatureSupport,
readRuntimeModelThinkingSupport,
readRuntimeModelToolCallSupport,
readRuntimeModelTopPSupport,
readRuntimeModelVariants,
} from "./runtime-model-readers"
import type {
GetModelCapabilitiesInput,
ModelCapabilities,
ModelCapabilitiesDiagnostics,
ModelCapabilityOverride,
} from "./types"
const MODEL_ID_OVERRIDES: Record<string, ModelCapabilityOverride> = {}
function normalizeLookupModelID(modelID: string): string {
return modelID.trim().toLowerCase()
}
function getOverride(modelID: string): ModelCapabilityOverride | undefined {
return MODEL_ID_OVERRIDES[normalizeLookupModelID(modelID)]
}
export function getModelCapabilities(input: GetModelCapabilitiesInput): ModelCapabilities {
const canonicalization = resolveModelIDAlias(input.modelID)
const override = getOverride(input.modelID)
const runtimeModel = readRuntimeModel(
input.runtimeModel ?? findProviderModelMetadata(input.providerID, input.modelID),
)
const runtimeSnapshot = input.runtimeSnapshot
const bundledSnapshot = input.bundledSnapshot ?? getBundledModelCapabilitiesSnapshot()
const snapshotEntry = runtimeSnapshot?.models?.[canonicalization.canonicalModelID]
?? bundledSnapshot.models[canonicalization.canonicalModelID]
const heuristicFamily = detectHeuristicModelFamily(canonicalization.canonicalModelID)
const runtimeVariants = readRuntimeModelVariants(runtimeModel)
const runtimeReasoning = readRuntimeModelReasoningSupport(runtimeModel)
const runtimeThinking = readRuntimeModelThinkingSupport(runtimeModel)
const runtimeTemperature = readRuntimeModelTemperatureSupport(runtimeModel)
const runtimeTopP = readRuntimeModelTopPSupport(runtimeModel)
const runtimeMaxOutputTokens = readRuntimeModelLimitOutput(runtimeModel)
const runtimeToolCall = readRuntimeModelToolCallSupport(runtimeModel)
const runtimeModalities = readRuntimeModelModalities(runtimeModel)
const snapshotSource: ModelCapabilitiesDiagnostics["snapshot"]["source"] =
runtimeSnapshot?.models?.[canonicalization.canonicalModelID]
? "runtime-snapshot"
: bundledSnapshot.models[canonicalization.canonicalModelID]
? "bundled-snapshot"
: "none"
const familySource: ModelCapabilitiesDiagnostics["family"]["source"] =
snapshotEntry?.family ? "snapshot" : heuristicFamily?.family ? "heuristic" : "none"
const variantsSource: ModelCapabilitiesDiagnostics["variants"]["source"] =
runtimeVariants ? "runtime" : override?.variants ? "override" : heuristicFamily?.variants ? "heuristic" : "none"
const reasoningEffortsSource: ModelCapabilitiesDiagnostics["reasoningEfforts"]["source"] =
override?.reasoningEfforts ? "override" : heuristicFamily?.reasoningEfforts ? "heuristic" : "none"
const reasoningSource: ModelCapabilitiesDiagnostics["reasoning"]["source"] =
runtimeReasoning === undefined ? snapshotEntry?.reasoning === undefined ? "none" : snapshotSource : "runtime"
const supportsThinkingSource: ModelCapabilitiesDiagnostics["supportsThinking"]["source"] =
override?.supportsThinking !== undefined
? "override"
: heuristicFamily?.supportsThinking !== undefined
? "heuristic"
: runtimeThinking !== undefined
? "runtime"
: snapshotEntry?.reasoning !== undefined
? snapshotSource
: "none"
const supportsTemperatureSource: ModelCapabilitiesDiagnostics["supportsTemperature"]["source"] =
runtimeTemperature !== undefined
? "runtime"
: override?.supportsTemperature !== undefined
? "override"
: snapshotEntry?.temperature !== undefined
? snapshotSource
: "none"
const supportsTopPSource: ModelCapabilitiesDiagnostics["supportsTopP"]["source"] =
runtimeTopP !== undefined ? "runtime" : override?.supportsTopP !== undefined ? "override" : "none"
const maxOutputTokensSource: ModelCapabilitiesDiagnostics["maxOutputTokens"]["source"] =
runtimeMaxOutputTokens !== undefined
? "runtime"
: snapshotEntry?.limit?.output !== undefined
? snapshotSource
: "none"
const toolCallSource: ModelCapabilitiesDiagnostics["toolCall"]["source"] =
runtimeToolCall !== undefined ? "runtime" : snapshotEntry?.toolCall !== undefined ? snapshotSource : "none"
const modalitiesSource: ModelCapabilitiesDiagnostics["modalities"]["source"] =
runtimeModalities !== undefined ? "runtime" : snapshotEntry?.modalities !== undefined ? snapshotSource : "none"
const resolutionMode: ModelCapabilitiesDiagnostics["resolutionMode"] =
snapshotSource !== "none" && canonicalization.source === "canonical"
? "snapshot-backed"
: snapshotSource !== "none"
? "alias-backed"
: familySource === "heuristic" || variantsSource === "heuristic" || reasoningEffortsSource === "heuristic"
? "heuristic-backed"
: "unknown"
return {
requestedModelID: canonicalization.requestedModelID,
canonicalModelID: canonicalization.canonicalModelID,
family: snapshotEntry?.family ?? heuristicFamily?.family,
variants: runtimeVariants ?? override?.variants ?? heuristicFamily?.variants,
reasoningEfforts: override?.reasoningEfforts ?? heuristicFamily?.reasoningEfforts,
reasoning: runtimeReasoning ?? snapshotEntry?.reasoning,
supportsThinking: override?.supportsThinking ?? heuristicFamily?.supportsThinking ?? runtimeThinking ?? snapshotEntry?.reasoning,
supportsTemperature: runtimeTemperature ?? override?.supportsTemperature ?? snapshotEntry?.temperature,
supportsTopP: runtimeTopP ?? override?.supportsTopP,
maxOutputTokens: runtimeMaxOutputTokens ?? snapshotEntry?.limit?.output,
toolCall: runtimeToolCall ?? snapshotEntry?.toolCall,
modalities: runtimeModalities ?? snapshotEntry?.modalities,
diagnostics: {
resolutionMode,
canonicalization: {
source: canonicalization.source,
...(canonicalization.ruleID ? { ruleID: canonicalization.ruleID } : {}),
},
snapshot: { source: snapshotSource },
family: { source: familySource },
variants: { source: variantsSource },
reasoningEfforts: { source: reasoningEffortsSource },
reasoning: { source: reasoningSource },
supportsThinking: { source: supportsThinkingSource },
supportsTemperature: { source: supportsTemperatureSource },
supportsTopP: { source: supportsTopPSource },
maxOutputTokens: { source: maxOutputTokensSource },
toolCall: { source: toolCallSource },
modalities: { source: modalitiesSource },
},
}
}
+21 -8
View File
@@ -1,9 +1,22 @@
export { getBundledModelCapabilitiesSnapshot } from "./bundled-snapshot"
export { getModelCapabilities } from "./get-model-capabilities"
import {
getBundledModelCapabilitiesSnapshot,
getModelCapabilities as getModelCapabilitiesFromCore,
} from "@oh-my-opencode/model-core"
import type { GetModelCapabilitiesInput, ModelCapabilities } from "@oh-my-opencode/model-core"
import * as connectedProvidersCache from "../connected-providers-cache"
export { getBundledModelCapabilitiesSnapshot }
export function getModelCapabilities(input: GetModelCapabilitiesInput): ModelCapabilities {
return getModelCapabilitiesFromCore({
...input,
providerCache: input.providerCache ?? connectedProvidersCache,
})
}
export type {
GetModelCapabilitiesInput,
ModelCapabilities,
ModelCapabilitiesDiagnostics,
ModelCapabilitiesSnapshot,
ModelCapabilitiesSnapshotEntry,
} from "./types"
GetModelCapabilitiesInput,
ModelCapabilities,
ModelCapabilitiesDiagnostics,
ModelCapabilitiesSnapshot,
ModelCapabilitiesSnapshotEntry,
} from "@oh-my-opencode/model-core"
@@ -1,203 +0,0 @@
import type { ModelMetadata } from "../connected-providers-cache"
import type { ModelCapabilities } from "./types"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined
}
function readStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const strings = value.filter((item): item is string => typeof item === "string")
return strings.length > 0 ? strings : undefined
}
function normalizeVariantKeys(value: unknown): string[] | undefined {
const arrayVariants = readStringArray(value)
if (arrayVariants) {
return arrayVariants.filter((v): v is string => typeof v === "string").map((variant) => variant.toLowerCase())
}
if (!isRecord(value)) {
return undefined
}
const variants = Object.keys(value).map((variant) => variant.toLowerCase())
return variants.length > 0 ? variants : undefined
}
function readModalityKeys(value: unknown): string[] | undefined {
const stringArray = readStringArray(value)
if (stringArray) {
return stringArray.filter((entry): entry is string => typeof entry === "string").map((entry) => entry.toLowerCase())
}
if (!isRecord(value)) {
return undefined
}
// Handle OpenCode's object-shaped modalities: { input: string[], output: string[] }
// When the full modalities object reaches here (e.g. via the normalizeModalities
// fallback path), flatten nested string arrays before applying toLowerCase.
const fromNested = Object.values(value)
.filter((v): v is string[] => Array.isArray(v))
.flat()
.filter((item): item is string => typeof item === "string")
if (fromNested.length > 0) {
return fromNested.map((entry) => entry.toLowerCase())
}
const enabled = Object.entries(value)
.filter(([, supported]) => supported === true)
.map(([modality]) => modality.toLowerCase())
return enabled.length > 0 ? enabled : undefined
}
function normalizeModalities(value: unknown): ModelCapabilities["modalities"] | undefined {
if (!isRecord(value)) {
return undefined
}
const input = readModalityKeys(value.input)
const output = readModalityKeys(value.output)
if (!input && !output) {
return undefined
}
return {
...(input ? { input } : {}),
...(output ? { output } : {}),
}
}
function readRuntimeModelCapabilities(
runtimeModel: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
return isRecord(runtimeModel?.capabilities) ? runtimeModel.capabilities : undefined
}
function readRuntimeModelBoolean(
runtimeModel: Record<string, unknown> | undefined,
keys: string[],
): boolean | undefined {
const runtimeCapabilities = readRuntimeModelCapabilities(runtimeModel)
for (const key of keys) {
const value = runtimeModel?.[key]
if (typeof value === "boolean") {
return value
}
const capabilityValue = runtimeCapabilities?.[key]
if (typeof capabilityValue === "boolean") {
return capabilityValue
}
}
return undefined
}
export function readRuntimeModel(
runtimeModel: ModelMetadata | Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
return isRecord(runtimeModel) ? runtimeModel : undefined
}
export function readRuntimeModelVariants(
runtimeModel: Record<string, unknown> | undefined,
): string[] | undefined {
const rootVariants = normalizeVariantKeys(runtimeModel?.variants)
if (rootVariants) {
return rootVariants
}
return normalizeVariantKeys(readRuntimeModelCapabilities(runtimeModel)?.variants)
}
export function readRuntimeModelModalities(
runtimeModel: Record<string, unknown> | undefined,
): ModelCapabilities["modalities"] | undefined {
const rootModalities = normalizeModalities(runtimeModel?.modalities)
if (rootModalities) {
return rootModalities
}
const runtimeCapabilities = readRuntimeModelCapabilities(runtimeModel)
return (
normalizeModalities(runtimeCapabilities?.modalities)
?? normalizeModalities(runtimeCapabilities)
)
}
export function readRuntimeModelReasoningSupport(
runtimeModel: Record<string, unknown> | undefined,
): boolean | undefined {
return readRuntimeModelBoolean(runtimeModel, ["reasoning"])
}
export function readRuntimeModelThinkingSupport(
runtimeModel: Record<string, unknown> | undefined,
): boolean | undefined {
const capabilityValue = readRuntimeModelReasoningSupport(runtimeModel)
if (capabilityValue !== undefined) {
return capabilityValue
}
const thinkingSupport = readRuntimeModelBoolean(runtimeModel, ["thinking", "supportsThinking"])
if (thinkingSupport !== undefined) {
return thinkingSupport
}
const runtimeCapabilities = readRuntimeModelCapabilities(runtimeModel)
for (const key of ["thinking", "supportsThinking"] as const) {
const value = runtimeCapabilities?.[key]
if (typeof value === "boolean") {
return value
}
}
return undefined
}
export function readRuntimeModelTemperatureSupport(
runtimeModel: Record<string, unknown> | undefined,
): boolean | undefined {
return readRuntimeModelBoolean(runtimeModel, ["temperature"])
}
export function readRuntimeModelTopPSupport(
runtimeModel: Record<string, unknown> | undefined,
): boolean | undefined {
return readRuntimeModelBoolean(runtimeModel, ["topP", "top_p"])
}
export function readRuntimeModelToolCallSupport(
runtimeModel: Record<string, unknown> | undefined,
): boolean | undefined {
return readRuntimeModelBoolean(runtimeModel, ["toolCall", "tool_call", "toolcall"])
}
export function readRuntimeModelLimitOutput(
runtimeModel: Record<string, unknown> | undefined,
): number | undefined {
const limit = isRecord(runtimeModel?.limit)
? runtimeModel.limit
: readRuntimeModelCapabilities(runtimeModel)?.limit
if (!isRecord(limit)) {
return undefined
}
const output = readNumber(limit.output)
// Treat 0 or negative as unknown so ?? fallback to bundled snapshot works
return output && output > 0 ? output : undefined
}
@@ -1,51 +0,0 @@
import type { ModelCapabilitiesSnapshotEntry } from "./types"
export const SUPPLEMENTAL_MODEL_CAPABILITIES: Record<string, ModelCapabilitiesSnapshotEntry> = {
"kimi-k2.6": {
id: "kimi-k2.6",
family: "kimi",
reasoning: true,
temperature: true,
toolCall: true,
modalities: {
input: ["text", "image", "video"],
output: ["text"],
},
limit: {
context: 262144,
output: 262144,
},
},
"gpt-5.5": {
id: "gpt-5.5",
family: "gpt",
reasoning: true,
temperature: false,
toolCall: true,
modalities: {
input: ["text", "image", "pdf"],
output: ["text"],
},
limit: {
context: 400000,
input: 272000,
output: 128000,
},
},
"gpt-5.4-mini-fast": {
id: "gpt-5.4-mini-fast",
family: "gpt-mini",
reasoning: true,
temperature: false,
toolCall: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 400000,
input: 272000,
output: 128000,
},
},
}
-80
View File
@@ -1,80 +0,0 @@
import type { ModelMetadata } from "../connected-providers-cache"
export type ModelCapabilitiesSnapshotEntry = {
id: string
family?: string
reasoning?: boolean
temperature?: boolean
toolCall?: boolean
modalities?: {
input?: string[]
output?: string[]
}
limit?: {
context?: number
input?: number
output?: number
}
}
export type ModelCapabilitiesSnapshot = {
generatedAt: string
sourceUrl: string
models: Record<string, ModelCapabilitiesSnapshotEntry>
}
export type ModelCapabilitiesDiagnostics = {
resolutionMode: "snapshot-backed" | "alias-backed" | "heuristic-backed" | "unknown"
canonicalization: {
source: "canonical" | "exact-alias" | "pattern-alias"
ruleID?: string
}
snapshot: {
source: "runtime-snapshot" | "bundled-snapshot" | "none"
}
family: { source: "snapshot" | "heuristic" | "none" }
variants: { source: "none" | "runtime" | "override" | "heuristic" | "canonical" }
reasoningEfforts: { source: "none" | "override" | "heuristic" }
reasoning: { source: "runtime" | "runtime-snapshot" | "bundled-snapshot" | "none" }
supportsThinking: { source: "runtime" | "override" | "heuristic" | "runtime-snapshot" | "bundled-snapshot" | "none" }
supportsTemperature: { source: "runtime" | "override" | "runtime-snapshot" | "bundled-snapshot" | "none" }
supportsTopP: { source: "runtime" | "override" | "none" }
maxOutputTokens: { source: "runtime" | "runtime-snapshot" | "bundled-snapshot" | "none" }
toolCall: { source: "runtime" | "runtime-snapshot" | "bundled-snapshot" | "none" }
modalities: { source: "runtime" | "runtime-snapshot" | "bundled-snapshot" | "none" }
}
export type ModelCapabilities = {
requestedModelID: string
canonicalModelID: string
family?: string
variants?: string[]
reasoningEfforts?: string[]
reasoning?: boolean
supportsThinking?: boolean
supportsTemperature?: boolean
supportsTopP?: boolean
maxOutputTokens?: number
toolCall?: boolean
modalities?: {
input?: string[]
output?: string[]
}
diagnostics: ModelCapabilitiesDiagnostics
}
export type GetModelCapabilitiesInput = {
providerID: string
modelID: string
runtimeModel?: ModelMetadata | Record<string, unknown>
runtimeSnapshot?: ModelCapabilitiesSnapshot
bundledSnapshot?: ModelCapabilitiesSnapshot
}
export type ModelCapabilityOverride = {
variants?: string[]
reasoningEfforts?: string[]
supportsThinking?: boolean
supportsTemperature?: boolean
supportsTopP?: boolean
}
-142
View File
@@ -1,142 +0,0 @@
import { describe, expect, test } from "bun:test"
import { resolveModelIDAlias } from "./model-capability-aliases"
describe("model-capability-aliases", () => {
test("keeps canonical model IDs unchanged", () => {
const result = resolveModelIDAlias("gpt-5.4")
expect(result).toEqual({
requestedModelID: "gpt-5.4",
canonicalModelID: "gpt-5.4",
source: "canonical",
})
})
test("strips provider prefixes when the input is already canonical", () => {
const result = resolveModelIDAlias("anthropic/claude-sonnet-4-6")
expect(result).toEqual({
requestedModelID: "anthropic/claude-sonnet-4-6",
canonicalModelID: "claude-sonnet-4-6",
source: "canonical",
})
})
test("normalizes gemini tier aliases through a pattern rule", () => {
const result = resolveModelIDAlias("gemini-3.1-pro-high")
expect(result).toEqual({
requestedModelID: "gemini-3.1-pro-high",
canonicalModelID: "gemini-3.1-pro",
source: "pattern-alias",
ruleID: "gemini-3.1-pro-tier-alias",
})
})
test("normalizes provider-prefixed gemini tier aliases to bare canonical IDs", () => {
const result = resolveModelIDAlias("google/gemini-3.1-pro-high")
expect(result).toEqual({
requestedModelID: "google/gemini-3.1-pro-high",
canonicalModelID: "gemini-3.1-pro",
source: "pattern-alias",
ruleID: "gemini-3.1-pro-tier-alias",
})
})
test("keeps exceptional gemini preview aliases as exact rules", () => {
const result = resolveModelIDAlias("gemini-3-pro-high")
expect(result).toEqual({
requestedModelID: "gemini-3-pro-high",
canonicalModelID: "gemini-3-pro-preview",
source: "exact-alias",
ruleID: "gemini-3-pro-tier-alias",
})
})
test("normalizes Kimi for Coding k2pb aliases to the snapshot ID", () => {
const result = resolveModelIDAlias("kimi-for-coding/k2pb")
expect(result).toEqual({
requestedModelID: "kimi-for-coding/k2pb",
canonicalModelID: "k2p5",
source: "exact-alias",
ruleID: "kimi-k2pb-alias",
})
})
test("normalizes GitHub Copilot dotted Claude Opus aliases to the snapshot ID", () => {
const result = resolveModelIDAlias("github-copilot/claude-opus-4.7")
expect(result).toEqual({
requestedModelID: "github-copilot/claude-opus-4.7",
canonicalModelID: "claude-opus-4-7",
source: "exact-alias",
ruleID: "claude-opus-dotted-version-alias",
})
})
test("does not resolve prototype keys as aliases", () => {
const result = resolveModelIDAlias("constructor")
expect(result).toEqual({
requestedModelID: "constructor",
canonicalModelID: "constructor",
source: "canonical",
})
})
test("normalizes provider-prefixed Claude thinking aliases through a pattern rule", () => {
const result = resolveModelIDAlias("anthropic/claude-opus-4-7-thinking")
expect(result).toEqual({
requestedModelID: "anthropic/claude-opus-4-7-thinking",
canonicalModelID: "claude-opus-4-7",
source: "pattern-alias",
ruleID: "claude-thinking-legacy-alias",
})
})
test("does not pattern-match nearby canonical Claude IDs incorrectly", () => {
const result = resolveModelIDAlias("claude-opus-4-7-think")
expect(result).toEqual({
requestedModelID: "claude-opus-4-7-think",
canonicalModelID: "claude-opus-4-7-think",
source: "canonical",
})
})
test("does not pattern-match canonical gemini preview IDs incorrectly", () => {
const result = resolveModelIDAlias("gemini-3.1-pro-preview")
expect(result).toEqual({
requestedModelID: "gemini-3.1-pro-preview",
canonicalModelID: "gemini-3.1-pro-preview",
source: "canonical",
})
})
test("normalizes legacy Claude thinking aliases through a pattern rule", () => {
const result = resolveModelIDAlias("claude-opus-4-7-thinking")
expect(result).toEqual({
requestedModelID: "claude-opus-4-7-thinking",
canonicalModelID: "claude-opus-4-7",
source: "pattern-alias",
ruleID: "claude-thinking-legacy-alias",
})
})
test("treats claude-opus-4-6-thinking as canonical, not as a legacy alias", () => {
const result = resolveModelIDAlias("claude-opus-4-6-thinking")
expect(result).toEqual({
requestedModelID: "claude-opus-4-6-thinking",
canonicalModelID: "claude-opus-4-6-thinking",
source: "canonical",
})
})
})
+10 -120
View File
@@ -1,120 +1,10 @@
export type ExactAliasRule = {
aliasModelID: string
ruleID: string
canonicalModelID: string
rationale: string
}
export type PatternAliasRule = {
ruleID: string
description: string
match: (normalizedModelID: string) => boolean
canonicalize: (normalizedModelID: string) => string
}
export type ModelIDAliasResolution = {
requestedModelID: string
canonicalModelID: string
source: "canonical" | "exact-alias" | "pattern-alias"
ruleID?: string
}
const EXACT_ALIAS_RULES: ReadonlyArray<ExactAliasRule> = [
{
aliasModelID: "gemini-3-pro-high",
ruleID: "gemini-3-pro-tier-alias",
canonicalModelID: "gemini-3-pro-preview",
rationale: "Legacy Gemini 3 tier suffixes still need to land on the canonical preview model.",
},
{
aliasModelID: "gemini-3-pro-low",
ruleID: "gemini-3-pro-tier-alias",
canonicalModelID: "gemini-3-pro-preview",
rationale: "Legacy Gemini 3 tier suffixes still need to land on the canonical preview model.",
},
{
aliasModelID: "k2pb",
ruleID: "kimi-k2pb-alias",
canonicalModelID: "k2p5",
rationale: "Kimi for Coding exposes k2pb while the bundled capabilities snapshot uses the canonical k2p5 ID.",
},
{
aliasModelID: "claude-opus-4.7",
ruleID: "claude-opus-dotted-version-alias",
canonicalModelID: "claude-opus-4-7",
rationale: "GitHub Copilot exposes Claude Opus 4.7 with dotted version syntax while the snapshot uses dashed syntax.",
},
]
const EXACT_ALIAS_RULES_BY_MODEL: ReadonlyMap<string, ExactAliasRule> = new Map(
EXACT_ALIAS_RULES.map((rule) => [rule.aliasModelID, rule]),
)
const PATTERN_ALIAS_RULES: ReadonlyArray<PatternAliasRule> = [
{
ruleID: "claude-thinking-legacy-alias",
description: "Normalizes the legacy claude-opus-4-7-thinking id to the canonical snapshot ID.",
match: (normalizedModelID) => /^claude-opus-4-7-thinking$/.test(normalizedModelID),
canonicalize: () => "claude-opus-4-7",
},
{
ruleID: "gemini-3.1-pro-tier-alias",
description: "Normalizes Gemini 3.1 Pro tier suffixes to the canonical snapshot ID.",
match: (normalizedModelID) => /^gemini-3\.1-pro-(?:high|low)$/.test(normalizedModelID),
canonicalize: () => "gemini-3.1-pro",
},
]
function normalizeLookupModelID(modelID: string): string {
return modelID.trim().toLowerCase()
}
function stripProviderPrefixForAliasLookup(normalizedModelID: string): string {
const slashIndex = normalizedModelID.indexOf("/")
if (slashIndex <= 0 || slashIndex === normalizedModelID.length - 1) {
return normalizedModelID
}
return normalizedModelID.slice(slashIndex + 1)
}
export function resolveModelIDAlias(modelID: string): ModelIDAliasResolution {
const requestedModelID = normalizeLookupModelID(modelID)
const aliasLookupModelID = stripProviderPrefixForAliasLookup(requestedModelID)
const exactRule = EXACT_ALIAS_RULES_BY_MODEL.get(aliasLookupModelID)
if (exactRule) {
return {
requestedModelID,
canonicalModelID: exactRule.canonicalModelID,
source: "exact-alias",
ruleID: exactRule.ruleID,
}
}
for (const rule of PATTERN_ALIAS_RULES) {
if (!rule.match(aliasLookupModelID)) {
continue
}
return {
requestedModelID,
canonicalModelID: rule.canonicalize(aliasLookupModelID),
source: "pattern-alias",
ruleID: rule.ruleID,
}
}
return {
requestedModelID,
canonicalModelID: aliasLookupModelID,
source: "canonical",
}
}
export function getExactModelIDAliasRules(): ReadonlyArray<ExactAliasRule> {
return EXACT_ALIAS_RULES
}
export function getPatternModelIDAliasRules(): ReadonlyArray<PatternAliasRule> {
return PATTERN_ALIAS_RULES
}
export type {
ExactAliasRule,
PatternAliasRule,
ModelIDAliasResolution,
} from "@oh-my-opencode/model-core"
export {
resolveModelIDAlias,
getExactModelIDAliasRules,
getPatternModelIDAliasRules,
} from "@oh-my-opencode/model-core"
@@ -1,120 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { ModelCapabilitiesSnapshot } from "./model-capabilities"
import { getBundledModelCapabilitiesSnapshot } from "./model-capabilities"
import {
collectModelCapabilityGuardrailIssues,
getBuiltInRequirementModelIDs,
} from "./model-capability-guardrails"
describe("model-capability-guardrails", () => {
test("keeps the current alias registry and built-in requirements aligned with the bundled snapshot", () => {
const issues = collectModelCapabilityGuardrailIssues()
expect(issues).toEqual([])
})
test("requires built-in requirement models to stay unique and sorted", () => {
const modelIDs = getBuiltInRequirementModelIDs()
expect(modelIDs).toEqual([...modelIDs].sort())
expect(new Set(modelIDs).size).toBe(modelIDs.length)
expect(modelIDs).toContain("claude-opus-4-7")
expect(modelIDs).toContain("gpt-5.5")
expect(modelIDs).toContain("kimi-k2.5")
})
test("flags exact aliases whose canonical target disappears from the snapshot", () => {
const bundledSnapshot = getBundledModelCapabilitiesSnapshot()
const brokenSnapshot: ModelCapabilitiesSnapshot = {
...bundledSnapshot,
models: Object.fromEntries(
Object.entries(bundledSnapshot.models).filter(([modelID]) => modelID !== "gemini-3-pro-preview"),
),
}
const issues = collectModelCapabilityGuardrailIssues({
snapshot: brokenSnapshot,
requirementModelIDs: [],
})
expect(issues).toContainEqual(
expect.objectContaining({
kind: "alias-target-missing-from-snapshot",
aliasModelID: "gemini-3-pro-high",
canonicalModelID: "gemini-3-pro-preview",
}),
)
})
test("flags pattern aliases when models.dev gains a canonical entry for the alias itself", () => {
const bundledSnapshot = getBundledModelCapabilitiesSnapshot()
const aliasCollisionSnapshot: ModelCapabilitiesSnapshot = {
...bundledSnapshot,
models: {
...bundledSnapshot.models,
"gemini-3.1-pro-high": {
id: "gemini-3.1-pro-high",
family: "gemini",
reasoning: true,
},
},
}
const issues = collectModelCapabilityGuardrailIssues({
snapshot: aliasCollisionSnapshot,
requirementModelIDs: [],
})
expect(issues).toContainEqual(
expect.objectContaining({
kind: "pattern-alias-collides-with-snapshot",
modelID: "gemini-3.1-pro-high",
canonicalModelID: "gemini-3.1-pro",
}),
)
})
test("flags exact aliases when models.dev gains a canonical entry for the alias itself", () => {
const bundledSnapshot = getBundledModelCapabilitiesSnapshot()
const aliasCollisionSnapshot: ModelCapabilitiesSnapshot = {
...bundledSnapshot,
models: {
...bundledSnapshot.models,
"gemini-3-pro-high": {
id: "gemini-3-pro-high",
family: "gemini",
reasoning: true,
},
},
}
const issues = collectModelCapabilityGuardrailIssues({
snapshot: aliasCollisionSnapshot,
requirementModelIDs: [],
})
expect(issues).toContainEqual(
expect.objectContaining({
kind: "exact-alias-collides-with-snapshot",
aliasModelID: "gemini-3-pro-high",
canonicalModelID: "gemini-3-pro-preview",
}),
)
})
test("flags built-in requirement models that rely on aliases instead of canonical IDs", () => {
const issues = collectModelCapabilityGuardrailIssues({
requirementModelIDs: ["gemini-3.1-pro-high"],
})
expect(issues).toContainEqual(
expect.objectContaining({
kind: "built-in-model-relies-on-alias",
modelID: "gemini-3.1-pro-high",
canonicalModelID: "gemini-3.1-pro",
ruleID: "gemini-3.1-pro-tier-alias",
}),
)
})
})
+5 -149
View File
@@ -1,149 +1,5 @@
import type { ModelCapabilitiesSnapshot } from "./model-capabilities"
import { getBundledModelCapabilitiesSnapshot } from "./model-capabilities"
import {
getExactModelIDAliasRules,
getPatternModelIDAliasRules,
resolveModelIDAlias,
} from "./model-capability-aliases"
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "./model-requirements"
export type ModelCapabilityGuardrailIssue =
| {
kind: "alias-target-missing-from-snapshot"
ruleID: string
aliasModelID: string
canonicalModelID: string
message: string
}
| {
kind: "exact-alias-collides-with-snapshot"
ruleID: string
aliasModelID: string
canonicalModelID: string
message: string
}
| {
kind: "pattern-alias-collides-with-snapshot"
ruleID: string
modelID: string
canonicalModelID: string
message: string
}
| {
kind: "built-in-model-relies-on-alias"
modelID: string
canonicalModelID: string
ruleID: string
message: string
}
| {
kind: "built-in-model-missing-from-snapshot"
modelID: string
canonicalModelID: string
message: string
}
type CollectModelCapabilityGuardrailIssuesInput = {
snapshot?: ModelCapabilitiesSnapshot
requirementModelIDs?: Iterable<string>
}
function normalizeLookupModelID(modelID: string): string {
return modelID.trim().toLowerCase()
}
export function getBuiltInRequirementModelIDs(): string[] {
const modelIDs = new Set<string>()
for (const requirement of Object.values(AGENT_MODEL_REQUIREMENTS)) {
for (const entry of requirement.fallbackChain) {
modelIDs.add(entry.model)
}
}
for (const requirement of Object.values(CATEGORY_MODEL_REQUIREMENTS)) {
for (const entry of requirement.fallbackChain) {
modelIDs.add(entry.model)
}
}
return [...modelIDs].sort()
}
export function collectModelCapabilityGuardrailIssues(
input: CollectModelCapabilityGuardrailIssuesInput = {},
): ModelCapabilityGuardrailIssue[] {
const snapshot = input.snapshot ?? getBundledModelCapabilitiesSnapshot()
const snapshotModelIDs = new Set(
Object.keys(snapshot.models).map((modelID) => normalizeLookupModelID(modelID)),
)
const requirementModelIDs = input.requirementModelIDs ?? getBuiltInRequirementModelIDs()
const issues: ModelCapabilityGuardrailIssue[] = []
for (const rule of getExactModelIDAliasRules()) {
if (!snapshotModelIDs.has(rule.canonicalModelID)) {
issues.push({
kind: "alias-target-missing-from-snapshot",
ruleID: rule.ruleID,
aliasModelID: rule.aliasModelID,
canonicalModelID: rule.canonicalModelID,
message: `Alias ${rule.aliasModelID} points to missing snapshot model ${rule.canonicalModelID}.`,
})
}
if (snapshotModelIDs.has(rule.aliasModelID)) {
issues.push({
kind: "exact-alias-collides-with-snapshot",
ruleID: rule.ruleID,
aliasModelID: rule.aliasModelID,
canonicalModelID: rule.canonicalModelID,
message: `Alias ${rule.aliasModelID} now exists in models.dev and should be reviewed instead of force-mapping to ${rule.canonicalModelID}.`,
})
}
}
for (const rule of getPatternModelIDAliasRules()) {
for (const modelID of snapshotModelIDs) {
if (!rule.match(modelID)) {
continue
}
const canonicalModelID = rule.canonicalize(modelID)
if (canonicalModelID === modelID) {
continue
}
issues.push({
kind: "pattern-alias-collides-with-snapshot",
ruleID: rule.ruleID,
modelID,
canonicalModelID,
message: `Pattern alias ${rule.ruleID} would rewrite canonical snapshot model ${modelID} to ${canonicalModelID}.`,
})
}
}
for (const modelID of requirementModelIDs) {
const aliasResolution = resolveModelIDAlias(modelID)
if (aliasResolution.source !== "canonical") {
issues.push({
kind: "built-in-model-relies-on-alias",
modelID: aliasResolution.requestedModelID,
canonicalModelID: aliasResolution.canonicalModelID,
ruleID: aliasResolution.ruleID ?? "unknown-alias-rule",
message: `Built-in requirement model ${aliasResolution.requestedModelID} should be canonical and not rely on alias rule ${aliasResolution.ruleID}.`,
})
}
if (!snapshotModelIDs.has(aliasResolution.canonicalModelID)) {
issues.push({
kind: "built-in-model-missing-from-snapshot",
modelID: aliasResolution.requestedModelID,
canonicalModelID: aliasResolution.canonicalModelID,
message: `Built-in requirement model ${aliasResolution.requestedModelID} resolves to ${aliasResolution.canonicalModelID}, which is missing from the bundled snapshot.`,
})
}
}
return issues
}
export type { ModelCapabilityGuardrailIssue } from "@oh-my-opencode/model-core"
export {
getBuiltInRequirementModelIDs,
collectModelCapabilityGuardrailIssues,
} from "@oh-my-opencode/model-core"
+5 -115
View File
@@ -1,115 +1,5 @@
import { normalizeModelID } from "./model-normalization"
export type HeuristicModelFamilyDefinition = {
family: string
includes?: string[]
pattern?: RegExp
variants?: string[]
reasoningEfforts?: string[]
reasoningEffortAliases?: Record<string, string>
supportsThinking?: boolean
}
export const HEURISTIC_MODEL_FAMILY_REGISTRY: ReadonlyArray<HeuristicModelFamilyDefinition> = [
{
family: "claude-opus",
pattern: /claude(?:-\d+(?:-\d+)*)?-opus/,
variants: ["low", "medium", "high", "max"],
supportsThinking: true,
},
{
family: "claude-non-opus",
includes: ["claude"],
variants: ["low", "medium", "high"],
supportsThinking: true,
},
{
family: "openai-reasoning",
pattern: /(?:^|\/)o\d(?:$|-)/,
variants: ["low", "medium", "high"],
reasoningEfforts: ["none", "minimal", "low", "medium", "high"],
},
{
family: "gpt-5",
includes: ["gpt-5"],
variants: ["low", "medium", "high", "xhigh"],
reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
},
{
family: "gpt-legacy",
includes: ["gpt"],
variants: ["low", "medium", "high"],
},
{
family: "gemini",
includes: ["gemini"],
variants: ["low", "medium", "high"],
},
{
family: "grok",
includes: ["grok"],
variants: ["low", "medium", "high"],
reasoningEfforts: ["low", "medium", "high"],
},
{
family: "kimi-thinking",
includes: ["kimi-thinking", "k2-thinking", "k2-think"],
pattern: /(?:kimi|k2).*-(?:thinking|think)/,
variants: ["low", "medium", "high"],
supportsThinking: true,
},
{
family: "kimi",
includes: ["kimi", "k2"],
variants: ["low", "medium", "high"],
supportsThinking: false,
},
{
family: "glm",
includes: ["glm"],
variants: ["low", "medium", "high"],
},
{
family: "minimax",
includes: ["minimax"],
variants: ["low", "medium", "high"],
supportsThinking: false,
},
{
family: "deepseek",
includes: ["deepseek"],
variants: ["low", "medium", "high"],
reasoningEfforts: ["high", "max"],
reasoningEffortAliases: {
low: "high",
medium: "high",
xhigh: "max",
},
},
{
family: "mistral",
includes: ["mistral", "codestral"],
variants: ["low", "medium", "high"],
},
{
family: "llama",
includes: ["llama"],
variants: ["low", "medium", "high"],
},
]
export function detectHeuristicModelFamily(modelID: string): HeuristicModelFamilyDefinition | undefined {
const normalizedModelID = normalizeModelID(modelID).toLowerCase()
for (const definition of HEURISTIC_MODEL_FAMILY_REGISTRY) {
if (definition.pattern?.test(normalizedModelID)) {
return definition
}
if (definition.includes?.some((value) => normalizedModelID.includes(value))) {
return definition
}
}
return undefined
}
export type { HeuristicModelFamilyDefinition } from "@oh-my-opencode/model-core"
export {
HEURISTIC_MODEL_FAMILY_REGISTRY,
detectHeuristicModelFamily,
} from "@oh-my-opencode/model-core"
-467
View File
@@ -1,467 +0,0 @@
declare const require: (name: string) => any
const { describe, expect, test, beforeEach, afterEach, mock, spyOn } = require("bun:test")
import * as connectedProvidersCache from "./connected-providers-cache"
let readConnectedProvidersCacheSpy: ReturnType<typeof spyOn> | undefined
const { shouldRetryError, selectFallbackProvider, isRetryableModelError } = await import("./model-error-classifier")
describe("model-error-classifier", () => {
beforeEach(() => {
readConnectedProvidersCacheSpy?.mockRestore()
readConnectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
})
afterEach(() => {
readConnectedProvidersCacheSpy?.mockRestore()
readConnectedProvidersCacheSpy = undefined
})
test("treats overloaded retry messages as retryable", () => {
//#given
const error = { message: "Provider is overloaded" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("treats cooling-down auto-retry messages as retryable", () => {
//#given
const error = {
message:
"All credentials for model claude-opus-4-7-thinking are cooling down [retrying in ~5 days attempt #1]",
}
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("selectFallbackProvider prefers first connected provider in preference order", () => {
//#given
readConnectedProvidersCacheSpy?.mockReturnValue(["anthropic", "nvidia"])
//#when
const provider = selectFallbackProvider(["anthropic", "nvidia"], "nvidia")
//#then
expect(provider).toBe("anthropic")
})
test("selectFallbackProvider falls back to next connected provider when first is disconnected", () => {
//#given
readConnectedProvidersCacheSpy?.mockReturnValue(["nvidia"])
//#when
const provider = selectFallbackProvider(["anthropic", "nvidia"])
//#then
expect(provider).toBe("nvidia")
})
test("selectFallbackProvider uses provider preference order when cache is missing", () => {
//#given - no cache file
//#when
const provider = selectFallbackProvider(["anthropic", "nvidia"], "nvidia")
//#then
expect(provider).toBe("anthropic")
})
test("selectFallbackProvider uses connected preferred provider when fallback providers are unavailable", () => {
//#given
readConnectedProvidersCacheSpy?.mockReturnValue(["provider-x"])
//#when
const provider = selectFallbackProvider(["provider-y"], "provider-x")
//#then
expect(provider).toBe("provider-x")
})
test("treats QuotaExceededError (PascalCase name) as non-retryable STOP error", () => {
//#given
const error = { name: "QuotaExceededError" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats quotaexceedederror (lowercase name) as non-retryable STOP error", () => {
//#given
const error = { name: "quotaexceedederror" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats InsufficientCreditsError (PascalCase name) as non-retryable STOP error", () => {
//#given
const error = { name: "InsufficientCreditsError" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats insufficientcreditserror (lowercase name) as non-retryable STOP error", () => {
//#given
const error = { name: "insufficientcreditserror" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats FreeUsageLimitError (PascalCase name) as non-retryable STOP error", () => {
//#given
const error = { name: "FreeUsageLimitError" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats freeusagelimiterror (lowercase name) as non-retryable STOP error", () => {
//#given
const error = { name: "freeusagelimiterror" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats quota reset message as non-retryable STOP error (no error name)", () => {
//#given
const error = { message: "quota will reset after 1 hour" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats quota exceeded message as non-retryable STOP error (no error name)", () => {
//#given
const error = { message: "quota exceeded for this billing period" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats usage limit reached message as non-retryable STOP error (no error name)", () => {
//#given
const error = { message: "usage limit has been reached for your account" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats insufficient credits message as non-retryable STOP error (no error name)", () => {
//#given
const error = { message: "insufficient credits to complete this request" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats 'bad request' message as retryable (GitHub Copilot rolling update)", () => {
//#given
const error = { message: "400 Bad Request" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("treats 'bad request' lowercase as retryable", () => {
//#given
const error = { message: "bad request: model temporarily unavailable" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("treats localized transient provider messages as retryable", () => {
//#given
const errors = [
{ message: "请求过于频繁,请稍后重试" },
{ message: "服务暂时不可用" },
{ message: "触发频率限制" },
]
//#when
const results = errors.map((error) => shouldRetryError(error))
//#then
expect(results).toEqual([true, true, true])
})
test("treats subscription quota message as non-retryable", () => {
//#given
const error = { message: "Subscription quota exceeded. You can continue using free models." }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("treats localized quota exhaustion messages as non-retryable stop errors", () => {
//#given
const errors = [
{ message: "已达到 5 小时的使用上限" },
{ message: "额度不足" },
{ message: "账户余额不足" },
{ message: "免费额度已耗尽" },
]
//#when
const results = errors.map((error) => shouldRetryError(error))
//#then
expect(results).toEqual([false, false, false, false])
})
test("treats HTTP 429 rate limit message as retryable", () => {
//#given
const error = { message: "429 Too Many Requests: rate limit reached" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("treats forbidden provider message as retryable", () => {
//#given
const error = { message: "Forbidden: Selected provider is forbidden" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("does not treat unrelated forbidden messages as retryable", () => {
//#given
const error = { message: "EACCES: forbidden write to /etc/hosts" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("does not treat unrelated 403 messages as retryable", () => {
//#given
const error = { message: "Tool returned HTTP 403 for the requested URL" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(false)
})
test("GLM 429 rate limit with statusCode and Chinese message triggers fallback (statusCode check)", () => {
//#given
const error = { statusCode: 429, message: "请求频率过高" }
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(true)
})
test("GLM 429 rate limit with statusCode and no message at all triggers fallback", () => {
//#given
const error = { statusCode: 429 }
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(true)
})
test("GLM 503 service unavailable with statusCode triggers fallback", () => {
//#given
const error = { statusCode: 503, message: "Service Unavailable" }
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(true)
})
test("GLM 529 overloaded with statusCode triggers fallback", () => {
//#given
const error = { statusCode: 529 }
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(true)
})
test("HTTP 400 with statusCode does NOT trigger fallback via statusCode alone (400 excluded)", () => {
//#given — message does NOT match any retryable pattern
const error = { statusCode: 400, message: "Invalid parameter: model_name" }
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(false)
})
test("HTTP 401 with statusCode does NOT trigger fallback (not a rate limit)", () => {
//#given
const error = { statusCode: 401, message: "Unauthorized" }
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(false)
})
test("GLM code 1304 daily quota 429 does NOT trigger fallback (STOP pattern wins)", () => {
//#given
const error = {
statusCode: 429,
message: "Daily call limit for this API key has been reached. Limit will reset at midnight UTC.",
}
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(false)
})
test("GLM account in arrears 429 does NOT trigger fallback (STOP pattern wins)", () => {
//#given
const error = {
statusCode: 429,
message: "Your account is in arrears, please recharge and try again.",
}
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(false)
})
test("GLM fair use policy violation 429 does NOT trigger fallback (STOP pattern wins)", () => {
//#given
const error = {
statusCode: 429,
message: "Request blocked under Fair Use Policy. Your request rate has been restricted.",
}
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(false)
})
test("STOP message pattern takes precedence over 429 statusCode", () => {
//#given
const error = {
statusCode: 429,
message: "quota exceeded for this account, usage limit has been reached",
}
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(false)
})
test("rate limit message without statusCode still works (backward compat)", () => {
//#given
const error = { message: "rate limit reached for requests" }
//#when
const result = isRetryableModelError(error)
//#then
expect(result).toBe(true)
})
test("treats OpenAI streaming server_error envelopes as retryable (issue #3799)", () => {
//#given: OpenAI surfaces its mid-stream error with type 'server_error'
const error = {
name: undefined,
message: "{\"error\":{\"type\":\"server_error\",\"message\":\"server_error\"}}",
}
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("treats the OpenAI prose 'An error occurred while processing' message as retryable (issue #3799)", () => {
//#given: the human-readable prose surfaced when OpenAI's stream fails
const error = {
name: undefined,
message: "An error occurred while processing your request. Please try again later.",
}
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
})
export {}
+21 -242
View File
@@ -1,250 +1,29 @@
import type { FallbackEntry } from "./model-requirements"
import { readConnectedProvidersCache } from "./connected-providers-cache"
import {
getNextFallback,
hasMoreFallbacks,
isRetryableModelError,
selectFallbackProviderWithCache,
shouldRetryError,
} from "@oh-my-opencode/model-core"
import type { ErrorInfo } from "@oh-my-opencode/model-core"
import * as connectedProvidersCache from "./connected-providers-cache"
/**
* Error names that indicate a retryable model error.
* These errors halt execution and should trigger fallback retry.
*/
const RETRYABLE_ERROR_NAMES = new Set([
"providermodelnotfounderror",
"ratelimiterror",
"modelunavailableerror",
"providerconnectionerror",
"authenticationerror",
])
const STOP_ERROR_NAMES = new Set([
"quotaexceedederror",
"insufficientcreditserror",
"freeusagelimiterror",
])
/**
* Error names that should NOT trigger retry.
* These errors are typically user-induced or fixable without switching models.
*/
const NON_RETRYABLE_ERROR_NAMES = new Set([
"messageabortederror",
"permissiondeniederror",
"contextlengtherror",
"timeouterror",
"validationerror",
"syntaxerror",
"usererror",
])
/**
* Message patterns that indicate a retryable error even without a known error name.
*/
const RETRYABLE_MESSAGE_PATTERNS = [
"rate_limit",
"rate limit",
"quota",
"all credentials for model",
"cooling down",
"exhausted your capacity",
"not found",
"unavailable",
"insufficient",
"too many requests",
"over limit",
"overloaded",
"bad gateway",
"bad request",
"unknown provider",
"provider not found",
"model_not_supported",
"model not supported",
"model is not supported",
"connection error",
"network error",
"timeout",
"service unavailable",
"internal_server_error",
"free usage",
"usage exceeded",
"credit",
"balance",
"temporarily unavailable",
"try again",
"请稍后重试",
"503",
"502",
"504",
"429",
"529",
"selected provider is forbidden",
"provider is forbidden",
// Chinese retryable patterns (Zhipu, etc.)
"频率限制", // "rate limit"
"请求过于频繁", // "too many requests"
"暂时不可用", // "temporarily unavailable"
"服务不可用", // "service unavailable"
// OpenAI streaming server_error events surface either as a literal "server_error"
// type or as the prose error sentence below. Without these patterns subagent
// streams stall instead of being retried (issue #3799).
"server_error",
"an error occurred while processing",
]
/**
* Message patterns that indicate a non-retryable STOP error (quota/billing exhaustion).
* These take precedence over RETRYABLE_MESSAGE_PATTERNS.
*/
const STOP_MESSAGE_PATTERNS = [
"quota will reset after",
"quota exceeded",
"usage limit has been reached",
"free usage limit",
"billing limit",
"billing hard limit",
"monthly limit",
"plan limit",
"subscription quota",
"subscription limit",
"payment required",
"out of credits",
"credits exhausted",
"insufficient credits",
"insufficient balance",
"credit balance",
"usage limit for this month",
"exhausted your capacity",
// GLM/Z.ai business error codes that indicate permanent quota/billing exhaustion
"daily call limit",
"daily limit",
"usage limit reached for",
"in arrears",
"fair use policy",
"recharge and try",
"使用上限",
"额度不足",
"余额不足",
"已耗尽",
]
const AUTO_RETRY_GATE_PATTERNS = [
"rate limit",
"cooling down",
"credentials for model",
]
function hasProviderAutoRetrySignal(message: string): boolean {
if (!message.includes("retrying in")) {
return false
}
return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern))
export type { ErrorInfo }
export {
isRetryableModelError,
shouldRetryError,
getNextFallback,
hasMoreFallbacks,
selectFallbackProviderWithCache,
}
export interface ErrorInfo {
name?: string
message?: string
/** HTTP status code from the provider response (e.g., 429 for rate limit) */
statusCode?: number
}
/**
* Determines if an error is a retryable model error.
* Returns true if it's a known retryable type OR matches retryable message patterns.
*/
export function isRetryableModelError(error: ErrorInfo): boolean {
// If we have an error name, check against known lists
if (error.name) {
const errorNameLower = error.name.toLowerCase()
// Explicit non-retryable takes precedence
if (NON_RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
return false
}
if (STOP_ERROR_NAMES.has(errorNameLower)) {
return false
}
// Check if it's a known retryable error
if (RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
return true
}
}
// Check message patterns for unknown errors
const msg = error.message?.toLowerCase() ?? ""
// STOP patterns take precedence over retryable patterns
if (STOP_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))) {
return false
}
if (hasProviderAutoRetrySignal(msg)) {
return true
}
// HTTP status code check: catches rate-limit errors regardless of message format/language.
// Uses the same codes as runtime-fallback config (400 excluded as it is a permanent client error).
if (
error.statusCode != null &&
(error.statusCode === 429 || error.statusCode === 503 || error.statusCode === 529)
) {
return true
}
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))
}
/**
* Determines if an error should trigger a fallback retry.
* Returns true for errors that halt execution.
*/
export function shouldRetryError(error: ErrorInfo): boolean {
return isRetryableModelError(error)
}
/**
* Gets the next fallback model from the chain based on attempt count.
* Returns undefined if all fallbacks have been exhausted.
*/
export function getNextFallback(
fallbackChain: FallbackEntry[],
attemptCount: number,
): FallbackEntry | undefined {
return fallbackChain[attemptCount]
}
/**
* Checks if there are more fallbacks available after the current attempt.
*/
export function hasMoreFallbacks(
fallbackChain: FallbackEntry[],
attemptCount: number,
): boolean {
return attemptCount < fallbackChain.length
}
/**
* Selects the best provider for a fallback entry.
* Priority:
* 1) First connected provider in the entry's provider preference order
* 2) Preferred provider when connected (and entry providers are unavailable)
* 3) First provider listed in the fallback entry
*/
export function selectFallbackProvider(
providers: string[],
preferredProviderID?: string,
): string {
const connectedProviders = readConnectedProvidersCache()
if (connectedProviders) {
const connectedSet = new Set(connectedProviders.map(p => p.toLowerCase()))
for (const provider of providers) {
if (connectedSet.has(provider.toLowerCase())) {
return provider
}
}
if (
preferredProviderID &&
connectedSet.has(preferredProviderID.toLowerCase())
) {
return preferredProviderID
}
}
return providers[0] || preferredProviderID || "opencode"
return selectFallbackProviderWithCache(
providers,
connectedProvidersCache,
preferredProviderID,
)
}
@@ -1,46 +0,0 @@
import { describe, it, expect } from "bun:test"
import { normalizeModelFormat } from "./model-format-normalizer"
describe("normalizeModelFormat", () => {
describe("string format input", () => {
it("splits provider/model format correctly", () => {
const result = normalizeModelFormat("opencode/glm-5-free")
expect(result).toEqual({ providerID: "opencode", modelID: "glm-5-free" })
})
it("handles provider with multiple slashes", () => {
const result = normalizeModelFormat("anthropic/claude-opus-4-7/max")
expect(result).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7/max" })
})
it("returns undefined for malformed string without separator", () => {
const result = normalizeModelFormat("invalid")
expect(result).toBeUndefined()
})
it("returns undefined for empty string", () => {
const result = normalizeModelFormat("")
expect(result).toBeUndefined()
})
})
describe("object format input", () => {
it("passthroughs object format unchanged", () => {
const input = { providerID: "opencode", modelID: "glm-5-free" }
const result = normalizeModelFormat(input)
expect(result).toEqual(input)
})
})
describe("edge cases", () => {
it("returns undefined for null", () => {
const result = normalizeModelFormat(null)
expect(result).toBeUndefined()
})
it("returns undefined for undefined", () => {
const result = normalizeModelFormat(undefined)
expect(result).toBeUndefined()
})
})
})
+1 -20
View File
@@ -1,20 +1 @@
export function normalizeModelFormat(
model: string | { providerID: string; modelID: string }
): { providerID: string; modelID: string } | undefined {
if (!model) {
return undefined
}
if (typeof model === "object" && "providerID" in model && "modelID" in model) {
return { providerID: model.providerID, modelID: model.modelID }
}
if (typeof model === "string") {
const parts = model.split("/")
if (parts.length >= 2) {
return { providerID: parts[0], modelID: parts.slice(1).join("/") }
}
}
return undefined
}
export { normalizeModelFormat } from "@oh-my-opencode/model-core"
-123
View File
@@ -1,123 +0,0 @@
import { describe, expect, test } from "bun:test"
import { normalizeModel, normalizeModelID } from "./model-normalization"
describe("normalizeModel", () => {
describe("#given undefined input", () => {
test("#when normalizeModel is called with undefined #then returns undefined", () => {
// given
const input = undefined
// when
const result = normalizeModel(input)
// then
expect(result).toBeUndefined()
})
})
describe("#given empty string", () => {
test("#when normalizeModel is called with empty string #then returns undefined", () => {
// given
const input = ""
// when
const result = normalizeModel(input)
// then
expect(result).toBeUndefined()
})
})
describe("#given whitespace-only string", () => {
test("#when normalizeModel is called with whitespace-only string #then returns undefined", () => {
// given
const input = " "
// when
const result = normalizeModel(input)
// then
expect(result).toBeUndefined()
})
})
describe("#given valid model string", () => {
test("#when normalizeModel is called with valid model string #then returns same string", () => {
// given
const input = "claude-3-opus"
// when
const result = normalizeModel(input)
// then
expect(result).toBe("claude-3-opus")
})
})
describe("#given string with leading and trailing spaces", () => {
test("#when normalizeModel is called with spaces #then returns trimmed string", () => {
// given
const input = " claude-3-opus "
// when
const result = normalizeModel(input)
// then
expect(result).toBe("claude-3-opus")
})
})
describe("#given string with only spaces", () => {
test("#when normalizeModel is called with only spaces #then returns undefined", () => {
// given
const input = " "
// when
const result = normalizeModel(input)
// then
expect(result).toBeUndefined()
})
})
})
describe("normalizeModelID", () => {
describe("#given model with dots in version numbers", () => {
test("#when normalizeModelID is called with claude-3.5-sonnet #then returns claude-3-5-sonnet", () => {
// given
const input = "claude-3.5-sonnet"
// when
const result = normalizeModelID(input)
// then
expect(result).toBe("claude-3-5-sonnet")
})
})
describe("#given model without dots", () => {
test("#when normalizeModelID is called with claude-opus #then returns unchanged", () => {
// given
const input = "claude-opus"
// when
const result = normalizeModelID(input)
// then
expect(result).toBe("claude-opus")
})
})
describe("#given model with multiple dot-numbers", () => {
test("#when normalizeModelID is called with model.1.2 #then returns model-1-2", () => {
// given
const input = "model.1.2"
// when
const result = normalizeModelID(input)
// then
expect(result).toBe("model-1-2")
})
})
})
+1 -8
View File
@@ -1,8 +1 @@
export function normalizeModel(model?: string): string | undefined {
const trimmed = model?.trim()
return trimmed || undefined
}
export function normalizeModelID(modelID: string): string {
return modelID.replace(/\.(\d+)/g, "-$1")
}
export { normalizeModel, normalizeModelID } from "@oh-my-opencode/model-core"
-659
View File
@@ -1,659 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
type FallbackEntry,
type ModelRequirement,
} from "./model-requirements"
describe("AGENT_MODEL_REQUIREMENTS", () => {
test("oracle has valid fallbackChain with gpt-5.5 as primary", () => {
// given - oracle agent requirement
const oracle = AGENT_MODEL_REQUIREMENTS["oracle"]
// when - accessing oracle requirement
// then - fallbackChain exists with gpt-5.5 as first entry
expect(oracle).toBeDefined()
expect(oracle.fallbackChain).toBeArray()
expect(oracle.fallbackChain.length).toBeGreaterThan(0)
const primary = oracle.fallbackChain[0]
expect(primary.providers).toContain("openai")
expect(primary.model).toBe("gpt-5.5")
expect(primary.variant).toBe("high")
})
test("sisyphus has claude-opus-4-7 as primary with k2p5, kimi-k2.5, gpt-5.5 medium fallbacks", () => {
// #given - sisyphus agent requirement
const sisyphus = AGENT_MODEL_REQUIREMENTS["sisyphus"]
// #when - accessing Sisyphus requirement
// #then - fallbackChain has 7 entries with correct ordering
expect(sisyphus).toBeDefined()
expect(sisyphus.fallbackChain).toBeArray()
expect(sisyphus.fallbackChain).toHaveLength(7)
expect(sisyphus.requiresAnyModel).toBe(true)
const primary = sisyphus.fallbackChain[0]
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
expect(primary.model).toBe("claude-opus-4-7")
expect(primary.variant).toBe("max")
const second = sisyphus.fallbackChain[1]
expect(second.providers).toEqual(["opencode-go", "vercel"])
expect(second.model).toBe("kimi-k2.6")
const third = sisyphus.fallbackChain[2]
expect(third.providers).toEqual(["kimi-for-coding"])
expect(third.model).toBe("k2p5")
const fourth = sisyphus.fallbackChain[3]
expect(fourth.model).toBe("kimi-k2.5")
const fifth = sisyphus.fallbackChain[4]
expect(fifth.providers).toContain("openai")
expect(fifth.model).toBe("gpt-5.5")
expect(fifth.variant).toBe("medium")
const sixth = sisyphus.fallbackChain[5]
expect(sixth.providers[0]).toBe("zai-coding-plan")
expect(sixth.model).toBe("glm-5")
const last = sisyphus.fallbackChain[6]
expect(last.providers[0]).toBe("opencode")
expect(last.model).toBe("big-pickle")
})
test("librarian has valid fallbackChain with openai/gpt-5.4-mini-fast as primary", () => {
// given - librarian agent requirement
const librarian = AGENT_MODEL_REQUIREMENTS["librarian"]
// when - accessing librarian requirement
// then - fallbackChain exists with openai/gpt-5.4-mini-fast as first entry
expect(librarian).toBeDefined()
expect(librarian.fallbackChain).toBeArray()
expect(librarian.fallbackChain).toHaveLength(6)
const primary = librarian.fallbackChain[0]
expect(primary.providers).toEqual(["openai"])
expect(primary.model).toBe("gpt-5.4-mini-fast")
const second = librarian.fallbackChain[1]
expect(second.providers).toContain("opencode-go")
expect(second.model).toBe("qwen3.5-plus")
const third = librarian.fallbackChain[2]
expect(third.providers).toEqual(["vercel"])
expect(third.model).toBe("minimax-m2.7-highspeed")
const quaternary = librarian.fallbackChain[3]
expect(quaternary.providers).toContain("opencode-go")
expect(quaternary.model).toBe("minimax-m2.7")
const quinary = librarian.fallbackChain[4]
expect(quinary.providers).toContain("anthropic")
expect(quinary.model).toBe("claude-haiku-4-5")
const sixth = librarian.fallbackChain[5]
expect(sixth.providers).toContain("openai")
expect(sixth.model).toBe("gpt-5.4-nano")
})
test("explore has valid fallbackChain with openai/gpt-5.4-mini-fast as primary", () => {
// given - explore agent requirement
const explore = AGENT_MODEL_REQUIREMENTS["explore"]
// when - accessing explore requirement
expect(explore).toBeDefined()
expect(explore.fallbackChain).toBeArray()
expect(explore.fallbackChain).toHaveLength(6)
const primary = explore.fallbackChain[0]
expect(primary.providers).toEqual(["openai"])
expect(primary.model).toBe("gpt-5.4-mini-fast")
const secondary = explore.fallbackChain[1]
expect(secondary.providers).toContain("opencode-go")
expect(secondary.model).toBe("qwen3.5-plus")
const third = explore.fallbackChain[2]
expect(third.providers).toEqual(["vercel"])
expect(third.model).toBe("minimax-m2.7-highspeed")
const quaternary = explore.fallbackChain[3]
expect(quaternary.providers).toContain("opencode-go")
expect(quaternary.model).toBe("minimax-m2.7")
const quinary = explore.fallbackChain[4]
expect(quinary.providers).toContain("anthropic")
expect(quinary.model).toBe("claude-haiku-4-5")
const sixth = explore.fallbackChain[5]
expect(sixth.providers).toContain("openai")
expect(sixth.model).toBe("gpt-5.4-nano")
})
test("multimodal-looker has valid fallbackChain with gpt-5.5 as primary", () => {
// given - multimodal-looker agent requirement
const multimodalLooker = AGENT_MODEL_REQUIREMENTS["multimodal-looker"]
// when - accessing multimodal-looker requirement
// then - fallbackChain: gpt-5.5 -> opencode-go/kimi-k2.6 -> glm-4.6v -> gpt-5-nano
expect(multimodalLooker).toBeDefined()
expect(multimodalLooker.fallbackChain).toBeArray()
expect(multimodalLooker.fallbackChain).toHaveLength(4)
const primary = multimodalLooker.fallbackChain[0]
expect(primary.providers).toEqual(["openai", "opencode", "vercel"])
expect(primary.model).toBe("gpt-5.5")
expect(primary.variant).toBe("medium")
const secondary = multimodalLooker.fallbackChain[1]
expect(secondary.providers).toEqual(["opencode-go", "vercel"])
expect(secondary.model).toBe("kimi-k2.6")
const tertiary = multimodalLooker.fallbackChain[2]
expect(tertiary.model).toBe("glm-4.6v")
const last = multimodalLooker.fallbackChain[3]
expect(last.providers).toEqual(["openai", "github-copilot", "opencode", "vercel"])
expect(last.model).toBe("gpt-5-nano")
})
test("prometheus has claude-opus-4-7 as primary", () => {
// #given - prometheus agent requirement
const prometheus = AGENT_MODEL_REQUIREMENTS["prometheus"]
// #when - accessing Prometheus requirement
// #then - claude-opus-4-7 is first
expect(prometheus).toBeDefined()
expect(prometheus.fallbackChain).toBeArray()
expect(prometheus.fallbackChain.length).toBeGreaterThan(1)
const primary = prometheus.fallbackChain[0]
expect(primary.model).toBe("claude-opus-4-7")
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
expect(primary.variant).toBe("max")
})
test("metis has claude-sonnet-4-6 as primary", () => {
// #given - metis agent requirement
const metis = AGENT_MODEL_REQUIREMENTS["metis"]
// #when - accessing Metis requirement
// #then - claude-sonnet-4-6 is first, claude-opus-4-7 max is the immediate fallback
expect(metis).toBeDefined()
expect(metis.fallbackChain).toBeArray()
expect(metis.fallbackChain.length).toBeGreaterThan(1)
const primary = metis.fallbackChain[0]
expect(primary.model).toBe("claude-sonnet-4-6")
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
expect(primary.variant).toBeUndefined()
const opusFallback = metis.fallbackChain[1]
expect(opusFallback.model).toBe("claude-opus-4-7")
expect(opusFallback.variant).toBe("max")
const openAiFallback = metis.fallbackChain.find((entry) => entry.providers.includes("openai"))
expect(openAiFallback).toEqual({
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
variant: "high",
})
})
test("momus has valid fallbackChain with gpt-5.5 as primary", () => {
// given - momus agent requirement
const momus = AGENT_MODEL_REQUIREMENTS["momus"]
// when - accessing Momus requirement
// then - fallbackChain exists with gpt-5.5 as first entry, variant xhigh
expect(momus).toBeDefined()
expect(momus.fallbackChain).toBeArray()
expect(momus.fallbackChain.length).toBeGreaterThan(0)
const primary = momus.fallbackChain[0]
expect(primary.model).toBe("gpt-5.5")
expect(primary.variant).toBe("xhigh")
expect(primary.providers[0]).toBe("openai")
})
test("atlas has valid fallbackChain with claude-sonnet-4-6 as primary", () => {
// given - atlas agent requirement
const atlas = AGENT_MODEL_REQUIREMENTS["atlas"]
// when - accessing Atlas requirement
// then - fallbackChain exists with claude-sonnet-4-6 as first entry
expect(atlas).toBeDefined()
expect(atlas.fallbackChain).toBeArray()
expect(atlas.fallbackChain).toHaveLength(4)
const primary = atlas.fallbackChain[0]
expect(primary.model).toBe("claude-sonnet-4-6")
expect(primary.providers[0]).toBe("anthropic")
const secondary = atlas.fallbackChain[1]
expect(secondary.model).toBe("kimi-k2.6")
expect(secondary.providers[0]).toBe("opencode-go")
const tertiary = atlas.fallbackChain[2]
expect(tertiary).toEqual({
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
variant: "medium",
})
const quaternary = atlas.fallbackChain[3]
expect(quaternary.model).toBe("minimax-m2.7")
expect(quaternary.providers[0]).toBe("opencode-go")
})
test("sisyphus-junior has an OpenAI fallback and minimax before big-pickle", () => {
// given - sisyphus-junior agent requirement
const sisyphusJunior = AGENT_MODEL_REQUIREMENTS["sisyphus-junior"]
// when - locating the OpenAI fallback entry
const openAiFallback = sisyphusJunior.fallbackChain.find((entry) => entry.providers.includes("openai"))
const openAiFallbackIndex = sisyphusJunior.fallbackChain.findIndex((entry) => entry.providers.includes("openai"))
const minimaxIndex = sisyphusJunior.fallbackChain.findIndex((entry) => entry.model === "minimax-m2.7")
const bigPickleIndex = sisyphusJunior.fallbackChain.findIndex((entry) => entry.model === "big-pickle")
// then
expect(openAiFallback).toEqual({
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
variant: "medium",
})
expect(openAiFallbackIndex).toBeGreaterThan(-1)
expect(minimaxIndex).toBeGreaterThan(openAiFallbackIndex)
expect(bigPickleIndex).toBeGreaterThan(minimaxIndex)
})
test("hephaestus supports openai, github-copilot, venice, and opencode providers", () => {
// #given - hephaestus agent requirement
const hephaestus = AGENT_MODEL_REQUIREMENTS["hephaestus"]
// #when - accessing hephaestus requirement
// #then - requiresProvider includes openai, github-copilot, venice, and opencode
expect(hephaestus).toBeDefined()
expect(hephaestus.requiresProvider).toEqual(["openai", "github-copilot", "venice", "opencode", "vercel"])
expect(hephaestus.requiresModel).toBeUndefined()
})
test("all 11 builtin agents have valid fallbackChain arrays", () => {
// #given - list of 11 agent names
const expectedAgents = [
"sisyphus",
"hephaestus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"prometheus",
"metis",
"momus",
"atlas",
"sisyphus-junior",
]
// when - checking AGENT_MODEL_REQUIREMENTS
const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS)
// #then - all agents present with valid fallbackChain
expect(definedAgents).toHaveLength(11)
for (const agent of expectedAgents) {
const requirement = AGENT_MODEL_REQUIREMENTS[agent]
expect(requirement).toBeDefined()
expect(requirement.fallbackChain).toBeArray()
expect(requirement.fallbackChain.length).toBeGreaterThan(0)
for (const entry of requirement.fallbackChain) {
expect(entry.providers).toBeArray()
expect(entry.providers.length).toBeGreaterThan(0)
expect(typeof entry.model).toBe("string")
expect(entry.model.length).toBeGreaterThan(0)
}
}
})
})
describe("CATEGORY_MODEL_REQUIREMENTS", () => {
test("ultrabrain has valid fallbackChain with gpt-5.5 as primary", () => {
// given - ultrabrain category requirement
const ultrabrain = CATEGORY_MODEL_REQUIREMENTS["ultrabrain"]
// when - accessing ultrabrain requirement
// then - fallbackChain exists with gpt-5.5 as first entry
expect(ultrabrain).toBeDefined()
expect(ultrabrain.fallbackChain).toBeArray()
expect(ultrabrain.fallbackChain.length).toBeGreaterThan(0)
const primary = ultrabrain.fallbackChain[0]
expect(primary.variant).toBe("xhigh")
expect(primary.model).toBe("gpt-5.5")
expect(primary.providers[0]).toBe("openai")
})
test("deep has valid fallbackChain with gpt-5.5 as primary", () => {
// given - deep category requirement
const deep = CATEGORY_MODEL_REQUIREMENTS["deep"]
// when - accessing deep requirement
// then - fallbackChain exists with gpt-5.5 as first entry, medium variant
expect(deep).toBeDefined()
expect(deep.fallbackChain).toBeArray()
expect(deep.fallbackChain.length).toBeGreaterThan(0)
const primary = deep.fallbackChain[0]
expect(primary.variant).toBe("medium")
expect(primary.model).toBe("gpt-5.5")
expect(primary.providers).toContain("openai")
expect(primary.providers).toContain("github-copilot")
})
test("visual-engineering has valid fallbackChain with gemini-3.1-pro high as primary", () => {
// given - visual-engineering category requirement
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
// when - accessing visual-engineering requirement
// then - fallbackChain: gemini-3.1-pro(high) → glm-5 → opus-4-6(max) → opencode-go/glm-5.1 → k2p5
expect(visualEngineering).toBeDefined()
expect(visualEngineering.fallbackChain).toBeArray()
expect(visualEngineering.fallbackChain).toHaveLength(5)
const primary = visualEngineering.fallbackChain[0]
expect(primary.providers[0]).toBe("google")
expect(primary.model).toBe("gemini-3.1-pro")
expect(primary.variant).toBe("high")
const second = visualEngineering.fallbackChain[1]
expect(second.providers[0]).toBe("zai-coding-plan")
expect(second.model).toBe("glm-5")
const third = visualEngineering.fallbackChain[2]
expect(third.model).toBe("claude-opus-4-7")
expect(third.variant).toBe("max")
const fourth = visualEngineering.fallbackChain[3]
expect(fourth.providers[0]).toBe("opencode-go")
expect(fourth.model).toBe("glm-5.1")
const fifth = visualEngineering.fallbackChain[4]
expect(fifth.providers[0]).toBe("kimi-for-coding")
expect(fifth.model).toBe("k2p5")
})
test("quick has valid fallbackChain with gpt-5.4-mini as primary and claude-haiku-4-5 as secondary", () => {
// given - quick category requirement
const quick = CATEGORY_MODEL_REQUIREMENTS["quick"]
// when - accessing quick requirement
// then - fallbackChain exists with gpt-5.4-mini as first entry, haiku as second
expect(quick).toBeDefined()
expect(quick.fallbackChain).toBeArray()
expect(quick.fallbackChain.length).toBeGreaterThan(1)
const primary = quick.fallbackChain[0]
expect(primary.model).toBe("gpt-5.4-mini")
expect(primary.providers).toContain("openai")
const secondary = quick.fallbackChain[1]
expect(secondary.model).toBe("claude-haiku-4-5")
expect(secondary.providers).toContain("anthropic")
})
test("unspecified-low has valid fallbackChain with claude-sonnet-4-6 as primary", () => {
// given - unspecified-low category requirement
const unspecifiedLow = CATEGORY_MODEL_REQUIREMENTS["unspecified-low"]
// when - accessing unspecified-low requirement
// then - fallbackChain exists with claude-sonnet-4-6 as first entry
expect(unspecifiedLow).toBeDefined()
expect(unspecifiedLow.fallbackChain).toBeArray()
expect(unspecifiedLow.fallbackChain.length).toBeGreaterThan(0)
const primary = unspecifiedLow.fallbackChain[0]
expect(primary.model).toBe("claude-sonnet-4-6")
expect(primary.providers[0]).toBe("anthropic")
})
test("unspecified-high has claude-opus-4-7 as primary and gpt-5.5 as secondary", () => {
// #given - unspecified-high category requirement
const unspecifiedHigh = CATEGORY_MODEL_REQUIREMENTS["unspecified-high"]
// #when - accessing unspecified-high requirement
// #then - claude-opus-4-7 is first and gpt-5.5 is second
expect(unspecifiedHigh).toBeDefined()
expect(unspecifiedHigh.fallbackChain).toBeArray()
expect(unspecifiedHigh.fallbackChain.length).toBeGreaterThan(1)
const primary = unspecifiedHigh.fallbackChain[0]
expect(primary.model).toBe("claude-opus-4-7")
expect(primary.variant).toBe("max")
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
const secondary = unspecifiedHigh.fallbackChain[1]
expect(secondary.model).toBe("gpt-5.5")
expect(secondary.variant).toBe("high")
expect(secondary.providers).toEqual(["openai", "github-copilot", "opencode", "vercel"])
})
test("artistry has valid fallbackChain with gemini-3.1-pro as primary", () => {
// given - artistry category requirement
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
// when - accessing artistry requirement
// then - fallbackChain exists with gemini-3.1-pro as first entry
expect(artistry).toBeDefined()
expect(artistry.fallbackChain).toBeArray()
expect(artistry.fallbackChain.length).toBeGreaterThan(0)
const primary = artistry.fallbackChain[0]
expect(primary.model).toBe("gemini-3.1-pro")
expect(primary.variant).toBe("high")
expect(primary.providers[0]).toBe("google")
})
test("writing has valid fallbackChain with gemini-3-flash as primary", () => {
// given - writing category requirement
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
// when - accessing writing requirement
// then - fallbackChain: gemini-3-flash -> kimi-k2.5 -> claude-sonnet-4-6 -> minimax-m2.7
expect(writing).toBeDefined()
expect(writing.fallbackChain).toBeArray()
expect(writing.fallbackChain).toHaveLength(4)
const primary = writing.fallbackChain[0]
expect(primary.model).toBe("gemini-3-flash")
expect(primary.providers[0]).toBe("google")
const second = writing.fallbackChain[1]
expect(second.model).toBe("kimi-k2.6")
expect(second.providers[0]).toBe("opencode-go")
const third = writing.fallbackChain[2]
expect(third.model).toBe("claude-sonnet-4-6")
expect(third.providers[0]).toBe("anthropic")
const fourth = writing.fallbackChain[3]
expect(fourth.model).toBe("minimax-m2.7")
expect(fourth.providers[0]).toBe("opencode-go")
})
test("all 8 categories have valid fallbackChain arrays", () => {
// given - list of 8 category names
const expectedCategories = [
"visual-engineering",
"ultrabrain",
"deep",
"artistry",
"quick",
"unspecified-low",
"unspecified-high",
"writing",
]
// when - checking CATEGORY_MODEL_REQUIREMENTS
const definedCategories = Object.keys(CATEGORY_MODEL_REQUIREMENTS)
// then - all categories present with valid fallbackChain
expect(definedCategories).toHaveLength(8)
for (const category of expectedCategories) {
const requirement = CATEGORY_MODEL_REQUIREMENTS[category]
expect(requirement).toBeDefined()
expect(requirement.fallbackChain).toBeArray()
expect(requirement.fallbackChain.length).toBeGreaterThan(0)
for (const entry of requirement.fallbackChain) {
expect(entry.providers).toBeArray()
expect(entry.providers.length).toBeGreaterThan(0)
expect(typeof entry.model).toBe("string")
expect(entry.model.length).toBeGreaterThan(0)
}
}
})
})
describe("FallbackEntry type", () => {
test("FallbackEntry structure is correct", () => {
// given - a valid FallbackEntry object
const entry: FallbackEntry = {
providers: ["anthropic", "github-copilot", "opencode"],
model: "claude-opus-4-7",
variant: "high",
}
// when - accessing properties
// then - all properties are accessible
expect(entry.providers).toEqual(["anthropic", "github-copilot", "opencode"])
expect(entry.model).toBe("claude-opus-4-7")
expect(entry.variant).toBe("high")
})
test("FallbackEntry variant is optional", () => {
// given - a FallbackEntry without variant
const entry: FallbackEntry = {
providers: ["opencode", "anthropic"],
model: "big-pickle",
}
// when - accessing variant
// then - variant is undefined
expect(entry.variant).toBeUndefined()
})
})
describe("ModelRequirement type", () => {
test("ModelRequirement structure with fallbackChain is correct", () => {
// given - a valid ModelRequirement object
const requirement: ModelRequirement = {
fallbackChain: [
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["openai", "github-copilot"], model: "gpt-5.5", variant: "high" },
],
}
// when - accessing properties
// then - fallbackChain is accessible with correct structure
expect(requirement.fallbackChain).toBeArray()
expect(requirement.fallbackChain).toHaveLength(2)
expect(requirement.fallbackChain[0].model).toBe("claude-opus-4-7")
expect(requirement.fallbackChain[1].model).toBe("gpt-5.5")
})
test("ModelRequirement variant is optional", () => {
// given - a ModelRequirement without top-level variant
const requirement: ModelRequirement = {
fallbackChain: [{ providers: ["opencode"], model: "big-pickle" }],
}
// when - accessing variant
// then - variant is undefined
expect(requirement.variant).toBeUndefined()
})
test("no model in fallbackChain has provider prefix", () => {
// given - all agent and category requirements
const allRequirements = [
...Object.values(AGENT_MODEL_REQUIREMENTS),
...Object.values(CATEGORY_MODEL_REQUIREMENTS),
]
// when - checking each model in fallbackChain
// then - none contain "/" (provider prefix)
for (const req of allRequirements) {
for (const entry of req.fallbackChain) {
expect(entry.model).not.toContain("/")
}
}
})
test("all fallbackChain entries have non-empty providers array", () => {
// given - all agent and category requirements
const allRequirements = [
...Object.values(AGENT_MODEL_REQUIREMENTS),
...Object.values(CATEGORY_MODEL_REQUIREMENTS),
]
// when - checking each entry in fallbackChain
// then - all have non-empty providers array
for (const req of allRequirements) {
for (const entry of req.fallbackChain) {
expect(entry.providers).toBeArray()
expect(entry.providers.length).toBeGreaterThan(0)
}
}
})
})
describe("requiresModel field in categories", () => {
test("deep category no longer has requiresModel (gpt-5.5 is widely available)", () => {
// given
const deep = CATEGORY_MODEL_REQUIREMENTS["deep"]
// when / #then
expect(deep.requiresModel).toBeUndefined()
})
test("artistry category no longer hard-requires gemini-3.1-pro", () => {
// given
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
// when / #then
expect(artistry.requiresModel).toBeUndefined()
})
})
describe("gpt-5.3-codex provider restrictions", () => {
test("no gpt-5.3-codex entry in AGENT_MODEL_REQUIREMENTS includes github-copilot as provider", () => {
// given - all agent requirements
const allAgentEntries = Object.values(AGENT_MODEL_REQUIREMENTS).flatMap(
(req) => req.fallbackChain
)
// when - filtering entries with gpt-5.3-codex model
const codexEntries = allAgentEntries.filter((entry) => entry.model === "gpt-5.3-codex")
// then - none of them include github-copilot as a provider
for (const entry of codexEntries) {
expect(entry.providers).not.toContain("github-copilot")
}
})
test("no gpt-5.3-codex entry in CATEGORY_MODEL_REQUIREMENTS includes github-copilot as provider", () => {
// given - all category requirements
const allCategoryEntries = Object.values(CATEGORY_MODEL_REQUIREMENTS).flatMap(
(req) => req.fallbackChain
)
// when - filtering entries with gpt-5.3-codex model
const codexEntries = allCategoryEntries.filter((entry) => entry.model === "gpt-5.3-codex")
// then - none of them include github-copilot as a provider
for (const entry of codexEntries) {
expect(entry.providers).not.toContain("github-copilot")
}
})
})
+5 -349
View File
@@ -1,349 +1,5 @@
export type FallbackEntry = {
providers: string[];
model: string;
variant?: string; // Entry-specific variant (e.g., GPT→high, Opus→max)
reasoningEffort?: string;
temperature?: number;
top_p?: number;
maxTokens?: number;
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number };
};
export type ModelRequirement = {
fallbackChain: FallbackEntry[];
variant?: string; // Default variant (used when entry doesn't specify one)
requiresModel?: string; // If set, only activates when this model is available (fuzzy match)
requiresAnyModel?: boolean; // If true, requires at least ONE model in fallbackChain to be available (or empty availability treated as unavailable)
requiresProvider?: string[]; // If set, only activates when any of these providers is connected
};
export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
sisyphus: {
fallbackChain: [
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{
providers: [
"opencode",
"moonshotai",
"moonshotai-cn",
"firmware",
"ollama-cloud",
"aihubmix",
"vercel",
],
model: "kimi-k2.5",
},
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.5", variant: "medium" },
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
{ providers: ["opencode"], model: "big-pickle" },
],
requiresAnyModel: true,
},
hephaestus: {
fallbackChain: [
{
providers: ["openai", "github-copilot", "venice", "opencode", "vercel"],
model: "gpt-5.5",
variant: "medium",
},
],
requiresProvider: ["openai", "github-copilot", "venice", "opencode", "vercel"],
},
oracle: {
fallbackChain: [
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
variant: "high",
},
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3.1-pro",
variant: "high",
},
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
librarian: {
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4-mini-fast" },
{ providers: ["opencode-go"], model: "qwen3.5-plus" },
{ providers: ["vercel"], model: "minimax-m2.7-highspeed" },
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4-nano" },
],
},
explore: {
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4-mini-fast" },
{ providers: ["opencode-go"], model: "qwen3.5-plus" },
{ providers: ["vercel"], model: "minimax-m2.7-highspeed" },
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4-nano" },
],
},
"multimodal-looker": {
fallbackChain: [
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.5", variant: "medium" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{ providers: ["zai-coding-plan", "vercel"], model: "glm-4.6v" },
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5-nano" },
],
},
prometheus: {
fallbackChain: [
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
variant: "max",
},
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3.1-pro",
},
],
},
metis: {
fallbackChain: [
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-sonnet-4-6",
},
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
variant: "max",
},
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
],
},
momus: {
fallbackChain: [
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
variant: "xhigh",
},
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
variant: "max",
},
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3.1-pro",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
atlas: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
variant: "medium",
},
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
],
},
"sisyphus-junior": {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
variant: "medium",
},
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
{ providers: ["opencode"], model: "big-pickle" },
],
},
};
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"visual-engineering": {
fallbackChain: [
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3.1-pro",
variant: "high",
},
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
],
},
ultrabrain: {
fallbackChain: [
{
providers: ["openai", "opencode", "vercel"],
model: "gpt-5.5",
variant: "xhigh",
},
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3.1-pro",
variant: "high",
},
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
deep: {
fallbackChain: [
{
providers: ["openai", "github-copilot", "venice", "opencode", "vercel"],
model: "gpt-5.5",
variant: "medium",
},
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
variant: "max",
},
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3.1-pro",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
artistry: {
fallbackChain: [
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3.1-pro",
variant: "high",
},
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
quick: {
fallbackChain: [
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.4-mini",
},
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-haiku-4-5",
},
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3-flash",
},
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
{ providers: ["opencode", "vercel"], model: "gpt-5-nano" },
],
},
"unspecified-low": {
fallbackChain: [
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-sonnet-4-6",
},
{
providers: ["openai", "opencode", "vercel"],
model: "gpt-5.3-codex",
variant: "medium",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3-flash",
},
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
],
},
"unspecified-high": {
fallbackChain: [
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
variant: "max",
},
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
variant: "high",
},
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{ providers: ["opencode", "vercel"], model: "kimi-k2.5" },
{
providers: [
"opencode",
"moonshotai",
"moonshotai-cn",
"firmware",
"ollama-cloud",
"aihubmix",
"vercel",
],
model: "kimi-k2.5",
},
],
},
writing: {
fallbackChain: [
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3-flash",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-sonnet-4-6",
},
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
],
},
};
export type { FallbackEntry, ModelRequirement } from "@oh-my-opencode/model-core"
export {
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
} from "@oh-my-opencode/model-core"
@@ -1,25 +0,0 @@
import { describe, expect, test } from "bun:test"
import { resolveModelPipeline } from "./model-resolution-pipeline"
describe("resolveModelPipeline", () => {
test("does not return unused explicit user config metadata in override result", () => {
// given
const result = resolveModelPipeline({
intent: {
userModel: "openai/gpt-5.3-codex",
},
constraints: {
availableModels: new Set<string>(),
},
})
// when
const hasExplicitUserConfigField = result
? Object.prototype.hasOwnProperty.call(result, "explicitUserConfig")
: false
// then
expect(result).toEqual({ model: "openai/gpt-5.3-codex", provenance: "override" })
expect(hasExplicitUserConfigField).toBe(false)
})
})
+17 -230
View File
@@ -1,235 +1,22 @@
import { log as writeLog } from "./logger"
import {
_setModelResolutionLogImplementationForTesting,
resolveModelPipeline as resolveModelPipelineFromCore,
} from "@oh-my-opencode/model-core"
import type {
PipelineModelResolutionRequest,
PipelineModelResolutionResult,
} from "@oh-my-opencode/model-core"
import * as connectedProvidersCache from "./connected-providers-cache"
import { fuzzyMatchModel } from "./model-availability"
import type { FallbackEntry } from "./model-requirements"
import { transformModelForProvider } from "./provider-model-id-transform"
import { normalizeModel } from "./model-normalization"
type LogImplementation = typeof writeLog
let logImplementationForTesting: LogImplementation | undefined
function log(message: string, data?: unknown): void {
const logImplementation = logImplementationForTesting ?? writeLog
if (arguments.length === 1) {
logImplementation(message)
return
}
logImplementation(message, data)
}
export function _setModelResolutionLogImplementationForTesting(
logImplementation: LogImplementation | undefined,
): void {
logImplementationForTesting = logImplementation
}
export type ModelResolutionRequest = {
intent?: {
uiSelectedModel?: string
userModel?: string
userFallbackModels?: string[]
categoryDefaultModel?: string
}
constraints: {
availableModels: Set<string>
connectedProviders?: string[] | null
}
policy?: {
fallbackChain?: FallbackEntry[]
systemDefaultModel?: string
}
}
export type ModelResolutionProvenance =
| "override"
| "category-default"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
provenance: ModelResolutionProvenance
variant?: string
attempted?: string[]
reason?: string
}
export { _setModelResolutionLogImplementationForTesting }
export function resolveModelPipeline(
request: ModelResolutionRequest,
): ModelResolutionResult | undefined {
const attempted: string[] = []
const { intent, constraints, policy } = request
const availableModels = constraints.availableModels
const fallbackChain = policy?.fallbackChain
const systemDefaultModel = policy?.systemDefaultModel
const normalizedUiModel = normalizeModel(intent?.uiSelectedModel)
if (normalizedUiModel) {
log("Model resolved via UI selection", { model: normalizedUiModel })
return { model: normalizedUiModel, provenance: "override" }
}
const normalizedUserModel = normalizeModel(intent?.userModel)
if (normalizedUserModel) {
log("Model resolved via config override", { model: normalizedUserModel })
return { model: normalizedUserModel, provenance: "override" }
}
const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel)
if (normalizedCategoryDefault) {
attempted.push(normalizedCategoryDefault)
if (availableModels.size > 0) {
const parts = normalizedCategoryDefault.split("/")
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
const match = fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
if (match) {
log("Model resolved via category default (fuzzy matched)", {
original: normalizedCategoryDefault,
matched: match,
})
return { model: match, provenance: "category-default", attempted }
}
} else {
const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache()
if (connectedProviders === null) {
log("Model resolved via category default (no cache, first run)", {
model: normalizedCategoryDefault,
})
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
}
const parts = normalizedCategoryDefault.split("/")
if (parts.length >= 2) {
const provider = parts[0]
if (connectedProviders.includes(provider)) {
const modelName = parts.slice(1).join("/")
const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}`
log("Model resolved via category default (connected provider)", {
model: transformedModel,
original: normalizedCategoryDefault,
})
return { model: transformedModel, provenance: "category-default", attempted }
}
}
}
log("Category default model not available, falling through to fallback chain", {
model: normalizedCategoryDefault,
})
}
//#when - user configured fallback_models, try them before hardcoded fallback chain
const userFallbackModels = intent?.userFallbackModels
if (userFallbackModels && userFallbackModels.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
if (connectedSet !== null) {
for (const model of userFallbackModels) {
attempted.push(model)
const parts = model.split("/")
if (parts.length >= 2) {
const provider = parts[0]
if (connectedSet.has(provider)) {
const modelName = parts.slice(1).join("/")
const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}`
log("Model resolved via user fallback_models (connected provider)", { model: transformedModel, original: model })
return { model: transformedModel, provenance: "provider-fallback", attempted }
}
}
}
log("No connected provider found in user fallback_models, falling through to hardcoded chain")
}
} else {
for (const model of userFallbackModels) {
attempted.push(model)
const parts = model.split("/")
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
const match = fuzzyMatchModel(model, availableModels, providerHint)
if (match) {
log("Model resolved via user fallback_models (availability confirmed)", { model: model, match })
return { model: match, provenance: "provider-fallback", attempted }
}
}
log("No available model found in user fallback_models, falling through to hardcoded chain")
}
}
if (fallbackChain && fallbackChain.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
if (connectedSet === null) {
log("Model fallback chain skipped (no connected providers cache) - falling through to system default")
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (connectedSet.has(provider)) {
const transformedModelId = transformModelForProvider(provider, entry.model)
const model = `${provider}/${transformedModelId}`
log("Model resolved via fallback chain (connected provider)", {
provider,
model: transformedModelId,
variant: entry.variant,
})
return {
model,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
}
log("No connected provider found in fallback chain, falling through to system default")
}
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
const fullModel = `${provider}/${entry.model}`
const match = fuzzyMatchModel(fullModel, availableModels, [provider])
if (match) {
log("Model resolved via fallback chain (availability confirmed)", {
provider,
model: entry.model,
match,
variant: entry.variant,
})
return {
model: match,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels)
if (crossProviderMatch) {
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
model: entry.model,
match: crossProviderMatch,
variant: entry.variant,
})
return {
model: crossProviderMatch,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
log("No available model found in fallback chain, falling through to system default")
}
}
if (systemDefaultModel === undefined) {
log("No model resolved - systemDefaultModel not configured")
return undefined
}
log("Model resolved via system default", { model: systemDefaultModel })
return { model: systemDefaultModel, provenance: "system-default", attempted }
request: PipelineModelResolutionRequest,
): PipelineModelResolutionResult | undefined {
return resolveModelPipelineFromCore(request, connectedProvidersCache)
}
export type {
PipelineModelResolutionRequest as ModelResolutionRequest,
PipelineModelResolutionProvenance as ModelResolutionProvenance,
PipelineModelResolutionResult as ModelResolutionResult,
} from "@oh-my-opencode/model-core"
+6 -41
View File
@@ -1,41 +1,6 @@
import type { FallbackEntry } from "./model-requirements"
export interface DelegatedModelConfig {
providerID: string
modelID: string
variant?: string
reasoningEffort?: string
temperature?: number
top_p?: number
maxTokens?: number
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
}
export type ModelResolutionRequest = {
intent?: {
uiSelectedModel?: string
userModel?: string
categoryDefaultModel?: string
}
constraints: {
availableModels: Set<string>
}
policy?: {
fallbackChain?: FallbackEntry[]
systemDefaultModel?: string
}
}
export type ModelResolutionProvenance =
| "override"
| "category-default"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
provenance: ModelResolutionProvenance
variant?: string
attempted?: string[]
reason?: string
}
export type {
DelegatedModelConfig,
ModelResolutionRequest,
ModelResolutionProvenance,
ModelResolutionResult,
} from "@oh-my-opencode/model-core"
-950
View File
@@ -1,950 +0,0 @@
import { describe, expect, test, spyOn, beforeEach, afterEach, mock } from "bun:test"
import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver"
import { _setModelResolutionLogImplementationForTesting } from "./model-resolution-pipeline"
import * as connectedProvidersCache from "./connected-providers-cache"
const logMock = mock(() => {})
describe("resolveModel", () => {
describe("priority chain", () => {
test("returns userModel when all three are set", () => {
// given
const input: ModelResolutionInput = {
userModel: "anthropic/claude-opus-4-7",
inheritedModel: "openai/gpt-5.4",
systemDefault: "google/gemini-3.1-pro",
}
// when
const result = resolveModel(input)
// then
expect(result).toBe("anthropic/claude-opus-4-7")
})
test("returns inheritedModel when userModel is undefined", () => {
// given
const input: ModelResolutionInput = {
userModel: undefined,
inheritedModel: "openai/gpt-5.4",
systemDefault: "google/gemini-3.1-pro",
}
// when
const result = resolveModel(input)
// then
expect(result).toBe("openai/gpt-5.4")
})
test("returns systemDefault when both userModel and inheritedModel are undefined", () => {
// given
const input: ModelResolutionInput = {
userModel: undefined,
inheritedModel: undefined,
systemDefault: "google/gemini-3.1-pro",
}
// when
const result = resolveModel(input)
// then
expect(result).toBe("google/gemini-3.1-pro")
})
})
describe("empty string handling", () => {
test("treats empty string as unset, uses fallback", () => {
// given
const input: ModelResolutionInput = {
userModel: "",
inheritedModel: "openai/gpt-5.4",
systemDefault: "google/gemini-3.1-pro",
}
// when
const result = resolveModel(input)
// then
expect(result).toBe("openai/gpt-5.4")
})
test("treats whitespace-only string as unset, uses fallback", () => {
// given
const input: ModelResolutionInput = {
userModel: " ",
inheritedModel: "",
systemDefault: "google/gemini-3.1-pro",
}
// when
const result = resolveModel(input)
// then
expect(result).toBe("google/gemini-3.1-pro")
})
})
describe("purity", () => {
test("same input returns same output (referential transparency)", () => {
// given
const input: ModelResolutionInput = {
userModel: "anthropic/claude-opus-4-7",
inheritedModel: "openai/gpt-5.4",
systemDefault: "google/gemini-3.1-pro",
}
// when
const result1 = resolveModel(input)
const result2 = resolveModel(input)
// then
expect(result1).toBe(result2)
})
})
})
describe("resolveModelWithFallback", () => {
beforeEach(() => {
logMock.mockClear()
_setModelResolutionLogImplementationForTesting(logMock)
})
afterEach(() => {
_setModelResolutionLogImplementationForTesting(undefined)
})
describe("Step 1: UI Selection (highest priority)", () => {
test("returns uiSelectedModel with override source when provided", () => {
// given
const input: ExtendedModelResolutionInput = {
uiSelectedModel: "opencode/big-pickle",
userModel: "anthropic/claude-opus-4-7",
fallbackChain: [
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-7" },
],
availableModels: new Set(["anthropic/claude-opus-4-7", "github-copilot/claude-opus-4-7-preview"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("opencode/big-pickle")
expect(result!.source).toBe("override")
expect(logMock).toHaveBeenCalledWith("Model resolved via UI selection", { model: "opencode/big-pickle" })
})
test("UI selection takes priority over config override", () => {
// given
const input: ExtendedModelResolutionInput = {
uiSelectedModel: "opencode/big-pickle",
userModel: "anthropic/claude-opus-4-7",
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("opencode/big-pickle")
expect(result!.source).toBe("override")
})
test("whitespace-only uiSelectedModel is treated as not provided", () => {
// given
const input: ExtendedModelResolutionInput = {
uiSelectedModel: " ",
userModel: "anthropic/claude-opus-4-7",
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("anthropic/claude-opus-4-7")
expect(logMock).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-7" })
})
test("empty string uiSelectedModel falls through to config override", () => {
// given
const input: ExtendedModelResolutionInput = {
uiSelectedModel: "",
userModel: "anthropic/claude-opus-4-7",
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("anthropic/claude-opus-4-7")
})
})
describe("Step 2: Config Override", () => {
test("returns userModel with override source when userModel is provided", () => {
// given
const input: ExtendedModelResolutionInput = {
userModel: "anthropic/claude-opus-4-7",
fallbackChain: [
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-7" },
],
availableModels: new Set(["anthropic/claude-opus-4-7", "github-copilot/claude-opus-4-7-preview"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("anthropic/claude-opus-4-7")
expect(result!.source).toBe("override")
expect(logMock).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-7" })
})
test("override takes priority even if model not in availableModels", () => {
// given
const input: ExtendedModelResolutionInput = {
userModel: "custom/my-model",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
],
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("custom/my-model")
expect(result!.source).toBe("override")
})
test("whitespace-only userModel is treated as not provided", () => {
// given
const input: ExtendedModelResolutionInput = {
userModel: " ",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
],
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.source).not.toBe("override")
})
test("empty string userModel is treated as not provided", () => {
// given
const input: ExtendedModelResolutionInput = {
userModel: "",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
],
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.source).not.toBe("override")
})
})
describe("Step 3: Provider fallback chain", () => {
test("tries providers in order within entry and returns first match", () => {
// given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-7" },
],
availableModels: new Set(["github-copilot/claude-opus-4-7-preview", "opencode/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("github-copilot/claude-opus-4-7-preview")
expect(result!.source).toBe("provider-fallback")
expect(logMock).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", {
provider: "github-copilot",
model: "claude-opus-4-7",
match: "github-copilot/claude-opus-4-7-preview",
variant: undefined,
})
})
test("respects provider priority order within entry", () => {
// given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.4" },
],
availableModels: new Set(["openai/gpt-5.4", "anthropic/claude-opus-4-7", "google/gemini-3.1-pro"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("openai/gpt-5.4")
expect(result!.source).toBe("provider-fallback")
})
test("tries next provider when first provider has no match", () => {
// given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic", "opencode"], model: "gpt-5-nano" },
],
availableModels: new Set(["opencode/gpt-5-nano"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("opencode/gpt-5-nano")
expect(result!.source).toBe("provider-fallback")
})
test("uses fuzzy matching within provider", () => {
// given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
],
availableModels: new Set(["anthropic/claude-opus-4-7", "github-copilot/claude-opus-4-7-preview"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("anthropic/claude-opus-4-7")
expect(result!.source).toBe("provider-fallback")
})
test("skips fallback chain when not provided", () => {
// given
const input: ExtendedModelResolutionInput = {
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.source).toBe("system-default")
})
test("skips fallback chain when empty", () => {
// given
const input: ExtendedModelResolutionInput = {
fallbackChain: [],
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.source).toBe("system-default")
})
test("case-insensitive fuzzy matching", () => {
// given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
],
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("anthropic/claude-opus-4-7")
expect(result!.source).toBe("provider-fallback")
})
test("cross-provider fuzzy match when preferred provider unavailable (librarian scenario)", () => {
// given - glm-5 is defined for zai-coding-plan, but only opencode has it
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["zai-coding-plan"], model: "glm-5" },
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(["opencode/glm-5", "anthropic/claude-sonnet-4-6"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then - should find glm-5 from opencode via cross-provider fuzzy match
expect(result!.model).toBe("opencode/glm-5")
expect(result!.source).toBe("provider-fallback")
expect(logMock).toHaveBeenCalledWith("Model resolved via fallback chain (cross-provider fuzzy match)", {
model: "glm-5",
match: "opencode/glm-5",
variant: undefined,
})
})
test("prefers specified provider over cross-provider match", () => {
// given - both zai-coding-plan and opencode have glm-5
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["zai-coding-plan"], model: "glm-5" },
],
availableModels: new Set(["zai-coding-plan/glm-5", "opencode/glm-5"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then - should prefer zai-coding-plan (specified provider) over opencode
expect(result!.model).toBe("zai-coding-plan/glm-5")
expect(result!.source).toBe("provider-fallback")
})
test("cross-provider match preserves variant from entry", () => {
// given - entry has variant, model found via cross-provider
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["zai-coding-plan"], model: "glm-5", variant: "high" },
],
availableModels: new Set(["opencode/glm-5"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then - variant should be preserved
expect(result!.model).toBe("opencode/glm-5")
expect(result!.variant).toBe("high")
})
test("cross-provider match tries next entry if no match found anywhere", () => {
// given - first entry model not available anywhere, second entry available
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["zai-coding-plan"], model: "nonexistent-model" },
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then - should fall through to second entry
expect(result!.model).toBe("anthropic/claude-sonnet-4-6")
expect(result!.source).toBe("provider-fallback")
})
})
describe("Step 4: System default fallback (no availability match)", () => {
test("returns system default when no availability match found in fallback chain", () => {
// given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "nonexistent-model" },
],
availableModels: new Set(["openai/gpt-5.4", "anthropic/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("google/gemini-3.1-pro")
expect(result!.source).toBe("system-default")
expect(logMock).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
})
test("returns undefined when availableModels empty and no connected providers cache exists", () => {
// given - both model cache and connected-providers cache are missing (first run)
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
],
availableModels: new Set(),
systemDefaultModel: undefined, // no system default configured
}
// when
const result = resolveModelWithFallback(input)
// then - should return undefined to let OpenCode use Provider.defaultModel()
expect(result).toBeUndefined()
cacheSpy.mockRestore()
})
test("uses connected provider from fallback when availableModels empty but cache exists", () => {
// given - model cache missing but connected-providers cache exists
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"])
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic", "openai"], model: "claude-opus-4-7" },
],
availableModels: new Set(),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then - should use connected provider (openai) from fallback chain
expect(result!.model).toBe("openai/claude-opus-4-7")
expect(result!.source).toBe("provider-fallback")
cacheSpy.mockRestore()
})
test("uses github-copilot when google not connected (visual-engineering scenario)", () => {
// given - user has github-copilot but not google connected
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"])
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
}
// when
const result = resolveModelWithFallback(input)
// then - should use github-copilot (second provider) since google not connected
// model name is transformed to preview variant for github-copilot provider
expect(result!.model).toBe("github-copilot/gemini-3.1-pro-preview")
expect(result!.source).toBe("provider-fallback")
cacheSpy.mockRestore()
})
test("falls through to system default when no provider in fallback is connected", () => {
// given - user only has anthropic connected, but fallback chain has openai/opencode
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["openai", "opencode"], model: "claude-haiku-4-5" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-opus-4-7-20251101",
}
// when
const result = resolveModelWithFallback(input)
// then - no provider in fallback is connected, fall through to system default
expect(result!.model).toBe("anthropic/claude-opus-4-7-20251101")
expect(result!.source).toBe("system-default")
cacheSpy.mockRestore()
})
test("falls through to system default when no cache and systemDefaultModel is provided", () => {
// given - no cache but system default is configured
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
],
availableModels: new Set(),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then - should fall through to system default
expect(result!.model).toBe("google/gemini-3.1-pro")
expect(result!.source).toBe("system-default")
cacheSpy.mockRestore()
})
test("returns system default when fallbackChain is not provided", () => {
// given
const input: ExtendedModelResolutionInput = {
availableModels: new Set(["openai/gpt-5.4"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("google/gemini-3.1-pro")
expect(result!.source).toBe("system-default")
})
})
describe("Multi-entry fallbackChain", () => {
test("resolves to claude-opus when OpenAI unavailable but Anthropic available (oracle scenario)", () => {
// given
const availableModels = new Set(["anthropic/claude-opus-4-7"])
// when
const result = resolveModelWithFallback({
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.4", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-7", variant: "max" },
],
availableModels,
systemDefaultModel: "system/default",
})
// then
expect(result!.model).toBe("anthropic/claude-opus-4-7")
expect(result!.source).toBe("provider-fallback")
})
test("tries all providers in first entry before moving to second entry", () => {
// given
const availableModels = new Set(["google/gemini-3.1-pro"])
// when
const result = resolveModelWithFallback({
fallbackChain: [
{ providers: ["openai", "anthropic"], model: "gpt-5.4" },
{ providers: ["google"], model: "gemini-3.1-pro" },
],
availableModels,
systemDefaultModel: "system/default",
})
// then
expect(result!.model).toBe("google/gemini-3.1-pro")
expect(result!.source).toBe("provider-fallback")
})
test("returns first matching entry even if later entries have better matches", () => {
// given
const availableModels = new Set([
"openai/gpt-5.4",
"anthropic/claude-opus-4-7",
])
// when
const result = resolveModelWithFallback({
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4" },
{ providers: ["anthropic"], model: "claude-opus-4-7" },
],
availableModels,
systemDefaultModel: "system/default",
})
// then
expect(result!.model).toBe("openai/gpt-5.4")
expect(result!.source).toBe("provider-fallback")
})
test("falls through to system default when none match availability", () => {
// given
const availableModels = new Set(["other/model"])
// when
const result = resolveModelWithFallback({
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4" },
{ providers: ["anthropic"], model: "claude-opus-4-7" },
{ providers: ["google"], model: "gemini-3.1-pro" },
],
availableModels,
systemDefaultModel: "system/default",
})
// then
expect(result!.model).toBe("system/default")
expect(result!.source).toBe("system-default")
})
})
describe("Type safety", () => {
test("result has correct ModelResolutionResult shape", () => {
// given
const input: ExtendedModelResolutionInput = {
userModel: "anthropic/claude-opus-4-7",
availableModels: new Set(),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result).toBeDefined()
expect(typeof result!.model).toBe("string")
expect(["override", "provider-fallback", "system-default"]).toContain(result!.source)
})
})
describe("categoryDefaultModel (fuzzy matching for category defaults)", () => {
test("applies fuzzy matching to categoryDefaultModel when userModel not provided", () => {
// given - gemini-3.1-pro is the category default, but only gemini-3.1-pro-preview is available
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3.1-pro",
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-7"]),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
}
// when
const result = resolveModelWithFallback(input)
// then - should fuzzy match gemini-3.1-pro → gemini-3.1-pro-preview
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
expect(result!.source).toBe("category-default")
})
test("categoryDefaultModel uses exact match when available", () => {
// given - exact match exists
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3.1-pro",
fallbackChain: [
{ providers: ["google"], model: "gemini-3.1-pro" },
],
availableModels: new Set(["google/gemini-3.1-pro", "google/gemini-3.1-pro-preview"]),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
}
// when
const result = resolveModelWithFallback(input)
// then - should use exact match
expect(result!.model).toBe("google/gemini-3.1-pro")
expect(result!.source).toBe("category-default")
})
test("categoryDefaultModel falls through to fallbackChain when no match in availableModels", () => {
// given - categoryDefaultModel has no match, but fallbackChain does
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3.1-pro",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
],
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "system/default",
}
// when
const result = resolveModelWithFallback(input)
// then - should fall through to fallbackChain
expect(result!.model).toBe("anthropic/claude-opus-4-7")
expect(result!.source).toBe("provider-fallback")
})
test("userModel takes priority over categoryDefaultModel", () => {
// given - both userModel and categoryDefaultModel provided
const input: ExtendedModelResolutionInput = {
userModel: "anthropic/claude-opus-4-7",
categoryDefaultModel: "google/gemini-3.1-pro",
fallbackChain: [
{ providers: ["google"], model: "gemini-3.1-pro" },
],
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-7"]),
systemDefaultModel: "system/default",
}
// when
const result = resolveModelWithFallback(input)
// then - userModel wins
expect(result!.model).toBe("anthropic/claude-opus-4-7")
expect(result!.source).toBe("override")
})
test("categoryDefaultModel works when availableModels is empty but connected provider exists", () => {
// given - no availableModels but connected provider cache exists
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3.1-pro",
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
}
// when
const result = resolveModelWithFallback(input)
// then - should use transformed categoryDefaultModel since google is connected
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
expect(result!.source).toBe("category-default")
cacheSpy.mockRestore()
})
test("transforms gemini-3-flash in categoryDefaultModel for google connected provider", () => {
// given - google connected, category default uses gemini-3-flash
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3-flash",
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-5",
}
// when
const result = resolveModelWithFallback(input)
// then - gemini-3-flash should be transformed to gemini-3-flash-preview
expect(result!.model).toBe("google/gemini-3-flash-preview")
expect(result!.source).toBe("category-default")
cacheSpy.mockRestore()
})
test("does not double-transform categoryDefaultModel already containing -preview", () => {
// given - category default already has -preview suffix
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3.1-pro-preview",
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-5",
}
// when
const result = resolveModelWithFallback(input)
// then - should NOT become gemini-3.1-pro-preview-preview
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
expect(result!.source).toBe("category-default")
cacheSpy.mockRestore()
})
test("transforms gemini-3.1-pro in fallback chain for google connected provider", () => {
// given - google connected, fallback chain has gemini-3.1-pro
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["google", "github-copilot"], model: "gemini-3.1-pro" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-5",
}
// when
const result = resolveModelWithFallback(input)
// then - should transform to preview variant for google provider
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
expect(result!.source).toBe("provider-fallback")
cacheSpy.mockRestore()
})
test("passes through non-gemini-3 models for google connected provider", () => {
// given - google connected, category default uses gemini-2.5-flash (no transform needed)
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-2.5-flash",
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-5",
}
// when
const result = resolveModelWithFallback(input)
// then - should pass through unchanged
expect(result!.model).toBe("google/gemini-2.5-flash")
expect(result!.source).toBe("category-default")
cacheSpy.mockRestore()
})
})
describe("Optional systemDefaultModel", () => {
test("returns undefined when systemDefaultModel is undefined and no fallback found", () => {
// given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "nonexistent-model" },
],
availableModels: new Set(["openai/gpt-5.4"]),
systemDefaultModel: undefined,
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result).toBeUndefined()
})
test("returns undefined when no fallbackChain and systemDefaultModel is undefined", () => {
// given
const input: ExtendedModelResolutionInput = {
availableModels: new Set(["openai/gpt-5.4"]),
systemDefaultModel: undefined,
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result).toBeUndefined()
})
test("still returns override when userModel provided even if systemDefaultModel undefined", () => {
// given
const input: ExtendedModelResolutionInput = {
userModel: "anthropic/claude-opus-4-7",
availableModels: new Set(),
systemDefaultModel: undefined,
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result).toBeDefined()
expect(result!.model).toBe("anthropic/claude-opus-4-7")
expect(result!.source).toBe("override")
})
test("still returns fallback match when systemDefaultModel undefined", () => {
// given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
],
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: undefined,
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result).toBeDefined()
expect(result!.model).toBe("anthropic/claude-opus-4-7")
expect(result!.source).toBe("provider-fallback")
})
})
})
+12 -106
View File
@@ -1,106 +1,12 @@
import type { FallbackEntry } from "./model-requirements"
import type { FallbackModelObject } from "../config/schema/fallback-models"
import { normalizeModel } from "./model-normalization"
import { resolveModelPipeline } from "./model-resolution-pipeline"
import { KNOWN_VARIANTS } from "./known-variants"
export type ModelResolutionInput = {
userModel?: string
inheritedModel?: string
systemDefault?: string
}
export type ModelSource =
| "override"
| "category-default"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
source: ModelSource
variant?: string
}
export type ExtendedModelResolutionInput = {
uiSelectedModel?: string
userModel?: string
userFallbackModels?: string[]
categoryDefaultModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
systemDefaultModel?: string
}
export function resolveModel(input: ModelResolutionInput): string | undefined {
return (
normalizeModel(input.userModel) ??
normalizeModel(input.inheritedModel) ??
input.systemDefault
)
}
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
): ModelResolutionResult | undefined {
const { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
const resolved = resolveModelPipeline({
intent: { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel },
constraints: { availableModels },
policy: { fallbackChain, systemDefaultModel },
})
if (!resolved) {
return undefined
}
return {
model: resolved.model,
source: resolved.provenance,
variant: resolved.variant,
}
}
/**
* Normalizes fallback_models config to a mixed array.
* Accepts string, string[], or mixed arrays of strings and FallbackModelObject entries.
*/
export function normalizeFallbackModels(
models: string | (string | FallbackModelObject)[] | undefined,
): (string | FallbackModelObject)[] | undefined {
if (!models) return undefined
if (typeof models === "string") return [models]
return models
}
/**
* Extracts plain model strings from a mixed fallback models array.
* Object entries are flattened to "model" or "model(variant)" strings.
* Use this when consumers need string[] (e.g., resolveModelForDelegateTask).
*/
export function flattenToFallbackModelStrings(
models: (string | FallbackModelObject)[] | undefined,
): string[] | undefined {
if (!models) return undefined
return models.map((entry) => {
if (typeof entry === "string") return entry
const variant = entry.variant
if (variant) {
// Strip any supported inline variant syntax before appending explicit override.
// Supports both parenthesized and space-suffix forms so we don't emit
// invalid strings like "provider/model high(low)".
const model = entry.model
.replace(/\([^()]+\)\s*$/, "")
.replace(/\s+([a-z][a-z0-9_-]*)\s*$/i, (match: string, suffix: string) => {
const normalized = String(suffix).toLowerCase()
return KNOWN_VARIANTS.has(normalized)
? ""
: match
})
.trim()
return `${model}(${variant})`
}
return entry.model
})
}
export type {
ModelResolutionInput,
ModelSource,
ModelResolutionResult,
ExtendedModelResolutionInput,
} from "@oh-my-opencode/model-core"
export {
resolveModel,
resolveModelWithFallback,
normalizeFallbackModels,
flattenToFallbackModelStrings,
} from "@oh-my-opencode/model-core"
+1 -12
View File
@@ -1,12 +1 @@
type CommandSource = "claude-code" | "opencode"
export function sanitizeModelField(model: unknown, source: CommandSource = "claude-code"): string | undefined {
if (source === "claude-code") {
return undefined
}
if (typeof model === "string" && model.trim().length > 0) {
return model.trim()
}
return undefined
}
export { sanitizeModelField } from "@oh-my-opencode/model-core"
@@ -1,645 +0,0 @@
import { describe, expect, test } from "bun:test"
import { getModelCapabilities } from "./model-capabilities"
import { resolveCompatibleModelSettings } from "./model-settings-compatibility"
describe("resolveCompatibleModelSettings", () => {
test("keeps supported Claude Opus variant unchanged", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-opus-4-7",
desired: { variant: "max" },
})
expect(result).toEqual({
variant: "max",
reasoningEffort: undefined,
changes: [],
})
})
test("uses model metadata first for variant support", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-opus-4-7",
desired: { variant: "max" },
capabilities: { variants: ["low", "medium", "high"] },
})
expect(result).toEqual({
variant: "high",
reasoningEffort: undefined,
changes: [
{
field: "variant",
from: "max",
to: "high",
reason: "unsupported-by-model-metadata",
},
],
})
})
test("prefers metadata over family heuristics even when family would allow a higher level", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-opus-4-7",
desired: { variant: "max" },
capabilities: { variants: ["low", "medium"] },
})
expect(result.variant).toBe("medium")
expect(result.changes).toEqual([
{
field: "variant",
from: "max",
to: "medium",
reason: "unsupported-by-model-metadata",
},
])
})
test("downgrades unsupported Claude Sonnet max variant to high when metadata is absent", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
desired: { variant: "max" },
})
expect(result.variant).toBe("high")
expect(result.changes).toEqual([
{
field: "variant",
from: "max",
to: "high",
reason: "unsupported-by-model-family",
},
])
})
test("keeps supported GPT reasoningEffort unchanged", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { reasoningEffort: "high" },
})
expect(result).toEqual({
variant: undefined,
reasoningEffort: "high",
changes: [],
})
})
test("keeps supported OpenAI reasoning-family effort for o-series models", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "o3-mini",
desired: { reasoningEffort: "high" },
})
expect(result).toEqual({
variant: undefined,
reasoningEffort: "high",
changes: [],
})
})
test("does not record case-only normalization as a compatibility downgrade", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { variant: "HIGH", reasoningEffort: "HIGH" },
})
expect(result).toEqual({
variant: "high",
reasoningEffort: "high",
changes: [],
})
})
test("drops reasoningEffort for standard GPT models (gpt-4.1)", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-4.1",
desired: { reasoningEffort: "high" },
})
expect(result.reasoningEffort).toBeUndefined()
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "high",
to: undefined,
reason: "unsupported-by-model-family",
},
])
})
test("drops reasoningEffort for Claude family", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
desired: { reasoningEffort: "high" },
})
expect(result.reasoningEffort).toBeUndefined()
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "high",
to: undefined,
reason: "unsupported-by-model-family",
},
])
})
test("handles combined variant and reasoningEffort normalization", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
desired: { variant: "max", reasoningEffort: "high" },
})
expect(result).toEqual({
variant: "high",
reasoningEffort: undefined,
changes: [
{
field: "variant",
from: "max",
to: "high",
reason: "unsupported-by-model-family",
},
{
field: "reasoningEffort",
from: "high",
to: undefined,
reason: "unsupported-by-model-family",
},
],
})
})
test("treats unknown model families conservatively by dropping unsupported settings", () => {
const result = resolveCompatibleModelSettings({
providerID: "mystery",
modelID: "mystery-model-1",
desired: { variant: "max", reasoningEffort: "high" },
})
expect(result).toEqual({
variant: undefined,
reasoningEffort: undefined,
changes: [
{
field: "variant",
from: "max",
to: undefined,
reason: "unknown-model-family",
},
{
field: "reasoningEffort",
from: "high",
to: undefined,
reason: "unknown-model-family",
},
],
})
})
// Provider-agnostic detection: model ID is the source of truth, not provider ID
test("detects Claude via any provider (provider-agnostic)", () => {
for (const providerID of ["anthropic", "aws-bedrock", "bedrock", "amazon-bedrock", "opencode", "my-custom-proxy", "google-vertex-anthropic"]) {
const result = resolveCompatibleModelSettings({
providerID,
modelID: "claude-sonnet-4-6",
desired: { variant: "max" },
})
expect(result.variant).toBe("high")
expect(result.changes[0]?.reason).toBe("unsupported-by-model-family")
}
})
test("detects Claude 3 Opus via any provider", () => {
const result = resolveCompatibleModelSettings({
providerID: "some-unknown-proxy",
modelID: "claude-3-opus-20240229",
desired: { variant: "max" },
})
expect(result.variant).toBe("max")
expect(result.changes).toEqual([])
})
test("detects OpenAI reasoning models without requiring openai provider", () => {
const result = resolveCompatibleModelSettings({
providerID: "azure-openai",
modelID: "o3-mini",
desired: { reasoningEffort: "high" },
})
expect(result.reasoningEffort).toBe("high")
expect(result.changes).toEqual([])
})
describe("model family registry coverage", () => {
const familyCases: Array<{
name: string
modelID: string
expectedVariants: string[]
hasReasoningEffort: boolean
}> = [
{ name: "Gemini", modelID: "gemini-3.1-pro", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "Grok", modelID: "grok-4.3", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: true },
{ name: "Kimi (kimi)", modelID: "kimi-k2.5", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "Kimi (k2)", modelID: "k2-v2", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "GLM", modelID: "glm-5", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "Minimax", modelID: "minimax-m2.5", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "DeepSeek", modelID: "deepseek-r2", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: true },
{ name: "Mistral", modelID: "mistral-large-next", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "Codestral → Mistral", modelID: "codestral-2506", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "Llama", modelID: "llama-4-maverick", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
]
for (const { name, modelID, expectedVariants, hasReasoningEffort } of familyCases) {
test(`${name} (${modelID}): keeps supported variant`, () => {
const highest = expectedVariants[expectedVariants.length - 1]
const result = resolveCompatibleModelSettings({
providerID: "any-provider",
modelID,
desired: { variant: highest },
})
expect(result.variant).toBe(highest)
expect(result.changes).toEqual([])
})
test(`${name} (${modelID}): downgrades unsupported variant`, () => {
const result = resolveCompatibleModelSettings({
providerID: "any-provider",
modelID,
desired: { variant: "max" },
})
const highest = expectedVariants[expectedVariants.length - 1]
expect(result.variant).toBe(highest)
expect(result.changes[0]?.reason).toBe("unsupported-by-model-family")
})
test(`${name} (${modelID}): ${hasReasoningEffort ? "keeps" : "drops"} reasoningEffort`, () => {
const result = resolveCompatibleModelSettings({
providerID: "any-provider",
modelID,
desired: { reasoningEffort: "high" },
})
if (hasReasoningEffort) {
expect(result.reasoningEffort).toBe("high")
expect(result.changes).toEqual([])
} else {
expect(result.reasoningEffort).toBeUndefined()
expect(result.changes[0]?.reason).toBe("unsupported-by-model-family")
}
})
}
})
test("GPT-5 keeps xhigh variant and reasoningEffort", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { variant: "xhigh", reasoningEffort: "xhigh" },
})
expect(result).toEqual({
variant: "xhigh",
reasoningEffort: "xhigh",
changes: [],
})
})
test("DeepSeek keeps canonical high and max reasoningEffort values", () => {
for (const reasoningEffort of ["high", "max"]) {
const result = resolveCompatibleModelSettings({
providerID: "openai-compatible",
modelID: "deepseek-v4-pro",
desired: { reasoningEffort },
})
expect(result.reasoningEffort).toBe(reasoningEffort)
expect(result.changes).toEqual([])
}
})
test("DeepSeek maps generic reasoningEffort levels to canonical API values", () => {
const cases = [
{ requested: "low", expected: "high" },
{ requested: "medium", expected: "high" },
{ requested: "xhigh", expected: "max" },
]
for (const { requested, expected } of cases) {
const result = resolveCompatibleModelSettings({
providerID: "openai-compatible",
modelID: "deepseek-v4-pro",
desired: { reasoningEffort: requested },
})
expect(result.reasoningEffort).toBe(expected)
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: requested,
to: expected,
reason: "unsupported-by-model-family",
},
])
}
})
test("DeepSeek maps generic reasoningEffort levels when capabilities come from heuristics", () => {
const capabilities = getModelCapabilities({
providerID: "openai-compatible",
modelID: "deepseek-v4-pro",
})
const result = resolveCompatibleModelSettings({
providerID: "openai-compatible",
modelID: "deepseek-v4-pro",
desired: { reasoningEffort: "xhigh" },
capabilities,
})
expect(result.reasoningEffort).toBe("max")
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "xhigh",
to: "max",
reason: "unsupported-by-model-family",
},
])
})
test("GPT-5 downgrades unsupported max variant to xhigh", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { variant: "max" },
})
expect(result).toEqual({
variant: "xhigh",
reasoningEffort: undefined,
changes: [
{
field: "variant",
from: "max",
to: "xhigh",
reason: "unsupported-by-model-family",
},
],
})
})
test("GPT-5 keeps none reasoningEffort", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { reasoningEffort: "none" },
})
expect(result).toEqual({
variant: undefined,
reasoningEffort: "none",
changes: [],
})
})
test("GPT-5 keeps minimal reasoningEffort", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { reasoningEffort: "minimal" },
})
expect(result).toEqual({
variant: undefined,
reasoningEffort: "minimal",
changes: [],
})
})
test("o-series keeps none reasoningEffort", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "o3-mini",
desired: { reasoningEffort: "none" },
})
expect(result).toEqual({
variant: undefined,
reasoningEffort: "none",
changes: [],
})
})
test("o-series downgrades xhigh reasoningEffort to high", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "o3-mini",
desired: { reasoningEffort: "xhigh" },
})
expect(result.reasoningEffort).toBe("high")
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "xhigh",
to: "high",
reason: "unsupported-by-model-family",
},
])
})
test("GPT-5 keeps xhigh but would downgrade a hypothetical beyond-max level", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { reasoningEffort: "xhigh" },
})
expect(result.reasoningEffort).toBe("xhigh")
expect(result.changes).toEqual([])
})
test("o-series downgrades unsupported variant to high", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "o3-mini",
desired: { variant: "max" },
})
expect(result.variant).toBe("high")
expect(result.changes).toEqual([
{
field: "variant",
from: "max",
to: "high",
reason: "unsupported-by-model-family",
},
])
})
test("drops unsupported temperature when capability metadata disables it", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { temperature: 0.7 },
capabilities: { supportsTemperature: false },
})
expect(result.temperature).toBeUndefined()
expect(result.changes).toEqual([
{
field: "temperature",
from: "0.7",
to: undefined,
reason: "unsupported-by-model-metadata",
},
])
})
test("drops thinking when model capabilities say it is unsupported", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { thinking: { type: "enabled", budgetTokens: 4096 } },
capabilities: { supportsThinking: false },
})
expect(result.thinking).toBeUndefined()
expect(result.changes).toEqual([
{
field: "thinking",
from: "{\"type\":\"enabled\",\"budgetTokens\":4096}",
to: undefined,
reason: "unsupported-by-model-metadata",
},
])
})
test("drops thinking for MiniMax M2.7 capabilities resolved from heuristics", () => {
// given
const capabilities = getModelCapabilities({
providerID: "volcengine",
modelID: "minimax-m2.7",
})
// when
const result = resolveCompatibleModelSettings({
providerID: "volcengine",
modelID: "minimax-m2.7",
desired: { thinking: { type: "enabled", budgetTokens: 4096 } },
capabilities,
})
// then
expect(result.thinking).toBeUndefined()
expect(result.changes[0]?.field).toBe("thinking")
expect(result.changes[0]?.reason).toBe("unsupported-by-model-metadata")
})
test("drops thinking for non-thinking Kimi K2.6 capabilities resolved from heuristics", () => {
// given
const capabilities = getModelCapabilities({
providerID: "volcengine",
modelID: "kimi-k2.6",
})
// when
const result = resolveCompatibleModelSettings({
providerID: "volcengine",
modelID: "kimi-k2.6",
desired: { thinking: { type: "enabled", budgetTokens: 4096 } },
capabilities,
})
// then
expect(result.thinking).toBeUndefined()
expect(result.changes[0]?.field).toBe("thinking")
expect(result.changes[0]?.reason).toBe("unsupported-by-model-metadata")
})
test("clamps maxTokens to the model output limit", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { maxTokens: 200_000 },
capabilities: { maxOutputTokens: 128_000 },
})
expect(result.maxTokens).toBe(128_000)
expect(result.changes).toEqual([
{
field: "maxTokens",
from: "200000",
to: "128000",
reason: "max-output-limit",
},
])
})
test("#given capabilities.maxOutputTokens is 0 #then maxTokens preserved unchanged", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { maxTokens: 200_000 },
capabilities: { maxOutputTokens: 0 },
})
expect(result.maxTokens).toBe(200_000)
expect(result.changes).toEqual([])
})
test("#given capabilities.maxOutputTokens is -1 #then maxTokens preserved unchanged", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { maxTokens: 200_000 },
capabilities: { maxOutputTokens: -1 },
})
expect(result.maxTokens).toBe(200_000)
expect(result.changes).toEqual([])
})
test("#given desired.maxTokens is 0 #then maxTokens is dropped", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { maxTokens: 0 },
capabilities: { maxOutputTokens: 128_000 },
})
expect(result.maxTokens).toBeUndefined()
expect(result.changes).toEqual([])
})
// Passthrough: undefined desired values produce no changes
test("no-op when desired settings are empty", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-opus-4-7",
desired: {},
})
expect(result).toEqual({
variant: undefined,
reasoningEffort: undefined,
changes: [],
})
})
})
+6 -217
View File
@@ -1,217 +1,6 @@
import { detectHeuristicModelFamily } from "./model-capability-heuristics"
type CompatibilityField = "variant" | "reasoningEffort" | "temperature" | "topP" | "maxTokens" | "thinking"
type DesiredModelSettings = {
variant?: string
reasoningEffort?: string
temperature?: number
topP?: number
maxTokens?: number
thinking?: Record<string, unknown>
}
type CompatibilityCapabilities = {
variants?: string[]
reasoningEfforts?: string[]
supportsTemperature?: boolean
supportsTopP?: boolean
maxOutputTokens?: number
supportsThinking?: boolean
}
export type ModelSettingsCompatibilityInput = {
providerID: string
modelID: string
desired: DesiredModelSettings
capabilities?: CompatibilityCapabilities
}
export type ModelSettingsCompatibilityChange = {
field: CompatibilityField
from: string
to?: string
reason:
| "unsupported-by-model-family"
| "unknown-model-family"
| "unsupported-by-model-metadata"
| "max-output-limit"
}
export type ModelSettingsCompatibilityResult = {
variant?: string
reasoningEffort?: string
temperature?: number
topP?: number
maxTokens?: number
thinking?: Record<string, unknown>
changes: ModelSettingsCompatibilityChange[]
}
const VARIANT_LADDER = ["low", "medium", "high", "xhigh", "max"]
const REASONING_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
function downgradeWithinLadder(value: string, allowed: string[], ladder: string[]): string | undefined {
const requestedIndex = ladder.indexOf(value)
if (requestedIndex === -1) return undefined
for (let index = requestedIndex; index >= 0; index -= 1) {
if (allowed.includes(ladder[index])) {
return ladder[index]
}
}
return undefined
}
function normalizeCapabilitiesVariants(capabilities: CompatibilityCapabilities | undefined): string[] | undefined {
if (!capabilities?.variants || capabilities.variants.length === 0) {
return undefined
}
return capabilities.variants.map((v) => v.toLowerCase())
}
function normalizeCapabilitiesReasoningEfforts(capabilities: CompatibilityCapabilities | undefined): string[] | undefined {
if (!capabilities?.reasoningEfforts || capabilities.reasoningEfforts.length === 0) {
return undefined
}
return capabilities.reasoningEfforts.map((value) => value.toLowerCase())
}
type FieldResolution = { value?: string; reason?: ModelSettingsCompatibilityChange["reason"] }
function resolveField(
normalized: string,
familyCaps: string[] | undefined,
ladder: string[],
familyKnown: boolean,
metadataOverride?: string[],
familyAliases?: Record<string, string>,
): FieldResolution {
const aliased = familyAliases?.[normalized]
if (aliased && (metadataOverride?.includes(aliased) || familyCaps?.includes(aliased))) {
return { value: aliased, reason: "unsupported-by-model-family" }
}
if (metadataOverride) {
if (metadataOverride.includes(normalized)) return { value: normalized }
return {
value: downgradeWithinLadder(normalized, metadataOverride, ladder),
reason: "unsupported-by-model-metadata",
}
}
if (familyCaps) {
if (familyCaps.includes(normalized)) return { value: normalized }
return {
value: downgradeWithinLadder(normalized, familyCaps, ladder),
reason: "unsupported-by-model-family",
}
}
if (familyKnown) {
return { value: undefined, reason: "unsupported-by-model-family" }
}
return { value: undefined, reason: "unknown-model-family" }
}
export function resolveCompatibleModelSettings(
input: ModelSettingsCompatibilityInput,
): ModelSettingsCompatibilityResult {
const family = detectHeuristicModelFamily(input.modelID)
const familyKnown = Boolean(family)
const changes: ModelSettingsCompatibilityChange[] = []
const metadataVariants = normalizeCapabilitiesVariants(input.capabilities)
const metadataReasoningEfforts = normalizeCapabilitiesReasoningEfforts(input.capabilities)
let variant = input.desired.variant
if (variant !== undefined) {
const normalized = variant.toLowerCase()
const resolved = resolveField(normalized, family?.variants, VARIANT_LADDER, familyKnown, metadataVariants)
if (resolved.value !== normalized && resolved.reason) {
changes.push({ field: "variant", from: variant, to: resolved.value, reason: resolved.reason })
}
variant = resolved.value
}
let reasoningEffort = input.desired.reasoningEffort
if (reasoningEffort !== undefined) {
const normalized = reasoningEffort.toLowerCase()
const resolved = resolveField(
normalized,
family?.reasoningEfforts,
REASONING_LADDER,
familyKnown,
metadataReasoningEfforts,
family?.reasoningEffortAliases,
)
if (resolved.value !== normalized && resolved.reason) {
changes.push({ field: "reasoningEffort", from: reasoningEffort, to: resolved.value, reason: resolved.reason })
}
reasoningEffort = resolved.value
}
let temperature = input.desired.temperature
if (temperature !== undefined && input.capabilities?.supportsTemperature === false) {
changes.push({
field: "temperature",
from: String(temperature),
to: undefined,
reason: "unsupported-by-model-metadata",
})
temperature = undefined
}
let topP = input.desired.topP
if (topP !== undefined && input.capabilities?.supportsTopP === false) {
changes.push({
field: "topP",
from: String(topP),
to: undefined,
reason: "unsupported-by-model-metadata",
})
topP = undefined
}
let maxTokens = input.desired.maxTokens
if (maxTokens !== undefined && maxTokens <= 0) {
maxTokens = undefined
}
if (
maxTokens !== undefined &&
input.capabilities?.maxOutputTokens !== undefined &&
input.capabilities.maxOutputTokens > 0 &&
maxTokens > input.capabilities.maxOutputTokens
) {
changes.push({
field: "maxTokens",
from: String(maxTokens),
to: String(input.capabilities.maxOutputTokens),
reason: "max-output-limit",
})
maxTokens = input.capabilities.maxOutputTokens
}
let thinking = input.desired.thinking
if (thinking !== undefined && input.capabilities?.supportsThinking === false) {
changes.push({
field: "thinking",
from: JSON.stringify(thinking),
to: undefined,
reason: "unsupported-by-model-metadata",
})
thinking = undefined
}
return {
variant,
reasoningEffort,
...(input.desired.temperature !== undefined ? { temperature } : {}),
...(input.desired.topP !== undefined ? { topP } : {}),
...(input.desired.maxTokens !== undefined ? { maxTokens } : {}),
...(input.desired.thinking !== undefined ? { thinking } : {}),
changes,
}
}
export type {
ModelSettingsCompatibilityInput,
ModelSettingsCompatibilityChange,
ModelSettingsCompatibilityResult,
} from "@oh-my-opencode/model-core"
export { resolveCompatibleModelSettings } from "@oh-my-opencode/model-core"
+1 -67
View File
@@ -1,67 +1 @@
const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"minimal",
"none",
"auto",
"thinking",
])
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
if (typeof rawModelID !== "string") {
return { modelID: "" }
}
const trimmedModelID = rawModelID.trim()
if (!trimmedModelID) {
return { modelID: "" }
}
const parenthesizedVariant = trimmedModelID.match(/^(.*)\(([^()]+)\)\s*$/)
if (parenthesizedVariant) {
const modelID = parenthesizedVariant[1]?.trim() ?? ""
const variant = parenthesizedVariant[2]?.trim()
return variant ? { modelID, variant } : { modelID }
}
const spaceVariant = trimmedModelID.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
if (spaceVariant) {
const modelID = spaceVariant[1]?.trim() ?? ""
const variant = spaceVariant[2]?.trim().toLowerCase()
if (variant && KNOWN_VARIANTS.has(variant)) {
return { modelID, variant }
}
}
return { modelID: trimmedModelID }
}
export function parseModelString(
model: string,
): { providerID: string; modelID: string; variant?: string } | undefined {
if (typeof model !== "string") return undefined
const trimmedModel = model.trim()
if (!trimmedModel) return undefined
const separatorIndex = trimmedModel.indexOf("/")
if (separatorIndex === -1) {
return undefined
}
const providerID = trimmedModel.slice(0, separatorIndex).trim()
const rawModelID = trimmedModel.slice(separatorIndex + 1).trim()
if (!providerID || !rawModelID) {
return undefined
}
const parsedModel = parseVariantFromModelID(rawModelID)
if (!parsedModel.modelID) {
return undefined
}
return parsedModel.variant
? { providerID, modelID: parsedModel.modelID, variant: parsedModel.variant }
: { providerID, modelID: parsedModel.modelID }
}
export { parseVariantFromModelID, parseModelString } from "@oh-my-opencode/model-core"