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.
@@ -1,39 +1,10 @@
1
1
  import { GatewayError } from "./ai-router-client.js";
2
+ import { NO_BASE_URL_ERROR, resolveDeviceInfo, startBrowserLogin } from "./browser-login.js";
2
3
  import { API_KEY_REF, SESSION_REF, PENDING_PASSWORD_REF, REMEMBERED_PASSWORD_REF, buildPiAiProviderProfiles, computeModelIds, computeRouteModels, } from "./provision.js";
3
- /**
4
- * 归一化 budgets:过滤空模型名与已下架模型(model_status === 'inactive',已不可调用,
5
- * 不进入额度展示),保留额度字段。状态缺省(旧网关)视同 active,正常保留。
6
- */
7
- export function computeBudgets(budgets) {
8
- return budgets
9
- .filter((b) => typeof b.model_name === 'string' && b.model_name.length > 0)
10
- .filter((b) => b.model_status !== 'inactive')
11
- .map((b) => ({
12
- model_name: b.model_name,
13
- ...(b.total_budget === undefined ? {} : { total_budget: b.total_budget }),
14
- ...(b.consumed_budget === undefined ? {} : { consumed_budget: b.consumed_budget }),
15
- ...(b.remaining_budget === undefined ? {} : { remaining_budget: b.remaining_budget }),
16
- }));
17
- }
18
- /** 归一化 keys:过滤无 id 项,只保留选择器需要的字段。 */
19
- export function computeKeys(keys) {
20
- return keys
21
- .filter((k) => typeof k.id === 'string' && k.id.length > 0)
22
- .map((k) => ({
23
- id: k.id,
24
- ...(k.employee_name === undefined ? {} : { employee_name: k.employee_name }),
25
- ...(k.team_name === undefined ? {} : { team_name: k.team_name }),
26
- ...(k.enabled === undefined ? {} : { enabled: k.enabled }),
27
- ...(k.max_concurrency === undefined ? {} : { max_concurrency: k.max_concurrency }),
28
- ...(k.key_masked === undefined ? {} : { key_masked: k.key_masked }),
29
- }));
30
- }
31
- /** 任意抛错 → 稳定 LoginError;GatewayError 原样,其余归为 GATEWAY_UNREACHABLE。 */
32
- export function toLoginError(error, fallback) {
33
- if (error instanceof GatewayError)
34
- return { code: error.code, message: error.message };
35
- return { code: 'GATEWAY_UNREACHABLE', message: fallback };
36
- }
4
+ import { computeBudgets, computeKeys, provisionSession, toLoginError, } from "./provision-session.js";
5
+ // 端口契约与供给尾部现居 src/provision-session.ts(供 browser-login.ts 共用且避免循环依赖);
6
+ // 此处原样再导出,保持既有导入面(index.ts / 测试)不变。
7
+ export { computeBudgets, computeKeys, provisionSession, toLoginError } from "./provision-session.js";
37
8
  /** 执行一次登录:拉 Key + budgets → 写凭据 → 写 pi-ai 配置 → 写状态。 */
