pi-provider-kimi-code 0.6.11 → 0.6.12

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/README.md CHANGED
@@ -183,7 +183,7 @@ If you already use another search or fetch tool, pick one path for a session. Ov
183
183
  Most users do not need environment variables. Two are worth knowing:
184
184
 
185
185
  - `KIMI_API_KEY` — static API key for CI or key-based access.
186
- - `KIMI_CODE_PROTOCOL` — `openai` by default; set to `anthropic` for Anthropic-compatible requests.
186
+ - `KIMI_CODE_PROTOCOL` — `openai` by default; set to `anthropic` for Anthropic-compatible requests, or `responses` for the OpenAI Responses API.
187
187
 
188
188
  Tools, protocol, and upload threshold are all configurable through `/kimi-settings` or JSON config files.
189
189
 
package/index.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  } from "./src/config.ts";
44
44
  import {
45
45
  KIMI_GLOBAL_FALLBACK_SOURCE_ID,
46
+ KIMI_WIRE_PROTOCOLS,
46
47
  PROVIDER_ID,
47
48
  PROVIDER_VERSION,
48
49
  getBaseUrl,
@@ -340,7 +341,7 @@ async function openSettingsMenu(
340
341
  label: "Protocol",
341
342
  description: "API protocol for Kimi requests",
342
343
  currentValue: drafts[scope].protocol,
343
- values: ["openai", "anthropic"],
344
+ values: ["openai", "anthropic", "responses"],
344
345
  });
