@genee/omp-opsx-addon 0.8.0 → 0.10.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.
@@ -2,11 +2,12 @@
2
2
  * Unified opsx config schema.
3
3
  *
4
4
  * `opsx.yml` (per project at `<cwd>/.omp/opsx.yml`, layered on top of
5
- * `~/.omp/agent/opsx.yml`) declares how each role is dispatched. Since
6
- * `acpx` was removed, every role is local — values describe the *model*
7
- * the role should run on, not an external agent to spawn.
5
+ * `~/.omp/agent/opsx.yml`) declares how each dispatch agent (coder /
6
+ * reviewer / planner / proposal-reviewer) is dispatched. Since `acpx` was
7
+ * removed, every agent is local — values describe the *model* the agent
8
+ * should run on, not an external agent to spawn.
8
9
  *
9
- * Accepted values for each role:
10
+ * Accepted values for each dispatch agent:
10
11
  * - `'auto'` — let the model-selector pick based on tier + usage.
11
12
  * - An OMP role alias: `default`, `smol`, `slow`, `vision`, `plan`,
12
13
  * `designer`, `commit`, `tiny`, `task`, `advisor` — expanded by the
@@ -16,15 +17,28 @@
16
17
  * `openai-codex/gpt-5-codex`.
17
18
  *
18
19
  * Top-level fields:
19
- * - `model_allowlist`: list of `provider/model` glob patterns — restricts
20
- * which models the auto-selector may pick from.
21
- * - `tiers`: list of `{ pattern, tier }` overrides for tier scoring.
22
- * - `role_tiers`: `role → tier_name` map overriding the default expected
23
- * tier (DEFAULT_ROLE_TIER in model-tiers) for the four opsx agents.
20
+ * - `agents`: `{ coder?, reviewer?, planner?, 'proposal-reviewer'? }` map
21
+ * of dispatch agent → value from the grammar above. Canonical form.
22
+ * - `agent_tiers`: dispatch agent → tier_name map overriding the default
23
+ * expected tier (DEFAULT_ROLE_TIER in model-tiers) for the four opsx
24
+ * agents. Canonical form.
25
+ * - Legacy keys `coder` / `reviewer` / `planner` / `proposal-reviewer`
26
+ * (top-level values) and `role_tiers` are no longer read (ignored). Each
27
+ * layer file that still contains them gets one aggregated migration
28
+ * warning per config read pointing at the move into `agents:` /
29
+ * rename to `agent_tiers:`. Affected agents fall back to `auto`.
24
30
  * - `model_role_tiers`: `omp_role → tier_name | 'skip'` map controlling
25
31
  * which selector tier each OMP model role (default/smol/slow/vision/plan/
26
32
  * designer/commit/tiny/task/advisor + custom roles) follows after
27
33
  * /pick-model; `skip` excludes the role from the session overlay.
34
+ * Disambiguation: those are the OMP host *model roles*, not the four
35
+ * opsx dispatch agents configured via `agents` / `agent_tiers`.
36
+ * - `model_allowlist`: list of `provider/model` glob patterns — restricts
37
+ * which models the auto-selector may pick from.
38
+ * - `tiers`: list of `{ pattern, tier }` overrides for tier scoring.
39
+ * - `concurrency_*`: adaptive per-provider concurrency tuner (see
40
+ * `CONCURRENCY_DEFAULTS` and the `provider-concurrency-control` spec).
41
+ * Flat snake_case keys with per-key fallback + warn — never throws.
28
42
  */
29
43
 
30
44
  import { existsSync, readFileSync } from 'fs';
@@ -32,6 +46,11 @@ import { homedir } from 'os';
32
46
  import { join } from 'path';
33
47
  import { YAML } from 'bun';
34
48
  import { TIER_NAMES, type TierName, type TierOverride } from './model-tiers.js';
49
+ import { canonicalizeProvider } from './usage-resolver.js';
50
+ import { DEFAULT_MIN_SAMPLES, SPEED_MODES, type SpeedAwareConfig, type SpeedMode } from './model-speed.js';
51
+ import { QUALITY_PREFERENCES, type QualityPreference, type RoutingConfig } from './complexity-router.js';
52
+ import { DEFAULT_THRESHOLD, DEFAULT_WINDOW, type StallEscalationConfig } from './stall-detector.js';
53
+ import { DEFAULT_WEIGHT, type ContinuityGuardConfig } from './continuity-guard.js';
35
54
 
36
55
  /** OMP-documented role aliases. Plugins MAY pass these to `task(..., model: '@alias')`. */
