pi-provider-freellmapi 1.0.2 → 1.0.4
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 +48 -4
- package/freellmapi.example.json +30 -1
- package/index.ts +678 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,6 +15,16 @@ reasoning/vision capabilities.
|
|
|
15
15
|
pi install npm:pi-provider-freellmapi
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
The extension registers three pi providers — all sharing the same model list from the gateway — so you can pick whichever API shape suits your use case:
|
|
19
|
+
|
|
20
|
+
| Provider | API type | Endpoint |
|
|
21
|
+
|----------|----------|----------|
|
|
22
|
+
| `freellmapi` | `openai-completions` | `/v1/chat/completions` |
|
|
23
|
+
| `freellmapi-responses` | `openai-responses` | `/v1/responses` |
|
|
24
|
+
| `freellmapi-anthropic` | `anthropic-messages` | `/v1/messages` |
|
|
25
|
+
|
|
26
|
+
Switch provider in the TUI with `/model`, or from the CLI:
|
|
27
|
+
|
|
18
28
|
## Configure
|
|
19
29
|
|
|
20
30
|
Copy the example config to `~/.pi/agent/freellmapi.json` and fill in your key:
|
|
@@ -35,7 +45,14 @@ cp freellmapi.example.json ~/.pi/agent/freellmapi.json
|
|
|
35
45
|
"fetchModels": true,
|
|
36
46
|
"contextWindow": 131072,
|
|
37
47
|
"maxTokens": 16384,
|
|
38
|
-
"compat": { "supportsDeveloperRole": false }
|
|
48
|
+
"compat": { "supportsDeveloperRole": false },
|
|
49
|
+
"endpoints": {
|
|
50
|
+
"embeddings": { "enabled": true, "model": "auto" },
|
|
51
|
+
"images": { "enabled": true, "model": "auto" },
|
|
52
|
+
"videos": { "enabled": true, "model": "inkling" },
|
|
53
|
+
"speech": { "enabled": true, "model": "auto" },
|
|
54
|
+
"transcriptions": { "enabled": true, "model": "whisper-1" }
|
|
55
|
+
}
|
|
39
56
|
}
|
|
40
57
|
```
|
|
41
58
|
|
|
@@ -43,18 +60,43 @@ The `apiKey` supports pi's value syntax: a literal, `$ENV_VAR`, `${ENV_VAR}`,
|
|
|
43
60
|
or `!command`. You can also skip the config file entirely and just export
|
|
44
61
|
`FREELLM_API_KEY`.
|
|
45
62
|
|
|
46
|
-
- Override the config path with `
|
|
63
|
+
- Override the config path with `PI_FREELLMAPI_CONFIG=/path/to/config.json`.
|
|
47
64
|
- Set `"fetchModels": false` to use only the static `models` list.
|
|
48
65
|
|
|
49
66
|
## Usage
|
|
50
67
|
|
|
51
68
|
```bash
|
|
52
|
-
pi --provider freellmapi --model auto
|
|
53
|
-
pi --provider freellmapi --model
|
|
69
|
+
pi --provider freellmapi-responses --model auto
|
|
70
|
+
pi --provider freellmapi-anthropic --model auto
|
|
54
71
|
```
|
|
55
72
|
|
|
56
73
|
Or open `/model` in the TUI and pick from the `FreeLLM API` group.
|
|
57
74
|
|
|
75
|
+
## Endpoint tools
|
|
76
|
+
|
|
77
|
+
In addition to the chat provider, the extension registers custom tools that
|
|
78
|
+
wrap the gateway's other OpenAI-compatible endpoints. They are enabled by
|
|
79
|
+
default; set `"enabled": false` (`"endpoints": { "images": false }`) or set a
|
|
80
|
+
default `model` per endpoint to customize them.
|
|
81
|
+
|
|
82
|
+
| Tool | Endpoint | What it does |
|
|
83
|
+
|------|----------|--------------|
|
|
84
|
+
| `freellm_embed` | `POST /embeddings` | Generates embeddings; writes JSON to a file |
|
|
85
|
+
| `freellm_image` | `POST /images/generations` | Generates image(s), returned inline |
|
|
86
|
+
| `freellm_video` | `POST /videos/generations` | Generates a video, polls the task, downloads the file |
|
|
87
|
+
| `freellm_speech` | `POST /audio/speech` | Synthesizes speech, writes audio to a file |
|
|
88
|
+
| `freellm_transcribe` | `POST /audio/transcriptions` | Transcribes a local path or URL, returns text |
|
|
89
|
+
|
|
90
|
+
Each tool accepts an optional `model` argument. When omitted, the extension
|
|
91
|
+
picks a default from (in priority order): the endpoint's `model` config, model
|
|
92
|
+
ids discovered from `{baseUrl}/models` matching that endpoint, or a
|
|
93
|
+
conventional OpenAI id (e.g. `auto` for embeddings/images/speech, `inkling`, `whisper-1`). If the gateway rejects a default, set the
|
|
94
|
+
endpoint's `model` in config.
|
|
95
|
+
|
|
96
|
+
Files written by the tools (embeddings JSON, images are returned inline, video,
|
|
97
|
+
speech audio) land in the session working directory with `freellm-*` names, or
|
|
98
|
+
an `outputFile` path you pass to the tool.
|
|
99
|
+
|
|
58
100
|
## Config reference
|
|
59
101
|
|
|
60
102
|
| Field | Default | Description |
|
|
@@ -68,6 +110,8 @@ Or open `/model` in the TUI and pick from the `FreeLLM API` group.
|
|
|
68
110
|
| `maxTokens` | `16384` | Fallback max output tokens |
|
|
69
111
|
| `compat` | `{"supportsDeveloperRole":false}` | Provider compatibility flags |
|
|
70
112
|
| `models` | `[]` | Static models (override discovered models by id) |
|
|
113
|
+
| `headers` | `{}` | Extra headers merged into every tool request |
|
|
114
|
+
| `endpoints` | all enabled | Per-endpoint `enabled` flag and default `model` |
|
|
71
115
|
|
|
72
116
|
## License
|
|
73
117
|
|
package/freellmapi.example.json
CHANGED
|
@@ -9,5 +9,34 @@
|
|
|
9
9
|
"compat": {
|
|
10
10
|
"supportsDeveloperRole": false
|
|
11
11
|
},
|
|
12
|
-
"models": []
|
|
12
|
+
"models": [],
|
|
13
|
+
"headers": {},
|
|
14
|
+
"endpoints": {
|
|
15
|
+
"embeddings": {
|
|
16
|
+
"enabled": true,
|
|
17
|
+
"model": "auto"
|
|
18
|
+
},
|
|
19
|
+
"images": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"model": "auto"
|
|
22
|
+
},
|
|
23
|
+
"videos": {
|
|
24
|
+
"enabled": true,
|
|
25
|
+
"model": "inkling"
|
|
26
|
+
},
|
|
27
|
+
"speech": {
|
|
28
|
+
"enabled": true,
|
|
29
|
+
"model": "auto"
|
|
30
|
+
},
|
|
31
|
+
"transcriptions": {
|
|
32
|
+
"enabled": true,
|
|
33
|
+
"model": "whisper-1"
|
|
34
|
+
},
|
|
35
|
+
"responses": {
|
|
36
|
+
"enabled": true
|
|
37
|
+
},
|
|
38
|
+
"anthropic": {
|
|
39
|
+
"enabled": true
|
|
40
|
+
}
|
|
41
|
+
}
|
|
13
42
|
}
|
package/index.ts
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-provider-freellmapi — register the FreeLLM API gateway as a pi provider.
|
|
3
3
|
*
|
|
4
|
-
* This extension registers `https://freeapi.n.cofire.cn/v1` as
|
|
5
|
-
* OpenAI-compatible
|
|
6
|
-
*
|
|
4
|
+
* This extension registers `https://freeapi.n.cofire.cn/v1` as three
|
|
5
|
+
* OpenAI-compatible providers and auto-discovers the models your API key can
|
|
6
|
+
* actually use:
|
|
7
|
+
*
|
|
8
|
+
* | Provider | API type | Endpoint |
|
|
9
|
+
* |----------|----------|----------|
|
|
10
|
+
* | `freellmapi` | `openai-completions` | `/v1/chat/completions` |
|
|
11
|
+
* | `freellmapi-responses` | `openai-responses` | `/v1/responses` |
|
|
12
|
+
* | `freellmapi-anthropic` | `anthropic-messages` | `/v1/messages` |
|
|
13
|
+
*
|
|
14
|
+
* It also registers a set of custom tools that wrap the other OpenAI-compatible
|
|
15
|
+
* endpoints the gateway exposes, so the agent can use them directly:
|
|
16
|
+
*
|
|
17
|
+
* - `POST /embeddings` → `freellm_embed`
|
|
18
|
+
* - `POST /images/generations` → `freellm_image`
|
|
19
|
+
* - `POST /videos/generations` → `freellm_video` (tasks + polling)
|
|
20
|
+
* - `POST /audio/speech` → `freellm_speech`
|
|
21
|
+
* - `POST /audio/transcriptions` → `freellm_transcribe`
|
|
22
|
+
*
|
|
23
|
+
* Each tool is enabled by default and can be turned off (or given a default
|
|
24
|
+
* model) via the `endpoints` block in the config file.
|
|
7
25
|
*
|
|
8
26
|
* ## Install
|
|
9
27
|
*
|
|
@@ -23,7 +41,14 @@
|
|
|
23
41
|
* "contextWindow": 131072, // fallback context window (tokens)
|
|
24
42
|
* "maxTokens": 16384, // fallback max output tokens
|
|
25
43
|
* "compat": { "supportsDeveloperRole": false },
|
|
26
|
-
* "models": []
|
|
44
|
+
* "models": [], // optional static models (override by id)
|
|
45
|
+
* "endpoints": {
|
|
46
|
+
* "embeddings": { "enabled": true, "model": "auto" },
|
|
47
|
+
* "images": { "enabled": true, "model": "auto" },
|
|
48
|
+
* "videos": { "enabled": true, "model": "inkling" },
|
|
49
|
+
* "speech": { "enabled": true, "model": "auto" },
|
|
50
|
+
* "transcriptions": { "enabled": true, "model": "whisper-1" }
|
|
51
|
+
* }
|
|
27
52
|
* }
|
|
28
53
|
* ```
|
|
29
54
|
*
|
|
@@ -39,9 +64,13 @@
|
|
|
39
64
|
*/
|
|
40
65
|
|
|
41
66
|
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
42
|
-
import {
|
|
67
|
+
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
68
|
+
import { Type } from "typebox";
|
|
69
|
+
import { execSync } from "node:child_process";
|
|
70
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
71
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
43
72
|
import { homedir } from "node:os";
|
|
44
|
-
import { resolve } from "node:path";
|
|
73
|
+
import { basename, dirname, extname, resolve } from "node:path";
|
|
45
74
|
|
|
46
75
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
47
76
|
|
|
@@ -53,6 +82,25 @@ interface RemoteModel {
|
|
|
53
82
|
supported_parameters?: string[];
|
|
54
83
|
}
|
|
55
84
|
|
|
85
|
+
interface EndpointConfig {
|
|
86
|
+
/** Whether the corresponding tool is registered (default: true). */
|
|
87
|
+
enabled?: boolean;
|
|
88
|
+
/** Default model id used when a tool call omits `model`. */
|
|
89
|
+
model?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface EndpointsConfig {
|
|
93
|
+
embeddings?: EndpointConfig | boolean;
|
|
94
|
+
images?: EndpointConfig | boolean;
|
|
95
|
+
videos?: EndpointConfig | boolean;
|
|
96
|
+
speech?: EndpointConfig | boolean;
|
|
97
|
+
transcriptions?: EndpointConfig | boolean;
|
|
98
|
+
/** OpenAI Responses API (`/v1/responses`). */
|
|
99
|
+
responses?: EndpointConfig | boolean;
|
|
100
|
+
/** Anthropic Messages API (`/v1/messages`). */
|
|
101
|
+
anthropic?: EndpointConfig | boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
interface ConfigFile {
|
|
57
105
|
name?: string;
|
|
58
106
|
label?: string;
|
|
@@ -61,8 +109,20 @@ interface ConfigFile {
|
|
|
61
109
|
fetchModels?: boolean;
|
|
62
110
|
contextWindow?: number;
|
|
63
111
|
maxTokens?: number;
|
|
112
|
+
/** Extra request headers merged into every tool request. */
|
|
113
|
+
headers?: Record<string, string>;
|
|
64
114
|
compat?: Record<string, unknown>;
|
|
65
115
|
models?: ProviderModelConfig[];
|
|
116
|
+
endpoints?: EndpointsConfig;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type ToolContent = (TextContent | ImageContent)[];
|
|
120
|
+
|
|
121
|
+
interface HttpInit {
|
|
122
|
+
method?: string;
|
|
123
|
+
headers?: Record<string, string>;
|
|
124
|
+
body?: string | FormData;
|
|
125
|
+
signal?: AbortSignal;
|
|
66
126
|
}
|
|
67
127
|
|
|
68
128
|
// ── Defaults ───────────────────────────────────────────────────────────────
|
|
@@ -86,6 +146,12 @@ const FALLBACK_MODELS: Array<{ id: string; name: string }> = [
|
|
|
86
146
|
{ id: "claude-sonnet-4-5", name: "Claude Sonnet slot" },
|
|
87
147
|
];
|
|
88
148
|
|
|
149
|
+
const VIDEO_TERMINAL_STATUSES = new Set([
|
|
150
|
+
"completed", "succeeded", "success", "done",
|
|
151
|
+
"failed", "error", "cancelled", "canceled", "killed", "rejected",
|
|
152
|
+
]);
|
|
153
|
+
const VIDEO_SUCCESS_STATUSES = new Set(["completed", "succeeded", "success", "done"]);
|
|
154
|
+
|
|
89
155
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
90
156
|
|
|
91
157
|
function loadConfig(configPath: string): ConfigFile {
|
|
@@ -100,6 +166,13 @@ function loadConfig(configPath: string): ConfigFile {
|
|
|
100
166
|
}
|
|
101
167
|
}
|
|
102
168
|
|
|
169
|
+
/** Normalize `endpoints.<x>` which may be a boolean or an object. */
|
|
170
|
+
function normalizeEndpoint(raw: EndpointConfig | boolean | undefined): EndpointConfig {
|
|
171
|
+
if (raw === undefined) return {};
|
|
172
|
+
if (typeof raw === "boolean") return { enabled: raw };
|
|
173
|
+
return raw;
|
|
174
|
+
}
|
|
175
|
+
|
|
103
176
|
/**
|
|
104
177
|
* Resolve an apiKey value that may use pi's value syntax:
|
|
105
178
|
* - `${ENV_VAR}` / `$ENV_VAR` (whole value) → environment variable
|
|
@@ -116,6 +189,18 @@ function resolveApiKey(raw: string): string {
|
|
|
116
189
|
return raw;
|
|
117
190
|
}
|
|
118
191
|
|
|
192
|
+
/** Resolve an apiKey for a direct tool request, executing `!command` if needed. */
|
|
193
|
+
function resolveApiKeyValue(raw: string): string {
|
|
194
|
+
if (raw.startsWith("!")) {
|
|
195
|
+
try {
|
|
196
|
+
return execSync(raw.slice(1), { encoding: "utf8", timeout: 30000 }).trim();
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw new Error(`[freellmapi] Failed to execute apiKey command: ${raw.slice(1)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return resolveApiKey(raw);
|
|
202
|
+
}
|
|
203
|
+
|
|
119
204
|
function isVision(name: string, id: string): boolean {
|
|
120
205
|
return /vision|vl|image|moondream/i.test(`${name} ${id}`);
|
|
121
206
|
}
|
|
@@ -173,6 +258,166 @@ function mergeModels(
|
|
|
173
258
|
return [...map.values()];
|
|
174
259
|
}
|
|
175
260
|
|
|
261
|
+
/** Pick the first available model id matching any of the patterns, else the fallback. */
|
|
262
|
+
function pickDefaultModel(ids: string[], patterns: RegExp[], fallback: string): string {
|
|
263
|
+
for (const pattern of patterns) {
|
|
264
|
+
const match = ids.find((id) => pattern.test(id));
|
|
265
|
+
if (match) return match;
|
|
266
|
+
}
|
|
267
|
+
return fallback;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function guessMimeFromExt(nameOrUrl: string): string | undefined {
|
|
271
|
+
const ext = extname(nameOrUrl.split("?")[0].split("#")[0]).toLowerCase();
|
|
272
|
+
const map: Record<string, string> = {
|
|
273
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
274
|
+
".webp": "image/webp", ".gif": "image/gif",
|
|
275
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".oga": "audio/ogg",
|
|
276
|
+
".opus": "audio/opus", ".flac": "audio/flac", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
|
277
|
+
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".mpeg": "video/mpeg",
|
|
278
|
+
};
|
|
279
|
+
return map[ext];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function guessVideoExt(url: string): string {
|
|
283
|
+
const ext = extname(url.split("?")[0].split("#")[0]).toLowerCase();
|
|
284
|
+
return [".mp4", ".webm", ".mov", ".mpeg", ".mkv"].includes(ext) ? ext : ".mp4";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function encodeBase64(bytes: Uint8Array): string {
|
|
288
|
+
return Buffer.from(bytes).toString("base64");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
292
|
+
|
|
293
|
+
async function readErrorBody(res: Response): Promise<string> {
|
|
294
|
+
const text = await res.text();
|
|
295
|
+
try {
|
|
296
|
+
const json = JSON.parse(text) as { error?: { message?: string }; message?: string };
|
|
297
|
+
return json?.error?.message ?? json?.message ?? text;
|
|
298
|
+
} catch {
|
|
299
|
+
return text;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Unified request helper: auth + config headers + JSON/multipart body handling. */
|
|
304
|
+
async function httpRequest(
|
|
305
|
+
baseUrl: string,
|
|
306
|
+
apiKey: string | undefined,
|
|
307
|
+
extraHeaders: Record<string, string> | undefined,
|
|
308
|
+
path: string,
|
|
309
|
+
init: HttpInit = {},
|
|
310
|
+
): Promise<Response> {
|
|
311
|
+
const reqHeaders = init.headers ?? {};
|
|
312
|
+
const isForm = typeof (init.body as FormData | undefined)?.append === "function";
|
|
313
|
+
|
|
314
|
+
const merged: Record<string, string> = { ...(extraHeaders ?? {}) };
|
|
315
|
+
for (const [k, v] of Object.entries(reqHeaders)) merged[k] = v;
|
|
316
|
+
|
|
317
|
+
const resolvedKey = apiKey ? resolveApiKeyValue(apiKey) : undefined;
|
|
318
|
+
if (resolvedKey && !("Authorization" in merged) && !("authorization" in merged)) {
|
|
319
|
+
merged["Authorization"] = `Bearer ${resolvedKey}`;
|
|
320
|
+
}
|
|
321
|
+
if (!isForm && !("Content-Type" in merged) && !("content-type" in merged)) {
|
|
322
|
+
merged["Content-Type"] = "application/json";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return fetch(baseUrl.replace(/\/+$/, "") + path, { ...init, headers: merged });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function writeBytes(targetPath: string, bytes: Uint8Array): Promise<void> {
|
|
329
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
330
|
+
await writeFile(targetPath, bytes);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function downloadBytes(url: string, signal?: AbortSignal): Promise<Uint8Array> {
|
|
334
|
+
const res = await fetch(url, { signal });
|
|
335
|
+
if (!res.ok) throw new Error(`Download failed (HTTP ${res.status}): ${url}`);
|
|
336
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function imageUrlToBase64(url: string): Promise<{ data: string; mimeType: string }> {
|
|
340
|
+
const bytes = await downloadBytes(url);
|
|
341
|
+
const mimeType = guessMimeFromExt(url) ?? "image/png";
|
|
342
|
+
return { data: encodeBase64(bytes), mimeType };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Lenient scan of a video task response for a downloadable URL. */
|
|
346
|
+
function findVideoUrl(value: unknown): string | undefined {
|
|
347
|
+
const seen = new Set<object>();
|
|
348
|
+
const stack: unknown[] = [value];
|
|
349
|
+
while (stack.length) {
|
|
350
|
+
const cur = stack.pop();
|
|
351
|
+
if (!cur || typeof cur !== "object" || seen.has(cur as object)) continue;
|
|
352
|
+
seen.add(cur as object);
|
|
353
|
+
|
|
354
|
+
if (Array.isArray(cur)) {
|
|
355
|
+
stack.push(...cur);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const record = cur as Record<string, unknown>;
|
|
360
|
+
for (const key of ["url", "download_url", "video_url", "file_url", "output_url", "play_url", "link", "href"]) {
|
|
361
|
+
const v = record[key];
|
|
362
|
+
if (typeof v === "string" && /^https?:\/\//i.test(v)) return v;
|
|
363
|
+
}
|
|
364
|
+
for (const key of ["output", "outputs", "result", "results", "video", "videos", "data"]) {
|
|
365
|
+
const v = record[key];
|
|
366
|
+
if (typeof v === "string" && /^https?:\/\//i.test(v)) return v;
|
|
367
|
+
if (v && typeof v === "object") stack.push(v);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function extractTaskId(payload: unknown): string | undefined {
|
|
374
|
+
const p = payload as Record<string, unknown> & {
|
|
375
|
+
data?: Array<Record<string, unknown> | string>;
|
|
376
|
+
};
|
|
377
|
+
const first = p?.data?.[0];
|
|
378
|
+
if (typeof first === "string") return first;
|
|
379
|
+
if (first && typeof first === "object" && typeof first["id"] === "string") return first["id"];
|
|
380
|
+
if (typeof p?.id === "string") return p.id;
|
|
381
|
+
if (typeof p?.task_id === "string") return p.task_id;
|
|
382
|
+
if (typeof p?.video_id === "string") return p.video_id;
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function extractVideoStatus(payload: unknown): string {
|
|
387
|
+
const p = payload as {
|
|
388
|
+
status?: unknown;
|
|
389
|
+
data?: Array<{ status?: unknown }>;
|
|
390
|
+
};
|
|
391
|
+
const raw = p?.status ?? p?.data?.[0]?.status ?? "";
|
|
392
|
+
return String(raw ?? "").toLowerCase();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function resolveAudioFile(
|
|
396
|
+
input: string,
|
|
397
|
+
cwd: string,
|
|
398
|
+
signal?: AbortSignal,
|
|
399
|
+
): Promise<{ bytes: Uint8Array; filename: string; mimeType: string }> {
|
|
400
|
+
if (/^https?:\/\//i.test(input)) {
|
|
401
|
+
const res = await fetch(input, { signal });
|
|
402
|
+
if (!res.ok) throw new Error(`Failed to fetch audio (HTTP ${res.status}): ${input}`);
|
|
403
|
+
const filename = basename(new URL(input).pathname) || "audio.mp3";
|
|
404
|
+
return {
|
|
405
|
+
bytes: new Uint8Array(await res.arrayBuffer()),
|
|
406
|
+
filename,
|
|
407
|
+
mimeType: guessMimeFromExt(filename) ?? "audio/mpeg",
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const path = resolve(cwd, input);
|
|
412
|
+
if (!existsSync(path)) throw new Error(`Audio file not found: ${path}`);
|
|
413
|
+
const buffer = await readFile(path);
|
|
414
|
+
return {
|
|
415
|
+
bytes: new Uint8Array(buffer),
|
|
416
|
+
filename: basename(path),
|
|
417
|
+
mimeType: guessMimeFromExt(path) ?? "audio/mpeg",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
176
421
|
// ── Extension Entry Point ──────────────────────────────────────────────────
|
|
177
422
|
|
|
178
423
|
export default async function (pi: ExtensionAPI) {
|
|
@@ -185,15 +430,32 @@ export default async function (pi: ExtensionAPI) {
|
|
|
185
430
|
const contextWindow = config.contextWindow ?? DEFAULTS.contextWindow;
|
|
186
431
|
const maxTokens = config.maxTokens ?? DEFAULTS.maxTokens;
|
|
187
432
|
const staticModels = config.models ?? [];
|
|
433
|
+
const extraHeaders = config.headers ?? {};
|
|
188
434
|
|
|
189
|
-
|
|
190
|
-
|
|
435
|
+
const endpoints: EndpointsConfig = config.endpoints ?? {};
|
|
436
|
+
const embeddingsCfg = normalizeEndpoint(endpoints.embeddings);
|
|
437
|
+
const imagesCfg = normalizeEndpoint(endpoints.images);
|
|
438
|
+
const videosCfg = normalizeEndpoint(endpoints.videos);
|
|
439
|
+
const speechCfg = normalizeEndpoint(endpoints.speech);
|
|
440
|
+
const transcriptionsCfg = normalizeEndpoint(endpoints.transcriptions);
|
|
441
|
+
const responsesCfg = normalizeEndpoint(endpoints.responses);
|
|
442
|
+
const anthropicCfg = normalizeEndpoint(endpoints.anthropic);
|
|
443
|
+
|
|
444
|
+
const endpointEnabled =
|
|
445
|
+
embeddingsCfg.enabled !== false ||
|
|
446
|
+
imagesCfg.enabled !== false ||
|
|
447
|
+
videosCfg.enabled !== false ||
|
|
448
|
+
speechCfg.enabled !== false ||
|
|
449
|
+
transcriptionsCfg.enabled !== false ||
|
|
450
|
+
responsesCfg.enabled !== false ||
|
|
451
|
+
anthropicCfg.enabled !== false;
|
|
452
|
+
|
|
453
|
+
// ── Model discovery (used for both chat models and endpoint defaults) ────
|
|
454
|
+
let remote: RemoteModel[] = [];
|
|
455
|
+
const shouldFetchModels = config.fetchModels !== false || endpointEnabled;
|
|
456
|
+
if (shouldFetchModels) {
|
|
191
457
|
try {
|
|
192
|
-
|
|
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));
|
|
458
|
+
remote = await fetchModelsFromEndpoint(baseUrl, apiKey);
|
|
197
459
|
} catch (err) {
|
|
198
460
|
console.warn(
|
|
199
461
|
`[freellmapi] Could not fetch models from ${baseUrl}:`,
|
|
@@ -202,6 +464,18 @@ export default async function (pi: ExtensionAPI) {
|
|
|
202
464
|
}
|
|
203
465
|
}
|
|
204
466
|
|
|
467
|
+
const availableIds = remote
|
|
468
|
+
.filter((m) => m.available !== false)
|
|
469
|
+
.map((m) => m.id);
|
|
470
|
+
|
|
471
|
+
let discovered: ProviderModelConfig[] = [];
|
|
472
|
+
if (config.fetchModels !== false) {
|
|
473
|
+
discovered = remote
|
|
474
|
+
.filter((m) => m.available !== false)
|
|
475
|
+
.filter((m) => typeof m.context_window === "number" && m.context_window > 0)
|
|
476
|
+
.map((m) => mapRemoteModel(m, contextWindow, maxTokens));
|
|
477
|
+
}
|
|
478
|
+
|
|
205
479
|
let models = mergeModels(staticModels, discovered);
|
|
206
480
|
|
|
207
481
|
if (models.length === 0) {
|
|
@@ -217,6 +491,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
217
491
|
}));
|
|
218
492
|
}
|
|
219
493
|
|
|
494
|
+
// ── Register chat provider ───────────────────────────────────────────────
|
|
220
495
|
const providerConfig: Record<string, unknown> = {
|
|
221
496
|
name: config.label ?? DEFAULTS.label,
|
|
222
497
|
baseUrl,
|
|
@@ -233,4 +508,393 @@ export default async function (pi: ExtensionAPI) {
|
|
|
233
508
|
console.log(
|
|
234
509
|
`[freellmapi] Registered "${providerId}" → ${baseUrl} (${models.length} model(s))`,
|
|
235
510
|
);
|
|
236
|
-
|
|
511
|
+
|
|
512
|
+
// ── Register additional API-type providers ───────────────────────────────
|
|
513
|
+
// The gateway is OpenAI-compatible but also supports the Responses and
|
|
514
|
+
// Anthropic Messages API shapes at /v1/responses and /v1/messages.
|
|
515
|
+
|
|
516
|
+
if (responsesCfg.enabled !== false) {
|
|
517
|
+
const responsesProviderId = `${providerId}-responses`;
|
|
518
|
+
pi.registerProvider(responsesProviderId, {
|
|
519
|
+
name: `${config.label ?? DEFAULTS.label} (Responses)`,
|
|
520
|
+
baseUrl,
|
|
521
|
+
apiKey,
|
|
522
|
+
api: "openai-responses",
|
|
523
|
+
models,
|
|
524
|
+
compat: { supportsDeveloperRole: false, ...(config.compat ?? {}) },
|
|
525
|
+
} as never);
|
|
526
|
+
console.log(`[freellmapi] Registered "${responsesProviderId}" → openai-responses (${models.length} model(s))`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (anthropicCfg.enabled !== false) {
|
|
530
|
+
const anthropicProviderId = `${providerId}-anthropic`;
|
|
531
|
+
pi.registerProvider(anthropicProviderId, {
|
|
532
|
+
name: `${config.label ?? DEFAULTS.label} (Anthropic)`,
|
|
533
|
+
baseUrl,
|
|
534
|
+
apiKey,
|
|
535
|
+
api: "anthropic-messages",
|
|
536
|
+
models,
|
|
537
|
+
compat: { supportsDeveloperRole: false, ...(config.compat ?? {}) },
|
|
538
|
+
} as never);
|
|
539
|
+
console.log(`[freellmapi] Registered "${anthropicProviderId}" → anthropic-messages (${models.length} model(s))`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── Register endpoint tools ──────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
// Default model per endpoint: explicit config wins, then discovery, then a
|
|
545
|
+
// conventional OpenAI id (which may need overriding for this gateway).
|
|
546
|
+
const embeddingsDefault =
|
|
547
|
+
embeddingsCfg.model ??
|
|
548
|
+
pickDefaultModel(availableIds, [/embed|embedding/i], "auto");
|
|
549
|
+
const imagesDefault =
|
|
550
|
+
imagesCfg.model ??
|
|
551
|
+
pickDefaultModel(availableIds, [/gemini.*image|flux|dall|sdxl|stable/i], "auto");
|
|
552
|
+
const videosDefault =
|
|
553
|
+
videosCfg.model ??
|
|
554
|
+
pickDefaultModel(availableIds, [/video|inkling|sora|veo|kling|hailuo|wan|pixverse|runway|pika|ray/i], "inkling");
|
|
555
|
+
const speechDefault =
|
|
556
|
+
speechCfg.model ??
|
|
557
|
+
pickDefaultModel(availableIds, [/tts|speech|voice|cosy|melotts|fish|bark/i], "auto");
|
|
558
|
+
const transcriptionsDefault =
|
|
559
|
+
transcriptionsCfg.model ??
|
|
560
|
+
pickDefaultModel(availableIds, [/whisper|transcri|asr|sensevoice|paraformer|audio/i], "whisper-1");
|
|
561
|
+
|
|
562
|
+
if (embeddingsCfg.enabled !== false) {
|
|
563
|
+
pi.registerTool({
|
|
564
|
+
name: "freellm_embed",
|
|
565
|
+
label: "Embed text",
|
|
566
|
+
description:
|
|
567
|
+
"Generate vector embeddings via the FreeLLM gateway (POST /embeddings). " +
|
|
568
|
+
"Useful for semantic search, clustering, similarity, or retrieval tasks.",
|
|
569
|
+
promptSnippet: "Generate text embeddings via the FreeLLM gateway (POST /embeddings)",
|
|
570
|
+
promptGuidelines: [
|
|
571
|
+
"Use freellm_embed when the task calls for semantic search, clustering, similarity, or retrieval embeddings rather than a chat completion.",
|
|
572
|
+
],
|
|
573
|
+
parameters: Type.Object({
|
|
574
|
+
input: Type.Union([
|
|
575
|
+
Type.String({ description: "Single text to embed" }),
|
|
576
|
+
Type.Array(Type.String({ description: "Text to embed" })),
|
|
577
|
+
]),
|
|
578
|
+
model: Type.Optional(Type.String({ description: `Embedding model id (default: ${embeddingsDefault})` })),
|
|
579
|
+
dimensions: Type.Optional(Type.Integer({ description: "Optional output dimension" })),
|
|
580
|
+
encodingFormat: Type.Optional(Type.String({ description: '"float" or "base64"' })),
|
|
581
|
+
outputFile: Type.Optional(Type.String({ description: "Path for the JSON result (default: freellm-embeddings-<ts>.json in cwd)" })),
|
|
582
|
+
}),
|
|
583
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
584
|
+
const model = params.model ?? embeddingsDefault;
|
|
585
|
+
const body: Record<string, unknown> = { model, input: params.input };
|
|
586
|
+
if (params.dimensions) body["dimensions"] = params.dimensions;
|
|
587
|
+
if (params.encodingFormat) body["encoding_format"] = params.encodingFormat;
|
|
588
|
+
|
|
589
|
+
const res = await httpRequest(baseUrl, apiKey, extraHeaders, "/embeddings", {
|
|
590
|
+
method: "POST",
|
|
591
|
+
body: JSON.stringify(body),
|
|
592
|
+
signal,
|
|
593
|
+
});
|
|
594
|
+
if (!res.ok) {
|
|
595
|
+
throw new Error(`[freellmapi] /embeddings failed (HTTP ${res.status}): ${await readErrorBody(res)}`);
|
|
596
|
+
}
|
|
597
|
+
const payload = (await res.json()) as { data?: Array<{ embedding?: unknown[] }>; model?: string; usage?: unknown };
|
|
598
|
+
const data = payload.data ?? [];
|
|
599
|
+
const dimension = data[0]?.embedding?.length ?? 0;
|
|
600
|
+
const outFile = resolve(ctx.cwd, params.outputFile ?? `freellm-embeddings-${Date.now()}.json`);
|
|
601
|
+
await writeBytes(outFile, new TextEncoder().encode(JSON.stringify(payload, null, 2)));
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
content: [{
|
|
605
|
+
type: "text",
|
|
606
|
+
text: `Generated ${data.length} embedding(s) of dimension ${dimension} using model "${payload.model ?? model}". Saved to ${outFile}`,
|
|
607
|
+
}],
|
|
608
|
+
details: { outputFile: outFile, count: data.length, dimension, usage: payload.usage },
|
|
609
|
+
};
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (imagesCfg.enabled !== false) {
|
|
615
|
+
pi.registerTool({
|
|
616
|
+
name: "freellm_image",
|
|
617
|
+
label: "Generate image",
|
|
618
|
+
description:
|
|
619
|
+
"Generate images via the FreeLLM gateway (POST /images/generations). " +
|
|
620
|
+
"Returns the image(s) inline so they can be viewed and described.",
|
|
621
|
+
promptSnippet: "Generate images via the FreeLLM gateway (POST /images/generations)",
|
|
622
|
+
promptGuidelines: [
|
|
623
|
+
"Use freellm_image when the user asks to create, draw, or generate an image.",
|
|
624
|
+
],
|
|
625
|
+
parameters: Type.Object({
|
|
626
|
+
prompt: Type.String({ description: "Image prompt" }),
|
|
627
|
+
model: Type.Optional(Type.String({ description: `Image model id (default: ${imagesDefault})` })),
|
|
628
|
+
n: Type.Optional(Type.Integer({ description: "Number of images (1-10)" })),
|
|
629
|
+
size: Type.Optional(Type.String({ description: 'e.g. "1024x1024"' })),
|
|
630
|
+
quality: Type.Optional(Type.String({ description: 'e.g. "standard" or "hd"' })),
|
|
631
|
+
style: Type.Optional(Type.String({ description: 'e.g. "vivid" or "natural"' })),
|
|
632
|
+
responseFormat: Type.Optional(Type.String({ description: '"b64_json" (default) or "url"' })),
|
|
633
|
+
}),
|
|
634
|
+
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
|
|
635
|
+
const model = params.model ?? imagesDefault;
|
|
636
|
+
const body: Record<string, unknown> = {
|
|
637
|
+
model,
|
|
638
|
+
prompt: params.prompt,
|
|
639
|
+
n: params.n ?? 1,
|
|
640
|
+
response_format: params.responseFormat ?? "b64_json",
|
|
641
|
+
};
|
|
642
|
+
if (params.size) body["size"] = params.size;
|
|
643
|
+
if (params.quality) body["quality"] = params.quality;
|
|
644
|
+
if (params.style) body["style"] = params.style;
|
|
645
|
+
|
|
646
|
+
const res = await httpRequest(baseUrl, apiKey, extraHeaders, "/images/generations", {
|
|
647
|
+
method: "POST",
|
|
648
|
+
body: JSON.stringify(body),
|
|
649
|
+
signal,
|
|
650
|
+
});
|
|
651
|
+
if (!res.ok) {
|
|
652
|
+
throw new Error(`[freellmapi] /images/generations failed (HTTP ${res.status}): ${await readErrorBody(res)}`);
|
|
653
|
+
}
|
|
654
|
+
const payload = (await res.json()) as {
|
|
655
|
+
data?: Array<{ b64_json?: string; url?: string; revised_prompt?: string }>;
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const content: ToolContent = [];
|
|
659
|
+
const images: Array<{ mimeType: string; data: string; revisedPrompt?: string }> = [];
|
|
660
|
+
const items = payload.data ?? [];
|
|
661
|
+
|
|
662
|
+
for (const item of items) {
|
|
663
|
+
let data = item.b64_json;
|
|
664
|
+
let mimeType = "image/png";
|
|
665
|
+
if (!data && item.url) {
|
|
666
|
+
try {
|
|
667
|
+
const downloaded = await imageUrlToBase64(item.url);
|
|
668
|
+
data = downloaded.data;
|
|
669
|
+
mimeType = downloaded.mimeType;
|
|
670
|
+
} catch {
|
|
671
|
+
content.push({ type: "text", text: `Image URL: ${item.url}` });
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (data) {
|
|
676
|
+
content.push({ type: "image", data, mimeType });
|
|
677
|
+
images.push({ mimeType, data, revisedPrompt: item.revised_prompt });
|
|
678
|
+
} else if (item.url) {
|
|
679
|
+
content.push({ type: "text", text: `Image URL: ${item.url}` });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const revised = images.map((i) => i.revisedPrompt).filter(Boolean);
|
|
684
|
+
const summary = `Generated ${images.length} image(s)` +
|
|
685
|
+
(revised.length ? ` (with revised prompts)` : "") +
|
|
686
|
+
(images.length !== items.length ? `; ${items.length - images.length} returned only URLs` : "");
|
|
687
|
+
content.unshift({ type: "text", text: summary });
|
|
688
|
+
|
|
689
|
+
return { content, details: { images } };
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (videosCfg.enabled !== false) {
|
|
695
|
+
pi.registerTool({
|
|
696
|
+
name: "freellm_video",
|
|
697
|
+
label: "Generate video",
|
|
698
|
+
description:
|
|
699
|
+
"Generate a video via the FreeLLM gateway (POST /videos/generations) with task polling. " +
|
|
700
|
+
"Downloads the finished video to a local file and reports the path.",
|
|
701
|
+
promptSnippet: "Generate videos via the FreeLLM gateway (POST /videos/generations)",
|
|
702
|
+
promptGuidelines: [
|
|
703
|
+
"Use freellm_video when the user asks to create or generate a video. It blocks until the render finishes, so prefer it only for explicit video requests.",
|
|
704
|
+
],
|
|
705
|
+
parameters: Type.Object({
|
|
706
|
+
prompt: Type.String({ description: "Video prompt" }),
|
|
707
|
+
model: Type.Optional(Type.String({ description: `Video model id (default: ${videosDefault})` })),
|
|
708
|
+
n: Type.Optional(Type.Integer({ description: "Number of videos" })),
|
|
709
|
+
size: Type.Optional(Type.String({ description: 'e.g. "1280x720"' })),
|
|
710
|
+
seconds: Type.Optional(Type.String({ description: 'e.g. "5"' })),
|
|
711
|
+
pollTimeoutSeconds: Type.Optional(Type.Number({ description: "Max time to wait for completion (default 300)" })),
|
|
712
|
+
}),
|
|
713
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
714
|
+
const model = params.model ?? videosDefault;
|
|
715
|
+
const body: Record<string, unknown> = { model, prompt: params.prompt };
|
|
716
|
+
if (params.n) body["n"] = params.n;
|
|
717
|
+
if (params.size) body["size"] = params.size;
|
|
718
|
+
if (params.seconds) body["seconds"] = params.seconds;
|
|
719
|
+
|
|
720
|
+
const submit = await httpRequest(baseUrl, apiKey, extraHeaders, "/videos/generations", {
|
|
721
|
+
method: "POST",
|
|
722
|
+
body: JSON.stringify(body),
|
|
723
|
+
signal,
|
|
724
|
+
});
|
|
725
|
+
if (!submit.ok) {
|
|
726
|
+
throw new Error(`[freellmapi] /videos/generations failed (HTTP ${submit.status}): ${await readErrorBody(submit)}`);
|
|
727
|
+
}
|
|
728
|
+
const submitted = (await submit.json()) as unknown;
|
|
729
|
+
|
|
730
|
+
const taskId = extractTaskId(submitted);
|
|
731
|
+
if (!taskId) {
|
|
732
|
+
const directUrl = findVideoUrl(submitted);
|
|
733
|
+
if (directUrl) {
|
|
734
|
+
const bytes = await downloadBytes(directUrl, signal);
|
|
735
|
+
const outFile = resolve(ctx.cwd, `freellm-video-${Date.now()}${guessVideoExt(directUrl)}`);
|
|
736
|
+
await writeBytes(outFile, bytes);
|
|
737
|
+
return {
|
|
738
|
+
content: [{ type: "text", text: `Video ready and downloaded (${bytes.length} bytes) to ${outFile}. Source URL: ${directUrl}` }],
|
|
739
|
+
details: { outputFile: outFile, url: directUrl },
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
throw new Error(`[freellmapi] /videos/generations returned no task id: ${JSON.stringify(submitted).slice(0, 500)}`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const timeoutSeconds = params.pollTimeoutSeconds ?? 300;
|
|
746
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
747
|
+
let task: unknown = submitted;
|
|
748
|
+
let status = extractVideoStatus(task);
|
|
749
|
+
|
|
750
|
+
while (!VIDEO_TERMINAL_STATUSES.has(status) && Date.now() < deadline) {
|
|
751
|
+
if (signal?.aborted) throw new Error("Video generation cancelled");
|
|
752
|
+
await sleep(3000);
|
|
753
|
+
const poll = await httpRequest(baseUrl, apiKey, extraHeaders, `/videos/${encodeURIComponent(taskId)}`, { signal });
|
|
754
|
+
if (!poll.ok) {
|
|
755
|
+
status = `error (poll HTTP ${poll.status})`;
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
task = (await poll.json()) as unknown;
|
|
759
|
+
status = extractVideoStatus(task);
|
|
760
|
+
if (!VIDEO_TERMINAL_STATUSES.has(status)) {
|
|
761
|
+
onUpdate?.({
|
|
762
|
+
content: [{ type: "text", text: `Video ${taskId} status: ${status || "processing"}…` }],
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (!VIDEO_SUCCESS_STATUSES.has(status)) {
|
|
768
|
+
throw new Error(
|
|
769
|
+
`[freellmapi] Video generation did not complete (status: ${status || "unknown"}). Last response: ${JSON.stringify(task).slice(0, 500)}`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const url = findVideoUrl(task);
|
|
774
|
+
if (!url) {
|
|
775
|
+
throw new Error(`[freellmapi] Video completed but no URL found: ${JSON.stringify(task).slice(0, 1000)}`);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const bytes = await downloadBytes(url, signal);
|
|
779
|
+
const outFile = resolve(ctx.cwd, `freellm-video-${taskId}${guessVideoExt(url)}`);
|
|
780
|
+
await writeBytes(outFile, bytes);
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
content: [{
|
|
784
|
+
type: "text",
|
|
785
|
+
text: `Video ready (status ${status}). Downloaded ${bytes.length} bytes to ${outFile}. Source URL: ${url}`,
|
|
786
|
+
}],
|
|
787
|
+
details: { taskId, status, outputFile: outFile, url },
|
|
788
|
+
};
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (speechCfg.enabled !== false) {
|
|
794
|
+
pi.registerTool({
|
|
795
|
+
name: "freellm_speech",
|
|
796
|
+
label: "Text to speech",
|
|
797
|
+
description:
|
|
798
|
+
"Synthesize speech from text via the FreeLLM gateway (POST /audio/speech). " +
|
|
799
|
+
"Writes the audio to a local file and reports the path.",
|
|
800
|
+
promptSnippet: "Synthesize speech via the FreeLLM gateway (POST /audio/speech)",
|
|
801
|
+
promptGuidelines: [
|
|
802
|
+
"Use freellm_speech when the user asks to generate, read aloud, or synthesize speech/audio from text.",
|
|
803
|
+
],
|
|
804
|
+
parameters: Type.Object({
|
|
805
|
+
text: Type.String({ description: "Text to synthesize" }),
|
|
806
|
+
model: Type.Optional(Type.String({ description: `TTS model id (default: ${speechDefault})` })),
|
|
807
|
+
voice: Type.Optional(Type.String({ description: 'Voice name (default: "alloy")' })),
|
|
808
|
+
speed: Type.Optional(Type.Number({ description: "Playback speed (0.25-4.0)" })),
|
|
809
|
+
format: Type.Optional(Type.String({ description: 'Audio format: "mp3" (default), "opus", "aac", "flac", "wav", "pcm"' })),
|
|
810
|
+
outputFile: Type.Optional(Type.String({ description: `Output path (default: freellm-speech-<ts>.<format> in cwd)` })),
|
|
811
|
+
}),
|
|
812
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
813
|
+
const model = params.model ?? speechDefault;
|
|
814
|
+
const format = params.format ?? "mp3";
|
|
815
|
+
const body: Record<string, unknown> = {
|
|
816
|
+
model,
|
|
817
|
+
input: params.text,
|
|
818
|
+
voice: params.voice ?? "alloy",
|
|
819
|
+
response_format: format,
|
|
820
|
+
};
|
|
821
|
+
if (params.speed != null) body["speed"] = params.speed;
|
|
822
|
+
|
|
823
|
+
const res = await httpRequest(baseUrl, apiKey, extraHeaders, "/audio/speech", {
|
|
824
|
+
method: "POST",
|
|
825
|
+
body: JSON.stringify(body),
|
|
826
|
+
signal,
|
|
827
|
+
});
|
|
828
|
+
if (!res.ok) {
|
|
829
|
+
throw new Error(`[freellmapi] /audio/speech failed (HTTP ${res.status}): ${await readErrorBody(res)}`);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
833
|
+
const outFile = resolve(ctx.cwd, params.outputFile ?? `freellm-speech-${Date.now()}.${format}`);
|
|
834
|
+
await writeBytes(outFile, bytes);
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
content: [{
|
|
838
|
+
type: "text",
|
|
839
|
+
text: `Synthesized ${bytes.length} bytes of ${format} audio with voice "${params.voice ?? "alloy"}" → ${outFile}`,
|
|
840
|
+
}],
|
|
841
|
+
details: { outputFile: outFile, format, bytes: bytes.length },
|
|
842
|
+
};
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (transcriptionsCfg.enabled !== false) {
|
|
848
|
+
pi.registerTool({
|
|
849
|
+
name: "freellm_transcribe",
|
|
850
|
+
label: "Transcribe audio",
|
|
851
|
+
description:
|
|
852
|
+
"Transcribe an audio file via the FreeLLM gateway (POST /audio/transcriptions, multipart upload). " +
|
|
853
|
+
"Accepts a local path or an http(s) URL.",
|
|
854
|
+
promptSnippet: "Transcribe audio via the FreeLLM gateway (POST /audio/transcriptions)",
|
|
855
|
+
promptGuidelines: [
|
|
856
|
+
"Use freellm_transcribe when the user asks to transcribe, caption, or get the text of an audio recording.",
|
|
857
|
+
],
|
|
858
|
+
parameters: Type.Object({
|
|
859
|
+
file: Type.String({ description: "Local path or http(s) URL of the audio file" }),
|
|
860
|
+
model: Type.Optional(Type.String({ description: `Transcription model id (default: ${transcriptionsDefault})` })),
|
|
861
|
+
language: Type.Optional(Type.String({ description: 'Optional ISO-639-1 language, e.g. "en"' })),
|
|
862
|
+
format: Type.Optional(Type.String({ description: 'Response format: "json" (default), "text", "srt", "verbose_json", "vtt"' })),
|
|
863
|
+
prompt: Type.Optional(Type.String({ description: "Optional hint to guide transcription style/vocabulary" })),
|
|
864
|
+
}),
|
|
865
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
866
|
+
const model = params.model ?? transcriptionsDefault;
|
|
867
|
+
const { bytes, filename, mimeType } = await resolveAudioFile(params.file, ctx.cwd, signal);
|
|
868
|
+
|
|
869
|
+
const form = new FormData();
|
|
870
|
+
form.append("file", new Blob([bytes as BlobPart], { type: mimeType }), filename);
|
|
871
|
+
form.append("model", model);
|
|
872
|
+
if (params.language) form.append("language", params.language);
|
|
873
|
+
if (params.format) form.append("response_format", params.format);
|
|
874
|
+
if (params.prompt) form.append("prompt", params.prompt);
|
|
875
|
+
|
|
876
|
+
const res = await httpRequest(baseUrl, apiKey, extraHeaders, "/audio/transcriptions", {
|
|
877
|
+
method: "POST",
|
|
878
|
+
body: form,
|
|
879
|
+
signal,
|
|
880
|
+
});
|
|
881
|
+
if (!res.ok) {
|
|
882
|
+
throw new Error(`[freellmapi] /audio/transcriptions failed (HTTP ${res.status}): ${await readErrorBody(res)}`);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const raw = await res.text();
|
|
886
|
+
let text = raw;
|
|
887
|
+
if (params.format === undefined || params.format === "json" || params.format === "verbose_json") {
|
|
888
|
+
try {
|
|
889
|
+
const json = JSON.parse(raw) as { text?: string };
|
|
890
|
+
text = json.text ?? raw;
|
|
891
|
+
} catch {
|
|
892
|
+
text = raw;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return { content: [{ type: "text", text }], details: { raw } };
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-freellmapi",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Register the FreeLLM API gateway (freeapi.n.cofire.cn) as an OpenAI-compatible provider in pi, with automatic model discovery",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "Register the FreeLLM API gateway (freeapi.n.cofire.cn) as an OpenAI-compatible provider in pi, with automatic model discovery and tools for embeddings, image/video generation, speech, and transcription",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|