Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/provider/deepseek-v4-effort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, test, expect } from "bun:test";
import {
DEEPSEEK_V4_EFFORTS,
isDeepSeekV4Model,
mapV4Effort,
} from "./deepseek-v4-effort.js";

describe("isDeepSeekV4Model", () => {
test.each([
"deepseek-v4",
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v4-flash-vision-exp",
])("true for V4 id %s", (model) => {
expect(isDeepSeekV4Model(model)).toBe(true);
});
test.each([
"deepseek-v3",
"deepseek-r1",
"gpt-5",
"kimi-k2",
"claude-sonnet-4.5",
"glm-5.3",
"grok-4.6",
"some-proxy-deepseek-v4-pro",
])("false for non-V4 id %s", (model) => {
expect(isDeepSeekV4Model(model)).toBe(false);
});
});

describe("DEEPSEEK_V4_EFFORTS", () => {
test("ladder is none/xhigh/max", () => {
expect(DEEPSEEK_V4_EFFORTS).toEqual(["none", "xhigh", "max"]);
});
});

describe("mapV4Effort", () => {
test.each([
["xhigh", "xhigh"],
["max", "max"],
] as const)("maps %s raw to %s", (effort, wire) => {
expect(mapV4Effort(effort)).toBe(wire);
});
test("maps none to null (drop the wire effort)", () => {
expect(mapV4Effort("none")).toBeNull();
});
test.each(["low", "medium", "high"] as const)(
"maps off-ladder %s to undefined",
(effort) => {
expect(mapV4Effort(effort)).toBeUndefined();
},
);
});
30 changes: 30 additions & 0 deletions src/provider/deepseek-v4-effort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ReasoningEffort } from "../agent/profile-types.js";

// DeepSeek V4 effort translator shared by the adapter and the effort picker.
// V4 ships under several ids across two catalogs — deepseek-v4-pro/-flash/
// -flash-vision-exp — so the family is matched by prefix rather than an exact
// id list, mirroring the grok/kimi/muse-spark prefix predicates elsewhere.
export function isDeepSeekV4Model(model: string): boolean {
return /^deepseek-v4/i.test(model.trim());
}

// The native V4 rungs. `none` is a toggle, never a wire effort; `xhigh`/`max`
// are the wire rungs sent raw (the API docs' low/high/max is the gateway
// normalization layer, not what the model/encoder honors).
export const DEEPSEEK_V4_EFFORTS: readonly ReasoningEffort[] = [
"none",
"xhigh",
"max",
];

// Translate a V4 effort to its raw wire value: `none` maps to null (the caller
// drops `reasoning_effort`, since `reasoning_effort:"none"` is illegal on the
// wire), `xhigh`/`max` pass through verbatim, and any off-ladder value returns
// undefined so the caller leaves effort untouched.
export function mapV4Effort(
effort: ReasoningEffort,
): "xhigh" | "max" | null | undefined {
if (effort === "none") return null;
if (effort === "xhigh" || effort === "max") return effort;
return undefined;
}
81 changes: 81 additions & 0 deletions src/provider/openai-compatible-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,84 @@ describe("openai-compatible adapter reasoning_content handling", () => {
expect(assistant?.["reasoning_content"]).toBe("ponder");
});
});

