@preapexis/pi-kit 1.0.13 → 1.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.
@@ -0,0 +1,302 @@
1
+ // cSpell:words litellm preapexis deepseek
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ type LiteLlmModelInfo = {
5
+ model_name?: string;
6
+ litellm_params?: {
7
+ model?: string;
8
+ };
9
+ model_info?: {
10
+ id?: string;
11
+ name?: string;
12
+ display_name?: string;
13
+ description?: string;
14
+ max_tokens?: number;
15
+ max_input_tokens?: number;
16
+ context_window?: number;
17
+ input_cost_per_token?: number;
18
+ output_cost_per_token?: number;
19
+ supports_vision?: boolean;
20
+ supports_function_calling?: boolean;
21
+ mode?: string;
22
+ };
23
+ };
24
+
25
+ type OpenAiModel = {
26
+ id?: string;
27
+ object?: string;
28
+ owned_by?: string;
29
+ };
30
+
31
+ type ModelPayload = {
32
+ data?: Array<LiteLlmModelInfo | OpenAiModel>;
33
+ };
34
+
35
+ type PiModel = {
36
+ id: string;
37
+ name: string;
38
+ reasoning: boolean;
39
+ input: Array<"text" | "image">;
40
+ cost: {
41
+ input: number;
42
+ output: number;
43
+ cacheRead: number;
44
+ cacheWrite: number;
45
+ };
46
+ contextWindow: number;
47
+ maxTokens: number;
48
+ compat?: {
49
+ supportsDeveloperRole?: boolean;
50
+ supportsReasoningEffort?: boolean;
51
+ };
52
+ };
53
+
54
+ const PROVIDER_ID = "litellm";
55
+ const DEFAULT_BASE_URL = "http://localhost:4000/v1";
56
+
57
+ export default async function (pi: ExtensionAPI): Promise<void> {
58
+ const baseUrl = normalizeBaseUrl(
59
+ process.env.LITELLM_BASE_URL ?? DEFAULT_BASE_URL
60
+ );
61
+
62
+ async function registerLiteLlmProvider(): Promise<void> {
63
+ const models = await discoverModels(baseUrl);
64
+
65
+ pi.registerProvider(PROVIDER_ID, {
66
+ name: "LiteLLM",
67
+ baseUrl,
68
+ apiKey: "$LITELLM_API_KEY",
69
+ api: "openai-completions",
70
+ models
71
+ });
72
+ }
73
+
74
+ await registerLiteLlmProvider();
75
+
76
+ pi.registerCommand("litellm-refresh", {
77
+ description: "Refresh LiteLLM models from the LiteLLM proxy",
78
+ handler: async (_args, ctx) => {
79
+ try {
80
+ await registerLiteLlmProvider();
81
+
82
+ ctx.ui.notify(
83
+ [
84
+ "LiteLLM models refreshed.",
85
+ "",
86
+ `Base URL: ${baseUrl}`,
87
+ "",
88
+ "Run /model to select a LiteLLM model."
89
+ ].join("\n"),
90
+ "info"
91
+ );
92
+ } catch (error) {
93
+ ctx.ui.notify(
94
+ [
95
+ "LiteLLM model refresh failed.",
96
+ "",
97
+ error instanceof Error ? error.message : String(error)
98
+ ].join("\n"),
99
+ "error"
100
+ );
101
+ }
102
+ }
103
+ });
104
+ }
105
+
106
+ function normalizeBaseUrl(value: string): string {
107
+ return value.replace(/\/+$/, "");
108
+ }
109
+
110
+ function getProxyRoot(baseUrl: string): string {
111
+ return baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl;
112
+ }
113
+
114
+ async function discoverModels(baseUrl: string): Promise<PiModel[]> {
115
+ const proxyRoot = getProxyRoot(baseUrl);
116
+
117
+ const endpoints = [
118
+ `${proxyRoot}/v1/model/info`,
119
+ `${proxyRoot}/model/info`,
120
+ `${baseUrl}/models`
121
+ ];
122
+
123
+ for (const endpoint of endpoints) {
124
+ try {
125
+ const payload = await fetchModelPayload(endpoint);
126
+ const models = payloadToPiModels(payload);
127
+
128
+ if (models.length > 0) {
129
+ return models;
130
+ }
131
+ } catch {
132
+ // Try the next endpoint.
133
+ }
134
+ }
135
+
136
+ const fallbackModels = getFallbackModels();
137
+
138
+ if (fallbackModels.length > 0) {
139
+ return fallbackModels;
140
+ }
141
+
142
+ throw new Error(
143
+ [
144
+ "Could not discover LiteLLM models.",
145
+ "",
146
+ `Tried: ${endpoints.join(", ")}`,
147
+ "",
148
+ "Make sure LiteLLM is running and set LITELLM_API_KEY if your proxy requires auth.",
149
+ "You can also set LITELLM_MODELS as a comma-separated fallback."
150
+ ].join("\n")
151
+ );
152
+ }
153
+
154
+ async function fetchModelPayload(url: string): Promise<ModelPayload> {
155
+ const headers: Record<string, string> = {
156
+ accept: "application/json"
157
+ };
158
+
159
+ const apiKey = process.env.LITELLM_API_KEY;
160
+
161
+ if (apiKey) {
162
+ headers.authorization = `Bearer ${apiKey}`;
163
+ }
164
+
165
+ const response = await fetch(url, {
166
+ method: "GET",
167
+ headers
168
+ });
169
+
170
+ if (!response.ok) {
171
+ throw new Error(`${url} returned HTTP ${response.status}`);
172
+ }
173
+
174
+ return (await response.json()) as ModelPayload;
175
+ }
176
+
177
+ function payloadToPiModels(payload: ModelPayload): PiModel[] {
178
+ const data = Array.isArray(payload.data) ? payload.data : [];
179
+ const seen = new Set<string>();
180
+ const models: PiModel[] = [];
181
+
182
+ for (const item of data) {
183
+ const model = modelFromPayloadItem(item);
184
+
185
+ if (!model) continue;
186
+ if (seen.has(model.id)) continue;
187
+
188
+ seen.add(model.id);
189
+ models.push(model);
190
+ }
191
+
192
+ return models.sort((a, b) => a.id.localeCompare(b.id));
193
+ }
194
+
195
+ function modelFromPayloadItem(
196
+ item: LiteLlmModelInfo | OpenAiModel
197
+ ): PiModel | undefined {
198
+ const modelInfo = "model_info" in item ? item.model_info : undefined;
199
+
200
+ const id =
201
+ "model_name" in item && item.model_name
202
+ ? item.model_name
203
+ : "id" in item && item.id
204
+ ? item.id
205
+ : modelInfo?.id;
206
+
207
+ if (!id) return undefined;
208
+
209
+ const name = modelInfo?.display_name ?? modelInfo?.name ?? id;
210
+
211
+ const contextWindow =
212
+ modelInfo?.context_window ?? modelInfo?.max_input_tokens ?? 128000;
213
+
214
+ const maxTokens = modelInfo?.max_tokens ?? 4096;
215
+
216
+ const supportsVision =
217
+ modelInfo?.supports_vision === true || looksLikeVisionModel(id);
218
+
219
+ return {
220
+ id,
221
+ name,
222
+ reasoning: looksLikeReasoningModel(id),
223
+ input: supportsVision ? ["text", "image"] : ["text"],
224
+ cost: {
225
+ input: costPerMillion(modelInfo?.input_cost_per_token),
226
+ output: costPerMillion(modelInfo?.output_cost_per_token),
227
+ cacheRead: 0,
228
+ cacheWrite: 0
229
+ },
230
+ contextWindow,
231
+ maxTokens,
232
+ compat: {
233
+ supportsDeveloperRole: false,
234
+ supportsReasoningEffort: false
235
+ }
236
+ };
237
+ }
238
+
239
+ function costPerMillion(value: number | undefined): number {
240
+ if (typeof value !== "number" || Number.isNaN(value)) {
241
+ return 0;
242
+ }
243
+
244
+ return value * 1_000_000;
245
+ }
246
+
247
+ function looksLikeVisionModel(id: string): boolean {
248
+ const lower = id.toLowerCase();
249
+
250
+ return (
251
+ lower.includes("vision") ||
252
+ lower.includes("vl") ||
253
+ lower.includes("gpt-4o") ||
254
+ lower.includes("gemini") ||
255
+ lower.includes("claude")
256
+ );
257
+ }
258
+
259
+ function looksLikeReasoningModel(id: string): boolean {
260
+ const lower = id.toLowerCase();
261
+
262
+ return (
263
+ lower.includes("reason") ||
264
+ lower.includes("thinking") ||
265
+ lower.includes("o1") ||
266
+ lower.includes("o3") ||
267
+ lower.includes("o4") ||
268
+ lower.includes("r1") ||
269
+ lower.includes("deepseek")
270
+ );
271
+ }
272
+
273
+ function getFallbackModels(): PiModel[] {
274
+ const raw = process.env.LITELLM_MODELS;
275
+
276
+ if (!raw?.trim()) {
277
+ return [];
278
+ }
279
+
280
+ return raw
281
+ .split(",")
282
+ .map((value) => value.trim())
283
+ .filter(Boolean)
284
+ .map((id) => ({
285
+ id,
286
+ name: id,
287
+ reasoning: looksLikeReasoningModel(id),
288
+ input: looksLikeVisionModel(id) ? ["text", "image"] : ["text"],
289
+ cost: {
290
+ input: 0,
291
+ output: 0,
292
+ cacheRead: 0,
293
+ cacheWrite: 0
294
+ },
295
+ contextWindow: 128000,
296
+ maxTokens: 4096,
297
+ compat: {
298
+ supportsDeveloperRole: false,
299
+ supportsReasoningEffort: false
300
+ }
301
+ }));
302
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preapexis/pi-kit",
3
- "version": "1.0.13",
3
+ "version": "1.1.0",
4
4
  "description": "Personal Pi coding-agent kit with safety extensions, status UI, prompt workflows, skills, and themes.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -33,7 +33,7 @@
33
33
  "scripts": {
34
34
  "test": "vitest run",
35
35
  "test:watch": "vitest",
36
- "release": "npm version patch && git push --follow-tags"
36
+ "git": "node scripts/git-release.mjs"
37
37
  },
38
38
  "devDependencies": {
39
39
  "typescript": "^5.5.0",
@@ -0,0 +1,144 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+ import readline from "node:readline/promises";
5
+
6
+ const rl = readline.createInterface({ input, output });
7
+
8
+ const message =
9
+ process.env.npm_config_msg ||
10
+ process.argv.slice(2).join(" ") ||
11
+ "chore: release";
12
+
13
+ function run(command, args) {
14
+ console.log(`\n> ${command} ${args.join(" ")}\n`);
15
+
16
+ const result = spawnSync(command, args, {
17
+ stdio: "inherit",
18
+ shell: true
19
+ });
20
+
21
+ if (result.status !== 0) {
22
+ console.error(`\nCommand failed: ${command} ${args.join(" ")}`);
23
+ process.exit(result.status ?? 1);
24
+ }
25
+ }
26
+
27
+ async function askYesNo(question, defaultValue = false) {
28
+ const suffix = defaultValue ? "Y/n" : "y/N";
29
+ const answer = (await rl.question(`${question} (${suffix}): `))
30
+ .trim()
31
+ .toLowerCase();
32
+
33
+ if (!answer) return defaultValue;
34
+
35
+ return answer === "y" || answer === "yes";
36
+ }
37
+
38
+ async function askVersionType() {
39
+ while (true) {
40
+ const answer = (await rl.question("Version bump type? patch/minor/major: "))
41
+ .trim()
42
+ .toLowerCase();
43
+
44
+ if (["patch", "minor", "major"].includes(answer)) {
45
+ return answer;
46
+ }
47
+
48
+ console.log("Please type patch, minor, or major.");
49
+ }
50
+ }
51
+
52
+ function readJson(path) {
53
+ return JSON.parse(readFileSync(path, "utf-8"));
54
+ }
55
+
56
+ function writeJson(path, data) {
57
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
58
+ }
59
+
60
+ function bumpVersion(version, type) {
61
+ const parts = version.split(".").map(Number);
62
+
63
+ if (parts.length !== 3 || parts.some(Number.isNaN)) {
64
+ throw new Error(`Invalid version: ${version}`);
65
+ }
66
+
67
+ if (type === "major") {
68
+ parts[0] += 1;
69
+ parts[1] = 0;
70
+ parts[2] = 0;
71
+ }
72
+
73
+ if (type === "minor") {
74
+ parts[1] += 1;
75
+ parts[2] = 0;
76
+ }
77
+
78
+ if (type === "patch") {
79
+ parts[2] += 1;
80
+ }
81
+
82
+ return parts.join(".");
83
+ }
84
+
85
+ function updatePackageVersion(nextVersion) {
86
+ const pkg = readJson("package.json");
87
+
88
+ pkg.version = nextVersion;
89
+ writeJson("package.json", pkg);
90
+
91
+ if (existsSync("package-lock.json")) {
92
+ const lock = readJson("package-lock.json");
93
+
94
+ if (lock.version) {
95
+ lock.version = nextVersion;
96
+ }
97
+
98
+ if (lock.packages?.[""]) {
99
+ lock.packages[""].version = nextVersion;
100
+ }
101
+
102
+ writeJson("package-lock.json", lock);
103
+ }
104
+ }
105
+
106
+ try {
107
+ const shouldBump = await askYesNo(
108
+ "Do you want to bump package version?",
109
+ true
110
+ );
111
+ let nextVersion = null;
112
+ let tag = null;
113
+
114
+ if (shouldBump) {
115
+ const bumpType = await askVersionType();
116
+ const pkg = readJson("package.json");
117
+
118
+ nextVersion = bumpVersion(pkg.version, bumpType);
119
+ tag = `v${nextVersion}`;
120
+
121
+ updatePackageVersion(nextVersion);
122
+
123
+ console.log(`\nBumped version to ${nextVersion}\n`);
124
+ }
125
+
126
+ const shouldPublish = await askYesNo("Do you want to publish to npm?", false);
127
+
128
+ run("git", ["add", "."]);
129
+ run("git", ["commit", "-m", `"${message}"`]);
130
+
131
+ if (tag) {
132
+ run("git", ["tag", tag]);
133
+ }
134
+
135
+ run("git", ["push", "--follow-tags"]);
136
+
137
+ if (shouldPublish) {
138
+ run("npm", ["publish", "--access", "public"]);
139
+ }
140
+
141
+ console.log("\nDone.\n");
142
+ } finally {
143
+ rl.close();
144
+ }
@@ -1,49 +1,61 @@
1
- import { describe, it, expect } from "vitest";
2
- import * as path from "path";
3
- import {
4
- listFiles,
5
- readFile,
6
- checkTypeScriptSyntax,
7
- hasDefaultExport
8
- } from "./helpers";
1
+ import * as path from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { checkTypeScriptSyntax, listFiles, readFile } from "./helpers.js";
4
+
5
+ function hasPiDefaultExport(source: string): boolean {
6
+ return (
7
+ /export\s+default\s+async\s+function\b/.test(source) ||
8
+ /export\s+default\s+function\b/.test(source) ||
9
+ /export\s+default\s+\(/.test(source) ||
10
+ /export\s*\{[^}]*\bas\s+default\b[^}]*\}/.test(source)
11
+ );
12
+ }
9
13
 
10
14
  describe("extensions", () => {
11
15
  const extDir = path.resolve("extensions");
12
16
 
13
17
  it("should have at least one extension file", async () => {
14
18
  const files = await listFiles(extDir);
15
- const tsFiles = files.filter((f) => f.endsWith(".ts"));
19
+ const tsFiles = files.filter((file: string) => file.endsWith(".ts"));
20
+
16
21
  expect(tsFiles.length).toBeGreaterThan(0);
17
22
  });
18
23
 
19
- it("should export a default function", async () => {
24
+ it("should export a default extension function", async () => {
20
25
  const files = await listFiles(extDir);
21
- const tsFiles = files.filter((f) => f.endsWith(".ts"));
26
+ const tsFiles = files.filter((file: string) => file.endsWith(".ts"));
22
27
 
23
28
  for (const file of tsFiles) {
24
29
  const source = await readFile(file);
25
- expect(hasDefaultExport(source)).toBe(true);
30
+
31
+ expect(
32
+ hasPiDefaultExport(source),
33
+ `${path.basename(file)} should export a default function`
34
+ ).toBe(true);
26
35
  }
27
36
  });
28
37
 
29
- it("should have no unexpected imports", async () => {
38
+ it("should have valid TypeScript syntax", async () => {
30
39
  const files = await listFiles(extDir);
31
- const tsFiles = files.filter((f) => f.endsWith(".ts"));
40
+ const tsFiles = files.filter((file: string) => file.endsWith(".ts"));
32
41
 
33
42
  for (const file of tsFiles) {
34
43
  const source = await readFile(file);
35
44
  const result = checkTypeScriptSyntax(source, path.basename(file));
36
- expect(result.errors).toEqual([]);
45
+
46
+ expect(result.errors, `${path.basename(file)} has syntax errors`).toEqual(
47
+ []
48
+ );
37
49
  }
38
50
  });
39
51
 
40
- it("should not contain duplicate responsibilities (by name)", async () => {
52
+ it("should not contain duplicate extension file names", async () => {
41
53
  const files = await listFiles(extDir);
42
- const tsFiles = files.filter((f) => f.endsWith(".ts"));
54
+ const tsFiles = files.filter((file: string) => file.endsWith(".ts"));
43
55
 
44
- // Each extension should have a unique, focused name
45
- const names = tsFiles.map((f) => path.basename(f, ".ts"));
56
+ const names = tsFiles.map((file: string) => path.basename(file, ".ts"));
46
57
  const uniqueNames = new Set(names);
58
+
47
59
  expect(uniqueNames.size).toBe(names.length);
48
60
  });
49
61
  });