From edc4f9ecb4202fdd0ae08764d1366e71051e0039 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 19 Aug 2026 01:15:14 +0200 Subject: [PATCH] fix: make provider-lock catalog check case-insensitive The matcher compares selector ids case-insensitively, but the lock's bundled-catalog lookup was exact-case: Anthropic/Claude-Opus-5 exact- matched OpenRouter's flat id while getBundledModel("Anthropic", ...) missed, silently re-enabling the aggregator shadow the lock exists to prevent. Scan the named provider's bundled ids case-insensitively. --- packages/coding-agent/src/config/model-resolver.ts | 13 ++++++++----- packages/coding-agent/test/model-resolver.test.ts | 7 +++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 6963269d7..4682ea532 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -22,7 +22,7 @@ import { modelMatchesHost } from "@oh-my-pi/pi-catalog/hosts"; import { buildModelProviderPriorityRank } from "@oh-my-pi/pi-catalog/identity"; import { stripThinkingVariantToken } from "@oh-my-pi/pi-catalog/identity/family"; import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking"; -import { type GeneratedProvider, getBundledModel, modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; +import { type GeneratedProvider, getBundledModels, modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models"; import { resolveBareVariantAlias, resolveVariantAlias } from "@oh-my-pi/pi-catalog/variant-collapse"; import { fuzzyMatch } from "@oh-my-pi/pi-tui"; @@ -619,12 +619,15 @@ function isProviderLockedCrossMatch(pattern: string, matchedModel: Model): if (slashIdx <= 0) { return false; } - const provider = pattern.slice(0, slashIdx); - const modelId = pattern.slice(slashIdx + 1); - if (matchedModel.provider.toLowerCase() === provider.toLowerCase()) { + const provider = pattern.slice(0, slashIdx).toLowerCase(); + const modelId = pattern.slice(slashIdx + 1).toLowerCase(); + if (matchedModel.provider.toLowerCase() === provider) { return false; } - return getBundledModel(provider as GeneratedProvider, modelId) !== undefined; + // Case-insensitive on both halves: the surrounding matcher lowercases the + // selector before comparing ids, so the lock must not evaporate on case + // variance (catalog provider keys are lowercase; model ids may not be). + return getBundledModels(provider as GeneratedProvider).some(m => m.id.toLowerCase() === modelId); } /** diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 8a34a3174..83b8af0e4 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -704,6 +704,13 @@ describe("parseModelPattern", () => { expect(result.model).toBeUndefined(); }); + test("the provider lock is case-insensitive (Anthropic/Claude-Opus-5 still fails closed)", () => { + // The matcher compares ids case-insensitively, so the lock must too — + // otherwise case variance silently re-enables the aggregator shadow. + const result = parseModelPattern("Anthropic/Claude-Opus-5", [openRouterOpus5]); + expect(result.model).toBeUndefined(); + }); + test("anthropic/claude-opus-5 resolves to the anthropic provider when it is available", () => { const result = parseModelPattern("anthropic/claude-opus-5", [anthropicOpus5, openRouterOpus5]); expect(result.model?.provider).toBe("anthropic");