@rol1n/pi-kit 0.0.0-stage → 0.1.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.
Files changed (120) hide show
  1. package/README.md +73 -2
  2. package/ask-u-questions/README.md +26 -0
  3. package/ask-u-questions/dialog-ui.ts +80 -0
  4. package/ask-u-questions/index.ts +92 -0
  5. package/ask-u-questions/multi-choice.ts +258 -0
  6. package/ask-u-questions/single-choice.ts +180 -0
  7. package/ask-u-questions/tool-renderers.ts +67 -0
  8. package/ask-u-questions/types.ts +192 -0
  9. package/continue-signal/README.md +20 -0
  10. package/continue-signal/index.ts +69 -0
  11. package/custom-providers/README.md +20 -0
  12. package/custom-providers/asr/handlers/dashscope.ts +154 -0
  13. package/custom-providers/asr/index.ts +37 -0
  14. package/custom-providers/asr/models.ts +18 -0
  15. package/custom-providers/asr/types.ts +20 -0
  16. package/custom-providers/chat/index.ts +33 -0
  17. package/custom-providers/chat/magpie.ts +258 -0
  18. package/custom-providers/chat/models.ts +111 -0
  19. package/custom-providers/classifier/index.ts +6 -0
  20. package/custom-providers/classifier/models.ts +1 -0
  21. package/custom-providers/image/handlers/dashscope.ts +240 -0
  22. package/custom-providers/image/handlers/openrouter.ts +185 -0
  23. package/custom-providers/image/index.ts +96 -0
  24. package/custom-providers/image/models.ts +71 -0
  25. package/custom-providers/image/types.ts +22 -0
  26. package/custom-providers/index.ts +64 -0
  27. package/custom-providers/types.ts +17 -0
  28. package/image-generate/README.md +25 -0
  29. package/image-generate/files.ts +49 -0
  30. package/image-generate/index.ts +220 -0
  31. package/image-generate/model-selector.ts +150 -0
  32. package/memory-log/README.md +54 -0
  33. package/memory-log/agent/consolidator/prompt.ts +47 -0
  34. package/memory-log/agent/consolidator/tools.ts +160 -0
  35. package/memory-log/agent/cost.ts +32 -0
  36. package/memory-log/agent/index.ts +42 -0
  37. package/memory-log/agent/observer/prompt.ts +73 -0
  38. package/memory-log/agent/observer/tool.ts +73 -0
  39. package/memory-log/commands/compact.ts +35 -0
  40. package/memory-log/commands/consolidate.ts +47 -0
  41. package/memory-log/commands/status.ts +50 -0
  42. package/memory-log/config.ts +253 -0
  43. package/memory-log/debug-log.ts +76 -0
  44. package/memory-log/hooks/compaction-hook.ts +161 -0
  45. package/memory-log/hooks/compaction-trigger.ts +160 -0
  46. package/memory-log/hooks/consolidator-trigger.ts +143 -0
  47. package/memory-log/hooks/observer-trigger.ts +176 -0
  48. package/memory-log/ids.ts +78 -0
  49. package/memory-log/index.ts +149 -0
  50. package/memory-log/ledger/fold.ts +77 -0
  51. package/memory-log/ledger/index.ts +7 -0
  52. package/memory-log/ledger/pool.ts +38 -0
  53. package/memory-log/ledger/progress.ts +181 -0
  54. package/memory-log/ledger/projection.ts +115 -0
  55. package/memory-log/ledger/render.ts +38 -0
  56. package/memory-log/ledger/serialize.ts +161 -0
  57. package/memory-log/ledger/types.ts +201 -0
  58. package/memory-log/memory/index-render.ts +45 -0
  59. package/memory-log/memory/paths.ts +120 -0
  60. package/memory-log/memory/session.ts +64 -0
  61. package/memory-log/runtime.ts +127 -0
  62. package/memory-log/spawn/launch.ts +133 -0
  63. package/memory-log/spawn/runs.ts +83 -0
  64. package/memory-log/tokens.ts +66 -0
  65. package/memory-log/ui/status-controller.ts +225 -0
  66. package/memory-log/ui/timeline.ts +103 -0
  67. package/model-vision/README.md +22 -0
  68. package/multi-harness/README.md +63 -0
  69. package/multi-harness/adapters.mjs +72 -0
  70. package/multi-harness/harness-done.mjs +14 -0
  71. package/multi-harness/index.ts +375 -0
  72. package/multi-harness/ipc.mjs +37 -0
  73. package/multi-harness/opencode-plugin.mjs +99 -0
  74. package/multi-harness/process-runner.mjs +115 -0
  75. package/multi-harness/turn-hook.mjs +29 -0
  76. package/multi-harness/worker-bridge.ts +52 -0
  77. package/oai-subquota/README.md +22 -0
  78. package/oai-subquota/index.ts +286 -0
  79. package/oai-subquota/snapshot.ts +293 -0
  80. package/oai-subquota/ui.ts +183 -0
  81. package/package.json +92 -4
  82. package/prompt-snipaste/README.md +27 -0
  83. package/prompt-snipaste/index.ts +143 -0
  84. package/prompt-snipaste/menu.ts +159 -0
  85. package/prompt-snipaste/prompts/ask-questions.md +8 -0
  86. package/prompt-snipaste/prompts/delegate-exploration.md +7 -0
  87. package/prompt-snipaste/prompts/diagnose-report.md +7 -0
  88. package/prompt-snipaste/prompts/frontend-tech-preset.md +18 -0
  89. package/prompt-snipaste/prompts/orchestrator-mode.md +7 -0
  90. package/prompt-snipaste/prompts/session-kickoff.md +7 -0
  91. package/prompt-snipaste/prompts/verify-not-assume.md +7 -0
  92. package/prompt-snipaste/renderers.ts +87 -0
  93. package/prompt-snipaste/snippets.ts +206 -0
  94. package/prompt-snipaste/types.ts +18 -0
  95. package/screenshot/README.md +14 -0
  96. package/srp-theme/README.md +18 -0
  97. package/srp-theme/footer.ts +193 -0
  98. package/srp-theme/index.ts +172 -0
  99. package/srp-theme/tps-meter.ts +441 -0
  100. package/subagent/README.md +35 -0
  101. package/subagent/bridge.ts +171 -0
  102. package/subagent/index.ts +590 -0
  103. package/subagent/mux.ts +621 -0
  104. package/subagent/session.ts +166 -0
  105. package/subagent/ui.ts +100 -0
  106. package/tui-asr/README.md +21 -0
  107. package/tui-asr/dictation.ts +272 -0
  108. package/tui-asr/index.ts +181 -0
  109. package/tui-asr/input.ts +87 -0
  110. package/tui-asr/model-selector.ts +163 -0
  111. package/tui-asr/status.ts +79 -0
  112. package/web-reach/README.md +19 -0
  113. package/web-reach/html.ts +83 -0
  114. package/web-reach/index.ts +82 -0
  115. package/web-reach/page.ts +214 -0
  116. package/web-reach/rsc.ts +161 -0
  117. package/web-reach/search.ts +85 -0
  118. package/web-reach/transport.ts +35 -0
  119. package/windows-wheel-fix/README.md +12 -0
  120. package/windows-wheel-fix/index.ts +44 -0
