@danceiny/gotry 0.2.0-rc.31 → 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.
Files changed (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. 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
+ }