pi-provider-kimi-code 0.6.12 → 0.6.14

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
@@ -37,6 +37,8 @@ export interface KimiPayloadContext {
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;
@@ -472,6 +623,75 @@ async function transformAnthropicPayloadFiles(
472
623
 
473
624
  const RESPONSES_EFFORTS = new Set(["low", "high", "max"]);
474
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
+
475
695
  function responsesSupportedEfforts(ctx: KimiPayloadContext): string[] {
476
696
  const advertised = ctx.modelConfig.supportEfforts;
477
697
  if (advertised?.length) return advertised.filter((effort) => RESPONSES_EFFORTS.has(effort));
@@ -547,6 +767,19 @@ export async function applyKimiPayloadMutations(
547
767
  );
548
768
  }
549
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
+
550
783
  // 2. File upload dispatch (protocol-specific).
551
784
  if (ctx.upload) {
552
785
  const uploadCache = ctx.uploadCacheScope ? uploadedFileCache : new Map<string, string>();
@@ -557,6 +790,11 @@ export async function applyKimiPayloadMutations(
557
790
  await transformAnthropicPayloadFiles(payload, ctx.upload, uploadCache, uploadCacheScope);
558
791
  }
559
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
+ }
560
798
  if (ctx.api === "openai-completions") {
561
799
  normalizeOpenAIAssistantToolCalls(payload);
562
800
  }
@@ -590,8 +828,12 @@ export async function applyKimiPayloadMutations(
590
828
 
591
829
  // 5. Spread extra_body into the top-level payload before normalization and
592
830
  // config caps. Top-level fields retain precedence over extra_body.
831
+ let extraBodyHadCap = false;
593
832
  if (isRecord(payload.extra_body)) {
594
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
+ );
595
837
  delete payload.extra_body;
596
838
  for (const [key, value] of Object.entries(extraBody)) {
597
839
  if (payload[key] === undefined) {
@@ -620,6 +862,29 @@ export async function applyKimiPayloadMutations(
620
862
  }
621
863
 
622
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
+ }
623
888
  // Official kimi-code sends temperature/top_p only when explicitly configured
624
889
  // (env KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P → generation.*); otherwise it
625
890
  // omits them and lets the server apply its own defaults. Mirror that exactly:
@@ -720,7 +985,7 @@ export async function applyKimiPayloadMutations(
720
985
  }
721
986
  }
722
987
 
723
- // 8. K2.7 Code API constraints: the server rejects tool_choice "required" /
988
+ // 8. Kimi coding endpoint constraints: the server rejects tool_choice "required" /
724
989
  // function-specific when thinking is enabled (always-on). Responses only
725
990
  // accepts auto. temperature/top_p are handled in step 6 — omitted unless
726
991
  // explicitly configured, matching the official kimi-code client rather
@@ -1,5 +1,13 @@
1
1
  // Tool schema deduplication: extract repeated sub-schemas into $defs/$ref
2
2
  // to stay under Moonshot's ~15 KB per-tool function.parameters limit.
3
+ //
4
+ // Known risk (upstream kimi-code #4489-era): the official client does the
5
+ // OPPOSITE for its Anthropic-protocol path — it fully dereferences $ref
6
+ // (normalizeKimiToolSchema) because Anthropic-style serving stacks do not
7
+ // resolve $defs. No managed Kimi model declares `protocol: "anthropic"` in
8
+ // /models yet (verified 2026-11), so dedup is safe on the OpenAI-compatible
9
+ // path this extension uses today. If a managed model ever ships with the
10
+ // anthropic protocol, gate this dedup off for that protocol.
3
11
 
4
12
  import { createHash } from "node:crypto";
5
13
 
@@ -58,10 +58,24 @@ export function setProtocol(
58
58
  return { ...config, protocol };
59
59
  }
60
60
 
61
+ export function setRegion(
62
+ config: KimiCodeConfig,
63
+ region: KimiCodeConfig["region"],
64
+ ): KimiCodeConfig {
65
+ return { ...config, region };
66
+ }
67
+
61
68
  export function setUploadThreshold(config: KimiCodeConfig, thresholdBytes: number): KimiCodeConfig {
62
69
  return { ...config, uploads: { ...config.uploads, thresholdBytes } };
63
70
  }
64
71
 
72
+ export function setThinkingKeep(
73
+ config: KimiCodeConfig,
74
+ thinkingKeep: KimiCodeConfig["model"]["thinkingKeep"],
75
+ ): KimiCodeConfig {
76
+ return { ...config, model: { ...config.model, thinkingKeep } };
77
+ }
78
+
65
79
  export function buildKimiMainTitle(
66
80
  config: KimiCodeConfig,
67
81
  extras: KimiOAuthExtras,
@@ -73,7 +87,9 @@ export function buildKimiMainTitle(
73
87
  "",
74
88
  `Model: ${modelName}`,
75
89
  `Protocol: ${config.protocol} (${sources.protocol})`,
90
+ `Region: ${formatRegionValue(config.region)} (${sources.region})`,
76
91
  `Upload threshold: ${formatByteSize(config.uploads.thresholdBytes)} (${sources.uploads.thresholdBytes})`,
92
+ `Thinking keep: ${config.model.thinkingKeep ?? "default"} (${sources.model.thinkingKeep})`,
77
93
  "",
78
94
  "Effective tools:",
79
95
  ...KIMI_TOOL_NAMES.map((toolName) => {
@@ -138,6 +154,18 @@ export function toolMenuItem(config: KimiCodeConfig, toolName: KimiToolName): st
138
154
  return `${toolName} -> ${formatToolStatus(config, toolName)}`;
139
155
  }
140
156
 
157
+ export function formatRegionValue(region: KimiCodeConfig["region"]): string {
158
+ return region ?? "auto";
159
+ }
160
+
161
+ export function formatRegionStatus(config: KimiCodeConfig): string {
162
+ return `region: ${formatRegionValue(config.region)}`;
163
+ }
164
+
165
+ export function regionMenuItem(config: KimiCodeConfig): string {
166
+ return `Region -> ${formatRegionValue(config.region)}`;
167
+ }
168
+
141
169
  export function protocolMenuItem(config: KimiCodeConfig): string {
142
170
  return `Protocol -> ${config.protocol}`;
143
171
  }
package/src/stream.ts CHANGED
@@ -83,6 +83,16 @@ import {
83
83
  getBaseUrl,
84
84
  } from "./constants.ts";
85
85
  import { getKimiProviderHeaders } from "./device.ts";
86
+ import { improveKimiErrorMessage } from "./errors.ts";
87
+
88
+ function improveErrorEvent(event: AssistantMessageEvent): AssistantMessageEvent {
89
+ if (event.type !== "error") return event;
90
+ const message = event.error?.errorMessage;
91
+ if (!message) return event;
92
+ const improved = improveKimiErrorMessage(message);
93
+ if (improved === message || !event.error) return event;
94
+ return { ...event, error: { ...event.error, errorMessage: improved } };
95
+ }
86
96
  import {
87
97
  isKimiAuthErrorMessage,
88
98
  readStoredOAuthCredential,
@@ -360,6 +370,7 @@ export function streamSimpleKimi(
360
370
  cacheKey,
361
371
  cacheRetention,
362
372
  reasoning: options?.reasoning,
373
+ requestMaxTokens: options?.maxTokens,
363
374
  modelConfig,
364
375
  });
365
376
  if (
@@ -470,10 +481,11 @@ export function streamSimpleKimi(
470
481
  }
471
482
 
472
483
  // First non-start, non-retry event: flush buffered prefix, then
473
- // stream normally.
484
+ // stream normally. Quota-exhaustion errors get an actionable
485
+ // rewrite (hard limit, not a transient 429).
474
486
  for (const e of prefixBuffer) filtered.push(e);
475
487
  prefixBuffer = [];
476
- filtered.push(event);
488
+ filtered.push(improveErrorEvent(event));
477
489
  }
478
490
 
479
491
  // Stream ended normally: flush any remaining buffered starts.
@@ -508,7 +520,9 @@ export function streamSimpleKimi(
508
520
  model: model.id,
509
521
  content: [],
510
522
  stopReason: "error",
511
- errorMessage: err instanceof Error ? err.message : String(err),
523
+ errorMessage: improveKimiErrorMessage(
524
+ err instanceof Error ? err.message : String(err),
525
+ ),
512
526
  usage: {
513
527
  input: 0,
514
528
  output: 0,
@@ -1,7 +1,7 @@
1
1
  import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
 
4
- import { PROVIDER_ID, getBaseUrl } from "../constants.ts";
4
+ import { PROVIDER_ID, getBaseUrl, normalizeKimiBaseV1 } from "../constants.ts";
5
5
  import { getKimiProviderHeaders } from "../device.ts";
6
6
  import {
7
7
  KIMI_LOGIN_REQUIRED_MESSAGE,
@@ -57,8 +57,7 @@ export function buildTimeoutSignal(signal: AbortSignal | undefined): {
57
57
  }
58
58
 
59
59
  export function getKimiBaseV1(): string {
60
- const base = getBaseUrl().replace(/\/+$/, "");
61
- return base.endsWith("/v1") ? base : `${base}/v1`;
60
+ return normalizeKimiBaseV1(getBaseUrl());
62
61
  }
63
62
 
64
63
  export function getKimiDatasourceUrl(): string {