@toddzheng024/dscode-bundle 0.7.5 → 0.7.7

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 (55) hide show
  1. package/THIRD_PARTY_NOTICES.md +3 -3
  2. package/cordis.patch.yml +26 -5
  3. package/package.json +4 -4
  4. package/plugins/auto-review/index.mjs +6 -1
  5. package/plugins/code-review/index.mjs +9 -4
  6. package/plugins/compaction/tetris.mjs +65 -0
  7. package/plugins/compaction/threshold.mjs +46 -0
  8. package/plugins/credentials/index.mjs +2 -2
  9. package/plugins/dscode/index.mjs +4 -10
  10. package/plugins/exec/cli.mjs +3 -2
  11. package/plugins/exec/index.mjs +6 -1
  12. package/plugins/i18n/messages.mjs +18 -0
  13. package/plugins/memory/index.mjs +7 -3
  14. package/plugins/openrouter/adapter.mjs +157 -0
  15. package/plugins/openrouter/index.mjs +112 -0
  16. package/plugins/openrouter/models.mjs +151 -0
  17. package/plugins/openrouter/search.mjs +109 -0
  18. package/plugins/openrouter/wire.mjs +413 -0
  19. package/plugins/providers/catalog.mjs +28 -26
  20. package/plugins/providers/effort.mjs +35 -0
  21. package/plugins/providers/openrouter-account.mjs +171 -0
  22. package/plugins/session-cards/index.mjs +5 -1
  23. package/plugins/session-metrics/balance.mjs +29 -19
  24. package/plugins/session-metrics/index.mjs +19 -6
  25. package/plugins/session-metrics/pricing.mjs +45 -14
  26. package/plugins/session-metrics/view.mjs +1 -1
  27. package/plugins/tui-tools/doctor.mjs +3 -1
  28. package/plugins/tui-tools/index.mjs +1 -1
  29. package/plugins/ultra/policy.mjs +0 -16
  30. package/presets/dscode/agent.cordis.yml +1 -1
  31. package/vendor/compaction-basic/index.js +983 -0
  32. package/vendor/compaction-basic/types/config.d.ts +37 -0
  33. package/vendor/compaction-basic/types/index.d.ts +84 -0
  34. package/vendor/compaction-basic/types/region.d.ts +65 -0
  35. package/vendor/compaction-basic/types/summarizer.d.ts +64 -0
  36. package/vendor/compaction-basic/types/types.d.ts +73 -0
  37. package/vendor/deepseek/index.js +1 -1
  38. package/vendor/subagent/index.js +3 -3
  39. package/vendor/tui/dscode-providers/catalog.mjs +28 -26
  40. package/vendor/tui/dscode-providers/effort.mjs +35 -0
  41. package/vendor/tui/dscode-providers/openrouter-account.mjs +171 -0
  42. package/vendor/tui/index.mjs +395 -133
  43. package/vendor/pi-ai/index.js +0 -2702
  44. package/vendor/pi-ai/types/adapter.d.ts +0 -105
  45. package/vendor/pi-ai/types/auth.d.ts +0 -60
  46. package/vendor/pi-ai/types/catalog.d.ts +0 -355
  47. package/vendor/pi-ai/types/config.d.ts +0 -208
  48. package/vendor/pi-ai/types/context.d.ts +0 -42
  49. package/vendor/pi-ai/types/discovery.d.ts +0 -43
  50. package/vendor/pi-ai/types/index.d.ts +0 -69
  51. package/vendor/pi-ai/types/login.d.ts +0 -21
  52. package/vendor/pi-ai/types/provider.d.ts +0 -59
  53. package/vendor/pi-ai/types/replay.d.ts +0 -63
  54. package/vendor/pi-ai/types/stream.d.ts +0 -43
  55. /package/vendor/{pi-ai → compaction-basic}/LICENSE +0 -0
