waypoi 0.0.0

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 (260) hide show
  1. package/.github/instructions/ui.instructions.md +42 -0
  2. package/.github/workflows/ci.yml +35 -0
  3. package/.github/workflows/publish.yml +71 -0
  4. package/.github/workflows/release.yml +48 -0
  5. package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
  6. package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
  7. package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
  8. package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
  10. package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
  11. package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
  12. package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
  13. package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
  14. package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
  15. package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
  16. package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
  17. package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
  18. package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
  19. package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
  20. package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
  21. package/AGENTS.md +48 -0
  22. package/CHANGELOG.md +131 -0
  23. package/README.md +552 -0
  24. package/assets/agent-mode.png +0 -0
  25. package/assets/categorize.png +0 -0
  26. package/assets/dashboard.png +0 -0
  27. package/assets/endpoint-proxy.png +0 -0
  28. package/assets/icon.png +0 -0
  29. package/assets/mcp-generate-image.png +0 -0
  30. package/assets/mcp-understand-image.png +0 -0
  31. package/assets/peek-token-flow.png +0 -0
  32. package/assets/playground.png +0 -0
  33. package/assets/sankey.png +0 -0
  34. package/cli/index.ts +2805 -0
  35. package/cli/legacyRewrite.ts +108 -0
  36. package/cli/modelRef.ts +24 -0
  37. package/dist/cli/index.js +2536 -0
  38. package/dist/cli/legacyRewrite.js +92 -0
  39. package/dist/cli/modelRef.js +20 -0
  40. package/dist/src/benchmark/artifacts.js +131 -0
  41. package/dist/src/benchmark/capabilityClassifier.js +81 -0
  42. package/dist/src/benchmark/capabilityStore.js +144 -0
  43. package/dist/src/benchmark/config.js +238 -0
  44. package/dist/src/benchmark/gates.js +118 -0
  45. package/dist/src/benchmark/jobs.js +252 -0
  46. package/dist/src/benchmark/runner.js +1847 -0
  47. package/dist/src/benchmark/schema.js +353 -0
  48. package/dist/src/benchmark/suites.js +314 -0
  49. package/dist/src/benchmark/tinyQaDataset.js +422 -0
  50. package/dist/src/benchmark/types.js +25 -0
  51. package/dist/src/config.js +47 -0
  52. package/dist/src/index.js +178 -0
  53. package/dist/src/mcp/client.js +215 -0
  54. package/dist/src/mcp/discovery.js +226 -0
  55. package/dist/src/mcp/policy.js +65 -0
  56. package/dist/src/mcp/registry.js +129 -0
  57. package/dist/src/mcp/service.js +460 -0
  58. package/dist/src/middleware/auth.js +179 -0
  59. package/dist/src/middleware/requestCapture.js +192 -0
  60. package/dist/src/middleware/requestStats.js +118 -0
  61. package/dist/src/pools/builder.js +132 -0
  62. package/dist/src/pools/repository.js +69 -0
  63. package/dist/src/pools/scheduler.js +360 -0
  64. package/dist/src/pools/types.js +2 -0
  65. package/dist/src/protocols/adapters/dashscope.js +267 -0
  66. package/dist/src/protocols/adapters/inferenceV2.js +346 -0
  67. package/dist/src/protocols/adapters/openai.js +27 -0
  68. package/dist/src/protocols/registry.js +99 -0
  69. package/dist/src/protocols/types.js +2 -0
  70. package/dist/src/providers/health.js +153 -0
  71. package/dist/src/providers/importer.js +289 -0
  72. package/dist/src/providers/modelRegistry.js +313 -0
  73. package/dist/src/providers/repository.js +361 -0
  74. package/dist/src/providers/types.js +2 -0
  75. package/dist/src/routes/admin.js +531 -0
  76. package/dist/src/routes/audio.js +295 -0
  77. package/dist/src/routes/chat.js +240 -0
  78. package/dist/src/routes/embeddings.js +157 -0
  79. package/dist/src/routes/images.js +288 -0
  80. package/dist/src/routes/mcp.js +256 -0
  81. package/dist/src/routes/mcpService.js +100 -0
  82. package/dist/src/routes/models.js +48 -0
  83. package/dist/src/routes/responses.js +711 -0
  84. package/dist/src/routes/sessions.js +450 -0
  85. package/dist/src/routes/stats.js +270 -0
  86. package/dist/src/routes/ui.js +97 -0
  87. package/dist/src/routes/videos.js +107 -0
  88. package/dist/src/routing/router.js +338 -0
  89. package/dist/src/services/imageGeneration.js +280 -0
  90. package/dist/src/services/imageUnderstanding.js +352 -0
  91. package/dist/src/services/videoGeneration.js +79 -0
  92. package/dist/src/storage/captureRepository.js +1591 -0
  93. package/dist/src/storage/files.js +157 -0
  94. package/dist/src/storage/imageCache.js +346 -0
  95. package/dist/src/storage/repositories.js +388 -0
  96. package/dist/src/storage/sessionRepository.js +370 -0
  97. package/dist/src/storage/statsRepository.js +204 -0
  98. package/dist/src/transport/httpClient.js +126 -0
  99. package/dist/src/types.js +2 -0
  100. package/dist/src/utils/messageMedia.js +285 -0
  101. package/dist/src/utils/modelCapabilities.js +108 -0
  102. package/dist/src/utils/modelDiscovery.js +170 -0
  103. package/dist/src/version.js +5 -0
  104. package/dist/src/workers/captureRetention.js +25 -0
  105. package/dist/src/workers/configWatcher.js +91 -0
  106. package/dist/src/workers/healthChecker.js +21 -0
  107. package/dist/src/workers/statsRotation.js +41 -0
  108. package/docs/LLM/output_schema.md +312 -0
  109. package/docs/benchmark.md +208 -0
  110. package/docs/mcp-guidelines.md +125 -0
  111. package/docs/mcp-service.md +178 -0
  112. package/docs/opencode.md +86 -0
  113. package/docs/providers.md +79 -0
  114. package/examples/benchmark.config.yaml +28 -0
  115. package/examples/providers/alibaba-dashscope.yaml +88 -0
  116. package/examples/providers/alibaba-llm.yaml +64 -0
  117. package/examples/providers/alibaba-registry.yaml +7 -0
  118. package/examples/providers/inference-v2-ray.yaml +29 -0
  119. package/examples/scenarios/assets/omni-call-sample.wav +0 -0
  120. package/examples/scenarios/custom.jsonl +5 -0
  121. package/examples/scenarios/custom.yaml +40 -0
  122. package/model-form-v2.png +0 -0
  123. package/package.json +66 -0
  124. package/provider-form-v2.png +0 -0
  125. package/provider-form.png +0 -0
  126. package/scripts/manual-test.sh +11 -0
  127. package/scripts/version-from-git.js +23 -0
  128. package/src/benchmark/artifacts.ts +149 -0
  129. package/src/benchmark/capabilityClassifier.ts +99 -0
  130. package/src/benchmark/capabilityStore.ts +174 -0
  131. package/src/benchmark/config.ts +337 -0
  132. package/src/benchmark/gates.ts +164 -0
  133. package/src/benchmark/jobs.ts +312 -0
  134. package/src/benchmark/runner.ts +2519 -0
  135. package/src/benchmark/schema.ts +443 -0
  136. package/src/benchmark/suites.ts +323 -0
  137. package/src/benchmark/tinyQaDataset.ts +428 -0
  138. package/src/benchmark/types.ts +442 -0
  139. package/src/config.ts +44 -0
  140. package/src/index.ts +195 -0
  141. package/src/mcp/client.ts +305 -0
  142. package/src/mcp/discovery.ts +266 -0
  143. package/src/mcp/policy.ts +105 -0
  144. package/src/mcp/registry.ts +164 -0
  145. package/src/mcp/service.ts +611 -0
  146. package/src/middleware/auth.ts +251 -0
  147. package/src/middleware/requestCapture.ts +245 -0
  148. package/src/middleware/requestStats.ts +163 -0
  149. package/src/pools/builder.ts +159 -0
  150. package/src/pools/repository.ts +71 -0
  151. package/src/pools/scheduler.ts +425 -0
  152. package/src/pools/types.ts +117 -0
  153. package/src/protocols/adapters/dashscope.ts +335 -0
  154. package/src/protocols/adapters/inferenceV2.ts +428 -0
  155. package/src/protocols/adapters/openai.ts +32 -0
  156. package/src/protocols/registry.ts +117 -0
  157. package/src/protocols/types.ts +81 -0
  158. package/src/providers/health.ts +207 -0
  159. package/src/providers/importer.ts +402 -0
  160. package/src/providers/modelRegistry.ts +415 -0
  161. package/src/providers/repository.ts +439 -0
  162. package/src/providers/types.ts +113 -0
  163. package/src/routes/admin.ts +666 -0
  164. package/src/routes/audio.ts +372 -0
  165. package/src/routes/chat.ts +301 -0
  166. package/src/routes/embeddings.ts +197 -0
  167. package/src/routes/images.ts +356 -0
  168. package/src/routes/mcp.ts +320 -0
  169. package/src/routes/mcpService.ts +114 -0
  170. package/src/routes/models.ts +50 -0
  171. package/src/routes/responses.ts +872 -0
  172. package/src/routes/sessions.ts +558 -0
  173. package/src/routes/stats.ts +312 -0
  174. package/src/routes/ui.ts +96 -0
  175. package/src/routes/videos.ts +132 -0
  176. package/src/routing/router.ts +501 -0
  177. package/src/services/imageGeneration.ts +396 -0
  178. package/src/services/imageUnderstanding.ts +449 -0
  179. package/src/services/videoGeneration.ts +127 -0
  180. package/src/storage/captureRepository.ts +1835 -0
  181. package/src/storage/files.ts +178 -0
  182. package/src/storage/imageCache.ts +405 -0
  183. package/src/storage/repositories.ts +494 -0
  184. package/src/storage/sessionRepository.ts +419 -0
  185. package/src/storage/statsRepository.ts +238 -0
  186. package/src/transport/httpClient.ts +145 -0
  187. package/src/types.ts +322 -0
  188. package/src/utils/messageMedia.ts +293 -0
  189. package/src/utils/modelCapabilities.ts +161 -0
  190. package/src/utils/modelDiscovery.ts +203 -0
  191. package/src/workers/captureRetention.ts +25 -0
  192. package/src/workers/configWatcher.ts +115 -0
  193. package/src/workers/healthChecker.ts +22 -0
  194. package/src/workers/statsRotation.ts +49 -0
  195. package/tests/benchmarkAdminRoutes.test.ts +82 -0
  196. package/tests/benchmarkBasics.test.ts +116 -0
  197. package/tests/captureAdminRoutes.test.ts +420 -0
  198. package/tests/captureRepository.test.ts +797 -0
  199. package/tests/cliLegacyRewrite.test.ts +45 -0
  200. package/tests/imageGeneration.service.test.ts +107 -0
  201. package/tests/imageUnderstanding.service.test.ts +123 -0
  202. package/tests/mcpPolicy.test.ts +105 -0
  203. package/tests/mcpService.test.ts +1245 -0
  204. package/tests/modelRef.test.ts +23 -0
  205. package/tests/modelsRoutes.test.ts +154 -0
  206. package/tests/sessionMediaCache.test.ts +167 -0
  207. package/tests/statsRoutes.test.ts +323 -0
  208. package/tsconfig.json +15 -0
  209. package/ui/index.html +16 -0
  210. package/ui/package-lock.json +8521 -0
  211. package/ui/package.json +52 -0
  212. package/ui/postcss.config.js +6 -0
  213. package/ui/public/assets/apple-touch-icon.png +0 -0
  214. package/ui/public/assets/favicon-16.png +0 -0
  215. package/ui/public/assets/favicon-32.png +0 -0
  216. package/ui/public/assets/icon-192.png +0 -0
  217. package/ui/public/assets/icon-512.png +0 -0
  218. package/ui/src/App.tsx +27 -0
  219. package/ui/src/api/client.ts +1503 -0
  220. package/ui/src/components/EndpointUsageGuide.tsx +361 -0
  221. package/ui/src/components/Layout.tsx +124 -0
  222. package/ui/src/components/MessageContent.tsx +365 -0
  223. package/ui/src/components/ToolCallMessage.tsx +179 -0
  224. package/ui/src/components/ToolPicker.tsx +442 -0
  225. package/ui/src/components/messageContentParser.test.ts +41 -0
  226. package/ui/src/components/messageContentParser.ts +73 -0
  227. package/ui/src/components/thinkingPreview.test.ts +27 -0
  228. package/ui/src/components/thinkingPreview.ts +15 -0
  229. package/ui/src/components/toMermaidSankey.test.ts +78 -0
  230. package/ui/src/components/toMermaidSankey.ts +56 -0
  231. package/ui/src/components/ui/button.tsx +58 -0
  232. package/ui/src/components/ui/input.tsx +21 -0
  233. package/ui/src/components/ui/textarea.tsx +21 -0
  234. package/ui/src/lib/utils.ts +6 -0
  235. package/ui/src/main.tsx +9 -0
  236. package/ui/src/pages/AgentPlayground.tsx +2010 -0
  237. package/ui/src/pages/Benchmark.tsx +988 -0
  238. package/ui/src/pages/Dashboard.tsx +581 -0
  239. package/ui/src/pages/Peek.tsx +962 -0
  240. package/ui/src/pages/Settings.tsx +2013 -0
  241. package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
  242. package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
  243. package/ui/src/pages/agentThinkingContent.test.ts +50 -0
  244. package/ui/src/pages/agentThinkingContent.ts +57 -0
  245. package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
  246. package/ui/src/pages/dashboardTokenUsage.ts +36 -0
  247. package/ui/src/pages/imageUpload.test.ts +39 -0
  248. package/ui/src/pages/imageUpload.ts +71 -0
  249. package/ui/src/pages/peekFilters.test.ts +29 -0
  250. package/ui/src/pages/peekFilters.ts +13 -0
  251. package/ui/src/pages/peekMedia.test.ts +58 -0
  252. package/ui/src/pages/peekMedia.ts +148 -0
  253. package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
  254. package/ui/src/pages/sessionAutoTitle.ts +106 -0
  255. package/ui/src/stores/settings.ts +58 -0
  256. package/ui/src/styles/globals.css +223 -0
  257. package/ui/src/vite-env.d.ts +8 -0
  258. package/ui/tailwind.config.js +106 -0
  259. package/ui/tsconfig.json +32 -0
  260. package/ui/vite.config.ts +37 -0
