mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 01:48:26 -10:00
Merge pull request #7301 from SivanCola/codex/token-rhythm-provider-glm-effort
feat: add Token Rhythm provider with model-aware reasoning
This commit is contained in:
21 files changed
+440
-27
No files matched your search
@@ -53,5 +53,5 @@ for (const path of localeChunks) {
|
||||
}
|
||||
|
||||
const rawInitialBytes = [...initialJS, ...initialCSS].reduce((total, path) => total + statSync(path).size, 0);
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_250 * 1024);
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_252 * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_100 * 1024);
|
||||
@@ -159,6 +159,13 @@ const nullableProvider = normalizeProviderView({
|
||||
eq(nullableProvider.name, "", "provider snapshots normalize a null name at the settings boundary");
|
||||
eq(nullableProvider.baseUrl, "", "provider snapshots normalize a null base URL at the settings boundary");
|
||||
|
||||
const glmProvider = normalizeProviderView({
|
||||
name: "custom-glm",
|
||||
baseUrl: "https://gateway.example.com/v1",
|
||||
reasoningProtocol: "glm",
|
||||
} as ProviderView);
|
||||
eq(glmProvider.reasoningProtocol, "glm", "provider snapshots preserve the explicit GLM reasoning protocol");
|
||||
|
||||
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
|
||||
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
|
||||
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
|
||||
|
||||
@@ -65,17 +65,40 @@ ok(
|
||||
/case "deepseek-anthropic":\s*return t\("settings\.addProvider\.preset\.deepseekAnthropicDesc"\)/.test(settingsSource),
|
||||
"DeepSeek Anthropic preset uses a localized description",
|
||||
);
|
||||
ok(
|
||||
/case "token-rhythm":\s*return t\("settings\.addProvider\.preset\.tokenRhythmDesc"\)/.test(settingsSource) &&
|
||||
/preset\.id === "token-rhythm"\) return t\("settings\.addProvider\.preset\.tokenRhythmLabel"\)/.test(settingsSource),
|
||||
"Token Rhythm preset localizes the English and Chinese brand names",
|
||||
);
|
||||
ok(
|
||||
[enLocaleSource, zhLocaleSource, zhTWLocaleSource].every((source) =>
|
||||
source.includes('"settings.addProvider.preset.deepseekResponsesDesc"') &&
|
||||
source.includes('"settings.addProvider.preset.deepseekAnthropicDesc"'),
|
||||
source.includes('"settings.addProvider.preset.deepseekAnthropicDesc"') &&
|
||||
source.includes('"settings.addProvider.preset.tokenRhythmLabel"') &&
|
||||
source.includes('"settings.addProvider.preset.tokenRhythmDesc"'),
|
||||
),
|
||||
"DeepSeek protocol preset descriptions are present in every supported locale",
|
||||
"provider preset localization is present in every supported locale",
|
||||
);
|
||||
ok(
|
||||
enLocaleSource.includes('"settings.addProvider.preset.tokenRhythmLabel": "Token Rhythm"') &&
|
||||
zhLocaleSource.includes('"settings.addProvider.preset.tokenRhythmLabel": "基元律动"') &&
|
||||
zhTWLocaleSource.includes('"settings.addProvider.preset.tokenRhythmLabel": "基元律动"'),
|
||||
"Token Rhythm preset uses the official English and Chinese brand names",
|
||||
);
|
||||
ok(
|
||||
[enLocaleSource, zhLocaleSource, zhTWLocaleSource].every((source) =>
|
||||
source.includes('"settings.reasoningProtocol.glm"'),
|
||||
),
|
||||
"GLM reasoning protocol is localized in every supported locale",
|
||||
);
|
||||
ok(
|
||||
/mockPreset\("deepseek-anthropic",\s*"DeepSeek Anthropic"/.test(bridgeSource),
|
||||
"browser mock exposes the DeepSeek Anthropic preset",
|
||||
);
|
||||
ok(
|
||||
/mockPreset\("token-rhythm",\s*"Token Rhythm"/.test(bridgeSource),
|
||||
"browser mock exposes the Token Rhythm preset",
|
||||
);
|
||||
ok(
|
||||
/function mockProviderPresetDisplayRank\(id: string\): number \{\s*if \(id === "deepseek-responses"\) return -1;\s*if \(id === "deepseek-anthropic"\) return 0;/.test(bridgeSource),
|
||||
"browser mock ranks Responses first and Anthropic as the adjacent compatibility preset",
|
||||
|
||||
@@ -780,7 +780,7 @@ const PROXY_MODES = ["auto", "custom", "off"] as const;
|
||||
// The settings UI uses it for subagent defaults; provider-specific levels are
|
||||
// inferred by the backend or edited in TOML for rare gateways.
|
||||
export const EFFORT_PRESETS: readonly string[] = ["low", "medium", "high", "xhigh", "max"];
|
||||
const REASONING_PROTOCOLS: readonly string[] = ["", "deepseek", "openai", "none"];
|
||||
const REASONING_PROTOCOLS: readonly string[] = ["", "deepseek", "glm", "openai", "none"];
|
||||
const THINKING_MODES: readonly string[] = ["", "enabled", "disabled", "adaptive"];
|
||||
const PROXY_TYPES = ["http", "https", "socks5", "socks5h"] as const;
|
||||
const LANGUAGE_PREFS: LangPref[] = ["", "zh", "en"];
|
||||
@@ -1481,6 +1481,8 @@ function reasoningProtocolLabel(protocol: string, t: ReturnType<typeof useT>): s
|
||||
switch (protocol) {
|
||||
case "deepseek":
|
||||
return t("settings.reasoningProtocol.deepseek");
|
||||
case "glm":
|
||||
return t("settings.reasoningProtocol.glm");
|
||||
case "openai":
|
||||
return t("settings.reasoningProtocol.openai");
|
||||
case "none":
|
||||
@@ -4907,6 +4909,8 @@ function providerPresetDescription(preset: ProviderPresetView, t: ReturnType<typ
|
||||
return t("settings.addProvider.preset.longcatOpenAIDesc");
|
||||
case "longcat-anthropic":
|
||||
return t("settings.addProvider.preset.longcatAnthropicDesc");
|
||||
case "token-rhythm":
|
||||
return t("settings.addProvider.preset.tokenRhythmDesc");
|
||||
case "kimi-cn":
|
||||
return t("settings.addProvider.preset.kimiCnDesc");
|
||||
case "kimi-global":
|
||||
@@ -4990,6 +4994,11 @@ function providerPresetDescription(preset: ProviderPresetView, t: ReturnType<typ
|
||||
}
|
||||
}
|
||||
|
||||
function providerPresetLabel(preset: ProviderPresetView, t: ReturnType<typeof useT>): string {
|
||||
if (preset.id === "token-rhythm") return t("settings.addProvider.preset.tokenRhythmLabel");
|
||||
return preset.label;
|
||||
}
|
||||
|
||||
function AddProviderPanel({
|
||||
mode,
|
||||
kinds,
|
||||
@@ -5031,7 +5040,7 @@ function AddProviderPanel({
|
||||
id: `preset:${preset.id}`,
|
||||
source: "preset" as const,
|
||||
presetID: preset.id,
|
||||
label: preset.label,
|
||||
label: providerPresetLabel(preset, t),
|
||||
description: providerPresetDescription(preset, t),
|
||||
keyEnv: preset.keyEnv,
|
||||
added: preset.added,
|
||||
|
||||
@@ -1089,6 +1089,12 @@ function mockPreset(id: string, label: string, description: string, keyEnv: stri
|
||||
|
||||
const mockKimiAPIModels = ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
|
||||
const mockLongCatModels = ["LongCat-2.0"];
|
||||
const mockTokenRhythmModels = ["deepseek-v4-flash", "deepseek-v4-pro", "glm-5", "glm-5.1", "minimax-m2.7", "kimi-k2.5", "kimi-k2.6", "minimax-m2.5", "mimo-v2.5-pro", "qwen3.7-max", "kimi-k2.7-code", "glm-5.2", "qwen3.8-max", "deepseek-v4-flash-0731"];
|
||||
const mockTokenRhythmModelOverrides = mockTokenRhythmModels.flatMap((model) => {
|
||||
if (model.startsWith("glm-")) return [{ model, reasoningProtocol: "glm", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" }];
|
||||
if (model.startsWith("deepseek-")) return [{ model, reasoningProtocol: "deepseek", supportedEfforts: model === "deepseek-v4-pro" ? ["disabled", "high", "max"] : ["disabled", "low", "high", "max"], defaultEffort: "high" }];
|
||||
return [];
|
||||
});
|
||||
const mockMiMoV25Models = ["mimo-v2.5-pro", "mimo-v2.5"];
|
||||
const mockMiniMaxModels = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"];
|
||||
const mockGLMAPIModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"];
|
||||
@@ -1111,6 +1117,7 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
mockPreset("deepseek-anthropic", "DeepSeek Anthropic", "Optional official DeepSeek Anthropic-compatible endpoint; Chat Completions remains the default.", "DEEPSEEK_API_KEY", mockProviderTemplate({ name: "deepseek-anthropic", kind: "anthropic", baseUrl: "https://api.deepseek.com/anthropic", models: ["deepseek-v4-flash", "deepseek-v4-pro"], default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", balanceUrl: "https://api.deepseek.com/user/balance", thinking: "enabled", contextWindow: 1000000, modelOverrides: [{ model: "deepseek-v4-flash", reasoningProtocol: "", supportedEfforts: ["disabled", "low", "high", "max"], defaultEffort: "high" }, { model: "deepseek-v4-pro", reasoningProtocol: "", supportedEfforts: ["disabled", "high", "max"], defaultEffort: "high" }] })),
|
||||
mockPreset("longcat-openai", "LongCat OpenAI", "LongCat Platform OpenAI-compatible endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-openai", kind: "openai", baseUrl: "https://api.longcat.chat/openai/v1", modelsUrl: "https://api.longcat.chat/openai/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("longcat-anthropic", "LongCat Anthropic", "LongCat Platform Anthropic-compatible Messages endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-anthropic", kind: "anthropic", baseUrl: "https://api.longcat.chat/anthropic", modelsUrl: "https://api.longcat.chat/anthropic/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", authHeader: true, contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("token-rhythm", "Token Rhythm", "Token Rhythm (基元律动) multi-model OpenAI-compatible gateway.", "TOKEN_RHYTHM_API_KEY", mockProviderTemplate({ name: "token-rhythm", kind: "openai", baseUrl: "https://tokenrhythm.studio/v1", modelsUrl: "https://tokenrhythm.studio/v1/models", models: mockTokenRhythmModels, visionModels: ["kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code"], default: "deepseek-v4-flash", apiKeyEnv: "TOKEN_RHYTHM_API_KEY", contextWindow: 1000000, modelOverrides: mockTokenRhythmModelOverrides })),
|
||||
mockPreset("kimi-cn", "Kimi CN API", "Moonshot Kimi China OpenAI-compatible API.", "KIMI_API_KEY", mockProviderTemplate({ name: "kimi-cn", kind: "openai", baseUrl: "https://api.moonshot.cn/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "KIMI_API_KEY", balanceUrl: "https://api.moonshot.cn/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none", modelOverrides: [{ model: "kimi-k3", reasoningProtocol: "openai", supportedEfforts: ["low", "high", "max"], defaultEffort: "max", contextWindow: 1048576 }] })),
|
||||
mockPreset("kimi-global", "Kimi Global API", "Moonshot Kimi international OpenAI-compatible API.", "MOONSHOT_API_KEY", mockProviderTemplate({ name: "kimi-global", kind: "openai", baseUrl: "https://api.moonshot.ai/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "MOONSHOT_API_KEY", balanceUrl: "https://api.moonshot.ai/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none", modelOverrides: [{ model: "kimi-k3", reasoningProtocol: "openai", supportedEfforts: ["low", "high", "max"], defaultEffort: "max", contextWindow: 1048576 }] })),
|
||||
mockPreset("kimi-coding-plan", "Kimi Coding Plan", "Kimi Coding Plan via its dedicated Anthropic-compatible endpoint.", "KIMI_CODING_API_KEY", mockProviderTemplate({ name: "kimi-coding-plan", kind: "anthropic", baseUrl: "https://api.kimi.com/coding/", models: ["kimi-for-coding"], visionModels: ["kimi-for-coding"], default: "kimi-for-coding", apiKeyEnv: "KIMI_CODING_API_KEY", headers: { "User-Agent": "claude-code/0.1.0" }, thinking: "adaptive", contextWindow: 262144 })),
|
||||
@@ -1174,6 +1181,7 @@ function mockProviderPresetDisplayRank(id: string): number {
|
||||
if (id === "deepseek-anthropic") return 0;
|
||||
if (id === "glm-cn" || id === "zai-global" || id.startsWith("glm-coding-plan-") || id.startsWith("zai-coding-plan-")) return 0;
|
||||
if (id.startsWith("longcat-")) return 1;
|
||||
if (id === "token-rhythm") return 1;
|
||||
if (id.startsWith("kimi-")) return 2;
|
||||
if (id.startsWith("minimax-")) return 3;
|
||||
return 4;
|
||||
|
||||
@@ -1401,7 +1401,7 @@ export interface ProviderView {
|
||||
keySourcePath?: string;
|
||||
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
|
||||
contextWindow: number;
|
||||
reasoningProtocol: string; // auto|deepseek|openai|none; empty = auto/model registry
|
||||
reasoningProtocol: string; // auto|deepseek|glm|openai|none; empty = auto/model registry
|
||||
thinking: string; // provider-specific thinking override: ""|enabled|disabled|adaptive
|
||||
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
|
||||
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
|
||||
|
||||
@@ -2419,6 +2419,8 @@ export const en = {
|
||||
"settings.addProvider.preset.deepseekAnthropicDesc": "Optional official DeepSeek Anthropic-compatible endpoint for Flash and Pro; Chat Completions remains the default.",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat Platform OpenAI-compatible endpoint with LongCat-2.0 default.",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat Platform Anthropic-compatible Messages endpoint using Bearer auth.",
|
||||
"settings.addProvider.preset.tokenRhythmLabel": "Token Rhythm",
|
||||
"settings.addProvider.preset.tokenRhythmDesc": "Token Rhythm multi-model gateway using its OpenAI-compatible API.",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi China API via Moonshot CN endpoint with K2 vision models.",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi international API via Moonshot global endpoint with K2 vision models.",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan via the dedicated Anthropic-compatible endpoint; not a CN/Global variant.",
|
||||
@@ -2527,6 +2529,7 @@ export const en = {
|
||||
"settings.reasoningProtocol": "Model capability mode",
|
||||
"settings.reasoningProtocol.auto": "Auto-detect (recommended)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek thinking (thinking + depth)",
|
||||
"settings.reasoningProtocol.glm": "GLM thinking (enabled / disabled)",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning (reasoning_effort)",
|
||||
"settings.reasoningProtocol.none": "Plain chat (no reasoning fields)",
|
||||
"settings.reasoningProtocolHint": "Auto-detect is best for most gateways. Switch only when a proxy is incompatible or the model documentation requires a specific reasoning shape.",
|
||||
|
||||
@@ -1632,6 +1632,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.addProvider.preset.deepseekAnthropicDesc": "DeepSeek 官方可選 Anthropic 相容端點,支援 Flash 與 Pro;預設仍使用 Chat Completions。",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 開放平台 OpenAI-compatible 端點,預設 LongCat-2.0。",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 開放平台 Anthropic-compatible Messages 端點,使用 Bearer 認證。",
|
||||
"settings.addProvider.preset.tokenRhythmLabel": "基元律动",
|
||||
"settings.addProvider.preset.tokenRhythmDesc": "基元律动多模型閘道,使用 OpenAI-compatible API 接入。",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi 中國 API,走 Moonshot CN 端點,預置 K2 視覺模型。",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 國際 API,走 Moonshot global 端點,預置 K2 視覺模型。",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走獨立 Anthropic-compatible 端點,不歸屬 CN/Global。",
|
||||
@@ -1738,6 +1740,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.reasoningProtocol": "模型能力模式",
|
||||
"settings.reasoningProtocol.auto": "自動識別(推薦)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek 思考(thinking + 深度)",
|
||||
"settings.reasoningProtocol.glm": "GLM 思考(開啟 / 關閉)",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning(reasoning_effort)",
|
||||
"settings.reasoningProtocol.none": "普通聊天(不傳送思考參數)",
|
||||
"settings.reasoningProtocolHint": "預設自動識別。只有代理不相容,或你確定模型需要特定 reasoning 格式時再切換。",
|
||||
|
||||
@@ -2422,6 +2422,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.addProvider.preset.deepseekAnthropicDesc": "DeepSeek 官方可选 Anthropic 兼容端点,支持 Flash 与 Pro;默认仍使用 Chat Completions。",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 开放平台 OpenAI-compatible 端点,默认 LongCat-2.0。",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 开放平台 Anthropic-compatible Messages 端点,使用 Bearer 认证。",
|
||||
"settings.addProvider.preset.tokenRhythmLabel": "基元律动",
|
||||
"settings.addProvider.preset.tokenRhythmDesc": "基元律动多模型网关,使用 OpenAI-compatible API 接入。",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi 国内 API,走 Moonshot CN 端点,预置 K2 视觉模型。",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 国际 API,走 Moonshot global 端点,预置 K2 视觉模型。",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走独立 Anthropic-compatible 端点,不归属 CN/Global。",
|
||||
@@ -2530,6 +2532,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.reasoningProtocol": "模型能力模式",
|
||||
"settings.reasoningProtocol.auto": "自动识别(推荐)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek 思考(thinking + 深度)",
|
||||
"settings.reasoningProtocol.glm": "GLM 思考(开启 / 关闭)",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning(reasoning_effort)",
|
||||
"settings.reasoningProtocol.none": "普通聊天(不发送思考参数)",
|
||||
"settings.reasoningProtocolHint": "默认自动识别。只有代理不兼容,或你确定模型需要特定 reasoning 格式时再切换。",
|
||||
|
||||
@@ -24,6 +24,17 @@ get a tailored request shape automatically — no extra config needed.
|
||||
|----------------|----------|-------------------|------------------|-------|
|
||||
| Kimi CN/Global `kimi-k3` | `api.moonshot.cn/v1`, `api.moonshot.ai/v1` | `reasoning_effort` | `low`, `high`, `max` | Always thinks; defaults to `max`. Reasonix replays the complete assistant message, uses `max_completion_tokens`, and omits K3's fixed sampling fields. |
|
||||
| OpenCode Go `kimi-k3` | `opencode.ai/zen/go/v1` | `reasoning_effort` | `high`, `max` | Relay-specific scale; defaults to `max` and keeps the relay's standard OpenAI-compatible request shape. |
|
||||
| Token Rhythm DeepSeek V4 | `tokenrhythm.studio/v1` | DeepSeek `thinking.type` + `reasoning_effort` | Model-specific DeepSeek scale | Selected through the preset's model override, independent of the gateway host. |
|
||||
| Token Rhythm GLM 5/5.1/5.2 | `tokenrhythm.studio/v1` | GLM `thinking.type` (`enabled`\|`disabled`) | `auto`, `enabled`, `disabled` | Selected through the preset's model override; `reasoning_effort` is omitted. |
|
||||
|
||||
On the Token Rhythm endpoint, exact GLM model IDs (`glm-5`, `glm-5.1`, and
|
||||
`glm-5.2`) automatically select the official GLM request shape even when an
|
||||
existing configuration has no `reasoning_protocol` field. The endpoint check
|
||||
keeps unrelated mixed-model gateways backward-compatible. A `model_overrides`
|
||||
entry with explicit `reasoning_protocol = "glm"` remains available for aliases
|
||||
and custom model IDs. While GLM thinking is enabled, Reasonix retains and
|
||||
returns the original `reasoning_content` unchanged in later history, as required
|
||||
by GLM interleaved and preserved thinking.
|
||||
|
||||
## DeepSeek Anthropic-compatible endpoint
|
||||
|
||||
|
||||
@@ -1294,7 +1294,8 @@ type ProviderEntry struct {
|
||||
VisionDetail string `toml:"vision_detail"`
|
||||
// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning
|
||||
// models. Empty/auto uses the model capability registry plus endpoint
|
||||
// heuristics; none disables automatic reasoning controls for this provider.
|
||||
// heuristics; glm selects GLM's thinking.type toggle; none disables automatic
|
||||
// reasoning controls for this provider.
|
||||
ReasoningProtocol string `toml:"reasoning_protocol"`
|
||||
// SupportedEfforts lists the /effort levels this provider/model exposes.
|
||||
// When non-empty, it overrides the built-in defaults derived from
|
||||
|
||||
+46
-12
@@ -10,6 +10,7 @@ import (
|
||||
const (
|
||||
ReasoningProtocolAuto = "auto"
|
||||
ReasoningProtocolDeepSeek = "deepseek"
|
||||
ReasoningProtocolGLM = "glm"
|
||||
ReasoningProtocolOpenAI = "openai"
|
||||
ReasoningProtocolNone = "none"
|
||||
)
|
||||
@@ -63,6 +64,8 @@ func EffortCapabilityForEntry(e *ProviderEntry) EffortCapability {
|
||||
return effortCapabilityFromModel(cap)
|
||||
}
|
||||
return deepSeekEffortCapability()
|
||||
case ReasoningProtocolGLM:
|
||||
return glmEffortCapability()
|
||||
case ReasoningProtocolOpenAI:
|
||||
return openAIEffortCapability()
|
||||
}
|
||||
@@ -72,6 +75,8 @@ func EffortCapabilityForEntry(e *ProviderEntry) EffortCapability {
|
||||
switch ReasoningProtocolForEntry(e) {
|
||||
case ReasoningProtocolDeepSeek:
|
||||
return deepSeekEffortCapability()
|
||||
case ReasoningProtocolGLM:
|
||||
return glmEffortCapability()
|
||||
case ReasoningProtocolOpenAI:
|
||||
return openAIEffortCapability()
|
||||
}
|
||||
@@ -89,7 +94,7 @@ func EffortCapabilityForEntry(e *ProviderEntry) EffortCapability {
|
||||
// mirrors that vocabulary. Default is "enabled" because GLM runs with
|
||||
// thinking on out of the box; "auto" means "don't override the model
|
||||
// default" (== enabled for GLM).
|
||||
return EffortCapability{Supported: true, Levels: []string{"auto", "enabled", "disabled"}, Default: "enabled"}
|
||||
return glmEffortCapability()
|
||||
case isLongCatEntry(e):
|
||||
// LongCat exposes the same binary thinking vocabulary on its
|
||||
// OpenAI-compatible endpoint and documents no reasoning_effort depth scale.
|
||||
@@ -164,6 +169,8 @@ func NormalizeEffort(e *ProviderEntry, raw string) (string, error) {
|
||||
default:
|
||||
return "", fmt.Errorf("usage: /effort auto|low|medium|high")
|
||||
}
|
||||
case ReasoningProtocolGLM:
|
||||
return normalizeGLMEffort(level)
|
||||
}
|
||||
switch {
|
||||
case isMiniMaxEntry(e):
|
||||
@@ -189,16 +196,7 @@ func NormalizeEffort(e *ProviderEntry, raw string) (string, error) {
|
||||
// depth levels onto the nearest valid value so a stale /effort high|low
|
||||
// still works. "off" is a retired DeepSeek level meaning "no thinking",
|
||||
// which maps to "disabled".
|
||||
switch level {
|
||||
case "enabled", "disabled":
|
||||
return level, nil
|
||||
case "off":
|
||||
return "disabled", nil
|
||||
case "low", "medium", "high", "xhigh", "max":
|
||||
return "enabled", nil
|
||||
default:
|
||||
return "", fmt.Errorf("usage: /effort auto|enabled|disabled")
|
||||
}
|
||||
return normalizeGLMEffort(level)
|
||||
case isLongCatEntry(e):
|
||||
// LongCat's knob is binary (enabled|disabled); depth-like aliases mean
|
||||
// thinking on, while the legacy off spellings disable it.
|
||||
@@ -305,6 +303,9 @@ func ReasoningProtocolForEntry(e *ProviderEntry) string {
|
||||
if cap, ok := resolvedModelReasoningCapability(e); ok {
|
||||
return cap.Protocol
|
||||
}
|
||||
if isTokenRhythmGLMEntry(e) {
|
||||
return ReasoningProtocolGLM
|
||||
}
|
||||
if isDeepSeekEntry(e) {
|
||||
return ReasoningProtocolDeepSeek
|
||||
}
|
||||
@@ -326,7 +327,7 @@ func normalizeReasoningProtocol(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", ReasoningProtocolAuto:
|
||||
return ""
|
||||
case ReasoningProtocolDeepSeek, ReasoningProtocolOpenAI, ReasoningProtocolNone:
|
||||
case ReasoningProtocolDeepSeek, ReasoningProtocolGLM, ReasoningProtocolOpenAI, ReasoningProtocolNone:
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
default:
|
||||
return ""
|
||||
@@ -354,6 +355,22 @@ func isZhipuEntry(e *ProviderEntry) bool {
|
||||
return e != nil && e.Kind == "openai" && openai.IsZhipu(e.BaseURL)
|
||||
}
|
||||
|
||||
// isTokenRhythmGLMEntry upgrades older Token Rhythm configurations that predate
|
||||
// per-model protocol overrides. Keep the rule scoped to the gateway and exact
|
||||
// official model IDs so unrelated mixed-model providers retain their existing
|
||||
// request shape.
|
||||
func isTokenRhythmGLMEntry(e *ProviderEntry) bool {
|
||||
if e == nil || e.Kind != "openai" || !openai.IsTokenRhythm(e.BaseURL) {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(e.Model)) {
|
||||
case "glm-5", "glm-5.1", "glm-5.2":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isLongCatEntry reports whether the entry points at LongCat's OpenAI-compatible
|
||||
// endpoint. See openai.IsLongCat for the host-matching rule.
|
||||
func isLongCatEntry(e *ProviderEntry) bool {
|
||||
@@ -394,6 +411,23 @@ func openAIEffortCapability() EffortCapability {
|
||||
return EffortCapability{Supported: true, Levels: []string{"auto", "low", "medium", "high"}, Default: "auto"}
|
||||
}
|
||||
|
||||
func glmEffortCapability() EffortCapability {
|
||||
return EffortCapability{Supported: true, Levels: []string{"auto", "enabled", "disabled"}, Default: "enabled"}
|
||||
}
|
||||
|
||||
func normalizeGLMEffort(level string) (string, error) {
|
||||
switch level {
|
||||
case "enabled", "disabled":
|
||||
return level, nil
|
||||
case "off":
|
||||
return "disabled", nil
|
||||
case "low", "medium", "high", "xhigh", "max":
|
||||
return "enabled", nil
|
||||
default:
|
||||
return "", fmt.Errorf("usage: /effort auto|enabled|disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func effortNotConfigurableError(e *ProviderEntry) error {
|
||||
name := ""
|
||||
if e != nil {
|
||||
|
||||
@@ -162,6 +162,64 @@ func TestEffortCapabilityZhipu(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffortCapabilityExplicitGLMProtocolOnGateway(t *testing.T) {
|
||||
e := &ProviderEntry{
|
||||
Name: "glm-gateway",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://gateway.example.com/v1",
|
||||
Model: "glm-5.2",
|
||||
ReasoningProtocol: ReasoningProtocolGLM,
|
||||
}
|
||||
cap := EffortCapabilityForEntry(e)
|
||||
want := []string{"auto", "enabled", "disabled"}
|
||||
if !cap.Supported || cap.Default != "enabled" || len(cap.Levels) != len(want) {
|
||||
t.Fatalf("explicit GLM capability = %+v, want levels %v default enabled", cap, want)
|
||||
}
|
||||
for i := range want {
|
||||
if cap.Levels[i] != want[i] {
|
||||
t.Fatalf("explicit GLM levels[%d] = %q, want %q", i, cap.Levels[i], want[i])
|
||||
}
|
||||
}
|
||||
if got, err := NormalizeEffort(e, "disabled"); err != nil || got != "disabled" {
|
||||
t.Fatalf("explicit GLM disabled = %q/%v, want disabled/nil", got, err)
|
||||
}
|
||||
if got, err := NormalizeEffort(e, "high"); err != nil || got != "enabled" {
|
||||
t.Fatalf("explicit GLM legacy high = %q/%v, want enabled/nil", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGLMModelRegistryUpgradesLegacyGatewayConfig(t *testing.T) {
|
||||
for _, model := range []string{"glm-5", "glm-5.1", "glm-5.2"} {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
// This is the shape persisted by an older Token Rhythm installation:
|
||||
// no reasoning_protocol and no model_overrides metadata.
|
||||
e := &ProviderEntry{
|
||||
Name: "token-rhythm",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://tokenrhythm.studio/v1",
|
||||
Model: model,
|
||||
}
|
||||
if got := ReasoningProtocolForEntry(e); got != ReasoningProtocolGLM {
|
||||
t.Fatalf("ReasoningProtocolForEntry() = %q, want %q", got, ReasoningProtocolGLM)
|
||||
}
|
||||
cap := EffortCapabilityForEntry(e)
|
||||
want := []string{"auto", "enabled", "disabled"}
|
||||
if !cap.Supported || cap.Default != "enabled" || !stringSlicesEqual(cap.Levels, want) {
|
||||
t.Fatalf("legacy gateway GLM capability = %+v, want levels %v default enabled", cap, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
nonGLM := &ProviderEntry{Kind: "openai", BaseURL: "https://tokenrhythm.studio/v1", Model: "my-glm-5.2-alias"}
|
||||
if got := ReasoningProtocolForEntry(nonGLM); got != "" {
|
||||
t.Fatalf("non-exact GLM alias protocol = %q, want empty without explicit override", got)
|
||||
}
|
||||
otherGateway := &ProviderEntry{Kind: "openai", BaseURL: "https://opencode.ai/zen/go/v1", Model: "glm-5.2"}
|
||||
if got := ReasoningProtocolForEntry(otherGateway); got != "" {
|
||||
t.Fatalf("unrelated gateway GLM protocol = %q, want empty without explicit override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffortCapabilityLongCat(t *testing.T) {
|
||||
e := &ProviderEntry{Kind: "openai", BaseURL: "https://api.longcat.chat/openai/v1", Model: "LongCat-2.0"}
|
||||
cap := EffortCapabilityForEntry(e)
|
||||
|
||||
@@ -59,6 +59,8 @@ func providerPresetDisplayRank(id string) int {
|
||||
return 0
|
||||
case strings.HasPrefix(id, "longcat-"):
|
||||
return 1
|
||||
case id == "token-rhythm":
|
||||
return 1
|
||||
case strings.HasPrefix(id, "kimi-"):
|
||||
return 2
|
||||
case strings.HasPrefix(id, "minimax-"):
|
||||
@@ -74,8 +76,15 @@ var (
|
||||
kimiAPIVisionModels = []string{"kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"}
|
||||
kimiCodingModels = []string{"kimi-for-coding"}
|
||||
|
||||
longCat20Models = []string{"LongCat-2.0"}
|
||||
deepSeekV4Models = []string{"deepseek-v4-flash", "deepseek-v4-pro"}
|
||||
longCat20Models = []string{"LongCat-2.0"}
|
||||
deepSeekV4Models = []string{"deepseek-v4-flash", "deepseek-v4-pro"}
|
||||
tokenRhythmModels = []string{
|
||||
"deepseek-v4-flash", "deepseek-v4-pro", "glm-5", "glm-5.1",
|
||||
"minimax-m2.7", "kimi-k2.5", "kimi-k2.6", "minimax-m2.5",
|
||||
"mimo-v2.5-pro", "qwen3.7-max", "kimi-k2.7-code", "glm-5.2",
|
||||
"qwen3.8-max", "deepseek-v4-flash-0731",
|
||||
}
|
||||
tokenRhythmVisionModels = []string{"kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code"}
|
||||
|
||||
mimoV25Models = []string{"mimo-v2.5-pro", "mimo-v2.5"}
|
||||
mimoV25VisionModels = []string{"mimo-v2.5"}
|
||||
@@ -122,6 +131,48 @@ func qwenModelContextOverrides() map[string]ProviderModelOverride {
|
||||
}
|
||||
}
|
||||
|
||||
func tokenRhythmModelOverrides() map[string]ProviderModelOverride {
|
||||
return map[string]ProviderModelOverride{
|
||||
"deepseek-v4-flash": {
|
||||
ReasoningProtocol: ReasoningProtocolDeepSeek,
|
||||
SupportedEfforts: []string{"disabled", "low", "high", "max"},
|
||||
DefaultEffort: "high",
|
||||
},
|
||||
"deepseek-v4-pro": {
|
||||
ReasoningProtocol: ReasoningProtocolDeepSeek,
|
||||
SupportedEfforts: []string{"disabled", "high", "max"},
|
||||
DefaultEffort: "high",
|
||||
},
|
||||
"deepseek-v4-flash-0731": {
|
||||
ReasoningProtocol: ReasoningProtocolDeepSeek,
|
||||
SupportedEfforts: []string{"disabled", "low", "high", "max"},
|
||||
DefaultEffort: "high",
|
||||
},
|
||||
"glm-5": {
|
||||
ReasoningProtocol: ReasoningProtocolGLM,
|
||||
SupportedEfforts: []string{"enabled", "disabled"},
|
||||
DefaultEffort: "enabled",
|
||||
},
|
||||
"glm-5.1": {
|
||||
ReasoningProtocol: ReasoningProtocolGLM,
|
||||
SupportedEfforts: []string{"enabled", "disabled"},
|
||||
DefaultEffort: "enabled",
|
||||
ContextWindow: 200_000,
|
||||
},
|
||||
"glm-5.2": {
|
||||
ReasoningProtocol: ReasoningProtocolGLM,
|
||||
SupportedEfforts: []string{"enabled", "disabled"},
|
||||
DefaultEffort: "enabled",
|
||||
},
|
||||
"minimax-m2.7": {ContextWindow: 200_000},
|
||||
"kimi-k2.5": {ContextWindow: 256_000},
|
||||
"kimi-k2.6": {ContextWindow: 256_000},
|
||||
"minimax-m2.5": {ContextWindow: 200_000},
|
||||
"mimo-v2.5-pro": {ContextWindow: 256_000},
|
||||
"kimi-k2.7-code": {ContextWindow: 256_000},
|
||||
}
|
||||
}
|
||||
|
||||
func kimiK3DirectOverride() ProviderModelOverride {
|
||||
return ProviderModelOverride{
|
||||
ReasoningProtocol: ReasoningProtocolOpenAI,
|
||||
@@ -195,6 +246,24 @@ var curatedProviderPresets = []ProviderPreset{
|
||||
Prices: longCat20Prices(longCat20Models),
|
||||
}},
|
||||
},
|
||||
{
|
||||
ID: "token-rhythm",
|
||||
Label: "Token Rhythm",
|
||||
Description: "Token Rhythm (基元律动) multi-model OpenAI-compatible gateway.",
|
||||
KeyEnv: "TOKEN_RHYTHM_API_KEY",
|
||||
Entries: []ProviderEntry{{
|
||||
Name: "token-rhythm",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://tokenrhythm.studio/v1",
|
||||
ModelsURL: "https://tokenrhythm.studio/v1/models",
|
||||
Models: tokenRhythmModels,
|
||||
VisionModels: tokenRhythmVisionModels,
|
||||
Default: "deepseek-v4-flash",
|
||||
APIKeyEnv: "TOKEN_RHYTHM_API_KEY",
|
||||
ContextWindow: 1_000_000,
|
||||
ModelOverrides: tokenRhythmModelOverrides(),
|
||||
}},
|
||||
},
|
||||
{
|
||||
ID: "kimi-cn",
|
||||
Label: "Kimi CN API",
|
||||
|
||||
@@ -7,6 +7,7 @@ func TestCuratedProviderPresetsCoverRequestedProviders(t *testing.T) {
|
||||
"deepseek-anthropic",
|
||||
"longcat-openai",
|
||||
"longcat-anthropic",
|
||||
"token-rhythm",
|
||||
"kimi-cn",
|
||||
"kimi-global",
|
||||
"kimi-coding-plan",
|
||||
@@ -138,6 +139,7 @@ func TestCuratedProviderPresetsDisplayOrder(t *testing.T) {
|
||||
"zai-coding-plan-global-anthropic",
|
||||
"longcat-openai",
|
||||
"longcat-anthropic",
|
||||
"token-rhythm",
|
||||
"kimi-cn",
|
||||
"kimi-global",
|
||||
"kimi-coding-plan",
|
||||
@@ -157,6 +159,52 @@ func TestCuratedProviderPresetsDisplayOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRhythmPresetMatchesPublicAPIIntegration(t *testing.T) {
|
||||
preset, ok := CuratedProviderPreset("token-rhythm")
|
||||
if !ok || len(preset.Entries) != 1 {
|
||||
t.Fatalf("Token Rhythm preset = %+v, want one entry", preset)
|
||||
}
|
||||
if preset.Label != "Token Rhythm" || preset.KeyEnv != "TOKEN_RHYTHM_API_KEY" {
|
||||
t.Fatalf("Token Rhythm identity = label %q key %q", preset.Label, preset.KeyEnv)
|
||||
}
|
||||
entry := preset.Entries[0]
|
||||
if entry.Kind != "openai" || entry.BaseURL != "https://tokenrhythm.studio/v1" || entry.ModelsURL != "https://tokenrhythm.studio/v1/models" {
|
||||
t.Fatalf("Token Rhythm endpoint mismatch: %+v", entry)
|
||||
}
|
||||
if entry.DefaultModel() != "deepseek-v4-flash" || !entry.HasModel("qwen3.8-max") || entry.HasModel("qwen-image-2.0") {
|
||||
t.Fatalf("Token Rhythm chat catalog mismatch: models=%v default=%q", entry.Models, entry.DefaultModel())
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := cfg.UpsertProvider(entry); err != nil {
|
||||
t.Fatalf("upsert Token Rhythm preset: %v", err)
|
||||
}
|
||||
deepseek, ok := cfg.ResolveModel("token-rhythm/deepseek-v4-flash")
|
||||
if !ok || deepseek.ContextWindow != 1_000_000 || ReasoningProtocolForEntry(deepseek) != ReasoningProtocolDeepSeek {
|
||||
t.Fatalf("Token Rhythm DeepSeek capability mismatch: %+v", deepseek)
|
||||
}
|
||||
kimi, ok := cfg.ResolveModel("token-rhythm/kimi-k2.7-code")
|
||||
if !ok || kimi.ContextWindow != 256_000 || !EffectiveVision(kimi) {
|
||||
t.Fatalf("Token Rhythm Kimi capability mismatch: %+v", kimi)
|
||||
}
|
||||
glm, ok := cfg.ResolveModel("token-rhythm/glm-5.1")
|
||||
if !ok || glm.ContextWindow != 200_000 || EffectiveVision(glm) || ReasoningProtocolForEntry(glm) != ReasoningProtocolGLM {
|
||||
t.Fatalf("Token Rhythm GLM capability mismatch: %+v", glm)
|
||||
}
|
||||
glmCap := EffortCapabilityForEntry(glm)
|
||||
if !glmCap.Supported || glmCap.Default != "enabled" || !stringSlicesEqual(glmCap.Levels, []string{"auto", "enabled", "disabled"}) {
|
||||
t.Fatalf("Token Rhythm GLM effort mismatch: %+v", glmCap)
|
||||
}
|
||||
flash0731, ok := cfg.ResolveModel("token-rhythm/deepseek-v4-flash-0731")
|
||||
if !ok || ReasoningProtocolForEntry(flash0731) != ReasoningProtocolDeepSeek {
|
||||
t.Fatalf("Token Rhythm DeepSeek 0731 protocol mismatch: %+v", flash0731)
|
||||
}
|
||||
flashCap := EffortCapabilityForEntry(flash0731)
|
||||
if !flashCap.Supported || flashCap.Default != "high" || !stringSlicesEqual(flashCap.Levels, []string{"auto", "disabled", "low", "high", "max"}) {
|
||||
t.Fatalf("Token Rhythm DeepSeek 0731 effort mismatch: %+v", flashCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCuratedProviderPresetsStepFunUsesOfficialBaseURLs(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
|
||||
@@ -365,7 +365,7 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
fmt.Fprintf(&b, "vision_detail = %q # openai image detail hint: low|high; empty = auto\n", p.VisionDetail)
|
||||
}
|
||||
if p.ReasoningProtocol != "" {
|
||||
fmt.Fprintf(&b, "reasoning_protocol = %q # auto|deepseek|openai|none; overrides model/endpoint reasoning detection\n", p.ReasoningProtocol)
|
||||
fmt.Fprintf(&b, "reasoning_protocol = %q # auto|deepseek|glm|openai|none; overrides model/endpoint reasoning detection\n", p.ReasoningProtocol)
|
||||
}
|
||||
if len(p.SupportedEfforts) > 0 {
|
||||
fmt.Fprintf(&b, "supported_efforts = %s # custom /effort levels exposed by this provider; overrides the built-in Kind/BaseURL default\n", renderStringArray(p.SupportedEfforts))
|
||||
|
||||
@@ -137,6 +137,17 @@ func IsZhipu(baseURL string) bool {
|
||||
matchesVendorHost(baseURL, "z.ai", "api.z.ai")
|
||||
}
|
||||
|
||||
// IsTokenRhythm reports whether baseURL points at Token Rhythm's official
|
||||
// OpenAI-compatible gateway. Keep this exact-host: model-aware protocol
|
||||
// upgrades must not affect unrelated subdomains or similarly named relays.
|
||||
func IsTokenRhythm(baseURL string) bool {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(u.Hostname(), "tokenrhythm.studio")
|
||||
}
|
||||
|
||||
// IsLongCat reports whether baseURL points at LongCat's OpenAI-compatible API.
|
||||
// LongCat uses the OpenAI chat shape, but gates thinking with thinking.type
|
||||
// enabled|disabled rather than the generic reasoning_effort field.
|
||||
|
||||
@@ -199,6 +199,24 @@ func TestIsZhipu(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTokenRhythm(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
baseURL string
|
||||
want bool
|
||||
}{
|
||||
{"https://tokenrhythm.studio/v1", true},
|
||||
{"https://TOKENRHYTHM.STUDIO/v1", true},
|
||||
{"https://api.tokenrhythm.studio/v1", false},
|
||||
{"https://tokenrhythm.example.com/v1", false},
|
||||
{"https://opencode.ai/zen/go/v1", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := IsTokenRhythm(tc.baseURL); got != tc.want {
|
||||
t.Errorf("IsTokenRhythm(%q) = %v, want %v", tc.baseURL, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLongCat(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
baseURL string
|
||||
|
||||
@@ -103,7 +103,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
|
||||
maxOutputTokens, _ := cfg.Extra["max_output_tokens"].(int)
|
||||
deepseekV4Flash := strings.EqualFold(strings.TrimSpace(cfg.Model), "deepseek-v4-flash")
|
||||
minimax := protocol == "" && IsMiniMax(cfg.BaseURL)
|
||||
zhipu := protocol == "" && IsZhipu(cfg.BaseURL)
|
||||
zhipu := protocol == "glm" || (protocol == "" && IsZhipu(cfg.BaseURL))
|
||||
longcat := protocol == "" && IsLongCat(cfg.BaseURL)
|
||||
ollamaCloud := protocol == "" && IsOllamaCloud(cfg.BaseURL)
|
||||
kimiK3 := IsKimiAPI(cfg.BaseURL) && strings.EqualFold(strings.TrimSpace(cfg.Model), "kimi-k3")
|
||||
@@ -292,13 +292,24 @@ func (c *client) RequiresToolCallReasoning() bool {
|
||||
}
|
||||
|
||||
func (c *client) RequiresReasoningRoundTrip() bool {
|
||||
return c != nil && c.kimiK3
|
||||
return c != nil && (c.kimiK3 || c.glmThinkingEnabled())
|
||||
}
|
||||
|
||||
func (c *client) WarnOnMissingToolCallReasoning() bool {
|
||||
return c.RequiresToolCallReasoning() && expectsDeepSeekToolCallReasoning(c.model, c.thinkingType)
|
||||
}
|
||||
|
||||
func (c *client) glmThinkingEnabled() bool {
|
||||
if c == nil || !c.zhipu {
|
||||
return false
|
||||
}
|
||||
t := c.effort
|
||||
if c.thinkingType != "" {
|
||||
t = c.thinkingType
|
||||
}
|
||||
return t != "disabled"
|
||||
}
|
||||
|
||||
func expectsDeepSeekToolCallReasoning(model, thinkingType string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(thinkingType), "enabled") {
|
||||
return true
|
||||
@@ -337,7 +348,7 @@ func (c *client) sendOpts() provider.SendOptions {
|
||||
|
||||
func normalizeReasoningProtocol(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "deepseek", "openai", "none":
|
||||
case "deepseek", "glm", "openai", "none":
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
default:
|
||||
return ""
|
||||
@@ -685,6 +696,12 @@ func (c *client) buildRequest(req provider.Request) chatRequest {
|
||||
if c.RequiresToolCallReasoning() || m.ReasoningContent != "" {
|
||||
cm.ReasoningContent = &m.ReasoningContent
|
||||
}
|
||||
case c.zhipu && m.ReasoningContent != "":
|
||||
// GLM interleaved and preserved thinking require provider-issued
|
||||
// reasoning content to be returned unchanged in later history. Keep
|
||||
// an existing value even after thinking is turned off so an
|
||||
// enabled→disabled session retains its valid history bytes.
|
||||
cm.ReasoningContent = &m.ReasoningContent
|
||||
}
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
|
||||
@@ -1395,6 +1395,96 @@ func TestNewZhipuSetsFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewExplicitGLMProtocolOnGateway(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
effort string
|
||||
want string
|
||||
}{
|
||||
{effort: "", want: "enabled"},
|
||||
{effort: "enabled", want: "enabled"},
|
||||
{effort: "disabled", want: "disabled"},
|
||||
} {
|
||||
p, err := New(provider.Config{
|
||||
Name: "glm-gateway",
|
||||
BaseURL: "https://gateway.example.com/v1",
|
||||
Model: "glm-5.2",
|
||||
APIKey: "k",
|
||||
Extra: map[string]any{
|
||||
"reasoning_protocol": "glm",
|
||||
"effort": tc.effort,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New(explicit GLM, effort=%q): %v", tc.effort, err)
|
||||
}
|
||||
c := p.(*client)
|
||||
if !c.zhipu {
|
||||
t.Fatalf("explicit GLM protocol did not select GLM wire shape")
|
||||
}
|
||||
req := c.buildRequest(provider.Request{})
|
||||
if req.Thinking == nil || req.Thinking.Type != tc.want {
|
||||
t.Fatalf("effort=%q thinking = %+v, want %q", tc.effort, req.Thinking, tc.want)
|
||||
}
|
||||
if req.ReasoningEffort != "" {
|
||||
t.Fatalf("explicit GLM protocol sent reasoning_effort=%q", req.ReasoningEffort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestRoundTripsGLMReasoningHistory(t *testing.T) {
|
||||
build := func(effort string) (*client, chatRequest) {
|
||||
p, err := New(provider.Config{
|
||||
Name: "glm-gateway",
|
||||
BaseURL: "https://tokenrhythm.studio/v1",
|
||||
Model: "glm-5.2",
|
||||
APIKey: "k",
|
||||
Extra: map[string]any{
|
||||
"reasoning_protocol": "glm",
|
||||
"effort": effort,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New(GLM, effort=%q): %v", effort, err)
|
||||
}
|
||||
c := p.(*client)
|
||||
out := c.buildRequest(provider.Request{Messages: []provider.Message{
|
||||
{Role: provider.RoleUser, Content: "inspect"},
|
||||
{Role: provider.RoleAssistant, ReasoningContent: "read main.go first", ToolCalls: []provider.ToolCall{{
|
||||
ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`,
|
||||
}}},
|
||||
{Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "package main"},
|
||||
{Role: provider.RoleUser, Content: "continue"},
|
||||
{Role: provider.RoleAssistant, Content: "done", ReasoningContent: "combine the result"},
|
||||
}})
|
||||
return c, out
|
||||
}
|
||||
|
||||
enabled, enabledReq := build("enabled")
|
||||
if enabled.RequiresToolCallReasoning() || !enabled.RequiresReasoningRoundTrip() {
|
||||
t.Fatal("thinking-enabled GLM must preserve complete reasoning history without enabling DeepSeek recovery policy")
|
||||
}
|
||||
if got := enabledReq.Messages[1].ReasoningContent; got == nil || *got != "read main.go first" {
|
||||
t.Fatalf("enabled GLM reasoning_content = %v, want provider-issued reasoning", got)
|
||||
}
|
||||
if got := enabledReq.Messages[4].ReasoningContent; got == nil || *got != "combine the result" {
|
||||
t.Fatalf("enabled GLM plain-turn reasoning_content = %v, want complete reasoning history", got)
|
||||
}
|
||||
if provider.WarnOnMissingToolCallReasoning(enabled) {
|
||||
t.Fatal("GLM must preserve available reasoning without entering DeepSeek-specific missing-reasoning recovery")
|
||||
}
|
||||
|
||||
disabled, disabledReq := build("disabled")
|
||||
if disabled.RequiresToolCallReasoning() || disabled.RequiresReasoningRoundTrip() {
|
||||
t.Fatal("thinking-disabled GLM must not require new reasoning round trips")
|
||||
}
|
||||
if got := disabledReq.Messages[1].ReasoningContent; got == nil || *got != "read main.go first" {
|
||||
t.Fatalf("disabled GLM must preserve reasoning from an earlier thinking round, got %v", got)
|
||||
}
|
||||
if got := disabledReq.Messages[4].ReasoningContent; got == nil || *got != "combine the result" {
|
||||
t.Fatalf("disabled GLM must preserve plain reasoning from an earlier thinking round, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRequestGenericThinking covers the vendor-agnostic `thinking` config
|
||||
// field on a provider we don't auto-detect: thinking.type is emitted as set, and
|
||||
// an empty/unset field leaves thinking off the wire entirely.
|
||||
|
||||
@@ -52,8 +52,8 @@ compact_force_ratio = 0.9 # force compacting at this high-water mark
|
||||
# recommended editable presets for Kimi CN/Global, Kimi Coding Plan, MiMo API
|
||||
# and Anthropic/token-plan regions, MiniMax CN/Global API and Anthropic routes,
|
||||
# GLM/Z.AI CN/Global API and coding-plan routes, OpenCode Go/Zen, Qwen/DashScope
|
||||
# CN/Global API and coding-plan routes, StepFun, NovitaAI, GMI, Vercel AI
|
||||
# Gateway, HuggingFace, NVIDIA, KiloCode, and Ollama Cloud.
|
||||
# CN/Global API and coding-plan routes, Token Rhythm (基元律动), StepFun,
|
||||
# NovitaAI, GMI, Vercel AI Gateway, HuggingFace, NVIDIA, KiloCode, and Ollama Cloud.
|
||||
[[providers]]
|
||||
name = "deepseek"
|
||||
kind = "openai"
|
||||
|
||||
Reference in new issue
Block a user