pi-multikey 1.2.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/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "pi-multikey",
3
+ "version": "1.2.0",
4
+ "description": "One pi provider backed by many API keys: automatic 429 rotation, per-request key leases for concurrent subagents, and a /multikey management TUI",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-extension",
9
+ "multikey",
10
+ "api-keys",
11
+ "rate-limit",
12
+ "429",
13
+ "provider",
14
+ "key-pool"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/kslamph/multikey.git"
20
+ },
21
+ "files": [
22
+ "index.ts",
23
+ "config.ts",
24
+ "presets.ts",
25
+ "probe.ts",
26
+ "pool.ts",
27
+ "stream.ts",
28
+ "manage.ts",
29
+ "tui.ts",
30
+ "README.md",
31
+ "README.zh.md",
32
+ "LICENSE"
33
+ ],
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-ai": "*",
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "@earendil-works/pi-tui": "*"
38
+ },
39
+ "pi": {
40
+ "extensions": [
41
+ "./index.ts"
42
+ ]
43
+ }
44
+ }
package/pool.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * KeyPool: per-pool API key state, lease acquisition, cooldowns, stats.
3
+ *
4
+ * Selection policy: among enabled keys not in cooldown, pick the one with the
5
+ * fewest in-flight requests, tie-broken by least-recently-used. This spreads
6
+ * concurrent subagents across different keys automatically.
7
+ */
8
+
9
+ import type { PoolConfig } from "./config.ts";
10
+ import { maskKey } from "./config.ts";
11
+
12
+ export type KeyOutcome = "ok" | "rate_limited" | "invalid" | "error";
13
+
14
+ export interface Lease {
15
+ key: string;
16
+ label: string;
17
+ acquiredAt: number;
18
+ }
19
+
20
+ interface KeyStat {
21
+ ok: number;
22
+ rateLimited: number;
23
+ invalid: number;
24
+ errors: number;
25
+ inflight: number;
26
+ cooldownUntil: number;
27
+ cooldownReason?: string;
28
+ lastUsed: number;
29
+ }
30
+
31
+ function newStat(): KeyStat {
32
+ return { ok: 0, rateLimited: 0, invalid: 0, errors: 0, inflight: 0, cooldownUntil: 0, lastUsed: 0 };
33
+ }
34
+
35
+ export class KeyPool {
36
+ config: PoolConfig;
37
+ private stats = new Map<string, KeyStat>();
38
+
39
+ constructor(config: PoolConfig) {
40
+ this.config = config;
41
+ }
42
+
43
+ private stat(key: string): KeyStat {
44
+ let s = this.stats.get(key);
45
+ if (!s) {
46
+ s = newStat();
47
+ this.stats.set(key, s);
48
+ }
49
+ return s;
50
+ }
51
+
52
+ private enabledKeys(): { key: string; label: string }[] {
53
+ return this.config.keys
54
+ .filter((k) => k.enabled !== false)
55
+ .map((k) => ({ key: k.key, label: k.label ?? maskKey(k.key) }));
56
+ }
57
+
58
+ get size(): number {
59
+ return this.enabledKeys().length;
60
+ }
61
+
62
+ mask(key: string): string {
63
+ return maskKey(key);
64
+ }
65
+
66
+ /** Replace config in place (keeps live stats). */
67
+ updateConfig(config: PoolConfig): void {
68
+ Object.assign(this.config, config);
69
+ }
70
+
71
+ /**
72
+ * Acquire a key lease. If every key is in cooldown, waits (signal-aware)
73
+ * until the earliest cooldown expires, then acquires that key.
74
+ */
75
+ async acquire(signal?: AbortSignal): Promise<Lease> {
76
+ for (;;) {
77
+ if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
78
+ const now = Date.now();
79
+ const candidates = this.enabledKeys()
80
+ .map((entry) => ({ ...entry, stat: this.stat(entry.key) }))
81
+ .filter((entry) => entry.stat.cooldownUntil <= now);
82
+
83
+ if (candidates.length > 0) {
84
+ candidates.sort((a, b) => a.stat.inflight - b.stat.inflight || a.stat.lastUsed - b.stat.lastUsed);
85
+ const chosen = candidates[0]!;
86
+ chosen.stat.inflight++;
87
+ chosen.stat.lastUsed = now;
88
+ return { key: chosen.key, label: chosen.label, acquiredAt: now };
89
+ }
90
+
91
+ // All keys cooling: wait for the earliest recovery (bounded to 60s).
92
+ const cooldowns = this.enabledKeys().map((entry) => this.stat(entry.key).cooldownUntil);
93
+ const earliest = Math.min(...cooldowns);
94
+ const waitMs = Math.max(250, Math.min(earliest - now, 60_000));
95
+ await this.sleep(waitMs, signal);
96
+ }
97
+ }
98
+
99
+ release(lease: Lease): void {
100
+ const s = this.stat(lease.key);
101
+ s.inflight = Math.max(0, s.inflight - 1);
102
+ }
103
+
104
+ report(lease: Lease, outcome: KeyOutcome, cooldownMs?: number): void {
105
+ const s = this.stat(lease.key);
106
+ const now = Date.now();
107
+ switch (outcome) {
108
+ case "ok":
109
+ s.ok++;
110
+ s.cooldownUntil = 0;
111
+ s.cooldownReason = undefined;
112
+ break;
113
+ case "rate_limited":
114
+ s.rateLimited++;
115
+ s.cooldownUntil = Math.max(s.cooldownUntil, now + (cooldownMs ?? this.config.cooldownMs ?? 20_000));
116
+ s.cooldownReason = "429";
117
+ break;
118
+ case "invalid":
119
+ s.invalid++;
120
+ s.cooldownUntil = Math.max(s.cooldownUntil, now + (this.config.invalidKeyCooldownMs ?? 600_000));
121
+ s.cooldownReason = "invalid";
122
+ break;
123
+ case "error":
124
+ s.errors++;
125
+ break;
126
+ }
127
+ }
128
+
129
+ private sleep(ms: number, signal?: AbortSignal): Promise<void> {
130
+ return new Promise((resolve, reject) => {
131
+ const timer = setTimeout(() => {
132
+ signal?.removeEventListener("abort", onAbort);
133
+ resolve();
134
+ }, ms);
135
+ const onAbort = () => {
136
+ clearTimeout(timer);
137
+ reject(new DOMException("Aborted", "AbortError"));
138
+ };
139
+ if (signal) {
140
+ if (signal.aborted) {
141
+ clearTimeout(timer);
142
+ reject(new DOMException("Aborted", "AbortError"));
143
+ return;
144
+ }
145
+ signal.addEventListener("abort", onAbort, { once: true });
146
+ }
147
+ });
148
+ }
149
+
150
+ /** One row per configured key, for TUI status display. */
151
+ statusRows(): {
152
+ label: string;
153
+ masked: string;
154
+ enabled: boolean;
155
+ inflight: number;
156
+ ok: number;
157
+ rateLimited: number;
158
+ invalid: number;
159
+ errors: number;
160
+ cooldownRemainingMs: number;
161
+ cooldownReason?: string;
162
+ }[] {
163
+ const now = Date.now();
164
+ return this.config.keys.map((k) => {
165
+ const s = this.stat(k.key);
166
+ return {
167
+ label: k.label ?? maskKey(k.key),
168
+ masked: maskKey(k.key),
169
+ enabled: k.enabled !== false,
170
+ inflight: s.inflight,
171
+ ok: s.ok,
172
+ rateLimited: s.rateLimited,
173
+ invalid: s.invalid,
174
+ errors: s.errors,
175
+ cooldownRemainingMs: Math.max(0, s.cooldownUntil - now),
176
+ cooldownReason: s.cooldownReason,
177
+ };
178
+ });
179
+ }
180
+ }
package/presets.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Built-in provider presets.
3
+ *
4
+ * A preset carries everything except credentials, so adding a provider is just
5
+ * "paste your keys". Model specs here are verified against provider docs and
6
+ * live probes — see README "Presets" for the evidence trail.
7
+ */
8
+
9
+ import type { PoolConfig, PoolModelConfig } from "./config.ts";
10
+
11
+ export interface Preset {
12
+ /** Stable id used in the config file's `_preset` marker. */
13
+ id: string;
14
+ name: string;
15
+ description: string;
16
+ /** Suggested pi provider id (user can override). */
17
+ defaultPoolId: string;
18
+ baseUrl: string;
19
+ api: string;
20
+ compat?: Record<string, unknown>;
21
+ /** Where to create/copy API keys. */
22
+ keyHint?: string;
23
+ models: PoolModelConfig[];
24
+ }
25
+
26
+ const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
27
+
28
+ /** Build a thinkingLevelMap that always writes every level (null = hidden in the UI). */
29
+ function levels(
30
+ supported: Partial<Record<(typeof LEVELS)[number], string | null>>,
31
+ ): Record<string, string | null> {
32
+ const map: Record<string, string | null> = {};
33
+ for (const level of LEVELS) {
34
+ map[level] = level in supported ? (supported[level] ?? null) : null;
35
+ }
36
+ return map;
37
+ }
38
+
39
+ const BAI_COMPAT = {
40
+ supportsDeveloperRole: false,
41
+ thinkingFormat: "deepseek",
42
+ requiresReasoningContentOnAssistantMessages: true,
43
+ supportsReasoningEffort: true,
44
+ };
45
+
46
+ export const PRESETS: Preset[] = [
47
+ {
48
+ id: "b-ai",
49
+ name: "B.AI",
50
+ description: "api.b.ai — DeepSeek V4 Flash (+vision), Hunyuan Hy3, MiMo V2.5, Qwen3.8, GLM 5.3 (6 models)",
51
+ defaultPoolId: "bai",
52
+ baseUrl: "https://api.b.ai/v1",
53
+ api: "openai-completions",
54
+ compat: BAI_COMPAT,
55
+ keyHint: "https://www.b.ai/ → API Keys (one entry per key; multiple keys share the load)",
56
+ models: [
57
+ {
58
+ // docs.b.ai/llmservice/models/deepseek-v4-flash + api-docs.deepseek.com/guides/thinking_mode
59
+ // Native effort tiers: low/high/max (medium & xhigh alias to high server-side).
60
+ id: "deepseek-v4-flash",
61
+ name: "DeepSeek V4 Flash",
62
+ reasoning: true,
63
+ input: ["text"],
64
+ contextWindow: 1_000_000,
65
+ maxTokens: 384_000,
66
+ thinkingLevelMap: levels({ off: "none", low: "low", high: "high", max: "max" }),
67
+ },
68
+ {
69
+ // No public card; experimental vision build on the same V4 Flash backbone.
70
+ id: "deepseek-v4-flash-vision-exp",
71
+ name: "DeepSeek V4 Flash Vision (exp)",
72
+ reasoning: true,
73
+ input: ["text", "image"],
74
+ contextWindow: 1_000_000,
75
+ maxTokens: 384_000,
76
+ thinkingLevelMap: levels({ off: "none", low: "low", high: "high", max: "max" }),
77
+ },
78
+ {
79
+ // docs.b.ai/llmservice/models/hy3 — Tencent modes: no_think / think_low / think_high.
80
+ id: "hy3",
81
+ name: "Hunyuan Hy3",
82
+ reasoning: true,
83
+ input: ["text"],
84
+ contextWindow: 262_144,
85
+ maxTokens: 131_072,
86
+ thinkingLevelMap: levels({ off: "none", low: "low", high: "high" }),
87
+ },
88
+ {
89
+ // docs.b.ai/llmservice/models/mimo-v2.5 + Xiaomi official: only `none` disables
90
+ // thinking; low/medium/high are accepted but behave identically. b.ai rejects
91
+ // minimal/xhigh/max with HTTP 400.
92
+ id: "mimo-v2.5",
93
+ name: "Xiaomi MiMo V2.5",
94
+ reasoning: true,
95
+ input: ["text", "image"],
96
+ contextWindow: 1_000_000,
97
+ maxTokens: 131_072,
98
+ thinkingLevelMap: levels({ off: "none", high: "high" }),
99
+ },
100
+ {
101
+ // docs.b.ai/llmservice/models/qwen3-8-flash — tiers offered: off/low/medium/xhigh.
102
+ id: "qwen3.8-flash",
103
+ name: "Qwen3.8 Flash",
104
+ reasoning: true,
105
+ input: ["text", "image"],
106
+ contextWindow: 1_000_000,
107
+ maxTokens: 131_072,
108
+ thinkingLevelMap: levels({ off: "none", low: "low", medium: "medium", xhigh: "xhigh" }),
109
+ },
110
+ {
111
+ // docs.b.ai/llmservice/models/glm-5-3-flash — always thinks (off unsupported);
112
+ // reasoning_effort: low/high/max, default max.
113
+ id: "glm-5.3-flash",
114
+ name: "GLM 5.3 Flash",
115
+ reasoning: true,
116
+ input: ["text", "image"],
117
+ contextWindow: 1_000_000,
118
+ maxTokens: 131_072,
119
+ thinkingLevelMap: levels({ low: "low", high: "high", max: "max" }),
120
+ },
121
+ ],
122
+ },
123
+ ];
124
+
125
+ export function findPreset(id: string): Preset | undefined {
126
+ return PRESETS.find((p) => p.id === id);
127
+ }
128
+
129
+ /** Materialize a preset into a pool config with the given keys. */
130
+ export function poolFromPreset(preset: Preset, poolId: string, keys: string[]): PoolConfig {
131
+ return {
132
+ id: poolId,
133
+ name: `${preset.name} (Key Pool)`,
134
+ baseUrl: preset.baseUrl,
135
+ api: preset.api,
136
+ compat: preset.compat ? { ...preset.compat } : undefined,
137
+ cooldownMs: 20_000,
138
+ invalidKeyCooldownMs: 600_000,
139
+ keys: keys.map((key, i) => ({ key, label: `key-${i + 1}`, enabled: true })),
140
+ // Deep copy so per-pool edits never mutate the shipped preset.
141
+ models: JSON.parse(JSON.stringify(preset.models)) as PoolModelConfig[],
142
+ };
143
+ }
package/probe.ts ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Endpoint probing: auto-detect the auth header style and fetch the model list.
3
+ *
4
+ * Strategy (no questions asked):
5
+ * 1. GET <baseUrl>/models with `Authorization: Bearer <key>` — if 200, we have
6
+ * the model list. If 401/403, retry with `x-api-key: <key>`.
7
+ * 2. /models is public on some gateways, so a 200 there doesn't prove the key
8
+ * works. We therefore verify auth with a tiny 1-token chat request using
9
+ * the detected style; 401/403 there rotates to the other style.
10
+ * 3. If even the chat probe accepts a bogus key, the endpoint simply doesn't
11
+ * check keys — we proceed with the default (bearer) and say so.
12
+ *
13
+ * Everything degrades gracefully: probe failures never block pool creation,
14
+ * they only downgrade to manual model entry.
15
+ */
16
+
17
+ export type AuthStyle = "bearer" | "api-key";
18
+
19
+ export interface RemoteModel {
20
+ id: string;
21
+ name?: string;
22
+ contextWindow?: number;
23
+ maxTokens?: number;
24
+ input?: ("text" | "image")[];
25
+ reasoning?: boolean;
26
+ }
27
+
28
+ export interface ProbeResult {
29
+ /** True when a model list was fetched (even from an open endpoint). */
30
+ ok: boolean;
31
+ /** Auth header style that verified (or the safe default when unverifiable). */
32
+ auth: AuthStyle;
33
+ /** "confirmed" = chat probe proved it · "unverified" = endpoint open / unreachable · "rejected" = both styles got 401/403. */
34
+ authStatus: "confirmed" | "unverified" | "rejected";
35
+ modelsUrl?: string;
36
+ models?: RemoteModel[];
37
+ /** Human-readable attempt log for transparency in the TUI. */
38
+ log: string[];
39
+ }
40
+
41
+ const MODELS_TIMEOUT_MS = 12_000;
42
+ const CHAT_TIMEOUT_MS = 25_000;
43
+
44
+ function trimSlash(url: string): string {
45
+ return url.replace(/\/+$/, "");
46
+ }
47
+
48
+ /** Candidate /models URLs: <base>/models, and <base>/v1/models when the base doesn't already end in a version segment. */
49
+ export function modelsUrlCandidates(baseUrl: string): string[] {
50
+ const base = trimSlash(baseUrl);
51
+ const candidates = [`${base}/models`];
52
+ if (!/\/v\d+(?:beta|preview)?$/.test(base)) candidates.push(`${base}/v1/models`);
53
+ return [...new Set(candidates)];
54
+ }
55
+
56
+ function authHeaders(style: AuthStyle, key: string): Record<string, string> {
57
+ return style === "bearer" ? { Authorization: `Bearer ${key}` } : { "x-api-key": key };
58
+ }
59
+
60
+ async function fetchJson(url: string, headers: Record<string, string>, timeoutMs: number): Promise<{ status: number; body?: unknown }> {
61
+ try {
62
+ const response = await fetch(url, {
63
+ method: "GET",
64
+ headers: { Accept: "application/json", ...headers },
65
+ signal: AbortSignal.timeout(timeoutMs),
66
+ });
67
+ let body: unknown;
68
+ try {
69
+ body = await response.json();
70
+ } catch {
71
+ // Non-JSON body (HTML error page etc.) — status is still meaningful.
72
+ }
73
+ return { status: response.status, body };
74
+ } catch (error) {
75
+ throw new Error(error instanceof Error ? error.message : String(error));
76
+ }
77
+ }
78
+
79
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
80
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
81
+ }
82
+
83
+ function firstNumber(source: Record<string, unknown>, keys: string[]): number | undefined {
84
+ for (const key of keys) {
85
+ const value = source[key];
86
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ /** Extract model entries from the many /models response shapes (OpenAI `{data:[]}`, bare array, `{models:[]}`). */
92
+ export function parseModelsResponse(body: unknown): RemoteModel[] {
93
+ const root = asRecord(body);
94
+ const list = Array.isArray(body) ? body : (root?.data ?? root?.models);
95
+ if (!Array.isArray(list)) return [];
96
+
97
+ const models: RemoteModel[] = [];
98
+ for (const entry of list) {
99
+ if (typeof entry === "string") {
100
+ if (entry.trim()) models.push({ id: entry.trim() });
101
+ continue;
102
+ }
103
+ const record = asRecord(entry);
104
+ if (!record) continue;
105
+ const id = typeof record.id === "string" && record.id.trim()
106
+ ? record.id.trim()
107
+ : typeof record.model === "string" && record.model.trim()
108
+ ? record.model.trim()
109
+ : typeof record.name === "string" && record.name.trim()
110
+ ? record.name.trim()
111
+ : undefined;
112
+ if (!id) continue;
113
+
114
+ const model: RemoteModel = { id };
115
+ if (typeof record.name === "string" && record.name.trim() && record.name.trim() !== id) model.name = record.name.trim();
116
+ else if (typeof record.display_name === "string" && record.display_name.trim() && record.display_name.trim() !== id) model.name = record.display_name.trim();
117
+
118
+ const contextWindow = firstNumber(record, ["context_length", "context_window", "max_model_len", "max_context_length", "context_size"]);
119
+ if (contextWindow) model.contextWindow = contextWindow;
120
+
121
+ const maxTokens = firstNumber(record, ["max_output_tokens", "max_completion_tokens", "output_token_limit"]);
122
+ if (maxTokens) model.maxTokens = maxTokens;
123
+
124
+ const modalities = record.input_modalities ?? asRecord(record.architecture)?.input_modalities;
125
+ if (Array.isArray(modalities)) {
126
+ const input: ("text" | "image")[] = ["text"];
127
+ if (modalities.some((m) => typeof m === "string" && m.toLowerCase() === "image")) input.push("image");
128
+ model.input = input;
129
+ } else if (typeof modalities === "string" && modalities.toLowerCase() === "image") {
130
+ model.input = ["text", "image"];
131
+ }
132
+
133
+ const reasoningFlag = asRecord(record.reasoning)?.supported ?? record.reasoning_supported;
134
+ if (typeof reasoningFlag === "boolean") model.reasoning = reasoningFlag;
135
+
136
+ models.push(model);
137
+ }
138
+ return models;
139
+ }
140
+
141
+ /**
142
+ * Verify a key with a minimal chat completion (a few tokens at most). Returns
143
+ * "ok" when auth was accepted (2xx, or 4xx that clearly got past auth like a
144
+ * bad-model/params 400/404), "rejected" on 401/403, "error" on network trouble.
145
+ */
146
+ async function chatProbe(baseUrl: string, style: AuthStyle, key: string, modelId: string): Promise<"ok" | "rejected" | "error"> {
147
+ try {
148
+ const response = await fetch(`${trimSlash(baseUrl)}/chat/completions`, {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json", ...authHeaders(style, key) },
151
+ body: JSON.stringify({ model: modelId, max_tokens: 4, messages: [{ role: "user", content: "ping" }] }),
152
+ signal: AbortSignal.timeout(CHAT_TIMEOUT_MS),
153
+ });
154
+ if (response.status === 401 || response.status === 403) return "rejected";
155
+ return "ok"; // 2xx, or 4xx past auth (bad model / params) — auth itself worked.
156
+ } catch {
157
+ return "error";
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Probe an endpoint with the user's key: detect auth style, verify the key,
163
+ * and fetch the selectable model list. `chatModelId` (from the fetched list or
164
+ * a previously configured model) enables the chat auth verification.
165
+ */
166
+ export async function probeEndpoint(
167
+ baseUrl: string,
168
+ key: string,
169
+ options?: { chatModelId?: string; authHint?: AuthStyle; onLog?: (line: string) => void },
170
+ ): Promise<ProbeResult> {
171
+ const log: string[] = [];
172
+ const emit = (line: string) => {
173
+ log.push(line);
174
+ options?.onLog?.(line);
175
+ };
176
+ const styles: AuthStyle[] = options?.authHint === "api-key" ? ["api-key", "bearer"] : ["bearer", "api-key"];
177
+
178
+ // --- 1. Fetch the model list, rotating auth styles on 401/403. ---
179
+ let models: RemoteModel[] | undefined;
180
+ let modelsUrl: string | undefined;
181
+ let auth: AuthStyle = styles[0]!;
182
+ let listAuthRejected = true; // did /models itself reject every style?
183
+
184
+ for (const style of styles) {
185
+ for (const url of modelsUrlCandidates(baseUrl)) {
186
+ let result: { status: number; body?: unknown };
187
+ try {
188
+ emit(`GET ${url} (${style === "bearer" ? "Authorization: Bearer" : "x-api-key"})…`);
189
+ result = await fetchJson(url, authHeaders(style, key), MODELS_TIMEOUT_MS);
190
+ } catch (error) {
191
+ emit(` network error: ${error instanceof Error ? error.message : String(error)}`);
192
+ continue;
193
+ }
194
+ if (result.status === 200) {
195
+ const parsed = parseModelsResponse(result.body);
196
+ if (parsed.length > 0) {
197
+ models = parsed;
198
+ modelsUrl = url;
199
+ auth = style;
200
+ listAuthRejected = false;
201
+ emit(` 200 — ${parsed.length} model(s) listed`);
202
+ break;
203
+ }
204
+ emit(` 200 but no models recognized in response`);
205
+ continue;
206
+ }
207
+ emit(` HTTP ${result.status}`);
208
+ if (result.status !== 401 && result.status !== 403) continue; // 404 etc: try next URL form
209
+ }
210
+ if (models) break;
211
+ }
212
+
213
+ // --- 2. Verify auth with a tiny chat request (models lists can be public). ---
214
+ const chatModelId =
215
+ options?.chatModelId ?? (models && models.length > 0 ? models.find((m) => m.id.toLowerCase().includes("free"))?.id ?? models[0]!.id : undefined);
216
+ let authStatus: ProbeResult["authStatus"] = "unverified";
217
+
218
+ if (chatModelId) {
219
+ let verified: AuthStyle | undefined;
220
+ for (const style of styles) {
221
+ emit(`auth check: 1-token chat on "${chatModelId}" with ${style === "bearer" ? "Bearer" : "x-api-key"}…`);
222
+ const verdict = await chatProbe(baseUrl, style, key, chatModelId);
223
+ if (verdict === "ok") {
224
+ verified = style;
225
+ emit(` accepted ✓ (style: ${style === "bearer" ? "Authorization: Bearer" : "x-api-key"})`);
226
+ break;
227
+ }
228
+ emit(` ${verdict === "rejected" ? "rejected (401/403)" : "network error"}`);
229
+ }
230
+
231
+ if (verified) {
232
+ auth = verified;
233
+ authStatus = "confirmed";
234
+ } else if (listAuthRejected) {
235
+ // /models also rejected the key with every style — likely a bad key.
236
+ authStatus = "rejected";
237
+ } else {
238
+ // Models listed fine but chat auth unverifiable: maybe the endpoint is open.
239
+ try {
240
+ emit(`auth check: same chat with a dummy key to detect open endpoints…`);
241
+ const bogus = await chatProbe(baseUrl, "bearer", "multikey-open-endpoint-check", chatModelId);
242
+ if (bogus === "ok") {
243
+ emit(` accepted — endpoint does not verify keys (open endpoint)`);
244
+ } else {
245
+ emit(` rejected — key was not verified; double-check it`);
246
+ authStatus = "rejected";
247
+ }
248
+ } catch {
249
+ authStatus = "unverified";
250
+ }
251
+ }
252
+ } else {
253
+ emit("no model id available — skipping chat auth check");
254
+ }
255
+
256
+ return {
257
+ ok: models !== undefined,
258
+ auth,
259
+ authStatus,
260
+ modelsUrl,
261
+ models,
262
+ log,
263
+ };
264
+ }