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,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}) }
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {randomBytes} from "node:crypto"
|
|
4
|
+
|
|
5
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/u
|
|
6
|
+
const NETWORK_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u
|
|
7
|
+
const RUN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u
|
|
8
|
+
const ALLOWED_HEADERS = new Set([
|
|
9
|
+
"authorization", "content-length", "content-type", "user-agent", "accept",
|
|
10
|
+
"accept-encoding", "accept-language", "sec-fetch-mode",
|
|
11
|
+
"x-stainless-arch", "x-stainless-lang", "x-stainless-os",
|
|
12
|
+
"x-stainless-package-version", "x-stainless-runtime",
|
|
13
|
+
"x-stainless-runtime-version", "x-stainless-retry-count", "x-stainless-timeout",
|
|
14
|
+
"x-stainless-async", "originator", "conversation_id", "session_id",
|
|
15
|
+
"version", "x-codex-turn-metadata"
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/** @param {{now?: () => number, random?: (bytes: number) => Buffer}} [options] */
|
|
19
|
+
export function createGrantStore(options = {}) {
|
|
20
|
+
const now = options.now ?? Date.now
|
|
21
|
+
const random = options.random ?? randomBytes
|
|
22
|
+
/** @type {Map<string, {runId: string, provider: "codex", networkId: string, model: string, expiresAt: number, active: boolean}>} */
|
|
23
|
+
const grants = new Map()
|
|
24
|
+
return {
|
|
25
|
+
/** @param {{runId: string, provider: "codex", networkId: string, model: string, ttlMs: number}} grant */
|
|
26
|
+
issue(grant) {
|
|
27
|
+
if (!RUN_PATTERN.test(grant.runId) || !NETWORK_PATTERN.test(grant.networkId)) throw new Error("Invalid broker grant")
|
|
28
|
+
if (grant.provider !== "codex") throw new Error("Invalid broker grant")
|
|
29
|
+
if (typeof grant.model !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(grant.model)) throw new Error("Invalid broker grant")
|
|
30
|
+
if (!Number.isSafeInteger(grant.ttlMs) || grant.ttlMs < 1 || grant.ttlMs > 3_600_000) throw new Error("Invalid broker grant")
|
|
31
|
+
const token = random(32).toString("base64url")
|
|
32
|
+
grants.set(token, {...grant, expiresAt: now() + grant.ttlMs, active: false})
|
|
33
|
+
return token
|
|
34
|
+
},
|
|
35
|
+
/** @param {string} token */
|
|
36
|
+
activate(token) {
|
|
37
|
+
const grant = grants.get(token)
|
|
38
|
+
if (!grant) throw new Error("Broker grant unknown token")
|
|
39
|
+
if (grant.expiresAt < now()) {
|
|
40
|
+
grants.delete(token)
|
|
41
|
+
throw new Error("Broker grant expired")
|
|
42
|
+
}
|
|
43
|
+
if (grant.active) throw new Error("Broker grant already active")
|
|
44
|
+
grant.active = true
|
|
45
|
+
return {...grant}
|
|
46
|
+
},
|
|
47
|
+
/** @param {string} token @param {{provider: string, networkId: string, runId?: string}} context */
|
|
48
|
+
authorize(token, context) {
|
|
49
|
+
const grant = grants.get(token)
|
|
50
|
+
if (!grant) throw new Error("Broker grant unknown token")
|
|
51
|
+
if (grant.provider !== context.provider || grant.networkId !== context.networkId || (context.runId !== undefined && grant.runId !== context.runId)) throw new Error("Broker grant lineage denied")
|
|
52
|
+
if (!grant.active) throw new Error("Broker grant pending")
|
|
53
|
+
if (grant.expiresAt < now()) {
|
|
54
|
+
grants.delete(token)
|
|
55
|
+
throw new Error("Broker grant expired")
|
|
56
|
+
}
|
|
57
|
+
return {...grant}
|
|
58
|
+
},
|
|
59
|
+
/** @param {string} token */
|
|
60
|
+
revoke(token) {
|
|
61
|
+
grants.delete(token)
|
|
62
|
+
},
|
|
63
|
+
clear() {
|
|
64
|
+
grants.clear()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {{method?: string, path?: string, headers?: Record<string, string | string[] | undefined>}} request
|
|
71
|
+
*/
|
|
72
|
+
export function validateBrokerRequest(request) {
|
|
73
|
+
if (request.method !== "POST" || request.path !== "/v1/responses") throw new Error("Broker request denied")
|
|
74
|
+
for (const header of Object.keys(request.headers ?? {})) {
|
|
75
|
+
if (!ALLOWED_HEADERS.has(header.toLowerCase())) throw new Error(`Broker request denied header: ${header.toLowerCase()}`)
|
|
76
|
+
}
|
|
77
|
+
return {method: "POST", path: "/v1/responses"}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const RESPONSE_KEYS = new Set([
|
|
81
|
+
"model", "input", "instructions", "stream", "tools", "tool_choice",
|
|
82
|
+
"parallel_tool_calls", "previous_response_id", "reasoning", "store",
|
|
83
|
+
"include", "metadata", "max_output_tokens", "temperature", "top_p", "truncation",
|
|
84
|
+
"prompt_cache_key"
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
/** @param {Buffer} body @param {string} allowedModel */
|
|
88
|
+
export function validateResponsesBody(body, allowedModel) {
|
|
89
|
+
const text = body.toString("utf8")
|
|
90
|
+
if (topLevelKeyCount(text, "model") !== 1) throw new Error("Broker request denied")
|
|
91
|
+
let value
|
|
92
|
+
try {
|
|
93
|
+
value = JSON.parse(text)
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error("Broker request denied")
|
|
96
|
+
}
|
|
97
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Broker request denied")
|
|
98
|
+
const unexpected = Object.keys(value).find((key) => !RESPONSE_KEYS.has(key))
|
|
99
|
+
if (unexpected !== undefined) throw new Error(`Broker request denied field: ${unexpected}`)
|
|
100
|
+
if (value.model !== allowedModel || !("input" in value)) throw new Error("Broker request denied")
|
|
101
|
+
return value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @param {string} text @param {string} wanted */
|
|
105
|
+
function topLevelKeyCount(text, wanted) {
|
|
106
|
+
let depth = 0
|
|
107
|
+
let count = 0
|
|
108
|
+
let index = 0
|
|
109
|
+
while (index < text.length) {
|
|
110
|
+
const character = text[index]
|
|
111
|
+
if (character === "\"") {
|
|
112
|
+
let string = ""
|
|
113
|
+
index += 1
|
|
114
|
+
while (index < text.length && text[index] !== "\"") {
|
|
115
|
+
if (text[index] === "\\") {
|
|
116
|
+
index += 2
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
string += text[index]
|
|
120
|
+
index += 1
|
|
121
|
+
}
|
|
122
|
+
if (depth === 1) {
|
|
123
|
+
let next = index + 1
|
|
124
|
+
while (/\s/u.test(text[next] ?? "")) next += 1
|
|
125
|
+
if (text[next] === ":" && string === wanted) count += 1
|
|
126
|
+
}
|
|
127
|
+
} else if (character === "{") depth += 1
|
|
128
|
+
else if (character === "}") depth -= 1
|
|
129
|
+
index += 1
|
|
130
|
+
}
|
|
131
|
+
return count
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @param {string | undefined} authorization */
|
|
135
|
+
export function bearerToken(authorization) {
|
|
136
|
+
const match = /^Bearer ([A-Za-z0-9_-]{32,128})$/u.exec(authorization ?? "")
|
|
137
|
+
if (!match || !TOKEN_PATTERN.test(match[1] ?? "")) throw new Error("Broker grant format denied")
|
|
138
|
+
return /** @type {string} */ (match[1])
|
|
139
|
+
}
|