@toddzheng024/dscode-bundle 0.7.6 → 0.7.8
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/THIRD_PARTY_NOTICES.md +3 -3
- package/cordis.patch.yml +26 -5
- package/package.json +4 -4
- package/plugins/auto-review/index.mjs +6 -1
- package/plugins/code-review/index.mjs +109 -62
- package/plugins/compaction/tetris.mjs +65 -0
- package/plugins/compaction/threshold.mjs +46 -0
- package/plugins/credentials/index.mjs +2 -2
- package/plugins/i18n/messages.mjs +18 -0
- package/plugins/memory/index.mjs +1 -1
- package/plugins/memory/pipeline.mjs +2 -1
- package/plugins/openrouter/adapter.mjs +157 -0
- package/plugins/openrouter/index.mjs +112 -0
- package/plugins/openrouter/models.mjs +157 -0
- package/plugins/openrouter/search.mjs +109 -0
- package/plugins/openrouter/wire.mjs +416 -0
- package/plugins/providers/catalog.mjs +18 -68
- package/plugins/providers/openrouter-account.mjs +171 -0
- package/plugins/session-bridge/communication.mjs +11 -0
- package/plugins/session-bridge/mailbox.mjs +58 -3
- package/plugins/session-bridge/server.mjs +3 -1
- package/plugins/session-cards/manager.mjs +13 -4
- package/plugins/session-metrics/balance.mjs +29 -19
- package/plugins/session-metrics/index.mjs +19 -9
- package/plugins/session-metrics/pricing.mjs +26 -6
- package/plugins/session-metrics/rate.mjs +3 -2
- package/plugins/session-metrics/store.mjs +59 -8
- package/plugins/session-metrics/view.mjs +13 -3
- package/plugins/tui-tools/index.mjs +2 -1
- package/plugins/ultra/policy.mjs +0 -16
- package/presets/dscode/agent.cordis.yml +12 -1
- package/vendor/compaction-basic/index.js +983 -0
- package/vendor/compaction-basic/types/config.d.ts +37 -0
- package/vendor/compaction-basic/types/index.d.ts +84 -0
- package/vendor/compaction-basic/types/region.d.ts +65 -0
- package/vendor/compaction-basic/types/summarizer.d.ts +64 -0
- package/vendor/compaction-basic/types/types.d.ts +73 -0
- package/vendor/tui/dscode-providers/catalog.mjs +18 -68
- package/vendor/tui/dscode-providers/openrouter-account.mjs +171 -0
- package/vendor/tui/index.mjs +390 -165
- package/plugins/session-metrics/openrouter-prices.mjs +0 -96
- package/vendor/pi-ai/index.js +0 -2701
- package/vendor/pi-ai/types/adapter.d.ts +0 -105
- package/vendor/pi-ai/types/auth.d.ts +0 -60
- package/vendor/pi-ai/types/catalog.d.ts +0 -355
- package/vendor/pi-ai/types/config.d.ts +0 -208
- package/vendor/pi-ai/types/context.d.ts +0 -42
- package/vendor/pi-ai/types/discovery.d.ts +0 -43
- package/vendor/pi-ai/types/index.d.ts +0 -69
- package/vendor/pi-ai/types/login.d.ts +0 -21
- package/vendor/pi-ai/types/provider.d.ts +0 -59
- package/vendor/pi-ai/types/replay.d.ts +0 -63
- package/vendor/pi-ai/types/stream.d.ts +0 -43
- /package/vendor/{pi-ai → compaction-basic}/LICENSE +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { LlmAdapter, LlmError, ReasoningEffortId, attributionHeaders, contentHasImage, offloadRequestImagesWithPolicy, offloadedImageText } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { listOpenRouterModels, openRouterModel } from './models.mjs';
|
|
3
|
+
import { PROVIDER, effortInfo, errorCode, errorMessage, modelReasoning, requestBody, retryAfterMs, sseData, translate } from './wire.mjs';
|
|
4
|
+
|
|
5
|
+
export { PROVIDER };
|
|
6
|
+
/** Context assumed for a model the listing does not size. */
|
|
7
|
+
export const DEFAULT_CONTEXT_WINDOW = 262144;
|
|
8
|
+
/** Output cap materialized when a caller names none; OpenRouter reserves credit for it. */
|
|
9
|
+
export const DEFAULT_OUTPUT_CAP = 131072;
|
|
10
|
+
const APP_URL = 'https://github.com/qiz029/dscode';
|
|
11
|
+
const IMAGE_POLICY = Object.freeze({ maxPixels: 2048 * 2048, maxBytes: 1024 * 1024 });
|
|
12
|
+
|
|
13
|
+
function modelInfo(provider, id, entry) {
|
|
14
|
+
return { provider, id, name: entry?.name ?? id, inputModalities: entry?.inputModalities?.length ? [...entry.inputModalities] : ['text'] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function collectImages(blocks, refs) {
|
|
18
|
+
for (const block of blocks) {
|
|
19
|
+
if (block.type === 'image') refs.set(block.attachment.attachmentId, block.attachment);
|
|
20
|
+
else if (block.type === 'tool-result') collectImages(block.content, refs);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* OpenRouter chat completions as a harness adapter: fetch + SSE with the model
|
|
26
|
+
* directory, reasoning controls and prices of OpenRouter's live listing. Connection
|
|
27
|
+
* facts and the key resolve per request, so settings and `/login` changes reach the
|
|
28
|
+
* next call.
|
|
29
|
+
*/
|
|
30
|
+
export class OpenRouterAdapter extends LlmAdapter {
|
|
31
|
+
/**
|
|
32
|
+
* @param config - `options()` connection facts, `resolveApiKey(connection)`, `ensureModels()`,
|
|
33
|
+
* optional `resolveAttachments()`, `resolveImageAccess(attachments, ref)` and `fetch`.
|
|
34
|
+
*/
|
|
35
|
+
constructor(config) {
|
|
36
|
+
super();
|
|
37
|
+
this.config = config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
providerInfo(provider) {
|
|
41
|
+
return { id: provider, name: 'OpenRouter' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
providerRetryPolicy() {
|
|
45
|
+
return this.config.options().retryPolicy;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Models that can drive an agent: text output and tool calls. */
|
|
49
|
+
async listModels(provider) {
|
|
50
|
+
await this.config.ensureModels();
|
|
51
|
+
return listOpenRouterModels().filter(([, entry]) => entry.tools !== false && entry.textOutput !== false).map(([id, entry]) => modelInfo(provider, id, entry));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async resolveModel(provider, model) {
|
|
55
|
+
await this.config.ensureModels();
|
|
56
|
+
const entry = openRouterModel(model);
|
|
57
|
+
const reasoning = modelReasoning(model, entry);
|
|
58
|
+
return {
|
|
59
|
+
...modelInfo(provider, model, entry),
|
|
60
|
+
context: { contextWindow: entry?.contextWindow ?? DEFAULT_CONTEXT_WINDOW },
|
|
61
|
+
...(entry?.maxOutput ? { defaultMaxTokens: Math.min(entry.maxOutput, DEFAULT_OUTPUT_CAP) } : {}),
|
|
62
|
+
...(reasoning ? { reasoning: {
|
|
63
|
+
efforts: reasoning.levels.map(id => ({ ...effortInfo(id), id: ReasoningEffortId(id) })),
|
|
64
|
+
...(reasoning.defaultEffort ? { defaultEffort: ReasoningEffortId(reasoning.defaultEffort) } : {}),
|
|
65
|
+
} } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async *stream(options) {
|
|
70
|
+
const connection = this.config.options();
|
|
71
|
+
const idle = new AbortController(), consumer = new AbortController();
|
|
72
|
+
let timer;
|
|
73
|
+
const pulse = () => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
timer = setTimeout(() => idle.abort(new Error('OpenRouter stream idle')), connection.streamIdleTimeoutMs);
|
|
76
|
+
timer.unref?.();
|
|
77
|
+
};
|
|
78
|
+
const signal = AbortSignal.any([idle.signal, consumer.signal, ...(options.signal ? [options.signal] : [])]);
|
|
79
|
+
try {
|
|
80
|
+
const apiKey = await this.config.resolveApiKey(connection);
|
|
81
|
+
await this.config.ensureModels();
|
|
82
|
+
const entry = openRouterModel(options.model);
|
|
83
|
+
pulse();
|
|
84
|
+
const images = await this.prepareImages(options, entry, connection, signal);
|
|
85
|
+
const body = requestBody(images?.options ?? options, { entry, images });
|
|
86
|
+
const fetchImpl = this.config.fetch ?? globalThis.fetch;
|
|
87
|
+
let response;
|
|
88
|
+
try {
|
|
89
|
+
response = await fetchImpl(`${connection.baseURL}/chat/completions`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
authorization: `Bearer ${apiKey}`,
|
|
93
|
+
'content-type': 'application/json',
|
|
94
|
+
accept: 'text/event-stream',
|
|
95
|
+
...attributionHeaders(),
|
|
96
|
+
'HTTP-Referer': APP_URL,
|
|
97
|
+
'X-OpenRouter-Title': 'DSCODE',
|
|
98
|
+
'X-OpenRouter-Categories': 'cli-agent',
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
signal,
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (signal.aborted) throw error;
|
|
105
|
+
throw new LlmError(`OpenRouter request to ${connection.baseURL} failed`, 'TRANSPORT', { cause: error });
|
|
106
|
+
}
|
|
107
|
+
// A rejected request, or a 200 whose JSON body holds only an error.
|
|
108
|
+
if (!response.ok || !response.headers.get('content-type')?.includes('text/event-stream')) {
|
|
109
|
+
const raw = await response.text();
|
|
110
|
+
let error;
|
|
111
|
+
try { error = JSON.parse(raw)?.error; } catch { /* not JSON */ }
|
|
112
|
+
if (response.ok && error === undefined) throw new LlmError(`OpenRouter returned a non-stream response: ${raw.slice(0, 120)}`, 'MALFORMED_RESPONSE');
|
|
113
|
+
const delay = retryAfterMs(response.headers.get('retry-after'));
|
|
114
|
+
const status = response.ok ? (Number.isInteger(error?.code) ? error.code : undefined) : response.status;
|
|
115
|
+
throw new LlmError(errorMessage(error, `OpenRouter API error (HTTP ${response.status})`), errorCode(response.ok ? undefined : response.status, error), {
|
|
116
|
+
cause: new Error(raw.length > 0 ? raw : `OpenRouter HTTP ${response.status}`),
|
|
117
|
+
...(status === undefined ? {} : { status }),
|
|
118
|
+
...(delay === undefined ? {} : { providerRetryAfterMs: delay }),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (!response.body) throw new LlmError('OpenRouter returned no response body', 'EMPTY_RESPONSE');
|
|
122
|
+
for await (const chunk of translate(sseData(response.body, pulse), { model: options.model })) {
|
|
123
|
+
// The idle clock measures the provider, not a slow consumer.
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
yield chunk;
|
|
126
|
+
pulse();
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (idle.signal.aborted && !options.signal?.aborted) throw new LlmError(`OpenRouter stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error });
|
|
130
|
+
if (options.signal?.aborted) throw new LlmError('OpenRouter request aborted by caller', 'ABORTED', { cause: error });
|
|
131
|
+
if (error instanceof LlmError) throw error;
|
|
132
|
+
throw new LlmError(`OpenRouter API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error });
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
consumer.abort('OpenRouter stream consumer stopped');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Read request versions of every image, oldest beyond the byte budget replaced by text first. */
|
|
140
|
+
async prepareImages(options, entry, connection, signal) {
|
|
141
|
+
if (!options.messages.some(message => contentHasImage(message.content))) return undefined;
|
|
142
|
+
if (!entry?.inputModalities?.includes('image')) throw new LlmError(`OpenRouter model "${options.model}" does not accept image input.`, 'UNSUPPORTED_CONTENT');
|
|
143
|
+
const attachments = this.config.resolveAttachments?.();
|
|
144
|
+
if (attachments === undefined) throw new LlmError('OpenRouter image input requires the durable attachment service.', 'UNSUPPORTED_CONTENT');
|
|
145
|
+
const access = ref => this.config.resolveImageAccess?.(attachments, ref);
|
|
146
|
+
const bounded = policyBytes => offloadRequestImagesWithPolicy(policyBytes.messages, {
|
|
147
|
+
representation: 'base64', maxBytes: connection.maxRequestImageBytes, byteQuantum: 1,
|
|
148
|
+
byteLength: policyBytes.byteLength, placeholder: ref => offloadedImageText(ref, access(ref)),
|
|
149
|
+
});
|
|
150
|
+
const estimated = bounded({ messages: options.messages, byteLength: ref => Math.min(ref.bytes, IMAGE_POLICY.maxBytes) });
|
|
151
|
+
const refs = new Map();
|
|
152
|
+
for (const message of estimated) collectImages(message.content, refs);
|
|
153
|
+
const versions = new Map(await Promise.all([...refs.values()].map(async ref => [ref.attachmentId, await attachments.readImageRequest(ref, IMAGE_POLICY, signal)])));
|
|
154
|
+
const exact = bounded({ messages: estimated, byteLength: ref => versions.get(ref.attachmentId).bytes });
|
|
155
|
+
return { options: { ...options, messages: [...exact] }, versions, access };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import { LlmError, RetryPolicySchema, assertUsableApiKey, resolveImageAttachmentAccess, resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
|
|
3
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
4
|
+
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
5
|
+
import { OpenRouterAdapter, PROVIDER } from './adapter.mjs';
|
|
6
|
+
import { ensureOpenRouterModels } from './models.mjs';
|
|
7
|
+
import { OpenRouterSearchProvider, RoutedSearchProvider } from './search.mjs';
|
|
8
|
+
|
|
9
|
+
// The `openrouter` route: DSCODE's own OpenRouter adapter over its live model
|
|
10
|
+
// listing, configured by the `llm-openrouter` settings section, plus web search
|
|
11
|
+
// that follows the session's route.
|
|
12
|
+
export const name = 'dscode-openrouter';
|
|
13
|
+
export const inject = ['llm'];
|
|
14
|
+
const NS = 'llm-openrouter';
|
|
15
|
+
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
16
|
+
|
|
17
|
+
export const Config = z.object({
|
|
18
|
+
apiKeyEnv: z.string().role('credential-ref').default('OPENROUTER_API_KEY'),
|
|
19
|
+
baseURL: z.string().default(DEFAULT_BASE_URL),
|
|
20
|
+
streamIdleTimeoutMs: z.number().min(1).default(300000),
|
|
21
|
+
maxRequestImageBytes: z.number().step(1).min(1).default(20 * 1024 * 1024),
|
|
22
|
+
searchModel: z.string().default('deepseek/deepseek-v4-flash'),
|
|
23
|
+
retryPolicy: RetryPolicySchema,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Validated connection facts from one config snapshot. */
|
|
27
|
+
export function resolveOptions(config = {}) {
|
|
28
|
+
const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? 300000;
|
|
29
|
+
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0) throw new Error(`${name}: streamIdleTimeoutMs must be a positive number`);
|
|
30
|
+
const baseURL = (config.baseURL || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
31
|
+
if (!URL.canParse(baseURL)) throw new Error(`${name}: baseURL must be a URL`);
|
|
32
|
+
return {
|
|
33
|
+
apiKeyEnv: credentialRef(config.apiKeyEnv || 'OPENROUTER_API_KEY'),
|
|
34
|
+
baseURL,
|
|
35
|
+
streamIdleTimeoutMs,
|
|
36
|
+
maxRequestImageBytes: config.maxRequestImageBytes ?? 20 * 1024 * 1024,
|
|
37
|
+
searchModel: config.searchModel || 'deepseek/deepseek-v4-flash',
|
|
38
|
+
retryPolicy: resolveRetryPolicy(config.retryPolicy, `${name}: retryPolicy`),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function apply(ctx, config = {}) {
|
|
43
|
+
let current = () => config, lastRaw, lastGood;
|
|
44
|
+
const options = () => {
|
|
45
|
+
const raw = current();
|
|
46
|
+
if (raw === lastRaw && lastGood !== undefined) return lastGood;
|
|
47
|
+
try {
|
|
48
|
+
lastGood = resolveOptions(raw);
|
|
49
|
+
lastRaw = raw;
|
|
50
|
+
return lastGood;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (lastGood === undefined) throw error;
|
|
53
|
+
lastRaw = raw;
|
|
54
|
+
ctx.logger.error(`${name}: keeping the last good configuration after an invalid settings section`);
|
|
55
|
+
ctx.logger.error(error);
|
|
56
|
+
return lastGood;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
options();
|
|
60
|
+
const resolveKey = async ref => {
|
|
61
|
+
const credentials = ctx.get('credentials');
|
|
62
|
+
if (credentials !== undefined) return (await credentials.resolve(ref))?.value || undefined;
|
|
63
|
+
return launchEnvironmentOf(ctx).get(ref)?.value || undefined;
|
|
64
|
+
};
|
|
65
|
+
const home = process.env.DSH_HOME;
|
|
66
|
+
const ensureModels = () => ensureOpenRouterModels({ home });
|
|
67
|
+
const adapter = new OpenRouterAdapter({
|
|
68
|
+
options,
|
|
69
|
+
ensureModels,
|
|
70
|
+
resolveApiKey: async connection => {
|
|
71
|
+
const key = await resolveKey(connection.apiKeyEnv);
|
|
72
|
+
if (key !== undefined) return assertUsableApiKey(key, name, connection.apiKeyEnv);
|
|
73
|
+
throw new LlmError(`${name}: no API key for OpenRouter; run /login openrouter, or export ${connection.apiKeyEnv} in the launching environment`, 'MISSING_CREDENTIAL');
|
|
74
|
+
},
|
|
75
|
+
resolveAttachments: () => ctx.get('attachments'),
|
|
76
|
+
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), ref),
|
|
77
|
+
});
|
|
78
|
+
ctx.llm.registerConfigurableProviders([{ provider: PROVIDER, displayName: 'OpenRouter', settingsNs: NS, settingsPath: [] }]);
|
|
79
|
+
const registration = ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
80
|
+
let registeredPolicy = JSON.stringify(options().retryPolicy);
|
|
81
|
+
ctx.inject(['settings'], settingsCtx => {
|
|
82
|
+
settingsCtx.settings.installSection(ctx, NS, Config, config, {
|
|
83
|
+
setSource: source => { current = source; },
|
|
84
|
+
// The retry policy is captured at registration: re-register the route when it changes.
|
|
85
|
+
onChange: () => {
|
|
86
|
+
const policy = JSON.stringify(options().retryPolicy);
|
|
87
|
+
if (policy === registeredPolicy) return;
|
|
88
|
+
registration.replace([PROVIDER]);
|
|
89
|
+
registeredPolicy = policy;
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
ctx.inject(['web'], webCtx => {
|
|
94
|
+
const openrouter = new OpenRouterSearchProvider(() => {
|
|
95
|
+
const connection = options();
|
|
96
|
+
return { baseURL: connection.baseURL, model: connection.searchModel, resolveApiKey: () => resolveKey(connection.apiKeyEnv) };
|
|
97
|
+
});
|
|
98
|
+
webCtx.web.registerSearchProvider(openrouter);
|
|
99
|
+
webCtx.web.registerSearchProvider(new RoutedSearchProvider({
|
|
100
|
+
openrouter,
|
|
101
|
+
// `ctx.web` keeps no per-call selection; the DeepSeek provider is looked up in its registry.
|
|
102
|
+
deepseek: () => webCtx.web.searchProviders?.get?.('deepseek-official'),
|
|
103
|
+
currentProvider: () => {
|
|
104
|
+
const agent = ctx.get('agents')?.currentInitiator?.();
|
|
105
|
+
return agent?.session?.requestHeader?.()?.config?.provider ?? agent?.options?.provider;
|
|
106
|
+
},
|
|
107
|
+
hasKey: async ref => (await resolveKey(credentialRef(ref))) !== undefined,
|
|
108
|
+
}));
|
|
109
|
+
});
|
|
110
|
+
// Warm the listing for an OpenRouter user, so the first /model opens with every model.
|
|
111
|
+
void resolveKey(options().apiKeyEnv).then(key => key === undefined ? undefined : ensureModels(), () => {});
|
|
112
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// OpenRouter's public model listing: context sizes, input modalities, reasoning
|
|
5
|
+
// controls and list prices for every model it serves. The OpenRouter adapter
|
|
6
|
+
// resolves models from it and the session metrics price calls from it, so one
|
|
7
|
+
// table, kept for a day in DSH_HOME, backs both. The listing quotes USD per
|
|
8
|
+
// token; the table keeps USD per million.
|
|
9
|
+
export const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
|
|
10
|
+
/** How long a first model lookup waits for the listing before answering without it. */
|
|
11
|
+
export const FORCED_LOAD_TIMEOUT_MS = 10_000;
|
|
12
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
/** How long a failed listing (or an unusable cache file) waits before the next attempt. */
|
|
14
|
+
export const RETRY_MS = 10 * 60 * 1000;
|
|
15
|
+
const FILE = 'openrouter-models.json';
|
|
16
|
+
const VERSION = 2;
|
|
17
|
+
const EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
18
|
+
let table = { fetchedAt: 0, models: {} };
|
|
19
|
+
/** When the on-disk cache was last consulted; an unusable file waits, then may retry, like the listing. */
|
|
20
|
+
let cacheReadAt = 0;
|
|
21
|
+
let attemptedAt = 0, pending;
|
|
22
|
+
|
|
23
|
+
const perMillion = value => {
|
|
24
|
+
const number = Number(value);
|
|
25
|
+
return value != null && value !== '' && Number.isFinite(number) && number >= 0 ? number * 1e6 : undefined;
|
|
26
|
+
};
|
|
27
|
+
const positive = value => Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
28
|
+
const ratesOf = pricing => {
|
|
29
|
+
const input = perMillion(pricing?.prompt), output = perMillion(pricing?.completion);
|
|
30
|
+
if (input === undefined || output === undefined) return undefined;
|
|
31
|
+
return { input, output, cacheRead: perMillion(pricing.input_cache_read), cacheWrite: perMillion(pricing.input_cache_write) };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function reasoningOf(reasoning) {
|
|
35
|
+
if (reasoning === null || typeof reasoning !== 'object') return undefined;
|
|
36
|
+
const offered = Array.isArray(reasoning.supported_efforts) ? EFFORTS.filter(level => reasoning.supported_efforts.includes(level)) : undefined;
|
|
37
|
+
return {
|
|
38
|
+
mandatory: reasoning.mandatory === true,
|
|
39
|
+
...(offered?.length ? { efforts: offered } : {}),
|
|
40
|
+
...(EFFORTS.includes(reasoning.default_effort) ? { defaultEffort: reasoning.default_effort } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Models from an OpenRouter `/models` body.
|
|
46
|
+
* @returns `{ [id]: { input, output, cacheRead?, cacheWrite?, tiers?, name?, contextWindow?, maxOutput?,
|
|
47
|
+
* inputModalities?, tools?, textOutput?, reasoning? } }` with prices in USD per million tokens;
|
|
48
|
+
* a tier applies once a request's prompt reaches its `minPromptTokens`.
|
|
49
|
+
*/
|
|
50
|
+
export function parseOpenRouterModels(body) {
|
|
51
|
+
const models = {};
|
|
52
|
+
for (const model of Array.isArray(body?.data) ? body.data : []) {
|
|
53
|
+
const base = typeof model?.id === 'string' ? ratesOf(model.pricing) : undefined;
|
|
54
|
+
if (!base) continue;
|
|
55
|
+
const tiers = (Array.isArray(model.pricing.overrides) ? model.pricing.overrides : [])
|
|
56
|
+
.map(tier => ({ minPromptTokens: Number(tier?.min_prompt_tokens), ...ratesOf({ ...model.pricing, ...tier }) }))
|
|
57
|
+
.filter(tier => Number.isFinite(tier.minPromptTokens) && tier.input !== undefined)
|
|
58
|
+
.sort((left, right) => left.minPromptTokens - right.minPromptTokens);
|
|
59
|
+
// The routed endpoint can serve less than the model's nominal context.
|
|
60
|
+
const windows = [model.context_length, model.top_provider?.context_length].map(positive).filter(Boolean);
|
|
61
|
+
const input = model.architecture?.input_modalities, output = model.architecture?.output_modalities;
|
|
62
|
+
const reasoning = reasoningOf(model.reasoning);
|
|
63
|
+
models[model.id] = {
|
|
64
|
+
...base, ...(tiers.length ? { tiers } : {}),
|
|
65
|
+
...(typeof model.name === 'string' && model.name.length > 0 ? { name: model.name } : {}),
|
|
66
|
+
...(windows.length ? { contextWindow: Math.min(...windows) } : {}),
|
|
67
|
+
...(positive(model.top_provider?.max_completion_tokens) ? { maxOutput: model.top_provider.max_completion_tokens } : {}),
|
|
68
|
+
...(Array.isArray(input) ? { inputModalities: ['text', 'image'].filter(modality => input.includes(modality)) } : {}),
|
|
69
|
+
...(Array.isArray(model.supported_parameters) ? { tools: model.supported_parameters.includes('tools') } : {}),
|
|
70
|
+
...(Array.isArray(output) ? { textOutput: output.includes('text') } : {}),
|
|
71
|
+
...(reasoning ? { reasoning } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return models;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One model's entry, or undefined when the table does not list it. */
|
|
78
|
+
export function openRouterModel(id) {
|
|
79
|
+
return Object.hasOwn(table.models, id) ? table.models[id] : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every listed model as `[id, entry]` pairs, in listing order. */
|
|
83
|
+
export function listOpenRouterModels() {
|
|
84
|
+
return Object.entries(table.models);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Prices for one model at a request's prompt size, or undefined when the table lists none. */
|
|
88
|
+
export function openRouterRates(model, promptTokens = 0) {
|
|
89
|
+
const entry = openRouterModel(model);
|
|
90
|
+
if (!entry) return undefined;
|
|
91
|
+
return entry.tiers?.findLast(tier => promptTokens >= tier.minPromptTokens) ?? entry;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Version stamp for rows priced from the live table: the day it was fetched. */
|
|
95
|
+
export function openRouterPriceVersion() {
|
|
96
|
+
return table.fetchedAt > 0 ? `openrouter-models-${new Date(table.fetchedAt).toISOString().slice(0, 10)}` : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Replace the table (and forget the last attempt); for tests and cache loads. */
|
|
100
|
+
export function setOpenRouterModels(models, fetchedAt = Date.now()) {
|
|
101
|
+
table = { fetchedAt, models };
|
|
102
|
+
cacheReadAt = 0;
|
|
103
|
+
attemptedAt = 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Load the cached table, then refetch it once it is a day old. A failure keeps the
|
|
108
|
+
* last table and waits ten minutes before trying again; never throws.
|
|
109
|
+
*/
|
|
110
|
+
export async function refreshOpenRouterModels({ home, fetch: fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
|
|
111
|
+
const path = home ? join(home, FILE) : undefined;
|
|
112
|
+
// A version bump or a corrupt body leaves the table empty: parse that file once per throttle window, not per request.
|
|
113
|
+
if (table.fetchedAt === 0 && path && now - cacheReadAt >= RETRY_MS) {
|
|
114
|
+
cacheReadAt = now;
|
|
115
|
+
try {
|
|
116
|
+
const cached = JSON.parse(readFileSync(path, 'utf8'));
|
|
117
|
+
if (cached?.version === VERSION && Number.isFinite(cached.fetchedAt) && cached.models && typeof cached.models === 'object') table = { fetchedAt: cached.fetchedAt, models: cached.models };
|
|
118
|
+
} catch { /* no usable cache */ }
|
|
119
|
+
}
|
|
120
|
+
if (now - table.fetchedAt < MAX_AGE_MS || now - attemptedAt < RETRY_MS || typeof fetchImpl !== 'function') return table;
|
|
121
|
+
if (pending) return pending;
|
|
122
|
+
attemptedAt = now;
|
|
123
|
+
pending = (async () => {
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetchImpl(OPENROUTER_MODELS_URL, { headers: { Accept: 'application/json' } });
|
|
126
|
+
if (!response.ok) return table;
|
|
127
|
+
const models = parseOpenRouterModels(await response.json());
|
|
128
|
+
if (Object.keys(models).length === 0) return table;
|
|
129
|
+
table = { fetchedAt: now, models };
|
|
130
|
+
if (path) {
|
|
131
|
+
mkdirSync(home, { recursive: true });
|
|
132
|
+
writeFileSync(`${path}.tmp`, JSON.stringify({ version: VERSION, ...table }));
|
|
133
|
+
renameSync(`${path}.tmp`, path);
|
|
134
|
+
}
|
|
135
|
+
return table;
|
|
136
|
+
} catch {
|
|
137
|
+
return table;
|
|
138
|
+
} finally {
|
|
139
|
+
pending = undefined;
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
return pending;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The table before a model lookup or a priced call: an empty table waits up to
|
|
147
|
+
* `timeoutMs` for the listing, a stale one refreshes in the background. Waits at
|
|
148
|
+
* most once per retry window, so an unreachable listing never stalls every call.
|
|
149
|
+
*/
|
|
150
|
+
export async function ensureOpenRouterModels({ timeoutMs = FORCED_LOAD_TIMEOUT_MS, ...options } = {}) {
|
|
151
|
+
const refresh = refreshOpenRouterModels(options);
|
|
152
|
+
if (Object.keys(table.models).length > 0) return table;
|
|
153
|
+
let timer;
|
|
154
|
+
await Promise.race([refresh, new Promise(resolve => { timer = setTimeout(resolve, timeoutMs); timer.unref?.(); })]);
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
return table;
|
|
157
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { WebError } from '@deepseek-ai/dsh-web';
|
|
2
|
+
|
|
3
|
+
// Web search through OpenRouter's web plugin, and the provider `ctx.web` is pinned to:
|
|
4
|
+
// it searches with the session's own route, so an OpenRouter session never needs a
|
|
5
|
+
// DeepSeek key and a DeepSeek session never bills OpenRouter.
|
|
6
|
+
export const OPENROUTER_SEARCH_ID = 'openrouter';
|
|
7
|
+
export const ROUTED_SEARCH_ID = 'dscode-web';
|
|
8
|
+
const DEFAULT_MAX_RESULTS = 8;
|
|
9
|
+
|
|
10
|
+
/** Sources from the `url_citation` annotations of an OpenRouter message, de-duplicated by URL. */
|
|
11
|
+
export function citationSources(message) {
|
|
12
|
+
const seen = new Set(), sources = [];
|
|
13
|
+
for (const annotation of Array.isArray(message?.annotations) ? message.annotations : []) {
|
|
14
|
+
const citation = annotation?.type === 'url_citation' ? annotation.url_citation : undefined;
|
|
15
|
+
if (typeof citation?.url !== 'string' || citation.url.length === 0 || seen.has(citation.url)) continue;
|
|
16
|
+
seen.add(citation.url);
|
|
17
|
+
sources.push({
|
|
18
|
+
url: citation.url,
|
|
19
|
+
...(typeof citation.title === 'string' && citation.title.length > 0 ? { title: citation.title } : {}),
|
|
20
|
+
...(typeof citation.content === 'string' && citation.content.length > 0 ? { snippet: citation.content } : {}),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return sources;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const aborted = (signal, error) => new WebError('OpenRouter search was cancelled', 'WEB_ABORTED', { cause: error ?? signal?.reason });
|
|
27
|
+
|
|
28
|
+
/** Exa search through OpenRouter's `web` plugin on a small model whose answer is discarded. */
|
|
29
|
+
export class OpenRouterSearchProvider {
|
|
30
|
+
id = OPENROUTER_SEARCH_ID;
|
|
31
|
+
|
|
32
|
+
/** @param resolveOptions - `{ baseURL, model, resolveApiKey(), fetch? }` for the next search. */
|
|
33
|
+
constructor(resolveOptions) {
|
|
34
|
+
this.resolveOptions = resolveOptions;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
available() {
|
|
38
|
+
return URL.canParse(this.resolveOptions().baseURL);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async search(request, signal) {
|
|
42
|
+
const options = this.resolveOptions();
|
|
43
|
+
const apiKey = await options.resolveApiKey();
|
|
44
|
+
if (signal?.aborted) throw aborted(signal);
|
|
45
|
+
if (!apiKey) throw new WebError('OpenRouter search has no API key: run /login openrouter or set OPENROUTER_API_KEY.', 'WEB_PROVIDER_CREDENTIAL_MISSING');
|
|
46
|
+
const body = {
|
|
47
|
+
model: options.model,
|
|
48
|
+
stream: false,
|
|
49
|
+
messages: [{ role: 'user', content: `Search the web for: ${request.query}` }],
|
|
50
|
+
plugins: [{ id: 'web', engine: 'exa', max_results: request.maxResults ?? DEFAULT_MAX_RESULTS }],
|
|
51
|
+
reasoning: { effort: 'none' },
|
|
52
|
+
max_tokens: 256,
|
|
53
|
+
};
|
|
54
|
+
let response, text;
|
|
55
|
+
try {
|
|
56
|
+
response = await (options.fetch ?? globalThis.fetch)(`${options.baseURL}/chat/completions`, {
|
|
57
|
+
method: 'POST', redirect: 'error', signal,
|
|
58
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', accept: 'application/json', 'HTTP-Referer': 'https://github.com/qiz029/dscode', 'X-OpenRouter-Title': 'DSCODE' },
|
|
59
|
+
body: JSON.stringify(body),
|
|
60
|
+
});
|
|
61
|
+
text = await response.text();
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (signal?.aborted) throw aborted(signal, error);
|
|
64
|
+
throw new WebError(`OpenRouter search request failed: ${error instanceof Error ? error.message : String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error });
|
|
65
|
+
}
|
|
66
|
+
let json;
|
|
67
|
+
try { json = JSON.parse(text); } catch { /* reported below */ }
|
|
68
|
+
if (!response.ok || json?.error || !Array.isArray(json?.choices)) {
|
|
69
|
+
const detail = typeof json?.error?.message === 'string' ? json.error.message : text.slice(0, 200);
|
|
70
|
+
throw new WebError(`OpenRouter search failed (HTTP ${response.status}): ${detail}`, 'WEB_PROVIDER_ERROR');
|
|
71
|
+
}
|
|
72
|
+
return { sources: citationSources(json.choices[0]?.message), truncated: false };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The pinned search provider: OpenRouter for an OpenRouter session, DeepSeek for a
|
|
78
|
+
* DeepSeek one, and otherwise whichever provider has a key (DeepSeek first).
|
|
79
|
+
*/
|
|
80
|
+
export class RoutedSearchProvider {
|
|
81
|
+
id = ROUTED_SEARCH_ID;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param routes - `openrouter` provider, `deepseek()` provider lookup, `currentProvider()` of the
|
|
85
|
+
* calling session, and `hasKey(ref)` for credential presence.
|
|
86
|
+
*/
|
|
87
|
+
constructor(routes) {
|
|
88
|
+
this.routes = routes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
available() {
|
|
92
|
+
return this.routes.openrouter.available() || this.routes.deepseek()?.available() === true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async pick() {
|
|
96
|
+
const { openrouter, deepseek: lookup, currentProvider, hasKey } = this.routes;
|
|
97
|
+
const deepseek = lookup();
|
|
98
|
+
const route = currentProvider();
|
|
99
|
+
if (route === 'openrouter') return openrouter;
|
|
100
|
+
if (route === 'deepseek-official' && deepseek) return deepseek;
|
|
101
|
+
if (deepseek && await hasKey('DEEPSEEK_API_KEY')) return deepseek;
|
|
102
|
+
if (await hasKey('OPENROUTER_API_KEY')) return openrouter;
|
|
103
|
+
return deepseek ?? openrouter;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async search(request, signal) {
|
|
107
|
+
return (await this.pick()).search(request, signal);
|
|
108
|
+
}
|
|
109
|
+
}
|