@jecruz/merc-ai 0.1.4 → 0.2.0

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 (111) hide show
  1. package/README.md +34 -7
  2. package/dist/env-api-keys.d.ts.map +1 -1
  3. package/dist/env-api-keys.js +1 -1
  4. package/dist/env-api-keys.js.map +1 -1
  5. package/dist/image-models.generated.d.ts +345 -0
  6. package/dist/image-models.generated.d.ts.map +1 -1
  7. package/dist/image-models.generated.js +348 -3
  8. package/dist/image-models.generated.js.map +1 -1
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/models.d.ts.map +1 -1
  14. package/dist/models.generated.d.ts +15282 -3882
  15. package/dist/models.generated.d.ts.map +1 -1
  16. package/dist/models.generated.js +18368 -10964
  17. package/dist/models.generated.js.map +1 -1
  18. package/dist/models.js +4 -1
  19. package/dist/models.js.map +1 -1
  20. package/dist/providers/amazon-bedrock.d.ts.map +1 -1
  21. package/dist/providers/amazon-bedrock.js +188 -19
  22. package/dist/providers/amazon-bedrock.js.map +1 -1
  23. package/dist/providers/anthropic.d.ts.map +1 -1
  24. package/dist/providers/anthropic.js +101 -34
  25. package/dist/providers/anthropic.js.map +1 -1
  26. package/dist/providers/azure-openai-responses.d.ts +2 -0
  27. package/dist/providers/azure-openai-responses.d.ts.map +1 -1
  28. package/dist/providers/azure-openai-responses.js +8 -3
  29. package/dist/providers/azure-openai-responses.js.map +1 -1
  30. package/dist/providers/google-shared.d.ts +15 -4
  31. package/dist/providers/google-shared.d.ts.map +1 -1
  32. package/dist/providers/google-shared.js +80 -15
  33. package/dist/providers/google-shared.js.map +1 -1
  34. package/dist/providers/google-vertex.d.ts.map +1 -1
  35. package/dist/providers/google-vertex.js +23 -60
  36. package/dist/providers/google-vertex.js.map +1 -1
  37. package/dist/providers/google.d.ts.map +1 -1
  38. package/dist/providers/google.js +19 -68
  39. package/dist/providers/google.js.map +1 -1
  40. package/dist/providers/mistral.d.ts +1 -1
  41. package/dist/providers/mistral.d.ts.map +1 -1
  42. package/dist/providers/mistral.js +44 -60
  43. package/dist/providers/mistral.js.map +1 -1
  44. package/dist/providers/openai-codex-responses.d.ts.map +1 -1
  45. package/dist/providers/openai-codex-responses.js +250 -124
  46. package/dist/providers/openai-codex-responses.js.map +1 -1
  47. package/dist/providers/openai-completions.d.ts.map +1 -1
  48. package/dist/providers/openai-completions.js +94 -46
  49. package/dist/providers/openai-completions.js.map +1 -1
  50. package/dist/providers/openai-prompt-cache.d.ts +3 -0
  51. package/dist/providers/openai-prompt-cache.d.ts.map +1 -0
  52. package/dist/providers/openai-prompt-cache.js +10 -0
  53. package/dist/providers/openai-prompt-cache.js.map +1 -0
  54. package/dist/providers/openai-responses-shared.d.ts.map +1 -1
  55. package/dist/providers/openai-responses-shared.js +154 -211
  56. package/dist/providers/openai-responses-shared.js.map +1 -1
  57. package/dist/providers/openai-responses.d.ts.map +1 -1
  58. package/dist/providers/openai-responses.js +15 -9
  59. package/dist/providers/openai-responses.js.map +1 -1
  60. package/dist/providers/openrouter-images.d.ts.map +1 -1
  61. package/dist/providers/openrouter-images.js +5 -2
  62. package/dist/providers/openrouter-images.js.map +1 -1
  63. package/dist/providers/simple-options.d.ts +15 -3
  64. package/dist/providers/simple-options.d.ts.map +1 -1
  65. package/dist/providers/simple-options.js +36 -4
  66. package/dist/providers/simple-options.js.map +1 -1
  67. package/dist/types.d.ts +35 -2
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js.map +1 -1
  70. package/dist/utils/error-body.d.ts +5 -0
  71. package/dist/utils/error-body.d.ts.map +1 -1
  72. package/dist/utils/error-body.js +53 -5
  73. package/dist/utils/error-body.js.map +1 -1
  74. package/dist/utils/estimate.d.ts +22 -0
  75. package/dist/utils/estimate.d.ts.map +1 -0
  76. package/dist/utils/estimate.js +104 -0
  77. package/dist/utils/estimate.js.map +1 -0
  78. package/dist/utils/event-stream.d.ts.map +1 -1
  79. package/dist/utils/event-stream.js +25 -7
  80. package/dist/utils/event-stream.js.map +1 -1
  81. package/dist/utils/json-parse.d.ts +3 -0
  82. package/dist/utils/json-parse.d.ts.map +1 -1
  83. package/dist/utils/json-parse.js +15 -0
  84. package/dist/utils/json-parse.js.map +1 -1
  85. package/dist/utils/oauth/anthropic.d.ts.map +1 -1
  86. package/dist/utils/oauth/anthropic.js +16 -10
  87. package/dist/utils/oauth/anthropic.js.map +1 -1
  88. package/dist/utils/oauth/github-copilot.d.ts.map +1 -1
  89. package/dist/utils/oauth/github-copilot.js +57 -21
  90. package/dist/utils/oauth/github-copilot.js.map +1 -1
  91. package/dist/utils/oauth/openai-codex.d.ts.map +1 -1
  92. package/dist/utils/oauth/openai-codex.js +73 -16
  93. package/dist/utils/oauth/openai-codex.js.map +1 -1
  94. package/dist/utils/oauth/types.d.ts +2 -0
  95. package/dist/utils/oauth/types.d.ts.map +1 -1
  96. package/dist/utils/oauth/types.js.map +1 -1
  97. package/dist/utils/overflow.d.ts +10 -1
  98. package/dist/utils/overflow.d.ts.map +1 -1
  99. package/dist/utils/overflow.js +47 -7
  100. package/dist/utils/overflow.js.map +1 -1
  101. package/dist/utils/provider-env.d.ts.map +1 -1
  102. package/dist/utils/provider-env.js +45 -10
  103. package/dist/utils/provider-env.js.map +1 -1
  104. package/dist/utils/tool-call-id.d.ts +11 -0
  105. package/dist/utils/tool-call-id.d.ts.map +1 -0
  106. package/dist/utils/tool-call-id.js +96 -0
  107. package/dist/utils/tool-call-id.js.map +1 -0
  108. package/dist/utils/validation.d.ts.map +1 -1
  109. package/dist/utils/validation.js +9 -0
  110. package/dist/utils/validation.js.map +1 -1
  111. package/package.json +6 -3