describe("openai-compatible adapter V4 effort wiring", () => {
function bodyForModel(
model: string,
options: InferenceOptions,
): Record<string, unknown> {
const adapter = createOpenAICompatibleAdapter({
...source,
model,
} as typeof source);
const built = adapter.buildRequest(messages, model, options);
return JSON.parse(built.body) as Record<string, unknown>;
}

test("none drops reasoning_effort and sets thinking off", () => {
const body = bodyForModel("deepseek-v4-pro", {
providerOptions: { reasoning_effort: "none" },
} as InferenceOptions);
expect("reasoning_effort" in body).toBe(false);
expect(body["chat_template_kwargs"]).toEqual({ thinking: false });
});

test.each(["xhigh", "max"] as const)(
"maps ladder %s through verbatim",
(effort) => {
const body = bodyForModel("deepseek-v4-pro", {
providerOptions: { reasoning_effort: effort },
} as InferenceOptions);
expect(body["reasoning_effort"]).toBe(effort);
},
);

test.each(["low", "medium", "high"] as const)(
"leaves off-ladder %s untouched",
(effort) => {
const body = bodyForModel("deepseek-v4-pro", {
providerOptions: { reasoning_effort: effort },
} as InferenceOptions);
expect(body["reasoning_effort"]).toBe(effort);
},
);

describe("v4-effort is no-op for non-V4", () => {
const models = [
"gpt-5",
"gpt-5.1",
"kimi-k2",
"grok-4.6",
"claude-sonnet-4.5",
"glm-5.3",
"some-openai-compatible-model",
"deepseek-r1",
"deepseek-v3",
];

test.each(models)(
"merged body for %s is byte-identical to a manual base+merge (V4 step no-op)",
(model) => {
const base = bodyForModel(model, {} as InferenceOptions);
const withEffort = bodyForModel(model, {
providerOptions: { reasoning_effort: "high" },
} as InferenceOptions);
const expected = {
...base,
...({ reasoning_effort: "high" } as const),
};
expect(withEffort).toEqual(expected);
},
);

test.each(models)(
"merged body for %s carries reasoning_effort verbatim",
(model) => {
const body = bodyForModel(model, {
providerOptions: { reasoning_effort: "high" },
} as InferenceOptions);
expect(body["reasoning_effort"]).toBe("high");
},
);
});
});
31 changes: 30 additions & 1 deletion src/provider/openai-compatible-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { type BuiltRequest, type ProviderAdapter } from "@intx/inference";
import { createOpenAIAdapter } from "@intx/inference/providers";
import type { ReasoningEffort } from "../agent/profile-types.js";
import { isDeepSeekV4Model, mapV4Effort } from "./deepseek-v4-effort.js";
import { normalizeNullDeltaFields } from "./null-delta-fields.js";

