dsh-bailinghub 0.6.0 → 0.7.0
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 +5 -0
- package/README.md +6 -6
- package/docs/COMPATIBILITY.md +7 -7
- package/docs/README.zh-CN.md +6 -6
- package/docs/RELEASE_NOTES_v0.7.0.en.md +15 -0
- package/docs/RELEASE_NOTES_v0.7.0.md +15 -0
- package/docs/UPGRADE_v0.7.0.en.md +15 -0
- package/docs/UPGRADE_v0.7.0.md +15 -0
- package/docs/USAGE_SERVICE.md +96 -0
- package/lib/index.js +9 -0
- package/lib/invocation-journal.js +5 -0
- package/lib/model-gateway-transport.js +246 -0
- package/lib/runtime.js +24 -0
- package/lib/session-usage-store.js +280 -0
- package/lib/usage-llm-stream.js +157 -0
- package/lib/usage-model-transport.js +206 -0
- package/lib/usage-provider-stream.js +82 -0
- package/package.json +9 -3
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
7
|
+
import { isProxy } from 'node:util/types'
|
|
8
|
+
|
|
9
|
+
const SCHEMAS = new Set(['bailing.usage-session.v1'])
|
|
10
|
+
const RECORD_KEYS = new Set(['schema', 'sessionId', 'revision', 'entries'])
|
|
11
|
+
const MAX_BYTES = 8 * 1024 * 1024
|
|
12
|
+
const LOCK_TIMEOUT_MS = 1_000
|
|
13
|
+
const PRIVATE_KEYS = new Set([
|
|
14
|
+
'token', 'tokens', 'accesstoken', 'refreshtoken', 'password', 'clientsecret',
|
|
15
|
+
'authorization', 'credential', 'credentials', 'secret', 'apikey',
|
|
16
|
+
'args', 'arguments', 'content', 'contents', 'body', 'prompt', 'messages',
|
|
17
|
+
])
|
|
18
|
+
const MAX_DEPTH = 128
|
|
19
|
+
|
|
20
|
+
function storeError(code = 'USAGE_STORE_UNAVAILABLE') {
|
|
21
|
+
const error = new Error(code === 'USAGE_STORE_CONFLICT'
|
|
22
|
+
? 'The usage binding changed or is being saved. Read it again before retrying.'
|
|
23
|
+
: 'The usage binding store is unavailable. Recovery metadata was not safely confirmed.')
|
|
24
|
+
error.code = code
|
|
25
|
+
return error
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeError(error) {
|
|
29
|
+
return ['USAGE_STORE_CONFLICT', 'USAGE_STORE_UNAVAILABLE'].includes(error?.code)
|
|
30
|
+
? error : storeError()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sessionKey(sessionId) {
|
|
34
|
+
if (typeof sessionId !== 'string' || !sessionId.length || sessionId.length > 4_096) throw storeError()
|
|
35
|
+
return createHash('sha256').update(sessionId).digest('hex')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The coordinator validates each original execution binding. This boundary accepts
|
|
39
|
+
// only JSON metadata and refuses credential or business payload fields. Tool names
|
|
40
|
+
// are values, so a tool named "content" or "arguments" remains valid metadata.
|
|
41
|
+
function snapshotJson(value, seen = new Set(), depth = 0) {
|
|
42
|
+
if (depth > MAX_DEPTH) throw storeError()
|
|
43
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
|
44
|
+
if (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) return value
|
|
45
|
+
if (!value || typeof value !== 'object' || seen.has(value) || isProxy(value)) throw storeError()
|
|
46
|
+
const array = Array.isArray(value)
|
|
47
|
+
if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw storeError()
|
|
48
|
+
seen.add(value)
|
|
49
|
+
try {
|
|
50
|
+
const entries = Reflect.ownKeys(value).filter((key) => !array || key !== 'length')
|
|
51
|
+
if (array && (entries.length !== value.length || entries.some((key, index) => key !== String(index)))) throw storeError()
|
|
52
|
+
const copy = array ? [] : {}
|
|
53
|
+
for (const key of entries) {
|
|
54
|
+
const property = Object.getOwnPropertyDescriptor(value, key)
|
|
55
|
+
if (typeof key !== 'string' || !property.enumerable || !Object.hasOwn(property, 'value') ||
|
|
56
|
+
PRIVATE_KEYS.has(key.replace(/[_-]/gu, '').toLowerCase()) || ['__proto__', 'constructor', 'prototype'].includes(key)) throw storeError()
|
|
57
|
+
copy[key] = snapshotJson(property.value, seen, depth + 1)
|
|
58
|
+
}
|
|
59
|
+
return copy
|
|
60
|
+
} finally {
|
|
61
|
+
seen.delete(value)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function snapshotRecord(sessionId, record) {
|
|
66
|
+
const copy = snapshotJson(record)
|
|
67
|
+
if (!copy || Array.isArray(copy) || !SCHEMAS.has(copy.schema) || copy.sessionId !== sessionId ||
|
|
68
|
+
!Number.isSafeInteger(copy.revision) || copy.revision < 1 || !Array.isArray(copy.entries) ||
|
|
69
|
+
Object.keys(copy).some((key) => !RECORD_KEYS.has(key))) throw storeError()
|
|
70
|
+
const request = copy.entries[0]?.request
|
|
71
|
+
if (copy.entries.length !== 1 || !request || request.metadata?.schema !== 'bailing.model-request.v1'
|
|
72
|
+
|| typeof request.metadata.operationId !== 'string' || !copy.sessionId.startsWith('model:')
|
|
73
|
+
|| !['prepared', 'completed', 'cancelled', 'rejected', 'unknown', 'closed_unresolved'].includes(request.state)
|
|
74
|
+
|| Object.keys(copy.entries[0]).some(key => key !== 'request')) throw storeError()
|
|
75
|
+
if (Buffer.byteLength(JSON.stringify(copy)) > MAX_BYTES) throw storeError()
|
|
76
|
+
return copy
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function nextRecord(sessionId, record, expectedRevision) {
|
|
80
|
+
sessionKey(sessionId)
|
|
81
|
+
if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)) throw storeError()
|
|
82
|
+
const copy = snapshotRecord(sessionId, record)
|
|
83
|
+
if (copy.revision !== (expectedRevision ?? 0) + 1) throw storeError()
|
|
84
|
+
return copy
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertRevision(current, expectedRevision) {
|
|
88
|
+
if ((current?.revision ?? null) !== expectedRevision) throw storeError('USAGE_STORE_CONFLICT')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function assertOwned(stat, kind) {
|
|
92
|
+
if (!(kind === 'directory' ? stat.isDirectory() : stat.isFile()) ||
|
|
93
|
+
(kind === 'file' && stat.nlink !== 1) ||
|
|
94
|
+
(typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
|
|
95
|
+
(process.platform !== 'win32' && (stat.mode & 0o777) !== (kind === 'directory' ? 0o700 : 0o600))) throw storeError()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sameFile(a, b) {
|
|
99
|
+
return a.dev === b.dev && a.ino === b.ino
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function defaultDirectory() {
|
|
103
|
+
const configured = process.env.DSH_HOME
|
|
104
|
+
const base = typeof configured === 'string' && configured.trim() ? configured : join(homedir(), '.dsh')
|
|
105
|
+
const expanded = base === '~' ? homedir() : /^~[/\\]/u.test(base) ? join(homedir(), base.slice(2)) : base
|
|
106
|
+
return join(resolve(expanded), 'plugins', 'dsh-bailinghub', 'session-usage')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function ensureDirectory(directory, create) {
|
|
110
|
+
if (create) await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
111
|
+
let stat
|
|
112
|
+
try {
|
|
113
|
+
stat = await lstat(directory)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (!create && error.code === 'ENOENT') return false
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
assertOwned(stat, 'directory')
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readRecord(path, sessionId) {
|
|
123
|
+
let before
|
|
124
|
+
try {
|
|
125
|
+
before = await lstat(path)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error.code === 'ENOENT') return null
|
|
128
|
+
throw error
|
|
129
|
+
}
|
|
130
|
+
assertOwned(before, 'file')
|
|
131
|
+
if (before.size > MAX_BYTES) throw storeError()
|
|
132
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
133
|
+
try {
|
|
134
|
+
const opened = await file.stat()
|
|
135
|
+
assertOwned(opened, 'file')
|
|
136
|
+
if (!sameFile(before, opened) || opened.size > MAX_BYTES) throw storeError()
|
|
137
|
+
// Bound the read even if an abnormal writer grows the file after fstat.
|
|
138
|
+
const bytes = Buffer.alloc(MAX_BYTES + 1)
|
|
139
|
+
let length = 0
|
|
140
|
+
while (length < bytes.length) {
|
|
141
|
+
const result = await file.read(bytes, length, bytes.length - length, length)
|
|
142
|
+
if (!result.bytesRead) break
|
|
143
|
+
length += result.bytesRead
|
|
144
|
+
}
|
|
145
|
+
if (length > MAX_BYTES) throw storeError()
|
|
146
|
+
return snapshotRecord(sessionId, JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length))))
|
|
147
|
+
} finally {
|
|
148
|
+
await file.close()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function acquireLock(path) {
|
|
153
|
+
const deadline = performance.now() + LOCK_TIMEOUT_MS
|
|
154
|
+
while (true) {
|
|
155
|
+
let file
|
|
156
|
+
let stat
|
|
157
|
+
try {
|
|
158
|
+
file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
159
|
+
stat = await file.stat()
|
|
160
|
+
await file.chmod(0o600)
|
|
161
|
+
assertOwned(await file.stat(), 'file')
|
|
162
|
+
return { file, stat }
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (file) {
|
|
165
|
+
if (stat) await releaseLock(path, { file, stat })
|
|
166
|
+
else await file.close().catch(() => {})
|
|
167
|
+
}
|
|
168
|
+
if (error.code !== 'EEXIST') throw error
|
|
169
|
+
try {
|
|
170
|
+
assertOwned(await lstat(path), 'file')
|
|
171
|
+
} catch (inspectionError) {
|
|
172
|
+
if (inspectionError.code === 'ENOENT') continue
|
|
173
|
+
throw inspectionError
|
|
174
|
+
}
|
|
175
|
+
if (performance.now() >= deadline) throw storeError('USAGE_STORE_CONFLICT')
|
|
176
|
+
// Never infer staleness or remove another process's lock.
|
|
177
|
+
await delay(20)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function releaseLock(path, lock) {
|
|
183
|
+
try {
|
|
184
|
+
const current = await lstat(path)
|
|
185
|
+
if (!sameFile(current, lock.stat) || !current.isFile()) throw storeError()
|
|
186
|
+
await unlink(path)
|
|
187
|
+
} finally {
|
|
188
|
+
await lock.file.close()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function syncDirectory(directory) {
|
|
193
|
+
// Windows does not expose fsync on directory handles through Node.
|
|
194
|
+
if (process.platform === 'win32') return
|
|
195
|
+
const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
|
196
|
+
try {
|
|
197
|
+
assertOwned(await handle.stat(), 'directory')
|
|
198
|
+
await handle.sync()
|
|
199
|
+
} finally {
|
|
200
|
+
await handle.close()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Durable model request metadata only. No user directory is touched until load/save. */
|
|
205
|
+
export function createFileSessionUsageStore({ directory } = {}) {
|
|
206
|
+
if (directory !== undefined && (typeof directory !== 'string' || !directory.trim())) throw storeError()
|
|
207
|
+
const root = directory === undefined ? defaultDirectory() : resolve(directory)
|
|
208
|
+
return {
|
|
209
|
+
async load(sessionId) {
|
|
210
|
+
try {
|
|
211
|
+
const key = sessionKey(sessionId)
|
|
212
|
+
if (!await ensureDirectory(root, false)) return null
|
|
213
|
+
return await readRecord(join(root, `${key}.json`), sessionId)
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw safeError(error)
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
async save(sessionId, record, expectedRevision) {
|
|
219
|
+
let lock
|
|
220
|
+
let temporaryPath
|
|
221
|
+
const key = sessionKey(sessionId)
|
|
222
|
+
const path = join(root, `${key}.json`)
|
|
223
|
+
const lockPath = join(root, `${key}.lock`)
|
|
224
|
+
try {
|
|
225
|
+
const copy = nextRecord(sessionId, record, expectedRevision)
|
|
226
|
+
await ensureDirectory(root, true)
|
|
227
|
+
lock = await acquireLock(lockPath)
|
|
228
|
+
assertRevision(await readRecord(path, sessionId), expectedRevision)
|
|
229
|
+
temporaryPath = join(root, `.${key}.${randomUUID()}.tmp`)
|
|
230
|
+
const temporary = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
231
|
+
try {
|
|
232
|
+
await temporary.chmod(0o600)
|
|
233
|
+
assertOwned(await temporary.stat(), 'file')
|
|
234
|
+
await temporary.writeFile(JSON.stringify(copy), 'utf8')
|
|
235
|
+
await temporary.sync()
|
|
236
|
+
} finally {
|
|
237
|
+
await temporary.close()
|
|
238
|
+
}
|
|
239
|
+
await rename(temporaryPath, path)
|
|
240
|
+
temporaryPath = undefined
|
|
241
|
+
await syncDirectory(root)
|
|
242
|
+
return snapshotRecord(sessionId, copy)
|
|
243
|
+
} catch (error) {
|
|
244
|
+
throw safeError(error)
|
|
245
|
+
} finally {
|
|
246
|
+
try {
|
|
247
|
+
if (temporaryPath) await unlink(temporaryPath).catch((error) => {
|
|
248
|
+
if (error.code !== 'ENOENT') throw storeError()
|
|
249
|
+
})
|
|
250
|
+
} finally {
|
|
251
|
+
if (lock) {
|
|
252
|
+
try {
|
|
253
|
+
await releaseLock(lockPath, lock)
|
|
254
|
+
} catch {
|
|
255
|
+
throw storeError()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Explicit in-memory adapter: CAS-compatible, but never survives process restart. */
|
|
265
|
+
export function createMemorySessionUsageStore() {
|
|
266
|
+
const records = new Map()
|
|
267
|
+
return {
|
|
268
|
+
async load(sessionId) {
|
|
269
|
+
sessionKey(sessionId)
|
|
270
|
+
const record = records.get(sessionId)
|
|
271
|
+
return record ? snapshotRecord(sessionId, record) : null
|
|
272
|
+
},
|
|
273
|
+
async save(sessionId, record, expectedRevision) {
|
|
274
|
+
const copy = nextRecord(sessionId, record, expectedRevision)
|
|
275
|
+
assertRevision(records.get(sessionId), expectedRevision)
|
|
276
|
+
records.set(sessionId, copy)
|
|
277
|
+
return snapshotRecord(sessionId, copy)
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { usageNativeModelChunks } from './usage-provider-stream.js'
|
|
2
|
+
|
|
3
|
+
const fail = (code = 'USAGE_STREAM_INCOMPLETE') => Object.assign(new Error(`Model gateway response is unavailable (${code}).`), {
|
|
4
|
+
code, feedback: { schema: 'bailing.usage-feedback.v1', code, dispatch: 'unknown', next_action: 'inspect_original', retryable: true },
|
|
5
|
+
})
|
|
6
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value)
|
|
7
|
+
|
|
8
|
+
/** Convert gateway events to the native DSH model stream. No planning or tool execution lives here.
|
|
9
|
+
* Preview text is incremental. Tool blocks and finish are exposed only after an active durable receipt.
|
|
10
|
+
*/
|
|
11
|
+
async function* legacyChunks(events) {
|
|
12
|
+
const textBlocks = new Map(); let index = 0, ended = false
|
|
13
|
+
function block(type) {
|
|
14
|
+
let value = textBlocks.get(type)
|
|
15
|
+
if (!value) { value = { index: index++, text: '', started: false }; textBlocks.set(type, value) }
|
|
16
|
+
return value
|
|
17
|
+
}
|
|
18
|
+
for await (const event of events) {
|
|
19
|
+
if (ended) throw fail()
|
|
20
|
+
if (event.type === 'delta') {
|
|
21
|
+
for (const [key, type] of [['reasoning_content', 'reasoning'], ['content', 'text']]) {
|
|
22
|
+
const value = event.delta?.[key]
|
|
23
|
+
if (value == null || value === '') continue
|
|
24
|
+
if (typeof value !== 'string') throw fail()
|
|
25
|
+
const part = block(type)
|
|
26
|
+
if (!part.started) { part.started = true; yield { type: 'block-start', index: part.index, blockType: type } }
|
|
27
|
+
part.text += value
|
|
28
|
+
yield { type: type === 'text' ? 'text-delta' : 'reasoning-delta', index: part.index, text: value }
|
|
29
|
+
}
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (event.type !== 'operation') continue
|
|
33
|
+
const receipt = event.operation
|
|
34
|
+
if (receipt?.result_state === 'failed' && receipt.delivery_state !== 'cancelled_late_result') throw Object.assign(new Error(receipt.error?.message ?? 'Model service rejected the request.'), {code: 'USAGE_PROVIDER_REJECTED', operationId: receipt.operation_id, feedback: {schema:'bailing.usage-feedback.v1',code:'USAGE_PROVIDER_REJECTED',dispatch:'rejected',next_action:'contact_operator',retryable:false}})
|
|
35
|
+
if (receipt?.delivery_state !== 'response_ready' || receipt.host_turn_active !== true) throw fail(
|
|
36
|
+
receipt?.delivery_state === 'cancelled_late_result' ? 'USAGE_CANCELLED' : receipt?.response_expired === true ? 'USAGE_ORIGINAL_RESULT_EXPIRED' : 'USAGE_ORIGINAL_PENDING')
|
|
37
|
+
const response = receipt.response, choice = response?.choices?.[0], message = choice?.message
|
|
38
|
+
if (!Array.isArray(response?.choices) || !object(message)
|
|
39
|
+
|| !['stop', 'length', 'tool_calls'].includes(choice.finish_reason)) throw fail('USAGE_RESPONSE_INVALID')
|
|
40
|
+
const calls = message.tool_calls ?? [], ids = new Set()
|
|
41
|
+
if (!Array.isArray(calls)) throw fail('USAGE_RESPONSE_INVALID')
|
|
42
|
+
for (const call of calls) {
|
|
43
|
+
if (call?.type !== 'function' || typeof call.id !== 'string' || !call.id || ids.has(call.id)
|
|
44
|
+
|| typeof call.function?.name !== 'string' || !call.function.name || typeof call.function.arguments !== 'string') throw fail('USAGE_RESPONSE_INVALID')
|
|
45
|
+
ids.add(call.id)
|
|
46
|
+
}
|
|
47
|
+
// Validate the whole final response against displayed previews before completing any native block.
|
|
48
|
+
for (const [key, type] of [['reasoning_content', 'reasoning'], ['content', 'text']]) {
|
|
49
|
+
const value = message[key] ?? ''
|
|
50
|
+
if (typeof value !== 'string' || !value.startsWith(textBlocks.get(type)?.text ?? '')) throw fail('USAGE_RESPONSE_INVALID')
|
|
51
|
+
}
|
|
52
|
+
for (const [key, type] of [['reasoning_content', 'reasoning'], ['content', 'text']]) {
|
|
53
|
+
const value = message[key] ?? ''
|
|
54
|
+
if (!value && !textBlocks.has(type)) continue
|
|
55
|
+
const part = block(type)
|
|
56
|
+
if (!part.started) yield { type: 'block-start', index: part.index, blockType: type }
|
|
57
|
+
if (value.length > part.text.length) yield { type: type === 'text' ? 'text-delta' : 'reasoning-delta', index: part.index, text: value.slice(part.text.length) }
|
|
58
|
+
yield { type: 'block-end', index: part.index, block: { type, text: value } }
|
|
59
|
+
}
|
|
60
|
+
for (const call of calls) {
|
|
61
|
+
const i = index++, value = { type: 'tool-call', id: call.id, name: call.function.name, arguments: call.function.arguments }
|
|
62
|
+
yield { type: 'block-start', index: i, blockType: 'tool-call' }
|
|
63
|
+
yield { type: 'tool-call-delta', index: i, id: value.id, name: value.name, argumentsDelta: value.arguments }
|
|
64
|
+
yield { type: 'block-end', index: i, block: value }
|
|
65
|
+
}
|
|
66
|
+
const usage = response.usage, count = value => Number.isSafeInteger(value) && value >= 0
|
|
67
|
+
if (count(usage?.prompt_tokens) && count(usage?.completion_tokens)) {
|
|
68
|
+
const cached = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens ?? 0
|
|
69
|
+
if (count(cached) && cached <= usage.prompt_tokens) yield { type: 'usage', usage: {
|
|
70
|
+
inputTokens: usage.prompt_tokens - cached, outputTokens: usage.completion_tokens, ...(cached ? { cacheReadTokens: cached } : {}),
|
|
71
|
+
} }
|
|
72
|
+
}
|
|
73
|
+
ended = true
|
|
74
|
+
yield { type: 'finish', reason: { kind: choice.finish_reason === 'length' ? 'max-tokens' : choice.finish_reason === 'tool_calls' ? 'tool-calls' : 'stop' } }
|
|
75
|
+
}
|
|
76
|
+
if (!ended) throw fail()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
/** OpenAI-compatible parsing is local. Provider-specific adapters can use usageNativeModelChunks. */
|
|
81
|
+
async function* compatibleChunks(response) {
|
|
82
|
+
const localError = () => Object.assign(new Error('The provider response could not be parsed by the local model adapter.'), {
|
|
83
|
+
code: 'MODEL_PROVIDER_RESPONSE_INVALID', status: response.status,
|
|
84
|
+
})
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
await response.body?.cancel()
|
|
87
|
+
throw Object.assign(new Error(`Model provider returned HTTP ${response.status}.`), { code: 'MODEL_PROVIDER_HTTP_ERROR', status: response.status })
|
|
88
|
+
}
|
|
89
|
+
async function* parsed() {
|
|
90
|
+
if (!(response.headers.get('content-type') ?? '').includes('text/event-stream')) {
|
|
91
|
+
let value
|
|
92
|
+
try { value = await response.json() } catch { throw localError() }
|
|
93
|
+
yield { type: 'operation', operation: { delivery_state: 'response_ready', host_turn_active: true, response: value } }
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
let buffer = '', data = [], content = '', reasoning = '', finish, usage
|
|
97
|
+
const calls = new Map(), reader = response.body.getReader(), decoder = new TextDecoder()
|
|
98
|
+
const frame = text => {
|
|
99
|
+
if (!text || text === '[DONE]') return null
|
|
100
|
+
try { return JSON.parse(text) } catch { throw localError() }
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
let eof = false
|
|
104
|
+
while (!eof) {
|
|
105
|
+
const next = await reader.read(); eof = next.done
|
|
106
|
+
buffer += eof ? decoder.decode() : decoder.decode(next.value, { stream: true })
|
|
107
|
+
let match
|
|
108
|
+
while ((match = /\r\n|\n|\r(?!$)/.exec(buffer))) {
|
|
109
|
+
const line = buffer.slice(0, match.index); buffer = buffer.slice(match.index + match[0].length)
|
|
110
|
+
if (line) { if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')); continue }
|
|
111
|
+
const text = data.join('\n'); data = []
|
|
112
|
+
if (text === '[DONE]') { eof = true; break }
|
|
113
|
+
const value = frame(text)
|
|
114
|
+
if (!value) continue
|
|
115
|
+
if (value.error) throw localError()
|
|
116
|
+
if (value.usage) usage = value.usage
|
|
117
|
+
const choice = value.choices?.[0], delta = choice?.delta
|
|
118
|
+
if (choice?.finish_reason != null) finish = choice.finish_reason
|
|
119
|
+
if (!delta) continue
|
|
120
|
+
if (typeof delta.content === 'string') content += delta.content
|
|
121
|
+
if (typeof delta.reasoning_content === 'string') reasoning += delta.reasoning_content
|
|
122
|
+
for (const call of delta.tool_calls ?? []) {
|
|
123
|
+
const index = call.index ?? 0
|
|
124
|
+
const target = calls.get(index) ?? { type: 'function', id: '', function: { name: '', arguments: '' } }
|
|
125
|
+
if (call.id) target.id = call.id
|
|
126
|
+
if (call.function?.name) target.function.name += call.function.name
|
|
127
|
+
if (typeof call.function?.arguments === 'string') target.function.arguments += call.function.arguments
|
|
128
|
+
calls.set(index, target)
|
|
129
|
+
}
|
|
130
|
+
yield { type: 'delta', delta: { content: delta.content, reasoning_content: delta.reasoning_content } }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} finally { await reader.cancel().catch(() => {}) }
|
|
134
|
+
// An incomplete provider stream is a local adapter error, not an unknown Hub operation.
|
|
135
|
+
if (finish == null) throw localError()
|
|
136
|
+
yield { type: 'operation', operation: { delivery_state: 'response_ready', host_turn_active: true, response: {
|
|
137
|
+
choices: [{ message: { role: 'assistant', content, reasoning_content: reasoning, tool_calls: [...calls.values()] }, finish_reason: finish }], usage,
|
|
138
|
+
} } }
|
|
139
|
+
}
|
|
140
|
+
try { yield* legacyChunks(parsed()) } catch (error) {
|
|
141
|
+
if (['USAGE_RESPONSE_INVALID', 'USAGE_STREAM_INCOMPLETE'].includes(error.code)) throw localError()
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function* usageLlmChunks(events) {
|
|
147
|
+
const iterator = events[Symbol.asyncIterator]()
|
|
148
|
+
let first
|
|
149
|
+
do { first = await iterator.next() } while (!first.done && !['provider', 'delta', 'operation'].includes(first.value.type))
|
|
150
|
+
async function* remaining() {
|
|
151
|
+
try { if (!first.done) yield first.value; for (;;) { const next = await iterator.next(); if (next.done) break; yield next.value } }
|
|
152
|
+
finally { await iterator.return?.() }
|
|
153
|
+
}
|
|
154
|
+
if (first.value?.type === 'provider' || first.value?.operation?.response?.schema === 'bailing.provider-response.v1') {
|
|
155
|
+
yield* usageNativeModelChunks(remaining(), compatibleChunks)
|
|
156
|
+
} else yield* legacyChunks(remaining()) // Original saved requests from before the transport envelope.
|
|
157
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { createFileSessionUsageStore } from './session-usage-store.js'
|
|
3
|
+
import { ModelGatewayTransport } from './model-gateway-transport.js'
|
|
4
|
+
|
|
5
|
+
const REQUEST_EVENT = 'bailinghub/usage-request'
|
|
6
|
+
const REQUIRED = ['capabilities', 'modelModels', 'modelSummary', 'modelComplete', 'modelStream', 'inspectModelRequest', 'cancelModelRequest']
|
|
7
|
+
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
8
|
+
function exact(value, maximum = 191) {
|
|
9
|
+
if (typeof value !== 'string' || !value.length || value.length > maximum || value.trim() !== value || /[\u0000-\u001f\u007f]/u.test(value)) throw failure('USAGE_INPUT_INVALID')
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
12
|
+
function failure(code, extra = {}) {
|
|
13
|
+
const priority = code === 'USAGE_STORAGE_ERROR' ? 'storage_error' : code === 'USAGE_RECOVERY_GAP' ? 'recovery_gap' : 'blocked'
|
|
14
|
+
return Object.assign(new Error(`The controlled usage service is unavailable (${code}).`), {
|
|
15
|
+
code, state: priority, feedback: { schema: 'bailing.usage-feedback.v1', code,
|
|
16
|
+
dispatch: 'not_dispatched', next_action: code === 'USAGE_UNSUPPORTED' ? 'upgrade' : 'resolve_error', retryable: false }, ...extra,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
function bindingOf(client) {
|
|
20
|
+
const binding = client?.binding
|
|
21
|
+
if (!binding || typeof binding !== 'object') throw failure('USAGE_UNSUPPORTED')
|
|
22
|
+
return Object.fromEntries(['hubUrl', 'userId', 'accountId', 'serviceId'].map((key) => [key, exact(binding[key], key === 'hubUrl' ? 2048 : 191)]))
|
|
23
|
+
}
|
|
24
|
+
function signalActive(signal) { if (signal?.aborted) throw failure('USAGE_CANCELLED') }
|
|
25
|
+
function validSession(session) {
|
|
26
|
+
exact(session?.id, 4096)
|
|
27
|
+
if (!Array.isArray(session.events)) throw failure('USAGE_RECOVERY_GAP')
|
|
28
|
+
}
|
|
29
|
+
// DSH also persists plugin context as user-role messages. Only the trusted
|
|
30
|
+
// producer's explicit source marks a real user input, never the model-facing role.
|
|
31
|
+
function isUserInput(event) {
|
|
32
|
+
return event?.type === 'user/message' && event.data?.source?.kind === 'user'
|
|
33
|
+
}
|
|
34
|
+
function eventFor(session, userMessageId) {
|
|
35
|
+
validSession(session)
|
|
36
|
+
const id = exact(userMessageId)
|
|
37
|
+
const events = session.events.filter((event) => ['user/message', REQUEST_EVENT].includes(event?.type) && event.data?.id === id)
|
|
38
|
+
if (events.length !== 1 || !Number.isSafeInteger(events[0].seq) || events[0].seq < 0) throw failure('USAGE_RECOVERY_GAP')
|
|
39
|
+
if (events[0].type === REQUEST_EVENT) {
|
|
40
|
+
if (events[0].data?.source !== 'host' || !['auxiliary', 'subagent', 'model'].includes(events[0].data?.kind)) throw failure('USAGE_RECOVERY_GAP')
|
|
41
|
+
} else if (!isUserInput(events[0]) || events[0].data.role !== 'user') throw failure('USAGE_USER_MESSAGE_REQUIRED')
|
|
42
|
+
return events[0]
|
|
43
|
+
}
|
|
44
|
+
function isLiveMessage(session, event) {
|
|
45
|
+
if (event.type === REQUEST_EVENT) return !session.events.some(item => item.seq > event.seq && item.type === 'bailinghub/model-request-group-ended' && item.data?.id === event.data.id)
|
|
46
|
+
return !session.events.some(item => item.seq > event.seq && (isUserInput(item) || item.type === 'turn/end'))
|
|
47
|
+
}
|
|
48
|
+
function primaryFailure(status) {
|
|
49
|
+
if (status?.state === 'storage_error' || status?.status === 'storage_error' || status?.storageError || status?.unsavedEvents > 0) return failure('USAGE_STORAGE_ERROR')
|
|
50
|
+
if (status?.state === 'recovery_gap' || status?.status === 'recovery_gap' || status?.recoveryGap) return failure('USAGE_RECOVERY_GAP')
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Host-owned USD model transport. Planning and tool execution remain in the native AgentLoop. */
|
|
55
|
+
export class BailingHubUsageModelTransport {
|
|
56
|
+
constructor({ client, store, ensureSessionPersisted, getPrimaryStatus, mode } = {}) {
|
|
57
|
+
if (mode !== undefined && mode !== 'model_gateway') throw failure('USAGE_INPUT_INVALID')
|
|
58
|
+
this.client = client
|
|
59
|
+
this.store = store ?? createFileSessionUsageStore()
|
|
60
|
+
this.ensureSessionPersisted = ensureSessionPersisted
|
|
61
|
+
this.getPrimaryStatus = getPrimaryStatus
|
|
62
|
+
this.queues = new Map()
|
|
63
|
+
this.cancelled = new Set()
|
|
64
|
+
this.ended = new Set()
|
|
65
|
+
this.storageFailures = new Set()
|
|
66
|
+
this.capabilitiesCache = null
|
|
67
|
+
this.modelTransport = new ModelGatewayTransport(this, { failure, hash, exact, eventFor, isLiveMessage, signalActive })
|
|
68
|
+
}
|
|
69
|
+
assertSupported() {
|
|
70
|
+
if (REQUIRED.some(method => typeof this.client?.[method] !== 'function') || typeof this.ensureSessionPersisted !== 'function') throw failure('USAGE_UNSUPPORTED')
|
|
71
|
+
return bindingOf(this.client)
|
|
72
|
+
}
|
|
73
|
+
async primary(session) {
|
|
74
|
+
validSession(session)
|
|
75
|
+
let status
|
|
76
|
+
try { status = await this.getPrimaryStatus?.(session) } catch { throw failure('USAGE_STORAGE_ERROR') }
|
|
77
|
+
const existing = primaryFailure(status)
|
|
78
|
+
if (existing) throw existing
|
|
79
|
+
if (this.storageFailures.has(session.id)) throw failure('USAGE_STORAGE_ERROR')
|
|
80
|
+
}
|
|
81
|
+
async durable(session) {
|
|
82
|
+
await this.primary(session)
|
|
83
|
+
this.assertSupported()
|
|
84
|
+
try { if (await this.ensureSessionPersisted(session) !== true) throw new Error() }
|
|
85
|
+
catch { throw failure('USAGE_STORAGE_ERROR') }
|
|
86
|
+
}
|
|
87
|
+
serial(session, operation) {
|
|
88
|
+
const previous = this.queues.get(session.id) ?? Promise.resolve()
|
|
89
|
+
const current = previous.catch(() => {}).then(operation)
|
|
90
|
+
this.queues.set(session.id, current)
|
|
91
|
+
current.finally(() => { if (this.queues.get(session.id) === current) this.queues.delete(session.id) }).catch(() => {})
|
|
92
|
+
return current
|
|
93
|
+
}
|
|
94
|
+
async negotiate(options = {}) {
|
|
95
|
+
this.assertSupported()
|
|
96
|
+
if (!options.refresh && this.capabilitiesCache?.until > Date.now()) return this.capabilitiesCache.value
|
|
97
|
+
const caps = await this.client.capabilities(options), token = caps?.model_gateway
|
|
98
|
+
if (caps?.schema !== 'bailing.usage.v1' || caps.supported !== true || caps.streaming !== true || caps.orchestration !== 'host'
|
|
99
|
+
|| token?.schema !== 'bailing.model-gateway.v1' || token.supported !== true || token.turn_required !== false
|
|
100
|
+
|| token.provider_response !== 'bailing.provider-response.v1' || token.settlement !== 'asynchronous'
|
|
101
|
+
|| token.orchestration !== 'host' || token.streaming !== true || token.billing_unit !== 'USD') throw failure('USAGE_UNSUPPORTED')
|
|
102
|
+
this.capabilitiesCache = { value: caps, until: Date.now() + 60_000 }
|
|
103
|
+
return caps
|
|
104
|
+
}
|
|
105
|
+
async model(options = {}) {
|
|
106
|
+
await this.negotiate(options)
|
|
107
|
+
return this.modelTransport.model(options)
|
|
108
|
+
}
|
|
109
|
+
/** Fetch permitted tools for the local host to register, without starting a business run. */
|
|
110
|
+
async modelTools(options = {}) {
|
|
111
|
+
await this.negotiate(options)
|
|
112
|
+
if (typeof this.client.modelTools !== 'function') throw failure('USAGE_UNSUPPORTED')
|
|
113
|
+
return this.client.modelTools(options)
|
|
114
|
+
}
|
|
115
|
+
/** One local tool call, tracked durably using the same original-request recovery protocol. */
|
|
116
|
+
async runModelTool(session, input, options = {}) {
|
|
117
|
+
validSession(session)
|
|
118
|
+
await this.durable(session)
|
|
119
|
+
eventFor(session, input?.userMessageId)
|
|
120
|
+
await this.negotiate(options)
|
|
121
|
+
if (typeof this.client.runModelTool !== 'function' || typeof this.client.modelTools !== 'function') throw failure('USAGE_UNSUPPORTED')
|
|
122
|
+
return this.modelTransport.complete(session, input, options, true)
|
|
123
|
+
}
|
|
124
|
+
async status(session, options = {}) {
|
|
125
|
+
await this.primary(session)
|
|
126
|
+
await this.negotiate(options)
|
|
127
|
+
return this.modelTransport.status(session, options)
|
|
128
|
+
}
|
|
129
|
+
async complete(session, input, options = {}) {
|
|
130
|
+
validSession(session)
|
|
131
|
+
await this.durable(session)
|
|
132
|
+
eventFor(session, input?.userMessageId)
|
|
133
|
+
await this.negotiate(options)
|
|
134
|
+
return this.modelTransport.complete(session, input, options)
|
|
135
|
+
}
|
|
136
|
+
/** A trusted host can meter a local helper/child request without inventing a user message.
|
|
137
|
+
* For requests inside a user turn, prefer complete/stream with that original owner Session and userMessageId.
|
|
138
|
+
*/
|
|
139
|
+
async prepareRequest(session, input) {
|
|
140
|
+
validSession(session)
|
|
141
|
+
if (!input || Object.keys(input).some(key => !['modelRequestId', 'serviceId', 'requestGroupId', 'requestKind',
|
|
142
|
+
'messages', 'tools', 'tool_choice', 'temperature', 'provider_options'].includes(key))) throw failure('USAGE_INPUT_INVALID')
|
|
143
|
+
const requestGroupId = exact(input.requestGroupId ?? `request_${hash(exact(input.modelRequestId)).slice(0, 40)}`)
|
|
144
|
+
const kind = input.requestKind ?? 'model'
|
|
145
|
+
if (!['model', 'auxiliary', 'subagent'].includes(kind)) throw failure('USAGE_INPUT_INVALID')
|
|
146
|
+
await this.serial(session, async () => {
|
|
147
|
+
await this.primary(session)
|
|
148
|
+
const existing = session.events.find(e => [REQUEST_EVENT, 'user/message'].includes(e.type) && e.data?.id === requestGroupId)
|
|
149
|
+
if (existing && (existing.type !== REQUEST_EVENT || existing.data.kind !== kind)) throw failure('USAGE_IDEMPOTENCY_CONFLICT')
|
|
150
|
+
if (!existing) {
|
|
151
|
+
if (typeof session.append !== 'function') throw failure('USAGE_RECOVERY_GAP')
|
|
152
|
+
session.append(REQUEST_EVENT, { id: requestGroupId, kind, source: 'host' })
|
|
153
|
+
}
|
|
154
|
+
await this.durable(session)
|
|
155
|
+
})
|
|
156
|
+
return { ...input, userMessageId: requestGroupId }
|
|
157
|
+
}
|
|
158
|
+
async completeRequest(session, input, options = {}) {
|
|
159
|
+
const prepared = await this.prepareRequest(session, input)
|
|
160
|
+
return { ...await this.complete(session, prepared, options), request_group_id: prepared.userMessageId }
|
|
161
|
+
}
|
|
162
|
+
async *streamRequest(session, input, options = {}) {
|
|
163
|
+
const prepared = await this.prepareRequest(session, input)
|
|
164
|
+
for await (const event of this.stream(session, prepared, options)) yield event.type === 'operation'
|
|
165
|
+
? { ...event, operation: { ...event.operation, request_group_id: prepared.userMessageId } } : event
|
|
166
|
+
}
|
|
167
|
+
/** Backpressured preview stream; only the terminal delivery may resume the local loop. */
|
|
168
|
+
async *stream(session, input, options = {}) {
|
|
169
|
+
const controller = new AbortController()
|
|
170
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal
|
|
171
|
+
const queue = []; let wake, stopped = false
|
|
172
|
+
const put = value => { queue.push(value); wake?.(); wake = null }
|
|
173
|
+
const running = this.complete(session, input, { ...options, signal,
|
|
174
|
+
onDelta: event => stopped ? undefined : new Promise(resolve => put({ event, ack: resolve })) })
|
|
175
|
+
.then(receipt => put({ event: { type: 'operation', operation: receipt }, done: true }), error => put({ error, done: true }))
|
|
176
|
+
let current
|
|
177
|
+
try {
|
|
178
|
+
for (;;) {
|
|
179
|
+
if (!queue.length) await new Promise(resolve => { wake = resolve })
|
|
180
|
+
current = queue.shift()
|
|
181
|
+
if (current.error) throw current.error
|
|
182
|
+
if (!current.done && (signal.aborted || this.cancelled.has(`${session.id}:${input.userMessageId}`) || this.ended.has(`${session.id}:${input.userMessageId}`)
|
|
183
|
+
|| !isLiveMessage(session, eventFor(session, input.userMessageId)))) { current.ack?.(); current = null; continue }
|
|
184
|
+
yield current.event
|
|
185
|
+
const terminal = current.done
|
|
186
|
+
current.ack?.(); current = null
|
|
187
|
+
if (terminal) return
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
stopped = true; controller.abort(); current?.ack?.()
|
|
191
|
+
for (const item of queue) item.ack?.()
|
|
192
|
+
await running
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async recoverOperation(session, operationId, options = {}) {
|
|
196
|
+
validSession(session)
|
|
197
|
+
return this.modelTransport.recover(session, operationId, options)
|
|
198
|
+
}
|
|
199
|
+
async close(session, userMessageId, cancelled, options = {}) {
|
|
200
|
+
validSession(session)
|
|
201
|
+
return this.modelTransport.close(session, userMessageId, cancelled, options)
|
|
202
|
+
}
|
|
203
|
+
endTurn(session, userMessageId, options) { return this.close(session, userMessageId, false, options) }
|
|
204
|
+
cancelTurn(session, userMessageId, options) { return this.close(session, userMessageId, true, options) }
|
|
205
|
+
}
|
|
206
|
+
export function createUsageModelTransport(options) { return new BailingHubUsageModelTransport(options) }
|