fix(coding-agent): preserved max model globs
Kept max as a selector alias only after literal model lookup misses and left scoped globs matching literal :max model ids.\n\nFixes #2727
This commit is contained in:
@@ -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 `:<level>` 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) {
|
||||
|
||||
@@ -53,7 +53,6 @@ const THINKING_LEVEL_BY_SELECTOR: Readonly<Record<string, ThinkingLevel>> = {
|
||||
[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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,37 @@ const mockOpenRouterModels: Model<Api>[] = [
|
||||
}),
|
||||
];
|
||||
|
||||
const mockMaxSuffixModels: Model<Api>[] = [
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user