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,372 @@
1
+ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+ import { randomUUID } from "crypto";
3
+ import { pipeline } from "stream";
4
+ import { routeRequest } from "../routing/router";
5
+ import { logRequest } from "../storage/repositories";
6
+ import { RequestLog } from "../types";
7
+ import { StoragePaths } from "../storage/files";
8
+ import { selectPoolCandidates } from "../pools/scheduler";
9
+ import { pickBestProviderModelByCapabilities } from "../providers/modelRegistry";
10
+ import { setCaptureError, setCaptureResponseOverride, setCaptureRouting } from "../middleware/requestCapture";
11
+
12
+ export async function registerAudioRoutes(app: FastifyInstance, paths: StoragePaths): Promise<void> {
13
+ // POST /v1/audio/transcriptions (speech-to-text)
14
+ app.post("/v1/audio/transcriptions", async (req: FastifyRequest, reply: FastifyReply) => {
15
+ // Multipart form data: file, model, language, prompt, response_format, temperature
16
+ const body = req.body as Record<string, unknown> | undefined;
17
+
18
+ const model = (body?.model as string) ?? await pickDefaultAudioModel(paths);
19
+ if (!model) {
20
+ reply.code(400).send({ error: { message: "No audio model available. Add or enable a provider model." } });
21
+ return;
22
+ }
23
+
24
+ const requestId = randomUUID();
25
+ const start = Date.now();
26
+ const controller = new AbortController();
27
+
28
+ req.raw.on("close", () => controller.abort());
29
+
30
+ try {
31
+ const outcome = await routeRequest(
32
+ paths,
33
+ model,
34
+ "/v1/audio/transcriptions",
35
+ body as Record<string, unknown>,
36
+ req.headers as Record<string, string | string[] | undefined>,
37
+ controller.signal,
38
+ {
39
+ endpointType: "audio",
40
+ requiredInput: ["audio"],
41
+ requiredOutput: ["text"],
42
+ }
43
+ );
44
+
45
+ const upstreamBody = await readBody(outcome.attempt.response);
46
+ setHeaders(reply, outcome.attempt.response.headers);
47
+ reply.code(outcome.attempt.response.statusCode).send(upstreamBody.payload);
48
+ setCaptureRouting(reply, {
49
+ publicModel: model,
50
+ endpointId: outcome.attempt.endpoint.id,
51
+ endpointName: outcome.attempt.endpoint.name,
52
+ upstreamModel: outcome.attempt.upstreamModel,
53
+ });
54
+
55
+ await logRequest(paths, buildLog(
56
+ requestId,
57
+ model,
58
+ outcome,
59
+ Date.now() - start
60
+ ));
61
+ } catch (error) {
62
+ const errorType = (error as { type?: string }).type ?? (error as Error).name;
63
+ setCaptureError(reply, { type: errorType, message: (error as Error).message });
64
+ await logRequest(paths, {
65
+ requestId,
66
+ ts: new Date(),
67
+ route: { publicModel: model },
68
+ request: { stream: false },
69
+ result: { errorType, errorMessage: (error as Error).message }
70
+ });
71
+ if (errorType === "invalid_request") {
72
+ reply.code(400).send({ error: { message: (error as Error).message } });
73
+ return;
74
+ }
75
+ if (errorType === "tls_verify_failed") {
76
+ reply.code(502).send({ error: { message: (error as Error).message, type: errorType } });
77
+ return;
78
+ }
79
+ const status =
80
+ errorType === "no_endpoints" ||
81
+ errorType === "protocol_stream_unsupported" ||
82
+ errorType === "unsupported_protocol" ||
83
+ errorType === "invalid_protocol_config"
84
+ ? 400
85
+ : errorType === "rate_limited"
86
+ ? 429
87
+ : 502;
88
+ reply.code(status).send({ error: { message: "Transcription unavailable", type: errorType } });
89
+ }
90
+ });
91
+
92
+ // POST /v1/audio/translations (translation to English)
93
+ app.post("/v1/audio/translations", async (req: FastifyRequest, reply: FastifyReply) => {
94
+ const body = req.body as Record<string, unknown> | undefined;
95
+
96
+ const model = (body?.model as string) ?? await pickDefaultAudioModel(paths);
97
+ if (!model) {
98
+ reply.code(400).send({ error: { message: "No audio model available. Add or enable a provider model." } });
99
+ return;
100
+ }
101
+
102
+ const requestId = randomUUID();
103
+ const start = Date.now();
104
+ const controller = new AbortController();
105
+
106
+ req.raw.on("close", () => controller.abort());
107
+
108
+ try {
109
+ const outcome = await routeRequest(
110
+ paths,
111
+ model,
112
+ "/v1/audio/translations",
113
+ body as Record<string, unknown>,
114
+ req.headers as Record<string, string | string[] | undefined>,
115
+ controller.signal,
116
+ {
117
+ endpointType: "audio",
118
+ requiredInput: ["audio"],
119
+ requiredOutput: ["text"],
120
+ }
121
+ );
122
+
123
+ const upstreamBody = await readBody(outcome.attempt.response);
124
+ setHeaders(reply, outcome.attempt.response.headers);
125
+ reply.code(outcome.attempt.response.statusCode).send(upstreamBody.payload);
126
+ setCaptureRouting(reply, {
127
+ publicModel: model,
128
+ endpointId: outcome.attempt.endpoint.id,
129
+ endpointName: outcome.attempt.endpoint.name,
130
+ upstreamModel: outcome.attempt.upstreamModel,
131
+ });
132
+
133
+ await logRequest(paths, buildLog(requestId, model, outcome, Date.now() - start));
134
+ } catch (error) {
135
+ const errorType = (error as { type?: string }).type ?? (error as Error).name;
136
+ setCaptureError(reply, { type: errorType, message: (error as Error).message });
137
+ await logRequest(paths, {
138
+ requestId,
139
+ ts: new Date(),
140
+ route: { publicModel: model },
141
+ request: { stream: false },
142
+ result: { errorType, errorMessage: (error as Error).message }
143
+ });
144
+ if (errorType === "invalid_request") {
145
+ reply.code(400).send({ error: { message: (error as Error).message } });
146
+ return;
147
+ }
148
+ if (errorType === "tls_verify_failed") {
149
+ reply.code(502).send({ error: { message: (error as Error).message, type: errorType } });
150
+ return;
151
+ }
152
+ const status =
153
+ errorType === "no_endpoints" ||
154
+ errorType === "protocol_stream_unsupported" ||
155
+ errorType === "unsupported_protocol" ||
156
+ errorType === "invalid_protocol_config"
157
+ ? 400
158
+ : errorType === "rate_limited"
159
+ ? 429
160
+ : 502;
161
+ reply.code(status).send({ error: { message: "Translation unavailable", type: errorType } });
162
+ }
163
+ });
164
+
165
+ // POST /v1/audio/speech (text-to-speech)
166
+ app.post("/v1/audio/speech", async (req: FastifyRequest, reply: FastifyReply) => {
167
+ const body = req.body as { model?: string; input: string; voice: string; response_format?: string; speed?: number } | undefined;
168
+
169
+ if (!body?.input || !body?.voice) {
170
+ reply.code(400).send({ error: { message: "input and voice are required" } });
171
+ return;
172
+ }
173
+
174
+ const model = body.model ?? await pickDefaultTtsModel(paths);
175
+ if (!model) {
176
+ reply.code(400).send({ error: { message: "No TTS model available. Add or enable a text-to-audio provider model." } });
177
+ return;
178
+ }
179
+
180
+ const requestId = randomUUID();
181
+ const start = Date.now();
182
+ const controller = new AbortController();
183
+
184
+ req.raw.on("close", () => controller.abort());
185
+
186
+ try {
187
+ const outcome = await routeRequest(
188
+ paths,
189
+ model,
190
+ "/v1/audio/speech",
191
+ { ...body, model } as Record<string, unknown>,
192
+ req.headers as Record<string, string | string[] | undefined>,
193
+ controller.signal,
194
+ {
195
+ endpointType: "audio",
196
+ requiredInput: ["text"],
197
+ requiredOutput: ["audio"],
198
+ }
199
+ );
200
+
201
+ // Speech returns binary audio - stream it directly
202
+ await streamResponse(reply, outcome.attempt.response);
203
+ setCaptureResponseOverride(
204
+ reply,
205
+ {
206
+ $type: "stream",
207
+ contentType: normalizeHeaders(outcome.attempt.response.headers)["content-type"] ?? "application/octet-stream",
208
+ note: "Audio stream captured as metadata",
209
+ },
210
+ outcome.attempt.response.headers
211
+ );
212
+ setCaptureRouting(reply, {
213
+ publicModel: model,
214
+ endpointId: outcome.attempt.endpoint.id,
215
+ endpointName: outcome.attempt.endpoint.name,
216
+ upstreamModel: outcome.attempt.upstreamModel,
217
+ });
218
+
219
+ await logRequest(paths, buildLog(requestId, model, outcome, Date.now() - start));
220
+ } catch (error) {
221
+ const errorType = (error as { type?: string }).type ?? (error as Error).name;
222
+ setCaptureError(reply, { type: errorType, message: (error as Error).message });
223
+ await logRequest(paths, {
224
+ requestId,
225
+ ts: new Date(),
226
+ route: { publicModel: model },
227
+ request: { stream: false },
228
+ result: { errorType, errorMessage: (error as Error).message }
229
+ });
230
+ // Don't try to send error if headers already sent (streaming started)
231
+ if (reply.raw.headersSent) {
232
+ req.log.warn({ err: error }, "Error after streaming started");
233
+ reply.raw.end();
234
+ return;
235
+ }
236
+ if (errorType === "invalid_request") {
237
+ reply.code(400).send({ error: { message: (error as Error).message } });
238
+ return;
239
+ }
240
+ if (errorType === "tls_verify_failed") {
241
+ reply.code(502).send({ error: { message: (error as Error).message, type: errorType } });
242
+ return;
243
+ }
244
+ const status =
245
+ errorType === "no_endpoints" ||
246
+ errorType === "protocol_stream_unsupported" ||
247
+ errorType === "unsupported_protocol" ||
248
+ errorType === "invalid_protocol_config"
249
+ ? 400
250
+ : errorType === "rate_limited"
251
+ ? 429
252
+ : 502;
253
+ reply.code(status).send({ error: { message: "Speech synthesis unavailable", type: errorType } });
254
+ }
255
+ });
256
+ }
257
+
258
+ async function pickDefaultAudioModel(paths: StoragePaths): Promise<string | null> {
259
+ const smart = await selectPoolCandidates(paths, "smart", {
260
+ requiredInput: ["audio"],
261
+ requiredOutput: ["text"],
262
+ }, {
263
+ operation: "audio_transcriptions",
264
+ stream: false,
265
+ });
266
+ if (smart && smart.candidates.length > 0) {
267
+ return "smart";
268
+ }
269
+
270
+ const byCapabilities = await pickBestProviderModelByCapabilities(
271
+ paths,
272
+ { requiredInput: ["audio"], requiredOutput: ["text"] },
273
+ "audio"
274
+ );
275
+ if (byCapabilities) {
276
+ return byCapabilities;
277
+ }
278
+ return null;
279
+ }
280
+
281
+ async function pickDefaultTtsModel(paths: StoragePaths): Promise<string | null> {
282
+ const smart = await selectPoolCandidates(paths, "smart", {
283
+ requiredInput: ["text"],
284
+ requiredOutput: ["audio"],
285
+ }, {
286
+ operation: "audio_speech",
287
+ stream: false,
288
+ });
289
+ if (smart && smart.candidates.length > 0) {
290
+ return "smart";
291
+ }
292
+
293
+ const byCapabilities = await pickBestProviderModelByCapabilities(
294
+ paths,
295
+ { requiredInput: ["text"], requiredOutput: ["audio"] },
296
+ "audio"
297
+ );
298
+ if (byCapabilities) {
299
+ return byCapabilities;
300
+ }
301
+ return null;
302
+ }
303
+
304
+ async function streamResponse(
305
+ reply: FastifyReply,
306
+ response: { statusCode: number; headers: Record<string, string | string[]>; body: NodeJS.ReadableStream }
307
+ ): Promise<void> {
308
+ const headers = normalizeHeaders(response.headers);
309
+ reply.raw.writeHead(response.statusCode, headers);
310
+ await new Promise<void>((resolve, reject) => {
311
+ pipeline(response.body, reply.raw, (err) => {
312
+ if (err) reject(err);
313
+ else resolve();
314
+ });
315
+ });
316
+ }
317
+
318
+ function setHeaders(reply: FastifyReply, headers: Record<string, string | string[]>): void {
319
+ const normalized = normalizeHeaders(headers);
320
+ for (const [key, value] of Object.entries(normalized)) {
321
+ reply.header(key, value);
322
+ }
323
+ }
324
+
325
+ function normalizeHeaders(headers: Record<string, string | string[]>): Record<string, string> {
326
+ const normalized: Record<string, string> = {};
327
+ for (const [key, value] of Object.entries(headers)) {
328
+ normalized[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
329
+ }
330
+ return normalized;
331
+ }
332
+
333
+ async function readBody(response: { body: NodeJS.ReadableStream; headers: Record<string, string | string[]> }): Promise<{ payload: unknown }> {
334
+ const chunks: Buffer[] = [];
335
+ for await (const chunk of response.body) {
336
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
337
+ }
338
+ const buffer = Buffer.concat(chunks);
339
+ const contentType = normalizeHeaders(response.headers)["content-type"] ?? "";
340
+ if (contentType.includes("application/json")) {
341
+ try {
342
+ return { payload: JSON.parse(buffer.toString("utf8")) };
343
+ } catch {
344
+ return { payload: buffer };
345
+ }
346
+ }
347
+ return { payload: buffer };
348
+ }
349
+
350
+ function buildLog(
351
+ requestId: string,
352
+ model: string,
353
+ outcome: { attempt: { endpoint: { id: string; name: string }; upstreamModel: string; response: { statusCode: number } } },
354
+ latencyMs: number
355
+ ): RequestLog {
356
+ return {
357
+ requestId,
358
+ ts: new Date(),
359
+ route: {
360
+ publicModel: model,
361
+ endpointId: outcome.attempt.endpoint.id,
362
+ endpointName: outcome.attempt.endpoint.name,
363
+ upstreamModel: outcome.attempt.upstreamModel
364
+ },
365
+ request: { stream: false },
366
+ result: {
367
+ statusCode: outcome.attempt.response.statusCode,
368
+ latencyMs,
369
+ totalTokens: null
370
+ }
371
+ };
372
+ }
@@ -0,0 +1,301 @@
1
+ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+ import { randomUUID } from "crypto";
3
+ import { pipeline } from "stream";
4
+ import { routeRequest } from "../routing/router";
5
+ import { logRequest } from "../storage/repositories";
6
+ import { RequestLog } from "../types";
7
+ import { StoragePaths } from "../storage/files";
8
+ import { selectPoolCandidates } from "../pools/scheduler";
9
+ import { pickBestProviderModelByCapabilities } from "../providers/modelRegistry";
10
+ import { normalizeMessagesForUpstream, scanMessageModalities } from "../utils/messageMedia";
11
+ import {
12
+ appendCaptureStreamChunk,
13
+ setCaptureDerivedRequest,
14
+ setCaptureError,
15
+ setCaptureResponseOverride,
16
+ setCaptureRouting,
17
+ startCaptureStreamResponse,
18
+ } from "../middleware/requestCapture";
19
+ import { setStatsPayload } from "../middleware/requestStats";
20
+ import { Transform } from "stream";
21
+
22
+ interface ChatBody {
23
+ model: string;
24
+ stream?: boolean;
25
+ max_tokens?: number;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ export async function registerChatRoutes(app: FastifyInstance, paths: StoragePaths): Promise<void> {
30
+ app.post("/v1/chat/completions", async (req: FastifyRequest, reply: FastifyReply) => {
31
+ let body = req.body as ChatBody | undefined;
32
+ if (!body?.model) {
33
+ const fallback = await pickDefaultModel(paths);
34
+ if (!fallback) {
35
+ reply.code(400).send({ error: { message: "model is required" } });
36
+ return;
37
+ }
38
+ body = { ...(body ?? {}), model: fallback };
39
+ }
40
+
41
+ const requestId = randomUUID();
42
+ const start = Date.now();
43
+ const controller = new AbortController();
44
+
45
+ req.raw.on("close", () => controller.abort());
46
+
47
+ try {
48
+ const messages = (body as unknown as { messages?: unknown }).messages;
49
+ const normalizedMessages = await normalizeMessagesForUpstream(paths, messages);
50
+ const bodyWithNormalizedMessages: Record<string, unknown> = {
51
+ ...(body as Record<string, unknown>),
52
+ messages: normalizedMessages,
53
+ };
54
+ setCaptureDerivedRequest(reply, { normalizedRequest: bodyWithNormalizedMessages });
55
+ const media = scanMessageModalities(normalizedMessages);
56
+ const outcome = await routeRequest(
57
+ paths,
58
+ body.model,
59
+ "/v1/chat/completions",
60
+ bodyWithNormalizedMessages,
61
+ req.headers as Record<string, string | string[] | undefined>,
62
+ controller.signal,
63
+ {
64
+ requiredInput: media.hasAudio
65
+ ? media.hasImage
66
+ ? ["text", "image", "audio"]
67
+ : ["text", "audio"]
68
+ : media.hasImage
69
+ ? ["text", "image"]
70
+ : ["text"],
71
+ requiredOutput: ["text"],
72
+ }
73
+ );
74
+
75
+ if (body.stream) {
76
+ setCaptureRouting(reply, {
77
+ publicModel: body.model,
78
+ endpointId: outcome.attempt.endpoint.id,
79
+ endpointName: outcome.attempt.endpoint.name,
80
+ upstreamModel: outcome.attempt.upstreamModel,
81
+ });
82
+ setStatsPayload(reply, {
83
+ endpointId: outcome.attempt.endpoint.id,
84
+ endpointName: outcome.attempt.endpoint.name,
85
+ upstreamModel: outcome.attempt.upstreamModel,
86
+ });
87
+ await streamResponse(reply, outcome.attempt.response);
88
+ await logRequest(paths, buildLog(requestId, body, outcome, Date.now() - start));
89
+ return;
90
+ }
91
+
92
+ const upstreamBody = await readBody(outcome.attempt.response);
93
+ setCaptureResponseOverride(reply, upstreamBody.payload, outcome.attempt.response.headers);
94
+ setHeaders(reply, outcome.attempt.response.headers);
95
+ reply.code(outcome.attempt.response.statusCode).send(upstreamBody.payload);
96
+ setCaptureRouting(reply, {
97
+ publicModel: body.model,
98
+ endpointId: outcome.attempt.endpoint.id,
99
+ endpointName: outcome.attempt.endpoint.name,
100
+ upstreamModel: outcome.attempt.upstreamModel,
101
+ });
102
+ setStatsPayload(reply, {
103
+ endpointId: outcome.attempt.endpoint.id,
104
+ endpointName: outcome.attempt.endpoint.name,
105
+ upstreamModel: outcome.attempt.upstreamModel,
106
+ totalTokens: upstreamBody.totalTokens,
107
+ promptTokens: upstreamBody.promptTokens,
108
+ completionTokens: upstreamBody.completionTokens,
109
+ });
110
+ await logRequest(paths, buildLog(requestId, body, outcome, Date.now() - start, upstreamBody.totalTokens));
111
+ } catch (error) {
112
+ const errorType = (error as { type?: string }).type ?? (error as Error).name;
113
+ setCaptureError(reply, { type: errorType, message: (error as Error).message });
114
+ await logRequest(paths, {
115
+ requestId,
116
+ ts: new Date(),
117
+ route: { publicModel: body?.model ?? "unknown" },
118
+ request: { stream: Boolean(body?.stream), maxTokens: body?.max_tokens },
119
+ result: {
120
+ errorType,
121
+ errorMessage: (error as Error).message
122
+ }
123
+ });
124
+ // Don't try to send error if headers already sent (streaming started)
125
+ if (reply.raw.headersSent) {
126
+ req.log.warn({ err: error }, "Error after streaming started");
127
+ reply.raw.end();
128
+ return;
129
+ }
130
+ const status =
131
+ errorType === "no_endpoints" ||
132
+ errorType === "protocol_stream_unsupported" ||
133
+ errorType === "unsupported_protocol" ||
134
+ errorType === "invalid_protocol_config"
135
+ ? 400
136
+ : errorType === "rate_limited"
137
+ ? 429
138
+ : 502;
139
+ if (errorType === "invalid_request") {
140
+ reply.code(400).send({ error: { message: (error as Error).message } });
141
+ return;
142
+ }
143
+ if (errorType === "tls_verify_failed") {
144
+ reply.code(502).send({ error: { message: (error as Error).message } });
145
+ return;
146
+ }
147
+ reply.code(status).send({ error: { message: "Upstream unavailable" } });
148
+ }
149
+ });
150
+ }
151
+
152
+ async function pickDefaultModel(paths: StoragePaths): Promise<string | null> {
153
+ const smart = await selectPoolCandidates(paths, "smart", {
154
+ requiredInput: ["text"],
155
+ requiredOutput: ["text"],
156
+ }, {
157
+ operation: "chat_completions",
158
+ stream: false,
159
+ });
160
+ if (smart && smart.candidates.length > 0) {
161
+ return "smart";
162
+ }
163
+
164
+ const byCapabilities = await pickBestProviderModelByCapabilities(
165
+ paths,
166
+ { requiredInput: ["text"], requiredOutput: ["text"] },
167
+ "llm"
168
+ );
169
+ if (byCapabilities) {
170
+ return byCapabilities;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ async function streamResponse(
176
+ reply: FastifyReply,
177
+ response: { statusCode: number; headers: Record<string, string | string[]>; body: NodeJS.ReadableStream }
178
+ ): Promise<{ bytes: number; text?: string; contentType: string }> {
179
+ const headers = normalizeHeaders(response.headers);
180
+ if (!headers["content-type"]) {
181
+ headers["content-type"] = "text/event-stream";
182
+ }
183
+ headers["cache-control"] = headers["cache-control"] ?? "no-cache";
184
+ const contentType = headers["content-type"] ?? "application/octet-stream";
185
+ startCaptureStreamResponse(reply, headers, contentType);
186
+
187
+ const chunks: Buffer[] = [];
188
+ const captureTap = new Transform({
189
+ transform(chunk, _enc, cb) {
190
+ const asBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
191
+ chunks.push(asBuffer);
192
+ appendCaptureStreamChunk(reply, asBuffer, { contentType, headers });
193
+ cb(null, chunk);
194
+ },
195
+ });
196
+
197
+ reply.raw.writeHead(response.statusCode, headers);
198
+ await new Promise<void>((resolve, reject) => {
199
+ pipeline(response.body, captureTap, reply.raw, (err) => {
200
+ if (err) {
201
+ reject(err);
202
+ return;
203
+ }
204
+ resolve();
205
+ });
206
+ });
207
+ const buffer = Buffer.concat(chunks);
208
+ const isText = contentType.includes("text/") || contentType.includes("json") || contentType.includes("event-stream");
209
+ setCaptureResponseOverride(
210
+ reply,
211
+ {
212
+ $type: "stream",
213
+ contentType,
214
+ bytes: buffer.byteLength,
215
+ text: isText ? buffer.toString("utf8") : undefined,
216
+ },
217
+ headers
218
+ );
219
+ return {
220
+ bytes: buffer.byteLength,
221
+ text: isText ? buffer.toString("utf8") : undefined,
222
+ contentType,
223
+ };
224
+ }
225
+
226
+ function setHeaders(reply: FastifyReply, headers: Record<string, string | string[]>): void {
227
+ const normalized = normalizeHeaders(headers);
228
+ for (const [key, value] of Object.entries(normalized)) {
229
+ reply.header(key, value);
230
+ }
231
+ }
232
+
233
+ function normalizeHeaders(headers: Record<string, string | string[]>): Record<string, string> {
234
+ const normalized: Record<string, string> = {};
235
+ for (const [key, value] of Object.entries(headers)) {
236
+ if (Array.isArray(value)) {
237
+ normalized[key.toLowerCase()] = value.join(", ");
238
+ } else {
239
+ normalized[key.toLowerCase()] = value;
240
+ }
241
+ }
242
+ return normalized;
243
+ }
244
+
245
+ async function readBody(
246
+ response: { body: NodeJS.ReadableStream; headers: Record<string, string | string[]> }
247
+ ): Promise<{
248
+ payload: unknown;
249
+ totalTokens: number | null;
250
+ promptTokens: number | null;
251
+ completionTokens: number | null;
252
+ }> {
253
+ const chunks: Buffer[] = [];
254
+ for await (const chunk of response.body) {
255
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
256
+ }
257
+ const buffer = Buffer.concat(chunks);
258
+ const contentType = normalizeHeaders(response.headers)["content-type"] ?? "";
259
+ if (contentType.includes("application/json")) {
260
+ try {
261
+ const payload = JSON.parse(buffer.toString("utf8"));
262
+ const usage = typeof payload === "object" && payload && (
263
+ payload as { usage?: { total_tokens?: number; prompt_tokens?: number; completion_tokens?: number } }
264
+ ).usage;
265
+ return {
266
+ payload,
267
+ totalTokens: usage?.total_tokens ?? null,
268
+ promptTokens: usage?.prompt_tokens ?? null,
269
+ completionTokens: usage?.completion_tokens ?? null,
270
+ };
271
+ } catch {
272
+ return { payload: buffer, totalTokens: null, promptTokens: null, completionTokens: null };
273
+ }
274
+ }
275
+ return { payload: buffer, totalTokens: null, promptTokens: null, completionTokens: null };
276
+ }
277
+
278
+ function buildLog(
279
+ requestId: string,
280
+ body: ChatBody,
281
+ outcome: { attempt: { endpoint: { id: string; name: string }; upstreamModel: string; response: { statusCode: number } } },
282
+ latencyMs: number,
283
+ totalTokens?: number | null
284
+ ): RequestLog {
285
+ return {
286
+ requestId,
287
+ ts: new Date(),
288
+ route: {
289
+ publicModel: body.model,
290
+ endpointId: outcome.attempt.endpoint.id,
291
+ endpointName: outcome.attempt.endpoint.name,
292
+ upstreamModel: outcome.attempt.upstreamModel
293
+ },
294
+ request: { stream: Boolean(body.stream), maxTokens: body.max_tokens },
295
+ result: {
296
+ statusCode: outcome.attempt.response.statusCode,
297
+ latencyMs,
298
+ totalTokens: totalTokens ?? null
299
+ }
300
+ };
301
+ }