@@ -0,0 +1,240 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname, resolve } from "node:path";
3
+
4
+ const MIME_BY_EXTENSION: Record<string, string | undefined> = {
5
+ ".png": "image/png",
6
+ ".jpg": "image/jpeg",
7
+ ".jpeg": "image/jpeg",
8
+ ".webp": "image/webp",
9
+ ".bmp": "image/bmp",
10
+ ".tif": "image/tiff",
11
+ ".tiff": "image/tiff",
12
+ ".gif": "image/gif",
13
+ };
14
+
15
+ /** DashScope caps reference images at 10 MB. */
16
+ const MAX_REFERENCE_BYTES = 10 * 1024 * 1024;
17
+
18
+ /**
19
+ * Content item accepted by the DashScope multimodal-generation endpoint.
20
+ * `url` is our extension over Pi's native `image` content: DashScope accepts public
21
+ * URLs directly, so inlining them as base64 would be a pointless download.
22
+ */
23
+ type DashscopeInput = {
24
+ type: string;
25
+ text?: string;
26
+ data?: string;
27
+ mimeType?: string;
28
+ url?: string;
29
+ };
30
+
31
+ /**
32
+ * `parameters.size` uses the `width*height` form (asterisk, not `x`).
33
+ * All values below stay inside qwen-image-3.0's documented bounds: total pixels
34
+ * within 512*512..2048*2048 and aspect ratio within 1:8..8:1.
35
+ */
36
+ const DASHSCOPE_RATIO_SIZES: Record<string, string> = {
37
+ "1:1": "1024*1024",
38
+ "2:3": "1024*1536",
39
+ "3:2": "1536*1024",
40
+ "3:4": "1728*2368",
41
+ "4:3": "2368*1728",
42
+ "9:16": "1080*1920",
43
+ "16:9": "1920*1080",
44
+ };
45
+
46
+ export async function generateDashscopeImages(
47
+ model: { api: string; provider: string; id: string; baseUrl?: string },
48
+ context: { input: DashscopeInput[] },
49
+ options?: {
50
+ apiKey?: string;
51
+ signal?: AbortSignal;
52
+ fetch?: typeof fetch;
53
+ onPayload?: (payload: unknown, model: unknown) => unknown | Promise<unknown>;
54
+ onResponse?: (response: { status: number; headers: Record<string, string> }, model: unknown) => void | Promise<void>;
55
+ },
56
+ ) {
57
+ const result = {
58
+ api: model.api,
59
+ provider: model.provider,
60
+ model: model.id,
61
+ output: [] as { type: "image"; data: string; mimeType: string }[],
62
+ stopReason: "stop" as "stop" | "error" | "aborted",
63
+ timestamp: Date.now(),
64
+ errorMessage: undefined as string | undefined,
65
+ };
66
+
67
+ try {
68
+ if (!options?.apiKey) throw new Error("No API key for dashscope; use /login first");
69
+
70
+ // DashScope expects image objects before exactly one text instruction.
71
+ const content: Array<{ text: string } | { image: string }> = [];
72
+ for (const item of context.input) {
73
+ if (item.type === "image") {
74
+ if (item.url) content.push({ image: item.url });
75
+ else if (item.data && item.mimeType) content.push({ image: `data:${item.mimeType};base64,${item.data}` });
76
+ else throw new Error("DashScope image input requires either a url or base64 data + mimeType");
77
+ } else if (item.text) {
78
+ content.push({ text: item.text });
79
+ }
80
+ }
81
+ const prompt = content
82
+ .filter((item): item is { text: string } => "text" in item)
83
+ .map((item) => item.text)
84
+ .join("\n")
85
+ .trim();
86
+ if (!prompt) throw new Error("DashScope image generation requires a text prompt");
87
+
88
+ let payload: unknown = {
89
+ model: model.id,
90
+ input: { messages: [{ role: "user", content }] },
91
+ parameters: { prompt_extend: true },
92
+ };
93
+ const replacement = await options.onPayload?.(payload, model);
94
+ if (replacement !== undefined) payload = replacement;
95
+ const request = options.fetch ?? fetch;
96
+ const response = await request(`${model.baseUrl}/services/aigc/multimodal-generation/generation`, {
97
+ method: "POST",
98
+ headers: {
99
+ "Content-Type": "application/json",
100
+ Authorization: `Bearer ${options.apiKey}`,
101
+ },
102
+ body: JSON.stringify(payload),
103
+ signal: options.signal,
104
+ });
105
+ await options.onResponse?.({
106
+ status: response.status,
107
+ headers: Object.fromEntries(response.headers),
108
+ }, model);
109
+ if (!response.ok) throw new Error(`DashScope image request failed (HTTP ${response.status})`);
110
+ const data = await response.json() as {
111
+ request_id?: string;
112
+ output?: { choices?: { message?: { content?: { image?: string }[] } }[] };
113
+ };
114
+ const imageUrls = data.output?.choices?.flatMap((choice) =>
115
+ choice.message?.content?.flatMap((item) => item.image ? [item.image] : []) ?? []
116
+ ) ?? [];
117
+ if (imageUrls.length === 0) throw new Error("DashScope returned no images");
118
+
119
+ for (const imageUrl of imageUrls) {
120
+ const url = new URL(imageUrl);
121
+ if (url.protocol !== "https:" || !/^dashscope-result-[a-z0-9-]+\.oss-[a-z0-9-]+\.aliyuncs\.com$/.test(url.hostname)) {
122
+ throw new Error("DashScope returned an unexpected image URL");
123
+ }
124
+ const imageResponse = await request(url, { signal: options.signal, redirect: "manual" });
125
+ if (!imageResponse.ok) throw new Error(`Image download failed (HTTP ${imageResponse.status})`);
126
+ const mimeType = imageResponse.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase()
127
+ ?? (url.pathname.toLowerCase().endsWith(".png") ? "image/png" : "");
128
+ if (!["image/png", "image/jpeg", "image/webp"].includes(mimeType)) {
129
+ throw new Error("DashScope returned an unsupported image type");
130
+ }
131
+ if (!imageResponse.body) throw new Error("DashScope returned an empty image body");
132
+ const reader = imageResponse.body.getReader();
133
+ const chunks: Uint8Array[] = [];
134
+ let size = 0;
135
+ while (true) {
136
+ const { done, value } = await reader.read();
137
+ if (done) break;
138
+ size += value.byteLength;
139
+ if (size > 25 * 1024 * 1024) {
140
+ await reader.cancel();
141
+ throw new Error("DashScope image exceeds 25 MB");
142
+ }
143
+ chunks.push(value);
144
+ }
145
+ if (size === 0) throw new Error("DashScope returned an empty image");
146
+ result.output.push({
147
+ type: "image",
148
+ data: Buffer.concat(chunks, size).toString("base64"),
149
+ mimeType,
150
+ });
151
+ }
152
+ return { ...result, responseId: data.request_id };
153
+ } catch (error) {
154
+ result.stopReason = options?.signal?.aborted ? "aborted" : "error";
155
+ result.errorMessage = error instanceof Error ? error.message : String(error);
156
+ result.output = [];
157
+ return result;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Direct entry point used by `image-generate.ts`: adds the two capabilities Pi's native
163
+ * `ImagesContext` cannot express, an output aspect ratio and a reference image.
164
+ */
165
+ export async function generateDashscopeImage(
166
+ ctx: { prompt: string; model: string; aspectRatio?: string; image?: string },
167
+ options: { apiKey: string; baseUrl?: string; cwd?: string; signal?: AbortSignal },
168
+ ) {
169
+ const input: DashscopeInput[] = [];
170
+ if (ctx.image) input.push(await toDashscopeReference(ctx.image, options.cwd));
171
+ input.push({ type: "text", text: ctx.prompt });
172
+
173
+ const size = resolveSize(ctx.aspectRatio);
174
+ const res = await generateDashscopeImages(
175
+ {
176
+ api: "dashscope-images",
177
+ provider: "dashscope",
178
+ id: ctx.model,
179
+ baseUrl: options.baseUrl || "https://dashscope.aliyuncs.com/api/v1",
180
+ },
181
+ { input },
182
+ {
183
+ apiKey: options.apiKey,
184
+ signal: options.signal,
185
+ ...(size
186
+ ? {
187
+ onPayload: (payload: unknown) => {
188
+ const current = payload as { parameters?: Record<string, unknown> };
189
+ return { ...current, parameters: { ...current.parameters, size } };
190
+ },
191
+ }
192
+ : {}),
193
+ },
194
+ );
195
+
196
+ if (res.stopReason === "error" || !res.output[0]) {
197
+ throw new Error(`DashScope image generation failed: ${res.errorMessage || "Unknown error"}`);
198
+ }
199
+
200
+ const out = res.output[0];
201
+ return {
202
+ buffer: Buffer.from(out.data, "base64"),
203
+ mimeType: out.mimeType,
204
+ model: ctx.model,
205
+ provider: "dashscope",
206
+ };
207
+ }
208
+
209
+ /** Map `16:9`-style ratios onto DashScope's `width*height` sizes. Unknown ratios are dropped. */
210
+ export function resolveSize(aspectRatio?: string): string | undefined {
211
+ if (!aspectRatio) return undefined;
212
+ return DASHSCOPE_RATIO_SIZES[aspectRatio.trim()];
213
+ }
214
+
215
+ /** Turn an `image` argument into a DashScope image object, failing loudly on a missing local file. */
216
+ async function toDashscopeReference(reference: string, cwd?: string): Promise<DashscopeInput> {
217
+ const value = reference.trim();
218
+ if (value.startsWith("http://") || value.startsWith("https://")) {
219
+ return { type: "image", url: value };
220
+ }
221
+
222
+ const localPath = resolve(cwd || process.cwd(), value);
223
+ let buffer: Buffer;
224
+ try {
225
+ buffer = await readFile(localPath);
226
+ } catch (error) {
227
+ const reason = error instanceof Error ? error.message : String(error);
228
+ throw new Error(`Reference image not readable at ${localPath}: ${reason}`);
229
+ }
230
+
231
+ const mimeType = MIME_BY_EXTENSION[extname(localPath).toLowerCase()];
232
+ if (!mimeType) {
233
+ throw new Error(`Unsupported reference image type: ${extname(localPath) || localPath}`);
234
+ }
235
+ if (buffer.byteLength > MAX_REFERENCE_BYTES) {
236
+ throw new Error("Reference image exceeds DashScope's 10 MB limit");
237
+ }
238
+
239
+ return { type: "image", data: buffer.toString("base64"), mimeType };
240
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * OpenRouter image generation over OpenRouter's dedicated Images API (`POST /api/v1/images`).
3
+ *
4
+ * This deliberately does NOT reuse Pi's built-in `openrouter-images` api: Pi implements that
5
+ * on top of chat completions with `modalities: ["image"]`, which carries neither `aspect_ratio`
6
+ * nor `input_references`. Both are supported here, so `image-generate.ts` calls this handler
7
+ * directly instead of `ctx.modelRegistry.generateImages()`.
8
+ * The model list still comes from Pi's registry (`getModelsOfType("image")`) and auth from
9
+ * `getApiKeyForProvider("openrouter")` -- the same credential Pi's built-in provider uses.
10
+ */
11
+ import { open } from "node:fs/promises";
12
+ import { extname, resolve } from "node:path";
13
+ import type { GenerateImageContext, GeneratedImageOutput, ImageModelDefinition } from "../types.ts";
14
+
15
+ const OPENROUTER_IMAGE_ENDPOINT = "https://openrouter.ai/api/v1/images";
16
+ const OPENROUTER_MODELS_ENDPOINT = "https://openrouter.ai/api/v1/images/models";
17
+ const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
18
+ const MAX_REDIRECTS = 5;
19
+
20
+ const MIME_BY_EXTENSION: Record<string, string | undefined> = {
21
+ ".png": "image/png",
22
+ ".jpg": "image/jpeg",
23
+ ".jpeg": "image/jpeg",
24
+ ".webp": "image/webp",
25
+ ".gif": "image/gif",
26
+ ".bmp": "image/bmp",
27
+ };
28
+
29
+ async function fetchRemoteImageBuffer(
30
+ url: string,
31
+ signal?: AbortSignal,
32
+ ): Promise<{ buffer: Buffer; mimeType: string }> {
33
+ let currentUrl = url;
34
+ for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect++) {
35
+ const parsed = new URL(currentUrl);
36
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
37
+ throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
38
+ }
39
+
40
+ const response = await fetch(currentUrl, {
41
+ redirect: "manual",
42
+ headers: { Accept: "image/png,image/jpeg,image/webp,image/gif,image/bmp,*/*" },
43
+ signal,
44
+ });
45
+
46
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
47
+ const loc = response.headers.get("location");
48
+ if (!loc) throw new Error(`Redirect missing location header (HTTP ${response.status})`);
49
+ if (redirect === MAX_REDIRECTS) throw new Error("Too many redirects downloading image");
50
+ currentUrl = new URL(loc, currentUrl).href;
51
+ continue;
52
+ }
53
+
54
+ if (!response.ok) {
55
+ throw new Error(`Failed to download image (HTTP ${response.status})`);
56
+ }
57
+
58
+ const rawMime = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() || "image/png";
59
+ const arrayBuf = await response.arrayBuffer();
60
+ if (arrayBuf.byteLength > MAX_IMAGE_BYTES) {
61
+ throw new Error("Image exceeds maximum allowed size (25 MB)");
62
+ }
63
+
64
+ return { buffer: Buffer.from(arrayBuf), mimeType: rawMime };
65
+ }
66
+ throw new Error("Failed to download image: too many redirects");
67
+ }
68
+
69
+ export async function generateOpenrouterImage(
70
+ ctx: GenerateImageContext,
71
+ options: { apiKey: string; signal?: AbortSignal; cwd?: string },
72
+ ): Promise<GeneratedImageOutput> {
73
+ const payload: Record<string, unknown> = {
74
+ model: ctx.model,
75
+ prompt: ctx.prompt,
76
+ };
77
+
78
+ if (ctx.aspectRatio) {
79
+ payload.aspect_ratio = ctx.aspectRatio;
80
+ }
81
+
82
+ if (ctx.image) {
83
+ const imgRef = ctx.image.trim();
84
+ if (imgRef.startsWith("http://") || imgRef.startsWith("https://")) {
85
+ payload.input_references = [{ type: "image_url", image_url: { url: imgRef } }];
86
+ } else {
87
+ const localImgPath = resolve(options.cwd || process.cwd(), imgRef);
88
+ let buf: Buffer;
89
+ try {
90
+ const fileHandle = await open(localImgPath, "r");
91
+ buf = await fileHandle.readFile();
92
+ await fileHandle.close();
93
+ } catch (error) {
94
+ const reason = error instanceof Error ? error.message : String(error);
95
+ throw new Error(`Reference image not readable at ${localImgPath}: ${reason}`);
96
+ }
97
+ const ext = extname(localImgPath).toLowerCase();
98
+ const mime = MIME_BY_EXTENSION[ext];
99
+ if (!mime) {
100
+ throw new Error(`Unsupported reference image type: ${ext || localImgPath}`);
101
+ }
102
+ payload.input_references = [
103
+ {
104
+ type: "image_url",
105
+ image_url: { url: `data:${mime};base64,${buf.toString("base64")}` },
106
+ },
107
+ ];
108
+ }
109
+ }
110
+
111
+ const response = await fetch(OPENROUTER_IMAGE_ENDPOINT, {
112
+ method: "POST",
113
+ headers: {
114
+ Authorization: `Bearer ${options.apiKey}`,
115
+ "Content-Type": "application/json",
116
+ "HTTP-Referer": "https://pi.dev",
117
+ "X-Title": "Pi Image Generate Extension",
118
+ },
119
+ body: JSON.stringify(payload),
120
+ signal: options.signal,
121
+ });
122
+
123
+ if (!response.ok) {
124
+ const errText = await response.text();
125
+ throw new Error(`OpenRouter image request failed (HTTP ${response.status}): ${errText.slice(0, 400)}`);
126
+ }
127
+
128
+ const data = (await response.json()) as {
129
+ data?: Array<{ b64_json?: string; url?: string; revised_prompt?: string }>;
130
+ };
131
+
132
+ const item = data.data?.[0];
133
+ if (!item) {
134
+ throw new Error("OpenRouter returned an empty image list.");
135
+ }
136
+
137
+ let imgBuffer: Buffer;
138
+ let mimeType = "image/png";
139
+
140
+ if (item.b64_json) {
141
+ imgBuffer = Buffer.from(item.b64_json, "base64");
142
+ } else if (item.url) {
143
+ const downloaded = await fetchRemoteImageBuffer(item.url, options.signal);
144
+ imgBuffer = downloaded.buffer;
145
+ mimeType = downloaded.mimeType;
146
+ } else {
147
+ throw new Error("OpenRouter response did not contain image data or URL.");
148
+ }
149
+
150
+ return {
151
+ buffer: imgBuffer,
152
+ mimeType,
153
+ model: ctx.model,
154
+ provider: "openrouter",
155
+ revisedPrompt: item.revised_prompt,
156
+ };
157
+ }
158
+
159
+ export async function fetchOpenrouterCatalog(signal?: AbortSignal): Promise<ImageModelDefinition[]> {
160
+ const list: ImageModelDefinition[] = [];
161
+ try {
162
+ const timeoutSig = AbortSignal.any([AbortSignal.timeout(4000), ...(signal ? [signal] : [])]);
163
+ const resp = await fetch(OPENROUTER_MODELS_ENDPOINT, {
164
+ signal: timeoutSig,
165
+ headers: { Accept: "application/json" },
166
+ });
167
+ if (resp.ok) {
168
+ const data = (await resp.json()) as { data?: Array<{ id?: string; name?: string; description?: string }> };
169
+ if (Array.isArray(data?.data)) {
170
+ for (const item of data.data) {
171
+ if (!item?.id || typeof item.id !== "string") continue;
172
+ const id = item.id.trim();
173
+ const name = typeof item.name === "string" ? item.name.trim() : id;
174
+ const desc =
175
+ typeof item.description === "string" && item.description.trim()
176
+ ? item.description.trim().replace(/\s+/g, " ").slice(0, 60) + "..."
177
+ : "OpenRouter Image Model";
178
+
179
+ list.push({ id, name, provider: "openrouter", description: desc });
180
+ }
181
+ }
182
+ }
183
+ } catch {}
184
+ return list;
185
+ }
@@ -0,0 +1,96 @@
1
+ import type { ModalityProvidersRecord } from "../types.ts";
2
+ import {
3
+ DASHSCOPE_IMAGE_URL,
4
+ DASHSCOPE_IMAGE_MODELS,
5
+ resolveImageModel,
6
+ toImageModelDefinition,
7
+ } from "./models.ts";
8
+ import { generateDashscopeImages, generateDashscopeImage } from "./handlers/dashscope.ts";
9
+ import { generateOpenrouterImage, fetchOpenrouterCatalog } from "./handlers/openrouter.ts";
10
+ import type { GenerateImageContext, GeneratedImageOutput, ImageModelDefinition } from "./types.ts";
11
+
12
+ export * from "./types.ts";
13
+ export * from "./models.ts";
14
+ export * from "./handlers/dashscope.ts";
15
+ export * from "./handlers/openrouter.ts";
16
+
17
+ /** Minimal shape of a Pi registry image model. */
18
+ export type RegistryImageModel = { id: string; name?: string; provider: string };
19
+
20
+ /**
21
+ * Only DashScope is registered here.
22
+ *
23
+ * `openrouter` must NOT be registered: `pi.registerProvider()` with a `models` list
24
+ * replaces that provider's entire built-in catalog (chat included). Pi already ships
25
+ * the OpenRouter image models natively under the same provider + credential.
26
+ */
27
+ export const imageProviders: ModalityProvidersRecord = {
28
+ dashscope: {
29
+ name: "Aliyun DashScope",
30
+ models: DASHSCOPE_IMAGE_MODELS.map((m) => ({
31
+ type: "image",
32
+ id: m.id,
33
+ name: m.name,
34
+ api: "dashscope-images",
35
+ baseUrl: DASHSCOPE_IMAGE_URL,
36
+ input: ["text", "image"],
37
+ output: ["image"],
38
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
39
+ })),
40
+ images: {
41
+ "dashscope-images": {
42
+ generateImages: generateDashscopeImages as any,
43
+ },
44
+ },
45
+ },
46
+ };
47
+
48
+ /**
49
+ * Build the catalog shown by the model selector.
50
+ *
51
+ * Sources, in priority order: the provider registry (`ctx.modelRegistry.getModelsOfType("image")`,
52
+ * which already covers both OpenRouter's built-in catalog and the DashScope registration above),
53
+ * followed by the live OpenRouter catalog for models newer than the bundled snapshot.
54
+ */
55
+ export async function fetchAllAvailableImageModels(
56
+ registryModels: readonly RegistryImageModel[] = [],
57
+ signal?: AbortSignal,
58
+ ): Promise<ImageModelDefinition[]> {
59
+ const map = new Map<string, ImageModelDefinition>();
60
+ const add = (model: ImageModelDefinition) => {
61
+ const key = model.id.toLowerCase();
62
+ if (!map.has(key)) map.set(key, model);
63
+ };
64
+
65
+ for (const model of DASHSCOPE_IMAGE_MODELS) add(model);
66
+ for (const model of registryModels) add(toImageModelDefinition(model));
67
+ for (const model of await fetchOpenrouterCatalog(signal)) add(model);
68
+
69
+ return Array.from(map.values());
70
+ }
71
+
72
+ export async function generateImage(
73
+ ctx: GenerateImageContext,
74
+ options: {
75
+ apiKey: string;
76
+ provider?: string;
77
+ baseUrl?: string;
78
+ signal?: AbortSignal;
79
+ cwd?: string;
80
+ },
81
+ ): Promise<GeneratedImageOutput> {
82
+ const model = resolveImageModel(ctx.model);
83
+ const provider = options.provider ?? (model.startsWith("qwen-image") ? "dashscope" : "openrouter");
84
+
85
+ if (provider === "dashscope") {
86
+ return generateDashscopeImage(
87
+ { prompt: ctx.prompt, model, aspectRatio: ctx.aspectRatio, image: ctx.image },
88
+ { apiKey: options.apiKey, baseUrl: options.baseUrl || DASHSCOPE_IMAGE_URL, cwd: options.cwd, signal: options.signal },
89
+ );
90
+ }
91
+
92
+ return generateOpenrouterImage(
93
+ { ...ctx, model },
94
+ { apiKey: options.apiKey, signal: options.signal, cwd: options.cwd },
95
+ );
96
+ }
@@ -0,0 +1,71 @@
1
+ import type { ImageModelDefinition } from "./types.ts";
2
+
3
+ export const DASHSCOPE_IMAGE_URL =
4
+ process.env.DASHSCOPE_IMAGE_URL?.trim().replace(/\/+$/, "") ||
5
+ "https://dashscope.aliyuncs.com/api/v1";
6
+
7
+ /**
8
+ * DashScope is not a Pi built-in image provider, so its catalog is declared here
9
+ * and registered through `imageProviders`.
10
+ *
11
+ * OpenRouter is deliberately absent: Pi already ships its image catalog (61 models)
12
+ * under its built-in `openrouter` provider, using the same `/login openrouter` credential.
13
+ */
14
+ export const DASHSCOPE_IMAGE_MODELS: ImageModelDefinition[] = [
15
+ {
16
+ id: "qwen-image-3.0-pro",
17
+ name: "Qwen Image 3.0 Pro",
18
+ provider: "dashscope",
19
+ description: "Aliyun DashScope flagship image generation model with strong typography and rendering capabilities",
20
+ },
21
+ ];
22
+
23
+ /** Project a Pi registry image model into the selector's view model. */
24
+ export function toImageModelDefinition(model: {
25
+ id: string;
26
+ name?: string;
27
+ provider: string;
28
+ }): ImageModelDefinition {
29
+ return {
30
+ id: model.id,
31
+ name: model.name?.trim() || model.id,
32
+ provider: model.provider,
33
+ description: `${model.provider} image model`,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Resolve a user-supplied model argument against the provider registry.
39
+ *
40
+ * The registry is the single source of truth, so no hand-written alias table is kept:
41
+ * OpenRouter's catalog belongs to Pi and changes without notice, and a stale alias would
42
+ * silently return a model id that no longer exists.
43
+ *
44
+ * Order: exact id -> exact display name -> substring (shortest match wins, so `gpt-image-1`
45
+ * prefers `openai/gpt-image-1` over `openai/gpt-image-1-mini`). Anything unresolved is
46
+ * returned verbatim so the provider reports the real error.
47
+ */
48
+ export function resolveImageModel(
49
+ model?: string,
50
+ availableModels?: readonly ImageModelDefinition[],
51
+ defaultModel = "google/gemini-3.1-flash-image",
52
+ ): string {
53
+ const trimmed = model?.trim() ?? "";
54
+ if (!trimmed) return defaultModel;
55
+ if (!availableModels?.length) return trimmed;
56
+
57
+ const lower = trimmed.toLowerCase();
58
+
59
+ const exact = availableModels.find((m) => m.id.toLowerCase() === lower);
60
+ if (exact) return exact.id;
61
+
62
+ const byName = availableModels.find((m) => m.name.toLowerCase() === lower);
63
+ if (byName) return byName.id;
64
+
65
+ const partial = availableModels
66
+ .filter((m) => m.id.toLowerCase().includes(lower))
67
+ .sort((a, b) => a.id.length - b.id.length);
68
+ if (partial.length) return partial[0].id;
69
+
70
+ return trimmed;
71
+ }
@@ -0,0 +1,22 @@
1
+ export interface ImageModelDefinition {
2
+ id: string;
3
+ name: string;
4
+ provider: "openrouter" | "dashscope" | string;
5
+ description: string;
6
+ }
7
+
8
+ export interface GenerateImageContext {
9
+ prompt: string;
10
+ model: string;
11
+ aspectRatio?: string;
12
+ image?: string; // local file path or remote image url
13
+ cwd?: string;
14
+ }
15
+
16
+ export interface GeneratedImageOutput {
17
+ buffer: Buffer;
18
+ mimeType: string;
19
+ model: string;
20
+ provider: string;
21
+ revisedPrompt?: string;
22
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Register custom providers through Pi's native model and /login APIs.
3
+ * Organizes models and providers by Pi's core model types: chat, image, classifier, and asr.
4
+ * Credentials belong in Pi's auth.json, not here.
5
+ */
6
+
7
+ import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
8
+ import { chatProviders } from "./chat/index.ts";
9
+ import { imageProviders } from "./image/index.ts";
10
+ import { classifierProviders } from "./classifier/index.ts";
11
+ import { asrProviders } from "./asr/index.ts";
12
+
13
+ export { asrProviders, ALL_ASR_MODELS, createAsrProvider, type AsrModelDefinition, type StreamingAsrProvider } from "./asr/index.ts";
14
+ export { imageProviders, fetchAllAvailableImageModels, generateImage, resolveImageModel, type ImageModelDefinition } from "./image/index.ts";
15
+
16
+ const BATCH_LOCK_KEY = "__piCustomProvidersBatchLock";
17
+
18
+ export function ensureCustomProviders(pi: ExtensionAPI): void {
19
+ const g = globalThis as Record<string, unknown>;
20
+ if (g[BATCH_LOCK_KEY]) return;
21
+ g[BATCH_LOCK_KEY] = true;
22
+ setTimeout(() => {
23
+ g[BATCH_LOCK_KEY] = false;
24
+ }, 0).unref?.();
25
+
26
+ const providerIds = new Set<string>([
27
+ ...Object.keys(chatProviders),
28
+ ...Object.keys(imageProviders),
29
+ ...Object.keys(classifierProviders),
30
+ ...Object.keys(asrProviders),
31
+ ]);
32
+
33
+ for (const id of providerIds) {
34
+ const chat = chatProviders[id];
35
+ const image = imageProviders[id];
36
+ const classifier = classifierProviders[id];
37
+ const asr = asrProviders[id];
38
+
39
+ const config: ProviderConfig = {
40
+ name: chat?.name ?? image?.name ?? classifier?.name ?? asr?.name ?? id,
41
+ baseUrl: chat?.baseUrl ?? image?.baseUrl ?? classifier?.baseUrl,
42
+ api: (chat?.api ?? "openai-completions") as any,
43
+ ...(chat?.apiKey ? { apiKey: chat.apiKey } : {}),
44
+ ...(chat?.refreshModels ? { refreshModels: chat.refreshModels } : {}),
45
+ models: [
46
+ ...(chat?.models ?? []),
47
+ ...(image?.models ?? []),
48
+ ...(classifier?.models ?? []),
49
+ ],
50
+ ...(image?.images || chat?.images || classifier?.images
51
+ ? { images: { ...chat?.images, ...image?.images, ...classifier?.images } }
52
+ : {}),
53
+ ...(classifier?.classifiers || chat?.classifiers || image?.classifiers
54
+ ? { classifiers: { ...chat?.classifiers, ...image?.classifiers, ...classifier?.classifiers } }
55
+ : {}),
56
+ };
57
+
58
+ pi.registerProvider(id, config);
59
+ }
60
+ }
61
+
62
+ export default function customProviders(pi: ExtensionAPI): void {
63
+ ensureCustomProviders(pi);
64
+ }