pi-provider-kimi-code 0.5.1 → 0.6.1

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/device.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // headers, and the cross-platform device-model string. Pure helpers live near
3
3
  // the side-effecting one-shots (file IO, exec) they support.
4
4
 
5
+ import { execFileSync } from "node:child_process";
5
6
  import { randomBytes } from "node:crypto";
6
7
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import os from "node:os";
@@ -96,11 +97,24 @@ export function computeDeviceModel(input: DeviceModelInput): string {
96
97
  return `${system} ${arch}`.trim();
97
98
  }
98
99
 
100
+ function macOsProductVersion(): string | undefined {
101
+ try {
102
+ const version = execFileSync("/usr/bin/sw_vers", ["-productVersion"], {
103
+ encoding: "utf-8",
104
+ timeout: 1000,
105
+ }).trim();
106
+ return version.length > 0 ? version : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
99
112
  function getDeviceModel(): string {
100
113
  return computeDeviceModel({
101
114
  platform: process.platform,
102
115
  release: os.release(),
103
116
  arch: os.machine() || process.arch,
117
+ macVersion: process.platform === "darwin" ? macOsProductVersion() : undefined,
104
118
  });
105
119
  }
106
120
 
package/src/models.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // Model identity: discovery against the server's /v1/models endpoint, plus the
2
- // extras-merging helpers used by both the OAuth modifyModels hook and the
3
- // `KIMI_MODEL_*` env-override path.
2
+ // extras-merging helpers used by both registration and the OAuth modifyModels hook.
4
3
 
5
4
  import type { Api, Model, OAuthCredentials } from "@earendil-works/pi-ai";
5
+ import type { KimiInputModality, KimiResolvedModelConfig, ModelConfig } from "./config.ts";
6
6
 
7
- import { getBaseUrl } from "./constants.ts";
7
+ import { type KimiWireProtocol, getBaseUrl } from "./constants.ts";
8
8
  import { getCommonHeaders } from "./device.ts";
9
9
 
10
10
  export interface KimiOAuthExtras {
@@ -13,16 +13,95 @@ export interface KimiOAuthExtras {
13
13
  contextLength?: number;
14
14
  supportsReasoning?: boolean;
15
15
  supportsImageIn?: boolean;
16
+ supportsVideoIn?: boolean;
17
+ supportsThinkingType?: "only" | "no" | "both";
16
18
  }
17
19
 
18
20
  export type KimiOAuthCredentials = OAuthCredentials & KimiOAuthExtras;
19
21
 
22
+ const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
23
+
24
+ export interface DiscoverKimiModelMetadataOptions {
25
+ timeoutMs?: number;
26
+ }
27
+
28
+ function mergeInputModalities(
29
+ input: readonly KimiInputModality[],
30
+ extras: Partial<Pick<KimiOAuthExtras, "supportsImageIn" | "supportsVideoIn">>,
31
+ ): KimiInputModality[] {
32
+ const next = new Set<KimiInputModality>(input);
33
+ next.add("text");
34
+ if (typeof extras.supportsImageIn === "boolean") {
35
+ if (extras.supportsImageIn) next.add("image");
36
+ else next.delete("image");
37
+ }
38
+ if (typeof extras.supportsVideoIn === "boolean") {
39
+ if (extras.supportsVideoIn) next.add("video");
40
+ else next.delete("video");
41
+ }
42
+ return (["text", "image", "video"] as const).filter((modality) => next.has(modality));
43
+ }
44
+
45
+ // Pricing per million tokens in USD (CNY converted at ~7.25).
46
+ // Source: https://platform.kimi.com/docs/pricing/chat-k27-code
47
+ const COST_STANDARD = { input: 0.897, output: 3.724, cacheRead: 0.179, cacheWrite: 0.897 };
48
+ const COST_HIGH_SPEED = { input: 1.793, output: 7.448, cacheRead: 0.359, cacheWrite: 1.793 };
49
+
50
+ function resolveModelCost(modelDisplay: string | undefined): {
51
+ input: number;
52
+ output: number;
53
+ cacheRead: number;
54
+ cacheWrite: number;
55
+ } {
56
+ if (modelDisplay && /high\s*speed/i.test(modelDisplay)) return COST_HIGH_SPEED;
57
+ return COST_STANDARD;
58
+ }
59
+
60
+ export function buildKimiModelFromConfig(config: ModelConfig): Model<Api> {
61
+ return {
62
+ id: "kimi-for-coding",
63
+ name: "Kimi for Coding",
64
+ reasoning: config.reasoning,
65
+ input: [...config.input] as unknown as ("text" | "image" | "video")[],
66
+ cost: { ...COST_STANDARD },
67
+ contextWindow: config.contextWindow,
68
+ maxTokens: config.maxTokens,
69
+ } as Model<Api>;
70
+ }
71
+
72
+ export function resolveKimiModelConfig(
73
+ config: ModelConfig,
74
+ extras: Partial<KimiOAuthExtras>,
75
+ ): KimiResolvedModelConfig {
76
+ const resolved: KimiResolvedModelConfig = { ...config, input: [...config.input] };
77
+ if (typeof extras.contextLength === "number" && extras.contextLength > 0) {
78
+ resolved.contextWindow = extras.contextLength;
79
+ }
80
+ if (typeof extras.supportsThinkingType === "string") {
81
+ resolved.supportsThinkingType = extras.supportsThinkingType;
82
+ resolved.reasoning = extras.supportsThinkingType !== "no";
83
+ } else if (typeof extras.supportsReasoning === "boolean") {
84
+ resolved.reasoning = extras.supportsReasoning;
85
+ }
86
+ if (typeof extras.supportsImageIn === "boolean" || typeof extras.supportsVideoIn === "boolean") {
87
+ resolved.input = mergeInputModalities(resolved.input, extras);
88
+ }
89
+ return resolved;
90
+ }
91
+
20
92
  interface KimiServerModel {
21
93
  id?: unknown;
22
94
  display_name?: unknown;
23
95
  context_length?: unknown;
24
96
  supports_reasoning?: unknown;
25
97
  supports_image_in?: unknown;
98
+ supports_video_in?: unknown;
99
+ supports_thinking_type?: unknown;
100
+ }
101
+
102
+ function parseSupportsThinkingType(value: unknown): "only" | "no" | "both" | undefined {
103
+ if (value === "only" || value === "no" || value === "both") return value;
104
+ return undefined;
26
105
  }
27
106
 
28
107
  export function buildModelsUrl(baseUrl: string): string {
@@ -30,14 +109,23 @@ export function buildModelsUrl(baseUrl: string): string {
30
109
  return base.endsWith("/v1") ? `${base}/models` : `${base}/v1/models`;
31
110
  }
32
111
 
33
- function getModelsUrl(): string {
34
- return buildModelsUrl(getBaseUrl());
112
+ function getModelsUrl(protocol?: KimiWireProtocol): string {
113
+ return buildModelsUrl(getBaseUrl(protocol));
35
114
  }
36
115
 
37
- export async function discoverKimiModelMetadata(accessToken: string): Promise<KimiOAuthExtras> {
116
+ export async function discoverKimiModelMetadata(
117
+ accessToken: string,
118
+ protocol?: KimiWireProtocol,
119
+ options: DiscoverKimiModelMetadataOptions = {},
120
+ ): Promise<KimiOAuthExtras> {
38
121
  if (!accessToken) return {};
122
+ const timeoutMs = options.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
123
+ const controller = new AbortController();
124
+ const timeout =
125
+ timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs).unref() : undefined;
39
126
  try {
40
- const response = await fetch(getModelsUrl(), {
127
+ const response = await fetch(getModelsUrl(protocol), {
128
+ signal: controller.signal,
41
129
  headers: {
42
130
  ...getCommonHeaders(),
43
131
  Authorization: `Bearer ${accessToken}`,
@@ -47,23 +135,30 @@ export async function discoverKimiModelMetadata(accessToken: string): Promise<Ki
47
135
  if (!response.ok) return {};
48
136
  const json = (await response.json()) as { data?: unknown };
49
137
  const list = Array.isArray(json.data) ? (json.data as KimiServerModel[]) : [];
50
- const preferred =
51
- list.find((m) => typeof m.id === "string" && m.id === "kimi-for-coding") ?? list[0];
138
+ const preferred = list.find((m) => typeof m.id === "string" && m.id === "kimi-for-coding");
52
139
  if (!preferred || typeof preferred.id !== "string") return {};
53
140
  const extras: KimiOAuthExtras = { wireModelId: preferred.id };
54
141
  if (typeof preferred.display_name === "string") extras.modelDisplay = preferred.display_name;
55
142
  if (typeof preferred.context_length === "number" && preferred.context_length > 0) {
56
143
  extras.contextLength = preferred.context_length;
57
144
  }
58
- if (typeof preferred.supports_reasoning === "boolean") {
145
+ const thinkingType = parseSupportsThinkingType(preferred.supports_thinking_type);
146
+ if (thinkingType) {
147
+ extras.supportsThinkingType = thinkingType;
148
+ } else if (typeof preferred.supports_reasoning === "boolean") {
59
149
  extras.supportsReasoning = preferred.supports_reasoning;
60
150
  }
61
151
  if (typeof preferred.supports_image_in === "boolean") {
62
152
  extras.supportsImageIn = preferred.supports_image_in;
63
153
  }
154
+ if (typeof preferred.supports_video_in === "boolean") {
155
+ extras.supportsVideoIn = preferred.supports_video_in;
156
+ }
64
157
  return extras;
65
158
  } catch {
66
159
  return {};
160
+ } finally {
161
+ if (timeout) clearTimeout(timeout);
67
162
  }
68
163
  }
69
164
 
@@ -71,9 +166,11 @@ export function applyKimiOAuthExtrasToModel(
71
166
  model: Model<Api>,
72
167
  extras: KimiOAuthExtras,
73
168
  ): Model<Api> {
74
- const next: Model<Api> & { wireModelId?: string } = { ...model };
169
+ const next: Model<Api> & { wireModelId?: string; supportsThinkingType?: "only" | "no" | "both" } =
170
+ { ...model };
75
171
  if (typeof extras.modelDisplay === "string" && extras.modelDisplay) {
76
172
  next.name = extras.modelDisplay;
173
+ next.cost = resolveModelCost(extras.modelDisplay);
77
174
  }
78
175
  if (typeof extras.contextLength === "number" && extras.contextLength > 0) {
79
176
  next.contextWindow = extras.contextLength;
@@ -81,49 +178,16 @@ export function applyKimiOAuthExtrasToModel(
81
178
  if (typeof extras.wireModelId === "string" && extras.wireModelId) {
82
179
  next.wireModelId = extras.wireModelId;
83
180
  }
84
- if (typeof extras.supportsReasoning === "boolean") {
181
+ if (typeof extras.supportsThinkingType === "string") {
182
+ next.reasoning = extras.supportsThinkingType !== "no";
183
+ next.supportsThinkingType = extras.supportsThinkingType;
184
+ } else if (typeof extras.supportsReasoning === "boolean") {
85
185
  next.reasoning = extras.supportsReasoning;
186
+ next.supportsThinkingType = undefined;
86
187
  }
87
- if (typeof extras.supportsImageIn === "boolean") {
88
- const input = ["text"];
89
- if (extras.supportsImageIn) input.push("image");
188
+ if (typeof extras.supportsImageIn === "boolean" || typeof extras.supportsVideoIn === "boolean") {
189
+ const input = mergeInputModalities(next.input as KimiInputModality[], extras);
90
190
  (next as unknown as { input: string[] }).input = input;
91
191
  }
92
192
  return next;
93
193
  }
94
-
95
- function parseKimiModelCapabilities(value: string | undefined): KimiOAuthExtras | null {
96
- if (!value) return null;
97
- const caps = new Set(
98
- value
99
- .split(",")
100
- .map((cap) => cap.trim().toLowerCase())
101
- .filter(Boolean),
102
- );
103
- return {
104
- supportsReasoning: caps.has("thinking") || caps.has("always_thinking"),
105
- supportsImageIn: caps.has("image_in"),
106
- };
107
- }
108
-
109
- export function applyKimiEnvOverridesToModel(model: Model<Api>): Model<Api> {
110
- const extras: KimiOAuthExtras = {};
111
- const modelName = process.env.KIMI_MODEL_NAME?.trim();
112
- if (modelName) {
113
- extras.wireModelId = modelName;
114
- extras.modelDisplay = modelName;
115
- }
116
-
117
- const maxContextSize = process.env.KIMI_MODEL_MAX_CONTEXT_SIZE?.trim();
118
- if (maxContextSize) {
119
- const parsed = parseInt(maxContextSize, 10);
120
- if (Number.isFinite(parsed) && parsed > 0) {
121
- extras.contextLength = parsed;
122
- }
123
- }
124
-
125
- const capabilities = parseKimiModelCapabilities(process.env.KIMI_MODEL_CAPABILITIES);
126
- if (capabilities) Object.assign(extras, capabilities);
127
-
128
- return applyKimiOAuthExtrasToModel(model, extras);
129
- }
package/src/payload.ts CHANGED
@@ -3,9 +3,11 @@
3
3
  // and the top-level applyKimiPayloadMutations that orchestrates them.
4
4
 
5
5
  import type { CacheRetention, ThinkingLevel } from "@earendil-works/pi-ai";
6
+ import type { KimiResolvedModelConfig, ModelReasoningEntry } from "./config.ts";
6
7
 
7
8
  import { getBaseUrl } from "./constants.ts";
8
9
  import { getCommonHeaders } from "./device.ts";
10
+ import { optimizeToolSchemas } from "./schema-dedup.ts";
9
11
 
10
12
  // =============================================================================
11
13
  // Shared types + small utilities
@@ -16,12 +18,6 @@ const DEFAULT_KIMI_INLINE_UPLOAD_THRESHOLD_BYTES = 1 * 1024 * 1024;
16
18
  export type JsonRecord = Record<string, unknown>;
17
19
  export type Uploader = (mimeType: string, data: string) => Promise<string | null>;
18
20
 
19
- export interface KimiEnvOverrides {
20
- temperature?: number;
21
- topP?: number;
22
- maxCompletionTokens?: number;
23
- }
24
-
25
21
  export function resolveCacheRetention(value?: CacheRetention): CacheRetention {
26
22
  if (value === "none" || value === "short" || value === "long") return value;
27
23
  const envRetention = process.env.PI_CACHE_RETENTION;
@@ -37,21 +33,28 @@ export interface KimiPayloadContext {
37
33
  cacheKey?: string;
38
34
  cacheRetention: CacheRetention;
39
35
  reasoning?: ThinkingLevel;
40
- thinkingKeep?: string;
41
- envOverrides: KimiEnvOverrides;
36
+ modelConfig: KimiResolvedModelConfig;
42
37
  }
43
38
 
44
39
  export function isRecord(value: unknown): value is JsonRecord {
45
40
  return typeof value === "object" && value !== null && !Array.isArray(value);
46
41
  }
47
42
 
48
- function mapThinkingLevel(level?: string): { effort: string | null; enabled: boolean } | undefined {
49
- if (!level) return undefined;
50
- if (level === "none" || level === "off") return { effort: null, enabled: false };
51
- if (level === "minimal" || level === "low") return { effort: "low", enabled: true };
52
- if (level === "medium") return { effort: "medium", enabled: true };
53
- if (level === "high" || level === "xhigh") return { effort: "high", enabled: true };
54
- return undefined;
43
+ export function resolveReasoningForLevel(
44
+ level: string,
45
+ config: KimiResolvedModelConfig,
46
+ ): ModelReasoningEntry | undefined {
47
+ return config.reasoningMap[level];
48
+ }
49
+
50
+ function resolveThinkingLevel(ctx: KimiPayloadContext): ThinkingLevel | undefined {
51
+ if (ctx.modelConfig.supportsThinkingType === "no") return undefined;
52
+ if (ctx.modelConfig.supportsThinkingType === "only") {
53
+ if (!ctx.reasoning) return "low";
54
+ const mapped = resolveReasoningForLevel(ctx.reasoning, ctx.modelConfig);
55
+ if (mapped && !mapped.enabled) return "low";
56
+ }
57
+ return ctx.reasoning;
55
58
  }
56
59
 
57
60
  function parseInlineUploadThreshold(raw: string | undefined): number {
@@ -81,17 +84,6 @@ function getUploadFilename(mimeType: string): string {
81
84
  return map[mimeType] ?? "upload.bin";
82
85
  }
83
86
 
84
- export function readEnvOverrides(): KimiEnvOverrides {
85
- const out: KimiEnvOverrides = {};
86
- const temp = process.env.KIMI_MODEL_TEMPERATURE;
87
- if (temp) out.temperature = parseFloat(temp);
88
- const topP = process.env.KIMI_MODEL_TOP_P;
89
- if (topP) out.topP = parseFloat(topP);
90
- const maxCompletionTokens = process.env.KIMI_MODEL_MAX_COMPLETION_TOKENS;
91
- if (maxCompletionTokens) out.maxCompletionTokens = parseInt(maxCompletionTokens, 10);
92
- return out;
93
- }
94
-
95
87
  // =============================================================================
96
88
  // File upload (I/O edge)
97
89
  // =============================================================================
@@ -100,10 +92,12 @@ export async function uploadKimiFile(
100
92
  apiKey: string,
101
93
  mimeType: string,
102
94
  data: string,
95
+ thresholdBytes?: number,
103
96
  ): Promise<string | null> {
104
97
  const buffer = Buffer.from(data, "base64");
105
98
  if (!mimeType.startsWith("image/")) return null;
106
- const threshold = parseInlineUploadThreshold(process.env.KIMI_CODE_UPLOAD_THRESHOLD_BYTES);
99
+ const threshold =
100
+ thresholdBytes ?? parseInlineUploadThreshold(process.env.KIMI_CODE_UPLOAD_THRESHOLD_BYTES);
107
101
  if (buffer.length <= threshold) return null;
108
102
 
109
103
  const filename = getUploadFilename(mimeType);
@@ -406,6 +400,9 @@ export async function applyKimiPayloadMutations(
406
400
  normalizeOpenAIAssistantToolCalls(payload);
407
401
  normalizeOpenAIToolSchemas(payload);
408
402
  }
403
+ if (Array.isArray(payload.tools)) {
404
+ payload.tools = optimizeToolSchemas(payload.tools);
405
+ }
409
406
 
410
407
  // 3. prompt_cache_key injection. Respect any key already on the payload,
411
408
  // otherwise fall back to the caller-provided cacheKey (sessionId or
@@ -420,35 +417,81 @@ export async function applyKimiPayloadMutations(
420
417
  if (resolved) payload.prompt_cache_key = resolved;
421
418
  }
422
419
 
423
- // 4. Normalize deprecated max_tokens and apply env-level hyperparameter
424
- // overrides (pre-parsed into numbers by caller).
425
- if (payload.max_completion_tokens === undefined && typeof payload.max_tokens === "number") {
426
- payload.max_completion_tokens = payload.max_tokens;
420
+ // 4. Request usage stats on streaming responses (OpenAI only —
421
+ // Anthropic /messages does not support stream_options).
422
+ if (ctx.api === "openai-completions" && payload.stream === true) {
423
+ payload.stream_options = isRecord(payload.stream_options)
424
+ ? { ...(payload.stream_options as JsonRecord), include_usage: true }
425
+ : { include_usage: true };
426
+ }
427
+
428
+ // 5. Normalize deprecated max_tokens (OpenAI path only — Anthropic
429
+ // /messages uses max_tokens natively).
430
+ if (ctx.api === "openai-completions") {
431
+ if (payload.max_completion_tokens === undefined && typeof payload.max_tokens === "number") {
432
+ payload.max_completion_tokens = payload.max_tokens;
433
+ }
434
+ delete payload.max_tokens;
435
+ }
436
+
437
+ const generation = ctx.modelConfig.generation;
438
+ if (generation.temperature !== undefined) payload.temperature = generation.temperature;
439
+ if (generation.topP !== undefined) payload.top_p = generation.topP;
440
+ if (generation.maxCompletionTokens !== undefined) {
441
+ payload.max_completion_tokens = generation.maxCompletionTokens;
427
442
  }
428
- delete payload.max_tokens;
429
443
 
430
- const { temperature, topP, maxCompletionTokens } = ctx.envOverrides;
431
- if (temperature !== undefined) payload.temperature = temperature;
432
- if (topP !== undefined) payload.top_p = topP;
433
- if (maxCompletionTokens !== undefined) {
434
- payload.max_completion_tokens = maxCompletionTokens;
444
+ // 5. Spread extra_body into top-level payload before reasoning mapping,
445
+ // matching upstream (Kimi expects thinking etc. at the root).
446
+ if (isRecord(payload.extra_body)) {
447
+ const extraBody = payload.extra_body as JsonRecord;
448
+ delete payload.extra_body;
449
+ for (const [key, value] of Object.entries(extraBody)) {
450
+ if (payload[key] === undefined) {
451
+ payload[key] = value;
452
+ }
453
+ }
435
454
  }
436
455
 
437
- // 5. Reasoning effort mapping.
438
- if (ctx.reasoning) {
439
- const mapped = mapThinkingLevel(ctx.reasoning);
456
+ // 6. Reasoning effort mapping. Merges into any existing top-level thinking
457
+ // field (which may have come from extra_body above).
458
+ const resolvedReasoning = resolveThinkingLevel(ctx);
459
+ if (resolvedReasoning) {
460
+ const mapped = resolveReasoningForLevel(resolvedReasoning, ctx.modelConfig);
440
461
  if (mapped) {
441
- payload.reasoning_effort = mapped.effort;
442
- const extraBody = isRecord(payload.extra_body) ? payload.extra_body : {};
443
- const oldThinking = isRecord(extraBody.thinking) ? extraBody.thinking : {};
444
- extraBody.thinking = {
462
+ if (mapped.effort !== null) {
463
+ payload.reasoning_effort = mapped.effort;
464
+ } else {
465
+ delete payload.reasoning_effort;
466
+ }
467
+ const oldThinking = isRecord(payload.thinking) ? payload.thinking : {};
468
+ payload.thinking = {
445
469
  ...oldThinking,
446
470
  type: mapped.enabled ? "enabled" : "disabled",
447
471
  };
448
- if (mapped.enabled && ctx.thinkingKeep) {
449
- (extraBody.thinking as JsonRecord).keep = ctx.thinkingKeep;
472
+ if (mapped.enabled && ctx.modelConfig.thinkingKeep) {
473
+ (payload.thinking as JsonRecord).keep = ctx.modelConfig.thinkingKeep;
450
474
  }
451
- payload.extra_body = extraBody;
475
+ }
476
+ }
477
+
478
+ // 7. K2.7 Code API constraints: the server rejects non-default values for
479
+ // temperature (must be 1.0) and top_p (must be 0.95), and tool_choice
480
+ // "required" / function-specific when thinking is enabled (always-on).
481
+ if (payload.temperature !== undefined && payload.temperature !== 1) {
482
+ delete payload.temperature;
483
+ }
484
+ if (payload.top_p !== undefined && payload.top_p !== 0.95) {
485
+ delete payload.top_p;
486
+ }
487
+ if (payload.tool_choice !== undefined) {
488
+ const tc = payload.tool_choice;
489
+ const isAllowed =
490
+ tc === "auto" ||
491
+ tc === "none" ||
492
+ (isRecord(tc) && (tc.type === "auto" || tc.type === "none"));
493
+ if (!isAllowed) {
494
+ payload.tool_choice = isRecord(tc) ? { type: "auto" } : "auto";
452
495
  }
453
496
  }
454
497
  }