refactor(shared): improve model resolver with 3-step resolution

Implement resolveModelWithFallback() that tries: 1) user override, 2) fuzzy match from requirements chain against available models, 3) system default. Export new model utilities from shared index.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
justsisyphus
2026-01-22 22:43:27 +09:00
parent bc62c23a85
commit 54b4844d3f
3 changed files with 475 additions and 59 deletions
+72 -28
View File
@@ -1,35 +1,79 @@
/**
* Input for model resolution.
* All model strings are optional except systemDefault which is the terminal fallback.
*/
import { log } from "./logger"
import { fuzzyMatchModel } from "./model-availability"
import type { FallbackEntry } from "./model-requirements"
export type ModelResolutionInput = {
/** Model from user category config */
userModel?: string;
/** Model inherited from parent task/session */
inheritedModel?: string;
/** System default model from OpenCode config - always required */
systemDefault: string;
};
userModel?: string
inheritedModel?: string
systemDefault: string
}
export type ModelSource =
| "override"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
source: ModelSource
}
export type ExtendedModelResolutionInput = {
userModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
systemDefaultModel: string
}
/**
* Normalizes a model string.
* Trims whitespace and treats empty/whitespace-only as undefined.
*/
function normalizeModel(model?: string): string | undefined {
const trimmed = model?.trim();
return trimmed || undefined;
const trimmed = model?.trim()
return trimmed || undefined
}
/**
* Resolves the effective model using priority chain:
* userModel → inheritedModel → systemDefault
*
* Empty strings and whitespace-only strings are treated as unset.
*/
export function resolveModel(input: ModelResolutionInput): string {
return (
normalizeModel(input.userModel) ??
normalizeModel(input.inheritedModel) ??
input.systemDefault
);
return (
normalizeModel(input.userModel) ??
normalizeModel(input.inheritedModel) ??
input.systemDefault
)
}
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
): ModelResolutionResult {
const { userModel, fallbackChain, availableModels, systemDefaultModel } = input
// Step 1: Override
const normalizedUserModel = normalizeModel(userModel)
if (normalizedUserModel) {
log("Model resolved via override", { model: normalizedUserModel })
return { model: normalizedUserModel, source: "override" }
}
// Step 2: Provider fallback chain (with availability check)
if (fallbackChain && fallbackChain.length > 0) {
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 })
return { model: match, source: "provider-fallback" }
}
}
}
// Step 3: Use first entry in fallbackChain as fallback (no availability match found)
// This ensures category/agent intent is honored even if availableModels is incomplete
const firstEntry = fallbackChain[0]
if (firstEntry.providers.length > 0) {
const fallbackModel = `${firstEntry.providers[0]}/${firstEntry.model}`
log("Model resolved via fallback chain first entry (no availability match)", { model: fallbackModel })
return { model: fallbackModel, source: "provider-fallback" }
}
}
// Step 4: System default
log("Model resolved via system default", { model: systemDefaultModel })
return { model: systemDefaultModel, source: "system-default" }
}