pi-provider-kimi-code 0.3.0 → 0.3.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.3.0",
4
- "description": "Pi extension for Kimi Code — reuse your Kimi Code Plan in pi-coding-agent (K2.6, K2.5)",
3
+ "version": "0.3.2",
4
+ "description": "Use Kimi Code in Pi without losing the Kimi-specific bits",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -36,6 +36,7 @@
36
36
  "author": "Leechael",
37
37
  "files": [
38
38
  "index.ts",
39
+ "src",
39
40
  "package.json",
40
41
  "README.md"
41
42
  ],
@@ -43,20 +44,23 @@
43
44
  "scripts": {
44
45
  "clean": "echo 'nothing to clean'",
45
46
  "build": "echo 'nothing to build'",
46
- "check": "echo 'nothing to check'",
47
+ "check": "tsc --noEmit",
48
+ "test": "node --test tests/*.test.ts",
47
49
  "lint": "oxlint .",
48
50
  "format": "oxfmt . --write",
49
51
  "format:check": "oxfmt . --check",
50
52
  "pre-commit": "prek run"
51
53
  },
52
54
  "peerDependencies": {
53
- "@mariozechner/pi-coding-agent": "*",
54
- "@mariozechner/pi-ai": "*"
55
+ "@earendil-works/pi-coding-agent": "*",
56
+ "@earendil-works/pi-ai": "*"
55
57
  },
56
58
  "devDependencies": {
57
59
  "@j178/prek": "^0.3.8",
60
+ "@types/node": "^24.0.0",
58
61
  "oxfmt": "^0.44.0",
59
- "oxlint": "^1.59.0"
62
+ "oxlint": "^1.59.0",
63
+ "typescript": "^5.9.0"
60
64
  },