@@ -0,0 +1,413 @@
1
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError, QUOTA_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm';
2
+ import { OPENROUTER_EFFORTS } from '../providers/catalog.mjs';
3
+ import { ultraRequest } from '../ultra/policy.mjs';
4
+
5
+ // OpenRouter chat completions on the wire: request bodies, the SSE stream, and
6
+ // the translation into harness stream chunks. Pure functions; the adapter owns I/O.
7
+
8
+ export const PROVIDER = 'openrouter';
9
+ /** `replayState.response.kind` for responses this adapter produced. */
10
+ export const REPLAY_KIND = 'dscode-openrouter';
11
+ const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:';
12
+ // OpenRouter serves DeepSeek V4 thinking as none/high/xhigh; the route keeps the
13
+ // official off/low/high/max detents (and Ultra on max) with the spelling it accepts.
14
+ const DEEPSEEK_WIRE = Object.freeze({ ...OPENROUTER_EFFORTS, ultra: OPENROUTER_EFFORTS.max });
15
+ const DEEPSEEK_V4 = /^deepseek\/deepseek-v4/;
16
+ // Models whose provider rejects an assistant tool-call turn without `reasoning_content`.
17
+ const REASONING_CONTENT_MODELS = /^(?:deepseek\/deepseek-v4|moonshotai\/kimi-k2\.6)/;
18
+ // Alibaba caches only at explicit breakpoints.
19
+ const EXPLICIT_CACHE_MODELS = /^qwen\//;
20
+ const DEFAULT_LEVELS = ['low', 'medium', 'high'];
21
+ // OpenRouter picks the upstream endpoint, but only among endpoints that honour every
22
+ // request parameter (by default it silently drops unsupported ones) and that do not
23
+ // serve fp4-quantized weights. INT4 stays: Kimi ships native INT4 weights.
24
+ const ROUTING = Object.freeze({ require_parameters: true, quantizations: Object.freeze(['int4', 'int8', 'fp6', 'fp8', 'mxfp8', 'fp16', 'bf16', 'fp32', 'unknown']) });
25
+ const NAMES = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'XHigh', max: 'Max', ultra: 'Ultra' };
26
+ export const ULTRA_DESCRIPTION = 'DSCODE: max reasoning plus deliberate subagent collaboration; higher total token use.';
27
+
28
+ /**
29
+ * Reasoning controls a model offers through this route.
30
+ * @param model - OpenRouter model id.
31
+ * @param entry - the model's listing entry, if any.
32
+ * @returns `{ levels, defaultEffort?, wire }` with harness effort ids and their wire
33
+ * spelling, or undefined when the model has no reasoning control.
34
+ */
35
+ export function modelReasoning(model, entry) {
36
+ if (DEEPSEEK_V4.test(model)) return { levels: ['off', 'low', 'high', 'max', 'ultra'], defaultEffort: 'high', wire: DEEPSEEK_WIRE };
37
+ const reasoning = entry?.reasoning;
38
+ if (!reasoning) return undefined;
39
+ // No allowlist means OpenRouter maps any standard level; offer the common three.
40
+ const levels = reasoning.efforts ?? DEFAULT_LEVELS;
41
+ const ultra = levels.includes('max');
42
+ const wire = Object.fromEntries([...(reasoning.mandatory ? [] : [['off', 'none']]), ...levels.map(level => [level, level]), ...(ultra ? [['ultra', 'max']] : [])]);
43
+ // Like the official route, requests default to high; a model without high keeps its own default.
44
+ const defaultEffort = levels.includes('high') ? 'high' : levels.includes(reasoning.defaultEffort) ? reasoning.defaultEffort : undefined;
45
+ return { levels: Object.keys(wire), ...(defaultEffort ? { defaultEffort } : {}), wire };
46
+ }
47
+
48
+ /** Display metadata for one harness effort id. */
49
+ export function effortInfo(id) {
50
+ return { id, name: NAMES[id] ?? id, ...(id === 'ultra' ? { description: ULTRA_DESCRIPTION } : {}) };
51
+ }
52
+
53
+ /** A title needs no deliberation: reasoning off when the model allows it, else its lowest level. */
54
+ function titleEffort(reasoning) {
55
+ return reasoning.wire.off !== undefined ? 'off' : reasoning.levels.find(level => level !== 'ultra');
56
+ }
57
+
58
+ const textOf = blocks => blocks.filter(block => block.type === 'text').map(block => block.text).join('');
59
+
60
+ function contentParts(blocks, images) {
61
+ const parts = [];
62
+ for (const block of blocks) {
63
+ if (block.type === 'text' && block.text.length > 0) parts.push({ type: 'text', text: block.text });
64
+ else if (block.type === 'image') {
65
+ const version = images?.versions.get(block.attachment.attachmentId);
66
+ if (version === undefined) { parts.push({ type: 'text', text: textOnlyImageText(block.attachment) }); continue; }
67
+ parts.push({ type: 'text', text: requestImageHandleText(block.attachment, version, images.access?.(block.attachment)) });
68
+ parts.push({ type: 'image_url', image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` } });
69
+ }
70
+ }
71
+ return parts;
72
+ }
73
+
74
+ const collapse = parts => parts.every(part => part.type === 'text') ? parts.map(part => part.text).join('') : parts;
75
+
76
+ /** Reasoning details this adapter (or the pi-ai adapter before it) stored for a same-model replay. */
77
+ function replayedDetails(message) {
78
+ const blocks = message.source?.replayState?.blocks;
79
+ if (!Array.isArray(blocks) || blocks.length !== message.content.length) return [];
80
+ const details = [];
81
+ blocks.forEach((block, index) => {
82
+ if (message.content[index]?.type !== 'reasoning') return;
83
+ if (Array.isArray(block?.reasoningDetails)) details.push(...block.reasoningDetails);
84
+ else if (typeof block?.thinkingSignature === 'string') {
85
+ try {
86
+ const parsed = JSON.parse(block.thinkingSignature);
87
+ if (Array.isArray(parsed)) details.push(...parsed);
88
+ } catch { /* a plain field-name signature carries no details */ }
89
+ }
90
+ });
91
+ return details;
92
+ }
93
+
94
+ function serializeAssistant(message, model) {
95
+ const text = textOf(message.content);
96
+ const calls = message.content.filter(block => block.type === 'tool-call')
97
+ .map(block => ({ id: block.id, type: 'function', function: { name: block.name, arguments: block.arguments } }));
98
+ // An empty assistant turn (reasoning only) has nothing a provider accepts.
99
+ if (text.length === 0 && calls.length === 0) return undefined;
100
+ // Reasoning replays only to the model that produced it.
101
+ const sameModel = message.source?.provider === PROVIDER && message.source?.model === model;
102
+ const details = sameModel ? replayedDetails(message) : [];
103
+ const reasoning = sameModel ? message.content.filter(block => block.type === 'reasoning').map(block => block.text).join('') : '';
104
+ return {
105
+ role: 'assistant',
106
+ content: text,
107
+ ...(calls.length > 0 ? { tool_calls: calls } : {}),
108
+ ...(details.length > 0 ? { reasoning_details: details } : reasoning.length > 0 ? { reasoning } : {}),
109
+ ...(REASONING_CONTENT_MODELS.test(model) ? { reasoning_content: '' } : {}),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Harness history as OpenRouter chat messages. Each tool result becomes a `tool`
115
+ * message; its images follow in one user message, which `tool` content cannot carry.
116
+ * @param images - prepared request images (`versions` by attachment id, `access`), when the request has any.
117
+ */
118
+ export function serializeMessages(messages, { model, system, images } = {}) {
119
+ const wire = [];
120
+ if (system !== undefined && system.length > 0) wire.push({ role: 'system', content: system });
121
+ let pendingImages = [];
122
+ const flush = () => {
123
+ if (pendingImages.length === 0) return;
124
+ wire.push({ role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingImages] });
125
+ pendingImages = [];
126
+ };
127
+ for (const message of messages) {
128
+ if (message.role === 'system') {
129
+ flush();
130
+ const text = textOf(message.content);
131
+ if (text.length > 0) wire.push({ role: 'system', content: text });
132
+ continue;
133
+ }
134
+ if (message.role === 'assistant') {
135
+ flush();
136
+ const entry = serializeAssistant(message, model);
137
+ if (entry) wire.push(entry);
138
+ continue;
139
+ }
140
+ const results = message.content.filter(block => block.type === 'tool-result');
141
+ const parts = contentParts(message.content.filter(block => block.type !== 'tool-result'), images);
142
+ if (parts.length > 0 || results.length === 0) {
143
+ flush();
144
+ wire.push({ role: 'user', content: collapse(parts) });
145
+ }
146
+ for (const result of results) {
147
+ const nested = contentParts(result.content, images);
148
+ const text = nested.filter(part => part.type === 'text').map(part => part.text).join('');
149
+ const attached = nested.filter(part => part.type !== 'text');
150
+ wire.push({ role: 'tool', tool_call_id: result.toolCallId, content: text || (attached.length > 0 ? '(see attached image)' : '(no output)') });
151
+ pendingImages.push(...attached);
152
+ }
153
+ }
154
+ flush();
155
+ return wire;
156
+ }
157
+
158
+ const cacheMarked = content => typeof content === 'string'
159
+ ? [{ type: 'text', text: content, cache_control: { type: 'ephemeral' } }]
160
+ : content.map((part, index) => index === content.findLastIndex(candidate => candidate.type === 'text') ? { ...part, cache_control: { type: 'ephemeral' } } : part);
161
+
162
+ /** Explicit cache breakpoints for models that cache only at them: the system prompt and the latest user turn. */
163
+ export function withCacheBreakpoints(messages, model) {
164
+ if (!EXPLICIT_CACHE_MODELS.test(model)) return messages;
165
+ const targets = new Set([messages.findIndex(message => message.role === 'system'), messages.findLastIndex(message => message.role === 'user')]);
166
+ return messages.map((message, index) => targets.has(index) && message.content !== '' ? { ...message, content: cacheMarked(message.content) } : message);
167
+ }
168
+
169
+ /**
170
+ * The chat-completions body for one harness request.
171
+ * @param options - harness request (images already prepared into `images`).
172
+ * @param context - `{ entry, images }`: the model's listing entry and prepared request images.
173
+ */
174
+ export function requestBody(options, { entry, images } = {}) {
175
+ const reasoning = modelReasoning(options.model, entry);
176
+ const effort = options.purpose === 'session-title' && reasoning ? titleEffort(reasoning) : options.reasoningEffort;
177
+ const wireEffort = effort === undefined ? undefined : reasoning?.wire[effort];
178
+ if (effort !== undefined && reasoning !== undefined && wireEffort === undefined) {
179
+ throw new LlmError(`OpenRouter model "${options.model}" does not offer reasoning effort "${effort}"`, 'UNSUPPORTED_REASONING_EFFORT');
180
+ }
181
+ let messages = serializeMessages(options.messages, { model: options.model, system: options.system, images });
182
+ messages = withCacheBreakpoints(ultraRequest(options, messages), options.model);
183
+ // Delegation tools are offered at every effort; workflow and ralph never are.
184
+ const tools = (options.tools ?? []).filter(tool => tool.name !== 'workflow' && tool.name !== 'ralph')
185
+ .map(tool => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters } }));
186
+ return {
187
+ model: options.model,
188
+ messages,
189
+ stream: true,
190
+ ...(tools.length > 0 ? { tools } : {}),
191
+ ...(wireEffort !== undefined ? { reasoning: { effort: wireEffort } } : {}),
192
+ // Endpoints declare `max_tokens`; `max_completion_tokens` would fail `require_parameters` almost everywhere.
193
+ ...(options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}),
194
+ ...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
195
+ ...(options.stop !== undefined ? { stop: options.stop } : {}),
196
+ // The sticky-routing key: one session keeps one upstream endpoint and its warm cache.
197
+ ...(options.sessionId !== undefined ? { session_id: String(options.sessionId).slice(0, 256) } : {}),
198
+ provider: { require_parameters: ROUTING.require_parameters, quantizations: [...ROUTING.quantizations] },
199
+ };
200
+ }
201
+
202
+ // OpenRouter's typed `metadata.error_type`, which it asks clients to route on before the status.
203
+ const ERROR_TYPES = Object.freeze({
204
+ context_length_exceeded: CONTEXT_WINDOW_EXCEEDED_CODE, token_limit_exceeded: CONTEXT_WINDOW_EXCEEDED_CODE,
205
+ payment_required: QUOTA_EXCEEDED_CODE, authentication: 'AUTH', permission_denied: 'AUTH',
206
+ rate_limit_exceeded: 'RATE_LIMIT', provider_overloaded: 'SERVER', provider_unavailable: 'SERVER', server: 'SERVER', unmapped: 'SERVER',
207
+ timeout: 'TIMEOUT', content_policy_violation: 'CONTENT_POLICY', refusal: 'CONTENT_POLICY',
208
+ });
209
+
210
+ /**
211
+ * Harness failure code for an OpenRouter error.
212
+ * @param status - HTTP status of a rejected request, or undefined for an error inside a 200 stream.
213
+ * @param error - the `error` object (`code`, `message`, `metadata`).
214
+ */
215
+ export function errorCode(status, error) {
216
+ const type = error?.metadata?.error_type;
217
+ if (typeof type === 'string' && Object.hasOwn(ERROR_TYPES, type)) return ERROR_TYPES[type];
218
+ const code = Number.isInteger(status) ? status : Number(error?.code);
219
+ // A 402 names token counts ("fewer max_tokens"); its status decides before any wording does.
220
+ if (code === 402) return QUOTA_EXCEEDED_CODE;
221
+ const detail = [error?.message, error?.metadata?.raw].filter(value => typeof value === 'string').join(' ');
222
+ if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE;
223
+ if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE;
224
+ if (code === 401 || code === 403) return 'AUTH';
225
+ if (code === 429) return 'RATE_LIMIT';
226
+ if (code === 408 || code === 504) return 'TIMEOUT';
227
+ if (code >= 500) return 'SERVER';
228
+ if (code >= 400) return 'INVALID_REQUEST';
229
+ // A stream that failed after its 200 without a numeric code is an upstream failure worth retrying.
230
+ return 'SERVER';
231
+ }
232
+
233
+ /** Human-readable message for an OpenRouter error object. */
234
+ export function errorMessage(error, fallback) {
235
+ const message = typeof error?.message === 'string' && error.message.length > 0 ? error.message : fallback;
236
+ const provider = error?.metadata?.provider_name;
237
+ return typeof provider === 'string' && provider.length > 0 ? `${message} (provider: ${provider})` : message;
238
+ }
239
+
240
+ /** Retry-After as milliseconds (seconds or an HTTP date), when valid. */
241
+ export function retryAfterMs(value) {
242
+ if (value === null || value === undefined) return undefined;
243
+ const delay = /^\d+$/.test(value) ? Number(value) * 1e3 : Date.parse(value) - Date.now();
244
+ return Number.isFinite(delay) && delay > 0 ? delay : undefined;
245
+ }
246
+
247
+ /**
248
+ * SSE `data:` payloads from a response body, skipping `:` keep-alive comments.
249
+ * @param onActivity - called for every received chunk, comments included.
250
+ */
251
+ export async function* sseData(body, onActivity) {
252
+ const decoder = new TextDecoder();
253
+ let buffer = '', data = [];
254
+ const lines = function* (final) {
255
+ let newline;
256
+ while ((newline = buffer.indexOf('\n')) >= 0 || final && buffer.length > 0) {
257
+ let line = newline >= 0 ? buffer.slice(0, newline) : buffer;
258
+ buffer = newline >= 0 ? buffer.slice(newline + 1) : '';
259
+ if (line.endsWith('\r')) line = line.slice(0, -1);
260
+ if (line === '') {
261
+ if (data.length > 0) yield data.join('\n');
262
+ data = [];
263
+ } else if (line.startsWith('data:')) data.push(line.slice(line.startsWith('data: ') ? 6 : 5));
264
+ }
265
+ };
266
+ for await (const chunk of body) {
267
+ onActivity?.();
268
+ buffer += decoder.decode(chunk, { stream: true });
269
+ yield* lines(false);
270
+ }
271
+ buffer += decoder.decode();
272
+ yield* lines(true);
273
+ if (data.length > 0) yield data.join('\n');
274
+ }
275
+
276
+ /** Map OpenRouter usage to disjoint harness counts (`prompt_tokens` includes cache reads and writes). */
277
+ export function mapUsage(usage) {
278
+ const valid = value => Number.isSafeInteger(value) && value >= 0;
279
+ const prompt = usage?.prompt_tokens, completion = usage?.completion_tokens;
280
+ if (!valid(prompt) || !valid(completion)) return undefined;
281
+ const read = valid(usage.prompt_tokens_details?.cached_tokens) ? usage.prompt_tokens_details.cached_tokens : 0;
282
+ const write = valid(usage.prompt_tokens_details?.cache_write_tokens) ? usage.prompt_tokens_details.cache_write_tokens : 0;
283
+ const reasoning = usage.completion_tokens_details?.reasoning_tokens;
284
+ return {
285
+ inputTokens: Math.max(0, prompt - read - write),
286
+ outputTokens: completion,
287
+ ...(usage.total_tokens === undefined || usage.total_tokens === prompt + completion ? { totalTokens: prompt + completion } : {}),
288
+ ...(read > 0 ? { cacheReadTokens: read } : {}),
289
+ ...(write > 0 ? { cacheWriteTokens: write } : {}),
290
+ ...(valid(reasoning) && reasoning > 0 ? { reasoningTokens: reasoning } : {}),
291
+ };
292
+ }
293
+
294
+ /** Streamed reasoning details merged into the blocks a replay sends back unmodified. */
295
+ function mergeDetails(items) {
296
+ const merged = [];
297
+ for (const item of items) {
298
+ if (item === null || typeof item !== 'object') continue;
299
+ const last = merged.at(-1);
300
+ if (last && last.type === item.type && item.type !== 'reasoning.encrypted' && (last.index ?? 0) === (item.index ?? 0)) {
301
+ if (typeof item.text === 'string') last.text = (last.text ?? '') + item.text;
302
+ if (typeof item.summary === 'string') last.summary = (last.summary ?? '') + item.summary;
303
+ for (const key of ['id', 'format', 'signature']) if (item[key] !== undefined && item[key] !== null && item[key] !== '') last[key] = item[key];
304
+ } else merged.push({ ...item });
305
+ }
306
+ return merged;
307
+ }
308
+
309
+ function closeBlock(block) {
310
+ if (block.kind === 'tool-call') return { type: 'tool-call', id: block.callId ?? '', name: block.name ?? '', arguments: block.text };
311
+ return { type: block.kind, text: block.text };
312
+ }
313
+
314
+ function finishReason(reason, blocks) {
315
+ if (reason === 'length') return { kind: 'max-tokens' };
316
+ if (reason === 'content_filter') return { kind: 'error', failure: { message: 'OpenRouter stopped the response for content filtering', code: 'CONTENT_FILTER' } };
317
+ if (reason === 'tool_calls' || blocks.some(block => block.kind === 'tool-call') && (reason === undefined || reason === 'stop')) return { kind: 'tool-calls' };
318
+ if (reason === undefined || reason === null || reason === 'stop' || reason === 'end') {
319
+ return blocks.length === 0 ? { kind: 'error', failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE } } : { kind: 'stop' };
320
+ }
321
+ return { kind: 'error', failure: { message: `model stopped: ${reason}`, code: String(reason).toUpperCase() } };
322
+ }
323
+
324
+ /**
325
+ * Translate SSE payloads into harness chunks. Block ends, usage and the finish are
326
+ * held until `[DONE]`; the finish of a successful response carries the replay state:
327
+ * reasoning details per block, the generation id, the serving provider and the billed cost.
328
+ * An error inside the stream throws with its routed code.
329
+ */
330
+ export async function* translate(payloads, { model }) {
331
+ let nextIndex = 0, textBlock, reasoningBlock, finish, usage, cost, id, provider;
332
+ const tools = new Map();
333
+ const order = [];
334
+ const open = kind => {
335
+ const block = { index: nextIndex++, kind, text: '', details: [] };
336
+ order.push(block);
337
+ return block;
338
+ };
339
+ for await (const payload of payloads) {
340
+ if (payload === '[DONE]') {
341
+ for (const block of order) yield { type: 'block-end', index: block.index, block: closeBlock(block) };
342
+ if (usage) yield { type: 'usage', usage };
343
+ const reason = finishReason(finish, order);
344
+ const succeeded = reason.kind === 'stop' || reason.kind === 'tool-calls' || reason.kind === 'max-tokens';
345
+ yield {
346
+ type: 'finish', reason,
347
+ ...(succeeded ? { replayState: {
348
+ response: { kind: REPLAY_KIND, version: 1, model, ...(id ? { id } : {}), ...(provider ? { provider } : {}), ...(cost !== undefined ? { cost } : {}) },
349
+ blocks: order.map(block => block.kind === 'reasoning' && block.details.length > 0 ? { type: 'reasoning', reasoningDetails: mergeDetails(block.details) } : { type: block.kind }),
350
+ } } : {}),
351
+ };
352
+ return;
353
+ }
354
+ let chunk;
355
+ try {
356
+ chunk = JSON.parse(payload);
357
+ } catch {
358
+ throw new LlmError(`malformed OpenRouter stream payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE');
359
+ }
360
+ if (chunk?.error) {
361
+ const status = Number.isInteger(chunk.error.code) ? chunk.error.code : undefined;
362
+ throw new LlmError(errorMessage(chunk.error, 'OpenRouter stream error'), errorCode(undefined, chunk.error), status === undefined ? {} : { status });
363
+ }
364
+ if (typeof chunk?.id === 'string') id ??= chunk.id;
365
+ if (typeof chunk?.provider === 'string') provider ??= chunk.provider;
366
+ for (const choice of chunk?.choices ?? []) {
367
+ const delta = choice.delta ?? {};
368
+ const details = Array.isArray(delta.reasoning_details) ? delta.reasoning_details : [];
369
+ let reasoning = typeof delta.reasoning === 'string' ? delta.reasoning : typeof delta.reasoning_content === 'string' ? delta.reasoning_content : '';
370
+ if (reasoning.length === 0) reasoning = details.map(detail => detail?.type === 'reasoning.text' ? detail.text : detail?.type === 'reasoning.summary' ? detail.summary : '').filter(text => typeof text === 'string').join('');
371
+ if (reasoning.length > 0 || details.length > 0) {
372
+ if (!reasoningBlock) {
373
+ reasoningBlock = open('reasoning');
374
+ yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' };
375
+ }
376
+ reasoningBlock.details.push(...details);
377
+ if (reasoning.length > 0) {
378
+ reasoningBlock.text += reasoning;
379
+ yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning };
380
+ }
381
+ }
382
+ if (typeof delta.content === 'string' && delta.content.length > 0) {
383
+ if (!textBlock) {
384
+ textBlock = open('text');
385
+ yield { type: 'block-start', index: textBlock.index, blockType: 'text' };
386
+ }
387
+ textBlock.text += delta.content;
388
+ yield { type: 'text-delta', index: textBlock.index, text: delta.content };
389
+ }
390
+ for (const call of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) {
391
+ const key = call.index ?? call.id;
392
+ let block = tools.get(key);
393
+ if (!block) {
394
+ block = open('tool-call');
395
+ tools.set(key, block);
396
+ yield { type: 'block-start', index: block.index, blockType: 'tool-call' };
397
+ }
398
+ // id and name arrive once; an empty or null repeat is no update.
399
+ if (typeof call.id === 'string' && call.id.length > 0) block.callId = call.id;
400
+ if (typeof call.function?.name === 'string' && call.function.name.length > 0) block.name = call.function.name;
401
+ const fragment = typeof call.function?.arguments === 'string' ? call.function.arguments : '';
402
+ block.text += fragment;
403
+ yield { type: 'tool-call-delta', index: block.index, id: block.callId ?? '', ...(block.name !== undefined ? { name: block.name } : {}), argumentsDelta: fragment };
404
+ }
405
+ if (typeof choice.finish_reason === 'string') finish = choice.finish_reason;
406
+ }
407
+ if (chunk?.usage) {
408
+ usage = mapUsage(chunk.usage) ?? usage;
409
+ if (Number.isFinite(chunk.usage.cost) && chunk.usage.cost >= 0) cost = chunk.usage.cost;
410
+ }
411
+ }
412
+ throw new LlmError('OpenRouter stream ended without [DONE]', 'TRANSPORT');
413
+ }
@@ -1,37 +1,46 @@
1
1
  // Model providers `/provider` switches between. DeepSeek's official API is the
2
- // native `llm-deepseek` route; OpenRouter reaches the same DeepSeek models
3
- // through pi-ai's catalog route, which the base composition mounts dormant until
4
- // a `llm-pi-ai:` settings section declares it.
2
+ // native `llm-deepseek` route; OpenRouter is DSCODE's own adapter
3
+ // (plugins/openrouter), which serves OpenRouter's live model listing and is always
4
+ // registered. This module ships beside the TUI too, so it imports nothing.
5
5
 
6
6
  export const PROVIDERS = Object.freeze([
7
7
  { id: 'deepseek-official', name: 'DeepSeek', aliases: ['deepseek', 'deepseek-official', 'official'], credentialRef: 'DEEPSEEK_API_KEY', defaultModel: 'deepseek-flash' },
8
- { id: 'openrouter', name: 'OpenRouter', aliases: ['openrouter', 'open-router'], credentialRef: 'OPENROUTER_API_KEY', defaultModel: 'deepseek/deepseek-v4-flash' },
8
+ // The optional management key reads account data only; it cannot call models.
9
+ { id: 'openrouter', name: 'OpenRouter', aliases: ['openrouter', 'open-router'], credentialRef: 'OPENROUTER_API_KEY', managementRef: 'OPENROUTER_MANAGEMENT_KEY', defaultModel: 'deepseek/deepseek-v4-flash' },
9
10
  ]);
10
11
 
12
+ // The pi-ai adapter served OpenRouter until 0.7.6, from this settings section.
11
13
  const PI_AI_NS = 'llm-pi-ai';
12
14
 
13
15
  // OpenRouter serves DeepSeek V4 thinking as none/high/xhigh. DeepSeek itself
14
16
  // answers `low` as high and `max` as xhigh, so the route offers the official
15
17
  // low/high/max detents (and Ultra on top of max) with the wire spelling OpenRouter
16
18
  // accepts; session cards and delegated children that ask for `low` keep working.
17
- const OPENROUTER_EFFORTS = Object.freeze({ off: 'none', low: 'high', high: 'high', max: 'xhigh' });
19
+ export const OPENROUTER_EFFORTS = Object.freeze({ off: 'none', low: 'high', high: 'high', max: 'xhigh' });
18
20
 
19
- /** The DeepSeek models the OpenRouter route declares, with their official-route counterparts. */
21
+ /** The DeepSeek models the OpenRouter route serves, with their official-route counterparts. */
20
22
  export const OPENROUTER_MODELS = Object.freeze([
21
23
  { id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash', official: ['deepseek-flash', 'deepseek-v4-flash'] },
22
24
  { id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro', official: ['deepseek-v4-pro'] },
23
25
  { id: 'deepseek/deepseek-v4-flash-vision-exp', name: 'DeepSeek V4 Flash Vision Exp', official: ['deepseek-v4-flash-vision-exp'] },
24
26
  ]);
25
27
 
26
- /** The `llm-pi-ai` profile `/provider openrouter` writes: installed-catalog models narrowed to DeepSeek. */
27
- export function openRouterProfile() {
28
- return {
29
- displayName: 'OpenRouter',
30
- apiKeyEnv: 'OPENROUTER_API_KEY',
31
- // Like the official route, requests default to high; it also gives /effort the DSCODE detent bar.
32
- reasoning: 'high',
33
- models: OPENROUTER_MODELS.map(({ id, name }) => ({ id, name, reasoningEfforts: { ...OPENROUTER_EFFORTS } })),
34
- };
28
+ /**
29
+ * Remove the `openrouter` profile earlier builds wrote into the pi-ai section. The
30
+ * pi-ai adapter is no longer mounted, so the profile is inert, and it would claim the
31
+ * route a second time if that adapter were ever mounted again.
32
+ * Never throws: /model and /provider must open regardless.
33
+ * @returns whether the settings changed.
34
+ */
35
+ export async function migrateOpenRouterProfile(settings) {
36
+ try {
37
+ const descriptor = settings?.describe?.({ redactSecrets: true }).find(entry => entry.ns === PI_AI_NS);
38
+ if (!descriptor || settings.writable !== true || descriptor.value?.providers?.openrouter === undefined) return false;
39
+ await settings.mutate(PI_AI_NS, [{ op: 'unset', path: ['providers', 'openrouter'] }], descriptor.revision);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
35
44
  }
36
45
 
37
46
  export function providerSpec(id) {
@@ -106,23 +115,16 @@ export function credentialState(row) {
106
115
  }
107
116
 
108
117
  /**
109
- * Declare a provider's route before it is used. Only OpenRouter needs one; a
110
- * profile the user already has (their own models or endpoint) is left alone.
118
+ * Prepare a provider before a switch. Both routes are always registered; switching
119
+ * to OpenRouter only clears the inert pi-ai profile earlier builds wrote.
111
120
  * @param settings - the host settings service.
112
121
  * @returns whether the settings changed.
113
122
  */
114
123
  export async function ensureProviderRoute(settings, provider) {
115
- if (provider !== 'openrouter') return false;
116
- if (typeof settings?.describe !== 'function' || typeof settings.mutate !== 'function') throw new Error('settings are unavailable; OpenRouter cannot be configured in this profile');
117
- const descriptor = settings.describe({ redactSecrets: true }).find(entry => entry.ns === PI_AI_NS);
118
- if (!descriptor) throw new Error('the OpenRouter adapter (llm-pi-ai) is not mounted in this profile');
119
- if (descriptor.value?.providers?.openrouter !== undefined) return false;
120
- if (settings.writable !== true) throw new Error('settings are read-only; OpenRouter cannot be configured here');
121
- await settings.mutate(PI_AI_NS, [{ op: 'set', path: ['providers', 'openrouter'], value: openRouterProfile() }], descriptor.revision);
122
- return true;
124
+ return provider === 'openrouter' ? migrateOpenRouterProfile(settings) : false;
123
125
  }
124
126
 
125
- /** Wait for a freshly declared route to reach the model directory. */
127
+ /** Wait for a route's models to reach the model directory. */
126
128
  export async function waitForModels(loadModels, provider, { attempts = 30, delayMs = 100 } = {}) {
127
129
  let directory;
128
130
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -0,0 +1,35 @@
1
+ // Reasoning levels differ per model: DeepSeek offers low/high/max, GPT models
2
+ // minimal through high, and many OpenRouter models none at all. Auxiliary calls
3
+ // name the level they would like and send the nearest one the model offers, or no
4
+ // effort when the model offers no levels.
5
+
6
+ /** Standard reasoning levels, lowest first. */
7
+ export const EFFORT_LEVELS = Object.freeze(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
8
+
9
+ /**
10
+ * The level to request from a model.
11
+ * @param offered - the model's effort ids, or `undefined` when its capability is unknown.
12
+ * @param wanted - the level the caller would like.
13
+ * @returns `wanted` when offered or when the capability is unknown; otherwise the nearest
14
+ * offered level at or above it, else the highest below; `undefined` when the model offers
15
+ * no standard level.
16
+ */
17
+ export function chooseEffort(offered, wanted) {
18
+ if (wanted === undefined || offered === undefined || offered.includes(wanted)) return wanted;
19
+ const rank = EFFORT_LEVELS.indexOf(wanted);
20
+ const levels = EFFORT_LEVELS.filter(level => offered.includes(level));
21
+ if (rank < 0 || levels.length === 0) return undefined;
22
+ return levels.find(level => EFFORT_LEVELS.indexOf(level) >= rank) ?? levels.at(-1);
23
+ }
24
+
25
+ /**
26
+ * {@link chooseEffort} for a route, reading the model's levels from the LLM service.
27
+ * A service without model metadata, or a failed lookup, keeps `wanted`.
28
+ */
29
+ export async function effortFor(llm, route, wanted, signal) {
30
+ if (wanted === undefined || typeof llm?.resolveModelInfo !== 'function' || !route?.provider || !route?.model) return wanted;
31
+ let info;
32
+ try { info = await llm.resolveModelInfo(route.provider, route.model, signal); }
33
+ catch { return wanted; }
34
+ return chooseEffort(info?.reasoning?.efforts?.map(effort => effort.id) ?? [], wanted);
35
+ }