@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33
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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/extension-bridge.js +140 -46
- package/dist/capabilities/session/gui-flight.js +281 -69
- package/dist/capabilities/session/gui-hotel.js +74 -17
- package/dist/capabilities/session-search.js +6 -5
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +42 -8
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/index.js +31 -1
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +4 -1
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/extension-bridge.ts +176 -65
- package/ts/capabilities/session/gui-flight.ts +318 -88
- package/ts/capabilities/session/gui-hotel.ts +72 -18
- package/ts/capabilities/session-search.ts +17 -5
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/index.ts +28 -2
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway chat-completions LLM provider for the embedded Booking Copilot.
|
|
3
|
+
*
|
|
4
|
+
* The team LLM gateway (new-api) speaks the OpenAI chat-completions wire
|
|
5
|
+
* dialect (`data: {...}` frames closed by a `data: [DONE]` sentinel). dsh
|
|
6
|
+
* 0.2.x's bundled DeepSeek adapter moved to the DeepSeek Messages protocol,
|
|
7
|
+
* whose strict translator rejects the gateway's chat-completions stream —
|
|
8
|
+
* most visibly when an upstream model emits a truncated tool-call argument
|
|
9
|
+
* JSON that the harness-side schema-repair path could otherwise recover
|
|
10
|
+
* (hotel-be#33424 / Danceiny/gotry#660 evidence chain). This plugin registers
|
|
11
|
+
* a chat-completions adapter so the planner keeps a wire dialect the whole
|
|
12
|
+
* stack (gateway + harness repair paths) is proven against.
|
|
13
|
+
*
|
|
14
|
+
* Ported from the dsh 0.1.5-rc.1 `dsh-llm-deepseek` adapter (text + tools
|
|
15
|
+
* only; the images/files/replay machinery the planner never used is dropped).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
|
19
|
+
|
|
20
|
+
export const name = 'llm-gateway-chat'
|
|
21
|
+
export const inject = ['llm']
|
|
22
|
+
|
|
23
|
+
/** Provider route this plugin registers; selected via DEEPSEEK_PROVIDER. */
|
|
24
|
+
export const GATEWAY_PROVIDER = 'gateway-chat'
|
|
25
|
+
|
|
26
|
+
function assertTextOnly(content) {
|
|
27
|
+
if (typeof content === 'string') return
|
|
28
|
+
for (const block of content) {
|
|
29
|
+
if (block.type === 'image' || block.type === 'file') {
|
|
30
|
+
throw new LlmError('gateway chat provider accepts text and tool content only', 'UNSUPPORTED_CONTENT')
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function flattenText(blocks) {
|
|
36
|
+
if (typeof blocks === 'string') return blocks
|
|
37
|
+
return blocks.filter((block) => block.type === 'text').map((block) => block.text).join('')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Serialize one assistant message (text + reasoning + tool calls). */
|
|
41
|
+
function serializeAssistant(message) {
|
|
42
|
+
const text = flattenText(message.content)
|
|
43
|
+
const reasoning = message.content.filter((block) => block.type === 'reasoning').map((block) => block.text).join('')
|
|
44
|
+
const toolCalls = message.content.filter((block) => block.type === 'tool-call').map((block) => ({
|
|
45
|
+
id: block.id,
|
|
46
|
+
type: 'function',
|
|
47
|
+
function: { name: block.name, arguments: block.arguments },
|
|
48
|
+
}))
|
|
49
|
+
return {
|
|
50
|
+
role: 'assistant',
|
|
51
|
+
content: text,
|
|
52
|
+
...(reasoning.length > 0 ? { reasoning_content: reasoning } : {}),
|
|
53
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Serialize the conversation. Tool-result blocks become standalone
|
|
59
|
+
* `{role: 'tool'}` wire messages after their message's text.
|
|
60
|
+
*/
|
|
61
|
+
function serializeMessages(messages) {
|
|
62
|
+
const wire = []
|
|
63
|
+
for (const message of messages) {
|
|
64
|
+
assertTextOnly(message.content)
|
|
65
|
+
if (message.role === 'system') {
|
|
66
|
+
wire.push({ role: 'system', content: flattenText(message.content) })
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (message.role === 'assistant') {
|
|
70
|
+
wire.push(serializeAssistant(message))
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
const toolResults = message.content.filter((block) => block.type === 'tool-result')
|
|
74
|
+
const text = flattenText(message.content)
|
|
75
|
+
if (text.length > 0 || toolResults.length === 0) wire.push({ role: 'user', content: text })
|
|
76
|
+
for (const result of toolResults) {
|
|
77
|
+
wire.push({
|
|
78
|
+
role: 'tool',
|
|
79
|
+
tool_call_id: result.toolCallId,
|
|
80
|
+
content: flattenText(result.content) || '(no output)',
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return wire
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Map the wire finish_reason vocabulary to the harness FinishReason. */
|
|
88
|
+
function mapFinishReason(reason) {
|
|
89
|
+
switch (reason) {
|
|
90
|
+
case 'stop': return { kind: 'stop' }
|
|
91
|
+
case 'tool_calls': return { kind: 'tool-calls' }
|
|
92
|
+
case 'length': return { kind: 'max-tokens' }
|
|
93
|
+
default: return { kind: 'error', failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() } }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Map wire usage to disjoint harness counts (cache reads subtracted from prompt). */
|
|
98
|
+
function mapUsage(usage) {
|
|
99
|
+
const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens
|
|
100
|
+
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
|
101
|
+
const combined = usage.prompt_tokens + usage.completion_tokens
|
|
102
|
+
const hasExactTotal = Number.isSafeInteger(usage.prompt_tokens) && usage.prompt_tokens >= 0
|
|
103
|
+
&& Number.isSafeInteger(usage.completion_tokens) && usage.completion_tokens >= 0
|
|
104
|
+
&& Number.isSafeInteger(combined) && (usage.total_tokens === undefined || usage.total_tokens === combined)
|
|
105
|
+
return {
|
|
106
|
+
inputTokens: usage.prompt_tokens - (cacheRead ?? 0),
|
|
107
|
+
outputTokens: usage.completion_tokens,
|
|
108
|
+
...(hasExactTotal ? { totalTokens: combined } : {}),
|
|
109
|
+
...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}),
|
|
110
|
+
...(reasoning !== undefined ? { reasoningTokens: reasoning } : {}),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Accept one streamed identity field; empty/null re-sends mean "no update". */
|
|
115
|
+
function acceptIdentity(current, incoming) {
|
|
116
|
+
return typeof incoming === 'string' && incoming.length > 0 ? incoming : current
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Assemble the final ContentBlock for one open block. */
|
|
120
|
+
function closeBlock(block) {
|
|
121
|
+
switch (block.kind) {
|
|
122
|
+
case 'text': return { type: 'text', text: block.text }
|
|
123
|
+
case 'reasoning': return { type: 'reasoning', text: block.text }
|
|
124
|
+
case 'tool-call': return { type: 'tool-call', id: block.callId ?? '', name: block.name ?? '', arguments: block.text }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Parse an SSE byte/text stream into data payloads; `[DONE]` yielded verbatim as the sentinel. */
|
|
129
|
+
async function* parseSse(stream, onActivity) {
|
|
130
|
+
const decoder = new TextDecoder()
|
|
131
|
+
let buffer = ''
|
|
132
|
+
let sawDone = false
|
|
133
|
+
for await (const chunk of stream) {
|
|
134
|
+
onActivity?.()
|
|
135
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
136
|
+
let index
|
|
137
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
138
|
+
const line = buffer.slice(0, index).replace(/\r$/, '')
|
|
139
|
+
buffer = buffer.slice(index + 1)
|
|
140
|
+
if (!line.startsWith('data:')) continue
|
|
141
|
+
const data = line.slice(5).trimStart()
|
|
142
|
+
if (data === '[DONE]') { sawDone = true; yield '[DONE]'; return }
|
|
143
|
+
if (data.length > 0) yield data
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
buffer += decoder.decode()
|
|
147
|
+
for (const line of buffer.split('\n')) {
|
|
148
|
+
if (!line.startsWith('data:')) continue
|
|
149
|
+
const data = line.slice(5).trimStart()
|
|
150
|
+
if (data === '[DONE]') { sawDone = true; yield '[DONE]'; return }
|
|
151
|
+
if (data.length > 0) yield data
|
|
152
|
+
}
|
|
153
|
+
if (!sawDone) throw new LlmError('SSE stream ended without [DONE]', 'STREAM_CLOSED')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks.
|
|
158
|
+
* Deltas stream as they arrive; block-ends, usage, and finish are deferred to
|
|
159
|
+
* the `[DONE]` sentinel. A `stop` (or absent) finish with no opened blocks is
|
|
160
|
+
* a degenerate provider completion and maps to an EMPTY_RESPONSE error finish.
|
|
161
|
+
*/
|
|
162
|
+
async function* translate(payloads) {
|
|
163
|
+
let nextIndex = 0
|
|
164
|
+
let textBlock
|
|
165
|
+
let reasoningBlock
|
|
166
|
+
const toolBlocks = new Map()
|
|
167
|
+
const order = []
|
|
168
|
+
let pendingFinish
|
|
169
|
+
let pendingUsage
|
|
170
|
+
function open(kind) {
|
|
171
|
+
const block = { index: nextIndex++, kind, text: '' }
|
|
172
|
+
order.push(block)
|
|
173
|
+
return block
|
|
174
|
+
}
|
|
175
|
+
for await (const payload of payloads) {
|
|
176
|
+
if (payload === '[DONE]') {
|
|
177
|
+
for (const block of order) {
|
|
178
|
+
yield { type: 'block-end', index: block.index, block: closeBlock(block) }
|
|
179
|
+
}
|
|
180
|
+
if (pendingUsage) yield { type: 'usage', usage: pendingUsage }
|
|
181
|
+
const reason = pendingFinish ?? { kind: 'stop' }
|
|
182
|
+
yield {
|
|
183
|
+
type: 'finish',
|
|
184
|
+
reason: reason.kind === 'stop' && order.length === 0
|
|
185
|
+
? { kind: 'error', failure: { message: 'model returned a completed response with no content', code: 'EMPTY_RESPONSE' } }
|
|
186
|
+
: reason,
|
|
187
|
+
}
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
let chunk
|
|
191
|
+
try {
|
|
192
|
+
chunk = JSON.parse(payload)
|
|
193
|
+
} catch {
|
|
194
|
+
throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE')
|
|
195
|
+
}
|
|
196
|
+
for (const choice of chunk.choices ?? []) {
|
|
197
|
+
const delta = choice.delta
|
|
198
|
+
const reasoning = delta?.reasoning_content
|
|
199
|
+
if (typeof reasoning === 'string' && reasoning.length > 0) {
|
|
200
|
+
if (!reasoningBlock) {
|
|
201
|
+
reasoningBlock = open('reasoning')
|
|
202
|
+
yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' }
|
|
203
|
+
}
|
|
204
|
+
reasoningBlock.text += reasoning
|
|
205
|
+
yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning }
|
|
206
|
+
}
|
|
207
|
+
const content = delta?.content
|
|
208
|
+
if (typeof content === 'string' && content.length > 0) {
|
|
209
|
+
if (!textBlock) {
|
|
210
|
+
textBlock = open('text')
|
|
211
|
+
yield { type: 'block-start', index: textBlock.index, blockType: 'text' }
|
|
212
|
+
}
|
|
213
|
+
textBlock.text += content
|
|
214
|
+
yield { type: 'text-delta', index: textBlock.index, text: content }
|
|
215
|
+
}
|
|
216
|
+
for (const call of delta?.tool_calls ?? []) {
|
|
217
|
+
let block = toolBlocks.get(call.index)
|
|
218
|
+
if (!block) {
|
|
219
|
+
block = open('tool-call')
|
|
220
|
+
toolBlocks.set(call.index, block)
|
|
221
|
+
yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
|
|
222
|
+
}
|
|
223
|
+
block.callId = acceptIdentity(block.callId, call.id)
|
|
224
|
+
block.name = acceptIdentity(block.name, call.function?.name)
|
|
225
|
+
const fragment = call.function?.arguments ?? ''
|
|
226
|
+
block.text += fragment
|
|
227
|
+
yield {
|
|
228
|
+
type: 'tool-call-delta',
|
|
229
|
+
index: block.index,
|
|
230
|
+
id: block.callId ?? '',
|
|
231
|
+
...(block.name !== undefined ? { name: block.name } : {}),
|
|
232
|
+
argumentsDelta: fragment,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (typeof choice.finish_reason === 'string') pendingFinish = mapFinishReason(choice.finish_reason)
|
|
236
|
+
}
|
|
237
|
+
if (chunk.usage) pendingUsage = mapUsage(chunk.usage)
|
|
238
|
+
}
|
|
239
|
+
throw new LlmError('SSE payload stream ended without [DONE]', 'STREAM_CLOSED')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Resolve the thinking/effort wire pair, ported verbatim from the dsh
|
|
244
|
+
* 0.1.5-rc.1 DeepSeek adapter (resolveThinking). The harness profile pins the
|
|
245
|
+
* planner route at thinking=enabled/effort=high — the model is expected to
|
|
246
|
+
* <think> before emitting the typed decision, and dropping these fields runs
|
|
247
|
+
* the model in the gateway's default (non-thinking) mode, which measurably
|
|
248
|
+
* ignores the planner's fine-grained intent rules.
|
|
249
|
+
*/
|
|
250
|
+
function resolveThinking(options) {
|
|
251
|
+
if (options.purpose === 'session-title') return { thinking: { type: 'disabled' } }
|
|
252
|
+
const effort = options.reasoningEffort
|
|
253
|
+
if (effort === 'off') return { thinking: { type: 'disabled' } }
|
|
254
|
+
if (effort === 'low' || effort === 'high' || effort === 'max') {
|
|
255
|
+
return { thinking: { type: 'enabled' }, reasoning_effort: effort }
|
|
256
|
+
}
|
|
257
|
+
return { thinking: { type: 'enabled' }, reasoning_effort: 'high' }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Chat-completions adapter: fetch + SSE against an OpenAI-compatible
|
|
262
|
+
* endpoint, emitting harness StreamChunks. Transport-only: connection facts
|
|
263
|
+
* and the bearer token arrive through per-operation resolvers owned by the
|
|
264
|
+
* registering plugin.
|
|
265
|
+
*/
|
|
266
|
+
export class GatewayChatAdapter extends LlmAdapter {
|
|
267
|
+
constructor(dependencies) {
|
|
268
|
+
super()
|
|
269
|
+
this.config = dependencies
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
providerInfo(provider) {
|
|
273
|
+
return { id: provider, name: this.config.providerName ?? 'Gateway Chat' }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
providerRetryPolicy(_provider) {
|
|
277
|
+
return this.config.retryPolicy
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
listModels(provider) {
|
|
281
|
+
return Promise.resolve([this.modelInfo(provider, this.config.resolveConnection().model ?? 'deepseek-flash')])
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
resolveModel(provider, model, _signal) {
|
|
285
|
+
return Promise.resolve(this.modelInfo(provider, model))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Exact model metadata: id/name are schema-required; context sizes the harness budget. */
|
|
289
|
+
modelInfo(provider, model) {
|
|
290
|
+
return {
|
|
291
|
+
provider,
|
|
292
|
+
id: model,
|
|
293
|
+
name: model,
|
|
294
|
+
inputModalities: ['text'],
|
|
295
|
+
context: { contextWindow: this.config.contextWindow ?? 128_000 },
|
|
296
|
+
defaultMaxTokens: this.config.maxTokens ?? 16_384,
|
|
297
|
+
reasoning: {
|
|
298
|
+
efforts: [
|
|
299
|
+
{ id: 'off', name: 'Off', description: 'Use for simple tasks that do not need reasoning.' },
|
|
300
|
+
{ id: 'low', name: 'Low' },
|
|
301
|
+
{ id: 'high', name: 'High' },
|
|
302
|
+
{ id: 'max', name: 'Max' },
|
|
303
|
+
],
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async prepareCall(provider, model, _signal) {
|
|
309
|
+
return {
|
|
310
|
+
model: this.modelInfo(provider, model),
|
|
311
|
+
stream: (options) => this.streamWithConnection(options, this.config.resolveConnection(), this.config.resolveApiKey),
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
stream(options) {
|
|
316
|
+
const connection = this.config.resolveConnection()
|
|
317
|
+
return this.streamWithConnection(options, connection, this.config.resolveApiKey)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async *streamWithConnection(options, connection, resolveApiKey) {
|
|
321
|
+
const consumer = new AbortController()
|
|
322
|
+
const signal = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal])
|
|
323
|
+
const iterator = this.request(options, signal, connection, resolveApiKey)[Symbol.asyncIterator]()
|
|
324
|
+
let exhausted = false
|
|
325
|
+
try {
|
|
326
|
+
while (true) {
|
|
327
|
+
const result = await iterator.next()
|
|
328
|
+
if (result.done) {
|
|
329
|
+
exhausted = true
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
yield result.value
|
|
333
|
+
}
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (options.signal?.aborted) throw new LlmError('gateway chat request aborted by caller', 'ABORTED', { cause: error })
|
|
336
|
+
if (error instanceof LlmError) throw error
|
|
337
|
+
throw new LlmError(`gateway chat stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error })
|
|
338
|
+
} finally {
|
|
339
|
+
consumer.abort('gateway chat stream consumer stopped')
|
|
340
|
+
if (!exhausted && iterator.return !== undefined) {
|
|
341
|
+
try { await iterator.return() } catch { /* teardown race */ }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async *request(options, signal, connection, resolveApiKey) {
|
|
347
|
+
const apiKey = await resolveApiKey(connection)
|
|
348
|
+
const tools = (options.tools ?? []).map((tool) => ({
|
|
349
|
+
type: 'function',
|
|
350
|
+
function: { name: tool.name, description: tool.description, parameters: tool.parameters },
|
|
351
|
+
}))
|
|
352
|
+
const messages = serializeMessages(options.messages)
|
|
353
|
+
if (typeof options.system === 'string' && options.system.length > 0 && messages[0]?.role !== 'system') {
|
|
354
|
+
messages.unshift({ role: 'system', content: options.system })
|
|
355
|
+
}
|
|
356
|
+
const thinking = resolveThinking(options)
|
|
357
|
+
const body = {
|
|
358
|
+
model: options.model,
|
|
359
|
+
messages,
|
|
360
|
+
stream: true,
|
|
361
|
+
stream_options: { include_usage: true },
|
|
362
|
+
...thinking,
|
|
363
|
+
...(tools.length > 0 ? { tools, tool_choice: 'auto' } : {}),
|
|
364
|
+
...(options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}),
|
|
365
|
+
...(options.stop !== undefined && options.stop.length > 0 ? { stop: options.stop } : {}),
|
|
366
|
+
...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
367
|
+
}
|
|
368
|
+
const base = connection.baseURL.replace(/\/+$/u, '')
|
|
369
|
+
const endpoint = new URL(base).pathname.endsWith('/v1') ? `${base}/chat/completions` : `${base}/v1/chat/completions`
|
|
370
|
+
const response = await fetch(endpoint, {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: {
|
|
373
|
+
'authorization': `Bearer ${apiKey}`,
|
|
374
|
+
'content-type': 'application/json',
|
|
375
|
+
'accept': 'text/event-stream',
|
|
376
|
+
},
|
|
377
|
+
body: JSON.stringify(body),
|
|
378
|
+
signal,
|
|
379
|
+
})
|
|
380
|
+
if (!response.ok) {
|
|
381
|
+
const detail = (await response.text().catch(() => '')).slice(0, 300)
|
|
382
|
+
const status = response.status
|
|
383
|
+
if (status === 401 || status === 403) throw new LlmError(`gateway chat auth failed: ${detail}`, 'AUTH')
|
|
384
|
+
if (status === 429) throw new LlmError(`gateway chat rate limited: ${detail}`, 'RATE_LIMIT')
|
|
385
|
+
if (status === 400 || status === 413 || status === 422) throw new LlmError(`gateway chat rejected the request: ${detail}`, 'INVALID_REQUEST')
|
|
386
|
+
throw new LlmError(`gateway chat HTTP ${status}: ${detail}`, status >= 500 ? 'SERVER' : 'INVALID_REQUEST')
|
|
387
|
+
}
|
|
388
|
+
if (response.body === null) throw new LlmError('gateway chat returned an empty body', 'EMPTY_RESPONSE')
|
|
389
|
+
yield* translate(parseSse(response.body, () => undefined))
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Register the gateway-chat provider route. Modeled on the dsh-llm-deepseek
|
|
395
|
+
* api-key plugin: one adapter instance, one provider route, credentials from
|
|
396
|
+
* the launching environment (the planner child only ever sees the env the
|
|
397
|
+
* parent explicitly forwards).
|
|
398
|
+
*/
|
|
399
|
+
export function apply(ctx) {
|
|
400
|
+
const resolveConnection = () => ({
|
|
401
|
+
baseURL: launchEnv(ctx).get('DEEPSEEK_BASE_URL')?.value ?? '',
|
|
402
|
+
models: [],
|
|
403
|
+
})
|
|
404
|
+
const resolveApiKey = async () => {
|
|
405
|
+
const ambient = launchEnv(ctx).get('DEEPSEEK_API_KEY')
|
|
406
|
+
if (ambient !== undefined && ambient.value.length > 0) return ambient.value
|
|
407
|
+
throw new LlmError('llm-gateway-chat: DEEPSEEK_API_KEY is missing from the planner environment', 'MISSING_CREDENTIAL')
|
|
408
|
+
}
|
|
409
|
+
ctx.llm.registerConfigurableProviders([{
|
|
410
|
+
provider: GATEWAY_PROVIDER,
|
|
411
|
+
displayName: 'Gateway Chat',
|
|
412
|
+
settingsNs: 'llm-gateway-chat',
|
|
413
|
+
settingsPath: [],
|
|
414
|
+
}])
|
|
415
|
+
ctx.llm.registerAdapter([GATEWAY_PROVIDER], new GatewayChatAdapter({
|
|
416
|
+
resolveConnection,
|
|
417
|
+
resolveApiKey,
|
|
418
|
+
providerName: 'Gateway Chat',
|
|
419
|
+
}))
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Read the dsh launch environment (ambient process env of the subprocess). */
|
|
423
|
+
function launchEnv(ctx) {
|
|
424
|
+
try {
|
|
425
|
+
const environment = ctx.get('launchEnvironment')
|
|
426
|
+
if (environment !== undefined) return environment
|
|
427
|
+
} catch { /* fallback below */ }
|
|
428
|
+
return {
|
|
429
|
+
get: (ref) => {
|
|
430
|
+
const value = process.env[ref]
|
|
431
|
+
return value === undefined ? undefined : { value }
|
|
432
|
+
},
|
|
433
|
+
}
|
|
434
|
+
}
|