@oh-my-pi/pi-ai 17.0.4 → 17.0.6

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.
@@ -100,6 +100,8 @@ export type AnthropicHeaderOptions = {
100
100
  isCloudflareAiGateway?: boolean;
101
101
  claudeCodeSessionId?: string;
102
102
  claudeCodeBetas?: readonly string[];
103
+ /** Allow explicit fingerprint headers to replace OAuth defaults on non-official endpoints. */
104
+ allowAnthropicHeaderOverrides?: boolean;
103
105
  };
104
106
 
105
107
  export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined {
@@ -144,7 +146,8 @@ const claudeCodeAgentBetaDefaults = [
144
146
  midConversationSystemBeta,
145
147
  "advanced-tool-use-2025-11-20",
146
148
  ] as const;
147
- const claudeCodeAgentPostEffortBetas = ["extended-cache-ttl-2025-04-11"] as const;
149
+ const extendedCacheTtlBeta = "extended-cache-ttl-2025-04-11";
150
+ const claudeCodeAgentPostEffortBetas = [extendedCacheTtlBeta] as const;
148
151
  const fineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14";
149
152
  const interleavedThinkingBeta = "interleaved-thinking-2025-05-14";
150
153
  // Asks the API to redact thinking blocks from responses. Only sent when the
@@ -225,22 +228,36 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
225
228
  const acceptHeader = oauthToken ? "application/json" : stream ? "text/event-stream" : "application/json";
226
229
  const isCloudflare = options.isCloudflareAiGateway ?? false;
227
230
  const honorAuthorization = !oauthToken && !isCloudflare;
231
+ const allowAnthropicHeaderOverrides =
232
+ oauthToken &&
233
+ options.allowAnthropicHeaderOverrides === true &&
234
+ !isCloudflare &&
235
+ !isOfficialAnthropicApiUrl(options.baseUrl);
228
236
  const honorApiKey = !isCloudflare;
229
237
  const modelHeaders: Record<string, string> = {};
238
+ const anthropicHeaderOverrides: Record<string, string> = {};
230
239
  const filteredEnforcedKeys: string[] = [];
231
- for (const [key, value] of Object.entries(options.modelHeaders ?? {})) {
232
- const lowerKey = key.toLowerCase();
233
- if (enforcedHeaderKeys.has(lowerKey)) {
234
- // user-agent is always re-applied explicitly. authorization / x-api-key
235
- // are silently re-applied in honoring branches and dropped + logged
236
- // where the branch enforces its own credential.
237
- if (lowerKey === "user-agent") continue;
238
- if (lowerKey === "authorization" && honorAuthorization) continue;
239
- if (lowerKey === "x-api-key" && honorApiKey) continue;
240
- filteredEnforcedKeys.push(key);
241
- continue;
240
+ const headerSource = options.modelHeaders;
241
+ if (headerSource) {
242
+ for (const key in headerSource) {
243
+ const value = headerSource[key];
244
+ const lowerKey = key.toLowerCase();
245
+ if (enforcedHeaderKeys.has(lowerKey)) {
246
+ if (allowAnthropicHeaderOverrides && overridableAnthropicHeaderKeys.has(lowerKey)) {
247
+ anthropicHeaderOverrides[key] = value;
248
+ continue;
249
+ }
250
+ // user-agent is always re-applied explicitly. authorization / x-api-key
251
+ // are silently re-applied in honoring branches and dropped + logged
252
+ // where the branch enforces its own credential.
253
+ if (lowerKey === "user-agent") continue;
254
+ if (lowerKey === "authorization" && honorAuthorization) continue;
255
+ if (lowerKey === "x-api-key" && honorApiKey) continue;
256
+ filteredEnforcedKeys.push(key);
257
+ continue;
258
+ }
259
+ modelHeaders[key] = value;
242
260
  }
243
- modelHeaders[key] = value;
244
261
  }
245
262
  if (filteredEnforcedKeys.length > 0) {
246
263
  // Caller/env-supplied values (options.headers, ANTHROPIC_CUSTOM_HEADERS)
@@ -266,7 +283,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
266
283
  const userAgent = isClaudeCodeClientUserAgent(incomingUserAgent)
267
284
  ? incomingUserAgent
268
285
  : `claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`;
269
- return {
286
+ const headers = {
270
287
  ...modelHeaders,
271
288
  ...claudeCodeHeaders,
272
289
  Accept: acceptHeader,
@@ -278,6 +295,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
278
295
  "User-Agent": userAgent,
279
296
  ...(incomingApiKey ? { "X-Api-Key": incomingApiKey } : {}),
280
297
  };
298
+ return allowAnthropicHeaderOverrides ? mergeHeaders(headers, anthropicHeaderOverrides) : headers;
281
299
  } else if (!isOfficialAnthropicApiUrl(options.baseUrl)) {
282
300
  return {
283
301
  ...modelHeaders,
@@ -424,7 +442,15 @@ function getCacheControl(
424
442
  cacheRetention: CacheRetention | undefined,
425
443
  isOAuthToken: boolean,
426
444
  ): { retention: CacheRetention; cacheControl?: AnthropicCacheControl } {
427
- const retention = cacheRetention ?? (isOAuthToken ? "long" : resolveCacheRetention(undefined));
445
+ // OAuth mirrors Claude Code and always defaults to 1h retention. API-key
446
+ // requests also default to 1h where the endpoint supports it (canonical
447
+ // Anthropic API, `compat.supportsLongCacheRetention`): agent sessions
448
+ // routinely idle past 5 minutes waiting on background jobs, and a 5m
449
+ // breakpoint cold-misses the entire prefix on resume. PI_CACHE_RETENTION
450
+ // still overrides the API-key default in either direction.
451
+ const retention = isOAuthToken
452
+ ? (cacheRetention ?? "long")
453
+ : resolveCacheRetention(cacheRetention, model.compat.supportsLongCacheRetention ? "long" : "short");
428
454
  if (retention === "none") {
429
455
  return { retention };
430
456
  }
@@ -512,6 +538,10 @@ const enforcedHeaderKeys = new Set(
512
538
  ].map(key => key.toLowerCase()),
513
539
  );
514
540
 
541
+ const overridableAnthropicHeaderKeys = new Set(
542
+ [...Object.keys(claudeCodeHeaders), "anthropic-beta", "User-Agent", "x-app"].map(key => key.toLowerCase()),
543
+ );
544
+
515
545
  const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
516
546
 
517
547
  function createClaudeBillingHeader(firstUserMessageText: string): string {
@@ -1853,6 +1883,17 @@ const streamAnthropicOnce = (
1853
1883
  ) {
1854
1884
  extraBetas.push(contextManagementBeta);
1855
1885
  }
1886
+ // `ttl: "1h"` requires the extended-cache-ttl beta on API-key
1887
+ // requests. OAuth requests never add it here: agent requests
1888
+ // already carry it in the Claude Code beta list, and utility
1889
+ // requests must not deviate from CC's header fingerprint.
1890
+ if (
1891
+ !(options?.isOAuth ?? isAnthropicOAuthToken(apiKey)) &&
1892
+ getCacheControl(model, options?.cacheRetention, false).cacheControl?.ttl === "1h" &&
1893
+ !extraBetas.includes(extendedCacheTtlBeta)
1894
+ ) {
1895
+ extraBetas.push(extendedCacheTtlBeta);
1896
+ }
1856
1897
  // Server-side fallback beta chain: opt-in via `options.fallbacks`.
1857
1898
  // Nested overrides (`speed`, `output_config.effort`,
1858
1899
  // `output_config.task_budget`) reuse the same top-level betas
@@ -2791,6 +2832,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
2791
2832
  dynamicHeaders,
2792
2833
  ),
2793
2834
  isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway",
2835
+ allowAnthropicHeaderOverrides: model.compat.allowAnthropicHeaderOverrides,
2794
2836
  claudeCodeSessionId,
2795
2837
  claudeCodeBetas: oauthToken
2796
2838
  ? buildClaudeCodeBetas(
@@ -2868,7 +2868,7 @@ function buildGrpcRequest(
2868
2868
  conversationId: state.conversationId,
2869
2869
  });
2870
2870
 
2871
- options?.onPayload?.(runRequest);
2871
+ options?.onPayload?.(runRequest, model);
2872
2872
 
2873
2873
  // Tools are sent later via requestContext (exec handshake)
2874
2874
 
@@ -43,9 +43,11 @@ import type {
43
43
  Tool,
44
44
  ToolCall,
45
45
  } from "../types";
46
+ import { isDemotedThinking } from "../utils/block-symbols";
46
47
  import { deterministicUuid } from "../utils/deterministic-id";
47
48
  import { AssistantMessageEventStream } from "../utils/event-stream";
48
49
  import { toolWireSchema } from "../utils/schema/wire";
50
+ import { transformMessages } from "./transform-messages";
49
51
 
50
52
  /** Base host for Codeium/Windsurf's Cascade chat API (Connect protocol over HTTP/1.1). */
51
53
  export const DEVIN_API_URL = "https://server.codeium.com";
@@ -78,6 +80,13 @@ const CONNECT_END_STREAM_FLAG = 0x02;
78
80
  * fails fast instead of consuming memory.
79
81
  */
80
82
  const MAX_CONNECT_FRAME_PAYLOAD = 16 * 1024 * 1024;
83
+ /**
84
+ * Recovery heuristic for opaque Devin `invalid_argument` trailers. This is not
85
+ * asserted to be the backend's hard limit: small requests can hit the same
86
+ * intermittent error, while compactable message history this large is likely
87
+ * to benefit from the existing context-overflow maintenance path.
88
+ */
89
+ const LARGE_HISTORY_RECOVERY_BYTES = 512 * 1024;
81
90
 
82
91
  export const streamDevin: StreamFunction<"devin-agent"> = (
83
92
  model: Model<"devin-agent">,
@@ -157,10 +166,14 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
157
166
  const auth = await fetchDevinAuthMetadata(apiKey, baseUrl, fetchImpl, options?.signal);
158
167
  const chatBaseUrl = auth.baseUrl ?? baseUrl;
159
168
  const request = buildDevinChatRequest(model, context, options, apiKey, auth.userJwt);
160
- logger.debug("devin: sending chat request", { model: model.id, tools: context.tools?.length ?? 0 });
161
-
162
169
  const reqBytes = toBinary(GetChatMessageRequestSchema, request);
163
170
  const gz = gzipSync(reqBytes);
171
+ logger.debug("devin: sending chat request", {
172
+ model: model.id,
173
+ tools: context.tools?.length ?? 0,
174
+ requestBytes: reqBytes.byteLength,
175
+ compressedBytes: gz.byteLength,
176
+ });
164
177
  const frame = Buffer.alloc(5 + gz.length);
165
178
  frame[0] = CONNECT_COMPRESSED_FLAG;
166
179
  frame.writeUInt32BE(gz.length, 1);
@@ -223,7 +236,53 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
223
236
  if (flag & CONNECT_END_STREAM_FLAG) {
224
237
  const trailerBytes = flag & CONNECT_COMPRESSED_FLAG ? gunzipSync(payload) : payload;
225
238
  const trailerError = readConnectTrailerError(trailerBytes.toString("utf8").trim());
226
- if (trailerError) throw new AIError.ValidationError(trailerError);
239
+ if (trailerError) {
240
+ const error = new AIError.ValidationError(trailerError.formatted);
241
+ if (
242
+ firstTokenTime === undefined &&
243
+ trailerError.code.toLowerCase() === "invalid_argument" &&
244
+ /\binternal error\b/i.test(trailerError.message)
245
+ ) {
246
+ // The full protobuf also contains the system prompt and tool
247
+ // schemas, which history maintenance cannot shrink. Re-encode
248
+ // only the repeated history field before choosing recovery.
249
+ let activeTailCount = 0;
250
+ const lastRole = context.messages.at(-1)?.role;
251
+ if (lastRole === "user" || lastRole === "developer") {
252
+ activeTailCount = 1;
253
+ // A trailing developer message can accompany the current user
254
+ // prompt. Earlier user-role records may instead be flushed
255
+ // execution history and must remain eligible for compaction.
256
+ if (lastRole === "developer") {
257
+ for (let i = context.messages.length - 2; i >= 0; i--) {
258
+ const role = context.messages[i].role;
259
+ if (role !== "user" && role !== "developer") break;
260
+ activeTailCount++;
261
+ }
262
+ }
263
+ }
264
+ const shrinkablePrompts =
265
+ activeTailCount > 0
266
+ ? request.chatMessagePrompts.slice(0, -activeTailCount)
267
+ : request.chatMessagePrompts;
268
+ const historyBytes = toBinary(
269
+ GetChatMessageRequestSchema,
270
+ create(GetChatMessageRequestSchema, {
271
+ chatMessagePrompts: shrinkablePrompts,
272
+ }),
273
+ ).byteLength;
274
+ if (historyBytes >= LARGE_HISTORY_RECOVERY_BYTES) {
275
+ AIError.attach(error, AIError.create(AIError.Flag.ContextOverflow));
276
+ logger.warn("devin: treating large-history invalid_argument as context overflow", {
277
+ model: model.id,
278
+ historyBytes,
279
+ requestBytes: reqBytes.byteLength,
280
+ compressedBytes: gz.byteLength,
281
+ });
282
+ }
283
+ }
284
+ throw error;
285
+ }
227
286
  continue;
228
287
  }
229
288
 
@@ -322,7 +381,8 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
322
381
  output.usage.output = Number(msg.usage.outputTokens);
323
382
  output.usage.cacheRead = Number(msg.usage.cacheReadTokens);
324
383
  output.usage.cacheWrite = Number(msg.usage.cacheWriteTokens);
325
- output.usage.totalTokens = output.usage.input + output.usage.output;
384
+ output.usage.totalTokens =
385
+ output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
326
386
  }
327
387
  }
328
388
 
@@ -442,6 +502,7 @@ function buildDevinChatRequest(
442
502
  options?.stopSequences && options.stopSequences.length > 0
443
503
  ? [...DEVIN_DEFAULT_STOP_PATTERNS, ...options.stopSequences]
444
504
  : DEVIN_DEFAULT_STOP_PATTERNS;
505
+ const messages = transformMessages(context.messages, model);
445
506
  return create(GetChatMessageRequestSchema, {
446
507
  metadata: create(MetadataSchema, {
447
508
  apiKey,
@@ -453,7 +514,7 @@ function buildDevinChatRequest(
453
514
  locale: "en",
454
515
  }),
455
516
  prompt: (context.systemPrompt ?? []).join("\n\n"),
456
- chatMessagePrompts: buildChatMessagePrompts(context.messages, cascadeId),
517
+ chatMessagePrompts: buildChatMessagePrompts(messages, cascadeId, model),
457
518
  chatModelUid: options?.chatModelUid ?? model.requestModelId ?? model.id,
458
519
  requestType: ChatMessageRequestType.CASCADE,
459
520
  plannerMode: ConversationalPlannerMode.DEFAULT,
@@ -485,7 +546,11 @@ function buildDevinChatRequest(
485
546
  }
486
547
 
487
548
  /** Map omp `Message` history onto Cascade `ChatMessagePrompt`s (USER / SYSTEM / TOOL channels). */
488
- function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMessagePrompt[] {
549
+ function buildChatMessagePrompts(
550
+ messages: Message[],
551
+ cascadeId: string,
552
+ model: Model<"devin-agent">,
553
+ ): ChatMessagePrompt[] {
489
554
  const prompts: ChatMessagePrompt[] = [];
490
555
  // messageId seeds are `cascadeId\0index\0role[...]` — prompt text is excluded
491
556
  // so ids stay stable across content edits / history rebuilds.
@@ -513,16 +578,18 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
513
578
  }),
514
579
  );
515
580
  } else if (msg.role === "assistant") {
581
+ const isNativeDevinMessage =
582
+ msg.api === model.api && msg.provider === model.provider && msg.model === model.id;
516
583
  let promptText = "";
517
584
  let thinkingText = "";
518
585
  let signature = "";
519
586
  const toolCalls: ChatToolCall[] = [];
520
587
  for (const part of msg.content) {
521
588
  if (part.type === "text") {
522
- promptText += part.text;
589
+ promptText += `${part.text}${isDemotedThinking(part) ? "\n" : ""}`;
523
590
  } else if (part.type === "thinking") {
524
591
  thinkingText += part.thinking;
525
- if (!signature && part.thinkingSignature) signature = part.thinkingSignature;
592
+ if (isNativeDevinMessage && !signature && part.thinkingSignature) signature = part.thinkingSignature;
526
593
  } else if (part.type === "toolCall") {
527
594
  toolCalls.push(
528
595
  create(ChatToolCallSchema, {
@@ -533,9 +600,13 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
533
600
  );
534
601
  }
535
602
  }
603
+ if (!promptText && !thinkingText && !signature && toolCalls.length === 0) continue;
536
604
  prompts.push(
537
605
  create(ChatMessagePromptSchema, {
538
- messageId: msg.responseId ?? `bot-${deterministicUuid(`${cascadeId}\0${index}\0assistant`)}`,
606
+ messageId:
607
+ isNativeDevinMessage && msg.responseId
608
+ ? msg.responseId
609
+ : `bot-${deterministicUuid(`${cascadeId}\0${index}\0assistant`)}`,
539
610
  source: ChatMessageSource.SYSTEM,
540
611
  prompt: promptText,
541
612
  thinking: thinkingText,
@@ -569,12 +640,18 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
569
640
  return prompts;
570
641
  }
571
642
 
643
+ interface ConnectTrailerError {
644
+ code: string;
645
+ message: string;
646
+ formatted: string;
647
+ }
648
+
572
649
  /**
573
- * Parse a Connect end-of-stream JSON trailer and return a human-readable error
574
- * string when it carries `{ error: { code, message } }`, else `null`. The trailer
575
- * is untrusted server output, so the shape is checked with guards rather than asserted.
650
+ * Parse a Connect end-of-stream JSON trailer and return its structured error
651
+ * when it carries `{ error: { code, message } }`, else `null`. The trailer is
652
+ * untrusted server output, so the shape is checked with guards rather than asserted.
576
653
  */
577
- function readConnectTrailerError(text: string): string | null {
654
+ function readConnectTrailerError(text: string): ConnectTrailerError | null {
578
655
  if (text.length === 0) return null;
579
656
  let parsed: unknown;
580
657
  try {
@@ -588,5 +665,9 @@ function readConnectTrailerError(text: string): string | null {
588
665
  const code = "code" in err && typeof err.code === "string" ? err.code : "";
589
666
  const message = "message" in err && typeof err.message === "string" ? err.message : "";
590
667
  if (!code && !message) return null;
591
- return `Devin stream error${code ? ` ${code}` : ""}: ${message}`;
668
+ return {
669
+ code,
670
+ message,
671
+ formatted: `Devin stream error${code ? ` ${code}` : ""}: ${message}`,
672
+ };
592
673
  }
@@ -3913,7 +3913,8 @@ function redactHeaders(headers: Headers): Record<string, string> {
3913
3913
  return redacted;
3914
3914
  }
3915
3915
 
3916
- function resolveCodexResponsesUrl(baseUrl: string | undefined): string {
3916
+ /** Resolve a Codex Responses endpoint exactly as the chat and compaction transports do. */
3917
+ export function resolveCodexResponsesUrl(baseUrl: string | undefined): string {
3917
3918
  const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : CODEX_BASE_URL;
3918
3919
  const normalized = raw.replace(/\/+$/, "");
3919
3920
  if (normalized.endsWith("/codex/responses")) return normalized;
@@ -42,7 +42,13 @@ import {
42
42
  import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http";
43
43
  import { notifyProviderResponse } from "../utils/provider-response";
44
44
  import { callWithCopilotModelRetry } from "../utils/retry";
45
- import { adaptSchemaForStrict, NO_STRICT, normalizeSchemaForMoonshot, toolWireSchema } from "../utils/schema";
45
+ import {
46
+ adaptSchemaForStrict,
47
+ NO_STRICT,
48
+ normalizeSchemaForMoonshot,
49
+ sanitizeSchemaForGrammar,
50
+ toolWireSchema,
51
+ } from "../utils/schema";
46
52
  import {
47
53
  type HealedToolCall,
48
54
  StreamMarkupHealing,
@@ -671,7 +677,7 @@ const streamOpenAICompletionsOnce = (
671
677
  }
672
678
  activeReasoningEffortFallbackKey = reasoningEffortFallbackKey;
673
679
  activeRequestParams = params;
674
- options?.onPayload?.(params);
680
+ options?.onPayload?.(params, model);
675
681
  rawRequestDump = {
676
682
  provider: model.provider,
677
683
  api: output.api,
@@ -1431,6 +1437,20 @@ function dropOpenRouterKimiForcedToolReasoning(
1431
1437
  }
1432
1438
  }
1433
1439
 
1440
+ function hasActiveNativeKimiK3Reasoning(
1441
+ model: Model<"openai-completions">,
1442
+ options: OpenAICompletionsOptions | undefined,
1443
+ ): boolean {
1444
+ if (model.provider !== "kimi-code" || model.id.toLowerCase() !== "k3" || !model.reasoning) return false;
1445
+ if (options?.reasoning === undefined || options.disableReasoning) return false;
1446
+ try {
1447
+ const url = new URL(model.baseUrl);
1448
+ return url.hostname === "api.kimi.com" && (url.pathname === "/coding" || url.pathname.startsWith("/coding/"));
1449
+ } catch {
1450
+ return false;
1451
+ }
1452
+ }
1453
+
1434
1454
  function buildParams(
1435
1455
  model: Model<"openai-completions">,
1436
1456
  context: Context,
@@ -1518,6 +1538,20 @@ function buildParams(
1518
1538
  ) {
1519
1539
  params.tool_choice = "required";
1520
1540
  }
1541
+ const forcedToolName =
1542
+ typeof params.tool_choice === "object" && params.tool_choice !== null && "function" in params.tool_choice
1543
+ ? params.tool_choice.function.name
1544
+ : undefined;
1545
+ if (
1546
+ forcedToolName !== undefined &&
1547
+ Array.isArray(params.tools) &&
1548
+ params.tools.some(tool => tool.type === "function" && tool.function.name === forcedToolName) &&
1549
+ hasActiveNativeKimiK3Reasoning(model, options)
1550
+ ) {
1551
+ // Native K3 reasoning is incompatible with selecting a specific function.
1552
+ // Preserve the hard tool-use contract while letting K3 choose among tools.
1553
+ params.tool_choice = "required";
1554
+ }
1521
1555
  if (isForcedToolChoice(params.tool_choice) && !initialCompat.supportsForcedToolChoice) {
1522
1556
  // Some thinking-required OpenAI-compatible models reject forced
1523
1557
  // `tool_choice` while still accepting tools with the default auto
@@ -1537,10 +1571,6 @@ function buildParams(
1537
1571
  delete params.tool_choice;
1538
1572
  }
1539
1573
 
1540
- const forcedToolName =
1541
- typeof params.tool_choice === "object" && params.tool_choice !== null && "function" in params.tool_choice
1542
- ? params.tool_choice.function.name
1543
- : undefined;
1544
1574
  if (
1545
1575
  forcedToolName !== undefined &&
1546
1576
  (!Array.isArray(params.tools) ||
@@ -2205,10 +2235,16 @@ function convertTools(
2205
2235
  description: tool.description || "",
2206
2236
  // Moonshot/Kimi native hosts validate against the stricter MFJS subset
2207
2237
  // (const→enum, typed enums, no validators) and 400 otherwise.
2238
+ // Grammar-constrained local backends (llama.cpp, LM Studio, vLLM)
2239
+ // build a GBNF grammar from the schema and 400 with
2240
+ // `Unrecognized schema: true` on the bare boolean subschema
2241
+ // `toolWireSchema` emits for open fields (issue #5914).
2208
2242
  parameters:
2209
2243
  compat.toolSchemaFlavor === "moonshot-mfjs"
2210
2244
  ? (normalizeSchemaForMoonshot(wireParameters) as Record<string, unknown>)
2211
- : wireParameters,
2245
+ : compat.toolSchemaFlavor === "grammar"
2246
+ ? sanitizeSchemaForGrammar(wireParameters)
2247
+ : wireParameters,
2212
2248
  // Only include strict if provider supports it. Some reject unknown fields.
2213
2249
  ...(includeStrict ? { strict: true } : includeExplicitFalse ? { strict: false } : {}),
2214
2250
  },