pi-provider-freellmapi 1.0.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 dolfly
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,74 @@
1
+ # pi-provider-freellmapi
2
+
3
+ A [pi](https://pi.dev) extension that registers the **FreeLLM API** gateway
4
+ ([freeapi.n.cofire.cn](https://freeapi.n.cofire.cn)) as an OpenAI-compatible
5
+ provider, with automatic model discovery.
6
+
7
+ It only exposes models your API key can actually use (the gateway reports
8
+ ~250 models, but most are `available: false` for a free key — this extension
9
+ filters those out), and it reads each model's real context window and
10
+ reasoning/vision capabilities.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pi install npm:pi-provider-freellmapi
16
+ ```
17
+
18
+ ## Configure
19
+
20
+ Copy the example config to `~/.pi/agent/freellmapi.json` and fill in your key:
21
+
22
+ ```bash
23
+ cp "$(pi config-dir 2>/dev/null || echo ~/.pi/agent)/freellmapi.json" freellmapi.example.json
24
+ # or just:
25
+ mkdir -p ~/.pi/agent
26
+ cp freellmapi.example.json ~/.pi/agent/freellmapi.json
27
+ ```
28
+
29
+ ```json
30
+ {
31
+ "name": "freellmapi",
32
+ "label": "FreeLM API",
33
+ "baseUrl": "https://freeapi.n.cofire.cn/v1",
34
+ "apiKey": "freellmapi-...",
35
+ "fetchModels": true,
36
+ "contextWindow": 131072,
37
+ "maxTokens": 16384,
38
+ "compat": { "supportsDeveloperRole": false }
39
+ }
40
+ ```
41
+
42
+ The `apiKey` supports pi's value syntax: a literal, `$ENV_VAR`, `${ENV_VAR}`,
43
+ or `!command`. You can also skip the config file entirely and just export
44
+ `FREELM_API_KEY`.
45
+
46
+ - Override the config path with `PI_FREELMAPI_CONFIG=/path/to/config.json`.
47
+ - Set `"fetchModels": false` to use only the static `models` list.
48
+
49
+ ## Usage
50
+
51
+ ```bash
52
+ pi --provider freellmapi --model auto
53
+ pi --provider freellmapi --model deepseek-v4-flash
54
+ ```
55
+
56
+ Or open `/model` in the TUI and pick from the `FreeLM API` group.
57
+
58
+ ## Config reference
59
+
60
+ | Field | Default | Description |
61
+ |-------|---------|-------------|
62
+ | `name` | `freellmapi` | Provider id (`--provider <name>`) |
63
+ | `label` | `FreeLM API` | Display label shown in `/model` |
64
+ | `baseUrl` | `https://freeapi.n.cofire.cn/v1` | OpenAI-compatible endpoint |
65
+ | `apiKey` | `$FREELM_API_KEY` | API key (literal / env / command) |
66
+ | `fetchModels` | `true` | Discover models from `{baseUrl}/models` |
67
+ | `contextWindow` | `131072` | Fallback context window per model |
68
+ | `maxTokens` | `16384` | Fallback max output tokens |
69
+ | `compat` | `{"supportsDeveloperRole":false}` | Provider compatibility flags |
70
+ | `models` | `[]` | Static models (override discovered models by id) |
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "freellmapi",
3
+ "label": "FreeLM API",
4
+ "baseUrl": "https://freeapi.n.cofire.cn/v1",
5
+ "apiKey": "$FREELLM_API_KEY",
6
+ "fetchModels": true,
7
+ "contextWindow": 131072,
8
+ "maxTokens": 16384,
9
+ "compat": {
10
+ "supportsDeveloperRole": false
11
+ },
12
+ "models": []
13
+ }
package/index.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * pi-provider-freellmapi — register the FreeLLM API gateway as a pi provider.
3
+ *
4
+ * This extension registers `https://freeapi.n.cofire.cn/v1` as an
5
+ * OpenAI-compatible (`openai-completions`) provider and auto-discovers the
6
+ * models your API key can actually use.
7
+ *
8
+ * ## Install
9
+ *
10
+ * ```bash
11
+ * pi install npm:pi-provider-freellmapi
12
+ * ```
13
+ *
14
+ * Then create the config file `~/.pi/agent/freellmapi.json`:
15
+ *
16
+ * ```json
17
+ * {
18
+ * "name": "freellmapi", // provider id → --provider freellmapi
19
+ * "label": "FreeLM API", // display label in /model
20
+ * "baseUrl": "https://freeapi.n.cofire.cn/v1",
21
+ * "apiKey": "freellmapi-...", // literal, $ENV_VAR, or ${ENV_VAR}
22
+ * "fetchModels": true, // discover models from {baseUrl}/models
23
+ * "contextWindow": 131072, // fallback context window (tokens)
24
+ * "maxTokens": 16384, // fallback max output tokens
25
+ * "compat": { "supportsDeveloperRole": false },
26
+ * "models": [] // optional static models (override by id)
27
+ * }
28
+ * ```
29
+ *
30
+ * Config path override: `PI_FREELLMAPI_CONFIG=/path/to/config.json`.
31
+ * API key fallback env var: `FREELLM_API_KEY` (used when the config file has
32
+ * no `apiKey`).
33
+ *
34
+ * Only models the gateway reports as `available` (with a positive
35
+ * `context_window`) are registered, so the list stays limited to models that
36
+ * work with your key. Reasoning support is detected from the gateway's
37
+ * `supported_parameters` (`reasoning_effort`) or the model name; vision models
38
+ * get `input: ["text", "image"]`.
39
+ */
40
+
41
+ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
42
+ import { readFileSync, existsSync } from "node:fs";
43
+ import { homedir } from "node:os";
44
+ import { resolve } from "node:path";
45
+
46
+ // ── Types ──────────────────────────────────────────────────────────────────
47
+
48
+ interface RemoteModel {
49
+ id: string;
50
+ name?: string;
51
+ context_window?: number | null;
52
+ available?: boolean;
53
+ supported_parameters?: string[];
54
+ }
55
+
56
+ interface ConfigFile {
57
+ name?: string;
58
+ label?: string;
59
+ baseUrl?: string;
60
+ apiKey?: string;
61
+ fetchModels?: boolean;
62
+ contextWindow?: number;
63
+ maxTokens?: number;
64
+ compat?: Record<string, unknown>;
65
+ models?: ProviderModelConfig[];
66
+ }
67
+
68
+ // ── Defaults ───────────────────────────────────────────────────────────────
69
+
70
+ const DEFAULT_CONFIG_PATH = resolve(homedir(), ".pi", "agent", "freellmapi.json");
71
+
72
+ const DEFAULTS = {
73
+ name: "freellmapi",
74
+ label: "FreeLM API",
75
+ baseUrl: "https://freeapi.n.cofire.cn/v1",
76
+ contextWindow: 131072,
77
+ maxTokens: 16384,
78
+ } as const;
79
+
80
+ /** Fallback models when discovery fails (or fetchModels is off and no static list). */
81
+ const FALLBACK_MODELS: Array<{ id: string; name: string }> = [
82
+ { id: "auto", name: "Auto (router picks best available model)" },
83
+ { id: "fusion", name: "Fusion (panel of models + judge)" },
84
+ { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
85
+ { id: "qwen3.7-flash", name: "Qwen3.7 Flash" },
86
+ { id: "claude-sonnet-4-5", name: "Claude Sonnet slot" },
87
+ ];
88
+
89
+ // ── Helpers ────────────────────────────────────────────────────────────────
90
+
91
+ function loadConfig(configPath: string): ConfigFile {
92
+ if (!existsSync(configPath)) {
93
+ return {};
94
+ }
95
+ try {
96
+ return JSON.parse(readFileSync(configPath, "utf-8")) as ConfigFile;
97
+ } catch (err) {
98
+ console.error(`[freellmapi] Failed to parse config: ${configPath}`, err);
99
+ return {};
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Resolve an apiKey value that may use pi's value syntax:
105
+ * - `${ENV_VAR}` / `$ENV_VAR` (whole value) → environment variable
106
+ * - `!command` → returned as-is (pi resolves it at request time)
107
+ * - literal string → returned as-is
108
+ */
109
+ function resolveApiKey(raw: string): string {
110
+ if (raw.startsWith("!")) return raw;
111
+ const braced = raw.match(/^\$\{([^}]+)\}$/);
112
+ if (braced) return process.env[braced[1]] ?? "";
113
+ if (raw.startsWith("$") && !raw.startsWith("$$")) {
114
+ return process.env[raw.slice(1)] ?? raw;
115
+ }
116
+ return raw;
117
+ }
118
+
119
+ function isVision(name: string, id: string): boolean {
120
+ return /vision|vl|image|moondream/i.test(`${name} ${id}`);
121
+ }
122
+
123
+ /** A model supports thinking if the gateway advertises reasoning_effort, or the name hints at it. */
124
+ function isReasoning(name: string, id: string, params: string[]): boolean {
125
+ return params.includes("reasoning_effort") || /reasoning|thinking/i.test(`${name} ${id}`);
126
+ }
127
+
128
+ async function fetchModelsFromEndpoint(
129
+ baseUrl: string,
130
+ apiKey?: string,
131
+ ): Promise<RemoteModel[]> {
132
+ const url = baseUrl.replace(/\/+$/, "") + "/models";
133
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
134
+ if (apiKey) headers["Authorization"] = `Bearer ${resolveApiKey(apiKey)}`;
135
+
136
+ const response = await fetch(url, { headers });
137
+ if (!response.ok) {
138
+ throw new Error(`HTTP ${response.status} from ${url}`);
139
+ }
140
+ const payload = (await response.json()) as { data?: RemoteModel[] };
141
+ return Array.isArray(payload.data) ? payload.data : [];
142
+ }
143
+
144
+ function mapRemoteModel(
145
+ m: RemoteModel,
146
+ contextWindow: number,
147
+ maxTokens: number,
148
+ ): ProviderModelConfig {
149
+ const name = m.name?.trim() || m.id;
150
+ const window = typeof m.context_window === "number" && m.context_window > 0
151
+ ? m.context_window
152
+ : contextWindow;
153
+ const params = m.supported_parameters ?? [];
154
+ return {
155
+ id: m.id,
156
+ name,
157
+ reasoning: isReasoning(name, m.id, params),
158
+ input: isVision(name, m.id) ? ["text", "image"] : ["text"],
159
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
160
+ contextWindow: window,
161
+ maxTokens: Math.min(maxTokens, window),
162
+ };
163
+ }
164
+
165
+ function mergeModels(
166
+ staticModels: ProviderModelConfig[],
167
+ discoveredModels: ProviderModelConfig[],
168
+ ): ProviderModelConfig[] {
169
+ const map = new Map<string, ProviderModelConfig>();
170
+ for (const m of discoveredModels) map.set(m.id, m);
171
+ // Static models override discovered ones by id.
172
+ for (const m of staticModels) map.set(m.id, m);
173
+ return [...map.values()];
174
+ }
175
+
176
+ // ── Extension Entry Point ──────────────────────────────────────────────────
177
+
178
+ export default async function (pi: ExtensionAPI) {
179
+ const configPath = process.env["PI_FREELLMAPI_CONFIG"] ?? DEFAULT_CONFIG_PATH;
180
+ const config = loadConfig(configPath);
181
+
182
+ const providerId = config.name ?? DEFAULTS.name;
183
+ const baseUrl = (config.baseUrl ?? DEFAULTS.baseUrl).replace(/\/+$/, "");
184
+ const apiKey = config.apiKey ?? process.env["FREELLM_API_KEY"];
185
+ const contextWindow = config.contextWindow ?? DEFAULTS.contextWindow;
186
+ const maxTokens = config.maxTokens ?? DEFAULTS.maxTokens;
187
+ const staticModels = config.models ?? [];
188
+
189
+ let discovered: ProviderModelConfig[] = [];
190
+ if (config.fetchModels !== false) {
191
+ try {
192
+ const remote = await fetchModelsFromEndpoint(baseUrl, apiKey);
193
+ discovered = remote
194
+ .filter((m) => m.available !== false)
195
+ .filter((m) => typeof m.context_window === "number" && m.context_window > 0)
196
+ .map((m) => mapRemoteModel(m, contextWindow, maxTokens));
197
+ } catch (err) {
198
+ console.warn(
199
+ `[freellmapi] Could not fetch models from ${baseUrl}:`,
200
+ err instanceof Error ? err.message : err,
201
+ );
202
+ }
203
+ }
204
+
205
+ let models = mergeModels(staticModels, discovered);
206
+
207
+ if (models.length === 0) {
208
+ console.warn(`[freellmapi] No models discovered; registering fallback list`);
209
+ models = FALLBACK_MODELS.map(({ id, name }) => ({
210
+ id,
211
+ name,
212
+ reasoning: /reasoning|thinking/i.test(name),
213
+ input: ["text"] as ("text" | "image")[],
214
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
215
+ contextWindow,
216
+ maxTokens: Math.min(maxTokens, contextWindow),
217
+ }));
218
+ }
219
+
220
+ const providerConfig: Record<string, unknown> = {
221
+ name: config.label ?? DEFAULTS.label,
222
+ baseUrl,
223
+ apiKey,
224
+ api: "openai-completions",
225
+ models,
226
+ compat: {
227
+ supportsDeveloperRole: false,
228
+ ...(config.compat ?? {}),
229
+ },
230
+ };
231
+
232
+ pi.registerProvider(providerId, providerConfig as never);
233
+ console.log(
234
+ `[freellmapi] Registered "${providerId}" → ${baseUrl} (${models.length} model(s))`,
235
+ );
236
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "pi-provider-freellmapi",
3
+ "version": "1.0.0",
4
+ "description": "Register the FreeLLM API gateway (freeapi.n.cofire.cn) as an OpenAI-compatible provider in pi, with automatic model discovery",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-package",
8
+ "pi-extension",
9
+ "provider",
10
+ "openai",
11
+ "llm",
12
+ "freellmapi",
13
+ "free"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "dolfly",
17
+ "files": [
18
+ "index.ts",
19
+ "README.md",
20
+ "LICENSE",
21
+ "freellmapi.example.json"
22
+ ],
23
+ "pi": {
24
+ "extensions": [
25
+ "./index.ts"
26
+ ]
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-ai": "*",
30
+ "@earendil-works/pi-agent-core": "*",
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*",
33
+ "typebox": "*"
34
+ }
35
+ }