@rigelbuild/omp-catalog 18.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1204 -0
- package/LICENSE +22 -0
- package/README.md +43 -0
- package/THIRD-PARTY-NOTICES.txt +22909 -0
- package/dist/types/build.d.ts +19 -0
- package/dist/types/compat/anthropic.d.ts +18 -0
- package/dist/types/compat/apply.d.ts +7 -0
- package/dist/types/compat/bedrock.d.ts +3 -0
- package/dist/types/compat/devin.d.ts +9 -0
- package/dist/types/compat/openai.d.ts +28 -0
- package/dist/types/discovery/antigravity.d.ts +70 -0
- package/dist/types/discovery/codex.d.ts +36 -0
- package/dist/types/discovery/cursor-proto.d.ts +5368 -0
- package/dist/types/discovery/cursor.d.ts +23 -0
- package/dist/types/discovery/devin-proto.d.ts +1549 -0
- package/dist/types/discovery/devin.d.ts +24 -0
- package/dist/types/discovery/gemini-cli.d.ts +33 -0
- package/dist/types/discovery/gemini.d.ts +25 -0
- package/dist/types/discovery/gitlab-duo-workflow.d.ts +30 -0
- package/dist/types/discovery/index.d.ts +7 -0
- package/dist/types/discovery/openai-compatible.d.ts +89 -0
- package/dist/types/discovery/protobuf.d.ts +102 -0
- package/dist/types/effort.d.ts +10 -0
- package/dist/types/fireworks-model-id.d.ts +23 -0
- package/dist/types/hosts.d.ts +169 -0
- package/dist/types/identity/bundled.d.ts +3 -0
- package/dist/types/identity/classify.d.ts +69 -0
- package/dist/types/identity/dialect.d.ts +3 -0
- package/dist/types/identity/family.d.ts +239 -0
- package/dist/types/identity/id.d.ts +12 -0
- package/dist/types/identity/index.d.ts +8 -0
- package/dist/types/identity/markers.d.ts +4 -0
- package/dist/types/identity/priority.d.ts +1 -0
- package/dist/types/identity/reference.d.ts +29 -0
- package/dist/types/index.d.ts +19 -0
- package/dist/types/model-cache.d.ts +23 -0
- package/dist/types/model-manager.d.ts +77 -0
- package/dist/types/model-thinking.d.ts +70 -0
- package/dist/types/model-tokenizer.d.ts +8 -0
- package/dist/types/models.d.ts +14 -0
- package/dist/types/openai-pricing.d.ts +17 -0
- package/dist/types/provider-models/bundled-references.d.ts +13 -0
- package/dist/types/provider-models/cache-provider-id.d.ts +11 -0
- package/dist/types/provider-models/descriptor-types.d.ts +76 -0
- package/dist/types/provider-models/descriptors.d.ts +505 -0
- package/dist/types/provider-models/google.d.ts +29 -0
- package/dist/types/provider-models/index.d.ts +7 -0
- package/dist/types/provider-models/ollama.d.ts +22 -0
- package/dist/types/provider-models/openai-compat.d.ts +646 -0
- package/dist/types/provider-models/special.d.ts +53 -0
- package/dist/types/types.d.ts +876 -0
- package/dist/types/utils.d.ts +20 -0
- package/dist/types/variant-collapse.d.ts +137 -0
- package/dist/types/wire/alibaba-token-plan.d.ts +22 -0
- package/dist/types/wire/codex.d.ts +66 -0
- package/dist/types/wire/coreweave.d.ts +12 -0
- package/dist/types/wire/gemini-headers.d.ts +62 -0
- package/dist/types/wire/github-copilot.d.ts +47 -0
- package/dist/types/wire/image-fetchers.d.ts +76 -0
- package/package.json +102 -0
- package/src/build.ts +103 -0
- package/src/compat/anthropic.ts +179 -0
- package/src/compat/apply.ts +15 -0
- package/src/compat/bedrock.ts +153 -0
- package/src/compat/devin.ts +12 -0
- package/src/compat/openai.ts +864 -0
- package/src/discovery/antigravity.ts +257 -0
- package/src/discovery/codex.ts +456 -0
- package/src/discovery/cursor-proto.ts +8329 -0
- package/src/discovery/cursor.ts +409 -0
- package/src/discovery/devin-proto.ts +2037 -0
- package/src/discovery/devin.ts +152 -0
- package/src/discovery/gemini-cli.ts +198 -0
- package/src/discovery/gemini.ts +260 -0
- package/src/discovery/gitlab-duo-workflow.ts +859 -0
- package/src/discovery/index.ts +7 -0
- package/src/discovery/openai-compatible.ts +266 -0
- package/src/discovery/protobuf.ts +1073 -0
- package/src/effort.ts +18 -0
- package/src/fireworks-model-id.ts +50 -0
- package/src/hosts.ts +178 -0
- package/src/identity/bundled.ts +28 -0
- package/src/identity/classify.ts +242 -0
- package/src/identity/dialect.ts +42 -0
- package/src/identity/family.ts +477 -0
- package/src/identity/id.ts +81 -0
- package/src/identity/index.ts +8 -0
- package/src/identity/markers.ts +49 -0
- package/src/identity/priority.ts +59 -0
- package/src/identity/reference.ts +167 -0
- package/src/index.ts +19 -0
- package/src/model-cache.ts +346 -0
- package/src/model-manager.ts +683 -0
- package/src/model-thinking.ts +925 -0
- package/src/model-tokenizer.ts +75 -0
- package/src/models.json +304657 -0
- package/src/models.json.d.ts +9 -0
- package/src/models.ts +108 -0
- package/src/openai-pricing.ts +29 -0
- package/src/provider-models/bundled-references.ts +87 -0
- package/src/provider-models/cache-provider-id.ts +97 -0
- package/src/provider-models/descriptor-types.ts +85 -0
- package/src/provider-models/descriptors.ts +585 -0
- package/src/provider-models/google.ts +117 -0
- package/src/provider-models/index.ts +7 -0
- package/src/provider-models/ollama.ts +204 -0
- package/src/provider-models/openai-compat.ts +6405 -0
- package/src/provider-models/special.ts +213 -0
- package/src/types.ts +1032 -0
- package/src/utils.ts +69 -0
- package/src/variant-collapse.ts +1555 -0
- package/src/wire/alibaba-token-plan.ts +54 -0
- package/src/wire/codex.ts +117 -0
- package/src/wire/coreweave.ts +51 -0
- package/src/wire/gemini-headers.ts +135 -0
- package/src/wire/github-copilot.ts +147 -0
- package/src/wire/image-fetchers.ts +159 -0
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-API compat builders — chat-completions and Responses flavors.
|
|
3
|
+
*
|
|
4
|
+
* `buildOpenAICompat`/`buildOpenAIResponsesCompat` run exactly once per model
|
|
5
|
+
* (from `buildModel`): detection writes a fresh record, sparse spec overrides
|
|
6
|
+
* are assigned onto it in place, and conditional policies are materialized as
|
|
7
|
+
* complete alternate views. Request handlers read `model.compat` fields and
|
|
8
|
+
* never detect, resolve, or allocate.
|
|
9
|
+
*/
|
|
10
|
+
import { isFireworksFastModelId } from "../fireworks-model-id";
|
|
11
|
+
import { hostMatchesUrl, modelMatchesHost } from "../hosts";
|
|
12
|
+
import { bareModelId, parseOpenAIModel, semverGte } from "../identity/classify";
|
|
13
|
+
import {
|
|
14
|
+
isAnthropicNamespacedModelId,
|
|
15
|
+
isClaudeModelId,
|
|
16
|
+
isDeepseekModelIdOrName,
|
|
17
|
+
isGlm52ReasoningEffortModelId,
|
|
18
|
+
isGrokReasoningEffortCapable,
|
|
19
|
+
isGrokXHighEffortCapable,
|
|
20
|
+
isKimiK3ModelId,
|
|
21
|
+
isKimiK26ModelId,
|
|
22
|
+
isKimiModelId,
|
|
23
|
+
isMimoModelIdOrName,
|
|
24
|
+
isOpenAISamplingRestrictedModelId,
|
|
25
|
+
isQwen38PlusTemplateEffortModelId,
|
|
26
|
+
isQwenModelId,
|
|
27
|
+
} from "../identity/family";
|
|
28
|
+
import type {
|
|
29
|
+
ModelSpec,
|
|
30
|
+
OpenAICompat,
|
|
31
|
+
OpenAIStreamMarkupHealingPattern,
|
|
32
|
+
ResolvedOpenAICompat,
|
|
33
|
+
ResolvedOpenAIResponsesCompat,
|
|
34
|
+
ResolvedOpenAISharedCompat,
|
|
35
|
+
ResolvedOpenRouterCompat,
|
|
36
|
+
} from "../types";
|
|
37
|
+
import { applyCompatOverrides } from "./apply";
|
|
38
|
+
|
|
39
|
+
/** GLM coding-plan SKUs idle for minutes mid-reasoning; see `streamIdleTimeoutMs`. */
|
|
40
|
+
const GLM_CODING_PLAN_MODEL_PATTERN = /(^|\/)glm-5(?:[.-]|$)/i;
|
|
41
|
+
const GLM_CODING_PLAN_STREAM_IDLE_TIMEOUT_MS = 600_000;
|
|
42
|
+
/** Direct DeepSeek reasoning models stall between thinking and answer phases. */
|
|
43
|
+
const DEEPSEEK_REASONING_STREAM_IDLE_TIMEOUT_MS = 300_000;
|
|
44
|
+
/** Kimi K2.6 and native K2.7 Code can spend several minutes reasoning before the first visible token. */
|
|
45
|
+
const KIMI_REASONING_STREAM_IDLE_TIMEOUT_MS = 300_000;
|
|
46
|
+
/**
|
|
47
|
+
* Native Kimi K2.7 Code requires `thinking.type: "enabled"` and rejects
|
|
48
|
+
* disabled thinking. Match the public id, its Fast variant, and the
|
|
49
|
+
* `kimi-code/kimi-for-coding` alias (which keeps the family name).
|
|
50
|
+
* Caller-disabled requests on non-native dialects (Fireworks `openai`,
|
|
51
|
+
* OpenRouter `openrouter`, …) MUST keep their per-dialect disable shape —
|
|
52
|
+
* gating on `isMoonshotKimi` is the caller's responsibility.
|
|
53
|
+
*/
|
|
54
|
+
const KIMI_K27_CODE_MODEL_PATTERN = /(?:^|\/)kimi[-._]?k2(?:[._-]?|p)7[-._]?code(?:[-._]?highspeed)?$/i;
|
|
55
|
+
|
|
56
|
+
function matchesKimiK27CodeFamily(spec: ModelSpec<"openai-completions">): boolean {
|
|
57
|
+
if (KIMI_K27_CODE_MODEL_PATTERN.test(spec.id)) return true;
|
|
58
|
+
return spec.id === "kimi-for-coding" && /k2\.?7 code/i.test(spec.name ?? "");
|
|
59
|
+
}
|
|
60
|
+
/** Xiaomi MiMo Pro on api.xiaomimimo.com can stall ~2min before the first event (issue #1770). */
|
|
61
|
+
const XIAOMI_MIMO_STREAM_IDLE_TIMEOUT_MS = 300_000;
|
|
62
|
+
/** Alibaba Coding Plan (coding-intl.dashscope) qwen models idle before the first event (issue #1770). */
|
|
63
|
+
const ALIBABA_CODING_PLAN_STREAM_IDLE_TIMEOUT_MS = 600_000;
|
|
64
|
+
/** Local OpenAI-compatible backends can spend minutes cold-loading a model before the first SSE event. */
|
|
65
|
+
const LOCAL_OPENAI_COMPAT_STREAM_IDLE_TIMEOUT_MS = 300_000;
|
|
66
|
+
const MINIMAX_PROVIDER_OR_ID_PATTERN = /minimax/i;
|
|
67
|
+
const DSML_HEALING_PROVIDERS = new Set([
|
|
68
|
+
"ollama",
|
|
69
|
+
"ollama-cloud",
|
|
70
|
+
"nvidia",
|
|
71
|
+
"deepseek",
|
|
72
|
+
"fireworks",
|
|
73
|
+
"nanogpt",
|
|
74
|
+
"opencode-go",
|
|
75
|
+
"openrouter",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// Ollama's OpenAI-compatible `reasoning.effort` accepts `high|medium|low|max|none`;
|
|
79
|
+
// `ollama`-provider reasoning models carry the wire-exact `low..max` effort
|
|
80
|
+
// ladder (see getModelDefinedEfforts), so no compat-level remapping is needed.
|
|
81
|
+
// Custom OpenAI-compatible providers pointed at a local Ollama port under a
|
|
82
|
+
// different provider id must set `compat.reasoningEffortMap` themselves.
|
|
83
|
+
|
|
84
|
+
function resolveReasoningDisableMode(
|
|
85
|
+
thinkingFormat: ResolvedOpenAISharedCompat["thinkingFormat"],
|
|
86
|
+
): ResolvedOpenAISharedCompat["reasoningDisableMode"] {
|
|
87
|
+
switch (thinkingFormat) {
|
|
88
|
+
case "openrouter":
|
|
89
|
+
return "openrouter-enabled-false";
|
|
90
|
+
case "zai":
|
|
91
|
+
case "kimi":
|
|
92
|
+
return "zai-thinking-disabled";
|
|
93
|
+
case "qwen":
|
|
94
|
+
return "qwen-enable-thinking-false";
|
|
95
|
+
case "qwen-chat-template":
|
|
96
|
+
return "qwen-template-false";
|
|
97
|
+
default:
|
|
98
|
+
return "lowest-effort";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pick the leaked-markup healer for an OpenAI-compatible visible-text stream.
|
|
104
|
+
* Kimi chat-template tokens and DeepSeek DSML envelopes need their dedicated
|
|
105
|
+
* tool-call grammars. Every other OpenAI-compatible model defaults to
|
|
106
|
+
* `"thinking"` so leaked reasoning idioms (e.g. a Gemini ` ```thinking ` fence
|
|
107
|
+
* on OpenRouter) are recovered from `delta.content` — **except** the official
|
|
108
|
+
* OpenAI endpoint (`provider: "openai"` + `api.openai.com`), which returns
|
|
109
|
+
* structured reasoning and never leaks, so it heals nothing (returns
|
|
110
|
+
* `undefined`) to avoid misfiring on legitimate fenced content.
|
|
111
|
+
*/
|
|
112
|
+
function detectStreamMarkupHealingPattern(
|
|
113
|
+
provider: string,
|
|
114
|
+
modelId: string,
|
|
115
|
+
baseUrl: string,
|
|
116
|
+
): OpenAIStreamMarkupHealingPattern | undefined {
|
|
117
|
+
if (provider === "kimi-code" || provider === "moonshot" || /kimi[-/_.]?k2/i.test(modelId)) {
|
|
118
|
+
return "kimi";
|
|
119
|
+
}
|
|
120
|
+
if (isDeepseekModelIdOrName(modelId) && DSML_HEALING_PROVIDERS.has(provider)) {
|
|
121
|
+
return "dsml";
|
|
122
|
+
}
|
|
123
|
+
if (isOfficialOpenAIEndpoint(provider, baseUrl)) return undefined;
|
|
124
|
+
return "thinking";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Strict official-OpenAI check: provider id `openai` and an `api.openai.com` host (missing baseUrl defaults there). */
|
|
128
|
+
function isOfficialOpenAIEndpoint(provider: string, baseUrl: string): boolean {
|
|
129
|
+
if (provider !== "openai") return false;
|
|
130
|
+
if (!baseUrl) return true;
|
|
131
|
+
try {
|
|
132
|
+
return new URL(baseUrl).hostname === "api.openai.com";
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Explicit prompt-cache breakpoints are a GPT-5.6+ first-party contract. Keep
|
|
140
|
+
* this intentionally narrow: compatible gateways and older OpenAI models
|
|
141
|
+
* reject the new request fields unless their catalog compat opts in.
|
|
142
|
+
*/
|
|
143
|
+
function supportsOfficialOpenAIPromptCacheBreakpoints(provider: string, modelId: string, baseUrl: string): boolean {
|
|
144
|
+
if (!isOfficialOpenAIEndpoint(provider, baseUrl)) return false;
|
|
145
|
+
const model = parseOpenAIModel(bareModelId(modelId));
|
|
146
|
+
return model !== null && semverGte(model.version, "5.6");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* OpenCode's gateways (https://opencode.ai/zen|go) gate `reasoning_content`
|
|
151
|
+
* on the request's thinking state for every model they front (Kimi K2.x,
|
|
152
|
+
* DeepSeek V4, GLM-5.x, Qwen3.x, MiMo, MiniMax, …): they 400 with `Extra
|
|
153
|
+
* inputs are not permitted` when thinking is off but the field is supplied
|
|
154
|
+
* (#1071), and 400 with `thinking is enabled but reasoning_content is missing
|
|
155
|
+
* in assistant tool call message at index N` (#1484) when thinking is on and
|
|
156
|
+
* the field is absent. The base compat therefore leaves the replay off, and
|
|
157
|
+
* this `whenThinking` policy reactivates it for thinking-engaged requests.
|
|
158
|
+
* `allowsSyntheticReasoningContentForToolCalls` is forced to `false` on the
|
|
159
|
+
* same path: the gateway specifically requires `reasoning_content`, and the
|
|
160
|
+
* synthetic-friendly default would echo whichever field the upstream streamed
|
|
161
|
+
* (e.g. `reasoning` for many opencode turns), landing the replay in the wrong
|
|
162
|
+
* key and re-triggering the 400.
|
|
163
|
+
*/
|
|
164
|
+
const OPENCODE_WHEN_THINKING: NonNullable<OpenAICompat["whenThinking"]> = {
|
|
165
|
+
requiresReasoningContentForToolCalls: true,
|
|
166
|
+
allowsSyntheticReasoningContentForToolCalls: false,
|
|
167
|
+
reasoningContentField: "reasoning_content",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const KIMI_K3_REASONING_EFFORT_MAP: NonNullable<OpenAICompat["reasoningEffortMap"]> = {
|
|
171
|
+
minimal: "low",
|
|
172
|
+
medium: "high",
|
|
173
|
+
xhigh: "max",
|
|
174
|
+
max: "max",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const MIMO_REASONING_EFFORT_MAP: NonNullable<OpenAICompat["reasoningEffortMap"]> = {
|
|
178
|
+
minimal: "low",
|
|
179
|
+
xhigh: "high",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Shared `minimal → low` clamp. xhigh-capable Grok keeps `xhigh` unmapped. */
|
|
183
|
+
const XAI_RESPONSES_MINIMAL_EFFORT_MAP: NonNullable<OpenAICompat["reasoningEffortMap"]> = {
|
|
184
|
+
minimal: "low",
|
|
185
|
+
};
|
|
186
|
+
/** Grok 4.5 / 4.3 / 3-mini: leftover `xhigh`/`max` clamp to `high`. */
|
|
187
|
+
const XAI_RESPONSES_CLAMPED_EFFORT_MAP: NonNullable<OpenAICompat["reasoningEffortMap"]> = {
|
|
188
|
+
minimal: "low",
|
|
189
|
+
xhigh: "high",
|
|
190
|
+
max: "high",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Wire effort remap for first-party xAI Responses. */
|
|
194
|
+
export function xaiResponsesReasoningEffortMap(modelId: string): NonNullable<OpenAICompat["reasoningEffortMap"]> {
|
|
195
|
+
return isGrokXHighEffortCapable(modelId) ? XAI_RESPONSES_MINIMAL_EFFORT_MAP : XAI_RESPONSES_CLAMPED_EFFORT_MAP;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function mergeModelReasoningEffortMap(
|
|
199
|
+
compat: ResolvedOpenAISharedCompat,
|
|
200
|
+
modelId: string,
|
|
201
|
+
isMimoReasoningEffortModel: boolean,
|
|
202
|
+
): void {
|
|
203
|
+
let detected: NonNullable<OpenAICompat["reasoningEffortMap"]>;
|
|
204
|
+
if (isKimiK3ModelId(modelId)) {
|
|
205
|
+
detected = KIMI_K3_REASONING_EFFORT_MAP;
|
|
206
|
+
} else if (isMimoReasoningEffortModel) {
|
|
207
|
+
detected = MIMO_REASONING_EFFORT_MAP;
|
|
208
|
+
} else {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
compat.reasoningEffortMap = { ...detected, ...compat.reasoningEffortMap };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function detectStrictModeSupport(provider: string, baseUrl: string): boolean {
|
|
215
|
+
if (
|
|
216
|
+
provider === "openai" ||
|
|
217
|
+
provider === "openrouter" ||
|
|
218
|
+
provider === "cerebras" ||
|
|
219
|
+
provider === "together" ||
|
|
220
|
+
provider === "github-copilot" ||
|
|
221
|
+
provider === "zenmux"
|
|
222
|
+
) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
return (
|
|
226
|
+
hostMatchesUrl(baseUrl, "openai") ||
|
|
227
|
+
hostMatchesUrl(baseUrl, "azureOpenAI") ||
|
|
228
|
+
hostMatchesUrl(baseUrl, "cerebras") ||
|
|
229
|
+
hostMatchesUrl(baseUrl, "together") ||
|
|
230
|
+
hostMatchesUrl(baseUrl, "openrouter") ||
|
|
231
|
+
hostMatchesUrl(baseUrl, "deepseekFamily")
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Local OpenAI-compatible inference servers whose chat templates re-tokenize
|
|
237
|
+
* the entire prompt every request — llama.cpp prefix-KV-cache reuse only
|
|
238
|
+
* survives when the rendered tokens stay byte-identical across turns. The
|
|
239
|
+
* runtime auto-enables {@link OpenAICompat.replayReasoningContent} for these
|
|
240
|
+
* providers (and for any provider pointed at a loopback / RFC1918 baseUrl) so
|
|
241
|
+
* Qwen3 / DeepSeek-R1 / GLM templates can reconstruct the prior assistant
|
|
242
|
+
* turn's `<think>` block from `reasoning_content` (#3528).
|
|
243
|
+
*/
|
|
244
|
+
const LOCAL_OPENAI_COMPAT_PROVIDERS = new Set(["llama.cpp", "lm-studio", "vllm", "ollama"]);
|
|
245
|
+
|
|
246
|
+
/** Hosts that accept only none/auto/required rather than a named tool-choice object. */
|
|
247
|
+
const STRING_ONLY_NAMED_TOOL_CHOICE_PROVIDERS: Record<string, true> = {
|
|
248
|
+
"llama.cpp": true,
|
|
249
|
+
"lm-studio": true,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Local proxy providers that share the loopback-default baseUrl but forward
|
|
254
|
+
* to an unrelated upstream (OpenAI, Anthropic, …) rather than running a
|
|
255
|
+
* chat-template renderer themselves — `replayReasoningContent` would push
|
|
256
|
+
* `reasoning_content` to the upstream, which gains no KV-cache benefit and
|
|
257
|
+
* may 400 on the extra field. Excluded from BOTH the provider check above
|
|
258
|
+
* and the loopback heuristic below; users who want the replay on a custom
|
|
259
|
+
* proxy setup can opt in via the sparse `compat.replayReasoningContent`
|
|
260
|
+
* override.
|
|
261
|
+
*/
|
|
262
|
+
const PROXY_OPENAI_COMPAT_PROVIDERS = new Set(["litellm"]);
|
|
263
|
+
|
|
264
|
+
function hasLocalLoopbackBaseUrl(baseUrl: string | undefined): boolean {
|
|
265
|
+
if (!baseUrl) return false;
|
|
266
|
+
let hostname: string;
|
|
267
|
+
try {
|
|
268
|
+
hostname = new URL(baseUrl).hostname.toLowerCase();
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
hostname === "localhost" ||
|
|
274
|
+
hostname === "127.0.0.1" ||
|
|
275
|
+
hostname === "0.0.0.0" ||
|
|
276
|
+
hostname === "::1" ||
|
|
277
|
+
hostname === "[::1]"
|
|
278
|
+
) {
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
if (/^10\./.test(hostname)) return true;
|
|
282
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
283
|
+
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname)) return true;
|
|
284
|
+
if (hostname.endsWith(".local")) return true;
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build the resolved chat-completions compat record for a model spec.
|
|
290
|
+
* Provider takes precedence over URL-based detection since it's explicitly configured.
|
|
291
|
+
*/
|
|
292
|
+
export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): ResolvedOpenAICompat {
|
|
293
|
+
const provider = spec.provider;
|
|
294
|
+
const baseUrl = spec.baseUrl;
|
|
295
|
+
const hostModel = { provider, baseUrl };
|
|
296
|
+
|
|
297
|
+
const isCerebras = modelMatchesHost(hostModel, "cerebras");
|
|
298
|
+
const isZai = modelMatchesHost(hostModel, "zai");
|
|
299
|
+
const isZhipu = modelMatchesHost(hostModel, "zhipu");
|
|
300
|
+
const supportsZaiReasoningEffort = (isZai || isZhipu) && isGlm52ReasoningEffortModelId(spec.id);
|
|
301
|
+
const isKilo = modelMatchesHost(hostModel, "kilo");
|
|
302
|
+
const isKimiModel = isKimiModelId(spec.id);
|
|
303
|
+
const isMoonshotNative = modelMatchesHost(hostModel, "moonshotNative");
|
|
304
|
+
const isMoonshotKimi = isKimiModel && isMoonshotNative;
|
|
305
|
+
// Native Kimi K3 uses OpenAI-style `reasoning_effort` with mandatory
|
|
306
|
+
// low/high/max thinking, not the K2.x binary `thinking: { type }` block.
|
|
307
|
+
const isKimiK3 = isKimiK3ModelId(spec.id);
|
|
308
|
+
const isMoonshotKimiK3 = isMoonshotKimi && isKimiK3;
|
|
309
|
+
const requiresEnabledThinking = isMoonshotKimi && matchesKimiK27CodeFamily(spec);
|
|
310
|
+
const usesMoonshotKimiPreservedThinking = isMoonshotKimi && isKimiK26ModelId(spec.id);
|
|
311
|
+
const isAnthropicModel =
|
|
312
|
+
modelMatchesHost(hostModel, "anthropic") || isClaudeModelId(spec.id) || isAnthropicNamespacedModelId(spec.id);
|
|
313
|
+
const isAlibaba = modelMatchesHost(hostModel, "alibabaDashscope");
|
|
314
|
+
const isNvidiaNim = modelMatchesHost(hostModel, "nvidia");
|
|
315
|
+
const isVenice = modelMatchesHost(hostModel, "venice");
|
|
316
|
+
const isQwen = isQwenModelId(spec.id);
|
|
317
|
+
// DeepSeek V4 (and other reasoning-capable DeepSeek models) reject follow-up requests in
|
|
318
|
+
// thinking mode unless prior assistant tool-call turns include `reasoning_content`. The
|
|
319
|
+
// upstream model is reachable through many OpenAI-compat hosts (api.deepseek.com, Deepinfra,
|
|
320
|
+
// Kilo, NVIDIA NIM, Zenmux, OpenRouter, …), so we match by model id/name as well as by
|
|
321
|
+
// provider/baseUrl. The flag is gated by `spec.reasoning` because the invariant only
|
|
322
|
+
// applies when thinking mode is actually engaged.
|
|
323
|
+
const lowerId = spec.id.toLowerCase();
|
|
324
|
+
const lowerName = (spec.name ?? "").toLowerCase();
|
|
325
|
+
const isXiaomiHost = modelMatchesHost(hostModel, "xiaomi");
|
|
326
|
+
const isXiaomiMimo = isXiaomiHost && (isMimoModelIdOrName(spec.id) || isMimoModelIdOrName(spec.name ?? ""));
|
|
327
|
+
const isMimoReasoningEffortModel =
|
|
328
|
+
!isXiaomiHost && (isMimoModelIdOrName(spec.id) || isMimoModelIdOrName(spec.name ?? ""));
|
|
329
|
+
// OpenCode Zen's `big-pickle` is a DeepSeek reasoning alias; the upstream
|
|
330
|
+
// 400s come from DeepSeek and require exact reasoning_content replay.
|
|
331
|
+
const isOpenCodeDeepseekAlias =
|
|
332
|
+
provider === "opencode-zen" && (lowerId === "big-pickle" || lowerName === "big pickle");
|
|
333
|
+
const isDeepseekFamily =
|
|
334
|
+
modelMatchesHost(hostModel, "deepseekFamily") ||
|
|
335
|
+
isDeepseekModelIdOrName(spec.id) ||
|
|
336
|
+
isDeepseekModelIdOrName(spec.name ?? "") ||
|
|
337
|
+
isOpenCodeDeepseekAlias;
|
|
338
|
+
const isDirectDeepseekApi = modelMatchesHost(hostModel, "deepseekDirect");
|
|
339
|
+
const isDeepseekReasoning = isDeepseekFamily && Boolean(spec.reasoning);
|
|
340
|
+
const isDirectDeepseekReasoning = isDirectDeepseekApi && isDeepseekReasoning;
|
|
341
|
+
const isGrok = modelMatchesHost(hostModel, "xai");
|
|
342
|
+
const isMistral = modelMatchesHost(hostModel, "mistral");
|
|
343
|
+
const isOpenCodeHost = modelMatchesHost(hostModel, "opencode");
|
|
344
|
+
// Google AI Studio's OpenAI-compat shim (`generativelanguage.googleapis.com/v1beta/openai`)
|
|
345
|
+
// implements a subset of chat-completions and 400s on `store` ("Unknown name \"store\"").
|
|
346
|
+
const isGoogleAistudioOpenAI = hostMatchesUrl(baseUrl, "googleAistudio");
|
|
347
|
+
const isNonStandard =
|
|
348
|
+
isCerebras ||
|
|
349
|
+
isGrok ||
|
|
350
|
+
isMistral ||
|
|
351
|
+
isGoogleAistudioOpenAI ||
|
|
352
|
+
hostMatchesUrl(baseUrl, "chutes") ||
|
|
353
|
+
hostMatchesUrl(baseUrl, "deepseekFamily") ||
|
|
354
|
+
hostMatchesUrl(baseUrl, "fireworks") ||
|
|
355
|
+
isAlibaba ||
|
|
356
|
+
isZai ||
|
|
357
|
+
isZhipu ||
|
|
358
|
+
isKilo ||
|
|
359
|
+
isQwen ||
|
|
360
|
+
isXiaomiHost ||
|
|
361
|
+
isMoonshotNative ||
|
|
362
|
+
isOpenCodeHost;
|
|
363
|
+
const isOpenCodeProvider = provider === "opencode-go" || provider === "opencode-zen";
|
|
364
|
+
const isLocalOpenAICompatBackend =
|
|
365
|
+
!PROXY_OPENAI_COMPAT_PROVIDERS.has(provider) &&
|
|
366
|
+
(LOCAL_OPENAI_COMPAT_PROVIDERS.has(provider) || hasLocalLoopbackBaseUrl(baseUrl));
|
|
367
|
+
// Stream-timeout floor applies to ANY loopback/RFC1918 backend, INCLUDING
|
|
368
|
+
// local proxies (litellm) excluded from `isLocalOpenAICompatBackend` above:
|
|
369
|
+
// widening the first-event/idle abort ceiling only helps a slow local
|
|
370
|
+
// upstream and never pushes an extra wire field, so the proxy carve-out (a
|
|
371
|
+
// `replayReasoningContent` safety measure) must not also strip the timeout
|
|
372
|
+
// floor. Without this, a loopback litellm fronting a cold/reprocessing
|
|
373
|
+
// llama-server aborts prefill at the 100s default and retry-loops (#4786).
|
|
374
|
+
const isLocalServingBackend = isLocalOpenAICompatBackend || hasLocalLoopbackBaseUrl(baseUrl);
|
|
375
|
+
|
|
376
|
+
const useMaxTokens =
|
|
377
|
+
isMistral ||
|
|
378
|
+
isMoonshotNative ||
|
|
379
|
+
isZai ||
|
|
380
|
+
isZhipu ||
|
|
381
|
+
hostMatchesUrl(baseUrl, "chutes") ||
|
|
382
|
+
hostMatchesUrl(baseUrl, "fireworks") ||
|
|
383
|
+
isDirectDeepseekApi;
|
|
384
|
+
|
|
385
|
+
const supportsPromptCacheBreakpoints = supportsOfficialOpenAIPromptCacheBreakpoints(provider, spec.id, baseUrl);
|
|
386
|
+
// Hosts whose chat-completions endpoints are known to accept multiple
|
|
387
|
+
// leading `system`/`developer` messages (preferred for KV-cache reuse).
|
|
388
|
+
// Anything outside this allowlist defaults to coalescing because
|
|
389
|
+
// strict chat templates (Qwen 3.5+ via vLLM, MiniMax, etc.) reject
|
|
390
|
+
// follow-up system messages with a 400.
|
|
391
|
+
const isOpenAIHost = modelMatchesHost(hostModel, "openai");
|
|
392
|
+
const isAzureHost = modelMatchesHost(hostModel, "azureOpenAI");
|
|
393
|
+
const isOpenRouter = modelMatchesHost(hostModel, "openrouter");
|
|
394
|
+
const isVercelGateway = modelMatchesHost(hostModel, "vercelAIGateway");
|
|
395
|
+
const isTogether = modelMatchesHost(hostModel, "together");
|
|
396
|
+
const isFireworks = hostMatchesUrl(baseUrl, "fireworks");
|
|
397
|
+
const isGroqHost = modelMatchesHost(hostModel, "groq");
|
|
398
|
+
const isCopilotHost = provider === "github-copilot";
|
|
399
|
+
const isZenmuxHost = provider === "zenmux";
|
|
400
|
+
// Endpoints/models that MUST receive a single system block. MiniMax's OpenAI
|
|
401
|
+
// endpoint returns error 2013 on multiple system messages; the Qwen 3.5+ chat
|
|
402
|
+
// template raises "System message must be at the beginning" / 500s with an
|
|
403
|
+
// internal_server_error when any system block appears past index 0. That
|
|
404
|
+
// template ships with the weights, so every Qwen-serving vLLM/SGLang host
|
|
405
|
+
// hits it — confirmed on Alibaba Dashscope, Qwen Portal, and Fireworks
|
|
406
|
+
// (`fireworks/qwen3.7-plus` 500'd on two leading system blocks). Gate on the
|
|
407
|
+
// Qwen family itself, not per-host: coalescing only trades away KV-cache reuse.
|
|
408
|
+
const isMiniMaxHost = modelMatchesHost(hostModel, "minimax");
|
|
409
|
+
const isQwenPortal = modelMatchesHost(hostModel, "qwenPortal");
|
|
410
|
+
const supportsMultipleSystemMessagesDefault =
|
|
411
|
+
!isMiniMaxHost &&
|
|
412
|
+
!isAlibaba &&
|
|
413
|
+
!isQwenPortal &&
|
|
414
|
+
!isQwen &&
|
|
415
|
+
(isOpenAIHost ||
|
|
416
|
+
isAzureHost ||
|
|
417
|
+
isOpenRouter ||
|
|
418
|
+
isCerebras ||
|
|
419
|
+
isTogether ||
|
|
420
|
+
isFireworks ||
|
|
421
|
+
isGroqHost ||
|
|
422
|
+
isDeepseekFamily ||
|
|
423
|
+
isMistral ||
|
|
424
|
+
isGrok ||
|
|
425
|
+
isZai ||
|
|
426
|
+
isZhipu ||
|
|
427
|
+
isCopilotHost ||
|
|
428
|
+
isZenmuxHost);
|
|
429
|
+
|
|
430
|
+
// Stream-watchdog floor: GLM coding-plan SKUs, Kimi K2.6, direct
|
|
431
|
+
// DeepSeek reasoning models, and local OpenAI-compatible backends can idle
|
|
432
|
+
// for minutes while reasoning or cold-loading weights; widen the idle
|
|
433
|
+
// timeout so warm-ups stop aborting and retrying.
|
|
434
|
+
const streamIdleTimeoutMs =
|
|
435
|
+
GLM_CODING_PLAN_MODEL_PATTERN.test(spec.id) && (isZai || isZhipu || isOpenCodeHost)
|
|
436
|
+
? GLM_CODING_PLAN_STREAM_IDLE_TIMEOUT_MS
|
|
437
|
+
: provider === "alibaba-coding-plan"
|
|
438
|
+
? ALIBABA_CODING_PLAN_STREAM_IDLE_TIMEOUT_MS
|
|
439
|
+
: isXiaomiMimo
|
|
440
|
+
? XIAOMI_MIMO_STREAM_IDLE_TIMEOUT_MS
|
|
441
|
+
: spec.reasoning &&
|
|
442
|
+
(isKimiK26ModelId(spec.id) ||
|
|
443
|
+
isMoonshotKimiK3 ||
|
|
444
|
+
(isMoonshotKimi && matchesKimiK27CodeFamily(spec)))
|
|
445
|
+
? KIMI_REASONING_STREAM_IDLE_TIMEOUT_MS
|
|
446
|
+
: spec.reasoning && isDirectDeepseekApi
|
|
447
|
+
? DEEPSEEK_REASONING_STREAM_IDLE_TIMEOUT_MS
|
|
448
|
+
: isLocalServingBackend
|
|
449
|
+
? LOCAL_OPENAI_COMPAT_STREAM_IDLE_TIMEOUT_MS
|
|
450
|
+
: undefined;
|
|
451
|
+
|
|
452
|
+
// Fireworks "Fast" variants (`<id>-fast`) are served from the router
|
|
453
|
+
// namespace (`accounts/fireworks/routers/<id>-fast`), like Fire Pass, rather
|
|
454
|
+
// than the `models/` namespace the rest of the `fireworks` provider uses.
|
|
455
|
+
const isFireworksFastRouter = provider === "fireworks" && isFireworksFastModelId(spec.id);
|
|
456
|
+
const wireModelIdMode: ResolvedOpenAISharedCompat["wireModelIdMode"] =
|
|
457
|
+
provider === "firepass" || isFireworksFastRouter
|
|
458
|
+
? "firepass"
|
|
459
|
+
: provider === "fireworks"
|
|
460
|
+
? "fireworks"
|
|
461
|
+
: isOpenRouter
|
|
462
|
+
? "openrouter"
|
|
463
|
+
: "raw";
|
|
464
|
+
const thinkingFormat: ResolvedOpenAISharedCompat["thinkingFormat"] =
|
|
465
|
+
(isMoonshotKimi && !isMoonshotKimiK3) || isZai || isZhipu || isXiaomiMimo
|
|
466
|
+
? "zai"
|
|
467
|
+
: isOpenRouter
|
|
468
|
+
? "openrouter"
|
|
469
|
+
: isQwen && (isNvidiaNim || provider === "vllm")
|
|
470
|
+
? "qwen-chat-template"
|
|
471
|
+
: isQwen && (isFireworks || isVenice)
|
|
472
|
+
? "openai"
|
|
473
|
+
: isAlibaba || isQwen
|
|
474
|
+
? "qwen"
|
|
475
|
+
: "openai";
|
|
476
|
+
|
|
477
|
+
const compat: ResolvedOpenAICompat = {
|
|
478
|
+
supportsStore: !isNonStandard,
|
|
479
|
+
// `developer` is an OpenAI-Responses-era extension to the chat-completions schema. Almost
|
|
480
|
+
// every OpenAI-compatible host other than OpenAI itself (and Azure OpenAI, which mirrors
|
|
481
|
+
// the schema exactly) treats it as an unknown role: Moonshot returns a 400 "tokenization
|
|
482
|
+
// failed", Groq/Cerebras/etc. error or silently misroute. Default to `system` and require
|
|
483
|
+
// callers to opt in via `compat.supportsDeveloperRole: true` for hosts known to mirror
|
|
484
|
+
// OpenAI's reasoning-API surface.
|
|
485
|
+
supportsDeveloperRole: isOpenAIHost || isAzureHost,
|
|
486
|
+
supportsMultipleSystemMessages: supportsMultipleSystemMessagesDefault,
|
|
487
|
+
supportsReasoningEffort: !isGrok && !isXiaomiMimo && (!(isZai || isZhipu) || supportsZaiReasoningEffort),
|
|
488
|
+
// GitHub Copilot's chat-completions endpoint rejects reasoning params wholesale.
|
|
489
|
+
supportsReasoningParams: provider !== "github-copilot",
|
|
490
|
+
// OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit
|
|
491
|
+
// temperature/top_p/… with a 400 on every serving host (#5606).
|
|
492
|
+
supportsSamplingParams: !isOpenAISamplingRestrictedModelId(spec.id),
|
|
493
|
+
// xAI reasoning models 400 on presence/frequency penalties and stop.
|
|
494
|
+
supportsPenaltyAndStopParams: !(isGrok && Boolean(spec.reasoning)),
|
|
495
|
+
reasoningEffortMap: {},
|
|
496
|
+
supportsUsageInStreaming: !isCerebras,
|
|
497
|
+
// Kimi (including via OpenRouter and Fireworks router-form IDs such as
|
|
498
|
+
// `accounts/fireworks/routers/kimi-*`) calculates TPM rate limits based on
|
|
499
|
+
// max_tokens, not actual output. The official Kimi K2 model guidance
|
|
500
|
+
// (https://docs.fireworks.ai/models/kimi-k2) also requires `max_tokens` for
|
|
501
|
+
// every call since the family can otherwise emit very long reasoning traces
|
|
502
|
+
// before the final answer.
|
|
503
|
+
alwaysSendMaxTokens: isKimiModel,
|
|
504
|
+
// Native Kimi K3 always reasons through `reasoning_effort` (never the
|
|
505
|
+
// K2.x binary `thinking` block that #827's forced-tool-choice conflict is
|
|
506
|
+
// about), so suppressing its effort would leave K3 in an unsupported mode.
|
|
507
|
+
disableReasoningOnForcedToolChoice: (isKimiModel && !isMoonshotKimiK3) || isAnthropicModel,
|
|
508
|
+
disableReasoningOnToolChoice: isDeepseekFamily && Boolean(spec.reasoning) && !isOpenRouter,
|
|
509
|
+
supportsToolChoice: !isDirectDeepseekReasoning,
|
|
510
|
+
// DeepSeek reasoning models on OpenCode Zen/Go 400 with
|
|
511
|
+
// "Thinking mode does not support this tool_choice" when a specific
|
|
512
|
+
// function is forced while the gateway's default thinking mode is active.
|
|
513
|
+
// Downgrade only on those gateways: other hosts can turn thinking off via
|
|
514
|
+
// disableReasoningOnToolChoice and must retain hard tool selection.
|
|
515
|
+
supportsForcedToolChoice: !requiresEnabledThinking && !(isOpenCodeHost && isDeepseekReasoning),
|
|
516
|
+
supportsNamedToolChoice: STRING_ONLY_NAMED_TOOL_CHOICE_PROVIDERS[provider] !== true,
|
|
517
|
+
maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
|
|
518
|
+
requiresToolResultName: isMistral,
|
|
519
|
+
requiresAssistantAfterToolResult: isMistral,
|
|
520
|
+
requiresThinkingAsText: isMistral,
|
|
521
|
+
requiresMistralToolIds: isMistral,
|
|
522
|
+
// Only Kimi's native K2.x hosts (Moonshot / Kimi-code, matched by
|
|
523
|
+
// `isMoonshotKimi`) speak the z.ai binary `thinking: { type }` field.
|
|
524
|
+
// K3 and Kimi reached through OpenAI-compatible proxies drive reasoning
|
|
525
|
+
// via OpenAI-style `reasoning_effort`.
|
|
526
|
+
// NVIDIA NIM hosts Qwen with the vLLM convention
|
|
527
|
+
// (`chat_template_kwargs.enable_thinking`); top-level `enable_thinking`
|
|
528
|
+
// is rejected by NIM's `additionalProperties: false` request schema
|
|
529
|
+
// (issue #2299).
|
|
530
|
+
thinkingFormat,
|
|
531
|
+
kimiApiFormat: undefined,
|
|
532
|
+
reasoningDisableMode: isVenice ? "venice-disable-thinking" : resolveReasoningDisableMode(thinkingFormat),
|
|
533
|
+
omitReasoningEffort: false,
|
|
534
|
+
includeEncryptedReasoning: true,
|
|
535
|
+
filterReasoningHistory: isOpenRouter && isAnthropicModel,
|
|
536
|
+
thinkingKeep: usesMoonshotKimiPreservedThinking ? "all" : undefined,
|
|
537
|
+
reasoningContentField: "reasoning_content",
|
|
538
|
+
// Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`:
|
|
539
|
+
// - Kimi: documented invariant on its native API.
|
|
540
|
+
// - DeepSeek-family reasoning models, including aliased OpenCode Zen models
|
|
541
|
+
// like `big-pickle`, validate exact thinking-mode replay.
|
|
542
|
+
// - Xiaomi MiMo models require exact `reasoning_content` replay on
|
|
543
|
+
// thinking-mode tool-call continuations across standard and Token Plan hosts.
|
|
544
|
+
// - Any reasoning-capable model reached through OpenRouter can enforce this
|
|
545
|
+
// server-side whenever the request is in thinking mode. We can't translate
|
|
546
|
+
// Anthropic's redacted/encrypted reasoning into provider-native plaintext,
|
|
547
|
+
// so cross-provider continuations rely on a placeholder.
|
|
548
|
+
// OpenCode Kimi aliases handle reasoning content internally and reject
|
|
549
|
+
// client-sent `reasoning_content`, so exclude only that Kimi-on-OpenCode path
|
|
550
|
+
// (the `whenThinking` policy below re-enables the replay for thinking turns).
|
|
551
|
+
requiresReasoningContentForToolCalls:
|
|
552
|
+
(isKimiModel && !isOpenCodeProvider) ||
|
|
553
|
+
(isDeepseekFamily && Boolean(spec.reasoning)) ||
|
|
554
|
+
isXiaomiMimo ||
|
|
555
|
+
(isOpenRouter && Boolean(spec.reasoning)),
|
|
556
|
+
requiresReasoningContentForAllAssistantTurns:
|
|
557
|
+
((isDeepseekFamily && Boolean(spec.reasoning)) || isXiaomiMimo) && !isOpenRouter,
|
|
558
|
+
// DeepSeek V4 and Xiaomi MiMo reject synthetic reasoning_content placeholders (".") on tool-call turns.
|
|
559
|
+
// Kimi and OpenRouter accept them when actual reasoning is unavailable.
|
|
560
|
+
allowsSyntheticReasoningContentForToolCalls: (!isDeepseekFamily || !spec.reasoning) && !isXiaomiMimo,
|
|
561
|
+
// Local llama.cpp-style servers re-tokenize the entire chat-template
|
|
562
|
+
// prompt each request; Qwen3 / DeepSeek-R1 / GLM templates reconstruct
|
|
563
|
+
// the prior assistant turn's `<think>` block from `reasoning_content`,
|
|
564
|
+
// so dropping the field re-renders the assistant turn without thinking
|
|
565
|
+
// content and forces full prompt re-processing (#3528). The
|
|
566
|
+
// `requires*ReasoningContent*` flags above stay off for these hosts —
|
|
567
|
+
// they accept but don't validate the field — so the encoder needs a
|
|
568
|
+
// distinct opt-in to replay on every reasoning turn. NOT gated on
|
|
569
|
+
// `spec.reasoning`: the runtime discovery paths for `llama.cpp` /
|
|
570
|
+
// `lm-studio` / `openai-models-list` hardcode `reasoning: false`
|
|
571
|
+
// because the upstream `/models` endpoints don't advertise the
|
|
572
|
+
// capability, but the OpenAI stream parser still records incoming
|
|
573
|
+
// `reasoning_content` deltas as thinking blocks. Gating on the spec
|
|
574
|
+
// flag would leave every discovered local Qwen / DeepSeek model
|
|
575
|
+
// re-triggering #3528. The encoder only writes `reasoning_content`
|
|
576
|
+
// when a thinking block actually exists on the turn
|
|
577
|
+
// (`nonEmptyThinkingBlocks.length > 0`), so the flag is a no-op on
|
|
578
|
+
// pure-text histories.
|
|
579
|
+
replayReasoningContent: isLocalOpenAICompatBackend,
|
|
580
|
+
// `preserve_thinking: true` makes the Qwen3.6+ chat template render
|
|
581
|
+
// `<think>...</think>` for older assistant turns too, instead of
|
|
582
|
+
// stripping it the moment a new user message moves them past
|
|
583
|
+
// `last_query_index`. Without it, the slot's KV cache (which holds the
|
|
584
|
+
// raw `<think>X</think>` tokens emitted during generation) diverges
|
|
585
|
+
// from the next-turn render and llama.cpp falls back to full prompt
|
|
586
|
+
// re-processing — the exact symptom reported in #3541. Auto-enabled
|
|
587
|
+
// for Qwen thinking dialects on local llama.cpp-style backends (paired
|
|
588
|
+
// with `replayReasoningContent` above). Non-Qwen templates ignore the
|
|
589
|
+
// parameter, so the flag stays a no-op outside the Qwen path.
|
|
590
|
+
qwenPreserveThinking:
|
|
591
|
+
(thinkingFormat === "qwen" || thinkingFormat === "qwen-chat-template") && isLocalOpenAICompatBackend,
|
|
592
|
+
// Qwen 3.8+ templates steer thinking depth via the `reasoning_effort`
|
|
593
|
+
// template kwarg (low/medium/xhigh, default xhigh); without routing the
|
|
594
|
+
// requested effort there, the enable_thinking toggle alone leaves the
|
|
595
|
+
// model at xhigh no matter what the user selects.
|
|
596
|
+
// Local-only like `qwenPreserveThinking`: first-party Qwen APIs
|
|
597
|
+
// (Dashscope, Qwen Portal) drive effort through their own OpenAI-style
|
|
598
|
+
// dialect, and local Ollama keeps its native effort vocabulary.
|
|
599
|
+
qwenTemplateReasoningEffort:
|
|
600
|
+
(thinkingFormat === "qwen" || thinkingFormat === "qwen-chat-template") &&
|
|
601
|
+
isLocalOpenAICompatBackend &&
|
|
602
|
+
provider !== "ollama" &&
|
|
603
|
+
isQwen38PlusTemplateEffortModelId(spec.id),
|
|
604
|
+
requiresAssistantContentForToolCalls: isKimiModel || isDirectDeepseekReasoning,
|
|
605
|
+
cacheControlFormat: isOpenRouter && spec.id.startsWith("anthropic/") ? "anthropic" : undefined,
|
|
606
|
+
supportsPromptCacheBreakpoints,
|
|
607
|
+
promptCacheBreakpointTtl: supportsPromptCacheBreakpoints ? "30m" : undefined,
|
|
608
|
+
openRouterRouting: undefined,
|
|
609
|
+
vercelGatewayRouting: undefined,
|
|
610
|
+
isOpenRouterHost: isOpenRouter,
|
|
611
|
+
wireModelIdMode,
|
|
612
|
+
isVercelGatewayHost: isVercelGateway,
|
|
613
|
+
supportsStrictMode: detectStrictModeSupport(provider, baseUrl),
|
|
614
|
+
extraBody: undefined,
|
|
615
|
+
toolStrictMode: isCerebras ? "all_strict" : "mixed",
|
|
616
|
+
// Kimi-family ids trigger MFJS on any host, not just native base URLs:
|
|
617
|
+
// proxies (OpenRouter, custom gateways) forward `tools.function.parameters`
|
|
618
|
+
// to Moonshot verbatim, which 400s on non-MFJS constructs.
|
|
619
|
+
toolSchemaFlavor:
|
|
620
|
+
isMoonshotNative || isKimiModel ? "moonshot-mfjs" : isLocalOpenAICompatBackend ? "grammar" : undefined,
|
|
621
|
+
streamFirstEventTimeoutMs: isLocalServingBackend ? 0 : undefined,
|
|
622
|
+
streamIdleTimeoutMs,
|
|
623
|
+
stripDeepseekSpecialTokens:
|
|
624
|
+
isDeepseekModelIdOrName(spec.id) && (provider === "nvidia" || provider === "deepseek"),
|
|
625
|
+
streamMarkupHealingPattern: detectStreamMarkupHealingPattern(provider, spec.id, baseUrl),
|
|
626
|
+
reasoningDeltasMayBeCumulative:
|
|
627
|
+
MINIMAX_PROVIDER_OR_ID_PATTERN.test(provider) || MINIMAX_PROVIDER_OR_ID_PATTERN.test(spec.id),
|
|
628
|
+
emptyLengthFinishIsContextError: provider === "ollama",
|
|
629
|
+
usesOpenAIToolCallIdLimit: provider === "openai",
|
|
630
|
+
promptCacheSessionHeader: isGrok ? "x-grok-conv-id" : undefined,
|
|
631
|
+
dropThinkingWhenReasoningEffort: provider === "fireworks",
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
applyCompatOverrides(compat, spec.compat);
|
|
635
|
+
const deepseekThinking = compat.extraBody?.thinking;
|
|
636
|
+
if (
|
|
637
|
+
isDirectDeepseekReasoning &&
|
|
638
|
+
typeof deepseekThinking === "object" &&
|
|
639
|
+
deepseekThinking !== null &&
|
|
640
|
+
"type" in deepseekThinking &&
|
|
641
|
+
deepseekThinking.type === "enabled"
|
|
642
|
+
) {
|
|
643
|
+
const extraBody = { ...compat.extraBody };
|
|
644
|
+
delete extraBody.thinking;
|
|
645
|
+
compat.extraBody = Object.keys(extraBody).length > 0 ? extraBody : undefined;
|
|
646
|
+
}
|
|
647
|
+
if (spec.compat?.reasoningDisableMode === undefined) {
|
|
648
|
+
compat.reasoningDisableMode = requiresEnabledThinking
|
|
649
|
+
? "omit"
|
|
650
|
+
: isDirectDeepseekReasoning
|
|
651
|
+
? "zai-thinking-disabled"
|
|
652
|
+
: isVenice
|
|
653
|
+
? "venice-disable-thinking"
|
|
654
|
+
: resolveReasoningDisableMode(compat.thinkingFormat);
|
|
655
|
+
}
|
|
656
|
+
if (spec.compat?.omitReasoningEffort === undefined && !compat.supportsReasoningEffort) {
|
|
657
|
+
compat.omitReasoningEffort = true;
|
|
658
|
+
}
|
|
659
|
+
mergeModelReasoningEffortMap(compat, spec.id, isMimoReasoningEffortModel);
|
|
660
|
+
|
|
661
|
+
const whenThinkingPolicy =
|
|
662
|
+
spec.compat?.whenThinking ??
|
|
663
|
+
(isDirectDeepseekReasoning
|
|
664
|
+
? { extraBody: { ...compat.extraBody, thinking: { type: "enabled" } } }
|
|
665
|
+
: isOpenCodeProvider && spec.reasoning
|
|
666
|
+
? OPENCODE_WHEN_THINKING
|
|
667
|
+
: undefined);
|
|
668
|
+
if (whenThinkingPolicy) {
|
|
669
|
+
const variant: ResolvedOpenAICompat = { ...compat };
|
|
670
|
+
applyCompatOverrides(variant, whenThinkingPolicy);
|
|
671
|
+
if (whenThinkingPolicy.reasoningDisableMode === undefined) {
|
|
672
|
+
variant.reasoningDisableMode = isVenice
|
|
673
|
+
? "venice-disable-thinking"
|
|
674
|
+
: resolveReasoningDisableMode(variant.thinkingFormat);
|
|
675
|
+
}
|
|
676
|
+
if (whenThinkingPolicy.omitReasoningEffort === undefined && !variant.supportsReasoningEffort) {
|
|
677
|
+
variant.omitReasoningEffort = true;
|
|
678
|
+
}
|
|
679
|
+
mergeModelReasoningEffortMap(variant, spec.id, isMimoReasoningEffortModel);
|
|
680
|
+
compat.whenThinking = variant;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return compat;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
interface OpenAIResponsesSpecLike {
|
|
687
|
+
id?: string;
|
|
688
|
+
provider: string;
|
|
689
|
+
name: string;
|
|
690
|
+
baseUrl: string;
|
|
691
|
+
reasoning?: boolean;
|
|
692
|
+
compat?: OpenAICompat;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Build the resolved Responses-API compat record. Most shared OpenAI-compatible
|
|
697
|
+
* capability defaults intentionally mirror chat-completions, while Responses-
|
|
698
|
+
* only behavior (developer role, prompt cache, pairing strictness, image detail)
|
|
699
|
+
* keeps endpoint-specific detection. Azure is detected by provider id as well
|
|
700
|
+
* as URL — bundled `azure` models carry no baseUrl (the deployment host is per-
|
|
701
|
+
* resource, resolved at runtime) — while OpenAI/Copilot developer-role and
|
|
702
|
+
* prompt-cache detection stay URL-keyed, as the historical call sites were.
|
|
703
|
+
*/
|
|
704
|
+
export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): ResolvedOpenAIResponsesCompat {
|
|
705
|
+
const baseUrl = spec.baseUrl ?? "";
|
|
706
|
+
const isAzure = modelMatchesHost({ provider: spec.provider, baseUrl }, "azureOpenAI");
|
|
707
|
+
const isOpenRouter = modelMatchesHost({ provider: spec.provider, baseUrl }, "openrouter");
|
|
708
|
+
const isOpenAIUrl = hostMatchesUrl(baseUrl, "openai");
|
|
709
|
+
const isVercelGateway = modelMatchesHost({ provider: spec.provider, baseUrl }, "vercelAIGateway");
|
|
710
|
+
const id = spec.id ?? "";
|
|
711
|
+
const supportsPromptCacheBreakpoints = supportsOfficialOpenAIPromptCacheBreakpoints(spec.provider, id, baseUrl);
|
|
712
|
+
const thinkingFormat: ResolvedOpenAISharedCompat["thinkingFormat"] = isOpenRouter ? "openrouter" : "openai";
|
|
713
|
+
const isKimiModel = id ? isKimiModelId(id) : false;
|
|
714
|
+
const isAnthropicModel = id ? isClaudeModelId(id) || isAnthropicNamespacedModelId(id) : false;
|
|
715
|
+
const isDeepseekFamily = id ? isDeepseekModelIdOrName(id) || isDeepseekModelIdOrName(spec.name) : false;
|
|
716
|
+
const reasoningCapable = Boolean(spec.reasoning);
|
|
717
|
+
// `replayReasoningContent` is Responses-only-false, so the proxy carve-out is
|
|
718
|
+
// irrelevant here; the stream-timeout floor still applies to ANY loopback /
|
|
719
|
+
// RFC1918 backend, including local proxies (litellm), so a slow local
|
|
720
|
+
// upstream is not aborted at the 100s default and retry-looped (#4786).
|
|
721
|
+
const isLocalServingBackend =
|
|
722
|
+
(!PROXY_OPENAI_COMPAT_PROVIDERS.has(spec.provider) && LOCAL_OPENAI_COMPAT_PROVIDERS.has(spec.provider)) ||
|
|
723
|
+
hasLocalLoopbackBaseUrl(baseUrl);
|
|
724
|
+
const isXaiHost = modelMatchesHost({ provider: spec.provider, baseUrl }, "xai");
|
|
725
|
+
|
|
726
|
+
const compat: ResolvedOpenAIResponsesCompat = {
|
|
727
|
+
supportsDeveloperRole: isAzure || isOpenAIUrl || hostMatchesUrl(baseUrl, "githubCopilot"),
|
|
728
|
+
supportsStrictMode: isAzure || detectStrictModeSupport(spec.provider, baseUrl),
|
|
729
|
+
// Paid `xai` and SuperGrok `xai-oauth` share api.x.ai `/v1/responses`.
|
|
730
|
+
// Only the Grok effort-capable allowlist accepts `reasoning.effort`;
|
|
731
|
+
// other reasoners (grok-build, grok-code-fast-1, …) 400 if it is sent.
|
|
732
|
+
supportsReasoningEffort: !isXaiHost || isGrokReasoningEffortCapable(id),
|
|
733
|
+
supportsLongPromptCacheRetention: isOpenAIUrl,
|
|
734
|
+
supportsPromptCacheBreakpoints,
|
|
735
|
+
promptCacheBreakpointTtl: supportsPromptCacheBreakpoints ? "30m" : undefined,
|
|
736
|
+
// Azure OpenAI and GitHub Copilot Responses paths require tool results
|
|
737
|
+
// to strictly match prior tool calls when building Responses inputs.
|
|
738
|
+
strictResponsesPairing: isAzure || spec.provider === "github-copilot",
|
|
739
|
+
// GitHub Copilot and first-party xAI `/v1/responses` reject
|
|
740
|
+
// `detail: "original"` (400 / 422). Every other host preserves
|
|
741
|
+
// native-resolution frames (snapcompact relies on `original`). Detect
|
|
742
|
+
// Copilot by provider id or base-URL host so a model pointed at the
|
|
743
|
+
// Copilot host under a different provider id still clamps.
|
|
744
|
+
supportsImageDetailOriginal:
|
|
745
|
+
!isXaiHost && !modelMatchesHost({ provider: spec.provider, baseUrl }, "githubCopilot"),
|
|
746
|
+
// api.x.ai rejects `reasoning.summary` (SuperGrok and paid key alike).
|
|
747
|
+
supportsReasoningSummary: !isXaiHost,
|
|
748
|
+
reasoningEffortMap: isXaiHost ? { ...xaiResponsesReasoningEffortMap(id) } : {},
|
|
749
|
+
supportsReasoningParams: true,
|
|
750
|
+
// OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit
|
|
751
|
+
// temperature/top_p/… with a 400 on every serving host (#5606).
|
|
752
|
+
supportsSamplingParams: !isOpenAISamplingRestrictedModelId(id),
|
|
753
|
+
// xAI `/v1/responses` rejects presence/frequency penalties for every
|
|
754
|
+
// model, not only reasoners (https://docs.x.ai/developers/rest-api-reference/inference/chat).
|
|
755
|
+
supportsPenaltyAndStopParams: !isXaiHost,
|
|
756
|
+
thinkingFormat,
|
|
757
|
+
reasoningDisableMode: resolveReasoningDisableMode(thinkingFormat),
|
|
758
|
+
omitReasoningEffort: false,
|
|
759
|
+
// Ask xAI `/v1/responses` for `reasoning.encrypted_content` and replay
|
|
760
|
+
// those items on later turns. OpenRouter Anthropic still filters
|
|
761
|
+
// reasoning wrappers independently.
|
|
762
|
+
includeEncryptedReasoning: true,
|
|
763
|
+
filterReasoningHistory: isOpenRouter && isAnthropicModel,
|
|
764
|
+
disableReasoningOnForcedToolChoice: isKimiModel,
|
|
765
|
+
disableReasoningOnToolChoice: isDeepseekFamily && reasoningCapable && !isOpenRouter,
|
|
766
|
+
supportsToolChoice: true,
|
|
767
|
+
supportsForcedToolChoice: spec.provider !== "opencode-go" && spec.provider !== "opencode-zen",
|
|
768
|
+
supportsNamedToolChoice: STRING_ONLY_NAMED_TOOL_CHOICE_PROVIDERS[spec.provider] !== true,
|
|
769
|
+
reasoningContentField: "reasoning_content",
|
|
770
|
+
requiresReasoningContentForToolCalls:
|
|
771
|
+
(isKimiModel || (isDeepseekFamily && reasoningCapable) || (isOpenRouter && reasoningCapable)) &&
|
|
772
|
+
reasoningCapable,
|
|
773
|
+
requiresReasoningContentForAllAssistantTurns: isDeepseekFamily && reasoningCapable && !isOpenRouter,
|
|
774
|
+
allowsSyntheticReasoningContentForToolCalls: !isDeepseekFamily || !reasoningCapable,
|
|
775
|
+
// The Responses API replays reasoning through encrypted `summary` items,
|
|
776
|
+
// not via a top-level `reasoning_content` field — this flag is
|
|
777
|
+
// chat-completions-only.
|
|
778
|
+
replayReasoningContent: false,
|
|
779
|
+
// Responses-only; the Qwen `preserve_thinking` template knob lives on
|
|
780
|
+
// the chat-completions wire shape, never on Responses.
|
|
781
|
+
qwenPreserveThinking: false,
|
|
782
|
+
qwenTemplateReasoningEffort: false,
|
|
783
|
+
requiresThinkingAsText: false,
|
|
784
|
+
requiresMistralToolIds: false,
|
|
785
|
+
requiresToolResultName: false,
|
|
786
|
+
requiresAssistantAfterToolResult: false,
|
|
787
|
+
requiresAssistantContentForToolCalls: isKimiModel,
|
|
788
|
+
openRouterRouting: undefined,
|
|
789
|
+
vercelGatewayRouting: undefined,
|
|
790
|
+
isOpenRouterHost: isOpenRouter,
|
|
791
|
+
isVercelGatewayHost: isVercelGateway,
|
|
792
|
+
wireModelIdMode: isOpenRouter ? "openrouter" : "raw",
|
|
793
|
+
// Mirrors buildOpenAICompat: Kimi behind a Responses-capable proxy still
|
|
794
|
+
// lands on Moonshot's MFJS validator.
|
|
795
|
+
toolSchemaFlavor: isKimiModel ? "moonshot-mfjs" : undefined,
|
|
796
|
+
alwaysSendMaxTokens: spec.id ? isKimiModelId(spec.id) : false,
|
|
797
|
+
supportsObfuscationOptOut: isOpenAIUrl || spec.provider === "openai",
|
|
798
|
+
stripDeepseekSpecialTokens:
|
|
799
|
+
Boolean(id) && isDeepseekModelIdOrName(id) && (spec.provider === "nvidia" || spec.provider === "deepseek"),
|
|
800
|
+
streamMarkupHealingPattern: id ? detectStreamMarkupHealingPattern(spec.provider, id, baseUrl) : undefined,
|
|
801
|
+
reasoningDeltasMayBeCumulative:
|
|
802
|
+
MINIMAX_PROVIDER_OR_ID_PATTERN.test(spec.provider) || (id ? MINIMAX_PROVIDER_OR_ID_PATTERN.test(id) : false),
|
|
803
|
+
emptyLengthFinishIsContextError: spec.provider === "ollama",
|
|
804
|
+
usesOpenAIToolCallIdLimit: spec.provider === "openai",
|
|
805
|
+
promptCacheSessionHeader: isXaiHost ? "x-grok-conv-id" : undefined,
|
|
806
|
+
streamFirstEventTimeoutMs: isLocalServingBackend ? 0 : spec.compat?.streamFirstEventTimeoutMs,
|
|
807
|
+
streamIdleTimeoutMs: isLocalServingBackend
|
|
808
|
+
? LOCAL_OPENAI_COMPAT_STREAM_IDLE_TIMEOUT_MS
|
|
809
|
+
: spec.compat?.streamIdleTimeoutMs,
|
|
810
|
+
};
|
|
811
|
+
applyCompatOverrides(compat, spec.compat);
|
|
812
|
+
if (isXaiHost) {
|
|
813
|
+
const canonical = xaiResponsesReasoningEffortMap(id);
|
|
814
|
+
compat.reasoningEffortMap = { ...compat.reasoningEffortMap, ...canonical };
|
|
815
|
+
// xhigh-capable Grok advertises unmapped `xhigh`; drop a stale clamp
|
|
816
|
+
// from previous snapshots so 4.6 / 16-agent mode is not rewritten to `high`.
|
|
817
|
+
for (const key of ["xhigh", "max"] as const) {
|
|
818
|
+
if (!(key in canonical)) {
|
|
819
|
+
delete compat.reasoningEffortMap[key];
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (spec.compat?.reasoningDisableMode === undefined) {
|
|
824
|
+
compat.reasoningDisableMode = resolveReasoningDisableMode(compat.thinkingFormat);
|
|
825
|
+
}
|
|
826
|
+
if (spec.compat?.omitReasoningEffort === undefined && !compat.supportsReasoningEffort) {
|
|
827
|
+
compat.omitReasoningEffort = true;
|
|
828
|
+
}
|
|
829
|
+
// xai-oauth cache/discovery rows written before a SKU joined the
|
|
830
|
+
// effort-capable allowlist still carry omitReasoningEffort: true. The
|
|
831
|
+
// allowlist is the live wire contract; do not let that stale flag hide
|
|
832
|
+
// the picker or strip reasoning.effort.
|
|
833
|
+
if (
|
|
834
|
+
spec.provider === "xai-oauth" &&
|
|
835
|
+
isGrokReasoningEffortCapable(id) &&
|
|
836
|
+
spec.compat?.supportsReasoningEffort !== false
|
|
837
|
+
) {
|
|
838
|
+
compat.supportsReasoningEffort = true;
|
|
839
|
+
compat.omitReasoningEffort = false;
|
|
840
|
+
}
|
|
841
|
+
return compat;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
type ResponsesOnlyCompat = Omit<ResolvedOpenAIResponsesCompat, keyof ResolvedOpenAISharedCompat>;
|
|
845
|
+
|
|
846
|
+
function pickResponsesOnly(compat: ResolvedOpenAIResponsesCompat): ResponsesOnlyCompat {
|
|
847
|
+
return {
|
|
848
|
+
supportsLongPromptCacheRetention: compat.supportsLongPromptCacheRetention,
|
|
849
|
+
strictResponsesPairing: compat.strictResponsesPairing,
|
|
850
|
+
supportsImageDetailOriginal: compat.supportsImageDetailOriginal,
|
|
851
|
+
supportsObfuscationOptOut: compat.supportsObfuscationOptOut,
|
|
852
|
+
supportsReasoningSummary: compat.supportsReasoningSummary,
|
|
853
|
+
isVercelGatewayHost: compat.isVercelGatewayHost,
|
|
854
|
+
} satisfies ResponsesOnlyCompat;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export function buildOpenRouterCompat(spec: ModelSpec<"openrouter">): ResolvedOpenRouterCompat {
|
|
858
|
+
const chat = buildOpenAICompat({
|
|
859
|
+
...spec,
|
|
860
|
+
api: "openai-completions",
|
|
861
|
+
} as ModelSpec<"openai-completions">);
|
|
862
|
+
const responses = buildOpenAIResponsesCompat(spec);
|
|
863
|
+
return { ...chat, ...pickResponsesOnly(responses) } as ResolvedOpenRouterCompat;
|
|
864
|
+
}
|