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
package/cli/index.ts ADDED
@@ -0,0 +1,2805 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import {
4
+ getModelCapabilitiesForEndpoint,
5
+ getUsageByEndpoint,
6
+ listEndpoints,
7
+ setEndpointDisabled,
8
+ updateHealthCheck
9
+ } from "../src/storage/repositories";
10
+ import { Agent, request } from "undici";
11
+ import { ensureStorageDir, resolveStoragePaths } from "../src/storage/files";
12
+ import { spawn } from "child_process";
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import { routeRequest } from "../src/routing/router";
16
+ import { aggregateStats } from "../src/storage/statsRepository";
17
+ import { listMcpServers, addMcpServer, removeMcpServer, updateMcpServer } from "../src/mcp/registry";
18
+ import { listBenchmarkExamples, runBenchmark } from "../src/benchmark/runner";
19
+ import { importProviders } from "../src/providers/importer";
20
+ import { listModelsForApi } from "../src/providers/modelRegistry";
21
+ import { getProviderModelHealthMap, probeProviderModels } from "../src/providers/health";
22
+ import {
23
+ canonicalProviderModelId,
24
+ deleteProviderModel,
25
+ getProviderById,
26
+ getProviderModel,
27
+ listProviderModels,
28
+ listProviders,
29
+ setProviderModelApiKey,
30
+ setProviderModelEnabled,
31
+ setProviderEnabled,
32
+ updateProvider,
33
+ updateProviderModel,
34
+ normalizeDomainSuffixes,
35
+ upsertProvider,
36
+ upsertProviderModel,
37
+ } from "../src/providers/repository";
38
+ import { rebuildDefaultPools } from "../src/pools/builder";
39
+ import { listPools } from "../src/pools/repository";
40
+ import { canonicalizeProtocol, hasProtocolAdapter, listAdapterOperations } from "../src/protocols/registry";
41
+ import { ProviderModelRecord, ProviderProtocol, ProviderRecord } from "../src/providers/types";
42
+ import { ModelCapabilities, ModelModality } from "../src/types";
43
+ import { rewriteLegacyArgv, LegacyRewriteResult } from "./legacyRewrite";
44
+ import { parseModelRef } from "./modelRef";
45
+ import { appConfig } from "../src/config";
46
+
47
+ if (!process.env.WAYPOI_DIR && appConfig.storageDirOverride) {
48
+ process.env.WAYPOI_DIR = appConfig.storageDirOverride;
49
+ }
50
+
51
+ const program = new Command();
52
+
53
+ type CanonicalCommandContext = {
54
+ json?: boolean;
55
+ quiet?: boolean;
56
+ noColor?: boolean;
57
+ };
58
+
59
+ const paths = resolveStoragePaths();
60
+ const pidFile = path.join(paths.baseDir, appConfig.pidFileName);
61
+ const DEFAULT_PORT = String(appConfig.port);
62
+ const DISPLAY_NAME = appConfig.appName.charAt(0).toUpperCase() + appConfig.appName.slice(1);
63
+
64
+ function resolveDefaultRegistryPath(): string {
65
+ const candidates = [
66
+ path.resolve(process.cwd(), "providers/free-llm-api/registry.yaml"),
67
+ path.resolve(__dirname, "../providers/free-llm-api/registry.yaml"),
68
+ path.resolve(__dirname, "../../providers/free-llm-api/registry.yaml"),
69
+ ];
70
+ for (const candidate of candidates) {
71
+ if (fs.existsSync(candidate)) {
72
+ return candidate;
73
+ }
74
+ }
75
+ return candidates[0];
76
+ }
77
+
78
+ /**
79
+ * Perform an on-demand health check for all endpoints.
80
+ * Updates health.json with fresh status before returning.
81
+ */
82
+ async function refreshHealthStatus(): Promise<void> {
83
+ const endpoints = (await listEndpoints(paths)).filter((endpoint) => !endpoint.disabled);
84
+ await Promise.all(
85
+ endpoints.map(async (endpoint) => {
86
+ const start = Date.now();
87
+ try {
88
+ const dispatcher = endpoint.insecureTls
89
+ ? new Agent({ connect: { rejectUnauthorized: false } })
90
+ : undefined;
91
+ const url = new URL("/v1/models", endpoint.baseUrl).toString();
92
+ const headers: Record<string, string> = {};
93
+ if (endpoint.apiKey) {
94
+ headers.authorization = `Bearer ${endpoint.apiKey}`;
95
+ }
96
+ const response = await request(url, {
97
+ method: "GET",
98
+ headers,
99
+ headersTimeout: 3000,
100
+ bodyTimeout: 3000,
101
+ dispatcher
102
+ });
103
+ const latency = Date.now() - start;
104
+ response.body.resume();
105
+ if (response.statusCode >= 200 && response.statusCode < 300) {
106
+ await updateHealthCheck(paths, endpoint.id, "up", latency);
107
+ console.log(`✓ ${endpoint.name}: UP (${response.statusCode}, ${latency}ms)`);
108
+ } else {
109
+ await updateHealthCheck(paths, endpoint.id, "down", null);
110
+ console.log(`✗ ${endpoint.name}: DOWN (status ${response.statusCode})`);
111
+ }
112
+ } catch (error) {
113
+ await updateHealthCheck(paths, endpoint.id, "down", null);
114
+ const errorMsg = (error as Error).message || "unknown error";
115
+ console.log(`✗ ${endpoint.name}: DOWN (${errorMsg})`);
116
+ }
117
+ })
118
+ );
119
+ }
120
+
121
+ function printJson(payload: unknown): void {
122
+ console.log(JSON.stringify(payload, null, 2));
123
+ }
124
+
125
+ function printWarning(message: string): void {
126
+ console.error(message);
127
+ }
128
+
129
+ function printErrorWithSuggestion(message: string, suggestions: string[] = []): void {
130
+ console.error(message);
131
+ for (const suggestion of suggestions) {
132
+ console.error(suggestion);
133
+ }
134
+ }
135
+
136
+ function warnLegacyRewrite(result: LegacyRewriteResult): void {
137
+ if (!result.legacyUsed || process.env.WAYPOI_NO_WARN === "1") {
138
+ return;
139
+ }
140
+ const oldCmd = result.oldCmd ?? "";
141
+ const newCmd = result.newCmd ? `waypoi ${result.newCmd}` : "waypoi --help";
142
+ printWarning(`Deprecated command: ${oldCmd}`);
143
+ printWarning(`Use instead: ${newCmd}`);
144
+ }
145
+
146
+ program
147
+ .name(appConfig.appName)
148
+ .description("Waypoi proxy and operations CLI")
149
+ .version("0.7.1-beta.3")
150
+ .option("--json", "Machine-readable JSON output where supported")
151
+ .option("--quiet", "Suppress non-essential output")
152
+ .option("--no-color", "Disable ANSI color output");
153
+
154
+ program.addHelpText(
155
+ "after",
156
+ `
157
+ Examples:
158
+ ${appConfig.appName} providers
159
+ ${appConfig.appName} models
160
+ ${appConfig.appName} models show provider-id/model-id
161
+ ${appConfig.appName} service
162
+ ${appConfig.appName} logs -f
163
+ ${appConfig.appName} bench --suite smoke
164
+ `
165
+ );
166
+
167
+ program
168
+ .command("add")
169
+ .description("Add a new endpoint")
170
+ .requiredOption("--name <name>", "Endpoint display name")
171
+ .requiredOption("--url <url>", "Base URL of the endpoint")
172
+ .requiredOption("--priority <priority>", "Routing priority (lower = preferred)")
173
+ .option("--type <type>", "Endpoint type: llm (chat/completions), diffusion (/images/generations), audio, embedding", "llm")
174
+ .option("--insecureTls", "Allow self-signed TLS certificates")
175
+ .option("--apiKey <apiKey>", "Bearer token for Authorization header")
176
+ .option("--model <mapping...>", "Model mapping as 'public' or 'public=upstream'. If endpoint has 1 model, upstream is auto-detected.")
177
+ .action(async () => {
178
+ console.error(
179
+ "Endpoint writes are deprecated in v0.5.0. Use `waypoi models add ...` and migration commands."
180
+ );
181
+ process.exitCode = 1;
182
+ });
183
+
184
+ program
185
+ .command("ls")
186
+ .option("--no-check", "Skip health check for faster listing")
187
+ .option("--verbose", "Show full endpoint fields")
188
+ .action(async (options) => {
189
+ await ensureStorageDir(paths);
190
+ // Refresh health status unless --no-check is specified
191
+ if (options.check !== false) {
192
+ await refreshHealthStatus();
193
+ }
194
+ const endpoints = await listEndpoints(paths);
195
+ if (endpoints.length === 0) {
196
+ console.log("No endpoints found.");
197
+ return;
198
+ }
199
+ const rows = options.verbose
200
+ ? endpoints.map((endpoint) => ({
201
+ id: endpoint.id,
202
+ name: endpoint.name,
203
+ baseUrl: endpoint.baseUrl,
204
+ type: endpoint.type,
205
+ disabled: endpoint.disabled ? "yes" : "no",
206
+ status: endpoint.health.status,
207
+ priority: endpoint.priority,
208
+ }))
209
+ : endpoints.map((endpoint) => ({
210
+ name: endpoint.name,
211
+ host: compactEndpointUrl(endpoint.baseUrl),
212
+ type: endpoint.type,
213
+ disabled: endpoint.disabled ? "yes" : "no",
214
+ status: endpoint.health.status,
215
+ prio: endpoint.priority,
216
+ }));
217
+ console.table(rows);
218
+ });
219
+
220
+ program
221
+ .command("test")
222
+ .argument("<model>")
223
+ .action(async (model) => {
224
+ await ensureStorageDir(paths);
225
+ const start = Date.now();
226
+ const controller = new AbortController();
227
+ try {
228
+ const type = await resolveModelType(model);
229
+ const isImage = type === "diffusion";
230
+ const requestPath = isImage ? "/v1/images/generations" : "/v1/chat/completions";
231
+ const payload = isImage
232
+ ? { model, prompt: "A small blue square on a white background." }
233
+ : { model, messages: [{ role: "user", content: "Say hello in one short sentence." }], max_tokens: 32 };
234
+ const outcome = await routeRequest(paths, model, requestPath, payload, {}, controller.signal);
235
+ const responseBody = await readResponsePayload(outcome.attempt.response);
236
+ const latency = Date.now() - start;
237
+ console.log(JSON.stringify({ status: outcome.attempt.response.statusCode, latencyMs: latency, response: responseBody }, null, 2));
238
+ } catch (error) {
239
+ console.error((error as Error).message);
240
+ process.exitCode = 1;
241
+ }
242
+ });
243
+
244
+ program
245
+ .command("rm")
246
+ .argument("<idOrName>")
247
+ .action(async () => {
248
+ console.error(
249
+ "Endpoint writes are deprecated in v0.5.0. Disable or migrate endpoints instead of deleting them."
250
+ );
251
+ process.exitCode = 1;
252
+ });
253
+
254
+ program
255
+ .command("edit")
256
+ .description("Open the config file in your editor")
257
+ .action(async () => {
258
+ console.error(
259
+ "Endpoint config edit is blocked in v0.5.0. Use provider/model management commands instead."
260
+ );
261
+ process.exitCode = 1;
262
+ });
263
+
264
+ program
265
+ .command("stat")
266
+ .description("Run a health check against each endpoint")
267
+ .action(async () => {
268
+ await ensureStorageDir(paths);
269
+ const endpoints = await listEndpoints(paths);
270
+ const activeEndpoints = endpoints.filter((endpoint) => !endpoint.disabled);
271
+ if (activeEndpoints.length === 0) {
272
+ console.log("No endpoints found.");
273
+ return;
274
+ }
275
+ const results = await Promise.all(
276
+ activeEndpoints.map(async (endpoint) => {
277
+ const start = Date.now();
278
+ try {
279
+ const dispatcher = endpoint.insecureTls
280
+ ? new Agent({ connect: { rejectUnauthorized: false } })
281
+ : undefined;
282
+ const headers: Record<string, string> = {};
283
+ if (endpoint.apiKey) {
284
+ headers.authorization = `Bearer ${endpoint.apiKey}`;
285
+ }
286
+ const response = await request(new URL("/v1/models", endpoint.baseUrl).toString(), {
287
+ method: "GET",
288
+ headers,
289
+ headersTimeout: 3000,
290
+ bodyTimeout: 3000,
291
+ dispatcher
292
+ });
293
+ response.body.resume();
294
+ const latency = Date.now() - start;
295
+ const status = response.statusCode >= 200 && response.statusCode < 300 ? "up" : "down";
296
+ await updateHealthCheck(paths, endpoint.id, status, status === "up" ? latency : null);
297
+ return { name: endpoint.name, status, statusCode: response.statusCode, latencyMs: latency };
298
+ } catch (error) {
299
+ await updateHealthCheck(paths, endpoint.id, "down", null);
300
+ return { name: endpoint.name, status: "down", error: (error as Error).message };
301
+ }
302
+ })
303
+ );
304
+ const disabledRows = endpoints
305
+ .filter((endpoint) => endpoint.disabled)
306
+ .map((endpoint) => ({
307
+ name: endpoint.name,
308
+ status: "disabled",
309
+ error: "skipped (disabled)",
310
+ }));
311
+ if (disabledRows.length > 0) {
312
+ results.push(...disabledRows);
313
+ }
314
+ console.table(results);
315
+ });
316
+
317
+ // Alias: waypoi status -> waypoi stat
318
+ program
319
+ .command("status")
320
+ .description("Alias for 'stat' - Run a health check against each endpoint")
321
+ .action(async () => {
322
+ await ensureStorageDir(paths);
323
+ const endpoints = await listEndpoints(paths);
324
+ const activeEndpoints = endpoints.filter((endpoint) => !endpoint.disabled);
325
+ if (activeEndpoints.length === 0) {
326
+ console.log("No endpoints found.");
327
+ return;
328
+ }
329
+ const results = await Promise.all(
330
+ activeEndpoints.map(async (endpoint) => {
331
+ const start = Date.now();
332
+ try {
333
+ const dispatcher = endpoint.insecureTls
334
+ ? new Agent({ connect: { rejectUnauthorized: false } })
335
+ : undefined;
336
+ const headers: Record<string, string> = {};
337
+ if (endpoint.apiKey) {
338
+ headers.authorization = `Bearer ${endpoint.apiKey}`;
339
+ }
340
+ const response = await request(new URL("/v1/models", endpoint.baseUrl).toString(), {
341
+ method: "GET",
342
+ headers,
343
+ headersTimeout: 3000,
344
+ bodyTimeout: 3000,
345
+ dispatcher
346
+ });
347
+ response.body.resume();
348
+ const latency = Date.now() - start;
349
+ const status = response.statusCode >= 200 && response.statusCode < 300 ? "up" : "down";
350
+ await updateHealthCheck(paths, endpoint.id, status, status === "up" ? latency : null);
351
+ return { name: endpoint.name, status, statusCode: response.statusCode, latencyMs: latency };
352
+ } catch (error) {
353
+ await updateHealthCheck(paths, endpoint.id, "down", null);
354
+ return { name: endpoint.name, status: "down", error: (error as Error).message };
355
+ }
356
+ })
357
+ );
358
+ const disabledRows = endpoints
359
+ .filter((endpoint) => endpoint.disabled)
360
+ .map((endpoint) => ({
361
+ name: endpoint.name,
362
+ status: "disabled",
363
+ error: "skipped (disabled)",
364
+ }));
365
+ if (disabledRows.length > 0) {
366
+ results.push(...disabledRows);
367
+ }
368
+ console.table(results);
369
+ });
370
+
371
+ program
372
+ .command("acct")
373
+ .description("Aggregate token usage per endpoint from logs")
374
+ .action(async () => {
375
+ await ensureStorageDir(paths);
376
+ const endpoints = await listEndpoints(paths);
377
+ const usage = await getUsageByEndpoint(paths);
378
+ if (usage.length === 0) {
379
+ console.log("No usage records found.");
380
+ return;
381
+ }
382
+ const byId = new Map(usage.map((entry) => [entry.endpointId, entry]));
383
+ const rows = endpoints.map((endpoint) => {
384
+ const entry = byId.get(endpoint.id);
385
+ return {
386
+ id: endpoint.id,
387
+ name: endpoint.name,
388
+ totalTokens: entry?.totalTokens ?? 0,
389
+ requests: entry?.count ?? 0
390
+ };
391
+ });
392
+ console.table(rows);
393
+ });
394
+
395
+ const service = program
396
+ .command("service")
397
+ .alias("srv")
398
+ .description("Manage the Waypoi service process")
399
+ .action(async () => {
400
+ await ensureStorageDir(paths);
401
+ const pid = readPid(pidFile);
402
+ if (pid && isRunning(pidFile)) {
403
+ console.log(`${DISPLAY_NAME} is running (pid ${pid}).`);
404
+ return;
405
+ }
406
+ console.log(`${DISPLAY_NAME} is not running.`);
407
+ });
408
+
409
+ service
410
+ .command("start")
411
+ .description("Start Waypoi in the background (PID file)")
412
+ .action(async () => {
413
+ await startService();
414
+ });
415
+
416
+ service
417
+ .command("stop")
418
+ .description("Stop Waypoi")
419
+ .action(async () => {
420
+ await stopService();
421
+ });
422
+
423
+ service
424
+ .command("restart")
425
+ .description("Restart Waypoi")
426
+ .action(async () => {
427
+ await stopService();
428
+ await startService();
429
+ });
430
+
431
+ service
432
+ .command("status")
433
+ .description("Show service status")
434
+ .action(async () => {
435
+ await ensureStorageDir(paths);
436
+ const pid = readPid(pidFile);
437
+ if (pid && isRunning(pidFile)) {
438
+ console.log(`${DISPLAY_NAME} is running (pid ${pid}).`);
439
+ return;
440
+ }
441
+ console.log(`${DISPLAY_NAME} is not running.`);
442
+ });
443
+
444
+ // ─────────────────────────────────────────────────────────────────────────────
445
+ // Logs Command
446
+ // ─────────────────────────────────────────────────────────────────────────────
447
+
448
+ program
449
+ .command("logs")
450
+ .description("Tail the waypoi log file")
451
+ .option("-f, --follow", "Follow log output (like tail -f)")
452
+ .option("-n, --lines <n>", "Number of lines to show", "50")
453
+ .action(async (options) => {
454
+ await ensureStorageDir(paths);
455
+ const logFile = path.join(paths.baseDir, "waypoi.log");
456
+
457
+ if (!fs.existsSync(logFile)) {
458
+ console.log("No log file found. Start the service first.");
459
+ return;
460
+ }
461
+
462
+ const lines = Number(options.lines) || 50;
463
+
464
+ if (options.follow) {
465
+ // Tail with follow using spawn
466
+ const tail = spawn("tail", ["-n", String(lines), "-f", logFile], {
467
+ stdio: "inherit"
468
+ });
469
+
470
+ process.on("SIGINT", () => {
471
+ tail.kill();
472
+ process.exit(0);
473
+ });
474
+
475
+ await new Promise((resolve) => {
476
+ tail.on("exit", resolve);
477
+ });
478
+ } else {
479
+ // Just show last N lines
480
+ try {
481
+ const content = fs.readFileSync(logFile, "utf8");
482
+ const allLines = content.split("\n");
483
+ const lastLines = allLines.slice(-lines).join("\n");
484
+ console.log(lastLines);
485
+ } catch (error) {
486
+ console.error(`Failed to read log file: ${(error as Error).message}`);
487
+ process.exitCode = 1;
488
+ }
489
+ }
490
+ });
491
+
492
+ // ─────────────────────────────────────────────────────────────────────────────
493
+ // Stats Command
494
+ // ─────────────────────────────────────────────────────────────────────────────
495
+
496
+ program
497
+ .command("stats")
498
+ .description("Show request statistics")
499
+ .option("--window <window>", "Time window (e.g., 24h, 7d)", "7d")
500
+ .option("--json", "Output as JSON")
501
+ .action(async (options) => {
502
+ await ensureStorageDir(paths);
503
+
504
+ // Parse window
505
+ const windowStr = options.window;
506
+ let windowMs: number;
507
+ if (windowStr.endsWith("h")) {
508
+ windowMs = parseInt(windowStr) * 60 * 60 * 1000;
509
+ } else if (windowStr.endsWith("d")) {
510
+ windowMs = parseInt(windowStr) * 24 * 60 * 60 * 1000;
511
+ } else {
512
+ windowMs = parseInt(windowStr) || 7 * 24 * 60 * 60 * 1000;
513
+ }
514
+
515
+ try {
516
+ const stats = await aggregateStats(paths, windowMs);
517
+
518
+ if (options.json) {
519
+ console.log(JSON.stringify(stats, null, 2));
520
+ return;
521
+ }
522
+
523
+ // Pretty print
524
+ console.log("\n📊 Waypoi Statistics");
525
+ console.log(` Window: ${stats.window}\n`);
526
+
527
+ console.log("── Request Summary ──");
528
+ console.log(` Total: ${stats.total}`);
529
+ console.log(` Success: ${stats.success}`);
530
+ console.log(` Errors: ${stats.errors}`);
531
+ console.log(` Rate: ${stats.total > 0 ? ((stats.success / stats.total) * 100).toFixed(1) : 0}% success\n`);
532
+
533
+ console.log("── Latency (ms) ──");
534
+ console.log(` Avg: ${stats.avgLatencyMs?.toFixed(0) ?? "N/A"}`);
535
+ console.log(` P50: ${stats.p50LatencyMs?.toFixed(0) ?? "N/A"}`);
536
+ console.log(` P95: ${stats.p95LatencyMs?.toFixed(0) ?? "N/A"}`);
537
+ console.log(` P99: ${stats.p99LatencyMs?.toFixed(0) ?? "N/A"}\n`);
538
+
539
+ console.log("── Token Usage ──");
540
+ console.log(` Total: ${stats.totalTokens.toLocaleString()}`);
541
+ console.log(` Per Hour: ${stats.tokensPerHour?.toFixed(0) ?? "N/A"}\n`);
542
+
543
+ if (Object.keys(stats.byModel).length > 0) {
544
+ console.log("── By Model ──");
545
+ console.table(
546
+ Object.entries(stats.byModel).map(([model, data]) => ({
547
+ model,
548
+ requests: data.count,
549
+ avgLatencyMs: data.avgLatencyMs.toFixed(0),
550
+ tokens: data.tokens.toLocaleString()
551
+ }))
552
+ );
553
+ }
554
+
555
+ if (Object.keys(stats.byEndpoint).length > 0) {
556
+ console.log("── By Endpoint ──");
557
+ console.table(
558
+ Object.entries(stats.byEndpoint).map(([id, data]) => ({
559
+ id: id.slice(0, 8),
560
+ requests: data.count,
561
+ avgLatencyMs: data.avgLatencyMs.toFixed(0),
562
+ tokens: data.tokens.toLocaleString(),
563
+ errors: data.errors
564
+ }))
565
+ );
566
+ }
567
+ } catch (error) {
568
+ console.error(`Failed to load stats: ${(error as Error).message}`);
569
+ process.exitCode = 1;
570
+ }
571
+ });
572
+
573
+ // ─────────────────────────────────────────────────────────────────────────────
574
+ // MCP Commands
575
+ // ─────────────────────────────────────────────────────────────────────────────
576
+
577
+ async function listMcpServersAction(options: { json?: boolean } = {}): Promise<void> {
578
+ await ensureStorageDir(paths);
579
+ try {
580
+ const servers = await listMcpServers(paths);
581
+
582
+ if (servers.length === 0) {
583
+ console.log("No MCP servers configured.");
584
+ return;
585
+ }
586
+
587
+ if (options.json) {
588
+ printJson(servers);
589
+ return;
590
+ }
591
+
592
+ console.table(
593
+ servers.map((s) => ({
594
+ id: s.id.slice(0, 8),
595
+ name: s.name,
596
+ url: s.url,
597
+ status: s.status,
598
+ enabled: s.enabled ? "✓" : "✗",
599
+ tools: s.toolCount ?? 0
600
+ }))
601
+ );
602
+ } catch (error) {
603
+ console.error(`Failed to list servers: ${(error as Error).message}`);
604
+ process.exitCode = 1;
605
+ }
606
+ }
607
+
608
+ const mcp = program
609
+ .command("mcp")
610
+ .description("Manage MCP servers for agentic workflows")
611
+ .action(async () => {
612
+ await listMcpServersAction();
613
+ });
614
+
615
+ mcp
616
+ .command("add")
617
+ .description("Add a new MCP server")
618
+ .requiredOption("--name <name>", "Server name")
619
+ .requiredOption("--url <url>", "Server URL (streamable HTTP)")
620
+ .option("--disabled", "Add as disabled")
621
+ .action(async (options) => {
622
+ await ensureStorageDir(paths);
623
+ try {
624
+ const server = await addMcpServer(paths, {
625
+ name: options.name,
626
+ url: options.url,
627
+ enabled: !options.disabled
628
+ });
629
+ console.log(`Added MCP server: ${server.name}`);
630
+ console.log(JSON.stringify(server, null, 2));
631
+ } catch (error) {
632
+ console.error(`Failed to add server: ${(error as Error).message}`);
633
+ process.exitCode = 1;
634
+ }
635
+ });
636
+
637
+ mcp
638
+ .command("list")
639
+ .alias("ls")
640
+ .description("List all MCP servers")
641
+ .option("--json", "Output as JSON")
642
+ .action(async (options) => {
643
+ await listMcpServersAction(options);
644
+ });
645
+
646
+ mcp
647
+ .command("rm")
648
+ .alias("remove")
649
+ .description("Remove an MCP server")
650
+ .argument("<idOrName>", "Server ID (prefix) or name")
651
+ .action(async (idOrName) => {
652
+ await ensureStorageDir(paths);
653
+ try {
654
+ const servers = await listMcpServers(paths);
655
+ const server = servers.find(
656
+ (s) => s.id.startsWith(idOrName) || s.name.toLowerCase() === idOrName.toLowerCase()
657
+ );
658
+
659
+ if (!server) {
660
+ console.error("Server not found");
661
+ process.exitCode = 1;
662
+ return;
663
+ }
664
+
665
+ await removeMcpServer(paths, server.id);
666
+ console.log(`Removed MCP server: ${server.name}`);
667
+ } catch (error) {
668
+ console.error(`Failed to remove server: ${(error as Error).message}`);
669
+ process.exitCode = 1;
670
+ }
671
+ });
672
+
673
+ mcp
674
+ .command("enable")
675
+ .description("Enable an MCP server")
676
+ .argument("<idOrName>", "Server ID (prefix) or name")
677
+ .action(async (idOrName) => {
678
+ await ensureStorageDir(paths);
679
+ try {
680
+ const servers = await listMcpServers(paths);
681
+ const server = servers.find(
682
+ (s) => s.id.startsWith(idOrName) || s.name.toLowerCase() === idOrName.toLowerCase()
683
+ );
684
+
685
+ if (!server) {
686
+ console.error("Server not found");
687
+ process.exitCode = 1;
688
+ return;
689
+ }
690
+
691
+ await updateMcpServer(paths, server.id, { enabled: true });
692
+ console.log(`Enabled MCP server: ${server.name}`);
693
+ } catch (error) {
694
+ console.error(`Failed to enable server: ${(error as Error).message}`);
695
+ process.exitCode = 1;
696
+ }
697
+ });
698
+
699
+ mcp
700
+ .command("disable")
701
+ .description("Disable an MCP server")
702
+ .argument("<idOrName>", "Server ID (prefix) or name")
703
+ .action(async (idOrName) => {
704
+ await ensureStorageDir(paths);
705
+ try {
706
+ const servers = await listMcpServers(paths);
707
+ const server = servers.find(
708
+ (s) => s.id.startsWith(idOrName) || s.name.toLowerCase() === idOrName.toLowerCase()
709
+ );
710
+
711
+ if (!server) {
712
+ console.error("Server not found");
713
+ process.exitCode = 1;
714
+ return;
715
+ }
716
+
717
+ await updateMcpServer(paths, server.id, { enabled: false });
718
+ console.log(`Disabled MCP server: ${server.name}`);
719
+ } catch (error) {
720
+ console.error(`Failed to disable server: ${(error as Error).message}`);
721
+ process.exitCode = 1;
722
+ }
723
+ });
724
+
725
+ // ─────────────────────────────────────────────────────────────────────────────
726
+ // Provider Commands
727
+ // ─────────────────────────────────────────────────────────────────────────────
728
+
729
+ async function listProvidersAction(options: { json?: boolean; verbose?: boolean; check?: boolean } = {}): Promise<void> {
730
+ await ensureStorageDir(paths);
731
+ if (options.check !== false) {
732
+ await probeProviderModels(paths);
733
+ }
734
+ const providers = await listProviders(paths);
735
+ if (options.json) {
736
+ printJson(providers);
737
+ return;
738
+ }
739
+ if (providers.length === 0) {
740
+ console.log("No providers imported.");
741
+ return;
742
+ }
743
+ const healthMap = await getProviderModelHealthMap(paths);
744
+ const rows = options.verbose
745
+ ? providers.map((provider) => ({
746
+ protocol: provider.protocolRaw ?? provider.protocol,
747
+ operations:
748
+ listAdapterOperations(provider.protocol)?.operations.join(",") ?? "-",
749
+ streamOps:
750
+ listAdapterOperations(provider.protocol)?.streamOperations.join(",") ?? "-",
751
+ id: provider.id,
752
+ name: provider.name,
753
+ enabled: provider.enabled ? "yes" : "no",
754
+ tls: provider.insecureTls ? "insecure" : "strict",
755
+ autoInsecureDomains: provider.autoInsecureTlsDomains?.length ?? 0,
756
+ routable: provider.supportsRouting ? "yes" : "no",
757
+ models: provider.models.length,
758
+ scored: provider.models.filter((model) => typeof model.benchmark?.livebench === "number")
759
+ .length,
760
+ hasKey: provider.apiKey || provider.models.some((model) => Boolean(model.apiKey)) ? "yes" : "no",
761
+ health: summarizeProviderHealth(provider.models, healthMap),
762
+ }))
763
+ : providers.map((provider) => ({
764
+ id: provider.id,
765
+ protocol: provider.protocolRaw ?? provider.protocol,
766
+ enabled: provider.enabled ? "yes" : "no",
767
+ tls: provider.insecureTls ? "insecure" : "strict",
768
+ autoInsecureDomains: provider.autoInsecureTlsDomains?.length ?? 0,
769
+ models: provider.models.length,
770
+ scored: provider.models.filter((model) => typeof model.benchmark?.livebench === "number")
771
+ .length,
772
+ hasKey: provider.apiKey || provider.models.some((model) => Boolean(model.apiKey)) ? "yes" : "no",
773
+ health: summarizeProviderHealth(provider.models, healthMap),
774
+ }));
775
+ console.table(rows);
776
+ }
777
+
778
+ const provider = program
779
+ .command("providers")
780
+ .alias("provider")
781
+ .alias("prov")
782
+ .description("Manage provider catalog and smart pools")
783
+ .action(async () => {
784
+ await listProvidersAction();
785
+ });
786
+
787
+ provider.addHelpText(
788
+ "after",
789
+ `
790
+ Default: \`waypoi providers\` runs \`providers list\`.
791
+ Examples:
792
+ waypoi providers
793
+ waypoi providers show provider-id
794
+ waypoi providers import --registry ./providers/registry.yaml -f .env
795
+ `
796
+ );
797
+
798
+ provider
799
+ .command("import")
800
+ .description("Import providers from a registry and load credentials")
801
+ .option(
802
+ "--registry <path>",
803
+ "Path to providers registry yaml",
804
+ resolveDefaultRegistryPath()
805
+ )
806
+ .option("-f, --env-file <path>", "Path to .env file", ".env")
807
+ .option("--overwrite-auth", "Overwrite stored provider keys with env values")
808
+ .option("--no-rebuild-pools", "Skip automatic smart pool rebuild")
809
+ .action(async (options) => {
810
+ await ensureStorageDir(paths);
811
+ try {
812
+ const result = await importProviders(paths, {
813
+ registryPath: options.registry,
814
+ envFilePath: options.envFile,
815
+ overwriteAuth: Boolean(options.overwriteAuth),
816
+ });
817
+ let rebuilt = 0;
818
+ if (options.rebuildPools !== false) {
819
+ const pools = await rebuildDefaultPools(paths);
820
+ rebuilt = pools.length;
821
+ }
822
+ console.log(`Imported providers: ${result.importedProviders}`);
823
+ console.log(`Imported models: ${result.importedModels}`);
824
+ if (rebuilt > 0) {
825
+ console.log(`Rebuilt pools: ${rebuilt}`);
826
+ }
827
+ if (result.warnings.length > 0) {
828
+ console.log("Warnings:");
829
+ for (const warning of result.warnings) {
830
+ console.log(` - ${warning}`);
831
+ }
832
+ }
833
+ } catch (error) {
834
+ console.error(`Provider import failed: ${(error as Error).message}`);
835
+ process.exitCode = 1;
836
+ }
837
+ });
838
+
839
+ provider
840
+ .command("ls")
841
+ .alias("list")
842
+ .description("List providers")
843
+ .option("--json", "Output as JSON")
844
+ .option("--verbose", "Show protocol operation details")
845
+ .option("--no-check", "Skip health check for faster listing")
846
+ .action(async (options) => {
847
+ await listProvidersAction(options);
848
+ });
849
+
850
+ provider
851
+ .command("show")
852
+ .description("Show one provider")
853
+ .argument("<providerId>")
854
+ .action(async (providerId) => {
855
+ await ensureStorageDir(paths);
856
+ const providerRecord = await getProviderById(paths, providerId);
857
+ if (!providerRecord) {
858
+ console.error("Provider not found");
859
+ process.exitCode = 1;
860
+ return;
861
+ }
862
+ const adapterOps = listAdapterOperations(providerRecord.protocol);
863
+ console.log(
864
+ JSON.stringify(
865
+ {
866
+ ...providerRecord,
867
+ supportedOperations: adapterOps?.operations ?? [],
868
+ streamSupportedOperations: adapterOps?.streamOperations ?? [],
869
+ },
870
+ null,
871
+ 2
872
+ )
873
+ );
874
+ });
875
+
876
+ provider
877
+ .command("update")
878
+ .description("Update provider TLS policy and allowlist")
879
+ .argument("<providerId>")
880
+ .option("--insecure-tls", "Set provider default TLS mode to insecure")
881
+ .option("--strict-tls", "Set provider default TLS mode to strict")
882
+ .option("--auto-insecure-domain <suffix...>", "Set auto-insecure TLS allowlist domains")
883
+ .option("--clear-auto-insecure-domains", "Clear auto-insecure TLS allowlist")
884
+ .option("--no-rebuild", "Skip automatic smart pool rebuild")
885
+ .action(async (providerId, options) => {
886
+ await ensureStorageDir(paths);
887
+ if (options.insecureTls && options.strictTls) {
888
+ console.error("Choose either --insecure-tls or --strict-tls, not both.");
889
+ process.exitCode = 1;
890
+ return;
891
+ }
892
+ const patch: Partial<ProviderRecord> = {};
893
+ if (options.insecureTls) {
894
+ patch.insecureTls = true;
895
+ }
896
+ if (options.strictTls) {
897
+ patch.insecureTls = false;
898
+ }
899
+ if (options.clearAutoInsecureDomains) {
900
+ patch.autoInsecureTlsDomains = [];
901
+ } else if (options.autoInsecureDomain) {
902
+ patch.autoInsecureTlsDomains = normalizeDomainSuffixes(options.autoInsecureDomain);
903
+ }
904
+
905
+ if (Object.keys(patch).length === 0) {
906
+ console.error("No provider changes requested.");
907
+ process.exitCode = 1;
908
+ return;
909
+ }
910
+
911
+ const updated = await updateProvider(paths, providerId, patch);
912
+ if (!updated) {
913
+ console.error("Provider not found");
914
+ process.exitCode = 1;
915
+ return;
916
+ }
917
+ if (options.rebuild !== false) {
918
+ await rebuildDefaultPools(paths);
919
+ }
920
+ console.log(`Updated provider: ${updated.id}`);
921
+ });
922
+
923
+ provider
924
+ .command("models")
925
+ .description("List models for a provider")
926
+ .argument("<providerId>")
927
+ .option("--free", "Only free models")
928
+ .option("--modality <modality>", "Filter by modality (e.g., text-to-text,image-to-text)")
929
+ .option("--json", "Output as JSON")
930
+ .action(async (providerId, options) => {
931
+ await ensureStorageDir(paths);
932
+ const providerRecord = await getProviderById(paths, providerId);
933
+ if (!providerRecord) {
934
+ console.error("Provider not found");
935
+ process.exitCode = 1;
936
+ return;
937
+ }
938
+ const modality = typeof options.modality === "string" ? options.modality.trim() : undefined;
939
+ const filtered = providerRecord.models.filter((model) => {
940
+ if (options.free && !model.free) {
941
+ return false;
942
+ }
943
+ if (modality && !model.modalities.includes(modality)) {
944
+ return false;
945
+ }
946
+ return true;
947
+ });
948
+ if (options.json) {
949
+ console.log(JSON.stringify(filtered, null, 2));
950
+ return;
951
+ }
952
+ console.table(
953
+ filtered.map((model) => ({
954
+ id: model.modelId,
955
+ upstream: model.upstreamModel,
956
+ baseUrl: model.baseUrl ?? providerRecord.baseUrl,
957
+ enabled: model.enabled === false ? "no" : "yes",
958
+ free: model.free ? "yes" : "no",
959
+ modalities: model.modalities.join(","),
960
+ livebench: model.benchmark?.livebench ?? "-",
961
+ }))
962
+ );
963
+ });
964
+
965
+ const providerModel = provider
966
+ .command("model")
967
+ .description("Manage provider-owned models");
968
+
969
+ providerModel
970
+ .command("ls")
971
+ .description("List models for a provider")
972
+ .argument("<providerId>")
973
+ .option("--json", "Output as JSON")
974
+ .option("--enabled", "Only show enabled models")
975
+ .option("--modality <modality>", "Filter by modality (e.g. text-to-text,image-to-text)")
976
+ .option("--verbose", "Show full model metadata")
977
+ .option("--no-check", "Skip health check for faster listing")
978
+ .action(async (providerId, options) => {
979
+ await ensureStorageDir(paths);
980
+ if (options.check !== false) {
981
+ await probeProviderModels(paths);
982
+ }
983
+ const healthMap = await getProviderModelHealthMap(paths);
984
+ const models = await listProviderModels(paths, providerId);
985
+ if (!models) {
986
+ console.error("Provider not found");
987
+ process.exitCode = 1;
988
+ return;
989
+ }
990
+
991
+ const modality = typeof options.modality === "string" ? options.modality.trim() : undefined;
992
+ const filtered = models.filter((model) => {
993
+ if (options.enabled && model.enabled === false) {
994
+ return false;
995
+ }
996
+ if (modality && !model.modalities.includes(modality)) {
997
+ return false;
998
+ }
999
+ return true;
1000
+ });
1001
+
1002
+ if (options.json) {
1003
+ console.log(JSON.stringify(filtered, null, 2));
1004
+ return;
1005
+ }
1006
+
1007
+ const rows = options.verbose
1008
+ ? filtered.map((model) => ({
1009
+ providerModelId: model.providerModelId,
1010
+ modelId: model.modelId,
1011
+ upstreamModel: model.upstreamModel,
1012
+ enabled: model.enabled === false ? "no" : "yes",
1013
+ tls: model.insecureTls === undefined ? "inherit" : model.insecureTls ? "insecure" : "strict",
1014
+ endpointType: model.endpointType,
1015
+ baseUrl: model.baseUrl ?? "-",
1016
+ aliases: (model.aliases ?? []).join(","),
1017
+ free: model.free ? "yes" : "no",
1018
+ livebench: model.benchmark?.livebench ?? "-",
1019
+ status: healthMap[model.providerModelId]?.status ?? "-",
1020
+ latency: formatLatency(healthMap[model.providerModelId]?.latencyMsEwma),
1021
+ lastStatus: healthMap[model.providerModelId]?.lastStatusCode ?? "-",
1022
+ lastError: healthMap[model.providerModelId]?.lastError ?? "-",
1023
+ }))
1024
+ : filtered.map((model) => ({
1025
+ id: model.modelId,
1026
+ enabled: model.enabled === false ? "no" : "yes",
1027
+ tls: model.insecureTls === undefined ? "inherit" : model.insecureTls ? "insecure" : "strict",
1028
+ type: model.endpointType,
1029
+ aliases: (model.aliases ?? []).length,
1030
+ livebench: model.benchmark?.livebench ?? "-",
1031
+ status: healthMap[model.providerModelId]?.status ?? "-",
1032
+ latency: formatLatency(healthMap[model.providerModelId]?.latencyMsEwma),
1033
+ lastStatus: healthMap[model.providerModelId]?.lastStatusCode ?? "-",
1034
+ lastError: healthMap[model.providerModelId]?.lastError ?? "-",
1035
+ }));
1036
+ console.table(rows);
1037
+ });
1038
+
1039
+ providerModel
1040
+ .command("show")
1041
+ .description("Show one model from a provider")
1042
+ .argument("<providerId>")
1043
+ .argument("<modelRef>")
1044
+ .action(async (providerId, modelRef) => {
1045
+ await ensureStorageDir(paths);
1046
+ const model = await getProviderModel(paths, providerId, modelRef);
1047
+ if (!model) {
1048
+ console.error("Model not found");
1049
+ process.exitCode = 1;
1050
+ return;
1051
+ }
1052
+ console.log(JSON.stringify(model, null, 2));
1053
+ });
1054
+
1055
+ providerModel
1056
+ .command("add")
1057
+ .description("Add a model under a provider")
1058
+ .argument("<providerId>")
1059
+ .requiredOption("--model-id <id>", "Provider model ID suffix")
1060
+ .requiredOption("--upstream <name>", "Upstream model name")
1061
+ .requiredOption("--base-url <url>", "Base URL for this model")
1062
+ .option("--api-key <key>", "API key for this model")
1063
+ .option("--insecure-tls", "Allow self-signed TLS certificates for this model")
1064
+ .option("--endpoint-type <type>", "Endpoint type (llm|diffusion|audio|embedding)", "llm")
1065
+ .option("--capability <spec...>", "Capability spec, e.g. text->text or text+image->text")
1066
+ .option("--alias <alias...>", "Legacy/public aliases")
1067
+ .option("--free", "Mark model as free")
1068
+ .option("--no-free", "Mark model as not free")
1069
+ .option("--disabled", "Add model in disabled state")
1070
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1071
+ .action(async (providerId, options) => {
1072
+ await ensureStorageDir(paths);
1073
+ const providerRecord = await getProviderById(paths, providerId);
1074
+ if (!providerRecord) {
1075
+ console.error("Provider not found");
1076
+ process.exitCode = 1;
1077
+ return;
1078
+ }
1079
+
1080
+ const endpointType = normalizeType(options.endpointType);
1081
+ const capabilities = options.capability
1082
+ ? parseCapabilitySpecs(options.capability)
1083
+ : defaultCapabilitiesForEndpointType(endpointType);
1084
+ const modelId = String(options.modelId).trim();
1085
+ const providerModelId = canonicalProviderModelId(providerId, modelId);
1086
+ const modelRecord: ProviderModelRecord = {
1087
+ providerModelId,
1088
+ providerId,
1089
+ modelId,
1090
+ upstreamModel: String(options.upstream).trim(),
1091
+ baseUrl: String(options.baseUrl).trim(),
1092
+ apiKey: options.apiKey,
1093
+ insecureTls: options.insecureTls ? true : undefined,
1094
+ enabled: options.disabled ? false : true,
1095
+ aliases: normalizeAliasList(options.alias ?? []),
1096
+ free: options.free !== false,
1097
+ modalities: capabilitiesToModalities(capabilities),
1098
+ capabilities,
1099
+ endpointType,
1100
+ };
1101
+ const result = await upsertProviderModel(paths, providerId, modelRecord);
1102
+ if (!result) {
1103
+ console.error("Failed to add model");
1104
+ process.exitCode = 1;
1105
+ return;
1106
+ }
1107
+ if (options.rebuild !== false) {
1108
+ await rebuildDefaultPools(paths);
1109
+ }
1110
+ console.log(`Model ${result.created ? "added" : "updated"}: ${providerModelId}`);
1111
+ });
1112
+
1113
+ providerModel
1114
+ .command("update")
1115
+ .description("Update a provider model")
1116
+ .argument("<providerId>")
1117
+ .argument("<modelRef>")
1118
+ .option("--upstream <name>", "Set upstream model")
1119
+ .option("--base-url <url>", "Set base URL")
1120
+ .option("--clear-base-url", "Clear model-specific base URL override")
1121
+ .option("--api-key <key>", "Set API key")
1122
+ .option("--clear-api-key", "Clear model-specific API key")
1123
+ .option("--insecure-tls", "Enable insecure TLS for this model")
1124
+ .option("--clear-insecure-tls", "Clear model TLS override and inherit provider setting")
1125
+ .option("--endpoint-type <type>", "Endpoint type")
1126
+ .option("--capability <spec...>", "Replace capabilities")
1127
+ .option("--alias <alias...>", "Set aliases")
1128
+ .option("--free", "Set free=true")
1129
+ .option("--not-free", "Set free=false")
1130
+ .option("--enabled", "Set enabled=true")
1131
+ .option("--disabled", "Set enabled=false")
1132
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1133
+ .action(async (providerId, modelRef, options) => {
1134
+ await ensureStorageDir(paths);
1135
+ const patch: Partial<ProviderModelRecord> = {};
1136
+ if (typeof options.upstream === "string") {
1137
+ patch.upstreamModel = options.upstream.trim();
1138
+ }
1139
+ if (typeof options.baseUrl === "string") {
1140
+ patch.baseUrl = options.baseUrl.trim();
1141
+ }
1142
+ if (options.clearBaseUrl) {
1143
+ patch.baseUrl = undefined;
1144
+ }
1145
+ if (typeof options.apiKey === "string") {
1146
+ patch.apiKey = options.apiKey;
1147
+ }
1148
+ if (options.clearApiKey) {
1149
+ patch.apiKey = undefined;
1150
+ }
1151
+ if (options.insecureTls) {
1152
+ patch.insecureTls = true;
1153
+ }
1154
+ if (options.clearInsecureTls) {
1155
+ patch.insecureTls = undefined;
1156
+ }
1157
+ if (typeof options.endpointType === "string") {
1158
+ patch.endpointType = normalizeType(options.endpointType);
1159
+ }
1160
+ if (options.capability) {
1161
+ const capabilities = parseCapabilitySpecs(options.capability);
1162
+ patch.capabilities = capabilities;
1163
+ patch.modalities = capabilitiesToModalities(capabilities);
1164
+ }
1165
+ if (options.alias) {
1166
+ patch.aliases = normalizeAliasList(options.alias);
1167
+ }
1168
+ if (options.free) {
1169
+ patch.free = true;
1170
+ }
1171
+ if (options.notFree) {
1172
+ patch.free = false;
1173
+ }
1174
+ if (options.enabled) {
1175
+ patch.enabled = true;
1176
+ }
1177
+ if (options.disabled) {
1178
+ patch.enabled = false;
1179
+ }
1180
+ if (Object.keys(patch).length === 0) {
1181
+ console.error("No changes requested.");
1182
+ process.exitCode = 1;
1183
+ return;
1184
+ }
1185
+
1186
+ const updated = await updateProviderModel(paths, providerId, modelRef, patch);
1187
+ if (!updated) {
1188
+ console.error("Model not found");
1189
+ process.exitCode = 1;
1190
+ return;
1191
+ }
1192
+ if (options.rebuild !== false) {
1193
+ await rebuildDefaultPools(paths);
1194
+ }
1195
+ console.log(`Updated model: ${updated.providerModelId}`);
1196
+ });
1197
+
1198
+ providerModel
1199
+ .command("rm")
1200
+ .description("Remove a provider model")
1201
+ .argument("<providerId>")
1202
+ .argument("<modelRef>")
1203
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1204
+ .action(async (providerId, modelRef, options) => {
1205
+ await ensureStorageDir(paths);
1206
+ const removed = await deleteProviderModel(paths, providerId, modelRef);
1207
+ if (!removed) {
1208
+ console.error("Model not found");
1209
+ process.exitCode = 1;
1210
+ return;
1211
+ }
1212
+ if (options.rebuild !== false) {
1213
+ await rebuildDefaultPools(paths);
1214
+ }
1215
+ console.log(`Removed model: ${removed.providerModelId}`);
1216
+ });
1217
+
1218
+ providerModel
1219
+ .command("enable")
1220
+ .description("Enable a provider model")
1221
+ .argument("<providerId>")
1222
+ .argument("<modelRef>")
1223
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1224
+ .action(async (providerId, modelRef, options) => {
1225
+ await ensureStorageDir(paths);
1226
+ const model = await setProviderModelEnabled(paths, providerId, modelRef, true);
1227
+ if (!model) {
1228
+ console.error("Model not found");
1229
+ process.exitCode = 1;
1230
+ return;
1231
+ }
1232
+ if (options.rebuild !== false) {
1233
+ await rebuildDefaultPools(paths);
1234
+ }
1235
+ console.log(`Enabled model: ${model.providerModelId}`);
1236
+ });
1237
+
1238
+ providerModel
1239
+ .command("disable")
1240
+ .description("Disable a provider model")
1241
+ .argument("<providerId>")
1242
+ .argument("<modelRef>")
1243
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1244
+ .action(async (providerId, modelRef, options) => {
1245
+ await ensureStorageDir(paths);
1246
+ const model = await setProviderModelEnabled(paths, providerId, modelRef, false);
1247
+ if (!model) {
1248
+ console.error("Model not found");
1249
+ process.exitCode = 1;
1250
+ return;
1251
+ }
1252
+ if (options.rebuild !== false) {
1253
+ await rebuildDefaultPools(paths);
1254
+ }
1255
+ console.log(`Disabled model: ${model.providerModelId}`);
1256
+ });
1257
+
1258
+ providerModel
1259
+ .command("set-key")
1260
+ .description("Set plaintext API key for a provider model")
1261
+ .argument("<providerId>")
1262
+ .argument("<modelRef>")
1263
+ .option("--api-key <key>", "API key value")
1264
+ .option("--env-var <name>", "Read API key from environment variable")
1265
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1266
+ .action(async (providerId, modelRef, options) => {
1267
+ await ensureStorageDir(paths);
1268
+ let apiKey: string | undefined = options.apiKey;
1269
+ if (!apiKey && options.envVar) {
1270
+ apiKey = process.env[String(options.envVar)] ?? undefined;
1271
+ if (!apiKey) {
1272
+ console.error(`Environment variable '${options.envVar}' is not set.`);
1273
+ process.exitCode = 1;
1274
+ return;
1275
+ }
1276
+ }
1277
+ if (!apiKey) {
1278
+ console.error("Provide --api-key or --env-var.");
1279
+ process.exitCode = 1;
1280
+ return;
1281
+ }
1282
+ const model = await setProviderModelApiKey(paths, providerId, modelRef, apiKey);
1283
+ if (!model) {
1284
+ console.error("Model not found");
1285
+ process.exitCode = 1;
1286
+ return;
1287
+ }
1288
+ if (options.rebuild !== false) {
1289
+ await rebuildDefaultPools(paths);
1290
+ }
1291
+ console.log(`Updated key for model: ${model.providerModelId}`);
1292
+ });
1293
+
1294
+ provider
1295
+ .command("enable")
1296
+ .description("Enable a provider")
1297
+ .argument("<providerId>")
1298
+ .action(async (providerId) => {
1299
+ await ensureStorageDir(paths);
1300
+ const updated = await setProviderEnabled(paths, providerId, true);
1301
+ if (!updated) {
1302
+ console.error("Provider not found");
1303
+ process.exitCode = 1;
1304
+ return;
1305
+ }
1306
+ await rebuildDefaultPools(paths);
1307
+ console.log(`Enabled provider: ${updated.id}`);
1308
+ });
1309
+
1310
+ provider
1311
+ .command("disable")
1312
+ .description("Disable a provider")
1313
+ .argument("<providerId>")
1314
+ .action(async (providerId) => {
1315
+ await ensureStorageDir(paths);
1316
+ const updated = await setProviderEnabled(paths, providerId, false);
1317
+ if (!updated) {
1318
+ console.error("Provider not found");
1319
+ process.exitCode = 1;
1320
+ return;
1321
+ }
1322
+ await rebuildDefaultPools(paths);
1323
+ console.log(`Disabled provider: ${updated.id}`);
1324
+ });
1325
+
1326
+ provider
1327
+ .command("migrate-endpoints")
1328
+ .description("Copy matching endpoints into a provider and disable source endpoints")
1329
+ .requiredOption("--provider <id>", "Destination provider ID (e.g. pcai)")
1330
+ .option("--match-domain <domain>", "Hostname suffix to migrate (e.g. ai-application.stjude.org)")
1331
+ .option("--all", "Migrate all endpoints (ignore domain filter)")
1332
+ .option("--protocol <protocol>", "Protocol for destination provider", "openai")
1333
+ .action(async (options) => {
1334
+ await ensureStorageDir(paths);
1335
+
1336
+ const providerId = String(options.provider).trim();
1337
+ const domain = typeof options.matchDomain === "string" ? options.matchDomain.trim().toLowerCase() : "";
1338
+ const includeAll = options.all === true;
1339
+ const protocol = normalizeProviderProtocol(String(options.protocol));
1340
+ const now = new Date().toISOString();
1341
+ const warnings: string[] = [];
1342
+ let skippedEndpoints = 0;
1343
+ let migratedModels = 0;
1344
+ let createdModels = 0;
1345
+ let updatedModels = 0;
1346
+ let disabledEndpoints = 0;
1347
+ const endpointIdsToDisable = new Set<string>();
1348
+
1349
+ if (!providerId) {
1350
+ console.error("--provider is required");
1351
+ process.exitCode = 1;
1352
+ return;
1353
+ }
1354
+ if (!includeAll && !domain) {
1355
+ console.error("Provide --match-domain <domain> or use --all.");
1356
+ process.exitCode = 1;
1357
+ return;
1358
+ }
1359
+
1360
+ const allEndpoints = await listEndpoints(paths);
1361
+ const matchedEndpoints = allEndpoints.filter((endpoint) => {
1362
+ if (includeAll) {
1363
+ return true;
1364
+ }
1365
+ try {
1366
+ const host = new URL(endpoint.baseUrl).hostname.toLowerCase();
1367
+ return hostMatchesDomain(host, domain);
1368
+ } catch (error) {
1369
+ warnings.push(`Skipped endpoint '${endpoint.name}': invalid baseUrl (${(error as Error).message})`);
1370
+ skippedEndpoints += 1;
1371
+ return false;
1372
+ }
1373
+ });
1374
+
1375
+ if (matchedEndpoints.length === 0) {
1376
+ console.log(
1377
+ includeAll
1378
+ ? "No endpoints found to migrate."
1379
+ : `No endpoints matched domain suffix '${domain}'.`
1380
+ );
1381
+ return;
1382
+ }
1383
+
1384
+ const existingProvider = await getProviderById(paths, providerId);
1385
+ const providerSeed: ProviderRecord = {
1386
+ id: providerId,
1387
+ name: existingProvider?.name ?? providerId.toUpperCase(),
1388
+ description: existingProvider?.description ?? `Migrated endpoints for ${includeAll ? "all legacy endpoints" : domain}`,
1389
+ docs: existingProvider?.docs,
1390
+ protocol,
1391
+ protocolRaw: options.protocol,
1392
+ protocolConfig: existingProvider?.protocolConfig,
1393
+ baseUrl: existingProvider?.baseUrl ?? matchedEndpoints[0].baseUrl,
1394
+ enabled: existingProvider?.enabled ?? true,
1395
+ supportsRouting: hasProtocolAdapter(protocol),
1396
+ auth: existingProvider?.auth ?? { type: "bearer" },
1397
+ envVar: existingProvider?.envVar,
1398
+ apiKey: existingProvider?.apiKey,
1399
+ limits: existingProvider?.limits,
1400
+ models: existingProvider?.models ?? [],
1401
+ warnings: existingProvider?.warnings,
1402
+ importedAt: existingProvider?.importedAt ?? now,
1403
+ };
1404
+
1405
+ await upsertProvider(paths, providerSeed);
1406
+
1407
+ for (const endpoint of matchedEndpoints) {
1408
+ if (endpoint.models.length === 0) {
1409
+ warnings.push(`Endpoint '${endpoint.name}' has no models; skipped.`);
1410
+ skippedEndpoints += 1;
1411
+ continue;
1412
+ }
1413
+
1414
+ let migratedFromEndpoint = 0;
1415
+ for (const mapping of endpoint.models) {
1416
+ const capabilities = getModelCapabilitiesForEndpoint(endpoint.type, mapping);
1417
+ const canonicalId = canonicalProviderModelId(providerId, mapping.publicName);
1418
+ const aliases = normalizeAliasList([mapping.publicName, ...(existingProvider?.models ?? [])
1419
+ .filter((m) => m.modelId === mapping.publicName)
1420
+ .flatMap((m) => m.aliases ?? [])]);
1421
+ const modelRecord: ProviderModelRecord = {
1422
+ providerModelId: canonicalId,
1423
+ providerId,
1424
+ modelId: mapping.publicName,
1425
+ upstreamModel: mapping.upstreamModel,
1426
+ baseUrl: endpoint.baseUrl,
1427
+ apiKey: endpoint.apiKey,
1428
+ insecureTls: endpoint.insecureTls,
1429
+ enabled: true,
1430
+ aliases,
1431
+ free: true,
1432
+ modalities: capabilitiesToModalities(capabilities),
1433
+ capabilities,
1434
+ endpointType: endpoint.type,
1435
+ };
1436
+ const result = await upsertProviderModel(paths, providerId, modelRecord);
1437
+ if (!result) {
1438
+ warnings.push(`Failed to write model '${mapping.publicName}' into provider '${providerId}'.`);
1439
+ continue;
1440
+ }
1441
+ migratedModels += 1;
1442
+ migratedFromEndpoint += 1;
1443
+ if (result.created) {
1444
+ createdModels += 1;
1445
+ } else {
1446
+ updatedModels += 1;
1447
+ }
1448
+ }
1449
+ if (migratedFromEndpoint > 0) {
1450
+ endpointIdsToDisable.add(endpoint.id);
1451
+ } else {
1452
+ warnings.push(`Endpoint '${endpoint.name}' had no models migrated; left enabled.`);
1453
+ }
1454
+ }
1455
+
1456
+ for (const endpoint of matchedEndpoints) {
1457
+ if (!endpointIdsToDisable.has(endpoint.id)) {
1458
+ continue;
1459
+ }
1460
+ if (endpoint.disabled) {
1461
+ continue;
1462
+ }
1463
+ const updatedEndpoint = await setEndpointDisabled(paths, endpoint.id, true);
1464
+ if (updatedEndpoint) {
1465
+ disabledEndpoints += 1;
1466
+ }
1467
+ }
1468
+
1469
+ const pools = await rebuildDefaultPools(paths);
1470
+ const reportPath = writeMigrationReport(paths.baseDir, {
1471
+ timestamp: now,
1472
+ providerId,
1473
+ includeAll,
1474
+ domain: domain || undefined,
1475
+ matchedEndpoints: matchedEndpoints.length,
1476
+ migratedModels,
1477
+ createdModels,
1478
+ updatedModels,
1479
+ disabledEndpoints,
1480
+ skippedEndpoints,
1481
+ warnings,
1482
+ });
1483
+
1484
+ console.log(`Migrated provider: ${providerId}`);
1485
+ console.log(`Matched endpoints: ${matchedEndpoints.length}`);
1486
+ console.log(`Migrated models: ${migratedModels} (created ${createdModels}, updated ${updatedModels})`);
1487
+ console.log(`Disabled source endpoints: ${disabledEndpoints}`);
1488
+ console.log(`Rebuilt pools: ${pools.length}`);
1489
+ console.log(`Migration report: ${reportPath}`);
1490
+
1491
+ if (warnings.length > 0) {
1492
+ console.log("Warnings:");
1493
+ for (const warning of warnings) {
1494
+ console.log(` - ${warning}`);
1495
+ }
1496
+ }
1497
+ if (skippedEndpoints > 0) {
1498
+ console.log(`Skipped endpoints: ${skippedEndpoints}`);
1499
+ }
1500
+ });
1501
+
1502
+ provider
1503
+ .command("pools")
1504
+ .description("List smart pools")
1505
+ .option("--json", "Output as JSON")
1506
+ .action(async (options) => {
1507
+ await ensureStorageDir(paths);
1508
+ const pools = await listPools(paths);
1509
+ if (options.json) {
1510
+ console.log(JSON.stringify(pools, null, 2));
1511
+ return;
1512
+ }
1513
+ if (pools.length === 0) {
1514
+ console.log("No pools found.");
1515
+ return;
1516
+ }
1517
+ console.table(
1518
+ pools.map((pool) => ({
1519
+ id: pool.id,
1520
+ aliases: pool.aliases.join(","),
1521
+ candidates: pool.candidates.length,
1522
+ strategy: pool.strategy,
1523
+ }))
1524
+ );
1525
+ });
1526
+
1527
+ type ResolvedModelTarget = {
1528
+ providerId: string;
1529
+ modelId: string;
1530
+ model: ProviderModelRecord;
1531
+ };
1532
+
1533
+ async function resolveModelTarget(modelRef: string): Promise<ResolvedModelTarget | null> {
1534
+ const parsed = parseModelRef(modelRef);
1535
+ if (parsed.providerId) {
1536
+ const model = await getProviderModel(paths, parsed.providerId, parsed.modelId);
1537
+ if (!model) {
1538
+ printErrorWithSuggestion(
1539
+ `Model not found: ${parsed.providerId}/${parsed.modelId}`,
1540
+ [
1541
+ `Try: waypoi models ${parsed.providerId}`,
1542
+ "List providers: waypoi providers",
1543
+ ]
1544
+ );
1545
+ process.exitCode = 1;
1546
+ return null;
1547
+ }
1548
+ return {
1549
+ providerId: parsed.providerId,
1550
+ modelId: parsed.modelId,
1551
+ model,
1552
+ };
1553
+ }
1554
+
1555
+ const providers = await listProviders(paths);
1556
+ const matches: ResolvedModelTarget[] = [];
1557
+ for (const providerEntry of providers) {
1558
+ for (const model of providerEntry.models) {
1559
+ if (
1560
+ model.modelId === parsed.modelId ||
1561
+ model.providerModelId === parsed.modelId ||
1562
+ (model.aliases ?? []).includes(parsed.modelId)
1563
+ ) {
1564
+ matches.push({
1565
+ providerId: providerEntry.id,
1566
+ modelId: model.modelId,
1567
+ model,
1568
+ });
1569
+ }
1570
+ }
1571
+ }
1572
+
1573
+ if (matches.length === 0) {
1574
+ printErrorWithSuggestion(
1575
+ `Unknown model '${parsed.modelId}'`,
1576
+ [
1577
+ "Try: waypoi models",
1578
+ "List providers: waypoi providers",
1579
+ ]
1580
+ );
1581
+ process.exitCode = 1;
1582
+ return null;
1583
+ }
1584
+
1585
+ if (matches.length > 1) {
1586
+ const candidates = matches
1587
+ .map((entry) => ` - waypoi models show ${entry.providerId}/${entry.modelId}`)
1588
+ .slice(0, 10);
1589
+ printErrorWithSuggestion(
1590
+ `Model '${parsed.modelId}' is ambiguous across providers.`,
1591
+ [
1592
+ "Use a provider-qualified model reference:",
1593
+ ...candidates,
1594
+ ]
1595
+ );
1596
+ process.exitCode = 1;
1597
+ return null;
1598
+ }
1599
+
1600
+ return matches[0];
1601
+ }
1602
+
1603
+ async function listModelsAction(
1604
+ providerId: string | undefined,
1605
+ options: {
1606
+ json?: boolean;
1607
+ enabled?: boolean;
1608
+ modality?: string;
1609
+ verbose?: boolean;
1610
+ check?: boolean;
1611
+ }
1612
+ ): Promise<void> {
1613
+ await ensureStorageDir(paths);
1614
+ if (options.check !== false) {
1615
+ await probeProviderModels(paths);
1616
+ }
1617
+ const healthMap = await getProviderModelHealthMap(paths);
1618
+ const modality = typeof options.modality === "string" ? options.modality.trim() : undefined;
1619
+
1620
+ if (providerId) {
1621
+ const models = await listProviderModels(paths, providerId);
1622
+ if (!models) {
1623
+ printErrorWithSuggestion(
1624
+ `Provider not found: ${providerId}`,
1625
+ ["List providers: waypoi providers"]
1626
+ );
1627
+ process.exitCode = 1;
1628
+ return;
1629
+ }
1630
+ const filtered = models.filter((model) => {
1631
+ if (options.enabled && model.enabled === false) {
1632
+ return false;
1633
+ }
1634
+ if (modality && !model.modalities.includes(modality)) {
1635
+ return false;
1636
+ }
1637
+ return true;
1638
+ });
1639
+ if (options.json) {
1640
+ printJson(filtered);
1641
+ return;
1642
+ }
1643
+ const rows = options.verbose
1644
+ ? filtered.map((model) => ({
1645
+ provider: providerId,
1646
+ providerModelId: model.providerModelId,
1647
+ modelId: model.modelId,
1648
+ upstreamModel: model.upstreamModel,
1649
+ enabled: model.enabled === false ? "no" : "yes",
1650
+ tls: model.insecureTls === undefined ? "inherit" : model.insecureTls ? "insecure" : "strict",
1651
+ endpointType: model.endpointType,
1652
+ baseUrl: model.baseUrl ?? "-",
1653
+ aliases: (model.aliases ?? []).join(","),
1654
+ free: model.free ? "yes" : "no",
1655
+ livebench: model.benchmark?.livebench ?? "-",
1656
+ status: healthMap[model.providerModelId]?.status ?? "-",
1657
+ latency: formatLatency(healthMap[model.providerModelId]?.latencyMsEwma),
1658
+ lastStatus: healthMap[model.providerModelId]?.lastStatusCode ?? "-",
1659
+ lastError: healthMap[model.providerModelId]?.lastError ?? "-",
1660
+ }))
1661
+ : filtered.map((model) => ({
1662
+ provider: providerId,
1663
+ id: model.modelId,
1664
+ enabled: model.enabled === false ? "no" : "yes",
1665
+ tls: model.insecureTls === undefined ? "inherit" : model.insecureTls ? "insecure" : "strict",
1666
+ type: model.endpointType,
1667
+ aliases: (model.aliases ?? []).length,
1668
+ livebench: model.benchmark?.livebench ?? "-",
1669
+ status: healthMap[model.providerModelId]?.status ?? "-",
1670
+ latency: formatLatency(healthMap[model.providerModelId]?.latencyMsEwma),
1671
+ }));
1672
+ console.table(rows);
1673
+ return;
1674
+ }
1675
+
1676
+ const providers = await listProviders(paths);
1677
+ const flattened = providers.flatMap((providerEntry) =>
1678
+ providerEntry.models.map((model) => ({ providerId: providerEntry.id, model }))
1679
+ );
1680
+ const filtered = flattened.filter(({ model }) => {
1681
+ if (options.enabled && model.enabled === false) {
1682
+ return false;
1683
+ }
1684
+ if (modality && !model.modalities.includes(modality)) {
1685
+ return false;
1686
+ }
1687
+ return true;
1688
+ });
1689
+ if (options.json) {
1690
+ printJson(
1691
+ filtered.map(({ providerId, model }) => ({
1692
+ ...model,
1693
+ providerId,
1694
+ }))
1695
+ );
1696
+ return;
1697
+ }
1698
+ const rows = filtered.map(({ providerId, model }) => ({
1699
+ provider: providerId,
1700
+ model: model.modelId,
1701
+ enabled: model.enabled === false ? "no" : "yes",
1702
+ type: model.endpointType,
1703
+ aliases: (model.aliases ?? []).length,
1704
+ livebench: model.benchmark?.livebench ?? "-",
1705
+ status: healthMap[model.providerModelId]?.status ?? "-",
1706
+ latency: formatLatency(healthMap[model.providerModelId]?.latencyMsEwma),
1707
+ }));
1708
+ console.table(rows);
1709
+ }
1710
+
1711
+ const models = program
1712
+ .command("models")
1713
+ .alias("model")
1714
+ .description("List and manage provider-owned models")
1715
+ .argument("[providerId]")
1716
+ .option("--json", "Output as JSON")
1717
+ .option("--enabled", "Only show enabled models")
1718
+ .option("--modality <modality>", "Filter by modality (e.g. text-to-text,image-to-text)")
1719
+ .option("--verbose", "Show full model metadata")
1720
+ .option("--no-check", "Skip health check for faster listing")
1721
+ .action(async (providerId, options) => {
1722
+ await listModelsAction(providerId, options);
1723
+ });
1724
+
1725
+ models.addHelpText(
1726
+ "after",
1727
+ `
1728
+ Default: \`waypoi models [providerId]\` runs \`models list [providerId]\`.
1729
+ Examples:
1730
+ waypoi models
1731
+ waypoi models provider-id
1732
+ waypoi models show provider-id/model-id
1733
+ `
1734
+ );
1735
+
1736
+ models
1737
+ .command("list")
1738
+ .alias("ls")
1739
+ .description("List models, optionally filtered by provider")
1740
+ .argument("[providerId]")
1741
+ .option("--json", "Output as JSON")
1742
+ .option("--enabled", "Only show enabled models")
1743
+ .option("--modality <modality>", "Filter by modality (e.g. text-to-text,image-to-text)")
1744
+ .option("--verbose", "Show full model metadata")
1745
+ .option("--no-check", "Skip health check for faster listing")
1746
+ .action(async (providerId, options) => {
1747
+ await listModelsAction(providerId, options);
1748
+ });
1749
+
1750
+ models
1751
+ .command("show")
1752
+ .description("Show one model (provider/model preferred)")
1753
+ .argument("<modelRef>")
1754
+ .action(async (modelRef) => {
1755
+ await ensureStorageDir(paths);
1756
+ const resolved = await resolveModelTarget(modelRef);
1757
+ if (!resolved) {
1758
+ return;
1759
+ }
1760
+ printJson({
1761
+ ...resolved.model,
1762
+ providerId: resolved.providerId,
1763
+ modelId: resolved.modelId,
1764
+ });
1765
+ });
1766
+
1767
+ models
1768
+ .command("enable")
1769
+ .description("Enable a provider model")
1770
+ .argument("<modelRef>")
1771
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1772
+ .action(async (modelRef, options) => {
1773
+ await ensureStorageDir(paths);
1774
+ const resolved = await resolveModelTarget(modelRef);
1775
+ if (!resolved) {
1776
+ return;
1777
+ }
1778
+ const model = await setProviderModelEnabled(paths, resolved.providerId, resolved.modelId, true);
1779
+ if (!model) {
1780
+ printErrorWithSuggestion(`Model not found: ${modelRef}`, ["Try: waypoi models"]);
1781
+ process.exitCode = 1;
1782
+ return;
1783
+ }
1784
+ if (options.rebuild !== false) {
1785
+ await rebuildDefaultPools(paths);
1786
+ }
1787
+ console.log(`Enabled model: ${model.providerModelId}`);
1788
+ });
1789
+
1790
+ models
1791
+ .command("disable")
1792
+ .description("Disable a provider model")
1793
+ .argument("<modelRef>")
1794
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1795
+ .action(async (modelRef, options) => {
1796
+ await ensureStorageDir(paths);
1797
+ const resolved = await resolveModelTarget(modelRef);
1798
+ if (!resolved) {
1799
+ return;
1800
+ }
1801
+ const model = await setProviderModelEnabled(paths, resolved.providerId, resolved.modelId, false);
1802
+ if (!model) {
1803
+ printErrorWithSuggestion(`Model not found: ${modelRef}`, ["Try: waypoi models"]);
1804
+ process.exitCode = 1;
1805
+ return;
1806
+ }
1807
+ if (options.rebuild !== false) {
1808
+ await rebuildDefaultPools(paths);
1809
+ }
1810
+ console.log(`Disabled model: ${model.providerModelId}`);
1811
+ });
1812
+
1813
+ models
1814
+ .command("set-key")
1815
+ .description("Set plaintext API key for a provider model")
1816
+ .argument("<modelRef>")
1817
+ .option("--api-key <key>", "API key value")
1818
+ .option("--env-var <name>", "Read API key from environment variable")
1819
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1820
+ .action(async (modelRef, options) => {
1821
+ await ensureStorageDir(paths);
1822
+ const resolved = await resolveModelTarget(modelRef);
1823
+ if (!resolved) {
1824
+ return;
1825
+ }
1826
+ let apiKey: string | undefined = options.apiKey;
1827
+ if (!apiKey && options.envVar) {
1828
+ apiKey = process.env[String(options.envVar)] ?? undefined;
1829
+ if (!apiKey) {
1830
+ printErrorWithSuggestion(`Environment variable '${options.envVar}' is not set.`, [
1831
+ "Provide --api-key <key> or set the environment variable.",
1832
+ ]);
1833
+ process.exitCode = 1;
1834
+ return;
1835
+ }
1836
+ }
1837
+ if (!apiKey) {
1838
+ printErrorWithSuggestion("Provide --api-key or --env-var.", [
1839
+ "Try: waypoi models set-key provider/model --env-var API_KEY",
1840
+ ]);
1841
+ process.exitCode = 1;
1842
+ return;
1843
+ }
1844
+ const model = await setProviderModelApiKey(paths, resolved.providerId, resolved.modelId, apiKey);
1845
+ if (!model) {
1846
+ printErrorWithSuggestion(`Model not found: ${modelRef}`, ["Try: waypoi models"]);
1847
+ process.exitCode = 1;
1848
+ return;
1849
+ }
1850
+ if (options.rebuild !== false) {
1851
+ await rebuildDefaultPools(paths);
1852
+ }
1853
+ console.log(`Updated key for model: ${model.providerModelId}`);
1854
+ });
1855
+
1856
+ models
1857
+ .command("add")
1858
+ .description("Add a model under a provider")
1859
+ .argument("<providerId>")
1860
+ .requiredOption("--model-id <id>", "Provider model ID suffix")
1861
+ .requiredOption("--upstream <name>", "Upstream model name")
1862
+ .requiredOption("--base-url <url>", "Base URL for this model")
1863
+ .option("--api-key <key>", "API key for this model")
1864
+ .option("--insecure-tls", "Allow self-signed TLS certificates for this model")
1865
+ .option("--endpoint-type <type>", "Endpoint type (llm|diffusion|audio|embedding)", "llm")
1866
+ .option("--capability <spec...>", "Capability spec, e.g. text->text or text+image->text")
1867
+ .option("--alias <alias...>", "Legacy/public aliases")
1868
+ .option("--free", "Mark model as free")
1869
+ .option("--no-free", "Mark model as not free")
1870
+ .option("--disabled", "Add model in disabled state")
1871
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1872
+ .action(async (providerId, options) => {
1873
+ await ensureStorageDir(paths);
1874
+ const providerRecord = await getProviderById(paths, providerId);
1875
+ if (!providerRecord) {
1876
+ printErrorWithSuggestion(`Provider not found: ${providerId}`, ["List providers: waypoi providers"]);
1877
+ process.exitCode = 1;
1878
+ return;
1879
+ }
1880
+ const endpointType = normalizeType(options.endpointType);
1881
+ const capabilities = options.capability
1882
+ ? parseCapabilitySpecs(options.capability)
1883
+ : defaultCapabilitiesForEndpointType(endpointType);
1884
+ const modelId = String(options.modelId).trim();
1885
+ const providerModelId = canonicalProviderModelId(providerId, modelId);
1886
+ const modelRecord: ProviderModelRecord = {
1887
+ providerModelId,
1888
+ providerId,
1889
+ modelId,
1890
+ upstreamModel: String(options.upstream).trim(),
1891
+ baseUrl: String(options.baseUrl).trim(),
1892
+ apiKey: options.apiKey,
1893
+ insecureTls: options.insecureTls ? true : undefined,
1894
+ enabled: options.disabled ? false : true,
1895
+ aliases: normalizeAliasList(options.alias ?? []),
1896
+ free: options.free !== false,
1897
+ modalities: capabilitiesToModalities(capabilities),
1898
+ capabilities,
1899
+ endpointType,
1900
+ };
1901
+ const result = await upsertProviderModel(paths, providerId, modelRecord);
1902
+ if (!result) {
1903
+ console.error("Failed to add model");
1904
+ process.exitCode = 1;
1905
+ return;
1906
+ }
1907
+ if (options.rebuild !== false) {
1908
+ await rebuildDefaultPools(paths);
1909
+ }
1910
+ console.log(`Model ${result.created ? "added" : "updated"}: ${providerModelId}`);
1911
+ });
1912
+
1913
+ models
1914
+ .command("update")
1915
+ .description("Update a provider model")
1916
+ .argument("<providerId>")
1917
+ .argument("<modelRef>")
1918
+ .option("--upstream <name>", "Set upstream model")
1919
+ .option("--base-url <url>", "Set base URL")
1920
+ .option("--clear-base-url", "Clear model-specific base URL override")
1921
+ .option("--api-key <key>", "Set API key")
1922
+ .option("--clear-api-key", "Clear model-specific API key")
1923
+ .option("--insecure-tls", "Enable insecure TLS for this model")
1924
+ .option("--clear-insecure-tls", "Clear model TLS override and inherit provider setting")
1925
+ .option("--endpoint-type <type>", "Endpoint type")
1926
+ .option("--capability <spec...>", "Replace capabilities")
1927
+ .option("--alias <alias...>", "Set aliases")
1928
+ .option("--free", "Set free=true")
1929
+ .option("--not-free", "Set free=false")
1930
+ .option("--enabled", "Set enabled=true")
1931
+ .option("--disabled", "Set enabled=false")
1932
+ .option("--no-rebuild", "Skip automatic pool rebuild")
1933
+ .action(async (providerId, modelRef, options) => {
1934
+ await ensureStorageDir(paths);
1935
+ const patch: Partial<ProviderModelRecord> = {};
1936
+ if (typeof options.upstream === "string") {
1937
+ patch.upstreamModel = options.upstream.trim();
1938
+ }
1939
+ if (typeof options.baseUrl === "string") {
1940
+ patch.baseUrl = options.baseUrl.trim();
1941
+ }
1942
+ if (options.clearBaseUrl) {
1943
+ patch.baseUrl = undefined;
1944
+ }
1945
+ if (typeof options.apiKey === "string") {
1946
+ patch.apiKey = options.apiKey;
1947
+ }
1948
+ if (options.clearApiKey) {
1949
+ patch.apiKey = undefined;
1950
+ }
1951
+ if (options.insecureTls) {
1952
+ patch.insecureTls = true;
1953
+ }
1954
+ if (options.clearInsecureTls) {
1955
+ patch.insecureTls = undefined;
1956
+ }
1957
+ if (typeof options.endpointType === "string") {
1958
+ patch.endpointType = normalizeType(options.endpointType);
1959
+ }
1960
+ if (options.capability) {
1961
+ const capabilities = parseCapabilitySpecs(options.capability);
1962
+ patch.capabilities = capabilities;
1963
+ patch.modalities = capabilitiesToModalities(capabilities);
1964
+ }
1965
+ if (options.alias) {
1966
+ patch.aliases = normalizeAliasList(options.alias);
1967
+ }
1968
+ if (options.free) {
1969
+ patch.free = true;
1970
+ }
1971
+ if (options.notFree) {
1972
+ patch.free = false;
1973
+ }
1974
+ if (options.enabled) {
1975
+ patch.enabled = true;
1976
+ }
1977
+ if (options.disabled) {
1978
+ patch.enabled = false;
1979
+ }
1980
+ if (Object.keys(patch).length === 0) {
1981
+ printErrorWithSuggestion("No changes requested.", [
1982
+ "Try: waypoi models update <providerId> <modelRef> --upstream <name>",
1983
+ ]);
1984
+ process.exitCode = 1;
1985
+ return;
1986
+ }
1987
+ const updated = await updateProviderModel(paths, providerId, modelRef, patch);
1988
+ if (!updated) {
1989
+ printErrorWithSuggestion(`Model not found: ${providerId}/${modelRef}`, [
1990
+ `Try: waypoi models ${providerId}`,
1991
+ ]);
1992
+ process.exitCode = 1;
1993
+ return;
1994
+ }
1995
+ if (options.rebuild !== false) {
1996
+ await rebuildDefaultPools(paths);
1997
+ }
1998
+ console.log(`Updated model: ${updated.providerModelId}`);
1999
+ });
2000
+
2001
+ models
2002
+ .command("rm")
2003
+ .description("Remove a provider model")
2004
+ .argument("<providerId>")
2005
+ .argument("<modelRef>")
2006
+ .option("--no-rebuild", "Skip automatic pool rebuild")
2007
+ .action(async (providerId, modelRef, options) => {
2008
+ await ensureStorageDir(paths);
2009
+ const removed = await deleteProviderModel(paths, providerId, modelRef);
2010
+ if (!removed) {
2011
+ printErrorWithSuggestion(`Model not found: ${providerId}/${modelRef}`, [
2012
+ `Try: waypoi models ${providerId}`,
2013
+ ]);
2014
+ process.exitCode = 1;
2015
+ return;
2016
+ }
2017
+ if (options.rebuild !== false) {
2018
+ await rebuildDefaultPools(paths);
2019
+ }
2020
+ console.log(`Removed model: ${removed.providerModelId}`);
2021
+ });
2022
+
2023
+ // ─────────────────────────────────────────────────────────────────────────────
2024
+ // Benchmark Command
2025
+ // ─────────────────────────────────────────────────────────────────────────────
2026
+
2027
+ program
2028
+ .command("bench")
2029
+ .alias("benchmark")
2030
+ .description("Run showcase benchmark examples or internal diagnostic suites")
2031
+ .option("--suite <name>", "Built-in suite to run (default: showcase)")
2032
+ .option("--example <id>", "Run one built-in example from the selected suite")
2033
+ .option("--list-examples", "List showcase examples and exit")
2034
+ .option("--scenario <path>", "Scenario file (.json, .jsonl, .yaml)")
2035
+ .option("--model <name>", "Override model for all scenarios")
2036
+ .option("--out <path>", "Output file path or directory for benchmark artifact")
2037
+ .option("--config <path>", "Benchmark config file (YAML or JSON)")
2038
+ .option("--profile <name>", "Benchmark profile (local|ci)")
2039
+ .option("--mode <name>", "Execution mode (showcase|diagnostic)")
2040
+ .option("--baseline <path>", "Baseline benchmark JSON for regression comparison")
2041
+ .option("--update-cap-cache", "Persist capability findings to capability cache")
2042
+ .option("--cap-ttl-days <n>", "Capability cache TTL days for freshness/output", parseInt)
2043
+ .option("--temperature <n>", "Run-level temperature override", parseFloat)
2044
+ .option("--top-p <n>", "Run-level top_p override", parseFloat)
2045
+ .option("--max-tokens <n>", "Run-level max_tokens override", parseInt)
2046
+ .option("--presence-penalty <n>", "Run-level presence penalty override", parseFloat)
2047
+ .option("--frequency-penalty <n>", "Run-level frequency penalty override", parseFloat)
2048
+ .option("--seed <n>", "Run-level seed override", parseInt)
2049
+ .option("--stop <value>", "Run-level stop sequence override (comma-separated for multiple)")
2050
+ .action(async (options) => {
2051
+ await ensureStorageDir(paths);
2052
+ try {
2053
+ if (options.listExamples) {
2054
+ const suiteName = options.suite ?? "showcase";
2055
+ const examples = listBenchmarkExamples(suiteName);
2056
+ console.log(`\nExamples in suite '${suiteName}':\n`);
2057
+ console.table(
2058
+ examples.map((example) => ({
2059
+ id: example.id,
2060
+ mode: example.mode,
2061
+ title: example.title,
2062
+ source: example.exampleSource,
2063
+ tools: example.requiresAvailableTools ? "required" : "optional",
2064
+ }))
2065
+ );
2066
+ return;
2067
+ }
2068
+
2069
+ const { report, artifactPath, textArtifactPath } = await runBenchmark(paths, {
2070
+ temperature: options.temperature,
2071
+ top_p: options.topP,
2072
+ max_tokens: options.maxTokens,
2073
+ presence_penalty: options.presencePenalty,
2074
+ frequency_penalty: options.frequencyPenalty,
2075
+ seed: options.seed,
2076
+ stop: typeof options.stop === "string"
2077
+ ? options.stop.split(",").map((item: string) => item.trim()).filter(Boolean)
2078
+ : undefined,
2079
+ suite: options.suite,
2080
+ exampleId: options.example,
2081
+ scenarioPath: options.scenario,
2082
+ modelOverride: options.model,
2083
+ outPath: options.out,
2084
+ configPath: options.config,
2085
+ profile: options.profile,
2086
+ baselinePath: options.baseline,
2087
+ executionMode: options.mode,
2088
+ updateCapCache: options.updateCapCache,
2089
+ capTtlDays: options.capTtlDays,
2090
+ });
2091
+
2092
+ console.log("\n🏁 Benchmark complete");
2093
+ console.log(` Profile: ${report.profile}`);
2094
+ console.log(` Mode: ${report.executionMode}`);
2095
+ if (report.suite) {
2096
+ console.log(` Suite: ${report.suite}`);
2097
+ }
2098
+ if (report.exampleId) {
2099
+ console.log(` Example: ${report.exampleId}`);
2100
+ }
2101
+ if (report.capabilityMatrix) {
2102
+ console.log(` Cap TTL: ${report.capabilityMatrix.ttlDays}d`);
2103
+ }
2104
+ console.log(` Scenarios: ${report.total}`);
2105
+ console.log(` Executed: ${report.executed}`);
2106
+ console.log(` Skipped: ${report.skipped}`);
2107
+ console.log(` Success: ${report.succeeded}`);
2108
+ console.log(` Failed: ${report.failed}`);
2109
+ console.log(` SuccessRate: ${(report.successRate * 100).toFixed(1)}%`);
2110
+ console.log(` AvgLatency: ${report.avgLatencyMs}ms`);
2111
+ console.log(` P95Latency: ${report.p95LatencyMs}ms`);
2112
+ console.log(` Tokens: ${report.totalTokens}`);
2113
+ console.log(` ToolCalls: ${report.totalToolCalls}`);
2114
+ console.log(` Throughput: ${report.avgThroughputTokensPerSec.toFixed(2)} t/s`);
2115
+ console.log(` Artifact: ${artifactPath}\n`);
2116
+ console.log(` Summary: ${textArtifactPath}\n`);
2117
+
2118
+ if (report.executionMode === "showcase" && report.scenarioDetails.length > 0) {
2119
+ console.log("Showcase details:");
2120
+ for (const detail of report.scenarioDetails) {
2121
+ console.log(`- ${detail.example?.title ?? detail.id}`);
2122
+ console.log(` Goal: ${detail.example?.userVisibleGoal ?? "n/a"}`);
2123
+ console.log(` Model: ${detail.model}`);
2124
+ console.log(` Verdict: ${detail.verdict}`);
2125
+ if (detail.usedToolNames.length > 0) {
2126
+ console.log(` Tools: ${detail.usedToolNames.join(", ")}`);
2127
+ }
2128
+ if (detail.finalResponsePreview) {
2129
+ console.log(` Final: ${detail.finalResponsePreview}`);
2130
+ }
2131
+ if (detail.exchanges.length > 0) {
2132
+ const finalExchange = detail.exchanges[detail.exchanges.length - 1];
2133
+ console.log(` Request: ${finalExchange.requestPath}`);
2134
+ console.log(` Response: ${finalExchange.responsePreview}`);
2135
+ }
2136
+ }
2137
+ console.log();
2138
+ }
2139
+
2140
+ if (report.warnings.length > 0) {
2141
+ console.log("Warnings:");
2142
+ for (const warning of report.warnings) {
2143
+ console.log(` - ${warning}`);
2144
+ }
2145
+ console.log();
2146
+ }
2147
+
2148
+ const skipped = report.results.filter((item) => item.status === "skipped");
2149
+ if (skipped.length > 0) {
2150
+ console.log("Skipped scenarios:");
2151
+ console.table(
2152
+ skipped.map((item) => ({
2153
+ id: item.id,
2154
+ mode: item.mode,
2155
+ reason: item.skippedReason ?? "no compatible model",
2156
+ }))
2157
+ );
2158
+ }
2159
+
2160
+ if (report.gateResults.soft.messages.length > 0) {
2161
+ console.log("Soft gate warnings:");
2162
+ for (const warning of report.gateResults.soft.messages) {
2163
+ console.log(` - ${warning}`);
2164
+ }
2165
+ console.log();
2166
+ }
2167
+
2168
+ if (!report.gateResults.hard.passed) {
2169
+ console.log("Hard gate failures:");
2170
+ for (const failure of report.gateResults.hard.messages) {
2171
+ console.log(` - ${failure}`);
2172
+ }
2173
+ console.log();
2174
+
2175
+ const failed = report.results.filter((item) => !item.success);
2176
+ if (failed.length > 0) {
2177
+ console.log("Failed scenarios:");
2178
+ console.table(
2179
+ failed.map((item) => ({
2180
+ id: item.id,
2181
+ mode: item.mode,
2182
+ model: item.model,
2183
+ passRate: `${(item.passRate * 100).toFixed(1)}%`,
2184
+ error: item.errorReasons[0] ?? "failed",
2185
+ }))
2186
+ );
2187
+ }
2188
+
2189
+ process.exitCode = 1;
2190
+ } else {
2191
+ const failed = report.results.filter((item) => !item.success);
2192
+ if (failed.length > 0) {
2193
+ console.log("Scenarios below pass-rate threshold:");
2194
+ console.table(
2195
+ failed.map((item) => ({
2196
+ id: item.id,
2197
+ mode: item.mode,
2198
+ model: item.model,
2199
+ passRate: `${(item.passRate * 100).toFixed(1)}%`,
2200
+ error: item.errorReasons[0] ?? "failed",
2201
+ }))
2202
+ );
2203
+ }
2204
+ }
2205
+
2206
+ if (report.gateResults.soft.messages.length > 0 && report.gateResults.hard.passed) {
2207
+ console.log("Benchmark finished with soft warnings (exit code 0).");
2208
+ }
2209
+
2210
+ if (report.capabilityMatrix && report.capabilityMatrix.models.length > 0) {
2211
+ console.log("\nCapability Matrix:");
2212
+ const rows = report.capabilityMatrix.models.map((model) => ({
2213
+ model: model.model,
2214
+ freshness: model.freshness,
2215
+ verified: model.lastVerifiedAt,
2216
+ chat: model.findings.chat_basic.status,
2217
+ tools: model.findings.chat_tool_calls.status,
2218
+ embed: model.findings.embeddings.status,
2219
+ image: model.findings.images_generation.status,
2220
+ audioIn: model.findings.audio_transcription.status,
2221
+ audioOut: model.findings.audio_speech.status,
2222
+ }));
2223
+ console.table(rows);
2224
+ }
2225
+ } catch (error) {
2226
+ console.error(`Benchmark failed: ${(error as Error).message}`);
2227
+ process.exitCode = 1;
2228
+ }
2229
+ });
2230
+
2231
+ // ─────────────────────────────────────────────────────────────────────────────
2232
+ // Chat Command (requires the waypoi server to be running)
2233
+ // ─────────────────────────────────────────────────────────────────────────────
2234
+
2235
+ program
2236
+ .command("chat")
2237
+ .description("Send a message and stream the response (server must be running)")
2238
+ .argument("[message]", "Message to send (reads from stdin if omitted)")
2239
+ .option("--model <model>", "Model to use")
2240
+ .option("--session <id>", "Continue an existing session")
2241
+ .option("--no-stream", "Return full response instead of streaming")
2242
+ .option("--port <port>", "Waypoi server port", DEFAULT_PORT)
2243
+ .option("--json", "Output raw JSON response (implies --no-stream)")
2244
+ .action(async (message: string | undefined, options: {
2245
+ model?: string;
2246
+ session?: string;
2247
+ stream: boolean;
2248
+ port: string;
2249
+ json?: boolean;
2250
+ }) => {
2251
+ const baseUrl = `http://localhost:${options.port}`;
2252
+ let content = message;
2253
+
2254
+ // Read from stdin if no argument given and stdin is piped
2255
+ if (!content && !process.stdin.isTTY) {
2256
+ const chunks: Buffer[] = [];
2257
+ for await (const chunk of process.stdin) {
2258
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
2259
+ }
2260
+ content = Buffer.concat(chunks).toString("utf8").trim();
2261
+ }
2262
+
2263
+ if (!content) {
2264
+ console.error("Provide a message as argument or pipe it via stdin.");
2265
+ process.exitCode = 1;
2266
+ return;
2267
+ }
2268
+
2269
+ // Resolve or create session
2270
+ let sessionId = options.session;
2271
+ if (!sessionId) {
2272
+ try {
2273
+ const resp = await request(`${baseUrl}/admin/sessions`, {
2274
+ method: "POST",
2275
+ headers: { "content-type": "application/json" },
2276
+ body: JSON.stringify({ model: options.model }),
2277
+ });
2278
+ const body = await resp.body.json() as { id?: string };
2279
+ sessionId = body.id;
2280
+ } catch (err) {
2281
+ console.error(`Cannot reach server at ${baseUrl} — is it running? (${appConfig.appName} service start)`);
2282
+ console.error((err as Error).message);
2283
+ process.exitCode = 1;
2284
+ return;
2285
+ }
2286
+ }
2287
+
2288
+ const payload: Record<string, unknown> = {
2289
+ model: options.model ?? "smart",
2290
+ messages: [{ role: "user", content }],
2291
+ stream: !options.json && options.stream !== false,
2292
+ };
2293
+
2294
+ const useStream = !options.json && options.stream !== false;
2295
+
2296
+ try {
2297
+ const resp = await request(`${baseUrl}/v1/chat/completions`, {
2298
+ method: "POST",
2299
+ headers: { "content-type": "application/json" },
2300
+ body: JSON.stringify(payload),
2301
+ });
2302
+
2303
+ if (!useStream || options.json) {
2304
+ const body = await resp.body.json() as { choices?: Array<{ message?: { content?: string } }> };
2305
+ if (options.json) {
2306
+ printJson(body);
2307
+ } else {
2308
+ const text = body.choices?.[0]?.message?.content ?? "";
2309
+ process.stdout.write(text + "\n");
2310
+ }
2311
+ return;
2312
+ }
2313
+
2314
+ // Streaming SSE
2315
+ let fullContent = "";
2316
+ const decoder = new TextDecoder();
2317
+ for await (const chunk of resp.body) {
2318
+ const text = decoder.decode(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
2319
+ for (const line of text.split("\n")) {
2320
+ if (!line.startsWith("data: ")) continue;
2321
+ const data = line.slice(6).trim();
2322
+ if (data === "[DONE]") break;
2323
+ try {
2324
+ const parsed = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }> };
2325
+ const delta = parsed.choices?.[0]?.delta?.content;
2326
+ if (delta) {
2327
+ process.stdout.write(delta);
2328
+ fullContent += delta;
2329
+ }
2330
+ } catch {
2331
+ // Skip malformed SSE chunk
2332
+ }
2333
+ }
2334
+ }
2335
+ process.stdout.write("\n");
2336
+
2337
+ // Save to session
2338
+ if (sessionId) {
2339
+ const model = options.model ?? "smart";
2340
+ await request(`${baseUrl}/admin/sessions/${sessionId}/messages`, {
2341
+ method: "POST",
2342
+ headers: { "content-type": "application/json" },
2343
+ body: JSON.stringify({ role: "user", content }),
2344
+ });
2345
+ await request(`${baseUrl}/admin/sessions/${sessionId}/messages`, {
2346
+ method: "POST",
2347
+ headers: { "content-type": "application/json" },
2348
+ body: JSON.stringify({ role: "assistant", content: fullContent, model }),
2349
+ });
2350
+ console.error(`\n[session: ${sessionId}]`);
2351
+ }
2352
+ } catch (err) {
2353
+ console.error(`Chat request failed: ${(err as Error).message}`);
2354
+ process.exitCode = 1;
2355
+ }
2356
+ });
2357
+
2358
+ // ─────────────────────────────────────────────────────────────────────────────
2359
+ // Sessions Command
2360
+ // ─────────────────────────────────────────────────────────────────────────────
2361
+
2362
+ const sessions = program
2363
+ .command("sessions")
2364
+ .alias("session")
2365
+ .description("Manage chat sessions")
2366
+ .option("--port <port>", "Waypoi server port", DEFAULT_PORT)
2367
+ .option("--json", "Output as JSON")
2368
+ .action(async (options: { port: string; json?: boolean }) => {
2369
+ await listSessionsAction(options);
2370
+ });
2371
+
2372
+ async function listSessionsAction(options: { port: string; json?: boolean }): Promise<void> {
2373
+ const baseUrl = `http://localhost:${options.port}`;
2374
+ try {
2375
+ const resp = await request(`${baseUrl}/admin/sessions`, { method: "GET" });
2376
+ const body = await resp.body.json() as Array<{
2377
+ id: string;
2378
+ name: string;
2379
+ model?: string;
2380
+ messageCount?: number;
2381
+ updatedAt?: string;
2382
+ }>;
2383
+ if (options.json) {
2384
+ printJson(body);
2385
+ return;
2386
+ }
2387
+ if (!body.length) {
2388
+ console.log("No sessions found.");
2389
+ return;
2390
+ }
2391
+ console.table(body.map((s) => ({
2392
+ id: s.id.slice(0, 8),
2393
+ name: s.name,
2394
+ model: s.model ?? "-",
2395
+ messages: s.messageCount ?? "-",
2396
+ updated: s.updatedAt ? new Date(s.updatedAt).toLocaleString() : "-",
2397
+ })));
2398
+ } catch (err) {
2399
+ console.error(`Cannot reach server — is it running? (${appConfig.appName} service start)\n${(err as Error).message}`);
2400
+ process.exitCode = 1;
2401
+ }
2402
+ }
2403
+
2404
+ sessions
2405
+ .command("list")
2406
+ .alias("ls")
2407
+ .description("List sessions")
2408
+ .option("--port <port>", "Waypoi server port", DEFAULT_PORT)
2409
+ .option("--json", "Output as JSON")
2410
+ .action(async (options: { port: string; json?: boolean }) => {
2411
+ await listSessionsAction(options);
2412
+ });
2413
+
2414
+ sessions
2415
+ .command("show <id>")
2416
+ .description("Print full message history of a session")
2417
+ .option("--port <port>", "Waypoi server port", DEFAULT_PORT)
2418
+ .option("--json", "Output as JSON")
2419
+ .action(async (id: string, options: { port: string; json?: boolean }) => {
2420
+ const baseUrl = `http://localhost:${options.port}`;
2421
+ try {
2422
+ const resp = await request(`${baseUrl}/admin/sessions/${id}`, { method: "GET" });
2423
+ const body = await resp.body.json() as { messages?: Array<{ role: string; content?: unknown; model?: string; createdAt?: string }> };
2424
+ if (options.json) {
2425
+ printJson(body);
2426
+ return;
2427
+ }
2428
+ for (const msg of body.messages ?? []) {
2429
+ const ts = msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString() : "";
2430
+ const label = msg.model ? `${msg.role} (${msg.model})` : msg.role;
2431
+ console.log(`\n[${ts}] ${label}:`);
2432
+ if (typeof msg.content === "string") {
2433
+ console.log(msg.content);
2434
+ } else {
2435
+ console.log(JSON.stringify(msg.content, null, 2));
2436
+ }
2437
+ }
2438
+ } catch (err) {
2439
+ console.error(`${(err as Error).message}`);
2440
+ process.exitCode = 1;
2441
+ }
2442
+ });
2443
+
2444
+ sessions
2445
+ .command("rm <id>")
2446
+ .alias("delete")
2447
+ .description("Delete a session")
2448
+ .option("--port <port>", "Waypoi server port", DEFAULT_PORT)
2449
+ .action(async (id: string, options: { port: string }) => {
2450
+ const baseUrl = `http://localhost:${options.port}`;
2451
+ try {
2452
+ await request(`${baseUrl}/admin/sessions/${id}`, { method: "DELETE" });
2453
+ console.log(`Deleted session: ${id}`);
2454
+ } catch (err) {
2455
+ console.error(`${(err as Error).message}`);
2456
+ process.exitCode = 1;
2457
+ }
2458
+ });
2459
+
2460
+ sessions
2461
+ .command("export <id>")
2462
+ .description("Export session messages as JSONL to stdout")
2463
+ .option("--port <port>", "Waypoi server port", DEFAULT_PORT)
2464
+ .action(async (id: string, options: { port: string }) => {
2465
+ const baseUrl = `http://localhost:${options.port}`;
2466
+ try {
2467
+ const resp = await request(`${baseUrl}/admin/sessions/${id}`, { method: "GET" });
2468
+ const body = await resp.body.json() as { messages?: unknown[] };
2469
+ for (const msg of body.messages ?? []) {
2470
+ process.stdout.write(JSON.stringify(msg) + "\n");
2471
+ }
2472
+ } catch (err) {
2473
+ console.error(`${(err as Error).message}`);
2474
+ process.exitCode = 1;
2475
+ }
2476
+ });
2477
+
2478
+ // ─────────────────────────────────────────────────────────────────────────────
2479
+ const rawArgv = process.argv.slice(2);
2480
+ const rewriteResult = rewriteLegacyArgv(rawArgv);
2481
+ warnLegacyRewrite(rewriteResult);
2482
+
2483
+ program.parseAsync(["node", "waypoi", ...rewriteResult.argv]).catch((error) => {
2484
+ console.error(error);
2485
+ process.exit(1);
2486
+ });
2487
+
2488
+ function compactEndpointUrl(value: string, maxLength = 36): string {
2489
+ try {
2490
+ const parsed = new URL(value);
2491
+ return truncateText(`${parsed.protocol}//${parsed.host}`, maxLength);
2492
+ } catch {
2493
+ return truncateText(value, maxLength);
2494
+ }
2495
+ }
2496
+
2497
+ function truncateText(value: string, maxLength: number): string {
2498
+ if (value.length <= maxLength) {
2499
+ return value;
2500
+ }
2501
+ if (maxLength <= 1) {
2502
+ return value.slice(0, maxLength);
2503
+ }
2504
+ return `${value.slice(0, maxLength - 1)}…`;
2505
+ }
2506
+
2507
+ function normalizeProviderProtocol(value: string): ProviderProtocol {
2508
+ const normalized = canonicalizeProtocol(value);
2509
+ if (normalized === "openai" || normalized === "inference_v2") {
2510
+ return normalized;
2511
+ }
2512
+ return "unknown";
2513
+ }
2514
+
2515
+ function hostMatchesDomain(hostname: string, domain: string): boolean {
2516
+ const normalizedHost = hostname.toLowerCase();
2517
+ const normalizedDomain = domain.replace(/^\*\./, "").toLowerCase();
2518
+ return normalizedHost === normalizedDomain || normalizedHost.endsWith(`.${normalizedDomain}`);
2519
+ }
2520
+
2521
+ function capabilitiesToModalities(capabilities: ModelCapabilities): string[] {
2522
+ const modalities = new Set<string>();
2523
+ const hasTextInput = capabilities.input.includes("text");
2524
+ const hasImageInput = capabilities.input.includes("image");
2525
+ const hasAudioInput = capabilities.input.includes("audio");
2526
+ const hasTextOutput = capabilities.output.includes("text");
2527
+ const hasImageOutput = capabilities.output.includes("image");
2528
+ const hasAudioOutput = capabilities.output.includes("audio");
2529
+ const hasEmbeddingOutput = capabilities.output.includes("embedding");
2530
+
2531
+ if (hasTextInput && hasTextOutput) {
2532
+ modalities.add("text-to-text");
2533
+ }
2534
+ if (hasImageInput && hasTextOutput) {
2535
+ modalities.add("image-to-text");
2536
+ }
2537
+ if (hasTextInput && hasImageOutput) {
2538
+ modalities.add("text-to-image");
2539
+ }
2540
+ if (hasAudioInput && hasTextOutput) {
2541
+ modalities.add("audio-to-text");
2542
+ }
2543
+ if (hasTextInput && hasAudioOutput) {
2544
+ modalities.add("text-to-audio");
2545
+ }
2546
+ if (hasTextInput && hasEmbeddingOutput) {
2547
+ modalities.add("text-to-embedding");
2548
+ }
2549
+
2550
+ return Array.from(modalities);
2551
+ }
2552
+
2553
+ function defaultCapabilitiesForEndpointType(
2554
+ endpointType: "llm" | "diffusion" | "audio" | "embedding" | "video"
2555
+ ): ModelCapabilities {
2556
+ if (endpointType === "embedding") {
2557
+ return { input: ["text"], output: ["embedding"], source: "configured" };
2558
+ }
2559
+ if (endpointType === "diffusion") {
2560
+ return { input: ["text"], output: ["image"], source: "configured" };
2561
+ }
2562
+ if (endpointType === "audio") {
2563
+ return { input: ["audio"], output: ["text"], source: "configured" };
2564
+ }
2565
+ if (endpointType === "video") {
2566
+ return { input: ["text"], output: ["video"], source: "configured" };
2567
+ }
2568
+ return {
2569
+ input: ["text"],
2570
+ output: ["text"],
2571
+ supportsTools: true,
2572
+ supportsStreaming: true,
2573
+ source: "configured",
2574
+ };
2575
+ }
2576
+
2577
+ function parseCapabilitySpecs(values: string[]): ModelCapabilities {
2578
+ const input = new Set<ModelModality>();
2579
+ const output = new Set<ModelModality>();
2580
+ for (const value of values) {
2581
+ const [inputSpec, outputSpec] = value.split("->").map((part) => part.trim());
2582
+ if (!inputSpec || !outputSpec) {
2583
+ throw new Error(`Invalid capability spec '${value}'. Use format input->output, e.g. text+image->text`);
2584
+ }
2585
+ for (const modality of inputSpec.split("+").map((item) => item.trim())) {
2586
+ input.add(parseModality(modality));
2587
+ }
2588
+ for (const modality of outputSpec.split("+").map((item) => item.trim())) {
2589
+ output.add(parseModality(modality));
2590
+ }
2591
+ }
2592
+ if (input.size === 0 || output.size === 0) {
2593
+ throw new Error("Capability spec must include at least one input and one output modality.");
2594
+ }
2595
+ return {
2596
+ input: Array.from(input),
2597
+ output: Array.from(output),
2598
+ source: "configured",
2599
+ };
2600
+ }
2601
+
2602
+ function parseModality(value: string): ModelModality {
2603
+ if (value === "text" || value === "image" || value === "audio" || value === "embedding" || value === "video") {
2604
+ return value;
2605
+ }
2606
+ throw new Error(`Unsupported modality '${value}'. Use one of: text,image,audio,embedding,video.`);
2607
+ }
2608
+
2609
+ function normalizeAliasList(values: string[]): string[] {
2610
+ const seen = new Set<string>();
2611
+ for (const value of values) {
2612
+ const alias = value.trim();
2613
+ if (alias.length > 0) {
2614
+ seen.add(alias);
2615
+ }
2616
+ }
2617
+ return Array.from(seen);
2618
+ }
2619
+
2620
+ function writeMigrationReport(baseDir: string, payload: Record<string, unknown>): string {
2621
+ const migrationsDir = path.join(baseDir, "migrations");
2622
+ fs.mkdirSync(migrationsDir, { recursive: true });
2623
+ const filePath = path.join(
2624
+ migrationsDir,
2625
+ `migrate-${new Date().toISOString().replace(/[:.]/g, "-")}.json`
2626
+ );
2627
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8");
2628
+ return filePath;
2629
+ }
2630
+
2631
+ function isImageModel(model: string): boolean {
2632
+ const name = model.toLowerCase();
2633
+ return name.includes("diffusion") || name.includes("stable") || name.includes("sd") || name.includes("flux");
2634
+ }
2635
+
2636
+ function isAudioModel(model: string): boolean {
2637
+ const name = model.toLowerCase();
2638
+ return name.includes("whisper") || name.includes("tts") || name.includes("speech");
2639
+ }
2640
+
2641
+ function isVideoModel(model: string): boolean {
2642
+ const name = model.toLowerCase();
2643
+ return name.includes("wan") || name.includes("video") || name.includes("i2v") || name.includes("t2v");
2644
+ }
2645
+
2646
+ async function resolveModelType(model: string): Promise<"llm" | "diffusion" | "audio" | "embedding" | "video"> {
2647
+ const providerModels = await listModelsForApi(paths);
2648
+ const providerMatch = providerModels.find((entry) => entry.id === model || entry.aliases.includes(model));
2649
+ if (providerMatch) {
2650
+ return providerMatch.endpoint_type;
2651
+ }
2652
+ const endpoints = await listEndpoints(paths);
2653
+ const match = endpoints.find((endpoint) =>
2654
+ endpoint.models.some((entry) => entry.publicName === model)
2655
+ );
2656
+ if (match) {
2657
+ return match.type;
2658
+ }
2659
+ if (isImageModel(model)) return "diffusion";
2660
+ if (isAudioModel(model)) return "audio";
2661
+ if (isVideoModel(model)) return "video";
2662
+ return "llm";
2663
+ }
2664
+
2665
+ function normalizeType(value: string): "llm" | "diffusion" | "audio" | "embedding" | "video" {
2666
+ if (value === "diffusion") return "diffusion";
2667
+ if (value === "audio") return "audio";
2668
+ if (value === "embedding") return "embedding";
2669
+ if (value === "video") return "video";
2670
+ return "llm";
2671
+ }
2672
+
2673
+ async function readResponsePayload(response: { body: NodeJS.ReadableStream; headers: Record<string, string | string[]> }): Promise<unknown> {
2674
+ const chunks: Buffer[] = [];
2675
+ for await (const chunk of response.body) {
2676
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2677
+ }
2678
+ const buffer = Buffer.concat(chunks);
2679
+ const contentType = normalizeHeaders(response.headers)["content-type"] ?? "";
2680
+ if (contentType.includes("application/json")) {
2681
+ try {
2682
+ return JSON.parse(buffer.toString("utf8"));
2683
+ } catch {
2684
+ return buffer.toString("utf8");
2685
+ }
2686
+ }
2687
+ return buffer.toString("utf8");
2688
+ }
2689
+
2690
+ function normalizeHeaders(headers: Record<string, string | string[]>): Record<string, string> {
2691
+ const normalized: Record<string, string> = {};
2692
+ for (const [key, value] of Object.entries(headers)) {
2693
+ normalized[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
2694
+ }
2695
+ return normalized;
2696
+ }
2697
+
2698
+ function readPid(filePath: string): number | null {
2699
+ try {
2700
+ const raw = fs.readFileSync(filePath, "utf8").trim();
2701
+ const pid = Number(raw);
2702
+ return Number.isFinite(pid) ? pid : null;
2703
+ } catch {
2704
+ return null;
2705
+ }
2706
+ }
2707
+
2708
+ function isRunning(filePath: string): boolean {
2709
+ const pid = readPid(filePath);
2710
+ if (!pid) {
2711
+ return false;
2712
+ }
2713
+ try {
2714
+ process.kill(pid, 0);
2715
+ return true;
2716
+ } catch {
2717
+ return false;
2718
+ }
2719
+ }
2720
+
2721
+ function summarizeProviderHealth(
2722
+ models: ProviderModelRecord[],
2723
+ healthMap: Record<string, { status?: string }>
2724
+ ): string {
2725
+ const enabled = models.filter((model) => model.enabled !== false);
2726
+ let up = 0;
2727
+ let down = 0;
2728
+ for (const model of enabled) {
2729
+ const health = healthMap[model.providerModelId];
2730
+ if (health?.status === "up") {
2731
+ up += 1;
2732
+ } else if (health?.status === "down") {
2733
+ down += 1;
2734
+ }
2735
+ }
2736
+ return `${up}/${down}/${enabled.length}`;
2737
+ }
2738
+
2739
+ function formatLatency(latency?: number): string {
2740
+ if (!latency || !Number.isFinite(latency)) {
2741
+ return "-";
2742
+ }
2743
+ return `${Math.round(latency)}ms`;
2744
+ }
2745
+
2746
+ async function startService(): Promise<void> {
2747
+ await ensureStorageDir(paths);
2748
+ const existingPid = readPid(pidFile);
2749
+ if (existingPid) {
2750
+ if (isRunning(pidFile)) {
2751
+ console.log(`${DISPLAY_NAME} is already running.`);
2752
+ return;
2753
+ }
2754
+ fs.unlinkSync(pidFile);
2755
+ }
2756
+ const rootDir = getPackageRoot();
2757
+ const entry = path.join(rootDir, "dist", "src", "index.js");
2758
+ if (!fs.existsSync(entry)) {
2759
+ console.error(`Missing ${entry}. Run npm run build first.`);
2760
+ process.exitCode = 1;
2761
+ return;
2762
+ }
2763
+ const child = spawn(process.execPath, [entry], {
2764
+ detached: true,
2765
+ stdio: "ignore",
2766
+ env: {
2767
+ ...process.env
2768
+ }
2769
+ });
2770
+ if (!child.pid) {
2771
+ console.error(`Failed to start ${DISPLAY_NAME}.`);
2772
+ process.exitCode = 1;
2773
+ return;
2774
+ }
2775
+ child.unref();
2776
+ fs.writeFileSync(pidFile, String(child.pid), "utf8");
2777
+ console.log(`${DISPLAY_NAME} started (pid ${child.pid}).`);
2778
+ }
2779
+
2780
+ async function stopService(): Promise<void> {
2781
+ await ensureStorageDir(paths);
2782
+ const pid = readPid(pidFile);
2783
+ if (!pid) {
2784
+ console.log(`${DISPLAY_NAME} is not running.`);
2785
+ return;
2786
+ }
2787
+ try {
2788
+ process.kill(pid);
2789
+ fs.unlinkSync(pidFile);
2790
+ console.log(`${DISPLAY_NAME} stopped.`);
2791
+ } catch (error) {
2792
+ console.error(`Failed to stop: ${(error as Error).message}`);
2793
+ process.exitCode = 1;
2794
+ }
2795
+ }
2796
+
2797
+ function getPackageRoot(): string {
2798
+ const dir = __dirname;
2799
+ const base = path.basename(dir);
2800
+ const parent = path.basename(path.dirname(dir));
2801
+ if (base === "cli" && parent === "dist") {
2802
+ return path.resolve(dir, "..", "..");
2803
+ }
2804
+ return path.resolve(dir, "..");
2805
+ }