waypoi 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.github/instructions/ui.instructions.md +42 -0
  2. package/.github/workflows/ci.yml +35 -0
  3. package/.github/workflows/publish.yml +71 -0
  4. package/.github/workflows/release.yml +48 -0
  5. package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
  6. package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
  7. package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
  8. package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
  10. package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
  11. package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
  12. package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
  13. package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
  14. package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
  15. package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
  16. package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
  17. package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
  18. package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
  19. package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
  20. package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
  21. package/AGENTS.md +48 -0
  22. package/CHANGELOG.md +131 -0
  23. package/README.md +552 -0
  24. package/assets/agent-mode.png +0 -0
  25. package/assets/categorize.png +0 -0
  26. package/assets/dashboard.png +0 -0
  27. package/assets/endpoint-proxy.png +0 -0
  28. package/assets/icon.png +0 -0
  29. package/assets/mcp-generate-image.png +0 -0
  30. package/assets/mcp-understand-image.png +0 -0
  31. package/assets/peek-token-flow.png +0 -0
  32. package/assets/playground.png +0 -0
  33. package/assets/sankey.png +0 -0
  34. package/cli/index.ts +2805 -0
  35. package/cli/legacyRewrite.ts +108 -0
  36. package/cli/modelRef.ts +24 -0
  37. package/dist/cli/index.js +2536 -0
  38. package/dist/cli/legacyRewrite.js +92 -0
  39. package/dist/cli/modelRef.js +20 -0
  40. package/dist/src/benchmark/artifacts.js +131 -0
  41. package/dist/src/benchmark/capabilityClassifier.js +81 -0
  42. package/dist/src/benchmark/capabilityStore.js +144 -0
  43. package/dist/src/benchmark/config.js +238 -0
  44. package/dist/src/benchmark/gates.js +118 -0
  45. package/dist/src/benchmark/jobs.js +252 -0
  46. package/dist/src/benchmark/runner.js +1847 -0
  47. package/dist/src/benchmark/schema.js +353 -0
  48. package/dist/src/benchmark/suites.js +314 -0
  49. package/dist/src/benchmark/tinyQaDataset.js +422 -0
  50. package/dist/src/benchmark/types.js +25 -0
  51. package/dist/src/config.js +47 -0
  52. package/dist/src/index.js +178 -0
  53. package/dist/src/mcp/client.js +215 -0
  54. package/dist/src/mcp/discovery.js +226 -0
  55. package/dist/src/mcp/policy.js +65 -0
  56. package/dist/src/mcp/registry.js +129 -0
  57. package/dist/src/mcp/service.js +460 -0
  58. package/dist/src/middleware/auth.js +179 -0
  59. package/dist/src/middleware/requestCapture.js +192 -0
  60. package/dist/src/middleware/requestStats.js +118 -0
  61. package/dist/src/pools/builder.js +132 -0
  62. package/dist/src/pools/repository.js +69 -0
  63. package/dist/src/pools/scheduler.js +360 -0
  64. package/dist/src/pools/types.js +2 -0
  65. package/dist/src/protocols/adapters/dashscope.js +267 -0
  66. package/dist/src/protocols/adapters/inferenceV2.js +346 -0
  67. package/dist/src/protocols/adapters/openai.js +27 -0
  68. package/dist/src/protocols/registry.js +99 -0
  69. package/dist/src/protocols/types.js +2 -0
  70. package/dist/src/providers/health.js +153 -0
  71. package/dist/src/providers/importer.js +289 -0
  72. package/dist/src/providers/modelRegistry.js +313 -0
  73. package/dist/src/providers/repository.js +361 -0
  74. package/dist/src/providers/types.js +2 -0
  75. package/dist/src/routes/admin.js +531 -0
  76. package/dist/src/routes/audio.js +295 -0
  77. package/dist/src/routes/chat.js +240 -0
  78. package/dist/src/routes/embeddings.js +157 -0
  79. package/dist/src/routes/images.js +288 -0
  80. package/dist/src/routes/mcp.js +256 -0
  81. package/dist/src/routes/mcpService.js +100 -0
  82. package/dist/src/routes/models.js +48 -0
  83. package/dist/src/routes/responses.js +711 -0
  84. package/dist/src/routes/sessions.js +450 -0
  85. package/dist/src/routes/stats.js +270 -0
  86. package/dist/src/routes/ui.js +97 -0
  87. package/dist/src/routes/videos.js +107 -0
  88. package/dist/src/routing/router.js +338 -0
  89. package/dist/src/services/imageGeneration.js +280 -0
  90. package/dist/src/services/imageUnderstanding.js +352 -0
  91. package/dist/src/services/videoGeneration.js +79 -0
  92. package/dist/src/storage/captureRepository.js +1591 -0
  93. package/dist/src/storage/files.js +157 -0
  94. package/dist/src/storage/imageCache.js +346 -0
  95. package/dist/src/storage/repositories.js +388 -0
  96. package/dist/src/storage/sessionRepository.js +370 -0
  97. package/dist/src/storage/statsRepository.js +204 -0
  98. package/dist/src/transport/httpClient.js +126 -0
  99. package/dist/src/types.js +2 -0
  100. package/dist/src/utils/messageMedia.js +285 -0
  101. package/dist/src/utils/modelCapabilities.js +108 -0
  102. package/dist/src/utils/modelDiscovery.js +170 -0
  103. package/dist/src/version.js +5 -0
  104. package/dist/src/workers/captureRetention.js +25 -0
  105. package/dist/src/workers/configWatcher.js +91 -0
  106. package/dist/src/workers/healthChecker.js +21 -0
  107. package/dist/src/workers/statsRotation.js +41 -0
  108. package/docs/LLM/output_schema.md +312 -0
  109. package/docs/benchmark.md +208 -0
  110. package/docs/mcp-guidelines.md +125 -0
  111. package/docs/mcp-service.md +178 -0
  112. package/docs/opencode.md +86 -0
  113. package/docs/providers.md +79 -0
  114. package/examples/benchmark.config.yaml +28 -0
  115. package/examples/providers/alibaba-dashscope.yaml +88 -0
  116. package/examples/providers/alibaba-llm.yaml +64 -0
  117. package/examples/providers/alibaba-registry.yaml +7 -0
  118. package/examples/providers/inference-v2-ray.yaml +29 -0
  119. package/examples/scenarios/assets/omni-call-sample.wav +0 -0
  120. package/examples/scenarios/custom.jsonl +5 -0
  121. package/examples/scenarios/custom.yaml +40 -0
  122. package/model-form-v2.png +0 -0
  123. package/package.json +66 -0
  124. package/provider-form-v2.png +0 -0
  125. package/provider-form.png +0 -0
  126. package/scripts/manual-test.sh +11 -0
  127. package/scripts/version-from-git.js +23 -0
  128. package/src/benchmark/artifacts.ts +149 -0
  129. package/src/benchmark/capabilityClassifier.ts +99 -0
  130. package/src/benchmark/capabilityStore.ts +174 -0
  131. package/src/benchmark/config.ts +337 -0
  132. package/src/benchmark/gates.ts +164 -0
  133. package/src/benchmark/jobs.ts +312 -0
  134. package/src/benchmark/runner.ts +2519 -0
  135. package/src/benchmark/schema.ts +443 -0
  136. package/src/benchmark/suites.ts +323 -0
  137. package/src/benchmark/tinyQaDataset.ts +428 -0
  138. package/src/benchmark/types.ts +442 -0
  139. package/src/config.ts +44 -0
  140. package/src/index.ts +195 -0
  141. package/src/mcp/client.ts +305 -0
  142. package/src/mcp/discovery.ts +266 -0
  143. package/src/mcp/policy.ts +105 -0
  144. package/src/mcp/registry.ts +164 -0
  145. package/src/mcp/service.ts +611 -0
  146. package/src/middleware/auth.ts +251 -0
  147. package/src/middleware/requestCapture.ts +245 -0
  148. package/src/middleware/requestStats.ts +163 -0
  149. package/src/pools/builder.ts +159 -0
  150. package/src/pools/repository.ts +71 -0
  151. package/src/pools/scheduler.ts +425 -0
  152. package/src/pools/types.ts +117 -0
  153. package/src/protocols/adapters/dashscope.ts +335 -0
  154. package/src/protocols/adapters/inferenceV2.ts +428 -0
  155. package/src/protocols/adapters/openai.ts +32 -0
  156. package/src/protocols/registry.ts +117 -0
  157. package/src/protocols/types.ts +81 -0
  158. package/src/providers/health.ts +207 -0
  159. package/src/providers/importer.ts +402 -0
  160. package/src/providers/modelRegistry.ts +415 -0
  161. package/src/providers/repository.ts +439 -0
  162. package/src/providers/types.ts +113 -0
  163. package/src/routes/admin.ts +666 -0
  164. package/src/routes/audio.ts +372 -0
  165. package/src/routes/chat.ts +301 -0
  166. package/src/routes/embeddings.ts +197 -0
  167. package/src/routes/images.ts +356 -0
  168. package/src/routes/mcp.ts +320 -0
  169. package/src/routes/mcpService.ts +114 -0
  170. package/src/routes/models.ts +50 -0
  171. package/src/routes/responses.ts +872 -0
  172. package/src/routes/sessions.ts +558 -0
  173. package/src/routes/stats.ts +312 -0
  174. package/src/routes/ui.ts +96 -0
  175. package/src/routes/videos.ts +132 -0
  176. package/src/routing/router.ts +501 -0
  177. package/src/services/imageGeneration.ts +396 -0
  178. package/src/services/imageUnderstanding.ts +449 -0
  179. package/src/services/videoGeneration.ts +127 -0
  180. package/src/storage/captureRepository.ts +1835 -0
  181. package/src/storage/files.ts +178 -0
  182. package/src/storage/imageCache.ts +405 -0
  183. package/src/storage/repositories.ts +494 -0
  184. package/src/storage/sessionRepository.ts +419 -0
  185. package/src/storage/statsRepository.ts +238 -0
  186. package/src/transport/httpClient.ts +145 -0
  187. package/src/types.ts +322 -0
  188. package/src/utils/messageMedia.ts +293 -0
  189. package/src/utils/modelCapabilities.ts +161 -0
  190. package/src/utils/modelDiscovery.ts +203 -0
  191. package/src/workers/captureRetention.ts +25 -0
  192. package/src/workers/configWatcher.ts +115 -0
  193. package/src/workers/healthChecker.ts +22 -0
  194. package/src/workers/statsRotation.ts +49 -0
  195. package/tests/benchmarkAdminRoutes.test.ts +82 -0
  196. package/tests/benchmarkBasics.test.ts +116 -0
  197. package/tests/captureAdminRoutes.test.ts +420 -0
  198. package/tests/captureRepository.test.ts +797 -0
  199. package/tests/cliLegacyRewrite.test.ts +45 -0
  200. package/tests/imageGeneration.service.test.ts +107 -0
  201. package/tests/imageUnderstanding.service.test.ts +123 -0
  202. package/tests/mcpPolicy.test.ts +105 -0
  203. package/tests/mcpService.test.ts +1245 -0
  204. package/tests/modelRef.test.ts +23 -0
  205. package/tests/modelsRoutes.test.ts +154 -0
  206. package/tests/sessionMediaCache.test.ts +167 -0
  207. package/tests/statsRoutes.test.ts +323 -0
  208. package/tsconfig.json +15 -0
  209. package/ui/index.html +16 -0
  210. package/ui/package-lock.json +8521 -0
  211. package/ui/package.json +52 -0
  212. package/ui/postcss.config.js +6 -0
  213. package/ui/public/assets/apple-touch-icon.png +0 -0
  214. package/ui/public/assets/favicon-16.png +0 -0
  215. package/ui/public/assets/favicon-32.png +0 -0
  216. package/ui/public/assets/icon-192.png +0 -0
  217. package/ui/public/assets/icon-512.png +0 -0
  218. package/ui/src/App.tsx +27 -0
  219. package/ui/src/api/client.ts +1503 -0
  220. package/ui/src/components/EndpointUsageGuide.tsx +361 -0
  221. package/ui/src/components/Layout.tsx +124 -0
  222. package/ui/src/components/MessageContent.tsx +365 -0
  223. package/ui/src/components/ToolCallMessage.tsx +179 -0
  224. package/ui/src/components/ToolPicker.tsx +442 -0
  225. package/ui/src/components/messageContentParser.test.ts +41 -0
  226. package/ui/src/components/messageContentParser.ts +73 -0
  227. package/ui/src/components/thinkingPreview.test.ts +27 -0
  228. package/ui/src/components/thinkingPreview.ts +15 -0
  229. package/ui/src/components/toMermaidSankey.test.ts +78 -0
  230. package/ui/src/components/toMermaidSankey.ts +56 -0
  231. package/ui/src/components/ui/button.tsx +58 -0
  232. package/ui/src/components/ui/input.tsx +21 -0
  233. package/ui/src/components/ui/textarea.tsx +21 -0
  234. package/ui/src/lib/utils.ts +6 -0
  235. package/ui/src/main.tsx +9 -0
  236. package/ui/src/pages/AgentPlayground.tsx +2010 -0
  237. package/ui/src/pages/Benchmark.tsx +988 -0
  238. package/ui/src/pages/Dashboard.tsx +581 -0
  239. package/ui/src/pages/Peek.tsx +962 -0
  240. package/ui/src/pages/Settings.tsx +2013 -0
  241. package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
  242. package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
  243. package/ui/src/pages/agentThinkingContent.test.ts +50 -0
  244. package/ui/src/pages/agentThinkingContent.ts +57 -0
  245. package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
  246. package/ui/src/pages/dashboardTokenUsage.ts +36 -0
  247. package/ui/src/pages/imageUpload.test.ts +39 -0
  248. package/ui/src/pages/imageUpload.ts +71 -0
  249. package/ui/src/pages/peekFilters.test.ts +29 -0
  250. package/ui/src/pages/peekFilters.ts +13 -0
  251. package/ui/src/pages/peekMedia.test.ts +58 -0
  252. package/ui/src/pages/peekMedia.ts +148 -0
  253. package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
  254. package/ui/src/pages/sessionAutoTitle.ts +106 -0
  255. package/ui/src/stores/settings.ts +58 -0
  256. package/ui/src/styles/globals.css +223 -0
  257. package/ui/src/vite-env.d.ts +8 -0
  258. package/ui/tailwind.config.js +106 -0
  259. package/ui/tsconfig.json +32 -0
  260. package/ui/vite.config.ts +37 -0