37
56
  export const OMP_ROLE_ALIASES = new Set<string>([
@@ -50,28 +69,51 @@ export const OMP_ROLE_ALIASES = new Set<string>([
50
69
  /** Special "let the plugin decide" marker. */
51
70
  export const AUTO_TOKEN = 'auto';
52
71
 
53
- /** Roles the plugin manages. */
54
- export const OPSX_ROLES = ['coder', 'reviewer', 'planner', 'proposal-reviewer'] as const;
55
- export type OpsxRole = (typeof OPSX_ROLES)[number];
72
+ /** Dispatch agents the plugin manages. */
73
+ export const OPSX_AGENTS = ['coder', 'reviewer', 'planner', 'proposal-reviewer'] as const;
74
+ export type OpsxAgent = (typeof OPSX_AGENTS)[number];
75
+
76
+ /** Keys superseded by `agents` / `agent_tiers`; no longer read — warn-only migration detect. */
77
+ const LEGACY_AGENT_KEYS: readonly (OpsxAgent | 'role_tiers')[] = [...OPSX_AGENTS, 'role_tiers'];
56
78
 
57
79
  /** Raw YAML shape — every key is optional. */
58
80
  export interface OpsxYamlShape {
59
- coder?: string;
60
- reviewer?: string;
61
- planner?: string;
62
- 'proposal-reviewer'?: string;
63
- primary?: string;
81
+ /** Dispatch agent → value; canonical form. */
82
+ agents?: Partial<Record<OpsxAgent, string>>;
64
83
  model_allowlist?: string[];
65
84
  tiers?: TierOverride[];
66
- role_tiers?: Partial<Record<OpsxRole, TierName>>;
85
+ /** Dispatch agent → tier override for the four opsx agents. */
86
+ agent_tiers?: Partial<Record<OpsxAgent, TierName>>;
67
87
  /** OMP model role → tier override; `skip` excludes the role from the overlay. Keys are arbitrary strings (custom roles allowed). */
68
88
  model_role_tiers?: Record<string, string>;
69
89
  plan_providers?: string[];
90
+ /** Providers removed from the effective plan set: (defaults ∪ plan_providers) − plan_providers_remove. */
91
+ plan_providers_remove?: string[];
92
+ /** false collapses the PAYG-last coverage partition (pay-as-you-go candidates compete normally). Default true. */
93
+ payg_last_resort?: boolean;
94
+ /** china 家族缺省排除 + 同账号区域变体偏好方向(true = 国内语境:排除 gpt/claude/openai/anthropic 家族、区域变体保留国内入口)。缺省 false。 */
95
+ china?: boolean;
70
96
  probe_enabled?: boolean;
71
97
  probe_timeout_ms?: number;
72
98
  probe_http_enabled?: boolean;
73
99
  probe_ttl_ms?: number;
74
100
  excluded_providers?: string[];
101
+ /** provider → 服务端路由哨兵模型 id(如 `ark-coding-plan: ark-code-latest`)。键经 canonicalizeProvider 规范化。 */
102
+ provider_auto?: Record<string, string>;
103
+ /** provider → 哨兵声明档位(tiny|low|mid|high|top);非法值 warn 丢弃,未声明按 mid。 */
104
+ provider_auto_tiers?: Record<string, string>;
105
+ /** provider → 模式数组:列出的 provider 收窄到命中模型,未列出不受限。 */
106
+ provider_models?: Record<string, string[]>;
107
+ /** 速度感知选择:enabled(缺省 true)、mode(tie-break|aggressive,缺省 tie-break)、min_samples(缺省 20)。 */
108
+ speed_aware?: { enabled?: boolean; mode?: string; min_samples?: number };
109
+ /** 难度路由(W4):prompt 分类 → 写入集 tier 目标偏移。缺省 false。 */
110
+ difficulty_routing?: { enabled?: boolean };
111
+ /** stall 升档(W4):同签名滑窗重复 → default 目标 +1。缺省 false / 6 / 3。 */
112
+ stall_escalation?: { enabled?: boolean; window?: number; threshold?: number };
113
+ /** 连续性守门(W4):在选模型粘性。缺省 false / weight 0.6。 */
114
+ continuity_guard?: { enabled?: boolean; weight?: number };
115
+ /** 质量带旋钮(W4):balanced | cost | quality。缺省 balanced(独立于路由开关)。 */
116
+ quality_preference?: string;
75
117
  status_probe_enabled?: boolean;
76
118
  redis_enabled?: boolean;
77
119
  redis_host?: string;
@@ -82,24 +124,64 @@ export interface OpsxYamlShape {
82
124
  redis_probe_interval_ms?: number;
83
125
  redis_unhealthy_retry_ms?: number;
84
126
  redis_ttl_ms?: number;
127
+ /** 会话启动自动应用一次选择(缺省 true);false 时 session_start 不应用、不写覆盖,行为与本键引入前一致。 */
128
+ autoselect_on_start?: boolean;
129
+ concurrency_enabled?: boolean;
130
+ concurrency_floor?: number;
131
+ concurrency_ceiling?: number;
132
+ concurrency_start?: number;
133
+ concurrency_up_ms?: number;
134
+ concurrency_down_ms?: number;
135
+ concurrency_latency_k?: number;
136
+ concurrency_min_samples?: number;
137
+ concurrency_queue_wait_high_ms?: number;
138
+ concurrency_write_min_interval_ms?: number;
139
+ concurrency_lease_ttl_ms?: number;
140
+ concurrency_decay_after_ms?: number;
141
+ concurrency_eval_interval_ms?: number;
142
+ concurrency_queue_wait_window_ms?: number;
143
+ concurrency_providers?: string[];
144
+ concurrency_ceiling_by_provider?: Record<string, number>;
85
145
  }
86
146
 
87
- /** A role value as parsed. */
88
- export type RoleValue =
147
+ /** An agent value as parsed. */
148
+ export type AgentValue =
89
149
  | { kind: 'auto' }
90
150
  | { kind: 'omp-alias'; alias: string }
91
151
  | { kind: 'model'; modelId: string }
92
152
  | { kind: 'provider-model'; provider: string; modelId: string };
93
153
 
94
154
  export interface ResolvedOpsxConfig {
95
- roles: Record<OpsxRole, RoleValue>;
155
+ agents: Record<OpsxAgent, AgentValue>;
96
156
  modelAllowlist: string[];
97
157
  tierOverrides: TierOverride[];
98
- roleTiers: Partial<Record<OpsxRole, TierName>>;
158
+ agentTiers: Partial<Record<OpsxAgent, TierName>>;
99
159
  /** OMP model role → tier (or `'skip'`); drives the pick-model role overlay. */
100
160
  ompRoleTiers: Record<string, TierName | 'skip'>;
101
161
  /** Provider ids that have monthly plan subscriptions. */
102
162
  planProviders: string[];
163
+ /** Canonical provider ids removed from the effective plan set. */
164
+ planProvidersRemove: string[];
165
+ /** PAYG candidates sort last (coverage partition). false collapses the partition. Default true. */
166
+ paygLastResort: boolean;
167
+ /** Standing china family exclusion + regional-variant preference direction. Default false (= status quo). */
168
+ china: boolean;
169
+ /** provider → 服务端路由哨兵模型 id(键已 canonicalizeProvider 规范化)。空对象 = 无哨兵。 */
170
+ providerAuto: Record<string, string>;
171
+ /** provider → 哨兵声明档位(值已过 TIER_NAMES 校验)。空对象 = 全部缺省 mid。 */
172
+ providerAutoTiers: Record<string, TierName>;
173
+ /** provider → 模式数组(键已规范化):仅列出的 provider 被收窄,未列出不受限。 */
174
+ providerModels: Record<string, string[]>;
175
+ /** 速度感知选择三键(解析层已校验并补齐缺省;enabled=false 时装配层零读取)。 */
176
+ speedAware: SpeedAwareConfig;
177
+ /** W4 难度路由(缺省 false:结构改造 opt-in,W1-W3 收益不依赖它)。 */
178
+ difficultyRouting: RoutingConfig;
179
+ /** W4 stall 升档(缺省 false / window 6 / threshold 3)。 */
180
+ stallEscalation: StallEscalationConfig;
181
+ /** W4 连续性守门(缺省 false / weight 0.6)。 */
182
+ continuityGuard: ContinuityGuardConfig;
183
+ /** W4 质量带旋钮(缺省 balanced = 既有比较器分支逐字节)。 */
184
+ qualityPreference: QualityPreference;
103
185
  warnings: string[];
104
186
  /** 是否开启可达性探测,默认 true */
105
187
  probe_enabled: boolean;
@@ -131,10 +213,53 @@ export interface ResolvedOpsxConfig {
131
213
  redis_unhealthy_retry_ms: number;
132
214
  /** Redis key TTL ms(默认 180000)。 */
133
215
  redis_ttl_ms: number;
216
+ /** 会话启动自动应用一次选择,默认 true;false 时 session_start handler 直接返回(不 setModel、不写覆盖)。 */
217
+ autoselectOnStart: boolean;
218
+ /** 是否开启自适应并发控制,默认 true;false 时装配层不创建控制器(零写入)。 */
219
+ concurrency_enabled: boolean;
220
+ /**
221
+ * 全局并发上限下界,默认 4(须为 ≥ 1 的整数)。每个 provider 的**有效下限**
222
+ * 为 `min(concurrency_floor, initial)`(见 concurrency-tuner 的
223
+ * `effectiveFloor` / `createProviderState`):该值设定区间,MUST NOT 抬高
224
+ * provider 自身的静态现值(静态 3 的 provider 下限与起点都保持 3)。
225
+ */
226
+ concurrency_floor: number;
227
+ /**
228
+ * 全局并发上限上界,默认 16。低于**有效 floor** 时:floor 未有效显式配置
229
+ * (缺省或非法回落)则把 floor 压到该 ceiling(区间退化为单点),否则回落
230
+ * 默认(ceiling → 16,必要时 floor 也回落默认);见 `parseOpsxConfig`。
231
+ */
232
+ concurrency_ceiling: number;
233
+ /** 无现有静态值时的保守启动值,默认 4(区间 clamp 由控制器负责)。 */
234
+ concurrency_start: number;
235
+ /** 加性上调所需干净窗口时长 ms,默认 30000。 */
236
+ concurrency_up_ms: number;
237
+ /** 乘性下调后的冷却时长 ms,默认 60000。 */
238
+ concurrency_down_ms: number;
239
+ /** 延迟膨胀判定比值,默认 2(> 1 的任意正数,允许小数)。 */
240
+ concurrency_latency_k: number;
241
+ /** 延迟判定所需最小样本数,默认 5。 */
242
+ concurrency_min_samples: number;
243
+ /** 排队等待 p50 高企阈值 ms,默认 2000(超阈值禁止上调)。 */
244
+ concurrency_queue_wait_high_ms: number;
245
+ /** 同一 provider 两次成功写入的最小间隔 ms,默认 20000。 */
246
+ concurrency_write_min_interval_ms: number;
247
+ /** 租约 TTL ms,默认 60000。 */
248
+ concurrency_lease_ttl_ms: number;
249
+ /** 无观测衰减周期 ms,默认 86400000(24h)。 */
250
+ concurrency_decay_after_ms: number;
251
+ /** 评估节拍 ms,默认 30000。 */
252
+ concurrency_eval_interval_ms: number;
253
+ /** 排队等待护栏统计窗口 ms,默认 60000(独立于评估节拍)。 */
254
+ concurrency_queue_wait_window_ms: number;
255
+ /** 显式希望由控制器试探上限的 provider(规范化 id,去重、声明顺序)。 */
256
+ concurrency_providers: string[];
257
+ /** per-provider ceiling 覆盖(键已规范化,值为 ≥ floor 的正整数)。 */
258
+ concurrency_ceiling_by_provider: Record<string, number>;
134
259
  }
135
260
 
136
261
  /** Default values applied when no config is provided. */
137
- export const DEFAULT_OPSX: Record<OpsxRole, string> = {
262
+ export const DEFAULT_OPSX_AGENTS: Record<OpsxAgent, string> = {
138
263
  coder: 'auto',
139
264
  reviewer: 'auto',
140
265
  planner: 'auto',
@@ -149,8 +274,8 @@ function splitProviderModel(raw: string): { provider?: string; modelId: string }
149
274
  return { provider: provider || undefined, modelId };
150
275
  }
151
276
 
152
- /** Parse a single role value string into a RoleValue. */
153
- export function parseRoleValue(raw: string | undefined, _warn: (msg: string) => void): RoleValue {
277
+ /** Parse a single agent value string into an AgentValue. */
278
+ export function parseAgentValue(raw: string | undefined, _warn: (msg: string) => void): AgentValue {
154
279
  if (raw === undefined || raw === null) {
155
280
  return { kind: 'auto' };
156
281
  }
@@ -162,7 +287,7 @@ export function parseRoleValue(raw: string | undefined, _warn: (msg: string) =>
162
287
  const legacyStripped = trimmed.replace(/^(pi|local):/i, '').trim();
163
288
  if (legacyStripped !== trimmed) {
164
289
  _warn(`[omp-opsx-addon] legacy "${trimmed}" → "${legacyStripped}" (pi:/local: prefix no longer needed)`);
165
- return parseRoleValue(legacyStripped, _warn);
290
+ return parseAgentValue(legacyStripped, _warn);
166
291
  }
167
292
  if (OMP_ROLE_ALIASES.has(trimmed.toLowerCase())) {
168
293
  return { kind: 'omp-alias', alias: trimmed.toLowerCase() };
@@ -202,22 +327,26 @@ export function parseTierOverrides(
202
327
  return out;
203
328
  }
204
329
 
205
- /** Validate role_tiers entries. */
206
- export function parseRoleTiers(
330
+ /**
331
+ * Validate tier entries for the four opsx agents. `keyLabel` names the config
332
+ * key being parsed (always `'agent_tiers'` after legacy `role_tiers` removal).
333
+ */
334
+ export function parseAgentTiers(
207
335
  raw: unknown,
336
+ keyLabel: 'agent_tiers',
208
337
  warn: (msg: string) => void,
209
- ): Partial<Record<OpsxRole, TierName>> {
338
+ ): Partial<Record<OpsxAgent, TierName>> {
210
339
  if (!raw || typeof raw !== 'object') return {};
211
- const out: Partial<Record<OpsxRole, TierName>> = {};
212
- for (const role of OPSX_ROLES) {
340
+ const out: Partial<Record<OpsxAgent, TierName>> = {};
341
+ for (const role of OPSX_AGENTS) {
213
342
  const v = (raw as Record<string, unknown>)[role];
214
343
  if (v === undefined || v === null) continue;
215
344
  if (typeof v !== 'string') {
216
- warn(`[omp-opsx-addon] role_tiers.${role}: expected tier name, got ${typeof v}`);
345
+ warn(`[omp-opsx-addon] ${keyLabel}.${role}: expected tier name, got ${typeof v}`);
217
346
  continue;
218
347
  }
219
348
  if (!TIER_NAMES.includes(v as TierName)) {
220
- warn(`[omp-opsx-addon] role_tiers.${role}: unknown tier "${v}" (use one of ${TIER_NAMES.join('/')})`);
349
+ warn(`[omp-opsx-addon] ${keyLabel}.${role}: unknown tier "${v}" (use one of ${TIER_NAMES.join('/')})`);
221
350
  continue;
222
351
  }
223
352
  out[role] = v as TierName;
@@ -306,6 +435,375 @@ export function parseStringArray(raw: unknown, key: string, warn: (msg: string)
306
435
  return out;
307
436
  }
308
437
 
438
+ /**
439
+ * `concurrency_*` 默认值 —— 逐键对应 spec `provider-concurrency-control`
440
+ * 的「配置面与降级」Requirement(不要按 tasks 里的数字出现顺序推断)。
441
+ */
442
+ export const CONCURRENCY_DEFAULTS = {
443
+ enabled: true,
444
+ floor: 4,
445
+ ceiling: 16,
446
+ start: 4,
447
+ up_ms: 30_000,
448
+ down_ms: 60_000,
449
+ latency_k: 2,
450
+ min_samples: 5,
451
+ queue_wait_high_ms: 2_000,
452
+ write_min_interval_ms: 20_000,
453
+ lease_ttl_ms: 60_000,
454
+ decay_after_ms: 86_400_000,
455
+ eval_interval_ms: 30_000,
456
+ queue_wait_window_ms: 60_000,
457
+ } as const;
458
+
459
+ /** 解析正数(允许小数,用于 `concurrency_latency_k` 这类比值):非法则返回 defaultValue 并 warn。 */
460
+ export function parsePositiveNumber(raw: unknown, key: string, defaultValue: number, warn: (msg: string) => void): number {
461
+ if (raw === undefined || raw === null) return defaultValue;
462
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return raw;
463
+ warn(`[omp-opsx-addon] ${key}: expected positive number, got ${JSON.stringify(raw)}`);
464
+ return defaultValue;
465
+ }
466
+
467
+ /** 规范化 provider id 列表:canonicalize + 去重,保持声明顺序;非字符串/空串跳过并 warn。 */
468
+ function canonicalizeProviderList(raw: readonly unknown[], key: string, warn: (msg: string) => void): string[] {
469
+ const out: string[] = [];
470
+ const seen = new Set<string>();
471
+ for (const entry of raw) {
472
+ if (typeof entry !== 'string') {
473
+ warn(`[omp-opsx-addon] ${key}: skipping non-string entry ${JSON.stringify(entry)}`);
474
+ continue;
475
+ }
476
+ const canonical = canonicalizeProvider(entry.trim());
477
+ if (!canonical) {
478
+ warn(`[omp-opsx-addon] ${key}: skipping empty provider id`);
479
+ continue;
480
+ }
481
+ if (!seen.has(canonical)) {
482
+ seen.add(canonical);
483
+ out.push(canonical);
484
+ }
485
+ }
486
+ return out;
487
+ }
488
+
489
+ /** 解析 `concurrency_providers`:非数组回落 [] 并 warn;条目规范化、去重。 */
490
+ export function parseConcurrencyProviders(raw: unknown, warn: (msg: string) => void): string[] {
491
+ if (raw === undefined || raw === null) return [];
492
+ if (!Array.isArray(raw)) {
493
+ warn(`[omp-opsx-addon] concurrency_providers: expected array of provider ids, got ${JSON.stringify(raw)}`);
494
+ return [];
495
+ }
496
+ return canonicalizeProviderList(raw, 'concurrency_providers', warn);
497
+ }
498
+
499
+ /** 解析 `plan_providers` / `plan_providers_remove`:非数组回落 [] 并 warn;条目规范化、去重。 */
500
+ function parsePlanProviderList(raw: unknown, key: string, warn: (msg: string) => void): string[] {
501
+ if (raw === undefined || raw === null) return [];
502
+ if (!Array.isArray(raw)) {
503
+ warn(`[omp-opsx-addon] ${key}: expected array of provider ids, got ${JSON.stringify(raw)}`);
504
+ return [];
505
+ }
506
+ return canonicalizeProviderList(raw, key, warn);
507
+ }
508
+
509
+ /**
510
+ * 解析 `concurrency_ceiling_by_provider`:键规范化(大小写 / `arkcodingplan` 别名同源),
511
+ * 值须为 ≥ 1 的正整数;非法条目跳过并 warn(回落到全局 `concurrency_ceiling`),
512
+ * 非对象回落 {} 并 warn。
513
+ *
514
+ * 低于全局 `floor` 的覆盖**保留**并 warn:它是用户对单个 provider 的显式收窄,
515
+ * 丢弃它会让该 provider 的 ceiling 回落到全局值(等于抬高用户设定)。控制器的
516
+ * `createProviderState` 会把该 provider 的有效下限压到 `min(floor, 静态现值,
517
+ * 该 ceiling)`,因此 `floor > ceiling` 的覆盖仍自洽(该 provider 固定在它的
518
+ * ceiling 上)。
519
+ */
520
+ export function parseCeilingByProvider(
521
+ raw: unknown,
522
+ floor: number,
523
+ warn: (msg: string) => void,
524
+ ): Record<string, number> {
525
+ if (raw === undefined || raw === null) return {};
526
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
527
+ warn(`[omp-opsx-addon] concurrency_ceiling_by_provider: expected object mapping provider → ceiling, got ${JSON.stringify(raw)}`);
528
+ return {};
529
+ }
530
+ const out: Record<string, number> = {};
531
+ for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
532
+ const canonical = canonicalizeProvider(id.trim());
533
+ if (!canonical) {
534
+ warn('[omp-opsx-addon] concurrency_ceiling_by_provider: skipping empty provider id');
535
+ continue;
536
+ }
537
+ if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
538
+ warn(`[omp-opsx-addon] concurrency_ceiling_by_provider.${id}: expected positive integer, got ${JSON.stringify(value)}`);
539
+ continue;
540
+ }
541
+ if (value < floor) {
542
+ warn(
543
+ `[omp-opsx-addon] concurrency_ceiling_by_provider.${id}: ${value} < concurrency_floor ${floor}; keeping the override(有效下限 = min(${value}, provider 静态现值))`,
544
+ );
545
+ }
546
+ if (out[canonical] !== undefined && out[canonical] !== value) {
547
+ warn(`[omp-opsx-addon] concurrency_ceiling_by_provider.${id}: duplicate canonical id "${canonical}" (${out[canonical]} → ${value}); later key wins`);
548
+ }
549
+ out[canonical] = value;
550
+ }
551
+ return out;
552
+ }
553
+
554
+ /** 解析 `provider_auto`:键规范化(大小写 / 别名同源),键值均须字符串;非法条目跳过并 warn。 */
555
+ export function parseProviderAuto(raw: unknown, warn: (msg: string) => void): Record<string, string> {
556
+ if (raw === undefined || raw === null) return {};
557
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
558
+ warn(`[omp-opsx-addon] provider_auto: expected object mapping provider → auto model id, got ${JSON.stringify(raw)}`);
559
+ return {};
560
+ }
561
+ const out: Record<string, string> = {};
562
+ for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
563
+ const canonical = canonicalizeProvider(id.trim());
564
+ if (!canonical) {
565
+ warn('[omp-opsx-addon] provider_auto: skipping empty provider id');
566
+ continue;
567
+ }
568
+ if (typeof value !== 'string' || !value.trim()) {
569
+ warn(`[omp-opsx-addon] provider_auto.${id}: expected model id string, got ${JSON.stringify(value)}`);
570
+ continue;
571
+ }
572
+ out[canonical] = value.trim();
573
+ }
574
+ return out;
575
+ }
576
+
577
+ /** 解析 `provider_auto_tiers`:键规范化,值经 TIER_NAMES 校验;非法值 warn 并丢弃(消费侧缺省 mid)。 */
578
+ export function parseProviderAutoTiers(raw: unknown, warn: (msg: string) => void): Record<string, TierName> {
579
+ if (raw === undefined || raw === null) return {};
580
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
581
+ warn(`[omp-opsx-addon] provider_auto_tiers: expected object mapping provider → tier name, got ${JSON.stringify(raw)}`);
582
+ return {};
583
+ }
584
+ const out: Record<string, TierName> = {};
585
+ for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
586
+ const canonical = canonicalizeProvider(id.trim());
587
+ if (!canonical) {
588
+ warn('[omp-opsx-addon] provider_auto_tiers: skipping empty provider id');
589
+ continue;
590
+ }
591
+ if (typeof value !== 'string' || !(TIER_NAMES as readonly string[]).includes(value)) {
592
+ warn(`[omp-opsx-addon] provider_auto_tiers.${id}: expected one of ${TIER_NAMES.join('|')}, got ${JSON.stringify(value)}`);
593
+ continue;
594
+ }
595
+ out[canonical] = value as TierName;
596
+ }
597
+ return out;
598
+ }
599
+
600
+ /** 解析 `provider_models`:键规范化,值须为字符串数组(模式);非数组整键 warn 丢弃,非字符串条目跳过并 warn。 */
601
+ export function parseProviderModels(raw: unknown, warn: (msg: string) => void): Record<string, string[]> {
602
+ if (raw === undefined || raw === null) return {};
603
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
604
+ warn(`[omp-opsx-addon] provider_models: expected object mapping provider → pattern array, got ${JSON.stringify(raw)}`);
605
+ return {};
606
+ }
607
+ const out: Record<string, string[]> = {};
608
+ for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
609
+ const canonical = canonicalizeProvider(id.trim());
610
+ if (!canonical) {
611
+ warn('[omp-opsx-addon] provider_models: skipping empty provider id');
612
+ continue;
613
+ }
614
+ if (!Array.isArray(value)) {
615
+ warn(`[omp-opsx-addon] provider_models.${id}: expected array of patterns, got ${JSON.stringify(value)}`);
616
+ continue;
617
+ }
618
+ const patterns: string[] = [];
619
+ for (const entry of value) {
620
+ if (typeof entry !== 'string' || !entry.trim()) {
621
+ warn(`[omp-opsx-addon] provider_models.${id}: skipping non-string/empty pattern ${JSON.stringify(entry)}`);
622
+ continue;
623
+ }
624
+ patterns.push(entry.trim());
625
+ }
626
+ if (patterns.length > 0) out[canonical] = patterns;
627
+ }
628
+ return out;
629
+ }
630
+
631
+ /** speed_aware 三键缺省(enabled 缺省开启:T2 类别硬分区 + 缺数据零漂移已消解开启风险)。 */
632
+ export function defaultSpeedAware(): SpeedAwareConfig {
633
+ return { enabled: true, mode: 'tie-break', minSamples: DEFAULT_MIN_SAMPLES };
634
+ }
635
+
636
+ /** 解析 `speed_aware`:非对象整键 warn 回落缺省;mode 非法枚举 / min_samples 非正数 / enabled 非布尔各键 warn 回落各自缺省(parseProviderAutoTiers warn 矩阵同款)。 */
637
+ export function parseSpeedAware(raw: unknown, warn: (msg: string) => void): SpeedAwareConfig {
638
+ if (raw === undefined || raw === null) return defaultSpeedAware();
639
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
640
+ warn(`[omp-opsx-addon] speed_aware: expected object mapping { enabled, mode, min_samples }, got ${JSON.stringify(raw)}`);
641
+ return defaultSpeedAware();
642
+ }
643
+ const obj = raw as { enabled?: unknown; mode?: unknown; min_samples?: unknown };
644
+ let enabled = true;
645
+ if (obj.enabled !== undefined && obj.enabled !== null) {
646
+ if (typeof obj.enabled === 'boolean') {
647
+ enabled = obj.enabled;
648
+ } else {
649
+ warn(`[omp-opsx-addon] speed_aware.enabled: expected boolean, got ${JSON.stringify(obj.enabled)}`);
650
+ }
651
+ }
652
+ let mode: SpeedMode = 'tie-break';
653
+ if (obj.mode !== undefined && obj.mode !== null) {
654
+ if (typeof obj.mode === 'string' && (SPEED_MODES as readonly string[]).includes(obj.mode)) {
655
+ mode = obj.mode as SpeedMode;
656
+ } else {
657
+ warn(`[omp-opsx-addon] speed_aware.mode: expected one of ${SPEED_MODES.join('|')}, got ${JSON.stringify(obj.mode)}`);
658
+ }
659
+ }
660
+ let minSamples = DEFAULT_MIN_SAMPLES;
661
+ if (obj.min_samples !== undefined && obj.min_samples !== null) {
662
+ if (typeof obj.min_samples === 'number' && Number.isFinite(obj.min_samples) && obj.min_samples > 0) {
663
+ minSamples = obj.min_samples;
664
+ } else {
665
+ warn(`[omp-opsx-addon] speed_aware.min_samples: expected positive number, got ${JSON.stringify(obj.min_samples)}`);
666
+ }
667
+ }
668
+ return { enabled, mode, minSamples };
669
+ }
670
+
671
+ /** W4 难度路由缺省(enabled 缺省 false:结构改造 opt-in,与 speed_aware 缺省 true 的差异见 design D7)。 */
672
+ export function defaultDifficultyRouting(): RoutingConfig {
673
+ return { enabled: false };
674
+ }
675
+
676
+ /** 解析 `difficulty_routing`:非对象整键 warn 回落缺省;enabled 非布尔逐键 warn 回落(parseSpeedAware 矩阵同款)。 */
677
+ export function parseDifficultyRouting(raw: unknown, warn: (msg: string) => void): RoutingConfig {
678
+ if (raw === undefined || raw === null) return defaultDifficultyRouting();
679
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
680
+ warn(`[omp-opsx-addon] difficulty_routing: expected object mapping { enabled }, got ${JSON.stringify(raw)}`);
681
+ return defaultDifficultyRouting();
682
+ }
683
+ const obj = raw as { enabled?: unknown };
684
+ let enabled = false;
685
+ if (obj.enabled !== undefined && obj.enabled !== null) {
686
+ if (typeof obj.enabled === 'boolean') {
687
+ enabled = obj.enabled;
688
+ } else {
689
+ warn(`[omp-opsx-addon] difficulty_routing.enabled: expected boolean, got ${JSON.stringify(obj.enabled)}`);
690
+ }
691
+ }
692
+ return { enabled };
693
+ }
694
+
695
+ /** W4 stall 升档缺省(window=6 / threshold=3,LiteLLM 同参)。 */
696
+ export function defaultStallEscalation(): StallEscalationConfig {
697
+ return { enabled: false, window: DEFAULT_WINDOW, threshold: DEFAULT_THRESHOLD };
698
+ }
699
+
700
+ /** 解析 `stall_escalation`:非对象整键回落;enabled 非布尔、window/threshold 非正整数各键 warn 回落各自缺省。 */
701
+ export function parseStallEscalation(raw: unknown, warn: (msg: string) => void): StallEscalationConfig {
702
+ if (raw === undefined || raw === null) return defaultStallEscalation();
703
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
704
+ warn(`[omp-opsx-addon] stall_escalation: expected object mapping { enabled, window, threshold }, got ${JSON.stringify(raw)}`);
705
+ return defaultStallEscalation();
706
+ }
707
+ const obj = raw as { enabled?: unknown; window?: unknown; threshold?: unknown };
708
+ let enabled = false;
709
+ if (obj.enabled !== undefined && obj.enabled !== null) {
710
+ if (typeof obj.enabled === 'boolean') {
711
+ enabled = obj.enabled;
712
+ } else {
713
+ warn(`[omp-opsx-addon] stall_escalation.enabled: expected boolean, got ${JSON.stringify(obj.enabled)}`);
714
+ }
715
+ }
716
+ let window = DEFAULT_WINDOW;
717
+ if (obj.window !== undefined && obj.window !== null) {
718
+ if (typeof obj.window === 'number' && Number.isInteger(obj.window) && obj.window > 0) {
719
+ window = obj.window;
720
+ } else {
721
+ warn(`[omp-opsx-addon] stall_escalation.window: expected positive integer, got ${JSON.stringify(obj.window)}`);
722
+ }
723
+ }
724
+ let threshold = DEFAULT_THRESHOLD;
725
+ if (obj.threshold !== undefined && obj.threshold !== null) {
726
+ if (typeof obj.threshold === 'number' && Number.isInteger(obj.threshold) && obj.threshold > 0) {
727
+ threshold = obj.threshold;
728
+ } else {
729
+ warn(`[omp-opsx-addon] stall_escalation.threshold: expected positive integer, got ${JSON.stringify(obj.threshold)}`);
730
+ }
731
+ }
732
+ return { enabled, window, threshold };
733
+ }
734
+
735
+ /** W4 连续性守门缺省(weight 缺省 0.6:越高越保守/粘性越强)。 */
736
+ export function defaultContinuityGuard(): ContinuityGuardConfig {
737
+ return { enabled: false, weight: DEFAULT_WEIGHT };
738
+ }
739
+
740
+ /** 解析 `continuity_guard`:非对象整键回落;enabled 非布尔、weight 越出 (0,1] 开闭区间各键 warn 回落各自缺省。 */
741
+ export function parseContinuityGuard(raw: unknown, warn: (msg: string) => void): ContinuityGuardConfig {
742
+ if (raw === undefined || raw === null) return defaultContinuityGuard();
743
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
744
+ warn(`[omp-opsx-addon] continuity_guard: expected object mapping { enabled, weight }, got ${JSON.stringify(raw)}`);
745
+ return defaultContinuityGuard();
746
+ }
747
+ const obj = raw as { enabled?: unknown; weight?: unknown };
748
+ let enabled = false;
749
+ if (obj.enabled !== undefined && obj.enabled !== null) {
750
+ if (typeof obj.enabled === 'boolean') {
751
+ enabled = obj.enabled;
752
+ } else {
753
+ warn(`[omp-opsx-addon] continuity_guard.enabled: expected boolean, got ${JSON.stringify(obj.enabled)}`);
754
+ }
755
+ }
756
+ let weight = DEFAULT_WEIGHT;
757
+ if (obj.weight !== undefined && obj.weight !== null) {
758
+ if (typeof obj.weight === 'number' && Number.isFinite(obj.weight) && obj.weight > 0 && obj.weight <= 1) {
759
+ weight = obj.weight;
760
+ } else {
761
+ warn(`[omp-opsx-addon] continuity_guard.weight: expected number in (0, 1], got ${JSON.stringify(obj.weight)}`);
762
+ }
763
+ }
764
+ return { enabled, weight };
765
+ }
766
+
767
+ /** 解析 `quality_preference`:非法枚举 warn 回落 balanced(独立于三开关的顶层旋钮)。 */
768
+ export function parseQualityPreference(raw: unknown, warn: (msg: string) => void): QualityPreference {
769
+ if (raw === undefined || raw === null) return 'balanced';
770
+ if (typeof raw === 'string' && (QUALITY_PREFERENCES as readonly string[]).includes(raw)) {
771
+ return raw as QualityPreference;
772
+ }
773
+ warn(`[omp-opsx-addon] quality_preference: expected one of ${QUALITY_PREFERENCES.join('|')}, got ${JSON.stringify(raw)}`);
774
+ return 'balanced';
775
+ }
776
+
777
+ /**
778
+ * 受管 provider 集合(spec: 受管集合 = `concurrency_providers` ∪
779
+ * `providers.maxInFlightRequests` 的键)。纯函数:`providers.maxInFlightRequests`
780
+ * 的读取由装配层负责(config root 的 `config.yml`)。
781
+ *
782
+ * 两侧 id 均经 `canonicalizeProvider` 规范化(大小写与 `ArkCodingPlan` →
783
+ * `ark-coding-plan` 别名收敛为同一列),去重并返回稳定顺序 —— 先
784
+ * `concurrency_providers` 的声明序,再 limits 侧新增键序。
785
+ */
786
+ export function resolveManagedProviders(
787
+ concurrencyProviders: readonly string[] | undefined,
788
+ providerLimitIds: readonly string[] | Record<string, unknown> | undefined,
789
+ ): string[] {
790
+ const limitIds = Array.isArray(providerLimitIds)
791
+ ? providerLimitIds
792
+ : providerLimitIds
793
+ ? Object.keys(providerLimitIds)
794
+ : [];
795
+ const out: string[] = [];
796
+ const seen = new Set<string>();
797
+ for (const id of [...(concurrencyProviders ?? []), ...limitIds]) {
798
+ if (typeof id !== 'string') continue;
799
+ const canonical = canonicalizeProvider(id.trim());
800
+ if (!canonical || seen.has(canonical)) continue;
801
+ seen.add(canonical);
802
+ out.push(canonical);
803
+ }
804
+ return out;
805
+ }
806
+
309
807
  /** Parse the full raw opsx config. */
310
808
  export function parseOpsxConfig(
311
809
  raw: unknown,
@@ -318,16 +816,21 @@ export function parseOpsxConfig(
318
816
  warn(m);
319
817
  };
320
818
 
321
- function sanitizePlanProviders(raw: unknown): string[] {
322
- if (raw === undefined || raw === null) return [];
323
- if (!Array.isArray(raw)) return [];
324
- return raw.filter((v): v is string => typeof v === 'string');
819
+ // `agents` mapping (canonical form): the key set is exactly the four
820
+ // managed agent names, so unknown subkeys are reported and dropped
821
+ // instead of vanishing silently.
822
+ const agentsMap = obj.agents as Record<string, unknown> | undefined;
823
+ if (agentsMap && typeof agentsMap === 'object') {
824
+ for (const key of Object.keys(agentsMap)) {
825
+ if (!(OPSX_AGENTS as readonly string[]).includes(key)) {
826
+ localWarn(`[omp-opsx-addon] agents: unknown agent "${key}" (valid: ${OPSX_AGENTS.join(', ')}); entry ignored`);
827
+ }
828
+ }
325
829
  }
326
-
327
- const roles: Record<OpsxRole, RoleValue> = {} as Record<OpsxRole, RoleValue>;
328
- for (const role of OPSX_ROLES) {
329
- const raw = (obj as Record<string, unknown>)[role];
330
- roles[role] = parseRoleValue(raw as string | undefined, localWarn);
830
+ const agents: Record<OpsxAgent, AgentValue> = {} as Record<OpsxAgent, AgentValue>;
831
+ for (const role of OPSX_AGENTS) {
832
+ const raw = agentsMap?.[role];
833
+ agents[role] = parseAgentValue(raw as string | undefined, localWarn);
331
834
  }
332
835
  let redisHost = '127.0.0.1';
333
836
  if (obj.redis_host !== undefined) {
@@ -338,13 +841,72 @@ export function parseOpsxConfig(
338
841
  }
339
842
  }
340
843
 
844
+ // 并发控制:floor / ceiling 先各自回落,再校验区间(ceiling < floor)。
845
+ // floor 未显式配置时它只是默认值,MUST NOT 反过来抬高用户显式设定的 ceiling
846
+ // ——那等于放开了用户明确设下的上界。这种情况下把 floor 压到 ceiling(区间
847
+ // 退化为单点),保持上界意图;两端都显式给出矛盾值时仍按 spec 回落默认。
848
+ const floorConfigured = typeof obj.concurrency_floor === 'number'
849
+ && Number.isFinite(obj.concurrency_floor)
850
+ && Number.isInteger(obj.concurrency_floor)
851
+ && obj.concurrency_floor > 0;
852
+ let concurrencyFloor = parsePositiveInt(obj.concurrency_floor, 'concurrency_floor', CONCURRENCY_DEFAULTS.floor, localWarn);
853
+ let concurrencyCeiling = parsePositiveInt(obj.concurrency_ceiling, 'concurrency_ceiling', CONCURRENCY_DEFAULTS.ceiling, localWarn);
854
+ if (concurrencyCeiling < concurrencyFloor) {
855
+ if (!floorConfigured) {
856
+ localWarn(
857
+ `[omp-opsx-addon] concurrency_ceiling: ${concurrencyCeiling} < default concurrency_floor ${concurrencyFloor}; using the ceiling as the floor`,
858
+ );
859
+ concurrencyFloor = concurrencyCeiling;
860
+ } else {
861
+ localWarn(
862
+ `[omp-opsx-addon] concurrency_ceiling: ${concurrencyCeiling} < concurrency_floor ${concurrencyFloor}; falling back to ${CONCURRENCY_DEFAULTS.ceiling}`,
863
+ );
864
+ concurrencyCeiling = CONCURRENCY_DEFAULTS.ceiling;
865
+ if (concurrencyCeiling < concurrencyFloor) {
866
+ localWarn(
867
+ `[omp-opsx-addon] concurrency_floor: ${concurrencyFloor} > default ceiling ${CONCURRENCY_DEFAULTS.ceiling}; falling back to ${CONCURRENCY_DEFAULTS.floor}`,
868
+ );
869
+ concurrencyFloor = CONCURRENCY_DEFAULTS.floor;
870
+ }
871
+ }
872
+ }
873
+
874
+ // 并发控制:`concurrency_up_ms` 是「干净窗口」的长度,`concurrency_eval_interval_ms`
875
+ // 是评估节奏。`evaluateWindow` 每次评估最多记 1 个干净窗口并丢弃余数
876
+ // (`cleanWindowMs = 0`),所以 up_ms ≤ eval_interval 时二者的 limit 轨迹
877
+ // 完全一致;把 up_ms 归一为 eval_interval 只是让配置反映真实生效值,
878
+ // 不改变任何行为(见 concurrency-tuner.ts 的 `max(up_ms, eval_interval_ms)`)。
879
+ let concurrencyUpMs = parsePositiveInt(obj.concurrency_up_ms, 'concurrency_up_ms', CONCURRENCY_DEFAULTS.up_ms, localWarn);
880
+ const concurrencyEvalIntervalMs = parsePositiveInt(obj.concurrency_eval_interval_ms, 'concurrency_eval_interval_ms', CONCURRENCY_DEFAULTS.eval_interval_ms, localWarn);
881
+ if (concurrencyUpMs < concurrencyEvalIntervalMs) {
882
+ localWarn(
883
+ `[omp-opsx-addon] concurrency_up_ms: ${concurrencyUpMs} < concurrency_eval_interval_ms ${concurrencyEvalIntervalMs}; using ${concurrencyEvalIntervalMs} (the effective clean window is max(up_ms, eval_interval_ms))`,
884
+ );
885
+ concurrencyUpMs = concurrencyEvalIntervalMs;
886
+ }
887
+
341
888
  return {
342
- roles,
889
+ agents,
343
890
  modelAllowlist: parseModelAllowlist(obj.model_allowlist, localWarn),
344
891
  tierOverrides: parseTierOverrides(obj.tiers, localWarn),
345
- roleTiers: parseRoleTiers(obj.role_tiers, localWarn),
892
+ agentTiers: parseAgentTiers(
893
+ obj.agent_tiers,
894
+ 'agent_tiers',
895
+ localWarn,
896
+ ),
346
897
  ompRoleTiers: parseOmpRoleTiers(obj.model_role_tiers, localWarn),
347
- planProviders: sanitizePlanProviders(obj.plan_providers),
898
+ planProviders: parsePlanProviderList(obj.plan_providers, 'plan_providers', localWarn),
899
+ planProvidersRemove: parsePlanProviderList(obj.plan_providers_remove, 'plan_providers_remove', localWarn),
900
+ paygLastResort: parseProbeBool(obj.payg_last_resort, 'payg_last_resort', true, localWarn),
901
+ china: parseProbeBool(obj.china, 'china', false, localWarn),
902
+ providerAuto: parseProviderAuto(obj.provider_auto, localWarn),
903
+ providerAutoTiers: parseProviderAutoTiers(obj.provider_auto_tiers, localWarn),
904
+ providerModels: parseProviderModels(obj.provider_models, localWarn),
905
+ speedAware: parseSpeedAware(obj.speed_aware, localWarn),
906
+ difficultyRouting: parseDifficultyRouting(obj.difficulty_routing, localWarn),
907
+ stallEscalation: parseStallEscalation(obj.stall_escalation, localWarn),
908
+ continuityGuard: parseContinuityGuard(obj.continuity_guard, localWarn),
909
+ qualityPreference: parseQualityPreference(obj.quality_preference, localWarn),
348
910
  warnings,
349
911
  probe_enabled: parseProbeBool(obj.probe_enabled, 'probe_enabled', true, localWarn),
350
912
  probe_timeout_ms: parsePositiveInt(obj.probe_timeout_ms, 'probe_timeout_ms', 3000, localWarn),
@@ -361,6 +923,23 @@ export function parseOpsxConfig(
361
923
  redis_probe_interval_ms: parsePositiveInt(obj.redis_probe_interval_ms, 'redis_probe_interval_ms', 30_000, localWarn),
362
924
  redis_unhealthy_retry_ms: parsePositiveInt(obj.redis_unhealthy_retry_ms, 'redis_unhealthy_retry_ms', 5_000, localWarn),
363
925
  redis_ttl_ms: parsePositiveInt(obj.redis_ttl_ms, 'redis_ttl_ms', 180_000, localWarn),
926
+ autoselectOnStart: parseProbeBool(obj.autoselect_on_start, 'autoselect_on_start', true, localWarn),
927
+ concurrency_enabled: parseProbeBool(obj.concurrency_enabled, 'concurrency_enabled', CONCURRENCY_DEFAULTS.enabled, localWarn),
928
+ concurrency_floor: concurrencyFloor,
929
+ concurrency_ceiling: concurrencyCeiling,
930
+ concurrency_start: parsePositiveInt(obj.concurrency_start, 'concurrency_start', CONCURRENCY_DEFAULTS.start, localWarn),
931
+ concurrency_up_ms: concurrencyUpMs,
932
+ concurrency_down_ms: parsePositiveInt(obj.concurrency_down_ms, 'concurrency_down_ms', CONCURRENCY_DEFAULTS.down_ms, localWarn),
933
+ concurrency_latency_k: parsePositiveNumber(obj.concurrency_latency_k, 'concurrency_latency_k', CONCURRENCY_DEFAULTS.latency_k, localWarn),
934
+ concurrency_min_samples: parsePositiveInt(obj.concurrency_min_samples, 'concurrency_min_samples', CONCURRENCY_DEFAULTS.min_samples, localWarn),
935
+ concurrency_queue_wait_high_ms: parsePositiveInt(obj.concurrency_queue_wait_high_ms, 'concurrency_queue_wait_high_ms', CONCURRENCY_DEFAULTS.queue_wait_high_ms, localWarn),
936
+ concurrency_write_min_interval_ms: parsePositiveInt(obj.concurrency_write_min_interval_ms, 'concurrency_write_min_interval_ms', CONCURRENCY_DEFAULTS.write_min_interval_ms, localWarn),
937
+ concurrency_lease_ttl_ms: parsePositiveInt(obj.concurrency_lease_ttl_ms, 'concurrency_lease_ttl_ms', CONCURRENCY_DEFAULTS.lease_ttl_ms, localWarn),
938
+ concurrency_decay_after_ms: parsePositiveInt(obj.concurrency_decay_after_ms, 'concurrency_decay_after_ms', CONCURRENCY_DEFAULTS.decay_after_ms, localWarn),
939
+ concurrency_eval_interval_ms: concurrencyEvalIntervalMs,
940
+ concurrency_queue_wait_window_ms: parsePositiveInt(obj.concurrency_queue_wait_window_ms, 'concurrency_queue_wait_window_ms', CONCURRENCY_DEFAULTS.queue_wait_window_ms, localWarn),
941
+ concurrency_providers: parseConcurrencyProviders(obj.concurrency_providers, localWarn),
942
+ concurrency_ceiling_by_provider: parseCeilingByProvider(obj.concurrency_ceiling_by_provider, concurrencyFloor, localWarn),
364
943
  };
365
944
  }
366
945
 
@@ -381,28 +960,30 @@ function readOpsxYamlFile(path: string): OpsxYamlShape {
381
960
  return {};
382
961
  }
383
962
  }
384
- function pickRoleKeys(raw: OpsxYamlShape): Partial<Record<OpsxRole, string>> {
385
- const out: Partial<Record<OpsxRole, string>> = {};
386
- for (const role of OPSX_ROLES) {
387
- const v = raw[role];
388
- if (v === undefined || v === null) continue;
389
- out[role] = String(v);
390
- }
391
- return out;
392
- }
393
-
394
963
  function pickTopLevelKeys(raw: OpsxYamlShape): OpsxYamlShape {
395
964
  return {
965
+ agents: raw.agents,
966
+ agent_tiers: raw.agent_tiers,
396
967
  model_allowlist: raw.model_allowlist,
397
968
  tiers: raw.tiers,
398
- role_tiers: raw.role_tiers,
399
969
  model_role_tiers: raw.model_role_tiers,
400
970
  plan_providers: raw.plan_providers,
971
+ plan_providers_remove: raw.plan_providers_remove,
972
+ payg_last_resort: raw.payg_last_resort,
973
+ china: raw.china,
401
974
  probe_enabled: raw.probe_enabled,
402
975
  probe_timeout_ms: raw.probe_timeout_ms,
403
976
  probe_http_enabled: raw.probe_http_enabled,
404
977
  probe_ttl_ms: raw.probe_ttl_ms,
405
978
  excluded_providers: raw.excluded_providers,
979
+ provider_auto: raw.provider_auto,
980
+ provider_auto_tiers: raw.provider_auto_tiers,
981
+ provider_models: raw.provider_models,
982
+ speed_aware: raw.speed_aware,
983
+ difficulty_routing: raw.difficulty_routing,
984
+ stall_escalation: raw.stall_escalation,
985
+ continuity_guard: raw.continuity_guard,
986
+ quality_preference: raw.quality_preference,
406
987
  status_probe_enabled: raw.status_probe_enabled,
407
988
  redis_enabled: raw.redis_enabled,
408
989
  redis_host: raw.redis_host,
@@ -413,6 +994,23 @@ function pickTopLevelKeys(raw: OpsxYamlShape): OpsxYamlShape {
413
994
  redis_probe_interval_ms: raw.redis_probe_interval_ms,
414
995
  redis_unhealthy_retry_ms: raw.redis_unhealthy_retry_ms,
415
996
  redis_ttl_ms: raw.redis_ttl_ms,
997
+ autoselect_on_start: raw.autoselect_on_start,
998
+ concurrency_enabled: raw.concurrency_enabled,
999
+ concurrency_floor: raw.concurrency_floor,
1000
+ concurrency_ceiling: raw.concurrency_ceiling,
1001
+ concurrency_start: raw.concurrency_start,
1002
+ concurrency_up_ms: raw.concurrency_up_ms,
1003
+ concurrency_down_ms: raw.concurrency_down_ms,
1004
+ concurrency_latency_k: raw.concurrency_latency_k,
1005
+ concurrency_min_samples: raw.concurrency_min_samples,
1006
+ concurrency_queue_wait_high_ms: raw.concurrency_queue_wait_high_ms,
1007
+ concurrency_write_min_interval_ms: raw.concurrency_write_min_interval_ms,
1008
+ concurrency_lease_ttl_ms: raw.concurrency_lease_ttl_ms,
1009
+ concurrency_decay_after_ms: raw.concurrency_decay_after_ms,
1010
+ concurrency_eval_interval_ms: raw.concurrency_eval_interval_ms,
1011
+ concurrency_queue_wait_window_ms: raw.concurrency_queue_wait_window_ms,
1012
+ concurrency_providers: raw.concurrency_providers,
1013
+ concurrency_ceiling_by_provider: raw.concurrency_ceiling_by_provider,
416
1014
  };
417
1015
  }
418
1016
 
@@ -441,11 +1039,42 @@ function warnLegacySettingsJson(
441
1039
  }
442
1040
  }
443
1041
 
1042
+ /**
1043
+ * Per-layer migration diagnostics: one aggregated warning when a layer file
1044
+ * still uses a removed legacy key (all offending names in a single line),
1045
+ * plus one warning per unknown `agents` subkey (the mapping's key set is
1046
+ * exactly the four managed agents, so a typo would otherwise vanish silently).
1047
+ */
1048
+ function warnAgentRenameDiagnostics(
1049
+ path: string,
1050
+ raw: OpsxYamlShape,
1051
+ warn: (msg: string) => void,
1052
+ ): void {
1053
+ const legacy = LEGACY_AGENT_KEYS.filter((key) => {
1054
+ const v = (raw as Record<string, unknown>)[key];
1055
+ return v !== undefined && v !== null;
1056
+ });
1057
+ if (legacy.length > 0) {
1058
+ warn(
1059
+ `[omp-opsx-addon] ${path}: legacy key(s) ${legacy.map((k) => `'${k}'`).join(', ')} are no longer read (ignored); ` +
1060
+ `migrate top-level role keys into the 'agents:' mapping / rename 'role_tiers' to 'agent_tiers'.`,
1061
+ );
1062
+ }
1063
+ const agents = raw.agents;
1064
+ if (agents && typeof agents === 'object') {
1065
+ for (const key of Object.keys(agents)) {
1066
+ if (!(OPSX_AGENTS as readonly string[]).includes(key)) {
1067
+ warn(`[omp-opsx-addon] ${path}: agents.${key}: unknown agent (valid: ${OPSX_AGENTS.join(', ')}); entry ignored`);
1068
+ }
1069
+ }
1070
+ }
1071
+ }
1072
+
444
1073
  /**
445
1074
  * Read opsx config from two-layer opsx.yml:
446
1075
  * - global: `<homeDir>/.omp/agent/opsx.yml`
447
- * - project: `<cwd>/.omp/opsx.yml` (per-key override on roles; full overlay on
448
- * `model_allowlist` / `tiers` / `role_tiers`)
1076
+ * - project: `<cwd>/.omp/opsx.yml` (per-key override on `agents`; full
1077
+ * overlay on `model_allowlist` / `tiers` / `agent_tiers`)
449
1078
  *
450
1079
  * `homeDir` is overridable for tests; defaults to `os.homedir()`.
451
1080
  */
@@ -471,30 +1100,51 @@ export function readOpsxSettingsFromPaths(
471
1100
  const homeFromGlobal = globalPath.replace(/\.omp\/agent\/opsx\.yml$/, '');
472
1101
  const projectFromPath = projectPath.replace(/\.omp\/opsx\.yml$/, '');
473
1102
  warnLegacySettingsJson(projectFromPath, homeFromGlobal, warn);
1103
+ warnAgentRenameDiagnostics(globalPath, globalYaml, warn);
1104
+ warnAgentRenameDiagnostics(projectPath, projectYaml, warn);
474
1105
 
475
- const globalRoles = pickRoleKeys(globalYaml);
476
- const projectRoles = pickRoleKeys(projectYaml);
1106
+ // Top-level: project fully replaces global when present.
1107
+ const projectTop = pickTopLevelKeys(projectYaml);
1108
+ const globalTop = pickTopLevelKeys(globalYaml);
477
1109
 
478
- // Per-role: project overrides global, fall back to DEFAULT_OPSX.
1110
+ // Per-agent: project overrides global, fall back to DEFAULT_OPSX_AGENTS.
479
1111
  const merged: OpsxYamlShape = { ...globalYaml };
480
- for (const role of OPSX_ROLES) {
481
- const v = projectRoles[role] ?? globalRoles[role] ?? DEFAULT_OPSX[role];
482
- (merged as Record<string, unknown>)[role] = v;
1112
+ const foldedAgents: Partial<Record<OpsxAgent, string>> = {};
1113
+ for (const role of OPSX_AGENTS) {
1114
+ foldedAgents[role] = projectTop.agents?.[role] ?? globalTop.agents?.[role] ?? DEFAULT_OPSX_AGENTS[role];
483
1115
  }
1116
+ merged.agents = foldedAgents;
484
1117
 
485
- // Top-level: project fully replaces global when present.
486
- const projectTop = pickTopLevelKeys(projectYaml);
487
- const globalTop = pickTopLevelKeys(globalYaml);
488
1118
  merged.model_allowlist = projectTop.model_allowlist ?? globalTop.model_allowlist;
489
1119
  merged.tiers = projectTop.tiers ?? globalTop.tiers;
490
- merged.role_tiers = projectTop.role_tiers ?? globalTop.role_tiers;
1120
+ // Tier map: whole-key overlay (project map replaces global's).
1121
+ merged.agent_tiers = projectTop.agent_tiers ?? globalTop.agent_tiers;
491
1122
  merged.model_role_tiers = projectTop.model_role_tiers ?? globalTop.model_role_tiers;
492
1123
  merged.plan_providers = projectTop.plan_providers ?? globalTop.plan_providers;
1124
+ merged.plan_providers_remove = projectTop.plan_providers_remove ?? globalTop.plan_providers_remove;
1125
+ merged.payg_last_resort = projectTop.payg_last_resort ?? globalTop.payg_last_resort;
1126
+ // `??` keeps an explicit project `false` (parseProbeBool only falls back on
1127
+ // null/undefined AFTER the merge, so undefined reveals the other layer).
1128
+ merged.china = projectTop.china ?? globalTop.china;
493
1129
  merged.probe_enabled = projectTop.probe_enabled ?? globalTop.probe_enabled;
494
1130
  merged.probe_timeout_ms = projectTop.probe_timeout_ms ?? globalTop.probe_timeout_ms;
495
1131
  merged.probe_http_enabled = projectTop.probe_http_enabled ?? globalTop.probe_http_enabled;
496
1132
  merged.probe_ttl_ms = projectTop.probe_ttl_ms ?? globalTop.probe_ttl_ms;
497
1133
  merged.excluded_providers = projectTop.excluded_providers ?? globalTop.excluded_providers;
1134
+ // Provider 键控 map 同为整键覆盖(先例 concurrency_ceiling_by_provider):项目层
1135
+ // 声明任一键即完整替换全局同名键,不做 per-key 叠加——只想加键就把全局条目抄过来。
1136
+ merged.provider_auto = projectTop.provider_auto ?? globalTop.provider_auto;
1137
+ merged.provider_auto_tiers = projectTop.provider_auto_tiers ?? globalTop.provider_auto_tiers;
1138
+ merged.provider_models = projectTop.provider_models ?? globalTop.provider_models;
1139
+ // 对象键一律整键覆盖(同上 provider_* 先例):项目层声明 speed_aware 即
1140
+ // 整份替换全局,不做字段级叠加。
1141
+ merged.speed_aware = projectTop.speed_aware ?? globalTop.speed_aware;
1142
+ // W4 路由四键同款整键覆盖:项目层声明任一键即完整替换全局同名键。
1143
+ merged.difficulty_routing = projectTop.difficulty_routing ?? globalTop.difficulty_routing;
1144
+ merged.stall_escalation = projectTop.stall_escalation ?? globalTop.stall_escalation;
1145
+ merged.continuity_guard = projectTop.continuity_guard ?? globalTop.continuity_guard;
1146
+ // 顶层标量键同 `??` 惯例(项目层显式值优先,undefined 露出另一层)。
1147
+ merged.quality_preference = projectTop.quality_preference ?? globalTop.quality_preference;
498
1148
  merged.status_probe_enabled = projectTop.status_probe_enabled ?? globalTop.status_probe_enabled;
499
1149
  merged.redis_enabled = projectTop.redis_enabled ?? globalTop.redis_enabled;
500
1150
  merged.redis_host = projectTop.redis_host ?? globalTop.redis_host;
@@ -505,6 +1155,23 @@ export function readOpsxSettingsFromPaths(
505
1155
  merged.redis_probe_interval_ms = projectTop.redis_probe_interval_ms ?? globalTop.redis_probe_interval_ms;
506
1156
  merged.redis_unhealthy_retry_ms = projectTop.redis_unhealthy_retry_ms ?? globalTop.redis_unhealthy_retry_ms;
507
1157
  merged.redis_ttl_ms = projectTop.redis_ttl_ms ?? globalTop.redis_ttl_ms;
1158
+ merged.autoselect_on_start = projectTop.autoselect_on_start ?? globalTop.autoselect_on_start;
1159
+ merged.concurrency_enabled = projectTop.concurrency_enabled ?? globalTop.concurrency_enabled;
1160
+ merged.concurrency_floor = projectTop.concurrency_floor ?? globalTop.concurrency_floor;
1161
+ merged.concurrency_ceiling = projectTop.concurrency_ceiling ?? globalTop.concurrency_ceiling;
1162
+ merged.concurrency_start = projectTop.concurrency_start ?? globalTop.concurrency_start;
1163
+ merged.concurrency_up_ms = projectTop.concurrency_up_ms ?? globalTop.concurrency_up_ms;
1164
+ merged.concurrency_down_ms = projectTop.concurrency_down_ms ?? globalTop.concurrency_down_ms;
1165
+ merged.concurrency_latency_k = projectTop.concurrency_latency_k ?? globalTop.concurrency_latency_k;
1166
+ merged.concurrency_min_samples = projectTop.concurrency_min_samples ?? globalTop.concurrency_min_samples;
1167
+ merged.concurrency_queue_wait_high_ms = projectTop.concurrency_queue_wait_high_ms ?? globalTop.concurrency_queue_wait_high_ms;
1168
+ merged.concurrency_write_min_interval_ms = projectTop.concurrency_write_min_interval_ms ?? globalTop.concurrency_write_min_interval_ms;
1169
+ merged.concurrency_lease_ttl_ms = projectTop.concurrency_lease_ttl_ms ?? globalTop.concurrency_lease_ttl_ms;
1170
+ merged.concurrency_decay_after_ms = projectTop.concurrency_decay_after_ms ?? globalTop.concurrency_decay_after_ms;
1171
+ merged.concurrency_eval_interval_ms = projectTop.concurrency_eval_interval_ms ?? globalTop.concurrency_eval_interval_ms;
1172
+ merged.concurrency_queue_wait_window_ms = projectTop.concurrency_queue_wait_window_ms ?? globalTop.concurrency_queue_wait_window_ms;
1173
+ merged.concurrency_providers = projectTop.concurrency_providers ?? globalTop.concurrency_providers;
1174
+ merged.concurrency_ceiling_by_provider = projectTop.concurrency_ceiling_by_provider ?? globalTop.concurrency_ceiling_by_provider;
508
1175
 
509
1176
  return parseOpsxConfig(merged, warn);
510
1177
  }