@genee/omp-opsx-addon 0.2.0 → 0.4.0

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,289 @@
1
+ /**
2
+ * Provider 状态探测模块。
3
+ *
4
+ * 对无 usage fetcher 的 provider(如 ArkCodingPlan)主动发最小探测请求,
5
+ * 区分「429 类错误 vs 正常模型」,驱动底部 widget「可用/耗尽」二元态。
6
+ *
7
+ * 探测是 fire-and-forget 异步操作,绝不阻塞渲染路径。结果写入 provider-status.ts
8
+ * 的进程内状态机(exhausted 集合),渲染自动读取。
9
+ *
10
+ * 探测分类:
11
+ * - ok(2xx)→ markAvailable(清除耗尽)
12
+ * - exhausted(429,或 403 非 region)→ markExhausted
13
+ * - region-blocked(403 + region 关键词)→ markExhausted(归耗尽显示)
14
+ * - auth-error(401)/ model-not-found(404)/ network-error(异常/超时)→ 不写状态
15
+ * (宁缺毋谎:无法区分「provider 真不可用」与「探测请求构造失真」)
16
+ *
17
+ * Cooldown ledger:进程内 Map<canonicalProvider, lastProbedAt>,复用 probe_ttl_ms。
18
+ * markStatusProbeStarted 在任何 await 之前写入,彻底关死 in-flight 竞态窗口。
19
+ */
20
+
21
+ import { REGION_BLOCK_TOKENS } from './reachability-probe.js';
22
+ import { canonicalizeProvider } from './usage-resolver.js';
23
+ import { markExhausted, markAvailable } from './provider-status.js';
24
+ import { getLogger } from './logger.js';
25
+
26
+ // ── 类型 ────────────────────────────────────────────────────────────────
27
+
28
+ /** 状态探测的分类结果。 */
29
+ export type StatusProbeClass =
30
+ | 'ok' // 2xx:真实模型、真实 key,请求走通
31
+ | 'exhausted' // 429,或 403 非 region 文本
32
+ | 'region-blocked' // 403 + region 关键词
33
+ | 'auth-error' // 401
34
+ | 'model-not-found' // 404
35
+ | 'network-error'; // fetch 抛异常 / 超时
36
+
37
+ /** 状态探测结果。 */
38
+ export interface StatusProbeResult {
39
+ provider: string;
40
+ class: StatusProbeClass;
41
+ detail?: string;
42
+ probedAt: number;
43
+ }
44
+
45
+ /** 网络探测选项。 */
46
+ export interface StatusProbeOptions {
47
+ baseUrl: string;
48
+ model: string;
49
+ apiKey: string;
50
+ timeoutMs: number;
51
+ fetchImpl?: typeof fetch;
52
+ }
53
+
54
+ /** 调度配置。 */
55
+ export interface ScheduleConfig {
56
+ enabled: boolean;
57
+ ttlMs: number;
58
+ timeoutMs: number;
59
+ excluded: string[];
60
+ }
61
+
62
+ /** 调度选项。 */
63
+ export interface ScheduleOptions {
64
+ placeholderIds: string[];
65
+ models: { id: string; provider: string; baseUrl?: string }[];
66
+ getApiKey?: (provider: string) => Promise<string | undefined>;
67
+ config: ScheduleConfig;
68
+ fetchImpl?: typeof fetch;
69
+ }
70
+
71
+ // ── 纯分类函数 ─────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * 纯分类:HTTP 状态 + 响应体文本 → 分类。
75
+ * 无 IO,直接单测。
76
+ */
77
+ export function classifyStatusResponse(status: number, bodyText: string): StatusProbeClass {
78
+ // 2xx → ok
79
+ if (status >= 200 && status < 300) {
80
+ return 'ok';
81
+ }
82
+ // 429 → exhausted
83
+ if (status === 429) {
84
+ return 'exhausted';
85
+ }
86
+ // 403 + region 关键词 → region-blocked,否则 exhausted
87
+ if (status === 403) {
88
+ const lower = bodyText.toLowerCase();
89
+ if (REGION_BLOCK_TOKENS.some((tok) => lower.includes(tok))) {
90
+ return 'region-blocked';
91
+ }
92
+ return 'exhausted';
93
+ }
94
+ // 401 → auth-error
95
+ if (status === 401) {
96
+ return 'auth-error';
97
+ }
98
+ // 404 → model-not-found
99
+ if (status === 404) {
100
+ return 'model-not-found';
101
+ }
102
+ // 其余一律 → network-error(detail 记原始 status)
103
+ return 'network-error';
104
+ }
105
+
106
+ // ── 网络探测函数 ───────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * 对单个 provider 执行状态探测。
110
+ * 绝不抛异常:所有错误/超时/解析失败 → network-error 结果。
111
+ */
112
+ export async function probeProviderStatus(
113
+ provider: string,
114
+ opts: StatusProbeOptions,
115
+ ): Promise<StatusProbeResult> {
116
+ const fetchFn = opts.fetchImpl ?? fetch;
117
+ const url = `${opts.baseUrl.replace(/\/+$/, '')}/chat/completions`;
118
+
119
+ try {
120
+ const response = await fetchFn(url, {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ 'Authorization': `Bearer ${opts.apiKey}`,
125
+ },
126
+ body: JSON.stringify({
127
+ model: opts.model,
128
+ messages: [{ role: 'user', content: 'hi' }],
129
+ max_tokens: 1,
130
+ }),
131
+ signal: AbortSignal.timeout(opts.timeoutMs),
132
+ });
133
+
134
+ let bodyText = '';
135
+ try {
136
+ bodyText = await response.text();
137
+ } catch {
138
+ // 响应体读取失败 → 按空文本处理
139
+ bodyText = '';
140
+ }
141
+
142
+ const classification = classifyStatusResponse(response.status, bodyText);
143
+ return {
144
+ provider,
145
+ class: classification,
146
+ detail: classification === 'network-error' ? `status ${response.status}` : undefined,
147
+ probedAt: Date.now(),
148
+ };
149
+ } catch (e: unknown) {
150
+ // 网络/超时/AbortError → network-error,绝不抛
151
+ const err = e as Error & { name?: string };
152
+ const detail = err.name === 'AbortError' || err.name === 'TimeoutError'
153
+ ? 'timeout'
154
+ : (err.message ?? String(e));
155
+ return {
156
+ provider,
157
+ class: 'network-error',
158
+ detail,
159
+ probedAt: Date.now(),
160
+ };
161
+ }
162
+ }
163
+
164
+ // ── Cooldown ledger ─────────────────────────────────────────────────────
165
+
166
+ const probeLedger = new Map<string, number>();
167
+
168
+ /** 检查是否已到下次探测时间。 */
169
+ export function isStatusProbeDue(provider: string, ttlMs: number): boolean {
170
+ const canonical = canonicalizeProvider(provider);
171
+ const lastProbedAt = probeLedger.get(canonical);
172
+ if (lastProbedAt === undefined) {
173
+ return true;
174
+ }
175
+ return Date.now() - lastProbedAt >= ttlMs;
176
+ }
177
+
178
+ /** 标记探测已开始(在任何 await 之前调用,关死竞态窗口)。 */
179
+ export function markStatusProbeStarted(provider: string): void {
180
+ const canonical = canonicalizeProvider(provider);
181
+ probeLedger.set(canonical, Date.now());
182
+ }
183
+
184
+ /** 测试 seam:清空 ledger。 */
185
+ export function _resetForTest(): void {
186
+ probeLedger.clear();
187
+ }
188
+
189
+ // ── 调度入口 ─────────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * 调度状态探测。
193
+ *
194
+ * 探测是 fire-and-forget 异步操作,绝不阻塞调用路径。
195
+ * 返回写入计数(可能为任意非负整数),调用方可据此判断是否需要重渲染。
196
+ *
197
+ * 异常全部吞没:fetch 失败/超时/解析失败/状态机写入失败均不影响其他 provider。
198
+ */
199
+ export async function scheduleStatusProbes(opts: ScheduleOptions): Promise<number> {
200
+ const { placeholderIds, models, getApiKey, config, fetchImpl } = opts;
201
+
202
+ // 快速短路:未启用或无可用的 getApiKey → 零请求,不抛 TypeError
203
+ if (!config.enabled || getApiKey === undefined) {
204
+ return 0;
205
+ }
206
+
207
+ // 同步过滤候选:excluded + 无 baseUrl/无模型
208
+ const excludedSet = new Set(config.excluded.map(canonicalizeProvider));
209
+ const modelMap = new Map<string, { id: string; baseUrl: string }>();
210
+ for (const m of models) {
211
+ if (m.baseUrl && !modelMap.has(canonicalizeProvider(m.provider))) {
212
+ modelMap.set(canonicalizeProvider(m.provider), { id: m.id, baseUrl: m.baseUrl });
213
+ }
214
+ }
215
+
216
+ const candidates = placeholderIds.filter((id) => {
217
+ const canonical = canonicalizeProvider(id);
218
+ if (excludedSet.has(canonical)) {
219
+ return false;
220
+ }
221
+ const modelInfo = modelMap.get(canonical);
222
+ if (!modelInfo) {
223
+ return false;
224
+ }
225
+ // cooldown 检查
226
+ if (!isStatusProbeDue(id, config.ttlMs)) {
227
+ return false;
228
+ }
229
+ return true;
230
+ });
231
+
232
+ if (candidates.length === 0) {
233
+ return 0;
234
+ }
235
+
236
+ // 对每个候选:先 markStatusProbeStarted(占位),再 await getApiKey
237
+ // 关死 in-flight 竞态窗口
238
+ const probePromises = candidates.map(async (provider) => {
239
+ markStatusProbeStarted(provider);
240
+ const apiKey = await getApiKey(provider);
241
+ if (!apiKey) {
242
+ // 无 key → 跳过请求但保留 cooldown 占位(下个 TTL 再查)
243
+ return null;
244
+ }
245
+ const modelInfo = modelMap.get(canonicalizeProvider(provider));
246
+ if (!modelInfo) {
247
+ return null;
248
+ }
249
+ return probeProviderStatus(provider, {
250
+ baseUrl: modelInfo.baseUrl,
251
+ model: modelInfo.id,
252
+ apiKey,
253
+ timeoutMs: config.timeoutMs,
254
+ fetchImpl,
255
+ });
256
+ });
257
+
258
+ // 并发探测,单个失败不连坐
259
+ const results = await Promise.allSettled(probePromises);
260
+ let written = 0;
261
+
262
+ for (const result of results) {
263
+ if (result.status === 'fulfilled') {
264
+ const probeResult = result.value;
265
+ if (!probeResult) {
266
+ continue; // 跳过的 provider
267
+ }
268
+ const canonical = canonicalizeProvider(probeResult.provider);
269
+ // 映射到状态机:exhausted/region-blocked → markExhausted;ok → markAvailable
270
+ if (probeResult.class === 'exhausted' || probeResult.class === 'region-blocked') {
271
+ markExhausted(probeResult.provider);
272
+ written++;
273
+ getLogger().info(`[omp-opsx-addon] status probe: ${canonical} -> ${probeResult.class}`);
274
+ } else if (probeResult.class === 'ok') {
275
+ markAvailable(probeResult.provider);
276
+ written++;
277
+ getLogger().info(`[omp-opsx-addon] status probe: ${canonical} -> ok`);
278
+ } else {
279
+ // auth-error/model-not-found/network-error 不写状态
280
+ getLogger().warn(
281
+ `[omp-opsx-addon] status probe: ${canonical} -> ${probeResult.class}${probeResult.detail ? ` (${probeResult.detail})` : ''}`,
282
+ );
283
+ }
284
+ }
285
+ // rejected promise 在理论上不应发生(probeProviderStatus 已吞没所有异常),防御性忽略
286
+ }
287
+
288
+ return written;
289
+ }
@@ -100,15 +100,15 @@ export function buildStaticPrompt(): string {
100
100
  | 审查代码实现 | task(agent:"reviewer", task:"审阅代码 + 执行全局验证(lint/单测/e2e)") |
101
101
 
102
102
  ## 2 个 Loop
103
- - **Loop 1 – Propose → Review**:planner 完成 → proposal-reviewer → P0/P1 发回 → 通过告知用户。Propose-review loop:最多 planner→reviewer 往复 2 轮。
104
- - **Loop 2 – Code → Review**:coder 完成(交付前自验证)→ code-reviewer 审阅 + 全局验证 → P0/P1 发回修复 → 仅 P2+ 视为通过。Code-review loop:最多 coder→reviewer 往复 3 轮(含首次实现),reviewer 兼执行全局验证。
103
+ - **Loop 1 – Propose → Review**:planner 完成 → proposal-reviewer → P0/P1 发回 → 通过告知用户。Propose-review loop:最多 planner→reviewer 往复 2 轮。委派 planner/proposal-reviewer 的 task 描述须含『先读 openspec/changes/<name>/scratchpad.md,禁止重复探索』。
104
+ - **Loop 2 – Code → Review**:coder 完成(交付前自验证)→ code-reviewer 审阅 + 全局验证 → P0/P1 发回修复 → 仅 P2+ 视为通过。Code-review loop:最多 coder→reviewer 往复 3 轮(含首次实现),reviewer 兼执行全局验证。委派 coder/code-reviewer 的 task 描述须含『先读 openspec/changes/<name>/scratchpad.md,禁止重复探索』。
105
105
  - 达上限时停止自动流转,向用户说明未解决的问题并请求决策。
106
106
 
107
107
  ## /goal 集成(可选)
108
108
  Planner 在 proposal 末尾输出 \`## Budget Estimate\`;主 agent 提取用于 \`/goal\`。
109
109
 
110
110
  ## 自动流程协作
111
- coder 输出 STATUS: blocked 且含 SESSION: → 阅读阻塞原因决策;收到 P0/P1 → 修复再审。
111
+ coder 输出 STATUS: blocked 且含 SESSION: → 阅读阻塞原因决策;收到 P0/P1 → 修复再审。P0/P1 发回修复时 task 描述同样须含『先读 openspec/changes/<name>/scratchpad.md,禁止重复探索』。
112
112
 
113
113
  ## 上下文卫生(主 session 预算)
114
114
  - 代码调研/搜代码/理解架构 → task(agent:"scout");主 session 不亲自 read 全文调研。
@@ -118,7 +118,7 @@ coder 输出 STATUS: blocked 且含 SESSION: → 阅读阻塞原因决策;收到
118
118
  - 主 session 上下文超过 ~50k tokens:必须先委托再继续,禁止继续亲自调研。`;
119
119
  }
