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 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 `PI_FREELMAPI_CONFIG=/path/to/config.json`.
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 deepseek-v4-flash
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
 
@@ -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 an
5
- * OpenAI-compatible (`openai-completions`) provider and auto-discovers the
6
- * models your API key can actually use.
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": [] // optional static models (override by id)
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 { readFileSync, existsSync } from "node:fs";
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
- let discovered: ProviderModelConfig[] = [];
190
- if (config.fetchModels !== false) {
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
- 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));
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.2",
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",