@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.
@@ -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
+ }