@phi-code-admin/phi-code 0.75.4 → 0.75.6
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/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/model-selector.js +3 -1
- package/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/extensions/phi/README.md +21 -1
- package/extensions/phi/init.ts +388 -376
- package/extensions/phi/models.ts +236 -0
- package/extensions/phi/providers/live-models.ts +493 -0
- package/extensions/phi/providers/opencode-go.ts +15 -10
- package/extensions/phi/setup.ts +102 -40
- package/extensions/phi/smart-router.ts +63 -19
- package/package.json +1 -1
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Models Registry - Unified runtime model fetch for every supported provider.
|
|
3
|
+
*
|
|
4
|
+
* Goals:
|
|
5
|
+
* - Always show the most up-to-date model list (live API call when reachable).
|
|
6
|
+
* - Survive offline / 401 / unknown errors via a versioned static fallback.
|
|
7
|
+
* - Share a single in-memory cache (TTL 1h) across phi-init, /setup, and /model.
|
|
8
|
+
* - Never throw — every public function returns a result object {models, source}.
|
|
9
|
+
*
|
|
10
|
+
* Each provider exposes a discovery endpoint (most are OpenAI-compatible
|
|
11
|
+
* `GET /v1/models`). For exceptions:
|
|
12
|
+
* - Anthropic uses `GET /v1/models` with `x-api-key` + `anthropic-version`.
|
|
13
|
+
* - Google uses `GET /v1beta/models?key=<key>`.
|
|
14
|
+
* - OpenRouter is reachable without a key.
|
|
15
|
+
* - Ollama / LM Studio expose `GET /v1/models` locally.
|
|
16
|
+
*
|
|
17
|
+
* Static fallbacks below are version-pinned to the date in `LAST_VERIFIED` and
|
|
18
|
+
* intentionally conservative (only models known to exist at that date). They
|
|
19
|
+
* are exposed so the wizard can show *something* before the first live fetch.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
OPENCODE_GO_FALLBACK_MODELS,
|
|
24
|
+
getOpenCodeGoModels,
|
|
25
|
+
pingOpenCodeGo,
|
|
26
|
+
} from "./opencode-go.js";
|
|
27
|
+
import { ALIBABA_MODELS, ALIBABA_PROVIDERS, pingAlibaba } from "./alibaba.js";
|
|
28
|
+
|
|
29
|
+
export const LAST_VERIFIED = "2026-05-15";
|
|
30
|
+
|
|
31
|
+
export interface LiveModel {
|
|
32
|
+
id: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
contextWindow?: number;
|
|
35
|
+
maxTokens?: number;
|
|
36
|
+
reasoning?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type LiveModelSource = "live" | "cache" | "fallback" | "unsupported";
|
|
40
|
+
|
|
41
|
+
export interface LiveModelsResult {
|
|
42
|
+
models: LiveModel[];
|
|
43
|
+
source: LiveModelSource;
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FetchOptions {
|
|
48
|
+
apiKey?: string;
|
|
49
|
+
forceRefresh?: boolean;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface CacheEntry {
|
|
54
|
+
models: LiveModel[];
|
|
55
|
+
fetchedAt: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
59
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
60
|
+
|
|
61
|
+
const cache = new Map<string, CacheEntry>();
|
|
62
|
+
const inflight = new Map<string, Promise<LiveModel[]>>();
|
|
63
|
+
|
|
64
|
+
function isCacheValid(entry: CacheEntry | undefined, now: number): boolean {
|
|
65
|
+
return entry !== undefined && now - entry.fetchedAt < CACHE_TTL_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function fetchJson(
|
|
69
|
+
url: string,
|
|
70
|
+
headers: Record<string, string>,
|
|
71
|
+
timeoutMs: number,
|
|
72
|
+
): Promise<unknown> {
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(url, { signal: controller.signal, headers });
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
throw new Error(`HTTP ${res.status}`);
|
|
79
|
+
}
|
|
80
|
+
return await res.json();
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── OpenAI-style fetch (used by openai/openrouter/groq/local) ───────────────
|
|
87
|
+
|
|
88
|
+
interface OpenAIModelsResponse {
|
|
89
|
+
data?: Array<{
|
|
90
|
+
id?: string;
|
|
91
|
+
name?: string;
|
|
92
|
+
context_length?: number;
|
|
93
|
+
context_window?: number;
|
|
94
|
+
max_tokens?: number;
|
|
95
|
+
top_provider?: { context_length?: number; max_completion_tokens?: number };
|
|
96
|
+
supported_parameters?: string[];
|
|
97
|
+
}>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mapOpenAIModels(data: OpenAIModelsResponse): LiveModel[] {
|
|
101
|
+
const items = data.data ?? [];
|
|
102
|
+
return items
|
|
103
|
+
.filter((m): m is { id: string } & typeof m => typeof m?.id === "string" && m.id.length > 0)
|
|
104
|
+
.map((m) => {
|
|
105
|
+
const ctx = m.context_length ?? m.context_window ?? m.top_provider?.context_length;
|
|
106
|
+
const maxOut = m.max_tokens ?? m.top_provider?.max_completion_tokens;
|
|
107
|
+
const reasoning = (m.supported_parameters ?? []).includes("reasoning");
|
|
108
|
+
return {
|
|
109
|
+
id: m.id,
|
|
110
|
+
name: m.name ?? m.id,
|
|
111
|
+
contextWindow: typeof ctx === "number" && ctx > 0 ? ctx : undefined,
|
|
112
|
+
maxTokens: typeof maxOut === "number" && maxOut > 0 ? maxOut : undefined,
|
|
113
|
+
reasoning,
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Anthropic fetch ─────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
interface AnthropicModelsResponse {
|
|
121
|
+
data?: Array<{ id?: string; display_name?: string; type?: string }>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function mapAnthropicModels(data: AnthropicModelsResponse): LiveModel[] {
|
|
125
|
+
const items = data.data ?? [];
|
|
126
|
+
return items
|
|
127
|
+
.filter((m): m is { id: string } & typeof m => typeof m?.id === "string" && m.id.length > 0)
|
|
128
|
+
.map((m) => ({
|
|
129
|
+
id: m.id,
|
|
130
|
+
name: m.display_name ?? m.id,
|
|
131
|
+
reasoning: true,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Google fetch ────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
interface GoogleModelsResponse {
|
|
138
|
+
models?: Array<{
|
|
139
|
+
name?: string;
|
|
140
|
+
displayName?: string;
|
|
141
|
+
inputTokenLimit?: number;
|
|
142
|
+
outputTokenLimit?: number;
|
|
143
|
+
supportedGenerationMethods?: string[];
|
|
144
|
+
}>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mapGoogleModels(data: GoogleModelsResponse): LiveModel[] {
|
|
148
|
+
const items = data.models ?? [];
|
|
149
|
+
return items
|
|
150
|
+
.filter((m): m is { name: string } & typeof m => typeof m?.name === "string")
|
|
151
|
+
.filter((m) => (m.supportedGenerationMethods ?? []).includes("generateContent"))
|
|
152
|
+
.map((m) => ({
|
|
153
|
+
id: m.name.replace(/^models\//, ""),
|
|
154
|
+
name: m.displayName ?? m.name,
|
|
155
|
+
contextWindow: m.inputTokenLimit,
|
|
156
|
+
maxTokens: m.outputTokenLimit,
|
|
157
|
+
reasoning: true,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Static fallbacks ────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const STATIC_OPENAI: LiveModel[] = [
|
|
164
|
+
{ id: "gpt-5.4", name: "GPT-5.4", contextWindow: 400_000, maxTokens: 128_000, reasoning: true },
|
|
165
|
+
{ id: "gpt-5", name: "GPT-5", contextWindow: 400_000, maxTokens: 128_000, reasoning: true },
|
|
166
|
+
{ id: "gpt-5-mini", name: "GPT-5 Mini", contextWindow: 400_000, maxTokens: 64_000, reasoning: true },
|
|
167
|
+
{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000, maxTokens: 16_384 },
|
|
168
|
+
{ id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128_000, maxTokens: 16_384 },
|
|
169
|
+
{ id: "o3", name: "o3", contextWindow: 200_000, maxTokens: 100_000, reasoning: true },
|
|
170
|
+
{ id: "o3-mini", name: "o3 Mini", contextWindow: 200_000, maxTokens: 100_000, reasoning: true },
|
|
171
|
+
{ id: "o1", name: "o1", contextWindow: 200_000, maxTokens: 100_000, reasoning: true },
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
const STATIC_ANTHROPIC: LiveModel[] = [
|
|
175
|
+
{ id: "claude-opus-4-7", name: "Claude Opus 4.7", reasoning: true },
|
|
176
|
+
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", reasoning: true },
|
|
177
|
+
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", reasoning: true },
|
|
178
|
+
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true },
|
|
179
|
+
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", reasoning: true },
|
|
180
|
+
{ id: "claude-3-5-haiku-20241022", name: "Claude 3.5 Haiku", reasoning: false },
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const STATIC_GOOGLE: LiveModel[] = [
|
|
184
|
+
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview", contextWindow: 2_000_000, reasoning: true },
|
|
185
|
+
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextWindow: 2_000_000, reasoning: true },
|
|
186
|
+
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", contextWindow: 1_000_000, reasoning: true },
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const STATIC_GROQ: LiveModel[] = [
|
|
190
|
+
{ id: "openai/gpt-oss-120b", name: "GPT-OSS 120B", contextWindow: 128_000 },
|
|
191
|
+
{ id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B", contextWindow: 128_000 },
|
|
192
|
+
{ id: "moonshotai/kimi-k2-instruct", name: "Kimi K2 Instruct", contextWindow: 128_000 },
|
|
193
|
+
{ id: "qwen/qwen3-32b", name: "Qwen 3 32B", contextWindow: 131_000 },
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
const STATIC_OPENROUTER: LiveModel[] = [
|
|
197
|
+
{ id: "anthropic/claude-opus-4-7", name: "Claude Opus 4.7", reasoning: true },
|
|
198
|
+
{ id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6", reasoning: true },
|
|
199
|
+
{ id: "openai/gpt-5.4", name: "GPT-5.4", reasoning: true },
|
|
200
|
+
{ id: "moonshotai/kimi-k2.6", name: "Kimi K2.6", reasoning: true },
|
|
201
|
+
{ id: "z-ai/glm-5", name: "GLM 5", reasoning: true },
|
|
202
|
+
{ id: "minimax/MiniMax-M2.7", name: "MiniMax M2.7", reasoning: true },
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
function staticFallbackFor(providerId: string): LiveModel[] {
|
|
206
|
+
switch (providerId) {
|
|
207
|
+
case "opencode-go":
|
|
208
|
+
return OPENCODE_GO_FALLBACK_MODELS.map((m) => ({
|
|
209
|
+
id: m.id,
|
|
210
|
+
name: m.name ?? m.id,
|
|
211
|
+
contextWindow: m.contextWindow,
|
|
212
|
+
maxTokens: m.maxTokens,
|
|
213
|
+
reasoning: true,
|
|
214
|
+
}));
|
|
215
|
+
case "alibaba-codingplan":
|
|
216
|
+
case "alibaba-codingplan-anthropic":
|
|
217
|
+
return ALIBABA_MODELS.map((m) => ({
|
|
218
|
+
id: m.id,
|
|
219
|
+
name: m.name,
|
|
220
|
+
contextWindow: m.contextWindow,
|
|
221
|
+
maxTokens: m.maxTokens,
|
|
222
|
+
reasoning: m.reasoning,
|
|
223
|
+
}));
|
|
224
|
+
case "openai":
|
|
225
|
+
return STATIC_OPENAI;
|
|
226
|
+
case "anthropic":
|
|
227
|
+
return STATIC_ANTHROPIC;
|
|
228
|
+
case "google":
|
|
229
|
+
return STATIC_GOOGLE;
|
|
230
|
+
case "groq":
|
|
231
|
+
return STATIC_GROQ;
|
|
232
|
+
case "openrouter":
|
|
233
|
+
return STATIC_OPENROUTER;
|
|
234
|
+
default:
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── Per-provider fetchers ───────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
async function fetchOpenAI(apiKey: string, timeoutMs: number): Promise<LiveModel[]> {
|
|
242
|
+
const raw = (await fetchJson(
|
|
243
|
+
"https://api.openai.com/v1/models",
|
|
244
|
+
{ Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
245
|
+
timeoutMs,
|
|
246
|
+
)) as OpenAIModelsResponse;
|
|
247
|
+
return mapOpenAIModels(raw);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function fetchAnthropic(apiKey: string, timeoutMs: number): Promise<LiveModel[]> {
|
|
251
|
+
const raw = (await fetchJson(
|
|
252
|
+
"https://api.anthropic.com/v1/models?limit=100",
|
|
253
|
+
{
|
|
254
|
+
"x-api-key": apiKey,
|
|
255
|
+
"anthropic-version": "2023-06-01",
|
|
256
|
+
Accept: "application/json",
|
|
257
|
+
},
|
|
258
|
+
timeoutMs,
|
|
259
|
+
)) as AnthropicModelsResponse;
|
|
260
|
+
return mapAnthropicModels(raw);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function fetchGoogle(apiKey: string, timeoutMs: number): Promise<LiveModel[]> {
|
|
264
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}&pageSize=200`;
|
|
265
|
+
const raw = (await fetchJson(url, { Accept: "application/json" }, timeoutMs)) as GoogleModelsResponse;
|
|
266
|
+
return mapGoogleModels(raw);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function fetchOpenRouter(apiKey: string | undefined, timeoutMs: number): Promise<LiveModel[]> {
|
|
270
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
271
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
272
|
+
const raw = (await fetchJson("https://openrouter.ai/api/v1/models", headers, timeoutMs)) as OpenAIModelsResponse;
|
|
273
|
+
return mapOpenAIModels(raw);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function fetchGroq(apiKey: string, timeoutMs: number): Promise<LiveModel[]> {
|
|
277
|
+
const raw = (await fetchJson(
|
|
278
|
+
"https://api.groq.com/openai/v1/models",
|
|
279
|
+
{ Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
280
|
+
timeoutMs,
|
|
281
|
+
)) as OpenAIModelsResponse;
|
|
282
|
+
return mapOpenAIModels(raw);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function fetchAlibaba(apiKey: string, timeoutMs: number): Promise<LiveModel[]> {
|
|
286
|
+
const raw = (await fetchJson(
|
|
287
|
+
`${ALIBABA_PROVIDERS.openai.baseUrl}/models`,
|
|
288
|
+
{ Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
289
|
+
timeoutMs,
|
|
290
|
+
)) as OpenAIModelsResponse;
|
|
291
|
+
const live = mapOpenAIModels(raw);
|
|
292
|
+
// Alibaba does not return contextWindow/maxTokens; enrich from the static spec when possible.
|
|
293
|
+
const specById = new Map(ALIBABA_MODELS.map((m) => [m.id, m] as const));
|
|
294
|
+
return live.map((m) => {
|
|
295
|
+
const spec = specById.get(m.id);
|
|
296
|
+
return spec
|
|
297
|
+
? {
|
|
298
|
+
...m,
|
|
299
|
+
name: m.name ?? spec.name,
|
|
300
|
+
contextWindow: m.contextWindow ?? spec.contextWindow,
|
|
301
|
+
maxTokens: m.maxTokens ?? spec.maxTokens,
|
|
302
|
+
reasoning: m.reasoning ?? spec.reasoning,
|
|
303
|
+
}
|
|
304
|
+
: m;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function fetchLocal(baseUrl: string, timeoutMs: number, label: string): Promise<LiveModel[]> {
|
|
309
|
+
const raw = (await fetchJson(
|
|
310
|
+
`${baseUrl}/models`,
|
|
311
|
+
{ Authorization: `Bearer ${label}`, Accept: "application/json" },
|
|
312
|
+
timeoutMs,
|
|
313
|
+
)) as OpenAIModelsResponse;
|
|
314
|
+
return mapOpenAIModels(raw).map((m) => ({ ...m, reasoning: m.reasoning ?? false }));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ─── Dispatcher ──────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
async function dispatchFetch(providerId: string, options: FetchOptions): Promise<LiveModel[]> {
|
|
320
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
321
|
+
const apiKey = options.apiKey;
|
|
322
|
+
|
|
323
|
+
switch (providerId) {
|
|
324
|
+
case "opencode-go": {
|
|
325
|
+
const result = await getOpenCodeGoModels({
|
|
326
|
+
apiKey,
|
|
327
|
+
forceRefresh: options.forceRefresh,
|
|
328
|
+
timeoutMs,
|
|
329
|
+
});
|
|
330
|
+
return result.models.map((m) => ({
|
|
331
|
+
id: m.id,
|
|
332
|
+
name: m.name ?? m.id,
|
|
333
|
+
contextWindow: m.contextWindow,
|
|
334
|
+
maxTokens: m.maxTokens,
|
|
335
|
+
reasoning: true,
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
case "alibaba-codingplan":
|
|
339
|
+
case "alibaba-codingplan-anthropic":
|
|
340
|
+
if (!apiKey) throw new Error("Alibaba requires an API key for live listing");
|
|
341
|
+
return await fetchAlibaba(apiKey, timeoutMs);
|
|
342
|
+
case "openai":
|
|
343
|
+
if (!apiKey) throw new Error("OpenAI requires an API key for live listing");
|
|
344
|
+
return await fetchOpenAI(apiKey, timeoutMs);
|
|
345
|
+
case "anthropic":
|
|
346
|
+
if (!apiKey) throw new Error("Anthropic requires an API key for live listing");
|
|
347
|
+
return await fetchAnthropic(apiKey, timeoutMs);
|
|
348
|
+
case "google":
|
|
349
|
+
if (!apiKey) throw new Error("Google requires an API key for live listing");
|
|
350
|
+
return await fetchGoogle(apiKey, timeoutMs);
|
|
351
|
+
case "openrouter":
|
|
352
|
+
return await fetchOpenRouter(apiKey, timeoutMs);
|
|
353
|
+
case "groq":
|
|
354
|
+
if (!apiKey) throw new Error("Groq requires an API key for live listing");
|
|
355
|
+
return await fetchGroq(apiKey, timeoutMs);
|
|
356
|
+
case "ollama":
|
|
357
|
+
return await fetchLocal("http://localhost:11434/v1", timeoutMs, "ollama");
|
|
358
|
+
case "lm-studio":
|
|
359
|
+
return await fetchLocal("http://localhost:1234/v1", timeoutMs, "lm-studio");
|
|
360
|
+
default:
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Live-fetch the model catalog for a provider. Never throws.
|
|
367
|
+
* Resolution order:
|
|
368
|
+
* 1. Fresh in-process cache (TTL 1h) unless `forceRefresh` is true.
|
|
369
|
+
* 2. Live API call (may need apiKey for authenticated providers).
|
|
370
|
+
* 3. Previous cache entry, if any (even if stale).
|
|
371
|
+
* 4. Static fallback (versioned).
|
|
372
|
+
*/
|
|
373
|
+
export async function fetchLiveModels(
|
|
374
|
+
providerId: string,
|
|
375
|
+
options: FetchOptions = {},
|
|
376
|
+
): Promise<LiveModelsResult> {
|
|
377
|
+
const now = Date.now();
|
|
378
|
+
const force = options.forceRefresh === true;
|
|
379
|
+
|
|
380
|
+
const cached = cache.get(providerId);
|
|
381
|
+
if (!force && isCacheValid(cached, now)) {
|
|
382
|
+
return { models: cached!.models, source: "cache" };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Coalesce concurrent fetches for the same provider.
|
|
386
|
+
const inflightKey = `${providerId}:${options.apiKey ?? ""}`;
|
|
387
|
+
let promise = inflight.get(inflightKey);
|
|
388
|
+
if (!promise) {
|
|
389
|
+
promise = dispatchFetch(providerId, options);
|
|
390
|
+
inflight.set(inflightKey, promise);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const models = await promise;
|
|
395
|
+
if (models.length === 0) {
|
|
396
|
+
const fallback = staticFallbackFor(providerId);
|
|
397
|
+
return fallback.length > 0
|
|
398
|
+
? { models: fallback, source: "fallback" }
|
|
399
|
+
: { models: [], source: "unsupported" };
|
|
400
|
+
}
|
|
401
|
+
cache.set(providerId, { models, fetchedAt: now });
|
|
402
|
+
return { models, source: "live" };
|
|
403
|
+
} catch (err) {
|
|
404
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
405
|
+
if (cached) {
|
|
406
|
+
return { models: cached.models, source: "cache", error: message };
|
|
407
|
+
}
|
|
408
|
+
const fallback = staticFallbackFor(providerId);
|
|
409
|
+
if (fallback.length > 0) {
|
|
410
|
+
return { models: fallback, source: "fallback", error: message };
|
|
411
|
+
}
|
|
412
|
+
return { models: [], source: "unsupported", error: message };
|
|
413
|
+
} finally {
|
|
414
|
+
inflight.delete(inflightKey);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Ping a provider's auth endpoint to confirm the API key is valid before saving.
|
|
420
|
+
* Returns `{ok: true}` on success, `{ok: false, error}` on any failure.
|
|
421
|
+
*
|
|
422
|
+
* Falls back to a HEAD on the models endpoint for providers without a dedicated ping.
|
|
423
|
+
*/
|
|
424
|
+
export async function pingProvider(
|
|
425
|
+
providerId: string,
|
|
426
|
+
apiKey: string,
|
|
427
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
428
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
429
|
+
switch (providerId) {
|
|
430
|
+
case "opencode-go":
|
|
431
|
+
return await pingOpenCodeGo(apiKey, timeoutMs);
|
|
432
|
+
case "alibaba-codingplan":
|
|
433
|
+
case "alibaba-codingplan-anthropic":
|
|
434
|
+
return await pingAlibaba(apiKey, timeoutMs);
|
|
435
|
+
default: {
|
|
436
|
+
try {
|
|
437
|
+
const models = await dispatchFetch(providerId, { apiKey, timeoutMs, forceRefresh: true });
|
|
438
|
+
return models.length > 0
|
|
439
|
+
? { ok: true }
|
|
440
|
+
: { ok: false, error: "no models returned" };
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Map a LiveModel to the persisted models.json model shape (used by ApiKeyStore.setKey).
|
|
450
|
+
* Reasonable defaults are applied when the upstream API omits a field.
|
|
451
|
+
*/
|
|
452
|
+
export function toPersistedModel(m: LiveModel): {
|
|
453
|
+
id: string;
|
|
454
|
+
name: string;
|
|
455
|
+
reasoning: boolean;
|
|
456
|
+
input: readonly ["text"];
|
|
457
|
+
contextWindow: number;
|
|
458
|
+
maxTokens: number;
|
|
459
|
+
} {
|
|
460
|
+
return {
|
|
461
|
+
id: m.id,
|
|
462
|
+
name: m.name ?? m.id,
|
|
463
|
+
reasoning: m.reasoning ?? true,
|
|
464
|
+
input: ["text"] as const,
|
|
465
|
+
contextWindow: m.contextWindow ?? 128_000,
|
|
466
|
+
maxTokens: m.maxTokens ?? 16_384,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Reset the in-memory cache. Useful for tests and `/models refresh`.
|
|
472
|
+
*/
|
|
473
|
+
export function resetLiveModelsCache(providerId?: string): void {
|
|
474
|
+
if (providerId) {
|
|
475
|
+
cache.delete(providerId);
|
|
476
|
+
for (const key of inflight.keys()) {
|
|
477
|
+
if (key.startsWith(`${providerId}:`)) inflight.delete(key);
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
cache.clear();
|
|
481
|
+
inflight.clear();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Inspect what's currently in cache for a provider (returns undefined when cold).
|
|
487
|
+
* Exposed for diagnostics and the /models refresh command.
|
|
488
|
+
*/
|
|
489
|
+
export function peekCache(providerId: string): { models: LiveModel[]; ageMs: number } | undefined {
|
|
490
|
+
const entry = cache.get(providerId);
|
|
491
|
+
if (!entry) return undefined;
|
|
492
|
+
return { models: entry.models, ageMs: Date.now() - entry.fetchedAt };
|
|
493
|
+
}
|
|
@@ -43,22 +43,27 @@ interface OpenCodeGoModelsResponse {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Fallback static list of OpenCode Go models.
|
|
46
|
-
* Last verified: 2026-05-
|
|
46
|
+
* Last verified: 2026-05-15 (15 models, sourced from the live /v1/models endpoint).
|
|
47
47
|
* Used when network unreachable or auth fails before configuration.
|
|
48
|
+
*
|
|
49
|
+
* Refresh with: `curl -s https://opencode.ai/zen/go/v1/models | jq '.data[].id'`
|
|
48
50
|
*/
|
|
49
51
|
export const OPENCODE_GO_FALLBACK_MODELS: readonly OpenCodeGoModel[] = [
|
|
52
|
+
{ id: "minimax-m2.7", name: "MiniMax M2.7", contextWindow: 1_000_000, maxTokens: 16_384 },
|
|
53
|
+
{ id: "minimax-m2.5", name: "MiniMax M2.5", contextWindow: 1_000_000, maxTokens: 16_384 },
|
|
50
54
|
{ id: "kimi-k2.6", name: "Kimi K2.6", contextWindow: 256_000, maxTokens: 16_384 },
|
|
51
55
|
{ id: "kimi-k2.5", name: "Kimi K2.5", contextWindow: 256_000, maxTokens: 16_384 },
|
|
52
|
-
{ id: "
|
|
53
|
-
{ id: "qwen3-coder-plus", name: "Qwen 3 Coder Plus", contextWindow: 1_000_000, maxTokens: 16_384 },
|
|
54
|
-
{ id: "glm-4.6", name: "GLM 4.6", contextWindow: 200_000, maxTokens: 128_000 },
|
|
56
|
+
{ id: "glm-5.1", name: "GLM 5.1", contextWindow: 200_000, maxTokens: 128_000 },
|
|
55
57
|
{ id: "glm-5", name: "GLM 5", contextWindow: 200_000, maxTokens: 128_000 },
|
|
56
|
-
{ id: "deepseek-
|
|
57
|
-
{ id: "
|
|
58
|
-
{ id: "
|
|
59
|
-
{ id: "
|
|
60
|
-
{ id: "
|
|
61
|
-
{ id: "
|
|
58
|
+
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", contextWindow: 128_000, maxTokens: 8_192 },
|
|
59
|
+
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", contextWindow: 128_000, maxTokens: 8_192 },
|
|
60
|
+
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", contextWindow: 1_000_000, maxTokens: 16_384 },
|
|
61
|
+
{ id: "qwen3.5-plus", name: "Qwen 3.5 Plus", contextWindow: 1_000_000, maxTokens: 16_384 },
|
|
62
|
+
{ id: "mimo-v2-pro", name: "MiMo V2 Pro", contextWindow: 200_000, maxTokens: 16_384 },
|
|
63
|
+
{ id: "mimo-v2-omni", name: "MiMo V2 Omni", contextWindow: 200_000, maxTokens: 16_384 },
|
|
64
|
+
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", contextWindow: 200_000, maxTokens: 16_384 },
|
|
65
|
+
{ id: "mimo-v2.5", name: "MiMo V2.5", contextWindow: 200_000, maxTokens: 16_384 },
|
|
66
|
+
{ id: "hy3-preview", name: "Hy3 Preview", contextWindow: 128_000, maxTokens: 16_384 },
|
|
62
67
|
] as const;
|
|
63
68
|
|
|
64
69
|
interface CacheEntry {
|