pi-agnes-tools 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +662 -0
- package/README.md +64 -38
- package/extensions/agnes-tools.ts +424 -121
- package/package.json +6 -2
- package/skills/agnes-media/SKILL.md +43 -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,19 @@ 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
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const images =
|
|
222
|
-
const num_frames = params.num_frames || 121;
|
|
223
|
-
const frame_rate = params.frame_rate || 24;
|
|
224
|
-
|
|
225
|
-
checkVideoModel(rawModel);
|
|
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
|
+
const body = { model: opts.model, prompt: opts.prompt };
|
|
187
|
+
if (opts.num_frames) body.num_frames = opts.num_frames;
|
|
188
|
+
if (opts.frame_rate) body.frame_rate = opts.frame_rate;
|
|
189
|
+
const images = opts.images || [];
|
|
229
190
|
if (images.length === 1) body.image = images[0];
|
|
230
191
|
if (images.length > 1) body.extra_body = { image: images, mode: "keyframes" };
|
|
231
192
|
|
|
232
193
|
const response = await fetch(baseUrl + "/videos", {
|
|
233
194
|
method: "POST",
|
|
234
|
-
headers,
|
|
195
|
+
headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json" },
|
|
235
196
|
body: JSON.stringify(body),
|
|
236
|
-
signal,
|
|
197
|
+
signal: opts.signal,
|
|
237
198
|
});
|
|
238
199
|
const task = await response.json().catch(() => null);
|
|
239
200
|
if (!response.ok) {
|
|
@@ -242,36 +203,320 @@ async function executeVideo(_toolCallId, params, signal) {
|
|
|
242
203
|
const videoId = (task && (task.video_id || task.id || task.task_id)) || null;
|
|
243
204
|
if (!videoId) throw new Error("Agnes video API returned no video_id");
|
|
244
205
|
|
|
245
|
-
const result = task.status === "completed" ? task : await pollVideo(baseUrl, videoId, apiKey, signal);
|
|
206
|
+
const result = task.status === "completed" ? task : await pollVideo(baseUrl, videoId, apiKey, opts.signal);
|
|
246
207
|
const url = result && result.metadata && result.metadata.url;
|
|
247
208
|
if (!url) throw new Error("Agnes video API returned no metadata.url");
|
|
248
209
|
|
|
249
210
|
const directory = join(process.cwd(), ".pi", "generated-videos");
|
|
250
211
|
await mkdir(directory, { recursive: true });
|
|
251
|
-
const filePath = join(directory,
|
|
212
|
+
const filePath = join(directory, opts.model + "-" + Date.now() + ".mp4");
|
|
252
213
|
const videoRes = await fetch(url);
|
|
253
214
|
if (!videoRes.ok) throw new Error("Unable to download generated video: HTTP " + videoRes.status);
|
|
254
215
|
await writeFile(filePath, Buffer.from(await videoRes.arrayBuffer()));
|
|
216
|
+
return { filePath, remoteUrl: url };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// agnes_image / agnes_video tools (always registered — no provider conflict)
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
255
222
|
|
|
256
|
-
|
|
223
|
+
const imageParams = Type.Object({
|
|
224
|
+
prompt: { type: "string", description: "Text prompt describing the image to generate." },
|
|
225
|
+
model: {
|
|
226
|
+
type: "string",
|
|
227
|
+
description: "Agnes image model id. One of: " + [...IMAGE_MODELS].join(", ") + ". Default: " + DEFAULT_IMAGE_MODEL + ".",
|
|
228
|
+
},
|
|
229
|
+
endpoint: {
|
|
230
|
+
type: "string",
|
|
231
|
+
enum: ["agnes", "agnes-cn"],
|
|
232
|
+
description: "Which Agnes endpoint to use: agnes (international, apihub.agnes-ai.com) or agnes-cn (China, api.agnes-ai.cn). Default: agnes.",
|
|
233
|
+
},
|
|
234
|
+
images: {
|
|
235
|
+
type: "array",
|
|
236
|
+
items: { type: "string" },
|
|
237
|
+
description:
|
|
238
|
+
"Optional list of base64-encoded image data URIs (data:<mime>;base64,<data>) to use as reference/conditioning images.",
|
|
239
|
+
},
|
|
240
|
+
response_format: {
|
|
241
|
+
type: "string",
|
|
242
|
+
description: "Response image format. Default: png.",
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
async function executeImage(_toolCallId, params) {
|
|
247
|
+
const prompt = params.prompt;
|
|
248
|
+
const rawModel = params.model || DEFAULT_IMAGE_MODEL;
|
|
249
|
+
const endpointId = params.endpoint || "agnes";
|
|
250
|
+
checkImageModel(rawModel);
|
|
251
|
+
const { baseUrl, apiKey } = getEndpoint(endpointId);
|
|
252
|
+
|
|
253
|
+
const saved = await requestImage(baseUrl, apiKey, {
|
|
254
|
+
model: rawModel,
|
|
255
|
+
prompt,
|
|
256
|
+
images: params.images || [],
|
|
257
|
+
response_format: params.response_format || "png",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const text = saved.remoteUrl
|
|
261
|
+
? "\n\nSaved local copy: " + fileLink(saved.filePath) + "\n\nImage URL may expire according to Agnes retention policy."
|
|
262
|
+
: "Generated image saved to: " + fileLink(saved.filePath);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
content: [{ type: "text", text }],
|
|
266
|
+
details: { filePath: saved.filePath, model: rawModel, endpoint: endpointId, remoteUrl: saved.remoteUrl },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const videoParams = Type.Object({
|
|
271
|
+
prompt: { type: "string", description: "Text prompt describing the video to generate." },
|
|
272
|
+
model: {
|
|
273
|
+
type: "string",
|
|
274
|
+
description: "Agnes video model id. One of: " + [...VIDEO_MODELS].join(", ") + ". Default: " + DEFAULT_VIDEO_MODEL + ".",
|
|
275
|
+
},
|
|
276
|
+
endpoint: {
|
|
277
|
+
type: "string",
|
|
278
|
+
enum: ["agnes", "agnes-cn"],
|
|
279
|
+
description: "Which Agnes endpoint to use: agnes (international, apihub.agnes-ai.com) or agnes-cn (China, api.agnes-ai.cn). Default: agnes.",
|
|
280
|
+
},
|
|
281
|
+
images: {
|
|
282
|
+
type: "array",
|
|
283
|
+
items: { type: "string" },
|
|
284
|
+
description: "Optional reference image(s) as base64 data URIs. 1 image = image-to-video; >1 = keyframes mode.",
|
|
285
|
+
},
|
|
286
|
+
num_frames: { type: "integer", description: "Number of frames. Default: 121." },
|
|
287
|
+
frame_rate: { type: "integer", description: "Frames per second. Default: 24." },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
async function executeVideo(_toolCallId, params, signal) {
|
|
291
|
+
const prompt = params.prompt;
|
|
292
|
+
const rawModel = params.model || DEFAULT_VIDEO_MODEL;
|
|
293
|
+
const endpointId = params.endpoint || "agnes";
|
|
294
|
+
checkVideoModel(rawModel);
|
|
295
|
+
const { baseUrl, apiKey } = getEndpoint(endpointId);
|
|
296
|
+
|
|
297
|
+
const saved = await requestVideo(baseUrl, apiKey, {
|
|
298
|
+
model: rawModel,
|
|
299
|
+
prompt,
|
|
300
|
+
images: params.images || [],
|
|
301
|
+
num_frames: params.num_frames || 121,
|
|
302
|
+
frame_rate: params.frame_rate || 24,
|
|
303
|
+
signal,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const text = "Generated video saved to: " + fileLink(saved.filePath) + "\n\nVideo URL: " + saved.remoteUrl;
|
|
257
307
|
return {
|
|
258
308
|
content: [{ type: "text", text }],
|
|
259
|
-
details: { filePath, model: rawModel, endpoint: endpointId, remoteUrl:
|
|
309
|
+
details: { filePath: saved.filePath, model: rawModel, endpoint: endpointId, remoteUrl: saved.remoteUrl },
|
|
260
310
|
};
|
|
261
311
|
}
|
|
262
312
|
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Standalone provider fallback (only when pi-agnes is NOT installed).
|
|
315
|
+
// Mirrors pi-agnes: seed catalog + /v1/models discovery + stream routing,
|
|
316
|
+
// so /model selection works out of the box without pi-agnes.
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
function toModelConfig(id) {
|
|
320
|
+
const limits =
|
|
321
|
+
id.startsWith("agnes-2.5") || id.startsWith("agnes-3")
|
|
322
|
+
? { contextWindow: 1048576, maxTokens: 65536 }
|
|
323
|
+
: id.startsWith("agnes-2.0")
|
|
324
|
+
? { contextWindow: 1048576, maxTokens: 32768 }
|
|
325
|
+
: { contextWindow: 131072, maxTokens: 32768 };
|
|
326
|
+
return {
|
|
327
|
+
id,
|
|
328
|
+
name: id,
|
|
329
|
+
reasoning: false,
|
|
330
|
+
input: ["text", "image"],
|
|
331
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
332
|
+
...limits,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function fetchStandaloneModels(baseUrl, apiKey, signal) {
|
|
337
|
+
const headers = {};
|
|
338
|
+
if (apiKey) headers["Authorization"] = "Bearer " + apiKey;
|
|
339
|
+
const res = await fetch(baseUrl + "/models", { headers, redirect: "follow", signal });
|
|
340
|
+
if (!res.ok) throw new Error("HTTP " + res.status + " " + res.statusText);
|
|
341
|
+
const payload = await res.json().catch(() => null);
|
|
342
|
+
const data = payload && Array.isArray(payload.data) ? payload.data : Array.isArray(payload) ? payload : [];
|
|
343
|
+
return data
|
|
344
|
+
.filter((m) => m && m.id && isTextModel(m.id))
|
|
345
|
+
.map((m) => toModelConfig(m.id));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function makeRefreshModels(baseUrl, apiKeyEnv, providerId) {
|
|
349
|
+
return async ({ signal, stored, publish, allowNetwork, credential }) => {
|
|
350
|
+
const cached = stored && Array.isArray(stored.models) ? stored.models : undefined;
|
|
351
|
+
if (!allowNetwork || (signal && signal.aborted)) return cached;
|
|
352
|
+
const apiKey = credential && credential.type === "api_key" ? credential.key : process.env[apiKeyEnv];
|
|
353
|
+
let models;
|
|
354
|
+
try {
|
|
355
|
+
models = await fetchStandaloneModels(baseUrl, apiKey, signal);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (cached) return cached;
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
if (models.length > 0) {
|
|
361
|
+
await publish({ persist: { provider: providerId, models } });
|
|
362
|
+
return models;
|
|
363
|
+
}
|
|
364
|
+
return cached;
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function latestUserMessage(context) {
|
|
369
|
+
const messages = context && Array.isArray(context.messages) ? context.messages : [];
|
|
370
|
+
return [...messages].reverse().find((m) => m && m.role === "user");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function extractPrompt(context) {
|
|
374
|
+
const user = latestUserMessage(context);
|
|
375
|
+
if (!user) return { prompt: "", images: [] };
|
|
376
|
+
if (typeof user.content === "string") return { prompt: user.content, images: [] };
|
|
377
|
+
return {
|
|
378
|
+
prompt: user.content
|
|
379
|
+
.filter((part) => part && part.type === "text")
|
|
380
|
+
.map((part) => part.text || "")
|
|
381
|
+
.join("\n"),
|
|
382
|
+
images: user.content.filter((part) => part && part.type === "image"),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function pushDone(stream, output, text) {
|
|
387
|
+
output.content.push({ type: "text", text });
|
|
388
|
+
stream.push({ type: "text_start", contentIndex: 0, partial: output });
|
|
389
|
+
stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial: output });
|
|
390
|
+
stream.push({ type: "text_end", contentIndex: 0, content: text, partial: output });
|
|
391
|
+
output.stopReason = "stop";
|
|
392
|
+
stream.push({ type: "done", reason: "stop", message: output });
|
|
393
|
+
stream.end();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function pushError(stream, output, error, signal) {
|
|
397
|
+
output.stopReason = signal && signal.aborted ? "aborted" : "error";
|
|
398
|
+
output.errorMessage = error instanceof Error ? error.message : String(error);
|
|
399
|
+
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
400
|
+
stream.end();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function newOutput(model) {
|
|
404
|
+
return {
|
|
405
|
+
role: "assistant",
|
|
406
|
+
content: [],
|
|
407
|
+
api: model.api,
|
|
408
|
+
provider: model.provider,
|
|
409
|
+
model: model.id,
|
|
410
|
+
usage: {
|
|
411
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
|
|
412
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
413
|
+
},
|
|
414
|
+
stopReason: "pending",
|
|
415
|
+
timestamp: Date.now(),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function streamStandaloneImage(model, context, options) {
|
|
420
|
+
const stream = createAssistantMessageEventStream();
|
|
421
|
+
const output = newOutput(model);
|
|
422
|
+
(async () => {
|
|
423
|
+
try {
|
|
424
|
+
stream.push({ type: "start", partial: output });
|
|
425
|
+
const extracted = extractPrompt(context);
|
|
426
|
+
if (!extracted.prompt) throw new Error("Image generation requires a text prompt");
|
|
427
|
+
const endpointId = model.provider === "agnes-cn" ? "agnes-cn" : "agnes";
|
|
428
|
+
const { baseUrl, apiKey } = getEndpoint(endpointId);
|
|
429
|
+
const saved = await requestImage(baseUrl, apiKey, {
|
|
430
|
+
model: model.id,
|
|
431
|
+
prompt: extracted.prompt,
|
|
432
|
+
images: extracted.images.map((img) => "data:" + img.mimeType + ";base64," + img.data),
|
|
433
|
+
response_format: "png",
|
|
434
|
+
signal: options && options.signal,
|
|
435
|
+
});
|
|
436
|
+
const text = saved.remoteUrl
|
|
437
|
+
? "\n\nSaved local copy: " + fileLink(saved.filePath) + "\n\nImage URL may expire according to Agnes retention policy."
|
|
438
|
+
: "Generated image saved to: " + fileLink(saved.filePath);
|
|
439
|
+
pushDone(stream, output, text);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
pushError(stream, output, error, options && options.signal);
|
|
442
|
+
}
|
|
443
|
+
})();
|
|
444
|
+
return stream;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function streamStandaloneVideo(model, context, options) {
|
|
448
|
+
const stream = createAssistantMessageEventStream();
|
|
449
|
+
const output = newOutput(model);
|
|
450
|
+
(async () => {
|
|
451
|
+
try {
|
|
452
|
+
stream.push({ type: "start", partial: output });
|
|
453
|
+
const extracted = extractPrompt(context);
|
|
454
|
+
if (!extracted.prompt) throw new Error("Video generation requires a text prompt");
|
|
455
|
+
const endpointId = model.provider === "agnes-cn" ? "agnes-cn" : "agnes";
|
|
456
|
+
const { baseUrl, apiKey } = getEndpoint(endpointId);
|
|
457
|
+
const saved = await requestVideo(baseUrl, apiKey, {
|
|
458
|
+
model: model.id,
|
|
459
|
+
prompt: extracted.prompt,
|
|
460
|
+
images: extracted.images.map((img) => "data:" + img.mimeType + ";base64," + img.data),
|
|
461
|
+
num_frames: 121,
|
|
462
|
+
frame_rate: 24,
|
|
463
|
+
signal: options && options.signal,
|
|
464
|
+
});
|
|
465
|
+
pushDone(stream, output, "Generated video saved to: " + fileLink(saved.filePath) + "\n\nVideo URL: " + saved.remoteUrl);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
pushError(stream, output, error, options && options.signal);
|
|
468
|
+
}
|
|
469
|
+
})();
|
|
470
|
+
return stream;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function streamStandalone(model, context, options) {
|
|
474
|
+
if (isVideoModel(model.id)) return streamStandaloneVideo(model, context, options);
|
|
475
|
+
if (isImageModel(model.id)) return streamStandaloneImage(model, context, options);
|
|
476
|
+
return openAICompletionsApi().streamSimple(model, context, options);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function debugLog(msg) {
|
|
480
|
+
if (process.env.PI_AGNES_TOOLS_DEBUG) {
|
|
481
|
+
try { console.error("[pi-agnes-tools] " + msg); } catch { /* ignore */ }
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function registerAgnesProviders(pi) {
|
|
486
|
+
const defs = [
|
|
487
|
+
{ id: "agnes", name: "Agnes AI", baseUrl: ENDPOINTS.agnes.baseUrl, apiKeyEnv: "AGNES_API_KEY" },
|
|
488
|
+
{ id: "agnes-cn", name: "Agnes AI (CN)", baseUrl: ENDPOINTS["agnes-cn"].baseUrl, apiKeyEnv: "AGNES_CN_API_KEY" },
|
|
489
|
+
];
|
|
490
|
+
for (const def of defs) {
|
|
491
|
+
// Omit apiKey when the env var is absent so /login can supply the key —
|
|
492
|
+
// same convention pi-agnes uses.
|
|
493
|
+
const apiKeyRef = process.env[def.apiKeyEnv] ? "$" + def.apiKeyEnv : undefined;
|
|
494
|
+
pi.registerProvider(def.id, {
|
|
495
|
+
name: def.name,
|
|
496
|
+
baseUrl: def.baseUrl,
|
|
497
|
+
...(apiKeyRef ? { apiKey: apiKeyRef } : {}),
|
|
498
|
+
api: "openai-completions",
|
|
499
|
+
streamSimple: streamStandalone,
|
|
500
|
+
models: AGNES_SEED.map((id) => toModelConfig(id)),
|
|
501
|
+
refreshModels: makeRefreshModels(def.baseUrl, def.apiKeyEnv, def.id),
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
263
506
|
// ---------------------------------------------------------------------------
|
|
264
507
|
// Extension entry
|
|
265
508
|
// ---------------------------------------------------------------------------
|
|
266
509
|
|
|
267
510
|
export default function (pi) {
|
|
511
|
+
// Tools are always safe: unique names, no provider overlap.
|
|
268
512
|
pi.registerTool({
|
|
269
513
|
name: "agnes_image",
|
|
270
514
|
label: "Agnes Image",
|
|
271
515
|
description:
|
|
272
516
|
"Generate an image via the Agnes AI API (no model switch needed). " +
|
|
273
517
|
"Saves a local copy under .pi/generated-images/ and returns the saved path plus the (possibly expiring) remote URL. " +
|
|
274
|
-
"
|
|
518
|
+
"endpoint: agnes = international (apihub.agnes-ai.com, default), agnes-cn = China (api.agnes-ai.cn). " +
|
|
519
|
+
"Auth: AGNES_API_KEY or AGNES_CN_API_KEY env, else the /login-stored Agnes key.",
|
|
275
520
|
parameters: imageParams,
|
|
276
521
|
executionMode: "parallel",
|
|
277
522
|
execute: executeImage,
|
|
@@ -284,9 +529,67 @@ export default function (pi) {
|
|
|
284
529
|
"Generate a video via the Agnes AI API (no model switch needed). " +
|
|
285
530
|
"Async: creates a task, polls every 5s until done (up to 30 min), then downloads the .mp4 to .pi/generated-videos/. " +
|
|
286
531
|
"Supports image-to-video (1 reference image) and keyframes mode (>1 image). " +
|
|
287
|
-
"
|
|
532
|
+
"endpoint: agnes = international (default), agnes-cn = China. " +
|
|
533
|
+
"Auth: AGNES_API_KEY or AGNES_CN_API_KEY env, else the /login-stored Agnes key.",
|
|
288
534
|
parameters: videoParams,
|
|
289
535
|
executionMode: "sequential",
|
|
290
536
|
execute: executeVideo,
|
|
291
537
|
});
|
|
538
|
+
|
|
539
|
+
// Patch around a pi-ai quirk that bites this stack: when a streaming tool-call
|
|
540
|
+
// delta arrives without an `id`, the tool-call block is recorded with
|
|
541
|
+
// `id: ""` (openai-completions.js line ~326). On the next turn pi replays
|
|
542
|
+
// history to OpenAI Chat Completions, which rejects `tool_call_id: ""`
|
|
543
|
+
// with: "missing field `tool_call_id` at line 1 column N".
|
|
544
|
+
// We strip assistant tool-call blocks with empty ids and the matching
|
|
545
|
+
// orphan tool-result messages before each request to any agnes provider,
|
|
546
|
+
// so the upstream never sees an empty id. This is harmless when no
|
|
547
|
+
// empty-id blocks exist (the common case).
|
|
548
|
+
pi.on("before_provider_request", (event) => {
|
|
549
|
+
try {
|
|
550
|
+
const payload = event.payload;
|
|
551
|
+
if (!payload || typeof payload !== "object") return;
|
|
552
|
+
const messages = Array.isArray(payload.messages) ? payload.messages : null;
|
|
553
|
+
if (!messages) return;
|
|
554
|
+
// Pass 1: strip empty-id tool-call blocks from assistant messages.
|
|
555
|
+
let strippedAny = false;
|
|
556
|
+
for (const msg of messages) {
|
|
557
|
+
if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
558
|
+
const filtered = msg.content.filter((block) => {
|
|
559
|
+
if (block && block.type === "toolCall" && (!block.id || typeof block.id !== "string" || block.id.length === 0)) {
|
|
560
|
+
strippedAny = true;
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
return true;
|
|
564
|
+
});
|
|
565
|
+
if (filtered.length !== msg.content.length) msg.content = filtered;
|
|
566
|
+
}
|
|
567
|
+
// Pass 2: drop orphan tool-result messages with empty toolCallId
|
|
568
|
+
// (covers both internal camelCase and wire-format snake_case fields).
|
|
569
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
570
|
+
const m = messages[i];
|
|
571
|
+
if (!m) continue;
|
|
572
|
+
const isEmptyCamel = m.role === "toolResult" && (typeof m.toolCallId !== "string" || m.toolCallId.length === 0);
|
|
573
|
+
const isEmptySnake = m.role === "tool" && (typeof m.tool_call_id !== "string" || m.tool_call_id.length === 0);
|
|
574
|
+
if (isEmptyCamel || isEmptySnake) {
|
|
575
|
+
messages.splice(i, 1);
|
|
576
|
+
strippedAny = true;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (strippedAny) {
|
|
580
|
+
debugLog("stripped empty-id tool-call/tool-result blocks before provider request");
|
|
581
|
+
}
|
|
582
|
+
} catch (error) {
|
|
583
|
+
debugLog("before_provider_request sanitizer failed: " + (error instanceof Error ? error.message : String(error)));
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Providers: this plugin owns `agnes` (international) and `agnes-cn`
|
|
588
|
+
// (China) outright — seed catalog + live discovery + stream routing — so
|
|
589
|
+
// every text, image and video model is selectable via /model and --model.
|
|
590
|
+
try {
|
|
591
|
+
registerAgnesProviders(pi);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
debugLog("provider registration failed: " + (error instanceof Error ? error.message : String(error)));
|
|
594
|
+
}
|
|
292
595
|
}
|