dsh-task-time-cost 0.0.0-stage → 0.2.8-beta.2

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 (70) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/COMPATIBILITY.md +26 -0
  3. package/CONTRIBUTING.md +11 -0
  4. package/LICENSE +21 -0
  5. package/README.en.md +60 -0
  6. package/README.md +59 -2
  7. package/THIRD_PARTY_NOTICES.md +113 -0
  8. package/cordis.patch.yml +4 -0
  9. package/data/baselines.json +12 -0
  10. package/data/defaults.json +21 -0
  11. package/data/models-dev-prices.json +1 -0
  12. package/data/prices.json +7 -0
  13. package/data/sources.json +15 -0
  14. package/docs/public/DETAIL.md +1 -0
  15. package/docs/public/DETAIL.zh.md +1 -0
  16. package/docs/public/acceptance.md +26 -0
  17. package/docs/public/guide.en.md +33 -0
  18. package/docs/public/guide.zh.md +35 -0
  19. package/docs/public/preview.svg +1 -0
  20. package/docs/public/privacy.md +13 -0
  21. package/docs/public/publishing.md +9 -0
  22. package/icon.svg +1 -0
  23. package/lib/client.js +175 -0
  24. package/lib/index.js +22948 -0
  25. package/lib/index.js.map +7 -0
  26. package/locale/en.json +7 -0
  27. package/locale/zh.json +7 -0
  28. package/package.json +122 -4
  29. package/scripts/import-public-prices.mjs +35 -0
  30. package/src/client/INTEGRATION.md +61 -0
  31. package/src/client/config.ts +24 -0
  32. package/src/client/details.tsx +126 -0
  33. package/src/client/format.ts +218 -0
  34. package/src/client/index.tsx +225 -0
  35. package/src/client/locale.ts +353 -0
  36. package/src/client/management.tsx +246 -0
  37. package/src/client/placement.ts +26 -0
  38. package/src/client/remote.ts +59 -0
  39. package/src/client/store.ts +210 -0
  40. package/src/client/styles.ts +94 -0
  41. package/src/client/use-panel-placement.ts +54 -0
  42. package/src/client-entry.ts +12 -0
  43. package/src/host/config-entry.ts +47 -0
  44. package/src/host/config.ts +85 -0
  45. package/src/host/engine.ts +580 -0
  46. package/src/host/facts.ts +170 -0
  47. package/src/host/forecast-cost.ts +16 -0
  48. package/src/host/index.ts +269 -0
  49. package/src/host/record-schema.ts +175 -0
  50. package/src/host/repository.ts +167 -0
  51. package/src/host/runtime.ts +99 -0
  52. package/src/shared/ai-plan.ts +45 -0
  53. package/src/shared/baseline-selection.ts +43 -0
  54. package/src/shared/baselines.ts +135 -0
  55. package/src/shared/call-size.ts +32 -0
  56. package/src/shared/config.ts +65 -0
  57. package/src/shared/forecast.ts +91 -0
  58. package/src/shared/index.ts +12 -0
  59. package/src/shared/ledger.ts +38 -0
  60. package/src/shared/money.ts +46 -0
  61. package/src/shared/performance.ts +216 -0
  62. package/src/shared/pricing.ts +293 -0
  63. package/src/shared/public-prices.ts +57 -0
  64. package/src/shared/remote-contract.ts +72 -0
  65. package/src/shared/retained-time.ts +14 -0
  66. package/src/shared/time.ts +207 -0
  67. package/src/shared/types.ts +66 -0
  68. package/src/shared/usage.ts +232 -0
  69. package/src/shared/wire.ts +134 -0
  70. package/src/shared/workload.ts +265 -0
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Task time and cost estimator",
4
+ "description": "A reference for the wait: open the clock beside Token statistics for remaining time, elapsed time, Token budgets and cost references. Settings and calibration shows local route samples. No separate estimator call, saved message bodies or statistics uploads by default. Public list prices are not channel invoices; missing stays unknown. Preview for Windows / DSH 0.2.2-rc.2; exact native running-line placement is unsupported.",
5
+ "detail": "docs/public/DETAIL.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "任务耗时与费用预估",
4
+ "description": "让等待有个参考:在 Token 统计旁点击时钟,查看剩余时间、实际用时、Token 预算和费用参考;在“设置与校准”查看本地路由样本。无需另起估价模型,默认不保存正文或上传统计。公共目录价不代表渠道账单,缺价保持未知。当前为 Windows / DSH 0.2.2-rc.2 公开预览版;精确原生运行状态行内位置尚不支持。",
5
+ "detail": "docs/public/DETAIL.zh.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,124 @@
1
1
  {
2
2
  "name": "dsh-task-time-cost",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.8-beta.2",
4
+ "type": "module",
5
+ "description": "Remaining-time, Token budget and reference cost estimates for DeepSeek Harness, calibrated locally without a separate model call.",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=24",
9
+ "dsh": "0.2.2-rc.2"
10
+ },
11
+ "exports": {
12
+ ".": "./lib/index.js",
13
+ "./client": "./lib/client.js",
14
+ "./locale/*.json": "./locale/*.json",
15
+ "./icon": "./icon.svg"
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "src",
20
+ "cordis.patch.yml",
21
+ "locale",
22
+ "icon.svg",
23
+ "data",
24
+ "scripts/import-public-prices.mjs",
25
+ "README.md",
26
+ "README.en.md",
27
+ "COMPATIBILITY.md",
28
+ "CHANGELOG.md",
29
+ "LICENSE",
30
+ "THIRD_PARTY_NOTICES.md",
31
+ "docs/public",
32
+ "CONTRIBUTING.md"
33
+ ],
34
+ "scripts": {
35
+ "build": "node scripts/build.mjs",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "node --test tests/**/*.test.ts",
38
+ "check": "npm run typecheck && npm test && npm run build",
39
+ "pack:check": "npm pack --dry-run --ignore-scripts"
40
+ },
41
+ "keywords": [
42
+ "dsh-plugin",
43
+ "deepseek-harness",
44
+ "task-estimation",
45
+ "eta",
46
+ "cost-estimation",
47
+ "token-usage",
48
+ "llm-observability",
49
+ "dsh",
50
+ "dsh-bundle",
51
+ "local-first",
52
+ "developer-tools"
53
+ ],
54
+ "dsh": {
55
+ "bundle": {
56
+ "patch": "./cordis.patch.yml"
57
+ },
58
+ "client": {
59
+ "platform": "web",
60
+ "immediately": true,
61
+ "external": [
62
+ "react"
63
+ ],
64
+ "inject": [
65
+ "@deepseek-ai/dsh-client-ui-conversation"
66
+ ]
67
+ }
68
+ },
69
+ "peerDependencies": {
70
+ "@deepseek-ai/cordis": "4.1.0",
71
+ "@deepseek-ai/dsh-typert-protocol": "0.2.2-rc.2",
72
+ "@deepseek-ai/schemastery": "3.19.0"
73
+ },
74
+ "devDependencies": {
75
+ "@deepseek-ai/cordis": "4.1.0",
76
+ "@deepseek-ai/dsh-agent": "0.2.2-rc.2",
77
+ "@deepseek-ai/dsh-api-gateway": "0.2.2-rc.2",
78
+ "@deepseek-ai/dsh-api-session-controller": "0.2.2-rc.2",
79
+ "@deepseek-ai/dsh-client-connection": "0.2.2-rc.2",
80
+ "@deepseek-ai/dsh-client-locale": "0.2.2-rc.2",
81
+ "@deepseek-ai/dsh-client-ui-chat": "0.2.2-rc.2",
82
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.2-rc.2",
83
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.2-rc.2",
84
+ "@deepseek-ai/dsh-config-editor": "0.2.2-rc.2",
85
+ "@deepseek-ai/dsh-llm": "0.2.2-rc.2",
86
+ "@deepseek-ai/dsh-session": "0.2.2-rc.2",
87
+ "@deepseek-ai/dsh-session-projection": "0.2.2-rc.2",
88
+ "@deepseek-ai/dsh-storage-domain": "0.2.2-rc.2",
89
+ "@deepseek-ai/dsh-storage-json": "0.2.2-rc.2",
90
+ "@deepseek-ai/dsh-system-prompt": "0.2.2-rc.2",
91
+ "@deepseek-ai/dsh-token-meter": "0.2.2-rc.2",
92
+ "@deepseek-ai/dsh-tools": "0.2.2-rc.2",
93
+ "@deepseek-ai/dsh-typert-protocol": "0.2.2-rc.2",
94
+ "@deepseek-ai/dsh-typert-registry": "0.2.2-rc.2",
95
+ "@deepseek-ai/dsh-user-approval": "0.2.2-rc.2",
96
+ "@deepseek-ai/dsh-user-questions": "0.2.2-rc.2",
97
+ "@deepseek-ai/schemastery": "3.19.0",
98
+ "@types/node": "^24.0.0",
99
+ "@types/react": "^19.0.0",
100
+ "@types/react-dom": "19.2.0",
101
+ "esbuild": "0.28.2",
102
+ "react": "19.2.0",
103
+ "react-dom": "19.2.0",
104
+ "typescript": "~5.9.3",
105
+ "zod": "4.6.6"
106
+ },
107
+ "author": {
108
+ "name": "leicheng42",
109
+ "url": "https://github.com/leicheng42"
110
+ },
111
+ "repository": {
112
+ "type": "git",
113
+ "url": "git+https://github.com/leicheng42/dsh-task-time-cost.git"
114
+ },
115
+ "homepage": "https://github.com/leicheng42/dsh-task-time-cost#readme",
116
+ "bugs": {
117
+ "url": "https://github.com/leicheng42/dsh-task-time-cost/issues"
118
+ },
119
+ "publishConfig": {
120
+ "access": "public",
121
+ "registry": "https://registry.npmjs.org",
122
+ "tag": "beta"
123
+ }
124
+ }
@@ -0,0 +1,35 @@
1
+ // Explicit maintainer import of the public models.dev API. The plugin never
2
+ // fetches it in the background or reads a gateway's settings/credentials.
3
+ import { readFile, writeFile } from 'node:fs/promises';
4
+ import { createHash } from 'node:crypto';
5
+ const input = process.argv[2];
6
+ if (!input) throw new Error('Usage: node scripts/import-public-prices.mjs <models.dev api.json>');
7
+ const bytes = await readFile(input);
8
+ const api = JSON.parse(bytes.toString('utf8'));
9
+ const rows = [];
10
+ let unsupported = 0;
11
+ const prices = cost => {
12
+ const result = {};
13
+ for (const [source, target] of [['input', 'inputTokens'], ['output', 'outputTokens'], ['cache_read', 'cacheReadTokens'], ['cache_write_5m', 'cacheWrite5mTokens'], ['cache_write_1h', 'cacheWrite1hTokens']]) {
14
+ const n = cost?.[source];
15
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0) result[target] = n.toLocaleString('en-US', { useGrouping: false, maximumFractionDigits: 12 });
16
+ }
17
+ return result;
18
+ };
19
+ for (const [provider, info] of Object.entries(api)) {
20
+ for (const [model, value] of Object.entries(info.models ?? {})) {
21
+ const rates = prices(value.cost);
22
+ if (rates.inputTokens === undefined || rates.outputTokens === undefined) continue;
23
+ const tiers = value.cost?.tiers ?? [];
24
+ if (!Array.isArray(tiers) || tiers.some(t => t.tier?.type !== 'context' || !Number.isSafeInteger(t.tier.size) || t.tier.size <= 0)) { unsupported++; continue; }
25
+ const rules = tiers.map((t, index) => ({ id: `context-${index}`, when: { promptTokensAbove: t.tier.size }, mode: 'whole-request', prices: prices(t) }));
26
+ // Older API entries provide the same legacy condition without tiers.
27
+ if (!tiers.length && value.cost?.context_over_200k) rules.push({ id: 'context-200k', when: { promptTokensAbove: 200_000 }, mode: 'whole-request', prices: prices(value.cost.context_over_200k) });
28
+ rows.push({ provider, model, prices: rates, ...(rules.length ? { rules } : {}) });
29
+ }
30
+ }
31
+ rows.sort((a, b) => `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`, 'en'));
32
+ const retrievedAt = new Date().toISOString();
33
+ const data = { source: 'https://models.dev/api.json', license: 'MIT', retrievedAt, version: `models.dev-${retrievedAt.slice(0, 10)}-${createHash('sha256').update(bytes).digest('hex').slice(0, 12)}`, rows };
34
+ await writeFile(new URL('../data/models-dev-prices.json', import.meta.url), JSON.stringify(data) + '\n');
35
+ console.log(JSON.stringify({ rows: rows.length, unsupportedTierModels: unsupported, version: data.version, retrievedAt }));
@@ -0,0 +1,61 @@
1
+ # Client 对接与验证报告
2
+
3
+ 范围:仅 `src/client`、`tests/client`。对应共享 task-2。业务 DTO 以 `src/shared/wire.ts` 为准,Client 不修改 DTO、Host 或根构建文件。
4
+
5
+ ## 入口与封装
6
+
7
+ `src/client/index.tsx` 是普通 TSX 模块,导出:
8
+
9
+ ```ts
10
+ export const inject = ['slots', 'locale', 'remote'];
11
+ export function applyClient(ctx: Context): void;
12
+ export const apply = applyClient;
13
+ ```
14
+
15
+ lead 的 Client factory wrapper 必须先挂载 `remoteContribution`,再调用 `applyClient(ctx)`。唯一 Cordis 引用为真实 `@deepseek-ai/cordis` 的 `import type { Context }`;无包级 remote 读取,无 Harness Client 包运行时导入。Client factory 协议与包 id 由 lead 的 esbuild wrapper 处理。
16
+
17
+ 在 apply 内读取 `ctx.get('remote').taskEstimator`、slots、locale。locale 注册 `task-estimate` 命名空间的 `zh` / `en`;界面随 locale revision 更新。注册:
18
+
19
+ | 槽 | id | order | 数据位置 |
20
+ |---|---|---:|---|
21
+ | `conversation.composer.dock` | `task-estimate` | 2 | `sessionId` 与 `useProjection('taskEstimator')` |
22
+ | `conversation.chat.turnTail` | `task-estimate` | 10 | `turn.turn` 与 `seq` |
23
+
24
+ 投影业务值直接为 `{ revision: number, currentTurn: number | null } | undefined`。当前回合随 revision 立即刷新;历史尾部按明确 turn 读取,避免每个新事件刷新所有历史回合。
25
+
26
+ ## RPC
27
+
28
+ 所有请求处理外层 `RemoteResult`;管理请求还检查内层 `ManagementResult.ok`。Host 拒绝保留 code/message,网络异常、业务错误、15 秒超时均可见并可重试。卸载或取消会 abort,请求晚到不会覆盖新状态。
29
+
30
+ | 方法 / action | payload / 参数 | 结果处理 |
31
+ |---|---|---|
32
+ | `snapshot(sessionId, turn, signal)` | session id、明确 turn 或 null | 检查 RemoteResult、EstimatorView.error、snapshot.error |
33
+ | `manage('read', '', signal)` | 空字符串 | data.config / history / ProfileSummary[] / diagnostics / storage |
34
+ | `manage('saveConfig', json, signal)` | 完整配置 JSON 字符串 | 本地对象/有限数字检查,完整业务校验由 Host 执行 |
35
+ | `manage('importBaselines', content, signal)` | JSON 或 CSV 原文 | Host 识别格式,界面显示返回失败 |
36
+ | `manage('export', '', signal)` | 空字符串 | exported 字符串变为 JSON Blob 下载,组件释放 URL |
37
+ | `manage('resetCalibration', '', signal)` | 空字符串 | 用户先展开本地二次确认,再发 RPC |
38
+
39
+ 重置提示与 Host 已确认:重置本地性能、工作量学习;保留任务历史、价表/配置、导入基准及 DSH 会话。
40
+
41
+ ## 显示与生命周期
42
+
43
+ - dock 空闲时显示低干扰入口;运行时主行显示工作耗时、预计剩余、预计总费用;turnTail 显示结束状态、耗时及已记录费用。
44
+ - 缺价、partial、unavailable 明确说明无法完整估算;已知金额单列,不将局部费用当总额或上限。金额直接格式化十进制字符串,不经过浮点数,不同币种分开。
45
+ - 展开包含 Token 分类/覆盖、性能与来源、样本、初始对照、原因及管理。推理/缓存分类不重复求和。常见 reason code 有中英文说明,未知 code 保留可见。
46
+ - 一个 SnapshotStore 共享 session/turn 请求、inflight 和轮询;运行/排队默认 5 秒轮询,等待用户/结束停止轮询;投影立即更新以覆盖短任务。
47
+ - 独立共享本地时钟默认 1 秒更新文字,不发 RPC。等待用户时冻结工作计时和剩余区间,单独累计暂停时间。重置所有 timer/subscription/AbortController 由组件或 Cordis effect 持有。
48
+ - 所有容器/控件继承字体,颜色只使用已核验 `--dsw-alias-*` tokens。原生 details/summary、带 label 的表单、键盘 focus、真实 pending/error、触屏较大点击区和 reduced-motion 支持均在真实槽组件中实现。
49
+
50
+ ## 验证
51
+
52
+ 2026-11-12:
53
+
54
+ 1. `node --test tests/client/*.test.ts`:**21 passed, 0 failed**(最后一轮约 291ms)。
55
+ 2. Client 入口与三个 test 文件的 scoped 严格 TypeScript 检查:**通过**。使用根 tsconfig 同等 target / NodeNext / strict / skipLibCheck / JSX React 参数,引用真实 Cordis Context,未改根配置。
56
+ 3. esbuild `bundle:true, platform:'browser', format:'cjs', jsx:'transform', external:['react'], write:false`:**通过**。metafile 断言唯一外部依赖是 `react`;没有写构建产物。
57
+ 4. 静态运行时 import 检查:只有 React;Cordis 仅 type import;没有 `any`、Harness Client 运行时依赖、预览 HTML、mock shell 或 React/DOM 仿真。
58
+
59
+ 21 个测试覆盖:零值与 unknown、区间合法性、亚秒延迟、极小金额/大整数精度、多币种、缺价/partial 不假上限、暂停/结束本地计时、中英 key 一致性、配置 JSON 对象校验、RemoteResult 和业务双层失败、原文 payload、超时与 abort、无 message 时仍显示错误 code、共享请求、投影合并和短任务、首次晚到投影、5 秒运行轮询、卸载后过期响应保护、本地时钟不发网络请求与清理。
60
+
61
+ 当前环境 Client Inspect 无页面响应;既有 browser control ENOENT。槽与 locale 使用 lead 已读取的精确合同,TurnLocation 与 locale ids 又由官方固定版本 npm 声明核对。**本报告不宣称真实页面视觉验证通过**。实际 factory 安装/槽渲染、明暗主题、窄屏、完整配置操作及下载交互由 lead 的真实宿主集成验证继续完成。
@@ -0,0 +1,24 @@
1
+ import type { MessageKey } from './locale.ts';
2
+
3
+ export const configFields: ReadonlyArray<{ key: string; label: MessageKey; min: number; max?: number; step: number }> = [
4
+ { key: 'performanceAlpha', label: 'performanceAlpha', min: 0.01, max: 1, step: 0.01 },
5
+ { key: 'workloadAlpha', label: 'workloadAlpha', min: 0.01, max: 1, step: 0.01 },
6
+ { key: 'predictionRefreshMs', label: 'predictionRefreshMs', min: 1, step: 1 },
7
+ { key: 'timerRefreshMs', label: 'timerRefreshMs', min: 1, step: 1 },
8
+ { key: 'historyRetentionDays', label: 'historyRetentionDays', min: 1, step: 1 },
9
+ { key: 'profileStaleDays', label: 'profileStaleDays', min: 1, step: 1 },
10
+ { key: 'minConditionalSamples', label: 'minConditionalSamples', min: 1, step: 1 },
11
+ { key: 'heuristicFirstTextMs', label: 'heuristicFirstTextMs', min: 0, step: 1 },
12
+ { key: 'heuristicVisibleTps', label: 'heuristicVisibleTps', min: 0.01, step: 0.1 },
13
+ { key: 'heuristicTotalCallMs', label: 'heuristicTotalCallMs', min: 0, step: 1 },
14
+ ];
15
+
16
+ export function parseConfiguration(text: string): Record<string, unknown> {
17
+ const value: unknown = JSON.parse(text);
18
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('CONFIG_OBJECT_REQUIRED');
19
+ return value as Record<string, unknown>;
20
+ }
21
+
22
+ export function numericFields(config: Record<string, unknown>): Record<string, string> {
23
+ return Object.fromEntries(configFields.map(({ key }) => [key, typeof config[key] === 'number' ? String(config[key]) : '']));
24
+ }
@@ -0,0 +1,126 @@
1
+ import * as React from 'react';
2
+ import type { EstimateSnapshot } from '../shared/wire.ts';
3
+ import { costSummary, formatCoverage, formatDuration, formatEta, formatLatency, formatMoneyRanges, formatNumber, formatOrigin, formatTaskType, formatTokenRange, localTiming } from './format.ts';
4
+ import type { MessageKey, Translate } from './locale.ts';
5
+
6
+ function Facts({ rows }: { rows: ReadonlyArray<readonly [string, React.ReactNode]> }) {
7
+ return <dl className="dte-facts">{rows.map(([label, value], index) => <React.Fragment key={`${label}-${index}`}><dt>{label}</dt><dd>{value}</dd></React.Fragment>)}</dl>;
8
+ }
9
+
10
+ const reasonKeys: Record<string, MessageKey> = {
11
+ 'engineering-assumption': 'reasonHeuristic', 'engineering-workload-template': 'reasonWorkloadTemplate',
12
+ 'local-workload-ewma': 'reasonLocalWorkload', 'public-endpoint-reference': 'reasonPublicEndpoint',
13
+ 'reference-range-not-calibrated-confidence': 'reasonReferenceRange', 'conditional-samples-insufficient': 'reasonFewSamples',
14
+ 'reference-range-exhausted-reassessing': 'reasonReassessing', 'output-budget-exhausted-task-still-running': 'reasonBudgetExhausted',
15
+ 'waiting-for-model-response': 'reasonWaitingModel', 'mixed-route-upstream-unknown': 'reasonMixedRoute',
16
+ 'same-provider-model-fallback': 'reasonRouteFallback', 'visible-speed-still-prior': 'reasonSpeedPrior',
17
+ 'stale-profile-retained-for-audit': 'reasonStale', 'reasoning-mode-unknown': 'reasonReasoningUnknown',
18
+ 'no-matching-price': 'reasonNoPrice', 'billing-identity-unknown': 'reasonBillingUnknown',
19
+ 'future-input-cache-split-unknown': 'reasonCacheUnknown', 'future-reasoning-budget-unknown': 'reasonFutureReasoning',
20
+ 'public-reference-price-not-provider-bill': 'reasonReferencePrice', 'user-explicit-same-as-reference': 'reasonSameAs',
21
+ 'price-multiplier-condition-unknown': 'reasonPriceCondition', 'forecast-scenario-reference': 'reasonForecastScenario',
22
+ 'text-stage-no-repeat-wait': 'reasonNoRepeatWait', 'reasoning-already-in-first-text-wait': 'reasonReasoningIncluded',
23
+ 'first-text-plus-body': 'reasonFirstTextBody', 'same-mode-total-call-fallback': 'reasonTotalFallback',
24
+ 'conditional-observed-remaining-range': 'reasonConditional', 'classification:heuristic': 'reasonClassification',
25
+ 'explicit-artifact-count': 'reasonArtifactCount', 'observable-plan-steps': 'reasonPlanSteps',
26
+ 'file-batch-assumption': 'reasonFileBatch', 'explicit-output-token-budget': 'reasonOutputBudget',
27
+ 'requested-length-token-conversion:heuristic': 'reasonLengthConversion', 'observable-attachments': 'reasonAttachments',
28
+ 'no-classification-match': 'reasonNoClassification', 'task-scope-changed': 'reasonScopeChanged',
29
+ 'workload-data-incomplete': 'reasonIncompleteWorkload', 'request-completed': 'reasonRequestCompleted',
30
+ 'endpoint-identity-unknown': 'reasonEndpointUnknown', 'endpoint-unresolved-mixed-route': 'reasonEndpointUnknown',
31
+ 'continuation-local-task-history': 'reasonContinuationHistory', 'continuation-history-insufficient': 'reasonContinuationUnknown',
32
+ 'ai-workload-plan-reference': 'reasonAiPlan',
33
+ };
34
+ export function reasonText(reason: string, t: Translate): string {
35
+ if (reasonKeys[reason]) return t(reasonKeys[reason]);
36
+ if (reason.startsWith('classification-rule:')) return t('reasonClassificationRule', { value: reason.slice('classification-rule:'.length) });
37
+ return t('reasonCode', { value: reason });
38
+ }
39
+
40
+ export function EstimateDetails({ snapshot, receivedAt, now, locale, t }: { snapshot: EstimateSnapshot; receivedAt: number; now: number; locale: string; t: Translate }) {
41
+ const timing = localTiming(snapshot, receivedAt, now);
42
+ const performance = snapshot.performance;
43
+ const text = (value: string | null | undefined) => value || t('unknown');
44
+ const number = (value: number | null | undefined, fraction = 0) => formatNumber(value, locale, t, fraction);
45
+ const referencePrice = snapshot.reasons.includes('public-reference-price-not-provider-bill');
46
+ const knownCost = costSummary(snapshot, locale, t);
47
+ const forecast = snapshot.forecastCostStatus === 'free' ? t('free') : formatMoneyRanges(snapshot.estimatedTotalCost, locale, t);
48
+ const partial = snapshot.forecastCostStatus === 'partial' || snapshot.forecastCostStatus === 'unavailable' || snapshot.observedCostStatus === 'partial' || snapshot.observedCostStatus === 'unpriced';
49
+
50
+ return <section aria-label={t('details')}>
51
+ <p className="dte-muted dte-small">{t('scope')}</p>
52
+ <Facts rows={[
53
+ [t('activeTime'), formatDuration(timing.activeMs, t)], [t('pausedTime'), formatDuration(timing.pausedMs, t)],
54
+ [t('wallTime'), formatDuration(timing.wallMs, t)], [t('remaining'), formatEta(timing.remaining, t)],
55
+ [t('estimatedTotal'), formatEta(snapshot.estimatedTotalMs, t)],
56
+ [t('tokenForecast'), formatTokenRange(snapshot.estimatedTotalTokens, locale, t)],
57
+ [t('tokenRemainingOutput'), formatTokenRange(snapshot.estimatedRemainingOutputTokens, locale, t)],
58
+ [t(referencePrice ? 'referenceCost' : 'knownCost'), costSummary(snapshot, locale, t)],
59
+ [t('estimatedCost'), snapshot.forecastCostStatus === 'partial' ? t('partialKnown', { value: forecast }) : forecast],
60
+ [t('observedCostStatus'), formatCoverage(snapshot.observedCostStatus, t)], [t('forecastCostStatus'), formatCoverage(snapshot.forecastCostStatus, t)],
61
+ ]} />
62
+ {snapshot.estimatedRemainingOutputTokens != null && <p className="dte-muted dte-small">{t('tokenRemainingNote')}</p>}
63
+ {partial && <p className="dte-warn dte-small">{t('incompleteCostDetail')}</p>}
64
+ {snapshot.unpricedCalls > 0 && <p className="dte-muted dte-small">{t('noPrice', { count: number(snapshot.unpricedCalls) })}</p>}
65
+ {snapshot.incompleteForecastUnits > 0 && <p className="dte-muted dte-small">{t('incompleteForecast', { count: number(snapshot.incompleteForecastUnits) })}</p>}
66
+
67
+ <details className="dte-subsection">
68
+ <summary>{t('tokenTotal')}</summary>
69
+ <Facts rows={[
70
+ [t('tokenTotal'), number(snapshot.observedTokens)], [t('tokenCoverage'), formatCoverage(snapshot.observedTokenStatus, t)],
71
+ [t('tokenInput'), number(snapshot.tokens.input)], [t('tokenCacheRead'), number(snapshot.tokens.cacheRead)],
72
+ [t('tokenCacheWrite'), number(snapshot.tokens.cacheWrite)], [t('tokenOutput'), number(snapshot.tokens.output)],
73
+ [t('tokenReasoning'), number(snapshot.tokens.reasoning)],
74
+ ]} />
75
+ <p className="dte-muted dte-small">{t('tokensNote')}</p>
76
+ </details>
77
+ <details className="dte-subsection">
78
+ <summary>{t('basis')}</summary>
79
+ <Facts rows={[
80
+ [t('origin'), formatOrigin(snapshot.estimateOrigin, t)], [t('taskType'), formatTaskType(snapshot.taskType, t)],
81
+ [t('localSamples'), number(snapshot.localPerformanceSamples)], [t('workloadSamples'), number(snapshot.workloadSamples)],
82
+ [t('calls'), t('callCounts', { completed: number(snapshot.calls.completed), active: number(snapshot.calls.active), failed: number(snapshot.calls.failed) })],
83
+ [t('tools'), t('toolCounts', { completed: number(snapshot.tools.completed), active: number(snapshot.tools.active) })],
84
+ [t('toolTime'), formatDuration(snapshot.tools.workMs, t)], [t('algorithm'), snapshot.algorithmVersion],
85
+ ]} />
86
+ </details>
87
+ <details className="dte-subsection">
88
+ <summary>{t('performance')}</summary>
89
+ {!performance ? <p className="dte-muted">{t('notAvailable')}</p> : <>
90
+ <Facts rows={[
91
+ [t('provider'), text(performance.providerId)], [t('model'), text(performance.modelId)], [t('endpoint'), text(performance.endpointFingerprint)],
92
+ [t('requestMode'), text(performance.requestMode)], [t('reasoningEffort'), text(performance.reasoningEffort)], [t('serviceTier'), text(performance.serviceTier)],
93
+ [t('firstContent'), formatLatency(performance.firstContentMs, locale, t)], [t('firstText'), formatLatency(performance.firstTextMs, locale, t)],
94
+ [t('visibleSpeed'), performance.visibleTps == null ? t('unknown') : t('tokensPerSecond', { value: number(performance.visibleTps, 2) })],
95
+ [t('callDuration'), formatLatency(performance.totalCallMs, locale, t)], [t('origin'), text(performance.source)],
96
+ [t('performanceTrust'), performance.trust === 'observed' ? t('performanceObserved') : t('performanceReference')],
97
+ [t('firstContentSource'), performance.metricOrigins ? formatOrigin(performance.metricOrigins.firstContent, t) : t('unknown')],
98
+ [t('firstTextSource'), performance.metricOrigins ? formatOrigin(performance.metricOrigins.firstText, t) : t('unknown')],
99
+ [t('speedSource'), performance.metricOrigins ? formatOrigin(performance.metricOrigins.speed, t) : t('unknown')],
100
+ [t('totalSource'), performance.metricOrigins ? formatOrigin(performance.metricOrigins.total, t) : t('unknown')],
101
+ ]} />
102
+ {performance.stale && <p className="dte-warn">{t('staleProfile')}</p>}
103
+ </>}
104
+ </details>
105
+ <details className="dte-subsection">
106
+ <summary>{t('initialComparison')}</summary>
107
+ <Facts rows={[
108
+ [t('initialTime'), formatEta(snapshot.initial?.estimatedTotalMs, t)],
109
+ [t('initialCost'), formatMoneyRanges(snapshot.initial?.estimatedTotalCost, locale, t)],
110
+ [t('activeTime'), formatDuration(timing.activeMs, t)], [t('knownCost'), knownCost],
111
+ ]} />
112
+ </details>
113
+ <details className="dte-subsection">
114
+ <summary>{t('priceSources')}</summary>
115
+ {!snapshot.priceSources.length ? <p className="dte-muted">{t('noPriceSources')}</p> : <ul>{snapshot.priceSources.map((source, index) => {
116
+ let href: string | null = null;
117
+ try { const url = new URL(source); if (url.protocol === 'http:' || url.protocol === 'https:') href = url.href; } catch { /* A catalog id is valid source data, not a URL. */ }
118
+ return <li key={`${index}-${source}`}>{href ? <a href={href} target="_blank" rel="noopener noreferrer">{source}</a> : t('sourceDetail', { value: source })}</li>;
119
+ })}</ul>}
120
+ </details>
121
+ <details className="dte-subsection">
122
+ <summary>{t('reasons')}</summary>
123
+ {!snapshot.reasons.length ? <p className="dte-muted">{t('noReasons')}</p> : <ul>{snapshot.reasons.map((reason, index) => <li key={`${index}-${reason}`}>{reasonText(reason, t)}</li>)}</ul>}
124
+ </details>
125
+ </section>;
126
+ }
@@ -0,0 +1,218 @@
1
+ import type { EstimateSnapshot, Money, MoneyRange, ProfileSummary, Span, TaskState } from '../shared/wire.ts';
2
+ import type { MessageKey, Translate } from './locale.ts';
3
+
4
+ export function numberLocale(locale: string): string {
5
+ return locale.startsWith('zh') ? 'zh-CN' : locale || 'en';
6
+ }
7
+
8
+ export function formatNumber(value: number | null | undefined, locale: string, t: Translate, fractionDigits = 0): string {
9
+ if (value == null || !Number.isFinite(value) || value < 0) return t('unknown');
10
+ return new Intl.NumberFormat(numberLocale(locale), { maximumFractionDigits: fractionDigits }).format(value);
11
+ }
12
+
13
+ /**
14
+ * Durations use unit letters so every row keeps the same narrow footprint in
15
+ * both locales; above ten minutes the seconds are dropped because a one-line
16
+ * ETA is read as "about how long", not as a stopwatch.
17
+ */
18
+ export function formatDuration(milliseconds: number | null | undefined, t: Translate): string {
19
+ if (milliseconds == null || !Number.isFinite(milliseconds) || milliseconds < 0) return t('unknown');
20
+ const seconds = Math.floor(milliseconds / 1_000);
21
+ if (seconds < 60) return t('durationSeconds', { value: seconds });
22
+ const minutes = Math.floor(seconds / 60);
23
+ if (minutes < 60) {
24
+ const rest = seconds % 60;
25
+ if (rest === 0) return t('durationMinutesOnly', { value: minutes });
26
+ return minutes >= 10 ? t('durationMinutesOnly', { value: minutes }) : t('durationMinutes', { value: minutes, seconds: rest });
27
+ }
28
+ const hours = Math.floor(minutes / 60);
29
+ const restMinutes = minutes % 60;
30
+ if (restMinutes === 0) return t('durationHoursOnly', { value: hours });
31
+ return hours >= 10 ? t('durationHoursOnly', { value: hours }) : t('durationHours', { value: hours, minutes: restMinutes });
32
+ }
33
+
34
+ export function formatLatency(milliseconds: number | null | undefined, locale: string, t: Translate): string {
35
+ if (milliseconds == null || !Number.isFinite(milliseconds) || milliseconds < 0) return t('unknown');
36
+ if (milliseconds < 1_000) return t('durationMilliseconds', { value: formatNumber(milliseconds, locale, t, 1) });
37
+ if (milliseconds < 60_000) return t('durationSeconds', { value: formatNumber(milliseconds / 1_000, locale, t, 2) });
38
+ return formatDuration(milliseconds, t);
39
+ }
40
+
41
+ export function validSpan(range: Span | null | undefined): range is Span {
42
+ return !!range && Number.isFinite(range.low) && Number.isFinite(range.high) && range.low >= 0 && range.low <= range.high;
43
+ }
44
+
45
+ export function formatDurationRange(range: Span | null | undefined, t: Translate): string {
46
+ if (!validSpan(range)) return t('unknown');
47
+ const low = formatDuration(range.low, t);
48
+ const high = formatDuration(range.high, t);
49
+ return low === high ? low : t('range', { low, high });
50
+ }
51
+
52
+ /**
53
+ * Rounding steps for a forecast. An estimate is not a stopwatch: a minute-level
54
+ * figure such as "8m–30m" is what a reader acts on, while "8m 30s" claims a
55
+ * precision the model does not have. Bounds deliberately snap outward so the
56
+ * printed interval never looks narrower than the computed one.
57
+ */
58
+ const ETA_STEPS: ReadonlyArray<readonly [number, number]> = [
59
+ [60_000, 15_000], [600_000, 60_000], [3_600_000, 300_000], [Number.POSITIVE_INFINITY, 900_000],
60
+ ];
61
+ function roundEta(milliseconds: number, direction: 'down' | 'up'): number {
62
+ const step = ETA_STEPS.find(([limit]) => milliseconds < limit)![1];
63
+ const rounded = direction === 'down' ? Math.floor(milliseconds / step) * step : Math.ceil(milliseconds / step) * step;
64
+ return Math.max(0, rounded);
65
+ }
66
+ /** Coarse duration: whole minutes above a minute, so no bound prints stray seconds. */
67
+ function formatCoarse(ms: number, t: Translate): string {
68
+ const minutes = Math.round(ms / 60_000);
69
+ if (ms < 60_000) return t('durationSeconds', { value: Math.round(ms / 1_000) });
70
+ if (minutes < 60) return t('durationMinutesOnly', { value: minutes });
71
+ const hours = Math.floor(minutes / 60);
72
+ const rest = minutes % 60;
73
+ return rest === 0 ? t('durationHoursOnly', { value: hours }) : t('durationHours', { value: hours, minutes: rest });
74
+ }
75
+
76
+ /** Compact ETA for the one-line display: coarse bounds, latin units, stable width. */
77
+ export function formatEta(range: Span | null | undefined, t: Translate): string {
78
+ if (!validSpan(range)) return t('unknown');
79
+ const roundedLow = roundEta(range.low, 'down');
80
+ const low = formatCoarse(roundedLow, t);
81
+ const high = formatCoarse(roundEta(range.high, 'up'), t);
82
+ if (range.high > 0 && roundedLow === 0) return t('upTo', { value: high });
83
+ return low === high ? low : t('range', { low, high });
84
+ }
85
+
86
+ export function formatTokenRange(range: Span | null | undefined, locale: string, t: Translate): string {
87
+ if (!validSpan(range)) return t('unknown');
88
+ const low = formatNumber(range.low, locale, t);
89
+ const high = formatNumber(range.high, locale, t);
90
+ return t('tokenCount', { value: low === high ? low : t('range', { low, high }) });
91
+ }
92
+
93
+ /** Format the decimal string exactly: tiny prices and values above 2^53 never round through Number. */
94
+ export function formatDecimal(amount: string, locale: string): string | null {
95
+ const match = /^(\d+)(?:\.(\d+))?$/.exec(amount);
96
+ if (!match) return null;
97
+ const integer = new Intl.NumberFormat(numberLocale(locale), { maximumFractionDigits: 0 }).format(BigInt(match[1]));
98
+ const fraction = (match[2] ?? '').replace(/0+$/, '');
99
+ if (!fraction) return integer;
100
+ const point = new Intl.NumberFormat(numberLocale(locale)).formatToParts(1.1).find((part) => part.type === 'decimal')?.value ?? '.';
101
+ return `${integer}${point}${fraction}`;
102
+ }
103
+
104
+ export function formatMoney(value: Money, locale: string, t: Translate): string {
105
+ const amount = formatDecimal(value.amount, locale);
106
+ return amount === null || !value.currency ? t('unknown') : t('money', { currency: value.currency, value: amount });
107
+ }
108
+
109
+ export function formatMoneyList(values: readonly Money[] | null | undefined, locale: string, t: Translate): string {
110
+ if (!values?.length) return t('unknown');
111
+ return values.map((value) => formatMoney(value, locale, t)).join(t('listSeparator'));
112
+ }
113
+
114
+ export function formatMoneyRanges(values: readonly MoneyRange[] | null | undefined, locale: string, t: Translate): string {
115
+ if (!values?.length) return t('unknown');
116
+ return values.map((value) => {
117
+ const low = formatDecimal(value.range.low, locale);
118
+ const high = formatDecimal(value.range.high, locale);
119
+ if (low === null || high === null || !value.currency) return t('unknown');
120
+ return low === high
121
+ ? t('money', { currency: value.currency, value: low })
122
+ : t('moneyRange', { currency: value.currency, low, high });
123
+ }).join(t('listSeparator'));
124
+ }
125
+
126
+ export function isLive(state: TaskState): boolean {
127
+ return state === 'running' || state === 'queued';
128
+ }
129
+
130
+ export function isTerminal(state: TaskState): boolean {
131
+ return state === 'completed' || state === 'failed' || state === 'cancelled' || state === 'interrupted';
132
+ }
133
+
134
+ const states: Record<TaskState, MessageKey> = {
135
+ queued: 'stateQueued', running: 'stateRunning', waiting_user: 'stateWaiting', completed: 'stateCompleted',
136
+ failed: 'stateFailed', cancelled: 'stateCancelled', interrupted: 'stateInterrupted',
137
+ };
138
+ export function formatState(state: TaskState, t: Translate): string { return t(states[state]); }
139
+
140
+ const origins: Record<EstimateSnapshot['estimateOrigin'], MessageKey> = {
141
+ heuristic: 'originHeuristic', 'public-measured': 'originPublic', 'local-measured': 'originLocal', mixed: 'originMixed',
142
+ };
143
+ export function formatOrigin(origin: EstimateSnapshot['estimateOrigin'], t: Translate): string { return t(origins[origin]); }
144
+
145
+ const taskTypes: Record<string, MessageKey> = {
146
+ question: 'taskQuestion', writing: 'taskWriting', 'file-analysis': 'taskFileAnalysis', code: 'taskCode', research: 'taskResearch', unknown: 'taskUnknown',
147
+ };
148
+ export function formatTaskType(type: string, t: Translate): string { return t(taskTypes[type] ?? 'taskUnknown'); }
149
+
150
+ export function formatCoverage(status: string, t: Translate): string {
151
+ const keys: Record<string, MessageKey> = { complete: 'costComplete', partial: 'costPartial', unpriced: 'costUnpriced', free: 'free', reference: 'costReference', unavailable: 'costUnavailable', unknown: 'unknown' };
152
+ return t(keys[status] ?? 'unknown');
153
+ }
154
+
155
+ /**
156
+ * True when no price applies at all: nothing observed is priced and nothing
157
+ * forecast is priced. That is a configuration fact, not a failed calculation,
158
+ * so the UI must say "prices are not configured" instead of an error sentence.
159
+ */
160
+ export function costUnconfigured(snapshot: EstimateSnapshot): boolean {
161
+ if (snapshot.observedCostStatus === 'free' || snapshot.forecastCostStatus === 'free') return false;
162
+ return snapshot.priceSources.length === 0 && snapshot.knownCost.length === 0 && (snapshot.estimatedTotalCost?.length ?? 0) === 0;
163
+ }
164
+
165
+ export function costSummary(snapshot: EstimateSnapshot, locale: string, t: Translate): string {
166
+ if (snapshot.observedCostStatus === 'free') return t('free');
167
+ if (!snapshot.knownCost.length) return t('unknown');
168
+ const value = formatMoneyList(snapshot.knownCost, locale, t);
169
+ return snapshot.observedCostStatus === 'complete' ? value : t('partialKnown', { value });
170
+ }
171
+
172
+ /** Advance text only between Host snapshots. Waiting freezes active time and the remaining range. */
173
+ export function localTiming(snapshot: EstimateSnapshot, receivedAt: number, now: number) {
174
+ const delta = Math.max(0, now - receivedAt);
175
+ const activeDelta = snapshot.state === 'running' ? delta : 0;
176
+ const wallDelta = isTerminal(snapshot.state) ? 0 : delta;
177
+ return {
178
+ activeMs: snapshot.elapsedActiveMs + activeDelta,
179
+ pausedMs: snapshot.elapsedPausedMs + (snapshot.state === 'waiting_user' ? delta : 0),
180
+ wallMs: snapshot.elapsedWallMs + wallDelta,
181
+ remaining: validSpan(snapshot.remainingMs) ? {
182
+ low: Math.max(0, snapshot.remainingMs.low - activeDelta),
183
+ high: Math.max(0, snapshot.remainingMs.high - activeDelta),
184
+ } : null,
185
+ };
186
+ }
187
+
188
+ export function remainingSummary(snapshot: EstimateSnapshot, receivedAt: number, now: number, t: Translate): string {
189
+ const { remaining } = localTiming(snapshot, receivedAt, now);
190
+ if (!remaining) return t('unknownRemaining');
191
+ if (snapshot.state === 'running' && remaining.high === 0) return t('overdue');
192
+ return t('estimatedRemaining', { value: formatDurationRange(remaining, t) });
193
+ }
194
+
195
+ export function formatDate(time: number, locale: string, t: Translate): string {
196
+ if (!Number.isFinite(time) || time <= 0) return t('unknown');
197
+ return new Intl.DateTimeFormat(numberLocale(locale), { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(time);
198
+ }
199
+
200
+ export interface ProfileMetricsText { latency: string; speed: string; samples: string; measured: string; source: string }
201
+
202
+ /**
203
+ * One calibration row. A metric is printed plainly only when local samples back
204
+ * it; prior-only values stay visibly marked as reference so a heuristic prior is
205
+ * never read as a measurement. Missing values remain unknown, never zero.
206
+ */
207
+ export function formatProfileMetrics(profile: ProfileSummary, locale: string, t: Translate): ProfileMetricsText {
208
+ const latency = formatLatency(profile.firstTextMs, locale, t);
209
+ const speedValue = profile.visibleTps == null ? null : t('tokensPerSecond', { value: formatNumber(profile.visibleTps, locale, t, 1) });
210
+ const priorSpeed = profile.priorVisibleTps == null ? null : t('tokensPerSecond', { value: formatNumber(profile.priorVisibleTps, locale, t, 1) });
211
+ return {
212
+ latency: profile.firstTextMs !== null && profile.firstTextSamples === 0 ? t('referenceValue', { value: latency }) : latency,
213
+ speed: profile.visibleTps != null && profile.speedSamples > 0 ? speedValue! : priorSpeed === null ? t('unknown') : t('referenceValue', { value: priorSpeed }),
214
+ samples: t('profileSamples', { value: formatNumber(profile.totalCallSamples, locale, t) }),
215
+ measured: profile.testedAt === null || profile.testedAt <= 0 ? t('profileNotMeasured') : t('profileMeasuredAt', { value: formatDate(profile.testedAt, locale, t) }),
216
+ source: t('sourceDetail', { value: formatOrigin(profile.sourceKind, t) }),
217
+ };
218
+ }