@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.
Files changed (117) hide show
  1. package/CHANGELOG.md +1204 -0
  2. package/LICENSE +22 -0
  3. package/README.md +43 -0
  4. package/THIRD-PARTY-NOTICES.txt +22909 -0
  5. package/dist/types/build.d.ts +19 -0
  6. package/dist/types/compat/anthropic.d.ts +18 -0
  7. package/dist/types/compat/apply.d.ts +7 -0
  8. package/dist/types/compat/bedrock.d.ts +3 -0
  9. package/dist/types/compat/devin.d.ts +9 -0
  10. package/dist/types/compat/openai.d.ts +28 -0
  11. package/dist/types/discovery/antigravity.d.ts +70 -0
  12. package/dist/types/discovery/codex.d.ts +36 -0
  13. package/dist/types/discovery/cursor-proto.d.ts +5368 -0
  14. package/dist/types/discovery/cursor.d.ts +23 -0
  15. package/dist/types/discovery/devin-proto.d.ts +1549 -0
  16. package/dist/types/discovery/devin.d.ts +24 -0
  17. package/dist/types/discovery/gemini-cli.d.ts +33 -0
  18. package/dist/types/discovery/gemini.d.ts +25 -0
  19. package/dist/types/discovery/gitlab-duo-workflow.d.ts +30 -0
  20. package/dist/types/discovery/index.d.ts +7 -0
  21. package/dist/types/discovery/openai-compatible.d.ts +89 -0
  22. package/dist/types/discovery/protobuf.d.ts +102 -0
  23. package/dist/types/effort.d.ts +10 -0
  24. package/dist/types/fireworks-model-id.d.ts +23 -0
  25. package/dist/types/hosts.d.ts +169 -0
  26. package/dist/types/identity/bundled.d.ts +3 -0
  27. package/dist/types/identity/classify.d.ts +69 -0
  28. package/dist/types/identity/dialect.d.ts +3 -0
  29. package/dist/types/identity/family.d.ts +239 -0
  30. package/dist/types/identity/id.d.ts +12 -0
  31. package/dist/types/identity/index.d.ts +8 -0
  32. package/dist/types/identity/markers.d.ts +4 -0
  33. package/dist/types/identity/priority.d.ts +1 -0
  34. package/dist/types/identity/reference.d.ts +29 -0
  35. package/dist/types/index.d.ts +19 -0
  36. package/dist/types/model-cache.d.ts +23 -0
  37. package/dist/types/model-manager.d.ts +77 -0
  38. package/dist/types/model-thinking.d.ts +70 -0
  39. package/dist/types/model-tokenizer.d.ts +8 -0
  40. package/dist/types/models.d.ts +14 -0
  41. package/dist/types/openai-pricing.d.ts +17 -0
  42. package/dist/types/provider-models/bundled-references.d.ts +13 -0
  43. package/dist/types/provider-models/cache-provider-id.d.ts +11 -0
  44. package/dist/types/provider-models/descriptor-types.d.ts +76 -0
  45. package/dist/types/provider-models/descriptors.d.ts +505 -0
  46. package/dist/types/provider-models/google.d.ts +29 -0
  47. package/dist/types/provider-models/index.d.ts +7 -0
  48. package/dist/types/provider-models/ollama.d.ts +22 -0
  49. package/dist/types/provider-models/openai-compat.d.ts +646 -0
  50. package/dist/types/provider-models/special.d.ts +53 -0
  51. package/dist/types/types.d.ts +876 -0
  52. package/dist/types/utils.d.ts +20 -0
  53. package/dist/types/variant-collapse.d.ts +137 -0
  54. package/dist/types/wire/alibaba-token-plan.d.ts +22 -0
  55. package/dist/types/wire/codex.d.ts +66 -0
  56. package/dist/types/wire/coreweave.d.ts +12 -0
  57. package/dist/types/wire/gemini-headers.d.ts +62 -0
  58. package/dist/types/wire/github-copilot.d.ts +47 -0
  59. package/dist/types/wire/image-fetchers.d.ts +76 -0
  60. package/package.json +102 -0
  61. package/src/build.ts +103 -0
  62. package/src/compat/anthropic.ts +179 -0
  63. package/src/compat/apply.ts +15 -0
  64. package/src/compat/bedrock.ts +153 -0
  65. package/src/compat/devin.ts +12 -0
  66. package/src/compat/openai.ts +864 -0
  67. package/src/discovery/antigravity.ts +257 -0
  68. package/src/discovery/codex.ts +456 -0
  69. package/src/discovery/cursor-proto.ts +8329 -0
  70. package/src/discovery/cursor.ts +409 -0
  71. package/src/discovery/devin-proto.ts +2037 -0
  72. package/src/discovery/devin.ts +152 -0
  73. package/src/discovery/gemini-cli.ts +198 -0
  74. package/src/discovery/gemini.ts +260 -0
  75. package/src/discovery/gitlab-duo-workflow.ts +859 -0
  76. package/src/discovery/index.ts +7 -0
  77. package/src/discovery/openai-compatible.ts +266 -0
  78. package/src/discovery/protobuf.ts +1073 -0
  79. package/src/effort.ts +18 -0
  80. package/src/fireworks-model-id.ts +50 -0
  81. package/src/hosts.ts +178 -0
  82. package/src/identity/bundled.ts +28 -0
  83. package/src/identity/classify.ts +242 -0
  84. package/src/identity/dialect.ts +42 -0
  85. package/src/identity/family.ts +477 -0
  86. package/src/identity/id.ts +81 -0
  87. package/src/identity/index.ts +8 -0
  88. package/src/identity/markers.ts +49 -0
  89. package/src/identity/priority.ts +59 -0
  90. package/src/identity/reference.ts +167 -0
  91. package/src/index.ts +19 -0
  92. package/src/model-cache.ts +346 -0
  93. package/src/model-manager.ts +683 -0
  94. package/src/model-thinking.ts +925 -0
  95. package/src/model-tokenizer.ts +75 -0
  96. package/src/models.json +304657 -0
  97. package/src/models.json.d.ts +9 -0
  98. package/src/models.ts +108 -0
  99. package/src/openai-pricing.ts +29 -0
  100. package/src/provider-models/bundled-references.ts +87 -0
  101. package/src/provider-models/cache-provider-id.ts +97 -0
  102. package/src/provider-models/descriptor-types.ts +85 -0
  103. package/src/provider-models/descriptors.ts +585 -0
  104. package/src/provider-models/google.ts +117 -0
  105. package/src/provider-models/index.ts +7 -0
  106. package/src/provider-models/ollama.ts +204 -0
  107. package/src/provider-models/openai-compat.ts +6405 -0
  108. package/src/provider-models/special.ts +213 -0
  109. package/src/types.ts +1032 -0
  110. package/src/utils.ts +69 -0
  111. package/src/variant-collapse.ts +1555 -0
  112. package/src/wire/alibaba-token-plan.ts +54 -0
  113. package/src/wire/codex.ts +117 -0
  114. package/src/wire/coreweave.ts +51 -0
  115. package/src/wire/gemini-headers.ts +135 -0
  116. package/src/wire/github-copilot.ts +147 -0
  117. 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
+ }