@toddzheng024/dscode-bundle 0.7.6 → 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 (44) 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/compaction/tetris.mjs +65 -0
  6. package/plugins/compaction/threshold.mjs +46 -0
  7. package/plugins/credentials/index.mjs +2 -2
  8. package/plugins/i18n/messages.mjs +18 -0
  9. package/plugins/openrouter/adapter.mjs +157 -0
  10. package/plugins/openrouter/index.mjs +112 -0
  11. package/plugins/openrouter/models.mjs +151 -0
  12. package/plugins/openrouter/search.mjs +109 -0
  13. package/plugins/openrouter/wire.mjs +413 -0
  14. package/plugins/providers/catalog.mjs +18 -68
  15. package/plugins/providers/openrouter-account.mjs +171 -0
  16. package/plugins/session-metrics/balance.mjs +29 -19
  17. package/plugins/session-metrics/index.mjs +19 -9
  18. package/plugins/session-metrics/pricing.mjs +26 -6
  19. package/plugins/session-metrics/view.mjs +1 -1
  20. package/plugins/ultra/policy.mjs +0 -16
  21. package/presets/dscode/agent.cordis.yml +1 -1
  22. package/vendor/compaction-basic/index.js +983 -0
  23. package/vendor/compaction-basic/types/config.d.ts +37 -0
  24. package/vendor/compaction-basic/types/index.d.ts +84 -0
  25. package/vendor/compaction-basic/types/region.d.ts +65 -0
  26. package/vendor/compaction-basic/types/summarizer.d.ts +64 -0
  27. package/vendor/compaction-basic/types/types.d.ts +73 -0
  28. package/vendor/tui/dscode-providers/catalog.mjs +18 -68
  29. package/vendor/tui/dscode-providers/openrouter-account.mjs +171 -0
  30. package/vendor/tui/index.mjs +390 -165
  31. package/plugins/session-metrics/openrouter-prices.mjs +0 -96
  32. package/vendor/pi-ai/index.js +0 -2701
  33. package/vendor/pi-ai/types/adapter.d.ts +0 -105
  34. package/vendor/pi-ai/types/auth.d.ts +0 -60
  35. package/vendor/pi-ai/types/catalog.d.ts +0 -355
  36. package/vendor/pi-ai/types/config.d.ts +0 -208
  37. package/vendor/pi-ai/types/context.d.ts +0 -42
  38. package/vendor/pi-ai/types/discovery.d.ts +0 -43
  39. package/vendor/pi-ai/types/index.d.ts +0 -69
  40. package/vendor/pi-ai/types/login.d.ts +0 -21
  41. package/vendor/pi-ai/types/provider.d.ts +0 -59
  42. package/vendor/pi-ai/types/replay.d.ts +0 -63
  43. package/vendor/pi-ai/types/stream.d.ts +0 -43
  44. /package/vendor/{pi-ai → compaction-basic}/LICENSE +0 -0
@@ -19,9 +19,6 @@ Local changes: DSCODE effort, shell control, TUI commands and footer.
19
19
  @deepseek-ai/dsh-llm-deepseek@0.1.5-rc.1: MIT; {"type":"git","url":"git+https://github.com/deepseek-ai/deepseek-harness.git","directory":"packages/llm/llm-deepseek"}
20
20
  Local changes: DSCODE effort, shell control, TUI commands and footer.
21
21
 
22
- @deepseek-ai/dsh-llm-pi-ai@0.1.5-rc.1: MIT; {"type":"git","url":"git+https://github.com/deepseek-ai/deepseek-harness.git","directory":"packages/llm/llm-pi-ai"}
23
- Local changes: DSCODE effort, shell control, TUI commands and footer.
24
-
25
22
  @deepseek-ai/dsh-tool-bash@0.1.5-rc.1: MIT; {"type":"git","url":"git+https://github.com/deepseek-ai/deepseek-harness.git","directory":"packages/shell/tool-bash"}
26
23
  Local changes: DSCODE effort, shell control, TUI commands and footer.
27
24
 
@@ -30,3 +27,6 @@ Local changes: DSCODE effort, shell control, TUI commands and footer.
30
27
 
31
28
  @deepseek-ai/dsh-terminal-bash@0.1.5-rc.1: MIT; {"type":"git","url":"git+https://github.com/deepseek-ai/deepseek-harness.git","directory":"packages/terminal/terminal-bash"}
32
29
  Local changes: DSCODE effort, shell control, TUI commands and footer.
30
+
31
+ @deepseek-ai/dsh-compaction-basic@0.1.5-rc.1: MIT; {"type":"git","url":"git+https://github.com/deepseek-ai/deepseek-harness.git","directory":"packages/compaction/compaction-basic"}
32
+ Local changes: DSCODE effort, shell control, TUI commands and footer.
package/cordis.patch.yml CHANGED
@@ -662,6 +662,29 @@
662
662
  config:
663
663
  policy: ask
664
664
 
665
+ # OpenRouter is DSCODE's own adapter (plugins/openrouter); the pi-ai route that
666
+ # served it before stays unmounted so the two never claim the same route.
667
+ - id: llm-pi-ai
668
+ disabled: true
669
+
670
+ # Web search follows the session's route: OpenRouter's web plugin for an
671
+ # OpenRouter session, DeepSeek's native search for a DeepSeek one. A patch
672
+ # replaces the row's whole config, so fetch keeps its provider here.
673
+ - id: web
674
+ config:
675
+ searchProvider: dscode-web
676
+ fetchProvider: http
677
+
678
+ # A reasoning model spends tokens before its title; 64 left most titles empty.
679
+ # DeepSeek's route disables thinking for titles, so its usage stays small.
680
+ - id: session-title-llm
681
+ config:
682
+ targetWords: 5
683
+ targetCjkCharacters: 10
684
+ maxInputBytes: 4096
685
+ maxOutputTokens: 1024
686
+ timeoutMs: 60000
687
+
665
688
  - id: computer-use
666
689
  config:
667
690
  observationTtlMs: 30000
@@ -702,11 +725,13 @@
702
725
  - id: credentials
703
726
  name: "@toddzheng024/dscode-bundle/credentials"
704
727
  - insert:
728
+ - id: dscode-openrouter
729
+ name: "@toddzheng024/dscode-bundle/openrouter"
705
730
  - id: dscode-auto-review
706
731
  name: "@toddzheng024/dscode-bundle/auto-review"
707
732
  config:
708
733
  timeoutMs: 30000
709
- maxOutputTokens: 768
734
+ maxOutputTokens: 4096
710
735
  maxReviewsPerTurn: 20
711
736
  - id: dscode-session-metrics
712
737
  name: "@toddzheng024/dscode-bundle/session-metrics"
@@ -738,12 +763,8 @@
738
763
 
739
764
  - id: llm-deepseek
740
765
  disabled: true
741
- - id: llm-pi-ai
742
- disabled: true
743
766
  - insert:
744
767
  - id: dscode-deepseek
745
768
  name: '@toddzheng024/dscode-bundle/deepseek'
746
- - id: dscode-pi-ai
747
- name: '@toddzheng024/dscode-bundle/pi-ai'
748
769
  - id: dscode-bootstrap
749
770
  name: '@toddzheng024/dscode-bundle/bootstrap'
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.6",
2
+ "version": "0.7.7",
3
3
  "type": "module",
4
4
  "license": "MIT",
5
5
  "author": "Todd Zheng",
@@ -44,14 +44,15 @@
44
44
  "./tui": "./vendor/tui/index.mjs",
45
45
  "./startup": "./vendor/tui/startup.mjs",
46
46
  "./deepseek": "./vendor/deepseek/index.js",
47
- "./pi-ai": "./vendor/pi-ai/index.js",
48
47
  "./bash": "./vendor/bash/index.js",
49
48
  "./persistent": "./vendor/persistent/index.js",
50
49
  "./terminal": "./vendor/terminal/index.js",
50
+ "./compaction-basic": "./vendor/compaction-basic/index.js",
51
51
  "./policy": "./plugins/dscode/index.mjs",