@@ -0,0 +1,197 @@
1
+ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+ import { randomUUID } from "crypto";
3
+ import { routeRequest } from "../routing/router";
4
+ import { logRequest } from "../storage/repositories";
5
+ import { RequestLog } from "../types";
6
+ import { StoragePaths } from "../storage/files";
7
+ import { selectPoolCandidates } from "../pools/scheduler";
8
+ import { pickBestProviderModelByCapabilities } from "../providers/modelRegistry";
9
+ import { setCaptureError, setCaptureRouting } from "../middleware/requestCapture";
10
+ import { setStatsPayload } from "../middleware/requestStats";
11
+
12
+ interface EmbeddingsBody {
13
+ model: string;
14
+ input: string | string[];
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ export async function registerEmbeddingsRoutes(app: FastifyInstance, paths: StoragePaths): Promise<void> {
19
+ app.post("/v1/embeddings", async (req: FastifyRequest, reply: FastifyReply) => {
20
+ let body = req.body as EmbeddingsBody | undefined;
21
+ if (!body?.model) {
22
+ const smart = await selectPoolCandidates(paths, "smart", {
23
+ requiredInput: ["text"],
24
+ requiredOutput: ["embedding"],
25
+ }, {
26
+ operation: "embeddings",
27
+ stream: false,
28
+ });
29
+ if (smart && smart.candidates.length > 0) {
30
+ body = { ...(body ?? { input: "" }), model: "smart" };
31
+ }
32
+ }
33
+ if (!body?.model) {
34
+ const direct = await pickBestProviderModelByCapabilities(
35
+ paths,
36
+ { requiredInput: ["text"], requiredOutput: ["embedding"] },
37
+ "embedding"
38
+ );
39
+ if (direct) {
40
+ body = { ...(body ?? { input: "" }), model: direct };
41
+ }
42
+ }
43
+ if (!body?.model) {
44
+ reply.code(400).send({ error: { message: "model is required" } });
45
+ return;
46
+ }
47
+
48
+ const requestId = randomUUID();
49
+ const start = Date.now();
50
+ const controller = new AbortController();
51
+
52
+ req.raw.on("close", () => controller.abort());
53
+
54
+ try {
55
+ const outcome = await routeRequest(
56
+ paths,
57
+ body.model,
58
+ "/v1/embeddings",
59
+ body as Record<string, unknown>,
60
+ req.headers as Record<string, string | string[] | undefined>,
61
+ controller.signal,
62
+ {
63
+ endpointType: "embedding",
64
+ requiredInput: ["text"],
65
+ requiredOutput: ["embedding"],
66
+ }
67
+ );
68
+
69
+ const upstreamBody = await readBody(outcome.attempt.response);
70
+ setHeaders(reply, outcome.attempt.response.headers);
71
+ reply.code(outcome.attempt.response.statusCode).send(upstreamBody.payload);
72
+ setCaptureRouting(reply, {
73
+ publicModel: body.model,
74
+ endpointId: outcome.attempt.endpoint.id,
75
+ endpointName: outcome.attempt.endpoint.name,
76
+ upstreamModel: outcome.attempt.upstreamModel,
77
+ });
78
+ setStatsPayload(reply, {
79
+ endpointId: outcome.attempt.endpoint.id,
80
+ endpointName: outcome.attempt.endpoint.name,
81
+ upstreamModel: outcome.attempt.upstreamModel,
82
+ totalTokens: upstreamBody.totalTokens,
83
+ promptTokens: upstreamBody.promptTokens,
84
+ completionTokens: upstreamBody.completionTokens,
85
+ });
86
+ await logRequest(paths, buildLog(requestId, body, outcome, Date.now() - start, upstreamBody.totalTokens));
87
+ } catch (error) {
88
+ const errorType = (error as { type?: string }).type ?? (error as Error).name;
89
+ setCaptureError(reply, { type: errorType, message: (error as Error).message });
90
+ await logRequest(paths, {
91
+ requestId,
92
+ ts: new Date(),
93
+ route: { publicModel: body?.model ?? "unknown" },
94
+ request: { stream: false },
95
+ result: {
96
+ errorType,
97
+ errorMessage: (error as Error).message
98
+ }
99
+ });
100
+ if (errorType === "invalid_request") {
101
+ reply.code(400).send({ error: { message: (error as Error).message } });
102
+ return;
103
+ }
104
+ if (errorType === "tls_verify_failed") {
105
+ reply.code(502).send({ error: { message: (error as Error).message } });
106
+ return;
107
+ }
108
+ const status =
109
+ errorType === "no_endpoints" ||
110
+ errorType === "protocol_stream_unsupported" ||
111
+ errorType === "unsupported_protocol" ||
112
+ errorType === "invalid_protocol_config"
113
+ ? 400
114
+ : errorType === "rate_limited"
115
+ ? 429
116
+ : 502;
117
+ reply.code(status).send({ error: { message: "Upstream unavailable" } });
118
+ }
119
+ });
120
+ }
121
+
122
+ function setHeaders(reply: FastifyReply, headers: Record<string, string | string[]>): void {
123
+ const normalized = normalizeHeaders(headers);
124
+ for (const [key, value] of Object.entries(normalized)) {
125
+ reply.header(key, value);
126
+ }
127
+ }
128
+
129
+ function normalizeHeaders(headers: Record<string, string | string[]>): Record<string, string> {
130
+ const normalized: Record<string, string> = {};
131
+ for (const [key, value] of Object.entries(headers)) {
132
+ if (Array.isArray(value)) {
133
+ normalized[key.toLowerCase()] = value.join(", ");
134
+ } else {
135
+ normalized[key.toLowerCase()] = value;
136
+ }
137
+ }
138
+ return normalized;
139
+ }
140
+
141
+ async function readBody(
142
+ response: { body: NodeJS.ReadableStream; headers: Record<string, string | string[]> }
143
+ ): Promise<{
144
+ payload: unknown;
145
+ totalTokens: number | null;
146
+ promptTokens: number | null;
147
+ completionTokens: number | null;
148
+ }> {
149
+ const chunks: Buffer[] = [];
150
+ for await (const chunk of response.body) {
151
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
152
+ }
153
+ const buffer = Buffer.concat(chunks);
154
+ const contentType = normalizeHeaders(response.headers)["content-type"] ?? "";
155
+ if (contentType.includes("application/json")) {
156
+ try {
157
+ const payload = JSON.parse(buffer.toString("utf8"));
158
+ const usage = typeof payload === "object" && payload && (
159
+ payload as { usage?: { total_tokens?: number; prompt_tokens?: number; completion_tokens?: number } }
160
+ ).usage;
161
+ return {
162
+ payload,
163
+ totalTokens: usage?.total_tokens ?? null,
164
+ promptTokens: usage?.prompt_tokens ?? null,
165
+ completionTokens: usage?.completion_tokens ?? null,
166
+ };
167
+ } catch {
168
+ return { payload: buffer, totalTokens: null, promptTokens: null, completionTokens: null };
169
+ }
170
+ }
171
+ return { payload: buffer, totalTokens: null, promptTokens: null, completionTokens: null };
172
+ }
173
+
174
+ function buildLog(
175
+ requestId: string,
176
+ body: EmbeddingsBody,
177
+ outcome: { attempt: { endpoint: { id: string; name: string }; upstreamModel: string; response: { statusCode: number } } },
178
+ latencyMs: number,
179
+ totalTokens?: number | null
180
+ ): RequestLog {
181
+ return {
182
+ requestId,
183
+ ts: new Date(),
184
+ route: {
185
+ publicModel: body.model,
186
+ endpointId: outcome.attempt.endpoint.id,
187
+ endpointName: outcome.attempt.endpoint.name,
188
+ upstreamModel: outcome.attempt.upstreamModel
189
+ },
190
+ request: { stream: false },
191
+ result: {
192
+ statusCode: outcome.attempt.response.statusCode,
193
+ latencyMs,
194
+ totalTokens: totalTokens ?? null
195
+ }
196
+ };
197
+ }
@@ -0,0 +1,356 @@
1
+ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+ import { randomUUID } from "crypto";
3
+ import { routeRequest } from "../routing/router";
4
+ import { logRequest } from "../storage/repositories";
5
+ import { ImageGenerationRequest, RequestLog } from "../types";
6
+ import { StoragePaths } from "../storage/files";
7
+ import { selectPoolCandidates } from "../pools/scheduler";
8
+ import { pickBestProviderModelByCapabilities } from "../providers/modelRegistry";
9
+ import { resolveGenerationModel, runImageGeneration } from "../services/imageGeneration";
10
+ import { setCaptureError, setCaptureRouting } from "../middleware/requestCapture";
11
+
12
+ export async function registerImageRoutes(app: FastifyInstance, paths: StoragePaths): Promise<void> {
13
+ // POST /v1/images/generations
14
+ app.post("/v1/images/generations", async (req: FastifyRequest, reply: FastifyReply) => {
15
+ const body = req.body as ImageGenerationRequest | undefined;
16
+
17
+ if (!body?.prompt) {
18
+ reply.code(400).send({ error: { message: "prompt is required" } });
19
+ return;
20
+ }
21
+
22
+ const model = await resolveGenerationModel(paths, body.model);
23
+ if (!model) {
24
+ reply.code(400).send({ error: { message: "No diffusion model available. Add or enable a provider model." } });
25
+ return;
26
+ }
27
+
28
+ const requestId = randomUUID();
29
+ const start = Date.now();
30
+ const controller = new AbortController();
31
+
32
+ req.raw.on("close", () => controller.abort());
33
+
34
+ try {
35
+ const generated = await runImageGeneration(
36
+ paths,
37
+ { ...body, model },
38
+ req.headers as Record<string, string | string[] | undefined>,
39
+ controller.signal
40
+ );
41
+ setHeaders(reply, generated.headers);
42
+ reply.code(generated.statusCode).send(generated.payload);
43
+ setCaptureRouting(reply, {
44
+ publicModel: model,
45
+ endpointId: generated.route.endpointId,
46
+ endpointName: generated.route.endpointName,
47
+ upstreamModel: generated.route.upstreamModel,
48
+ });
49
+
50
+ await logRequest(paths, buildLog(
51
+ requestId,
52
+ model,
53
+ {
54
+ attempt: {
55
+ endpoint: {
56
+ id: generated.route.endpointId,
57
+ name: generated.route.endpointName,
58
+ },
59
+ upstreamModel: generated.route.upstreamModel,
60
+ response: {
61
+ statusCode: generated.statusCode,
62
+ },
63
+ },
64
+ },
65
+ Date.now() - start,
66
+ false
67
+ ));
68
+ } catch (error) {
69
+ const errorType = (error as { type?: string }).type ?? (error as Error).name;
70
+ setCaptureError(reply, { type: errorType, message: (error as Error).message });
71
+ await logRequest(paths, {
72
+ requestId,
73
+ ts: new Date(),
74
+ route: { publicModel: model },
75
+ request: { stream: false },
76
+ result: {
77
+ errorType,
78
+ errorMessage: (error as Error).message
79
+ }
80
+ });
81
+ if (errorType === "invalid_request") {
82
+ reply.code(400).send({ error: { message: (error as Error).message } });
83
+ return;
84
+ }
85
+ if (errorType === "tls_verify_failed") {
86
+ reply.code(502).send({ error: { message: (error as Error).message, type: errorType } });
87
+ return;
88
+ }
89
+ const status =
90
+ errorType === "no_endpoints" ||
91
+ errorType === "protocol_stream_unsupported" ||
92
+ errorType === "unsupported_protocol" ||
93
+ errorType === "invalid_protocol_config"
94
+ ? 400
95
+ : errorType === "rate_limited"
96
+ ? 429
97
+ : 502;
98
+ reply.code(status).send({ error: { message: "Image generation unavailable", type: errorType } });
99
+ }
100
+ });
101
+
102
+ // POST /v1/images/edits (passthrough)
103
+ app.post("/v1/images/edits", async (req: FastifyRequest, reply: FastifyReply) => {
104
+ const body = req.body as { model?: string; prompt: string } | undefined;
105
+
106
+ if (!body?.prompt) {
107
+ reply.code(400).send({ error: { message: "prompt is required" } });
108
+ return;
109
+ }
110
+
111
+ const model = body.model ?? await pickDefaultDiffusionModel(paths);
112
+ if (!model) {
113
+ reply.code(400).send({ error: { message: "No diffusion model available" } });
114
+ return;
115
+ }
116
+
117
+ const requestId = randomUUID();
118
+ const start = Date.now();
119
+ const controller = new AbortController();
120
+
121
+ req.raw.on("close", () => controller.abort());
122
+
123
+ try {
124
+ const outcome = await routeRequest(
125
+ paths,
126
+ model,
127
+ "/v1/images/edits",
128
+ body as Record<string, unknown>,
129
+ req.headers as Record<string, string | string[] | undefined>,
130
+ controller.signal,
131
+ {
132
+ endpointType: "diffusion",
133
+ requiredInput: ["image"],
134
+ requiredOutput: ["image"],
135
+ }
136
+ );
137
+
138
+ const upstreamBody = await readBody(outcome.attempt.response);
139
+ setHeaders(reply, outcome.attempt.response.headers);
140
+ reply.code(outcome.attempt.response.statusCode).send(upstreamBody.payload);
141
+ setCaptureRouting(reply, {
142
+ publicModel: model,
143
+ endpointId: outcome.attempt.endpoint.id,
144
+ endpointName: outcome.attempt.endpoint.name,
145
+ upstreamModel: outcome.attempt.upstreamModel,
146
+ });
147
+
148
+ await logRequest(paths, buildLog(requestId, model, outcome, Date.now() - start, false));
149
+ } catch (error) {
150
+ const errorType = (error as { type?: string }).type ?? (error as Error).name;
151
+ setCaptureError(reply, { type: errorType, message: (error as Error).message });
152
+ await logRequest(paths, {
153
+ requestId,
154
+ ts: new Date(),
155
+ route: { publicModel: model },
156
+ request: { stream: false },
157
+ result: { errorType, errorMessage: (error as Error).message }
158
+ });
159
+ if (errorType === "invalid_request") {
160
+ reply.code(400).send({ error: { message: (error as Error).message } });
161
+ return;
162
+ }
163
+ if (errorType === "tls_verify_failed") {
164
+ reply.code(502).send({ error: { message: (error as Error).message, type: errorType } });
165
+ return;
166
+ }
167
+ const status =
168
+ errorType === "no_endpoints" ||
169
+ errorType === "protocol_stream_unsupported" ||
170
+ errorType === "unsupported_protocol" ||
171
+ errorType === "invalid_protocol_config"
172
+ ? 400
173
+ : errorType === "rate_limited"
174
+ ? 429
175
+ : 502;
176
+ reply.code(status).send({ error: { message: "Image edit unavailable", type: errorType } });
177
+ }
178
+ });
179
+
180
+ // POST /v1/images/variations (passthrough)
181
+ app.post("/v1/images/variations", async (req: FastifyRequest, reply: FastifyReply) => {
182
+ const body = req.body as { model?: string } | undefined;
183
+
184
+ const model = body?.model ?? await pickDefaultImageEditModel(paths);
185
+ if (!model) {
186
+ reply.code(400).send({ error: { message: "No diffusion model available" } });
187
+ return;
188
+ }
189
+
190
+ const requestId = randomUUID();
191
+ const start = Date.now();
192
+ const controller = new AbortController();
193
+
194
+ req.raw.on("close", () => controller.abort());
195
+
196
+ try {
197
+ const outcome = await routeRequest(
198
+ paths,
199
+ model,
200
+ "/v1/images/variations",
201
+ (body ?? {}) as Record<string, unknown>,
202
+ req.headers as Record<string, string | string[] | undefined>,
203
+ controller.signal,
204
+ {
205
+ endpointType: "diffusion",
206
+ requiredInput: ["image"],
207
+ requiredOutput: ["image"],
208
+ }
209
+ );
210
+
211
+ const upstreamBody = await readBody(outcome.attempt.response);
212
+ setHeaders(reply, outcome.attempt.response.headers);
213
+ reply.code(outcome.attempt.response.statusCode).send(upstreamBody.payload);
214
+ setCaptureRouting(reply, {
215
+ publicModel: model,
216
+ endpointId: outcome.attempt.endpoint.id,
217
+ endpointName: outcome.attempt.endpoint.name,
218
+ upstreamModel: outcome.attempt.upstreamModel,
219
+ });
220
+
221
+ await logRequest(paths, buildLog(requestId, model, outcome, Date.now() - start, false));
222
+ } catch (error) {
223
+ const errorType = (error as { type?: string }).type ?? (error as Error).name;
224
+ setCaptureError(reply, { type: errorType, message: (error as Error).message });
225
+ await logRequest(paths, {
226
+ requestId,
227
+ ts: new Date(),
228
+ route: { publicModel: model },
229
+ request: { stream: false },
230
+ result: { errorType, errorMessage: (error as Error).message }
231
+ });
232
+ if (errorType === "invalid_request") {
233
+ reply.code(400).send({ error: { message: (error as Error).message } });
234
+ return;
235
+ }
236
+ if (errorType === "tls_verify_failed") {
237
+ reply.code(502).send({ error: { message: (error as Error).message, type: errorType } });
238
+ return;
239
+ }
240
+ const status =
241
+ errorType === "no_endpoints" ||
242
+ errorType === "protocol_stream_unsupported" ||
243
+ errorType === "unsupported_protocol" ||
244
+ errorType === "invalid_protocol_config"
245
+ ? 400
246
+ : errorType === "rate_limited"
247
+ ? 429
248
+ : 502;
249
+ reply.code(status).send({ error: { message: "Image variation unavailable", type: errorType } });
250
+ }
251
+ });
252
+ }
253
+
254
+ async function pickDefaultDiffusionModel(paths: StoragePaths): Promise<string | null> {
255
+ const smart = await selectPoolCandidates(paths, "smart", {
256
+ requiredInput: ["text"],
257
+ requiredOutput: ["image"],
258
+ }, {
259
+ operation: "images_generation",
260
+ stream: false,
261
+ });
262
+ if (smart && smart.candidates.length > 0) {
263
+ return "smart";
264
+ }
265
+
266
+ const byCapabilities = await pickBestProviderModelByCapabilities(
267
+ paths,
268
+ { requiredInput: ["text"], requiredOutput: ["image"] },
269
+ "diffusion"
270
+ );
271
+ if (byCapabilities) {
272
+ return byCapabilities;
273
+ }
274
+ return null;
275
+ }
276
+
277
+ async function pickDefaultImageEditModel(paths: StoragePaths): Promise<string | null> {
278
+ const smart = await selectPoolCandidates(paths, "smart", {
279
+ requiredInput: ["image", "text"],
280
+ requiredOutput: ["image"],
281
+ }, {
282
+ operation: "images_edits",
283
+ stream: false,
284
+ });
285
+ if (smart && smart.candidates.length > 0) {
286
+ return "smart";
287
+ }
288
+
289
+ const byCapabilities = await pickBestProviderModelByCapabilities(
290
+ paths,
291
+ { requiredInput: ["image"], requiredOutput: ["image"] },
292
+ "diffusion"
293
+ );
294
+ if (byCapabilities) {
295
+ return byCapabilities;
296
+ }
297
+ return pickDefaultDiffusionModel(paths);
298
+ }
299
+
300
+ function setHeaders(reply: FastifyReply, headers: Record<string, string | string[]>): void {
301
+ for (const [key, value] of Object.entries(headers)) {
302
+ if (Array.isArray(value)) {
303
+ reply.header(key.toLowerCase(), value.join(", "));
304
+ } else {
305
+ reply.header(key.toLowerCase(), value);
306
+ }
307
+ }
308
+ }
309
+
310
+ async function readBody(response: { body: NodeJS.ReadableStream; headers: Record<string, string | string[]> }): Promise<{ payload: unknown }> {
311
+ const chunks: Buffer[] = [];
312
+ for await (const chunk of response.body) {
313
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
314
+ }
315
+ const buffer = Buffer.concat(chunks);
316
+ const contentType = normalizeContentType(response.headers);
317
+ if (contentType.includes("application/json")) {
318
+ try {
319
+ return { payload: JSON.parse(buffer.toString("utf8")) };
320
+ } catch {
321
+ return { payload: buffer };
322
+ }
323
+ }
324
+ return { payload: buffer };
325
+ }
326
+
327
+ function normalizeContentType(headers: Record<string, string | string[]>): string {
328
+ const ct = headers["content-type"] ?? headers["Content-Type"];
329
+ if (Array.isArray(ct)) return ct.join(", ");
330
+ return ct ?? "";
331
+ }
332
+
333
+ function buildLog(
334
+ requestId: string,
335
+ model: string,
336
+ outcome: { attempt: { endpoint: { id: string; name: string }; upstreamModel: string; response: { statusCode: number } } },
337
+ latencyMs: number,
338
+ stream: boolean
339
+ ): RequestLog {
340
+ return {
341
+ requestId,
342
+ ts: new Date(),
343
+ route: {
344
+ publicModel: model,
345
+ endpointId: outcome.attempt.endpoint.id,
346
+ endpointName: outcome.attempt.endpoint.name,
347
+ upstreamModel: outcome.attempt.upstreamModel
348
+ },
349
+ request: { stream },
350
+ result: {
351
+ statusCode: outcome.attempt.response.statusCode,
352
+ latencyMs,
353
+ totalTokens: null
354
+ }
355
+ };
356
+ }