pi-tinyllm 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-tinyllm contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # pi-tinyllm
2
+
3
+ [![CI](https://github.com/mipsel64/pi-tinyllm/actions/workflows/ci.yml/badge.svg)](https://github.com/mipsel64/pi-tinyllm/actions/workflows/ci.yml)
4
+
5
+ A standalone [Pi](https://pi.dev) package that discovers models from a local [TinyLLM](https://github.com/mipsel64/tinyllm) gateway and inherits their metadata from Pi's installed model catalogs.
6
+
7
+ ## Install
8
+
9
+ Install the published package:
10
+
11
+ ```sh
12
+ pi install npm:pi-tinyllm
13
+ ```
14
+
15
+ Or install the current GitHub version:
16
+
17
+ ```sh
18
+ pi install git:github.com/mipsel64/pi-tinyllm
19
+ ```
20
+
21
+ Or try a local checkout without installing it:
22
+
23
+ ```sh
24
+ pi -e /path/to/pi-tinyllm --list-models tinyllm
25
+ ```
26
+
27
+ Install a local checkout for future Pi sessions:
28
+
29
+ ```sh
30
+ pi install /path/to/pi-tinyllm
31
+ ```
32
+
33
+ ## Configure
34
+
35
+ Set the gateway URL and its bearer token before starting Pi:
36
+
37
+ ```sh
38
+ export TINYLLM_BASE_URL=http://127.0.0.1:8080
39
+ export TINYLLM_API_KEY=your-gateway-token
40
+ pi --list-models tinyllm
41
+ ```
42
+
43
+ `TINYLLM_BASE_URL` defaults to `http://127.0.0.1:8080`. Values ending in `/anthropic` or `/v1` are accepted and normalized. When TinyLLM authentication is disabled, explicitly set the token accepted by that gateway (commonly `TINYLLM_API_KEY=tinyllm`); this package does not guess a credential.
44
+
45
+ You can store both the gateway URL and key through Pi's native `/login` flow instead of keeping them in the environment. Environment configuration is still needed for first-run `--list-models`, because extensions cannot read Pi's credential store before registration. Later native refreshes use the stored values.
46
+
47
+ Select a discovered public ID without removing its TinyLLM namespace:
48
+
49
+ ```sh
50
+ pi --provider tinyllm --model anthropic/claude-sonnet-4-6
51
+ ```
52
+
53
+ ## Behavior and limitations
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.
58
+ - Anthropic Messages, OpenAI Responses, and Chat Completions models use TinyLLM's corresponding routes.
59
+ - Unknown aliases, unknown models, and models using unsupported wire APIs are omitted rather than assigned guessed metadata.
60
+ - Pi owns the persisted model catalog. Failed refreshes retain the last known good catalog, and offline refreshes restore it.
61
+ - Pi 0.87.1 performs only a cache refresh after registering an extension provider. This package does one bounded first-run discovery before registration so `--list-models` works with environment configuration. Pi may then restore an older persisted catalog during the same startup; a later native catalog refresh reconciles it. The package does not maintain a second cache.
62
+
63
+ ## Publishing
64
+
65
+ npm requires the first version to exist before trusted publishing can be configured. Publish `0.1.0` once with `npm login && npm publish`, then add this trusted publisher in the package settings on npmjs.com:
66
+
67
+ - Organization or user: `mipsel64`
68
+ - Repository: `pi-tinyllm`
69
+ - Workflow: `release.yml`
70
+ - Environment: leave blank
71
+ - Allowed action: `npm publish`
72
+
73
+ Future releases are tokenless and include npm provenance:
74
+
75
+ ```sh
76
+ npm version patch
77
+ git push origin main --follow-tags
78
+ ```
79
+
80
+ ## Development
81
+
82
+ ```sh
83
+ npm test
84
+ ```
85
+
86
+ Requires Node.js 22.19 or newer. Pi supplies the peer packages at runtime.
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,322 @@
1
+ import {
2
+ createProvider,
3
+ type Api,
4
+ type Model,
5
+ type Provider,
6
+ type ProviderStreams,
7
+ } from "@earendil-works/pi-ai";
8
+ import { anthropicMessagesApi, openAICompletionsApi, openAIResponsesApi } from "@earendil-works/pi-ai/compat";
9
+ import { getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+
12
+ const DEFAULT_BASE_URL = "http://127.0.0.1:8080";
13
+ const MAX_DIAGNOSTIC_IDS = 5;
14
+ const MAX_DIAGNOSTIC_ID_LENGTH = 120;
15
+ const SUPPORTED_APIS = new Map<Api, Api>([
16
+ ["anthropic-messages", "anthropic-messages"],
17
+ ["openai-completions", "openai-completions"],
18
+ ["openai-responses", "openai-responses"],
19
+ ["openai-codex-responses", "openai-responses"],
20
+ ["azure-openai-responses", "openai-responses"],
21
+ ]);
22
+
23
+ type Warn = (message: string) => void;
24
+
25
+ export interface TinyllmProviderOptions {
26
+ baseUrl?: string;
27
+ fetch?: typeof fetch;
28
+ warn?: Warn;
29
+ }
30
+
31
+ export function normalizeBaseUrl(value: string): string {
32
+ const baseUrl = value.trim().replace(/\/+$/, "");
33
+ return baseUrl.replace(/\/(?:anthropic|v1)$/, "");
34
+ }
35
+
36
+ export function apiBaseUrl(baseUrl: string, api: Api): string {
37
+ const root = normalizeBaseUrl(baseUrl);
38
+ return api === "anthropic-messages" ? `${root}/anthropic` : `${root}/v1`;
39
+ }
40
+
41
+ function catalogCandidates(prefix: string): string[] {
42
+ if (prefix === "openai") return ["openai-codex", "openai"];
43
+ if (prefix === "codex") return ["openai-codex"];
44
+ return getBuiltinProviders().includes(prefix as ReturnType<typeof getBuiltinProviders>[number]) ? [prefix] : [];
45
+ }
46
+
47
+ function parsePublicId(publicId: string): { prefix: string; nativeId: string } | undefined {
48
+ if (publicId !== publicId.trim() || publicId.includes("//")) return undefined;
49
+ const slash = publicId.indexOf("/");
50
+ if (slash <= 0 || slash === publicId.length - 1) return undefined;
51
+ return { prefix: publicId.slice(0, slash), nativeId: publicId.slice(slash + 1) };
52
+ }
53
+
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
+ );
59
+ if (model) return model as Model<Api>;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function routedCompat(source: Model<Api>, api: Api): Model<Api>["compat"] {
65
+ if (api !== "anthropic-messages") return source.compat;
66
+ const compat = source.compat as Model<"anthropic-messages">["compat"];
67
+ if (!compat?.allowedFallbackModels) return compat;
68
+ return {
69
+ ...compat,
70
+ allowedFallbackModels: compat.allowedFallbackModels.map((fallback) => ({
71
+ ...fallback,
72
+ provider: "tinyllm",
73
+ })),
74
+ };
75
+ }
76
+
77
+ export function mapDiscoveredModels(
78
+ ids: readonly unknown[],
79
+ baseUrl: string,
80
+ warn: Warn = console.warn,
81
+ ): Model<Api>[] {
82
+ const models: Model<Api>[] = [];
83
+ const seen = new Set<string>();
84
+ const omitted: string[] = [];
85
+ let omittedCount = 0;
86
+ const omit = (id: string) => {
87
+ 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);
93
+ };
94
+
95
+ for (const value of ids) {
96
+ if (typeof value !== "string") {
97
+ omit("<invalid id>");
98
+ continue;
99
+ }
100
+ const discovered = parsePublicId(value);
101
+ if (
102
+ discovered?.prefix === "openai"
103
+ && discovered.nativeId.startsWith("gpt-")
104
+ && discovered.nativeId.endsWith("-fast")
105
+ ) {
106
+ continue;
107
+ }
108
+ const publicId = value;
109
+ if (seen.has(publicId)) continue;
110
+ seen.add(publicId);
111
+ const parsed = parsePublicId(publicId);
112
+ const source = parsed ? lookupCatalogModel(parsed.prefix, parsed.nativeId) : undefined;
113
+ const api = source ? SUPPORTED_APIS.get(source.api) : undefined;
114
+ if (!parsed || !source || !api) {
115
+ omit(value);
116
+ continue;
117
+ }
118
+ models.push({
119
+ ...source,
120
+ id: publicId,
121
+ provider: "tinyllm",
122
+ api,
123
+ baseUrl: apiBaseUrl(baseUrl, api),
124
+ compat: routedCompat(source, api),
125
+ });
126
+ }
127
+
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
+ }
132
+ return models;
133
+ }
134
+
135
+ export async function discoverModels(
136
+ baseUrl: string,
137
+ apiKey: string,
138
+ signal: AbortSignal,
139
+ fetchImpl: typeof fetch = fetch,
140
+ warn: Warn = console.warn,
141
+ ): Promise<Model<Api>[]> {
142
+ const response = await fetchImpl(`${normalizeBaseUrl(baseUrl)}/v1/models`, {
143
+ headers: { Authorization: `Bearer ${apiKey}` },
144
+ signal,
145
+ });
146
+ if (!response.ok) throw new Error(`TinyLLM model discovery failed with HTTP ${response.status}`);
147
+
148
+ let payload: unknown;
149
+ try {
150
+ payload = await response.json();
151
+ } catch (error) {
152
+ signal.throwIfAborted();
153
+ throw new Error("TinyLLM model discovery returned invalid JSON", { cause: error });
154
+ }
155
+ if (!payload || typeof payload !== "object" || !("data" in payload) || !Array.isArray(payload.data)) {
156
+ throw new Error("TinyLLM model discovery returned an invalid payload");
157
+ }
158
+ return mapDiscoveredModels(
159
+ payload.data.map((entry) =>
160
+ entry && typeof entry === "object" && "id" in entry ? (entry as { id?: unknown }).id : undefined,
161
+ ),
162
+ baseUrl,
163
+ warn,
164
+ );
165
+ }
166
+
167
+ function routedStreams(delegate: ProviderStreams, api: Api): ProviderStreams {
168
+ const route = <T extends Model<Api>>(model: T): T =>
169
+ ({ ...model, api, provider: "tinyllm", baseUrl: apiBaseUrl(model.baseUrl, api) }) as T;
170
+ const routeOptions = <T extends { apiKey?: string; headers?: Record<string, string> } | undefined>(options: T): T => {
171
+ if (api !== "anthropic-messages" || !options?.apiKey) return options;
172
+ return {
173
+ ...options,
174
+ headers: { ...options.headers, Authorization: `Bearer ${options.apiKey}` },
175
+ } as T;
176
+ };
177
+ return {
178
+ stream: (model, context, options) => delegate.stream(route(model), context, routeOptions(options)),
179
+ streamSimple: (model, context, options) => delegate.streamSimple(route(model), context, routeOptions(options)),
180
+ };
181
+ }
182
+
183
+ export function createTinyllmProvider(options: TinyllmProviderOptions = {}): Provider<Api> {
184
+ const defaultBaseUrl = normalizeBaseUrl(options.baseUrl ?? process.env.TINYLLM_BASE_URL ?? DEFAULT_BASE_URL);
185
+ const warn = options.warn ?? console.warn;
186
+ const fetchImpl = options.fetch ?? fetch;
187
+
188
+ return createProvider<Api>({
189
+ id: "tinyllm",
190
+ name: "TinyLLM",
191
+ baseUrl: defaultBaseUrl,
192
+ auth: {
193
+ apiKey: {
194
+ name: "TinyLLM API key",
195
+ async login({ prompt, signal }) {
196
+ signal.throwIfAborted();
197
+ const enteredBaseUrl = await prompt({
198
+ type: "text",
199
+ message: "TinyLLM URL",
200
+ placeholder: defaultBaseUrl,
201
+ });
202
+ signal.throwIfAborted();
203
+ const key = await prompt({ type: "secret", message: "TinyLLM API key" });
204
+ signal.throwIfAborted();
205
+ return {
206
+ type: "api_key",
207
+ key,
208
+ env: { TINYLLM_BASE_URL: normalizeBaseUrl(enteredBaseUrl || defaultBaseUrl) },
209
+ };
210
+ },
211
+ async resolve({ ctx, credential, signal }) {
212
+ signal.throwIfAborted();
213
+ const key = credential?.key ?? (await ctx.env("TINYLLM_API_KEY"));
214
+ signal.throwIfAborted();
215
+ if (!key) return undefined;
216
+ const configuredBaseUrl = credential?.env?.TINYLLM_BASE_URL ?? (await ctx.env("TINYLLM_BASE_URL"));
217
+ signal.throwIfAborted();
218
+ const baseUrl = normalizeBaseUrl(configuredBaseUrl ?? defaultBaseUrl);
219
+ return {
220
+ auth: { apiKey: key, baseUrl },
221
+ env: { TINYLLM_BASE_URL: baseUrl },
222
+ source: credential?.key !== undefined ? "Stored API key" : "TINYLLM_API_KEY",
223
+ };
224
+ },
225
+ },
226
+ },
227
+ models: [],
228
+ fetchModels: async ({ credential, signal }) => {
229
+ if (credential?.type !== "api_key" || !credential.key) {
230
+ throw new Error("TinyLLM API key is not configured");
231
+ }
232
+ return discoverModels(
233
+ credential.env?.TINYLLM_BASE_URL ?? defaultBaseUrl,
234
+ credential.key,
235
+ signal,
236
+ fetchImpl,
237
+ warn,
238
+ );
239
+ },
240
+ api: {
241
+ "anthropic-messages": routedStreams(anthropicMessagesApi(), "anthropic-messages"),
242
+ "openai-responses": routedStreams(openAIResponsesApi(), "openai-responses"),
243
+ "openai-completions": routedStreams(openAICompletionsApi(), "openai-completions"),
244
+ },
245
+ });
246
+ }
247
+
248
+ export async function bootstrapProvider(provider: Provider<Api>, apiKey: string, baseUrl: string): Promise<void> {
249
+ if (!provider.refreshModels) return;
250
+ const signal = AbortSignal.timeout(5_000);
251
+ await provider.refreshModels({
252
+ credential: { type: "api_key", key: apiKey, env: { TINYLLM_BASE_URL: normalizeBaseUrl(baseUrl) } },
253
+ allowNetwork: true,
254
+ signal,
255
+ async publish({ update }) {
256
+ update?.();
257
+ return true;
258
+ },
259
+ });
260
+ }
261
+
262
+ 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");
264
+ }
265
+
266
+ export function rewriteFastPayload(
267
+ payload: unknown,
268
+ enabled: boolean,
269
+ model: Pick<Model<Api>, "provider" | "id"> | undefined,
270
+ ): unknown {
271
+ if (!enabled || !isFastCapable(model) || !payload || typeof payload !== "object" || Array.isArray(payload)) {
272
+ return payload;
273
+ }
274
+ const request = payload as Record<string, unknown>;
275
+ if (typeof request.model !== "string" || request.model !== model.id || request.model.endsWith("-fast")) return payload;
276
+ return { ...request, model: `${request.model}-fast` };
277
+ }
278
+
279
+ const FAST_STATE = "tinyllm-fast-mode";
280
+
281
+ export function registerFastMode(pi: ExtensionAPI): void {
282
+ let enabled = false;
283
+ const restore = (ctx: ExtensionContext) => {
284
+ enabled = false;
285
+ for (const entry of ctx.sessionManager.getBranch()) {
286
+ if (entry.type === "custom" && entry.customType === FAST_STATE) {
287
+ enabled = (entry.data as { enabled?: unknown } | undefined)?.enabled === true;
288
+ }
289
+ }
290
+ };
291
+
292
+ pi.on("session_start", (_event, ctx) => restore(ctx));
293
+ pi.on("session_tree", (_event, ctx) => restore(ctx));
294
+ pi.on("before_provider_request", (event, ctx) => rewriteFastPayload(event.payload, enabled, ctx.model));
295
+ pi.registerCommand("fast", {
296
+ description: "Toggle TinyLLM fast routing for the selected OpenAI GPT model",
297
+ handler: async (_args, ctx) => {
298
+ if (!isFastCapable(ctx.model)) {
299
+ ctx.ui.notify("/fast is only available for TinyLLM openai/gpt-* models", "warning");
300
+ return;
301
+ }
302
+ enabled = !enabled;
303
+ pi.appendEntry(FAST_STATE, { enabled });
304
+ ctx.ui.notify(`TinyLLM fast routing ${enabled ? "enabled" : "disabled"}`, "info");
305
+ },
306
+ });
307
+ }
308
+
309
+ export default async function tinyllmExtension(pi: ExtensionAPI): Promise<void> {
310
+ registerFastMode(pi);
311
+ const baseUrl = process.env.TINYLLM_BASE_URL ?? DEFAULT_BASE_URL;
312
+ const provider = createTinyllmProvider({ baseUrl });
313
+ const apiKey = process.env.TINYLLM_API_KEY;
314
+ if (apiKey) {
315
+ try {
316
+ await bootstrapProvider(provider, apiKey, baseUrl);
317
+ } catch (error) {
318
+ console.warn(`TinyLLM startup discovery unavailable: ${error instanceof Error ? error.message : String(error)}`);
319
+ }
320
+ }
321
+ pi.registerProvider(provider);
322
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "pi-tinyllm",
3
+ "version": "0.1.0",
4
+ "description": "Dynamic TinyLLM provider package for Pi",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/mipsel64/pi-tinyllm.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/mipsel64/pi-tinyllm/issues"
13
+ },
14
+ "homepage": "https://github.com/mipsel64/pi-tinyllm#readme",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "extensions"
20
+ ],
21
+ "keywords": [
22
+ "pi-package"
23
+ ],
24
+ "pi": {
25
+ "extensions": [
26
+ "./extensions/tinyllm.ts"
27
+ ]
28
+ },
29
+ "scripts": {
30
+ "test": "node --test --experimental-strip-types test/*.test.ts",
31
+ "prepublishOnly": "npm test"
32
+ },
33
+ "engines": {
34
+ "node": ">=22.19.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-ai": "*",
38
+ "@earendil-works/pi-coding-agent": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@earendil-works/pi-ai": "0.87.1",
42
+ "@earendil-works/pi-coding-agent": "0.87.1"
43
+ }
44
+ }