// The stock OpenAI adapter builds the request body from a fixed set of fields
Expand Down Expand Up @@ -45,14 +47,41 @@ export function createOpenAICompatibleAdapter(
const providerOptions = options.providerOptions;
const hasProviderOptions =
providerOptions !== undefined && Object.keys(providerOptions).length > 0;
// Normalize the model once so every predicate below reads from the same
// lower-cased, trimmed string rather than re-normalizing per call.
const m = model.toLowerCase().trim();
// DeepSeek returns HTTP 400 if `reasoning_content` appears in input messages,
// whereas the base adapter emits it for any model with thinking enabled.
const stripReasoning = model.toLowerCase().includes("deepseek");
const stripReasoning = m.includes("deepseek");
needsDeepSeekPatch = stripReasoning;
const isV4 = isDeepSeekV4Model(m);
if (!hasProviderOptions && !stripReasoning) return ensureAccept(built);

const body = JSON.parse(built.body) as Record<string, unknown>;
if (hasProviderOptions) Object.assign(body, providerOptions);
// V4's ladder (none/xhigh/max) is translated here: `none` drops
// reasoning_effort and toggles the encoder's thinking off (it is illegal
// on the wire), while `xhigh`/`max` go out raw. Effort absent under V4 is
// left absent. Off-ladder efforts (low/medium/high) are left untouched.
if (isV4) {
if ("reasoning_effort" in body) {
const raw = body["reasoning_effort"] as ReasoningEffort;
const wireEffort = mapV4Effort(raw);
if (wireEffort === null) {
delete body["reasoning_effort"];
// Merge into any existing kwargs so provider-supplied keys survive.
const kwargs =
body["chat_template_kwargs"] !== null &&
typeof body["chat_template_kwargs"] === "object"
? (body["chat_template_kwargs"] as Record<string, unknown>)
: {};
kwargs.thinking = false;
Object.assign(body, { chat_template_kwargs: kwargs });
} else if (wireEffort !== undefined) {
body["reasoning_effort"] = wireEffort;
}
}
}
if (stripReasoning && Array.isArray(body["messages"])) {
for (const msg of body["messages"]) {
if (msg !== null && typeof msg === "object")
Expand Down
24 changes: 24 additions & 0 deletions src/provider/reasoning-effort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ describe("supportedEfforts", () => {
expect(supportedEfforts("glm-5.3")).toEqual(["low", "high", "max"]);
});

test("V4 model gets the native none/xhigh/max ladder", () => {
expect(supportedEfforts("deepseek-v4-pro")).toEqual([
"none",
"xhigh",
"max",
]);
});
test("pre-V4 deepseek-v3 stays on the unknown-model subset", () => {
expect(supportedEfforts("deepseek-v3")).toEqual(["low", "medium", "high"]);
});

// Every catalog Muse Spark id reaches supportedEfforts unnormalized, so
// they must all land on the same ladder — two ids pin the family rule.
test.each(["muse-spark-1.3-contributor", "muse-spark-1.3-contributor-free"])(
Expand Down Expand Up @@ -175,6 +186,15 @@ describe("validateEffort", () => {
expect(validateEffort("glm-5.3", "medium").ok).toBe(false);
expect(validateEffort("glm-5.3", "max")).toEqual({ ok: true });
});

test("V4 accepts none/xhigh/max and rejects medium (and low/high)", () => {
for (const effort of ["none", "xhigh", "max"] as const) {
expect(validateEffort("deepseek-v4-pro", effort)).toEqual({ ok: true });
}
for (const effort of ["medium", "low", "high"] as const) {
expect(validateEffort("deepseek-v4-pro", effort).ok).toBe(false);
}
});
});

describe("cycleReasoningEffort", () => {
Expand Down Expand Up @@ -448,6 +468,10 @@ describe("defaultEffortForModel", () => {
expect(defaultEffortForModel("glm-5.3")).toBe("max");
});

test("V4 model defaults to max", () => {
expect(defaultEffortForModel("deepseek-v4-pro")).toBe("max");
});

test("gpt-5 and o-series default to medium", () => {
expect(defaultEffortForModel("gpt-5")).toBe("medium");
expect(defaultEffortForModel("o4-mini")).toBe("medium");
Expand Down
8 changes: 8 additions & 0 deletions src/provider/reasoning-effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
// profile schema and the runtime cannot drift. Re-exported here for callers
// that already import from this module.
import { REASONING_EFFORTS as CANONICAL_EFFORTS } from "../agent/profile-types.js";
import {
DEEPSEEK_V4_EFFORTS,
isDeepSeekV4Model,
} from "./deepseek-v4-effort.js";

export const REASONING_EFFORTS = CANONICAL_EFFORTS;
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
Expand Down Expand Up @@ -186,6 +190,9 @@ export function supportedEfforts(
if (GLM_53_MODELS.includes(model)) {
return [...GLM_53_EFFORTS];
}
if (isDeepSeekV4Model(model)) {
return [...DEEPSEEK_V4_EFFORTS];
}
if (isMuseSparkModel(model)) {
return [...MUSE_SPARK_EFFORTS];
}
Expand Down Expand Up @@ -287,6 +294,7 @@ export function defaultEffortForModel(
}
if (model.startsWith("grok")) return pick("high");
if (GLM_53_MODELS.includes(model)) return pick("max");
if (isDeepSeekV4Model(model)) return pick("max");
if (isMuseSparkModel(model)) return pick("low");
if (!isCodex && supported.includes("none")) return "none";
if (isCodex || isKnownOpenAIReasoningModel(model)) return pick("medium");
Expand Down
Loading