@@ -0,0 +1,159 @@
1
+ import { StoragePaths } from "../storage/files";
2
+ import { listProviders } from "../providers/repository";
3
+ import { supportsRequirements } from "../utils/modelCapabilities";
4
+ import { ModelModality } from "../types";
5
+ import { loadPools, savePools } from "./repository";
6
+ import { PoolCandidate, PoolDefinition } from "./types";
7
+ import { getEffectiveModelInsecureTls } from "../providers/repository";
8
+
9
+ interface PoolTemplate {
10
+ id: string;
11
+ aliases: string[];
12
+ requiredInput: ModelModality[];
13
+ requiredOutput: ModelModality[];
14
+ }
15
+
16
+ const DEFAULT_SCORE_FALLBACK = 20;
17
+
18
+ const DEFAULT_POOLS: PoolTemplate[] = [
19
+ {
20
+ id: "smart",
21
+ aliases: ["smart"],
22
+ requiredInput: [],
23
+ requiredOutput: [],
24
+ },
25
+ ];
26
+
27
+ function buildCandidate(
28
+ provider: Awaited<ReturnType<typeof listProviders>>[number],
29
+ model: Awaited<ReturnType<typeof listProviders>>[number]["models"][number],
30
+ scoreFallback: number
31
+ ): PoolCandidate {
32
+ const effectiveBaseUrl = model.baseUrl ?? provider.baseUrl;
33
+ const score = model.benchmark?.livebench;
34
+ return {
35
+ id: model.providerModelId,
36
+ providerModelId: model.providerModelId,
37
+ providerId: provider.id,
38
+ providerName: provider.name,
39
+ providerEnabled: provider.enabled,
40
+ modelEnabled: model.enabled !== false,
41
+ modelId: model.modelId,
42
+ aliases: model.aliases ?? [],
43
+ upstreamModel: model.upstreamModel,
44
+ baseUrl: effectiveBaseUrl ?? "",
45
+ apiKey: model.apiKey ?? provider.apiKey,
46
+ insecureTls: getEffectiveModelInsecureTls(provider, model),
47
+ autoInsecureTlsDomains: provider.autoInsecureTlsDomains ?? [],
48
+ protocol: provider.protocol,
49
+ protocolConfig: provider.protocolConfig,
50
+ auth: provider.auth,
51
+ supportsRouting: provider.supportsRouting,
52
+ free: model.free,
53
+ endpointType: model.endpointType,
54
+ capabilities: model.capabilities,
55
+ score: typeof score === "number" ? score : scoreFallback,
56
+ scoreSource: typeof score === "number" ? "benchmark.livebench" : "fallback",
57
+ limits: {
58
+ requestsPerMinute: model.limits?.requests?.perMinute ?? provider.limits?.requests?.perMinute,
59
+ requestsPerHour: model.limits?.requests?.perHour ?? provider.limits?.requests?.perHour,
60
+ requestsPerDay: model.limits?.requests?.perDay ?? provider.limits?.requests?.perDay,
61
+ requestsPerWeek: model.limits?.requests?.perWeek ?? provider.limits?.requests?.perWeek,
62
+ tokensPerMinute: model.limits?.tokens?.perMinute ?? provider.limits?.tokens?.perMinute,
63
+ tokensPerHour: model.limits?.tokens?.perHour ?? provider.limits?.tokens?.perHour,
64
+ tokensPerDay: model.limits?.tokens?.perDay ?? provider.limits?.tokens?.perDay,
65
+ tokensPerWeek: model.limits?.tokens?.perWeek ?? provider.limits?.tokens?.perWeek,
66
+ },
67
+ };
68
+ }
69
+
70
+ export async function rebuildDefaultPools(
71
+ paths: StoragePaths,
72
+ scoreFallback = DEFAULT_SCORE_FALLBACK
73
+ ): Promise<PoolDefinition[]> {
74
+ const existing = await loadPools(paths);
75
+ const providers = await listProviders(paths);
76
+
77
+ const userPools = existing.pools.filter((p) => p.userDefined);
78
+ const autoPools = existing.pools.filter((p) => !p.userDefined);
79
+
80
+ const autoPoolIds = new Set(autoPools.map((p) => p.id));
81
+
82
+ const pools: PoolDefinition[] = DEFAULT_POOLS.map((template) => {
83
+ const existingAuto = autoPools.find((p) => p.id === template.id);
84
+ return {
85
+ id: template.id,
86
+ name: existingAuto?.name ?? template.id,
87
+ aliases: template.aliases,
88
+ enabled: existingAuto?.enabled ?? true,
89
+ strategy: existingAuto?.strategy ?? "highest_rank_available",
90
+ requiredInput: template.requiredInput,
91
+ requiredOutput: template.requiredOutput,
92
+ scoreFallback,
93
+ candidates: [],
94
+ candidateSelection: existingAuto?.candidateSelection ?? [],
95
+ userDefined: false,
96
+ updatedAt: new Date().toISOString(),
97
+ };
98
+ });
99
+
100
+ const allCandidates = new Map<string, PoolCandidate>();
101
+ for (const provider of providers) {
102
+ for (const model of provider.models) {
103
+ const candidate = buildCandidate(provider, model, scoreFallback);
104
+ allCandidates.set(`${provider.id}/${model.modelId}`, candidate);
105
+ }
106
+ }
107
+
108
+ for (const pool of pools) {
109
+ for (const [key, candidate] of allCandidates) {
110
+ if (!candidate.modelEnabled || !candidate.providerEnabled) continue;
111
+ if (!candidate.free && !pool.userDefined) continue;
112
+ if (
113
+ supportsRequirements(candidate.capabilities, {
114
+ requiredInput: pool.requiredInput,
115
+ requiredOutput: pool.requiredOutput,
116
+ })
117
+ ) {
118
+ pool.candidates.push(candidate);
119
+ }
120
+ }
121
+
122
+ if (pool.candidateSelection.length > 0) {
123
+ const selected = pool.candidateSelection
124
+ .map((sel) => allCandidates.get(sel))
125
+ .filter((c): c is PoolCandidate => c !== undefined);
126
+ if (selected.length > 0) {
127
+ pool.candidates = selected;
128
+ }
129
+ }
130
+
131
+ pool.candidates = pool.candidates.sort((a, b) => {
132
+ if (b.score !== a.score) {
133
+ return b.score - a.score;
134
+ }
135
+ return `${a.providerId}/${a.modelId}`.localeCompare(`${b.providerId}/${b.modelId}`);
136
+ });
137
+ }
138
+
139
+ for (const userPool of userPools) {
140
+ if (!allPoolsHasId(pools, userPool.id)) {
141
+ userPool.candidates = (userPool.candidateSelection ?? [])
142
+ .map((sel) => allCandidates.get(sel))
143
+ .filter((c): c is PoolCandidate => c !== undefined);
144
+ userPool.candidates = userPool.candidates.sort((a, b) => {
145
+ if (b.score !== a.score) return b.score - a.score;
146
+ return `${a.providerId}/${a.modelId}`.localeCompare(`${b.providerId}/${b.modelId}`);
147
+ });
148
+ userPool.updatedAt = new Date().toISOString();
149
+ pools.push(userPool);
150
+ }
151
+ }
152
+
153
+ await savePools(paths, pools);
154
+ return pools;
155
+ }
156
+
157
+ function allPoolsHasId(pools: PoolDefinition[], id: string): boolean {
158
+ return pools.some((p) => p.id === id);
159
+ }
@@ -0,0 +1,71 @@
1
+ import { StoragePaths, readJsonFile, writeJsonFile } from "../storage/files";
2
+ import { PoolDefinition, PoolStateFile, PoolStoreFile } from "./types";
3
+
4
+ const POOLS_VERSION = 1;
5
+ const STATE_VERSION = 1;
6
+
7
+ function defaultPoolStore(): PoolStoreFile {
8
+ return {
9
+ version: POOLS_VERSION,
10
+ updatedAt: new Date().toISOString(),
11
+ pools: [],
12
+ };
13
+ }
14
+
15
+ function defaultPoolState(): PoolStateFile {
16
+ return {
17
+ version: STATE_VERSION,
18
+ updatedAt: new Date().toISOString(),
19
+ candidates: {},
20
+ };
21
+ }
22
+
23
+ export async function loadPools(paths: StoragePaths): Promise<PoolStoreFile> {
24
+ const store = await readJsonFile<PoolStoreFile>(paths.poolsPath, defaultPoolStore());
25
+ if (!Array.isArray(store.pools)) {
26
+ return defaultPoolStore();
27
+ }
28
+ return {
29
+ version: Number.isFinite(store.version) ? store.version : POOLS_VERSION,
30
+ updatedAt: typeof store.updatedAt === "string" ? store.updatedAt : new Date().toISOString(),
31
+ pools: store.pools,
32
+ };
33
+ }
34
+
35
+ export async function savePools(paths: StoragePaths, pools: PoolDefinition[]): Promise<void> {
36
+ await writeJsonFile(paths.poolsPath, {
37
+ version: POOLS_VERSION,
38
+ updatedAt: new Date().toISOString(),
39
+ pools,
40
+ } satisfies PoolStoreFile);
41
+ }
42
+
43
+ export async function listPools(paths: StoragePaths): Promise<PoolDefinition[]> {
44
+ const store = await loadPools(paths);
45
+ return [...store.pools].sort((a, b) => a.id.localeCompare(b.id));
46
+ }
47
+
48
+ export async function getPoolByAlias(paths: StoragePaths, alias: string): Promise<PoolDefinition | null> {
49
+ const pools = await listPools(paths);
50
+ return pools.find((pool) => pool.aliases.includes(alias)) ?? null;
51
+ }
52
+
53
+ export async function loadPoolState(paths: StoragePaths): Promise<PoolStateFile> {
54
+ const state = await readJsonFile<PoolStateFile>(paths.poolStatePath, defaultPoolState());
55
+ if (!state.candidates || typeof state.candidates !== "object") {
56
+ return defaultPoolState();
57
+ }
58
+ return {
59
+ version: Number.isFinite(state.version) ? state.version : STATE_VERSION,
60
+ updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : new Date().toISOString(),
61
+ candidates: state.candidates,
62
+ };
63
+ }
64
+
65
+ export async function savePoolState(paths: StoragePaths, state: PoolStateFile): Promise<void> {
66
+ await writeJsonFile(paths.poolStatePath, {
67
+ ...state,
68
+ version: STATE_VERSION,
69
+ updatedAt: new Date().toISOString(),
70
+ } satisfies PoolStateFile);
71
+ }
@@ -0,0 +1,425 @@
1
+ import { EndpointDoc, ModelModality } from "../types";
2
+ import { StoragePaths, defaultHealth } from "../storage/files";
3
+ import { supportsRequirements } from "../utils/modelCapabilities";
4
+ import { getProtocolAdapter } from "../protocols/registry";
5
+ import { ProtocolOperation } from "../protocols/types";
6
+ import { getPoolByAlias, loadPoolState, savePoolState } from "./repository";
7
+ import { PoolCandidate, PoolCandidateState, PoolSelection } from "./types";
8
+ import { getProviderModelHealthMap } from "../providers/health";
9
+
10
+ const DEFAULT_COOLDOWN_MS = 60_000;
11
+
12
+ export async function selectPoolCandidates(
13
+ paths: StoragePaths,
14
+ alias: string,
15
+ requirements?: { requiredInput?: ModelModality[]; requiredOutput?: ModelModality[] },
16
+ routing?: { operation: ProtocolOperation; stream: boolean }
17
+ ): Promise<PoolSelection | null> {
18
+ const pool = await getPoolByAlias(paths, alias);
19
+ if (!pool) {
20
+ return null;
21
+ }
22
+
23
+ const healthMap = await getProviderModelHealthMap(paths);
24
+ const state = await loadPoolState(paths);
25
+ const now = Date.now();
26
+ const skipped: PoolSelection["skipped"] = [];
27
+ const candidates: PoolCandidate[] = [];
28
+
29
+ for (const candidate of pool.candidates) {
30
+ const candidateState = getCandidateState(state.candidates, candidate.id);
31
+ refreshWindows(candidateState, now);
32
+
33
+ const adapter = getProtocolAdapter(candidate.protocol);
34
+ if (!candidate.supportsRouting || !adapter) {
35
+ skipped.push({ candidateId: candidate.id, reason: "unsupported_protocol" });
36
+ continue;
37
+ }
38
+
39
+ if (!candidate.baseUrl) {
40
+ skipped.push({ candidateId: candidate.id, reason: "missing_base_url" });
41
+ continue;
42
+ }
43
+
44
+ if (routing) {
45
+ const support = adapter.supports({
46
+ operation: routing.operation,
47
+ stream: routing.stream,
48
+ capabilities: candidate.capabilities,
49
+ requiredInput: requirements?.requiredInput,
50
+ requiredOutput: requirements?.requiredOutput,
51
+ });
52
+ if (!support.supported) {
53
+ skipped.push({
54
+ candidateId: candidate.id,
55
+ reason: support.reason ?? "unsupported_operation",
56
+ });
57
+ continue;
58
+ }
59
+ }
60
+
61
+ const requiresApiKey = (candidate.auth?.type ?? "bearer") !== "none";
62
+ if (requiresApiKey && !candidate.apiKey) {
63
+ skipped.push({ candidateId: candidate.id, reason: "missing_api_key" });
64
+ continue;
65
+ }
66
+
67
+ if (!candidate.providerEnabled) {
68
+ skipped.push({ candidateId: candidate.id, reason: "provider_disabled" });
69
+ continue;
70
+ }
71
+ if (!candidate.modelEnabled) {
72
+ skipped.push({ candidateId: candidate.id, reason: "model_disabled" });
73
+ continue;
74
+ }
75
+ if (candidate.providerModelId) {
76
+ const health = healthMap[candidate.providerModelId];
77
+ if (health?.status === "down") {
78
+ skipped.push({ candidateId: candidate.id, reason: "health_down" });
79
+ continue;
80
+ }
81
+ }
82
+
83
+ if (
84
+ requirements &&
85
+ !supportsRequirements(candidate.capabilities, {
86
+ requiredInput: requirements.requiredInput,
87
+ requiredOutput: requirements.requiredOutput,
88
+ })
89
+ ) {
90
+ skipped.push({ candidateId: candidate.id, reason: "capability_mismatch" });
91
+ continue;
92
+ }
93
+
94
+ if (candidateState.cooldownUntil && new Date(candidateState.cooldownUntil).getTime() > now) {
95
+ skipped.push({ candidateId: candidate.id, reason: "cooldown" });
96
+ continue;
97
+ }
98
+
99
+ if (isRequestBudgetExhausted(candidate, candidateState)) {
100
+ skipped.push({ candidateId: candidate.id, reason: "request_budget_exhausted" });
101
+ continue;
102
+ }
103
+
104
+ candidates.push(candidate);
105
+ }
106
+
107
+ candidates.sort((a, b) => {
108
+ if (b.score !== a.score) {
109
+ return b.score - a.score;
110
+ }
111
+ const aState = getCandidateState(state.candidates, a.id);
112
+ const bState = getCandidateState(state.candidates, b.id);
113
+ const aFailureRatio = failureRatio(aState);
114
+ const bFailureRatio = failureRatio(bState);
115
+ if (aFailureRatio !== bFailureRatio) {
116
+ return aFailureRatio - bFailureRatio;
117
+ }
118
+ const aLatency = aState.latencyMsEwma ?? Number.POSITIVE_INFINITY;
119
+ const bLatency = bState.latencyMsEwma ?? Number.POSITIVE_INFINITY;
120
+ if (aLatency !== bLatency) {
121
+ return aLatency - bLatency;
122
+ }
123
+ return `${a.providerId}/${a.modelId}`.localeCompare(`${b.providerId}/${b.modelId}`);
124
+ });
125
+
126
+ await savePoolState(paths, state);
127
+
128
+ return {
129
+ pool,
130
+ candidates,
131
+ skipped,
132
+ };
133
+ }
134
+
135
+ export async function markPoolAttempt(
136
+ paths: StoragePaths,
137
+ candidate: PoolCandidate,
138
+ estimatedTokens: number
139
+ ): Promise<void> {
140
+ const state = await loadPoolState(paths);
141
+ const entry = getCandidateState(state.candidates, candidate.id);
142
+ const now = Date.now();
143
+ refreshWindows(entry, now);
144
+
145
+ entry.attempts += 1;
146
+ entry.minuteRequests += 1;
147
+ entry.dayRequests += 1;
148
+ if (estimatedTokens > 0) {
149
+ entry.minuteTokens += estimatedTokens;
150
+ entry.dayTokens += estimatedTokens;
151
+ }
152
+ entry.lastUsedAt = new Date(now).toISOString();
153
+
154
+ await savePoolState(paths, state);
155
+ }
156
+
157
+ export async function markPoolSuccess(
158
+ paths: StoragePaths,
159
+ candidate: PoolCandidate,
160
+ latencyMs: number,
161
+ consumedTokens = 0
162
+ ): Promise<void> {
163
+ const state = await loadPoolState(paths);
164
+ const entry = getCandidateState(state.candidates, candidate.id);
165
+ const now = Date.now();
166
+ refreshWindows(entry, now);
167
+
168
+ entry.successes += 1;
169
+ entry.lastError = undefined;
170
+ entry.cooldownUntil = undefined;
171
+ entry.latencyMsEwma = ewma(entry.latencyMsEwma, latencyMs);
172
+ entry.lastUsedAt = new Date(now).toISOString();
173
+ if (consumedTokens > 0) {
174
+ entry.minuteTokens += consumedTokens;
175
+ entry.dayTokens += consumedTokens;
176
+ }
177
+
178
+ await savePoolState(paths, state);
179
+ }
180
+
181
+ export async function markPoolFailure(
182
+ paths: StoragePaths,
183
+ candidate: PoolCandidate,
184
+ details: {
185
+ error: string;
186
+ rateLimited?: boolean;
187
+ headers?: Record<string, string | string[]>;
188
+ }
189
+ ): Promise<void> {
190
+ const state = await loadPoolState(paths);
191
+ const entry = getCandidateState(state.candidates, candidate.id);
192
+ const now = Date.now();
193
+ refreshWindows(entry, now);
194
+
195
+ entry.failures += 1;
196
+ entry.lastError = details.error;
197
+ entry.lastUsedAt = new Date(now).toISOString();
198
+
199
+ if (details.rateLimited) {
200
+ entry.rateLimitHits += 1;
201
+ const cooldownMs = deriveCooldownMsFromHeaders(details.headers ?? {}, DEFAULT_COOLDOWN_MS);
202
+ entry.cooldownUntil = new Date(now + cooldownMs).toISOString();
203
+ } else if (entry.failures >= 3) {
204
+ entry.cooldownUntil = new Date(now + 30_000).toISOString();
205
+ }
206
+
207
+ await savePoolState(paths, state);
208
+ }
209
+
210
+ export function buildEndpointFromCandidate(candidate: PoolCandidate): EndpointDoc {
211
+ const now = new Date();
212
+ return {
213
+ id: `pool:${candidate.id}`,
214
+ name: `${candidate.providerName}/${candidate.modelId}`,
215
+ baseUrl: candidate.baseUrl,
216
+ apiKey: candidate.apiKey,
217
+ disabled: false,
218
+ insecureTls: candidate.insecureTls === true,
219
+ priority: 0,
220
+ type: candidate.endpointType,
221
+ models: [
222
+ {
223
+ publicName: candidate.modelId,
224
+ upstreamModel: candidate.upstreamModel,
225
+ capabilities: candidate.capabilities,
226
+ },
227
+ ],
228
+ health: defaultHealth(),
229
+ createdAt: now,
230
+ updatedAt: now,
231
+ };
232
+ }
233
+
234
+ export function estimateTokensFromPayload(payload: Record<string, unknown>): number {
235
+ const maxTokens = payload.max_tokens;
236
+ if (typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0) {
237
+ return Math.floor(maxTokens);
238
+ }
239
+ return 0;
240
+ }
241
+
242
+ export function deriveCooldownMsFromHeaders(
243
+ headers: Record<string, string | string[]>,
244
+ fallbackMs: number
245
+ ): number {
246
+ const retryAfter = headerValue(headers, "retry-after");
247
+ if (retryAfter) {
248
+ const asNumber = Number(retryAfter);
249
+ if (Number.isFinite(asNumber) && asNumber >= 0) {
250
+ return Math.max(1_000, asNumber * 1_000);
251
+ }
252
+ const asDate = Date.parse(retryAfter);
253
+ if (Number.isFinite(asDate)) {
254
+ return Math.max(1_000, asDate - Date.now());
255
+ }
256
+ }
257
+
258
+ const resetKeys = ["x-ratelimit-reset", "x-ratelimit-reset-requests", "ratelimit-reset"];
259
+ for (const key of resetKeys) {
260
+ const value = headerValue(headers, key);
261
+ if (!value) {
262
+ continue;
263
+ }
264
+ const parsed = Number(value);
265
+ if (!Number.isFinite(parsed)) {
266
+ continue;
267
+ }
268
+ if (parsed > 1_000_000_000) {
269
+ return Math.max(1_000, parsed * 1_000 - Date.now());
270
+ }
271
+ return Math.max(1_000, parsed * 1_000);
272
+ }
273
+
274
+ return fallbackMs;
275
+ }
276
+
277
+ function headerValue(headers: Record<string, string | string[]>, key: string): string | undefined {
278
+ const exact = headers[key];
279
+ if (typeof exact === "string") {
280
+ return exact;
281
+ }
282
+ if (Array.isArray(exact) && exact.length > 0) {
283
+ return exact[0];
284
+ }
285
+ const found = Object.entries(headers).find(([name]) => name.toLowerCase() === key.toLowerCase());
286
+ if (!found) {
287
+ return undefined;
288
+ }
289
+ const value = found[1];
290
+ if (typeof value === "string") {
291
+ return value;
292
+ }
293
+ if (Array.isArray(value) && value.length > 0) {
294
+ return value[0];
295
+ }
296
+ return undefined;
297
+ }
298
+
299
+ function getCandidateState(
300
+ states: Record<string, PoolCandidateState>,
301
+ candidateId: string
302
+ ): PoolCandidateState {
303
+ const existing = states[candidateId];
304
+ if (existing) {
305
+ return existing;
306
+ }
307
+
308
+ const created: PoolCandidateState = {
309
+ candidateId,
310
+ attempts: 0,
311
+ successes: 0,
312
+ failures: 0,
313
+ rateLimitHits: 0,
314
+ minuteRequests: 0,
315
+ minuteTokens: 0,
316
+ hourRequests: 0,
317
+ hourTokens: 0,
318
+ dayRequests: 0,
319
+ dayTokens: 0,
320
+ weekRequests: 0,
321
+ weekTokens: 0,
322
+ };
323
+ states[candidateId] = created;
324
+ return created;
325
+ }
326
+
327
+ function refreshWindows(state: PoolCandidateState, now: number): void {
328
+ const minuteStart = state.minuteWindowStartedAt
329
+ ? new Date(state.minuteWindowStartedAt).getTime()
330
+ : Number.NaN;
331
+ if (!Number.isFinite(minuteStart) || now - minuteStart >= 60_000) {
332
+ state.minuteWindowStartedAt = new Date(now).toISOString();
333
+ state.minuteRequests = 0;
334
+ state.minuteTokens = 0;
335
+ }
336
+
337
+ const hourStart = state.hourWindowStartedAt
338
+ ? new Date(state.hourWindowStartedAt).getTime()
339
+ : Number.NaN;
340
+ if (!Number.isFinite(hourStart) || now - hourStart >= 3_600_000) {
341
+ state.hourWindowStartedAt = new Date(now).toISOString();
342
+ state.hourRequests = 0;
343
+ state.hourTokens = 0;
344
+ }
345
+
346
+ const dayStart = state.dayWindowStartedAt
347
+ ? new Date(state.dayWindowStartedAt).getTime()
348
+ : Number.NaN;
349
+ if (!Number.isFinite(dayStart) || now - dayStart >= 86_400_000) {
350
+ state.dayWindowStartedAt = new Date(now).toISOString();
351
+ state.dayRequests = 0;
352
+ state.dayTokens = 0;
353
+ }
354
+
355
+ const weekStart = state.weekWindowStartedAt
356
+ ? new Date(state.weekWindowStartedAt).getTime()
357
+ : Number.NaN;
358
+ if (!Number.isFinite(weekStart) || now - weekStart >= 604_800_000) {
359
+ state.weekWindowStartedAt = new Date(now).toISOString();
360
+ state.weekRequests = 0;
361
+ state.weekTokens = 0;
362
+ }
363
+
364
+ if (state.cooldownUntil && new Date(state.cooldownUntil).getTime() <= now) {
365
+ state.cooldownUntil = undefined;
366
+ }
367
+ }
368
+
369
+ function isRequestBudgetExhausted(candidate: PoolCandidate, state: PoolCandidateState): boolean {
370
+ if (typeof candidate.limits?.requestsPerMinute === "number") {
371
+ if (state.minuteRequests >= candidate.limits.requestsPerMinute) {
372
+ return true;
373
+ }
374
+ }
375
+ if (typeof candidate.limits?.requestsPerHour === "number") {
376
+ if (state.hourRequests >= candidate.limits.requestsPerHour) {
377
+ return true;
378
+ }
379
+ }
380
+ if (typeof candidate.limits?.requestsPerDay === "number") {
381
+ if (state.dayRequests >= candidate.limits.requestsPerDay) {
382
+ return true;
383
+ }
384
+ }
385
+ if (typeof candidate.limits?.requestsPerWeek === "number") {
386
+ if (state.weekRequests >= candidate.limits.requestsPerWeek) {
387
+ return true;
388
+ }
389
+ }
390
+ if (typeof candidate.limits?.tokensPerMinute === "number") {
391
+ if (state.minuteTokens >= candidate.limits.tokensPerMinute) {
392
+ return true;
393
+ }
394
+ }
395
+ if (typeof candidate.limits?.tokensPerHour === "number") {
396
+ if (state.hourTokens >= candidate.limits.tokensPerHour) {
397
+ return true;
398
+ }
399
+ }
400
+ if (typeof candidate.limits?.tokensPerDay === "number") {
401
+ if (state.dayTokens >= candidate.limits.tokensPerDay) {
402
+ return true;
403
+ }
404
+ }
405
+ if (typeof candidate.limits?.tokensPerWeek === "number") {
406
+ if (state.weekTokens >= candidate.limits.tokensPerWeek) {
407
+ return true;
408
+ }
409
+ }
410
+ return false;
411
+ }
412
+
413
+ function failureRatio(state: PoolCandidateState): number {
414
+ if (state.attempts <= 0) {
415
+ return 0;
416
+ }
417
+ return state.failures / state.attempts;
418
+ }
419
+
420
+ function ewma(previous: number | undefined, next: number, alpha = 0.2): number {
421
+ if (previous === undefined) {
422
+ return next;
423
+ }
424
+ return alpha * next + (1 - alpha) * previous;
425
+ }