38
9
  export async function runLogin(opts) {
39
10
  const pending = opts.value.pendingLogin;
@@ -49,45 +20,27 @@ export async function runLogin(opts) {
49
20
  const password = await opts.credentials.resolve(pending.passwordRef);
50
21
  if (password === undefined)
51
22
  throw new GatewayError('AUTH_FAILED', '登录凭据缺失,请重新登录');
52
- const { sessionCookie, user } = await opts.gateway.login(pending.username, password.value);
23
+ // 表单回退也上报设备信息(网关记录登录设备);设备未知时 resolveDeviceInfo 会生成,
24
+ // 任何情况下都不得因设备信息让登录失败。
25
+ const device = await resolveDeviceInfo(opts.credentials);
26
+ const { sessionCookie, user } = await opts.gateway.login(pending.username, password.value, device);
53
27
  await opts.credentials.set(SESSION_REF, sessionCookie);
54
- const keys = await opts.gateway.getMyKeys(sessionCookie);
55
- const key = await opts.gateway.getMyKey(sessionCookie, opts.value.keyId);
56
- const modelIds = computeModelIds(key.budgets);
57
- await opts.credentials.set(API_KEY_REF, key.key.key);
58
- if (opts.value.rememberPassword === true)
59
- await opts.credentials.set(REMEMBERED_PASSWORD_REF, password.value);
60
- else
61
- await opts.credentials.unset(REMEMBERED_PASSWORD_REF).catch(() => { });
62
- if (pending.passwordRef !== REMEMBERED_PASSWORD_REF)
63
- await opts.credentials.unset(pending.passwordRef);
64
- await opts.piAi.applyAiRouterProfiles(modelIds.length > 0
65
- ? buildPiAiProviderProfiles({ baseURL, routeModels: computeRouteModels(key.budgets, key.model_mapping ?? []) })
66
- : null);
67
- // 中文名(display_name)随登录响应透传;缺失/空白时清掉旧值,UI 回退 username。
68
- const displayName = user.display_name?.trim() || undefined;
69
- const set = {
28
+ await provisionSession({
29
+ ...opts,
30
+ baseURL,
31
+ sessionCookie,
32
+ user,
70
33
  username: pending.username,
71
- ...(displayName === undefined ? {} : { displayName }),
72
- lastLogin: {
73
- at: new Date().toISOString(),
74
- modelCount: modelIds.length,
75
- gatewayName: key.gateway_name ?? '',
34
+ requestUnset: ['pendingLogin'],
35
+ onApiKeyWritten: async () => {
36
+ if (opts.value.rememberPassword === true)
37
+ await opts.credentials.set(REMEMBERED_PASSWORD_REF, password.value);
38
+ else
39
+ await opts.credentials.unset(REMEMBERED_PASSWORD_REF).catch(() => { });
40
+ if (pending.passwordRef !== REMEMBERED_PASSWORD_REF)
41
+ await opts.credentials.unset(pending.passwordRef);
76
42
  },
77
- budgets: computeBudgets(key.budgets),
78
- keys: computeKeys(keys),
79
- };
80
- const unset = ['pendingLogin'];
81
- if (displayName === undefined)
82
- unset.push('displayName');
83
- if (key.key.enabled === false) {
84
- set.lastError = { code: 'KEY_DISABLED', message: 'Key 当前被禁用,调用会失败' };
85
- }
86
- else {
87
- unset.push('lastError');
88
- }
89
- await opts.state.patch({ set, unset });
90
- await opts.ensureWorkspace?.();
43
+ });
91
44
  return null;
92
45
  }
93
46
  catch (error) {
@@ -119,9 +72,10 @@ export async function runRefresh(opts) {
119
72
  const key = await opts.gateway.getMyKey(session.value, opts.value.keyId);
120
73
  const modelIds = computeModelIds(key.budgets);
121
74
  await opts.credentials.set(API_KEY_REF, key.key.key);
122
- await opts.piAi.applyAiRouterProfiles(modelIds.length > 0
123
- ? buildPiAiProviderProfiles({ baseURL, routeModels: computeRouteModels(key.budgets, key.model_mapping ?? []) })
124
- : null);
75
+ const routeModels = modelIds.length > 0 ? computeRouteModels(key.budgets, key.model_mapping ?? []) : undefined;
76
+ await opts.piAi.applyAiRouterProfiles(routeModels === undefined ? null : buildPiAiProviderProfiles({ baseURL, routeModels }));
77
+ if (routeModels !== undefined)
78
+ await opts.defaultModel?.ensure(routeModels);
125
79
  const set = {
126
80
  lastLogin: {
127
81
  at: new Date().toISOString(),
@@ -174,14 +128,30 @@ export async function runLogout(opts) {
174
128
  await opts.credentials.unset(REMEMBERED_PASSWORD_REF).catch(() => { });
175
129
  await opts.piAi.applyAiRouterProfiles(null);
176
130
  await opts.state.patch({
177
- unset: ['pendingLogin', 'refreshRequest', 'logoutRequest', 'username', 'displayName', 'lastLogin', 'lastError', 'keyId', 'budgets', 'keys'],
131
+ unset: [
132
+ 'pendingLogin', 'refreshRequest', 'logoutRequest', 'browserLoginRequest', 'browserLogin',
133
+ 'username', 'displayName', 'lastLogin', 'lastError', 'keyId', 'budgets', 'keys',
134
+ ],
178
135
  });
179
136
  }
180
- /** 分发一次 ai-router 命名空间变更:三个请求字段至多存在一个。 */
137
+ /**
138
+ * 浏览器授权登录的发起:生成 PKCE 与 state,回写 `browserLogin` 供渲染端 window.open。
139
+ * 见 browser-login.ts;此处只做分发与错误码归一。
140
+ */
141
+ export async function handleBrowserLoginRequest(opts) {
142
+ const started = await startBrowserLogin(opts);
143
+ if (started !== undefined)
144
+ return null;
145
+ // startBrowserLogin 已在状态里写了同一个 lastError(缺 baseURL)。
146
+ return NO_BASE_URL_ERROR;
147
+ }
148
+ /** 分发一次 ai-router 命名空间变更:请求字段至多存在一个。 */
181
149
  export async function handleAiRouterSectionChange(opts) {
182
150
  const v = opts.value;
183
151
  if (v.pendingLogin !== undefined)
184
152
  return runLogin(opts);
153
+ if (v.browserLoginRequest !== undefined)
154
+ return handleBrowserLoginRequest(opts);
185
155
  if (v.refreshRequest !== undefined)
186
156
  return runRefresh(opts);
187
157
  if (v.logoutRequest !== undefined) {
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 会话供给的共享契约与实现:端口接口、状态字段形状、归一化与错误映射,
3
+ * 以及「会话已建立后」的供给尾部 `provisionSession`。
4
+ *
5
+ * 本模块被 `orchestration.ts`(表单登录 / 刷新 / 登出)与 `browser-login.ts`
6
+ * (浏览器授权登录)各自直接 import,**它自身不 import 这两者**,因此不存在循环依赖
7
+ * (打包产物里也不会出现模块级 TDZ 风险)。
8
+ */
9
+ import { GatewayError } from "./ai-router-client.js";
10
+ import { API_KEY_REF, buildPiAiProviderProfiles, computeModelIds, computeRouteModels, } from "./provision.js";
11
+ /**
12
+ * 归一化 budgets:过滤空模型名与已下架模型(model_status === 'inactive',已不可调用,
13
+ * 不进入额度展示),保留额度字段。状态缺省(旧网关)视同 active,正常保留。
14
+ */
15
+ export function computeBudgets(budgets) {
16
+ return budgets
17
+ .filter((b) => typeof b.model_name === 'string' && b.model_name.length > 0)
18
+ .filter((b) => b.model_status !== 'inactive')
19
+ .map((b) => ({
20
+ model_name: b.model_name,
21
+ ...(b.total_budget === undefined ? {} : { total_budget: b.total_budget }),
22
+ ...(b.consumed_budget === undefined ? {} : { consumed_budget: b.consumed_budget }),
23
+ ...(b.remaining_budget === undefined ? {} : { remaining_budget: b.remaining_budget }),
24
+ }));
25
+ }
26
+ /** 归一化 keys:过滤无 id 项,只保留选择器需要的字段。 */
27
+ export function computeKeys(keys) {
28
+ return keys
29
+ .filter((k) => typeof k.id === 'string' && k.id.length > 0)
30
+ .map((k) => ({
31
+ id: k.id,
32
+ ...(k.employee_name === undefined ? {} : { employee_name: k.employee_name }),
33
+ ...(k.team_name === undefined ? {} : { team_name: k.team_name }),
34
+ ...(k.enabled === undefined ? {} : { enabled: k.enabled }),
35
+ ...(k.max_concurrency === undefined ? {} : { max_concurrency: k.max_concurrency }),
36
+ ...(k.key_masked === undefined ? {} : { key_masked: k.key_masked }),
37
+ }));
38
+ }
39
+ /** 任意抛错 → 稳定 LoginError;GatewayError 原样,其余归为 GATEWAY_UNREACHABLE。 */
40
+ export function toLoginError(error, fallback) {
41
+ if (error instanceof GatewayError)
42
+ return { code: error.code, message: error.message };
43
+ return { code: 'GATEWAY_UNREACHABLE', message: fallback };
44
+ }
45
+ /**
46
+ * 会话已建立后的共享供给尾部:拉 Key → 写凭据 → 写 pi-ai 配置 → 校正默认模型 → 写状态 →
47
+ * ensure 工作区。表单登录(orchestration.ts 的 runLogin)与浏览器授权登录
48
+ * (browser-login.ts)共用同一段,保证两条登录路径的供给结果逐字段等价。
49
+ */
50
+ export async function provisionSession(opts) {
51
+ const keys = await opts.gateway.getMyKeys(opts.sessionCookie);
52
+ const key = await opts.gateway.getMyKey(opts.sessionCookie, opts.value.keyId);
53
+ const modelIds = computeModelIds(key.budgets);
54
+ await opts.credentials.set(API_KEY_REF, key.key.key);
55
+ await opts.onApiKeyWritten?.();
56
+ // 线路归属只算一次:写 profile 与校正默认模型都用它。
57
+ const routeModels = modelIds.length > 0 ? computeRouteModels(key.budgets, key.model_mapping ?? []) : undefined;
58
+ await opts.piAi.applyAiRouterProfiles(routeModels === undefined ? null : buildPiAiProviderProfiles({ baseURL: opts.baseURL, routeModels }));
59
+ if (routeModels !== undefined)
60
+ await opts.defaultModel?.ensure(routeModels);
61
+ // 中文名(display_name)随登录响应透传;缺失/空白时清掉旧值,UI 回退 username。
62
+ const displayName = opts.user.display_name?.trim() || undefined;
63
+ const set = {
64
+ ...(opts.username === undefined ? {} : { username: opts.username }),
65
+ ...(displayName === undefined ? {} : { displayName }),
66
+ lastLogin: {
67
+ at: new Date().toISOString(),
68
+ modelCount: modelIds.length,
69
+ gatewayName: key.gateway_name ?? '',
70
+ },
71
+ budgets: computeBudgets(key.budgets),
72
+ keys: computeKeys(keys),
73
+ };
74
+ const unset = [...(opts.requestUnset ?? [])];
75
+ if (displayName === undefined)
76
+ unset.push('displayName');
77
+ if (key.key.enabled === false) {
78
+ set.lastError = { code: 'KEY_DISABLED', message: 'Key 当前被禁用,调用会失败' };
79
+ }
80
+ else {
81
+ unset.push('lastError');
82
+ }
83
+ await opts.state.patch({ set, unset });
84
+ await opts.ensureWorkspace?.();
85
+ }
package/lib/provision.js CHANGED
@@ -9,36 +9,66 @@ export const REMEMBERED_PASSWORD_REF = 'AI_ROUTER_REMEMBERED_PASSWORD';
9
9
  /**
10
10
  * 注册到 llm-pi-ai 的协议路由表:路由名 → pi-ai 协议。
11
11
  * 网关按「入站协议 + 模型」匹配模型映射(internal/service/provider_service.go),
12
- * 而 pi-ai 的 `api` 是路由级的 —— 因此每个协议一条路由,模型按映射归属到对应路由,
12
+ * 而 pi-ai 的 `api` 是路由级的 —— 因此每个协议一条路由,模型按映射进入对应路由,
13
13
  * 切模型时 dsh 自动落到讲该协议的路由,协议随模型走。
14
- * 主路由固定用 Responses 协议(/v1/responses),OpenAI 兼容模型统一落到它。
14
+ *
15
+ * 路由名固定为「线路C/M/R」,后缀即该路由讲的协议首字母(同一模型可出现在多条线路里):
16
+ * - 线路C `ai-router-chat`:openai-completions(chat/completions,兜底协议)
17
+ * - 线路M `ai-router-anthropic`:anthropic-messages
18
+ * - 线路R `ai-router`:openai-responses(key 与协议保持不变,兼容既有
19
+ * `agent-default-model: provider: ai-router`)
15
20
  */
16
21
  export const PROVIDER_ROUTES = {
17
- 'ai-router': 'openai-responses',
22
+ 'ai-router-chat': 'openai-completions',
18
23
  'ai-router-anthropic': 'anthropic-messages',
24
+ 'ai-router': 'openai-responses',
19
25
  };
20
26
  /** DSH 当前能接受的模态集。网关可声明 video/audio,但 DSH 尚不能接,故输入前求交过滤。 */
21
27
  export const DSH_SUPPORTED_MODALITIES = ['text', 'image'];
22
- /** 固定写入顺序:先主路由(兼容 agent-default-model 的 provider: ai-router)。 */
23
- export const PROVIDER_ROUTE_NAMES = ['ai-router', 'ai-router-anthropic'];
24
- /** 旧版独立 responses 路由(现已并入主路由);仅用于升级时清理残留 provider。 */
28
+ /** 固定写入顺序:线路C → 线路M → 线路R(按后缀字母序,与模型选择器的分组顺序一致)。 */
29
+ export const PROVIDER_ROUTE_NAMES = [
30
+ 'ai-router-chat', 'ai-router-anthropic', 'ai-router',
31
+ ];
32
+ /** 旧版独立 responses 路由(现已并入线路R);仅用于升级时清理残留 provider。 */
25
33
  export const LEGACY_PROVIDER_ROUTE_NAMES = ['ai-router-responses'];
26
- /** 各路由在 dsh Models 页的展示名(主路由保留原名,避免破坏既有引用)。 */
34
+ /**
35
+ * 各路由在 dsh Models 页的展示名(模型选择器的分组标题)。
36
+ * 后缀即该路由讲的协议首字母:C = chat/completions、M = messages、R = responses。
37
+ * 同一个模型可以同时出现在多条线路里(按协议分组,不做唯一归属)。
38
+ */
27
39
  const ROUTE_DISPLAY_NAMES = {
28
- 'ai-router': 'AI Router',
29
- 'ai-router-anthropic': 'AI Router · Anthropic',
40
+ 'ai-router-chat': '线路C',
41
+ 'ai-router-anthropic': '线路M',
42
+ 'ai-router': '线路R',
30
43
  };
44
+ /**
45
+ * 网关模型映射协议名(下划线式),即 `models.ProtocolType` 的取值集合
46
+ * (spec §6.2 词汇表)。计价端点 `POST /api/me/cost-estimate` 的 `route` 用这套名字,
47
+ * 而 DSH 会话里的 `routes[].provider` 是**线路名**(连字符式)——两者在 Host 侧用
48
+ * {@link routeToProtocol} 翻译,客户端不需要知道网关协议名。
49
+ */
50
+ export const GATEWAY_PROTOCOLS = ['openai_chat_completions', 'anthropic_messages', 'openai_responses'];
31
51
  /** 网关模型映射协议名(下划线式)→ 插件路由(连字符式)。 */
32
52
  const GATEWAY_PROTOCOL_TO_ROUTE = {
33
- openai_chat_completions: 'ai-router',
34
- anthropic_messages: 'ai-router-anthropic',
35
53
  openai_responses: 'ai-router',
54
+ anthropic_messages: 'ai-router-anthropic',
55
+ openai_chat_completions: 'ai-router-chat',
36
56
  };
37
57
  /**
38
- * 模型归属协议的优先级:OpenAI 兼容映射(chat/responses)统一落主路由(responses),
39
- * anthropic 模型落 anthropic 路由。仅决定「多协议可用时归谁」,单协议模型不受影响。
58
+ * 线路名 → 网关协议名({@link GATEWAY_PROTOCOL_TO_ROUTE} 的反查,单点真相)。
59
+ *
60
+ * 未知线路与遗留线路(`ai-router-responses`,升级时已清理)一律返回 `undefined`——
61
+ * 调用方必须**丢弃**该轮而不是上报:网关对 `route` 做严格相等匹配,非法值会让整批 400。
62
+ * @param route - DSH 线路名(会话 `routes[].provider` 的取值)。
63
+ * @returns 网关协议名;不在映射内时为 undefined。
40
64
  */
41
- const ROUTE_PRIORITY = ['ai-router', 'ai-router-anthropic'];
65
+ export function routeToProtocol(route) {
66
+ for (const protocol of GATEWAY_PROTOCOLS) {
67
+ if (GATEWAY_PROTOCOL_TO_ROUTE[protocol] === route)
68
+ return protocol;
69
+ }
70
+ return undefined;
71
+ }
42
72
  /**
43
73
  * 从 budgets 归一化出去重、非空的模型 id 列表。已下架模型(model_status === 'inactive',
44
74
  * 网关 PreCheck 直接 402)不在「能成功调用」集合内,剔除;状态缺省(旧网关)视同 active。
@@ -111,27 +141,47 @@ function matchesRoute(item, route, model) {
111
141
  return sourceMatches(item.source_model, model);
112
142
  }
113
143
  /**
114
- * 按网关模型映射把预算内模型拆到各协议路由:
115
- * 每个模型归属「优先级最高且映射命中(精确或通配)的协议」;
116
- * 无任何映射命中的模型落到主路由(默认 responses,网关侧仍有 `*` 兜底,保持预算内模型均可选)。
144
+ * 按网关模型映射把预算内模型拆到各协议路由(一条路由一个协议):
145
+ * 模型进入**每一条**「映射命中(精确或通配)的协议」对应的线路 —— 按协议分组,不按模型唯一归属,
146
+ * 所以同一个模型可以同时出现在多条线路里(例如既声明了 responses 又声明了 anthropic 映射的模型,
147
+ * 线路R 和线路M 都会列出它),不做去重:用户从哪条线路选它,请求就走那条线路的协议。
148
+ * 无任何映射命中的模型落到兜底线路 chat/completions:网关的裸 `*` 兜底映射通常声明在该协议下,
149
+ * 用 responses 入站反而匹配不到任何映射(网关按入站协议过滤映射)。
117
150
  * 返回每模型描述符(含 DSH 可接受的 input 模态),供 profile 注入。
118
151
  */
119
152
  export function computeRouteModels(budgets, mapping) {
120
153
  const routes = {};
121
154
  const unmatched = [];
122
155
  for (const model of computeModelDescriptors(budgets)) {
123
- const route = ROUTE_PRIORITY.find((r) => mapping.some((item) => matchesRoute(item, r, model.id)));
124
- if (route === undefined) {
125
- unmatched.push(model);
126
- continue;
156
+ let matched = false;
157
+ for (const route of PROVIDER_ROUTE_NAMES) {
158
+ if (!mapping.some((item) => matchesRoute(item, route, model.id)))
159
+ continue;
160
+ (routes[route] ??= []).push(model);
161
+ matched = true;
127
162
  }
128
- ;
129
- (routes[route] ??= []).push(model);
163
+ if (!matched)
164
+ unmatched.push(model);
130
165
  }
131
166
  if (unmatched.length > 0)
132
- routes['ai-router'] = [...(routes['ai-router'] ?? []), ...unmatched];
167
+ routes['ai-router-chat'] = [...(routes['ai-router-chat'] ?? []), ...unmatched];
133
168
  return routes;
134
169
  }
170
+ /**
171
+ * 兜底默认模型:按线路顺序 C→M→R 取第一条非空线路的第一个模型。
172
+ * 线路C 讲的 chat/completions 最通用(网关的裸 `*` 兜底映射也声明在该协议下),
173
+ * 因此优先;模型顺序沿用网关 `/api/me/key` 的 budgets 顺序。
174
+ * @param routeModels - 各线路的模型归属。
175
+ * @returns 兜底选择;所有线路都为空时 undefined。
176
+ */
177
+ export function pickFallbackDefaultModel(routeModels) {
178
+ for (const route of PROVIDER_ROUTE_NAMES) {
179
+ const first = routeModels[route]?.[0];
180
+ if (first !== undefined)
181
+ return { provider: route, model: first.id };
182
+ }
183
+ return undefined;
184
+ }
135
185
  /**
136
186
  * 各协议的 pi-ai baseURL:OpenAI 系 SDK 把 baseURL 视为根(追加 chat/completions、
137
187
  * responses),补 `/v1`;Anthropic SDK 自带 `v1/` 前缀,必须传裸 base,