diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 70dca51a3..c28c066bd 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -53,6 +53,16 @@ export interface ScopedModel { explicitThinkingLevel: boolean; } +interface ThinkingSuffixOptions { + allowMaxAlias?: boolean; +} + +function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): ThinkingLevel | undefined { + const level = parseThinkingLevel(value); + if (level !== undefined) return level; + return options?.allowMaxAlias === true && value === "max" ? ThinkingLevel.XHigh : undefined; +} + /** * Split a trailing `:` thinking selector off a model pattern. * @@ -62,10 +72,14 @@ export interface ScopedModel { * role-alias callers pass `PREFIX_MODEL_ROLE.length` so the base is at least * as long as the `pi/` prefix. */ -function splitThinkingSuffix(pattern: string, minColonIndex = -1): { base: string; level?: ThinkingLevel } { +function splitThinkingSuffix( + pattern: string, + minColonIndex = -1, + options?: ThinkingSuffixOptions, +): { base: string; level?: ThinkingLevel } { const colonIdx = pattern.lastIndexOf(":"); if (colonIdx <= minColonIndex) return { base: pattern }; - const level = parseThinkingLevel(pattern.slice(colonIdx + 1)); + const level = parseThinkingSuffix(pattern.slice(colonIdx + 1), options); return level ? { base: pattern.slice(0, colonIdx), level } : { base: pattern }; } @@ -574,8 +588,10 @@ function parseModelPatternWithContext( return { model: exactMatch, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false }; } - // No match - try stripping a valid thinking suffix and recursing - const { base, level } = splitThinkingSuffix(pattern); + // No match - try stripping a valid thinking suffix and recursing. + // `max` is accepted only after the full pattern failed, so literal model IDs + // ending in `:max` keep winning over the alias. + const { base, level } = splitThinkingSuffix(pattern, -1, { allowMaxAlias: true }); if (level) { const result = parseModelPatternWithContext(base, availableModels, context, options); if (result.model) { diff --git a/packages/coding-agent/src/thinking.ts b/packages/coding-agent/src/thinking.ts index 96a25655f..01926df48 100644 --- a/packages/coding-agent/src/thinking.ts +++ b/packages/coding-agent/src/thinking.ts @@ -53,7 +53,6 @@ const THINKING_LEVEL_BY_SELECTOR: Readonly> = { [ThinkingLevel.Medium]: ThinkingLevel.Medium, [ThinkingLevel.High]: ThinkingLevel.High, [ThinkingLevel.XHigh]: ThinkingLevel.XHigh, - max: ThinkingLevel.XHigh, }; /** @@ -141,6 +140,7 @@ const AUTO_THINKING_METADATA: ConfiguredThinkingLevelMetadata = { */ export function parseConfiguredThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined { if (value === AUTO_THINKING) return AUTO_THINKING; + if (value === "max") return ThinkingLevel.XHigh; return parseThinkingLevel(value); } diff --git a/packages/coding-agent/test/auto-thinking-classifier.test.ts b/packages/coding-agent/test/auto-thinking-classifier.test.ts index 3a9ed8227..5ffc81e11 100644 --- a/packages/coding-agent/test/auto-thinking-classifier.test.ts +++ b/packages/coding-agent/test/auto-thinking-classifier.test.ts @@ -3,7 +3,6 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { Effort } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { parseDifficultyBucket, parseDifficultyLevel } from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier"; -import { parseModelString } from "@oh-my-pi/pi-coding-agent/config/model-resolver"; import { AUTO_THINKING, clampAutoThinkingEffort, @@ -44,14 +43,9 @@ describe("auto thinking classifier helpers", () => { expect(clampAutoThinkingEffort(model, Effort.Minimal)).toBe(Effort.Low); }); - it("accepts max as the top thinking selector alias", () => { + it("accepts max as the top configured thinking alias", () => { expect(parseEffort("max")).toBe(Effort.XHigh); - expect(parseThinkingLevel("max")).toBe(ThinkingLevel.XHigh); + expect(parseThinkingLevel("max")).toBeUndefined(); expect(parseConfiguredThinkingLevel("max")).toBe(ThinkingLevel.XHigh); - expect(parseModelString("deepseek/deepseek-v4-pro:max")).toEqual({ - provider: "deepseek", - id: "deepseek-v4-pro", - thinkingLevel: ThinkingLevel.XHigh, - }); }); }); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 91513f0e8..8fcba258c 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -96,6 +96,37 @@ const mockOpenRouterModels: Model[] = [ }), ]; +const mockMaxSuffixModels: Model[] = [ + buildModel({ + id: "coding-router", + name: "NanoGPT Coding Router", + api: "openai-completions", + provider: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1", + reasoning: true, + thinking: { + mode: "effort", + efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + }, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }), + buildModel({ + id: "coding-router:max", + name: "NanoGPT Coding Router Max", + api: "openai-completions", + provider: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }), +]; + const mockProviderOverlapModels: Model<"anthropic-messages">[] = [ buildModel({ id: "kimi-k2.5", @@ -288,6 +319,21 @@ describe("parseModelPattern", () => { expect(result.warning).toBeUndefined(); } }); + test("max aliases the highest thinking level after the literal pattern misses", () => { + const result = parseModelPattern("gpt-5.3-codex:max", allModels); + expect(result.model?.id).toBe("gpt-5.3-codex"); + expect(result.thinkingLevel).toBe(Effort.XHigh); + expect(result.explicitThinkingLevel).toBe(true); + expect(result.warning).toBeUndefined(); + }); + + test("literal model ids ending in max win over the thinking alias", () => { + const result = parseModelPattern("nanogpt/coding-router:max", mockMaxSuffixModels); + expect(result.model?.id).toBe("coding-router:max"); + expect(result.thinkingLevel).toBeUndefined(); + expect(result.explicitThinkingLevel).toBe(false); + expect(result.warning).toBeUndefined(); + }); }); describe("patterns with invalid thinking levels", () => { @@ -856,6 +902,19 @@ describe("resolveModelScope", () => { "github-copilot/anthropic/claude-sonnet-4.5", ]); }); + test("preserves literal :max in scoped-model globs", async () => { + const registry = { + getAvailable: () => mockMaxSuffixModels, + getCanonicalVariants: (_id: string, _opts?: unknown): CanonicalModelVariant[] => [], + }; + + const scoped = await resolveModelScope(["nanogpt/*:max"], registry); + + expect(scoped).toHaveLength(1); + expect(scoped[0].model.id).toBe("coding-router:max"); + expect(scoped[0].thinkingLevel).toBeUndefined(); + expect(scoped[0].explicitThinkingLevel).toBe(false); + }); }); describe("parseModelString", () => { @@ -1085,6 +1144,11 @@ describe("filterAvailableModelsByEnabledPatterns", () => { expect(result).toHaveLength(1); expect(result[0].provider).toBe("anthropic"); }); + test("preserves literal :max in enabledModels globs", () => { + const result = filterAvailableModelsByEnabledPatterns(mockMaxSuffixModels, ["nanogpt/*:max"], registry); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("coding-router:max"); + }); test("evaluates glob patterns against bare model id", () => { const result = filterAvailableModelsByEnabledPatterns(models, ["claude-*"], registry);