pi-provider-kimi-code 0.6.11 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/payload.ts CHANGED
@@ -31,12 +31,14 @@ export function resolveCacheRetention(value?: CacheRetention): CacheRetention {
31
31
  }
32
32
 
33
33
  export interface KimiPayloadContext {
34
- api: "anthropic-messages" | "openai-completions";
34
+ api: "anthropic-messages" | "openai-completions" | "openai-responses";
35
35
  upload?: Uploader;
36
36
  uploadCacheScope?: string;
37
37
  cacheKey?: string;
38
38
  cacheRetention: CacheRetention;
39
39
  reasoning?: ThinkingLevel;
40
+ /** Per-request cap from the caller (pi options.maxTokens); wins over the omission policy. */
41
+ requestMaxTokens?: number;
40
42
  modelConfig: KimiResolvedModelConfig;
41
43
  }
42
44
 
@@ -264,6 +266,155 @@ async function transformOpenAIPayloadFiles(
264
266
  }
265
267
  }
266
268
 
269
+ // -----------------------------------------------------------------------------
270
+ // Per-request inline media budget (upstream kimi-code #3784,
271
+ // mediaResolverService.applyMediaBudget): accumulated inline media count
272
+ // against a 20 MB per-request base64 wire-size budget (not decoded media
273
+ // bytes); when exceeded, the oldest items are dropped until the total is under
274
+ // the 10 MB base64 wire-size low-water mark and replaced with an omission
275
+ // placeholder. OpenAI data URLs count the full data: URL string, including its
276
+ // small prefix overhead, while Anthropic base64 blocks count the source data
277
+ // string. Uploaded ms:// references are tiny and never counted. Media is
278
+ // deduped by content hash so a repeated image counts once and every occurrence
279
+ // is dropped together.
280
+ // -----------------------------------------------------------------------------
281
+
282
+ const REQUEST_MEDIA_BUDGET_BYTES = 20 * 1024 * 1024;
283
+ const REQUEST_MEDIA_BUDGET_LOW_BYTES = 10 * 1024 * 1024;
284
+
285
+ interface InlineMediaBudgetEntry {
286
+ kind: "image" | "video";
287
+ key: string;
288
+ bytes: number;
289
+ /** Indices into messages[...].content[...] (a third index addresses a tool_result's content). */
290
+ path: number[];
291
+ }
292
+
293
+ function inlineMediaKey(kind: string, payload: string): string {
294
+ return createHash("sha256").update(kind).update("\0").update(payload).digest("hex");
295
+ }
296
+
297
+ function collectOpenAIInlineMedia(payload: JsonRecord): InlineMediaBudgetEntry[] {
298
+ const entries: InlineMediaBudgetEntry[] = [];
299
+ if (!Array.isArray(payload.messages)) return entries;
300
+ for (const [messageIndex, message] of payload.messages.entries()) {
301
+ if (!isRecord(message) || !Array.isArray(message.content)) continue;
302
+ for (const [blockIndex, block] of (message.content as unknown[]).entries()) {
303
+ if (!isRecord(block)) continue;
304
+ const field =
305
+ block.type === "image_url" ? "image_url" : block.type === "video_url" ? "video_url" : null;
306
+ if (!field) continue;
307
+ const kind = block.type === "image_url" ? "image" : "video";
308
+ const value = block[field];
309
+ const url =
310
+ typeof value === "string"
311
+ ? value
312
+ : isRecord(value) && typeof value.url === "string"
313
+ ? value.url
314
+ : null;
315
+ if (!url || !url.startsWith("data:")) continue;
316
+ entries.push({
317
+ kind,
318
+ key: inlineMediaKey(kind, url),
319
+ bytes: url.length,
320
+ path: [messageIndex, blockIndex],
321
+ });
322
+ }
323
+ }
324
+ return entries;
325
+ }
326
+
327
+ function collectAnthropicInlineMedia(payload: JsonRecord): InlineMediaBudgetEntry[] {
328
+ const entries: InlineMediaBudgetEntry[] = [];
329
+ if (!Array.isArray(payload.messages)) return entries;
330
+ const collect = (messageIndex: number, path: number[], block: unknown): void => {
331
+ if (!isRecord(block)) return;
332
+ const kind = block.type === "image" || block.type === "video" ? block.type : null;
333
+ if (
334
+ kind &&
335
+ isRecord(block.source) &&
336
+ block.source.type === "base64" &&
337
+ typeof block.source.media_type === "string" &&
338
+ typeof block.source.data === "string"
339
+ ) {
340
+ const data = block.source.data;
341
+ entries.push({
342
+ kind,
343
+ key: inlineMediaKey(kind, `${block.source.media_type}\0${data}`),
344
+ bytes: data.length,
345
+ path: [messageIndex, ...path],
346
+ });
347
+ }
348
+ };
349
+ for (const [messageIndex, message] of payload.messages.entries()) {
350
+ if (!isRecord(message) || !Array.isArray(message.content)) continue;
351
+ for (const [blockIndex, block] of (message.content as unknown[]).entries()) {
352
+ if (isRecord(block) && block.type === "tool_result" && Array.isArray(block.content)) {
353
+ for (const [nestedIndex, nested] of (block.content as unknown[]).entries()) {
354
+ collect(messageIndex, [blockIndex, nestedIndex], nested);
355
+ }
356
+ continue;
357
+ }
358
+ collect(messageIndex, [blockIndex], block);
359
+ }
360
+ }
361
+ return entries;
362
+ }
363
+
364
+ export function applyInlineMediaBudget(
365
+ payload: JsonRecord,
366
+ api: "anthropic-messages" | "openai-completions",
367
+ budgetBytes: number = REQUEST_MEDIA_BUDGET_BYTES,
368
+ lowBytes: number = REQUEST_MEDIA_BUDGET_LOW_BYTES,
369
+ ): boolean {
370
+ const entries =
371
+ api === "anthropic-messages"
372
+ ? collectAnthropicInlineMedia(payload)
373
+ : collectOpenAIInlineMedia(payload);
374
+ if (entries.length === 0) return false;
375
+
376
+ // Deduplicate by content: repeated media counts once, drops hit every copy.
377
+ const bytesByKey = new Map<string, number>();
378
+ for (const entry of entries) {
379
+ if (!bytesByKey.has(entry.key)) bytesByKey.set(entry.key, entry.bytes);
380
+ }
381
+ let total = 0;
382
+ for (const bytes of bytesByKey.values()) total += bytes;
383
+ if (total <= budgetBytes) return false;
384
+
385
+ const dropped = new Set<string>();
386
+ for (const [key, bytes] of bytesByKey) {
387
+ if (total <= lowBytes) break;
388
+ if (bytes === 0) continue;
389
+ dropped.add(key);
390
+ total -= bytes;
391
+ }
392
+
393
+ const messages = payload.messages as unknown[];
394
+ let droppedCount = 0;
395
+ for (const entry of entries) {
396
+ if (!dropped.has(entry.key)) continue;
397
+ const message = messages[entry.path[0]] as JsonRecord;
398
+ const content = message.content as unknown[];
399
+ const placeholder = {
400
+ type: "text",
401
+ text: `[${entry.kind} omitted: dropped to fit the request media budget]`,
402
+ };
403
+ if (entry.path.length === 2) {
404
+ content[entry.path[1]] = placeholder;
405
+ } else {
406
+ const toolResult = content[entry.path[1]] as JsonRecord;
407
+ (toolResult.content as unknown[])[entry.path[2]] = placeholder;
408
+ }
409
+ droppedCount += 1;
410
+ }
411
+ console.warn(
412
+ `[kimi-coding] inline media exceeded the ${String(budgetBytes / (1024 * 1024))} MB ` +
413
+ `per-request budget; ${String(droppedCount)} media item(s) omitted`,
414
+ );
415
+ return true;
416
+ }
417
+
267
418
  function isEffectivelyEmptyOpenAIContent(content: unknown): boolean {
268
419
  if (typeof content === "string") return content.trim() === "";
269
420
  if (!Array.isArray(content)) return false;
@@ -410,10 +561,12 @@ function recurseJsonSchemaPropertyTypes(node: unknown): void {
410
561
  function normalizeOpenAIToolSchemas(payload: JsonRecord): void {
411
562
  if (!Array.isArray(payload.tools)) return;
412
563
  for (const tool of payload.tools) {
413
- if (!isRecord(tool) || !isRecord(tool.function)) continue;
414
- const parameters = tool.function.parameters;
415
- if (!isRecord(parameters)) continue;
416
- recurseJsonSchemaPropertyTypes(parameters);
564
+ if (!isRecord(tool)) continue;
565
+ // Chat Completions nests the schema under tool.function.parameters;
566
+ // Responses function tools carry parameters at the top level.
567
+ const holder: JsonRecord = isRecord(tool.function) ? tool.function : tool;
568
+ if (!isRecord(holder.parameters)) continue;
569
+ recurseJsonSchemaPropertyTypes(holder.parameters);
417
570
  }
418
571
  }
419
572
 
@@ -468,6 +621,141 @@ async function transformAnthropicPayloadFiles(
468
621
  // steps below testable with fixture payloads.
469
622
  // =============================================================================
470
623
 
624
+ const RESPONSES_EFFORTS = new Set(["low", "high", "max"]);
625
+
626
+ // Empty/whitespace-only text parts in the Responses `input` array. Assistant
627
+ // turns that produced no text (reasoning + tool calls only) replay as
628
+ // output_text:"" and upstream 400s on text non-emptiness; see issue #78.
629
+ const RESPONSES_TEXT_PART_TYPES = new Set(["output_text", "text", "input_text"]);
630
+
631
+ function isEmptyTextPart(part: unknown): boolean {
632
+ return (
633
+ isRecord(part) &&
634
+ typeof part.type === "string" &&
635
+ RESPONSES_TEXT_PART_TYPES.has(part.type) &&
636
+ (typeof part.text !== "string" || part.text.trim() === "")
637
+ );
638
+ }
639
+
640
+ export function stripEmptyResponsesTextParts(payload: JsonRecord): boolean {
641
+ if (!Array.isArray(payload.input)) return false;
642
+ let changed = false;
643
+ const input: unknown[] = [];
644
+ for (const item of payload.input) {
645
+ if (!isRecord(item)) {
646
+ input.push(item);
647
+ continue;
648
+ }
649
+ // Typed non-message items (function_call, reasoning, ...) pass through.
650
+ if (typeof item.type === "string" && item.type !== "message") {
651
+ input.push(item);
652
+ continue;
653
+ }
654
+ const role = item.role;
655
+ const content = item.content;
656
+ const isAssistantLike = role === "assistant" || role == null;
657
+ if (typeof content === "string") {
658
+ if (isAssistantLike && content.trim() === "") {
659
+ changed = true;
660
+ continue;
661
+ }
662
+ input.push(item);
663
+ continue;
664
+ }
665
+ if (!Array.isArray(content)) {
666
+ // A message item with missing/non-array content carries nothing to
667
+ // render; drop assistant-shaped ones instead of replaying a
668
+ // contentless message (the upstream 400 path this fix targets).
669
+ if (isAssistantLike && (content === undefined || content === null)) {
670
+ changed = true;
671
+ continue;
672
+ }
673
+ input.push(item);
674
+ continue;
675
+ }
676
+ const next = content.filter((part) => !isEmptyTextPart(part));
677
+ // An assistant message with nothing left — either filtered here or
678
+ // arriving empty — carries no information; drop it rather than send a
679
+ // contentless message. User/system messages keep their content.
680
+ if (next.length === 0 && isAssistantLike) {
681
+ changed = true;
682
+ continue;
683
+ }
684
+ if (next.length === content.length) {
685
+ input.push(item);
686
+ continue;
687
+ }
688
+ changed = true;
689
+ input.push({ ...item, content: next });
690
+ }
691
+ if (changed) payload.input = input;
692
+ return changed;
693
+ }
694
+
695
+ function responsesSupportedEfforts(ctx: KimiPayloadContext): string[] {
696
+ const advertised = ctx.modelConfig.supportEfforts;
697
+ if (advertised?.length) return advertised.filter((effort) => RESPONSES_EFFORTS.has(effort));
698
+ return [...RESPONSES_EFFORTS];
699
+ }
700
+
701
+ function setResponsesReasoning(payload: JsonRecord, effort: string | null): void {
702
+ const oldReasoning = isRecord(payload.reasoning) ? payload.reasoning : {};
703
+ if (!effort) {
704
+ delete payload.reasoning;
705
+ return;
706
+ }
707
+ const reasoning: JsonRecord = { ...oldReasoning, effort };
708
+ if (reasoning.summary === undefined) reasoning.summary = "auto";
709
+ payload.reasoning = reasoning;
710
+ }
711
+
712
+ function normalizeResponsesUpstreamEffort(payload: JsonRecord, ctx: KimiPayloadContext): void {
713
+ const supported = responsesSupportedEfforts(ctx);
714
+ const oldEffort = isRecord(payload.reasoning) ? payload.reasoning.effort : undefined;
715
+ if (typeof oldEffort === "string" && supported.includes(oldEffort)) return;
716
+ if (typeof oldEffort === "string") {
717
+ const mapped = resolveReasoningForLevel(oldEffort, ctx.modelConfig);
718
+ if (mapped?.enabled && mapped.effort && supported.includes(mapped.effort)) {
719
+ setResponsesReasoning(payload, mapped.effort);
720
+ return;
721
+ }
722
+ }
723
+ const fallback = ctx.modelConfig.defaultEffort;
724
+ setResponsesReasoning(payload, fallback && supported.includes(fallback) ? fallback : null);
725
+ }
726
+
727
+ function applyResponsesThinking(payload: JsonRecord, ctx: KimiPayloadContext): void {
728
+ delete payload.thinking;
729
+ delete payload.reasoning_effort;
730
+ delete payload.output_config;
731
+ if (ctx.modelConfig.supportsThinkingType === "no") {
732
+ delete payload.reasoning;
733
+ return;
734
+ }
735
+ const resolvedReasoning = resolveThinkingLevel(ctx);
736
+ if (!resolvedReasoning) {
737
+ normalizeResponsesUpstreamEffort(payload, ctx);
738
+ return;
739
+ }
740
+ const mapped = resolveReasoningForLevel(resolvedReasoning, ctx.modelConfig);
741
+ if (!mapped) {
742
+ normalizeResponsesUpstreamEffort(payload, ctx);
743
+ return;
744
+ }
745
+ if (!mapped.enabled) {
746
+ delete payload.reasoning;
747
+ return;
748
+ }
749
+ const effort = ctx.reasoning ? mapped.effort : (ctx.modelConfig.defaultEffort ?? mapped.effort);
750
+ const supported = responsesSupportedEfforts(ctx);
751
+ const oldReasoning = isRecord(payload.reasoning) ? payload.reasoning : {};
752
+ const reasoning: JsonRecord = { ...oldReasoning };
753
+ delete reasoning.effort;
754
+ if (effort !== null && supported.includes(effort)) reasoning.effort = effort;
755
+ if (reasoning.summary === undefined) reasoning.summary = "auto";
756
+ payload.reasoning = reasoning;
757
+ }
758
+
471
759
  export async function applyKimiPayloadMutations(
472
760
  payload: JsonRecord,
473
761
  ctx: KimiPayloadContext,
@@ -479,6 +767,19 @@ export async function applyKimiPayloadMutations(
479
767
  );
480
768
  }
481
769
 
770
+ // 1b. Responses wire: drop empty text parts replayed from text-less
771
+ // assistant turns (issue #78). pi-ai's Responses serializer keeps empty
772
+ // output_text blocks — unlike its Chat Completions serializer, which
773
+ // filters them — so a reasoning/tool-only turn is replayed as
774
+ // {role:"assistant", content:[{type:"output_text", text:""}]} on every
775
+ // subsequent request, and upstream endpoints reject the whole payload with
776
+ // a 400-class text-non-emptiness error. function_call / reasoning / all
777
+ // other part types are untouched; an assistant message left with no
778
+ // content is dropped entirely.
779
+ if (ctx.api === "openai-responses") {
780
+ stripEmptyResponsesTextParts(payload);
781
+ }
782
+
482
783
  // 2. File upload dispatch (protocol-specific).
483
784
  if (ctx.upload) {
484
785
  const uploadCache = ctx.uploadCacheScope ? uploadedFileCache : new Map<string, string>();
@@ -489,8 +790,15 @@ export async function applyKimiPayloadMutations(
489
790
  await transformAnthropicPayloadFiles(payload, ctx.upload, uploadCache, uploadCacheScope);
490
791
  }
491
792
  }
793
+ if (ctx.api === "openai-completions" || ctx.api === "anthropic-messages") {
794
+ // Runs after the upload transforms so media already promoted to ms://
795
+ // references (tiny URLs) are never counted against the budget.
796
+ applyInlineMediaBudget(payload, ctx.api);
797
+ }
492
798
  if (ctx.api === "openai-completions") {
493
799
  normalizeOpenAIAssistantToolCalls(payload);
800
+ }
801
+ if (ctx.api === "openai-completions" || ctx.api === "openai-responses") {
494
802
  normalizeOpenAIToolSchemas(payload);
495
803
  }
496
804
  if (Array.isArray(payload.tools)) {
@@ -520,8 +828,12 @@ export async function applyKimiPayloadMutations(
520
828
 
521
829
  // 5. Spread extra_body into the top-level payload before normalization and
522
830
  // config caps. Top-level fields retain precedence over extra_body.
831
+ let extraBodyHadCap = false;
523
832
  if (isRecord(payload.extra_body)) {
524
833
  const extraBody = payload.extra_body as JsonRecord;
834
+ extraBodyHadCap = ["max_tokens", "max_completion_tokens", "max_output_tokens"].some(
835
+ (key) => typeof extraBody[key] === "number",
836
+ );
525
837
  delete payload.extra_body;
526
838
  for (const [key, value] of Object.entries(extraBody)) {
527
839
  if (payload[key] === undefined) {
@@ -530,16 +842,49 @@ export async function applyKimiPayloadMutations(
530
842
  }
531
843
  }
532
844
 
533
- // 6. Normalize deprecated max_tokens (OpenAI path only — Anthropic
534
- // /messages uses max_tokens natively).
845
+ // 6. Normalize output-token field names per protocol.
535
846
  if (ctx.api === "openai-completions") {
536
847
  if (payload.max_completion_tokens === undefined && typeof payload.max_tokens === "number") {
537
848
  payload.max_completion_tokens = payload.max_tokens;
538
849
  }
539
850
  delete payload.max_tokens;
851
+ } else if (ctx.api === "openai-responses") {
852
+ if (payload.max_output_tokens === undefined) {
853
+ if (typeof payload.max_completion_tokens === "number") {
854
+ payload.max_output_tokens = payload.max_completion_tokens;
855
+ } else if (typeof payload.max_tokens === "number") {
856
+ payload.max_output_tokens = payload.max_tokens;
857
+ }
858
+ }
859
+ delete payload.max_tokens;
860
+ delete payload.max_completion_tokens;
861
+ delete payload.prompt_cache_retention;
540
862
  }
541
863
 
542
864
  const generation = ctx.modelConfig.generation;
865
+ // Output cap policy (mirrors upstream kimi-code #4091): omit the completion
866
+ // cap on the OpenAI wires unless explicitly configured. pi-ai seeds a cap
867
+ // clamped to the context window (clampMaxTokensToContext), and forwarding
868
+ // that value breaks strict serving stacks (e.g. bare vLLM) that reject a
869
+ // cap above the model's real output limit with repeated 400s. A cap counts
870
+ // as explicit when generation.maxCompletionTokens is configured
871
+ // (KIMI_MODEL_MAX_COMPLETION_TOKENS / config generation.maxCompletionTokens),
872
+ // model.maxTokens holds a non-window value, or the caller supplies a
873
+ // per-request cap through options.maxTokens / extra_body. Anthropic /messages requires
874
+ // max_tokens, so that wire keeps pi-ai's window-clamped cap — the same
875
+ // window - usedContextTokens value upstream fills in when unset.
876
+ if (ctx.api === "openai-completions" || ctx.api === "openai-responses") {
877
+ const capKey = ctx.api === "openai-responses" ? "max_output_tokens" : "max_completion_tokens";
878
+ const explicitModelCap =
879
+ typeof ctx.modelConfig.maxTokens === "number" &&
880
+ ctx.modelConfig.maxTokens > 0 &&
881
+ ctx.modelConfig.maxTokens !== ctx.modelConfig.contextWindow;
882
+ const explicitRequestCap =
883
+ extraBodyHadCap || (typeof ctx.requestMaxTokens === "number" && ctx.requestMaxTokens > 0);
884
+ if (generation.maxCompletionTokens === undefined && !explicitModelCap && !explicitRequestCap) {
885
+ delete payload[capKey];
886
+ }
887
+ }
543
888
  // Official kimi-code sends temperature/top_p only when explicitly configured
544
889
  // (env KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P → generation.*); otherwise it
545
890
  // omits them and lets the server apply its own defaults. Mirror that exactly:
@@ -552,7 +897,12 @@ export async function applyKimiPayloadMutations(
552
897
  // Output cap: an explicit cap (KIMI_MODEL_MAX_COMPLETION_TOKENS / config →
553
898
  // generation.maxCompletionTokens) is honored as a hardCap on the request value.
554
899
  if (generation.maxCompletionTokens !== undefined) {
555
- const maxTokensKey = ctx.api === "anthropic-messages" ? "max_tokens" : "max_completion_tokens";
900
+ const maxTokensKey =
901
+ ctx.api === "anthropic-messages"
902
+ ? "max_tokens"
903
+ : ctx.api === "openai-responses"
904
+ ? "max_output_tokens"
905
+ : "max_completion_tokens";
556
906
  const currentMaxTokens = payload[maxTokensKey];
557
907
  payload[maxTokensKey] =
558
908
  typeof currentMaxTokens === "number"
@@ -560,87 +910,98 @@ export async function applyKimiPayloadMutations(
560
910
  : generation.maxCompletionTokens;
561
911
  }
562
912
 
563
- // 7. Reasoning effort mapping. Kimi now accepts effort only inside the
564
- // thinking object, and only for values advertised by the model catalog.
565
- delete payload.reasoning_effort;
566
- if (ctx.modelConfig.supportsThinkingType === "no") {
567
- delete payload.thinking;
568
- }
569
- const resolvedReasoning = resolveThinkingLevel(ctx);
570
- if (resolvedReasoning) {
571
- const mapped = resolveReasoningForLevel(resolvedReasoning, ctx.modelConfig);
572
- if (mapped) {
573
- const oldThinking = isRecord(payload.thinking) ? payload.thinking : {};
574
- const effort = ctx.reasoning
575
- ? mapped.effort
576
- : (ctx.modelConfig.defaultEffort ?? mapped.effort);
577
- const effortSupported =
578
- effort !== null && ctx.modelConfig.supportEfforts?.includes(effort) === true;
579
- if (ctx.api === "anthropic-messages" && oldThinking.type === "adaptive") {
580
- // pi-ai >=0.82 builds adaptive thinking for models carrying
581
- // compat.forceAdaptiveThinking (streamSimpleKimi sets it on the
582
- // anthropic runtime model). Keep the adaptive shape — effort lives in
583
- // top-level output_config there, not inside thinking — and only
584
- // replace it with an explicit disable when the level maps to off.
585
- if (mapped.enabled) {
586
- if (effortSupported) payload.output_config = { effort };
587
- else delete payload.output_config;
913
+ // 7. Reasoning effort mapping.
914
+ // Completions/Anthropic: effort lives in thinking / output_config.
915
+ // Responses: pi-ai's native transport uses reasoning.effort; drop the
916
+ // Completions thinking object and clamp effort to the catalog.
917
+ if (ctx.api === "openai-responses") {
918
+ applyResponsesThinking(payload, ctx);
919
+ } else {
920
+ delete payload.reasoning_effort;
921
+ if (ctx.modelConfig.supportsThinkingType === "no") {
922
+ delete payload.thinking;
923
+ }
924
+ const resolvedReasoning = resolveThinkingLevel(ctx);
925
+ if (resolvedReasoning) {
926
+ const mapped = resolveReasoningForLevel(resolvedReasoning, ctx.modelConfig);
927
+ if (mapped) {
928
+ const oldThinking = isRecord(payload.thinking) ? payload.thinking : {};
929
+ const effort = ctx.reasoning
930
+ ? mapped.effort
931
+ : (ctx.modelConfig.defaultEffort ?? mapped.effort);
932
+ const effortSupported =
933
+ effort !== null && ctx.modelConfig.supportEfforts?.includes(effort) === true;
934
+ if (ctx.api === "anthropic-messages" && oldThinking.type === "adaptive") {
935
+ // pi-ai >=0.82 builds adaptive thinking for models carrying
936
+ // compat.forceAdaptiveThinking (streamSimpleKimi sets it on the
937
+ // anthropic runtime model). Keep the adaptive shape — effort lives in
938
+ // top-level output_config there, not inside thinking — and only
939
+ // replace it with an explicit disable when the level maps to off.
940
+ if (mapped.enabled) {
941
+ if (effortSupported) payload.output_config = { effort };
942
+ else delete payload.output_config;
943
+ } else {
944
+ payload.thinking = { type: "disabled" };
945
+ delete payload.output_config;
946
+ }
588
947
  } else {
589
- payload.thinking = { type: "disabled" };
590
- delete payload.output_config;
948
+ const thinking: JsonRecord = {
949
+ ...oldThinking,
950
+ type: mapped.enabled ? "enabled" : "disabled",
951
+ };
952
+ delete thinking.effort;
953
+ if (!mapped.enabled) delete thinking.keep;
954
+ if (mapped.enabled && effortSupported) {
955
+ thinking.effort = effort;
956
+ }
957
+ if (mapped.enabled && ctx.modelConfig.thinkingKeep) {
958
+ thinking.keep = ctx.modelConfig.thinkingKeep;
959
+ }
960
+ payload.thinking = thinking;
591
961
  }
592
- } else {
593
- const thinking: JsonRecord = {
594
- ...oldThinking,
595
- type: mapped.enabled ? "enabled" : "disabled",
596
- };
597
- delete thinking.effort;
598
- if (!mapped.enabled) delete thinking.keep;
599
- if (mapped.enabled && effortSupported) {
600
- thinking.effort = effort;
601
- }
602
- if (mapped.enabled && ctx.modelConfig.thinkingKeep) {
603
- thinking.keep = ctx.modelConfig.thinkingKeep;
604
- }
605
- payload.thinking = thinking;
606
962
  }
607
963
  }
608
- }
609
964
 
610
- // Preserved-thinking Kimi endpoints require every replayed assistant turn to
611
- // carry reasoning_content, including turns whose reasoning delta was empty.
612
- // pi-ai drops empty thinking blocks while building Chat Completions history,
613
- // so restore the explicit empty field after the final thinking mode is known.
614
- if (
615
- ctx.api === "openai-completions" &&
616
- isRecord(payload.thinking) &&
617
- payload.thinking.type !== "disabled" &&
618
- payload.thinking.keep === "all" &&
619
- Array.isArray(payload.messages)
620
- ) {
621
- for (const message of payload.messages) {
622
- if (
623
- isRecord(message) &&
624
- message.role === "assistant" &&
625
- message.reasoning_content === undefined
626
- ) {
627
- message.reasoning_content = "";
965
+ // Preserved-thinking Kimi endpoints require every replayed assistant turn to
966
+ // carry reasoning_content, including turns whose reasoning delta was empty.
967
+ // pi-ai drops empty thinking blocks while building Chat Completions history,
968
+ // so restore the explicit empty field after the final thinking mode is known.
969
+ if (
970
+ ctx.api === "openai-completions" &&
971
+ isRecord(payload.thinking) &&
972
+ payload.thinking.type !== "disabled" &&
973
+ payload.thinking.keep === "all" &&
974
+ Array.isArray(payload.messages)
975
+ ) {
976
+ for (const message of payload.messages) {
977
+ if (
978
+ isRecord(message) &&
979
+ message.role === "assistant" &&
980
+ message.reasoning_content === undefined
981
+ ) {
982
+ message.reasoning_content = "";
983
+ }
628
984
  }
629
985
  }
630
986
  }
631
987
 
632
- // 8. K2.7 Code API constraints: the server rejects tool_choice "required" /
633
- // function-specific when thinking is enabled (always-on). temperature/top_p
634
- // are handled in step 6 — omitted unless explicitly configured, matching the
635
- // official kimi-code client rather than pinned to 1.0/0.95.
988
+ // 8. Kimi coding endpoint constraints: the server rejects tool_choice "required" /
989
+ // function-specific when thinking is enabled (always-on). Responses only
990
+ // accepts auto. temperature/top_p are handled in step 6 — omitted unless
991
+ // explicitly configured, matching the official kimi-code client rather
992
+ // than pinned to 1.0/0.95.
636
993
  if (payload.tool_choice !== undefined) {
637
- const tc = payload.tool_choice;
638
- const isAllowed =
639
- tc === "auto" ||
640
- tc === "none" ||
641
- (isRecord(tc) && (tc.type === "auto" || tc.type === "none"));
642
- if (!isAllowed) {
643
- payload.tool_choice = isRecord(tc) ? { type: "auto" } : "auto";
994
+ if (ctx.api === "openai-responses") {
995
+ payload.tool_choice = "auto";
996
+ } else {
997
+ const tc = payload.tool_choice;
998
+ const isAllowed =
999
+ tc === "auto" ||
1000
+ tc === "none" ||
1001
+ (isRecord(tc) && (tc.type === "auto" || tc.type === "none"));
1002
+ if (!isAllowed) {
1003
+ payload.tool_choice = isRecord(tc) ? { type: "auto" } : "auto";
1004
+ }
644
1005
  }
645
1006
  }
646
1007
  }
@@ -232,9 +232,12 @@ export function optimizeToolSchemas(tools: unknown[]): unknown[] {
232
232
 
233
233
  let changed = false;
234
234
  const result = tools.map((tool) => {
235
- if (!isRecord(tool) || !isRecord(tool.function)) return tool;
236
- const fn = tool.function as JsonRecord;
237
- const params = fn.parameters;
235
+ if (!isRecord(tool)) return tool;
236
+ // Chat Completions nests the schema under tool.function.parameters;
237
+ // Responses function tools carry parameters at the top level.
238
+ const fn = isRecord(tool.function) ? tool.function : undefined;
239
+ const holder: JsonRecord = fn ?? tool;
240
+ const params = holder.parameters;
238
241
  if (!isRecord(params)) return tool;
239
242
 
240
243
  const size = jsonSize(params);
@@ -242,7 +245,9 @@ export function optimizeToolSchemas(tools: unknown[]): unknown[] {
242
245
 
243
246
  const optimized = deduplicateSchema(params);
244
247
  changed = true;
245
- return { ...tool, function: { ...fn, parameters: optimized } };
248
+ return fn
249
+ ? { ...tool, function: { ...fn, parameters: optimized } }
250
+ : { ...tool, parameters: optimized };
246
251
  });
247
252
 
248
253
  cachedFingerprint = fp;