Files
oh-my-pi/packages/ai/test/model-thinking.test.ts
T
2026-05-12 04:09:33 +02:00

483 lines
14 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import {
applyGeneratedModelPolicies,
clampThinkingLevelForModel,
Effort,
enrichModelThinking,
linkOpenAIPromotionTargets,
mapEffortToAnthropicAdaptiveEffort,
mapEffortToGoogleThinkingLevel,
requireSupportedEffort,
} from "@oh-my-pi/pi-ai/model-thinking";
import type { Api, Model, Provider } from "@oh-my-pi/pi-ai/types";
function createModel<TApi extends Api>(overrides: {
id: string;
api: TApi;
provider: Provider;
reasoning?: boolean;
}): Model<TApi> {
return enrichModelThinking({
id: overrides.id,
name: overrides.id,
api: overrides.api,
provider: overrides.provider,
baseUrl: "",
reasoning: overrides.reasoning ?? true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
});
}
describe("model thinking metadata", () => {
it("stores supported efforts for Codex mini in model metadata", () => {
const model = createModel({
id: "gpt-5.1-codex-mini",
api: "openai-codex-responses",
provider: "openai-codex",
});
expect(model.thinking).toEqual({
mode: "effort",
minLevel: Effort.Medium,
maxLevel: Effort.High,
});
expect(() => requireSupportedEffort(model, Effort.Low)).toThrow(/Supported efforts: medium, high/);
expect(() => requireSupportedEffort(model, Effort.XHigh)).toThrow(/Supported efforts: medium, high/);
});
it("stores xhigh support directly in metadata for GPT-5.2", () => {
const model = createModel({
id: "gpt-5.2-codex",
api: "openai-codex-responses",
provider: "openai-codex",
});
expect(model.thinking).toEqual({
mode: "effort",
minLevel: Effort.Low,
maxLevel: Effort.XHigh,
});
expect(requireSupportedEffort(model, Effort.XHigh)).toBe(Effort.XHigh);
});
it("maps Gemini 3 Pro only for supported levels", () => {
const model = createModel({
id: "gemini-3-pro-preview",
api: "google-generative-ai",
provider: "google",
});
expect(model.thinking).toEqual({
mode: "google-level",
minLevel: Effort.Low,
maxLevel: Effort.High,
levels: [Effort.Low, Effort.High],
});
expect(mapEffortToGoogleThinkingLevel(model, Effort.Low)).toBe("LOW");
expect(mapEffortToGoogleThinkingLevel(model, Effort.High)).toBe("HIGH");
expect(() => mapEffortToGoogleThinkingLevel(model, Effort.Medium)).toThrow(/not supported/);
});
it("encodes anthropic transport mode in metadata", () => {
const opus45 = createModel({
id: "claude-opus-4-5",
api: "anthropic-messages",
provider: "anthropic",
});
const opus46 = createModel({
id: "claude-opus-4.6",
api: "anthropic-messages",
provider: "anthropic",
});
const opus47 = createModel({
id: "claude-opus-4.7",
api: "anthropic-messages",
provider: "anthropic",
});
const opus47Bedrock = createModel({
id: "us.anthropic.claude-opus-4-7",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
});
const sonnet46 = createModel({
id: "claude-sonnet-4.6",
api: "anthropic-messages",
provider: "anthropic",
});
expect(opus45.thinking?.mode).toBe("anthropic-budget-effort");
expect(opus46.thinking?.mode).toBe("anthropic-adaptive");
expect(sonnet46.thinking?.mode).toBe("anthropic-adaptive");
expect(opus46.thinking).toEqual({
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.XHigh,
});
expect(sonnet46.thinking).toEqual({
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
});
// Opus 4.6 has no real xhigh level — pi-ai aliases XHigh to Anthropic's "max".
expect(mapEffortToAnthropicAdaptiveEffort(opus46, Effort.XHigh)).toBe("max");
// Opus 4.7 on Messages API sends the new literal "xhigh" level.
expect(mapEffortToAnthropicAdaptiveEffort(opus47, Effort.XHigh)).toBe("xhigh");
// Bedrock Converse is not the Messages API, so xhigh is not available there yet.
expect(mapEffortToAnthropicAdaptiveEffort(opus47Bedrock, Effort.XHigh)).toBe("max");
expect(() => mapEffortToAnthropicAdaptiveEffort(sonnet46, Effort.XHigh)).toThrow(/not supported/);
});
});
describe("generated model policies", () => {
it("refreshes thinking metadata and applies parsed catalog corrections", () => {
const models: Model<Api>[] = [
{
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://example.com",
reasoning: true,
thinking: {
mode: "budget",
minLevel: Effort.High,
maxLevel: Effort.High,
},
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 1.5, cacheWrite: 18.75 },
contextWindow: 1000000,
maxTokens: 32000,
},
{
id: "anthropic.claude-opus-4-6-v1:0",
name: "Claude Opus 4.6",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://example.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 1.5, cacheWrite: 18.75 },
contextWindow: 1000000,
maxTokens: 32000,
},
{
id: "gpt-5.2-codex",
name: "GPT-5.2 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://example.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 32000,
},
{
id: "gpt-5.4-mini",
name: "GPT-5.4 mini",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://example.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 32000,
priority: 2,
},
];
applyGeneratedModelPolicies(models);
expect(models[0]?.thinking).toEqual({
mode: "anthropic-budget-effort",
minLevel: Effort.Minimal,
maxLevel: Effort.XHigh,
});
expect(models[0]?.cost.cacheRead).toBe(0.5);
expect(models[0]?.cost.cacheWrite).toBe(6.25);
expect(models[1]?.thinking).toEqual({
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.XHigh,
});
expect(models[1]?.cost.cacheRead).toBe(0.5);
expect(models[1]?.cost.cacheWrite).toBe(6.25);
expect(models[1]?.contextWindow).toBe(1000000);
expect(models[2]?.contextWindow).toBe(272000);
expect(models[3]?.contextWindow).toBe(272000);
expect(models[3]?.priority).toBe(1);
});
it("normalizes Copilot generated fallback limits", () => {
const models: Model<Api>[] = [
{
...createModel({
id: "claude-opus-4.6",
api: "anthropic-messages",
provider: "github-copilot",
}),
contextWindow: 144000,
maxTokens: 64000,
},
{
...createModel({
id: "gpt-5.4-mini",
api: "openai-responses",
provider: "github-copilot",
}),
contextWindow: 400000,
maxTokens: 128000,
},
{
...createModel({
id: "grok-code-fast-1",
api: "openai-completions",
provider: "github-copilot",
}),
contextWindow: 128000,
maxTokens: 64000,
},
];
applyGeneratedModelPolicies(models);
expect(models[0]?.contextWindow).toBe(168000);
expect(models[0]?.maxTokens).toBe(32000);
expect(models[1]?.contextWindow).toBe(272000);
expect(models[1]?.maxTokens).toBe(128000);
expect(models[2]?.contextWindow).toBe(192000);
expect(models[2]?.maxTokens).toBe(64000);
});
it("links spark variants and gpt-5.5 to their context promotion targets", () => {
const models = [
createModel({
id: "gpt-5.3-codex-spark",
api: "openai-codex-responses",
provider: "openai-codex",
}),
createModel({
id: "gpt-5.5",
api: "openai-codex-responses",
provider: "openai-codex",
}),
createModel({
id: "gpt-5.4",
api: "openai-codex-responses",
provider: "openai-codex",
}),
];
linkOpenAIPromotionTargets(models);
expect(models[0]?.contextPromotionTarget).toBe("openai-codex/gpt-5.5");
expect(models[1]?.contextPromotionTarget).toBe("openai-codex/gpt-5.4");
});
it("sets freeform apply_patch metadata for first-party GPT-5 Responses models", () => {
const models: Model<Api>[] = [
createModel({
id: "gpt-5.4",
api: "openai-responses",
provider: "openai",
}),
createModel({
id: "gpt-5.3-codex-spark",
api: "openai-codex-responses",
provider: "openai-codex",
}),
{
...createModel({
id: "gpt-5.3-codex-spark",
api: "openai-responses",
provider: "opencode",
}),
applyPatchToolType: "freeform",
},
{
...createModel({
id: "gpt-5.4",
api: "openai-completions",
provider: "litellm",
}),
applyPatchToolType: "freeform",
},
];
applyGeneratedModelPolicies(models);
expect(models[0]?.applyPatchToolType).toBe("freeform");
expect(models[1]?.applyPatchToolType).toBe("freeform");
expect(models[2]?.applyPatchToolType).toBeUndefined();
expect(models[3]?.applyPatchToolType).toBeUndefined();
});
});
describe("model thinking runtime helpers", () => {
it("clamps from explicit metadata instead of inferring from model id", () => {
const model: Model<"openai-codex-responses"> = {
id: "custom-reasoner",
name: "Custom Reasoner",
api: "openai-codex-responses",
provider: "custom",
baseUrl: "https://example.com",
reasoning: true,
thinking: {
mode: "effort",
minLevel: Effort.Medium,
maxLevel: Effort.High,
},
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
};
expect(clampThinkingLevelForModel(model, Effort.Minimal)).toBe(Effort.Medium);
expect(clampThinkingLevelForModel(model, Effort.XHigh)).toBe(Effort.High);
expect(clampThinkingLevelForModel(model, Effort.High)).toBe(Effort.High);
});
it('forces "off" for non-reasoning models', () => {
const model = createModel({
id: "plain-model",
api: "openai-responses",
provider: "openai",
reasoning: false,
});
expect(clampThinkingLevelForModel(model, Effort.High)).toBeUndefined();
});
it("enables xhigh for openai-completions API (custom models)", () => {
const model = createModel({
id: "custom-model",
api: "openai-completions",
provider: "custom",
});
// openai-completions should support xhigh by default
expect(model.thinking?.maxLevel).toBe(Effort.XHigh);
expect(requireSupportedEffort(model, Effort.XHigh)).toBe(Effort.XHigh);
});
it("does not expose xhigh for binary-thinking openai-compat transports", () => {
const model = enrichModelThinking({
id: "glm-4.7",
name: "GLM-4.7",
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/v1",
reasoning: true,
compat: {
thinkingFormat: "zai",
},
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 32000,
} satisfies Model<"openai-completions">);
expect(model.thinking).toEqual({
mode: "effort",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
});
expect(requireSupportedEffort(model, Effort.High)).toBe(Effort.High);
expect(() => requireSupportedEffort(model, Effort.XHigh)).toThrow(
/Supported efforts: minimal, low, medium, high/,
);
});
it("derives binary-thinking fallback from resolved compat when catalog compat is partial", () => {
const model = enrichModelThinking({
id: "qwen/qwen3-32b",
name: "Qwen 3 32B",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
compat: {
supportsToolChoice: true,
},
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 32000,
} satisfies Model<"openai-completions">);
expect(model.thinking).toEqual({
mode: "effort",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
});
expect(requireSupportedEffort(model, Effort.High)).toBe(Effort.High);
expect(() => requireSupportedEffort(model, Effort.XHigh)).toThrow(
/Supported efforts: minimal, low, medium, high/,
);
});
it("enables xhigh for openai-responses and openai-codex-responses APIs", () => {
const responsesModel = createModel({
id: "custom-responses",
api: "openai-responses",
provider: "custom",
});
const codexModel = createModel({
id: "custom-codex",
api: "openai-codex-responses",
provider: "custom",
});
// Both should support xhigh
expect(responsesModel.thinking?.maxLevel).toBe(Effort.XHigh);
expect(codexModel.thinking?.maxLevel).toBe(Effort.XHigh);
expect(requireSupportedEffort(responsesModel, Effort.XHigh)).toBe(Effort.XHigh);
expect(requireSupportedEffort(codexModel, Effort.XHigh)).toBe(Effort.XHigh);
});
it("rejects reasoning models that are missing thinking metadata at runtime", () => {
const model = {
id: "broken-reasoner",
name: "Broken Reasoner",
api: "openai-responses",
provider: "custom",
baseUrl: "https://example.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
} as Model<"openai-responses">;
expect(() => requireSupportedEffort(model, Effort.High)).toThrow(/missing thinking metadata/);
});
it("drops empty thinking metadata so presence checks stay meaningful", () => {
const model = enrichModelThinking({
id: "plain-model",
name: "Plain Model",
api: "openai-responses",
provider: "custom",
baseUrl: "https://example.com",
reasoning: false,
thinking: {
mode: "effort",
minLevel: Effort.High,
maxLevel: Effort.Low,
},
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
} satisfies Model<"openai-responses">);
expect(model.thinking).toBeUndefined();
});
});