345
346
  items.push({
346
347
  id: "uploadThreshold",
@@ -462,8 +463,8 @@ const MODULE_UNAVAILABLE_CODES = new Set([
462
463
  // model was resolved, so a session created before a protocol switch still
463
464
  // carries the other id and would keep crashing. streamSimpleKimi routes on the
464
465
  // model's wireProtocol / the resolved runtime config and never on model.api, so
465
- // both ids dispatch identically.
466
- const KIMI_FALLBACK_APIS = [getKimiApiType("openai"), getKimiApiType("anthropic")];
466
+ // all three ids dispatch identically.
467
+ const KIMI_FALLBACK_APIS = KIMI_WIRE_PROTOCOLS.map((protocol) => getKimiApiType(protocol));
467
468
 
468
469
  type PiAiApiRegistry = {
469
470
  registerApiProvider: (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
4
4
  "description": "Kimi Code models (K3, K2.7, HighSpeed) in Pi: OAuth shared login with kimi-code CLI, file uploads, and Kimi-native tools",
5
5
  "keywords": [
6
6
  "ai-provider",
@@ -57,14 +57,14 @@
57
57
  "proper-lockfile": "^4.1.2"
58
58
  },
59
59
  "devDependencies": {
60
- "@earendil-works/pi-ai": "^0.83.0",
61
- "@earendil-works/pi-coding-agent": "^0.83.0",
62
- "@earendil-works/pi-tui": "^0.83.0",
63
- "@j178/prek": "^0.4.4",
60
+ "@earendil-works/pi-ai": "^0.85.1",
61
+ "@earendil-works/pi-coding-agent": "^0.85.1",
62
+ "@earendil-works/pi-tui": "^0.85.1",
63
+ "@j178/prek": "^0.5.2",
64
64
  "@types/node": "^24.0.0",
65
65
  "@types/proper-lockfile": "^4.1.4",
66
- "oxfmt": "^0.44.0",
67
- "oxlint": "^1.59.0",
66
+ "oxfmt": "^0.67.0",
67
+ "oxlint": "^1.82.0",
68
68
  "typescript": "^5.9.0"
69
69
  },
70
70
  "peerDependencies": {
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
- import { PROVIDER_ID } from "./constants.ts";
4
+ import { PROVIDER_ID, type KimiWireProtocol } from "./constants.ts";
5
5
 
6
6
  export const KIMI_TOOL_NAMES = ["moonshot_search", "moonshot_fetch", "kimi_datasource"] as const;
7
7
 
@@ -46,7 +46,7 @@ export interface KimiCodeConfig {
46
46
  model: ModelConfig;
47
47
  tools: Record<KimiToolName, { enabled: boolean; default_collapsed: boolean }>;
48
48
  uploads: { thresholdBytes: number };
49
- protocol: "openai" | "anthropic";
49
+ protocol: KimiWireProtocol;
50
50
  }
51
51
 
52
52
  export type KimiCodeConfigPatch = Partial<{
@@ -315,8 +315,12 @@ function requireProtocol(
315
315
  configPath: string,
316
316
  pointer: string,
317
317
  ): KimiCodeConfig["protocol"] {
318
- if (raw === "openai" || raw === "anthropic") return raw;
319
- return fail(configPath, pointer, `expected "openai" | "anthropic", got ${JSON.stringify(raw)}`);
318
+ if (raw === "openai" || raw === "anthropic" || raw === "responses") return raw;
319
+ return fail(
320
+ configPath,
321
+ pointer,
322
+ `expected "openai" | "anthropic" | "responses", got ${JSON.stringify(raw)}`,
323
+ );
320
324
  }
321
325
 
322
326
  function requireInputArray(raw: unknown, configPath: string, pointer: string): KimiInputModality[] {
package/src/constants.ts CHANGED
@@ -9,39 +9,54 @@ import { join } from "node:path";
9
9
  export const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
10
10
  export const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
11
11
 
12
- export type KimiWireProtocol = "openai" | "anthropic";
12
+ export const KIMI_WIRE_PROTOCOLS = ["openai", "anthropic", "responses"] as const;
13
+ export type KimiWireProtocol = (typeof KIMI_WIRE_PROTOCOLS)[number];
14
+ export type KimiApiProtocol = "openai-completions" | "anthropic-messages" | "openai-responses";
15
+ export type KimiApiType =
16
+ | "kimi-openai-completions"
17
+ | "kimi-anthropic-messages"
18
+ | "kimi-openai-responses";
19
+
20
+ // KIMI_CODE_PROTOCOL supports openai (default), anthropic, and responses.
21
+ // Unknown values fall back to openai, matching the previous two-protocol parser.
22
+ export function parseKimiWireProtocol(value: string | undefined): KimiWireProtocol {
23
+ if (value === "anthropic" || value === "responses") return value;
24
+ return "openai";
25
+ }
13
26
 
14
- // KIMI_CODE_PROTOCOL supports two values: "openai" (default) and "anthropic".
15
- export const ENV_KIMI_CODE_PROTOCOL: KimiWireProtocol =
16
- process.env.KIMI_CODE_PROTOCOL === "anthropic" ? "anthropic" : "openai";
27
+ export const ENV_KIMI_CODE_PROTOCOL: KimiWireProtocol = parseKimiWireProtocol(
28
+ process.env.KIMI_CODE_PROTOCOL,
29
+ );
17
30
 
18
31
  export const IS_OPENAI_PROTOCOL = ENV_KIMI_CODE_PROTOCOL === "openai";
19
32
 
20
- export function getApiProtocol(
21
- protocol: KimiWireProtocol,
22
- ): "openai-completions" | "anthropic-messages" {
23
- return protocol === "openai" ? "openai-completions" : "anthropic-messages";
33
+ export function getApiProtocol(protocol: KimiWireProtocol): KimiApiProtocol {
34
+ if (protocol === "anthropic") return "anthropic-messages";
35
+ if (protocol === "responses") return "openai-responses";
36
+ return "openai-completions";
24
37
  }
25
38
 
26
39
  export const PROTOCOL = getApiProtocol(ENV_KIMI_CODE_PROTOCOL);
27
40
 
28
- export function getKimiApiType(
29
- protocol: KimiWireProtocol,
30
- ): "kimi-openai-completions" | "kimi-anthropic-messages" {
31
- return protocol === "openai" ? "kimi-openai-completions" : "kimi-anthropic-messages";
41
+ export function getKimiApiType(protocol: KimiWireProtocol): KimiApiType {
42
+ if (protocol === "anthropic") return "kimi-anthropic-messages";
43
+ if (protocol === "responses") return "kimi-openai-responses";
44
+ return "kimi-openai-completions";
32
45
  }
33
46
 
34
47
  // Use a custom api identifier so this provider never conflicts with the
35
- // built-in "anthropic-messages" or "openai-completions" stream handlers.
48
+ // built-in anthropic-messages / openai-completions / openai-responses handlers.
36
49
  export const KIMI_API_TYPE = getKimiApiType(ENV_KIMI_CODE_PROTOCOL);
37
50
 
38
51
  export function getDefaultBaseUrl(protocol: KimiWireProtocol): string {
39
- return protocol === "openai" ? "https://api.kimi.com/coding/v1" : "https://api.kimi.com/coding";
52
+ return protocol === "anthropic"
53
+ ? "https://api.kimi.com/coding"
54
+ : "https://api.kimi.com/coding/v1";
40
55
  }
41
56
 
42
57
  export const DEFAULT_BASE_URL = getDefaultBaseUrl(ENV_KIMI_CODE_PROTOCOL);
43
58
 
44
- export const PROVIDER_VERSION = "0.6.11";
59
+ export const PROVIDER_VERSION = "0.6.12";
45
60
 
46
61
  export const KIMI_CODE_USER_AGENT = `pi-provider-kimi-code/${PROVIDER_VERSION}`;
47
62
  export const KIMI_PLATFORM = "pi";
package/src/models.ts CHANGED
@@ -186,6 +186,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
186
186
  return typeof value === "object" && value !== null && !Array.isArray(value);
187
187
  }
188
188
 
189
+ // Server /v1/models uses "response"; kimi-code maps that to openai_responses.
190
+ // Accept the server value plus the user-facing and internal aliases.
191
+ function parseKimiCatalogProtocol(value: unknown): KimiWireProtocol | undefined {
192
+ if (value === "openai" || value === "anthropic" || value === "responses") return value;
193
+ if (value === "response" || value === "openai_responses") return "responses";
194
+ return undefined;
195
+ }
196
+
189
197
  function parseThinkEfforts(value: unknown): {
190
198
  supportEfforts?: string[];
191
199
  defaultEffort?: string;
@@ -223,9 +231,8 @@ function parseKimiModelMetadata(model: KimiServerModel): KimiModelMetadata | und
223
231
  if (typeof model.supports_video_in === "boolean") {
224
232
  metadata.supportsVideoIn = model.supports_video_in;
225
233
  }
226
- if (model.protocol === "openai" || model.protocol === "anthropic") {
227
- metadata.protocol = model.protocol;
228
- }
234
+ const protocol = parseKimiCatalogProtocol(model.protocol);
235
+ if (protocol) metadata.protocol = protocol;
229
236
  Object.assign(metadata, parseThinkEfforts(model.think_efforts));
230
237
  return metadata;
231
238
  }
package/src/payload.ts CHANGED
@@ -31,7 +31,7 @@ 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;
@@ -410,10 +410,12 @@ function recurseJsonSchemaPropertyTypes(node: unknown): void {
410
410
  function normalizeOpenAIToolSchemas(payload: JsonRecord): void {
411
411
  if (!Array.isArray(payload.tools)) return;
412
412
  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);
413
+ if (!isRecord(tool)) continue;
414
+ // Chat Completions nests the schema under tool.function.parameters;
415
+ // Responses function tools carry parameters at the top level.
416
+ const holder: JsonRecord = isRecord(tool.function) ? tool.function : tool;
417
+ if (!isRecord(holder.parameters)) continue;
418
+ recurseJsonSchemaPropertyTypes(holder.parameters);
417
419
  }
418
420
  }
419
421
 
@@ -468,6 +470,72 @@ async function transformAnthropicPayloadFiles(
468
470
  // steps below testable with fixture payloads.
469
471
  // =============================================================================
470
472
 
473
+ const RESPONSES_EFFORTS = new Set(["low", "high", "max"]);
474
+
475
+ function responsesSupportedEfforts(ctx: KimiPayloadContext): string[] {
476
+ const advertised = ctx.modelConfig.supportEfforts;
477
+ if (advertised?.length) return advertised.filter((effort) => RESPONSES_EFFORTS.has(effort));
478
+ return [...RESPONSES_EFFORTS];
479
+ }
480
+
481
+ function setResponsesReasoning(payload: JsonRecord, effort: string | null): void {
482
+ const oldReasoning = isRecord(payload.reasoning) ? payload.reasoning : {};
483
+ if (!effort) {
484
+ delete payload.reasoning;
485
+ return;
486
+ }
487
+ const reasoning: JsonRecord = { ...oldReasoning, effort };
488
+ if (reasoning.summary === undefined) reasoning.summary = "auto";
489
+ payload.reasoning = reasoning;
490
+ }
491
+
492
+ function normalizeResponsesUpstreamEffort(payload: JsonRecord, ctx: KimiPayloadContext): void {
493
+ const supported = responsesSupportedEfforts(ctx);
494
+ const oldEffort = isRecord(payload.reasoning) ? payload.reasoning.effort : undefined;
495
+ if (typeof oldEffort === "string" && supported.includes(oldEffort)) return;
496
+ if (typeof oldEffort === "string") {
497
+ const mapped = resolveReasoningForLevel(oldEffort, ctx.modelConfig);
498
+ if (mapped?.enabled && mapped.effort && supported.includes(mapped.effort)) {
499
+ setResponsesReasoning(payload, mapped.effort);
500
+ return;
501
+ }
502
+ }
503
+ const fallback = ctx.modelConfig.defaultEffort;
504
+ setResponsesReasoning(payload, fallback && supported.includes(fallback) ? fallback : null);
505
+ }
506
+
507
+ function applyResponsesThinking(payload: JsonRecord, ctx: KimiPayloadContext): void {
508
+ delete payload.thinking;
509
+ delete payload.reasoning_effort;
510
+ delete payload.output_config;
511
+ if (ctx.modelConfig.supportsThinkingType === "no") {
512
+ delete payload.reasoning;
513
+ return;
514
+ }
515
+ const resolvedReasoning = resolveThinkingLevel(ctx);
516
+ if (!resolvedReasoning) {
517
+ normalizeResponsesUpstreamEffort(payload, ctx);
518
+ return;
519
+ }
520
+ const mapped = resolveReasoningForLevel(resolvedReasoning, ctx.modelConfig);
521
+ if (!mapped) {
522
+ normalizeResponsesUpstreamEffort(payload, ctx);
523
+ return;
524
+ }
525
+ if (!mapped.enabled) {
526
+ delete payload.reasoning;
527
+ return;
528
+ }
529
+ const effort = ctx.reasoning ? mapped.effort : (ctx.modelConfig.defaultEffort ?? mapped.effort);
530
+ const supported = responsesSupportedEfforts(ctx);
531
+ const oldReasoning = isRecord(payload.reasoning) ? payload.reasoning : {};
532
+ const reasoning: JsonRecord = { ...oldReasoning };
533
+ delete reasoning.effort;
534
+ if (effort !== null && supported.includes(effort)) reasoning.effort = effort;
535
+ if (reasoning.summary === undefined) reasoning.summary = "auto";
536
+ payload.reasoning = reasoning;
537
+ }
538
+
471
539
  export async function applyKimiPayloadMutations(
472
540
  payload: JsonRecord,
473
541
  ctx: KimiPayloadContext,
@@ -491,6 +559,8 @@ export async function applyKimiPayloadMutations(
491
559
  }
492
560
  if (ctx.api === "openai-completions") {
493
561
  normalizeOpenAIAssistantToolCalls(payload);
562
+ }
563
+ if (ctx.api === "openai-completions" || ctx.api === "openai-responses") {
494
564
  normalizeOpenAIToolSchemas(payload);
495
565
  }
496
566
  if (Array.isArray(payload.tools)) {
@@ -530,13 +600,23 @@ export async function applyKimiPayloadMutations(
530
600
  }
531
601
  }
532
602
 
533
- // 6. Normalize deprecated max_tokens (OpenAI path only — Anthropic
534
- // /messages uses max_tokens natively).
603
+ // 6. Normalize output-token field names per protocol.
535
604
  if (ctx.api === "openai-completions") {
536
605
  if (payload.max_completion_tokens === undefined && typeof payload.max_tokens === "number") {
537
606
  payload.max_completion_tokens = payload.max_tokens;
538
607
  }
539
608
  delete payload.max_tokens;
609
+ } else if (ctx.api === "openai-responses") {
610
+ if (payload.max_output_tokens === undefined) {
611
+ if (typeof payload.max_completion_tokens === "number") {
612
+ payload.max_output_tokens = payload.max_completion_tokens;
613
+ } else if (typeof payload.max_tokens === "number") {
614
+ payload.max_output_tokens = payload.max_tokens;
615
+ }
616
+ }
617
+ delete payload.max_tokens;
618
+ delete payload.max_completion_tokens;
619
+ delete payload.prompt_cache_retention;
540
620
  }
541
621
 
542
622
  const generation = ctx.modelConfig.generation;
@@ -552,7 +632,12 @@ export async function applyKimiPayloadMutations(
552
632
  // Output cap: an explicit cap (KIMI_MODEL_MAX_COMPLETION_TOKENS / config →
553
633
  // generation.maxCompletionTokens) is honored as a hardCap on the request value.
554
634
  if (generation.maxCompletionTokens !== undefined) {
555
- const maxTokensKey = ctx.api === "anthropic-messages" ? "max_tokens" : "max_completion_tokens";
635
+ const maxTokensKey =
636
+ ctx.api === "anthropic-messages"
637
+ ? "max_tokens"
638
+ : ctx.api === "openai-responses"
639
+ ? "max_output_tokens"
640
+ : "max_completion_tokens";
556
641
  const currentMaxTokens = payload[maxTokensKey];
557
642
  payload[maxTokensKey] =
558
643
  typeof currentMaxTokens === "number"
@@ -560,87 +645,98 @@ export async function applyKimiPayloadMutations(
560
645
  : generation.maxCompletionTokens;
561
646
  }
562
647
 
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;
648
+ // 7. Reasoning effort mapping.
649
+ // Completions/Anthropic: effort lives in thinking / output_config.
650
+ // Responses: pi-ai's native transport uses reasoning.effort; drop the
651
+ // Completions thinking object and clamp effort to the catalog.
652
+ if (ctx.api === "openai-responses") {
653
+ applyResponsesThinking(payload, ctx);
654
+ } else {
655
+ delete payload.reasoning_effort;
656
+ if (ctx.modelConfig.supportsThinkingType === "no") {
657
+ delete payload.thinking;
658
+ }
659
+ const resolvedReasoning = resolveThinkingLevel(ctx);
660
+ if (resolvedReasoning) {
661
+ const mapped = resolveReasoningForLevel(resolvedReasoning, ctx.modelConfig);
662
+ if (mapped) {
663
+ const oldThinking = isRecord(payload.thinking) ? payload.thinking : {};
664
+ const effort = ctx.reasoning
665
+ ? mapped.effort
666
+ : (ctx.modelConfig.defaultEffort ?? mapped.effort);
667
+ const effortSupported =
668
+ effort !== null && ctx.modelConfig.supportEfforts?.includes(effort) === true;
669
+ if (ctx.api === "anthropic-messages" && oldThinking.type === "adaptive") {
670
+ // pi-ai >=0.82 builds adaptive thinking for models carrying
671
+ // compat.forceAdaptiveThinking (streamSimpleKimi sets it on the
672
+ // anthropic runtime model). Keep the adaptive shape — effort lives in
673
+ // top-level output_config there, not inside thinking — and only
674
+ // replace it with an explicit disable when the level maps to off.
675
+ if (mapped.enabled) {
676
+ if (effortSupported) payload.output_config = { effort };
677
+ else delete payload.output_config;
678
+ } else {
679
+ payload.thinking = { type: "disabled" };
680
+ delete payload.output_config;
681
+ }
588
682
  } else {
589
- payload.thinking = { type: "disabled" };
590
- delete payload.output_config;
591
- }
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;
683
+ const thinking: JsonRecord = {
684
+ ...oldThinking,
685
+ type: mapped.enabled ? "enabled" : "disabled",
686
+ };
687
+ delete thinking.effort;
688
+ if (!mapped.enabled) delete thinking.keep;
689
+ if (mapped.enabled && effortSupported) {
690
+ thinking.effort = effort;
691
+ }
692
+ if (mapped.enabled && ctx.modelConfig.thinkingKeep) {
693
+ thinking.keep = ctx.modelConfig.thinkingKeep;
694
+ }
695
+ payload.thinking = thinking;
604
696
  }
605
- payload.thinking = thinking;
606
697
  }
607
698
  }
608
- }
609
699
 
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 = "";
700
+ // Preserved-thinking Kimi endpoints require every replayed assistant turn to
701
+ // carry reasoning_content, including turns whose reasoning delta was empty.
702
+ // pi-ai drops empty thinking blocks while building Chat Completions history,
703
+ // so restore the explicit empty field after the final thinking mode is known.
704
+ if (
705
+ ctx.api === "openai-completions" &&
706
+ isRecord(payload.thinking) &&
707
+ payload.thinking.type !== "disabled" &&
708
+ payload.thinking.keep === "all" &&
709
+ Array.isArray(payload.messages)
710
+ ) {
711
+ for (const message of payload.messages) {
712
+ if (
713
+ isRecord(message) &&
714
+ message.role === "assistant" &&
715
+ message.reasoning_content === undefined
716
+ ) {
717
+ message.reasoning_content = "";
718
+ }
628
719
  }
629
720
  }
630
721
  }
631
722
 
632
723
  // 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.
724
+ // function-specific when thinking is enabled (always-on). Responses only
725
+ // accepts auto. temperature/top_p are handled in step 6 — omitted unless
726
+ // explicitly configured, matching the official kimi-code client rather
727
+ // than pinned to 1.0/0.95.
636
728
  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";
729
+ if (ctx.api === "openai-responses") {
730
+ payload.tool_choice = "auto";
731
+ } else {
732
+ const tc = payload.tool_choice;
733
+ const isAllowed =
734
+ tc === "auto" ||
735
+ tc === "none" ||
736
+ (isRecord(tc) && (tc.type === "auto" || tc.type === "none"));
737
+ if (!isAllowed) {
738
+ payload.tool_choice = isRecord(tc) ? { type: "auto" } : "auto";
739
+ }
644
740
  }
645
741
  }
646
742
  }
@@ -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;
package/src/stream.ts CHANGED
@@ -32,8 +32,10 @@ type KimiStreamSimple = (
32
32
  const piAiRuntime = piAi as unknown as {
33
33
  anthropicMessagesApi?: () => { streamSimple: KimiStreamSimple };
34
34
  openAICompletionsApi?: () => { streamSimple: KimiStreamSimple };
35
+ openAIResponsesApi?: () => { streamSimple: KimiStreamSimple };
35
36
  streamSimpleAnthropic?: KimiStreamSimple;
36
37
  streamSimpleOpenAICompletions?: KimiStreamSimple;
38
+ streamSimpleOpenAIResponses?: KimiStreamSimple;
37
39
  lazyApi?: (load: () => Promise<object>) => { streamSimple: KimiStreamSimple };
38
40
  };
39
41
 
@@ -43,22 +45,43 @@ const piAiRuntime = piAi as unknown as {
43
45
  // evaluated in plain-Node environments where the subpaths do resolve.
44
46
  const anthropicMessagesModule = "@earendil-works/pi-ai/api/anthropic-messages";
45
47
  const openAICompletionsModule = "@earendil-works/pi-ai/api/openai-completions";
48
+ const openAIResponsesModule = "@earendil-works/pi-ai/api/openai-responses";
46
49
 
47
- const streamSimpleAnthropic: KimiStreamSimple =
48
- piAiRuntime.anthropicMessagesApi?.().streamSimple ??
49
- piAiRuntime.streamSimpleAnthropic ??
50
- piAiRuntime.lazyApi?.(() => import(anthropicMessagesModule)).streamSimple!;
51
- const streamSimpleOpenAICompletions: KimiStreamSimple =
52
- piAiRuntime.openAICompletionsApi?.().streamSimple ??
53
- piAiRuntime.streamSimpleOpenAICompletions ??
54
- piAiRuntime.lazyApi?.(() => import(openAICompletionsModule)).streamSimple!;
50
+ // lazyApi is the last resort; it only exists on pi-ai versions that ship the
51
+ // ./api/* subpath modules. Undefined when none of the three lookup paths
52
+ // resolve (e.g. pi <=0.79 has no responses entry point at all).
53
+ function lazyStreamSimple(load: () => Promise<object>): KimiStreamSimple | undefined {
54
+ return piAiRuntime.lazyApi?.(load)?.streamSimple;
55
+ }
56
+
57
+ // Not readonly: tests swap entries to simulate runtimes missing an API.
58
+ const streamSimpleImpls: Record<KimiWireProtocol, KimiStreamSimple | undefined> = {
59
+ openai:
60
+ piAiRuntime.openAICompletionsApi?.().streamSimple ??
61
+ piAiRuntime.streamSimpleOpenAICompletions ??
62
+ lazyStreamSimple(() => import(openAICompletionsModule)),
63
+ anthropic:
64
+ piAiRuntime.anthropicMessagesApi?.().streamSimple ??
65
+ piAiRuntime.streamSimpleAnthropic ??
66
+ lazyStreamSimple(() => import(anthropicMessagesModule)),
67
+ responses:
68
+ piAiRuntime.openAIResponsesApi?.().streamSimple ??
69
+ piAiRuntime.streamSimpleOpenAIResponses ??
70
+ lazyStreamSimple(() => import(openAIResponsesModule)),
71
+ };
55
72
  import {
56
73
  DEFAULT_KIMI_CODE_CONFIG,
57
74
  type KimiCodeConfig,
58
75
  type KimiResolvedModelConfig,
59
76
  } from "./config.ts";
60
77
 
61
- import { ENV_KIMI_CODE_PROTOCOL, PROVIDER_ID, getApiProtocol, getBaseUrl } from "./constants.ts";
78
+ import {
79
+ ENV_KIMI_CODE_PROTOCOL,
80
+ PROVIDER_ID,
81
+ type KimiWireProtocol,
82
+ getApiProtocol,
83
+ getBaseUrl,
84
+ } from "./constants.ts";
62
85
  import { getKimiProviderHeaders } from "./device.ts";
63
86
  import {
64
87
  isKimiAuthErrorMessage,
@@ -224,6 +247,40 @@ export function mergeKimiRequestHeaders(headers?: HeaderMap): HeaderMap {
224
247
  return { ...getKimiProviderHeaders(), ...headers };
225
248
  }
226
249
 
250
+ function streamSimpleForProtocol(
251
+ protocol: KimiWireProtocol,
252
+ model: Model<Api>,
253
+ context: Context,
254
+ options: SimpleStreamOptions,
255
+ ): AssistantMessageEventStream {
256
+ const impl = streamSimpleImpls[protocol];
257
+ // Resolution runs once at module load against whichever pi-ai shape is
258
+ // installed, so an old pi-ai can leave the entry point undefined. Throw a
259
+ // descriptive error here — the caller invokes this inside the retry loop's
260
+ // try, so it surfaces as a stream error event instead of an unhandled
261
+ // rejection from a bare "not a function" TypeError.
262
+ if (!impl) {
263
+ throw new Error(
264
+ `installed pi-ai has no streamSimple entry point for ${getApiProtocol(protocol)}; ` +
265
+ "upgrade pi-ai or choose another KIMI_CODE_PROTOCOL",
266
+ );
267
+ }
268
+ return impl(model, context, options);
269
+ }
270
+
271
+ // Test hook: swap a resolved stream factory (pass undefined to simulate a
272
+ // pi-ai runtime that lacks the entry point). Returns a restore function.
273
+ export function overrideStreamSimpleForTests(
274
+ protocol: KimiWireProtocol,
275
+ impl: KimiStreamSimple | undefined,
276
+ ): () => void {
277
+ const original = streamSimpleImpls[protocol];
278
+ streamSimpleImpls[protocol] = impl;
279
+ return () => {
280
+ streamSimpleImpls[protocol] = original;
281
+ };
282
+ }
283
+
227
284
  export function streamSimpleKimi(
228
285
  model: Model<Api>,
229
286
  context: Context,
@@ -332,8 +389,9 @@ export function streamSimpleKimi(
332
389
  const patchedOptions = buildPatchedOptions(currentKey);
333
390
  // Route by the module-level protocol flag, not model.api, since we
334
391
  // register with a custom api type (kimi-openai-completions /
335
- // kimi-anthropic-messages) to avoid overriding the built-in
336
- // Anthropic/OpenAI stream handlers.
392
+ // kimi-anthropic-messages / kimi-openai-responses) to avoid overriding
393
+ // the built-in Anthropic/OpenAI stream handlers.
394
+ const existingCompat = (model as { compat?: Record<string, unknown> }).compat;
337
395
  const runtimeModel = {
338
396
  ...model,
339
397
  api: apiProtocol,
@@ -342,33 +400,38 @@ export function streamSimpleKimi(
342
400
  // rejects/ignores the interleaved-thinking beta header; pi-ai >=0.82
343
401
  // keys both behaviors off these compat flags (its own built-in
344
402
  // kimi-coding models carry them in generated metadata).
403
+ // Responses rejects prompt_cache_retention (400) unless this is off.
345
404
  ...(wireProtocol === "anthropic"
346
405
  ? {
347
406
  compat: {
348
- ...(model as { compat?: Record<string, unknown> }).compat,
407
+ ...existingCompat,
349
408
  forceAdaptiveThinking: true,
350
409
  allowEmptySignature: true,
351
410
  },
352
411
  }
353
- : {}),
412
+ : wireProtocol === "responses"
413
+ ? {
414
+ compat: {
415
+ ...existingCompat,
416
+ supportsLongCacheRetention: false,
417
+ },
418
+ }
419
+ : {}),
354
420
  } as Model<Api>;
355
- const upstream =
356
- wireProtocol === "openai"
357
- ? streamSimpleOpenAICompletions(
358
- runtimeModel as Model<"openai-completions">,
359
- context,
360
- patchedOptions,
361
- )
362
- : streamSimpleAnthropic(
363
- runtimeModel as Model<"anthropic-messages">,
364
- context,
365
- patchedOptions,
366
- );
367
421
 
368
422
  let shouldRetry = false;
369
423
  let prefixBuffer: AssistantMessageEvent[] = [];
370
424
 
371
425
  try {
426
+ // Inside the try so a missing pi-ai stream entry point (old pi-ai +
427
+ // a protocol it does not ship) surfaces through the catch's error
428
+ // event instead of escaping the async IIFE as an unhandled rejection.
429
+ const upstream = streamSimpleForProtocol(
430
+ wireProtocol,
431
+ runtimeModel,
432
+ context,
433
+ patchedOptions,
434
+ );
372
435
  for await (const event of filterEmptyResponseStream(upstream)) {
373
436
  // streamAnthropic emits a synthetic "start" event synchronously,
374
437
  // before the for-await loop begins iterating and therefore before