pi-agnes-tools 0.1.0 → 0.3.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/.github/banner.png +0 -0
- package/LICENSE +662 -0
- package/README.md +80 -38
- package/extensions/agnes-tools.ts +435 -123
- package/package.json +12 -5
- package/skills/agnes-media/SKILL.md +50 -0
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-agnes-tools
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The single Agnes AI plugin for pi: full model catalog (text, image, video)
|
|
5
|
+
* for both endpoints PLUS image/video generation as callable tools.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Providers (`agnes` = international, `agnes-cn` = China): seed catalog +
|
|
8
|
+
* live /v1/models discovery + stream routing, so every model is selectable
|
|
9
|
+
* via /model and --model.
|
|
10
|
+
* Tools: `agnes_image` (default agnes-image-2.5-flash) and `agnes_video`
|
|
11
|
+
* (default agnes-video-2.5-flash) — no model switch needed.
|
|
9
12
|
*
|
|
10
|
-
* Auth:
|
|
13
|
+
* Auth: AGNES_API_KEY / AGNES_CN_API_KEY env, else /login-stored key.
|
|
11
14
|
* Saves: .pi/generated-images/ and .pi/generated-videos/ (project-relative).
|
|
12
15
|
*/
|
|
13
16
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
@@ -16,22 +19,51 @@ import { join } from "node:path";
|
|
|
16
19
|
import { homedir } from "node:os";
|
|
17
20
|
import { pathToFileURL } from "node:url";
|
|
18
21
|
|
|
19
|
-
// typebox
|
|
20
|
-
// fall back to a bare import when running as plain ESM (dev / jiti).
|
|
22
|
+
// typebox is provided to extensions by pi itself (bundled/virtual module).
|
|
21
23
|
import * as _typebox from "typebox";
|
|
22
24
|
const Type = _typebox.Type;
|
|
23
25
|
|
|
26
|
+
// pi-ai stream helpers (same import pi-agnes uses; resolved via pi's loader).
|
|
27
|
+
import {
|
|
28
|
+
createAssistantMessageEventStream,
|
|
29
|
+
openAICompletionsApi,
|
|
30
|
+
} from "@earendil-works/pi-ai";
|
|
31
|
+
|
|
24
32
|
// ---------------------------------------------------------------------------
|
|
25
33
|
// Shared helpers
|
|
26
34
|
// ---------------------------------------------------------------------------
|
|
27
35
|
|
|
28
36
|
const ENDPOINTS = {
|
|
29
|
-
agnes: { baseUrl: "https://apihub.agnes-ai.com/v1", apiKeyEnv: "AGNES_API_KEY"
|
|
30
|
-
"agnes-cn": { baseUrl: "https://api.agnes-ai.cn/v1", apiKeyEnv: "AGNES_CN_API_KEY"
|
|
37
|
+
agnes: { baseUrl: "https://apihub.agnes-ai.com/v1", apiKeyEnv: "AGNES_API_KEY" },
|
|
38
|
+
"agnes-cn": { baseUrl: "https://api.agnes-ai.cn/v1", apiKeyEnv: "AGNES_CN_API_KEY" },
|
|
31
39
|
};
|
|
32
40
|
|
|
33
|
-
const IMAGE_MODELS = new Set(["agnes-image-2.0-flash", "agnes-image-2.1-flash"]);
|
|
41
|
+
const IMAGE_MODELS = new Set(["agnes-image-2.0-flash", "agnes-image-2.1-flash", "agnes-image-2.5-flash"]);
|
|
34
42
|
const VIDEO_MODELS = new Set(["agnes-video-v2.0", "agnes-video-2.5", "agnes-video-2.5-flash"]);
|
|
43
|
+
const DEFAULT_IMAGE_MODEL = "agnes-image-2.5-flash";
|
|
44
|
+
const DEFAULT_VIDEO_MODEL = "agnes-video-2.5-flash";
|
|
45
|
+
|
|
46
|
+
// Text/LLM models only — image/video generation goes through the tools +
|
|
47
|
+
// skill, not the /model selector.
|
|
48
|
+
const AGNES_SEED = [
|
|
49
|
+
"agnes-2.5-flash",
|
|
50
|
+
"agnes-2.5-pro",
|
|
51
|
+
"agnes-2.5-pro-alpha",
|
|
52
|
+
"agnes-2.0-flash",
|
|
53
|
+
"agnes-3.0-flash",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function isTextModel(id) {
|
|
57
|
+
return !id.startsWith("agnes-image-") && !id.startsWith("agnes-video-");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isImageModel(id) {
|
|
61
|
+
return IMAGE_MODELS.has(id) || id.startsWith("agnes-image-");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isVideoModel(id) {
|
|
65
|
+
return VIDEO_MODELS.has(id) || id.startsWith("agnes-video-");
|
|
66
|
+
}
|
|
35
67
|
|
|
36
68
|
function fileLink(p, label = p) {
|
|
37
69
|
return "[" + label + "](" + pathToFileURL(p).href + ")";
|
|
@@ -42,19 +74,17 @@ function resolveApiKey(endpoint) {
|
|
|
42
74
|
// 1) Environment variable (fastest, always works)
|
|
43
75
|
const envKey = process.env[cfg.apiKeyEnv];
|
|
44
76
|
if (envKey) return envKey;
|
|
45
|
-
// 2) /login-stored key in pi's auth store
|
|
77
|
+
// 2) /login-stored key in pi's auth store (same key pi-agnes uses)
|
|
46
78
|
try {
|
|
47
79
|
const authPath = join(homedir(), ".pi", "agent", "auth.json");
|
|
48
80
|
const auth = JSON.parse(readFileSync(authPath, "utf8"));
|
|
49
|
-
|
|
50
|
-
// provider id, and the CN endpoint uses the same account/key in most setups).
|
|
51
|
-
const entry = auth[cfg.authKey] || auth["agnes"];
|
|
81
|
+
const entry = auth[endpoint] || auth["agnes"];
|
|
52
82
|
if (entry && entry.key) return entry.key;
|
|
53
83
|
} catch {
|
|
54
84
|
// ignore read/parse failures; fall through
|
|
55
85
|
}
|
|
56
86
|
throw new Error(
|
|
57
|
-
"No API key found for " + cfg.apiKeyEnv + ". Set the " + cfg.apiKeyEnv + " environment variable, or run /login with the
|
|
87
|
+
"No API key found for " + cfg.apiKeyEnv + ". Set the " + cfg.apiKeyEnv + " environment variable, or run /login with the Agnes provider."
|
|
58
88
|
);
|
|
59
89
|
}
|
|
60
90
|
|
|
@@ -65,77 +95,28 @@ function getEndpoint(endpoint) {
|
|
|
65
95
|
}
|
|
66
96
|
|
|
67
97
|
function checkImageModel(model) {
|
|
68
|
-
if (!
|
|
98
|
+
if (!isImageModel(model)) {
|
|
69
99
|
throw new Error("Unknown Agnes image model: " + model + ". Known models: " + [...IMAGE_MODELS].join(", "));
|
|
70
100
|
}
|
|
71
101
|
}
|
|
72
102
|
|
|
73
103
|
function checkVideoModel(model) {
|
|
74
|
-
if (!
|
|
104
|
+
if (!isVideoModel(model)) {
|
|
75
105
|
throw new Error("Unknown Agnes video model: " + model + ". Known models: " + [...VIDEO_MODELS].join(", "));
|
|
76
106
|
}
|
|
77
107
|
}
|
|
78
108
|
|
|
79
109
|
// ---------------------------------------------------------------------------
|
|
80
|
-
//
|
|
110
|
+
// Shared REST cores (used by both the tools and the standalone stream router)
|
|
81
111
|
// ---------------------------------------------------------------------------
|
|
82
112
|
|
|
83
|
-
|
|
84
|
-
prompt: { type: "string", description: "Text prompt describing the image to generate." },
|
|
85
|
-
model: {
|
|
86
|
-
type: "string",
|
|
87
|
-
description: "Agnes image model id. One of: " + [...IMAGE_MODELS].join(", ") + ". Default: agnes-image-2.1-flash.",
|
|
88
|
-
},
|
|
89
|
-
endpoint: {
|
|
90
|
-
type: "string",
|
|
91
|
-
enum: ["agnes", "agnes-cn"],
|
|
92
|
-
description: "Which Agnes endpoint to use. Default: agnes (apihub.agnes-ai.com).",
|
|
93
|
-
},
|
|
94
|
-
images: {
|
|
95
|
-
type: "array",
|
|
96
|
-
items: { type: "string" },
|
|
97
|
-
description:
|
|
98
|
-
"Optional list of base64-encoded image data URIs (data:<mime>;base64,<data>) to use as reference/conditioning images.",
|
|
99
|
-
},
|
|
100
|
-
response_format: {
|
|
101
|
-
type: "string",
|
|
102
|
-
description: "Response image format. Default: png.",
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
async function executeImage(_toolCallId, params) {
|
|
107
|
-
const prompt = params.prompt;
|
|
108
|
-
const rawModel = params.model || "agnes-image-2.1-flash";
|
|
109
|
-
const endpointId = params.endpoint || "agnes";
|
|
110
|
-
const images = params.images || [];
|
|
111
|
-
const response_format = params.response_format || "png";
|
|
112
|
-
|
|
113
|
-
checkImageModel(rawModel);
|
|
114
|
-
const { baseUrl, headers } = getEndpoint(endpointId);
|
|
115
|
-
|
|
116
|
-
const body = { model: rawModel, prompt, response_format };
|
|
117
|
-
if (images.length > 0) {
|
|
118
|
-
body.image = images;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const response = await fetch(baseUrl + "/images/generations", {
|
|
122
|
-
method: "POST",
|
|
123
|
-
headers,
|
|
124
|
-
body: JSON.stringify(body),
|
|
125
|
-
});
|
|
126
|
-
const payload = await response.json().catch(() => null);
|
|
127
|
-
if (!response.ok) {
|
|
128
|
-
throw new Error((payload && payload.error && payload.error.message) || "Agnes image API HTTP " + response.status);
|
|
129
|
-
}
|
|
130
|
-
const image = payload && payload.data && payload.data[0];
|
|
131
|
-
if (!image) throw new Error("Agnes image API returned no image data");
|
|
132
|
-
|
|
113
|
+
async function saveImagePayload(image, modelId) {
|
|
133
114
|
const directory = join(process.cwd(), ".pi", "generated-images");
|
|
134
115
|
await mkdir(directory, { recursive: true });
|
|
135
116
|
const mime = image.mime_type || "image/png";
|
|
136
117
|
const ext =
|
|
137
118
|
mime.includes("png") ? "png" : mime.includes("jpeg") ? "jpg" : mime.includes("webp") ? "webp" : mime.includes("gif") ? "gif" : "png";
|
|
138
|
-
const filePath = join(directory,
|
|
119
|
+
const filePath = join(directory, modelId + "-" + Date.now() + "." + ext);
|
|
139
120
|
if (image.b64_json) {
|
|
140
121
|
await writeFile(filePath, Buffer.from(image.b64_json, "base64"));
|
|
141
122
|
} else if (image.url) {
|
|
@@ -145,40 +126,27 @@ async function executeImage(_toolCallId, params) {
|
|
|
145
126
|
} else {
|
|
146
127
|
throw new Error("Agnes image API returned no url or b64_json");
|
|
147
128
|
}
|
|
148
|
-
|
|
149
|
-
const text = image.url
|
|
150
|
-
? "\n\nSaved local copy: " + fileLink(filePath) + "\n\nImage URL may expire according to Agnes retention policy."
|
|
151
|
-
: "Generated image saved to: " + fileLink(filePath);
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
content: [{ type: "text", text }],
|
|
155
|
-
details: { filePath, model: rawModel, endpoint: endpointId, remoteUrl: image.url || null },
|
|
156
|
-
};
|
|
129
|
+
return { filePath, mimeType: mime, remoteUrl: image.url || null };
|
|
157
130
|
}
|
|
158
131
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
description: "Optional reference image(s) as base64 data URIs. 1 image = image-to-video; >1 = keyframes mode.",
|
|
178
|
-
},
|
|
179
|
-
num_frames: { type: "integer", description: "Number of frames. Default: 121." },
|
|
180
|
-
frame_rate: { type: "integer", description: "Frames per second. Default: 24." },
|
|
181
|
-
});
|
|
132
|
+
async function requestImage(baseUrl, apiKey, opts) {
|
|
133
|
+
const body = { model: opts.model, prompt: opts.prompt };
|
|
134
|
+
if (opts.response_format) body.response_format = opts.response_format;
|
|
135
|
+
if (opts.images && opts.images.length > 0) body.image = opts.images;
|
|
136
|
+
const response = await fetch(baseUrl + "/images/generations", {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json" },
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
signal: opts.signal,
|
|
141
|
+
});
|
|
142
|
+
const payload = await response.json().catch(() => null);
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
throw new Error((payload && payload.error && payload.error.message) || "Agnes image API HTTP " + response.status);
|
|
145
|
+
}
|
|
146
|
+
const image = payload && payload.data && payload.data[0];
|
|
147
|
+
if (!image) throw new Error("Agnes image API returned no image data");
|
|
148
|
+
return saveImagePayload(image, opts.model);
|
|
149
|
+
}
|
|
182
150
|
|
|
183
151
|
async function pollVideo(baseUrl, videoId, apiKey, signal) {
|
|
184
152
|
const deadline = Date.now() + 30 * 60 * 1000;
|
|
@@ -214,26 +182,23 @@ async function pollVideo(baseUrl, videoId, apiKey, signal) {
|
|
|
214
182
|
throw new Error("Agnes video generation timed out after 30 minutes");
|
|
215
183
|
}
|
|
216
184
|
|
|
217
|
-
async function
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const { baseUrl, apiKey, headers } = getEndpoint(endpointId);
|
|
227
|
-
|
|
228
|
-
const body = { model: rawModel, prompt, num_frames, frame_rate };
|
|
185
|
+
async function requestVideo(baseUrl, apiKey, opts) {
|
|
186
|
+
// NOTE: the API accepts different fields per model. The flash/2.5 routes
|
|
187
|
+
// reject `num_frames` / `frame_rate` as request fields; v2.0 accepts the
|
|
188
|
+
// request with defaults. So we only send them when the caller explicitly
|
|
189
|
+
// provides them — never as hard-coded defaults.
|
|
190
|
+
const body = { model: opts.model, prompt: opts.prompt };
|
|
191
|
+
if (opts.num_frames) body.num_frames = opts.num_frames;
|
|
192
|
+
if (opts.frame_rate) body.frame_rate = opts.frame_rate;
|
|
193
|
+
const images = opts.images || [];
|
|
229
194
|
if (images.length === 1) body.image = images[0];
|
|
230
195
|
if (images.length > 1) body.extra_body = { image: images, mode: "keyframes" };
|
|
231
196
|
|
|
232
197
|
const response = await fetch(baseUrl + "/videos", {
|
|
233
198
|
method: "POST",
|
|
234
|
-
headers,
|
|
199
|
+
headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json" },
|
|
235
200
|
body: JSON.stringify(body),
|
|
236
|
-
signal,
|
|
201
|
+
signal: opts.signal,
|
|
237
202
|
});
|
|
238
203
|
const task = await response.json().catch(() => null);
|
|
239
204
|
if (!response.ok) {
|
|
@@ -242,36 +207,325 @@ async function executeVideo(_toolCallId, params, signal) {
|
|
|
242
207
|
const videoId = (task && (task.video_id || task.id || task.task_id)) || null;
|
|
243
208
|
if (!videoId) throw new Error("Agnes video API returned no video_id");
|
|
244
209
|
|
|
245
|
-
const result = task.status === "completed" ? task : await pollVideo(baseUrl, videoId, apiKey, signal);
|
|
246
|
-
|
|
247
|
-
|
|
210
|
+
const result = task.status === "completed" ? task : await pollVideo(baseUrl, videoId, apiKey, opts.signal);
|
|
211
|
+
// The completed payload carries the video URL at top-level `url` (v2.0 and
|
|
212
|
+
// friends). Some routes nest it under `metadata.url`, so check both.
|
|
213
|
+
const url = (result && (result.url || (result.metadata && result.metadata.url))) || null;
|
|
214
|
+
if (!url) throw new Error("Agnes video API returned no video url");
|
|
248
215
|
|
|
249
216
|
const directory = join(process.cwd(), ".pi", "generated-videos");
|
|
250
217
|
await mkdir(directory, { recursive: true });
|
|
251
|
-
const filePath = join(directory,
|
|
218
|
+
const filePath = join(directory, opts.model + "-" + Date.now() + ".mp4");
|
|
252
219
|
const videoRes = await fetch(url);
|
|
253
220
|
if (!videoRes.ok) throw new Error("Unable to download generated video: HTTP " + videoRes.status);
|
|
254
221
|
await writeFile(filePath, Buffer.from(await videoRes.arrayBuffer()));
|
|
222
|
+
return { filePath, remoteUrl: url };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// agnes_image / agnes_video tools (always registered — no provider conflict)
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
255
228
|
|
|
256
|
-
|
|
229
|
+
const imageParams = Type.Object({
|
|
230
|
+
prompt: { type: "string", description: "Text prompt describing the image to generate." },
|
|
231
|
+
model: {
|
|
232
|
+
type: "string",
|
|
233
|
+
description: "Agnes image model id. One of: " + [...IMAGE_MODELS].join(", ") + ". Default: " + DEFAULT_IMAGE_MODEL + ".",
|
|
234
|
+
},
|
|
235
|
+
endpoint: {
|
|
236
|
+
type: "string",
|
|
237
|
+
enum: ["agnes", "agnes-cn"],
|
|
238
|
+
description: "Which Agnes endpoint to use: agnes (international, apihub.agnes-ai.com) or agnes-cn (China, api.agnes-ai.cn). Default: agnes.",
|
|
239
|
+
},
|
|
240
|
+
images: {
|
|
241
|
+
type: "array",
|
|
242
|
+
items: { type: "string" },
|
|
243
|
+
description:
|
|
244
|
+
"Optional list of base64-encoded image data URIs (data:<mime>;base64,<data>) to use as reference/conditioning images.",
|
|
245
|
+
},
|
|
246
|
+
response_format: {
|
|
247
|
+
type: "string",
|
|
248
|
+
description: "Response image format. Default: png.",
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
async function executeImage(_toolCallId, params) {
|
|
253
|
+
const prompt = params.prompt;
|
|
254
|
+
const rawModel = params.model || DEFAULT_IMAGE_MODEL;
|
|
255
|
+
const endpointId = params.endpoint || "agnes";
|
|
256
|
+
checkImageModel(rawModel);
|
|
257
|
+
const { baseUrl, apiKey } = getEndpoint(endpointId);
|
|
258
|
+
|
|
259
|
+
const saved = await requestImage(baseUrl, apiKey, {
|
|
260
|
+
model: rawModel,
|
|
261
|
+
prompt,
|
|
262
|
+
images: params.images || [],
|
|
263
|
+
response_format: params.response_format || "png",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const text = saved.remoteUrl
|
|
267
|
+
? "\n\nSaved local copy: " + fileLink(saved.filePath) + "\n\nImage URL may expire according to Agnes retention policy."
|
|
268
|
+
: "Generated image saved to: " + fileLink(saved.filePath);
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
content: [{ type: "text", text }],
|
|
272
|
+
details: { filePath: saved.filePath, model: rawModel, endpoint: endpointId, remoteUrl: saved.remoteUrl },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const videoParams = Type.Object({
|
|
277
|
+
prompt: { type: "string", description: "Text prompt describing the video to generate." },
|
|
278
|
+
model: {
|
|
279
|
+
type: "string",
|
|
280
|
+
description: "Agnes video model id. One of: " + [...VIDEO_MODELS].join(", ") + ". Default: " + DEFAULT_VIDEO_MODEL + ".",
|
|
281
|
+
},
|
|
282
|
+
endpoint: {
|
|
283
|
+
type: "string",
|
|
284
|
+
enum: ["agnes", "agnes-cn"],
|
|
285
|
+
description: "Which Agnes endpoint to use: agnes (international, apihub.agnes-ai.com) or agnes-cn (China, api.agnes-ai.cn). Default: agnes.",
|
|
286
|
+
},
|
|
287
|
+
images: {
|
|
288
|
+
type: "array",
|
|
289
|
+
items: { type: "string" },
|
|
290
|
+
description: "Optional reference image(s) as base64 data URIs. 1 image = image-to-video; >1 = keyframes mode.",
|
|
291
|
+
},
|
|
292
|
+
num_frames: { type: "integer", description: "Number of frames. Default: 121." },
|
|
293
|
+
frame_rate: { type: "integer", description: "Frames per second. Default: 24." },
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
async function executeVideo(_toolCallId, params, signal) {
|
|
297
|
+
const prompt = params.prompt;
|
|
298
|
+
const rawModel = params.model || DEFAULT_VIDEO_MODEL;
|
|
299
|
+
const endpointId = params.endpoint || "agnes";
|
|
300
|
+
checkVideoModel(rawModel);
|
|
301
|
+
const { baseUrl, apiKey } = getEndpoint(endpointId);
|
|
302
|
+
|
|
303
|
+
const saved = await requestVideo(baseUrl, apiKey, {
|
|
304
|
+
model: rawModel,
|
|
305
|
+
prompt,
|
|
306
|
+
images: params.images || [],
|
|
307
|
+
// Only forward when explicitly provided — some Agnes video routes reject
|
|
308
|
+
// num_frames/frame_rate as request fields and use their own defaults.
|
|
309
|
+
num_frames: params.num_frames,
|
|
310
|
+
frame_rate: params.frame_rate,
|
|
311
|
+
signal,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const text = "Generated video saved to: " + fileLink(saved.filePath) + "\n\nVideo URL: " + saved.remoteUrl;
|
|
257
315
|
return {
|
|
258
316
|
content: [{ type: "text", text }],
|
|
259
|
-
details: { filePath, model: rawModel, endpoint: endpointId, remoteUrl:
|
|
317
|
+
details: { filePath: saved.filePath, model: rawModel, endpoint: endpointId, remoteUrl: saved.remoteUrl },
|
|
260
318
|
};
|
|
261
319
|
}
|
|
262
320
|
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Standalone provider fallback (only when pi-agnes is NOT installed).
|
|
323
|
+
// Mirrors pi-agnes: seed catalog + /v1/models discovery + stream routing,
|
|
324
|
+
// so /model selection works out of the box without pi-agnes.
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
function toModelConfig(id) {
|
|
328
|
+
const limits =
|
|
329
|
+
id.startsWith("agnes-2.5") || id.startsWith("agnes-3")
|
|
330
|
+
? { contextWindow: 1048576, maxTokens: 65536 }
|
|
331
|
+
: id.startsWith("agnes-2.0")
|
|
332
|
+
? { contextWindow: 1048576, maxTokens: 32768 }
|
|
333
|
+
: { contextWindow: 131072, maxTokens: 32768 };
|
|
334
|
+
return {
|
|
335
|
+
id,
|
|
336
|
+
name: id,
|
|
337
|
+
reasoning: false,
|
|
338
|
+
input: ["text", "image"],
|
|
339
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
340
|
+
...limits,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function fetchStandaloneModels(baseUrl, apiKey, signal) {
|
|
345
|
+
const headers = {};
|
|
346
|
+
if (apiKey) headers["Authorization"] = "Bearer " + apiKey;
|
|
347
|
+
const res = await fetch(baseUrl + "/models", { headers, redirect: "follow", signal });
|
|
348
|
+
if (!res.ok) throw new Error("HTTP " + res.status + " " + res.statusText);
|
|
349
|
+
const payload = await res.json().catch(() => null);
|
|
350
|
+
const data = payload && Array.isArray(payload.data) ? payload.data : Array.isArray(payload) ? payload : [];
|
|
351
|
+
return data
|
|
352
|
+
.filter((m) => m && m.id && isTextModel(m.id))
|
|
353
|
+
.map((m) => toModelConfig(m.id));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function makeRefreshModels(baseUrl, apiKeyEnv, providerId) {
|
|
357
|
+
return async ({ signal, stored, publish, allowNetwork, credential }) => {
|
|
358
|
+
const cached = stored && Array.isArray(stored.models) ? stored.models : undefined;
|
|
359
|
+
if (!allowNetwork || (signal && signal.aborted)) return cached;
|
|
360
|
+
const apiKey = credential && credential.type === "api_key" ? credential.key : process.env[apiKeyEnv];
|
|
361
|
+
let models;
|
|
362
|
+
try {
|
|
363
|
+
models = await fetchStandaloneModels(baseUrl, apiKey, signal);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (cached) return cached;
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
if (models.length > 0) {
|
|
369
|
+
await publish({ persist: { provider: providerId, models } });
|
|
370
|
+
return models;
|
|
371
|
+
}
|
|
372
|
+
return cached;
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function latestUserMessage(context) {
|
|
377
|
+
const messages = context && Array.isArray(context.messages) ? context.messages : [];
|
|
378
|
+
return [...messages].reverse().find((m) => m && m.role === "user");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function extractPrompt(context) {
|
|
382
|
+
const user = latestUserMessage(context);
|
|
383
|
+
if (!user) return { prompt: "", images: [] };
|
|
384
|
+
if (typeof user.content === "string") return { prompt: user.content, images: [] };
|
|
385
|
+
return {
|
|
386
|
+
prompt: user.content
|
|
387
|
+
.filter((part) => part && part.type === "text")
|
|
388
|
+
.map((part) => part.text || "")
|
|
389
|
+
.join("\n"),
|
|
390
|
+
images: user.content.filter((part) => part && part.type === "image"),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function pushDone(stream, output, text) {
|
|
395
|
+
output.content.push({ type: "text", text });
|
|
396
|
+
stream.push({ type: "text_start", contentIndex: 0, partial: output });
|
|
397
|
+
stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial: output });
|
|
398
|
+
stream.push({ type: "text_end", contentIndex: 0, content: text, partial: output });
|
|
399
|
+
output.stopReason = "stop";
|
|
400
|
+
stream.push({ type: "done", reason: "stop", message: output });
|
|
401
|
+
stream.end();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function pushError(stream, output, error, signal) {
|
|
405
|
+
output.stopReason = signal && signal.aborted ? "aborted" : "error";
|
|
406
|
+
output.errorMessage = error instanceof Error ? error.message : String(error);
|
|
407
|
+
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
408
|
+
stream.end();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function newOutput(model) {
|
|
412
|
+
return {
|
|
413
|
+
role: "assistant",
|
|
414
|
+
content: [],
|
|
415
|
+
api: model.api,
|
|
416
|
+
provider: model.provider,
|
|
417
|
+
model: model.id,
|
|
418
|
+
usage: {
|
|
419
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
|
|
420
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
421
|
+
},
|
|
422
|
+
stopReason: "pending",
|
|
423
|
+
timestamp: Date.now(),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function streamStandaloneImage(model, context, options) {
|
|
428
|
+
const stream = createAssistantMessageEventStream();
|
|
429
|
+
const output = newOutput(model);
|
|
430
|
+
(async () => {
|
|
431
|
+
try {
|
|
432
|
+
stream.push({ type: "start", partial: output });
|
|
433
|
+
const extracted = extractPrompt(context);
|
|
434
|
+
if (!extracted.prompt) throw new Error("Image generation requires a text prompt");
|
|
435
|
+
const endpointId = model.provider === "agnes-cn" ? "agnes-cn" : "agnes";
|
|
436
|
+
const { baseUrl, apiKey } = getEndpoint(endpointId);
|
|
437
|
+
const saved = await requestImage(baseUrl, apiKey, {
|
|
438
|
+
model: model.id,
|
|
439
|
+
prompt: extracted.prompt,
|
|
440
|
+
images: extracted.images.map((img) => "data:" + img.mimeType + ";base64," + img.data),
|
|
441
|
+
response_format: "png",
|
|
442
|
+
signal: options && options.signal,
|
|
443
|
+
});
|
|
444
|
+
const text = saved.remoteUrl
|
|
445
|
+
? "\n\nSaved local copy: " + fileLink(saved.filePath) + "\n\nImage URL may expire according to Agnes retention policy."
|
|
446
|
+
: "Generated image saved to: " + fileLink(saved.filePath);
|
|
447
|
+
pushDone(stream, output, text);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
pushError(stream, output, error, options && options.signal);
|
|
450
|
+
}
|
|
451
|
+
})();
|
|
452
|
+
return stream;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function streamStandaloneVideo(model, context, options) {
|
|
456
|
+
const stream = createAssistantMessageEventStream();
|
|
457
|
+
const output = newOutput(model);
|
|
458
|
+
(async () => {
|
|
459
|
+
try {
|
|
460
|
+
stream.push({ type: "start", partial: output });
|
|
461
|
+
const extracted = extractPrompt(context);
|
|
462
|
+
if (!extracted.prompt) throw new Error("Video generation requires a text prompt");
|
|
463
|
+
const endpointId = model.provider === "agnes-cn" ? "agnes-cn" : "agnes";
|
|
464
|
+
const { baseUrl, apiKey } = getEndpoint(endpointId);
|
|
465
|
+
const saved = await requestVideo(baseUrl, apiKey, {
|
|
466
|
+
model: model.id,
|
|
467
|
+
prompt: extracted.prompt,
|
|
468
|
+
images: extracted.images.map((img) => "data:" + img.mimeType + ";base64," + img.data),
|
|
469
|
+
num_frames: 121,
|
|
470
|
+
frame_rate: 24,
|
|
471
|
+
signal: options && options.signal,
|
|
472
|
+
});
|
|
473
|
+
pushDone(stream, output, "Generated video saved to: " + fileLink(saved.filePath) + "\n\nVideo URL: " + saved.remoteUrl);
|
|
474
|
+
pushDone(stream, output, "Generated video saved to: " + fileLink(saved.filePath) + "\n\nVideo URL: " + saved.remoteUrl);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
pushError(stream, output, error, options && options.signal);
|
|
477
|
+
}
|
|
478
|
+
})();
|
|
479
|
+
return stream;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function streamStandalone(model, context, options) {
|
|
483
|
+
if (isVideoModel(model.id)) return streamStandaloneVideo(model, context, options);
|
|
484
|
+
if (isImageModel(model.id)) return streamStandaloneImage(model, context, options);
|
|
485
|
+
return openAICompletionsApi().streamSimple(model, context, options);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function debugLog(msg) {
|
|
489
|
+
if (process.env.PI_AGNES_TOOLS_DEBUG) {
|
|
490
|
+
try { console.error("[pi-agnes-tools] " + msg); } catch { /* ignore */ }
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function registerAgnesProviders(pi) {
|
|
495
|
+
const defs = [
|
|
496
|
+
{ id: "agnes", name: "Agnes AI", baseUrl: ENDPOINTS.agnes.baseUrl, apiKeyEnv: "AGNES_API_KEY" },
|
|
497
|
+
{ id: "agnes-cn", name: "Agnes AI (CN)", baseUrl: ENDPOINTS["agnes-cn"].baseUrl, apiKeyEnv: "AGNES_CN_API_KEY" },
|
|
498
|
+
];
|
|
499
|
+
for (const def of defs) {
|
|
500
|
+
// Omit apiKey when the env var is absent so /login can supply the key —
|
|
501
|
+
// same convention pi-agnes uses.
|
|
502
|
+
const apiKeyRef = process.env[def.apiKeyEnv] ? "$" + def.apiKeyEnv : undefined;
|
|
503
|
+
pi.registerProvider(def.id, {
|
|
504
|
+
name: def.name,
|
|
505
|
+
baseUrl: def.baseUrl,
|
|
506
|
+
...(apiKeyRef ? { apiKey: apiKeyRef } : {}),
|
|
507
|
+
api: "openai-completions",
|
|
508
|
+
streamSimple: streamStandalone,
|
|
509
|
+
models: AGNES_SEED.map((id) => toModelConfig(id)),
|
|
510
|
+
refreshModels: makeRefreshModels(def.baseUrl, def.apiKeyEnv, def.id),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
263
515
|
// ---------------------------------------------------------------------------
|
|
264
516
|
// Extension entry
|
|
265
517
|
// ---------------------------------------------------------------------------
|
|
266
518
|
|
|
267
519
|
export default function (pi) {
|
|
520
|
+
// Tools are always safe: unique names, no provider overlap.
|
|
268
521
|
pi.registerTool({
|
|
269
522
|
name: "agnes_image",
|
|
270
523
|
label: "Agnes Image",
|
|
271
524
|
description:
|
|
272
525
|
"Generate an image via the Agnes AI API (no model switch needed). " +
|
|
273
526
|
"Saves a local copy under .pi/generated-images/ and returns the saved path plus the (possibly expiring) remote URL. " +
|
|
274
|
-
"
|
|
527
|
+
"endpoint: agnes = international (apihub.agnes-ai.com, default), agnes-cn = China (api.agnes-ai.cn). " +
|
|
528
|
+
"Auth: AGNES_API_KEY or AGNES_CN_API_KEY env, else the /login-stored Agnes key.",
|
|
275
529
|
parameters: imageParams,
|
|
276
530
|
executionMode: "parallel",
|
|
277
531
|
execute: executeImage,
|
|
@@ -284,9 +538,67 @@ export default function (pi) {
|
|
|
284
538
|
"Generate a video via the Agnes AI API (no model switch needed). " +
|
|
285
539
|
"Async: creates a task, polls every 5s until done (up to 30 min), then downloads the .mp4 to .pi/generated-videos/. " +
|
|
286
540
|
"Supports image-to-video (1 reference image) and keyframes mode (>1 image). " +
|
|
287
|
-
"
|
|
541
|
+
"endpoint: agnes = international (default), agnes-cn = China. " +
|
|
542
|
+
"Auth: AGNES_API_KEY or AGNES_CN_API_KEY env, else the /login-stored Agnes key.",
|
|
288
543
|
parameters: videoParams,
|
|
289
544
|
executionMode: "sequential",
|
|
290
545
|
execute: executeVideo,
|
|
291
546
|
});
|
|
547
|
+
|
|
548
|
+
// Patch around a pi-ai quirk that bites this stack: when a streaming tool-call
|
|
549
|
+
// delta arrives without an `id`, the tool-call block is recorded with
|
|
550
|
+
// `id: ""` (openai-completions.js line ~326). On the next turn pi replays
|
|
551
|
+
// history to OpenAI Chat Completions, which rejects `tool_call_id: ""`
|
|
552
|
+
// with: "missing field `tool_call_id` at line 1 column N".
|
|
553
|
+
// We strip assistant tool-call blocks with empty ids and the matching
|
|
554
|
+
// orphan tool-result messages before each request to any agnes provider,
|
|
555
|
+
// so the upstream never sees an empty id. This is harmless when no
|
|
556
|
+
// empty-id blocks exist (the common case).
|
|
557
|
+
pi.on("before_provider_request", (event) => {
|
|
558
|
+
try {
|
|
559
|
+
const payload = event.payload;
|
|
560
|
+
if (!payload || typeof payload !== "object") return;
|
|
561
|
+
const messages = Array.isArray(payload.messages) ? payload.messages : null;
|
|
562
|
+
if (!messages) return;
|
|
563
|
+
// Pass 1: strip empty-id tool-call blocks from assistant messages.
|
|
564
|
+
let strippedAny = false;
|
|
565
|
+
for (const msg of messages) {
|
|
566
|
+
if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
567
|
+
const filtered = msg.content.filter((block) => {
|
|
568
|
+
if (block && block.type === "toolCall" && (!block.id || typeof block.id !== "string" || block.id.length === 0)) {
|
|
569
|
+
strippedAny = true;
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
return true;
|
|
573
|
+
});
|
|
574
|
+
if (filtered.length !== msg.content.length) msg.content = filtered;
|
|
575
|
+
}
|
|
576
|
+
// Pass 2: drop orphan tool-result messages with empty toolCallId
|
|
577
|
+
// (covers both internal camelCase and wire-format snake_case fields).
|
|
578
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
579
|
+
const m = messages[i];
|
|
580
|
+
if (!m) continue;
|
|
581
|
+
const isEmptyCamel = m.role === "toolResult" && (typeof m.toolCallId !== "string" || m.toolCallId.length === 0);
|
|
582
|
+
const isEmptySnake = m.role === "tool" && (typeof m.tool_call_id !== "string" || m.tool_call_id.length === 0);
|
|
583
|
+
if (isEmptyCamel || isEmptySnake) {
|
|
584
|
+
messages.splice(i, 1);
|
|
585
|
+
strippedAny = true;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (strippedAny) {
|
|
589
|
+
debugLog("stripped empty-id tool-call/tool-result blocks before provider request");
|
|
590
|
+
}
|
|
591
|
+
} catch (error) {
|
|
592
|
+
debugLog("before_provider_request sanitizer failed: " + (error instanceof Error ? error.message : String(error)));
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Providers: this plugin owns `agnes` (international) and `agnes-cn`
|
|
597
|
+
// (China) outright — seed catalog + live discovery + stream routing — so
|
|
598
|
+
// every text, image and video model is selectable via /model and --model.
|
|
599
|
+
try {
|
|
600
|
+
registerAgnesProviders(pi);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
debugLog("provider registration failed: " + (error instanceof Error ? error.message : String(error)));
|
|
603
|
+
}
|
|
292
604
|
}
|