@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.
- package/README.md +82 -0
- package/index.ts +184 -66
- package/lib/agent-defs.ts +40 -4
- package/lib/edit-variant-pin.ts +4 -4
- package/lib/logger.ts +33 -0
- package/lib/model-roles.ts +270 -0
- package/lib/pipe-core.ts +784 -0
- package/lib/pipe-push.ts +467 -0
- package/lib/reachability-cache.ts +2 -1
- package/lib/reachability-probe.ts +4 -3
- package/lib/status-probe.ts +289 -0
- package/lib/system-prompt.ts +4 -4
- package/lib/unified-config.ts +48 -1
- package/lib/usage-poller.ts +208 -7
- package/lib/usage-redis-multiproc.worker.ts +5 -1
- package/lib/usage-render.ts +146 -95
- package/lib/usage-resolver.ts +10 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/system-prompt.ts
CHANGED
|
@@ -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. */
|
package/lib/unified-config.ts
CHANGED
|
@@ -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;
|