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 +21 -0
- package/README.md +74 -0
- package/freellmapi.example.json +13 -0
- package/index.ts +236 -0
- package/package.json +35 -0
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
|
+
}
|