@genee/omp-opsx-addon 0.7.0 → 0.9.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`: same semantics as their
27
+ * replacements, still honored; each layer file using them gets one
28
+ * aggregated warning per config read. Scheduled for removal after one
29
+ * release cycle; within one file the new keys win.
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>([
@@ -54,24 +73,58 @@ export const AUTO_TOKEN = 'auto';
54
73
  export const OPSX_ROLES = ['coder', 'reviewer', 'planner', 'proposal-reviewer'] as const;
55
74
  export type OpsxRole = (typeof OPSX_ROLES)[number];
56
75
 
76
+ /** Keys superseded by `agents` / `agent_tiers`; still honored, warned on read. */
77
+ const LEGACY_AGENT_KEYS: readonly (OpsxRole | 'role_tiers')[] = [...OPSX_ROLES, 'role_tiers'];
78
+
57
79
  /** Raw YAML shape — every key is optional. */
58
80
  export interface OpsxYamlShape {
81
+ /** @deprecated Legacy of `agents.coder` — still honored; warned per layer file; removal after one release cycle. */
59
82
  coder?: string;
83
+ /** @deprecated Legacy of `agents.reviewer` — still honored; warned per layer file; removal after one release cycle. */
60
84
  reviewer?: string;
85
+ /** @deprecated Legacy of `agents.planner` — still honored; warned per layer file; removal after one release cycle. */
61
86
  planner?: string;
87
+ /** @deprecated Legacy of `agents['proposal-reviewer']` — still honored; warned per layer file; removal after one release cycle. */
62
88
  'proposal-reviewer'?: string;
89
+ /** Dispatch agent → value; canonical replacement for the legacy top-level role keys. */
90
+ agents?: Partial<Record<OpsxRole, string>>;
63
91
  primary?: string;
64
92
  model_allowlist?: string[];
65
93
  tiers?: TierOverride[];
94
+ /** @deprecated Legacy name of `agent_tiers` (same schema) — still honored; warned per layer file; removal after one release cycle. */
66
95
  role_tiers?: Partial<Record<OpsxRole, TierName>>;
96
+ /** Dispatch agent → tier override for the four opsx agents. */
97
+ agent_tiers?: Partial<Record<OpsxRole, TierName>>;
67
98
  /** OMP model role → tier override; `skip` excludes the role from the overlay. Keys are arbitrary strings (custom roles allowed). */
68
99
  model_role_tiers?: Record<string, string>;
69
100
  plan_providers?: string[];
101
+ /** Providers removed from the effective plan set: (defaults ∪ plan_providers) − plan_providers_remove. */
102
+ plan_providers_remove?: string[];
103
+ /** false collapses the PAYG-last coverage partition (pay-as-you-go candidates compete normally). Default true. */
104
+ payg_last_resort?: boolean;
105
+ /** china 家族缺省排除 + 同账号区域变体偏好方向(true = 国内语境:排除 gpt/claude/openai/anthropic 家族、区域变体保留国内入口)。缺省 false。 */
106
+ china?: boolean;
70
107
  probe_enabled?: boolean;
71
108
  probe_timeout_ms?: number;
72
109
  probe_http_enabled?: boolean;
73
110
  probe_ttl_ms?: number;
74
111
  excluded_providers?: string[];
112
+ /** provider → 服务端路由哨兵模型 id(如 `ark-coding-plan: ark-code-latest`)。键经 canonicalizeProvider 规范化。 */
113
+ provider_auto?: Record<string, string>;
114
+ /** provider → 哨兵声明档位(tiny|low|mid|high|top);非法值 warn 丢弃,未声明按 mid。 */
115
+ provider_auto_tiers?: Record<string, string>;
116
+ /** provider → 模式数组:列出的 provider 收窄到命中模型,未列出不受限。 */
117
+ provider_models?: Record<string, string[]>;
118
+ /** 速度感知选择:enabled(缺省 true)、mode(tie-break|aggressive,缺省 tie-break)、min_samples(缺省 20)。 */
119
+ speed_aware?: { enabled?: boolean; mode?: string; min_samples?: number };
120
+ /** 难度路由(W4):prompt 分类 → 写入集 tier 目标偏移。缺省 false。 */
121
+ difficulty_routing?: { enabled?: boolean };
122
+ /** stall 升档(W4):同签名滑窗重复 → default 目标 +1。缺省 false / 6 / 3。 */
123
+ stall_escalation?: { enabled?: boolean; window?: number; threshold?: number };
124
+ /** 连续性守门(W4):在选模型粘性。缺省 false / weight 0.6。 */
125
+ continuity_guard?: { enabled?: boolean; weight?: number };
126
+ /** 质量带旋钮(W4):balanced | cost | quality。缺省 balanced(独立于路由开关)。 */
127
+ quality_preference?: string;
75
128
  status_probe_enabled?: boolean;
76
129
  redis_enabled?: boolean;
77
130
  redis_host?: string;
@@ -82,6 +135,24 @@ export interface OpsxYamlShape {
82
135
  redis_probe_interval_ms?: number;
83
136
  redis_unhealthy_retry_ms?: number;
84
137
  redis_ttl_ms?: number;
138
+ /** 会话启动自动应用一次选择(缺省 true);false 时 session_start 不应用、不写覆盖,行为与本键引入前一致。 */
139
+ autoselect_on_start?: boolean;
140
+ concurrency_enabled?: boolean;
141
+ concurrency_floor?: number;
142
+ concurrency_ceiling?: number;
143
+ concurrency_start?: number;
144
+ concurrency_up_ms?: number;
145
+ concurrency_down_ms?: number;
146
+ concurrency_latency_k?: number;
147
+ concurrency_min_samples?: number;
148
+ concurrency_queue_wait_high_ms?: number;
149
+ concurrency_write_min_interval_ms?: number;
150
+ concurrency_lease_ttl_ms?: number;
151
+ concurrency_decay_after_ms?: number;
152
+ concurrency_eval_interval_ms?: number;
153
+ concurrency_queue_wait_window_ms?: number;
154
+ concurrency_providers?: string[];
155
+ concurrency_ceiling_by_provider?: Record<string, number>;
85
156
  }
86
157
 
87
158
  /** A role value as parsed. */
@@ -100,6 +171,28 @@ export interface ResolvedOpsxConfig {
100
171
  ompRoleTiers: Record<string, TierName | 'skip'>;
101
172
  /** Provider ids that have monthly plan subscriptions. */
102
173
  planProviders: string[];
174
+ /** Canonical provider ids removed from the effective plan set. */
175
+ planProvidersRemove: string[];
176
+ /** PAYG candidates sort last (coverage partition). false collapses the partition. Default true. */
177
+ paygLastResort: boolean;
178
+ /** Standing china family exclusion + regional-variant preference direction. Default false (= status quo). */
179
+ china: boolean;
180
+ /** provider → 服务端路由哨兵模型 id(键已 canonicalizeProvider 规范化)。空对象 = 无哨兵。 */
181
+ providerAuto: Record<string, string>;
182
+ /** provider → 哨兵声明档位(值已过 TIER_NAMES 校验)。空对象 = 全部缺省 mid。 */
183
+ providerAutoTiers: Record<string, TierName>;
184
+ /** provider → 模式数组(键已规范化):仅列出的 provider 被收窄,未列出不受限。 */
185
+ providerModels: Record<string, string[]>;
186
+ /** 速度感知选择三键(解析层已校验并补齐缺省;enabled=false 时装配层零读取)。 */
187
+ speedAware: SpeedAwareConfig;
188
+ /** W4 难度路由(缺省 false:结构改造 opt-in,W1-W3 收益不依赖它)。 */
189
+ difficultyRouting: RoutingConfig;
190
+ /** W4 stall 升档(缺省 false / window 6 / threshold 3)。 */
191
+ stallEscalation: StallEscalationConfig;
192
+ /** W4 连续性守门(缺省 false / weight 0.6)。 */
193
+ continuityGuard: ContinuityGuardConfig;
194
+ /** W4 质量带旋钮(缺省 balanced = 既有比较器分支逐字节)。 */
195
+ qualityPreference: QualityPreference;
103
196
  warnings: string[];
104
197
  /** 是否开启可达性探测,默认 true */
105
198
  probe_enabled: boolean;
@@ -131,6 +224,49 @@ export interface ResolvedOpsxConfig {
131
224
  redis_unhealthy_retry_ms: number;
132
225
  /** Redis key TTL ms(默认 180000)。 */
133
226
  redis_ttl_ms: number;
227
+ /** 会话启动自动应用一次选择,默认 true;false 时 session_start handler 直接返回(不 setModel、不写覆盖)。 */
228
+ autoselectOnStart: boolean;
229
+ /** 是否开启自适应并发控制,默认 true;false 时装配层不创建控制器(零写入)。 */
230
+ concurrency_enabled: boolean;
231
+ /**
232
+ * 全局并发上限下界,默认 4(须为 ≥ 1 的整数)。每个 provider 的**有效下限**
233
+ * 为 `min(concurrency_floor, initial)`(见 concurrency-tuner 的
234
+ * `effectiveFloor` / `createProviderState`):该值设定区间,MUST NOT 抬高
235
+ * provider 自身的静态现值(静态 3 的 provider 下限与起点都保持 3)。
236
+ */
237
+ concurrency_floor: number;
238
+ /**
239
+ * 全局并发上限上界,默认 16。低于**有效 floor** 时:floor 未有效显式配置
240
+ * (缺省或非法回落)则把 floor 压到该 ceiling(区间退化为单点),否则回落
241
+ * 默认(ceiling → 16,必要时 floor 也回落默认);见 `parseOpsxConfig`。
242
+ */
243
+ concurrency_ceiling: number;
244
+ /** 无现有静态值时的保守启动值,默认 4(区间 clamp 由控制器负责)。 */
245
+ concurrency_start: number;
246
+ /** 加性上调所需干净窗口时长 ms,默认 30000。 */
247
+ concurrency_up_ms: number;
248
+ /** 乘性下调后的冷却时长 ms,默认 60000。 */
249
+ concurrency_down_ms: number;
250
+ /** 延迟膨胀判定比值,默认 2(> 1 的任意正数,允许小数)。 */
251
+ concurrency_latency_k: number;
252
+ /** 延迟判定所需最小样本数,默认 5。 */
253
+ concurrency_min_samples: number;
254
+ /** 排队等待 p50 高企阈值 ms,默认 2000(超阈值禁止上调)。 */
255
+ concurrency_queue_wait_high_ms: number;
256
+ /** 同一 provider 两次成功写入的最小间隔 ms,默认 20000。 */
257
+ concurrency_write_min_interval_ms: number;
258
+ /** 租约 TTL ms,默认 60000。 */
259
+ concurrency_lease_ttl_ms: number;
260
+ /** 无观测衰减周期 ms,默认 86400000(24h)。 */
261
+ concurrency_decay_after_ms: number;
262
+ /** 评估节拍 ms,默认 30000。 */
263
+ concurrency_eval_interval_ms: number;
264
+ /** 排队等待护栏统计窗口 ms,默认 60000(独立于评估节拍)。 */
265
+ concurrency_queue_wait_window_ms: number;
266
+ /** 显式希望由控制器试探上限的 provider(规范化 id,去重、声明顺序)。 */
267
+ concurrency_providers: string[];
268
+ /** per-provider ceiling 覆盖(键已规范化,值为 ≥ floor 的正整数)。 */
269
+ concurrency_ceiling_by_provider: Record<string, number>;
134
270
  }
135
271
 
136
272
  /** Default values applied when no config is provided. */
@@ -202,9 +338,14 @@ export function parseTierOverrides(
202
338
  return out;
203
339
  }
204
340
 
205
- /** Validate role_tiers entries. */
341
+ /**
342
+ * Validate tier entries for the four opsx agents. `keyLabel` names the config
343
+ * key being parsed (`agent_tiers`, or legacy `role_tiers`) so warnings point
344
+ * at the key the user actually wrote.
345
+ */
206
346
  export function parseRoleTiers(
207
347
  raw: unknown,
348
+ keyLabel: 'agent_tiers' | 'role_tiers',
208
349
  warn: (msg: string) => void,
209
350
  ): Partial<Record<OpsxRole, TierName>> {
210
351
  if (!raw || typeof raw !== 'object') return {};
@@ -213,11 +354,11 @@ export function parseRoleTiers(
213
354
  const v = (raw as Record<string, unknown>)[role];
214
355
  if (v === undefined || v === null) continue;
215
356
  if (typeof v !== 'string') {
216
- warn(`[omp-opsx-addon] role_tiers.${role}: expected tier name, got ${typeof v}`);
357
+ warn(`[omp-opsx-addon] ${keyLabel}.${role}: expected tier name, got ${typeof v}`);
217
358
  continue;
218
359
  }
219
360
  if (!TIER_NAMES.includes(v as TierName)) {
220
- warn(`[omp-opsx-addon] role_tiers.${role}: unknown tier "${v}" (use one of ${TIER_NAMES.join('/')})`);
361
+ warn(`[omp-opsx-addon] ${keyLabel}.${role}: unknown tier "${v}" (use one of ${TIER_NAMES.join('/')})`);
221
362
  continue;
222
363
  }
223
364
  out[role] = v as TierName;
@@ -306,6 +447,375 @@ export function parseStringArray(raw: unknown, key: string, warn: (msg: string)
306
447
  return out;
307
448
  }
308
449
 
450
+ /**
451
+ * `concurrency_*` 默认值 —— 逐键对应 spec `provider-concurrency-control`
452
+ * 的「配置面与降级」Requirement(不要按 tasks 里的数字出现顺序推断)。
453
+ */
454
+ export const CONCURRENCY_DEFAULTS = {
455
+ enabled: true,
456
+ floor: 4,
457
+ ceiling: 16,
458
+ start: 4,
459
+ up_ms: 30_000,
460
+ down_ms: 60_000,
461
+ latency_k: 2,
462
+ min_samples: 5,
463
+ queue_wait_high_ms: 2_000,
464
+ write_min_interval_ms: 20_000,
465
+ lease_ttl_ms: 60_000,
466
+ decay_after_ms: 86_400_000,
467
+ eval_interval_ms: 30_000,
468
+ queue_wait_window_ms: 60_000,
469
+ } as const;
470
+
471
+ /** 解析正数(允许小数,用于 `concurrency_latency_k` 这类比值):非法则返回 defaultValue 并 warn。 */
472
+ export function parsePositiveNumber(raw: unknown, key: string, defaultValue: number, warn: (msg: string) => void): number {
473
+ if (raw === undefined || raw === null) return defaultValue;
474
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return raw;
475
+ warn(`[omp-opsx-addon] ${key}: expected positive number, got ${JSON.stringify(raw)}`);
476
+ return defaultValue;
477
+ }
478
+
479
+ /** 规范化 provider id 列表:canonicalize + 去重,保持声明顺序;非字符串/空串跳过并 warn。 */
480
+ function canonicalizeProviderList(raw: readonly unknown[], key: string, warn: (msg: string) => void): string[] {
481
+ const out: string[] = [];
482
+ const seen = new Set<string>();
483
+ for (const entry of raw) {
484
+ if (typeof entry !== 'string') {
485
+ warn(`[omp-opsx-addon] ${key}: skipping non-string entry ${JSON.stringify(entry)}`);
486
+ continue;
487
+ }
488
+ const canonical = canonicalizeProvider(entry.trim());
489
+ if (!canonical) {
490
+ warn(`[omp-opsx-addon] ${key}: skipping empty provider id`);
491
+ continue;
492
+ }
493
+ if (!seen.has(canonical)) {
494
+ seen.add(canonical);
495
+ out.push(canonical);
496
+ }
497
+ }
498
+ return out;
499
+ }
500
+
501
+ /** 解析 `concurrency_providers`:非数组回落 [] 并 warn;条目规范化、去重。 */
502
+ export function parseConcurrencyProviders(raw: unknown, warn: (msg: string) => void): string[] {
503
+ if (raw === undefined || raw === null) return [];
504
+ if (!Array.isArray(raw)) {
505
+ warn(`[omp-opsx-addon] concurrency_providers: expected array of provider ids, got ${JSON.stringify(raw)}`);
506
+ return [];
507
+ }
508
+ return canonicalizeProviderList(raw, 'concurrency_providers', warn);
509
+ }
510
+
511
+ /** 解析 `plan_providers` / `plan_providers_remove`:非数组回落 [] 并 warn;条目规范化、去重。 */
512
+ function parsePlanProviderList(raw: unknown, key: string, warn: (msg: string) => void): string[] {
513
+ if (raw === undefined || raw === null) return [];
514
+ if (!Array.isArray(raw)) {
515
+ warn(`[omp-opsx-addon] ${key}: expected array of provider ids, got ${JSON.stringify(raw)}`);
516
+ return [];
517
+ }
518
+ return canonicalizeProviderList(raw, key, warn);
519
+ }
520
+
521
+ /**
522
+ * 解析 `concurrency_ceiling_by_provider`:键规范化(大小写 / `arkcodingplan` 别名同源),
523
+ * 值须为 ≥ 1 的正整数;非法条目跳过并 warn(回落到全局 `concurrency_ceiling`),
524
+ * 非对象回落 {} 并 warn。
525
+ *
526
+ * 低于全局 `floor` 的覆盖**保留**并 warn:它是用户对单个 provider 的显式收窄,
527
+ * 丢弃它会让该 provider 的 ceiling 回落到全局值(等于抬高用户设定)。控制器的
528
+ * `createProviderState` 会把该 provider 的有效下限压到 `min(floor, 静态现值,
529
+ * 该 ceiling)`,因此 `floor > ceiling` 的覆盖仍自洽(该 provider 固定在它的
530
+ * ceiling 上)。
531
+ */
532
+ export function parseCeilingByProvider(
533
+ raw: unknown,
534
+ floor: number,
535
+ warn: (msg: string) => void,
536
+ ): Record<string, number> {
537
+ if (raw === undefined || raw === null) return {};
538
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
539
+ warn(`[omp-opsx-addon] concurrency_ceiling_by_provider: expected object mapping provider → ceiling, got ${JSON.stringify(raw)}`);
540
+ return {};
541
+ }
542
+ const out: Record<string, number> = {};
543
+ for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
544
+ const canonical = canonicalizeProvider(id.trim());
545
+ if (!canonical) {
546
+ warn('[omp-opsx-addon] concurrency_ceiling_by_provider: skipping empty provider id');
547
+ continue;
548
+ }
549
+ if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
550
+ warn(`[omp-opsx-addon] concurrency_ceiling_by_provider.${id}: expected positive integer, got ${JSON.stringify(value)}`);
551
+ continue;
552
+ }
553
+ if (value < floor) {
554
+ warn(
555
+ `[omp-opsx-addon] concurrency_ceiling_by_provider.${id}: ${value} < concurrency_floor ${floor}; keeping the override(有效下限 = min(${value}, provider 静态现值))`,
556
+ );
557
+ }
558
+ if (out[canonical] !== undefined && out[canonical] !== value) {
559
+ warn(`[omp-opsx-addon] concurrency_ceiling_by_provider.${id}: duplicate canonical id "${canonical}" (${out[canonical]} → ${value}); later key wins`);
560
+ }
561
+ out[canonical] = value;
562
+ }
563
+ return out;
564
+ }
565
+
566
+ /** 解析 `provider_auto`:键规范化(大小写 / 别名同源),键值均须字符串;非法条目跳过并 warn。 */
567
+ export function parseProviderAuto(raw: unknown, warn: (msg: string) => void): Record<string, string> {
568
+ if (raw === undefined || raw === null) return {};
569
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
570
+ warn(`[omp-opsx-addon] provider_auto: expected object mapping provider → auto model id, got ${JSON.stringify(raw)}`);
571
+ return {};
572
+ }
573
+ const out: Record<string, string> = {};
574
+ for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
575
+ const canonical = canonicalizeProvider(id.trim());
576
+ if (!canonical) {
577
+ warn('[omp-opsx-addon] provider_auto: skipping empty provider id');
578
+ continue;
579
+ }
580
+ if (typeof value !== 'string' || !value.trim()) {
581
+ warn(`[omp-opsx-addon] provider_auto.${id}: expected model id string, got ${JSON.stringify(value)}`);
582
+ continue;
583
+ }
584
+ out[canonical] = value.trim();
585
+ }
586
+ return out;
587
+ }
588
+
589
+ /** 解析 `provider_auto_tiers`:键规范化,值经 TIER_NAMES 校验;非法值 warn 并丢弃(消费侧缺省 mid)。 */
590
+ export function parseProviderAutoTiers(raw: unknown, warn: (msg: string) => void): Record<string, TierName> {
591
+ if (raw === undefined || raw === null) return {};
592
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
593
+ warn(`[omp-opsx-addon] provider_auto_tiers: expected object mapping provider → tier name, got ${JSON.stringify(raw)}`);
594
+ return {};
595
+ }
596
+ const out: Record<string, TierName> = {};
597
+ for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
598
+ const canonical = canonicalizeProvider(id.trim());
599
+ if (!canonical) {
600
+ warn('[omp-opsx-addon] provider_auto_tiers: skipping empty provider id');
601
+ continue;
602
+ }
603
+ if (typeof value !== 'string' || !(TIER_NAMES as readonly string[]).includes(value)) {
604
+ warn(`[omp-opsx-addon] provider_auto_tiers.${id}: expected one of ${TIER_NAMES.join('|')}, got ${JSON.stringify(value)}`);
605
+ continue;
606
+ }
607
+ out[canonical] = value as TierName;
608
+ }
609
+ return out;
610
+ }
611
+
612
+ /** 解析 `provider_models`:键规范化,值须为字符串数组(模式);非数组整键 warn 丢弃,非字符串条目跳过并 warn。 */
613
+ export function parseProviderModels(raw: unknown, warn: (msg: string) => void): Record<string, string[]> {
614
+ if (raw === undefined || raw === null) return {};
615
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
616
+ warn(`[omp-opsx-addon] provider_models: expected object mapping provider → pattern array, got ${JSON.stringify(raw)}`);
617
+ return {};
618
+ }
619
+ const out: Record<string, string[]> = {};
620
+ for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
621
+ const canonical = canonicalizeProvider(id.trim());
622
+ if (!canonical) {
623
+ warn('[omp-opsx-addon] provider_models: skipping empty provider id');
624
+ continue;
625
+ }
626
+ if (!Array.isArray(value)) {
627
+ warn(`[omp-opsx-addon] provider_models.${id}: expected array of patterns, got ${JSON.stringify(value)}`);
628
+ continue;
629
+ }
630
+ const patterns: string[] = [];
631
+ for (const entry of value) {
632
+ if (typeof entry !== 'string' || !entry.trim()) {
633
+ warn(`[omp-opsx-addon] provider_models.${id}: skipping non-string/empty pattern ${JSON.stringify(entry)}`);
634
+ continue;
635
+ }
636
+ patterns.push(entry.trim());
637
+ }
638
+ if (patterns.length > 0) out[canonical] = patterns;
639
+ }
640
+ return out;
641
+ }
642
+
643
+ /** speed_aware 三键缺省(enabled 缺省开启:T2 类别硬分区 + 缺数据零漂移已消解开启风险)。 */
644
+ export function defaultSpeedAware(): SpeedAwareConfig {
645
+ return { enabled: true, mode: 'tie-break', minSamples: DEFAULT_MIN_SAMPLES };
646
+ }
647
+
648
+ /** 解析 `speed_aware`:非对象整键 warn 回落缺省;mode 非法枚举 / min_samples 非正数 / enabled 非布尔各键 warn 回落各自缺省(parseProviderAutoTiers warn 矩阵同款)。 */
649
+ export function parseSpeedAware(raw: unknown, warn: (msg: string) => void): SpeedAwareConfig {
650
+ if (raw === undefined || raw === null) return defaultSpeedAware();
651
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
652
+ warn(`[omp-opsx-addon] speed_aware: expected object mapping { enabled, mode, min_samples }, got ${JSON.stringify(raw)}`);
653
+ return defaultSpeedAware();
654
+ }
655
+ const obj = raw as { enabled?: unknown; mode?: unknown; min_samples?: unknown };
656
+ let enabled = true;
657
+ if (obj.enabled !== undefined && obj.enabled !== null) {
658
+ if (typeof obj.enabled === 'boolean') {
659
+ enabled = obj.enabled;
660
+ } else {
661
+ warn(`[omp-opsx-addon] speed_aware.enabled: expected boolean, got ${JSON.stringify(obj.enabled)}`);
662
+ }
663
+ }
664
+ let mode: SpeedMode = 'tie-break';
665
+ if (obj.mode !== undefined && obj.mode !== null) {
666
+ if (typeof obj.mode === 'string' && (SPEED_MODES as readonly string[]).includes(obj.mode)) {
667
+ mode = obj.mode as SpeedMode;
668
+ } else {
669
+ warn(`[omp-opsx-addon] speed_aware.mode: expected one of ${SPEED_MODES.join('|')}, got ${JSON.stringify(obj.mode)}`);
670
+ }
671
+ }
672
+ let minSamples = DEFAULT_MIN_SAMPLES;
673
+ if (obj.min_samples !== undefined && obj.min_samples !== null) {
674
+ if (typeof obj.min_samples === 'number' && Number.isFinite(obj.min_samples) && obj.min_samples > 0) {
675
+ minSamples = obj.min_samples;
676
+ } else {
677
+ warn(`[omp-opsx-addon] speed_aware.min_samples: expected positive number, got ${JSON.stringify(obj.min_samples)}`);
678
+ }
679
+ }
680
+ return { enabled, mode, minSamples };
681
+ }
682
+
683
+ /** W4 难度路由缺省(enabled 缺省 false:结构改造 opt-in,与 speed_aware 缺省 true 的差异见 design D7)。 */
684
+ export function defaultDifficultyRouting(): RoutingConfig {
685
+ return { enabled: false };
686
+ }
687
+
688
+ /** 解析 `difficulty_routing`:非对象整键 warn 回落缺省;enabled 非布尔逐键 warn 回落(parseSpeedAware 矩阵同款)。 */
689
+ export function parseDifficultyRouting(raw: unknown, warn: (msg: string) => void): RoutingConfig {
690
+ if (raw === undefined || raw === null) return defaultDifficultyRouting();
691
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
692
+ warn(`[omp-opsx-addon] difficulty_routing: expected object mapping { enabled }, got ${JSON.stringify(raw)}`);
693
+ return defaultDifficultyRouting();
694
+ }
695
+ const obj = raw as { enabled?: unknown };
696
+ let enabled = false;
697
+ if (obj.enabled !== undefined && obj.enabled !== null) {
698
+ if (typeof obj.enabled === 'boolean') {
699
+ enabled = obj.enabled;
700
+ } else {
701
+ warn(`[omp-opsx-addon] difficulty_routing.enabled: expected boolean, got ${JSON.stringify(obj.enabled)}`);
702
+ }
703
+ }
704
+ return { enabled };
705
+ }
706
+
707
+ /** W4 stall 升档缺省(window=6 / threshold=3,LiteLLM 同参)。 */
708
+ export function defaultStallEscalation(): StallEscalationConfig {
709
+ return { enabled: false, window: DEFAULT_WINDOW, threshold: DEFAULT_THRESHOLD };
710
+ }
711
+
712
+ /** 解析 `stall_escalation`:非对象整键回落;enabled 非布尔、window/threshold 非正整数各键 warn 回落各自缺省。 */
713
+ export function parseStallEscalation(raw: unknown, warn: (msg: string) => void): StallEscalationConfig {
714
+ if (raw === undefined || raw === null) return defaultStallEscalation();
715
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
716
+ warn(`[omp-opsx-addon] stall_escalation: expected object mapping { enabled, window, threshold }, got ${JSON.stringify(raw)}`);
717
+ return defaultStallEscalation();
718
+ }
719
+ const obj = raw as { enabled?: unknown; window?: unknown; threshold?: unknown };
720
+ let enabled = false;
721
+ if (obj.enabled !== undefined && obj.enabled !== null) {
722
+ if (typeof obj.enabled === 'boolean') {
723
+ enabled = obj.enabled;
724
+ } else {
725
+ warn(`[omp-opsx-addon] stall_escalation.enabled: expected boolean, got ${JSON.stringify(obj.enabled)}`);
726
+ }
727
+ }
728
+ let window = DEFAULT_WINDOW;
729
+ if (obj.window !== undefined && obj.window !== null) {
730
+ if (typeof obj.window === 'number' && Number.isInteger(obj.window) && obj.window > 0) {
731
+ window = obj.window;
732
+ } else {
733
+ warn(`[omp-opsx-addon] stall_escalation.window: expected positive integer, got ${JSON.stringify(obj.window)}`);
734
+ }
735
+ }
736
+ let threshold = DEFAULT_THRESHOLD;
737
+ if (obj.threshold !== undefined && obj.threshold !== null) {
738
+ if (typeof obj.threshold === 'number' && Number.isInteger(obj.threshold) && obj.threshold > 0) {
739
+ threshold = obj.threshold;
740
+ } else {
741
+ warn(`[omp-opsx-addon] stall_escalation.threshold: expected positive integer, got ${JSON.stringify(obj.threshold)}`);
742
+ }
743
+ }
744
+ return { enabled, window, threshold };
745
+ }
746
+
747
+ /** W4 连续性守门缺省(weight 缺省 0.6:越高越保守/粘性越强)。 */
748
+ export function defaultContinuityGuard(): ContinuityGuardConfig {
749
+ return { enabled: false, weight: DEFAULT_WEIGHT };
750
+ }
751
+
752
+ /** 解析 `continuity_guard`:非对象整键回落;enabled 非布尔、weight 越出 (0,1] 开闭区间各键 warn 回落各自缺省。 */
753
+ export function parseContinuityGuard(raw: unknown, warn: (msg: string) => void): ContinuityGuardConfig {
754
+ if (raw === undefined || raw === null) return defaultContinuityGuard();
755
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
756
+ warn(`[omp-opsx-addon] continuity_guard: expected object mapping { enabled, weight }, got ${JSON.stringify(raw)}`);
757
+ return defaultContinuityGuard();
758
+ }
759
+ const obj = raw as { enabled?: unknown; weight?: unknown };
760
+ let enabled = false;
761
+ if (obj.enabled !== undefined && obj.enabled !== null) {
762
+ if (typeof obj.enabled === 'boolean') {
763
+ enabled = obj.enabled;
764
+ } else {
765
+ warn(`[omp-opsx-addon] continuity_guard.enabled: expected boolean, got ${JSON.stringify(obj.enabled)}`);
766
+ }
767
+ }
768
+ let weight = DEFAULT_WEIGHT;
769
+ if (obj.weight !== undefined && obj.weight !== null) {
770
+ if (typeof obj.weight === 'number' && Number.isFinite(obj.weight) && obj.weight > 0 && obj.weight <= 1) {
771
+ weight = obj.weight;
772
+ } else {
773
+ warn(`[omp-opsx-addon] continuity_guard.weight: expected number in (0, 1], got ${JSON.stringify(obj.weight)}`);
774
+ }
775
+ }
776
+ return { enabled, weight };
777
+ }
778
+
779
+ /** 解析 `quality_preference`:非法枚举 warn 回落 balanced(独立于三开关的顶层旋钮)。 */
780
+ export function parseQualityPreference(raw: unknown, warn: (msg: string) => void): QualityPreference {
781
+ if (raw === undefined || raw === null) return 'balanced';
782
+ if (typeof raw === 'string' && (QUALITY_PREFERENCES as readonly string[]).includes(raw)) {
783
+ return raw as QualityPreference;
784
+ }
785
+ warn(`[omp-opsx-addon] quality_preference: expected one of ${QUALITY_PREFERENCES.join('|')}, got ${JSON.stringify(raw)}`);
786
+ return 'balanced';
787
+ }
788
+
789
+ /**
790
+ * 受管 provider 集合(spec: 受管集合 = `concurrency_providers` ∪
791
+ * `providers.maxInFlightRequests` 的键)。纯函数:`providers.maxInFlightRequests`
792
+ * 的读取由装配层负责(config root 的 `config.yml`)。
793
+ *
794
+ * 两侧 id 均经 `canonicalizeProvider` 规范化(大小写与 `ArkCodingPlan` →
795
+ * `ark-coding-plan` 别名收敛为同一列),去重并返回稳定顺序 —— 先
796
+ * `concurrency_providers` 的声明序,再 limits 侧新增键序。
797
+ */
798
+ export function resolveManagedProviders(
799
+ concurrencyProviders: readonly string[] | undefined,
800
+ providerLimitIds: readonly string[] | Record<string, unknown> | undefined,
801
+ ): string[] {
802
+ const limitIds = Array.isArray(providerLimitIds)
803
+ ? providerLimitIds
804
+ : providerLimitIds
805
+ ? Object.keys(providerLimitIds)
806
+ : [];
807
+ const out: string[] = [];
808
+ const seen = new Set<string>();
809
+ for (const id of [...(concurrencyProviders ?? []), ...limitIds]) {
810
+ if (typeof id !== 'string') continue;
811
+ const canonical = canonicalizeProvider(id.trim());
812
+ if (!canonical || seen.has(canonical)) continue;
813
+ seen.add(canonical);
814
+ out.push(canonical);
815
+ }
816
+ return out;
817
+ }
818
+
309
819
  /** Parse the full raw opsx config. */
310
820
  export function parseOpsxConfig(
311
821
  raw: unknown,
@@ -318,15 +828,21 @@ export function parseOpsxConfig(
318
828
  warn(m);
319
829
  };
320
830
 
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');
831
+ // `agents` mapping (canonical form): the key set is exactly the four
832
+ // managed agent names, so unknown subkeys are reported and dropped
833
+ // instead of vanishing silently.
834
+ const agentsMap = obj.agents as Record<string, unknown> | undefined;
835
+ if (agentsMap && typeof agentsMap === 'object') {
836
+ for (const key of Object.keys(agentsMap)) {
837
+ if (!(OPSX_ROLES as readonly string[]).includes(key)) {
838
+ localWarn(`[omp-opsx-addon] agents: unknown agent "${key}" (valid: ${OPSX_ROLES.join(', ')}); entry ignored`);
839
+ }
840
+ }
325
841
  }
326
-
327
842
  const roles: Record<OpsxRole, RoleValue> = {} as Record<OpsxRole, RoleValue>;
328
843
  for (const role of OPSX_ROLES) {
329
- const raw = (obj as Record<string, unknown>)[role];
844
+ // Within one file the `agents` mapping beats the legacy top-level key.
845
+ const raw = agentsMap?.[role] ?? (obj as Record<string, unknown>)[role];
330
846
  roles[role] = parseRoleValue(raw as string | undefined, localWarn);
331
847
  }
332
848
  let redisHost = '127.0.0.1';
@@ -338,13 +854,72 @@ export function parseOpsxConfig(
338
854
  }
339
855
  }
340
856
 
857
+ // 并发控制:floor / ceiling 先各自回落,再校验区间(ceiling < floor)。
858
+ // floor 未显式配置时它只是默认值,MUST NOT 反过来抬高用户显式设定的 ceiling
859
+ // ——那等于放开了用户明确设下的上界。这种情况下把 floor 压到 ceiling(区间
860
+ // 退化为单点),保持上界意图;两端都显式给出矛盾值时仍按 spec 回落默认。
861
+ const floorConfigured = typeof obj.concurrency_floor === 'number'
862
+ && Number.isFinite(obj.concurrency_floor)
863
+ && Number.isInteger(obj.concurrency_floor)
864
+ && obj.concurrency_floor > 0;
865
+ let concurrencyFloor = parsePositiveInt(obj.concurrency_floor, 'concurrency_floor', CONCURRENCY_DEFAULTS.floor, localWarn);
866
+ let concurrencyCeiling = parsePositiveInt(obj.concurrency_ceiling, 'concurrency_ceiling', CONCURRENCY_DEFAULTS.ceiling, localWarn);
867
+ if (concurrencyCeiling < concurrencyFloor) {
868
+ if (!floorConfigured) {
869
+ localWarn(
870
+ `[omp-opsx-addon] concurrency_ceiling: ${concurrencyCeiling} < default concurrency_floor ${concurrencyFloor}; using the ceiling as the floor`,
871
+ );
872
+ concurrencyFloor = concurrencyCeiling;
873
+ } else {
874
+ localWarn(
875
+ `[omp-opsx-addon] concurrency_ceiling: ${concurrencyCeiling} < concurrency_floor ${concurrencyFloor}; falling back to ${CONCURRENCY_DEFAULTS.ceiling}`,
876
+ );
877
+ concurrencyCeiling = CONCURRENCY_DEFAULTS.ceiling;
878
+ if (concurrencyCeiling < concurrencyFloor) {
879
+ localWarn(
880
+ `[omp-opsx-addon] concurrency_floor: ${concurrencyFloor} > default ceiling ${CONCURRENCY_DEFAULTS.ceiling}; falling back to ${CONCURRENCY_DEFAULTS.floor}`,
881
+ );
882
+ concurrencyFloor = CONCURRENCY_DEFAULTS.floor;
883
+ }
884
+ }
885
+ }
886
+
887
+ // 并发控制:`concurrency_up_ms` 是「干净窗口」的长度,`concurrency_eval_interval_ms`
888
+ // 是评估节奏。`evaluateWindow` 每次评估最多记 1 个干净窗口并丢弃余数
889
+ // (`cleanWindowMs = 0`),所以 up_ms ≤ eval_interval 时二者的 limit 轨迹
890
+ // 完全一致;把 up_ms 归一为 eval_interval 只是让配置反映真实生效值,
891
+ // 不改变任何行为(见 concurrency-tuner.ts 的 `max(up_ms, eval_interval_ms)`)。
892
+ let concurrencyUpMs = parsePositiveInt(obj.concurrency_up_ms, 'concurrency_up_ms', CONCURRENCY_DEFAULTS.up_ms, localWarn);
893
+ const concurrencyEvalIntervalMs = parsePositiveInt(obj.concurrency_eval_interval_ms, 'concurrency_eval_interval_ms', CONCURRENCY_DEFAULTS.eval_interval_ms, localWarn);
894
+ if (concurrencyUpMs < concurrencyEvalIntervalMs) {
895
+ localWarn(
896
+ `[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))`,
897
+ );
898
+ concurrencyUpMs = concurrencyEvalIntervalMs;
899
+ }
900
+
341
901
  return {
342
902
  roles,
343
903
  modelAllowlist: parseModelAllowlist(obj.model_allowlist, localWarn),
344
904
  tierOverrides: parseTierOverrides(obj.tiers, localWarn),
345
- roleTiers: parseRoleTiers(obj.role_tiers, localWarn),
905
+ roleTiers: parseRoleTiers(
906
+ obj.agent_tiers ?? obj.role_tiers,
907
+ obj.agent_tiers !== undefined && obj.agent_tiers !== null ? 'agent_tiers' : 'role_tiers',
908
+ localWarn,
909
+ ),
346
910
  ompRoleTiers: parseOmpRoleTiers(obj.model_role_tiers, localWarn),
347
- planProviders: sanitizePlanProviders(obj.plan_providers),
911
+ planProviders: parsePlanProviderList(obj.plan_providers, 'plan_providers', localWarn),
912
+ planProvidersRemove: parsePlanProviderList(obj.plan_providers_remove, 'plan_providers_remove', localWarn),
913
+ paygLastResort: parseProbeBool(obj.payg_last_resort, 'payg_last_resort', true, localWarn),
914
+ china: parseProbeBool(obj.china, 'china', false, localWarn),
915
+ providerAuto: parseProviderAuto(obj.provider_auto, localWarn),
916
+ providerAutoTiers: parseProviderAutoTiers(obj.provider_auto_tiers, localWarn),
917
+ providerModels: parseProviderModels(obj.provider_models, localWarn),
918
+ speedAware: parseSpeedAware(obj.speed_aware, localWarn),
919
+ difficultyRouting: parseDifficultyRouting(obj.difficulty_routing, localWarn),
920
+ stallEscalation: parseStallEscalation(obj.stall_escalation, localWarn),
921
+ continuityGuard: parseContinuityGuard(obj.continuity_guard, localWarn),
922
+ qualityPreference: parseQualityPreference(obj.quality_preference, localWarn),
348
923
  warnings,
349
924
  probe_enabled: parseProbeBool(obj.probe_enabled, 'probe_enabled', true, localWarn),
350
925
  probe_timeout_ms: parsePositiveInt(obj.probe_timeout_ms, 'probe_timeout_ms', 3000, localWarn),
@@ -361,6 +936,23 @@ export function parseOpsxConfig(
361
936
  redis_probe_interval_ms: parsePositiveInt(obj.redis_probe_interval_ms, 'redis_probe_interval_ms', 30_000, localWarn),
362
937
  redis_unhealthy_retry_ms: parsePositiveInt(obj.redis_unhealthy_retry_ms, 'redis_unhealthy_retry_ms', 5_000, localWarn),
363
938
  redis_ttl_ms: parsePositiveInt(obj.redis_ttl_ms, 'redis_ttl_ms', 180_000, localWarn),
939
+ autoselectOnStart: parseProbeBool(obj.autoselect_on_start, 'autoselect_on_start', true, localWarn),
940
+ concurrency_enabled: parseProbeBool(obj.concurrency_enabled, 'concurrency_enabled', CONCURRENCY_DEFAULTS.enabled, localWarn),
941
+ concurrency_floor: concurrencyFloor,
942
+ concurrency_ceiling: concurrencyCeiling,
943
+ concurrency_start: parsePositiveInt(obj.concurrency_start, 'concurrency_start', CONCURRENCY_DEFAULTS.start, localWarn),
944
+ concurrency_up_ms: concurrencyUpMs,
945
+ concurrency_down_ms: parsePositiveInt(obj.concurrency_down_ms, 'concurrency_down_ms', CONCURRENCY_DEFAULTS.down_ms, localWarn),
946
+ concurrency_latency_k: parsePositiveNumber(obj.concurrency_latency_k, 'concurrency_latency_k', CONCURRENCY_DEFAULTS.latency_k, localWarn),
947
+ concurrency_min_samples: parsePositiveInt(obj.concurrency_min_samples, 'concurrency_min_samples', CONCURRENCY_DEFAULTS.min_samples, localWarn),
948
+ concurrency_queue_wait_high_ms: parsePositiveInt(obj.concurrency_queue_wait_high_ms, 'concurrency_queue_wait_high_ms', CONCURRENCY_DEFAULTS.queue_wait_high_ms, localWarn),
949
+ concurrency_write_min_interval_ms: parsePositiveInt(obj.concurrency_write_min_interval_ms, 'concurrency_write_min_interval_ms', CONCURRENCY_DEFAULTS.write_min_interval_ms, localWarn),
950
+ concurrency_lease_ttl_ms: parsePositiveInt(obj.concurrency_lease_ttl_ms, 'concurrency_lease_ttl_ms', CONCURRENCY_DEFAULTS.lease_ttl_ms, localWarn),
951
+ concurrency_decay_after_ms: parsePositiveInt(obj.concurrency_decay_after_ms, 'concurrency_decay_after_ms', CONCURRENCY_DEFAULTS.decay_after_ms, localWarn),
952
+ concurrency_eval_interval_ms: concurrencyEvalIntervalMs,
953
+ concurrency_queue_wait_window_ms: parsePositiveInt(obj.concurrency_queue_wait_window_ms, 'concurrency_queue_wait_window_ms', CONCURRENCY_DEFAULTS.queue_wait_window_ms, localWarn),
954
+ concurrency_providers: parseConcurrencyProviders(obj.concurrency_providers, localWarn),
955
+ concurrency_ceiling_by_provider: parseCeilingByProvider(obj.concurrency_ceiling_by_provider, concurrencyFloor, localWarn),
364
956
  };
365
957
  }
366
958
 
@@ -393,16 +985,29 @@ function pickRoleKeys(raw: OpsxYamlShape): Partial<Record<OpsxRole, string>> {
393
985
 
394
986
  function pickTopLevelKeys(raw: OpsxYamlShape): OpsxYamlShape {
395
987
  return {
988
+ agents: raw.agents,
989
+ agent_tiers: raw.agent_tiers,
396
990
  model_allowlist: raw.model_allowlist,
397
991
  tiers: raw.tiers,
398
992
  role_tiers: raw.role_tiers,
399
993
  model_role_tiers: raw.model_role_tiers,
400
994
  plan_providers: raw.plan_providers,
995
+ plan_providers_remove: raw.plan_providers_remove,
996
+ payg_last_resort: raw.payg_last_resort,
997
+ china: raw.china,
401
998
  probe_enabled: raw.probe_enabled,
402
999
  probe_timeout_ms: raw.probe_timeout_ms,
403
1000
  probe_http_enabled: raw.probe_http_enabled,
404
1001
  probe_ttl_ms: raw.probe_ttl_ms,
405
1002
  excluded_providers: raw.excluded_providers,
1003
+ provider_auto: raw.provider_auto,
1004
+ provider_auto_tiers: raw.provider_auto_tiers,
1005
+ provider_models: raw.provider_models,
1006
+ speed_aware: raw.speed_aware,
1007
+ difficulty_routing: raw.difficulty_routing,
1008
+ stall_escalation: raw.stall_escalation,
1009
+ continuity_guard: raw.continuity_guard,
1010
+ quality_preference: raw.quality_preference,
406
1011
  status_probe_enabled: raw.status_probe_enabled,
407
1012
  redis_enabled: raw.redis_enabled,
408
1013
  redis_host: raw.redis_host,
@@ -413,6 +1018,23 @@ function pickTopLevelKeys(raw: OpsxYamlShape): OpsxYamlShape {
413
1018
  redis_probe_interval_ms: raw.redis_probe_interval_ms,
414
1019
  redis_unhealthy_retry_ms: raw.redis_unhealthy_retry_ms,
415
1020
  redis_ttl_ms: raw.redis_ttl_ms,
1021
+ autoselect_on_start: raw.autoselect_on_start,
1022
+ concurrency_enabled: raw.concurrency_enabled,
1023
+ concurrency_floor: raw.concurrency_floor,
1024
+ concurrency_ceiling: raw.concurrency_ceiling,
1025
+ concurrency_start: raw.concurrency_start,
1026
+ concurrency_up_ms: raw.concurrency_up_ms,
1027
+ concurrency_down_ms: raw.concurrency_down_ms,
1028
+ concurrency_latency_k: raw.concurrency_latency_k,
1029
+ concurrency_min_samples: raw.concurrency_min_samples,
1030
+ concurrency_queue_wait_high_ms: raw.concurrency_queue_wait_high_ms,
1031
+ concurrency_write_min_interval_ms: raw.concurrency_write_min_interval_ms,
1032
+ concurrency_lease_ttl_ms: raw.concurrency_lease_ttl_ms,
1033
+ concurrency_decay_after_ms: raw.concurrency_decay_after_ms,
1034
+ concurrency_eval_interval_ms: raw.concurrency_eval_interval_ms,
1035
+ concurrency_queue_wait_window_ms: raw.concurrency_queue_wait_window_ms,
1036
+ concurrency_providers: raw.concurrency_providers,
1037
+ concurrency_ceiling_by_provider: raw.concurrency_ceiling_by_provider,
416
1038
  };
417
1039
  }
418
1040
 
@@ -441,11 +1063,44 @@ function warnLegacySettingsJson(
441
1063
  }
442
1064
  }
443
1065
 
1066
+ /**
1067
+ * Per-layer rename diagnostics: one aggregated warning when a layer file
1068
+ * still uses a legacy key (all offending names in a single line), plus one
1069
+ * warning per unknown `agents` subkey (the mapping's key set is exactly the
1070
+ * four managed agents, so a typo would otherwise vanish silently).
1071
+ */
1072
+ function warnAgentRenameDiagnostics(
1073
+ path: string,
1074
+ raw: OpsxYamlShape,
1075
+ warn: (msg: string) => void,
1076
+ ): void {
1077
+ const legacy = LEGACY_AGENT_KEYS.filter((key) => {
1078
+ const v = (raw as Record<string, unknown>)[key];
1079
+ return v !== undefined && v !== null;
1080
+ });
1081
+ if (legacy.length > 0) {
1082
+ warn(
1083
+ `[omp-opsx-addon] ${path}: legacy key(s) ${legacy.map((k) => `'${k}'`).join(', ')} still honored — ` +
1084
+ `move them into the 'agents:' mapping / rename 'role_tiers' to 'agent_tiers' ` +
1085
+ '(within one file the new keys win); legacy keys are scheduled for removal after one release cycle',
1086
+ );
1087
+ }
1088
+ const agents = raw.agents;
1089
+ if (agents && typeof agents === 'object') {
1090
+ for (const key of Object.keys(agents)) {
1091
+ if (!(OPSX_ROLES as readonly string[]).includes(key)) {
1092
+ warn(`[omp-opsx-addon] ${path}: agents.${key}: unknown agent (valid: ${OPSX_ROLES.join(', ')}); entry ignored`);
1093
+ }
1094
+ }
1095
+ }
1096
+ }
1097
+
444
1098
  /**
445
1099
  * Read opsx config from two-layer opsx.yml:
446
1100
  * - 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`)
