@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,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct usage fetchers — merged from omp-plan-usage.
|
|
3
|
+
* Each fetcher queries a provider's API for remaining quota when OMP's
|
|
4
|
+
* built-in AuthStorage.fetchUsageReports doesn't cover it.
|
|
5
|
+
*
|
|
6
|
+
* API keys are resolved at call time via `getApiKey(provider)`, which is
|
|
7
|
+
* wired to `ctx.modelRegistry.getApiKeyForProvider` by the plugin entry.
|
|
8
|
+
* OpenCode Go usage now comes from OMP built-in fetchUsageReports;
|
|
9
|
+
* this module only covers providers without a working built-in reporter.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { UsageReport } from '@oh-my-pi/pi-ai';
|
|
13
|
+
import type { DirectFetcher, BoundApiKeyResolver } from './usage-resolver.js';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async function deepSeekBalance(signal?: AbortSignal, getApiKey?: BoundApiKeyResolver): Promise<UsageReport | null> {
|
|
17
|
+
const key = await getApiKey?.();
|
|
18
|
+
if (!key) return null;
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch('https://api.deepseek.com/user/balance', {
|
|
21
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
22
|
+
signal,
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) return null;
|
|
25
|
+
const data = (await res.json()) as {
|
|
26
|
+
is_available: boolean;
|
|
27
|
+
balance_infos: Array<{ currency: string; total_balance: string; topped_up_balance: string }>;
|
|
28
|
+
};
|
|
29
|
+
const total = data.balance_infos.reduce((s, b) => s + Number(b.total_balance), 0);
|
|
30
|
+
const unit = (data.balance_infos[0]?.currency ?? 'CNY') as UsageReport['limits'][0]['amount']['unit'];
|
|
31
|
+
return {
|
|
32
|
+
provider: 'deepseek',
|
|
33
|
+
fetchedAt: Date.now(),
|
|
34
|
+
limits: [
|
|
35
|
+
{
|
|
36
|
+
id: 'deepseek:balance',
|
|
37
|
+
label: 'DeepSeek 余额',
|
|
38
|
+
scope: { provider: 'deepseek' },
|
|
39
|
+
amount: { remaining: total, unit } as unknown as UsageReport['limits'][0]['amount'],
|
|
40
|
+
status: 'ok',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function minimaxUsage(signal?: AbortSignal, getApiKey?: BoundApiKeyResolver): Promise<UsageReport | null> {
|
|
50
|
+
const key = await getApiKey?.();
|
|
51
|
+
if (!key) return null;
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch('https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains', {
|
|
54
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
55
|
+
signal,
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) return null;
|
|
58
|
+
const data = (await res.json()) as {
|
|
59
|
+
model_remains: Array<{
|
|
60
|
+
model_name: string;
|
|
61
|
+
current_interval_remaining_percent: number;
|
|
62
|
+
current_interval_status: number;
|
|
63
|
+
current_weekly_remaining_percent: number;
|
|
64
|
+
current_weekly_status: number;
|
|
65
|
+
}>;
|
|
66
|
+
};
|
|
67
|
+
const g = data.model_remains?.find((m) => m.model_name === 'general');
|
|
68
|
+
if (!g) return null;
|
|
69
|
+
const p5 = Math.round(g.current_interval_remaining_percent) / 100;
|
|
70
|
+
const pw = Math.round(g.current_weekly_remaining_percent) / 100;
|
|
71
|
+
return {
|
|
72
|
+
provider: 'minimax-code-cn',
|
|
73
|
+
fetchedAt: Date.now(),
|
|
74
|
+
limits: [
|
|
75
|
+
{
|
|
76
|
+
id: 'minimax-code-cn:5h',
|
|
77
|
+
label: 'MiniMax 5h',
|
|
78
|
+
scope: { provider: 'minimax-code-cn', windowId: '5h' },
|
|
79
|
+
window: { id: '5h', label: '5小时' },
|
|
80
|
+
amount: { usedFraction: 1 - p5, remainingFraction: p5, unit: 'percent' },
|
|
81
|
+
status: g.current_interval_status !== 1 ? 'exhausted' : 'ok',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'minimax-code-cn:1w',
|
|
85
|
+
label: 'MiniMax 周',
|
|
86
|
+
scope: { provider: 'minimax-code-cn', windowId: '1w' },
|
|
87
|
+
window: { id: '1w', label: '每周' },
|
|
88
|
+
amount: { usedFraction: 1 - pw, remainingFraction: pw, unit: 'percent' },
|
|
89
|
+
status: g.current_weekly_status !== 1 ? 'exhausted' : 'ok',
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Zhipu Coding Plan usage ──────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** Unit codes → window metadata. Unit 5 (1mo) excluded — it reports MCP usage, not Coding Plan quota. */
|
|
101
|
+
const ZHIPU_UNIT_TO_WINDOW: Record<number, { id: string; label: string }> = {
|
|
102
|
+
3: { id: '5h', label: '5小时' },
|
|
103
|
+
6: { id: '1w', label: '每周' },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
interface ZhipuLimitEntry {
|
|
107
|
+
type: string;
|
|
108
|
+
unit: number;
|
|
109
|
+
number: number;
|
|
110
|
+
percentage: number;
|
|
111
|
+
nextResetTime: number;
|
|
112
|
+
usage?: number;
|
|
113
|
+
remaining?: number;
|
|
114
|
+
currentValue?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function zhipuCodingPlanUsage(signal?: AbortSignal, getApiKey?: BoundApiKeyResolver): Promise<UsageReport | null> {
|
|
118
|
+
const key = await getApiKey?.();
|
|
119
|
+
if (!key) return null;
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch('https://open.bigmodel.cn/api/monitor/usage/quota/limit', {
|
|
122
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
123
|
+
signal,
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok) return null;
|
|
126
|
+
const data = (await res.json()) as { code: number; data?: { limits?: ZhipuLimitEntry[] } };
|
|
127
|
+
if (data.code !== 200 || !data.data?.limits?.length) return null;
|
|
128
|
+
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const limits = data.data.limits
|
|
131
|
+
.filter((l) => ZHIPU_UNIT_TO_WINDOW[l.unit])
|
|
132
|
+
.map((l) => {
|
|
133
|
+
const w = ZHIPU_UNIT_TO_WINDOW[l.unit];
|
|
134
|
+
const usedFraction = Math.min(l.percentage / 100, 1);
|
|
135
|
+
return {
|
|
136
|
+
id: `zhipu-coding-plan:${w.id}`,
|
|
137
|
+
label: `智谱 ${w.label}`,
|
|
138
|
+
scope: { provider: 'zhipu-coding-plan' as const, windowId: w.id },
|
|
139
|
+
window: { id: w.id, label: w.label, resetsAt: l.nextResetTime },
|
|
140
|
+
amount: { usedFraction, remainingFraction: 1 - usedFraction, unit: 'percent' as const },
|
|
141
|
+
status: (l.percentage >= 100 ? 'exhausted' : 'ok') as UsageReport['limits'][0]['status'],
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
if (limits.length === 0) return null;
|
|
145
|
+
return { provider: 'zhipu-coding-plan', fetchedAt: now, limits };
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const DIRECT_FETCHERS: DirectFetcher[] = [
|
|
152
|
+
{ provider: 'deepseek', fetch: deepSeekBalance },
|
|
153
|
+
{ provider: 'minimax-code-cn', fetch: minimaxUsage },
|
|
154
|
+
{ provider: 'zhipu-coding-plan', fetch: zhipuCodingPlanUsage },
|
|
155
|
+
];
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import type { ExtensionAPI } from '@oh-my-pi/pi-coding-agent';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Runtime edit-variant pin fallback for omp >= 18.0.11.
|
|
8
|
+
*
|
|
9
|
+
* The host hard-maps deepseek-v4-flash to the sloppy §/» marker grammar (it
|
|
10
|
+
* misreads hashline ranges), but the model repeatedly breaks sloppy control
|
|
11
|
+
* lines too — retry loops burn tokens and abort agents. Point it at the
|
|
12
|
+
* plain old_string/new_string "replace" variant instead.
|
|
13
|
+
*
|
|
14
|
+
* Up to 17.x this extension did that via
|
|
15
|
+
* `settings.override("edit.modelVariants", …)`. omp 18.0.11 dropped
|
|
16
|
+
* `edit.modelVariants` from the settings schema, so `override()` throws
|
|
17
|
+
* inside `get()`: the path-segment table has no entry for it and `getByPath`
|
|
18
|
+
* iterates `undefined`. The host still honors the key when it arrives through
|
|
19
|
+
* the global config file — `#getEditVariantEntries` reads the merged view
|
|
20
|
+
* directly and `resolveEditMode` prefers the per-model variant over
|
|
21
|
+
* `edit.mode` — so persisting once is functionally equivalent to the runtime
|
|
22
|
+
* pin.
|
|
23
|
+
*
|
|
24
|
+
* Idempotent and conservative: any pre-existing `modelVariants` key (user
|
|
25
|
+
* configured, or a previous run) leaves the file untouched. The write is
|
|
26
|
+
* atomic (temp file + rename) and never parses or re-serializes the user's
|
|
27
|
+
* file, so comments and formatting survive.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export type EditVariantPinOutcome = 'written' | 'already-configured' | 'no-config-file';
|
|
31
|
+
|
|
32
|
+
const CONFIG_NAMES = ['config.yml', 'config.yaml'];
|
|
33
|
+
|
|
34
|
+
/** First existing global config file under `agentDir`, or undefined. */
|
|
35
|
+
export function resolveGlobalConfigPath(agentDir: string): string | undefined {
|
|
36
|
+
for (const name of CONFIG_NAMES) {
|
|
37
|
+
const candidate = path.join(agentDir, name);
|
|
38
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Insert `pin` under `edit.modelVariants` in `configPath`.
|
|
45
|
+
* Throws on I/O errors; otherwise returns the outcome.
|
|
46
|
+
*
|
|
47
|
+
* `pin` keys/values must be YAML-safe plain tokens (letters, digits, `.`, `_`,
|
|
48
|
+
* `-`) — the extension only ever passes `{ 'deepseek-v4-flash': 'replace' }`.
|
|
49
|
+
*/
|
|
50
|
+
export function persistEditVariantPin(
|
|
51
|
+
configPath: string,
|
|
52
|
+
pin: Record<string, string>,
|
|
53
|
+
): EditVariantPinOutcome {
|
|
54
|
+
const entries = Object.entries(pin).filter(([, variant]) => variant);
|
|
55
|
+
if (entries.length === 0) return 'already-configured';
|
|
56
|
+
|
|
57
|
+
let raw: string;
|
|
58
|
+
try {
|
|
59
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 'no-config-file';
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Idempotence guard: a `modelVariants` key anywhere in the file means the
|
|
66
|
+
// user (or an earlier run) already owns this setting. Leave it alone.
|
|
67
|
+
if (/^[ \t]*modelVariants\s*:/m.test(raw)) return 'already-configured';
|
|
68
|
+
|
|
69
|
+
const block =
|
|
70
|
+
` modelVariants:\n` +
|
|
71
|
+
entries.map(([pattern, variant]) => ` ${pattern}: ${variant}`).join('\n');
|
|
72
|
+
|
|
73
|
+
let next: string;
|
|
74
|
+
const editLine = /^edit:\s*(#.*)?$/m.exec(raw);
|
|
75
|
+
if (editLine) {
|
|
76
|
+
// Insert as the first child of the existing top-level `edit:` block.
|
|
77
|
+
// `editLine[0]` spans to end of line, so the slice keeps the rest of
|
|
78
|
+
// the block (including its newline) intact.
|
|
79
|
+
const insertAt = editLine.index + editLine[0].length;
|
|
80
|
+
next = raw.slice(0, insertAt) + '\n' + block + raw.slice(insertAt);
|
|
81
|
+
} else {
|
|
82
|
+
// No edit block: append one, starting on a fresh line.
|
|
83
|
+
const needsGap = raw.length > 0 && !raw.endsWith('\n');
|
|
84
|
+
next = (needsGap ? raw + '\n' : raw) + 'edit:\n' + block + '\n';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tmpPath = `${configPath}.tmp-${process.pid}`;
|
|
88
|
+
try {
|
|
89
|
+
const mode = fs.statSync(configPath).mode;
|
|
90
|
+
fs.writeFileSync(tmpPath, next, 'utf8');
|
|
91
|
+
fs.chmodSync(tmpPath, mode);
|
|
92
|
+
fs.renameSync(tmpPath, configPath);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
try {
|
|
95
|
+
fs.unlinkSync(tmpPath);
|
|
96
|
+
} catch {
|
|
97
|
+
/* tmp may not exist */
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
return 'written';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pin the edit variant for the target models.
|
|
106
|
+
*
|
|
107
|
+
* Preferred path: in-memory `settings.override` (omp < 18.0.11). When that
|
|
108
|
+
* throws (18.0.11 removed the schema path), persist into the global
|
|
109
|
+
* config.yml once — idempotent, effective from the next session.
|
|
110
|
+
*/
|
|
111
|
+
export function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string>): void {
|
|
112
|
+
// `pi.pi` is the whole SDK module namespace; its `settings` field has no
|
|
113
|
+
// declared runtime instance type on the extension-facing surface, so
|
|
114
|
+
// narrow to the two methods we call (the same shape the host's Settings
|
|
115
|
+
// class exposes publicly).
|
|
116
|
+
const settings = pi.pi?.settings as unknown as
|
|
117
|
+
| { override(path: string, value: unknown): void; getAgentDir?: () => string }
|
|
118
|
+
| undefined;
|
|
119
|
+
if (!settings) {
|
|
120
|
+
console.warn('[omp-opsx-addon] pi.pi.settings unavailable; edit variant pin skipped');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
settings.override('edit.modelVariants', pin);
|
|
126
|
+
return;
|
|
127
|
+
} catch {
|
|
128
|
+
/* settings schema no longer knows this path — persist instead */
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
let agentDir: string;
|
|
133
|
+
try {
|
|
134
|
+
agentDir = settings.getAgentDir?.() ?? '';
|
|
135
|
+
} catch {
|
|
136
|
+
agentDir = '';
|
|
137
|
+
}
|
|
138
|
+
if (!agentDir) agentDir = path.join(os.homedir(), '.omp', 'agent');
|
|
139
|
+
|
|
140
|
+
const configPath = resolveGlobalConfigPath(agentDir);
|
|
141
|
+
if (!configPath) {
|
|
142
|
+
console.warn(
|
|
143
|
+
`[omp-opsx-addon] edit variant pin failed: no global config.yml found under ${agentDir}`,
|
|
144
|
+
);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const outcome = persistEditVariantPin(configPath, pin);
|
|
148
|
+
if (outcome === 'written') {
|
|
149
|
+
console.warn(
|
|
150
|
+
`[omp-opsx-addon] edit variant pin persisted to ${configPath} (effective from the next session)`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.warn('[omp-opsx-addon] edit variant pin failed:', err);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, unit-testable error-scan helpers for the 429/403 diagnostic path.
|
|
3
|
+
*
|
|
4
|
+
* `hasRateLimitError` / `hasRegionError` detect the two fault classes from an
|
|
5
|
+
* HTTP response or a tool result's text; `exclusionReasonStats` explains why a
|
|
6
|
+
* selector candidate set came up empty; `buildErrorDiagnosis` assembles the
|
|
7
|
+
* user-facing guidance message when a provider faults. The plugin never
|
|
8
|
+
* auto-switches on a fault — it refreshes the provider usage and suggests
|
|
9
|
+
* manual steps, so the helper surface is purely read-only.
|
|
10
|
+
*
|
|
11
|
+
* Policy decisions (see the fix contract):
|
|
12
|
+
* - 403 is a *provider-level* regional restriction — the diagnostic suggests
|
|
13
|
+
* switching provider, and no model is silently blocked.
|
|
14
|
+
* - Bare `'region'` is NOT a 403 signal ("regional pricing", `config.region`,
|
|
15
|
+
* "in your region" inside a benign message, etc.).
|
|
16
|
+
*/
|
|
17
|
+
import type { ProviderHealth } from './usage-resolver.js';
|
|
18
|
+
|
|
19
|
+
/** True when the text signals an HTTP 429 / rate-limit condition. */
|
|
20
|
+
export function hasRateLimitError(text: string): boolean {
|
|
21
|
+
const t = text.toLowerCase();
|
|
22
|
+
return /\b429\b/.test(t) ||
|
|
23
|
+
t.includes('rate limit') ||
|
|
24
|
+
t.includes('too many requests');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when the text signals an HTTP 403 / region-blocked condition. */
|
|
28
|
+
export function hasRegionError(text: string): boolean {
|
|
29
|
+
const t = text.toLowerCase();
|
|
30
|
+
return /\b403\b/.test(t) ||
|
|
31
|
+
t.includes('not available in your region') ||
|
|
32
|
+
t.includes('access denied') ||
|
|
33
|
+
t.includes('forbidden');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reasons why a candidate set ends up empty, for the `/pick-model` error
|
|
38
|
+
* message. Counts how many candidate models are shadowed by a 403 region
|
|
39
|
+
* block (with up to `maxBlockedExamples` examples) and how many distinct
|
|
40
|
+
* providers among the candidates are marked unreachable.
|
|
41
|
+
*/
|
|
42
|
+
export function exclusionReasonStats(
|
|
43
|
+
models: readonly { provider: string; id: string }[],
|
|
44
|
+
regionBlockedModels: ReadonlySet<string>,
|
|
45
|
+
unreachableProviders: ReadonlySet<string>,
|
|
46
|
+
maxBlockedExamples = 5,
|
|
47
|
+
): { blockedCount: number; blockedExamples: string[]; unreachableProviderCount: number } {
|
|
48
|
+
let blockedCount = 0;
|
|
49
|
+
const blockedExamples: string[] = [];
|
|
50
|
+
const unreachableProv = new Set<string>();
|
|
51
|
+
for (const m of models) {
|
|
52
|
+
const selector = `${m.provider}/${m.id}`;
|
|
53
|
+
if (regionBlockedModels.has(selector)) {
|
|
54
|
+
blockedCount++;
|
|
55
|
+
if (blockedExamples.length < maxBlockedExamples) blockedExamples.push(selector);
|
|
56
|
+
}
|
|
57
|
+
if (unreachableProviders.has(m.provider)) unreachableProv.add(m.provider);
|
|
58
|
+
}
|
|
59
|
+
return { blockedCount, blockedExamples, unreachableProviderCount: unreachableProv.size };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Assemble the user-facing guidance message emitted after a 429/403 fault.
|
|
63
|
+
* The plugin no longer auto-switches: it refreshes the provider usage and tells
|
|
64
|
+
* the user how to proceed manually. Suggestion enumeration is process-local (the
|
|
65
|
+
* live health map), never persisted. Unknown health degrades to the "state
|
|
66
|
+
* unknown" wording and a generic fallback suggestion.
|
|
67
|
+
*/
|
|
68
|
+
export function buildErrorDiagnosis(
|
|
69
|
+
errorType: '429' | '403',
|
|
70
|
+
source: string,
|
|
71
|
+
provider: string,
|
|
72
|
+
health: Map<string, ProviderHealth> | null,
|
|
73
|
+
): string {
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
lines.push(`⚠️ 检测到 ${errorType} 错误(来源: ${source})。插件已停止自动切换模型,请手动选择。`);
|
|
76
|
+
if (health?.get(provider)?.exhausted) {
|
|
77
|
+
lines.push(`诊断: ${provider} 额度已耗尽,当前不可用`);
|
|
78
|
+
const candidates = health
|
|
79
|
+
? [...health.keys()].filter((p) => p !== provider && !(health.get(p)?.exhausted))
|
|
80
|
+
: [];
|
|
81
|
+
const top = candidates.slice(0, 2);
|
|
82
|
+
lines.push(
|
|
83
|
+
top.length > 0
|
|
84
|
+
? `建议: /pick-model refresh 重选,或 ${top.map((p) => `/pick-model ${p}`).join('、')} 换用其他 provider`
|
|
85
|
+
: '建议: /pick-model refresh 重选,或换用其他 provider',
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
lines.push(`诊断: ${provider} 额度正常(或状态未知),错误可能为瞬时/单模型问题`);
|
|
89
|
+
lines.push(
|
|
90
|
+
`建议: /pick-model ${provider} 切换同 provider 其他模型(如 /pick-model ${provider}/*),或 /pick-model refresh 重选`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (errorType === '403') {
|
|
94
|
+
lines.push('该模型可能受区域限制,建议更换 provider');
|
|
95
|
+
}
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selector-based model filtering for `/pick-model <selector> [--china]`.
|
|
3
|
+
*
|
|
4
|
+
* A selector is matched with a three-level grammar (see `matchesSelector`):
|
|
5
|
+
* a bare word matches by token prefix (the loose "family" intent), a
|
|
6
|
+
* single-segment glob matches a full string, and a two-segment `provider/id`
|
|
7
|
+
* glob requires both sides. `--china` always uses the bare-word (token-prefix)
|
|
8
|
+
* semantics regardless of selector shape.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Lowercase and split on any run of non-alphanumeric characters into tokens. */
|
|
12
|
+
export function normalizeTokens(s: string): string[] {
|
|
13
|
+
return s
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.split(/[^a-z0-9]+/)
|
|
16
|
+
.filter((t) => t.length > 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* True when `word` is a token-prefix of `provider` or `id` (either side
|
|
21
|
+
* matching is enough). This is the bare-word (level-1) semantics: `glm` hits
|
|
22
|
+
* the id token of `zhipu-coding-plan/glm-5.3`, and `openai` hits the provider
|
|
23
|
+
* token of `openai/o4-mini` even though its id has no `gpt` token. A glob
|
|
24
|
+
* string passed here is tokenized the same way (non-alphanumerics split it,
|
|
25
|
+
* `*` is dropped), never dispatched through the glob grammar.
|
|
26
|
+
*/
|
|
27
|
+
export function matchesTokenPrefix(provider: string, id: string, word: string): boolean {
|
|
28
|
+
const wTokens = normalizeTokens(word);
|
|
29
|
+
const providerTokens = normalizeTokens(provider);
|
|
30
|
+
const idTokens = normalizeTokens(id);
|
|
31
|
+
return wTokens.some((w) =>
|
|
32
|
+
providerTokens.some((t) => t.startsWith(w)) || idTokens.some((t) => t.startsWith(w)),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Glob → full-string anchored regex. Only `*` is a wildcard (→ `.*`); every
|
|
38
|
+
* other character (including `?`, `.`, `-`) is a literal. Local reimplementation
|
|
39
|
+
* of `model-selector.ts` globToRegex with `?` kept literal (no wildcard need in
|
|
40
|
+
* provider/id selectors) — do not import across modules.
|
|
41
|
+
*/
|
|
42
|
+
function globToFullStringRegex(segment: string): RegExp {
|
|
43
|
+
const escaped = segment
|
|
44
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
45
|
+
.replace(/\?/g, '\\?')
|
|
46
|
+
.replace(/\*/g, '.*');
|
|
47
|
+
return new RegExp(`^${escaped}$`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when `selector` matches `provider/id` under the three-level grammar
|
|
52
|
+
* (all matching is case-insensitive):
|
|
53
|
+
*
|
|
54
|
+
* 1. Bare word (no `*`, no `/`): token-prefix OR of provider/id (legacy family).
|
|
55
|
+
* 2. Single-segment glob (no `/`, has `*`): full-string anchored glob, provider
|
|
56
|
+
* full-string OR id full-string.
|
|
57
|
+
* 3. Two-segment (contains `/`, split on first): each side is a level-2 glob on
|
|
58
|
+
* the provider/id full string, AND; an empty side is equivalent to `*`.
|
|
59
|
+
*/
|
|
60
|
+
export function matchesSelector(provider: string, id: string, selector: string): boolean {
|
|
61
|
+
const sel = selector.toLowerCase();
|
|
62
|
+
const hasStar = sel.includes('*');
|
|
63
|
+
const hasSlash = sel.includes('/');
|
|
64
|
+
|
|
65
|
+
if (!hasStar && !hasSlash) {
|
|
66
|
+
return matchesTokenPrefix(provider, id, sel);
|
|
67
|
+
}
|
|
68
|
+
if (!hasSlash) {
|
|
69
|
+
const re = globToFullStringRegex(sel);
|
|
70
|
+
return re.test(provider.toLowerCase()) || re.test(id.toLowerCase());
|
|
71
|
+
}
|
|
72
|
+
const slash = sel.indexOf('/');
|
|
73
|
+
const pSeg = sel.slice(0, slash);
|
|
74
|
+
const iSeg = sel.slice(slash + 1);
|
|
75
|
+
const pRe = pSeg === '' ? /^.*$/ : globToFullStringRegex(pSeg);
|
|
76
|
+
const iRe = iSeg === '' ? /^.*$/ : globToFullStringRegex(iSeg);
|
|
77
|
+
return pRe.test(provider.toLowerCase()) && iRe.test(id.toLowerCase());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Families excluded by `--china`. Provider or id token hit ⇒ drop. `openai` /
|
|
82
|
+
* `anthropic` are listed separately from `gpt`/`claude` so o-series models
|
|
83
|
+
* (e.g. `openai/o4-mini`) and non-claude-prefixed anthropic models are dropped
|
|
84
|
+
* via their provider token, not just the id prefix. `--china` always evaluates
|
|
85
|
+
* these with the bare-word (token-prefix) semantics, never the glob grammar.
|
|
86
|
+
*/
|
|
87
|
+
export const CHINA_EXCLUDE_FAMILIES = ['gpt', 'claude', 'openai', 'anthropic'];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Enumerate the family words present in a model pool (for the error-path help
|
|
91
|
+
* list). Includes each provider and the leading id token so every returned name
|
|
92
|
+
* is a valid family (i.e. it prefix-matches at least one available model).
|
|
93
|
+
*/
|
|
94
|
+
export function listFamilies(models: readonly { provider: string; id: string }[]): string[] {
|
|
95
|
+
const families = new Set<string>();
|
|
96
|
+
for (const m of models) {
|
|
97
|
+
families.add(m.provider.toLowerCase());
|
|
98
|
+
const idTokens = normalizeTokens(m.id);
|
|
99
|
+
if (idTokens.length > 0) families.add(idTokens[0]);
|
|
100
|
+
}
|
|
101
|
+
return [...families].sort();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Count models per provider (lowercased). Used by the error path to report the
|
|
106
|
+
* provider dimension — the information a bare selector alone cannot convey —
|
|
107
|
+
* displayed in descending model count. Only providers still in the pool are
|
|
108
|
+
* counted; order is undefined (the caller sorts for display).
|
|
109
|
+
*/
|
|
110
|
+
export function listProviders(models: readonly { provider: string; id: string }[]): Map<string, number> {
|
|
111
|
+
const counts = new Map<string, number>();
|
|
112
|
+
for (const m of models) {
|
|
113
|
+
const p = m.provider.toLowerCase();
|
|
114
|
+
counts.set(p, (counts.get(p) ?? 0) + 1);
|
|
115
|
+
}
|
|
116
|
+
return counts;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const RESERVED_SUBCOMMANDS: Record<string, AutoModelParseResult> = {
|
|
120
|
+
choices: { kind: 'choices' },
|
|
121
|
+
u: { kind: 'update' },
|
|
122
|
+
update: { kind: 'update' },
|
|
123
|
+
r: { kind: 'refresh' },
|
|
124
|
+
refresh: { kind: 'refresh' },
|
|
125
|
+
reset: { kind: 'reset' },
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export type AutoModelParseResult =
|
|
129
|
+
| { kind: 'choices' }
|
|
130
|
+
| { kind: 'update' }
|
|
131
|
+
| { kind: 'refresh' }
|
|
132
|
+
| { kind: 'reset' }
|
|
133
|
+
| { kind: 'selector'; selectors: string[] | null; china: boolean }
|
|
134
|
+
| { kind: 'error'; message: string };
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse `/pick-model` arguments.
|
|
138
|
+
*
|
|
139
|
+
* - Reserved subcommands (choices/u/update/r/refresh/reset) take priority ONLY when the
|
|
140
|
+
* whole input is a single token; mixing them with a selector or flag is a
|
|
141
|
+
* usage error rather than a silent ignore of the extra tokens.
|
|
142
|
+
* - `--china` is position-independent; selectors are OR-combined.
|
|
143
|
+
* - Selectors are only lowercased (the `*`/`/` structure is preserved, no token
|
|
144
|
+
* splitting) and split on whitespace — a selector containing `/` is kept whole.
|
|
145
|
+
* - Any unknown `-`/`--` flag is a usage error.
|
|
146
|
+
*/
|
|
147
|
+
export function parseAutoModelArgs(args: string): AutoModelParseResult {
|
|
148
|
+
const tokens = args
|
|
149
|
+
.trim()
|
|
150
|
+
.toLowerCase()
|
|
151
|
+
.split(/\s+/)
|
|
152
|
+
.filter((t) => t.length > 0);
|
|
153
|
+
if (tokens.length === 0) return { kind: 'choices' };
|
|
154
|
+
|
|
155
|
+
if (tokens.length === 1 && Object.hasOwn(RESERVED_SUBCOMMANDS, tokens[0])) {
|
|
156
|
+
return RESERVED_SUBCOMMANDS[tokens[0]];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const selectors: string[] = [];
|
|
160
|
+
let china = false;
|
|
161
|
+
for (const tok of tokens) {
|
|
162
|
+
if (tok === '--china') {
|
|
163
|
+
china = true;
|
|
164
|
+
} else if (tok.startsWith('-')) {
|
|
165
|
+
return { kind: 'error', message: `未知参数: ${tok}` };
|
|
166
|
+
} else if (Object.hasOwn(RESERVED_SUBCOMMANDS, tok)) {
|
|
167
|
+
return { kind: 'error', message: `保留字 "${tok}" 不能与 selector/flag 混用` };
|
|
168
|
+
} else {
|
|
169
|
+
selectors.push(tok);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { kind: 'selector', selectors: selectors.length > 0 ? [...new Set(selectors)] : null, china };
|
|
174
|
+
}
|