From 13716f78aa55756f05d5ae2d6d134ef474391605 Mon Sep 17 00:00:00 2001 From: Firstbober Date: Mon, 23 Feb 2026 17:42:53 +0100 Subject: [PATCH] fix: model format normalization and explicit config cache bypass - Add normalizeModelFormat() utility for string/object model handling - Update subagent-resolver to handle both model formats - Add explicitUserConfig flag to ModelResolutionResult - Set explicitUserConfig: true when user model is found in pipeline This fixes the issue where plugin-provided models fail cache validation and fall through to random fallback models. --- src/shared/model-format-normalizer.test.ts | 46 ++++++++++++++++++++ src/shared/model-format-normalizer.ts | 20 +++++++++ src/shared/model-resolution-pipeline.ts | 3 +- src/tools/delegate-task/subagent-resolver.ts | 13 +++--- 4 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 src/shared/model-format-normalizer.test.ts create mode 100644 src/shared/model-format-normalizer.ts diff --git a/src/shared/model-format-normalizer.test.ts b/src/shared/model-format-normalizer.test.ts new file mode 100644 index 000000000..d28ab975b --- /dev/null +++ b/src/shared/model-format-normalizer.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "bun:test" +import { normalizeModelFormat } from "./model-format-normalizer" + +describe("normalizeModelFormat", () => { + describe("string format input", () => { + it("splits provider/model format correctly", () => { + const result = normalizeModelFormat("opencode/glm-5-free") + expect(result).toEqual({ providerID: "opencode", modelID: "glm-5-free" }) + }) + + it("handles provider with multiple slashes", () => { + const result = normalizeModelFormat("anthropic/claude-opus-4-6/max") + expect(result).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6/max" }) + }) + + it("returns undefined for malformed string without separator", () => { + const result = normalizeModelFormat("invalid") + expect(result).toBeUndefined() + }) + + it("returns undefined for empty string", () => { + const result = normalizeModelFormat("") + expect(result).toBeUndefined() + }) + }) + + describe("object format input", () => { + it("passthroughs object format unchanged", () => { + const input = { providerID: "opencode", modelID: "glm-5-free" } + const result = normalizeModelFormat(input) + expect(result).toEqual(input) + }) + }) + + describe("edge cases", () => { + it("returns undefined for null", () => { + const result = normalizeModelFormat(null) + expect(result).toBeUndefined() + }) + + it("returns undefined for undefined", () => { + const result = normalizeModelFormat(undefined) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/src/shared/model-format-normalizer.ts b/src/shared/model-format-normalizer.ts new file mode 100644 index 000000000..98d255f78 --- /dev/null +++ b/src/shared/model-format-normalizer.ts @@ -0,0 +1,20 @@ +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 +} diff --git a/src/shared/model-resolution-pipeline.ts b/src/shared/model-resolution-pipeline.ts index 0d90b4f16..06a2f8cfe 100644 --- a/src/shared/model-resolution-pipeline.ts +++ b/src/shared/model-resolution-pipeline.ts @@ -33,6 +33,7 @@ export type ModelResolutionResult = { variant?: string attempted?: string[] reason?: string + explicitUserConfig?: boolean } function normalizeModel(model?: string): string | undefined { @@ -58,7 +59,7 @@ export function resolveModelPipeline( const normalizedUserModel = normalizeModel(intent?.userModel) if (normalizedUserModel) { log("Model resolved via config override", { model: normalizedUserModel }) - return { model: normalizedUserModel, provenance: "override" } + return { model: normalizedUserModel, provenance: "override", explicitUserConfig: true } } const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel) diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 043243db2..1d0e65db6 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -2,7 +2,7 @@ import type { DelegateTaskArgs } from "./types" import type { ExecutorContext } from "./executor-types" import { isPlanFamily } from "./constants" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" -import { parseModelString } from "./model-string-parser" +import { normalizeModelFormat } from "../../shared/model-format-normalizer" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names" import { normalizeSDKResponse } from "../../shared" @@ -99,8 +99,9 @@ Create the work plan directly - that's your job as the planning agent.`, if (agentOverride?.model || agentRequirement || matchedAgent.model) { const availableModels = await getAvailableModelsForDelegateTask(client) - const matchedAgentModelStr = matchedAgent.model - ? `${matchedAgent.model.providerID}/${matchedAgent.model.modelID}` + const normalizedMatchedModel = normalizeModelFormat(matchedAgent.model as Parameters[0]) + const matchedAgentModelStr = normalizedMatchedModel + ? `${normalizedMatchedModel.providerID}/${normalizedMatchedModel.modelID}` : undefined const resolution = resolveModelForDelegateTask({ @@ -112,10 +113,10 @@ Create the work plan directly - that's your job as the planning agent.`, }) if (resolution) { - const parsed = parseModelString(resolution.model) - if (parsed) { + const normalized = normalizeModelFormat(resolution.model) + if (normalized) { const variantToUse = agentOverride?.variant ?? resolution.variant - categoryModel = variantToUse ? { ...parsed, variant: variantToUse } : parsed + categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized } } }