Files
oh-my-pi/packages/ai/test/issue-2315-repro.test.ts
T
roboomp 2d44d7ff71 fix(ai): overlay compat.reasoningEffortMap onto thinking.effortMap
After PR #2317 moved detected effort maps into model.thinking.effortMap,
the request builders stopped reading the active compat's reasoningEffortMap
at all. That dropped two surfaces:

  1) compat.whenThinking variants whose reasoningEffortMap was applied at
     buildModel time were silently ignored on thinking turns.
  2) Custom raw Model configs that author reasoningEffortMap on compat (no
     buildModel pass) had no path onto the wire.

Introduce a single resolveActiveEffortMap helper in both openai-completions
and openai-responses that overlays the active compat's reasoningEffortMap on
top of model.thinking.effortMap. Default catalog path is unchanged because
the resolved compat.reasoningEffortMap is empty.

Fixes #2315
2026-06-11 23:37:49 +00:00

147 lines
5.8 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions";
import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { Effort } from "@oh-my-pi/pi-catalog/effort";
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
const testContext: Context = {
messages: [{ role: "user", content: "hello", timestamp: 0 }],
};
function createSseResponse(events: unknown[]): Response {
const payload = `${events.map(event => `data: ${typeof event === "string" ? event : JSON.stringify(event)}`).join("\n\n")}\n\n`;
return new Response(payload, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
interface CaptureOptions {
disableReasoning?: boolean;
reasoning?: Effort;
}
async function capturePayload(
model: Model<"openai-completions">,
options: CaptureOptions,
): Promise<Record<string, unknown>> {
let payload: Record<string, unknown> | undefined;
const fetchMock: FetchImpl = Object.assign(
async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
payload = JSON.parse(typeof init?.body === "string" ? init.body : "{}") as Record<string, unknown>;
return createSseResponse([
{
id: "x",
object: "chat.completion.chunk",
created: 0,
model: model.id,
choices: [{ index: 0, delta: { content: "ok" } }],
},
{
id: "x",
object: "chat.completion.chunk",
created: 0,
model: model.id,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
},
"[DONE]",
]);
},
{ preconnect: fetch.preconnect },
);
await streamOpenAICompletions(model, testContext, {
apiKey: "test-key",
fetch: fetchMock,
disableReasoning: options.disableReasoning,
reasoning: options.reasoning,
}).result();
if (!payload) throw new Error("Expected request payload");
return payload;
}
describe("issue #2315 — MiniMax M2 / GPT-OSS catalog excludes unsupported reasoning_effort tiers", () => {
it("declares only low/medium/high for fireworks/minimax-m2.7 and disables reasoning with low", async () => {
const model = getBundledModel("fireworks", "minimax-m2.7") as Model<"openai-completions">;
expect(getSupportedEfforts(model)).toEqual([Effort.Low, Effort.Medium, Effort.High]);
const body = await capturePayload(model, { disableReasoning: true });
// Pre-fix the catalog included `minimal`, so the Fireworks compat map turned
// the auto-thinking classifier's disableReasoning request into `"none"`.
expect(body.reasoning_effort).toBe("low");
});
it("declares only low/medium/high for fireworks/gpt-oss-120b and disables reasoning with low", async () => {
const model = getBundledModel("fireworks", "gpt-oss-120b") as Model<"openai-completions">;
expect(getSupportedEfforts(model)).toEqual([Effort.Low, Effort.Medium, Effort.High]);
const body = await capturePayload(model, { disableReasoning: true });
expect(body.reasoning_effort).toBe("low");
});
it("normalizes stale cached MiniMax M2 thinking metadata before disableReasoning sends a request", async () => {
const base = getBundledModel("fireworks", "minimax-m2.7") as Model<"openai-completions">;
const model = buildModel({
id: base.id,
name: base.name,
api: "openai-completions",
provider: "fireworks",
baseUrl: base.baseUrl,
reasoning: true,
thinking: {
mode: "effort",
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
effortMap: { minimal: "none", xhigh: "max" },
},
input: base.input,
cost: base.cost,
contextWindow: base.contextWindow,
maxTokens: base.maxTokens,
});
expect(getSupportedEfforts(model)).toEqual([Effort.Low, Effort.Medium, Effort.High]);
expect(model.thinking?.effortMap).toBeUndefined();
const body = await capturePayload(model, { disableReasoning: true });
expect(body.reasoning_effort).toBe("low");
});
it("preserves a custom compat.whenThinking reasoningEffortMap override at request time", async () => {
const base = getBundledModel("fireworks", "minimax-m2.7") as Model<"openai-completions">;
// Custom proxy: minimal stays clamped to low, xhigh is force-mapped to high
// via a whenThinking variant — the swap was getting lost when this PR
// moved effort maps onto `model.thinking.effortMap`.
const model = buildModel({
id: base.id,
name: base.name,
api: "openai-completions",
provider: "fireworks",
baseUrl: base.baseUrl,
reasoning: true,
thinking: { mode: "effort", efforts: [Effort.Low, Effort.Medium, Effort.High] },
compat: { whenThinking: { reasoningEffortMap: { high: "max" } } },
input: base.input,
cost: base.cost,
contextWindow: base.contextWindow,
maxTokens: base.maxTokens,
});
const body = await capturePayload(model, { reasoning: Effort.High });
expect(body.reasoning_effort).toBe("max");
});
it("preserves low/medium/high passthrough on fireworks/minimax-m2.7", async () => {
const model = getBundledModel("fireworks", "minimax-m2.7") as Model<"openai-completions">;
const lowBody = await capturePayload(model, { reasoning: Effort.Low });
expect(lowBody.reasoning_effort).toBe("low");
const medBody = await capturePayload(model, { reasoning: Effort.Medium });
expect(medBody.reasoning_effort).toBe("medium");
const highBody = await capturePayload(model, { reasoning: Effort.High });
expect(highBody.reasoning_effort).toBe("high");
});
it("keeps the Fireworks-wide minimal→none mapping for non-restricted models (glm-5.1)", async () => {
const model = getBundledModel("fireworks", "glm-5.1") as Model<"openai-completions">;
const body = await capturePayload(model, { disableReasoning: true });
expect(body.reasoning_effort).toBe("none");
});
});