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/README.md +26 -8
- package/index.ts +31 -3
- package/package.json +9 -8
- package/src/config.ts +40 -4
- package/src/constants.ts +112 -20
- package/src/models.ts +25 -4
- package/src/oauth.ts +42 -1
- package/src/payload.ts +440 -79
- package/src/schema-dedup.ts +9 -4
- package/src/settings-ui.ts +20 -0
- package/src/stream.ts +89 -25
- package/src/tools/common.ts +2 -3
- package/src/usage.ts +116 -7
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)
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
590
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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.
|
|
633
|
-
// function-specific when thinking is enabled (always-on).
|
|
634
|
-
// are handled in step 6 — omitted unless
|
|
635
|
-
// official kimi-code client rather
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
tc
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
}
|
package/src/schema-dedup.ts
CHANGED
|
@@ -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)
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
248
|
+
return fn
|
|
249
|
+
? { ...tool, function: { ...fn, parameters: optimized } }
|
|
250
|
+
: { ...tool, parameters: optimized };
|
|
246
251
|
});
|
|
247
252
|
|
|
248
253
|
cachedFingerprint = fp;
|