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/README.md +99 -51
- package/index.ts +42 -883
- package/package.json +10 -6
- package/src/constants.ts +48 -0
- package/src/device.ts +164 -0
- package/src/models.ts +129 -0
- package/src/oauth.ts +397 -0
- package/src/payload.ts +446 -0
- package/src/stream.ts +299 -0
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// OAuth subsystem: device flow, refresh-with-retry, kimi-cli credential
|
|
2
|
+
// reuse, the login / refresh handlers wired into pi's OAuth interface, and
|
|
3
|
+
// the stream-level auth refresh used by the streaming handler.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { OAuthCredential } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { AuthStorage } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
import { CLIENT_ID, PROVIDER_ID, RETRYABLE_REFRESH_STATUSES, getOAuthHost } from "./constants.ts";
|
|
14
|
+
import { getCommonHeaders } from "./device.ts";
|
|
15
|
+
import { type KimiOAuthCredentials, discoverKimiModelMetadata } from "./models.ts";
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Device flow + token endpoint
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
export interface DeviceAuthorization {
|
|
22
|
+
user_code: string;
|
|
23
|
+
device_code: string;
|
|
24
|
+
verification_uri: string;
|
|
25
|
+
verification_uri_complete: string;
|
|
26
|
+
expires_in: number;
|
|
27
|
+
interval: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TokenResponse {
|
|
31
|
+
access_token: string;
|
|
32
|
+
refresh_token: string;
|
|
33
|
+
expires_in: number;
|
|
34
|
+
scope: string;
|
|
35
|
+
token_type: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function requestDeviceAuthorization(): Promise<DeviceAuthorization> {
|
|
39
|
+
const response = await fetch(`${getOAuthHost()}/api/oauth/device_authorization`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
43
|
+
...getCommonHeaders(),
|
|
44
|
+
},
|
|
45
|
+
body: new URLSearchParams({
|
|
46
|
+
client_id: CLIENT_ID,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const text = await response.text().catch(() => "");
|
|
52
|
+
throw new Error(`Device authorization failed: ${response.status} ${text}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const data = (await response.json()) as {
|
|
56
|
+
user_code?: string;
|
|
57
|
+
device_code?: string;
|
|
58
|
+
verification_uri?: string;
|
|
59
|
+
verification_uri_complete?: string;
|
|
60
|
+
expires_in?: number;
|
|
61
|
+
interval?: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const verificationUrl = data.verification_uri_complete || data.verification_uri;
|
|
65
|
+
if (!data.user_code || !data.device_code || !verificationUrl) {
|
|
66
|
+
throw new Error("Invalid device authorization response");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
user_code: data.user_code,
|
|
71
|
+
device_code: data.device_code,
|
|
72
|
+
verification_uri: data.verification_uri || verificationUrl,
|
|
73
|
+
verification_uri_complete: verificationUrl,
|
|
74
|
+
expires_in: data.expires_in || 1800,
|
|
75
|
+
interval: data.interval || 5,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function requestDeviceToken(
|
|
80
|
+
auth: DeviceAuthorization,
|
|
81
|
+
): Promise<TokenResponse | "slow_down" | null> {
|
|
82
|
+
const response = await fetch(`${getOAuthHost()}/api/oauth/token`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
86
|
+
...getCommonHeaders(),
|
|
87
|
+
},
|
|
88
|
+
body: new URLSearchParams({
|
|
89
|
+
client_id: CLIENT_ID,
|
|
90
|
+
device_code: auth.device_code,
|
|
91
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (response.status === 200) {
|
|
96
|
+
const data = (await response.json()) as TokenResponse;
|
|
97
|
+
if (data.access_token && data.refresh_token) {
|
|
98
|
+
return data;
|
|
99
|
+
}
|
|
100
|
+
throw new Error("Token response missing required fields");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (response.status === 400) {
|
|
104
|
+
const data = (await response.json()) as { error?: string; error_description?: string };
|
|
105
|
+
if (data.error === "authorization_pending") {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (data.error === "slow_down") {
|
|
109
|
+
return "slow_down";
|
|
110
|
+
}
|
|
111
|
+
if (data.error === "expired_token") {
|
|
112
|
+
throw new Error("expired_token");
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`Token request failed: ${data.error_description || data.error || "unknown"}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const text = await response.text().catch(() => "");
|
|
118
|
+
throw new Error(`Token request failed: ${response.status} ${text}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface RefreshAccessTokenOptions {
|
|
122
|
+
maxRetries?: number;
|
|
123
|
+
sleep?: (ms: number) => Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sleep(ms: number): Promise<void> {
|
|
127
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function refreshAccessToken(
|
|
131
|
+
refreshToken: string,
|
|
132
|
+
options: RefreshAccessTokenOptions = {},
|
|
133
|
+
): Promise<TokenResponse> {
|
|
134
|
+
const maxRetries = options.maxRetries ?? 3;
|
|
135
|
+
const wait = options.sleep ?? sleep;
|
|
136
|
+
let lastError: unknown;
|
|
137
|
+
|
|
138
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch(`${getOAuthHost()}/api/oauth/token`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: {
|
|
143
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
144
|
+
...getCommonHeaders(),
|
|
145
|
+
},
|
|
146
|
+
body: new URLSearchParams({
|
|
147
|
+
client_id: CLIENT_ID,
|
|
148
|
+
grant_type: "refresh_token",
|
|
149
|
+
refresh_token: refreshToken,
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
const text = await response.text().catch(() => "");
|
|
155
|
+
if (response.status === 401 || response.status === 403) {
|
|
156
|
+
throw new Error(`Token refresh unauthorized: ${text}`);
|
|
157
|
+
}
|
|
158
|
+
if (RETRYABLE_REFRESH_STATUSES.has(response.status)) {
|
|
159
|
+
throw new Error(`Token refresh retryable: ${response.status} ${text}`);
|
|
160
|
+
}
|
|
161
|
+
throw new Error(`Token refresh failed: ${response.status} ${text}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const data = (await response.json()) as TokenResponse;
|
|
165
|
+
if (!data.access_token || !data.refresh_token) {
|
|
166
|
+
throw new Error("Token refresh response missing required fields");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return data;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
lastError = error;
|
|
172
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
173
|
+
if (message.startsWith("Token refresh unauthorized:")) throw error;
|
|
174
|
+
if (message.startsWith("Token refresh failed:")) throw error;
|
|
175
|
+
if (attempt < maxRetries - 1) {
|
|
176
|
+
await wait(2 ** attempt * 1000);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
throw new Error("Token refresh failed after retries.", { cause: lastError });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// =============================================================================
|
|
186
|
+
// Reuse existing kimi-cli credentials
|
|
187
|
+
//
|
|
188
|
+
// Users who already ran the upstream `kimi-cli` and signed in have a valid
|
|
189
|
+
// OAuth token sitting at `$KIMI_SHARE_DIR/credentials/kimi-code.json`
|
|
190
|
+
// (defaults to `~/.kimi/...`). Loading it lets users skip the device-flow
|
|
191
|
+
// dance entirely. Read-only: we never overwrite kimi-cli's file, only seed
|
|
192
|
+
// pi's auth.json via the value returned to pi's OAuth callback.
|
|
193
|
+
// =============================================================================
|
|
194
|
+
|
|
195
|
+
interface KimiCliCredentialsFile {
|
|
196
|
+
access_token?: string;
|
|
197
|
+
refresh_token?: string;
|
|
198
|
+
expires_at?: number; // Unix seconds (upstream convention)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function getKimiCliCredentialsPath(): string {
|
|
202
|
+
const shareDir = process.env.KIMI_SHARE_DIR || join(os.homedir(), ".kimi");
|
|
203
|
+
return join(shareDir, "credentials", "kimi-code.json");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readKimiCliCredentials(): KimiCliCredentialsFile | null {
|
|
207
|
+
const path = getKimiCliCredentialsPath();
|
|
208
|
+
try {
|
|
209
|
+
if (!existsSync(path)) return null;
|
|
210
|
+
const data = JSON.parse(readFileSync(path, "utf-8")) as KimiCliCredentialsFile;
|
|
211
|
+
if (!data.access_token || !data.refresh_token) return null;
|
|
212
|
+
return data;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function tryReuseKimiCliCredentials(
|
|
219
|
+
callbacks: OAuthLoginCallbacks,
|
|
220
|
+
): Promise<KimiOAuthCredentials | null> {
|
|
221
|
+
const data = readKimiCliCredentials();
|
|
222
|
+
if (!data) return null;
|
|
223
|
+
|
|
224
|
+
const expiresAtMs = typeof data.expires_at === "number" ? data.expires_at * 1000 : 0;
|
|
225
|
+
// 60s safety buffer so we don't hand pi a token that flips to expired
|
|
226
|
+
// between this returning and the first API call.
|
|
227
|
+
const stillFresh = expiresAtMs > Date.now() + 60_000;
|
|
228
|
+
|
|
229
|
+
callbacks.onProgress?.("Found existing kimi-cli credentials, reusing them.");
|
|
230
|
+
|
|
231
|
+
if (stillFresh) {
|
|
232
|
+
const extras = await discoverKimiModelMetadata(data.access_token!);
|
|
233
|
+
return {
|
|
234
|
+
access: data.access_token!,
|
|
235
|
+
refresh: data.refresh_token!,
|
|
236
|
+
expires: expiresAtMs,
|
|
237
|
+
...extras,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
callbacks.onProgress?.("kimi-cli access token expired, refreshing.");
|
|
242
|
+
try {
|
|
243
|
+
const token = await refreshAccessToken(data.refresh_token!);
|
|
244
|
+
const extras = await discoverKimiModelMetadata(token.access_token);
|
|
245
|
+
return {
|
|
246
|
+
access: token.access_token,
|
|
247
|
+
refresh: token.refresh_token,
|
|
248
|
+
expires: Date.now() + token.expires_in * 1000,
|
|
249
|
+
...extras,
|
|
250
|
+
};
|
|
251
|
+
} catch {
|
|
252
|
+
callbacks.onProgress?.("Refresh of kimi-cli token failed, falling back to device flow.");
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// =============================================================================
|
|
258
|
+
// OAuth login / refresh for extension registration
|
|
259
|
+
// =============================================================================
|
|
260
|
+
|
|
261
|
+
export async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise<KimiOAuthCredentials> {
|
|
262
|
+
const reused = await tryReuseKimiCliCredentials(callbacks);
|
|
263
|
+
if (reused) return reused;
|
|
264
|
+
|
|
265
|
+
// Keep trying until we get a token (handles expired device codes)
|
|
266
|
+
while (true) {
|
|
267
|
+
const auth = await requestDeviceAuthorization();
|
|
268
|
+
|
|
269
|
+
callbacks.onAuth({
|
|
270
|
+
url: auth.verification_uri_complete,
|
|
271
|
+
instructions: `Please visit the URL to authorize. Your code: ${auth.user_code}`,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
let interval = Math.max(auth.interval, 1) * 1000;
|
|
275
|
+
const expiresAt = Date.now() + auth.expires_in * 1000;
|
|
276
|
+
|
|
277
|
+
let token: TokenResponse | null = null;
|
|
278
|
+
let printedWaiting = false;
|
|
279
|
+
|
|
280
|
+
while (Date.now() < expiresAt) {
|
|
281
|
+
try {
|
|
282
|
+
const result = await requestDeviceToken(auth);
|
|
283
|
+
if (result === "slow_down") {
|
|
284
|
+
interval += 5000;
|
|
285
|
+
} else {
|
|
286
|
+
token = result;
|
|
287
|
+
if (token) break;
|
|
288
|
+
}
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (error instanceof Error && error.message === "expired_token") {
|
|
291
|
+
// Device code expired, restart the flow
|
|
292
|
+
if (callbacks.onProgress) {
|
|
293
|
+
callbacks.onProgress("Device code expired, restarting...");
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!printedWaiting) {
|
|
301
|
+
if (callbacks.onProgress) {
|
|
302
|
+
callbacks.onProgress("Waiting for authorization...");
|
|
303
|
+
}
|
|
304
|
+
printedWaiting = true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Check for abort
|
|
308
|
+
if (callbacks.signal?.aborted) {
|
|
309
|
+
throw new Error("Authorization aborted");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (token) {
|
|
316
|
+
const extras = await discoverKimiModelMetadata(token.access_token);
|
|
317
|
+
return {
|
|
318
|
+
access: token.access_token,
|
|
319
|
+
refresh: token.refresh_token,
|
|
320
|
+
expires: Date.now() + token.expires_in * 1000,
|
|
321
|
+
...extras,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// If we get here without a token, the device code expired - loop will retry
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function refreshKimiCodeToken(
|
|
330
|
+
credentials: OAuthCredentials,
|
|
331
|
+
): Promise<KimiOAuthCredentials> {
|
|
332
|
+
const token = await refreshAccessToken(credentials.refresh);
|
|
333
|
+
const extras = await discoverKimiModelMetadata(token.access_token);
|
|
334
|
+
return {
|
|
335
|
+
access: token.access_token,
|
|
336
|
+
refresh: token.refresh_token,
|
|
337
|
+
expires: Date.now() + token.expires_in * 1000,
|
|
338
|
+
...extras,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// =============================================================================
|
|
343
|
+
// Stream-level auth refresh: recover from server-side token invalidation
|
|
344
|
+
//
|
|
345
|
+
// pi-coding-agent only refreshes an OAuth token when the locally cached
|
|
346
|
+
// `expires` is in the past. If the server rotates/revokes the access token
|
|
347
|
+
// before that (common with short-lived session tokens), every request keeps
|
|
348
|
+
// returning 401. The streaming handler invokes refreshKimiAuthToken on the
|
|
349
|
+
// first auth error to force a refresh through AuthStorage (which persists the
|
|
350
|
+
// new credentials under a file lock), and retry once.
|
|
351
|
+
// =============================================================================
|
|
352
|
+
|
|
353
|
+
export function isKimiAuthErrorMessage(message: unknown): boolean {
|
|
354
|
+
const text = String(message ?? "").toLowerCase();
|
|
355
|
+
return (
|
|
356
|
+
/\b401\b/.test(text) ||
|
|
357
|
+
text.includes("unauthorized") ||
|
|
358
|
+
text.includes("incorrect api key") ||
|
|
359
|
+
text.includes("invalid api key")
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function refreshKimiAuthToken(currentKey: string): Promise<string | null> {
|
|
364
|
+
try {
|
|
365
|
+
const storage = AuthStorage.create();
|
|
366
|
+
const cred = storage.get(PROVIDER_ID);
|
|
367
|
+
if (!cred || cred.type !== "oauth") {
|
|
368
|
+
console.error(
|
|
369
|
+
`[kimi-coding] auth refresh skipped: no OAuth credentials for ${PROVIDER_ID} on disk`,
|
|
370
|
+
);
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// If disk already has a different valid token (e.g., another process or a
|
|
375
|
+
// previous retry refreshed it while the caller's in-memory cache went
|
|
376
|
+
// stale), reuse it without hitting the OAuth endpoint.
|
|
377
|
+
if (cred.access !== currentKey && Date.now() < cred.expires) {
|
|
378
|
+
console.error("[kimi-coding] auth refresh: reusing newer on-disk token");
|
|
379
|
+
return cred.access;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
console.error("[kimi-coding] auth refresh: requesting new access token");
|
|
383
|
+
const refreshed = await refreshAccessToken(cred.refresh);
|
|
384
|
+
const newCred: OAuthCredential = {
|
|
385
|
+
type: "oauth",
|
|
386
|
+
access: refreshed.access_token,
|
|
387
|
+
refresh: refreshed.refresh_token,
|
|
388
|
+
expires: Date.now() + refreshed.expires_in * 1000,
|
|
389
|
+
};
|
|
390
|
+
storage.set(PROVIDER_ID, newCred);
|
|
391
|
+
console.error("[kimi-coding] auth refresh: new token persisted");
|
|
392
|
+
return newCred.access;
|
|
393
|
+
} catch (err) {
|
|
394
|
+
console.error("[kimi-coding] auth refresh failed:", err);
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|