threadwire 0.1.8 → 0.1.9
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/CHANGELOG.md +12 -0
- package/README.md +3 -3
- package/TELEGRAM-INGRESS.md +8 -4
- package/bin/kimi-model-broker.js +5 -0
- package/docs/container-runtime.md +22 -0
- package/docs/isolated-provider-runtime.md +95 -0
- package/package.json +6 -3
- package/scripts/verify-package.js +8 -1
- package/src/activity-log.js +3 -3
- package/src/cli.js +20 -14
- package/src/isolated-runtime-client.js +9 -0
- package/src/isolated-runtime.js +199 -64
- package/src/isolated-state.js +105 -44
- package/src/isolated-worker.js +28 -7
- package/src/kimi-model-broker-policy.js +209 -0
- package/src/kimi-model-broker.js +325 -0
- package/src/kimi-oauth-store.js +267 -0
- package/src/providers/index.js +8 -3
- package/src/providers/kimi.js +165 -0
- package/src/telegram-ingress/command.js +4 -4
- package/src/telegram-ingress/config.js +11 -0
- package/src/telegram-ingress/core.js +33 -10
- package/src/telegram-ingress/http.js +2 -1
- package/src/telegram-webhook.js +8 -3
- package/src/workspace-profile.js +3 -3
- package/threadwire.workspace-profiles.json +1 -1
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {createServer} from "node:http"
|
|
4
|
+
import {once} from "node:events"
|
|
5
|
+
import {abortable} from "./absolute-deadline.js"
|
|
6
|
+
import {
|
|
7
|
+
createKimiGrantStore,
|
|
8
|
+
kimiBearerToken,
|
|
9
|
+
parseApprovedKimiModels,
|
|
10
|
+
validateKimiBrokerRequest,
|
|
11
|
+
validateKimiChatBody
|
|
12
|
+
} from "./kimi-model-broker-policy.js"
|
|
13
|
+
import {openKimiOAuthStore} from "./kimi-oauth-store.js"
|
|
14
|
+
|
|
15
|
+
const MAX_REQUEST_BYTES = 2_097_152
|
|
16
|
+
const MAX_RESPONSE_BYTES = 8_388_608
|
|
17
|
+
const UPSTREAM_URL = "https://api.kimi.com/coding/v1/chat/completions"
|
|
18
|
+
|
|
19
|
+
/** @typedef {{provider: "kimi", runId: string, networkId: string, taskId: string, sessionId: string, modelAlias: string, model: string, ttlMs: number}} KimiGrant */
|
|
20
|
+
/** @typedef {{alias: string, model: string, protocol: "kimi"}} ApprovedKimiModel */
|
|
21
|
+
/** @typedef {ReturnType<typeof createKimiGrantStore>} KimiGrantStore */
|
|
22
|
+
/** @typedef {Awaited<ReturnType<typeof openKimiOAuthStore>>} KimiOAuthStore */
|
|
23
|
+
|
|
24
|
+
/** @param {{environment?: NodeJS.ProcessEnv, fetchImplementation?: typeof fetch, oauthFetchImplementation?: typeof fetch}} [options] */
|
|
25
|
+
export async function startKimiModelBroker(options = {}) {
|
|
26
|
+
const environment = options.environment ?? process.env
|
|
27
|
+
const host = safeHost(environment.THREADWIRE_KIMI_MODEL_BROKER_HOST ?? "0.0.0.0")
|
|
28
|
+
const port = portValue(environment.THREADWIRE_KIMI_MODEL_BROKER_PORT, 8791)
|
|
29
|
+
const adminToken = authoritySetting(environment.THREADWIRE_KIMI_MODEL_BROKER_ADMIN_TOKEN, "THREADWIRE_KIMI_MODEL_BROKER_ADMIN_TOKEN")
|
|
30
|
+
const approvedModels = parseApprovedKimiModels(environment.THREADWIRE_ALLOWED_KIMI_MODELS)
|
|
31
|
+
const oauth = await openKimiOAuthStore({
|
|
32
|
+
file: safeSetting(environment.THREADWIRE_KIMI_OAUTH_FILE, "THREADWIRE_KIMI_OAUTH_FILE"),
|
|
33
|
+
fetchImplementation: options.oauthFetchImplementation ?? fetch
|
|
34
|
+
})
|
|
35
|
+
const fetchImplementation = options.fetchImplementation ?? fetch
|
|
36
|
+
const grants = createKimiGrantStore()
|
|
37
|
+
/** @type {Map<string, import("node:http").Server>} */
|
|
38
|
+
const listeners = new Map()
|
|
39
|
+
/** @type {Map<string, {controller: AbortController, timer: NodeJS.Timeout, sockets: Set<import("node:net").Socket>}>} */
|
|
40
|
+
const runtimes = new Map()
|
|
41
|
+
/** @type {Set<import("node:net").Socket>} */
|
|
42
|
+
const controlSockets = new Set()
|
|
43
|
+
let stopping = false
|
|
44
|
+
|
|
45
|
+
/** @param {string} token */
|
|
46
|
+
const revokeGrant = async (token) => {
|
|
47
|
+
grants.revoke(token)
|
|
48
|
+
const runtime = runtimes.get(token)
|
|
49
|
+
runtimes.delete(token)
|
|
50
|
+
if (runtime !== undefined) {
|
|
51
|
+
clearTimeout(runtime.timer)
|
|
52
|
+
runtime.controller.abort(new Error("Kimi broker grant revoked"))
|
|
53
|
+
for (const socket of runtime.sockets) socket.destroy()
|
|
54
|
+
runtime.sockets.clear()
|
|
55
|
+
}
|
|
56
|
+
await closeListener(listeners, token)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const server = createServer(async (request, response) => {
|
|
60
|
+
try {
|
|
61
|
+
if (stopping) throw new Error("Kimi model broker is shutting down")
|
|
62
|
+
if (request.method === "GET" && request.url === "/healthz") {
|
|
63
|
+
const health = await oauth.health()
|
|
64
|
+
sendJson(response, health.ready ? 200 : 503, health.ready
|
|
65
|
+
? {ok: true, models: approvedModels.size}
|
|
66
|
+
: {ok: false, needsLogin: true})
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
requireAdmin(request.headers.authorization, adminToken)
|
|
70
|
+
if (request.method === "POST" && request.url === "/admin/grants") {
|
|
71
|
+
const health = await oauth.health()
|
|
72
|
+
if (!health.ready) throw new Error("Kimi OAuth login required")
|
|
73
|
+
const body = await readJson(request)
|
|
74
|
+
const selection = parseGrant(body, approvedModels)
|
|
75
|
+
const token = grants.issue(selection.grant)
|
|
76
|
+
const controller = new AbortController()
|
|
77
|
+
/** @type {Set<import("node:net").Socket>} */
|
|
78
|
+
const sockets = new Set()
|
|
79
|
+
const listener = createServer((workerRequest, workerResponse) => {
|
|
80
|
+
proxyWorkerRequest(workerRequest, workerResponse, {
|
|
81
|
+
token, grant: selection.grant, grants, oauth, fetchImplementation, signal: controller.signal
|
|
82
|
+
}).catch((error) => {
|
|
83
|
+
if (workerResponse.destroyed) return
|
|
84
|
+
const status = clientError(error) ? 403 : 502
|
|
85
|
+
sendJson(workerResponse, status, {error: status === 403 ? "request denied" : "Kimi model broker unavailable"})
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
listener.on("connection", (socket) => {
|
|
89
|
+
if (controller.signal.aborted) return socket.destroy()
|
|
90
|
+
sockets.add(socket)
|
|
91
|
+
socket.once("close", () => sockets.delete(socket))
|
|
92
|
+
})
|
|
93
|
+
try { await listen(listener, 0, selection.brokerAddress) } catch (error) {
|
|
94
|
+
grants.revoke(token)
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
listeners.set(token, listener)
|
|
98
|
+
const timer = setTimeout(() => { void revokeGrant(token) }, selection.grant.ttlMs)
|
|
99
|
+
timer.unref()
|
|
100
|
+
runtimes.set(token, {controller, timer, sockets})
|
|
101
|
+
const address = listener.address()
|
|
102
|
+
if (address === null || typeof address === "string") throw new Error("Kimi broker listener unavailable")
|
|
103
|
+
sendJson(response, 201, {token, port: address.port})
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
if (request.method === "POST" && request.url?.startsWith("/admin/grants/") && request.url.endsWith("/activate")) {
|
|
107
|
+
const token = decodeURIComponent(request.url.slice("/admin/grants/".length, -"/activate".length))
|
|
108
|
+
grants.activate(token)
|
|
109
|
+
sendJson(response, 200, {ok: true})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (request.method === "DELETE" && request.url?.startsWith("/admin/grants/")) {
|
|
113
|
+
const token = decodeURIComponent(request.url.slice("/admin/grants/".length))
|
|
114
|
+
await revokeGrant(token)
|
|
115
|
+
response.writeHead(204)
|
|
116
|
+
response.end()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
sendJson(response, 403, {error: "request denied"})
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const status = clientError(error) ? 403 : 503
|
|
122
|
+
sendJson(response, status, {error: status === 403 ? "request denied" : "Kimi model broker unavailable"})
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
server.on("connection", (socket) => {
|
|
126
|
+
controlSockets.add(socket)
|
|
127
|
+
socket.once("close", () => controlSockets.delete(socket))
|
|
128
|
+
})
|
|
129
|
+
await listen(server, port, host)
|
|
130
|
+
return {
|
|
131
|
+
server,
|
|
132
|
+
activeListeners: () => listeners.size,
|
|
133
|
+
close: async () => {
|
|
134
|
+
if (stopping) return
|
|
135
|
+
stopping = true
|
|
136
|
+
for (const socket of controlSockets) socket.destroy()
|
|
137
|
+
await Promise.all([...listeners.keys()].map(revokeGrant))
|
|
138
|
+
await boundedClose(server)
|
|
139
|
+
},
|
|
140
|
+
config: {host, port, models: [...approvedModels.keys()]}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {import("node:http").IncomingMessage} request
|
|
146
|
+
* @param {import("node:http").ServerResponse} response
|
|
147
|
+
* @param {{token: string, grant: KimiGrant, grants: KimiGrantStore, oauth: KimiOAuthStore, fetchImplementation: typeof fetch, signal: AbortSignal}} options
|
|
148
|
+
*/
|
|
149
|
+
async function proxyWorkerRequest(request, response, options) {
|
|
150
|
+
throwIfAborted(options.signal)
|
|
151
|
+
const policyHeaders = {...request.headers}
|
|
152
|
+
delete policyHeaders.host
|
|
153
|
+
delete policyHeaders.connection
|
|
154
|
+
delete policyHeaders["transfer-encoding"]
|
|
155
|
+
validateKimiBrokerRequest({method: request.method ?? "", path: request.url ?? "", headers: policyHeaders})
|
|
156
|
+
const token = kimiBearerToken(request.headers.authorization)
|
|
157
|
+
if (token !== options.token) throw new Error("Kimi broker request denied")
|
|
158
|
+
const context = {
|
|
159
|
+
provider: "kimi", runId: options.grant.runId,
|
|
160
|
+
networkId: options.grant.networkId, taskId: options.grant.taskId,
|
|
161
|
+
sessionId: options.grant.sessionId, modelAlias: options.grant.modelAlias
|
|
162
|
+
}
|
|
163
|
+
const grant = options.grants.authorize(token, context)
|
|
164
|
+
const body = await readBody(request, options.signal)
|
|
165
|
+
options.grants.authorize(token, context)
|
|
166
|
+
validateKimiChatBody(body, grant.model)
|
|
167
|
+
const accessToken = await options.oauth.getAccessToken({signal: options.signal})
|
|
168
|
+
throwIfAborted(options.signal)
|
|
169
|
+
const upstream = await abortable(options.fetchImplementation(UPSTREAM_URL, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
redirect: "error",
|
|
172
|
+
headers: {
|
|
173
|
+
authorization: `Bearer ${accessToken}`,
|
|
174
|
+
"content-type": "application/json",
|
|
175
|
+
accept: "text/event-stream",
|
|
176
|
+
"user-agent": "threadwire-kimi-model-broker/1"
|
|
177
|
+
},
|
|
178
|
+
body,
|
|
179
|
+
signal: options.signal
|
|
180
|
+
}), options.signal)
|
|
181
|
+
throwIfAborted(options.signal)
|
|
182
|
+
const contentType = safeContentType(upstream.headers.get("content-type"))
|
|
183
|
+
response.writeHead(upstream.status, {"content-type": contentType, "cache-control": "no-store"})
|
|
184
|
+
await streamResponse(upstream, response, options.signal)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @param {Response} upstream @param {import("node:http").ServerResponse} response @param {AbortSignal} signal */
|
|
188
|
+
async function streamResponse(upstream, response, signal) {
|
|
189
|
+
if (upstream.body === null) { response.end(); return }
|
|
190
|
+
const reader = upstream.body.getReader()
|
|
191
|
+
let bytes = 0
|
|
192
|
+
try {
|
|
193
|
+
while (true) {
|
|
194
|
+
throwIfAborted(signal)
|
|
195
|
+
const {done, value} = await reader.read()
|
|
196
|
+
if (done) break
|
|
197
|
+
bytes += value.byteLength
|
|
198
|
+
if (bytes > MAX_RESPONSE_BYTES) throw new Error("Kimi broker response exceeded capacity")
|
|
199
|
+
if (!response.write(Buffer.from(value))) await abortable(once(response, "drain"), signal)
|
|
200
|
+
}
|
|
201
|
+
response.end()
|
|
202
|
+
} catch (error) {
|
|
203
|
+
await reader.cancel().catch(() => {})
|
|
204
|
+
response.destroy(error instanceof Error ? error : undefined)
|
|
205
|
+
throw error
|
|
206
|
+
} finally { reader.releaseLock() }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @param {unknown} value @param {Map<string, ApprovedKimiModel>} approvedModels @returns {{brokerAddress: string, grant: KimiGrant}} */
|
|
210
|
+
function parseGrant(value, approvedModels) {
|
|
211
|
+
if (!record(value) || !exactKeys(value, ["brokerAddress", "modelAlias", "networkId", "provider", "runId", "sessionId", "taskId", "ttlMs"])
|
|
212
|
+
|| value.provider !== "kimi" || typeof value.runId !== "string" || typeof value.networkId !== "string"
|
|
213
|
+
|| typeof value.taskId !== "string" || typeof value.sessionId !== "string"
|
|
214
|
+
|| typeof value.modelAlias !== "string" || typeof value.brokerAddress !== "string" || typeof value.ttlMs !== "number") {
|
|
215
|
+
throw new Error("Invalid Kimi broker grant")
|
|
216
|
+
}
|
|
217
|
+
if (!/^(?:\d{1,3}\.){3}\d{1,3}$/u.test(value.brokerAddress)) throw new Error("Invalid Kimi broker grant")
|
|
218
|
+
const model = approvedModels.get(value.modelAlias)
|
|
219
|
+
if (model === undefined) throw new Error("Kimi model is not allowed")
|
|
220
|
+
return {
|
|
221
|
+
brokerAddress: value.brokerAddress,
|
|
222
|
+
grant: {
|
|
223
|
+
provider: "kimi", runId: value.runId, networkId: value.networkId,
|
|
224
|
+
taskId: value.taskId, sessionId: value.sessionId,
|
|
225
|
+
modelAlias: model.alias, model: model.model, ttlMs: value.ttlMs
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @param {import("node:http").IncomingMessage} request @returns {Promise<unknown>} */
|
|
231
|
+
async function readJson(request) {
|
|
232
|
+
const body = await readBody(request)
|
|
233
|
+
try { return JSON.parse(body.toString("utf8")) } catch { throw new Error("Invalid Kimi broker request") }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @param {import("node:http").IncomingMessage} request @param {AbortSignal | undefined} [signal] @returns {Promise<Buffer>} */
|
|
237
|
+
async function readBody(request, signal) {
|
|
238
|
+
/** @type {Buffer[]} */
|
|
239
|
+
const chunks = []
|
|
240
|
+
let size = 0
|
|
241
|
+
for await (const chunk of request) {
|
|
242
|
+
throwIfAborted(signal)
|
|
243
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
244
|
+
size += buffer.length
|
|
245
|
+
if (size > MAX_REQUEST_BYTES) throw new Error("Kimi broker request exceeded capacity")
|
|
246
|
+
chunks.push(buffer)
|
|
247
|
+
}
|
|
248
|
+
throwIfAborted(signal)
|
|
249
|
+
return Buffer.concat(chunks)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @param {AbortSignal | undefined} signal */
|
|
253
|
+
function throwIfAborted(signal) {
|
|
254
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("Kimi broker request aborted")
|
|
255
|
+
}
|
|
256
|
+
/** @param {string | undefined} value @param {string} expected */
|
|
257
|
+
function requireAdmin(value, expected) {
|
|
258
|
+
if (value !== `Bearer ${expected}`) throw new Error("Kimi broker request denied")
|
|
259
|
+
}
|
|
260
|
+
/** @param {string | undefined} value @param {string} name @returns {string} */
|
|
261
|
+
function safeSetting(value, name) {
|
|
262
|
+
if (typeof value !== "string" || value.length === 0 || /[\0\r\n]/u.test(value)) throw new Error(`${name} is required`)
|
|
263
|
+
return value
|
|
264
|
+
}
|
|
265
|
+
/** @param {string | undefined} value @param {string} name @returns {string} */
|
|
266
|
+
function authoritySetting(value, name) {
|
|
267
|
+
const authority = safeSetting(value, name)
|
|
268
|
+
if (authority.length < 32 || authority.length > 512) throw new Error(`${name} must be at least 32 characters`)
|
|
269
|
+
return authority
|
|
270
|
+
}
|
|
271
|
+
/** @param {string} value @returns {string} */
|
|
272
|
+
function safeHost(value) {
|
|
273
|
+
if (value !== "0.0.0.0" && value !== "127.0.0.1" && value !== "::") throw new Error("Invalid Kimi model broker host")
|
|
274
|
+
return value
|
|
275
|
+
}
|
|
276
|
+
/** @param {string | undefined} value @param {number} fallback @returns {number} */
|
|
277
|
+
function portValue(value, fallback) {
|
|
278
|
+
if (value === undefined) return fallback
|
|
279
|
+
const number = Number(value)
|
|
280
|
+
if (!/^\d+$/u.test(value) || !Number.isSafeInteger(number) || number < 1 || number > 65535) throw new Error("Invalid Kimi model broker port")
|
|
281
|
+
return number
|
|
282
|
+
}
|
|
283
|
+
/** @param {string | null} value @returns {string} */
|
|
284
|
+
function safeContentType(value) {
|
|
285
|
+
return value && /^[\w.+-]+\/[\w.+-]+(?:;\s*charset=[\w-]+)?$/u.test(value) ? value : "application/octet-stream"
|
|
286
|
+
}
|
|
287
|
+
/** @param {import("node:http").ServerResponse} response @param {number} status @param {unknown} value */
|
|
288
|
+
function sendJson(response, status, value) {
|
|
289
|
+
if (response.headersSent || response.destroyed) return
|
|
290
|
+
response.writeHead(status, {"content-type": "application/json", "cache-control": "no-store"})
|
|
291
|
+
response.end(JSON.stringify(value))
|
|
292
|
+
}
|
|
293
|
+
/** @param {unknown} error @returns {boolean} */
|
|
294
|
+
function clientError(error) {
|
|
295
|
+
return error instanceof Error && /(denied|invalid|unknown token|expired|pending|already active|not allowed)/iu.test(error.message)
|
|
296
|
+
}
|
|
297
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
298
|
+
function record(value) { return typeof value === "object" && value !== null && !Array.isArray(value) }
|
|
299
|
+
/** @param {Record<string, unknown>} value @param {readonly string[]} expected @returns {boolean} */
|
|
300
|
+
function exactKeys(value, expected) {
|
|
301
|
+
const actual = Object.keys(value).sort()
|
|
302
|
+
const wanted = [...expected].sort()
|
|
303
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index])
|
|
304
|
+
}
|
|
305
|
+
/** @param {import("node:http").Server} server @param {number} port @param {string} host @returns {Promise<void>} */
|
|
306
|
+
function listen(server, port, host) {
|
|
307
|
+
return new Promise((resolve, reject) => {
|
|
308
|
+
server.once("error", reject)
|
|
309
|
+
server.listen(port, host, () => { server.off("error", reject); resolve(undefined) })
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
/** @param {import("node:http").Server} server @returns {Promise<void>} */
|
|
313
|
+
function boundedClose(server) {
|
|
314
|
+
return Promise.race([
|
|
315
|
+
new Promise((resolve) => server.close(() => resolve(undefined))),
|
|
316
|
+
new Promise((resolve) => { const timer = setTimeout(resolve, 1000); timer.unref() })
|
|
317
|
+
])
|
|
318
|
+
}
|
|
319
|
+
/** @param {Map<string, import("node:http").Server>} listeners @param {string} token */
|
|
320
|
+
async function closeListener(listeners, token) {
|
|
321
|
+
const listener = listeners.get(token)
|
|
322
|
+
listeners.delete(token)
|
|
323
|
+
if (listener === undefined) return
|
|
324
|
+
await boundedClose(listener)
|
|
325
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {randomBytes} from "node:crypto"
|
|
4
|
+
import {constants} from "node:fs"
|
|
5
|
+
import {chmod, lstat, open, rename, unlink} from "node:fs/promises"
|
|
6
|
+
import {dirname, isAbsolute, join} from "node:path"
|
|
7
|
+
|
|
8
|
+
const MAX_CREDENTIAL_BYTES = 65_536
|
|
9
|
+
const MAX_TOKEN_BYTES = 8_192
|
|
10
|
+
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
|
|
11
|
+
const TOKEN_URL = "https://auth.kimi.com/api/oauth/token"
|
|
12
|
+
const EXACT_KEYS = ["access_token", "expires_at", "expires_in", "refresh_token", "scope", "token_type"]
|
|
13
|
+
|
|
14
|
+
/** @typedef {{access_token: string, expires_at: number, expires_in: number, refresh_token: string, scope: string, token_type: "Bearer"}} KimiCredential */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{
|
|
18
|
+
* file: string,
|
|
19
|
+
* expectedUid?: number,
|
|
20
|
+
* now?: () => number,
|
|
21
|
+
* fetchImplementation?: typeof fetch,
|
|
22
|
+
* maxRefreshAttempts?: number,
|
|
23
|
+
* sleep?: (milliseconds: number) => Promise<void>,
|
|
24
|
+
* durabilityObserver?: (phase: string) => void | Promise<void>
|
|
25
|
+
* }} options
|
|
26
|
+
*/
|
|
27
|
+
export async function openKimiOAuthStore(options) {
|
|
28
|
+
if (!isAbsolute(options.file) || options.file.includes("\0")) throw unavailable()
|
|
29
|
+
const expectedUid = options.expectedUid ?? process.getuid?.() ?? -1
|
|
30
|
+
if (!Number.isSafeInteger(expectedUid) || expectedUid < 0) throw unavailable()
|
|
31
|
+
const now = options.now ?? (() => Math.floor(Date.now() / 1000))
|
|
32
|
+
const fetchImplementation = options.fetchImplementation ?? fetch
|
|
33
|
+
const maxRefreshAttempts = options.maxRefreshAttempts ?? 3
|
|
34
|
+
if (!Number.isSafeInteger(maxRefreshAttempts) || maxRefreshAttempts < 1 || maxRefreshAttempts > 3) throw unavailable()
|
|
35
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => {
|
|
36
|
+
const timer = setTimeout(resolve, milliseconds)
|
|
37
|
+
timer.unref()
|
|
38
|
+
}))
|
|
39
|
+
let credential = await readCredential(options.file, expectedUid)
|
|
40
|
+
/** @type {Promise<string> | undefined} */
|
|
41
|
+
let refreshInFlight
|
|
42
|
+
|
|
43
|
+
/** @param {AbortSignal | undefined} signal */
|
|
44
|
+
const refresh = async (signal) => {
|
|
45
|
+
if (credential.access_token.length === 0 || credential.refresh_token.length === 0) throw needsLogin()
|
|
46
|
+
/** @type {unknown} */
|
|
47
|
+
let lastFailure
|
|
48
|
+
for (let attempt = 0; attempt < maxRefreshAttempts; attempt++) {
|
|
49
|
+
if (signal?.aborted) throw signal.reason
|
|
50
|
+
let response
|
|
51
|
+
try {
|
|
52
|
+
const signals = [AbortSignal.timeout(30_000)]
|
|
53
|
+
if (signal !== undefined) signals.push(signal)
|
|
54
|
+
response = await fetchImplementation(TOKEN_URL, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
redirect: "error",
|
|
57
|
+
headers: {accept: "application/json", "content-type": "application/x-www-form-urlencoded"},
|
|
58
|
+
body: new URLSearchParams({
|
|
59
|
+
client_id: CLIENT_ID,
|
|
60
|
+
grant_type: "refresh_token",
|
|
61
|
+
refresh_token: credential.refresh_token
|
|
62
|
+
}).toString(),
|
|
63
|
+
signal: AbortSignal.any(signals)
|
|
64
|
+
})
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (signal?.aborted) throw signal.reason
|
|
67
|
+
lastFailure = error
|
|
68
|
+
if (attempt + 1 < maxRefreshAttempts) { await sleep(2 ** attempt * 100); continue }
|
|
69
|
+
throw refreshUnavailable(lastFailure)
|
|
70
|
+
}
|
|
71
|
+
const bytes = await readCappedResponse(response, signal).catch((error) => {
|
|
72
|
+
lastFailure = error
|
|
73
|
+
return undefined
|
|
74
|
+
})
|
|
75
|
+
if (bytes === undefined) {
|
|
76
|
+
if (attempt + 1 < maxRefreshAttempts) { await sleep(2 ** attempt * 100); continue }
|
|
77
|
+
throw refreshUnavailable(lastFailure)
|
|
78
|
+
}
|
|
79
|
+
/** @type {Record<string, unknown>} */
|
|
80
|
+
let body = {}
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(bytes.toString("utf8"))
|
|
83
|
+
if (record(parsed)) body = parsed
|
|
84
|
+
} catch { /* Invalid response bodies remain untrusted. */ }
|
|
85
|
+
if (response.status === 401 || response.status === 403 || body.error === "invalid_grant") {
|
|
86
|
+
credential = {
|
|
87
|
+
access_token: "", refresh_token: "", expires_at: 0,
|
|
88
|
+
scope: credential.scope, token_type: credential.token_type, expires_in: 0
|
|
89
|
+
}
|
|
90
|
+
await persistCredential(options.file, expectedUid, credential, options.durabilityObserver)
|
|
91
|
+
throw needsLogin()
|
|
92
|
+
}
|
|
93
|
+
if (response.ok) {
|
|
94
|
+
const replacement = tokenResponse(body, credential, now())
|
|
95
|
+
await persistCredential(options.file, expectedUid, replacement, options.durabilityObserver)
|
|
96
|
+
credential = replacement
|
|
97
|
+
return credential.access_token
|
|
98
|
+
}
|
|
99
|
+
if (![429, 500, 502, 503, 504].includes(response.status) || attempt + 1 >= maxRefreshAttempts) {
|
|
100
|
+
throw refreshUnavailable()
|
|
101
|
+
}
|
|
102
|
+
await sleep(2 ** attempt * 100)
|
|
103
|
+
}
|
|
104
|
+
throw refreshUnavailable(lastFailure)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
/** @param {{signal?: AbortSignal, force?: boolean}} [request] */
|
|
109
|
+
async getAccessToken(request = {}) {
|
|
110
|
+
if (credential.access_token.length === 0) throw needsLogin()
|
|
111
|
+
const threshold = Math.max(300, credential.expires_in * 0.5)
|
|
112
|
+
if (request.force !== true && credential.expires_at - now() > threshold) return credential.access_token
|
|
113
|
+
if (refreshInFlight !== undefined) return refreshInFlight
|
|
114
|
+
const operation = refresh(request.signal).finally(() => {
|
|
115
|
+
if (refreshInFlight === operation) refreshInFlight = undefined
|
|
116
|
+
})
|
|
117
|
+
refreshInFlight = operation
|
|
118
|
+
return operation
|
|
119
|
+
},
|
|
120
|
+
async health() {
|
|
121
|
+
return credential.access_token.length === 0
|
|
122
|
+
? {ready: false, needsLogin: true}
|
|
123
|
+
: {ready: true, needsLogin: false}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {string} file @param {number} expectedUid @returns {Promise<KimiCredential>} */
|
|
129
|
+
async function readCredential(file, expectedUid) {
|
|
130
|
+
const directory = dirname(file)
|
|
131
|
+
try {
|
|
132
|
+
const parent = await lstat(directory, {bigint: true})
|
|
133
|
+
if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== BigInt(expectedUid)
|
|
134
|
+
|| (parent.mode & 0o077n) !== 0n) throw unavailable()
|
|
135
|
+
let handle
|
|
136
|
+
try {
|
|
137
|
+
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
138
|
+
const metadata = await handle.stat({bigint: true})
|
|
139
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1n
|
|
140
|
+
|| metadata.uid !== BigInt(expectedUid) || (metadata.mode & 0o777n) !== 0o600n
|
|
141
|
+
|| metadata.size < 1n || metadata.size > BigInt(MAX_CREDENTIAL_BYTES)) throw unavailable()
|
|
142
|
+
const bytes = await handle.readFile()
|
|
143
|
+
return parseCredential(bytes)
|
|
144
|
+
} finally { await handle?.close().catch(() => {}) }
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof Error && error.message === unavailable().message) throw error
|
|
147
|
+
throw unavailable()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {Buffer} bytes @returns {KimiCredential} */
|
|
152
|
+
function parseCredential(bytes) {
|
|
153
|
+
/** @type {unknown} */
|
|
154
|
+
let value
|
|
155
|
+
try { value = JSON.parse(bytes.toString("utf8")) } catch { throw unavailable() }
|
|
156
|
+
if (!record(value) || !exactKeys(value, EXACT_KEYS)
|
|
157
|
+
|| !safeToken(value.access_token, true) || !safeToken(value.refresh_token, true)
|
|
158
|
+
|| typeof value.expires_at !== "number" || !Number.isSafeInteger(value.expires_at) || value.expires_at < 0
|
|
159
|
+
|| typeof value.expires_in !== "number" || !Number.isSafeInteger(value.expires_in) || value.expires_in < 0
|
|
160
|
+
|| typeof value.scope !== "string" || value.scope.length > 2048 || /[\0\r\n]/u.test(value.scope)
|
|
161
|
+
|| value.token_type !== "Bearer") throw unavailable()
|
|
162
|
+
const tombstone = value.access_token === "" && value.refresh_token === ""
|
|
163
|
+
&& value.expires_at === 0 && value.expires_in === 0
|
|
164
|
+
if (!tombstone && (value.access_token === "" || value.refresh_token === ""
|
|
165
|
+
|| value.expires_at < 1 || value.expires_in < 1)) throw unavailable()
|
|
166
|
+
return /** @type {KimiCredential} */ (value)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {unknown} value @param {KimiCredential} previous @param {number} currentTime @returns {KimiCredential} */
|
|
170
|
+
function tokenResponse(value, previous, currentTime) {
|
|
171
|
+
if (!record(value) || !safeToken(value.access_token, false) || !safeToken(value.refresh_token, false)
|
|
172
|
+
|| !Number.isFinite(Number(value.expires_in)) || Number(value.expires_in) <= 0) throw refreshUnavailable()
|
|
173
|
+
const expiresIn = Number(value.expires_in)
|
|
174
|
+
if (!Number.isSafeInteger(expiresIn) || expiresIn > 604_800) throw refreshUnavailable()
|
|
175
|
+
const scope = value.scope === undefined ? previous.scope : value.scope
|
|
176
|
+
const tokenType = value.token_type === undefined ? "Bearer" : value.token_type
|
|
177
|
+
if (typeof scope !== "string" || scope.length > 2048 || /[\0\r\n]/u.test(scope) || tokenType !== "Bearer") {
|
|
178
|
+
throw refreshUnavailable()
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
access_token: value.access_token,
|
|
182
|
+
refresh_token: value.refresh_token,
|
|
183
|
+
expires_at: currentTime + expiresIn,
|
|
184
|
+
scope,
|
|
185
|
+
token_type: "Bearer",
|
|
186
|
+
expires_in: expiresIn
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {string} file @param {number} expectedUid @param {KimiCredential} credential @param {((phase: string) => void | Promise<void>) | undefined} observer */
|
|
191
|
+
async function persistCredential(file, expectedUid, credential, observer) {
|
|
192
|
+
const directory = dirname(file)
|
|
193
|
+
const temporary = join(directory, `.kimi-code.json.tmp.${randomBytes(12).toString("hex")}`)
|
|
194
|
+
let handle
|
|
195
|
+
try {
|
|
196
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
197
|
+
await handle.writeFile(`${JSON.stringify(credential)}\n`)
|
|
198
|
+
await observer?.("before-file-sync")
|
|
199
|
+
await handle.sync()
|
|
200
|
+
await observer?.("after-file-sync")
|
|
201
|
+
await handle.close()
|
|
202
|
+
handle = undefined
|
|
203
|
+
await chmod(temporary, 0o600)
|
|
204
|
+
const metadata = await lstat(temporary, {bigint: true})
|
|
205
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1n
|
|
206
|
+
|| metadata.uid !== BigInt(expectedUid) || (metadata.mode & 0o777n) !== 0o600n) throw unavailable()
|
|
207
|
+
await observer?.("before-rename")
|
|
208
|
+
await rename(temporary, file)
|
|
209
|
+
await observer?.("after-rename")
|
|
210
|
+
const parent = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY)
|
|
211
|
+
try {
|
|
212
|
+
await observer?.("before-directory-sync")
|
|
213
|
+
await parent.sync()
|
|
214
|
+
await observer?.("after-directory-sync")
|
|
215
|
+
} finally { await parent.close() }
|
|
216
|
+
} catch (error) {
|
|
217
|
+
await handle?.close().catch(() => {})
|
|
218
|
+
await unlink(temporary).catch(() => {})
|
|
219
|
+
if (error instanceof Error && [needsLogin().message, refreshUnavailable().message].includes(error.message)) throw error
|
|
220
|
+
// Persistence errors may contain credential material or private paths; do not retain them as a cause.
|
|
221
|
+
// eslint-disable-next-line preserve-caught-error
|
|
222
|
+
throw new Error("Kimi OAuth credential persistence failed")
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @param {Response} response @param {AbortSignal | undefined} signal @returns {Promise<Buffer>} */
|
|
227
|
+
async function readCappedResponse(response, signal) {
|
|
228
|
+
if (response.body === null) return Buffer.alloc(0)
|
|
229
|
+
const reader = response.body.getReader()
|
|
230
|
+
/** @type {Buffer[]} */
|
|
231
|
+
const chunks = []
|
|
232
|
+
let size = 0
|
|
233
|
+
try {
|
|
234
|
+
while (true) {
|
|
235
|
+
if (signal?.aborted) throw signal.reason
|
|
236
|
+
const {done, value} = await reader.read()
|
|
237
|
+
if (done) break
|
|
238
|
+
size += value.byteLength
|
|
239
|
+
if (size > MAX_CREDENTIAL_BYTES) throw refreshUnavailable()
|
|
240
|
+
chunks.push(Buffer.from(value))
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
await reader.cancel().catch(() => {})
|
|
244
|
+
throw error
|
|
245
|
+
} finally { reader.releaseLock() }
|
|
246
|
+
return Buffer.concat(chunks)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @param {unknown} value @param {boolean} allowEmpty @returns {value is string} */
|
|
250
|
+
function safeToken(value, allowEmpty) {
|
|
251
|
+
return typeof value === "string" && (allowEmpty || value.length > 0)
|
|
252
|
+
&& value.length <= MAX_TOKEN_BYTES && !/[\0\r\n]/u.test(value)
|
|
253
|
+
}
|
|
254
|
+
/** @param {Record<string, unknown>} value @param {readonly string[]} expected @returns {boolean} */
|
|
255
|
+
function exactKeys(value, expected) {
|
|
256
|
+
const actual = Object.keys(value).sort()
|
|
257
|
+
const wanted = [...expected].sort()
|
|
258
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index])
|
|
259
|
+
}
|
|
260
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
261
|
+
function record(value) { return typeof value === "object" && value !== null && !Array.isArray(value) }
|
|
262
|
+
/** @returns {Error} */
|
|
263
|
+
function unavailable() { return new Error("Kimi OAuth credential file is unavailable") }
|
|
264
|
+
/** @returns {Error} */
|
|
265
|
+
function needsLogin() { return new Error("Kimi OAuth credential was rejected; re-login required") }
|
|
266
|
+
/** @param {unknown} [cause] @returns {Error} */
|
|
267
|
+
function refreshUnavailable(cause) { return new Error("Kimi OAuth refresh is unavailable", cause === undefined ? undefined : {cause}) }
|
package/src/providers/index.js
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import {buildClaudeCommand, claudeSessionId, createClaudeParser} from "./claude.js"
|
|
4
4
|
import {buildCodexCommand, codexSessionId, parseCodexEvent} from "./codex.js"
|
|
5
|
+
import {buildKimiCommand, createKimiParser, createKimiSessionId} from "./kimi.js"
|
|
5
6
|
import {buildOpenCodeCommand, createOpenCodeParser, createOpenCodeSessionId} from "./opencode.js"
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
* @typedef {{name: "codex" | "claude" | "opencode", executable: string, arguments: string[], parse: (record: unknown) => import("../types.js").WorkerEvent[], sessionId: (record: unknown) => string | undefined}} Provider
|
|
9
|
+
* @typedef {{name: "codex" | "claude" | "kimi" | "opencode", executable: string, arguments: string[], parse: (record: unknown) => import("../types.js").WorkerEvent[], sessionId: (record: unknown) => string | undefined}} Provider
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
|
-
/** @type {readonly ["codex", "claude", "opencode"]} */
|
|
12
|
-
export const PROVIDERS = ["codex", "claude", "opencode"]
|
|
12
|
+
/** @type {readonly ["codex", "claude", "kimi", "opencode"]} */
|
|
13
|
+
export const PROVIDERS = ["codex", "claude", "kimi", "opencode"]
|
|
13
14
|
|
|
14
15
|
/** @param {string} name @param {string[]} providerArguments @param {string} prompt @param {string | undefined} resumeSession @param {NodeJS.ProcessEnv} [environment] @returns {Provider} */
|
|
15
16
|
export function createProvider(name, providerArguments, prompt, resumeSession, environment = process.env) {
|
|
@@ -25,5 +26,9 @@ export function createProvider(name, providerArguments, prompt, resumeSession, e
|
|
|
25
26
|
const command = buildOpenCodeCommand(providerArguments, prompt, resumeSession, environment)
|
|
26
27
|
return {...command, name: "opencode", parse: createOpenCodeParser(), sessionId: createOpenCodeSessionId()}
|
|
27
28
|
}
|
|
29
|
+
if (name === "kimi") {
|
|
30
|
+
const command = buildKimiCommand(providerArguments, prompt, resumeSession)
|
|
31
|
+
return {...command, name: "kimi", parse: createKimiParser(), sessionId: createKimiSessionId()}
|
|
32
|
+
}
|
|
28
33
|
throw new Error(`--provider must be one of: ${PROVIDERS.join(", ")}`)
|
|
29
34
|
}
|