- postinstall.mjs: fix alias package detection - migrate-legacy-plugin-entry: dedupe + regression tests - task_system: default consistency across runtime paths - task() contract: consistent tool behavior - runtime model selection, tool cap, stale-task cancellation - recovery sanitization, context-limit gating - Ralph semantic DONE hardening, Atlas fallback persistence - native-skill description/content, skill path traversal guard - publish workflow: platform awaited via reusable workflow job - release: version edits reapplied before commit/tag - JSONC plugin migration: top-level plugin key safety - cold-cache: user fallback models skip disconnected providers - docs/version/release framing updates Verified: bun test (4599 pass), tsc --noEmit clean, bun run build clean
194 lines
7.3 KiB
TypeScript
194 lines
7.3 KiB
TypeScript
import type { FallbackEntry } from "../../shared/model-requirements"
|
|
import { normalizeModel } from "../../shared/model-normalization"
|
|
import { fuzzyMatchModel } from "../../shared/model-availability"
|
|
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
|
import { hasConnectedProvidersCache, hasProviderModelsCache, readConnectedProvidersCache } from "../../shared/connected-providers-cache"
|
|
import { log } from "../../shared/logger"
|
|
import { parseModelString, parseVariantFromModelID } from "./model-string-parser"
|
|
|
|
function isExplicitHighModel(model: string): boolean {
|
|
return /(?:^|\/)[^/]+-high$/.test(model)
|
|
}
|
|
|
|
function getExplicitHighBaseModel(model: string): string | null {
|
|
return isExplicitHighModel(model) ? model.replace(/-high$/, "") : null
|
|
}
|
|
|
|
function parseUserFallbackModel(fallbackModel: string): {
|
|
baseModel: string
|
|
providerHint?: string[]
|
|
variant?: string
|
|
} | undefined {
|
|
const normalizedFallback = normalizeModel(fallbackModel)
|
|
if (!normalizedFallback) {
|
|
return undefined
|
|
}
|
|
|
|
const parsedFullModel = parseModelString(normalizedFallback)
|
|
if (parsedFullModel) {
|
|
return {
|
|
baseModel: `${parsedFullModel.providerID}/${parsedFullModel.modelID}`,
|
|
providerHint: [parsedFullModel.providerID],
|
|
variant: parsedFullModel.variant,
|
|
}
|
|
}
|
|
|
|
const parsedModel = parseVariantFromModelID(normalizedFallback)
|
|
if (!parsedModel.modelID) {
|
|
return undefined
|
|
}
|
|
|
|
return {
|
|
baseModel: parsedModel.modelID,
|
|
variant: parsedModel.variant,
|
|
}
|
|
}
|
|
|
|
|
|
export function resolveModelForDelegateTask(input: {
|
|
userModel?: string
|
|
userFallbackModels?: string[]
|
|
categoryDefaultModel?: string
|
|
isUserConfiguredCategoryModel?: boolean
|
|
fallbackChain?: FallbackEntry[]
|
|
availableModels: Set<string>
|
|
systemDefaultModel?: string
|
|
}): { model: string; variant?: string; fallbackEntry?: FallbackEntry; matchedFallback?: boolean } | { skipped: true } | undefined {
|
|
const userModel = normalizeModel(input.userModel)
|
|
if (userModel) {
|
|
return { model: userModel }
|
|
}
|
|
|
|
const connectedProviders = input.availableModels.size === 0 ? readConnectedProvidersCache() : null
|
|
|
|
// Before provider cache is created (first run), skip model resolution entirely.
|
|
// OpenCode will use its system default model when no model is specified in the prompt.
|
|
if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) {
|
|
return { skipped: true }
|
|
}
|
|
|
|
const categoryDefault = normalizeModel(input.categoryDefaultModel)
|
|
const explicitHighBaseModel = categoryDefault ? getExplicitHighBaseModel(categoryDefault) : null
|
|
const explicitHighModel = explicitHighBaseModel ? categoryDefault : undefined
|
|
if (categoryDefault) {
|
|
if (input.isUserConfiguredCategoryModel) {
|
|
log("[resolveModelForDelegateTask] using user-configured category model (bypass validation)", {
|
|
categoryDefaultModel: categoryDefault,
|
|
})
|
|
return { model: categoryDefault }
|
|
}
|
|
|
|
if (input.availableModels.size === 0) {
|
|
const categoryProvider = categoryDefault.includes("/") ? categoryDefault.split("/")[0] : undefined
|
|
if (!connectedProviders || !categoryProvider || connectedProviders.includes(categoryProvider)) {
|
|
return { model: categoryDefault }
|
|
}
|
|
|
|
log("[resolveModelForDelegateTask] skipping disconnected category default on cold cache", {
|
|
categoryDefault,
|
|
connectedProviders,
|
|
})
|
|
}
|
|
|
|
const parts = categoryDefault.split("/")
|
|
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
|
|
const match = fuzzyMatchModel(categoryDefault, input.availableModels, providerHint)
|
|
if (match) {
|
|
if (isExplicitHighModel(categoryDefault) && match !== categoryDefault) {
|
|
return { model: categoryDefault }
|
|
}
|
|
|
|
return { model: match }
|
|
}
|
|
}
|
|
|
|
const userFallbackModels = input.userFallbackModels
|
|
if (userFallbackModels && userFallbackModels.length > 0) {
|
|
if (input.availableModels.size === 0) {
|
|
for (const fallbackModel of userFallbackModels) {
|
|
const parsedFallback = parseUserFallbackModel(fallbackModel)
|
|
if (!parsedFallback) continue
|
|
|
|
if (
|
|
connectedProviders &&
|
|
parsedFallback.providerHint &&
|
|
!parsedFallback.providerHint.some((provider) => connectedProviders.includes(provider))
|
|
) {
|
|
continue
|
|
}
|
|
|
|
return { model: parsedFallback.baseModel, variant: parsedFallback.variant, matchedFallback: true }
|
|
}
|
|
} else {
|
|
for (const fallbackModel of userFallbackModels) {
|
|
const parsedFallback = parseUserFallbackModel(fallbackModel)
|
|
if (!parsedFallback) continue
|
|
|
|
const match = fuzzyMatchModel(parsedFallback.baseModel, input.availableModels, parsedFallback.providerHint)
|
|
if (match) {
|
|
return { model: match, variant: parsedFallback.variant, matchedFallback: true }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const fallbackChain = input.fallbackChain
|
|
if (fallbackChain && fallbackChain.length > 0) {
|
|
if (input.availableModels.size === 0) {
|
|
if (connectedProviders) {
|
|
const connectedSet = new Set(connectedProviders)
|
|
for (const entry of fallbackChain) {
|
|
for (const provider of entry.providers) {
|
|
if (connectedSet.has(provider)) {
|
|
const transformedModelId = transformModelForProvider(provider, entry.model)
|
|
log("[resolveModelForDelegateTask] fallback chain resolved via connected provider", {
|
|
provider,
|
|
model: entry.model,
|
|
})
|
|
return { model: `${provider}/${transformedModelId}`, variant: entry.variant, fallbackEntry: entry, matchedFallback: true }
|
|
}
|
|
}
|
|
}
|
|
log("[resolveModelForDelegateTask] no connected provider found in fallback chain")
|
|
} else {
|
|
const first = fallbackChain[0]
|
|
const provider = first?.providers?.[0]
|
|
if (provider) {
|
|
const transformedModelId = transformModelForProvider(provider, first.model)
|
|
return { model: `${provider}/${transformedModelId}`, variant: first.variant, fallbackEntry: first, matchedFallback: true }
|
|
}
|
|
}
|
|
} else {
|
|
for (const entry of fallbackChain) {
|
|
for (const provider of entry.providers) {
|
|
const fullModel = `${provider}/${entry.model}`
|
|
const match = fuzzyMatchModel(fullModel, input.availableModels, [provider])
|
|
if (match) {
|
|
if (explicitHighModel && entry.variant === "high" && match === explicitHighBaseModel) {
|
|
return { model: explicitHighModel, fallbackEntry: entry, matchedFallback: true }
|
|
}
|
|
|
|
return { model: match, variant: entry.variant, fallbackEntry: entry, matchedFallback: true }
|
|
}
|
|
}
|
|
|
|
const crossProviderMatch = fuzzyMatchModel(entry.model, input.availableModels)
|
|
if (crossProviderMatch) {
|
|
if (explicitHighModel && entry.variant === "high" && crossProviderMatch === explicitHighBaseModel) {
|
|
return { model: explicitHighModel, fallbackEntry: entry, matchedFallback: true }
|
|
}
|
|
|
|
return { model: crossProviderMatch, variant: entry.variant, fallbackEntry: entry, matchedFallback: true }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const systemDefaultModel = normalizeModel(input.systemDefaultModel)
|
|
if (systemDefaultModel) {
|
|
return { model: systemDefaultModel }
|
|
}
|
|
|
|
return undefined
|
|
}
|