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.
- package/LICENSE +1 -1
- package/README.md +49 -8
- package/THIRD_PARTY_NOTICES.md +4 -3
- package/companions.mjs +508 -0
- package/cordis.patch.yml +0 -2
- package/docs/CLOSED_ISSUE_FOLLOWUPS.md +1 -1
- package/docs/COMPANION_PLUGINS.md +142 -0
- package/docs/CONTEXT_SLOTS.md +1 -1
- package/docs/COST_MEASUREMENT.md +2 -2
- package/docs/DEVELOPMENT.md +6 -11
- package/docs/DSH_STORE.md +14 -2
- package/docs/EDITOR_SAFETY_REPAIRS.md +2 -2
- package/docs/INSTALL.md +112 -4
- package/docs/ISSUES_121_123.md +4 -4
- package/docs/ISSUES_140_142.md +58 -0
- package/docs/ISSUES_149_150_151.md +77 -0
- package/docs/ISSUE_123_FOLLOWUP.md +3 -2
- package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
- package/docs/LEARNING.md +1 -1
- package/docs/MOBILE_READINESS.md +62 -0
- package/docs/PLUGIN_MAINTENANCE.md +1 -3
- package/docs/RELEASE_ACCEPTANCE.md +20 -3
- package/docs/REVIEW_ROUND_22.md +67 -0
- package/docs/REVIEW_ROUND_23.md +48 -0
- package/docs/REVIEW_ROUND_25.md +51 -0
- package/docs/SELECTION_UPGRADE_REPAIRS.md +2 -2
- package/docs/TOOLS.md +181 -604
- package/docs/WORKFLOWS.md +5 -5
- package/package.json +17 -21
- package/packages/openwrite-bridge/lib/client.d.ts +0 -1
- package/packages/openwrite-bridge/lib/client.js +90 -9
- package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
- package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
- package/packages/openwrite-bridge/lib/dog-delivery.js +3 -1
- package/packages/openwrite-bridge/lib/dog-review.js +15 -3
- package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
- package/packages/openwrite-bridge/lib/domain.js +250 -29
- package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
- package/packages/openwrite-bridge/lib/host-llm.js +657 -0
- package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
- package/packages/openwrite-bridge/lib/host-web.js +166 -0
- package/packages/openwrite-bridge/lib/index.d.ts +4 -0
- package/packages/openwrite-bridge/lib/index.js +150 -6
- package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
- package/packages/openwrite-bridge/lib/managed-runtime.js +176 -34
- package/packages/openwrite-bridge/lib/tool-policy.js +37 -12
- package/packages/openwrite-bridge/lib/tools.js +1223 -1227
- package/packages/openwrite-bridge/package.json +7 -5
- package/packages/studio-panel/lib/client.js +123207 -121534
- package/packages/studio-panel/lib/client.js.map +1 -1
- package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
- package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
- package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
- package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
- package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
- package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
- package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
- package/packages/studio-panel/lib/types/client/VditorBody.d.ts +1 -1
- package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
- package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
- package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
- package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
- package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
- package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
- package/packages/studio-panel/package.json +5 -3
- package/plugin.mjs +159 -20
- package/preset-definition.mjs +144 -4
- package/presets/openwrite/agent.cordis.yml +7 -9
- package/presets/openwrite/ops-integration.mjs +28 -0
- package/presets/openwrite/preset.yml +2 -1
- package/presets/openwrite/skills/canon/SKILL.md +3 -3
- package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
- package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
- package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
- package/presets/openwrite/skills/export-book/SKILL.md +3 -3
- package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
- package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
- package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
- package/presets/openwrite/skills/learn/SKILL.md +3 -3
- package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
- package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
- package/presets/openwrite/skills/progress/SKILL.md +1 -1
- package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
- package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
- package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
- package/presets/openwrite/skills/style-system/SKILL.md +13 -6
- package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
- package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
- package/presets/openwrite/skills/world-query/SKILL.md +16 -11
- package/presets/openwrite/skills/write-next/SKILL.md +1 -1
- package/release/{openwrite-5.8.20-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
- package/release/runtime-manifest.json +4 -13
- package/release/source.json +21 -21
- package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
- package/scripts/maintenance.mjs +67 -7
- package/scripts/plugin-doctor.mjs +179 -0
- package/scripts/prepare.mjs +26 -4
- package/scripts/safe-upgrade.mjs +38 -5
- package/dog-tools.mjs +0 -13
- package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
- package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
- package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
- package/scripts/dog/delivery-stage.js +0 -32
- package/scripts/dog/import-chapter.js +0 -20
- package/scripts/dog/import-record.js +0 -35
- package/scripts/dog/review-dimension.js +0 -41
- package/scripts/dog/review-record.js +0 -42
- package/vendor/dsh-dog/LICENSE +0 -29
- package/vendor/dsh-dog/README.md +0 -114
- package/vendor/dsh-dog/UPSTREAM.md +0 -30
- package/vendor/dsh-dog/cordis.patch.yml +0 -14
- package/vendor/dsh-dog/lib/client.js +0 -2960
- package/vendor/dsh-dog/lib/client.js.map +0 -1
- package/vendor/dsh-dog/lib/core--byUPJL8.js +0 -2191
- package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
- package/vendor/dsh-dog/lib/core.d.ts +0 -2
- package/vendor/dsh-dog/lib/core.js +0 -2
- package/vendor/dsh-dog/lib/index.d.ts +0 -143
- package/vendor/dsh-dog/lib/index.js +0 -1230
- package/vendor/dsh-dog/package.json +0 -110
- package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
- package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
- package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
- package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
- 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
|
+
}
|