refactor(packages): extract model-core package
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 test` 7312/1/2/7315 (baseline-matching drift)
|
||||
- `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.
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
|
||||
"@oh-my-opencode/boulder-state": "workspace:*",
|
||||
"@oh-my-opencode/comment-checker-core": "workspace:*",
|
||||
"@oh-my-opencode/model-core": "workspace:*",
|
||||
"@oh-my-opencode/rules-core": "workspace:*",
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
@@ -83,6 +84,13 @@
|
||||
"name": "@oh-my-opencode/comment-checker-core",
|
||||
"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": {
|
||||
"name": "@oh-my-opencode/rules-core",
|
||||
"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/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/utils": ["@oh-my-opencode/utils@workspace:packages/utils"],
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@
|
||||
"packages/ast-grep-core",
|
||||
"packages/ast-grep-mcp",
|
||||
"packages/utils",
|
||||
"packages/model-core",
|
||||
"packages/comment-checker-core",
|
||||
"packages/boulder-state"
|
||||
],
|
||||
@@ -46,7 +47,7 @@
|
||||
"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",
|
||||
"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",
|
||||
"test": "bun test",
|
||||
"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/boulder-state": "workspace:*",
|
||||
"@oh-my-opencode/comment-checker-core": "workspace:*",
|
||||
"@oh-my-opencode/model-core": "workspace:*",
|
||||
"@oh-my-opencode/rules-core": "workspace:*",
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260518.1",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
-1
@@ -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 type { ModelCapabilitiesSnapshot } from "./types"
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { findProviderModelMetadata } from "../connected-providers-cache"
|
||||
import { resolveModelIDAlias } from "../model-capability-aliases"
|
||||
import { detectHeuristicModelFamily } from "../model-capability-heuristics"
|
||||
import type { ProviderCache } from "../provider-cache"
|
||||
|
||||
import { getBundledModelCapabilitiesSnapshot } from "./bundled-snapshot"
|
||||
import {
|
||||
@@ -35,7 +35,7 @@ export function getModelCapabilities(input: GetModelCapabilitiesInput): ModelCap
|
||||
const canonicalization = resolveModelIDAlias(input.modelID)
|
||||
const override = getOverride(input.modelID)
|
||||
const runtimeModel = readRuntimeModel(
|
||||
input.runtimeModel ?? findProviderModelMetadata(input.providerID, input.modelID),
|
||||
input.runtimeModel ?? input.providerCache?.findProviderModelMetadata(input.providerID, input.modelID),
|
||||
)
|
||||
const runtimeSnapshot = input.runtimeSnapshot
|
||||
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
-1
@@ -1,4 +1,4 @@
|
||||
import type { ModelMetadata } from "../connected-providers-cache"
|
||||
import type { ModelMetadata } from "../provider-cache"
|
||||
|
||||
import type { ModelCapabilities } from "./types"
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
import type { ModelMetadata } from "../connected-providers-cache"
|
||||
import type { ModelMetadata, ProviderCache } from "../provider-cache"
|
||||
|
||||
export type ModelCapabilitiesSnapshotEntry = {
|
||||
id: string
|
||||
@@ -69,6 +69,7 @@ export type GetModelCapabilitiesInput = {
|
||||
runtimeModel?: ModelMetadata | Record<string, unknown>
|
||||
runtimeSnapshot?: ModelCapabilitiesSnapshot
|
||||
bundledSnapshot?: ModelCapabilitiesSnapshot
|
||||
providerCache?: ProviderCache
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -1,128 +1,6 @@
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
import type { FallbackModelObject } from "../config/schema/fallback-models"
|
||||
import { normalizeFallbackModels } from "./model-resolver"
|
||||
import { KNOWN_VARIANTS } from "./known-variants"
|
||||
|
||||
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
|
||||
if (typeof rawModel !== "string") {
|
||||
return { modelID: "" }
|
||||
}
|
||||
const trimmedModel = rawModel.trim()
|
||||
if (!trimmedModel) {
|
||||
return { modelID: "" }
|
||||
}
|
||||
|
||||
const parenthesizedVariant = trimmedModel.match(/^(.*)\(([^()]+)\)\s*$/)
|
||||
if (parenthesizedVariant) {
|
||||
const modelID = parenthesizedVariant[1]?.trim() ?? ""
|
||||
const variant = parenthesizedVariant[2]?.trim()
|
||||
return variant ? { modelID, variant } : { modelID }
|
||||
}
|
||||
|
||||
const spaceVariant = trimmedModel.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
|
||||
if (spaceVariant) {
|
||||
const modelID = spaceVariant[1]?.trim() ?? ""
|
||||
const variant = spaceVariant[2]?.trim().toLowerCase()
|
||||
if (variant && KNOWN_VARIANTS.has(variant)) {
|
||||
return { modelID, variant }
|
||||
}
|
||||
}
|
||||
|
||||
return { modelID: trimmedModel }
|
||||
}
|
||||
|
||||
export function parseFallbackModelEntry(
|
||||
model: string,
|
||||
contextProviderID: string | undefined,
|
||||
defaultProviderID = "opencode",
|
||||
): FallbackEntry | undefined {
|
||||
if (typeof model !== "string") return undefined
|
||||
const trimmed = model.trim()
|
||||
if (!trimmed) return undefined
|
||||
|
||||
const parts = trimmed.split("/")
|
||||
const providerID =
|
||||
parts.length >= 2 ? parts[0].trim() : (contextProviderID?.trim() || defaultProviderID)
|
||||
const rawModelID = parts.length >= 2 ? parts.slice(1).join("/").trim() : trimmed
|
||||
if (!providerID || !rawModelID) return undefined
|
||||
|
||||
const parsed = parseVariantFromModel(rawModelID)
|
||||
if (!parsed.modelID) return undefined
|
||||
|
||||
return {
|
||||
providers: [providerID],
|
||||
model: parsed.modelID,
|
||||
variant: parsed.variant,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFallbackModelObjectEntry(
|
||||
obj: FallbackModelObject,
|
||||
contextProviderID: string | undefined,
|
||||
defaultProviderID = "opencode",
|
||||
): FallbackEntry | undefined {
|
||||
const base = parseFallbackModelEntry(obj.model, contextProviderID, defaultProviderID)
|
||||
if (!base) return undefined
|
||||
|
||||
return {
|
||||
...base,
|
||||
variant: obj.variant ?? base.variant,
|
||||
reasoningEffort: obj.reasoningEffort,
|
||||
temperature: obj.temperature,
|
||||
top_p: obj.top_p,
|
||||
maxTokens: obj.maxTokens,
|
||||
thinking: obj.thinking,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most specific FallbackEntry whose `provider/model` is a prefix of
|
||||
* the resolved `provider/modelID`. Longest match wins so that e.g.
|
||||
* `openai/gpt-5.4-preview` picks the entry for `openai/gpt-5.4-preview` over
|
||||
* the shorter `openai/gpt-5.4`.
|
||||
*/
|
||||
export function findMostSpecificFallbackEntry(
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
chain: FallbackEntry[],
|
||||
): FallbackEntry | undefined {
|
||||
const resolved = `${providerID}/${modelID}`.toLowerCase()
|
||||
|
||||
// Collect entries whose provider/model is a prefix of the resolved model,
|
||||
// together with the length of the matching prefix (longest match wins).
|
||||
const matches: { entry: FallbackEntry; matchLen: number }[] = []
|
||||
for (const entry of chain) {
|
||||
for (const p of entry.providers) {
|
||||
const candidate = `${p}/${entry.model}`.toLowerCase()
|
||||
if (resolved.startsWith(candidate)) {
|
||||
matches.push({ entry, matchLen: candidate.length })
|
||||
break // one match per entry is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return undefined
|
||||
matches.sort((a, b) => b.matchLen - a.matchLen)
|
||||
return matches[0].entry
|
||||
}
|
||||
|
||||
export function buildFallbackChainFromModels(
|
||||
fallbackModels: string | (string | FallbackModelObject)[] | undefined,
|
||||
contextProviderID: string | undefined,
|
||||
defaultProviderID = "opencode",
|
||||
): FallbackEntry[] | undefined {
|
||||
const normalized = normalizeFallbackModels(fallbackModels)
|
||||
if (!normalized || normalized.length === 0) return undefined
|
||||
|
||||
const parsed = normalized
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
return parseFallbackModelEntry(entry, contextProviderID, defaultProviderID)
|
||||
}
|
||||
return parseFallbackModelObjectEntry(entry, contextProviderID, defaultProviderID)
|
||||
})
|
||||
.filter((entry): entry is FallbackEntry => entry !== undefined)
|
||||
|
||||
if (parsed.length === 0) return undefined
|
||||
return parsed
|
||||
}
|
||||
export {
|
||||
parseFallbackModelEntry,
|
||||
parseFallbackModelObjectEntry,
|
||||
findMostSpecificFallbackEntry,
|
||||
buildFallbackChainFromModels,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,16 +1 @@
|
||||
/**
|
||||
* Canonical set of recognised variant / effort tokens.
|
||||
* Used by parseFallbackModelEntry (space-suffix detection) and
|
||||
* flattenToFallbackModelStrings (inline-variant stripping).
|
||||
*/
|
||||
export const KNOWN_VARIANTS = new Set([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"minimal",
|
||||
"none",
|
||||
"auto",
|
||||
"thinking",
|
||||
])
|
||||
export { KNOWN_VARIANTS } from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
export { getBundledModelCapabilitiesSnapshot } from "./bundled-snapshot"
|
||||
export { getModelCapabilities } from "./get-model-capabilities"
|
||||
import {
|
||||
getBundledModelCapabilitiesSnapshot,
|
||||
getModelCapabilities as getModelCapabilitiesFromCore,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
import type { GetModelCapabilitiesInput, ModelCapabilities } from "@oh-my-opencode/model-core"
|
||||
import * as connectedProvidersCache from "../connected-providers-cache"
|
||||
|
||||
export { getBundledModelCapabilitiesSnapshot }
|
||||
|
||||
export function getModelCapabilities(input: GetModelCapabilitiesInput): ModelCapabilities {
|
||||
return getModelCapabilitiesFromCore({
|
||||
...input,
|
||||
providerCache: input.providerCache ?? connectedProvidersCache,
|
||||
})
|
||||
}
|
||||
export type {
|
||||
GetModelCapabilitiesInput,
|
||||
ModelCapabilities,
|
||||
ModelCapabilitiesDiagnostics,
|
||||
ModelCapabilitiesSnapshot,
|
||||
ModelCapabilitiesSnapshotEntry,
|
||||
} from "./types"
|
||||
GetModelCapabilitiesInput,
|
||||
ModelCapabilities,
|
||||
ModelCapabilitiesDiagnostics,
|
||||
ModelCapabilitiesSnapshot,
|
||||
ModelCapabilitiesSnapshotEntry,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,120 +1,10 @@
|
||||
export type ExactAliasRule = {
|
||||
aliasModelID: string
|
||||
ruleID: string
|
||||
canonicalModelID: string
|
||||
rationale: string
|
||||
}
|
||||
|
||||
export type PatternAliasRule = {
|
||||
ruleID: string
|
||||
description: string
|
||||
match: (normalizedModelID: string) => boolean
|
||||
canonicalize: (normalizedModelID: string) => string
|
||||
}
|
||||
|
||||
export type ModelIDAliasResolution = {
|
||||
requestedModelID: string
|
||||
canonicalModelID: string
|
||||
source: "canonical" | "exact-alias" | "pattern-alias"
|
||||
ruleID?: string
|
||||
}
|
||||
|
||||
const EXACT_ALIAS_RULES: ReadonlyArray<ExactAliasRule> = [
|
||||
{
|
||||
aliasModelID: "gemini-3-pro-high",
|
||||
ruleID: "gemini-3-pro-tier-alias",
|
||||
canonicalModelID: "gemini-3-pro-preview",
|
||||
rationale: "Legacy Gemini 3 tier suffixes still need to land on the canonical preview model.",
|
||||
},
|
||||
{
|
||||
aliasModelID: "gemini-3-pro-low",
|
||||
ruleID: "gemini-3-pro-tier-alias",
|
||||
canonicalModelID: "gemini-3-pro-preview",
|
||||
rationale: "Legacy Gemini 3 tier suffixes still need to land on the canonical preview model.",
|
||||
},
|
||||
{
|
||||
aliasModelID: "k2pb",
|
||||
ruleID: "kimi-k2pb-alias",
|
||||
canonicalModelID: "k2p5",
|
||||
rationale: "Kimi for Coding exposes k2pb while the bundled capabilities snapshot uses the canonical k2p5 ID.",
|
||||
},
|
||||
{
|
||||
aliasModelID: "claude-opus-4.7",
|
||||
ruleID: "claude-opus-dotted-version-alias",
|
||||
canonicalModelID: "claude-opus-4-7",
|
||||
rationale: "GitHub Copilot exposes Claude Opus 4.7 with dotted version syntax while the snapshot uses dashed syntax.",
|
||||
},
|
||||
]
|
||||
|
||||
const EXACT_ALIAS_RULES_BY_MODEL: ReadonlyMap<string, ExactAliasRule> = new Map(
|
||||
EXACT_ALIAS_RULES.map((rule) => [rule.aliasModelID, rule]),
|
||||
)
|
||||
|
||||
const PATTERN_ALIAS_RULES: ReadonlyArray<PatternAliasRule> = [
|
||||
{
|
||||
ruleID: "claude-thinking-legacy-alias",
|
||||
description: "Normalizes the legacy claude-opus-4-7-thinking id to the canonical snapshot ID.",
|
||||
match: (normalizedModelID) => /^claude-opus-4-7-thinking$/.test(normalizedModelID),
|
||||
canonicalize: () => "claude-opus-4-7",
|
||||
},
|
||||
{
|
||||
ruleID: "gemini-3.1-pro-tier-alias",
|
||||
description: "Normalizes Gemini 3.1 Pro tier suffixes to the canonical snapshot ID.",
|
||||
match: (normalizedModelID) => /^gemini-3\.1-pro-(?:high|low)$/.test(normalizedModelID),
|
||||
canonicalize: () => "gemini-3.1-pro",
|
||||
},
|
||||
]
|
||||
|
||||
function normalizeLookupModelID(modelID: string): string {
|
||||
return modelID.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function stripProviderPrefixForAliasLookup(normalizedModelID: string): string {
|
||||
const slashIndex = normalizedModelID.indexOf("/")
|
||||
if (slashIndex <= 0 || slashIndex === normalizedModelID.length - 1) {
|
||||
return normalizedModelID
|
||||
}
|
||||
|
||||
return normalizedModelID.slice(slashIndex + 1)
|
||||
}
|
||||
|
||||
export function resolveModelIDAlias(modelID: string): ModelIDAliasResolution {
|
||||
const requestedModelID = normalizeLookupModelID(modelID)
|
||||
const aliasLookupModelID = stripProviderPrefixForAliasLookup(requestedModelID)
|
||||
const exactRule = EXACT_ALIAS_RULES_BY_MODEL.get(aliasLookupModelID)
|
||||
if (exactRule) {
|
||||
return {
|
||||
requestedModelID,
|
||||
canonicalModelID: exactRule.canonicalModelID,
|
||||
source: "exact-alias",
|
||||
ruleID: exactRule.ruleID,
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of PATTERN_ALIAS_RULES) {
|
||||
if (!rule.match(aliasLookupModelID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
return {
|
||||
requestedModelID,
|
||||
canonicalModelID: rule.canonicalize(aliasLookupModelID),
|
||||
source: "pattern-alias",
|
||||
ruleID: rule.ruleID,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestedModelID,
|
||||
canonicalModelID: aliasLookupModelID,
|
||||
source: "canonical",
|
||||
}
|
||||
}
|
||||
|
||||
export function getExactModelIDAliasRules(): ReadonlyArray<ExactAliasRule> {
|
||||
return EXACT_ALIAS_RULES
|
||||
}
|
||||
|
||||
export function getPatternModelIDAliasRules(): ReadonlyArray<PatternAliasRule> {
|
||||
return PATTERN_ALIAS_RULES
|
||||
}
|
||||
export type {
|
||||
ExactAliasRule,
|
||||
PatternAliasRule,
|
||||
ModelIDAliasResolution,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
export {
|
||||
resolveModelIDAlias,
|
||||
getExactModelIDAliasRules,
|
||||
getPatternModelIDAliasRules,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,149 +1,5 @@
|
||||
import type { ModelCapabilitiesSnapshot } from "./model-capabilities"
|
||||
import { getBundledModelCapabilitiesSnapshot } from "./model-capabilities"
|
||||
import {
|
||||
getExactModelIDAliasRules,
|
||||
getPatternModelIDAliasRules,
|
||||
resolveModelIDAlias,
|
||||
} from "./model-capability-aliases"
|
||||
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "./model-requirements"
|
||||
|
||||
export type ModelCapabilityGuardrailIssue =
|
||||
| {
|
||||
kind: "alias-target-missing-from-snapshot"
|
||||
ruleID: string
|
||||
aliasModelID: string
|
||||
canonicalModelID: string
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
kind: "exact-alias-collides-with-snapshot"
|
||||
ruleID: string
|
||||
aliasModelID: string
|
||||
canonicalModelID: string
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
kind: "pattern-alias-collides-with-snapshot"
|
||||
ruleID: string
|
||||
modelID: string
|
||||
canonicalModelID: string
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
kind: "built-in-model-relies-on-alias"
|
||||
modelID: string
|
||||
canonicalModelID: string
|
||||
ruleID: string
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
kind: "built-in-model-missing-from-snapshot"
|
||||
modelID: string
|
||||
canonicalModelID: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type CollectModelCapabilityGuardrailIssuesInput = {
|
||||
snapshot?: ModelCapabilitiesSnapshot
|
||||
requirementModelIDs?: Iterable<string>
|
||||
}
|
||||
|
||||
function normalizeLookupModelID(modelID: string): string {
|
||||
return modelID.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function getBuiltInRequirementModelIDs(): string[] {
|
||||
const modelIDs = new Set<string>()
|
||||
|
||||
for (const requirement of Object.values(AGENT_MODEL_REQUIREMENTS)) {
|
||||
for (const entry of requirement.fallbackChain) {
|
||||
modelIDs.add(entry.model)
|
||||
}
|
||||
}
|
||||
|
||||
for (const requirement of Object.values(CATEGORY_MODEL_REQUIREMENTS)) {
|
||||
for (const entry of requirement.fallbackChain) {
|
||||
modelIDs.add(entry.model)
|
||||
}
|
||||
}
|
||||
|
||||
return [...modelIDs].sort()
|
||||
}
|
||||
|
||||
export function collectModelCapabilityGuardrailIssues(
|
||||
input: CollectModelCapabilityGuardrailIssuesInput = {},
|
||||
): ModelCapabilityGuardrailIssue[] {
|
||||
const snapshot = input.snapshot ?? getBundledModelCapabilitiesSnapshot()
|
||||
const snapshotModelIDs = new Set(
|
||||
Object.keys(snapshot.models).map((modelID) => normalizeLookupModelID(modelID)),
|
||||
)
|
||||
const requirementModelIDs = input.requirementModelIDs ?? getBuiltInRequirementModelIDs()
|
||||
const issues: ModelCapabilityGuardrailIssue[] = []
|
||||
|
||||
for (const rule of getExactModelIDAliasRules()) {
|
||||
if (!snapshotModelIDs.has(rule.canonicalModelID)) {
|
||||
issues.push({
|
||||
kind: "alias-target-missing-from-snapshot",
|
||||
ruleID: rule.ruleID,
|
||||
aliasModelID: rule.aliasModelID,
|
||||
canonicalModelID: rule.canonicalModelID,
|
||||
message: `Alias ${rule.aliasModelID} points to missing snapshot model ${rule.canonicalModelID}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (snapshotModelIDs.has(rule.aliasModelID)) {
|
||||
issues.push({
|
||||
kind: "exact-alias-collides-with-snapshot",
|
||||
ruleID: rule.ruleID,
|
||||
aliasModelID: rule.aliasModelID,
|
||||
canonicalModelID: rule.canonicalModelID,
|
||||
message: `Alias ${rule.aliasModelID} now exists in models.dev and should be reviewed instead of force-mapping to ${rule.canonicalModelID}.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of getPatternModelIDAliasRules()) {
|
||||
for (const modelID of snapshotModelIDs) {
|
||||
if (!rule.match(modelID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const canonicalModelID = rule.canonicalize(modelID)
|
||||
if (canonicalModelID === modelID) {
|
||||
continue
|
||||
}
|
||||
|
||||
issues.push({
|
||||
kind: "pattern-alias-collides-with-snapshot",
|
||||
ruleID: rule.ruleID,
|
||||
modelID,
|
||||
canonicalModelID,
|
||||
message: `Pattern alias ${rule.ruleID} would rewrite canonical snapshot model ${modelID} to ${canonicalModelID}.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const modelID of requirementModelIDs) {
|
||||
const aliasResolution = resolveModelIDAlias(modelID)
|
||||
if (aliasResolution.source !== "canonical") {
|
||||
issues.push({
|
||||
kind: "built-in-model-relies-on-alias",
|
||||
modelID: aliasResolution.requestedModelID,
|
||||
canonicalModelID: aliasResolution.canonicalModelID,
|
||||
ruleID: aliasResolution.ruleID ?? "unknown-alias-rule",
|
||||
message: `Built-in requirement model ${aliasResolution.requestedModelID} should be canonical and not rely on alias rule ${aliasResolution.ruleID}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (!snapshotModelIDs.has(aliasResolution.canonicalModelID)) {
|
||||
issues.push({
|
||||
kind: "built-in-model-missing-from-snapshot",
|
||||
modelID: aliasResolution.requestedModelID,
|
||||
canonicalModelID: aliasResolution.canonicalModelID,
|
||||
message: `Built-in requirement model ${aliasResolution.requestedModelID} resolves to ${aliasResolution.canonicalModelID}, which is missing from the bundled snapshot.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
export type { ModelCapabilityGuardrailIssue } from "@oh-my-opencode/model-core"
|
||||
export {
|
||||
getBuiltInRequirementModelIDs,
|
||||
collectModelCapabilityGuardrailIssues,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,115 +1,5 @@
|
||||
import { normalizeModelID } from "./model-normalization"
|
||||
|
||||
export type HeuristicModelFamilyDefinition = {
|
||||
family: string
|
||||
includes?: string[]
|
||||
pattern?: RegExp
|
||||
variants?: string[]
|
||||
reasoningEfforts?: string[]
|
||||
reasoningEffortAliases?: Record<string, string>
|
||||
supportsThinking?: boolean
|
||||
}
|
||||
|
||||
export const HEURISTIC_MODEL_FAMILY_REGISTRY: ReadonlyArray<HeuristicModelFamilyDefinition> = [
|
||||
{
|
||||
family: "claude-opus",
|
||||
pattern: /claude(?:-\d+(?:-\d+)*)?-opus/,
|
||||
variants: ["low", "medium", "high", "max"],
|
||||
supportsThinking: true,
|
||||
},
|
||||
{
|
||||
family: "claude-non-opus",
|
||||
includes: ["claude"],
|
||||
variants: ["low", "medium", "high"],
|
||||
supportsThinking: true,
|
||||
},
|
||||
{
|
||||
family: "openai-reasoning",
|
||||
pattern: /(?:^|\/)o\d(?:$|-)/,
|
||||
variants: ["low", "medium", "high"],
|
||||
reasoningEfforts: ["none", "minimal", "low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
family: "gpt-5",
|
||||
includes: ["gpt-5"],
|
||||
variants: ["low", "medium", "high", "xhigh"],
|
||||
reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
},
|
||||
{
|
||||
family: "gpt-legacy",
|
||||
includes: ["gpt"],
|
||||
variants: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
family: "gemini",
|
||||
includes: ["gemini"],
|
||||
variants: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
family: "grok",
|
||||
includes: ["grok"],
|
||||
variants: ["low", "medium", "high"],
|
||||
reasoningEfforts: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
family: "kimi-thinking",
|
||||
includes: ["kimi-thinking", "k2-thinking", "k2-think"],
|
||||
pattern: /(?:kimi|k2).*-(?:thinking|think)/,
|
||||
variants: ["low", "medium", "high"],
|
||||
supportsThinking: true,
|
||||
},
|
||||
{
|
||||
family: "kimi",
|
||||
includes: ["kimi", "k2"],
|
||||
variants: ["low", "medium", "high"],
|
||||
supportsThinking: false,
|
||||
},
|
||||
{
|
||||
family: "glm",
|
||||
includes: ["glm"],
|
||||
variants: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
family: "minimax",
|
||||
includes: ["minimax"],
|
||||
variants: ["low", "medium", "high"],
|
||||
supportsThinking: false,
|
||||
},
|
||||
{
|
||||
family: "deepseek",
|
||||
includes: ["deepseek"],
|
||||
variants: ["low", "medium", "high"],
|
||||
reasoningEfforts: ["high", "max"],
|
||||
reasoningEffortAliases: {
|
||||
low: "high",
|
||||
medium: "high",
|
||||
xhigh: "max",
|
||||
},
|
||||
},
|
||||
{
|
||||
family: "mistral",
|
||||
includes: ["mistral", "codestral"],
|
||||
variants: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
family: "llama",
|
||||
includes: ["llama"],
|
||||
variants: ["low", "medium", "high"],
|
||||
},
|
||||
]
|
||||
|
||||
export function detectHeuristicModelFamily(modelID: string): HeuristicModelFamilyDefinition | undefined {
|
||||
const normalizedModelID = normalizeModelID(modelID).toLowerCase()
|
||||
|
||||
for (const definition of HEURISTIC_MODEL_FAMILY_REGISTRY) {
|
||||
if (definition.pattern?.test(normalizedModelID)) {
|
||||
return definition
|
||||
}
|
||||
|
||||
if (definition.includes?.some((value) => normalizedModelID.includes(value))) {
|
||||
return definition
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
export type { HeuristicModelFamilyDefinition } from "@oh-my-opencode/model-core"
|
||||
export {
|
||||
HEURISTIC_MODEL_FAMILY_REGISTRY,
|
||||
detectHeuristicModelFamily,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,250 +1,29 @@
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
import { readConnectedProvidersCache } from "./connected-providers-cache"
|
||||
import {
|
||||
getNextFallback,
|
||||
hasMoreFallbacks,
|
||||
isRetryableModelError,
|
||||
selectFallbackProviderWithCache,
|
||||
shouldRetryError,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
import type { ErrorInfo } from "@oh-my-opencode/model-core"
|
||||
import * as connectedProvidersCache from "./connected-providers-cache"
|
||||
|
||||
/**
|
||||
* Error names that indicate a retryable model error.
|
||||
* These errors halt execution and should trigger fallback retry.
|
||||
*/
|
||||
const RETRYABLE_ERROR_NAMES = new Set([
|
||||
"providermodelnotfounderror",
|
||||
"ratelimiterror",
|
||||
"modelunavailableerror",
|
||||
"providerconnectionerror",
|
||||
"authenticationerror",
|
||||
])
|
||||
|
||||
const STOP_ERROR_NAMES = new Set([
|
||||
"quotaexceedederror",
|
||||
"insufficientcreditserror",
|
||||
"freeusagelimiterror",
|
||||
])
|
||||
|
||||
/**
|
||||
* Error names that should NOT trigger retry.
|
||||
* These errors are typically user-induced or fixable without switching models.
|
||||
*/
|
||||
const NON_RETRYABLE_ERROR_NAMES = new Set([
|
||||
"messageabortederror",
|
||||
"permissiondeniederror",
|
||||
"contextlengtherror",
|
||||
"timeouterror",
|
||||
"validationerror",
|
||||
"syntaxerror",
|
||||
"usererror",
|
||||
])
|
||||
|
||||
/**
|
||||
* Message patterns that indicate a retryable error even without a known error name.
|
||||
*/
|
||||
const RETRYABLE_MESSAGE_PATTERNS = [
|
||||
"rate_limit",
|
||||
"rate limit",
|
||||
"quota",
|
||||
"all credentials for model",
|
||||
"cooling down",
|
||||
"exhausted your capacity",
|
||||
"not found",
|
||||
"unavailable",
|
||||
"insufficient",
|
||||
"too many requests",
|
||||
"over limit",
|
||||
"overloaded",
|
||||
"bad gateway",
|
||||
"bad request",
|
||||
"unknown provider",
|
||||
"provider not found",
|
||||
"model_not_supported",
|
||||
"model not supported",
|
||||
"model is not supported",
|
||||
"connection error",
|
||||
"network error",
|
||||
"timeout",
|
||||
"service unavailable",
|
||||
"internal_server_error",
|
||||
"free usage",
|
||||
"usage exceeded",
|
||||
"credit",
|
||||
"balance",
|
||||
"temporarily unavailable",
|
||||
"try again",
|
||||
"请稍后重试",
|
||||
"503",
|
||||
"502",
|
||||
"504",
|
||||
"429",
|
||||
"529",
|
||||
"selected provider is forbidden",
|
||||
"provider is forbidden",
|
||||
// Chinese retryable patterns (Zhipu, etc.)
|
||||
"频率限制", // "rate limit"
|
||||
"请求过于频繁", // "too many requests"
|
||||
"暂时不可用", // "temporarily unavailable"
|
||||
"服务不可用", // "service unavailable"
|
||||
// OpenAI streaming server_error events surface either as a literal "server_error"
|
||||
// type or as the prose error sentence below. Without these patterns subagent
|
||||
// streams stall instead of being retried (issue #3799).
|
||||
"server_error",
|
||||
"an error occurred while processing",
|
||||
]
|
||||
|
||||
/**
|
||||
* Message patterns that indicate a non-retryable STOP error (quota/billing exhaustion).
|
||||
* These take precedence over RETRYABLE_MESSAGE_PATTERNS.
|
||||
*/
|
||||
const STOP_MESSAGE_PATTERNS = [
|
||||
"quota will reset after",
|
||||
"quota exceeded",
|
||||
"usage limit has been reached",
|
||||
"free usage limit",
|
||||
"billing limit",
|
||||
"billing hard limit",
|
||||
"monthly limit",
|
||||
"plan limit",
|
||||
"subscription quota",
|
||||
"subscription limit",
|
||||
"payment required",
|
||||
"out of credits",
|
||||
"credits exhausted",
|
||||
"insufficient credits",
|
||||
"insufficient balance",
|
||||
"credit balance",
|
||||
"usage limit for this month",
|
||||
"exhausted your capacity",
|
||||
// GLM/Z.ai business error codes that indicate permanent quota/billing exhaustion
|
||||
"daily call limit",
|
||||
"daily limit",
|
||||
"usage limit reached for",
|
||||
"in arrears",
|
||||
"fair use policy",
|
||||
"recharge and try",
|
||||
"使用上限",
|
||||
"额度不足",
|
||||
"余额不足",
|
||||
"已耗尽",
|
||||
]
|
||||
|
||||
const AUTO_RETRY_GATE_PATTERNS = [
|
||||
"rate limit",
|
||||
"cooling down",
|
||||
"credentials for model",
|
||||
]
|
||||
|
||||
function hasProviderAutoRetrySignal(message: string): boolean {
|
||||
if (!message.includes("retrying in")) {
|
||||
return false
|
||||
}
|
||||
return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern))
|
||||
export type { ErrorInfo }
|
||||
export {
|
||||
isRetryableModelError,
|
||||
shouldRetryError,
|
||||
getNextFallback,
|
||||
hasMoreFallbacks,
|
||||
selectFallbackProviderWithCache,
|
||||
}
|
||||
|
||||
export interface ErrorInfo {
|
||||
name?: string
|
||||
message?: string
|
||||
/** HTTP status code from the provider response (e.g., 429 for rate limit) */
|
||||
statusCode?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error is a retryable model error.
|
||||
* Returns true if it's a known retryable type OR matches retryable message patterns.
|
||||
*/
|
||||
export function isRetryableModelError(error: ErrorInfo): boolean {
|
||||
// If we have an error name, check against known lists
|
||||
if (error.name) {
|
||||
const errorNameLower = error.name.toLowerCase()
|
||||
// Explicit non-retryable takes precedence
|
||||
if (NON_RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
|
||||
return false
|
||||
}
|
||||
if (STOP_ERROR_NAMES.has(errorNameLower)) {
|
||||
return false
|
||||
}
|
||||
// Check if it's a known retryable error
|
||||
if (RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check message patterns for unknown errors
|
||||
const msg = error.message?.toLowerCase() ?? ""
|
||||
|
||||
// STOP patterns take precedence over retryable patterns
|
||||
if (STOP_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (hasProviderAutoRetrySignal(msg)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// HTTP status code check: catches rate-limit errors regardless of message format/language.
|
||||
// Uses the same codes as runtime-fallback config (400 excluded as it is a permanent client error).
|
||||
if (
|
||||
error.statusCode != null &&
|
||||
(error.statusCode === 429 || error.statusCode === 503 || error.statusCode === 529)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error should trigger a fallback retry.
|
||||
* Returns true for errors that halt execution.
|
||||
*/
|
||||
export function shouldRetryError(error: ErrorInfo): boolean {
|
||||
return isRetryableModelError(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next fallback model from the chain based on attempt count.
|
||||
* Returns undefined if all fallbacks have been exhausted.
|
||||
*/
|
||||
export function getNextFallback(
|
||||
fallbackChain: FallbackEntry[],
|
||||
attemptCount: number,
|
||||
): FallbackEntry | undefined {
|
||||
return fallbackChain[attemptCount]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there are more fallbacks available after the current attempt.
|
||||
*/
|
||||
export function hasMoreFallbacks(
|
||||
fallbackChain: FallbackEntry[],
|
||||
attemptCount: number,
|
||||
): boolean {
|
||||
return attemptCount < fallbackChain.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the best provider for a fallback entry.
|
||||
* Priority:
|
||||
* 1) First connected provider in the entry's provider preference order
|
||||
* 2) Preferred provider when connected (and entry providers are unavailable)
|
||||
* 3) First provider listed in the fallback entry
|
||||
*/
|
||||
export function selectFallbackProvider(
|
||||
providers: string[],
|
||||
preferredProviderID?: string,
|
||||
): string {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders.map(p => p.toLowerCase()))
|
||||
|
||||
for (const provider of providers) {
|
||||
if (connectedSet.has(provider.toLowerCase())) {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
preferredProviderID &&
|
||||
connectedSet.has(preferredProviderID.toLowerCase())
|
||||
) {
|
||||
return preferredProviderID
|
||||
}
|
||||
}
|
||||
|
||||
return providers[0] || preferredProviderID || "opencode"
|
||||
return selectFallbackProviderWithCache(
|
||||
providers,
|
||||
connectedProvidersCache,
|
||||
preferredProviderID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1 @@
|
||||
export function normalizeModelFormat(
|
||||
model: string | { providerID: string; modelID: string }
|
||||
): { providerID: string; modelID: string } | undefined {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof model === "object" && "providerID" in model && "modelID" in model) {
|
||||
return { providerID: model.providerID, modelID: model.modelID }
|
||||
}
|
||||
|
||||
if (typeof model === "string") {
|
||||
const parts = model.split("/")
|
||||
if (parts.length >= 2) {
|
||||
return { providerID: parts[0], modelID: parts.slice(1).join("/") }
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
export { normalizeModelFormat } from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
export function normalizeModel(model?: string): string | undefined {
|
||||
const trimmed = model?.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
export function normalizeModelID(modelID: string): string {
|
||||
return modelID.replace(/\.(\d+)/g, "-$1")
|
||||
}
|
||||
export { normalizeModel, normalizeModelID } from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,349 +1,5 @@
|
||||
export type FallbackEntry = {
|
||||
providers: string[];
|
||||
model: string;
|
||||
variant?: string; // Entry-specific variant (e.g., GPT→high, Opus→max)
|
||||
reasoningEffort?: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
maxTokens?: number;
|
||||
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number };
|
||||
};
|
||||
|
||||
export type ModelRequirement = {
|
||||
fallbackChain: FallbackEntry[];
|
||||
variant?: string; // Default variant (used when entry doesn't specify one)
|
||||
requiresModel?: string; // If set, only activates when this model is available (fuzzy match)
|
||||
requiresAnyModel?: boolean; // If true, requires at least ONE model in fallbackChain to be available (or empty availability treated as unavailable)
|
||||
requiresProvider?: string[]; // If set, only activates when any of these providers is connected
|
||||
};
|
||||
|
||||
export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
sisyphus: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{
|
||||
providers: [
|
||||
"opencode",
|
||||
"moonshotai",
|
||||
"moonshotai-cn",
|
||||
"firmware",
|
||||
"ollama-cloud",
|
||||
"aihubmix",
|
||||
"vercel",
|
||||
],
|
||||
model: "kimi-k2.5",
|
||||
},
|
||||
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.5", variant: "medium" },
|
||||
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
|
||||
{ providers: ["opencode"], model: "big-pickle" },
|
||||
],
|
||||
requiresAnyModel: true,
|
||||
},
|
||||
hephaestus: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "venice", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
variant: "medium",
|
||||
},
|
||||
],
|
||||
requiresProvider: ["openai", "github-copilot", "venice", "opencode", "vercel"],
|
||||
},
|
||||
oracle: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
variant: "high",
|
||||
},
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
],
|
||||
},
|
||||
librarian: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.4-mini-fast" },
|
||||
{ providers: ["opencode-go"], model: "qwen3.5-plus" },
|
||||
{ providers: ["vercel"], model: "minimax-m2.7-highspeed" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4-nano" },
|
||||
],
|
||||
},
|
||||
explore: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.4-mini-fast" },
|
||||
{ providers: ["opencode-go"], model: "qwen3.5-plus" },
|
||||
{ providers: ["vercel"], model: "minimax-m2.7-highspeed" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4-nano" },
|
||||
],
|
||||
},
|
||||
"multimodal-looker": {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.5", variant: "medium" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{ providers: ["zai-coding-plan", "vercel"], model: "glm-4.6v" },
|
||||
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5-nano" },
|
||||
],
|
||||
},
|
||||
prometheus: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
},
|
||||
],
|
||||
},
|
||||
metis: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
],
|
||||
},
|
||||
momus: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
variant: "xhigh",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
],
|
||||
},
|
||||
atlas: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
variant: "medium",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
],
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
variant: "medium",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode"], model: "big-pickle" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"visual-engineering": {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
],
|
||||
},
|
||||
ultrabrain: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
variant: "xhigh",
|
||||
},
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
],
|
||||
},
|
||||
deep: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "venice", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
variant: "medium",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
],
|
||||
},
|
||||
artistry: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
],
|
||||
},
|
||||
quick: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.4-mini",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3-flash",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
{ providers: ["opencode", "vercel"], model: "gpt-5-nano" },
|
||||
],
|
||||
},
|
||||
"unspecified-low": {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
providers: ["openai", "opencode", "vercel"],
|
||||
model: "gpt-5.3-codex",
|
||||
variant: "medium",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3-flash",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
],
|
||||
},
|
||||
"unspecified-high": {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
{ providers: ["opencode", "vercel"], model: "kimi-k2.5" },
|
||||
{
|
||||
providers: [
|
||||
"opencode",
|
||||
"moonshotai",
|
||||
"moonshotai-cn",
|
||||
"firmware",
|
||||
"ollama-cloud",
|
||||
"aihubmix",
|
||||
"vercel",
|
||||
],
|
||||
model: "kimi-k2.5",
|
||||
},
|
||||
],
|
||||
},
|
||||
writing: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3-flash",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
],
|
||||
},
|
||||
};
|
||||
export type { FallbackEntry, ModelRequirement } from "@oh-my-opencode/model-core"
|
||||
export {
|
||||
AGENT_MODEL_REQUIREMENTS,
|
||||
CATEGORY_MODEL_REQUIREMENTS,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,235 +1,22 @@
|
||||
import { log as writeLog } from "./logger"
|
||||
import {
|
||||
_setModelResolutionLogImplementationForTesting,
|
||||
resolveModelPipeline as resolveModelPipelineFromCore,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
import type {
|
||||
PipelineModelResolutionRequest,
|
||||
PipelineModelResolutionResult,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
import * as connectedProvidersCache from "./connected-providers-cache"
|
||||
import { fuzzyMatchModel } from "./model-availability"
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
import { transformModelForProvider } from "./provider-model-id-transform"
|
||||
import { normalizeModel } from "./model-normalization"
|
||||
|
||||
type LogImplementation = typeof writeLog
|
||||
|
||||
let logImplementationForTesting: LogImplementation | undefined
|
||||
|
||||
function log(message: string, data?: unknown): void {
|
||||
const logImplementation = logImplementationForTesting ?? writeLog
|
||||
if (arguments.length === 1) {
|
||||
logImplementation(message)
|
||||
return
|
||||
}
|
||||
logImplementation(message, data)
|
||||
}
|
||||
|
||||
export function _setModelResolutionLogImplementationForTesting(
|
||||
logImplementation: LogImplementation | undefined,
|
||||
): void {
|
||||
logImplementationForTesting = logImplementation
|
||||
}
|
||||
|
||||
export type ModelResolutionRequest = {
|
||||
intent?: {
|
||||
uiSelectedModel?: string
|
||||
userModel?: string
|
||||
userFallbackModels?: string[]
|
||||
categoryDefaultModel?: string
|
||||
}
|
||||
constraints: {
|
||||
availableModels: Set<string>
|
||||
connectedProviders?: string[] | null
|
||||
}
|
||||
policy?: {
|
||||
fallbackChain?: FallbackEntry[]
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelResolutionProvenance =
|
||||
| "override"
|
||||
| "category-default"
|
||||
| "provider-fallback"
|
||||
| "system-default"
|
||||
|
||||
export type ModelResolutionResult = {
|
||||
model: string
|
||||
provenance: ModelResolutionProvenance
|
||||
variant?: string
|
||||
attempted?: string[]
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export { _setModelResolutionLogImplementationForTesting }
|
||||
|
||||
export function resolveModelPipeline(
|
||||
request: ModelResolutionRequest,
|
||||
): ModelResolutionResult | undefined {
|
||||
const attempted: string[] = []
|
||||
const { intent, constraints, policy } = request
|
||||
const availableModels = constraints.availableModels
|
||||
const fallbackChain = policy?.fallbackChain
|
||||
const systemDefaultModel = policy?.systemDefaultModel
|
||||
|
||||
const normalizedUiModel = normalizeModel(intent?.uiSelectedModel)
|
||||
if (normalizedUiModel) {
|
||||
log("Model resolved via UI selection", { model: normalizedUiModel })
|
||||
return { model: normalizedUiModel, provenance: "override" }
|
||||
}
|
||||
|
||||
const normalizedUserModel = normalizeModel(intent?.userModel)
|
||||
if (normalizedUserModel) {
|
||||
log("Model resolved via config override", { model: normalizedUserModel })
|
||||
return { model: normalizedUserModel, provenance: "override" }
|
||||
}
|
||||
|
||||
const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel)
|
||||
if (normalizedCategoryDefault) {
|
||||
attempted.push(normalizedCategoryDefault)
|
||||
if (availableModels.size > 0) {
|
||||
const parts = normalizedCategoryDefault.split("/")
|
||||
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
|
||||
const match = fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
|
||||
if (match) {
|
||||
log("Model resolved via category default (fuzzy matched)", {
|
||||
original: normalizedCategoryDefault,
|
||||
matched: match,
|
||||
})
|
||||
return { model: match, provenance: "category-default", attempted }
|
||||
}
|
||||
} else {
|
||||
const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache()
|
||||
if (connectedProviders === null) {
|
||||
log("Model resolved via category default (no cache, first run)", {
|
||||
model: normalizedCategoryDefault,
|
||||
})
|
||||
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
|
||||
}
|
||||
const parts = normalizedCategoryDefault.split("/")
|
||||
if (parts.length >= 2) {
|
||||
const provider = parts[0]
|
||||
if (connectedProviders.includes(provider)) {
|
||||
const modelName = parts.slice(1).join("/")
|
||||
const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}`
|
||||
log("Model resolved via category default (connected provider)", {
|
||||
model: transformedModel,
|
||||
original: normalizedCategoryDefault,
|
||||
})
|
||||
return { model: transformedModel, provenance: "category-default", attempted }
|
||||
}
|
||||
}
|
||||
}
|
||||
log("Category default model not available, falling through to fallback chain", {
|
||||
model: normalizedCategoryDefault,
|
||||
})
|
||||
}
|
||||
|
||||
//#when - user configured fallback_models, try them before hardcoded fallback chain
|
||||
const userFallbackModels = intent?.userFallbackModels
|
||||
if (userFallbackModels && userFallbackModels.length > 0) {
|
||||
if (availableModels.size === 0) {
|
||||
const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
|
||||
if (connectedSet !== null) {
|
||||
for (const model of userFallbackModels) {
|
||||
attempted.push(model)
|
||||
const parts = model.split("/")
|
||||
if (parts.length >= 2) {
|
||||
const provider = parts[0]
|
||||
if (connectedSet.has(provider)) {
|
||||
const modelName = parts.slice(1).join("/")
|
||||
const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}`
|
||||
log("Model resolved via user fallback_models (connected provider)", { model: transformedModel, original: model })
|
||||
return { model: transformedModel, provenance: "provider-fallback", attempted }
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No connected provider found in user fallback_models, falling through to hardcoded chain")
|
||||
}
|
||||
} else {
|
||||
for (const model of userFallbackModels) {
|
||||
attempted.push(model)
|
||||
const parts = model.split("/")
|
||||
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
|
||||
const match = fuzzyMatchModel(model, availableModels, providerHint)
|
||||
if (match) {
|
||||
log("Model resolved via user fallback_models (availability confirmed)", { model: model, match })
|
||||
return { model: match, provenance: "provider-fallback", attempted }
|
||||
}
|
||||
}
|
||||
log("No available model found in user fallback_models, falling through to hardcoded chain")
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
if (availableModels.size === 0) {
|
||||
const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
|
||||
if (connectedSet === null) {
|
||||
log("Model fallback chain skipped (no connected providers cache) - falling through to system default")
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
if (connectedSet.has(provider)) {
|
||||
const transformedModelId = transformModelForProvider(provider, entry.model)
|
||||
const model = `${provider}/${transformedModelId}`
|
||||
log("Model resolved via fallback chain (connected provider)", {
|
||||
provider,
|
||||
model: transformedModelId,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return {
|
||||
model,
|
||||
provenance: "provider-fallback",
|
||||
variant: entry.variant,
|
||||
attempted,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No connected provider found in fallback chain, falling through to system default")
|
||||
}
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
const fullModel = `${provider}/${entry.model}`
|
||||
const match = fuzzyMatchModel(fullModel, availableModels, [provider])
|
||||
if (match) {
|
||||
log("Model resolved via fallback chain (availability confirmed)", {
|
||||
provider,
|
||||
model: entry.model,
|
||||
match,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return {
|
||||
model: match,
|
||||
provenance: "provider-fallback",
|
||||
variant: entry.variant,
|
||||
attempted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels)
|
||||
if (crossProviderMatch) {
|
||||
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
|
||||
model: entry.model,
|
||||
match: crossProviderMatch,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return {
|
||||
model: crossProviderMatch,
|
||||
provenance: "provider-fallback",
|
||||
variant: entry.variant,
|
||||
attempted,
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No available model found in fallback chain, falling through to system default")
|
||||
}
|
||||
}
|
||||
|
||||
if (systemDefaultModel === undefined) {
|
||||
log("No model resolved - systemDefaultModel not configured")
|
||||
return undefined
|
||||
}
|
||||
|
||||
log("Model resolved via system default", { model: systemDefaultModel })
|
||||
return { model: systemDefaultModel, provenance: "system-default", attempted }
|
||||
request: PipelineModelResolutionRequest,
|
||||
): PipelineModelResolutionResult | undefined {
|
||||
return resolveModelPipelineFromCore(request, connectedProvidersCache)
|
||||
}
|
||||
export type {
|
||||
PipelineModelResolutionRequest as ModelResolutionRequest,
|
||||
PipelineModelResolutionProvenance as ModelResolutionProvenance,
|
||||
PipelineModelResolutionResult as ModelResolutionResult,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,41 +1,6 @@
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
|
||||
export interface DelegatedModelConfig {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
maxTokens?: number
|
||||
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
|
||||
}
|
||||
|
||||
export type ModelResolutionRequest = {
|
||||
intent?: {
|
||||
uiSelectedModel?: string
|
||||
userModel?: string
|
||||
categoryDefaultModel?: string
|
||||
}
|
||||
constraints: {
|
||||
availableModels: Set<string>
|
||||
}
|
||||
policy?: {
|
||||
fallbackChain?: FallbackEntry[]
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelResolutionProvenance =
|
||||
| "override"
|
||||
| "category-default"
|
||||
| "provider-fallback"
|
||||
| "system-default"
|
||||
|
||||
export type ModelResolutionResult = {
|
||||
model: string
|
||||
provenance: ModelResolutionProvenance
|
||||
variant?: string
|
||||
attempted?: string[]
|
||||
reason?: string
|
||||
}
|
||||
export type {
|
||||
DelegatedModelConfig,
|
||||
ModelResolutionRequest,
|
||||
ModelResolutionProvenance,
|
||||
ModelResolutionResult,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
|
||||
+12
-106
@@ -1,106 +1,12 @@
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
import type { FallbackModelObject } from "../config/schema/fallback-models"
|
||||
import { normalizeModel } from "./model-normalization"
|
||||
import { resolveModelPipeline } from "./model-resolution-pipeline"
|
||||
import { KNOWN_VARIANTS } from "./known-variants"
|
||||
|
||||
export type ModelResolutionInput = {
|
||||
userModel?: string
|
||||
inheritedModel?: string
|
||||
systemDefault?: string
|
||||
}
|
||||
|
||||
export type ModelSource =
|
||||
| "override"
|
||||
| "category-default"
|
||||
| "provider-fallback"
|
||||
| "system-default"
|
||||
|
||||
export type ModelResolutionResult = {
|
||||
model: string
|
||||
source: ModelSource
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export type ExtendedModelResolutionInput = {
|
||||
uiSelectedModel?: string
|
||||
userModel?: string
|
||||
userFallbackModels?: string[]
|
||||
categoryDefaultModel?: string
|
||||
fallbackChain?: FallbackEntry[]
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
|
||||
|
||||
export function resolveModel(input: ModelResolutionInput): string | undefined {
|
||||
return (
|
||||
normalizeModel(input.userModel) ??
|
||||
normalizeModel(input.inheritedModel) ??
|
||||
input.systemDefault
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveModelWithFallback(
|
||||
input: ExtendedModelResolutionInput,
|
||||
): ModelResolutionResult | undefined {
|
||||
const { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
|
||||
const resolved = resolveModelPipeline({
|
||||
intent: { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel },
|
||||
constraints: { availableModels },
|
||||
policy: { fallbackChain, systemDefaultModel },
|
||||
})
|
||||
|
||||
if (!resolved) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
model: resolved.model,
|
||||
source: resolved.provenance,
|
||||
variant: resolved.variant,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes fallback_models config to a mixed array.
|
||||
* Accepts string, string[], or mixed arrays of strings and FallbackModelObject entries.
|
||||
*/
|
||||
export function normalizeFallbackModels(
|
||||
models: string | (string | FallbackModelObject)[] | undefined,
|
||||
): (string | FallbackModelObject)[] | undefined {
|
||||
if (!models) return undefined
|
||||
if (typeof models === "string") return [models]
|
||||
return models
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts plain model strings from a mixed fallback models array.
|
||||
* Object entries are flattened to "model" or "model(variant)" strings.
|
||||
* Use this when consumers need string[] (e.g., resolveModelForDelegateTask).
|
||||
*/
|
||||
export function flattenToFallbackModelStrings(
|
||||
models: (string | FallbackModelObject)[] | undefined,
|
||||
): string[] | undefined {
|
||||
if (!models) return undefined
|
||||
return models.map((entry) => {
|
||||
if (typeof entry === "string") return entry
|
||||
const variant = entry.variant
|
||||
if (variant) {
|
||||
// Strip any supported inline variant syntax before appending explicit override.
|
||||
// Supports both parenthesized and space-suffix forms so we don't emit
|
||||
// invalid strings like "provider/model high(low)".
|
||||
const model = entry.model
|
||||
.replace(/\([^()]+\)\s*$/, "")
|
||||
.replace(/\s+([a-z][a-z0-9_-]*)\s*$/i, (match: string, suffix: string) => {
|
||||
const normalized = String(suffix).toLowerCase()
|
||||
return KNOWN_VARIANTS.has(normalized)
|
||||
? ""
|
||||
: match
|
||||
})
|
||||
.trim()
|
||||
return `${model}(${variant})`
|
||||
}
|
||||
return entry.model
|
||||
})
|
||||
}
|
||||
export type {
|
||||
ModelResolutionInput,
|
||||
ModelSource,
|
||||
ModelResolutionResult,
|
||||
ExtendedModelResolutionInput,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
export {
|
||||
resolveModel,
|
||||
resolveModelWithFallback,
|
||||
normalizeFallbackModels,
|
||||
flattenToFallbackModelStrings,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,12 +1 @@
|
||||
type CommandSource = "claude-code" | "opencode"
|
||||
|
||||
export function sanitizeModelField(model: unknown, source: CommandSource = "claude-code"): string | undefined {
|
||||
if (source === "claude-code") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof model === "string" && model.trim().length > 0) {
|
||||
return model.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
export { sanitizeModelField } from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,217 +1,6 @@
|
||||
import { detectHeuristicModelFamily } from "./model-capability-heuristics"
|
||||
|
||||
type CompatibilityField = "variant" | "reasoningEffort" | "temperature" | "topP" | "maxTokens" | "thinking"
|
||||
|
||||
type DesiredModelSettings = {
|
||||
variant?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
topP?: number
|
||||
maxTokens?: number
|
||||
thinking?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type CompatibilityCapabilities = {
|
||||
variants?: string[]
|
||||
reasoningEfforts?: string[]
|
||||
supportsTemperature?: boolean
|
||||
supportsTopP?: boolean
|
||||
maxOutputTokens?: number
|
||||
supportsThinking?: boolean
|
||||
}
|
||||
|
||||
export type ModelSettingsCompatibilityInput = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
desired: DesiredModelSettings
|
||||
capabilities?: CompatibilityCapabilities
|
||||
}
|
||||
|
||||
export type ModelSettingsCompatibilityChange = {
|
||||
field: CompatibilityField
|
||||
from: string
|
||||
to?: string
|
||||
reason:
|
||||
| "unsupported-by-model-family"
|
||||
| "unknown-model-family"
|
||||
| "unsupported-by-model-metadata"
|
||||
| "max-output-limit"
|
||||
}
|
||||
|
||||
export type ModelSettingsCompatibilityResult = {
|
||||
variant?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
topP?: number
|
||||
maxTokens?: number
|
||||
thinking?: Record<string, unknown>
|
||||
changes: ModelSettingsCompatibilityChange[]
|
||||
}
|
||||
|
||||
const VARIANT_LADDER = ["low", "medium", "high", "xhigh", "max"]
|
||||
const REASONING_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
function downgradeWithinLadder(value: string, allowed: string[], ladder: string[]): string | undefined {
|
||||
const requestedIndex = ladder.indexOf(value)
|
||||
if (requestedIndex === -1) return undefined
|
||||
|
||||
for (let index = requestedIndex; index >= 0; index -= 1) {
|
||||
if (allowed.includes(ladder[index])) {
|
||||
return ladder[index]
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeCapabilitiesVariants(capabilities: CompatibilityCapabilities | undefined): string[] | undefined {
|
||||
if (!capabilities?.variants || capabilities.variants.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return capabilities.variants.map((v) => v.toLowerCase())
|
||||
}
|
||||
|
||||
function normalizeCapabilitiesReasoningEfforts(capabilities: CompatibilityCapabilities | undefined): string[] | undefined {
|
||||
if (!capabilities?.reasoningEfforts || capabilities.reasoningEfforts.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return capabilities.reasoningEfforts.map((value) => value.toLowerCase())
|
||||
}
|
||||
|
||||
type FieldResolution = { value?: string; reason?: ModelSettingsCompatibilityChange["reason"] }
|
||||
|
||||
function resolveField(
|
||||
normalized: string,
|
||||
familyCaps: string[] | undefined,
|
||||
ladder: string[],
|
||||
familyKnown: boolean,
|
||||
metadataOverride?: string[],
|
||||
familyAliases?: Record<string, string>,
|
||||
): FieldResolution {
|
||||
const aliased = familyAliases?.[normalized]
|
||||
if (aliased && (metadataOverride?.includes(aliased) || familyCaps?.includes(aliased))) {
|
||||
return { value: aliased, reason: "unsupported-by-model-family" }
|
||||
}
|
||||
|
||||
if (metadataOverride) {
|
||||
if (metadataOverride.includes(normalized)) return { value: normalized }
|
||||
return {
|
||||
value: downgradeWithinLadder(normalized, metadataOverride, ladder),
|
||||
reason: "unsupported-by-model-metadata",
|
||||
}
|
||||
}
|
||||
|
||||
if (familyCaps) {
|
||||
if (familyCaps.includes(normalized)) return { value: normalized }
|
||||
return {
|
||||
value: downgradeWithinLadder(normalized, familyCaps, ladder),
|
||||
reason: "unsupported-by-model-family",
|
||||
}
|
||||
}
|
||||
|
||||
if (familyKnown) {
|
||||
return { value: undefined, reason: "unsupported-by-model-family" }
|
||||
}
|
||||
|
||||
return { value: undefined, reason: "unknown-model-family" }
|
||||
}
|
||||
|
||||
export function resolveCompatibleModelSettings(
|
||||
input: ModelSettingsCompatibilityInput,
|
||||
): ModelSettingsCompatibilityResult {
|
||||
const family = detectHeuristicModelFamily(input.modelID)
|
||||
const familyKnown = Boolean(family)
|
||||
const changes: ModelSettingsCompatibilityChange[] = []
|
||||
const metadataVariants = normalizeCapabilitiesVariants(input.capabilities)
|
||||
const metadataReasoningEfforts = normalizeCapabilitiesReasoningEfforts(input.capabilities)
|
||||
|
||||
let variant = input.desired.variant
|
||||
if (variant !== undefined) {
|
||||
const normalized = variant.toLowerCase()
|
||||
const resolved = resolveField(normalized, family?.variants, VARIANT_LADDER, familyKnown, metadataVariants)
|
||||
if (resolved.value !== normalized && resolved.reason) {
|
||||
changes.push({ field: "variant", from: variant, to: resolved.value, reason: resolved.reason })
|
||||
}
|
||||
variant = resolved.value
|
||||
}
|
||||
|
||||
let reasoningEffort = input.desired.reasoningEffort
|
||||
if (reasoningEffort !== undefined) {
|
||||
const normalized = reasoningEffort.toLowerCase()
|
||||
const resolved = resolveField(
|
||||
normalized,
|
||||
family?.reasoningEfforts,
|
||||
REASONING_LADDER,
|
||||
familyKnown,
|
||||
metadataReasoningEfforts,
|
||||
family?.reasoningEffortAliases,
|
||||
)
|
||||
if (resolved.value !== normalized && resolved.reason) {
|
||||
changes.push({ field: "reasoningEffort", from: reasoningEffort, to: resolved.value, reason: resolved.reason })
|
||||
}
|
||||
reasoningEffort = resolved.value
|
||||
}
|
||||
|
||||
let temperature = input.desired.temperature
|
||||
if (temperature !== undefined && input.capabilities?.supportsTemperature === false) {
|
||||
changes.push({
|
||||
field: "temperature",
|
||||
from: String(temperature),
|
||||
to: undefined,
|
||||
reason: "unsupported-by-model-metadata",
|
||||
})
|
||||
temperature = undefined
|
||||
}
|
||||
|
||||
let topP = input.desired.topP
|
||||
if (topP !== undefined && input.capabilities?.supportsTopP === false) {
|
||||
changes.push({
|
||||
field: "topP",
|
||||
from: String(topP),
|
||||
to: undefined,
|
||||
reason: "unsupported-by-model-metadata",
|
||||
})
|
||||
topP = undefined
|
||||
}
|
||||
|
||||
let maxTokens = input.desired.maxTokens
|
||||
if (maxTokens !== undefined && maxTokens <= 0) {
|
||||
maxTokens = undefined
|
||||
}
|
||||
|
||||
if (
|
||||
maxTokens !== undefined &&
|
||||
input.capabilities?.maxOutputTokens !== undefined &&
|
||||
input.capabilities.maxOutputTokens > 0 &&
|
||||
maxTokens > input.capabilities.maxOutputTokens
|
||||
) {
|
||||
changes.push({
|
||||
field: "maxTokens",
|
||||
from: String(maxTokens),
|
||||
to: String(input.capabilities.maxOutputTokens),
|
||||
reason: "max-output-limit",
|
||||
})
|
||||
maxTokens = input.capabilities.maxOutputTokens
|
||||
}
|
||||
|
||||
let thinking = input.desired.thinking
|
||||
if (thinking !== undefined && input.capabilities?.supportsThinking === false) {
|
||||
changes.push({
|
||||
field: "thinking",
|
||||
from: JSON.stringify(thinking),
|
||||
to: undefined,
|
||||
reason: "unsupported-by-model-metadata",
|
||||
})
|
||||
thinking = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
variant,
|
||||
reasoningEffort,
|
||||
...(input.desired.temperature !== undefined ? { temperature } : {}),
|
||||
...(input.desired.topP !== undefined ? { topP } : {}),
|
||||
...(input.desired.maxTokens !== undefined ? { maxTokens } : {}),
|
||||
...(input.desired.thinking !== undefined ? { thinking } : {}),
|
||||
changes,
|
||||
}
|
||||
}
|
||||
export type {
|
||||
ModelSettingsCompatibilityInput,
|
||||
ModelSettingsCompatibilityChange,
|
||||
ModelSettingsCompatibilityResult,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
export { resolveCompatibleModelSettings } from "@oh-my-opencode/model-core"
|
||||
|
||||
@@ -1,67 +1 @@
|
||||
const KNOWN_VARIANTS = new Set([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"minimal",
|
||||
"none",
|
||||
"auto",
|
||||
"thinking",
|
||||
])
|
||||
|
||||
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
|
||||
if (typeof rawModelID !== "string") {
|
||||
return { modelID: "" }
|
||||
}
|
||||
const trimmedModelID = rawModelID.trim()
|
||||
if (!trimmedModelID) {
|
||||
return { modelID: "" }
|
||||
}
|
||||
|
||||
const parenthesizedVariant = trimmedModelID.match(/^(.*)\(([^()]+)\)\s*$/)
|
||||
if (parenthesizedVariant) {
|
||||
const modelID = parenthesizedVariant[1]?.trim() ?? ""
|
||||
const variant = parenthesizedVariant[2]?.trim()
|
||||
return variant ? { modelID, variant } : { modelID }
|
||||
}
|
||||
|
||||
const spaceVariant = trimmedModelID.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
|
||||
if (spaceVariant) {
|
||||
const modelID = spaceVariant[1]?.trim() ?? ""
|
||||
const variant = spaceVariant[2]?.trim().toLowerCase()
|
||||
if (variant && KNOWN_VARIANTS.has(variant)) {
|
||||
return { modelID, variant }
|
||||
}
|
||||
}
|
||||
|
||||
return { modelID: trimmedModelID }
|
||||
}
|
||||
|
||||
export function parseModelString(
|
||||
model: string,
|
||||
): { providerID: string; modelID: string; variant?: string } | undefined {
|
||||
if (typeof model !== "string") return undefined
|
||||
const trimmedModel = model.trim()
|
||||
if (!trimmedModel) return undefined
|
||||
|
||||
const separatorIndex = trimmedModel.indexOf("/")
|
||||
if (separatorIndex === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const providerID = trimmedModel.slice(0, separatorIndex).trim()
|
||||
const rawModelID = trimmedModel.slice(separatorIndex + 1).trim()
|
||||
if (!providerID || !rawModelID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsedModel = parseVariantFromModelID(rawModelID)
|
||||
if (!parsedModel.modelID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsedModel.variant
|
||||
? { providerID, modelID: parsedModel.modelID, variant: parsedModel.variant }
|
||||
: { providerID, modelID: parsedModel.modelID }
|
||||
}
|
||||
export { parseVariantFromModelID, parseModelString } from "@oh-my-opencode/model-core"
|
||||
|
||||
Reference in New Issue
Block a user