mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): use Qwen thinking controls for token plan reasoning models
fixes #6998 #6951
This commit is contained in:
parent
4f0437e2d5
commit
4c1a0b92e5
3 changed files with 254 additions and 27 deletions
|
|
@ -265,6 +265,33 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = {
|
|||
high: "high",
|
||||
max: "max",
|
||||
} as const;
|
||||
const QWEN_TOKEN_PLAN_HIGH_MAX_THINKING_LEVEL_MAP = {
|
||||
minimal: null,
|
||||
low: null,
|
||||
medium: null,
|
||||
high: "high",
|
||||
xhigh: null,
|
||||
max: "max",
|
||||
} as const;
|
||||
const QWEN_TOKEN_PLAN_QWEN38_THINKING_LEVEL_MAP = {
|
||||
minimal: null,
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: null,
|
||||
xhigh: "xhigh",
|
||||
max: null,
|
||||
} as const;
|
||||
const QWEN_TOKEN_PLAN_REASONING_EFFORT_UNSUPPORTED_MODEL_IDS = new Set([
|
||||
"MiniMax-M2.5",
|
||||
"deepseek-v3.2",
|
||||
"kimi-k2.5",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.7-code",
|
||||
"qwen3.6-flash",
|
||||
"qwen3.6-plus",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
]);
|
||||
|
||||
const KIMI_K3_MAX_TOKENS = 131072;
|
||||
const KIMI_K3_COST = {
|
||||
|
|
@ -1971,6 +1998,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
thinkingFormat: "qwen",
|
||||
supportsDeveloperRole: false,
|
||||
supportsStore: false,
|
||||
supportsReasoningEffort: true,
|
||||
};
|
||||
const qwenTokenPlanVariants = [
|
||||
{
|
||||
|
|
@ -1992,6 +2020,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
for (const [modelId, model] of Object.entries(providerModels)) {
|
||||
const m = model as ModelsDevModel;
|
||||
if (m.tool_call !== true) continue;
|
||||
const supportsReasoningEffort = !QWEN_TOKEN_PLAN_REASONING_EFFORT_UNSUPPORTED_MODEL_IDS.has(modelId);
|
||||
|
||||
models.push({
|
||||
id: modelId,
|
||||
|
|
@ -1999,7 +2028,17 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
api: "openai-completions",
|
||||
provider,
|
||||
baseUrl,
|
||||
compat: qwenTokenPlanCompat,
|
||||
compat: supportsReasoningEffort
|
||||
? qwenTokenPlanCompat
|
||||
: { ...qwenTokenPlanCompat, supportsReasoningEffort: false },
|
||||
...(supportsReasoningEffort
|
||||
? {
|
||||
thinkingLevelMap:
|
||||
modelId === "qwen3.8-max-preview"
|
||||
? QWEN_TOKEN_PLAN_QWEN38_THINKING_LEVEL_MAP
|
||||
: QWEN_TOKEN_PLAN_HIGH_MAX_THINKING_LEVEL_MAP,
|
||||
}
|
||||
: {}),
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
|
|
@ -2274,7 +2313,12 @@ async function generateModels() {
|
|||
allModels.push(...antLingModels);
|
||||
|
||||
for (const candidate of allModels) {
|
||||
if (candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4")) {
|
||||
if (
|
||||
candidate.api === "openai-completions" &&
|
||||
candidate.id.includes("deepseek-v4") &&
|
||||
candidate.provider !== "qwen-token-plan" &&
|
||||
candidate.provider !== "qwen-token-plan-cn"
|
||||
) {
|
||||
const preservesNativeReasoningEffort = candidate.provider === "openrouter" || candidate.provider === "opencode";
|
||||
candidate.compat = {
|
||||
...candidate.compat,
|
||||
|
|
@ -2417,29 +2461,6 @@ async function generateModels() {
|
|||
});
|
||||
}
|
||||
|
||||
// Add qwen3.8-max-preview to Qwen Token Plan providers until models.dev includes it
|
||||
for (const qwenTpProvider of ["qwen-token-plan", "qwen-token-plan-cn"] as const) {
|
||||
if (!allModels.some((m) => m.provider === qwenTpProvider && m.id === "qwen3.8-max-preview")) {
|
||||
const baseUrl =
|
||||
qwenTpProvider === "qwen-token-plan"
|
||||
? "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"
|
||||
: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
|
||||
allModels.push({
|
||||
id: "qwen3.8-max-preview",
|
||||
name: "Qwen3.8 Max Preview",
|
||||
api: "openai-completions",
|
||||
provider: qwenTpProvider,
|
||||
baseUrl,
|
||||
compat: { thinkingFormat: "qwen", supportsDeveloperRole: false, supportsStore: false } satisfies OpenAICompletionsCompat,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 65536,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add "auto" alias for openrouter/auto
|
||||
if (!allModels.some(m => m.provider === "openrouter" && m.id === "auto")) {
|
||||
allModels.push({
|
||||
|
|
|
|||
|
|
@ -752,6 +752,12 @@ function buildParams(
|
|||
}
|
||||
} else if (compat.thinkingFormat === "qwen" && model.reasoning) {
|
||||
(params as any).enable_thinking = !!options?.reasoningEffort;
|
||||
if (options?.reasoningEffort && compat.supportsReasoningEffort) {
|
||||
const effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort;
|
||||
if (typeof effort === "string") {
|
||||
(params as any).reasoning_effort = effort;
|
||||
}
|
||||
}
|
||||
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
|
||||
(params as any).chat_template_kwargs = {
|
||||
enable_thinking: !!options?.reasoningEffort,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,42 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { getModels } from "../src/compat.ts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getModels, streamSimple } from "../src/compat.ts";
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
chat = {
|
||||
completions: {
|
||||
create: () => {
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
choices: [{ delta: {}, finish_reason: "stop" }],
|
||||
usage: {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 1,
|
||||
prompt_tokens_details: { cached_tokens: 0 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
|
||||
withResponse: () => Promise<{
|
||||
data: typeof stream;
|
||||
response: { status: number; headers: Headers };
|
||||
}>;
|
||||
};
|
||||
promise.withResponse = async () => ({
|
||||
data: stream,
|
||||
response: { status: 200, headers: new Headers() },
|
||||
});
|
||||
return promise;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { default: FakeOpenAI };
|
||||
});
|
||||
|
||||
const TEXT_MODELS = [
|
||||
"MiniMax-M2.5",
|
||||
|
|
@ -21,6 +58,33 @@ const TEXT_MODELS = [
|
|||
|
||||
const IMAGE_MODELS = ["qwen-image-2.0", "qwen-image-2.0-pro", "wan2.7-image", "wan2.7-image-pro"];
|
||||
|
||||
const QWEN_THINKING_MODELS = [
|
||||
"deepseek-v3.2",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"glm-5",
|
||||
"glm-5.1",
|
||||
"glm-5.2",
|
||||
"kimi-k2.5",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.7-code",
|
||||
"qwen3.6-flash",
|
||||
"qwen3.6-plus",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.8-max-preview",
|
||||
] as const;
|
||||
|
||||
const QWEN_THINKING_MODEL_CASES = (["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) =>
|
||||
QWEN_THINKING_MODELS.map((modelId) => ({ provider, modelId })),
|
||||
);
|
||||
|
||||
const QWEN_REASONING_EFFORT_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro", "glm-5", "glm-5.1", "glm-5.2"] as const;
|
||||
|
||||
const QWEN_REASONING_EFFORT_MODEL_CASES = (["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) =>
|
||||
QWEN_REASONING_EFFORT_MODELS.map((modelId) => ({ provider, modelId })),
|
||||
);
|
||||
|
||||
describe("Qwen Token Plan models", () => {
|
||||
it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)("exposes all text models on %s", (provider) => {
|
||||
const modelIds = getModels(provider).map((model) => model.id);
|
||||
|
|
@ -35,4 +99,140 @@ describe("Qwen Token Plan models", () => {
|
|||
expect(modelIds, `${provider} should not include ${excluded}`).not.toContain(excluded);
|
||||
}
|
||||
});
|
||||
|
||||
// docs: https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=api&commonbuy=1#/api/?type=model&url=3016807
|
||||
it.each(QWEN_THINKING_MODEL_CASES)(
|
||||
"sends Qwen thinking fields for $provider/$modelId",
|
||||
async ({ provider, modelId }) => {
|
||||
const model = getModels(provider).find((candidate) => candidate.id === modelId);
|
||||
expect(model).toBeDefined();
|
||||
if (!model) throw new Error(`Missing model: ${provider}/${modelId}`);
|
||||
|
||||
let payload: unknown;
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hi",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
reasoning: "high",
|
||||
onPayload: (params) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
expect(payload).toHaveProperty("enable_thinking", true);
|
||||
expect(payload).not.toHaveProperty("thinking");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(QWEN_REASONING_EFFORT_MODEL_CASES)(
|
||||
"exposes Qwen reasoning_effort levels for $provider/$modelId",
|
||||
({ provider, modelId }) => {
|
||||
const model = getModels(provider).find((candidate) => candidate.id === modelId);
|
||||
expect(model).toBeDefined();
|
||||
if (!model) throw new Error(`Missing model: ${provider}/${modelId}`);
|
||||
|
||||
expect(model.thinkingLevelMap).toMatchObject({
|
||||
minimal: null,
|
||||
low: null,
|
||||
medium: null,
|
||||
high: "high",
|
||||
xhigh: null,
|
||||
max: "max",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)(
|
||||
"exposes qwen3.8 reasoning_effort levels on %s",
|
||||
(provider) => {
|
||||
const model = getModels(provider).find((candidate) => candidate.id === "qwen3.8-max-preview");
|
||||
expect(model).toBeDefined();
|
||||
if (!model) throw new Error(`Missing model: ${provider}/qwen3.8-max-preview`);
|
||||
|
||||
expect(model.thinkingLevelMap).toMatchObject({
|
||||
minimal: null,
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: null,
|
||||
xhigh: "xhigh",
|
||||
max: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(QWEN_REASONING_EFFORT_MODEL_CASES)(
|
||||
"sends Qwen reasoning_effort for $provider/$modelId",
|
||||
async ({ provider, modelId }) => {
|
||||
const model = getModels(provider).find((candidate) => candidate.id === modelId);
|
||||
expect(model).toBeDefined();
|
||||
if (!model) throw new Error(`Missing model: ${provider}/${modelId}`);
|
||||
|
||||
let payload: unknown;
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hi",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
reasoning: "high",
|
||||
onPayload: (params) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
expect(payload).toHaveProperty("reasoning_effort", "high");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)(
|
||||
"sends qwen3.8 max reasoning_effort on %s",
|
||||
async (provider) => {
|
||||
const model = getModels(provider).find((candidate) => candidate.id === "qwen3.8-max-preview");
|
||||
expect(model).toBeDefined();
|
||||
if (!model) throw new Error(`Missing model: ${provider}/qwen3.8-max-preview`);
|
||||
|
||||
let payload: unknown;
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hi",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
reasoning: "xhigh",
|
||||
onPayload: (params) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
expect(payload).toHaveProperty("enable_thinking", true);
|
||||
expect(payload).toHaveProperty("reasoning_effort", "xhigh");
|
||||
expect(payload).not.toHaveProperty("thinking");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue