@phi-code-admin/phi-code 0.75.4 → 0.75.5

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,236 @@
1
+ /**
2
+ * Models Extension - `/models` command for live model catalog management.
3
+ *
4
+ * Subcommands:
5
+ * /models : list models grouped by provider (uses cached/live data)
6
+ * /models list <id> : list models for a specific provider
7
+ * /models refresh : re-fetch the model catalog for every configured provider
8
+ * (writes the result into ~/.phi/agent/models.json,
9
+ * triggering ApiKeyStore hot-reload + ModelRegistry refresh).
10
+ * /models refresh <id>: refresh a single provider
11
+ *
12
+ * This is what keeps the model picker (/model) and the wizards (/setup,
13
+ * /phi-init) in sync with each provider's upstream catalog — for instance
14
+ * when OpenCode Go publishes a new model, a single `/models refresh` makes
15
+ * it appear everywhere without restarting Phi Code.
16
+ */
17
+
18
+ import { ApiKeyStore, type ExtensionAPI, getApiKeyStore } from "phi-code";
19
+ import { fetchLiveModels, peekCache, resetLiveModelsCache, toPersistedModel } from "./providers/live-models.js";
20
+
21
+ const PROVIDER_DISPLAY: Record<string, string> = {
22
+ "opencode-go": "OpenCode Go",
23
+ "alibaba-codingplan": "Alibaba Coding Plan (OpenAI-compat)",
24
+ "alibaba-codingplan-anthropic": "Alibaba Coding Plan (Anthropic-compat)",
25
+ openai: "OpenAI",
26
+ anthropic: "Anthropic",
27
+ google: "Google Gemini",
28
+ openrouter: "OpenRouter",
29
+ groq: "Groq",
30
+ ollama: "Ollama (local)",
31
+ "lm-studio": "LM Studio (local)",
32
+ };
33
+
34
+ function displayName(id: string): string {
35
+ return PROVIDER_DISPLAY[id] ?? id;
36
+ }
37
+
38
+ interface RefreshOutcome {
39
+ provider: string;
40
+ source: "live" | "cache" | "fallback" | "unsupported" | "skipped";
41
+ count: number;
42
+ error?: string;
43
+ }
44
+
45
+ async function refreshOne(
46
+ store: ApiKeyStore,
47
+ providerId: string,
48
+ ): Promise<RefreshOutcome> {
49
+ const stored = store.getProvider(providerId);
50
+ const apiKey = stored?.apiKey && !stored.apiKey.startsWith("$") && stored.apiKey !== "local"
51
+ ? stored.apiKey
52
+ : undefined;
53
+
54
+ resetLiveModelsCache(providerId);
55
+ const result = await fetchLiveModels(providerId, {
56
+ apiKey,
57
+ forceRefresh: true,
58
+ timeoutMs: 8_000,
59
+ });
60
+
61
+ if (result.source === "unsupported") {
62
+ return { provider: providerId, source: "skipped", count: 0, error: result.error };
63
+ }
64
+
65
+ const persisted = result.models.map(toPersistedModel);
66
+ if (persisted.length === 0) {
67
+ return { provider: providerId, source: result.source, count: 0, error: result.error };
68
+ }
69
+
70
+ // Preserve baseUrl/api/apiKey/headers from existing config; only models change.
71
+ const baseUrl =
72
+ stored?.baseUrl ??
73
+ (providerId === "opencode-go"
74
+ ? "https://opencode.ai/zen/go/v1"
75
+ : providerId === "alibaba-codingplan"
76
+ ? "https://coding-intl.dashscope.aliyuncs.com/v1"
77
+ : providerId === "alibaba-codingplan-anthropic"
78
+ ? "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
79
+ : providerId === "openai"
80
+ ? "https://api.openai.com/v1"
81
+ : providerId === "anthropic"
82
+ ? "https://api.anthropic.com/v1"
83
+ : providerId === "google"
84
+ ? "https://generativelanguage.googleapis.com/v1beta"
85
+ : providerId === "openrouter"
86
+ ? "https://openrouter.ai/api/v1"
87
+ : providerId === "groq"
88
+ ? "https://api.groq.com/openai/v1"
89
+ : providerId === "ollama"
90
+ ? "http://localhost:11434/v1"
91
+ : providerId === "lm-studio"
92
+ ? "http://localhost:1234/v1"
93
+ : undefined);
94
+
95
+ if (!baseUrl) {
96
+ return { provider: providerId, source: "skipped", count: 0, error: "unknown baseUrl" };
97
+ }
98
+
99
+ store.setKey(providerId, stored?.apiKey ?? "local", {
100
+ baseUrl,
101
+ api: stored?.api,
102
+ models: persisted,
103
+ });
104
+
105
+ return { provider: providerId, source: result.source, count: persisted.length, error: result.error };
106
+ }
107
+
108
+ export default function modelsExtension(pi: ExtensionAPI) {
109
+ const store = getApiKeyStore();
110
+
111
+ pi.registerCommand("models", {
112
+ description: "List or refresh the live model catalog (use `/models refresh` after a provider adds a new model)",
113
+ handler: async (args, ctx) => {
114
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
115
+ const sub = tokens[0]?.toLowerCase() ?? "";
116
+ const target = tokens[1];
117
+
118
+ try {
119
+ if (sub === "" || sub === "list") {
120
+ await listCommand(target, ctx);
121
+ return;
122
+ }
123
+ if (sub === "refresh") {
124
+ await refreshCommand(target, ctx);
125
+ return;
126
+ }
127
+ ctx.ui.notify(
128
+ "Unknown subcommand. Use: `/models [list|refresh] [provider-id]`",
129
+ "warning",
130
+ );
131
+ } catch (err) {
132
+ ctx.ui.notify(
133
+ `/models error: ${err instanceof Error ? err.message : String(err)}`,
134
+ "error",
135
+ );
136
+ }
137
+ },
138
+ });
139
+
140
+ async function listCommand(target: string | undefined, ctx: { ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): Promise<void> {
141
+ const providers = target ? [target] : store.listProviders();
142
+ if (providers.length === 0) {
143
+ ctx.ui.notify(
144
+ "No providers configured. Run `/setup` or `/phi-init` to add one.",
145
+ "info",
146
+ );
147
+ return;
148
+ }
149
+
150
+ let out = `**Model catalog (${providers.length} provider(s))**\n\n`;
151
+ for (const id of providers) {
152
+ const stored = store.getProvider(id);
153
+ const cached = peekCache(id);
154
+ const models = (Array.isArray(stored?.models) ? stored?.models : []) as Array<{ id?: string; name?: string }>;
155
+ const ageMin = cached ? Math.round(cached.ageMs / 60_000) : undefined;
156
+
157
+ out += ` **${displayName(id)}** \`${id}\``;
158
+ out += ` — ${models.length} model(s) persisted`;
159
+ if (ageMin !== undefined) out += ` (cache age: ${ageMin}m)`;
160
+ out += "\n";
161
+ if (models.length > 0) {
162
+ const ids = models.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
163
+ out += ` ${ids.join(", ")}\n`;
164
+ }
165
+ }
166
+ out += `\nUse \`/models refresh\` to re-fetch from each provider's API.`;
167
+ ctx.ui.notify(out, "info");
168
+ }
169
+
170
+ // Background refresh on session_start so every new Phi Code session reflects
171
+ // the latest provider catalogs without the user typing `/models refresh`.
172
+ // Failures are silent — startup must never be blocked by upstream API hiccups.
173
+ pi.on("session_start", async (_event, ctx) => {
174
+ try {
175
+ store.load();
176
+ } catch {
177
+ // no models.json yet
178
+ }
179
+ const providers = store.listProviders();
180
+ if (providers.length === 0) return;
181
+
182
+ // Fire-and-forget. Hot-reload via models_json_changed event surfaces results.
183
+ void (async () => {
184
+ let changed = 0;
185
+ for (const id of providers) {
186
+ const outcome = await refreshOne(store, id).catch(() => undefined);
187
+ if (outcome && outcome.source === "live" && outcome.count > 0) changed++;
188
+ }
189
+ if (changed > 0) {
190
+ try {
191
+ ctx.ui.notify(
192
+ `Refreshed ${changed}/${providers.length} provider catalog(s) in the background.`,
193
+ "info",
194
+ );
195
+ } catch {
196
+ // notify may fail if the TUI is mid-shutdown — ignore
197
+ }
198
+ pi.events.emit("models_json_changed", { source: "session-start-refresh" });
199
+ }
200
+ })();
201
+ });
202
+
203
+ async function refreshCommand(
204
+ target: string | undefined,
205
+ ctx: { ui: { notify: (m: string, t?: "info" | "warning" | "error") => void; setStatus?: (k: string, v?: string) => void } },
206
+ ): Promise<void> {
207
+ const providers = target ? [target] : store.listProviders();
208
+ if (providers.length === 0) {
209
+ ctx.ui.notify("No providers configured.", "warning");
210
+ return;
211
+ }
212
+ ctx.ui.notify(`Refreshing ${providers.length} provider(s)...`, "info");
213
+ ctx.ui.setStatus?.("models-refresh", "Fetching live model catalogs...");
214
+
215
+ const outcomes: RefreshOutcome[] = [];
216
+ for (const id of providers) {
217
+ const outcome = await refreshOne(store, id).catch((err) => ({
218
+ provider: id,
219
+ source: "skipped" as const,
220
+ count: 0,
221
+ error: err instanceof Error ? err.message : String(err),
222
+ }));
223
+ outcomes.push(outcome);
224
+ }
225
+ ctx.ui.setStatus?.("models-refresh", undefined);
226
+
227
+ let out = "**Refresh report:**\n";
228
+ for (const o of outcomes) {
229
+ const icon = o.source === "live" ? "[ok]" : o.source === "fallback" ? "[fb]" : o.source === "cache" ? "[c]" : "[--]";
230
+ out += ` ${icon} ${displayName(o.provider)} \`${o.provider}\` — ${o.count} model(s) (${o.source}${o.error ? `, ${o.error}` : ""})\n`;
231
+ }
232
+ out += `\nModels persisted to \`${store.configPath}\`. \`/model\` picker now reflects this catalog.`;
233
+ ctx.ui.notify(out, "info");
234
+ pi.events.emit("models_json_changed", { source: "models-refresh" });
235
+ }
236
+ }