refactor(packages): extract model-core package

This commit is contained in:
YeonGyu-Kim
2026-05-21 02:11:37 +09:00
parent f7ceb03efe
commit 2748009ff2
60 changed files with 2155 additions and 1834 deletions
+19
View File
@@ -0,0 +1,19 @@
Task 3 ProviderCache interface evidence
Defined interface:
- `packages/model-core/src/provider-cache.ts`
- `readConnectedProvidersCache(): string[] | null`
- `findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined`
Injection points:
- `packages/model-core/src/model-resolution-pipeline.ts`
- `resolveModelPipeline(request, providerCache)` uses `providerCache.readConnectedProvidersCache()`.
- `packages/model-core/src/model-error-classifier.ts`
- `selectFallbackProviderWithCache(providers, providerCache, preferredProviderID?)` uses `providerCache.readConnectedProvidersCache()`.
Adapter wiring in OMO:
- `src/shared/model-resolution-pipeline.ts` passes `connectedProvidersCache` into model-core resolver.
- `src/shared/model-error-classifier.ts` passes `connectedProvidersCache` into model-core provider selection.
+8
View File
@@ -0,0 +1,8 @@
Task 3 no-coupling evidence
- Extracted model resolution files into `packages/model-core/src` and replaced original `src/shared/*` with per-file shims.
- `packages/model-core/src/model-resolution-pipeline.ts` now accepts `providerCache: ProviderCache` and no longer imports `src/shared/connected-providers-cache` directly.
- `packages/model-core/src/model-error-classifier.ts` exposes `selectFallbackProviderWithCache(...)` and supports cache injection.
- OMO call-sites receive cache injection through shared adapters:
- `src/shared/model-resolution-pipeline.ts` injects `src/shared/connected-providers-cache` into model-core.
- `src/shared/model-error-classifier.ts` injects `src/shared/connected-providers-cache` into model-core.
+8
View File
@@ -0,0 +1,8 @@
Task 3 verification evidence
- `bun run typecheck` => exit 0
- `bun test` => `7312 pass / 1 skip / 2 fail / 7315 total` (matches baseline)
- `bun run build` => exit 0
Notes:
- The 2 failures are pre-existing baseline failures in `src/features/opencode-skill-loader/skill-content.test.ts`.
@@ -53,3 +53,13 @@
- `bun run typecheck` exit 0 - `bun run typecheck` exit 0
- `bun test` 7312/1/2/7315 (baseline-matching drift) - `bun test` 7312/1/2/7315 (baseline-matching drift)
- `bun run build` exit 0 - `bun run build` exit 0
## [2026-05-21T00:00:00Z] Task 3 retry (worktree)
- Extracted model resolution pipeline surface into `packages/model-core/` with moved sources/tests and package scaffold (`package.json`, `tsconfig.json`, barrel `src/index.ts`).
- Added ProviderCache DI seam in model-core:
- `model-resolution-pipeline.ts` accepts `providerCache`.
- `model-error-classifier.ts` exposes cache-injected provider selector.
- Kept OMO runtime cache implementation in `src/shared/connected-providers-cache.ts` and wired injections through shared shims.
- Recreated per-file `src/shared` shims with explicit symbol re-exports (no `export *` in shims).
- Moved `src/shared/model-capabilities/` subtree into model-core and kept shared adapter entry via `src/shared/model-capabilities/index.ts` wrapper.
- Verification pass: `bun run typecheck`=0, `bun test`=7312/1/2/7315 baseline, `bun run build`=0.
+10
View File
@@ -28,6 +28,7 @@
"@oh-my-opencode/ast-grep-mcp": "workspace:*", "@oh-my-opencode/ast-grep-mcp": "workspace:*",
"@oh-my-opencode/boulder-state": "workspace:*", "@oh-my-opencode/boulder-state": "workspace:*",
"@oh-my-opencode/comment-checker-core": "workspace:*", "@oh-my-opencode/comment-checker-core": "workspace:*",
"@oh-my-opencode/model-core": "workspace:*",
"@oh-my-opencode/rules-core": "workspace:*", "@oh-my-opencode/rules-core": "workspace:*",
"@oh-my-opencode/utils": "workspace:*", "@oh-my-opencode/utils": "workspace:*",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
@@ -83,6 +84,13 @@
"name": "@oh-my-opencode/comment-checker-core", "name": "@oh-my-opencode/comment-checker-core",
"version": "0.1.0", "version": "0.1.0",
}, },
"packages/model-core": {
"name": "@oh-my-opencode/model-core",
"version": "0.1.0",
"dependencies": {
"@oh-my-opencode/utils": "workspace:*",
},
},
"packages/rules-core": { "packages/rules-core": {
"name": "@oh-my-opencode/rules-core", "name": "@oh-my-opencode/rules-core",
"version": "0.1.0", "version": "0.1.0",
@@ -178,6 +186,8 @@
"@oh-my-opencode/comment-checker-core": ["@oh-my-opencode/comment-checker-core@workspace:packages/comment-checker-core"], "@oh-my-opencode/comment-checker-core": ["@oh-my-opencode/comment-checker-core@workspace:packages/comment-checker-core"],
"@oh-my-opencode/model-core": ["@oh-my-opencode/model-core@workspace:packages/model-core"],
"@oh-my-opencode/rules-core": ["@oh-my-opencode/rules-core@workspace:packages/rules-core"], "@oh-my-opencode/rules-core": ["@oh-my-opencode/rules-core@workspace:packages/rules-core"],
"@oh-my-opencode/utils": ["@oh-my-opencode/utils@workspace:packages/utils"], "@oh-my-opencode/utils": ["@oh-my-opencode/utils@workspace:packages/utils"],
+3 -1
View File
@@ -10,6 +10,7 @@
"packages/ast-grep-core", "packages/ast-grep-core",
"packages/ast-grep-mcp", "packages/ast-grep-mcp",
"packages/utils", "packages/utils",
"packages/model-core",
"packages/comment-checker-core", "packages/comment-checker-core",
"packages/boulder-state" "packages/boulder-state"
], ],
@@ -46,7 +47,7 @@
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build", "prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail", "test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
"typecheck": "tsgo --noEmit && bun run typecheck:packages", "typecheck": "tsgo --noEmit && bun run typecheck:packages",
"typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json", "typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json",
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json", "typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
"test": "bun test", "test": "bun test",
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build" "build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
@@ -94,6 +95,7 @@
"@oh-my-opencode/ast-grep-mcp": "workspace:*", "@oh-my-opencode/ast-grep-mcp": "workspace:*",
"@oh-my-opencode/boulder-state": "workspace:*", "@oh-my-opencode/boulder-state": "workspace:*",
"@oh-my-opencode/comment-checker-core": "workspace:*", "@oh-my-opencode/comment-checker-core": "workspace:*",
"@oh-my-opencode/model-core": "workspace:*",
"@oh-my-opencode/rules-core": "workspace:*", "@oh-my-opencode/rules-core": "workspace:*",
"@oh-my-opencode/utils": "workspace:*", "@oh-my-opencode/utils": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260518.1", "@typescript/native-preview": "7.0.0-dev.20260518.1",
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@oh-my-opencode/model-core",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Pure TypeScript model resolution core logic shared across harness adapters.",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./src/index.ts"
}
},
"types": "./index.d.ts",
"scripts": {
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "bun test src/*.test.ts"
},
"dependencies": {
"@oh-my-opencode/utils": "workspace:*"
}
}
@@ -0,0 +1,5 @@
export {
findProviderModelMetadata,
readConnectedProvidersCache,
readProviderModelsCache,
} from "../../../src/shared/connected-providers-cache"
@@ -0,0 +1,128 @@
import type { FallbackEntry } from "./model-requirements"
import type { FallbackModelObject } from "./fallback-model-object"
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
}
@@ -0,0 +1,9 @@
export type FallbackModelObject = {
readonly model: string
readonly variant?: string
readonly reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
readonly temperature?: number
readonly top_p?: number
readonly maxTokens?: number
readonly thinking?: { readonly type: "enabled" | "disabled"; readonly budgetTokens?: number }
}
+39
View File
@@ -0,0 +1,39 @@
export * from "./model-requirements"
export * from "./model-capability-aliases"
export * from "./model-capability-heuristics"
export * from "./model-capability-guardrails"
export * from "./model-settings-compatibility"
export type {
DelegatedModelConfig,
ModelResolutionRequest,
ModelResolutionProvenance,
ModelResolutionResult,
} from "./model-resolution-types"
export type {
ModelResolutionInput,
ModelSource,
ExtendedModelResolutionInput,
} from "./model-resolver"
export {
resolveModel,
resolveModelWithFallback,
normalizeFallbackModels,
flattenToFallbackModelStrings,
} from "./model-resolver"
export * from "./model-format-normalizer"
export * from "./model-normalization"
export * from "./model-string-parser"
export * from "./model-sanitizer"
export * from "./fallback-chain-from-models"
export * from "./known-variants"
export {
_setModelResolutionLogImplementationForTesting,
resolveModelPipeline,
} from "./model-resolution-pipeline"
export type {
ModelResolutionRequest as PipelineModelResolutionRequest,
ModelResolutionProvenance as PipelineModelResolutionProvenance,
ModelResolutionResult as PipelineModelResolutionResult,
} from "./model-resolution-pipeline"
export * from "./model-error-classifier"
export * from "./model-capabilities"
+16
View File
@@ -0,0 +1,16 @@
/**
* 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",
])
@@ -1,4 +1,4 @@
import bundledModelCapabilitiesSnapshotJson from "../../generated/model-capabilities.generated.json" import bundledModelCapabilitiesSnapshotJson from "../../../../src/generated/model-capabilities.generated.json"
import { SUPPLEMENTAL_MODEL_CAPABILITIES } from "./supplemental-entries" import { SUPPLEMENTAL_MODEL_CAPABILITIES } from "./supplemental-entries"
import type { ModelCapabilitiesSnapshot } from "./types" import type { ModelCapabilitiesSnapshot } from "./types"
@@ -1,6 +1,6 @@
import { findProviderModelMetadata } from "../connected-providers-cache"
import { resolveModelIDAlias } from "../model-capability-aliases" import { resolveModelIDAlias } from "../model-capability-aliases"
import { detectHeuristicModelFamily } from "../model-capability-heuristics" import { detectHeuristicModelFamily } from "../model-capability-heuristics"
import type { ProviderCache } from "../provider-cache"
import { getBundledModelCapabilitiesSnapshot } from "./bundled-snapshot" import { getBundledModelCapabilitiesSnapshot } from "./bundled-snapshot"
import { import {
@@ -35,7 +35,7 @@ export function getModelCapabilities(input: GetModelCapabilitiesInput): ModelCap
const canonicalization = resolveModelIDAlias(input.modelID) const canonicalization = resolveModelIDAlias(input.modelID)
const override = getOverride(input.modelID) const override = getOverride(input.modelID)
const runtimeModel = readRuntimeModel( const runtimeModel = readRuntimeModel(
input.runtimeModel ?? findProviderModelMetadata(input.providerID, input.modelID), input.runtimeModel ?? input.providerCache?.findProviderModelMetadata(input.providerID, input.modelID),
) )
const runtimeSnapshot = input.runtimeSnapshot const runtimeSnapshot = input.runtimeSnapshot
const bundledSnapshot = input.bundledSnapshot ?? getBundledModelCapabilitiesSnapshot() const bundledSnapshot = input.bundledSnapshot ?? getBundledModelCapabilitiesSnapshot()
@@ -0,0 +1,9 @@
export { getBundledModelCapabilitiesSnapshot } from "./bundled-snapshot"
export { getModelCapabilities } from "./get-model-capabilities"
export type {
GetModelCapabilitiesInput,
ModelCapabilities,
ModelCapabilitiesDiagnostics,
ModelCapabilitiesSnapshot,
ModelCapabilitiesSnapshotEntry,
} from "./types"
@@ -1,4 +1,4 @@
import type { ModelMetadata } from "../connected-providers-cache" import type { ModelMetadata } from "../provider-cache"
import type { ModelCapabilities } from "./types" import type { ModelCapabilities } from "./types"
@@ -1,4 +1,4 @@
import type { ModelMetadata } from "../connected-providers-cache" import type { ModelMetadata, ProviderCache } from "../provider-cache"
export type ModelCapabilitiesSnapshotEntry = { export type ModelCapabilitiesSnapshotEntry = {
id: string id: string
@@ -69,6 +69,7 @@ export type GetModelCapabilitiesInput = {
runtimeModel?: ModelMetadata | Record<string, unknown> runtimeModel?: ModelMetadata | Record<string, unknown>
runtimeSnapshot?: ModelCapabilitiesSnapshot runtimeSnapshot?: ModelCapabilitiesSnapshot
bundledSnapshot?: ModelCapabilitiesSnapshot bundledSnapshot?: ModelCapabilitiesSnapshot
providerCache?: ProviderCache
} }
export type ModelCapabilityOverride = { export type ModelCapabilityOverride = {
@@ -0,0 +1,120 @@
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
}
@@ -0,0 +1,149 @@
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
}
@@ -0,0 +1,115 @@
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
}
@@ -0,0 +1,258 @@
import type { FallbackEntry } from "./model-requirements"
import type { ProviderCache } from "./provider-cache"
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"
]
/**
* 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 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 {
return selectFallbackProviderWithCache(
providers,
connectedProvidersCache,
preferredProviderID,
)
}
export function selectFallbackProviderWithCache(
providers: string[],
providerCache: ProviderCache,
preferredProviderID?: string,
): string {
const connectedProviders = providerCache.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"
}
@@ -0,0 +1,20 @@
export function normalizeModelFormat(
model: string | { providerID: string; modelID: string } | null | undefined
): { 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
}
@@ -0,0 +1,8 @@
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")
}
@@ -0,0 +1,349 @@
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" },
],
},
};
@@ -0,0 +1,241 @@
import { fuzzyMatchModel } from "../../../src/shared/model-availability"
import type { FallbackEntry } from "./model-requirements"
import { transformModelForProvider } from "../../../src/shared/provider-model-id-transform"
import { normalizeModel } from "./model-normalization"
import type { ProviderCache } from "./provider-cache"
type LogImplementation = (message: string, data?: unknown) => void
let logImplementationForTesting: LogImplementation | undefined
function log(message: string, data?: unknown): void {
const logImplementation = logImplementationForTesting
if (!logImplementation) {
return
}
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 function resolveModelPipeline(
request: ModelResolutionRequest,
providerCache: ProviderCache = {
readConnectedProvidersCache: () => null,
findProviderModelMetadata: () => undefined,
},
): 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 ?? providerCache.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 ?? providerCache.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 ?? providerCache.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 }
}
@@ -0,0 +1,41 @@
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
}
+107
View File
@@ -0,0 +1,107 @@
import type { FallbackEntry } from "./model-requirements"
import type { FallbackModelObject } from "./fallback-model-object"
import { normalizeModel } from "./model-normalization"
import { resolveModelPipeline } from "./model-resolution-pipeline"
import { KNOWN_VARIANTS } from "./known-variants"
import * as connectedProvidersCache from "./connected-providers-cache"
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 },
}, connectedProvidersCache)
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
})
}
@@ -0,0 +1,12 @@
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
}
@@ -0,0 +1,217 @@
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,
}
}
@@ -0,0 +1,67 @@
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 }
}
+27
View File
@@ -0,0 +1,27 @@
export interface ModelMetadata {
readonly id: string
readonly provider?: string
readonly context?: number
readonly output?: number
readonly name?: string
readonly variants?: Record<string, unknown>
readonly limit?: {
readonly context?: number
readonly input?: number
readonly output?: number
}
readonly modalities?: {
readonly input?: string[]
readonly output?: string[]
}
readonly capabilities?: Record<string, unknown>
readonly reasoning?: boolean
readonly temperature?: boolean
readonly tool_call?: boolean
readonly [key: string]: unknown
}
export interface ProviderCache {
readConnectedProvidersCache(): string[] | null
findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ESNext"],
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
+6 -128
View File
@@ -1,128 +1,6 @@
import type { FallbackEntry } from "./model-requirements" export {
import type { FallbackModelObject } from "../config/schema/fallback-models" parseFallbackModelEntry,
import { normalizeFallbackModels } from "./model-resolver" parseFallbackModelObjectEntry,
import { KNOWN_VARIANTS } from "./known-variants" findMostSpecificFallbackEntry,
buildFallbackChainFromModels,
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } { } from "@oh-my-opencode/model-core"
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
}
+1 -16
View File
@@ -1,16 +1 @@
/** export { KNOWN_VARIANTS } from "@oh-my-opencode/model-core"
* 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",
])
+21 -8
View File
@@ -1,9 +1,22 @@
export { getBundledModelCapabilitiesSnapshot } from "./bundled-snapshot" import {
export { getModelCapabilities } from "./get-model-capabilities" 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 { export type {
GetModelCapabilitiesInput, GetModelCapabilitiesInput,
ModelCapabilities, ModelCapabilities,
ModelCapabilitiesDiagnostics, ModelCapabilitiesDiagnostics,
ModelCapabilitiesSnapshot, ModelCapabilitiesSnapshot,
ModelCapabilitiesSnapshotEntry, ModelCapabilitiesSnapshotEntry,
} from "./types" } from "@oh-my-opencode/model-core"
+10 -120
View File
@@ -1,120 +1,10 @@
export type ExactAliasRule = { export type {
aliasModelID: string ExactAliasRule,
ruleID: string PatternAliasRule,
canonicalModelID: string ModelIDAliasResolution,
rationale: string } from "@oh-my-opencode/model-core"
} export {
resolveModelIDAlias,
export type PatternAliasRule = { getExactModelIDAliasRules,
ruleID: string getPatternModelIDAliasRules,
description: string } from "@oh-my-opencode/model-core"
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
}
+5 -149
View File
@@ -1,149 +1,5 @@
import type { ModelCapabilitiesSnapshot } from "./model-capabilities" export type { ModelCapabilityGuardrailIssue } from "@oh-my-opencode/model-core"
import { getBundledModelCapabilitiesSnapshot } from "./model-capabilities" export {
import { getBuiltInRequirementModelIDs,
getExactModelIDAliasRules, collectModelCapabilityGuardrailIssues,
getPatternModelIDAliasRules, } from "@oh-my-opencode/model-core"
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
}
+5 -115
View File
@@ -1,115 +1,5 @@
import { normalizeModelID } from "./model-normalization" export type { HeuristicModelFamilyDefinition } from "@oh-my-opencode/model-core"
export {
export type HeuristicModelFamilyDefinition = { HEURISTIC_MODEL_FAMILY_REGISTRY,
family: string detectHeuristicModelFamily,
includes?: string[] } from "@oh-my-opencode/model-core"
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
}
+21 -242
View File
@@ -1,250 +1,29 @@
import type { FallbackEntry } from "./model-requirements" import {
import { readConnectedProvidersCache } from "./connected-providers-cache" 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"
/** export type { ErrorInfo }
* Error names that indicate a retryable model error. export {
* These errors halt execution and should trigger fallback retry. isRetryableModelError,
*/ shouldRetryError,
const RETRYABLE_ERROR_NAMES = new Set([ getNextFallback,
"providermodelnotfounderror", hasMoreFallbacks,
"ratelimiterror", selectFallbackProviderWithCache,
"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 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( export function selectFallbackProvider(
providers: string[], providers: string[],
preferredProviderID?: string, preferredProviderID?: string,
): string { ): string {
const connectedProviders = readConnectedProvidersCache() return selectFallbackProviderWithCache(
if (connectedProviders) { providers,
const connectedSet = new Set(connectedProviders.map(p => p.toLowerCase())) connectedProvidersCache,
preferredProviderID,
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"
} }
+1 -20
View File
@@ -1,20 +1 @@
export function normalizeModelFormat( export { normalizeModelFormat } from "@oh-my-opencode/model-core"
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
}
+1 -8
View File
@@ -1,8 +1 @@
export function normalizeModel(model?: string): string | undefined { export { normalizeModel, normalizeModelID } from "@oh-my-opencode/model-core"
const trimmed = model?.trim()
return trimmed || undefined
}
export function normalizeModelID(modelID: string): string {
return modelID.replace(/\.(\d+)/g, "-$1")
}
+5 -349
View File
@@ -1,349 +1,5 @@
export type FallbackEntry = { export type { FallbackEntry, ModelRequirement } from "@oh-my-opencode/model-core"
providers: string[]; export {
model: string; AGENT_MODEL_REQUIREMENTS,
variant?: string; // Entry-specific variant (e.g., GPT→high, Opus→max) CATEGORY_MODEL_REQUIREMENTS,
reasoningEffort?: string; } from "@oh-my-opencode/model-core"
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" },
],
},
};
+17 -230
View File
@@ -1,235 +1,22 @@
import { log as writeLog } from "./logger" import {
_setModelResolutionLogImplementationForTesting,
resolveModelPipeline as resolveModelPipelineFromCore,
} from "@oh-my-opencode/model-core"
import type {
PipelineModelResolutionRequest,
PipelineModelResolutionResult,
} from "@oh-my-opencode/model-core"
import * as connectedProvidersCache from "./connected-providers-cache" import * 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( export function resolveModelPipeline(
request: ModelResolutionRequest, request: PipelineModelResolutionRequest,
): ModelResolutionResult | undefined { ): PipelineModelResolutionResult | undefined {
const attempted: string[] = [] return resolveModelPipelineFromCore(request, connectedProvidersCache)
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 }
} }
export type {
PipelineModelResolutionRequest as ModelResolutionRequest,
PipelineModelResolutionProvenance as ModelResolutionProvenance,
PipelineModelResolutionResult as ModelResolutionResult,
} from "@oh-my-opencode/model-core"
+6 -41
View File
@@ -1,41 +1,6 @@
import type { FallbackEntry } from "./model-requirements" export type {
DelegatedModelConfig,
export interface DelegatedModelConfig { ModelResolutionRequest,
providerID: string ModelResolutionProvenance,
modelID: string ModelResolutionResult,
variant?: string } from "@oh-my-opencode/model-core"
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
}
+12 -106
View File
@@ -1,106 +1,12 @@
import type { FallbackEntry } from "./model-requirements" export type {
import type { FallbackModelObject } from "../config/schema/fallback-models" ModelResolutionInput,
import { normalizeModel } from "./model-normalization" ModelSource,
import { resolveModelPipeline } from "./model-resolution-pipeline" ModelResolutionResult,
import { KNOWN_VARIANTS } from "./known-variants" ExtendedModelResolutionInput,
} from "@oh-my-opencode/model-core"
export type ModelResolutionInput = { export {
userModel?: string resolveModel,
inheritedModel?: string resolveModelWithFallback,
systemDefault?: string normalizeFallbackModels,
} flattenToFallbackModelStrings,
} from "@oh-my-opencode/model-core"
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
})
}
+1 -12
View File
@@ -1,12 +1 @@
type CommandSource = "claude-code" | "opencode" export { sanitizeModelField } from "@oh-my-opencode/model-core"
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
}
+6 -217
View File
@@ -1,217 +1,6 @@
import { detectHeuristicModelFamily } from "./model-capability-heuristics" export type {
ModelSettingsCompatibilityInput,
type CompatibilityField = "variant" | "reasoningEffort" | "temperature" | "topP" | "maxTokens" | "thinking" ModelSettingsCompatibilityChange,
ModelSettingsCompatibilityResult,
type DesiredModelSettings = { } from "@oh-my-opencode/model-core"
variant?: string export { resolveCompatibleModelSettings } from "@oh-my-opencode/model-core"
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,
}
}
+1 -67
View File
@@ -1,67 +1 @@
const KNOWN_VARIANTS = new Set([ export { parseVariantFromModelID, parseModelString } from "@oh-my-opencode/model-core"
"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 }
}