120
120
 
121
- const STATIC_LINE_COUNT = buildStaticPrompt().split('\n').length;
121
+ export const STATIC_LINE_COUNT = buildStaticPrompt().split('\n').length;
122
122
 
123
123
  /** Dynamic layer: Dispatch config + change status. Placed after the static
124
124
  * layer so only this tail is re-prefilled when it changes between turns. */
@@ -20,7 +20,11 @@
20
20
  * which models the auto-selector may pick from.
21
21
  * - `tiers`: list of `{ pattern, tier }` overrides for tier scoring.
22
22
  * - `role_tiers`: `role → tier_name` map overriding the default expected
23
- * tier (DEFAULT_ROLE_TIER in model-tiers).
23
+ * tier (DEFAULT_ROLE_TIER in model-tiers) for the four opsx agents.
24
+ * - `model_role_tiers`: `omp_role → tier_name | 'skip'` map controlling
25
+ * which selector tier each OMP model role (default/smol/slow/vision/plan/
26
+ * designer/commit/tiny/task/advisor + custom roles) follows after
27
+ * /pick-model; `skip` excludes the role from the session overlay.
24
28
  */
25
29
 
26
30
  import { existsSync, readFileSync } from 'fs';
@@ -60,12 +64,15 @@ export interface OpsxYamlShape {
60
64
  model_allowlist?: string[];
61
65
  tiers?: TierOverride[];
62
66
  role_tiers?: Partial<Record<OpsxRole, TierName>>;
67
+ /** OMP model role → tier override; `skip` excludes the role from the overlay. Keys are arbitrary strings (custom roles allowed). */
68
+ model_role_tiers?: Record<string, string>;
63
69
  plan_providers?: string[];
64
70
  probe_enabled?: boolean;
65
71
  probe_timeout_ms?: number;
66
72
  probe_http_enabled?: boolean;
67
73
  probe_ttl_ms?: number;
68
74
  excluded_providers?: string[];
75
+ status_probe_enabled?: boolean;
69
76
  redis_enabled?: boolean;
70
77
  redis_host?: string;
71
78
  redis_port?: number;
@@ -89,6 +96,8 @@ export interface ResolvedOpsxConfig {
89
96
  modelAllowlist: string[];
90
97
  tierOverrides: TierOverride[];
91
98
  roleTiers: Partial<Record<OpsxRole, TierName>>;
99
+ /** OMP model role → tier (or `'skip'`); drives the pick-model role overlay. */
100
+ ompRoleTiers: Record<string, TierName | 'skip'>;
92
101
  /** Provider ids that have monthly plan subscriptions. */
93
102
  planProviders: string[];
94
103
  warnings: string[];
@@ -102,6 +111,8 @@ export interface ResolvedOpsxConfig {
102
111
  probe_ttl_ms: number;
103
112
  /** 静态排除的 provider 列表(零探测成本) */
104
113
  excluded_providers: string[];
114
+ /** 是否开启状态探测,默认 true */
115
+ status_probe_enabled: boolean;
105
116
  /** 是否启用本地 Redis 实时中转(默认 true;false 时纯文件路径)。 */
106
117
  redis_enabled: boolean;
107
118
  /** Redis host(默认 127.0.0.1)。 */
@@ -214,6 +225,36 @@ export function parseRoleTiers(
214
225
  return out;
215
226
  }
216
227
 
228
+ /**
229
+ * Validate `model_role_tiers` entries. Keys are OMP model role names
230
+ * (built-in OR custom — never checked against the built-in set); values
231
+ * must be a tier name or the sentinel `'skip'`. Invalid entries are dropped
232
+ * with a warning.
233
+ */
234
+ export function parseOmpRoleTiers(
235
+ raw: unknown,
236
+ warn: (msg: string) => void,
237
+ ): Record<string, TierName | 'skip'> {
238
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
239
+ const out: Record<string, TierName | 'skip'> = {};
240
+ for (const [role, v] of Object.entries(raw as Record<string, unknown>)) {
241
+ if (typeof v !== 'string') {
242
+ warn(`[omp-opsx-addon] model_role_tiers.${role}: expected tier name or 'skip', got ${typeof v}`);
243
+ continue;
244
+ }
245
+ if (v === 'skip') {
246
+ out[role] = 'skip';
247
+ continue;
248
+ }
249
+ if (!TIER_NAMES.includes(v as TierName)) {
250
+ warn(`[omp-opsx-addon] model_role_tiers.${role}: expected tier name or 'skip' (got "${v}"; use one of ${TIER_NAMES.join('/')})`);
251
+ continue;
252
+ }
253
+ out[role] = v as TierName;
254
+ }
255
+ return out;
256
+ }
257
+
217
258
  /** Validate the model_allowlist (array of glob strings). */
218
259
  export function parseModelAllowlist(
219
260
  raw: unknown,
@@ -302,6 +343,7 @@ export function parseOpsxConfig(
302
343
  modelAllowlist: parseModelAllowlist(obj.model_allowlist, localWarn),
303
344
  tierOverrides: parseTierOverrides(obj.tiers, localWarn),
304
345
  roleTiers: parseRoleTiers(obj.role_tiers, localWarn),
346
+ ompRoleTiers: parseOmpRoleTiers(obj.model_role_tiers, localWarn),
305
347
  planProviders: sanitizePlanProviders(obj.plan_providers),
306
348
  warnings,
307
349
  probe_enabled: parseProbeBool(obj.probe_enabled, 'probe_enabled', true, localWarn),
@@ -309,6 +351,7 @@ export function parseOpsxConfig(
309
351
  probe_http_enabled: parseProbeBool(obj.probe_http_enabled, 'probe_http_enabled', false, localWarn),
310
352
  probe_ttl_ms: parsePositiveInt(obj.probe_ttl_ms, 'probe_ttl_ms', 600_000, localWarn),
311
353
  excluded_providers: parseStringArray(obj.excluded_providers, 'excluded_providers', localWarn),
354
+ status_probe_enabled: parseProbeBool(obj.status_probe_enabled, 'status_probe_enabled', true, localWarn),
312
355
  redis_enabled: parseProbeBool(obj.redis_enabled, 'redis_enabled', true, localWarn),
313
356
  redis_host: redisHost,
314
357
  redis_port: parsePositiveInt(obj.redis_port, 'redis_port', 6379, localWarn),
@@ -353,12 +396,14 @@ function pickTopLevelKeys(raw: OpsxYamlShape): OpsxYamlShape {
353
396
  model_allowlist: raw.model_allowlist,
354
397
  tiers: raw.tiers,
355
398
  role_tiers: raw.role_tiers,
399
+ model_role_tiers: raw.model_role_tiers,
356
400
  plan_providers: raw.plan_providers,
357
401
  probe_enabled: raw.probe_enabled,
358
402
  probe_timeout_ms: raw.probe_timeout_ms,
359
403
  probe_http_enabled: raw.probe_http_enabled,
360
404
  probe_ttl_ms: raw.probe_ttl_ms,
361
405
  excluded_providers: raw.excluded_providers,
406
+ status_probe_enabled: raw.status_probe_enabled,
362
407
  redis_enabled: raw.redis_enabled,
363
408
  redis_host: raw.redis_host,
364
409
  redis_port: raw.redis_port,
@@ -443,12 +488,14 @@ export function readOpsxSettingsFromPaths(
443
488
  merged.model_allowlist = projectTop.model_allowlist ?? globalTop.model_allowlist;
444
489
  merged.tiers = projectTop.tiers ?? globalTop.tiers;
445
490
  merged.role_tiers = projectTop.role_tiers ?? globalTop.role_tiers;
491
+ merged.model_role_tiers = projectTop.model_role_tiers ?? globalTop.model_role_tiers;
446
492
  merged.plan_providers = projectTop.plan_providers ?? globalTop.plan_providers;
447
493
  merged.probe_enabled = projectTop.probe_enabled ?? globalTop.probe_enabled;
448
494
  merged.probe_timeout_ms = projectTop.probe_timeout_ms ?? globalTop.probe_timeout_ms;
449
495
  merged.probe_http_enabled = projectTop.probe_http_enabled ?? globalTop.probe_http_enabled;
450
496
  merged.probe_ttl_ms = projectTop.probe_ttl_ms ?? globalTop.probe_ttl_ms;
451
497
  merged.excluded_providers = projectTop.excluded_providers ?? globalTop.excluded_providers;
498
+ merged.status_probe_enabled = projectTop.status_probe_enabled ?? globalTop.status_probe_enabled;
452
499
  merged.redis_enabled = projectTop.redis_enabled ?? globalTop.redis_enabled;
453
500
  merged.redis_host = projectTop.redis_host ?? globalTop.redis_host;
454
501
  merged.redis_port = projectTop.redis_port ?? globalTop.redis_port;