@@ -4,11 +4,13 @@ import { calculateCost, clampThinkingLevel } from "../models.js";
4
4
  import { formatProviderError, normalizeProviderError } from "../utils/error-body.js";
5
5
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
6
6
  import { headersToRecord } from "../utils/headers.js";
7
- import { parseStreamingJson } from "../utils/json-parse.js";
7
+ import { finalizeToolCallArguments, parseStreamingJson } from "../utils/json-parse.js";
8
8
  import { getProviderEnvValue } from "../utils/provider-env.js";
9
9
  import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
10
+ import { createProviderToolCallIdNormalizer } from "../utils/tool-call-id.js";
10
11
  import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js";
11
12
  import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
13
+ import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
12
14
  import { buildBaseOptions } from "./simple-options.js";
13
15
  import { transformMessages } from "./transform-messages.js";
14
16
  /**
@@ -42,9 +44,8 @@ function isImageContentBlock(block) {
42
44
  return block.type === "image";
43
45
  }
44
46
  function isMiniMaxModel(model) {
45
- // LLM Dynamix is currently allowlisted to MiniMax-M3 on the local runtime;
46
- // retain the provider match when discovery normalizes the model ID.
47
- return model.provider === "llmdynamix" || model.id.toLowerCase().includes("minimax-m3");
47
+ // Merc-native: shared gateways carry multiple families; infer MiniMax only from the id.
48
+ return model.id.toLowerCase().includes("minimax-m3");
48
49
  }
49
50
  function createThinkingTagFilter(enabled) {
50
51
  let buffer = "";
@@ -102,6 +103,17 @@ function createThinkingTagFilter(enabled) {
102
103
  flush: () => (enabled ? consume() : buffer),
103
104
  };
104
105
  }
106
+ function isEncryptedReasoningDetail(detail) {
107
+ if (typeof detail !== "object" || detail === null) {
108
+ return false;
109
+ }
110
+ const candidate = detail;
111
+ return (candidate.type === "reasoning.encrypted" &&
112
+ typeof candidate.id === "string" &&
113
+ candidate.id.length > 0 &&
114
+ typeof candidate.data === "string" &&
115
+ candidate.data.length > 0);
116
+ }
105
117
  function isOpenRouterImageGenerationModel(model) {
106
118
  return model.provider === "openrouter" && model.compat?.openRouterImageGeneration === true;
107
119
  }
@@ -148,7 +160,7 @@ export const streamOpenAICompletions = (model, context, options) => {
148
160
  const requestOptions = {
149
161
  ...(options?.signal ? { signal: options.signal } : {}),
150
162
  ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
151
- ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
163
+ maxRetries: options?.maxRetries ?? 0,
152
164
  };
153
165
  if (isOpenRouterImageGenerationModel(model)) {
154
166
  const { stream_options: _streamOptions, ...nonStreamingBaseParams } = params;
@@ -219,10 +231,12 @@ export const streamOpenAICompletions = (model, context, options) => {
219
231
  let textBlock = null;
220
232
  let thinkingBlock = null;
221
233
  let hasFinishReason = false;
234
+ let hasIncompleteToolCallArguments = false;
222
235
  const toolCallBlocksByIndex = new Map();
223
236
  const toolCallBlocksById = new Map();
237
+ const pendingReasoningDetailsByToolCallId = new Map();
224
238
  const blocks = output.content;
225
- const thinkingTagFilter = createThinkingTagFilter(isMiniMaxModel(model) || compat.thinkingFormat === "minimax");
239
+ const thinkingTagFilter = createThinkingTagFilter(compat.thinkingFormat === "minimax");
226
240
  const getContentIndex = (block) => blocks.indexOf(block);
227
241
  const finishBlock = (block) => {
228
242
  const contentIndex = getContentIndex(block);
@@ -246,7 +260,14 @@ export const streamOpenAICompletions = (model, context, options) => {
246
260
  });
247
261
  }
248
262
  else if (block.type === "toolCall") {
249
- block.arguments = parseStreamingJson(block.partialArgs);
263
+ // Without finish_reason, only strictly valid raw arguments prove the call was not cut off.
264
+ try {
265
+ JSON.parse(block.partialArgs ?? "");
266
+ }
267
+ catch {
268
+ hasIncompleteToolCallArguments = true;
269
+ }
270
+ finalizeToolCallArguments(block, block.partialArgs);
250
271
  // Finalize in-place and strip the scratch buffers so replay only
251
272
  // carries parsed arguments.
252
273
  delete block.partialArgs;
@@ -291,6 +312,16 @@ export const streamOpenAICompletions = (model, context, options) => {
291
312
  }
292
313
  return thinkingBlock;
293
314
  };
315
+ const applyPendingReasoningDetail = (block) => {
316
+ if (!block.id) {
317
+ return;
318
+ }
319
+ const pendingReasoningDetail = pendingReasoningDetailsByToolCallId.get(block.id);
320
+ if (pendingReasoningDetail) {
321
+ block.thoughtSignature = pendingReasoningDetail;
322
+ pendingReasoningDetailsByToolCallId.delete(block.id);
323
+ }
324
+ };
294
325
  const ensureToolCallBlock = (toolCall) => {
295
326
  const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined;
296
327
  let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined;
@@ -326,6 +357,7 @@ export const streamOpenAICompletions = (model, context, options) => {
326
357
  if (toolCall.id) {
327
358
  toolCallBlocksById.set(toolCall.id, block);
328
359
  }
360
+ applyPendingReasoningDetail(block);
329
361
  return block;
330
362
  };
331
363
  for await (const chunk of openaiStream) {
@@ -377,8 +409,7 @@ export const streamOpenAICompletions = (model, context, options) => {
377
409
  }
378
410
  }
379
411
  if (foundReasoningField &&
380
- !((isMiniMaxModel(model) || compat.thinkingFormat === "minimax") &&
381
- (!options?.reasoningEffort || model.reasoning === false))) {
412
+ !(compat.thinkingFormat === "minimax" && (!options?.reasoningEffort || model.reasoning === false))) {
382
413
  const delta = deltaFields[foundReasoningField];
383
414
  if (typeof delta === "string" && delta.length > 0) {
384
415
  const thinkingSignature = model.provider === "opencode-go" && foundReasoningField === "reasoning"
@@ -419,12 +450,16 @@ export const streamOpenAICompletions = (model, context, options) => {
419
450
  }
420
451
  }
421
452
  const reasoningDetails = choice.delta.reasoning_details;
422
- if (reasoningDetails && Array.isArray(reasoningDetails)) {
453
+ if (Array.isArray(reasoningDetails)) {
423
454
  for (const detail of reasoningDetails) {
424
- if (detail.type === "reasoning.encrypted" && detail.id && detail.data) {
425
- const matchingToolCall = output.content.find((b) => b.type === "toolCall" && b.id === detail.id);
455
+ if (isEncryptedReasoningDetail(detail)) {
456
+ const serializedDetail = JSON.stringify(detail);
457
+ const matchingToolCall = toolCallBlocksById.get(detail.id);
426
458
  if (matchingToolCall) {
427
- matchingToolCall.thoughtSignature = JSON.stringify(detail);
459
+ matchingToolCall.thoughtSignature = serializedDetail;
460
+ }
461
+ else {
462
+ pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail);
428
463
  }
429
464
  }
430
465
  }
@@ -441,10 +476,16 @@ export const streamOpenAICompletions = (model, context, options) => {
441
476
  if (output.stopReason === "aborted") {
442
477
  throw new Error("Request was aborted");
443
478
  }
479
+ if (!hasFinishReason && !compat.supportsFinishReason) {
480
+ if (hasIncompleteToolCallArguments) {
481
+ throw new Error("Stream ended without finish_reason and with incomplete tool call arguments");
482
+ }
483
+ output.stopReason = output.content.some((block) => block.type === "toolCall") ? "toolUse" : "stop";
484
+ }
444
485
  if (output.stopReason === "error") {
445
486
  throw new Error(output.errorMessage || "Provider returned an error stop reason");
446
487
  }
447
- if (!hasFinishReason) {
488
+ if (compat.supportsFinishReason && !hasFinishReason) {
448
489
  throw new Error("Stream ended without finish_reason");
449
490
  }
450
491
  stream.push({ type: "done", reason: output.stopReason, message: output });
@@ -458,11 +499,17 @@ export const streamOpenAICompletions = (model, context, options) => {
458
499
  delete block.streamIndex;
459
500
  }
460
501
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
461
- output.errorMessage = formatProviderError(normalizeProviderError(error));
502
+ const normalized = normalizeProviderError(error);
503
+ output.errorMetadata = normalized.errorMetadata;
504
+ output.errorMessage = formatProviderError(normalized);
462
505
  // Some providers via OpenRouter give additional information in this field.
506
+ // normalizeProviderError already stringifies the parsed body (error.error)
507
+ // into errorMessage, so only append the raw metadata when it is not already
508
+ // present to avoid double-printing it.
463
509
  const rawMetadata = error?.error?.metadata?.raw;
464
- if (rawMetadata)
510
+ if (rawMetadata && !output.errorMessage.includes(String(rawMetadata))) {
465
511
  output.errorMessage += `\n${rawMetadata}`;
512
+ }
466
513
  stream.push({ type: "error", reason: output.stopReason, error: output });
467
514
  stream.end();
468
515
  }
@@ -474,7 +521,7 @@ export const streamSimpleOpenAICompletions = (model, context, options) => {
474
521
  if (!apiKey && model.authHeader !== false) {
475
522
  throw new Error(`No API key for provider: ${model.provider}`);
476
523
  }
477
- const base = buildBaseOptions(model, options, apiKey ?? "");
524
+ const base = buildBaseOptions(model, context, options, apiKey ?? "");
478
525
  const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
479
526
  const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
480
527
  const toolChoice = options?.toolChoice;
@@ -544,7 +591,7 @@ function buildParams(model, context, options, compat = getCompat(model), cacheRe
544
591
  stream: true,
545
592
  prompt_cache_key: (model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") ||
546
593
  (cacheRetention === "long" && compat.supportsLongCacheRetention)
547
- ? options?.sessionId
594
+ ? clampOpenAIPromptCacheKey(options?.sessionId)
548
595
  : undefined,
549
596
  prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined,
550
597
  };
@@ -581,12 +628,12 @@ function buildParams(model, context, options, compat = getCompat(model), cacheRe
581
628
  if (cacheControl) {
582
629
  applyAnthropicCacheControl(messages, params.tools, cacheControl);
583
630
  }
584
- if (options?.toolChoice) {
631
+ if (options?.toolChoice && params.tools?.length) {
585
632
  params.tool_choice = options.toolChoice;
586
633
  }
587
634
  if (compat.thinkingFormat === "zai" && model.reasoning) {
588
635
  const zaiParams = params;
589
- zaiParams.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
636
+ zaiParams.thinking = options?.reasoningEffort ? { type: "enabled", clear_thinking: false } : { type: "disabled" };
590
637
  if (options?.reasoningEffort && compat.supportsReasoningEffort) {
591
638
  const mappedEffort = model.thinkingLevelMap?.[options.reasoningEffort];
592
639
  const effort = mappedEffort === undefined ? options.reasoningEffort : mappedEffort;
@@ -595,7 +642,7 @@ function buildParams(model, context, options, compat = getCompat(model), cacheRe
595
642
  }
596
643
  }
597
644
  }
598
- else if (compat.thinkingFormat === "minimax" || isMiniMaxModel(model)) {
645
+ else if (compat.thinkingFormat === "minimax") {
599
646
  const minimaxParams = params;
600
647
  minimaxParams.thinking = { type: options?.reasoningEffort ? "adaptive" : "disabled" };
601
648
  minimaxParams.reasoning_split = true;
@@ -690,7 +737,7 @@ function addCacheControlToSystemPrompt(messages, cacheControl) {
690
737
  function addCacheControlToLastConversationMessage(messages, cacheControl) {
691
738
  for (let i = messages.length - 1; i >= 0; i--) {
692
739
  const message = messages[i];
693
- if (message.role === "user" || message.role === "assistant") {
740
+ if (message.role === "user" || message.role === "assistant" || message.role === "tool") {
694
741
  if (addCacheControlToMessage(message, cacheControl)) {
695
742
  return;
696
743
  }
@@ -708,7 +755,7 @@ function addCacheControlToInstructionMessage(message, cacheControl) {
708
755
  return addCacheControlToTextContent(message, cacheControl);
709
756
  }
710
757
  function addCacheControlToMessage(message, cacheControl) {
711
- if (message.role === "user" || message.role === "assistant") {
758
+ if (message.role === "user" || message.role === "assistant" || message.role === "tool") {
712
759
  return addCacheControlToTextContent(message, cacheControl);
713
760
  }
714
761
  return false;
@@ -743,20 +790,7 @@ function addCacheControlToTextContent(message, cacheControl) {
743
790
  }
744
791
  export function convertMessages(model, context, compat) {
745
792
  const params = [];
746
- const normalizeToolCallId = (id) => {
747
- // Handle pipe-separated IDs from OpenAI Responses API
748
- // Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =)
749
- // These come from providers like github-copilot, openai-codex, opencode
750
- // Extract just the call_id part and normalize it
751
- if (id.includes("|")) {
752
- const [callId] = id.split("|");
753
- // Sanitize to allowed chars and truncate to 40 chars (OpenAI limit)
754
- return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
755
- }
756
- if (model.provider === "openai")
757
- return id.length > 40 ? id.slice(0, 40) : id;
758
- return id;
759
- };
793
+ const normalizeToolCallId = createProviderToolCallIdNormalizer(model);
760
794
  const transformedMessages = transformMessages(context.messages, model, (id) => normalizeToolCallId(id));
761
795
  if (context.systemPrompt) {
762
796
  const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
@@ -782,7 +816,9 @@ export function convertMessages(model, context, compat) {
782
816
  });
783
817
  }
784
818
  else {
785
- const content = msg.content.map((item) => {
819
+ const content = msg.content
820
+ .filter((item) => item.type !== "text" || item.text.length > 0)
821
+ .map((item) => {
786
822
  if (item.type === "text") {
787
823
  return {
788
824
  type: "text",
@@ -981,10 +1017,13 @@ function convertTools(tools, compat) {
981
1017
  }
982
1018
  function parseChunkUsage(rawUsage, model) {
983
1019
  const promptTokens = rawUsage.prompt_tokens || 0;
984
- const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0;
1020
+ const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? rawUsage.cached_tokens ?? 0;
985
1021
  const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0;
986
1022
  // Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read
987
- // tokens (hits). OpenAI does not document or emit cache_write_tokens, but
1023
+ // tokens (hits). Providers disagree on placement: OpenAI/OpenRouter use
1024
+ // prompt_tokens_details.cached_tokens, DeepSeek uses prompt_cache_hit_tokens,
1025
+ // and Kimi documents top-level usage.cached_tokens on the final usage chunk.
1026
+ // OpenAI does not document or emit cache_write_tokens, but
988
1027
  // OpenRouter-compatible providers can include it as a separate write count.
989
1028
  // OpenRouter's own provider/tests affirm the separate mapping:
990
1029
  // https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409
@@ -1042,12 +1081,13 @@ function detectCompat(model) {
1042
1081
  const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com");
1043
1082
  const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com");
1044
1083
  const isMiniMax = isMiniMaxModel(model);
1084
+ const isDeepSeek = provider === "deepseek" || baseUrl.toLowerCase().includes("deepseek.com");
1045
1085
  const isNonStandard = provider === "cerebras" ||
1046
1086
  baseUrl.includes("cerebras.ai") ||
1047
1087
  provider === "xai" ||
1048
1088
  baseUrl.includes("api.x.ai") ||
1049
1089
  baseUrl.includes("chutes.ai") ||
1050
- baseUrl.includes("deepseek.com") ||
1090
+ isDeepSeek ||
1051
1091
  isZai ||
1052
1092
  isMoonshot ||
1053
1093
  provider === "opencode" ||
@@ -1055,16 +1095,23 @@ function detectCompat(model) {
1055
1095
  isCloudflareWorkersAI ||
1056
1096
  isCloudflareAiGateway ||
1057
1097
  isAntLing;
1058
- const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isAntLing;
1098
+ // Z.AI ignores max_completion_tokens, so its output cap only applies through max_tokens.
1099
+ const isZaiMaxTokensEndpoint = isZai || provider === "zai-coding-cn" || baseUrl.includes("open.bigmodel.cn");
1100
+ const useMaxTokens = baseUrl.includes("chutes.ai") ||
1101
+ isDeepSeek ||
1102
+ isMoonshot ||
1103
+ isCloudflareAiGateway ||
1104
+ isAntLing ||
1105
+ isZaiMaxTokensEndpoint;
1059
1106
  const isGrok = provider === "xai" || baseUrl.includes("api.x.ai");
1060
- const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com");
1061
- const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined;
1107
+ const cacheControlFormat = provider === "openrouter" && /^~?anthropic\//.test(model.id) ? "anthropic" : undefined;
1062
1108
  const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
1063
1109
  return {
1064
1110
  supportsStore: !isNonStandard,
1065
1111
  supportsDeveloperRole: !isNonStandard,
1066
1112
  supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isCloudflareAiGateway && !isAntLing,
1067
1113
  supportsUsageInStreaming: true,
1114
+ supportsFinishReason: true,
1068
1115
  maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
1069
1116
  requiresToolResultName: false,
1070
1117
  requiresAssistantAfterToolResult: false,
@@ -1087,7 +1134,7 @@ function detectCompat(model) {
1087
1134
  zaiToolStream: false,
1088
1135
  supportsStrictMode: !isMoonshot && !isCloudflareAiGateway,
1089
1136
  cacheControlFormat,
1090
- sendSessionAffinityHeaders: false,
1137
+ sendSessionAffinityHeaders: isOpenRouter,
1091
1138
  sessionAffinityFormat: isOpenRouter ? "openrouter" : "openai",
1092
1139
  supportsLongCacheRetention: !(isCloudflareWorkersAI || isCloudflareAiGateway || isAntLing),
1093
1140
  };
@@ -1105,13 +1152,14 @@ function getCompat(model) {
1105
1152
  supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole,
1106
1153
  supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort,
1107
1154
  supportsUsageInStreaming: model.compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming,
1155
+ supportsFinishReason: model.compat.supportsFinishReason ?? detected.supportsFinishReason,
1108
1156
  maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField,
1109
1157
  requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName,
1110
1158
  requiresAssistantAfterToolResult: model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult,
1111
1159
  requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText,
1112
1160
  requiresReasoningContentOnAssistantMessages: model.compat.requiresReasoningContentOnAssistantMessages ??
1113
1161
  detected.requiresReasoningContentOnAssistantMessages,
1114
- thinkingFormat: isMiniMaxModel(model) ? "minimax" : (model.compat.thinkingFormat ?? detected.thinkingFormat),
1162
+ thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat,
1115
1163
  openRouterRouting: model.compat.openRouterRouting ?? {},
1116
1164
  openRouterImageGeneration: model.compat.openRouterImageGeneration ?? detected.openRouterImageGeneration,
1117
1165
  vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting,