@toddzheng024/dscode-desktop 0.7.39 → 0.7.41
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/README.md +2 -2
- package/docs/CHANGELOG.md +33 -5
- package/docs/account-login.md +3 -1
- package/docs/acp.md +17 -3
- package/docs/browser-use.md +1 -1
- package/docs/custom-providers.md +3 -1
- package/docs/distribution.md +61 -33
- package/docs/email.md +4 -4
- package/docs/hub-distribution.md +3 -1
- package/docs/opencode-go.md +4 -2
- package/docs/releases/0.7.40.md +13 -0
- package/docs/releases/0.7.41.md +25 -0
- package/docs/session-metrics.md +9 -0
- package/docs/tui-commands.md +10 -7
- package/package.json +1 -1
- package/plugins/acp/index.mjs +3 -1
- package/plugins/acp/models.mjs +47 -0
- package/plugins/custom/config.mjs +1 -1
- package/plugins/custom/index.mjs +1 -1
- package/plugins/desktop/client.mjs +2 -2
- package/plugins/dscode/index.mjs +5 -1
- package/plugins/email/inbox.mjs +1 -1
- package/plugins/i18n/messages.d.mts +2 -0
- package/plugins/i18n/messages.mjs +16 -12
- package/plugins/providers/openrouter-account.mjs +4 -5
- package/plugins/session-metrics/balance.mjs +45 -28
- package/plugins/session-metrics/desktop-client.mjs +2 -2
- package/plugins/session-metrics/desktop-host.mjs +3 -2
- package/plugins/session-metrics/index.mjs +17 -4
- package/plugins/session-metrics/view.mjs +3 -3
- package/plugins/status/index.mjs +127 -0
- package/plugins/status/selection.mjs +6 -0
- package/plugins/triggers/scheduler-service.mjs +1 -1
- package/plugins/triggers/scheduler.mjs +2 -1
- package/plugins/tui-tools/hook-sources.mjs +2 -1
- package/plugins/tui-tools/index.mjs +2 -0
- package/plugins/tui-tools/update.mjs +6 -4
- package/plugins/tui-tools/workspace-discovery.mjs +17 -3
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { credentialKeyId, credentialKeyScope } from '@deepseek-ai/dsh-credentials';
|
|
2
|
+
import { isCustomId, keyRef } from '../custom/config.mjs';
|
|
3
|
+
import { providerSpec } from '../providers/catalog.mjs';
|
|
4
|
+
|
|
5
|
+
function credentialReference(ctx, provider) {
|
|
6
|
+
const entry = ctx.llm.listConfigurableProviders?.().find(entry => entry.provider === provider);
|
|
7
|
+
if (!entry) return providerSpec(provider)?.credentialRef;
|
|
8
|
+
const descriptor = ctx.get('settings')?.describe({ redactSecrets: true }).find(item => item.ns === entry.settingsNs);
|
|
9
|
+
const profile = entry.settingsPath.reduce((value, key) => value?.[key], descriptor?.value);
|
|
10
|
+
return typeof profile?.apiKeyEnv === 'string' ? profile.apiKeyEnv : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Local configuration facts only: no secret values or inference requests. */
|
|
14
|
+
export async function configuredProvider(ctx, provider) {
|
|
15
|
+
try {
|
|
16
|
+
const credentials = ctx.get('credentials');
|
|
17
|
+
if (isCustomId(provider)) {
|
|
18
|
+
const profile = ctx.get('dscodeCustom')?.providers.find(profile => profile.id === provider);
|
|
19
|
+
return !!profile && (profile.auth === 'none' || (await credentials?.describe(keyRef(provider)))?.configured === true);
|
|
20
|
+
}
|
|
21
|
+
// Harness 0.2 separates the account route from the API-key route.
|
|
22
|
+
if (provider === 'deepseek-account') {
|
|
23
|
+
return (await ctx.get('deepseekAccount')?.getState())?.status === 'credential-stored';
|
|
24
|
+
}
|
|
25
|
+
const ref = credentialReference(ctx, provider);
|
|
26
|
+
if (ref && (await credentials?.describe(ref))?.configured === true) return true;
|
|
27
|
+
const entries = ctx.get('authorization')?.list() ?? [];
|
|
28
|
+
for (const { key } of entries) {
|
|
29
|
+
if (credentialKeyScope(key) === 'llm-pi-ai' && credentialKeyId(key) === provider
|
|
30
|
+
&& (await credentials?.describeRecord(key))?.configured === true) return true;
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// One unavailable configuration source must not break the ACP picker.
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Scope discovery to ACP; native routing and the shared TUI catalog stay intact. */
|
|
39
|
+
export function configuredModels(ctx) {
|
|
40
|
+
return new Proxy(ctx.llm, {
|
|
41
|
+
get(target, key) {
|
|
42
|
+
if (key === 'listModels') return async provider => await configuredProvider(ctx, provider) ? target.listModels(provider) : [];
|
|
43
|
+
const value = Reflect.get(target, key, target);
|
|
44
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -52,7 +52,7 @@ export function validateProfile(input) {
|
|
|
52
52
|
|
|
53
53
|
/** Whole-file revision + cross-process lock: stale editors never overwrite another Host. */
|
|
54
54
|
export class CustomStore {
|
|
55
|
-
constructor(path = join(homedir(), '.dscode', 'providers.yaml')) { this.path = path; }
|
|
55
|
+
constructor(path = join(process.env.DSCODE_STANDALONE_ROOT && process.env.DSCODE_HOME ? process.env.DSCODE_HOME : join(homedir(), '.dscode'), 'providers.yaml')) { this.path = path; }
|
|
56
56
|
async read() {
|
|
57
57
|
let raw;
|
|
58
58
|
try { raw = await readFile(this.path, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; raw = ''; }
|
package/plugins/custom/index.mjs
CHANGED
|
@@ -138,7 +138,7 @@ export async function apply(ctx, config = {}) {
|
|
|
138
138
|
else if (ids.length) registration = ctx.llm.registerAdapter(ids, service.adapter);
|
|
139
139
|
} });
|
|
140
140
|
ctx.provide('dscodeCustom', service);
|
|
141
|
-
try { await service.refresh(); } catch { ctx.logger.warn('Could not read custom providers; repair
|
|
141
|
+
try { await service.refresh(); } catch { ctx.logger.warn('Could not read custom providers; repair %s', service.store.path); }
|
|
142
142
|
const refresh = () => { void service.refresh().catch(() => ctx.logger.warn('Invalid custom provider edit; retaining the last valid configuration')); };
|
|
143
143
|
watchFile(service.store.path, { persistent: false, interval: 1000 }, refresh);
|
|
144
144
|
ctx.effect(() => () => unwatchFile(service.store.path, refresh));
|
|
@@ -827,7 +827,7 @@ components.push({
|
|
|
827
827
|
id: '@toddzheng024/dscode-desktop',
|
|
828
828
|
factory: require => {
|
|
829
829
|
const { createElement: h, useState, useEffect, useRef } = require('react');
|
|
830
|
-
const money = (cost, partial = false) => cost === null || partial && cost === 0 ? 'Unknown' : `$${cost.toFixed(4)}${partial ? '+' : ''
|
|
830
|
+
const money = (cost, partial = false, currency = 'USD') => cost === null || partial && cost === 0 ? 'Unknown' : (currency === 'USD' ? `$${cost.toFixed(4)}` : `${cost.toFixed(4)} ${currency}`) + (partial ? '+' : '');
|
|
831
831
|
const percent = value => value === null ? 'Unknown' : `${value.toFixed(1)}%`;
|
|
832
832
|
const speed = value => value === null ? 'Unknown' : `${value.toFixed(1)} tokens/s`;
|
|
833
833
|
const control = { padding: '7px 10px', border: '.5px solid var(--dsw-alias-border-l3, #8886)', borderRadius: 'var(--dsw-radius-md, 6px)', background: 'transparent', color: 'inherit', font: 'inherit' };
|
|
@@ -858,7 +858,7 @@ components.push({
|
|
|
858
858
|
fact('Pending calls', state.pending ?? 'Unknown'), fact('Context estimate', percent(state.contextPercent)),
|
|
859
859
|
fact('Input cache share', percent(state.cachePercent)), fact('Smoothed request speed', speed(state.currentTps)),
|
|
860
860
|
fact('Session average speed', speed(state.averageTps)),
|
|
861
|
-
state.balance !== null && fact('Cached
|
|
861
|
+
state.balance !== null && state.balanceCurrency && fact(state.balanceSource === 'key-limit' ? 'Cached key spending limit' : 'Cached account balance', money(state.balance, false, state.balanceCurrency)),
|
|
862
862
|
state.subscription && fact('Cached subscription usage', state.subscription),
|
|
863
863
|
state.budget && fact('Configured cost budget', `${money(state.budget.spent)} / ${money(state.budget.limit)} · ${state.budget.state}`)),
|
|
864
864
|
h('p', { style: { fontSize: 12 } }, 'Unknown means a reading or price is unavailable. + marks a partial cost. Pending calls have not settled. Cached account figures may lag the provider.'),
|
package/plugins/dscode/index.mjs
CHANGED
|
@@ -9,8 +9,9 @@ import { boardSummary, DelegateBoard, setBoardSource } from './board.mjs';
|
|
|
9
9
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
10
10
|
import { worktreeBase } from '../worktree-subagent/worktree.mjs';
|
|
11
11
|
import { withChildTarget } from './child-target.mjs';
|
|
12
|
+
import { registerStatus } from '../status/index.mjs';
|
|
12
13
|
export const name = 'dscode-execution-policy';
|
|
13
|
-
export const inject = ['systemPrompt', 'tools', 'agents', 'commands', 'terminals', 'subagents'];
|
|
14
|
+
export const inject = ['systemPrompt', 'tools', 'agents', 'commands', 'terminals', 'subagents', 'llm'];
|
|
14
15
|
export const SHELL_POLICY = `Use bash as the persistent shell for reading, searching and modifying files. Prefer rg/rg --files, sed and standard CLI tools. Use apply_patch with a standard unified diff on stdin (git apply format, a/ and b/ paths); apply_patch --check validates before writing. It is not the *** Begin Patch format. Quote heredoc delimiters to avoid shell interpolation.
|
|
15
16
|
Each agent has its own persistent shell, initially in the session workspace. cd, exported variables, functions and background jobs persist only while this shell lives. Timeout, cancellation, exit, /shell reset and process restart discard shell state; resume restores conversation, not an OS process. Never assume an environment from a past session still exists. Inspect pwd when paths matter. Keep long-running processes controlled and clean them up when done.
|
|
16
17
|
Normal bash remains confined by the active sandbox. After a genuine sandbox denial, shell_retry provides a fresh, one-shot shell with the existing approval/escalation mechanism. Set an explicit absolute workdir and reconstruct needed non-secret setup; it does not inherit the persistent shell's cd, exports, functions or jobs. Use existing credential-aware CLIs; never paste secrets into arguments. Approval rejection is final for that action; do not work around it.
|
|
@@ -21,6 +22,7 @@ Delegation to child agents (subagent, subagent_fork) is available at every effor
|
|
|
21
22
|
// unrequested commits, invented test suites). Kept separate from the persona so
|
|
22
23
|
// it stays one reviewable unit and does not lengthen the deployment block.
|
|
23
24
|
export const CODE_DISCIPLINE = `Code discipline. Fix the problem at its root cause rather than with a surface patch, and keep the change inside the requested scope: do not fix unrelated bugs or failing tests, do not reformat or rename what the task did not ask for, and mention adjacent problems instead of taking them on. Match the surrounding code's style, naming and comment density; do not add inline comments, license or copyright headers unless the task or the neighbouring code requires it. Do not commit, create branches or rewrite history unless the user asks. Do not introduce a test suite to a repository that has none; where tests exist, extend the nearest relevant pattern. When the repository's own history would settle a question, read it with git log or git blame before guessing.`;
|
|
25
|
+
export const MARKDOWN_POLICY = 'Markdown output. Inside a code span, backslashes are literal and cannot escape a backtick. Wrap code containing backticks in a longer backtick delimiter (for example, two backticks around a template literal containing single backticks); add one padding space at both ends when the content starts or ends with a backtick. Use a fenced code block for multiline code, with a fence longer than any matching run inside it. Preserve literal backslashes and code text. Keep prose and punctuation outside code spans. For long file paths in explanations, prefer a short filename label with the full path as the link target instead of repeating the full path inline.';
|
|
24
26
|
|
|
25
27
|
// Claude-Code-derived working discipline: instruction precedence, acting instead
|
|
26
28
|
// of re-deriving, and correction economy. The memory and session sections already
|
|
@@ -61,10 +63,12 @@ const DOCS_REPO = 'https://github.com/qiz029/dscode/blob/main/docs/';
|
|
|
61
63
|
export const DOCS_SECTION = `To answer questions about DSCODE itself - what it supports, how a feature is configured, what a command does - treat the bundled user guides as the source of truth instead of guessing: ${existsSync(DOCS_DIR) ? `${DOCS_DIR} plus the repository root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, acp.md, email.md, browser-use.md, plugin-hub.md, computer-use.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, account-login.md, opencode-go.md, custom-providers.md, demo.md, triggers.md)` : `the repository documentation at ${DOCS_REPO} plus the root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, acp.md, email.md, browser-use.md, plugin-hub.md, computer-use.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, account-login.md, opencode-go.md, custom-providers.md, demo.md, triggers.md)`}. Read the relevant guide before answering rather than relying on memory of an earlier session. docs/CONTEXT-HANDOFF.md, docs/session-messaging-design.md, docs/cloud-webapp-host.md, docs/triggers-design.md, docs/verification.md, docs/maintainability.md and docs/vendored-tui-upgrade.md are internal development records, not user documentation: never quote them to a user or treat them as a specification. When the guides do not cover something, describe only what the running installation actually does and say plainly that it is not documented.`;
|
|
62
64
|
|
|
63
65
|
export function apply(ctx, config = {}) {
|
|
66
|
+
registerStatus(ctx);
|
|
64
67
|
const viewer = config.desktop ? 'Delegation board in the right sidebar' : '/delegate-dashboard';
|
|
65
68
|
ctx.systemPrompt.section({ name: 'dscode:shell-policy', order: 1050, text: SHELL_POLICY });
|
|
66
69
|
ctx.systemPrompt.section({ name: 'dscode:docs', order: 1052, text: DOCS_SECTION });
|
|
67
70
|
ctx.systemPrompt.section({ name: 'dscode:code-discipline', order: 1053, text: CODE_DISCIPLINE });
|
|
71
|
+
ctx.systemPrompt.section({ name: 'dscode:markdown', order: 1054, text: MARKDOWN_POLICY });
|
|
68
72
|
ctx.systemPrompt.section({ name: 'dscode:working-discipline', order: 1054, text: WORKING_DISCIPLINE });
|
|
69
73
|
ctx.systemPrompt.section({ name: 'dscode:child-policy', order: 1051, text: ({ scope }) => scope?.session?.header?.origin === 'subagent' && scope.session.header.agentPreset === 'dscode'
|
|
70
74
|
? 'You are a delegated worker. Complete your assigned task yourself and return a concise result to the parent. You cannot start or wake another agent; ask the parent to make any new delegation decision. Your parent is addressed as / in send_message.' : '' });
|
package/plugins/email/inbox.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export function emailPrompt(mail) {
|
|
|
39
39
|
}, null, 2);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export function createEmailInbox({ directory = process.env.DSCODE_EMAIL_DIR || join(homedir(), '.dscode', 'email') } = {}) {
|
|
42
|
+
export function createEmailInbox({ directory = process.env.DSCODE_EMAIL_DIR || join(process.env.DSCODE_STANDALONE_ROOT && process.env.DSCODE_HOME ? process.env.DSCODE_HOME : join(homedir(), '.dscode'), 'email') } = {}) {
|
|
43
43
|
return {
|
|
44
44
|
directory,
|
|
45
45
|
receive(value) {
|
|
@@ -19,3 +19,5 @@ export function normalizeLanguage(value: unknown): string | null;
|
|
|
19
19
|
export function languageName(code: string): string;
|
|
20
20
|
/** One message in one locale, with `{name}` parameters interpolated. */
|
|
21
21
|
export function t(locale: string, key: string, params?: Readonly<Record<string, unknown>>): string;
|
|
22
|
+
/** Package-local user preferences; legacy installs retain their existing location. */
|
|
23
|
+
export function terminalStateDirectory(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
@@ -41,7 +41,7 @@ export const MESSAGES = {
|
|
|
41
41
|
'language.set': 'language → {name}', 'language.title': '/language — interface language', 'language.currentMark': 'current', 'language.unknown': 'Unknown language "{value}". Choose en, zh-CN, zh-TW, ja, ko or es.',
|
|
42
42
|
'language.saveFailed': 'language save failed: {error}',
|
|
43
43
|
'doctor.logs.new': 'Only warnings/errors since this TUI version started are recorded.',
|
|
44
|
-
'footer.average': 'avg', 'footer.context': 'ctx', 'footer.cache': 'cache', 'footer.grokUsed': 'used', 'footer.grokResets': 'resets', 'footer.goLimited': 'limited until', 'composer.placeholder': 'type a message', 'composer.shellMode': '! shell mode · esc exits',
|
|
44
|
+
'footer.average': 'avg', 'footer.context': 'ctx', 'footer.cache': 'cache', 'footer.grokUsed': 'used', 'footer.grokResets': 'resets', 'footer.goLimited': 'limited until', 'footer.keyLimit': 'key', 'composer.placeholder': 'type a message', 'composer.shellMode': '! shell mode · esc exits',
|
|
45
45
|
'compaction.running': 'Compacting context, please wait', 'compaction.confirm.title': 'Switching to {model} will compact the conversation',
|
|
46
46
|
'compaction.confirm.usage': 'context {used} ≥ compaction threshold {threshold} ({window} × {ratio})', 'compaction.confirm.next': 'the next step compacts older history first',
|
|
47
47
|
'compaction.confirm.overflow': 'it already exceeds the {window} window: the next request compacts and retries', 'compaction.confirm.hint': 'y switch · n/esc back',
|
|
@@ -119,7 +119,7 @@ export const MESSAGES = {
|
|
|
119
119
|
'language.set': '语言 → {name}', 'language.title': '/language — 界面语言', 'language.currentMark': '当前', 'language.unknown': '未知语言 "{value}"。可选 en、zh-CN、zh-TW、ja、ko、es。',
|
|
120
120
|
'language.saveFailed': '语言设置保存失败:{error}',
|
|
121
121
|
'doctor.logs.new': '仅记录新版 TUI 启动后的 warning/error。',
|
|
122
|
-
'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': '缓存', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'footer.goLimited': '受限至', 'composer.placeholder': '输入消息', 'composer.shellMode': '! shell 模式 · esc 退出',
|
|
122
|
+
'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': '缓存', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'footer.goLimited': '受限至', 'footer.keyLimit': '额度', 'composer.placeholder': '输入消息', 'composer.shellMode': '! shell 模式 · esc 退出',
|
|
123
123
|
'compaction.running': '正在压缩上下文,请稍后', 'compaction.confirm.title': '切换到 {model} 会触发自动压缩',
|
|
124
124
|
'compaction.confirm.usage': '当前上下文 {used} ≥ 压缩阈值 {threshold}({window} × {ratio})', 'compaction.confirm.next': '下一步开始前会先压缩较早的对话',
|
|
125
125
|
'compaction.confirm.overflow': '已超出新模型的 {window} 上下文窗口:下一次请求会先压缩再重试', 'compaction.confirm.hint': 'y 确认切换 · n/esc 返回',
|
|
@@ -197,7 +197,7 @@ export const MESSAGES = {
|
|
|
197
197
|
'language.set': '語言 → {name}', 'language.title': '/language — 介面語言', 'language.currentMark': '目前', 'language.unknown': '未知語言 "{value}"。可選 en、zh-CN、zh-TW、ja、ko、es。',
|
|
198
198
|
'language.saveFailed': '語言設定儲存失敗:{error}',
|
|
199
199
|
'doctor.logs.new': '僅記錄新版 TUI 啟動後的 warning/error。',
|
|
200
|
-
'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': '快取', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'footer.goLimited': '受限至', 'composer.placeholder': '輸入訊息', 'composer.shellMode': '! shell 模式 · esc 離開',
|
|
200
|
+
'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': '快取', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'footer.goLimited': '受限至', 'footer.keyLimit': '額度', 'composer.placeholder': '輸入訊息', 'composer.shellMode': '! shell 模式 · esc 離開',
|
|
201
201
|
'compaction.running': '正在壓縮上下文,請稍候', 'compaction.confirm.title': '切換到 {model} 會觸發自動壓縮',
|
|
202
202
|
'compaction.confirm.usage': '目前上下文 {used} ≥ 壓縮閾值 {threshold}({window} × {ratio})', 'compaction.confirm.next': '下一步開始前會先壓縮較早的對話',
|
|
203
203
|
'compaction.confirm.overflow': '已超出新模型的 {window} 上下文視窗:下一次請求會先壓縮再重試', 'compaction.confirm.hint': 'y 確認切換 · n/esc 返回',
|
|
@@ -275,7 +275,7 @@ export const MESSAGES = {
|
|
|
275
275
|
'language.set': '言語 → {name}', 'language.title': '/language — 表示言語', 'language.currentMark': '現在', 'language.unknown': '不明な言語 "{value}"。en、zh-CN、zh-TW、ja、ko、es から選んでください。',
|
|
276
276
|
'language.saveFailed': '言語設定の保存に失敗しました:{error}',
|
|
277
277
|
'doctor.logs.new': 'この TUI バージョンの起動以降の warning/error のみ記録されています。',
|
|
278
|
-
'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': 'キャッシュ', 'footer.grokUsed': '使用', 'footer.grokResets': 'リセット', 'footer.goLimited': '制限解除', 'composer.placeholder': 'メッセージを入力', 'composer.shellMode': '! shell モード · esc で終了',
|
|
278
|
+
'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': 'キャッシュ', 'footer.grokUsed': '使用', 'footer.grokResets': 'リセット', 'footer.goLimited': '制限解除', 'footer.keyLimit': 'キー上限', 'composer.placeholder': 'メッセージを入力', 'composer.shellMode': '! shell モード · esc で終了',
|
|
279
279
|
'compaction.running': 'コンテキストを圧縮しています。しばらくお待ちください', 'compaction.confirm.title': '{model} に切り替えると自動圧縮が実行されます',
|
|
280
280
|
'compaction.confirm.usage': '現在のコンテキスト {used} ≥ 圧縮しきい値 {threshold}({window} × {ratio})', 'compaction.confirm.next': '次のステップの前に古い履歴を圧縮します',
|
|
281
281
|
'compaction.confirm.overflow': '新しいモデルの {window} ウィンドウを超えています。次のリクエストで圧縮して再試行します', 'compaction.confirm.hint': 'y 切り替え · n/esc 戻る',
|
|
@@ -353,7 +353,7 @@ export const MESSAGES = {
|
|
|
353
353
|
'language.set': '언어 → {name}', 'language.title': '/language — 인터페이스 언어', 'language.currentMark': '현재', 'language.unknown': '알 수 없는 언어 "{value}". en, zh-CN, zh-TW, ja, ko, es 중에서 선택하세요.',
|
|
354
354
|
'language.saveFailed': '언어 설정 저장 실패: {error}',
|
|
355
355
|
'doctor.logs.new': '이 TUI 버전 시작 이후의 warning/error만 기록됩니다.',
|
|
356
|
-
'footer.average': '평균', 'footer.context': 'ctx', 'footer.cache': '캐시', 'footer.grokUsed': '사용', 'footer.grokResets': '재설정', 'footer.goLimited': '제한 해제', 'composer.placeholder': '메시지를 입력하세요', 'composer.shellMode': '! shell 모드 · esc 종료',
|
|
356
|
+
'footer.average': '평균', 'footer.context': 'ctx', 'footer.cache': '캐시', 'footer.grokUsed': '사용', 'footer.grokResets': '재설정', 'footer.goLimited': '제한 해제', 'footer.keyLimit': '키 한도', 'composer.placeholder': '메시지를 입력하세요', 'composer.shellMode': '! shell 모드 · esc 종료',
|
|
357
357
|
'compaction.running': '컨텍스트를 압축하는 중입니다. 잠시만 기다려 주세요', 'compaction.confirm.title': '{model}(으)로 전환하면 자동 압축이 실행됩니다',
|
|
358
358
|
'compaction.confirm.usage': '현재 컨텍스트 {used} ≥ 압축 임계값 {threshold} ({window} × {ratio})', 'compaction.confirm.next': '다음 단계 전에 이전 대화를 먼저 압축합니다',
|
|
359
359
|
'compaction.confirm.overflow': '새 모델의 {window} 창을 이미 넘었습니다. 다음 요청에서 압축한 뒤 재시도합니다', 'compaction.confirm.hint': 'y 전환 · n/esc 뒤로',
|
|
@@ -431,7 +431,7 @@ export const MESSAGES = {
|
|
|
431
431
|
'language.set': 'idioma → {name}', 'language.title': '/language — idioma de la interfaz', 'language.currentMark': 'actual', 'language.unknown': 'Idioma desconocido "{value}". Elige en, zh-CN, zh-TW, ja, ko o es.',
|
|
432
432
|
'language.saveFailed': 'no se pudo guardar el idioma: {error}',
|
|
433
433
|
'doctor.logs.new': 'Solo se registran los warnings/errores desde que arrancó esta versión del TUI.',
|
|
434
|
-
'footer.average': 'prom', 'footer.context': 'ctx', 'footer.cache': 'caché', 'footer.grokUsed': 'usado', 'footer.grokResets': 'reinicia', 'footer.goLimited': 'limitado hasta', 'composer.placeholder': 'escribe un mensaje', 'composer.shellMode': '! modo shell · esc sale',
|
|
434
|
+
'footer.average': 'prom', 'footer.context': 'ctx', 'footer.cache': 'caché', 'footer.grokUsed': 'usado', 'footer.grokResets': 'reinicia', 'footer.goLimited': 'limitado hasta', 'footer.keyLimit': 'límite key', 'composer.placeholder': 'escribe un mensaje', 'composer.shellMode': '! modo shell · esc sale',
|
|
435
435
|
'compaction.running': 'Compactando el contexto, espera un momento', 'compaction.confirm.title': 'Cambiar a {model} compactará la conversación',
|
|
436
436
|
'compaction.confirm.usage': 'contexto {used} ≥ umbral de compactación {threshold} ({window} × {ratio})', 'compaction.confirm.next': 'el siguiente paso compacta antes el historial antiguo',
|
|
437
437
|
'compaction.confirm.overflow': 'ya supera la ventana de {window}: la siguiente petición compacta y reintenta', 'compaction.confirm.hint': 'y cambiar · n/esc volver',
|
|
@@ -512,8 +512,12 @@ export function t(locale, key, params) {
|
|
|
512
512
|
return text;
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
-
export function
|
|
516
|
-
return join(home, '.dsh', 'dsh-code'
|
|
515
|
+
export function terminalStateDirectory(home = homedir(), env = process.env) {
|
|
516
|
+
return env.DSCODE_STANDALONE_ROOT && env.DSCODE_HOME ? join(env.DSCODE_HOME, 'tui') : join(home, '.dsh', 'dsh-code');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export function languageFile(home = homedir(), env = process.env) {
|
|
520
|
+
return join(terminalStateDirectory(home, env), 'language.json');
|
|
517
521
|
}
|
|
518
522
|
|
|
519
523
|
/** The stored language, or English; DSCODE_LANGUAGE overrides the file for one process. */
|
|
@@ -521,17 +525,17 @@ export function readLanguage({ home = homedir(), env = process.env } = {}) {
|
|
|
521
525
|
const override = normalizeLanguage(env.DSCODE_LANGUAGE);
|
|
522
526
|
if (override) return override;
|
|
523
527
|
try {
|
|
524
|
-
const file = languageFile(home);
|
|
528
|
+
const file = languageFile(home, env);
|
|
525
529
|
if (!existsSync(file)) return 'en';
|
|
526
530
|
return normalizeLanguage(JSON.parse(readFileSync(file, 'utf8')).language) ?? 'en';
|
|
527
531
|
} catch { return 'en'; }
|
|
528
532
|
}
|
|
529
533
|
|
|
530
|
-
export function saveLanguage(code, { home = homedir() } = {}) {
|
|
534
|
+
export function saveLanguage(code, { home = homedir(), env = process.env } = {}) {
|
|
531
535
|
const normalized = normalizeLanguage(code);
|
|
532
536
|
if (!normalized) throw new Error(`Unknown language: ${code}`);
|
|
533
|
-
const file = languageFile(home);
|
|
534
|
-
mkdirSync(
|
|
537
|
+
const file = languageFile(home, env);
|
|
538
|
+
mkdirSync(terminalStateDirectory(home, env), { recursive: true });
|
|
535
539
|
writeFileSync(file, JSON.stringify({ language: normalized }, null, 2) + '\n');
|
|
536
540
|
return normalized;
|
|
537
541
|
}
|
|
@@ -6,8 +6,9 @@ export const OPENROUTER_API = 'https://openrouter.ai/api/v1';
|
|
|
6
6
|
export const MANAGEMENT_REF = 'OPENROUTER_MANAGEMENT_KEY';
|
|
7
7
|
|
|
8
8
|
const finite = value => {
|
|
9
|
+
if (!(typeof value === 'number' || typeof value === 'string' && value.trim())) return undefined;
|
|
9
10
|
const number = Number(value);
|
|
10
|
-
return
|
|
11
|
+
return Number.isFinite(number) ? number : undefined;
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
export class OpenRouterAccountError extends Error {
|
|
@@ -45,10 +46,8 @@ export function parseOpenRouterCredits(body) {
|
|
|
45
46
|
|
|
46
47
|
/** Remaining USD credit limit from a `/key` body; null when the key has no limit. */
|
|
47
48
|
export function parseOpenRouterKeyRemaining(body) {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const value = Number(remaining);
|
|
51
|
-
return Number.isFinite(value) ? Math.max(0, value) : null;
|
|
49
|
+
const value = finite(body?.data?.limit_remaining);
|
|
50
|
+
return value === undefined ? null : Math.max(0, value);
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
function keyOf(raw) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { OPENROUTER_API, parseOpenRouterCredits, parseOpenRouterKeyRemaining } from '../providers/openrouter-account.mjs';
|
|
2
3
|
|
|
3
4
|
export { parseOpenRouterCredits, parseOpenRouterKeyRemaining };
|
|
@@ -8,10 +9,10 @@ export { parseOpenRouterCredits, parseOpenRouterKeyRemaining };
|
|
|
8
9
|
// credits (`/credits`) are documented as management-only but are served to
|
|
9
10
|
// inference keys too; a key refused them falls back to its own remaining limit (`/key`).
|
|
10
11
|
const SOURCES = {
|
|
11
|
-
'deepseek-official': { env: 'DEEPSEEK_API_KEY', clock: true, requests: ({ key }) => [{ url: 'https://api.deepseek.com/user/balance', key,
|
|
12
|
+
'deepseek-official': { env: 'DEEPSEEK_API_KEY', clock: true, requests: ({ key }) => [{ url: 'https://api.deepseek.com/user/balance', key, source: 'account', parse: parseBalanceInfo }] },
|
|
12
13
|
openrouter: { env: 'OPENROUTER_API_KEY', managementEnv: 'OPENROUTER_MANAGEMENT_KEY', clock: false, requests: ({ key, managementKey }) => [
|
|
13
|
-
{ url: `${OPENROUTER_API}/credits`, key: managementKey
|
|
14
|
-
{ url: `${OPENROUTER_API}/key`, key, parse: parseOpenRouterKeyRemaining },
|
|
14
|
+
{ url: `${OPENROUTER_API}/credits`, key: managementKey || key, source: 'account', parse: body => usdBalance(parseOpenRouterCredits(body), 'account') },
|
|
15
|
+
{ url: `${OPENROUTER_API}/key`, key, source: 'key-limit', parse: body => usdBalance(parseOpenRouterKeyRemaining(body), 'key-limit') },
|
|
15
16
|
] },
|
|
16
17
|
};
|
|
17
18
|
export const BALANCE_PROVIDERS = Object.freeze(Object.keys(SOURCES));
|
|
@@ -21,16 +22,29 @@ const snapshots = new Map();
|
|
|
21
22
|
let clock = { anchor: null, skewMs: 0 };
|
|
22
23
|
const snapshotOf = provider => snapshots.get(provider) ?? { balance: null, fetchedAt: 0, pending: false };
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const usdBalance = (amount, source) => amount === null ? null : Object.freeze({ amount, currency: 'USD', source });
|
|
26
|
+
|
|
27
|
+
function parseBalanceInfo(body) {
|
|
28
|
+
if (body?.is_available === false) return null;
|
|
26
29
|
const infos = Array.isArray(body?.balance_infos) ? body.balance_infos : [];
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const balances = infos.flatMap(info => {
|
|
31
|
+
const value = info?.total_balance, currency = String(info?.currency ?? '').toUpperCase();
|
|
32
|
+
if (!(typeof value === 'number' || typeof value === 'string' && value.trim()) || !/^[A-Z]{3}$/.test(currency)) return [];
|
|
33
|
+
const amount = Number(value);
|
|
34
|
+
return Number.isFinite(amount) && amount >= 0 ? [Object.freeze({ amount, currency, source: 'account' })] : [];
|
|
35
|
+
});
|
|
36
|
+
return balances.find(info => info.currency === 'USD') ?? balances[0] ?? null;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
/** Numeric projection retained for callers that do not render currency or source. */
|
|
40
|
+
export function parseBalance(body) { return parseBalanceInfo(body)?.amount ?? null; }
|
|
41
|
+
export function balanceNow(provider = 'deepseek-official') { return balanceInfoNow(provider)?.amount ?? null; }
|
|
42
|
+
export function balanceInfoNow(provider = 'deepseek-official') { return snapshotOf(provider).balance; }
|
|
43
|
+
export function invalidateBalance(provider) { snapshots.delete(provider); }
|
|
44
|
+
export function formatBalance(balance, digits = 2) {
|
|
45
|
+
if (!balance) return '';
|
|
46
|
+
return balance.currency === 'USD' ? `$${balance.amount.toFixed(digits)}` : `${balance.amount.toFixed(digits)} ${balance.currency}`;
|
|
47
|
+
}
|
|
34
48
|
/** Clock anchored to the last DeepSeek balance response's `Date` header, else the local one. */
|
|
35
49
|
export function trustedNow() {
|
|
36
50
|
const local = Date.now();
|
|
@@ -42,40 +56,43 @@ export async function refreshBalance(options = {}) {
|
|
|
42
56
|
const provider = options.provider ?? 'deepseek-official';
|
|
43
57
|
const source = SOURCES[provider];
|
|
44
58
|
if (!source) return null;
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const managementKey = options.managementKey ?? (source.managementEnv ? process.env[source.managementEnv] : undefined);
|
|
59
|
+
const key = Object.hasOwn(options, 'key') ? options.key : process.env[source.env];
|
|
60
|
+
const managementKey = Object.hasOwn(options, 'managementKey') ? options.managementKey : source.managementEnv ? process.env[source.managementEnv] : undefined;
|
|
48
61
|
const requests = source.requests({ key, managementKey }).filter(request => request.key);
|
|
49
|
-
const
|
|
62
|
+
const identity = createHash('sha256').update(JSON.stringify(requests.map(({ url, key }) => [url, key]))).digest('hex');
|
|
63
|
+
let snapshot = snapshots.get(provider);
|
|
64
|
+
if (!snapshot || snapshot.identity !== identity) {
|
|
65
|
+
snapshot = { balance: null, fetchedAt: 0, pending: false, identity };
|
|
66
|
+
snapshots.set(provider, snapshot);
|
|
67
|
+
}
|
|
50
68
|
const now = Date.now();
|
|
51
|
-
|
|
52
|
-
if (snapshot.pending || snapshot.
|
|
69
|
+
if (requests.length === 0) return null;
|
|
70
|
+
if (snapshot.pending || snapshot.fetchedAt && now - snapshot.fetchedAt < CACHE_MS) return balanceNow(provider);
|
|
53
71
|
const retrySoon = () => Date.now() - (CACHE_MS - RETRY_MS);
|
|
54
|
-
if (requests.length === 0) { snapshots.set(provider, { ...snapshot, mode, fetchedAt: retrySoon(), pending: false }); return snapshot.balance; }
|
|
55
72
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
56
|
-
if (typeof fetchImpl !== 'function') return
|
|
57
|
-
|
|
73
|
+
if (typeof fetchImpl !== 'function') return balanceNow(provider);
|
|
74
|
+
snapshot.pending = true;
|
|
58
75
|
try {
|
|
59
76
|
let response, body, request;
|
|
60
77
|
for (request of requests) {
|
|
61
78
|
response = await fetchImpl(request.url, { headers: { Authorization: `Bearer ${request.key}`, Accept: 'application/json' } });
|
|
62
|
-
body = await response.json();
|
|
79
|
+
try { body = await response.json(); } catch { body = undefined; }
|
|
80
|
+
if (snapshots.get(provider) !== snapshot) return balanceNow(provider);
|
|
63
81
|
// A key refused this reading falls through to the next source.
|
|
64
82
|
if (response.ok || (response.status !== 401 && response.status !== 403)) break;
|
|
83
|
+
if (snapshot.balance?.source === request.source) snapshot.balance = null;
|
|
65
84
|
}
|
|
66
85
|
const header = source.clock ? response.headers?.get?.('date') : null;
|
|
67
86
|
const anchor = header ? Date.parse(header) : NaN;
|
|
68
87
|
const parsed = response.ok ? request.parse(body) : null;
|
|
69
88
|
if (Number.isFinite(anchor)) clock = { anchor, skewMs: anchor - Date.now() };
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
balance: parsed === null && response.ok ? null : parsed ?? snapshot.balance,
|
|
73
|
-
fetchedAt: parsed === null ? retrySoon() : Date.now(),
|
|
74
|
-
pending: false,
|
|
75
|
-
});
|
|
89
|
+
snapshot.balance = response.ok ? parsed : snapshot.balance?.source === request.source ? snapshot.balance : null;
|
|
90
|
+
snapshot.fetchedAt = parsed === null ? retrySoon() : Date.now();
|
|
76
91
|
} catch {
|
|
77
92
|
// A transient failure keeps the last known balance and retries sooner.
|
|
78
|
-
snapshots.
|
|
93
|
+
if (snapshots.get(provider) === snapshot) snapshot.fetchedAt = retrySoon();
|
|
94
|
+
} finally {
|
|
95
|
+
snapshot.pending = false;
|
|
79
96
|
}
|
|
80
|
-
return
|
|
97
|
+
return balanceNow(provider);
|
|
81
98
|
}
|
|
@@ -2,7 +2,7 @@ globalThis.__ModuleLoader__.load({
|
|
|
2
2
|
id: '@toddzheng024/dscode-desktop',
|
|
3
3
|
factory: require => {
|
|
4
4
|
const { createElement: h, useState, useEffect, useRef } = require('react');
|
|
5
|
-
const money = (cost, partial = false) => cost === null || partial && cost === 0 ? 'Unknown' : `$${cost.toFixed(4)}${partial ? '+' : ''
|
|
5
|
+
const money = (cost, partial = false, currency = 'USD') => cost === null || partial && cost === 0 ? 'Unknown' : (currency === 'USD' ? `$${cost.toFixed(4)}` : `${cost.toFixed(4)} ${currency}`) + (partial ? '+' : '');
|
|
6
6
|
const percent = value => value === null ? 'Unknown' : `${value.toFixed(1)}%`;
|
|
7
7
|
const speed = value => value === null ? 'Unknown' : `${value.toFixed(1)} tokens/s`;
|
|
8
8
|
const control = { padding: '7px 10px', border: '.5px solid var(--dsw-alias-border-l3, #8886)', borderRadius: 'var(--dsw-radius-md, 6px)', background: 'transparent', color: 'inherit', font: 'inherit' };
|
|
@@ -33,7 +33,7 @@ globalThis.__ModuleLoader__.load({
|
|
|
33
33
|
fact('Pending calls', state.pending ?? 'Unknown'), fact('Context estimate', percent(state.contextPercent)),
|
|
34
34
|
fact('Input cache share', percent(state.cachePercent)), fact('Smoothed request speed', speed(state.currentTps)),
|
|
35
35
|
fact('Session average speed', speed(state.averageTps)),
|
|
36
|
-
state.balance !== null && fact('Cached
|
|
36
|
+
state.balance !== null && state.balanceCurrency && fact(state.balanceSource === 'key-limit' ? 'Cached key spending limit' : 'Cached account balance', money(state.balance, false, state.balanceCurrency)),
|
|
37
37
|
state.subscription && fact('Cached subscription usage', state.subscription),
|
|
38
38
|
state.budget && fact('Configured cost budget', `${money(state.budget.spent)} / ${money(state.budget.limit)} · ${state.budget.state}`)),
|
|
39
39
|
h('p', { style: { fontSize: 12 } }, 'Unknown means a reading or price is unavailable. + marks a partial cost. Pending calls have not settled. Cached account figures may lag the provider.'),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { clientRequestSchema } from '@deepseek-ai/dsh-client-connection';
|
|
2
2
|
import { footerFor, goFooterFact, grokFooterFact } from './view.mjs';
|
|
3
|
-
import {
|
|
3
|
+
import { balanceInfoNow } from './balance.mjs';
|
|
4
4
|
import { canonicalProvider } from '../providers/catalog.mjs';
|
|
5
5
|
import { grokSubscriptionNow } from '../grok/billing.mjs';
|
|
6
6
|
import { goUsageNow } from '../opencode-go/usage.mjs';
|
|
@@ -13,12 +13,13 @@ export function desktopMetricsRequest(ctx, payload) {
|
|
|
13
13
|
if (!agent || ctx.agentPresets.composedPreset(agent.ctx) !== 'dscode') throw Error('Usage is available for an open DSCODE session.');
|
|
14
14
|
const route = agent.session.requestHeader()?.config ?? agent.options;
|
|
15
15
|
const provider = canonicalProvider(route.provider);
|
|
16
|
+
const balance = balanceInfoNow(provider);
|
|
16
17
|
return footerFor(agent.id, {}, 0, provider, 'en', (summary, context, _columns, rates) => ({
|
|
17
18
|
sessionId: agent.id, provider: route.provider, model: route.model,
|
|
18
19
|
cost: finite(summary.cost), partial: summary.unknown === true, calls: finite(summary.calls), pending: finite(summary.pending),
|
|
19
20
|
contextPercent: finite(context), cachePercent: finite(summary.cache),
|
|
20
21
|
currentTps: finite(rates.current), averageTps: finite(rates.average), requestActive: rates.active === true,
|
|
21
|
-
balance: finite(
|
|
22
|
+
balance: finite(balance?.amount), balanceCurrency: balance?.currency ?? null, balanceSource: balance?.source ?? null,
|
|
22
23
|
subscription: provider === 'grok' ? grokFooterFact(grokSubscriptionNow()) : provider === 'opencode-go' ? goFooterFact(goUsageNow()) : null,
|
|
23
24
|
turns: (summary.turns ?? []).slice(-50).reverse().map(turn => ({ turn: turn.turn, cost: finite(turn.cost), calls: turn.calls, partial: turn.unknown })),
|
|
24
25
|
totalTurns: summary.turns?.length ?? 0, unattributed: finite(summary.unattributed),
|
|
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { appendMetric } from './store.mjs';
|
|
4
4
|
import { estimateCost, priceVersionFor } from './pricing.mjs';
|
|
5
5
|
import { setMetricSource } from './view.mjs';
|
|
6
|
-
import { BALANCE_PROVIDERS, refreshBalance } from './balance.mjs';
|
|
6
|
+
import { BALANCE_PROVIDERS, invalidateBalance, refreshBalance } from './balance.mjs';
|
|
7
7
|
import { refreshOpenRouterModels } from '../openrouter/models.mjs';
|
|
8
8
|
import { REPLAY_KIND } from '../openrouter/wire.mjs';
|
|
9
9
|
import { providerSpec } from '../providers/catalog.mjs';
|
|
@@ -35,17 +35,22 @@ export function apply(ctx) {
|
|
|
35
35
|
// Remaining balance is best-effort decoration: resolve the key lazily (never
|
|
36
36
|
// inject the credentials service, so a missing one cannot fail startup), keep
|
|
37
37
|
// the request on a five-minute cache, and never let it reach the render path.
|
|
38
|
-
const
|
|
39
|
-
|
|
38
|
+
const generations = new Map();
|
|
39
|
+
let closed = false;
|
|
40
|
+
const refresh = (providers = BALANCE_PROVIDERS) => Promise.all(providers.map(async provider => {
|
|
41
|
+
const generation = (generations.get(provider) ?? 0) + 1;
|
|
42
|
+
generations.set(provider, generation);
|
|
40
43
|
try {
|
|
41
44
|
const spec = providerSpec(provider);
|
|
42
45
|
const secret = async ref => {
|
|
43
46
|
if (!ref) return undefined;
|
|
47
|
+
const credentials = ctx.get?.('credentials');
|
|
44
48
|
const resolved = await credentials?.resolve?.(ref);
|
|
45
49
|
return (typeof resolved === 'string' ? resolved : resolved?.value) || process.env[ref] || undefined;
|
|
46
50
|
};
|
|
47
51
|
// An OpenRouter management key, when stored, turns the balance into the account's credits.
|
|
48
52
|
const [key, managementKey] = await Promise.all([secret(spec.credentialRef), secret(spec.managementRef)]);
|
|
53
|
+
if (closed || generations.get(provider) !== generation) return;
|
|
49
54
|
await refreshBalance({ provider, key, managementKey });
|
|
50
55
|
// The listing needs no key, but only a user with an OpenRouter key uses it; the adapter waits for it before its first call.
|
|
51
56
|
if (provider === 'openrouter' && key) await refreshOpenRouterModels({ home });
|
|
@@ -53,10 +58,18 @@ export function apply(ctx) {
|
|
|
53
58
|
/* balance stays unknown */
|
|
54
59
|
}
|
|
55
60
|
}));
|
|
61
|
+
ctx.on('credentials/reference-updated', ref => {
|
|
62
|
+
const providers = BALANCE_PROVIDERS.filter(provider => {
|
|
63
|
+
const spec = providerSpec(provider);
|
|
64
|
+
return ref === spec.credentialRef || ref === spec.managementRef;
|
|
65
|
+
});
|
|
66
|
+
for (const provider of providers) invalidateBalance(provider);
|
|
67
|
+
return refresh(providers);
|
|
68
|
+
});
|
|
56
69
|
refresh();
|
|
57
70
|
const balanceTimer = setInterval(refresh, 5 * 60 * 1000);
|
|
58
71
|
if (typeof balanceTimer.unref === 'function') balanceTimer.unref();
|
|
59
|
-
ctx.effect(() => () => clearInterval(balanceTimer));
|
|
72
|
+
ctx.effect(() => () => { closed = true; clearInterval(balanceTimer); for (const provider of BALANCE_PROVIDERS) invalidateBalance(provider); });
|
|
60
73
|
const record = (id, entry) => { try { appendMetric(home, id, entry); } catch { ctx.logger.warn('Session cost telemetry could not be saved.'); } };
|
|
61
74
|
ctx.on('llm/stream', async function* (options, next) {
|
|
62
75
|
// Plugin calls made for a session carry no sessionId on the wire; they are charged through the async context.
|
|
@@ -2,7 +2,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
|
2
2
|
import { readMetrics } from './store.mjs';
|
|
3
3
|
import { t } from '../i18n/messages.mjs';
|
|
4
4
|
import { estimateCost, peakEmoji } from './pricing.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { balanceInfoNow, formatBalance, trustedNow } from './balance.mjs';
|
|
6
6
|
import { grokSubscriptionNow } from '../grok/billing.mjs';
|
|
7
7
|
import { goUsageNow } from '../opencode-go/usage.mjs';
|
|
8
8
|
import { canonicalProvider } from '../providers/catalog.mjs';
|
|
@@ -166,7 +166,7 @@ function footerFigures(metrics, context, rates, locale = 'en', provider = 'deeps
|
|
|
166
166
|
const ctx = figure(Number.isFinite(context) ? `${Math.round(context)}%` : '--', FIGURE.percent);
|
|
167
167
|
const cache = figure(metrics.cache === null ? '--' : `${metrics.cache.toFixed(1)}%`, FIGURE.share);
|
|
168
168
|
// The balance belongs to the provider serving the route; only DeepSeek's official one bills by a peak window.
|
|
169
|
-
const balance =
|
|
169
|
+
const balance = balanceInfoNow(provider);
|
|
170
170
|
const spend = metrics.unknown && metrics.cost === 0 ? '--' : `$${metrics.cost.toFixed(2)}${metrics.unknown ? '+' : ''}${metrics.pending ? '…' : ''}`;
|
|
171
171
|
// The money is the one live figure whose digits are not reserved: they cross a
|
|
172
172
|
// column a handful of times per session, and reserving them would evict a
|
|
@@ -174,7 +174,7 @@ function footerFigures(metrics, context, rates, locale = 'en', provider = 'deeps
|
|
|
174
174
|
// provider cannot report is left out instead of parked as `$--`.
|
|
175
175
|
const dollars = provider === 'grok' ? grokFooterFact(grokSubscriptionNow(), locale)
|
|
176
176
|
: provider === 'opencode-go' ? goFooterFact(goUsageNow(), locale)
|
|
177
|
-
: spend + (balance === null ? '' : ' /
|
|
177
|
+
: spend + (balance === null ? '' : ' / ' + (balance.source === 'key-limit' ? label('footer.keyLimit') + ' ' : '') + formatBalance(balance)) + (provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : '');
|
|
178
178
|
// The budget slot rides the money figure: it answers "how much of the session's
|
|
179
179
|
// limit is spent", which is a fact about the cost, not a second cost. A session
|
|
180
180
|
// with no limit renders exactly as before, so the footer only grows by choice.
|