52
52
  "./code-review": "./plugins/code-review/index.mjs",
53
53
  "./auto-review": "./plugins/auto-review/index.mjs",
54
54
  "./session-metrics": "./plugins/session-metrics/index.mjs",
55
+ "./openrouter": "./plugins/openrouter/index.mjs",
55
56
  "./tui-tools": "./plugins/tui-tools/index.mjs"
56
57
  },
57
58
  "dependencies": {
@@ -305,8 +306,7 @@
305
306
  "nodemailer": "10.0.9",
306
307
  "react": "18.3.1",
307
308
  "commander": "15.0.0",
308
- "eventsource-parser": "3.1.1",
309
- "@earendil-works/pi-ai": "0.85.1"
309
+ "eventsource-parser": "3.1.1"
310
310
  },
311
311
  "dsh": {
312
312
  "bundle": {
@@ -3,13 +3,15 @@ import { BlockAssembler, createSystemMessage, createUserMessage } from '@deepsee
3
3
  import { REVIEW_POLICY, escalationDiagnosticGrant, needsMcpApproval, redact, fingerprint, contextFor, parseDecision } from './policy.mjs';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { auditStore } from './audit.mjs';
6
+ import { effortFor } from '../providers/effort.mjs';
6
7
 
7
8
  export const name = 'dscode-auto-review';
8
9
  export const inject = ['approval', 'permissionPresets', 'tools', 'llm', 'commands'];
9
10
  export const Config = z.object({
10
11
  provider: z.string().default(''), model: z.string().default(''),
11
12
  timeoutMs: z.number().min(100).max(120000).default(30000),
12
- maxOutputTokens: z.number().step(1).min(128).max(2048).default(768),
13
+ // Reasoning tokens count against the cap: a reasoning model needs room before its verdict.
14
+ maxOutputTokens: z.number().step(1).min(128).max(16384).default(4096),
13
15
  maxReviewsPerTurn: z.number().step(1).min(1).max(100).default(20),
14
16
  maxEscalationGrantsPerTurn: z.number().step(1).min(0).max(10).default(2),
15
17
  auditDirectory: z.string(),
@@ -105,8 +107,11 @@ export function apply(ctx, config) {
105
107
  // file reads, or main-agent reasoning are added to the reviewer context.
106
108
  const operation = (async () => {
107
109
  let terminal = false;
110
+ // A verdict needs little deliberation: the lowest level near low the reviewer model offers.
111
+ const effort = await effortFor(ctx.llm, target, 'low', signal);
108
112
  for await (const chunk of ctx.llm.stream({
109
113
  provider: target.provider, model: target.model, sessionId: req.agent.session.id,
114
+ ...(effort === undefined ? {} : { reasoningEffort: effort }),
110
115
  messages: [createSystemMessage(REVIEW_POLICY, name), createUserMessage({
111
116
  content: [{ type: 'text', text: JSON.stringify({ action, context }) }],
112
117
  source: { kind: 'plugin', plugin: name },
@@ -0,0 +1,65 @@
1
+ // The compaction indicator: a small scripted Tetris bot. It lines each piece up
2
+ // over its slot, drops it and clears the full rows, the way compaction clears
3
+ // older history. The script tiles the well, so every run ends empty and loops.
4
+ // Cells: '.' empty, '@' falling piece, '#' settled, '=' a row being cleared.
5
+ export const TETRIS_WIDTH = 8;
6
+ export const TETRIS_HEIGHT = 4;
7
+ export const TETRIS_TICK_MS = 160;
8
+
9
+ const EMPTY_ROW = '.'.repeat(TETRIS_WIDTH);
10
+ // [row, column] cells inside each piece's box, row 0 on top.
11
+ const O = [[0, 0], [0, 1], [1, 0], [1, 1]];
12
+ const I = [[0, 0], [0, 1], [0, 2], [0, 3]];
13
+ const J = [[0, 0], [1, 0], [1, 1], [1, 2]];
14
+ const HOOK = [[0, 0], [0, 1], [0, 2], [1, 2]];
15
+ // Two rows cleared at once, then a single row whose leftovers fall and clear next.
16
+ const SCRIPT = [
17
+ { cells: O, x: 0 }, { cells: J, x: 2 }, { cells: HOOK, x: 3 }, { cells: O, x: 6 },
18
+ { cells: I, x: 0 }, { cells: O, x: 4 }, { cells: O, x: 6 }, { cells: I, x: 0 },
19
+ ];
20
+
21
+ function fits(board, cells, x, y) {
22
+ return cells.every(([row, column]) => y + row < TETRIS_HEIGHT && x + column >= 0 && x + column < TETRIS_WIDTH && board[y + row][x + column] === '.');
23
+ }
24
+
25
+ function paint(board, cells, x, y, mark) {
26
+ const rows = board.map(row => [...row]);
27
+ for (const [row, column] of cells) rows[y + row][x + column] = mark;
28
+ return rows.map(row => row.join(''));
29
+ }
30
+
31
+ /** Every frame of one loop, simulated from the script; throws when the script stops tiling the well. */
32
+ export function tetrisFrames() {
33
+ let board = Array.from({ length: TETRIS_HEIGHT }, () => EMPTY_ROW);
34
+ const frames = [];
35
+ for (const { cells, x: target } of SCRIPT) {
36
+ const width = Math.max(...cells.map(([, column]) => column)) + 1;
37
+ let x = Math.floor((TETRIS_WIDTH - width) / 2), y = 0;
38
+ if (!fits(board, cells, x, y)) throw Error('Tetris script spawns into the stack');
39
+ frames.push(paint(board, cells, x, y, '@'));
40
+ while (x !== target) {
41
+ x += Math.sign(target - x);
42
+ if (!fits(board, cells, x, y)) throw Error('Tetris script steers into the stack');
43
+ frames.push(paint(board, cells, x, y, '@'));
44
+ }
45
+ while (fits(board, cells, x, y + 1)) frames.push(paint(board, cells, x, ++y, '@'));
46
+ board = paint(board, cells, x, y, '#');
47
+ const kept = board.filter(row => row.includes('.'));
48
+ if (kept.length < TETRIS_HEIGHT) {
49
+ const flash = board.map(row => row.includes('.') ? row : '='.repeat(TETRIS_WIDTH));
50
+ frames.push(flash, flash);
51
+ board = [...Array.from({ length: TETRIS_HEIGHT - kept.length }, () => EMPTY_ROW), ...kept];
52
+ }
53
+ frames.push(board);
54
+ }
55
+ if (board.some(row => row !== EMPTY_ROW)) throw Error('Tetris script does not clear the well');
56
+ return frames;
57
+ }
58
+
59
+ const FRAMES = tetrisFrames();
60
+
61
+ /** The board rows for an animation tick; the loop wraps. */
62
+ export function tetrisFrame(tick) {
63
+ const index = Math.floor(tick) % FRAMES.length;
64
+ return FRAMES[index < 0 ? index + FRAMES.length : index];
65
+ }
@@ -0,0 +1,46 @@
1
+ import { cacheReadRatio } from '../session-metrics/pricing.mjs';
2
+ import { ensureOpenRouterModels } from '../openrouter/models.mjs';
3
+
4
+ // Auto-compaction threshold priced from the route's cache discount. Staying put
5
+ // re-reads the whole context at the cache-read price; compacting pays for a
6
+ // summary call and re-reads the kept tail at the full input price once. With a
7
+ // deep discount (r = cache read / input price) a long context is cheap to carry,
8
+ // so compaction waits; with no discount it pays off early.
9
+ export const DEFAULT_THRESHOLD_RATIO = 0.8;
10
+
11
+ /** Threshold ratio for a cache-read / input price ratio; unknown prices keep the upstream default. */
12
+ export function thresholdForCacheRatio(ratio) {
13
+ if (!Number.isFinite(ratio) || ratio < 0) return DEFAULT_THRESHOLD_RATIO;
14
+ // Prices are decimal fractions: 0.3 / 3 must land on the 0.1 boundary, not just below it.
15
+ const rounded = Math.round(ratio * 1e9) / 1e9;
16
+ return rounded < 0.1 ? 0.9 : rounded < 0.5 ? 0.8 : 0.6;
17
+ }
18
+
19
+ /** The route's threshold ratio, waiting for the OpenRouter listing when it has not loaded yet. */
20
+ export async function pricedThresholdRatio(provider, model, now = Date.now()) {
21
+ if (provider === 'openrouter') await ensureOpenRouterModels({ home: process.env.DSH_HOME, now });
22
+ return thresholdForCacheRatio(cacheReadRatio(provider, model, now));
23
+ }
24
+
25
+ /**
26
+ * A compaction-basic route policy with the priced threshold. A threshold the
27
+ * deployment configured, globally or for this route, is left as it is.
28
+ * @param config - resolved compaction-basic config; `dscodePricedThreshold` marks an unset top-level threshold.
29
+ * @param policy - that route's merged policy.
30
+ */
31
+ export async function pricedCompactionPolicy(config, policy, now = Date.now()) {
32
+ const { provider, model } = policy.target;
33
+ const override = config.modelPolicies?.find(entry => entry.provider === provider && entry.model === model);
34
+ if (config.dscodePricedThreshold !== true || override?.thresholdRatio !== undefined) return policy;
35
+ const thresholdRatio = await pricedThresholdRatio(provider, model, now);
36
+ // The retained tail must stay below the threshold, so a configured tail that large keeps the configured policy.
37
+ if (policy.retainRatio !== undefined && policy.retainRatio >= thresholdRatio) return policy;
38
+ return { ...policy, thresholdRatio };
39
+ }
40
+
41
+ /** Whether the session's current context would compact on a route's next step, or undefined when either size is unknown. */
42
+ export function compactionPreview({ used, contextWindow, thresholdRatio, label = '' }) {
43
+ if (!Number.isFinite(used) || used <= 0 || !Number.isInteger(contextWindow) || contextWindow <= 0) return undefined;
44
+ const threshold = Math.floor(contextWindow * thresholdRatio);
45
+ return { label, used, contextWindow, thresholdRatio, threshold, compacts: used >= threshold, overflows: used >= contextWindow };
46
+ }
@@ -5,8 +5,8 @@ import { Context, Service } from '@deepseek-ai/cordis';
5
5
  import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
6
6
  import { PROVIDERS } from '../providers/catalog.mjs';
7
7
 
8
- // The `/provider` keys (DeepSeek, OpenRouter) live in the shared store.
9
- const SHARED = new Set(PROVIDERS.map(provider => provider.credentialRef));
8
+ // The `/provider` keys (DeepSeek, OpenRouter and its management key) live in the shared store.
9
+ const SHARED = new Set(PROVIDERS.flatMap(provider => [provider.credentialRef, provider.managementRef].filter(Boolean)));
10
10
 
11
11
  // Use the native locked, watched, owner-only store. Keep this independent of
12
12
  // the installed profile so upgrades and different projects share credentials.
@@ -37,6 +37,9 @@ export const MESSAGES = {
37
37
  'language.saveFailed': 'language save failed: {error}',
38
38
  'doctor.logs.new': 'Only warnings/errors since this TUI version started are recorded.',
39
39
  'footer.current': 'current', 'footer.average': 'average', 'footer.context': 'context', 'footer.cache': 'cache hit', 'composer.placeholder': 'type a message',
40
+ 'compaction.running': 'Compacting context, please wait', 'compaction.confirm.title': 'Switching to {model} will compact the conversation',
41
+ 'compaction.confirm.usage': 'context {used} ≥ compaction threshold {threshold} ({window} × {ratio})', 'compaction.confirm.next': 'the next step compacts older history first',
42
+ 'compaction.confirm.overflow': 'it already exceeds the {window} window: the next request compacts and retries', 'compaction.confirm.hint': 'y switch · n/esc back',
40
43
  'doctor.nearTimeout': 'Assessment: {count} Bash calls ended at about 300 seconds, which matches the tool timeout; traces alone cannot prove the root cause. Next, check those calls\' shell completion markers and terminal errors.',
41
44
  'doctor.logs.none': 'No log file yet; earlier console logs were not persisted and cannot be recovered.',
42
45
  'doctor.evidence': 'Diagnostic evidence: {traces} recent sessions, {logs} warnings/errors.',
@@ -58,6 +61,9 @@ export const MESSAGES = {
58
61
  'language.saveFailed': '语言设置保存失败:{error}',
59
62
  'doctor.logs.new': '仅记录新版 TUI 启动后的 warning/error。',
60
63
  'footer.current': '当前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '缓存命中', 'composer.placeholder': '输入消息',
64
+ 'compaction.running': '正在压缩上下文,请稍后', 'compaction.confirm.title': '切换到 {model} 会触发自动压缩',
65
+ 'compaction.confirm.usage': '当前上下文 {used} ≥ 压缩阈值 {threshold}({window} × {ratio})', 'compaction.confirm.next': '下一步开始前会先压缩较早的对话',
66
+ 'compaction.confirm.overflow': '已超出新模型的 {window} 上下文窗口:下一次请求会先压缩再重试', 'compaction.confirm.hint': 'y 确认切换 · n/esc 返回',
61
67
  'doctor.nearTimeout': '判断:{count} 次 Bash 调用在约 300 秒结束,符合工具超时特征;trace 不能单独证明触发超时的根因。下一步检查这些调用的 shell 完成标记和终端错误。',
62
68
  'doctor.logs.none': '日志文件尚未建立;旧版控制台日志未持久化,无法回溯。',
63
69
  'doctor.evidence': '诊断证据:{traces} 个近期会话、{logs} 条 warning/error。',
@@ -79,6 +85,9 @@ export const MESSAGES = {
79
85
  'language.saveFailed': '語言設定儲存失敗:{error}',
80
86
  'doctor.logs.new': '僅記錄新版 TUI 啟動後的 warning/error。',
81
87
  'footer.current': '目前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '快取命中', 'composer.placeholder': '輸入訊息',
88
+ 'compaction.running': '正在壓縮上下文,請稍候', 'compaction.confirm.title': '切換到 {model} 會觸發自動壓縮',
89
+ 'compaction.confirm.usage': '目前上下文 {used} ≥ 壓縮閾值 {threshold}({window} × {ratio})', 'compaction.confirm.next': '下一步開始前會先壓縮較早的對話',
90
+ 'compaction.confirm.overflow': '已超出新模型的 {window} 上下文視窗:下一次請求會先壓縮再重試', 'compaction.confirm.hint': 'y 確認切換 · n/esc 返回',
82
91
  'doctor.nearTimeout': '判斷:{count} 次 Bash 呼叫在約 300 秒結束,符合工具逾時特徵;trace 無法單獨證明觸發逾時的根因。下一步檢查這些呼叫的 shell 完成標記和終端錯誤。',
83
92
  'doctor.logs.none': '日誌檔尚未建立;舊版主控台日誌未持久化,無法回溯。',
84
93
  'doctor.evidence': '診斷證據:{traces} 個近期工作階段、{logs} 筆 warning/error。',
@@ -100,6 +109,9 @@ export const MESSAGES = {
100
109
  'language.saveFailed': '言語設定の保存に失敗しました:{error}',
101
110
  'doctor.logs.new': 'この TUI バージョンの起動以降の warning/error のみ記録されています。',
102
111
  'footer.current': '現在', 'footer.average': '平均', 'footer.context': 'コンテキスト', 'footer.cache': 'キャッシュヒット', 'composer.placeholder': 'メッセージを入力',
112
+ 'compaction.running': 'コンテキストを圧縮しています。しばらくお待ちください', 'compaction.confirm.title': '{model} に切り替えると自動圧縮が実行されます',
113
+ 'compaction.confirm.usage': '現在のコンテキスト {used} ≥ 圧縮しきい値 {threshold}({window} × {ratio})', 'compaction.confirm.next': '次のステップの前に古い履歴を圧縮します',
114
+ 'compaction.confirm.overflow': '新しいモデルの {window} ウィンドウを超えています。次のリクエストで圧縮して再試行します', 'compaction.confirm.hint': 'y 切り替え · n/esc 戻る',
103
115
  'doctor.nearTimeout': '判断:Bash 呼び出し {count} 件が約 300 秒で終了しており、ツールのタイムアウトの特徴に一致します。トレースだけでは根本原因を証明できません。次はこれらの呼び出しのシェル完了マーカーと端末エラーを確認してください。',
104
116
  'doctor.logs.none': 'ログファイルはまだありません。以前のコンソールログは保存されておらず、遡れません。',
105
117
  'doctor.evidence': '診断の根拠:直近のセッション {traces} 件、warning/error {logs} 件。',
@@ -121,6 +133,9 @@ export const MESSAGES = {
121
133
  'language.saveFailed': '언어 설정 저장 실패: {error}',
122
134
  'doctor.logs.new': '이 TUI 버전 시작 이후의 warning/error만 기록됩니다.',
123
135
  'footer.current': '현재', 'footer.average': '평균', 'footer.context': '컨텍스트', 'footer.cache': '캐시 적중', 'composer.placeholder': '메시지를 입력하세요',
136
+ 'compaction.running': '컨텍스트를 압축하는 중입니다. 잠시만 기다려 주세요', 'compaction.confirm.title': '{model}(으)로 전환하면 자동 압축이 실행됩니다',
137
+ 'compaction.confirm.usage': '현재 컨텍스트 {used} ≥ 압축 임계값 {threshold} ({window} × {ratio})', 'compaction.confirm.next': '다음 단계 전에 이전 대화를 먼저 압축합니다',
138
+ 'compaction.confirm.overflow': '새 모델의 {window} 창을 이미 넘었습니다. 다음 요청에서 압축한 뒤 재시도합니다', 'compaction.confirm.hint': 'y 전환 · n/esc 뒤로',
124
139
  'doctor.nearTimeout': '판단: Bash 호출 {count}건이 약 300초에 종료되어 도구 시간 초과 특징과 일치합니다. 트레이스만으로는 근본 원인을 증명할 수 없습니다. 다음으로 해당 호출의 셸 완료 표시와 터미널 오류를 확인하세요.',
125
140
  'doctor.logs.none': '아직 로그 파일이 없습니다. 이전 콘솔 로그는 저장되지 않아 복구할 수 없습니다.',
126
141
  'doctor.evidence': '진단 근거: 최근 세션 {traces}개, warning/error {logs}건.',
@@ -142,6 +157,9 @@ export const MESSAGES = {
142
157
  'language.saveFailed': 'no se pudo guardar el idioma: {error}',
143
158
  'doctor.logs.new': 'Solo se registran los warnings/errores desde que arrancó esta versión del TUI.',
144
159
  'footer.current': 'actual', 'footer.average': 'promedio', 'footer.context': 'contexto', 'footer.cache': 'éxitos de caché', 'composer.placeholder': 'escribe un mensaje',
160
+ 'compaction.running': 'Compactando el contexto, espera un momento', 'compaction.confirm.title': 'Cambiar a {model} compactará la conversación',
161
+ 'compaction.confirm.usage': 'contexto {used} ≥ umbral de compactación {threshold} ({window} × {ratio})', 'compaction.confirm.next': 'el siguiente paso compacta antes el historial antiguo',
162
+ 'compaction.confirm.overflow': 'ya supera la ventana de {window}: la siguiente petición compacta y reintenta', 'compaction.confirm.hint': 'y cambiar · n/esc volver',
145
163
  'doctor.nearTimeout': 'Valoración: {count} llamadas a Bash terminaron a unos 300 segundos, lo que coincide con el tiempo de espera de la herramienta; las trazas por sí solas no prueban la causa raíz. A continuación, revisa los marcadores de finalización del shell y los errores de terminal de esas llamadas.',
146
164
  'doctor.logs.none': 'Aún no hay archivo de registro; los registros de consola anteriores no se conservaron y no se pueden recuperar.',
147
165
  'doctor.evidence': 'Evidencia del diagnóstico: {traces} sesiones recientes, {logs} warnings/errores.',
@@ -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
+ }