pi-tinyllm 0.1.0 → 0.1.2

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 CHANGED
@@ -52,9 +52,9 @@ pi --provider tinyllm --model anthropic/claude-sonnet-4-6
52
52
 
53
53
  ## Behavior and limitations
54
54
 
55
- - Canonical TinyLLM namespaces map to same-named Pi catalogs. `codex` maps to `openai-codex`; `openai` prefers `openai-codex` metadata and then `openai`.
56
- - Generated OpenAI `-fast` IDs are filtered from discovery. Select an advertised base `openai/gpt-*` model, then run `/fast` to toggle TinyLLM fast routing for the current session. The command changes only the outgoing request ID; run it again to disable fast routing.
57
- - `/fast` is available only while a `tinyllm` `openai/gpt-*` model is selected. Other models are left unchanged and produce a warning. The toggle follows the active session branch and is restored on reload or resume.
55
+ - Discovery uses TinyLLM's `/api/v1/models` endpoint, where `configured_models` contains explicit IDs and `providers` identifies every configured provider. Provider descriptors expand from Pi's matching installed catalog, including custom TinyLLM prefixes; a `404` falls back to `/v1/models` for explicit-ID discovery from older servers. Canonical `codex` maps to `openai-codex`, while canonical `openai` prefers `openai-codex` metadata and then `openai`. Expansion describes routable catalog models; it does not probe account-specific upstream entitlements.
56
+ - The extension status line shows the active Pi provider, such as `[tinyllm]` or `[openai-codex]`. Generated OpenAI `-fast` IDs are filtered from discovery; run `/fast` on an advertised OpenAI GPT model to toggle request-time fast routing. While enabled, the status line reads `[tinyllm] fast` for a selected TinyLLM model.
57
+ - `/fast` is available only while a TinyLLM model sourced from an OpenAI catalog has a native `gpt-*` ID. Other models are left unchanged and produce a warning. The toggle follows the active session branch and is restored on reload or resume.
58
58
  - Anthropic Messages, OpenAI Responses, and Chat Completions models use TinyLLM's corresponding routes.
59
59
  - Unknown aliases, unknown models, and models using unsupported wire APIs are omitted rather than assigned guessed metadata.
60
60
  - Pi owns the persisted model catalog. Failed refreshes retain the last known good catalog, and offline refreshes restore it.
@@ -21,6 +21,8 @@ const SUPPORTED_APIS = new Map<Api, Api>([
21
21
  ]);
22
22
 
23
23
  type Warn = (message: string) => void;
24
+ type BuiltinProvider = ReturnType<typeof getBuiltinProviders>[number];
25
+ type CatalogMap = ReadonlyMap<string, BuiltinProvider>;
24
26
 