1101
+ * - project: `<cwd>/.omp/opsx.yml` (per-key override on `agents` — legacy:
1102
+ * top-level role keys; full overlay on `model_allowlist` / `tiers` /
1103
+ * `agent_tiers` — legacy: `role_tiers`)
449
1104
  *
450
1105
  * `homeDir` is overridable for tests; defaults to `os.homedir()`.
451
1106
  */
@@ -471,30 +1126,63 @@ export function readOpsxSettingsFromPaths(
471
1126
  const homeFromGlobal = globalPath.replace(/\.omp\/agent\/opsx\.yml$/, '');
472
1127
  const projectFromPath = projectPath.replace(/\.omp\/opsx\.yml$/, '');
473
1128
  warnLegacySettingsJson(projectFromPath, homeFromGlobal, warn);
1129
+ warnAgentRenameDiagnostics(globalPath, globalYaml, warn);
1130
+ warnAgentRenameDiagnostics(projectPath, projectYaml, warn);
474
1131
 
475
1132
  const globalRoles = pickRoleKeys(globalYaml);
476
1133
  const projectRoles = pickRoleKeys(projectYaml);
477
1134
 
478
- // Per-role: project overrides global, fall back to DEFAULT_OPSX.
1135
+ // Top-level: project fully replaces global when present.
1136
+ const projectTop = pickTopLevelKeys(projectYaml);
1137
+ const globalTop = pickTopLevelKeys(globalYaml);
1138
+
1139
+ // Per-role: project overrides global, fall back to DEFAULT_OPSX. Within
1140
+ // each layer the `agents` mapping beats the legacy top-level key; across
1141
+ // layers it stays a per-key overlay (a project legacy key can override a
1142
+ // global `agents` entry and vice versa).
479
1143
  const merged: OpsxYamlShape = { ...globalYaml };
480
1144
  for (const role of OPSX_ROLES) {
481
- const v = projectRoles[role] ?? globalRoles[role] ?? DEFAULT_OPSX[role];
1145
+ const v = projectTop.agents?.[role] ?? projectRoles[role] ?? globalTop.agents?.[role] ?? globalRoles[role] ?? DEFAULT_OPSX[role];
482
1146
  (merged as Record<string, unknown>)[role] = v;
483
1147
  }
1148
+ // The loop already folded `agents` entries into the resolved legacy keys;
1149
+ // a raw mapping left on `merged` would shadow them inside parseOpsxConfig.
1150
+ merged.agents = undefined;
484
1151
 
485
- // Top-level: project fully replaces global when present.
486
- const projectTop = pickTopLevelKeys(projectYaml);
487
- const globalTop = pickTopLevelKeys(globalYaml);
488
1152
  merged.model_allowlist = projectTop.model_allowlist ?? globalTop.model_allowlist;
489
1153
  merged.tiers = projectTop.tiers ?? globalTop.tiers;
490
1154
  merged.role_tiers = projectTop.role_tiers ?? globalTop.role_tiers;
1155
+ // Tier map: `agent_tiers` beats `role_tiers` within each layer, then the
1156
+ // legacy whole-key overlay applies (project map replaces global's).
1157
+ const projectTiers = projectTop.agent_tiers ?? projectTop.role_tiers;
1158
+ const globalTiers = globalTop.agent_tiers ?? globalTop.role_tiers;
1159
+ merged.agent_tiers = projectTiers ?? globalTiers;
491
1160
  merged.model_role_tiers = projectTop.model_role_tiers ?? globalTop.model_role_tiers;
492
1161
  merged.plan_providers = projectTop.plan_providers ?? globalTop.plan_providers;
1162
+ merged.plan_providers_remove = projectTop.plan_providers_remove ?? globalTop.plan_providers_remove;
1163
+ merged.payg_last_resort = projectTop.payg_last_resort ?? globalTop.payg_last_resort;
1164
+ // `??` keeps an explicit project `false` (parseProbeBool only falls back on
1165
+ // null/undefined AFTER the merge, so undefined reveals the other layer).
1166
+ merged.china = projectTop.china ?? globalTop.china;
493
1167
  merged.probe_enabled = projectTop.probe_enabled ?? globalTop.probe_enabled;
494
1168
  merged.probe_timeout_ms = projectTop.probe_timeout_ms ?? globalTop.probe_timeout_ms;
495
1169
  merged.probe_http_enabled = projectTop.probe_http_enabled ?? globalTop.probe_http_enabled;
496
1170
  merged.probe_ttl_ms = projectTop.probe_ttl_ms ?? globalTop.probe_ttl_ms;
497
1171
  merged.excluded_providers = projectTop.excluded_providers ?? globalTop.excluded_providers;
1172
+ // Provider 键控 map 同为整键覆盖(先例 concurrency_ceiling_by_provider):项目层
1173
+ // 声明任一键即完整替换全局同名键,不做 per-key 叠加——只想加键就把全局条目抄过来。
1174
+ merged.provider_auto = projectTop.provider_auto ?? globalTop.provider_auto;
1175
+ merged.provider_auto_tiers = projectTop.provider_auto_tiers ?? globalTop.provider_auto_tiers;
1176
+ merged.provider_models = projectTop.provider_models ?? globalTop.provider_models;
1177
+ // 对象键一律整键覆盖(同上 provider_* 先例):项目层声明 speed_aware 即
1178
+ // 整份替换全局,不做字段级叠加。
1179
+ merged.speed_aware = projectTop.speed_aware ?? globalTop.speed_aware;
1180
+ // W4 路由四键同款整键覆盖:项目层声明任一键即完整替换全局同名键。
1181
+ merged.difficulty_routing = projectTop.difficulty_routing ?? globalTop.difficulty_routing;
1182
+ merged.stall_escalation = projectTop.stall_escalation ?? globalTop.stall_escalation;
1183
+ merged.continuity_guard = projectTop.continuity_guard ?? globalTop.continuity_guard;
1184
+ // 顶层标量键同 `??` 惯例(项目层显式值优先,undefined 露出另一层)。
1185
+ merged.quality_preference = projectTop.quality_preference ?? globalTop.quality_preference;
498
1186
  merged.status_probe_enabled = projectTop.status_probe_enabled ?? globalTop.status_probe_enabled;
499
1187
  merged.redis_enabled = projectTop.redis_enabled ?? globalTop.redis_enabled;
500
1188
  merged.redis_host = projectTop.redis_host ?? globalTop.redis_host;
@@ -505,6 +1193,23 @@ export function readOpsxSettingsFromPaths(
505
1193
  merged.redis_probe_interval_ms = projectTop.redis_probe_interval_ms ?? globalTop.redis_probe_interval_ms;
506
1194
  merged.redis_unhealthy_retry_ms = projectTop.redis_unhealthy_retry_ms ?? globalTop.redis_unhealthy_retry_ms;
507
1195
  merged.redis_ttl_ms = projectTop.redis_ttl_ms ?? globalTop.redis_ttl_ms;
1196
+ merged.autoselect_on_start = projectTop.autoselect_on_start ?? globalTop.autoselect_on_start;
1197
+ merged.concurrency_enabled = projectTop.concurrency_enabled ?? globalTop.concurrency_enabled;
1198
+ merged.concurrency_floor = projectTop.concurrency_floor ?? globalTop.concurrency_floor;
1199
+ merged.concurrency_ceiling = projectTop.concurrency_ceiling ?? globalTop.concurrency_ceiling;
1200
+ merged.concurrency_start = projectTop.concurrency_start ?? globalTop.concurrency_start;
1201
+ merged.concurrency_up_ms = projectTop.concurrency_up_ms ?? globalTop.concurrency_up_ms;
1202
+ merged.concurrency_down_ms = projectTop.concurrency_down_ms ?? globalTop.concurrency_down_ms;
1203
+ merged.concurrency_latency_k = projectTop.concurrency_latency_k ?? globalTop.concurrency_latency_k;
1204
+ merged.concurrency_min_samples = projectTop.concurrency_min_samples ?? globalTop.concurrency_min_samples;
1205
+ merged.concurrency_queue_wait_high_ms = projectTop.concurrency_queue_wait_high_ms ?? globalTop.concurrency_queue_wait_high_ms;
1206
+ merged.concurrency_write_min_interval_ms = projectTop.concurrency_write_min_interval_ms ?? globalTop.concurrency_write_min_interval_ms;
1207
+ merged.concurrency_lease_ttl_ms = projectTop.concurrency_lease_ttl_ms ?? globalTop.concurrency_lease_ttl_ms;
1208
+ merged.concurrency_decay_after_ms = projectTop.concurrency_decay_after_ms ?? globalTop.concurrency_decay_after_ms;
1209
+ merged.concurrency_eval_interval_ms = projectTop.concurrency_eval_interval_ms ?? globalTop.concurrency_eval_interval_ms;
1210
+ merged.concurrency_queue_wait_window_ms = projectTop.concurrency_queue_wait_window_ms ?? globalTop.concurrency_queue_wait_window_ms;
1211
+ merged.concurrency_providers = projectTop.concurrency_providers ?? globalTop.concurrency_providers;
1212
+ merged.concurrency_ceiling_by_provider = projectTop.concurrency_ceiling_by_provider ?? globalTop.concurrency_ceiling_by_provider;
508
1213
 
509
1214
  return parseOpsxConfig(merged, warn);
510
1215
  }