diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.ts b/src/features/claude-code-agent-loader/claude-model-mapper.ts index 7737de5f7..f0c0f9a86 100644 --- a/src/features/claude-code-agent-loader/claude-model-mapper.ts +++ b/src/features/claude-code-agent-loader/claude-model-mapper.ts @@ -11,6 +11,7 @@ const CLAUDE_CODE_ALIAS_MAP = new Map([ function mapClaudeModelString(model: string | undefined): string | undefined { if (!model) return undefined + if (typeof model !== "string") return undefined const trimmed = model.trim() if (trimmed.length === 0) return undefined diff --git a/src/shared/fallback-chain-from-models.test.ts b/src/shared/fallback-chain-from-models.test.ts index 16690d12c..94c6851ee 100644 --- a/src/shared/fallback-chain-from-models.test.ts +++ b/src/shared/fallback-chain-from-models.test.ts @@ -395,3 +395,65 @@ describe("findMostSpecificFallbackEntry", () => { }) }) }) + +// Regression: type-guard against non-string model field (issue #4145). +// Crash signature was `model.trim is not a function` aborting session.processor +// for every provider when a caller forwarded a FallbackModelObject where a +// plain string was expected. +describe("parseFallbackModelEntry: non-string input (issue #4145)", () => { + test("returns undefined when caller forwards an object instead of a string", () => { + //#given + const wrong = { model: "anthropic/claude-opus-4-7", variant: "high" } as unknown as string + + //#when + const parsed = parseFallbackModelEntry(wrong, "anthropic") + + //#then: must not throw, must reject the malformed entry + expect(parsed).toBeUndefined() + }) + + test("returns undefined for null and undefined", () => { + //#given + const nullInput = null as unknown as string + const undefinedInput = undefined as unknown as string + + //#when / #then + expect(parseFallbackModelEntry(nullInput, "anthropic")).toBeUndefined() + expect(parseFallbackModelEntry(undefinedInput, "anthropic")).toBeUndefined() + }) + + test("parseFallbackModelObjectEntry returns undefined when nested model field is non-string", () => { + //#given: FallbackModelObject whose .model was somehow forwarded as a nested + //object instead of a flat string (issue #4145 reproduction). + const malformedObject = { + model: ({ model: "claude-opus-4-7" } as unknown) as string, + variant: "high", + } + + //#when + const parsed = parseFallbackModelObjectEntry(malformedObject, "anthropic") + + //#then: must not throw, must reject the malformed entry + expect(parsed).toBeUndefined() + }) + + test("buildFallbackChainFromModels skips entries whose model is a number", () => { + //#given + const fallbackModels = [ + "openai/gpt-5.5", + (42 as unknown) as string, + ] + + //#when + const chain = buildFallbackChainFromModels(fallbackModels, "openai") + + //#then: only the valid string survives, the number is dropped without crashing + expect(chain).toEqual([ + { + providers: ["openai"], + model: "gpt-5.5", + variant: undefined, + }, + ]) + }) +}) diff --git a/src/shared/fallback-chain-from-models.ts b/src/shared/fallback-chain-from-models.ts index 12e615d76..248250e22 100644 --- a/src/shared/fallback-chain-from-models.ts +++ b/src/shared/fallback-chain-from-models.ts @@ -4,6 +4,9 @@ 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: "" } @@ -33,6 +36,7 @@ export function parseFallbackModelEntry( contextProviderID: string | undefined, defaultProviderID = "opencode", ): FallbackEntry | undefined { + if (typeof model !== "string") return undefined const trimmed = model.trim() if (!trimmed) return undefined diff --git a/src/shared/model-string-parser.ts b/src/shared/model-string-parser.ts index 220bbd880..b10cd989d 100644 --- a/src/shared/model-string-parser.ts +++ b/src/shared/model-string-parser.ts @@ -11,6 +11,9 @@ const KNOWN_VARIANTS = new Set([ ]) export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } { + if (typeof rawModelID !== "string") { + return { modelID: "" } + } const trimmedModelID = rawModelID.trim() if (!trimmedModelID) { return { modelID: "" } @@ -38,6 +41,7 @@ export function parseVariantFromModelID(rawModelID: string): { modelID: string; 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 diff --git a/src/tools/delegate-task/model-string-parser.ts b/src/tools/delegate-task/model-string-parser.ts index 820bb3cc3..a932ccd50 100644 --- a/src/tools/delegate-task/model-string-parser.ts +++ b/src/tools/delegate-task/model-string-parser.ts @@ -11,6 +11,9 @@ const KNOWN_VARIANTS = new Set([ ]) export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } { + if (typeof rawModelID !== "string") { + return { modelID: "" } + } const trimmedModelID = rawModelID.trim() if (!trimmedModelID) { return { modelID: "" } @@ -38,6 +41,7 @@ export function parseVariantFromModelID(rawModelID: string): { modelID: string; 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