25
27
  export interface TinyllmProviderOptions {
26
28
  baseUrl?: string;
@@ -38,10 +40,72 @@ export function apiBaseUrl(baseUrl: string, api: Api): string {
38
40
  return api === "anthropic-messages" ? `${root}/anthropic` : `${root}/v1`;
39
41
  }
40
42
 
41
- function catalogCandidates(prefix: string): string[] {
43
+ function isBuiltinProvider(value: string): value is BuiltinProvider {
44
+ return getBuiltinProviders().includes(value as BuiltinProvider);
45
+ }
46
+
47
+ function catalogCandidates(prefix: string, catalog?: BuiltinProvider): BuiltinProvider[] {
48
+ if (catalog) return [catalog];
42
49
  if (prefix === "openai") return ["openai-codex", "openai"];
43
50
  if (prefix === "codex") return ["openai-codex"];
44
- return getBuiltinProviders().includes(prefix as ReturnType<typeof getBuiltinProviders>[number]) ? [prefix] : [];
51
+ return isBuiltinProvider(prefix) ? [prefix] : [];
52
+ }
53
+
54
+ function diagnostic(value: string): string {
55
+ const sanitized = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
56
+ return sanitized.length > MAX_DIAGNOSTIC_ID_LENGTH
57
+ ? `${sanitized.slice(0, MAX_DIAGNOSTIC_ID_LENGTH - 3)}...`
58
+ : sanitized;
59
+ }
60
+
61
+ function warnOmitted(label: string, omitted: string[], count: number, warn: Warn): void {
62
+ if (omitted.length === 0) return;
63
+ const suffix = count > omitted.length ? ` (+${count - omitted.length} more)` : "";
64
+ warn(`TinyLLM omitted ${label}: ${omitted.join(", ")}${suffix}`);
65
+ }
66
+
67
+ function providerCatalogs(
68
+ value: unknown,
69
+ warn: Warn,
70
+ ): { catalogs: Map<string, BuiltinProvider>; ids: string[] } {
71
+ const catalogs = new Map<string, BuiltinProvider>();
72
+ const ids: string[] = [];
73
+ if (value === undefined) return { catalogs, ids };
74
+ const entries = Array.isArray(value) ? value : [undefined];
75
+ const omitted: string[] = [];
76
+ let omittedCount = 0;
77
+ const omit = (description: string) => {
78
+ omittedCount++;
79
+ if (omitted.length < MAX_DIAGNOSTIC_IDS) omitted.push(diagnostic(description));
80
+ };
81
+
82
+ for (const entry of entries) {
83
+ if (!entry || typeof entry !== "object") {
84
+ omit("<invalid provider>");
85
+ continue;
86
+ }
87
+ const { id, type, auth } = entry as { id?: unknown; type?: unknown; auth?: unknown };
88
+ if (
89
+ typeof id !== "string"
90
+ || !/^[A-Za-z0-9_.-]{1,64}$/.test(id)
91
+ || typeof type !== "string"
92
+ || (type === "openai" && auth !== "api_key" && auth !== "subscription")
93
+ ) {
94
+ omit(typeof id === "string" && typeof type === "string" ? `${id}:${type}` : "<invalid provider>");
95
+ continue;
96
+ }
97
+ const catalogName = type === "openai" && auth === "subscription" ? "openai-codex" : type;
98
+ if (!isBuiltinProvider(catalogName)) {
99
+ omit(`${id}:${type}`);
100
+ continue;
101
+ }
102
+ if (catalogs.has(id)) continue;
103
+ catalogs.set(id, catalogName);
104
+ for (const model of getBuiltinModels(catalogName)) ids.push(`${id}/${model.id}`);
105
+ }
106
+
107
+ warnOmitted("invalid or unsupported providers", omitted, omittedCount, warn);
108
+ return { catalogs, ids };
45
109
  }
46
110
 
47
111
  function parsePublicId(publicId: string): { prefix: string; nativeId: string } | undefined {
@@ -51,11 +115,13 @@ function parsePublicId(publicId: string): { prefix: string; nativeId: string } |
51
115
  return { prefix: publicId.slice(0, slash), nativeId: publicId.slice(slash + 1) };
52
116
  }
53
117
 
54
- function lookupCatalogModel(prefix: string, nativeId: string): Model<Api> | undefined {
55
- for (const provider of catalogCandidates(prefix)) {
56
- const model = getBuiltinModels(provider as Parameters<typeof getBuiltinModels>[0]).find(
57
- (candidate) => candidate.id === nativeId,
58
- );
118
+ function lookupCatalogModel(
119
+ prefix: string,
120
+ nativeId: string,
121
+ catalogs: CatalogMap,
122
+ ): Model<Api> | undefined {
123
+ for (const provider of catalogCandidates(prefix, catalogs.get(prefix))) {
124
+ const model = getBuiltinModels(provider).find((candidate) => candidate.id === nativeId);
59
125
  if (model) return model as Model<Api>;
60
126
  }
61
127
  return undefined;
@@ -78,6 +144,7 @@ export function mapDiscoveredModels(
78
144
  ids: readonly unknown[],
79
145
  baseUrl: string,
80
146
  warn: Warn = console.warn,
147
+ catalogs: CatalogMap = new Map(),
81
148
  ): Model<Api>[] {
82
149
  const models: Model<Api>[] = [];
83
150
  const seen = new Set<string>();
@@ -85,11 +152,7 @@ export function mapDiscoveredModels(
85
152
  let omittedCount = 0;
86
153
  const omit = (id: string) => {
87
154
  omittedCount++;
88
- const sanitized = id.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
89
- const display = sanitized.length > MAX_DIAGNOSTIC_ID_LENGTH
90
- ? `${sanitized.slice(0, MAX_DIAGNOSTIC_ID_LENGTH - 3)}...`
91
- : sanitized;
92
- if (omitted.length < MAX_DIAGNOSTIC_IDS) omitted.push(display);
155
+ if (omitted.length < MAX_DIAGNOSTIC_IDS) omitted.push(diagnostic(id));
93
156
  };
94
157
 
95
158
  for (const value of ids) {
@@ -98,8 +161,13 @@ export function mapDiscoveredModels(
98
161
  continue;
99
162
  }
100
163
  const discovered = parsePublicId(value);
164
+ const discoveredCatalog = discovered ? catalogs.get(discovered.prefix) : undefined;
101
165
  if (
102
- discovered?.prefix === "openai"
166
+ discovered
167
+ && (discovered.prefix === "openai"
168
+ || discovered.prefix === "codex"
169
+ || discoveredCatalog === "openai"
170
+ || discoveredCatalog === "openai-codex")
103
171
  && discovered.nativeId.startsWith("gpt-")
104
172
  && discovered.nativeId.endsWith("-fast")
105
173
  ) {
@@ -109,7 +177,7 @@ export function mapDiscoveredModels(
109
177
  if (seen.has(publicId)) continue;
110
178
  seen.add(publicId);
111
179
  const parsed = parsePublicId(publicId);
112
- const source = parsed ? lookupCatalogModel(parsed.prefix, parsed.nativeId) : undefined;
180
+ const source = parsed ? lookupCatalogModel(parsed.prefix, parsed.nativeId, catalogs) : undefined;
113
181
  const api = source ? SUPPORTED_APIS.get(source.api) : undefined;
114
182
  if (!parsed || !source || !api) {
115
183
  omit(value);
@@ -125,10 +193,7 @@ export function mapDiscoveredModels(
125
193
  });
126
194
  }
127
195
 
128
- if (omitted.length > 0) {
129
- const suffix = omittedCount > omitted.length ? ` (+${omittedCount - omitted.length} more)` : "";
130
- warn(`TinyLLM omitted unknown, malformed, or unsupported models: ${omitted.join(", ")}${suffix}`);
131
- }
196
+ warnOmitted("unknown, malformed, or unsupported models", omitted, omittedCount, warn);
132
197
  return models;
133
198
  }
134
199
 
@@ -139,10 +204,17 @@ export async function discoverModels(
139
204
  fetchImpl: typeof fetch = fetch,
140
205
  warn: Warn = console.warn,
141
206
  ): Promise<Model<Api>[]> {
142
- const response = await fetchImpl(`${normalizeBaseUrl(baseUrl)}/v1/models`, {
207
+ const root = normalizeBaseUrl(baseUrl);
208
+ const request = {
143
209
  headers: { Authorization: `Bearer ${apiKey}` },
144
210
  signal,
145
- });
211
+ };
212
+ let modelKey: "configured_models" | "data" = "configured_models";
213
+ let response = await fetchImpl(`${root}/api/v1/models`, request);
214
+ if (response.status === 404) {
215
+ modelKey = "data";
216
+ response = await fetchImpl(`${root}/v1/models`, request);
217
+ }
146
218
  if (!response.ok) throw new Error(`TinyLLM model discovery failed with HTTP ${response.status}`);
147
219
 
148
220
  let payload: unknown;
@@ -152,15 +224,25 @@ export async function discoverModels(
152
224
  signal.throwIfAborted();
153
225
  throw new Error("TinyLLM model discovery returned invalid JSON", { cause: error });
154
226
  }
155
- if (!payload || typeof payload !== "object" || !("data" in payload) || !Array.isArray(payload.data)) {
227
+ if (!payload || typeof payload !== "object") {
156
228
  throw new Error("TinyLLM model discovery returned an invalid payload");
157
229
  }
230
+ const body = payload as Record<string, unknown>;
231
+ const configuredModels = body[modelKey];
232
+ if (!Array.isArray(configuredModels)) {
233
+ throw new Error("TinyLLM model discovery returned an invalid payload");
234
+ }
235
+ const discovery = providerCatalogs(body.providers, warn);
158
236
  return mapDiscoveredModels(
159
- payload.data.map((entry) =>
160
- entry && typeof entry === "object" && "id" in entry ? (entry as { id?: unknown }).id : undefined,
161
- ),
237
+ [
238
+ ...configuredModels.map((entry) =>
239
+ entry && typeof entry === "object" && "id" in entry ? (entry as { id?: unknown }).id : undefined,
240
+ ),
241
+ ...discovery.ids,
242
+ ],
162
243
  baseUrl,
163
244
  warn,
245
+ discovery.catalogs,
164
246
  );
165
247
  }
166
248
 
@@ -260,7 +342,7 @@ export async function bootstrapProvider(provider: Provider<Api>, apiKey: string,
260
342
  }
261
343
 
262
344
  export function isFastCapable(model: Pick<Model<Api>, "provider" | "id"> | undefined): boolean {
263
- return model?.provider === "tinyllm" && /^openai\/gpt-.+/.test(model.id) && !model.id.endsWith("-fast");
345
+ return model?.provider === "tinyllm" && /^[^/]+\/gpt-.+/.test(model.id) && !model.id.endsWith("-fast");
264
346
  }
265
347
 
266
348
  export function rewriteFastPayload(
@@ -276,10 +358,20 @@ export function rewriteFastPayload(
276
358
  return { ...request, model: `${request.model}-fast` };
277
359
  }
278
360
 
361
+ const PROVIDER_STATUS = "model-provider";
279
362
  const FAST_STATE = "tinyllm-fast-mode";
280
363
 
364
+ export function registerProviderStatus(pi: ExtensionAPI): void {
365
+ const update = (ctx: ExtensionContext, provider = ctx.model?.provider) =>
366
+ ctx.ui.setStatus(PROVIDER_STATUS, provider ? `[${provider}]` : undefined);
367
+ pi.on("session_start", (_event, ctx) => update(ctx));
368
+ pi.on("session_tree", (_event, ctx) => update(ctx));
369
+ pi.on("model_select", (event, ctx) => update(ctx, event.model.provider));
370
+ }
371
+
281
372
  export function registerFastMode(pi: ExtensionAPI): void {
282
373
  let enabled = false;
374
+ const updateStatus = (ctx: ExtensionContext) => ctx.ui.setStatus(FAST_STATE, enabled ? "fast" : undefined);
283
375
  const restore = (ctx: ExtensionContext) => {
284
376
  enabled = false;
285
377
  for (const entry of ctx.sessionManager.getBranch()) {
@@ -287,6 +379,7 @@ export function registerFastMode(pi: ExtensionAPI): void {
287
379
  enabled = (entry.data as { enabled?: unknown } | undefined)?.enabled === true;
288
380
  }
289
381
  }
382
+ updateStatus(ctx);
290
383
  };
291
384
 
292
385
  pi.on("session_start", (_event, ctx) => restore(ctx));
@@ -296,17 +389,19 @@ export function registerFastMode(pi: ExtensionAPI): void {
296
389
  description: "Toggle TinyLLM fast routing for the selected OpenAI GPT model",
297
390
  handler: async (_args, ctx) => {
298
391
  if (!isFastCapable(ctx.model)) {
299
- ctx.ui.notify("/fast is only available for TinyLLM openai/gpt-* models", "warning");
392
+ ctx.ui.notify("/fast is only available for TinyLLM OpenAI GPT models", "warning");
300
393
  return;
301
394
  }
302
395
  enabled = !enabled;
303
396
  pi.appendEntry(FAST_STATE, { enabled });
397
+ updateStatus(ctx);
304
398
  ctx.ui.notify(`TinyLLM fast routing ${enabled ? "enabled" : "disabled"}`, "info");
305
399
  },
306
400
  });
307
401
  }
308
402
 
309
403
  export default async function tinyllmExtension(pi: ExtensionAPI): Promise<void> {
404
+ registerProviderStatus(pi);
310
405
  registerFastMode(pi);
311
406
  const baseUrl = process.env.TINYLLM_BASE_URL ?? DEFAULT_BASE_URL;
312
407
  const provider = createTinyllmProvider({ baseUrl });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tinyllm",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Dynamic TinyLLM provider package for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",