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.
@@ -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) }