61
65
  "pi": {
62
66
  "extensions": [
@@ -0,0 +1,48 @@
1
+ // Module-level constants and env-driven configuration shared across the
2
+ // provider's modules. Anything env-dependent that we read once at module load
3
+ // time lives here; helpers that need to be evaluated per request go in env.ts
4
+ // (not yet split).
5
+
6
+ import os from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ export const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
10
+ export const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
11
+
12
+ // KIMI_CODE_PROTOCOL supports two values: "openai" (default) and "anthropic".
13
+ export const IS_OPENAI_PROTOCOL = process.env.KIMI_CODE_PROTOCOL !== "anthropic";
14
+
15
+ export const PROTOCOL = IS_OPENAI_PROTOCOL ? "openai-completions" : "anthropic-messages";
16
+
17
+ // Use a custom api identifier so this provider never conflicts with the
18
+ // built-in "anthropic-messages" or "openai-completions" stream handlers.
19
+ export const KIMI_API_TYPE = IS_OPENAI_PROTOCOL
20
+ ? "kimi-openai-completions"
21
+ : "kimi-anthropic-messages";
22
+
23
+ export const DEFAULT_BASE_URL = IS_OPENAI_PROTOCOL
24
+ ? "https://api.kimi.com/coding/v1"
25
+ : "https://api.kimi.com/coding";
26
+
27
+ export const KIMI_CLI_VERSION = "1.44.0";
28
+ export const KIMI_CLI_USER_AGENT = `KimiCLI/${KIMI_CLI_VERSION}`;
29
+ export const KIMI_PLATFORM = "kimi_cli";
30
+
31
+ export const DEVICE_ID_PATH = join(os.homedir(), ".pi", "providers", "kimi-coding", "device_id");
32
+
33
+ export const DEFAULT_KIMI_MODEL_INPUT = ["text", "image"] as const;
34
+
35
+ export const RETRYABLE_REFRESH_STATUSES = new Set([429, 500, 502, 503, 504]);
36
+
37
+ export const PROVIDER_ID = "kimi-coding";
38
+
39
+ export function getOAuthHost(): string {
40
+ const value =
41
+ process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
42
+ return value.trim() || DEFAULT_OAUTH_HOST;
43
+ }
44
+
45
+ export function getBaseUrl(): string {
46
+ const value = process.env.KIMI_CODE_BASE_URL || process.env.KIMI_BASE_URL || DEFAULT_BASE_URL;
47
+ return value.trim() || DEFAULT_BASE_URL;
48
+ }
package/src/device.ts ADDED
@@ -0,0 +1,164 @@
1
+ // Device identification: device-id persistence, kimi-cli-compatible request
2
+ // headers, and the cross-platform device-model string. Pure helpers live near
3
+ // the side-effecting one-shots (file IO, exec) they support.
4
+
5
+ import { execSync } from "node:child_process";
6
+ import { randomBytes } from "node:crypto";
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import os from "node:os";
9
+ import { dirname } from "node:path";
10
+
11
+ import {
12
+ DEVICE_ID_PATH,
13
+ KIMI_CLI_USER_AGENT,
14
+ KIMI_CLI_VERSION,
15
+ KIMI_PLATFORM,
16
+ } from "./constants.ts";
17
+
18
+ function createDeviceId(): string {
19
+ return randomBytes(16).toString("hex");
20
+ }
21
+
22
+ function ensurePrivateFile(path: string): void {
23
+ try {
24
+ chmodSync(path, 0o600);
25
+ } catch {
26
+ // Ignore chmod failures on platforms/filesystems that do not support it.
27
+ }
28
+ }
29
+
30
+ function readPersistedDeviceId(): string | null {
31
+ try {
32
+ if (!existsSync(DEVICE_ID_PATH)) return null;
33
+ const deviceId = readFileSync(DEVICE_ID_PATH, "utf8").trim();
34
+ return deviceId || null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function persistDeviceId(deviceId: string): void {
41
+ try {
42
+ mkdirSync(dirname(DEVICE_ID_PATH), { recursive: true });
43
+ writeFileSync(DEVICE_ID_PATH, deviceId, "utf8");
44
+ ensurePrivateFile(DEVICE_ID_PATH);
45
+ } catch {
46
+ // Ignore persistence failures and fall back to the in-memory device id.
47
+ }
48
+ }
49
+
50
+ function getMacOSVersion(): string {
51
+ try {
52
+ return execSync("sw_vers -productVersion", { encoding: "utf8" }).trim();
53
+ } catch {
54
+ return os.release();
55
+ }
56
+ }
57
+
58
+ // Normalize Node's lower-case `process.platform` (`linux`, `freebsd`,
59
+ // `sunos`...) to Python `platform.system()` casing used by upstream kimi-cli.
60
+ const SYSTEM_NAME: Record<string, string> = {
61
+ aix: "AIX",
62
+ freebsd: "FreeBSD",
63
+ linux: "Linux",
64
+ openbsd: "OpenBSD",
65
+ sunos: "SunOS",
66
+ };
67
+
68
+ export interface DeviceModelInput {
69
+ platform: NodeJS.Platform;
70
+ release: string;
71
+ arch: string;
72
+ /** macOS productVersion (e.g. "15.2"). Required only on darwin. */
73
+ macVersion?: string;
74
+ }
75
+
76
+ // Pure function exposed for tests. Mirror of upstream kimi-cli's
77
+ // `_device_model()` in `src/kimi_cli/auth/oauth.py`.
78
+ export function computeDeviceModel(input: DeviceModelInput): string {
79
+ const { platform, release, arch, macVersion } = input;
80
+ if (platform === "darwin") {
81
+ const version = macVersion || release;
82
+ if (version && arch) return `macOS ${version} ${arch}`;
83
+ if (version) return `macOS ${version}`;
84
+ return `macOS ${arch}`.trim();
85
+ }
86
+ if (platform === "win32") {
87
+ // Only show the major release (e.g. "Windows 10", "Windows 11") to match
88
+ // the upstream behavior. Windows 11 still reports kernel version
89
+ // "10.0.xxxxx"; treat build ≥ 22000 as Windows 11.
90
+ const parts = release.split(".");
91
+ let label = parts[0];
92
+ if (label === "10" && parts.length >= 3) {
93
+ const build = parseInt(parts[2], 10);
94
+ if (!isNaN(build) && build >= 22000) {
95
+ label = "11";
96
+ }
97
+ }
98
+ if (label && arch) return `Windows ${label} ${arch}`;
99
+ if (label) return `Windows ${label}`;
100
+ return `Windows ${arch}`.trim();
101
+ }
102
+ const system = SYSTEM_NAME[platform] ?? platform;
103
+ if (release && arch) return `${system} ${release} ${arch}`;
104
+ if (release) return `${system} ${release}`;
105
+ return `${system} ${arch}`.trim();
106
+ }
107
+
108
+ function getDeviceModel(): string {
109
+ return computeDeviceModel({
110
+ platform: process.platform,
111
+ release: os.release(),
112
+ arch: os.machine() || process.arch,
113
+ macVersion: process.platform === "darwin" ? getMacOSVersion() : undefined,
114
+ });
115
+ }
116
+
117
+ export function getOsVersion(): string {
118
+ return os.version();
119
+ }
120
+
121
+ export function asciiHeaderValue(value: string, fallback = "unknown"): string {
122
+ const trimmed = value.trim();
123
+ /* oxlint-disable-next-line no-control-regex */
124
+ if (/^[\x00-\x7F]*$/.test(trimmed)) {
125
+ return trimmed;
126
+ }
127
+ /* oxlint-disable-next-line no-control-regex */
128
+ const sanitized = trimmed.replace(/[^\x00-\x7F]/g, "").trim();
129
+ return sanitized || fallback;
130
+ }
131
+
132
+ const DEVICE_MODEL = getDeviceModel();
133
+ let DEVICE_ID: string | null = null;
134
+
135
+ function getStableDeviceId(): string {
136
+ if (DEVICE_ID) {
137
+ return DEVICE_ID;
138
+ }
139
+
140
+ const persisted = readPersistedDeviceId();
141
+ if (persisted) {
142
+ DEVICE_ID = persisted;
143
+ return DEVICE_ID;
144
+ }
145
+
146
+ DEVICE_ID = createDeviceId();
147
+ persistDeviceId(DEVICE_ID);
148
+ return DEVICE_ID;
149
+ }
150
+
151
+ export function getCommonHeaders(): Record<string, string> {
152
+ const headers = {
153
+ "User-Agent": KIMI_CLI_USER_AGENT,
154
+ "X-Msh-Platform": KIMI_PLATFORM,
155
+ "X-Msh-Version": KIMI_CLI_VERSION,
156
+ "X-Msh-Device-Name": os.hostname(),
157
+ "X-Msh-Device-Model": DEVICE_MODEL,
158
+ "X-Msh-Os-Version": getOsVersion(),
159
+ "X-Msh-Device-Id": getStableDeviceId(),
160
+ };
161
+ return Object.fromEntries(
162
+ Object.entries(headers).map(([key, value]) => [key, asciiHeaderValue(value)]),
163
+ ) as Record<string, string>;
164
+ }
package/src/models.ts ADDED
@@ -0,0 +1,129 @@
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.
4
+
5
+ import type { Api, Model, OAuthCredentials } from "@earendil-works/pi-ai";
6
+
7
+ import { getBaseUrl } from "./constants.ts";
8
+ import { getCommonHeaders } from "./device.ts";
9
+
10
+ export interface KimiOAuthExtras {
11
+ wireModelId?: string;
12
+ modelDisplay?: string;
13
+ contextLength?: number;
14
+ supportsReasoning?: boolean;
15
+ supportsImageIn?: boolean;
16
+ }
17
+
18
+ export type KimiOAuthCredentials = OAuthCredentials & KimiOAuthExtras;
19
+
20
+ interface KimiServerModel {
21
+ id?: unknown;
22
+ display_name?: unknown;
23
+ context_length?: unknown;
24
+ supports_reasoning?: unknown;
25
+ supports_image_in?: unknown;
26
+ }
27
+
28
+ export function buildModelsUrl(baseUrl: string): string {
29
+ const base = baseUrl.replace(/\/+$/, "");
30
+ return base.endsWith("/v1") ? `${base}/models` : `${base}/v1/models`;
31
+ }
32
+
33
+ function getModelsUrl(): string {
34
+ return buildModelsUrl(getBaseUrl());
35
+ }
36
+
37
+ export async function discoverKimiModelMetadata(accessToken: string): Promise<KimiOAuthExtras> {
38
+ if (!accessToken) return {};
39
+ try {
40
+ const response = await fetch(getModelsUrl(), {
41
+ headers: {
42
+ ...getCommonHeaders(),
43
+ Authorization: `Bearer ${accessToken}`,
44
+ Accept: "application/json",
45
+ },
46
+ });
47
+ if (!response.ok) return {};
48
+ const json = (await response.json()) as { data?: unknown };
49
+ 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];
52
+ if (!preferred || typeof preferred.id !== "string") return {};
53
+ const extras: KimiOAuthExtras = { wireModelId: preferred.id };
54
+ if (typeof preferred.display_name === "string") extras.modelDisplay = preferred.display_name;
55
+ if (typeof preferred.context_length === "number" && preferred.context_length > 0) {
56
+ extras.contextLength = preferred.context_length;
57
+ }
58
+ if (typeof preferred.supports_reasoning === "boolean") {
59
+ extras.supportsReasoning = preferred.supports_reasoning;
60
+ }
61
+ if (typeof preferred.supports_image_in === "boolean") {
62
+ extras.supportsImageIn = preferred.supports_image_in;
63
+ }
64
+ return extras;
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+
70
+ export function applyKimiOAuthExtrasToModel(
71
+ model: Model<Api>,
72
+ extras: KimiOAuthExtras,
73
+ ): Model<Api> {
74
+ const next: Model<Api> & { wireModelId?: string } = { ...model };
75
+ if (typeof extras.modelDisplay === "string" && extras.modelDisplay) {
76
+ next.name = extras.modelDisplay;
77
+ }
78
+ if (typeof extras.contextLength === "number" && extras.contextLength > 0) {
79
+ next.contextWindow = extras.contextLength;
80
+ }
81
+ if (typeof extras.wireModelId === "string" && extras.wireModelId) {
82
+ next.wireModelId = extras.wireModelId;
83
+ }
84
+ if (typeof extras.supportsReasoning === "boolean") {
85
+ next.reasoning = extras.supportsReasoning;
86
+ }
87
+ if (typeof extras.supportsImageIn === "boolean") {
88
+ const input = ["text"];
89
+ if (extras.supportsImageIn) input.push("image");
90
+ (next as unknown as { input: string[] }).input = input;
91
+ }
92
+ return next;
93
+ }
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
+ }