dsh-openwrite 0.2.28 → 0.2.34

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 (125) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +49 -8
  3. package/THIRD_PARTY_NOTICES.md +4 -3
  4. package/companions.mjs +508 -0
  5. package/cordis.patch.yml +0 -2
  6. package/docs/CLOSED_ISSUE_FOLLOWUPS.md +1 -1
  7. package/docs/COMPANION_PLUGINS.md +142 -0
  8. package/docs/CONTEXT_SLOTS.md +1 -1
  9. package/docs/COST_MEASUREMENT.md +2 -2
  10. package/docs/DEVELOPMENT.md +6 -11
  11. package/docs/DSH_STORE.md +14 -2
  12. package/docs/EDITOR_SAFETY_REPAIRS.md +2 -2
  13. package/docs/INSTALL.md +112 -4
  14. package/docs/ISSUES_121_123.md +4 -4
  15. package/docs/ISSUES_140_142.md +58 -0
  16. package/docs/ISSUES_149_150_151.md +77 -0
  17. package/docs/ISSUE_123_FOLLOWUP.md +3 -2
  18. package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
  19. package/docs/LEARNING.md +1 -1
  20. package/docs/MOBILE_READINESS.md +62 -0
  21. package/docs/PLUGIN_MAINTENANCE.md +1 -3
  22. package/docs/RELEASE_ACCEPTANCE.md +20 -3
  23. package/docs/REVIEW_ROUND_22.md +67 -0
  24. package/docs/REVIEW_ROUND_23.md +48 -0
  25. package/docs/REVIEW_ROUND_25.md +51 -0
  26. package/docs/SELECTION_UPGRADE_REPAIRS.md +2 -2
  27. package/docs/TOOLS.md +181 -604
  28. package/docs/WORKFLOWS.md +5 -5
  29. package/package.json +17 -21
  30. package/packages/openwrite-bridge/lib/client.d.ts +0 -1
  31. package/packages/openwrite-bridge/lib/client.js +90 -9
  32. package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
  33. package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
  34. package/packages/openwrite-bridge/lib/dog-delivery.js +3 -1
  35. package/packages/openwrite-bridge/lib/dog-review.js +15 -3
  36. package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
  37. package/packages/openwrite-bridge/lib/domain.js +250 -29
  38. package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
  39. package/packages/openwrite-bridge/lib/host-llm.js +657 -0
  40. package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
  41. package/packages/openwrite-bridge/lib/host-web.js +166 -0
  42. package/packages/openwrite-bridge/lib/index.d.ts +4 -0
  43. package/packages/openwrite-bridge/lib/index.js +150 -6
  44. package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
  45. package/packages/openwrite-bridge/lib/managed-runtime.js +176 -34
  46. package/packages/openwrite-bridge/lib/tool-policy.js +37 -12
  47. package/packages/openwrite-bridge/lib/tools.js +1223 -1227
  48. package/packages/openwrite-bridge/package.json +7 -5
  49. package/packages/studio-panel/lib/client.js +123207 -121534
  50. package/packages/studio-panel/lib/client.js.map +1 -1
  51. package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
  52. package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
  53. package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
  54. package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
  55. package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
  56. package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
  57. package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
  58. package/packages/studio-panel/lib/types/client/VditorBody.d.ts +1 -1
  59. package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
  60. package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
  61. package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
  62. package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
  63. package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
  64. package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
  65. package/packages/studio-panel/package.json +5 -3
  66. package/plugin.mjs +159 -20
  67. package/preset-definition.mjs +144 -4
  68. package/presets/openwrite/agent.cordis.yml +7 -9
  69. package/presets/openwrite/ops-integration.mjs +28 -0
  70. package/presets/openwrite/preset.yml +2 -1
  71. package/presets/openwrite/skills/canon/SKILL.md +3 -3
  72. package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
  73. package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
  74. package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
  75. package/presets/openwrite/skills/export-book/SKILL.md +3 -3
  76. package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
  77. package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
  78. package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
  79. package/presets/openwrite/skills/learn/SKILL.md +3 -3
  80. package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
  81. package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
  82. package/presets/openwrite/skills/progress/SKILL.md +1 -1
  83. package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
  84. package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
  85. package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
  86. package/presets/openwrite/skills/style-system/SKILL.md +13 -6
  87. package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
  88. package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
  89. package/presets/openwrite/skills/world-query/SKILL.md +16 -11
  90. package/presets/openwrite/skills/write-next/SKILL.md +1 -1
  91. package/release/{openwrite-5.8.20-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
  92. package/release/runtime-manifest.json +4 -13
  93. package/release/source.json +21 -21
  94. package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
  95. package/scripts/maintenance.mjs +67 -7
  96. package/scripts/plugin-doctor.mjs +179 -0
  97. package/scripts/prepare.mjs +26 -4
  98. package/scripts/safe-upgrade.mjs +38 -5
  99. package/dog-tools.mjs +0 -13
  100. package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
  101. package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
  102. package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
  103. package/scripts/dog/delivery-stage.js +0 -32
  104. package/scripts/dog/import-chapter.js +0 -20
  105. package/scripts/dog/import-record.js +0 -35
  106. package/scripts/dog/review-dimension.js +0 -41
  107. package/scripts/dog/review-record.js +0 -42
  108. package/vendor/dsh-dog/LICENSE +0 -29
  109. package/vendor/dsh-dog/README.md +0 -114
  110. package/vendor/dsh-dog/UPSTREAM.md +0 -30
  111. package/vendor/dsh-dog/cordis.patch.yml +0 -14
  112. package/vendor/dsh-dog/lib/client.js +0 -2960
  113. package/vendor/dsh-dog/lib/client.js.map +0 -1
  114. package/vendor/dsh-dog/lib/core--byUPJL8.js +0 -2191
  115. package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
  116. package/vendor/dsh-dog/lib/core.d.ts +0 -2
  117. package/vendor/dsh-dog/lib/core.js +0 -2
  118. package/vendor/dsh-dog/lib/index.d.ts +0 -143
  119. package/vendor/dsh-dog/lib/index.js +0 -1230
  120. package/vendor/dsh-dog/package.json +0 -110
  121. package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
  122. package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
  123. package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
  124. package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
  125. package/vendor/dsh-dog/schemas/schema-0.2/verification.schema.json +0 -40
@@ -0,0 +1,657 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, randomUUID } from 'node:crypto';
3
+ /** Pseudo model id meaning "whatever dsh itself would use right now". */
4
+ export const HOST_CURRENT_MODEL = 'host/current';
5
+ const BODY_LIMIT_BYTES = 8 * 1024 * 1024;
6
+ const REQUEST_TIMEOUT_MS = 600_000;
7
+ function jsonBody(res, status, payload) {
8
+ const body = JSON.stringify(payload);
9
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
10
+ res.end(body);
11
+ }
12
+ /** Read a JSON request body under a hard size cap. */
13
+ async function readJsonBody(req) {
14
+ const chunks = [];
15
+ let size = 0;
16
+ for await (const chunk of req) {
17
+ size += chunk.length;
18
+ if (size > BODY_LIMIT_BYTES)
19
+ throw new Error('request body too large');
20
+ chunks.push(Buffer.from(chunk));
21
+ }
22
+ if (chunks.length === 0)
23
+ return {};
24
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
25
+ }
26
+ function textOf(content) {
27
+ if (typeof content === 'string')
28
+ return content;
29
+ if (Array.isArray(content)) {
30
+ return content
31
+ .map(part => {
32
+ if (typeof part === 'string')
33
+ return part;
34
+ if (part && typeof part === 'object' && typeof part.text === 'string') {
35
+ return part.text;
36
+ }
37
+ return '';
38
+ })
39
+ .join('');
40
+ }
41
+ return '';
42
+ }
43
+ /**
44
+ * Translate one OpenAI chat message into the host's message vocabulary.
45
+ *
46
+ * The host distinguishes the producer of a message (`source`), so a plain
47
+ * OpenAI transcript maps onto: system → system-prompt, user → user,
48
+ * assistant → model, tool → tool result.
49
+ */
50
+ function toHostMessages(messages, provider, model) {
51
+ return messages.map(message => {
52
+ const id = randomUUID();
53
+ const role = String(message.role || 'user').toLowerCase();
54
+ const text = textOf(message.content);
55
+ if (role === 'system' || role === 'developer') {
56
+ return { id, role: 'system', content: [{ type: 'text', text }], source: { kind: 'system-prompt' } };
57
+ }
58
+ if (role === 'tool') {
59
+ const callId = String(message.tool_call_id || '');
60
+ return {
61
+ id,
62
+ role: 'tool',
63
+ content: [{ type: 'text', text }],
64
+ source: { kind: 'tool', callId },
65
+ toolCallId: callId,
66
+ };
67
+ }
68
+ if (role === 'assistant') {
69
+ const content = [];
70
+ if (text !== '')
71
+ content.push({ type: 'text', text });
72
+ for (const call of message.tool_calls ?? []) {
73
+ content.push({
74
+ type: 'tool-call',
75
+ id: String(call.id ?? ''),
76
+ name: String(call.function?.name ?? ''),
77
+ arguments: String(call.function?.arguments ?? ''),
78
+ });
79
+ }
80
+ return { id, role: 'assistant', content, source: { kind: 'model', provider, model } };
81
+ }
82
+ return { id, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } };
83
+ });
84
+ }
85
+ /** Translate OpenAI function declarations into the host's tool schema shape. */
86
+ function toHostTools(tools) {
87
+ if (!Array.isArray(tools) || tools.length === 0)
88
+ return undefined;
89
+ const converted = [];
90
+ for (const entry of tools) {
91
+ if (!entry || typeof entry !== 'object')
92
+ continue;
93
+ const fn = entry.function;
94
+ const name = String(fn?.name ?? '');
95
+ if (name === '')
96
+ continue;
97
+ converted.push({
98
+ name,
99
+ description: String(fn?.description ?? ''),
100
+ parameters: (fn?.parameters ?? { type: 'object', properties: {} }),
101
+ });
102
+ }
103
+ return converted.length === 0 ? undefined : converted;
104
+ }
105
+ /** Consume the host's chunk stream into one OpenAI-shaped message. */
106
+ async function assemble(chunks) {
107
+ const partials = new Map();
108
+ const order = [];
109
+ let finishReason = 'stop';
110
+ let failureDetail = null;
111
+ let promptTokens = 0;
112
+ let completionTokens = 0;
113
+ let totalTokens = 0;
114
+ const ensure = (index, blockType) => {
115
+ let partial = partials.get(index);
116
+ if (partial === undefined) {
117
+ partial = { blockType, text: '', toolCallId: '', toolCallName: '', toolCallArguments: '' };
118
+ partials.set(index, partial);
119
+ order.push(index);
120
+ }
121
+ return partial;
122
+ };
123
+ for await (const chunk of chunks) {
124
+ if (chunk.type === 'block-start') {
125
+ ensure(chunk.index, chunk.blockType);
126
+ }
127
+ else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
128
+ const partial = ensure(chunk.index, chunk.type === 'text-delta' ? 'text' : 'reasoning');
129
+ if (partial.closed !== undefined)
130
+ continue;
131
+ partial.text += chunk.text;
132
+ }
133
+ else if (chunk.type === 'tool-call-delta') {
134
+ const partial = ensure(chunk.index, 'tool-call');
135
+ if (partial.closed !== undefined)
136
+ continue;
137
+ if (chunk.id !== '')
138
+ partial.toolCallId = chunk.id;
139
+ if (chunk.name !== undefined)
140
+ partial.toolCallName = chunk.name;
141
+ partial.toolCallArguments += chunk.argumentsDelta;
142
+ }
143
+ else if (chunk.type === 'block-end') {
144
+ const partial = ensure(chunk.index, chunk.block?.type ?? 'text');
145
+ // First close wins: a re-closed block is a straggler.
146
+ if (partial.closed === undefined)
147
+ partial.closed = chunk.block;
148
+ }
149
+ else if (chunk.type === 'usage') {
150
+ promptTokens = chunk.usage.inputTokens ?? promptTokens;
151
+ completionTokens = chunk.usage.outputTokens ?? completionTokens;
152
+ totalTokens = chunk.usage.totalTokens ?? promptTokens + completionTokens;
153
+ }
154
+ else if (chunk.type === 'finish') {
155
+ const kind = typeof chunk.reason === 'string' ? chunk.reason : String(chunk.reason?.kind ?? 'stop');
156
+ finishReason = kind;
157
+ if (kind === 'error' || kind === 'aborted') {
158
+ const failure = typeof chunk.reason === 'string' ? undefined : chunk.reason?.failure;
159
+ failureDetail = {
160
+ message: String(failure?.message ?? '宿主模型调用失败'),
161
+ code: String(failure?.code ?? kind),
162
+ status: Number(failure?.status ?? 0),
163
+ };
164
+ }
165
+ }
166
+ }
167
+ let content = '';
168
+ let reasoning = '';
169
+ const toolCalls = [];
170
+ for (const index of order) {
171
+ const partial = partials.get(index);
172
+ if (partial === undefined)
173
+ continue;
174
+ const closed = partial.closed;
175
+ const type = closed?.type ?? partial.blockType;
176
+ if (type === 'text')
177
+ content += closed?.text ?? partial.text;
178
+ else if (type === 'reasoning')
179
+ reasoning += closed?.text ?? partial.text;
180
+ else if (type === 'tool-call') {
181
+ toolCalls.push({
182
+ id: closed?.id ?? partial.toolCallId,
183
+ name: closed?.name ?? partial.toolCallName,
184
+ arguments: closed?.arguments ?? partial.toolCallArguments,
185
+ });
186
+ }
187
+ }
188
+ if (toolCalls.length > 0 && failureDetail === null)
189
+ finishReason = 'tool-calls';
190
+ return {
191
+ content,
192
+ reasoning,
193
+ toolCalls,
194
+ finishReason,
195
+ usage: { promptTokens, completionTokens, totalTokens },
196
+ failure: failureDetail,
197
+ };
198
+ }
199
+ /** Map the host's finish reason onto the OpenAI vocabulary. */
200
+ function openAiFinishReason(reason) {
201
+ if (reason === 'tool-calls')
202
+ return 'tool_calls';
203
+ if (reason === 'max-tokens')
204
+ return 'length';
205
+ return 'stop';
206
+ }
207
+ /**
208
+ * How long one provider's directory may take before the catalog gives up on it.
209
+ *
210
+ * A plugin that registers its own providers resolves its model directory over
211
+ * the network the first time it is asked, so a cold host can make a single
212
+ * provider slow: the our-free-model channel pack fetches each channel's
213
+ * directory with a timeout of its own. The catalog must still answer - a
214
+ * provider that misses the deadline is left out and the rest is returned.
215
+ */
216
+ const PROVIDER_DEADLINE_MS = 4000;
217
+ /**
218
+ * How long one model's context lookup may take.
219
+ *
220
+ * The context window is decoration on an entry that is otherwise complete, so
221
+ * a slow lookup costs the entry its decoration, never the entry.
222
+ */
223
+ const MODEL_DEADLINE_MS = 2000;
224
+ /**
225
+ * Resolve a promise, or give up after `ms` milliseconds.
226
+ *
227
+ * The loser is not cancelled - the host owns that promise - but its rejection
228
+ * is swallowed, so a late failure never becomes an unhandled rejection.
229
+ * @param work - the promise to wait for
230
+ * @param ms - how long to wait before giving up
231
+ * @returns the resolved value, or `undefined` when the work was too slow or failed
232
+ */
233
+ function withDeadline(work, ms) {
234
+ return new Promise((resolve) => {
235
+ const timer = setTimeout(() => resolve(undefined), ms);
236
+ void work.then((value) => { clearTimeout(timer); resolve(value); }, () => { clearTimeout(timer); resolve(undefined); });
237
+ });
238
+ }
239
+ export class HostLlmRelay {
240
+ options;
241
+ server;
242
+ port = 0;
243
+ closed = false;
244
+ constructor(options) {
245
+ this.options = options;
246
+ }
247
+ /** Bind the loopback port. Idempotent. */
248
+ async listen() {
249
+ if (this.server !== undefined)
250
+ return;
251
+ const server = createServer((req, res) => {
252
+ void this.handle(req, res).catch(() => {
253
+ if (!res.headersSent)
254
+ jsonBody(res, 500, { error: { message: 'host relay failed', type: 'server_error' } });
255
+ else
256
+ res.end();
257
+ });
258
+ });
259
+ await new Promise((resolve, reject) => {
260
+ server.once('error', reject);
261
+ server.listen(0, '127.0.0.1', () => {
262
+ server.removeListener('error', reject);
263
+ resolve();
264
+ });
265
+ });
266
+ const address = server.address();
267
+ this.port = typeof address === 'object' && address !== null ? address.port : 0;
268
+ this.server = server;
269
+ }
270
+ /** The OpenAI-compatible base URL Core should use. */
271
+ baseUrl() {
272
+ return `http://127.0.0.1:${this.port}/v1`;
273
+ }
274
+ /** The machine token Core must present. */
275
+ token() {
276
+ return this.options.token;
277
+ }
278
+ async close() {
279
+ if (this.closed)
280
+ return;
281
+ this.closed = true;
282
+ const server = this.server;
283
+ this.server = undefined;
284
+ if (server === undefined)
285
+ return;
286
+ await new Promise(resolve => server.close(() => resolve()));
287
+ }
288
+ /** The host's model directory, including the models other plugins registered. */
289
+ async catalog() {
290
+ const providers = this.options.llm.listProviders().map(provider => ({ id: provider.id, name: provider.name }));
291
+ // Every provider is asked at once, and every model of that provider at once
292
+ // too. The directory is assembled from plugins that each answer over the
293
+ // network, and asking them one after another made the whole answer as slow
294
+ // as their sum: ten providers that each spend a second on a cold start
295
+ // took ten seconds, and the Core behind this relay gives up after ten.
296
+ // The host's own model catalog asks them in parallel for the same reason.
297
+ const groups = await Promise.all(providers.map(provider => this.providerModels(provider)));
298
+ const models = groups.flat();
299
+ const current = this.options.defaultSelection?.();
300
+ if (current !== undefined && current.provider !== '' && current.model !== '') {
301
+ models.unshift({
302
+ id: HOST_CURRENT_MODEL,
303
+ provider: current.provider,
304
+ model: current.model,
305
+ label: '宿主当前模型',
306
+ description: `${current.provider} · ${current.model}`,
307
+ contextTokens: await this.contextTokens(current.provider, current.model),
308
+ });
309
+ }
310
+ return { available: true, providers, models, current: current ?? null, error: '' };
311
+ }
312
+ /** One provider's models, or none when that provider did not answer in time. */
313
+ async providerModels(provider) {
314
+ const deadline = this.options.providerDeadlineMs ?? PROVIDER_DEADLINE_MS;
315
+ let entries = [];
316
+ try {
317
+ entries = await withDeadline(Promise.resolve(this.options.llm.listModels(provider.id)), deadline) ?? [];
318
+ }
319
+ catch {
320
+ return [];
321
+ }
322
+ return await Promise.all(entries.map(async (entry) => ({
323
+ id: `${provider.id}/${entry.id}`,
324
+ provider: provider.id,
325
+ model: entry.id,
326
+ label: entry.name || entry.id,
327
+ description: entry.description ?? '',
328
+ contextTokens: await this.contextTokens(provider.id, entry.id),
329
+ })));
330
+ }
331
+ /** The route's own context budget, or 0 when the adapter declares none. */
332
+ async contextTokens(provider, model) {
333
+ const resolve = this.options.llm.resolveModelInfo;
334
+ if (typeof resolve !== 'function')
335
+ return 0;
336
+ const deadline = this.options.modelDeadlineMs ?? MODEL_DEADLINE_MS;
337
+ let info;
338
+ try {
339
+ info = await withDeadline(Promise.resolve(resolve.call(this.options.llm, provider, model)), deadline);
340
+ }
341
+ catch {
342
+ return 0;
343
+ }
344
+ const window = Number(info?.context?.contextWindow ?? 0);
345
+ return Number.isFinite(window) && window > 0 ? window : 0;
346
+ }
347
+ /** Split `provider/model`; the first segment is the provider route. */
348
+ resolveRoute(model) {
349
+ const raw = String(model || '').trim();
350
+ if (raw === HOST_CURRENT_MODEL) {
351
+ const selection = this.options.defaultSelection?.();
352
+ if (selection === undefined || selection.provider === '' || selection.model === '') {
353
+ throw new Error('宿主没有配置默认模型');
354
+ }
355
+ return { provider: selection.provider, model: selection.model, reasoningEffort: selection.reasoningEffort };
356
+ }
357
+ const separator = raw.indexOf('/');
358
+ if (separator <= 0 || separator === raw.length - 1) {
359
+ throw new Error(`模型必须写成 provider/model 形式: ${raw}`);
360
+ }
361
+ return { provider: raw.slice(0, separator), model: raw.slice(separator + 1) };
362
+ }
363
+ async handle(req, res) {
364
+ const url = String(req.url ?? '/');
365
+ if (url === '/v1/models') {
366
+ if (!this.authorized(req)) {
367
+ jsonBody(res, 401, { error: { message: 'invalid host relay token', type: 'invalid_request_error' } });
368
+ return;
369
+ }
370
+ if (req.method !== 'GET') {
371
+ jsonBody(res, 405, { error: { message: 'method not allowed', type: 'invalid_request_error' } });
372
+ return;
373
+ }
374
+ const catalog = await this.catalog();
375
+ jsonBody(res, 200, {
376
+ object: 'list',
377
+ data: catalog.models.map(entry => ({
378
+ id: entry.id,
379
+ object: 'model',
380
+ created: 0,
381
+ owned_by: entry.provider,
382
+ // Beyond the OpenAI shape: the Core side renders these fields, and
383
+ // an OpenAI-compatible client simply ignores what it does not know.
384
+ provider: entry.provider,
385
+ model: entry.model,
386
+ label: entry.label,
387
+ description: entry.description,
388
+ context_tokens: entry.contextTokens,
389
+ })),
390
+ });
391
+ return;
392
+ }
393
+ if (url === '/v1/chat/completions') {
394
+ if (!this.authorized(req)) {
395
+ jsonBody(res, 401, { error: { message: 'invalid host relay token', type: 'invalid_request_error' } });
396
+ return;
397
+ }
398
+ if (req.method !== 'POST') {
399
+ jsonBody(res, 405, { error: { message: 'method not allowed', type: 'invalid_request_error' } });
400
+ return;
401
+ }
402
+ await this.chatCompletions(req, res);
403
+ return;
404
+ }
405
+ jsonBody(res, 404, { error: { message: 'not found', type: 'invalid_request_error' } });
406
+ }
407
+ authorized(req) {
408
+ const header = req.headers['authorization'];
409
+ const raw = Array.isArray(header) ? header[0] : header;
410
+ if (typeof raw !== 'string')
411
+ return false;
412
+ return raw === `Bearer ${this.options.token}`;
413
+ }
414
+ async chatCompletions(req, res) {
415
+ let payload;
416
+ try {
417
+ payload = (await readJsonBody(req));
418
+ }
419
+ catch {
420
+ jsonBody(res, 400, { error: { message: 'invalid JSON body', type: 'invalid_request_error' } });
421
+ return;
422
+ }
423
+ const messages = Array.isArray(payload.messages) ? payload.messages : [];
424
+ if (messages.length === 0) {
425
+ jsonBody(res, 400, { error: { message: 'messages is required', type: 'invalid_request_error' } });
426
+ return;
427
+ }
428
+ let route;
429
+ try {
430
+ route = this.resolveRoute(String(payload.model ?? ''));
431
+ }
432
+ catch (error) {
433
+ jsonBody(res, 400, { error: { message: error instanceof Error ? error.message : String(error), type: 'invalid_request_error' } });
434
+ return;
435
+ }
436
+ const controller = new AbortController();
437
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
438
+ req.once('close', () => { if (!res.writableEnded)
439
+ controller.abort(); });
440
+ const options = {
441
+ provider: route.provider,
442
+ model: route.model,
443
+ messages: toHostMessages(messages, route.provider, route.model),
444
+ };
445
+ if (typeof payload.temperature === 'number')
446
+ options.temperature = payload.temperature;
447
+ if (typeof payload.max_tokens === 'number')
448
+ options.maxTokens = payload.max_tokens;
449
+ if (Array.isArray(payload.stop) && payload.stop.length > 0)
450
+ options.stop = payload.stop.map(String);
451
+ const tools = toHostTools(payload.tools);
452
+ if (tools !== undefined)
453
+ options.tools = tools;
454
+ // "No reasoning" reaches the Core as an OpenAI-shaped body field, because
455
+ // LiteLLM strips a `reasoning_effort` parameter for a custom model name
456
+ // (drop_params). The host speaks reasoning effort, so translate here: a
457
+ // flash model that keeps reasoning spends the whole output budget on
458
+ // reasoning and answers with an empty "length" stop, which the Core can
459
+ // only read as an unparseable answer.
460
+ const thinkingType = payload.thinking?.type;
461
+ if (typeof thinkingType === 'string' && thinkingType === 'disabled') {
462
+ options.reasoningEffort = 'off';
463
+ }
464
+ else {
465
+ const effort = payload.reasoning_effort ?? route.reasoningEffort;
466
+ if (typeof effort === 'string' && effort !== '')
467
+ options.reasoningEffort = effort;
468
+ }
469
+ options.signal = controller.signal;
470
+ let chunks;
471
+ try {
472
+ chunks = this.options.llm.stream(options);
473
+ }
474
+ catch (error) {
475
+ clearTimeout(timer);
476
+ jsonBody(res, 502, { error: { message: error instanceof Error ? error.message : String(error), type: 'server_error' } });
477
+ return;
478
+ }
479
+ chunks = this.withEffortFallback(options, chunks);
480
+ const completionId = `chatcmpl-${randomUUID()}`;
481
+ const created = Math.floor(Date.now() / 1000);
482
+ const requestedModel = String(payload.model ?? '');
483
+ if (payload.stream === true) {
484
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
485
+ let opened = false;
486
+ // An adapter may stream a block's deltas or only close it. Whatever it
487
+ // did, the client must see the content exactly once.
488
+ const streamedText = new Set();
489
+ const streamedTool = new Set();
490
+ try {
491
+ for await (const chunk of chunks) {
492
+ if (res.writableEnded)
493
+ break;
494
+ if (chunk.type === 'text-delta' && chunk.text !== '') {
495
+ opened = true;
496
+ streamedText.add(chunk.index);
497
+ this.writeChunk(res, completionId, created, requestedModel, { content: chunk.text });
498
+ }
499
+ else if (chunk.type === 'tool-call-delta') {
500
+ opened = true;
501
+ streamedTool.add(chunk.index);
502
+ this.writeChunk(res, completionId, created, requestedModel, {
503
+ tool_calls: [{ index: chunk.index, id: chunk.id, type: 'function', function: { name: chunk.name ?? '', arguments: chunk.argumentsDelta } }],
504
+ });
505
+ }
506
+ else if (chunk.type === 'block-end') {
507
+ const block = chunk.block;
508
+ if (block?.type === 'text' && !streamedText.has(chunk.index) && (block.text ?? '') !== '') {
509
+ opened = true;
510
+ streamedText.add(chunk.index);
511
+ this.writeChunk(res, completionId, created, requestedModel, { content: block.text });
512
+ }
513
+ else if (block?.type === 'tool-call' && !streamedTool.has(chunk.index)) {
514
+ opened = true;
515
+ streamedTool.add(chunk.index);
516
+ this.writeChunk(res, completionId, created, requestedModel, {
517
+ tool_calls: [{ index: chunk.index, id: block.id ?? '', type: 'function', function: { name: block.name ?? '', arguments: block.arguments ?? '' } }],
518
+ });
519
+ }
520
+ }
521
+ else if (chunk.type === 'usage') {
522
+ this.writeChunk(res, completionId, created, requestedModel, {}, {
523
+ prompt_tokens: chunk.usage.inputTokens ?? 0,
524
+ completion_tokens: chunk.usage.outputTokens ?? 0,
525
+ total_tokens: chunk.usage.totalTokens ?? 0,
526
+ });
527
+ }
528
+ else if (chunk.type === 'finish') {
529
+ const kind = typeof chunk.reason === 'string' ? chunk.reason : String(chunk.reason?.kind ?? 'stop');
530
+ if (kind === 'error' || kind === 'aborted') {
531
+ const failure = typeof chunk.reason === 'string' ? undefined : chunk.reason?.failure;
532
+ this.writeChunk(res, completionId, created, requestedModel, {}, undefined, undefined, String(failure?.message ?? '宿主模型调用失败'));
533
+ continue;
534
+ }
535
+ this.writeChunk(res, completionId, created, requestedModel, {}, undefined, openAiFinishReason(kind));
536
+ }
537
+ }
538
+ }
539
+ catch (error) {
540
+ this.writeChunk(res, completionId, created, requestedModel, {}, undefined, undefined, error instanceof Error ? error.message : String(error));
541
+ }
542
+ clearTimeout(timer);
543
+ if (!res.writableEnded) {
544
+ res.write('data: [DONE]\n\n');
545
+ res.end();
546
+ }
547
+ return;
548
+ }
549
+ try {
550
+ const assembled = await assemble(chunks);
551
+ clearTimeout(timer);
552
+ if (assembled.failure !== null) {
553
+ // The host's adapter reported a provider failure. Answering 200 with an
554
+ // empty completion would hide it: the caller would read "the model
555
+ // answered nothing" instead of "the provider refused". The provider's
556
+ // own status travels with it, so a rate limit stays a rate limit.
557
+ const status = assembled.failure.status >= 400 && assembled.failure.status <= 599
558
+ ? assembled.failure.status
559
+ : 502;
560
+ jsonBody(res, status, {
561
+ error: {
562
+ message: assembled.failure.message,
563
+ type: 'upstream_error',
564
+ code: assembled.failure.code,
565
+ },
566
+ });
567
+ return;
568
+ }
569
+ const message = { role: 'assistant', content: assembled.content };
570
+ if (assembled.toolCalls.length > 0) {
571
+ message['tool_calls'] = assembled.toolCalls.map(call => ({
572
+ id: call.id,
573
+ type: 'function',
574
+ function: { name: call.name, arguments: call.arguments },
575
+ }));
576
+ }
577
+ jsonBody(res, 200, {
578
+ id: completionId,
579
+ object: 'chat.completion',
580
+ created,
581
+ model: requestedModel,
582
+ choices: [{
583
+ index: 0,
584
+ message,
585
+ finish_reason: openAiFinishReason(assembled.finishReason),
586
+ }],
587
+ usage: {
588
+ prompt_tokens: assembled.usage.promptTokens,
589
+ completion_tokens: assembled.usage.completionTokens,
590
+ total_tokens: assembled.usage.totalTokens,
591
+ },
592
+ });
593
+ }
594
+ catch (error) {
595
+ clearTimeout(timer);
596
+ if (!res.headersSent) {
597
+ jsonBody(res, 502, { error: { message: error instanceof Error ? error.message : String(error), type: 'server_error' } });
598
+ }
599
+ else if (!res.writableEnded) {
600
+ res.end();
601
+ }
602
+ }
603
+ }
604
+ /**
605
+ * Retry once without the reasoning effort when the host refuses it.
606
+ *
607
+ * A model that declares no reasoning support rejects an explicit effort
608
+ * (the host reports `UNSUPPORTED_REASONING_EFFORT` as a terminal finish).
609
+ * Asking for "no reasoning" must never turn into a failed call, so the
610
+ * second attempt drops the effort and lets the provider decide. The retry
611
+ * only happens before any content was produced: the refusal arrives as the
612
+ * first chunk, so nothing has been written to the client yet.
613
+ */
614
+ async *withEffortFallback(options, first) {
615
+ let current = first;
616
+ for (let attempt = 0; attempt < 2; attempt++) {
617
+ let produced = false;
618
+ let refused = false;
619
+ for await (const chunk of current) {
620
+ if (!produced && chunk.type === 'finish') {
621
+ const reason = chunk.reason;
622
+ const kind = typeof reason === 'string' ? reason : String(reason?.kind ?? 'stop');
623
+ const code = typeof reason === 'string' ? '' : String(reason?.failure?.code ?? '');
624
+ if (kind === 'error' && code === 'UNSUPPORTED_REASONING_EFFORT' && options.reasoningEffort !== undefined) {
625
+ refused = true;
626
+ break;
627
+ }
628
+ }
629
+ produced = true;
630
+ yield chunk;
631
+ }
632
+ if (!refused)
633
+ return;
634
+ current = this.options.llm.stream({ ...options, reasoningEffort: undefined });
635
+ }
636
+ }
637
+ writeChunk(res, id, created, model, delta, usage, finishReason, errorMessage) {
638
+ if (res.writableEnded)
639
+ return;
640
+ const payload = {
641
+ id,
642
+ object: 'chat.completion.chunk',
643
+ created,
644
+ model,
645
+ choices: [{ index: 0, delta, finish_reason: finishReason ?? null }],
646
+ };
647
+ if (usage !== undefined)
648
+ payload['usage'] = usage;
649
+ if (errorMessage !== undefined)
650
+ payload['error'] = { message: errorMessage, type: 'server_error' };
651
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
652
+ }
653
+ }
654
+ /** A fresh machine token for one relay instance. */
655
+ export function newRelayToken() {
656
+ return randomBytes(32).toString('hex');
657
+ }