threadwire 0.1.6 → 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 +52 -0
- package/README.md +16 -5
- package/TELEGRAM-INGRESS.md +13 -3
- package/bin/isolated-runtime.js +5 -0
- package/bin/kimi-model-broker.js +5 -0
- package/bin/model-broker.js +5 -0
- package/docs/container-runtime.md +22 -0
- package/docs/isolated-provider-runtime.md +232 -0
- package/package.json +10 -3
- package/scripts/provider-shims/front-door.sh.template +18 -0
- package/scripts/verify-package.js +23 -1
- package/src/absolute-deadline.js +94 -0
- package/src/activity-log.js +3 -3
- package/src/cli.js +130 -25
- package/src/delegated-result-admission.js +1 -1
- package/src/docker-api.js +131 -0
- package/src/isolated-runtime-client.js +158 -0
- package/src/isolated-runtime.js +1117 -0
- package/src/isolated-state.js +470 -0
- package/src/isolated-worker.js +144 -0
- 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/model-broker-policy.js +139 -0
- package/src/model-broker.js +313 -0
- package/src/mount-policy.js +28 -0
- package/src/normalized-output.js +68 -0
- package/src/providers/index.js +8 -3
- package/src/providers/kimi.js +165 -0
- package/src/relay-write.js +44 -0
- package/src/telegram-ingress/command.js +4 -4
- package/src/telegram-ingress/config.js +18 -0
- package/src/telegram-ingress/core.js +65 -2
- package/src/telegram-ingress/http.js +2 -1
- package/src/telegram-webhook.js +11 -0
- package/src/workspace-profile.js +3 -3
- package/threadwire.workspace-profiles.json +1 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {randomBytes} from "node:crypto"
|
|
4
|
+
|
|
5
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/u
|
|
6
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u
|
|
7
|
+
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u
|
|
8
|
+
const ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u
|
|
9
|
+
const TASK_PATTERN = /^[0-9a-f]{64}$/u
|
|
10
|
+
const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u
|
|
11
|
+
|
|
12
|
+
/** @typedef {{provider: "kimi", runId: string, networkId: string, taskId: string, sessionId: string, modelAlias: string, model: string, expiresAt: number, active: boolean}} KimiGrant */
|
|
13
|
+
/** @typedef {{alias: string, model: string, protocol: "kimi"}} ApprovedKimiModel */
|
|
14
|
+
const ALLOWED_HEADERS = new Set([
|
|
15
|
+
"authorization", "content-length", "content-type", "accept", "accept-encoding",
|
|
16
|
+
"accept-language", "sec-fetch-mode", "user-agent",
|
|
17
|
+
"x-stainless-arch", "x-stainless-lang", "x-stainless-os",
|
|
18
|
+
"x-stainless-package-version", "x-stainless-runtime", "x-stainless-runtime-version",
|
|
19
|
+
"x-stainless-retry-count", "x-stainless-timeout", "x-stainless-async"
|
|
20
|
+
])
|
|
21
|
+
const BODY_KEYS = new Set([
|
|
22
|
+
"model", "messages", "stream", "stream_options", "tools", "thinking",
|
|
23
|
+
"max_completion_tokens", "temperature", "top_p", "n", "presence_penalty",
|
|
24
|
+
"frequency_penalty", "stop", "prompt_cache_key", "response_format"
|
|
25
|
+
])
|
|
26
|
+
const NUMERIC_KEYS = new Set([
|
|
27
|
+
"max_completion_tokens", "temperature", "top_p", "n", "presence_penalty", "frequency_penalty"
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
export const KIMI_CHAT_COMPLETIONS_PATH = "/coding/v1/chat/completions"
|
|
31
|
+
|
|
32
|
+
/** @param {{now?: () => number, random?: (bytes: number) => Buffer}} [options] */
|
|
33
|
+
export function createKimiGrantStore(options = {}) {
|
|
34
|
+
const now = options.now ?? Date.now
|
|
35
|
+
const random = options.random ?? randomBytes
|
|
36
|
+
/** @type {Map<string, KimiGrant>} */
|
|
37
|
+
const grants = new Map()
|
|
38
|
+
return {
|
|
39
|
+
/** @param {{provider: "kimi", runId: string, networkId: string, taskId: string, sessionId: string, modelAlias: string, model: string, ttlMs: number}} grant */
|
|
40
|
+
issue(grant) {
|
|
41
|
+
if (grant.provider !== "kimi" || !ID_PATTERN.test(grant.runId) || !ID_PATTERN.test(grant.networkId)
|
|
42
|
+
|| !TASK_PATTERN.test(grant.taskId) || !SESSION_PATTERN.test(grant.sessionId)
|
|
43
|
+
|| !ALIAS_PATTERN.test(grant.modelAlias) || !MODEL_PATTERN.test(grant.model)
|
|
44
|
+
|| !Number.isSafeInteger(grant.ttlMs) || grant.ttlMs < 1 || grant.ttlMs > 3_600_000) {
|
|
45
|
+
throw new Error("Invalid Kimi broker grant")
|
|
46
|
+
}
|
|
47
|
+
const token = random(32).toString("base64url")
|
|
48
|
+
if (!TOKEN_PATTERN.test(token) || grants.has(token)) throw new Error("Invalid Kimi broker grant")
|
|
49
|
+
grants.set(token, {...grant, expiresAt: now() + grant.ttlMs, active: false})
|
|
50
|
+
return token
|
|
51
|
+
},
|
|
52
|
+
/** @param {string} token */
|
|
53
|
+
activate(token) {
|
|
54
|
+
const grant = currentGrant(grants, token, now)
|
|
55
|
+
if (grant.active) throw new Error("Kimi broker grant already active")
|
|
56
|
+
grant.active = true
|
|
57
|
+
return {...grant}
|
|
58
|
+
},
|
|
59
|
+
/** @param {string} token @param {{provider: string, runId: string, networkId: string, taskId: string, sessionId: string, modelAlias: string}} context */
|
|
60
|
+
authorize(token, context) {
|
|
61
|
+
const grant = currentGrant(grants, token, now)
|
|
62
|
+
if (grant.provider !== context.provider || grant.runId !== context.runId
|
|
63
|
+
|| grant.networkId !== context.networkId || grant.taskId !== context.taskId
|
|
64
|
+
|| grant.sessionId !== context.sessionId || grant.modelAlias !== context.modelAlias) {
|
|
65
|
+
throw new Error("Kimi broker grant lineage denied")
|
|
66
|
+
}
|
|
67
|
+
if (!grant.active) throw new Error("Kimi broker grant pending")
|
|
68
|
+
return {...grant}
|
|
69
|
+
},
|
|
70
|
+
/** @param {string} token */
|
|
71
|
+
revoke(token) { grants.delete(token) },
|
|
72
|
+
clear() { grants.clear() }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {Map<string, KimiGrant>} grants
|
|
78
|
+
* @param {string} token
|
|
79
|
+
* @param {() => number} now
|
|
80
|
+
*/
|
|
81
|
+
function currentGrant(grants, token, now) {
|
|
82
|
+
const grant = grants.get(token)
|
|
83
|
+
if (!grant) throw new Error("Kimi broker grant unknown token")
|
|
84
|
+
if (grant.expiresAt < now()) {
|
|
85
|
+
grants.delete(token)
|
|
86
|
+
throw new Error("Kimi broker grant expired")
|
|
87
|
+
}
|
|
88
|
+
return grant
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {{method?: string, path?: string, headers?: Record<string, string | string[] | undefined>}} request */
|
|
92
|
+
export function validateKimiBrokerRequest(request) {
|
|
93
|
+
if (request.method !== "POST" || request.path !== "/v1/chat/completions") {
|
|
94
|
+
throw new Error("Kimi broker request denied")
|
|
95
|
+
}
|
|
96
|
+
for (const header of Object.keys(request.headers ?? {})) {
|
|
97
|
+
if (!ALLOWED_HEADERS.has(header.toLowerCase())) throw new Error(`Kimi broker request denied header: ${header.toLowerCase()}`)
|
|
98
|
+
}
|
|
99
|
+
return {method: "POST", path: "/v1/chat/completions", upstreamPath: KIMI_CHAT_COMPLETIONS_PATH}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param {Buffer} body @param {string} allowedModel */
|
|
103
|
+
export function validateKimiChatBody(body, allowedModel) {
|
|
104
|
+
const text = body.toString("utf8")
|
|
105
|
+
if (topLevelKeyCount(text, "model") !== 1) throw denied()
|
|
106
|
+
let value
|
|
107
|
+
try { value = JSON.parse(text) } catch { throw denied() }
|
|
108
|
+
if (!record(value) || Object.keys(value).some((key) => !BODY_KEYS.has(key))) throw denied()
|
|
109
|
+
if (value.model !== allowedModel || !Array.isArray(value.messages) || value.stream !== true) throw denied()
|
|
110
|
+
if (value.messages.length === 0 || value.messages.length > 4096 || !value.messages.every(validMessage)) throw denied()
|
|
111
|
+
if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.length > 512 || !value.tools.every(record))) throw denied()
|
|
112
|
+
if (value.stream_options !== undefined && (!record(value.stream_options)
|
|
113
|
+
|| !exactKeys(value.stream_options, ["include_usage"]) || value.stream_options.include_usage !== true)) throw denied()
|
|
114
|
+
if (value.thinking !== undefined && !validThinking(value.thinking)) throw denied()
|
|
115
|
+
if (value.response_format !== undefined && !record(value.response_format)) throw denied()
|
|
116
|
+
if (value.prompt_cache_key !== undefined && (typeof value.prompt_cache_key !== "string" || value.prompt_cache_key.length > 512)) throw denied()
|
|
117
|
+
if (value.stop !== undefined && !validStop(value.stop)) throw denied()
|
|
118
|
+
for (const key of NUMERIC_KEYS) {
|
|
119
|
+
const item = value[key]
|
|
120
|
+
if (item !== undefined && (typeof item !== "number" || !Number.isFinite(item))) throw denied()
|
|
121
|
+
}
|
|
122
|
+
if (value.max_completion_tokens !== undefined && (typeof value.max_completion_tokens !== "number"
|
|
123
|
+
|| !Number.isSafeInteger(value.max_completion_tokens)
|
|
124
|
+
|| value.max_completion_tokens < 1 || value.max_completion_tokens > 1_000_000)) throw denied()
|
|
125
|
+
if (value.n !== undefined && value.n !== 1) throw denied()
|
|
126
|
+
return value
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @param {unknown} value */
|
|
130
|
+
function validMessage(value) {
|
|
131
|
+
if (!record(value) || typeof value.role !== "string" || !["system", "user", "assistant", "tool"].includes(value.role)) return false
|
|
132
|
+
const allowed = new Set(["role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content", "reasoning_details", "reasoning", "tools"])
|
|
133
|
+
return Object.keys(value).every((key) => allowed.has(key))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @param {unknown} value */
|
|
137
|
+
function validThinking(value) {
|
|
138
|
+
if (!record(value) || Object.keys(value).some((key) => !["type", "effort", "keep"].includes(key))) return false
|
|
139
|
+
if (value.type !== undefined && value.type !== "enabled" && value.type !== "disabled") return false
|
|
140
|
+
if (value.effort !== undefined && (typeof value.effort !== "string" || !/^[A-Za-z0-9_-]{1,32}$/u.test(value.effort))) return false
|
|
141
|
+
return value.keep === undefined || value.keep === "all"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {unknown} value */
|
|
145
|
+
function validStop(value) {
|
|
146
|
+
return typeof value === "string" ? value.length <= 512
|
|
147
|
+
: Array.isArray(value) && value.length <= 16 && value.every((item) => typeof item === "string" && item.length <= 512)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @param {string | undefined} authorization */
|
|
151
|
+
export function kimiBearerToken(authorization) {
|
|
152
|
+
const match = /^Bearer ([A-Za-z0-9_-]{32,128})$/u.exec(authorization ?? "")
|
|
153
|
+
if (!match || !TOKEN_PATTERN.test(match[1] ?? "")) throw new Error("Kimi broker grant format denied")
|
|
154
|
+
return /** @type {string} */ (match[1])
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @param {string | undefined} value */
|
|
158
|
+
export function parseApprovedKimiModels(value) {
|
|
159
|
+
let parsed
|
|
160
|
+
try { parsed = JSON.parse(value ?? "") } catch { throw new Error("THREADWIRE_ALLOWED_KIMI_MODELS must contain allowed Kimi models") }
|
|
161
|
+
if (!record(parsed) || Object.keys(parsed).length < 1 || Object.keys(parsed).length > 32) {
|
|
162
|
+
throw new Error("THREADWIRE_ALLOWED_KIMI_MODELS must contain allowed Kimi models")
|
|
163
|
+
}
|
|
164
|
+
/** @type {Map<string, ApprovedKimiModel>} */
|
|
165
|
+
const models = new Map()
|
|
166
|
+
for (const [alias, item] of Object.entries(parsed)) {
|
|
167
|
+
if (!ALIAS_PATTERN.test(alias) || !record(item) || !exactKeys(item, ["model", "protocol"])
|
|
168
|
+
|| typeof item.model !== "string" || !MODEL_PATTERN.test(item.model) || item.protocol !== "kimi") {
|
|
169
|
+
throw new Error("THREADWIRE_ALLOWED_KIMI_MODELS must contain allowed Kimi models")
|
|
170
|
+
}
|
|
171
|
+
models.set(alias, {alias, model: item.model, protocol: "kimi"})
|
|
172
|
+
}
|
|
173
|
+
return models
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @returns {Error} */
|
|
177
|
+
function denied() { return new Error("Kimi broker request denied") }
|
|
178
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
179
|
+
function record(value) { return typeof value === "object" && value !== null && !Array.isArray(value) }
|
|
180
|
+
/** @param {Record<string, unknown>} value @param {readonly string[]} keys */
|
|
181
|
+
function exactKeys(value, keys) {
|
|
182
|
+
const actual = Object.keys(value).sort()
|
|
183
|
+
return actual.length === keys.length && actual.every((key, index) => key === [...keys].sort()[index])
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @param {string} text @param {string} wanted @returns {number} */
|
|
187
|
+
function topLevelKeyCount(text, wanted) {
|
|
188
|
+
let depth = 0
|
|
189
|
+
let count = 0
|
|
190
|
+
let index = 0
|
|
191
|
+
while (index < text.length) {
|
|
192
|
+
if (text[index] === "\"") {
|
|
193
|
+
let string = ""
|
|
194
|
+
index++
|
|
195
|
+
while (index < text.length && text[index] !== "\"") {
|
|
196
|
+
if (text[index] === "\\") { index += 2; continue }
|
|
197
|
+
string += text[index++]
|
|
198
|
+
}
|
|
199
|
+
if (depth === 1) {
|
|
200
|
+
let next = index + 1
|
|
201
|
+
while (/\s/u.test(text[next] ?? "")) next++
|
|
202
|
+
if (text[next] === ":" && string === wanted) count++
|
|
203
|
+
}
|
|
204
|
+
} else if (text[index] === "{") depth++
|
|
205
|
+
else if (text[index] === "}") depth--
|
|
206
|
+
index++
|
|
207
|
+
}
|
|
208
|
+
return count
|
|
209
|
+
}
|
|
@@ -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
|
+
}
|