@genee/omp-opsx-addon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/index.ts +1327 -0
- package/lib/agent-defs.ts +291 -0
- package/lib/change-scanner.ts +72 -0
- package/lib/cursor-auth.ts +230 -0
- package/lib/direct-fetchers.ts +155 -0
- package/lib/edit-variant-pin.ts +156 -0
- package/lib/error-scan.ts +97 -0
- package/lib/family-filter.ts +174 -0
- package/lib/model-selector.ts +307 -0
- package/lib/model-tiers.ts +164 -0
- package/lib/notify.ts +4 -0
- package/lib/openspec-tasks.ts +28 -0
- package/lib/peak-detector.ts +44 -0
- package/lib/reachability-cache.ts +288 -0
- package/lib/reachability-probe.ts +459 -0
- package/lib/reachability-refresh.ts +84 -0
- package/lib/selection-filters.ts +100 -0
- package/lib/selection-lifecycle.ts +17 -0
- package/lib/session-title.ts +186 -0
- package/lib/session-usage.ts +252 -0
- package/lib/state.ts +95 -0
- package/lib/system-prompt.ts +160 -0
- package/lib/tiers-data.ts +136 -0
- package/lib/tiers-updater.ts +146 -0
- package/lib/unified-config.ts +463 -0
- package/lib/usage-estimator.ts +154 -0
- package/lib/usage-poller.ts +1200 -0
- package/lib/usage-poller.worker.ts +31 -0
- package/lib/usage-redis-client.ts +594 -0
- package/lib/usage-redis-multiproc.worker.ts +80 -0
- package/lib/usage-redis-probe.ts +389 -0
- package/lib/usage-render.ts +526 -0
- package/lib/usage-resolver.ts +477 -0
- package/lib/usage-sampler.ts +77 -0
- package/lib/usage-widget.ts +112 -0
- package/package.json +47 -0
- package/types/bun.d.ts +21 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified opsx config schema.
|
|
3
|
+
*
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* Accepted values for each role:
|
|
10
|
+
* - `'auto'` — let the model-selector pick based on tier + usage.
|
|
11
|
+
* - An OMP role alias: `default`, `smol`, `slow`, `vision`, `plan`,
|
|
12
|
+
* `designer`, `commit`, `tiny`, `task`, `advisor` — expanded by the
|
|
13
|
+
* OMP `modelRoles` setting (not by this plugin) to a concrete model.
|
|
14
|
+
* - A concrete model id: `gpt-5`, `claude-opus-4-6`.
|
|
15
|
+
* - A provider-scoped selector: `anthropic/claude-opus-4-6`,
|
|
16
|
+
* `openai-codex/gpt-5-codex`.
|
|
17
|
+
*
|
|
18
|
+
* 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).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from 'fs';
|
|
27
|
+
import { homedir } from 'os';
|
|
28
|
+
import { join } from 'path';
|
|
29
|
+
import { YAML } from 'bun';
|
|
30
|
+
import { TIER_NAMES, type TierName, type TierOverride } from './model-tiers.js';
|
|
31
|
+
|
|
32
|
+
/** OMP-documented role aliases. Plugins MAY pass these to `task(..., model: '@alias')`. */
|
|
33
|
+
export const OMP_ROLE_ALIASES = new Set<string>([
|
|
34
|
+
'default',
|
|
35
|
+
'smol',
|
|
36
|
+
'slow',
|
|
37
|
+
'vision',
|
|
38
|
+
'plan',
|
|
39
|
+
'designer',
|
|
40
|
+
'commit',
|
|
41
|
+
'tiny',
|
|
42
|
+
'task',
|
|
43
|
+
'advisor',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Special "let the plugin decide" marker. */
|
|
47
|
+
export const AUTO_TOKEN = 'auto';
|
|
48
|
+
|
|
49
|
+
/** Roles the plugin manages. */
|
|
50
|
+
export const OPSX_ROLES = ['coder', 'reviewer', 'planner', 'proposal-reviewer'] as const;
|
|
51
|
+
export type OpsxRole = (typeof OPSX_ROLES)[number];
|
|
52
|
+
|
|
53
|
+
/** Raw YAML shape — every key is optional. */
|
|
54
|
+
export interface OpsxYamlShape {
|
|
55
|
+
coder?: string;
|
|
56
|
+
reviewer?: string;
|
|
57
|
+
planner?: string;
|
|
58
|
+
'proposal-reviewer'?: string;
|
|
59
|
+
primary?: string;
|
|
60
|
+
model_allowlist?: string[];
|
|
61
|
+
tiers?: TierOverride[];
|
|
62
|
+
role_tiers?: Partial<Record<OpsxRole, TierName>>;
|
|
63
|
+
plan_providers?: string[];
|
|
64
|
+
probe_enabled?: boolean;
|
|
65
|
+
probe_timeout_ms?: number;
|
|
66
|
+
probe_http_enabled?: boolean;
|
|
67
|
+
probe_ttl_ms?: number;
|
|
68
|
+
excluded_providers?: string[];
|
|
69
|
+
redis_enabled?: boolean;
|
|
70
|
+
redis_host?: string;
|
|
71
|
+
redis_port?: number;
|
|
72
|
+
redis_connect_timeout_ms?: number;
|
|
73
|
+
redis_command_timeout_ms?: number;
|
|
74
|
+
redis_reconcile_interval_ms?: number;
|
|
75
|
+
redis_probe_interval_ms?: number;
|
|
76
|
+
redis_unhealthy_retry_ms?: number;
|
|
77
|
+
redis_ttl_ms?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A role value as parsed. */
|
|
81
|
+
export type RoleValue =
|
|
82
|
+
| { kind: 'auto' }
|
|
83
|
+
| { kind: 'omp-alias'; alias: string }
|
|
84
|
+
| { kind: 'model'; modelId: string }
|
|
85
|
+
| { kind: 'provider-model'; provider: string; modelId: string };
|
|
86
|
+
|
|
87
|
+
export interface ResolvedOpsxConfig {
|
|
88
|
+
roles: Record<OpsxRole, RoleValue>;
|
|
89
|
+
modelAllowlist: string[];
|
|
90
|
+
tierOverrides: TierOverride[];
|
|
91
|
+
roleTiers: Partial<Record<OpsxRole, TierName>>;
|
|
92
|
+
/** Provider ids that have monthly plan subscriptions. */
|
|
93
|
+
planProviders: string[];
|
|
94
|
+
warnings: string[];
|
|
95
|
+
/** 是否开启可达性探测,默认 true */
|
|
96
|
+
probe_enabled: boolean;
|
|
97
|
+
/** 单端点 TLS 探测超时 ms,默认 3000 */
|
|
98
|
+
probe_timeout_ms: number;
|
|
99
|
+
/** 是否开启 HTTP 探测(第二层),默认 false */
|
|
100
|
+
probe_http_enabled: boolean;
|
|
101
|
+
/** 探测结果 TTL ms,默认 600000(10 分钟) */
|
|
102
|
+
probe_ttl_ms: number;
|
|
103
|
+
/** 静态排除的 provider 列表(零探测成本) */
|
|
104
|
+
excluded_providers: string[];
|
|
105
|
+
/** 是否启用本地 Redis 实时中转(默认 true;false 时纯文件路径)。 */
|
|
106
|
+
redis_enabled: boolean;
|
|
107
|
+
/** Redis host(默认 127.0.0.1)。 */
|
|
108
|
+
redis_host: string;
|
|
109
|
+
/** Redis port(默认 6379)。 */
|
|
110
|
+
redis_port: number;
|
|
111
|
+
/** TCP 连接超时 ms(默认 300)。 */
|
|
112
|
+
redis_connect_timeout_ms: number;
|
|
113
|
+
/** 单命令往返超时 ms(默认 500)。 */
|
|
114
|
+
redis_command_timeout_ms: number;
|
|
115
|
+
/** reconcile 周期 ms(默认 30000)。 */
|
|
116
|
+
redis_reconcile_interval_ms: number;
|
|
117
|
+
/** healthy 心跳 PING 周期 ms(默认 30000)。 */
|
|
118
|
+
redis_probe_interval_ms: number;
|
|
119
|
+
/** unhealthy 退避重试周期 ms(默认 5000)。 */
|
|
120
|
+
redis_unhealthy_retry_ms: number;
|
|
121
|
+
/** Redis key TTL ms(默认 180000)。 */
|
|
122
|
+
redis_ttl_ms: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Default values applied when no config is provided. */
|
|
126
|
+
export const DEFAULT_OPSX: Record<OpsxRole, string> = {
|
|
127
|
+
coder: 'auto',
|
|
128
|
+
reviewer: 'auto',
|
|
129
|
+
planner: 'auto',
|
|
130
|
+
'proposal-reviewer': 'auto',
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function splitProviderModel(raw: string): { provider?: string; modelId: string } {
|
|
134
|
+
const slash = raw.indexOf('/');
|
|
135
|
+
if (slash === -1) return { modelId: raw.trim() };
|
|
136
|
+
const provider = raw.slice(0, slash).trim();
|
|
137
|
+
const modelId = raw.slice(slash + 1).trim();
|
|
138
|
+
return { provider: provider || undefined, modelId };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Parse a single role value string into a RoleValue. */
|
|
142
|
+
export function parseRoleValue(raw: string | undefined, _warn: (msg: string) => void): RoleValue {
|
|
143
|
+
if (raw === undefined || raw === null) {
|
|
144
|
+
return { kind: 'auto' };
|
|
145
|
+
}
|
|
146
|
+
const trimmed = String(raw).trim();
|
|
147
|
+
if (!trimmed || trimmed === AUTO_TOKEN) {
|
|
148
|
+
return { kind: 'auto' };
|
|
149
|
+
}
|
|
150
|
+
// Legacy compat: strip `pi:` / `local:` prefix (old acpx-era config).
|
|
151
|
+
const legacyStripped = trimmed.replace(/^(pi|local):/i, '').trim();
|
|
152
|
+
if (legacyStripped !== trimmed) {
|
|
153
|
+
_warn(`[omp-opsx-addon] legacy "${trimmed}" → "${legacyStripped}" (pi:/local: prefix no longer needed)`);
|
|
154
|
+
return parseRoleValue(legacyStripped, _warn);
|
|
155
|
+
}
|
|
156
|
+
if (OMP_ROLE_ALIASES.has(trimmed.toLowerCase())) {
|
|
157
|
+
return { kind: 'omp-alias', alias: trimmed.toLowerCase() };
|
|
158
|
+
}
|
|
159
|
+
const { provider, modelId } = splitProviderModel(trimmed);
|
|
160
|
+
if (provider) {
|
|
161
|
+
return { kind: 'provider-model', provider, modelId };
|
|
162
|
+
}
|
|
163
|
+
return { kind: 'model', modelId: trimmed };
|
|
164
|
+
}
|
|
165
|
+
/** Validate tier override entries. Invalid entries are dropped with a warning. */
|
|
166
|
+
export function parseTierOverrides(
|
|
167
|
+
raw: unknown,
|
|
168
|
+
warn: (msg: string) => void,
|
|
169
|
+
): TierOverride[] {
|
|
170
|
+
if (!Array.isArray(raw)) return [];
|
|
171
|
+
const out: TierOverride[] = [];
|
|
172
|
+
for (const entry of raw) {
|
|
173
|
+
if (!entry || typeof entry !== 'object') {
|
|
174
|
+
warn(`[omp-opsx-addon] tiers: skipping non-object entry ${JSON.stringify(entry)}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const obj = entry as Record<string, unknown>;
|
|
178
|
+
if (
|
|
179
|
+
typeof obj.pattern !== 'string' ||
|
|
180
|
+
(typeof obj.tier !== 'string' && typeof obj.tier !== 'number')
|
|
181
|
+
) {
|
|
182
|
+
warn(`[omp-opsx-addon] tiers: skipping entry missing pattern/tier ${JSON.stringify(entry)}`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (typeof obj.tier === 'string' && !TIER_NAMES.includes(obj.tier as TierName)) {
|
|
186
|
+
warn(`[omp-opsx-addon] tiers: unknown tier name "${obj.tier}" (use one of ${TIER_NAMES.join('/')})`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
out.push({ pattern: obj.pattern, tier: obj.tier as TierName | number });
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Validate role_tiers entries. */
|
|
195
|
+
export function parseRoleTiers(
|
|
196
|
+
raw: unknown,
|
|
197
|
+
warn: (msg: string) => void,
|
|
198
|
+
): Partial<Record<OpsxRole, TierName>> {
|
|
199
|
+
if (!raw || typeof raw !== 'object') return {};
|
|
200
|
+
const out: Partial<Record<OpsxRole, TierName>> = {};
|
|
201
|
+
for (const role of OPSX_ROLES) {
|
|
202
|
+
const v = (raw as Record<string, unknown>)[role];
|
|
203
|
+
if (v === undefined || v === null) continue;
|
|
204
|
+
if (typeof v !== 'string') {
|
|
205
|
+
warn(`[omp-opsx-addon] role_tiers.${role}: expected tier name, got ${typeof v}`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!TIER_NAMES.includes(v as TierName)) {
|
|
209
|
+
warn(`[omp-opsx-addon] role_tiers.${role}: unknown tier "${v}" (use one of ${TIER_NAMES.join('/')})`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
out[role] = v as TierName;
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Validate the model_allowlist (array of glob strings). */
|
|
218
|
+
export function parseModelAllowlist(
|
|
219
|
+
raw: unknown,
|
|
220
|
+
warn: (msg: string) => void,
|
|
221
|
+
): string[] {
|
|
222
|
+
if (raw === undefined || raw === null) return [];
|
|
223
|
+
if (!Array.isArray(raw)) {
|
|
224
|
+
warn('[omp-opsx-addon] model_allowlist: expected array of glob strings');
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
const out: string[] = [];
|
|
228
|
+
for (const entry of raw) {
|
|
229
|
+
if (typeof entry !== 'string') {
|
|
230
|
+
warn(`[omp-opsx-addon] model_allowlist: skipping non-string entry ${JSON.stringify(entry)}`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
out.push(entry);
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
/** 解析布尔值:合法 boolean 返回,否则返回 defaultValue 并 warn。 */
|
|
238
|
+
export function parseProbeBool(raw: unknown, key: string, defaultValue: boolean, warn: (msg: string) => void): boolean {
|
|
239
|
+
if (raw === undefined || raw === null) return defaultValue;
|
|
240
|
+
if (typeof raw === 'boolean') return raw;
|
|
241
|
+
warn(`[omp-opsx-addon] ${key}: expected boolean, got ${JSON.stringify(raw)}`);
|
|
242
|
+
return defaultValue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 解析正整数值:合法正整数返回,否则返回 defaultValue 并 warn。 */
|
|
246
|
+
export function parsePositiveInt(raw: unknown, key: string, defaultValue: number, warn: (msg: string) => void): number {
|
|
247
|
+
if (raw === undefined || raw === null) return defaultValue;
|
|
248
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0 && Number.isInteger(raw)) return raw;
|
|
249
|
+
warn(`[omp-opsx-addon] ${key}: expected positive integer, got ${JSON.stringify(raw)}`);
|
|
250
|
+
return defaultValue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 解析字符串数组:合法则返回,否则返回 [] 并 warn。 */
|
|
254
|
+
export function parseStringArray(raw: unknown, key: string, warn: (msg: string) => void): string[] {
|
|
255
|
+
if (raw === undefined || raw === null) return [];
|
|
256
|
+
if (!Array.isArray(raw)) {
|
|
257
|
+
warn(`[omp-opsx-addon] ${key}: expected array, got ${JSON.stringify(raw)}`);
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
const out: string[] = [];
|
|
261
|
+
for (const entry of raw) {
|
|
262
|
+
if (typeof entry === 'string') out.push(entry);
|
|
263
|
+
else warn(`[omp-opsx-addon] ${key}: skipping non-string entry ${JSON.stringify(entry)}`);
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Parse the full raw opsx config. */
|
|
269
|
+
export function parseOpsxConfig(
|
|
270
|
+
raw: unknown,
|
|
271
|
+
warn: (msg: string) => void = () => {},
|
|
272
|
+
): ResolvedOpsxConfig {
|
|
273
|
+
const obj = (raw ?? {}) as OpsxYamlShape;
|
|
274
|
+
const warnings: string[] = [];
|
|
275
|
+
const localWarn = (m: string) => {
|
|
276
|
+
warnings.push(m);
|
|
277
|
+
warn(m);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
function sanitizePlanProviders(raw: unknown): string[] {
|
|
281
|
+
if (raw === undefined || raw === null) return [];
|
|
282
|
+
if (!Array.isArray(raw)) return [];
|
|
283
|
+
return raw.filter((v): v is string => typeof v === 'string');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const roles: Record<OpsxRole, RoleValue> = {} as Record<OpsxRole, RoleValue>;
|
|
287
|
+
for (const role of OPSX_ROLES) {
|
|
288
|
+
const raw = (obj as Record<string, unknown>)[role];
|
|
289
|
+
roles[role] = parseRoleValue(raw as string | undefined, localWarn);
|
|
290
|
+
}
|
|
291
|
+
let redisHost = '127.0.0.1';
|
|
292
|
+
if (obj.redis_host !== undefined) {
|
|
293
|
+
if (typeof obj.redis_host === 'string' && obj.redis_host.trim() !== '') {
|
|
294
|
+
redisHost = obj.redis_host;
|
|
295
|
+
} else {
|
|
296
|
+
localWarn(`[omp-opsx-addon] redis_host: expected non-empty string, got ${JSON.stringify(obj.redis_host)}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
roles,
|
|
302
|
+
modelAllowlist: parseModelAllowlist(obj.model_allowlist, localWarn),
|
|
303
|
+
tierOverrides: parseTierOverrides(obj.tiers, localWarn),
|
|
304
|
+
roleTiers: parseRoleTiers(obj.role_tiers, localWarn),
|
|
305
|
+
planProviders: sanitizePlanProviders(obj.plan_providers),
|
|
306
|
+
warnings,
|
|
307
|
+
probe_enabled: parseProbeBool(obj.probe_enabled, 'probe_enabled', true, localWarn),
|
|
308
|
+
probe_timeout_ms: parsePositiveInt(obj.probe_timeout_ms, 'probe_timeout_ms', 3000, localWarn),
|
|
309
|
+
probe_http_enabled: parseProbeBool(obj.probe_http_enabled, 'probe_http_enabled', false, localWarn),
|
|
310
|
+
probe_ttl_ms: parsePositiveInt(obj.probe_ttl_ms, 'probe_ttl_ms', 600_000, localWarn),
|
|
311
|
+
excluded_providers: parseStringArray(obj.excluded_providers, 'excluded_providers', localWarn),
|
|
312
|
+
redis_enabled: parseProbeBool(obj.redis_enabled, 'redis_enabled', true, localWarn),
|
|
313
|
+
redis_host: redisHost,
|
|
314
|
+
redis_port: parsePositiveInt(obj.redis_port, 'redis_port', 6379, localWarn),
|
|
315
|
+
redis_connect_timeout_ms: parsePositiveInt(obj.redis_connect_timeout_ms, 'redis_connect_timeout_ms', 300, localWarn),
|
|
316
|
+
redis_command_timeout_ms: parsePositiveInt(obj.redis_command_timeout_ms, 'redis_command_timeout_ms', 500, localWarn),
|
|
317
|
+
redis_reconcile_interval_ms: parsePositiveInt(obj.redis_reconcile_interval_ms, 'redis_reconcile_interval_ms', 30_000, localWarn),
|
|
318
|
+
redis_probe_interval_ms: parsePositiveInt(obj.redis_probe_interval_ms, 'redis_probe_interval_ms', 30_000, localWarn),
|
|
319
|
+
redis_unhealthy_retry_ms: parsePositiveInt(obj.redis_unhealthy_retry_ms, 'redis_unhealthy_retry_ms', 5_000, localWarn),
|
|
320
|
+
redis_ttl_ms: parsePositiveInt(obj.redis_ttl_ms, 'redis_ttl_ms', 180_000, localWarn),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Parse YAML text into an OpsxYamlShape. */
|
|
325
|
+
export function parseOpsxYaml(content: string): OpsxYamlShape {
|
|
326
|
+
const trimmed = content.trim();
|
|
327
|
+
if (!trimmed) return {};
|
|
328
|
+
const parsed = YAML.parse(trimmed);
|
|
329
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
330
|
+
return parsed as OpsxYamlShape;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function readOpsxYamlFile(path: string): OpsxYamlShape {
|
|
334
|
+
try {
|
|
335
|
+
if (!existsSync(path)) return {};
|
|
336
|
+
return parseOpsxYaml(readFileSync(path, 'utf-8'));
|
|
337
|
+
} catch {
|
|
338
|
+
return {};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function pickRoleKeys(raw: OpsxYamlShape): Partial<Record<OpsxRole, string>> {
|
|
342
|
+
const out: Partial<Record<OpsxRole, string>> = {};
|
|
343
|
+
for (const role of OPSX_ROLES) {
|
|
344
|
+
const v = raw[role];
|
|
345
|
+
if (v === undefined || v === null) continue;
|
|
346
|
+
out[role] = String(v);
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function pickTopLevelKeys(raw: OpsxYamlShape): OpsxYamlShape {
|
|
352
|
+
return {
|
|
353
|
+
model_allowlist: raw.model_allowlist,
|
|
354
|
+
tiers: raw.tiers,
|
|
355
|
+
role_tiers: raw.role_tiers,
|
|
356
|
+
plan_providers: raw.plan_providers,
|
|
357
|
+
probe_enabled: raw.probe_enabled,
|
|
358
|
+
probe_timeout_ms: raw.probe_timeout_ms,
|
|
359
|
+
probe_http_enabled: raw.probe_http_enabled,
|
|
360
|
+
probe_ttl_ms: raw.probe_ttl_ms,
|
|
361
|
+
excluded_providers: raw.excluded_providers,
|
|
362
|
+
redis_enabled: raw.redis_enabled,
|
|
363
|
+
redis_host: raw.redis_host,
|
|
364
|
+
redis_port: raw.redis_port,
|
|
365
|
+
redis_connect_timeout_ms: raw.redis_connect_timeout_ms,
|
|
366
|
+
redis_command_timeout_ms: raw.redis_command_timeout_ms,
|
|
367
|
+
redis_reconcile_interval_ms: raw.redis_reconcile_interval_ms,
|
|
368
|
+
redis_probe_interval_ms: raw.redis_probe_interval_ms,
|
|
369
|
+
redis_unhealthy_retry_ms: raw.redis_unhealthy_retry_ms,
|
|
370
|
+
redis_ttl_ms: raw.redis_ttl_ms,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function warnLegacySettingsJson(
|
|
375
|
+
directory: string,
|
|
376
|
+
homeDir: string,
|
|
377
|
+
warn: (msg: string) => void,
|
|
378
|
+
): void {
|
|
379
|
+
const paths = [
|
|
380
|
+
join(homeDir, '.omp', 'agent', 'settings.json'),
|
|
381
|
+
join(directory, '.omp', 'settings.json'),
|
|
382
|
+
];
|
|
383
|
+
for (const p of paths) {
|
|
384
|
+
try {
|
|
385
|
+
if (!existsSync(p)) continue;
|
|
386
|
+
const raw = JSON.parse(readFileSync(p, 'utf-8'));
|
|
387
|
+
if (raw?.opsx && typeof raw.opsx === 'object') {
|
|
388
|
+
warn(
|
|
389
|
+
`[omp-opsx-addon] Found legacy opsx in ${p}; migrate to opsx.yml (~/.omp/agent/opsx.yml or <cwd>/.omp/opsx.yml). The 'opsx' settings key is no longer read.`,
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
} catch {
|
|
394
|
+
// ignore unreadable / invalid JSON
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Read opsx config from two-layer opsx.yml:
|
|
401
|
+
* - global: `<homeDir>/.omp/agent/opsx.yml`
|
|
402
|
+
* - project: `<cwd>/.omp/opsx.yml` (per-key override on roles; full overlay on
|
|
403
|
+
* `model_allowlist` / `tiers` / `role_tiers`)
|
|
404
|
+
*
|
|
405
|
+
* `homeDir` is overridable for tests; defaults to `os.homedir()`.
|
|
406
|
+
*/
|
|
407
|
+
export function readOpsxSettings(
|
|
408
|
+
directory: string,
|
|
409
|
+
warn: (msg: string) => void = () => {},
|
|
410
|
+
homeDir: string = homedir(),
|
|
411
|
+
): ResolvedOpsxConfig {
|
|
412
|
+
const globalPath = join(homeDir, '.omp', 'agent', 'opsx.yml');
|
|
413
|
+
const projectPath = join(directory, '.omp', 'opsx.yml');
|
|
414
|
+
return readOpsxSettingsFromPaths(globalPath, projectPath, warn);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Lower-level: read from explicit paths. Test seam. */
|
|
418
|
+
export function readOpsxSettingsFromPaths(
|
|
419
|
+
globalPath: string,
|
|
420
|
+
projectPath: string,
|
|
421
|
+
warn: (msg: string) => void,
|
|
422
|
+
): ResolvedOpsxConfig {
|
|
423
|
+
const globalYaml = readOpsxYamlFile(globalPath);
|
|
424
|
+
const projectYaml = readOpsxYamlFile(projectPath);
|
|
425
|
+
|
|
426
|
+
const homeFromGlobal = globalPath.replace(/\.omp\/agent\/opsx\.yml$/, '');
|
|
427
|
+
const projectFromPath = projectPath.replace(/\.omp\/opsx\.yml$/, '');
|
|
428
|
+
warnLegacySettingsJson(projectFromPath, homeFromGlobal, warn);
|
|
429
|
+
|
|
430
|
+
const globalRoles = pickRoleKeys(globalYaml);
|
|
431
|
+
const projectRoles = pickRoleKeys(projectYaml);
|
|
432
|
+
|
|
433
|
+
// Per-role: project overrides global, fall back to DEFAULT_OPSX.
|
|
434
|
+
const merged: OpsxYamlShape = { ...globalYaml };
|
|
435
|
+
for (const role of OPSX_ROLES) {
|
|
436
|
+
const v = projectRoles[role] ?? globalRoles[role] ?? DEFAULT_OPSX[role];
|
|
437
|
+
(merged as Record<string, unknown>)[role] = v;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Top-level: project fully replaces global when present.
|
|
441
|
+
const projectTop = pickTopLevelKeys(projectYaml);
|
|
442
|
+
const globalTop = pickTopLevelKeys(globalYaml);
|
|
443
|
+
merged.model_allowlist = projectTop.model_allowlist ?? globalTop.model_allowlist;
|
|
444
|
+
merged.tiers = projectTop.tiers ?? globalTop.tiers;
|
|
445
|
+
merged.role_tiers = projectTop.role_tiers ?? globalTop.role_tiers;
|
|
446
|
+
merged.plan_providers = projectTop.plan_providers ?? globalTop.plan_providers;
|
|
447
|
+
merged.probe_enabled = projectTop.probe_enabled ?? globalTop.probe_enabled;
|
|
448
|
+
merged.probe_timeout_ms = projectTop.probe_timeout_ms ?? globalTop.probe_timeout_ms;
|
|
449
|
+
merged.probe_http_enabled = projectTop.probe_http_enabled ?? globalTop.probe_http_enabled;
|
|
450
|
+
merged.probe_ttl_ms = projectTop.probe_ttl_ms ?? globalTop.probe_ttl_ms;
|
|
451
|
+
merged.excluded_providers = projectTop.excluded_providers ?? globalTop.excluded_providers;
|
|
452
|
+
merged.redis_enabled = projectTop.redis_enabled ?? globalTop.redis_enabled;
|
|
453
|
+
merged.redis_host = projectTop.redis_host ?? globalTop.redis_host;
|
|
454
|
+
merged.redis_port = projectTop.redis_port ?? globalTop.redis_port;
|
|
455
|
+
merged.redis_connect_timeout_ms = projectTop.redis_connect_timeout_ms ?? globalTop.redis_connect_timeout_ms;
|
|
456
|
+
merged.redis_command_timeout_ms = projectTop.redis_command_timeout_ms ?? globalTop.redis_command_timeout_ms;
|
|
457
|
+
merged.redis_reconcile_interval_ms = projectTop.redis_reconcile_interval_ms ?? globalTop.redis_reconcile_interval_ms;
|
|
458
|
+
merged.redis_probe_interval_ms = projectTop.redis_probe_interval_ms ?? globalTop.redis_probe_interval_ms;
|
|
459
|
+
merged.redis_unhealthy_retry_ms = projectTop.redis_unhealthy_retry_ms ?? globalTop.redis_unhealthy_retry_ms;
|
|
460
|
+
merged.redis_ttl_ms = projectTop.redis_ttl_ms ?? globalTop.redis_ttl_ms;
|
|
461
|
+
|
|
462
|
+
return parseOpsxConfig(merged, warn);
|
|
463
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Second-level usage estimation: blend the low-frequency provider snapshot
|
|
3
|
+
* (stock: balance / window fractions) with the high-frequency sessionUsage
|
|
4
|
+
* consumption flow to produce a per-second estimate of remaining quota, then
|
|
5
|
+
* re-align to the exact snapshot on every actual fetch.
|
|
6
|
+
*
|
|
7
|
+
* Pure functions only — the host wires snapshot baselines and deltas.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Fixed USD→CNY conversion for balance-denominated (CNY) estimates. */
|
|
11
|
+
export const USD_CNY_FX = 7.2;
|
|
12
|
+
|
|
13
|
+
/** Bucketed token usage (cacheRead is billed as input, folded in upstream). */
|
|
14
|
+
export interface UsageBucket {
|
|
15
|
+
total: number;
|
|
16
|
+
input: number;
|
|
17
|
+
output: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Per-1M-token price in USD for one provider (averaged over its models). */
|
|
21
|
+
export interface ProviderPrice {
|
|
22
|
+
input: number;
|
|
23
|
+
output: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Snapshot shapes the estimator understands (subset of UsageLimit.amount). */
|
|
27
|
+
export interface LimitSnapshot {
|
|
28
|
+
/** Balance-style (CNY) remaining, when the provider is pay-as-you-go. */
|
|
29
|
+
remaining?: number;
|
|
30
|
+
/** Currency of `remaining` — estimate only computed for CNY balances. */
|
|
31
|
+
unit?: string;
|
|
32
|
+
/** Window-style remaining fraction (0..1), tokens or percent windows. */
|
|
33
|
+
remainingFraction?: number;
|
|
34
|
+
/** Window-style used fraction (0..1), an alternative to remainingFraction. */
|
|
35
|
+
usedFraction?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Normalize any stored per-provider sessionUsage value (legacy number → bucket). */
|
|
39
|
+
export function normalizeBucket(value: unknown): UsageBucket {
|
|
40
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
41
|
+
const total = value;
|
|
42
|
+
return { total, input: total / 2, output: total / 2 };
|
|
43
|
+
}
|
|
44
|
+
const o = value as Partial<UsageBucket> | null | undefined;
|
|
45
|
+
const total = typeof o?.total === 'number' && Number.isFinite(o.total) ? o.total : 0;
|
|
46
|
+
const input = typeof o?.input === 'number' && Number.isFinite(o.input) ? o.input : total / 2;
|
|
47
|
+
const output = typeof o?.output === 'number' && Number.isFinite(o.output) ? o.output : total - input;
|
|
48
|
+
return { total, input, output };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Sum a list of buckets into one. */
|
|
52
|
+
export function sumBuckets(buckets: Iterable<UsageBucket>): UsageBucket {
|
|
53
|
+
const acc = { total: 0, input: 0, output: 0 };
|
|
54
|
+
for (const b of buckets) {
|
|
55
|
+
acc.total += b.total;
|
|
56
|
+
acc.input += b.input;
|
|
57
|
+
acc.output += b.output;
|
|
58
|
+
}
|
|
59
|
+
return acc;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Average per-1M price over a provider's models (input/output buckets). */
|
|
63
|
+
export function providerPrice(
|
|
64
|
+
models: readonly { provider: string; cost?: { input: number; output: number } }[],
|
|
65
|
+
provider: string,
|
|
66
|
+
): ProviderPrice | undefined {
|
|
67
|
+
let input = 0;
|
|
68
|
+
let output = 0;
|
|
69
|
+
let n = 0;
|
|
70
|
+
for (const m of models) {
|
|
71
|
+
if (m.provider !== provider || !m.cost) continue;
|
|
72
|
+
input += m.cost.input;
|
|
73
|
+
output += m.cost.output;
|
|
74
|
+
n++;
|
|
75
|
+
}
|
|
76
|
+
if (n === 0) return undefined;
|
|
77
|
+
return { input: input / n, output: output / n };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** USD price of a delta bucket (tokens → USD; per-1M prices). */
|
|
81
|
+
export function spendUSD(delta: UsageBucket, price: ProviderPrice): number {
|
|
82
|
+
return (delta.input * price.input + delta.output * price.output) / 1_000_000;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Estimate the remaining amount/fraction after `delta` is spent since the
|
|
87
|
+
* snapshot. Returns undefined when the limit type cannot be estimated.
|
|
88
|
+
*/
|
|
89
|
+
export function estimateRemaining(
|
|
90
|
+
limit: LimitSnapshot,
|
|
91
|
+
delta: UsageBucket,
|
|
92
|
+
price: ProviderPrice,
|
|
93
|
+
): number | undefined {
|
|
94
|
+
// Balance-style (CNY): exact subtraction via price × FX.
|
|
95
|
+
if (typeof limit.remaining === 'number' && limit.unit === 'CNY') {
|
|
96
|
+
const spent = spendUSD(delta, price) * USD_CNY_FX;
|
|
97
|
+
return Math.max(0, limit.remaining - spent);
|
|
98
|
+
}
|
|
99
|
+
// Window-style: absolute token remaining + fraction → total = remaining/fraction;
|
|
100
|
+
// subtract spent tokens and re-derive the fraction.
|
|
101
|
+
if (
|
|
102
|
+
typeof limit.remaining === 'number' &&
|
|
103
|
+
typeof limit.remainingFraction === 'number' &&
|
|
104
|
+
limit.remainingFraction > 0
|
|
105
|
+
) {
|
|
106
|
+
const total = limit.remaining / limit.remainingFraction;
|
|
107
|
+
return Math.max(0, Math.min(1, (limit.remaining - delta.total) / total));
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Format a balance estimate with the estimate marker. */
|
|
113
|
+
export function formatBalanceEstimate(estimated: number): string {
|
|
114
|
+
return `~¥${estimated.toFixed(2)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Format a fraction estimate as a percent with the estimate marker. */
|
|
118
|
+
export function formatFractionEstimate(estimated: number): string {
|
|
119
|
+
return `~${Math.round(estimated * 100)}%`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build per-provider balance estimates (CNY) from the latest snapshot, the
|
|
124
|
+
* aggregated sessionUsage flow, and a baseline captured at the last fetch.
|
|
125
|
+
* Returns provider → replacement usage-line text; providers without a
|
|
126
|
+
* balance-style limit or without drift since the baseline are omitted.
|
|
127
|
+
*/
|
|
128
|
+
export function buildBalanceEstimates(
|
|
129
|
+
reports: readonly { provider: string; limits: readonly { amount?: { remaining?: number; unit?: string } }[] }[],
|
|
130
|
+
totals: Record<string, UsageBucket>,
|
|
131
|
+
models: readonly { provider: string; cost?: { input: number; output: number } }[],
|
|
132
|
+
baseline: Record<string, UsageBucket>,
|
|
133
|
+
): Map<string, string> {
|
|
134
|
+
const out = new Map<string, string>();
|
|
135
|
+
for (const r of reports) {
|
|
136
|
+
const limit = r.limits[0];
|
|
137
|
+
const amount = limit?.amount;
|
|
138
|
+
if (!amount || amount.unit !== 'CNY' || typeof amount.remaining !== 'number') continue;
|
|
139
|
+
const price = providerPrice(models, r.provider);
|
|
140
|
+
if (!price) continue;
|
|
141
|
+
const cur = totals[r.provider] ?? { total: 0, input: 0, output: 0 };
|
|
142
|
+
const base = baseline[r.provider] ?? cur;
|
|
143
|
+
const delta = {
|
|
144
|
+
total: cur.total - base.total,
|
|
145
|
+
input: cur.input - base.input,
|
|
146
|
+
output: cur.output - base.output,
|
|
147
|
+
};
|
|
148
|
+
if (delta.total <= 0) continue;
|
|
149
|
+
const estimated = estimateRemaining(amount, delta, price);
|
|
150
|
+
if (estimated === undefined || estimated === amount.remaining) continue;
|
|
151
|
+
out.set(r.provider, `余额 ${formatBalanceEstimate(estimated)}`);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|