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