@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,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model selector — picks the best `Model<Api>` for a given role.
|
|
3
|
+
*
|
|
4
|
+
* Sorts by (in order):
|
|
5
|
+
* 1. Effective gap = tierGap + surgePenalty (closest to expected wins).
|
|
6
|
+
* Surge penalty is applied during peak pricing windows and when
|
|
7
|
+
* monthly-plan providers have available quota (to avoid PAYG spend).
|
|
8
|
+
* 2. Rank (higher tier wins).
|
|
9
|
+
* 3. Plan priority: monthly-plan providers (0) before PAYG (1).
|
|
10
|
+
* 4. Remaining fraction (healthier first).
|
|
11
|
+
* 5. Cost (cheaper first).
|
|
12
|
+
* 6. Reputation (higher first).
|
|
13
|
+
* 7. Name (stable tie-break).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
17
|
+
import {
|
|
18
|
+
type TierName,
|
|
19
|
+
type TierOverride,
|
|
20
|
+
DEFAULT_ROLE_TIER,
|
|
21
|
+
scoreModelWithProvider,
|
|
22
|
+
tierNameToRank,
|
|
23
|
+
} from './model-tiers.js';
|
|
24
|
+
import type { ProviderHealth } from './usage-resolver.js';
|
|
25
|
+
import { getPeakStatus, isGLM5Model, type PeakStatus } from './peak-detector.js';
|
|
26
|
+
|
|
27
|
+
export interface ScoredCandidate {
|
|
28
|
+
model: Model<Api>;
|
|
29
|
+
selector: string;
|
|
30
|
+
rank: number;
|
|
31
|
+
tier: TierName;
|
|
32
|
+
reason: string;
|
|
33
|
+
exhausted: boolean;
|
|
34
|
+
remainingFraction?: number;
|
|
35
|
+
tierGap: number;
|
|
36
|
+
costInput: number;
|
|
37
|
+
/** 0 = provider has a monthly plan, 1 = PAYG / unknown. Lower preferred. */
|
|
38
|
+
planPriority: number;
|
|
39
|
+
/** Coding reputation 0-100 from model family (SWE-bench ranking). Higher preferred. */
|
|
40
|
+
reputation: number;
|
|
41
|
+
/** Surge pricing penalty (0 = normal, 1+ = penalized). Added to tierGap for sorting. */
|
|
42
|
+
surgePenalty: number;
|
|
43
|
+
}
|
|
44
|
+
export interface SelectorResult {
|
|
45
|
+
role: string;
|
|
46
|
+
expectedTier: TierName;
|
|
47
|
+
picked: Model<Api> | null;
|
|
48
|
+
candidates: ScoredCandidate[];
|
|
49
|
+
allCandidates: ScoredCandidate[];
|
|
50
|
+
decision: string;
|
|
51
|
+
pickedReason: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SelectorInput {
|
|
55
|
+
role: string;
|
|
56
|
+
models: Model<Api>[];
|
|
57
|
+
roleTiers?: Record<string, TierName>;
|
|
58
|
+
tierOverrides?: TierOverride[];
|
|
59
|
+
health?: Map<string, ProviderHealth>;
|
|
60
|
+
allowlist?: string[];
|
|
61
|
+
/** Provider names that have a monthly plan subscription. */
|
|
62
|
+
planProviders?: Set<string>;
|
|
63
|
+
/** Pre-computed peak status (for testing). If omitted, computed from current time. */
|
|
64
|
+
peakStatus?: PeakStatus;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function globToRegex(pattern: string): RegExp {
|
|
68
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
69
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type CursorBucket = 'builtin' | 'other';
|
|
73
|
+
|
|
74
|
+
const BUILTIN_ID_RE = /\b(auto|composer|grok|cursor-small|cursor-fast)\b/i;
|
|
75
|
+
|
|
76
|
+
export function classifyCursorBucket(
|
|
77
|
+
model: { id: string; name?: string },
|
|
78
|
+
autoBucketModels?: string[],
|
|
79
|
+
): CursorBucket {
|
|
80
|
+
const idLower = model.id.toLowerCase();
|
|
81
|
+
if (autoBucketModels?.some((m) => m.toLowerCase() === idLower)) return 'builtin';
|
|
82
|
+
const hay = `${model.name ?? ''} ${model.id}`;
|
|
83
|
+
if (BUILTIN_ID_RE.test(hay)) return 'builtin';
|
|
84
|
+
return 'other';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function candidateQuota(
|
|
88
|
+
model: { id: string; name?: string; provider: string },
|
|
89
|
+
health: ProviderHealth | undefined,
|
|
90
|
+
): { exhausted: boolean; remainingFraction?: number } {
|
|
91
|
+
if (!health) return { exhausted: false, remainingFraction: undefined };
|
|
92
|
+
if (health.policy !== 'bucket') {
|
|
93
|
+
return { exhausted: health.exhausted, remainingFraction: health.worstRemainingFraction };
|
|
94
|
+
}
|
|
95
|
+
const bucket = classifyCursorBucket(model, health.autoBucketModels);
|
|
96
|
+
const win = health.windows.find((w) => w.id === bucket) ?? health.windows.find((w) => w.id === 'other');
|
|
97
|
+
const ondemand = health.windows.find((w) => w.id === 'ondemand');
|
|
98
|
+
if (!win) {
|
|
99
|
+
return { exhausted: health.exhausted, remainingFraction: health.worstRemainingFraction };
|
|
100
|
+
}
|
|
101
|
+
if (win.exhausted) {
|
|
102
|
+
if (ondemand && !ondemand.exhausted) {
|
|
103
|
+
return { exhausted: false, remainingFraction: ondemand.remaining };
|
|
104
|
+
}
|
|
105
|
+
return { exhausted: true, remainingFraction: win.remaining ?? 0 };
|
|
106
|
+
}
|
|
107
|
+
return { exhausted: false, remainingFraction: win.remaining };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const DEFAULT_PLAN_PROVIDERS = new Set([
|
|
111
|
+
'minimax-code-cn',
|
|
112
|
+
'opencode-go',
|
|
113
|
+
'zhipu-coding-plan',
|
|
114
|
+
'kimi',
|
|
115
|
+
'openai-codex',
|
|
116
|
+
'cursor',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
export function mergePlanProviders(extra?: readonly string[]): Set<string> {
|
|
120
|
+
return new Set([...DEFAULT_PLAN_PROVIDERS, ...(extra ?? [])]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isAllowlisted(selector: string, allowlist: string[] | undefined): boolean {
|
|
124
|
+
if (!allowlist || allowlist.length === 0) return true;
|
|
125
|
+
return allowlist.some((p) => globToRegex(p).test(selector));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extract a numeric generation from a model id, so we can prefer
|
|
130
|
+
* newer models (v4 > v3, M3 > M2) within the same provider+tier.
|
|
131
|
+
* Returns [major, minor]; [0,0] when no version pattern is found.
|
|
132
|
+
*/
|
|
133
|
+
function extractGeneration(modelId: string): [number, number] {
|
|
134
|
+
// v4, v4-pro, deepseek-v4-flash → major=4
|
|
135
|
+
const vMatch = modelId.match(/\bv(\d+)\b/i);
|
|
136
|
+
if (vMatch) return [parseInt(vMatch[1], 10), 0];
|
|
137
|
+
// MiniMax M3, M2.7 → major=3 minor=0, or parse "2.7" from "M2.7"
|
|
138
|
+
const mmMatch = modelId.match(/\bM(\d+)(?:[._-](\d+))?\b/i);
|
|
139
|
+
if (mmMatch) return [parseInt(mmMatch[1], 10), parseInt(mmMatch[2] ?? '0', 10)];
|
|
140
|
+
// kimi-k2.7, glm-5.2 → major=2 minor=7
|
|
141
|
+
const kMatch = modelId.match(/\b[kqg](\d+)(?:[._-](\d+))?\b/i);
|
|
142
|
+
if (kMatch) return [parseInt(kMatch[1], 10), parseInt(kMatch[2] ?? '0', 10)];
|
|
143
|
+
// claude-opus-4-8, claude-sonnet-4-5 → hyphen-separated version
|
|
144
|
+
// claude-opus-4-8 → [4,8]; claude-opus-4-20250514 → [4,0] (date-stamp stripped)
|
|
145
|
+
const hMatch = modelId.match(/\b(claude|opus|sonnet|haiku|mythos|fable)[._-]?(\d+)[._-](\d+)\b/i);
|
|
146
|
+
if (hMatch) {
|
|
147
|
+
const minor = parseInt(hMatch[3], 10);
|
|
148
|
+
return [parseInt(hMatch[2], 10), minor >= 10_000 ? 0 : minor];
|
|
149
|
+
}
|
|
150
|
+
// claude-fable-5 → [5,0]
|
|
151
|
+
const hSingle = modelId.match(/\b(claude|opus|sonnet|haiku|mythos|fable)[._-]?(\d+)\b/i);
|
|
152
|
+
if (hSingle) return [parseInt(hSingle[2], 10), 0];
|
|
153
|
+
// gpt-5.5, glm-4.7 → decimal version
|
|
154
|
+
const dMatch = modelId.match(/\b(\d+)\.(\d+)\b/);
|
|
155
|
+
if (dMatch) return [parseInt(dMatch[1], 10), parseInt(dMatch[2], 10)];
|
|
156
|
+
return [0, 0];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Compare two generation tuples: higher major, then minor. */
|
|
160
|
+
function isNewer(a: [number, number], b: [number, number]): boolean {
|
|
161
|
+
if (a[0] !== b[0]) return a[0] > b[0];
|
|
162
|
+
return a[1] > b[1];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Keep the best candidate per (provider, tier) group.
|
|
166
|
+
* Prefers latest generation first, then reputation as tiebreaker. */
|
|
167
|
+
function dedupByProviderTier(candidates: ScoredCandidate[]): ScoredCandidate[] {
|
|
168
|
+
const best = new Map<string, ScoredCandidate>();
|
|
169
|
+
for (const c of candidates) {
|
|
170
|
+
const key = `${c.model.provider}:${c.tier}`;
|
|
171
|
+
const existing = best.get(key);
|
|
172
|
+
if (!existing) {
|
|
173
|
+
best.set(key, c);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const cGen = extractGeneration(c.model.id);
|
|
177
|
+
const eGen = extractGeneration(existing.model.id);
|
|
178
|
+
if (isNewer(cGen, eGen)) {
|
|
179
|
+
best.set(key, c);
|
|
180
|
+
} else if (cGen[0] === eGen[0] && cGen[1] === eGen[1]) {
|
|
181
|
+
// Same generation → reputation tiebreaks
|
|
182
|
+
if (c.reputation > existing.reputation ||
|
|
183
|
+
(c.reputation === existing.reputation && c.rank > existing.rank)) {
|
|
184
|
+
best.set(key, c);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return [...best.values()];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function selectModel(input: SelectorInput): SelectorResult {
|
|
192
|
+
const { role, models, tierOverrides, health, allowlist, planProviders, peakStatus } = input;
|
|
193
|
+
const roleTiers = input.roleTiers ?? DEFAULT_ROLE_TIER;
|
|
194
|
+
const expectedTier: TierName = roleTiers[role] ?? DEFAULT_ROLE_TIER[role] ?? 'mid';
|
|
195
|
+
const expectedRank = tierNameToRank(expectedTier);
|
|
196
|
+
|
|
197
|
+
const allCandidates: ScoredCandidate[] = models.map((model) => {
|
|
198
|
+
const selector = `${model.provider}/${model.id}`;
|
|
199
|
+
const score = scoreModelWithProvider(model.provider, model.id, model.name, tierOverrides);
|
|
200
|
+
const h = health?.get(model.provider);
|
|
201
|
+
const quota = candidateQuota(model, h);
|
|
202
|
+
return {
|
|
203
|
+
model,
|
|
204
|
+
selector,
|
|
205
|
+
rank: score.rank,
|
|
206
|
+
tier: score.tier,
|
|
207
|
+
reason: score.reason,
|
|
208
|
+
exhausted: quota.exhausted,
|
|
209
|
+
remainingFraction: quota.remainingFraction,
|
|
210
|
+
tierGap: Math.abs(score.rank - expectedRank),
|
|
211
|
+
costInput: model.cost?.input ?? 0,
|
|
212
|
+
planPriority: planProviders?.has(model.provider) ? 0 : 1,
|
|
213
|
+
reputation: score.reputation,
|
|
214
|
+
surgePenalty: 0,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const filtered = allCandidates.filter((c) => isAllowlisted(c.selector, allowlist) && !c.exhausted);
|
|
219
|
+
// Dedup: keep only the best model per (provider, tier) group.
|
|
220
|
+
// This prevents old models in the same tier (e.g. MiniMax-M2, M2.5, M2.7)
|
|
221
|
+
// from flooding the candidate pool against the latest (M3).
|
|
222
|
+
const deduped = dedupByProviderTier(filtered);
|
|
223
|
+
// ── surge pricing penalty ──────────────────────────────────────
|
|
224
|
+
// Peak-time pricing adjustments + monthly-plan preference.
|
|
225
|
+
const peak = peakStatus ?? getPeakStatus();
|
|
226
|
+
const hasMonthlyAvailable = deduped.some(
|
|
227
|
+
(c) => planProviders?.has(c.model.provider) && !c.exhausted,
|
|
228
|
+
);
|
|
229
|
+
for (const c of deduped) {
|
|
230
|
+
const isDS = c.model.provider === 'deepseek';
|
|
231
|
+
const isGLM5 = c.model.provider === 'zhipu-coding-plan' && isGLM5Model(c.model.id);
|
|
232
|
+
let penalty = 0;
|
|
233
|
+
if (isDS && peak.deepseekPeak) penalty = Math.max(penalty, 2);
|
|
234
|
+
if (isGLM5 && peak.glm5Peak) penalty = Math.max(penalty, 2);
|
|
235
|
+
// When any monthly-plan provider has available quota, heavily
|
|
236
|
+
// penalize DeepSeek (PAYG) to preserve plan budget.
|
|
237
|
+
if (isDS && hasMonthlyAvailable) penalty = Math.max(penalty, 3);
|
|
238
|
+
c.surgePenalty = penalty;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
let picked: ScoredCandidate | null = null;
|
|
244
|
+
if (deduped.length > 0) {
|
|
245
|
+
const sorted = [...deduped].sort((a, b) => {
|
|
246
|
+
const effA = a.tierGap + a.surgePenalty;
|
|
247
|
+
const effB = b.tierGap + b.surgePenalty;
|
|
248
|
+
if (effA !== effB) return effA - effB;
|
|
249
|
+
if (a.rank !== b.rank) return b.rank - a.rank;
|
|
250
|
+
// Same effective tier: prefer monthly plan, then cheapest model
|
|
251
|
+
if (a.planPriority !== b.planPriority) return a.planPriority - b.planPriority;
|
|
252
|
+
const ar = a.remainingFraction ?? 1;
|
|
253
|
+
const br = b.remainingFraction ?? 1;
|
|
254
|
+
if (ar !== br) return br - ar;
|
|
255
|
+
if (a.costInput !== b.costInput) return a.costInput - b.costInput;
|
|
256
|
+
if (a.reputation !== b.reputation) return b.reputation - a.reputation;
|
|
257
|
+
return a.selector.localeCompare(b.selector);
|
|
258
|
+
});
|
|
259
|
+
picked = sorted[0] ?? null;
|
|
260
|
+
}
|
|
261
|
+
const decision = buildDecisionLog(role, expectedTier, allCandidates, deduped, picked);
|
|
262
|
+
const pickedReason = picked
|
|
263
|
+
? `${picked.selector} (tier=${picked.tier}, gap=${picked.tierGap}${picked.surgePenalty > 0 ? `+${picked.surgePenalty}` : ''})`
|
|
264
|
+
: null;
|
|
265
|
+
|
|
266
|
+
return { role, expectedTier, picked: picked?.model ?? null, candidates: deduped, allCandidates, decision, pickedReason };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function buildDecisionLog(
|
|
270
|
+
role: string, expectedTier: TierName,
|
|
271
|
+
all: ScoredCandidate[], filtered: ScoredCandidate[], picked: ScoredCandidate | null,
|
|
272
|
+
): string {
|
|
273
|
+
const total = all.length;
|
|
274
|
+
const exhausted = all.filter((c) => c.exhausted).length;
|
|
275
|
+
if (total === 0) return `role=${role}: no models available`;
|
|
276
|
+
if (!picked) return `role=${role}: expected tier=${expectedTier}; ${total} scored, ${exhausted} exhausted → none`;
|
|
277
|
+
return [
|
|
278
|
+
`role=${role}: expected tier=${expectedTier}`,
|
|
279
|
+
`${total} available, ${exhausted} exhausted, ${filtered.length} candidates`,
|
|
280
|
+
`picked=${picked.selector} tier=${picked.tier} gap=${picked.tierGap}`,
|
|
281
|
+
`reason=${picked.reason}`,
|
|
282
|
+
].join(' | ');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function selectAllRoles(
|
|
286
|
+
roles: string[], models: Model<Api>[],
|
|
287
|
+
roleTiers: Record<string, TierName> | undefined,
|
|
288
|
+
tierOverrides: TierOverride[] | undefined,
|
|
289
|
+
health: Map<string, ProviderHealth> | undefined,
|
|
290
|
+
allowlist: string[] | undefined,
|
|
291
|
+
planProviders?: Set<string>,
|
|
292
|
+
peakStatus?: PeakStatus,
|
|
293
|
+
): Record<string, SelectorResult> {
|
|
294
|
+
const out: Record<string, SelectorResult> = {};
|
|
295
|
+
for (const role of roles) {
|
|
296
|
+
out[role] = selectModel({ role, models, roleTiers, tierOverrides, health, allowlist, planProviders, peakStatus });
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
/** One-line description of a selector result, suitable for system prompts. */
|
|
303
|
+
export function formatSelectorForPrompt(result: SelectorResult): string {
|
|
304
|
+
const { role, expectedTier, picked, pickedReason } = result;
|
|
305
|
+
if (!picked) return `• ${role}: 无可选 model (期望 tier=${expectedTier})`;
|
|
306
|
+
return `• ${role}: ${picked.provider}/${picked.id} (tier=${expectedTier}, ${pickedReason})`;
|
|
307
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model tier scoring.
|
|
3
|
+
*
|
|
4
|
+
* A "tier" is a numeric 0..N rank that ranks a model's capability. Higher = stronger.
|
|
5
|
+
*
|
|
6
|
+
* Tier resolution uses three layers (highest priority first):
|
|
7
|
+
* 1. User overrides from `opsx.yml` `tiers:` config
|
|
8
|
+
* 2. Data-driven lookup from `~/.omp/cache/tiers-data.json`
|
|
9
|
+
* 3. Heuristic fallback based on model name patterns
|
|
10
|
+
*
|
|
11
|
+
* Users can override the score in two ways via `~/.omp/agent/opsx.yml` /
|
|
12
|
+
* `<cwd>/.omp/opsx.yml`:
|
|
13
|
+
*
|
|
14
|
+
* tiers:
|
|
15
|
+
* - pattern: "*opus*"
|
|
16
|
+
* tier: top
|
|
17
|
+
* - pattern: "deepseek/deepseek-chat"
|
|
18
|
+
* tier: high
|
|
19
|
+
*
|
|
20
|
+
* Tier names map to numeric ranks via {@link TIER_RANK}. Unknown tier names
|
|
21
|
+
* fall back to the heuristic score.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { matchDataEntry } from './tiers-data.js';
|
|
25
|
+
|
|
26
|
+
/** Ordered tier names. Higher index = stronger. */
|
|
27
|
+
export const TIER_NAMES = ['tiny', 'low', 'mid', 'high', 'top'] as const;
|
|
28
|
+
export type TierName = (typeof TIER_NAMES)[number];
|
|
29
|
+
|
|
30
|
+
export const TIER_RANK: Record<TierName, number> = {
|
|
31
|
+
tiny: 0,
|
|
32
|
+
low: 1,
|
|
33
|
+
mid: 2,
|
|
34
|
+
high: 3,
|
|
35
|
+
top: 4,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_ROLE_TIER: Record<string, TierName> = {
|
|
39
|
+
coder: 'high',
|
|
40
|
+
reviewer: 'high',
|
|
41
|
+
planner: 'high',
|
|
42
|
+
'proposal-reviewer': 'high',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A user override rule from `opsx.yml` `tiers:`. */
|
|
46
|
+
export interface TierOverride {
|
|
47
|
+
pattern: string;
|
|
48
|
+
tier: TierName | number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TierScore {
|
|
52
|
+
rank: number;
|
|
53
|
+
tier: TierName;
|
|
54
|
+
reason: string;
|
|
55
|
+
/** Coding reputation 0–100, based on generation only (not family). */
|
|
56
|
+
reputation: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function tierNameToRank(name: TierName | string | number | undefined): number {
|
|
60
|
+
if (typeof name === 'number') return name;
|
|
61
|
+
if (typeof name === 'string' && name in TIER_RANK) return TIER_RANK[name as TierName];
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function rankToTierName(rank: number): TierName {
|
|
66
|
+
let best: TierName = 'tiny';
|
|
67
|
+
let bestDist = Infinity;
|
|
68
|
+
for (const t of TIER_NAMES) {
|
|
69
|
+
const d = Math.abs(TIER_RANK[t] - rank);
|
|
70
|
+
if (d < bestDist) { bestDist = d; best = t; }
|
|
71
|
+
}
|
|
72
|
+
return best;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reputation based only on model generation (stronger variants = higher reputation). */
|
|
76
|
+
function getReputation(haystack: string): number {
|
|
77
|
+
let rep = 50; // baseline
|
|
78
|
+
// Generation bonus — stronger variants boost reputation.
|
|
79
|
+
if (/\b(opus|ultra|pro\b|k3|glm-?5|5\.0|5\.1|5\.2)\b/i.test(haystack)) rep += 20;
|
|
80
|
+
else if (/\b(m3|o1|o3|sonnet|thinking|reasoner|gpt-?4|v[3-9]|4\.1|4o|4o-mini)\b/i.test(haystack)) rep += 10;
|
|
81
|
+
// Extra bonus for k3 over glm (user preference: k3 > glm5.2)
|
|
82
|
+
if (/\bk3\b/i.test(haystack)) rep += 5;
|
|
83
|
+
// Generation penalty — weaker variants reduce reputation.
|
|
84
|
+
if (/\b(haiku|mini|nano|lite|tiny|flash|small|light|k1)\b/i.test(haystack)) rep -= 15;
|
|
85
|
+
return Math.max(0, Math.min(100, rep));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Heuristic tier score from model id + name (fallback when data layer misses). */
|
|
89
|
+
function heuristicScore(modelId: string, modelName?: string): TierScore {
|
|
90
|
+
const haystack = `${modelName ?? ''} ${modelId}`.toLowerCase();
|
|
91
|
+
if (!haystack.trim()) return { rank: 0, tier: 'tiny', reason: 'empty identifier', reputation: 0 };
|
|
92
|
+
if (/\bhaiku\b/.test(haystack)) return { rank: TIER_RANK.low, tier: 'low', reason: 'name contains "haiku"', reputation: getReputation(haystack) };
|
|
93
|
+
if (/\b(mini|nano|lite|flash-lite|tiny|small)\b/.test(haystack)) return { rank: TIER_RANK.tiny, tier: 'tiny', reason: 'name contains mini/nano/lite/tiny/small', reputation: getReputation(haystack) };
|
|
94
|
+
if (/\b(opus|gpt-\d[\d.]*-pro|gemini-\d[\d.]*-pro|deep[-_ ]?(think|research)|ultra|\bm3\b|\bm2\b|kimi[._-]?k[23]|glm[._-]?5|codex-pro|o1|o3)\b/.test(haystack)) return { rank: TIER_RANK.top, tier: 'top', reason: 'name contains opus/o1/pro/ultra/M3/M2/kimi-k2-k3/glm-5/codex-pro/o1', reputation: getReputation(haystack) };
|
|
95
|
+
if (/\b(deepseek[._-]?)?\w+[._-]pro\b/.test(haystack)) return { rank: TIER_RANK.top, tier: 'top', reason: 'name contains "-pro" flagship suffix', reputation: getReputation(haystack) };
|
|
96
|
+
if (/\b(sonnet|thinking|reasoner)\b/.test(haystack)) return { rank: TIER_RANK.high, tier: 'high', reason: 'name contains sonnet/thinking/reasoner', reputation: getReputation(haystack) };
|
|
97
|
+
if (/\b(gpt-5|gpt-4\.1|gpt-4o|gpt-4)\b/.test(haystack)) return { rank: TIER_RANK.high, tier: 'high', reason: 'gpt-5/4.x family', reputation: getReputation(haystack) };
|
|
98
|
+
if (/\b(claude|gpt|gemini|minimax|deepseek|kimi|qwen|glm|llama|mistral|command[-_ ]?r)\b/.test(haystack)) return { rank: TIER_RANK.mid, tier: 'mid', reason: 'known family, no explicit tier signal', reputation: getReputation(haystack) };
|
|
99
|
+
return { rank: TIER_RANK.mid, tier: 'mid', reason: 'unknown family, defaulting to mid', reputation: 50 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Tier score from model id + name.
|
|
104
|
+
* Priority: data layer → heuristic.
|
|
105
|
+
* opsx.yml overrides are NOT applied here (handled by `scoreModel` / `scoreModelWithProvider`).
|
|
106
|
+
*/
|
|
107
|
+
export function scoreModelByName(modelId: string, modelName?: string): TierScore {
|
|
108
|
+
const dataMatch = matchDataEntry(modelId);
|
|
109
|
+
if (dataMatch) {
|
|
110
|
+
return {
|
|
111
|
+
rank: tierNameToRank(dataMatch.tier),
|
|
112
|
+
tier: dataMatch.tier,
|
|
113
|
+
reason: dataMatch.reason,
|
|
114
|
+
reputation: dataMatch.reputation,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return heuristicScore(modelId, modelName);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function globToRegex(pattern: string): RegExp {
|
|
121
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
122
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Apply user overrides to a score. First matching override wins. */
|
|
126
|
+
export function applyTierOverrides(
|
|
127
|
+
selector: string,
|
|
128
|
+
base: TierScore,
|
|
129
|
+
overrides?: TierOverride[],
|
|
130
|
+
): TierScore {
|
|
131
|
+
if (!overrides?.length) return base;
|
|
132
|
+
for (const rule of overrides) {
|
|
133
|
+
if (globToRegex(rule.pattern).test(selector)) {
|
|
134
|
+
const rank = tierNameToRank(rule.tier);
|
|
135
|
+
return { rank, tier: rankToTierName(rank), reason: `override: ${rule.pattern} → ${rule.tier}`, reputation: base.reputation };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return base;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Score a model given its id, name, and any user overrides. */
|
|
142
|
+
export function scoreModel(
|
|
143
|
+
modelId: string,
|
|
144
|
+
modelName: string | undefined,
|
|
145
|
+
overrides?: TierOverride[],
|
|
146
|
+
): TierScore {
|
|
147
|
+
const base = scoreModelByName(modelId, modelName);
|
|
148
|
+
return applyTierOverrides(modelId, base, overrides);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Score a model given its provider + id (so `provider/modelId` overrides work). */
|
|
152
|
+
export function scoreModelWithProvider(
|
|
153
|
+
provider: string,
|
|
154
|
+
modelId: string,
|
|
155
|
+
modelName: string | undefined,
|
|
156
|
+
overrides?: TierOverride[],
|
|
157
|
+
): TierScore {
|
|
158
|
+
// Provider-scoped data match has higher priority than bare-id heuristic
|
|
159
|
+
const dataMatch = matchDataEntry(modelId, provider);
|
|
160
|
+
const base = dataMatch
|
|
161
|
+
? { rank: tierNameToRank(dataMatch.tier), tier: dataMatch.tier, reason: dataMatch.reason, reputation: dataMatch.reputation }
|
|
162
|
+
: scoreModelByName(modelId, modelName);
|
|
163
|
+
return applyTierOverrides(`${provider}/${modelId}`, base, overrides);
|
|
164
|
+
}
|
package/lib/notify.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
/** Shared task checkbox parsing for openspec change tasks.md files */
|
|
5
|
+
export function countTaskCheckboxes(content: string): { completed: number; total: number } {
|
|
6
|
+
let completed = 0;
|
|
7
|
+
let total = 0;
|
|
8
|
+
for (const line of content.split('\n')) {
|
|
9
|
+
if (/^\s*- \[x\]/i.test(line)) {
|
|
10
|
+
completed++;
|
|
11
|
+
total++;
|
|
12
|
+
} else if (/^\s*- \[ \]/.test(line)) {
|
|
13
|
+
total++;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return { completed, total };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function countTasks(dir: string, name: string): Promise<{ current: number; total: number } | null> {
|
|
20
|
+
const path = join(dir, 'openspec', 'changes', name, 'tasks.md');
|
|
21
|
+
try {
|
|
22
|
+
const content = await readFile(path, 'utf-8');
|
|
23
|
+
const { completed, total } = countTaskCheckboxes(content);
|
|
24
|
+
return total > 0 ? { current: completed, total } : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peak-time detection for model surge pricing.
|
|
3
|
+
*
|
|
4
|
+
* DeepSeek peak: 9:00-12:00 and 14:00-18:00 Beijing time (UTC+8), all days.
|
|
5
|
+
* GLM-5 peak: Mon-Fri 14:00-18:00 Beijing time (UTC+8).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PeakStatus {
|
|
9
|
+
/** DeepSeek peak: 9:00-12:00 or 14:00-18:00 Beijing time, all days. */
|
|
10
|
+
deepseekPeak: boolean;
|
|
11
|
+
/** GLM-5 peak: Mon-Fri 14:00-18:00 Beijing time. */
|
|
12
|
+
glm5Peak: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000; // UTC+8
|
|
16
|
+
|
|
17
|
+
function beijingTime(d: Date): { hour: number; day: number } {
|
|
18
|
+
const utcMs = d.getTime() + d.getTimezoneOffset() * 60 * 1000;
|
|
19
|
+
const bjMs = utcMs + BEIJING_OFFSET_MS;
|
|
20
|
+
const bjDate = new Date(bjMs);
|
|
21
|
+
return { hour: bjDate.getUTCHours(), day: bjDate.getUTCDay() };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Determine current peak pricing windows.
|
|
26
|
+
* @param now — current time (injectable for testing).
|
|
27
|
+
*/
|
|
28
|
+
export function getPeakStatus(now: Date = new Date()): PeakStatus {
|
|
29
|
+
const { hour, day } = beijingTime(now);
|
|
30
|
+
|
|
31
|
+
// DeepSeek peak: 9:00-12:00 (inclusive start, exclusive end) or 14:00-18:00
|
|
32
|
+
const deepseekPeak = (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18);
|
|
33
|
+
|
|
34
|
+
// GLM-5 peak: Mon(1)-Fri(5) 14:00-18:00
|
|
35
|
+
const isWeekday = day >= 1 && day <= 5;
|
|
36
|
+
const glm5Peak = isWeekday && hour >= 14 && hour < 18;
|
|
37
|
+
|
|
38
|
+
return { deepseekPeak, glm5Peak };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Check if a model belongs to the GLM-5 family. */
|
|
42
|
+
export function isGLM5Model(modelId: string): boolean {
|
|
43
|
+
return /glm[._-]?5(?![0-9])/i.test(modelId);
|
|
44
|
+
}
|