hiwork-core 0.0.2 → 0.0.6

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.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * `/hiwork-usage` loopback RPC 的 Host 实现(设置页「用量与成本」的唯一取数口)。
3
+ *
4
+ * 设计要点(与 hiwork-automation 的 `src/rpc.ts` 同构):
5
+ * - 只注册一个 channel,endpoint 白名单在 {@link USAGE_RPC_ENDPOINTS},未知 endpoint
6
+ * 直接 `bad-request`;
7
+ * - payload 只允许 `{ keyId?, range? }`,任何其它字段 / 非法 range 一律 `bad-request`;
8
+ * - **网关会话 cookie 绝不离开 Host**:调用方只拿到端点原始响应,cookie 只在
9
+ * `resolveSession()` 内部出现;
10
+ * - 网关 `GatewayError` 的稳定 code 原样透传,但 message 先经
11
+ * {@link displayGatewayMessage} 消毒——baseURL / HTTP 状态码这类实现细节不进 UI
12
+ * (沿用 `src/client/login-gate.tsx` 的既有处理思路);
13
+ * - 其余异常收敛为通用失败(code `internal`),Host 只记日志、不把堆栈或原始消息
14
+ * 交给客户端。
15
+ *
16
+ * 本模块不 import cordis 运行时,只依赖结构化的 {@link UsageRpcContext} 端口,
17
+ * 便于单测直接伪造。
18
+ */
19
+ import { type GatewayCostEstimateItem, type GatewayCostEstimateReason, type GatewayCostEstimateResponse, type GatewayCostEstimateResult, type GatewayKey, type GatewayKeyResponse, type GatewayModelTrendsResponse, type GatewayRecharge, type GatewaySummaryResponse, type GatewayTrendsResponse, type GatewayUsageRange } from './ai-router-client.ts';
20
+ /** RPC 频道;必须与 client 半边 `src/client/usage-data.ts` 的 `USAGE_CHANNEL` 一致(由测试锁定)。 */
21
+ export declare const USAGE_RPC_CHANNEL = "/hiwork-usage";
22
+ /**
23
+ * 端点白名单。
24
+ *
25
+ * 前五项是 spec / plan 约定的端点;`key` 是**追加项**——KPI 的「剩余预算」只能取
26
+ * 网关权威的 `/api/me/key` budgets(`/api/me/keys` 是轻量列表、不含任何额度字段);
27
+ * `estimate` 是阶段 2 的逐轮计价端点(Task B3),见 {@link handleEstimateEndpoint}。
28
+ */
29
+ export declare const USAGE_RPC_ENDPOINTS: readonly ["summary", "trends", "model-trends", "recharges", "keys", "key", "estimate"];
30
+ export type UsageRpcEndpoint = (typeof USAGE_RPC_ENDPOINTS)[number];
31
+ /**
32
+ * 网关支持的用量时间窗口(与 `GatewayUsageRange` 同源,避免客户端伪造值)。
33
+ * `mtd`(本月 1 日 → 现在)需要网关侧支持;旧网关会按缺省回退成 `24h` 并在响应
34
+ * 的 `range` 里回填 `24h`,客户端据此判定「本月 KPI 不可用」。
35
+ */
36
+ export declare const USAGE_RPC_RANGES: readonly GatewayUsageRange[];
37
+ /** 端点入参:只允许 Key 与时间窗口,range 缺省由网关决定。 */
38
+ export interface UsageRpcPayload {
39
+ keyId?: string | undefined;
40
+ range?: GatewayUsageRange | undefined;
41
+ }
42
+ /** 客户端可安全展示的失败。 */
43
+ export interface UsageRpcFailure {
44
+ code: string;
45
+ message: string;
46
+ /** DSH Connection 信封要求 `details` 是对象;协议层没有额外细节时补空对象。 */
47
+ details: object;
48
+ }
49
+ /**
50
+ * `estimate` 端点的入参项:**线路名**(DSH `routes[].provider`)+ 模型 + 四个 token 桶。
51
+ *
52
+ * 线路名→协议名的翻译在 Host 侧({@link routeToProtocol})完成;token 桶是 DSH 原生
53
+ * 排他语义(`input_tokens` 不含 cache),折算成网关 `prompt_tokens` 由网关负责。
54
+ */
55
+ export interface UsageEstimateItem {
56
+ /** DSH 线路名(`ai-router-chat` / `ai-router-anthropic` / `ai-router`)。 */
57
+ route: string;
58
+ model: string;
59
+ /** 不含 cache 的输入 token。 */
60
+ input_tokens: number;
61
+ output_tokens: number;
62
+ /** 缺省视同 0(DSH 未能对全部尝试给出该桶时缺省)。 */
63
+ cache_read_tokens?: number;
64
+ cache_write_tokens?: number;
65
+ /** 计价时刻(RFC3339);缺省由网关取 now。 */
66
+ at?: string;
67
+ }
68
+ /** `estimate` 端点的响应:整轮汇总后的**单个**金额(客户端不做求和)。 */
69
+ export interface UsageEstimateResult {
70
+ /** 已计价项之和;`priced:false` 时为 0(调用方不得展示该值)。 */
71
+ cost: number;
72
+ /** 全部项都已计价且金额可信。任一项不可计价 → false。 */
73
+ priced: boolean;
74
+ /** 所有已计价项的 `channel_resolved` 逻辑与;`priced:false` 时为 false。 */
75
+ channelResolved: boolean;
76
+ /** `priced:false` 时的原因(可展示与否由客户端决定)。 */
77
+ reason?: UsageEstimateReason | undefined;
78
+ }
79
+ /**
80
+ * `priced:false` 的原因。
81
+ *
82
+ * 前五项是 Host 侧自己产生的降级原因(都不抛异常、都不是错误信封);后三项直接来自
83
+ * 网关单项结果({@link GatewayCostEstimateReason})。
84
+ */
85
+ export type UsageEstimateReason = 'no_session' | 'no_route' | 'too_many_items' | 'incomplete' | 'invalid_cost' | 'unpriced' | GatewayCostEstimateReason;
86
+ /** 网关契约的单批上限(spec §6.2「items 最多 50 项」)。 */
87
+ export declare const USAGE_ESTIMATE_MAX_ITEMS = 50;
88
+ /** 端点结果信封(与 DSH Connection 的 `ConnectionRpcResult` 同形)。 */
89
+ export type UsageRpcResult<T> = {
90
+ readonly ok: true;
91
+ readonly value: T;
92
+ } | {
93
+ readonly ok: false;
94
+ readonly error: UsageRpcFailure;
95
+ };
96
+ /** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
97
+ export declare class UsageRequestError extends Error {
98
+ readonly code: 'bad-request';
99
+ constructor(code: 'bad-request', message: string);
100
+ }
101
+ /** Host 侧 RPC 依赖的最小上下文(结构兼容 cordis Context,便于单测伪造)。 */
102
+ export interface UsageRpcConnection {
103
+ rpc: {
104
+ handle(channel: string, handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<unknown>, options: {
105
+ authority: 'loopback';
106
+ }): () => Promise<void> | void;
107
+ };
108
+ }
109
+ export interface UsageRpcContext {
110
+ readonly connection: UsageRpcConnection;
111
+ readonly logger: {
112
+ warn(message: string): void;
113
+ };
114
+ }
115
+ /**
116
+ * 网关客户端的用量子集:只声明本频道真正调用的方法(结构兼容 `GatewayClient`),
117
+ * 单测因此不需要伪造整个客户端。
118
+ */
119
+ export interface UsageGatewayPort {
120
+ getMySummary(sessionCookie: string, keyId?: string, range?: GatewayUsageRange): Promise<GatewaySummaryResponse>;
121
+ getMyTrends(sessionCookie: string, keyId?: string, range?: GatewayUsageRange): Promise<GatewayTrendsResponse>;
122
+ getMyModelTrends(sessionCookie: string, keyId?: string, range?: GatewayUsageRange): Promise<GatewayModelTrendsResponse>;
123
+ getMyKeyRecharges(sessionCookie: string, keyId?: string, modelName?: string): Promise<GatewayRecharge[]>;
124
+ getMyKeys(sessionCookie: string): Promise<GatewayKey[]>;
125
+ getMyKey(sessionCookie: string, keyId?: string): Promise<GatewayKeyResponse>;
126
+ /** 网关权威计价(`route` 已是协议名);只回金额与生效单价。 */
127
+ estimateCost(sessionCookie: string, items: GatewayCostEstimateItem[]): Promise<GatewayCostEstimateResponse>;
128
+ }
129
+ /** Host 侧取数依赖:会话解析 + 当前 baseURL 下的网关客户端。 */
130
+ export interface UsageRpcDeps {
131
+ /**
132
+ * 解析网关会话 cookie(`AI_ROUTER_SESSION`)。未登录 / 会话缺失返回 undefined。
133
+ * **返回值只在本模块内部使用,绝不出现在 RPC 响应里。**
134
+ */
135
+ resolveSession(): Promise<string | undefined>;
136
+ /** 当前 baseURL 对应的网关客户端;baseURL 未配置时返回 undefined。 */
137
+ gateway(): UsageGatewayPort | undefined;
138
+ /** Host 日志出口(只记事件与错误类型,不记会话值)。 */
139
+ log?: ((message: string) => void) | undefined;
140
+ }
141
+ /** 通用失败文案:不暴露任何网关细节。 */
142
+ export declare const USAGE_GENERIC_FAILURE_MESSAGE = "\u7528\u91CF\u6570\u636E\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
143
+ /** 未登录 / 会话失效的统一文案(与登录弹窗的 `SESSION_EXPIRED` 口径一致)。 */
144
+ export declare const USAGE_SESSION_EXPIRED_MESSAGE = "\u767B\u5F55\u72B6\u6001\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
145
+ /**
146
+ * 网关 message 消毒:含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,
147
+ * 其余(网关自己写好的中文业务文案)原样保留。code 不受影响,始终原样透传。
148
+ */
149
+ export declare function displayGatewayMessage(code: string, message: unknown): string;
150
+ /** payload 校验:只放行 `{ keyId?, range? }`,其余字段 / 非法值一律 `bad-request`。 */
151
+ export declare function parseUsagePayload(raw: unknown): UsageRpcPayload;
152
+ /** 是否为白名单端点。 */
153
+ export declare function isUsageRpcEndpoint(endpoint: string): endpoint is UsageRpcEndpoint;
154
+ /**
155
+ * `estimate` 端点 payload 校验:只放行 `{ items: [...] }`,形状非法一律 `bad-request`。
156
+ *
157
+ * **不在这里做数量上限**:条数取决于会话里出现的 (线路, 模型) 组合(数据),不是协议形状;
158
+ * 超限在 {@link handleEstimateEndpoint} 里降级为 `priced:false`(reason `too_many_items`),
159
+ * 免得把「本轮金额看不到」变成一条错误。
160
+ */
161
+ export declare function parseEstimatePayload(raw: unknown): UsageEstimateItem[];
162
+ /**
163
+ * 严格 RFC3339 校验(与网关 `time.Parse(time.RFC3339, ...)` 对齐):
164
+ * `YYYY-MM-DDThh:mm:ss[.fff](Z|±hh:mm)`,且各字段必须在真实取值范围内。
165
+ *
166
+ * **不能只靠 `Date.parse`**:Node 接受 `2026/09/10 03:16`、`Sep 10, 2026` 这类宽松格式
167
+ * (网关一律 400);而且 V8 会把 `2026-02-31T00:00:00Z` **滚动**成 3 月 3 日而不是报错,
168
+ * 所以日历合法性必须自己算。放过去任何一条就是整批金额消失。
169
+ * @param value - 客户端下发的 `at`。
170
+ * @returns 是否为网关可接受的 RFC3339 时间。
171
+ */
172
+ export declare function isRfc3339(value: string): boolean;
173
+ /**
174
+ * 汇总网关逐项结果(Host 侧求和,客户端零算术)。
175
+ *
176
+ * 语义:
177
+ * - 任一项 `priced:false` → 整体 `priced:false`(金额不可信,客户端不得显示);
178
+ * - `channelResolved` = 所有已计价项 `channel_resolved` 的**逻辑与**;
179
+ * - `cost` = 已计价项之和;
180
+ * - 返回条数与请求条数不一致 → `incomplete`(宁可不出金额,也不出一个少算的金额);
181
+ * - 金额非有限数 / 负数 → `invalid_cost`。
182
+ */
183
+ export declare function summarizeEstimate(results: readonly GatewayCostEstimateResult[], expected: number): UsageEstimateResult;
184
+ /**
185
+ * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
186
+ * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
187
+ *
188
+ * `estimate` 走独立分支({@link handleEstimateEndpoint}):它自带降级语义
189
+ * (无会话 / 无可用线路 → 成功信封里的 `priced:false`),且**不走** `callEndpoint`
190
+ * 的「原样透传网关响应」路径——金额必须在 Host 侧汇总。
191
+ */
192
+ export declare function handleUsageEndpoint(endpoint: string, rawPayload: unknown, deps: UsageRpcDeps, signal?: AbortSignal): Promise<UsageRpcResult<unknown>>;
193
+ /**
194
+ * 注册 `/hiwork-usage` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
195
+ */
196
+ export declare function registerUsageRpc(ctx: UsageRpcContext, deps: UsageRpcDeps): () => Promise<void>;
@@ -0,0 +1,386 @@
1
+ /**
2
+ * `/hiwork-usage` loopback RPC 的 Host 实现(设置页「用量与成本」的唯一取数口)。
3
+ *
4
+ * 设计要点(与 hiwork-automation 的 `src/rpc.ts` 同构):
5
+ * - 只注册一个 channel,endpoint 白名单在 {@link USAGE_RPC_ENDPOINTS},未知 endpoint
6
+ * 直接 `bad-request`;
7
+ * - payload 只允许 `{ keyId?, range? }`,任何其它字段 / 非法 range 一律 `bad-request`;
8
+ * - **网关会话 cookie 绝不离开 Host**:调用方只拿到端点原始响应,cookie 只在
9
+ * `resolveSession()` 内部出现;
10
+ * - 网关 `GatewayError` 的稳定 code 原样透传,但 message 先经
11
+ * {@link displayGatewayMessage} 消毒——baseURL / HTTP 状态码这类实现细节不进 UI
12
+ * (沿用 `src/client/login-gate.tsx` 的既有处理思路);
13
+ * - 其余异常收敛为通用失败(code `internal`),Host 只记日志、不把堆栈或原始消息
14
+ * 交给客户端。
15
+ *
16
+ * 本模块不 import cordis 运行时,只依赖结构化的 {@link UsageRpcContext} 端口,
17
+ * 便于单测直接伪造。
18
+ */
19
+ import { GatewayError, } from "./ai-router-client.js";
20
+ import { routeToProtocol } from "./provision.js";
21
+ /** RPC 频道;必须与 client 半边 `src/client/usage-data.ts` 的 `USAGE_CHANNEL` 一致(由测试锁定)。 */
22
+ export const USAGE_RPC_CHANNEL = '/hiwork-usage';
23
+ /**
24
+ * 端点白名单。
25
+ *
26
+ * 前五项是 spec / plan 约定的端点;`key` 是**追加项**——KPI 的「剩余预算」只能取
27
+ * 网关权威的 `/api/me/key` budgets(`/api/me/keys` 是轻量列表、不含任何额度字段);
28
+ * `estimate` 是阶段 2 的逐轮计价端点(Task B3),见 {@link handleEstimateEndpoint}。
29
+ */
30
+ export const USAGE_RPC_ENDPOINTS = ['summary', 'trends', 'model-trends', 'recharges', 'keys', 'key', 'estimate'];
31
+ /**
32
+ * 网关支持的用量时间窗口(与 `GatewayUsageRange` 同源,避免客户端伪造值)。
33
+ * `mtd`(本月 1 日 → 现在)需要网关侧支持;旧网关会按缺省回退成 `24h` 并在响应
34
+ * 的 `range` 里回填 `24h`,客户端据此判定「本月 KPI 不可用」。
35
+ */
36
+ export const USAGE_RPC_RANGES = ['today', 'yesterday', '24h', '7d', '30d', 'mtd'];
37
+ /** 网关契约的单批上限(spec §6.2「items 最多 50 项」)。 */
38
+ export const USAGE_ESTIMATE_MAX_ITEMS = 50;
39
+ /** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
40
+ export class UsageRequestError extends Error {
41
+ code;
42
+ constructor(code, message) {
43
+ super(message);
44
+ this.code = code;
45
+ this.name = 'UsageRequestError';
46
+ }
47
+ }
48
+ /** 各稳定错误码的用户可见文案(网关 message 含实现细节时用它兜底)。 */
49
+ const GATEWAY_MESSAGE_FALLBACK = {
50
+ SESSION_EXPIRED: '登录状态已过期,请重新登录',
51
+ GATEWAY_UNREACHABLE: '网关暂时不可用,请稍后重试',
52
+ NO_KEY_ASSIGNED: '当前账号尚未被分配虚拟 KEY,请联系管理员',
53
+ KEY_INVALID: '所选 KEY 无效或已被删除,请重新选择 KEY',
54
+ RATE_LIMITED: '请求过于频繁,请稍后再试',
55
+ NOT_FOUND: '网关未找到对应的数据',
56
+ AUTH_FAILED: '登录状态异常,请重新登录',
57
+ };
58
+ /** 通用失败文案:不暴露任何网关细节。 */
59
+ export const USAGE_GENERIC_FAILURE_MESSAGE = '用量数据暂时无法获取,请稍后重试';
60
+ /** 未登录 / 会话失效的统一文案(与登录弹窗的 `SESSION_EXPIRED` 口径一致)。 */
61
+ export const USAGE_SESSION_EXPIRED_MESSAGE = '登录状态已过期,请重新登录';
62
+ /**
63
+ * 网关 message 消毒:含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,
64
+ * 其余(网关自己写好的中文业务文案)原样保留。code 不受影响,始终原样透传。
65
+ */
66
+ export function displayGatewayMessage(code, message) {
67
+ const text = typeof message === 'string' ? message.trim() : '';
68
+ if (text === '')
69
+ return GATEWAY_MESSAGE_FALLBACK[code] ?? USAGE_GENERIC_FAILURE_MESSAGE;
70
+ const leaksImplementationDetail = /https?:\/\//i.test(text)
71
+ || /\bHTTP\s*\d{3}\b/i.test(text)
72
+ || /\bbaseURL\b/i.test(text);
73
+ if (leaksImplementationDetail)
74
+ return GATEWAY_MESSAGE_FALLBACK[code] ?? USAGE_GENERIC_FAILURE_MESSAGE;
75
+ return text;
76
+ }
77
+ /** payload 校验:只放行 `{ keyId?, range? }`,其余字段 / 非法值一律 `bad-request`。 */
78
+ export function parseUsagePayload(raw) {
79
+ if (raw === undefined || raw === null)
80
+ return {};
81
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
82
+ throw new UsageRequestError('bad-request', '用量请求参数不合法。');
83
+ }
84
+ const record = raw;
85
+ for (const key of Object.keys(record)) {
86
+ if (key !== 'keyId' && key !== 'range') {
87
+ throw new UsageRequestError('bad-request', `用量请求包含未知字段 '${key}'。`);
88
+ }
89
+ }
90
+ const payload = {};
91
+ const rawKeyId = record['keyId'];
92
+ if (rawKeyId !== undefined) {
93
+ if (typeof rawKeyId !== 'string')
94
+ throw new UsageRequestError('bad-request', '用量请求的 keyId 必须是字符串。');
95
+ const keyId = rawKeyId.trim();
96
+ if (keyId !== '')
97
+ payload.keyId = keyId;
98
+ }
99
+ const rawRange = record['range'];
100
+ if (rawRange !== undefined) {
101
+ if (typeof rawRange !== 'string' || !USAGE_RPC_RANGES.includes(rawRange)) {
102
+ throw new UsageRequestError('bad-request', `用量请求的 range 不合法(可用值:${USAGE_RPC_RANGES.join(' / ')})。`);
103
+ }
104
+ payload.range = rawRange;
105
+ }
106
+ return payload;
107
+ }
108
+ /** 是否为白名单端点。 */
109
+ export function isUsageRpcEndpoint(endpoint) {
110
+ return USAGE_RPC_ENDPOINTS.includes(endpoint);
111
+ }
112
+ /** `estimate` 入参项允许的字段(其余一律 `bad-request`)。 */
113
+ const ESTIMATE_ITEM_KEYS = [
114
+ 'route', 'model', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'at',
115
+ ];
116
+ /**
117
+ * `estimate` 端点 payload 校验:只放行 `{ items: [...] }`,形状非法一律 `bad-request`。
118
+ *
119
+ * **不在这里做数量上限**:条数取决于会话里出现的 (线路, 模型) 组合(数据),不是协议形状;
120
+ * 超限在 {@link handleEstimateEndpoint} 里降级为 `priced:false`(reason `too_many_items`),
121
+ * 免得把「本轮金额看不到」变成一条错误。
122
+ */
123
+ export function parseEstimatePayload(raw) {
124
+ if (raw === undefined || raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
125
+ throw new UsageRequestError('bad-request', '计价请求参数不合法。');
126
+ }
127
+ const record = raw;
128
+ for (const key of Object.keys(record)) {
129
+ if (key !== 'items')
130
+ throw new UsageRequestError('bad-request', `计价请求包含未知字段 '${key}'。`);
131
+ }
132
+ const rawItems = record['items'];
133
+ if (!Array.isArray(rawItems))
134
+ throw new UsageRequestError('bad-request', '计价请求的 items 必须是数组。');
135
+ if (rawItems.length === 0)
136
+ throw new UsageRequestError('bad-request', '计价请求的 items 不能为空。');
137
+ return rawItems.map((item, index) => parseEstimateItem(item, index));
138
+ }
139
+ /** 单项校验:必填 `route` / `model` / `input_tokens` / `output_tokens`,token 必须是非负整数。 */
140
+ function parseEstimateItem(raw, index) {
141
+ const position = index + 1;
142
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
143
+ throw new UsageRequestError('bad-request', `计价请求第 ${position} 项必须是对象。`);
144
+ }
145
+ const record = raw;
146
+ for (const key of Object.keys(record)) {
147
+ if (!ESTIMATE_ITEM_KEYS.includes(key)) {
148
+ throw new UsageRequestError('bad-request', `计价请求第 ${position} 项包含未知字段 '${key}'。`);
149
+ }
150
+ }
151
+ const item = {
152
+ route: requiredText(record['route'], position, 'route'),
153
+ model: requiredText(record['model'], position, 'model'),
154
+ input_tokens: tokenCount(record['input_tokens'], position, 'input_tokens'),
155
+ output_tokens: tokenCount(record['output_tokens'], position, 'output_tokens'),
156
+ };
157
+ const cacheRead = optionalTokenCount(record['cache_read_tokens'], position, 'cache_read_tokens');
158
+ if (cacheRead !== undefined)
159
+ item.cache_read_tokens = cacheRead;
160
+ const cacheWrite = optionalTokenCount(record['cache_write_tokens'], position, 'cache_write_tokens');
161
+ if (cacheWrite !== undefined)
162
+ item.cache_write_tokens = cacheWrite;
163
+ const at = record['at'];
164
+ if (at !== undefined) {
165
+ if (typeof at !== 'string' || !isRfc3339(at)) {
166
+ throw new UsageRequestError('bad-request', `计价请求第 ${position} 项的 at 必须是 RFC3339 时间。`);
167
+ }
168
+ item.at = at;
169
+ }
170
+ return item;
171
+ }
172
+ /**
173
+ * 严格 RFC3339 校验(与网关 `time.Parse(time.RFC3339, ...)` 对齐):
174
+ * `YYYY-MM-DDThh:mm:ss[.fff](Z|±hh:mm)`,且各字段必须在真实取值范围内。
175
+ *
176
+ * **不能只靠 `Date.parse`**:Node 接受 `2026/09/10 03:16`、`Sep 10, 2026` 这类宽松格式
177
+ * (网关一律 400);而且 V8 会把 `2026-02-31T00:00:00Z` **滚动**成 3 月 3 日而不是报错,
178
+ * 所以日历合法性必须自己算。放过去任何一条就是整批金额消失。
179
+ * @param value - 客户端下发的 `at`。
180
+ * @returns 是否为网关可接受的 RFC3339 时间。
181
+ */
182
+ export function isRfc3339(value) {
183
+ const match = RFC3339_PATTERN.exec(value);
184
+ if (match === null)
185
+ return false;
186
+ const field = (index) => Number(match[index] ?? Number.NaN);
187
+ const year = field(1);
188
+ const month = field(2);
189
+ const day = field(3);
190
+ const hour = field(4);
191
+ const minute = field(5);
192
+ const second = field(6);
193
+ if (![year, month, day, hour, minute, second].every((part) => Number.isInteger(part)))
194
+ return false;
195
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month))
196
+ return false;
197
+ if (hour > 23 || minute > 59 || second > 59)
198
+ return false;
199
+ // 偏移量只在非 Z 时出现;Go 同样拒绝超出 ±23:59 的偏移。
200
+ if (match[7] !== undefined) {
201
+ const offsetHour = field(8);
202
+ const offsetMinute = field(9);
203
+ if (!Number.isInteger(offsetHour) || !Number.isInteger(offsetMinute))
204
+ return false;
205
+ if (offsetHour > 23 || offsetMinute > 59)
206
+ return false;
207
+ }
208
+ return true;
209
+ }
210
+ /** 严格 RFC3339 形状(大小写敏感:Go 的 `time.RFC3339` 只认大写 `T` / `Z`)。 */
211
+ const RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/;
212
+ /** 该年该月的天数(不用 `Date`:它对 0–99 年有 1900 偏移)。 */
213
+ function daysInMonth(year, month) {
214
+ if (month === 2)
215
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
216
+ return [31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0;
217
+ }
218
+ function requiredText(value, position, field) {
219
+ if (typeof value !== 'string' || value.trim() === '') {
220
+ throw new UsageRequestError('bad-request', `计价请求第 ${position} 项的 ${field} 不能为空。`);
221
+ }
222
+ return value.trim();
223
+ }
224
+ function tokenCount(value, position, field) {
225
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
226
+ throw new UsageRequestError('bad-request', `计价请求第 ${position} 项的 ${field} 必须是非负整数。`);
227
+ }
228
+ return value;
229
+ }
230
+ function optionalTokenCount(value, position, field) {
231
+ return value === undefined ? undefined : tokenCount(value, position, field);
232
+ }
233
+ /** 未计价的统一结果(金额恒为 0,调用方**不得**展示)。 */
234
+ function unpriced(reason) {
235
+ return { cost: 0, priced: false, channelResolved: false, reason };
236
+ }
237
+ /**
238
+ * 汇总网关逐项结果(Host 侧求和,客户端零算术)。
239
+ *
240
+ * 语义:
241
+ * - 任一项 `priced:false` → 整体 `priced:false`(金额不可信,客户端不得显示);
242
+ * - `channelResolved` = 所有已计价项 `channel_resolved` 的**逻辑与**;
243
+ * - `cost` = 已计价项之和;
244
+ * - 返回条数与请求条数不一致 → `incomplete`(宁可不出金额,也不出一个少算的金额);
245
+ * - 金额非有限数 / 负数 → `invalid_cost`。
246
+ */
247
+ export function summarizeEstimate(results, expected) {
248
+ if (results.length !== expected)
249
+ return unpriced('incomplete');
250
+ let cost = 0;
251
+ let channelResolved = true;
252
+ for (const result of results) {
253
+ if (result?.priced !== true)
254
+ return unpriced(result?.reason ?? 'unpriced');
255
+ const value = result.cost;
256
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
257
+ return unpriced('invalid_cost');
258
+ cost += value;
259
+ if (result.channel_resolved !== true)
260
+ channelResolved = false;
261
+ }
262
+ return { cost, priced: true, channelResolved };
263
+ }
264
+ /**
265
+ * `estimate` 端点:逐轮计价并汇总,**永不抛异常**(异常由调用方的信封统一收敛)。
266
+ *
267
+ * 流程(Task B3):
268
+ * 1. 无会话 → `{priced:false, reason:'no_session'}`(成功信封,不是错误);
269
+ * 2. 逐项 `routeToProtocol(route)`,未知 / 遗留线路(`ai-router-responses`)**丢弃**该轮;
270
+ * 全部被丢弃 → `{priced:false, reason:'no_route'}`;
271
+ * 3. 条数超过网关契约(≤50)→ `{priced:false, reason:'too_many_items'}`;
272
+ * 4. 调网关 `estimateCost`,Host 侧用 {@link summarizeEstimate} 求和后返回单值。
273
+ */
274
+ async function handleEstimateEndpoint(items, deps, signal) {
275
+ const sessionCookie = await deps.resolveSession();
276
+ if (sessionCookie === undefined || sessionCookie === '')
277
+ return { ok: true, value: unpriced('no_session') };
278
+ if (isAborted(signal))
279
+ return failure('cancelled', '用量请求已取消。');
280
+ if (items.length > USAGE_ESTIMATE_MAX_ITEMS)
281
+ return { ok: true, value: unpriced('too_many_items') };
282
+ const gatewayItems = [];
283
+ for (const item of items) {
284
+ const protocol = routeToProtocol(item.route);
285
+ if (protocol === undefined)
286
+ continue;
287
+ gatewayItems.push({
288
+ route: protocol,
289
+ model: item.model,
290
+ input_tokens: item.input_tokens,
291
+ output_tokens: item.output_tokens,
292
+ ...(item.cache_read_tokens === undefined ? {} : { cache_read_tokens: item.cache_read_tokens }),
293
+ ...(item.cache_write_tokens === undefined ? {} : { cache_write_tokens: item.cache_write_tokens }),
294
+ ...(item.at === undefined ? {} : { at: item.at }),
295
+ });
296
+ }
297
+ if (gatewayItems.length === 0)
298
+ return { ok: true, value: unpriced('no_route') };
299
+ const gateway = deps.gateway();
300
+ if (gateway === undefined) {
301
+ return failure('GATEWAY_UNREACHABLE', '网关地址未配置,请先在「网关模型」里完成登录');
302
+ }
303
+ if (isAborted(signal))
304
+ return failure('cancelled', '用量请求已取消。');
305
+ const response = await gateway.estimateCost(sessionCookie, gatewayItems);
306
+ const results = response?.items;
307
+ if (!Array.isArray(results)) {
308
+ deps.log?.("hiwork-core: 计价 RPC 收到无效响应(items 不是数组)");
309
+ return failure('internal', USAGE_GENERIC_FAILURE_MESSAGE);
310
+ }
311
+ return { ok: true, value: summarizeEstimate(results, gatewayItems.length) };
312
+ }
313
+ /** 取消判定(用函数包一层,避免 TS 把属性窄化固化到 `await` 之后)。 */
314
+ function isAborted(signal) {
315
+ return signal?.aborted === true;
316
+ }
317
+ /**
318
+ * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
319
+ * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
320
+ *
321
+ * `estimate` 走独立分支({@link handleEstimateEndpoint}):它自带降级语义
322
+ * (无会话 / 无可用线路 → 成功信封里的 `priced:false`),且**不走** `callEndpoint`
323
+ * 的「原样透传网关响应」路径——金额必须在 Host 侧汇总。
324
+ */
325
+ export async function handleUsageEndpoint(endpoint, rawPayload, deps, signal) {
326
+ try {
327
+ if (isAborted(signal))
328
+ return failure('cancelled', '用量请求已取消。');
329
+ if (!isUsageRpcEndpoint(endpoint)) {
330
+ throw new UsageRequestError('bad-request', `未知的用量端点 '${endpoint}'`);
331
+ }
332
+ if (endpoint === 'estimate')
333
+ return await handleEstimateEndpoint(parseEstimatePayload(rawPayload), deps, signal);
334
+ const payload = parseUsagePayload(rawPayload);
335
+ const sessionCookie = await deps.resolveSession();
336
+ if (sessionCookie === undefined || sessionCookie === '') {
337
+ return failure('SESSION_EXPIRED', USAGE_SESSION_EXPIRED_MESSAGE);
338
+ }
339
+ const gateway = deps.gateway();
340
+ if (gateway === undefined) {
341
+ return failure('GATEWAY_UNREACHABLE', '网关地址未配置,请先在「网关模型」里完成登录');
342
+ }
343
+ if (isAborted(signal))
344
+ return failure('cancelled', '用量请求已取消。');
345
+ const value = await callEndpoint(gateway, sessionCookie, endpoint, payload);
346
+ return { ok: true, value };
347
+ }
348
+ catch (error) {
349
+ if (error instanceof UsageRequestError)
350
+ return failure(error.code, error.message);
351
+ if (error instanceof GatewayError) {
352
+ // code 原样透传(稳定契约),message 消毒后再给客户端。
353
+ return failure(error.code, displayGatewayMessage(error.code, error.message));
354
+ }
355
+ deps.log?.(`hiwork-core: 用量 RPC '${endpoint}' 失败(${error instanceof Error ? error.name : typeof error})`);
356
+ return failure('internal', USAGE_GENERIC_FAILURE_MESSAGE);
357
+ }
358
+ }
359
+ /** 单端点转发:返回网关原始响应,**不做任何字段重包装**(`estimate` 不走这里)。 */
360
+ async function callEndpoint(gateway, sessionCookie, endpoint, payload) {
361
+ switch (endpoint) {
362
+ case 'summary':
363
+ return await gateway.getMySummary(sessionCookie, payload.keyId, payload.range);
364
+ case 'trends':
365
+ return await gateway.getMyTrends(sessionCookie, payload.keyId, payload.range);
366
+ case 'model-trends':
367
+ return await gateway.getMyModelTrends(sessionCookie, payload.keyId, payload.range);
368
+ case 'recharges':
369
+ return await gateway.getMyKeyRecharges(sessionCookie, payload.keyId);
370
+ case 'keys':
371
+ return await gateway.getMyKeys(sessionCookie);
372
+ case 'key':
373
+ return await gateway.getMyKey(sessionCookie, payload.keyId);
374
+ }
375
+ }
376
+ function failure(code, message) {
377
+ return { ok: false, error: { code, message, details: {} } };
378
+ }
379
+ /**
380
+ * 注册 `/hiwork-usage` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
381
+ */
382
+ export function registerUsageRpc(ctx, deps) {
383
+ const handler = (endpoint, payload, signal) => handleUsageEndpoint(endpoint, payload, deps, signal);
384
+ const remove = ctx.connection.rpc.handle(USAGE_RPC_CHANNEL, handler, { authority: 'loopback' });
385
+ return async () => { await remove(); };
386
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hiwork-core",
3
- "version": "0.0.2",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "description": "HiWork 企业统一登录与模型自动供给插件(ai-router 网关登录,Web 表单,无 CLI 认证)",
6
6
  "license": "UNLICENSED",
@@ -33,7 +33,8 @@
33
33
  "@deepseek-ai/dsh-client-ui-slots",
34
34
  "@deepseek-ai/dsh-client-ui-settings",
35
35
  "@deepseek-ai/dsh-api-remotes",
36
- "@deepseek-ai/dsh-client-ui-primitives"
36
+ "@deepseek-ai/dsh-client-ui-primitives",
37
+ "@deepseek-ai/dsh-client-ui-chat"
37
38
  ]
38
39
  }
39
40
  },
@@ -44,8 +45,11 @@
44
45
  "@deepseek-ai/cordis": "4.0.2",
45
46
  "@deepseek-ai/dsh-api-remotes": "0.1.2-rc.1",
46
47
  "@deepseek-ai/dsh-client-store": "0.1.2-rc.1",
48
+ "@deepseek-ai/dsh-client-ui-chat": "0.1.2-rc.1",
49
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.2-rc.1",
47
50
  "@deepseek-ai/dsh-client-ui-primitives": "0.1.2-rc.1",
48
51
  "@deepseek-ai/dsh-client-ui-renderer": "0.1.2-rc.1",
52
+ "@deepseek-ai/dsh-client-ui-session": "0.1.2-rc.1",
49
53
  "@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1",
50
54
  "@deepseek-ai/dsh-client-ui-sidebar": "0.1.2-rc.1",
51
55
  "@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",