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