refactor(packages): extract model-core package
This commit is contained in:
@@ -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 +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" },
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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 +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"
|
||||
|
||||
@@ -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 +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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 +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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 +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"
|
||||
|
||||
Reference in New Issue
Block a user