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.
- package/.github/instructions/ui.instructions.md +42 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +71 -0
- package/.github/workflows/release.yml +48 -0
- package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
- package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
- package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
- package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
- package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
- package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +131 -0
- package/README.md +552 -0
- package/assets/agent-mode.png +0 -0
- package/assets/categorize.png +0 -0
- package/assets/dashboard.png +0 -0
- package/assets/endpoint-proxy.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/mcp-generate-image.png +0 -0
- package/assets/mcp-understand-image.png +0 -0
- package/assets/peek-token-flow.png +0 -0
- package/assets/playground.png +0 -0
- package/assets/sankey.png +0 -0
- package/cli/index.ts +2805 -0
- package/cli/legacyRewrite.ts +108 -0
- package/cli/modelRef.ts +24 -0
- package/dist/cli/index.js +2536 -0
- package/dist/cli/legacyRewrite.js +92 -0
- package/dist/cli/modelRef.js +20 -0
- package/dist/src/benchmark/artifacts.js +131 -0
- package/dist/src/benchmark/capabilityClassifier.js +81 -0
- package/dist/src/benchmark/capabilityStore.js +144 -0
- package/dist/src/benchmark/config.js +238 -0
- package/dist/src/benchmark/gates.js +118 -0
- package/dist/src/benchmark/jobs.js +252 -0
- package/dist/src/benchmark/runner.js +1847 -0
- package/dist/src/benchmark/schema.js +353 -0
- package/dist/src/benchmark/suites.js +314 -0
- package/dist/src/benchmark/tinyQaDataset.js +422 -0
- package/dist/src/benchmark/types.js +25 -0
- package/dist/src/config.js +47 -0
- package/dist/src/index.js +178 -0
- package/dist/src/mcp/client.js +215 -0
- package/dist/src/mcp/discovery.js +226 -0
- package/dist/src/mcp/policy.js +65 -0
- package/dist/src/mcp/registry.js +129 -0
- package/dist/src/mcp/service.js +460 -0
- package/dist/src/middleware/auth.js +179 -0
- package/dist/src/middleware/requestCapture.js +192 -0
- package/dist/src/middleware/requestStats.js +118 -0
- package/dist/src/pools/builder.js +132 -0
- package/dist/src/pools/repository.js +69 -0
- package/dist/src/pools/scheduler.js +360 -0
- package/dist/src/pools/types.js +2 -0
- package/dist/src/protocols/adapters/dashscope.js +267 -0
- package/dist/src/protocols/adapters/inferenceV2.js +346 -0
- package/dist/src/protocols/adapters/openai.js +27 -0
- package/dist/src/protocols/registry.js +99 -0
- package/dist/src/protocols/types.js +2 -0
- package/dist/src/providers/health.js +153 -0
- package/dist/src/providers/importer.js +289 -0
- package/dist/src/providers/modelRegistry.js +313 -0
- package/dist/src/providers/repository.js +361 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/routes/admin.js +531 -0
- package/dist/src/routes/audio.js +295 -0
- package/dist/src/routes/chat.js +240 -0
- package/dist/src/routes/embeddings.js +157 -0
- package/dist/src/routes/images.js +288 -0
- package/dist/src/routes/mcp.js +256 -0
- package/dist/src/routes/mcpService.js +100 -0
- package/dist/src/routes/models.js +48 -0
- package/dist/src/routes/responses.js +711 -0
- package/dist/src/routes/sessions.js +450 -0
- package/dist/src/routes/stats.js +270 -0
- package/dist/src/routes/ui.js +97 -0
- package/dist/src/routes/videos.js +107 -0
- package/dist/src/routing/router.js +338 -0
- package/dist/src/services/imageGeneration.js +280 -0
- package/dist/src/services/imageUnderstanding.js +352 -0
- package/dist/src/services/videoGeneration.js +79 -0
- package/dist/src/storage/captureRepository.js +1591 -0
- package/dist/src/storage/files.js +157 -0
- package/dist/src/storage/imageCache.js +346 -0
- package/dist/src/storage/repositories.js +388 -0
- package/dist/src/storage/sessionRepository.js +370 -0
- package/dist/src/storage/statsRepository.js +204 -0
- package/dist/src/transport/httpClient.js +126 -0
- package/dist/src/types.js +2 -0
- package/dist/src/utils/messageMedia.js +285 -0
- package/dist/src/utils/modelCapabilities.js +108 -0
- package/dist/src/utils/modelDiscovery.js +170 -0
- package/dist/src/version.js +5 -0
- package/dist/src/workers/captureRetention.js +25 -0
- package/dist/src/workers/configWatcher.js +91 -0
- package/dist/src/workers/healthChecker.js +21 -0
- package/dist/src/workers/statsRotation.js +41 -0
- package/docs/LLM/output_schema.md +312 -0
- package/docs/benchmark.md +208 -0
- package/docs/mcp-guidelines.md +125 -0
- package/docs/mcp-service.md +178 -0
- package/docs/opencode.md +86 -0
- package/docs/providers.md +79 -0
- package/examples/benchmark.config.yaml +28 -0
- package/examples/providers/alibaba-dashscope.yaml +88 -0
- package/examples/providers/alibaba-llm.yaml +64 -0
- package/examples/providers/alibaba-registry.yaml +7 -0
- package/examples/providers/inference-v2-ray.yaml +29 -0
- package/examples/scenarios/assets/omni-call-sample.wav +0 -0
- package/examples/scenarios/custom.jsonl +5 -0
- package/examples/scenarios/custom.yaml +40 -0
- package/model-form-v2.png +0 -0
- package/package.json +66 -0
- package/provider-form-v2.png +0 -0
- package/provider-form.png +0 -0
- package/scripts/manual-test.sh +11 -0
- package/scripts/version-from-git.js +23 -0
- package/src/benchmark/artifacts.ts +149 -0
- package/src/benchmark/capabilityClassifier.ts +99 -0
- package/src/benchmark/capabilityStore.ts +174 -0
- package/src/benchmark/config.ts +337 -0
- package/src/benchmark/gates.ts +164 -0
- package/src/benchmark/jobs.ts +312 -0
- package/src/benchmark/runner.ts +2519 -0
- package/src/benchmark/schema.ts +443 -0
- package/src/benchmark/suites.ts +323 -0
- package/src/benchmark/tinyQaDataset.ts +428 -0
- package/src/benchmark/types.ts +442 -0
- package/src/config.ts +44 -0
- package/src/index.ts +195 -0
- package/src/mcp/client.ts +305 -0
- package/src/mcp/discovery.ts +266 -0
- package/src/mcp/policy.ts +105 -0
- package/src/mcp/registry.ts +164 -0
- package/src/mcp/service.ts +611 -0
- package/src/middleware/auth.ts +251 -0
- package/src/middleware/requestCapture.ts +245 -0
- package/src/middleware/requestStats.ts +163 -0
- package/src/pools/builder.ts +159 -0
- package/src/pools/repository.ts +71 -0
- package/src/pools/scheduler.ts +425 -0
- package/src/pools/types.ts +117 -0
- package/src/protocols/adapters/dashscope.ts +335 -0
- package/src/protocols/adapters/inferenceV2.ts +428 -0
- package/src/protocols/adapters/openai.ts +32 -0
- package/src/protocols/registry.ts +117 -0
- package/src/protocols/types.ts +81 -0
- package/src/providers/health.ts +207 -0
- package/src/providers/importer.ts +402 -0
- package/src/providers/modelRegistry.ts +415 -0
- package/src/providers/repository.ts +439 -0
- package/src/providers/types.ts +113 -0
- package/src/routes/admin.ts +666 -0
- package/src/routes/audio.ts +372 -0
- package/src/routes/chat.ts +301 -0
- package/src/routes/embeddings.ts +197 -0
- package/src/routes/images.ts +356 -0
- package/src/routes/mcp.ts +320 -0
- package/src/routes/mcpService.ts +114 -0
- package/src/routes/models.ts +50 -0
- package/src/routes/responses.ts +872 -0
- package/src/routes/sessions.ts +558 -0
- package/src/routes/stats.ts +312 -0
- package/src/routes/ui.ts +96 -0
- package/src/routes/videos.ts +132 -0
- package/src/routing/router.ts +501 -0
- package/src/services/imageGeneration.ts +396 -0
- package/src/services/imageUnderstanding.ts +449 -0
- package/src/services/videoGeneration.ts +127 -0
- package/src/storage/captureRepository.ts +1835 -0
- package/src/storage/files.ts +178 -0
- package/src/storage/imageCache.ts +405 -0
- package/src/storage/repositories.ts +494 -0
- package/src/storage/sessionRepository.ts +419 -0
- package/src/storage/statsRepository.ts +238 -0
- package/src/transport/httpClient.ts +145 -0
- package/src/types.ts +322 -0
- package/src/utils/messageMedia.ts +293 -0
- package/src/utils/modelCapabilities.ts +161 -0
- package/src/utils/modelDiscovery.ts +203 -0
- package/src/workers/captureRetention.ts +25 -0
- package/src/workers/configWatcher.ts +115 -0
- package/src/workers/healthChecker.ts +22 -0
- package/src/workers/statsRotation.ts +49 -0
- package/tests/benchmarkAdminRoutes.test.ts +82 -0
- package/tests/benchmarkBasics.test.ts +116 -0
- package/tests/captureAdminRoutes.test.ts +420 -0
- package/tests/captureRepository.test.ts +797 -0
- package/tests/cliLegacyRewrite.test.ts +45 -0
- package/tests/imageGeneration.service.test.ts +107 -0
- package/tests/imageUnderstanding.service.test.ts +123 -0
- package/tests/mcpPolicy.test.ts +105 -0
- package/tests/mcpService.test.ts +1245 -0
- package/tests/modelRef.test.ts +23 -0
- package/tests/modelsRoutes.test.ts +154 -0
- package/tests/sessionMediaCache.test.ts +167 -0
- package/tests/statsRoutes.test.ts +323 -0
- package/tsconfig.json +15 -0
- package/ui/index.html +16 -0
- package/ui/package-lock.json +8521 -0
- package/ui/package.json +52 -0
- package/ui/postcss.config.js +6 -0
- package/ui/public/assets/apple-touch-icon.png +0 -0
- package/ui/public/assets/favicon-16.png +0 -0
- package/ui/public/assets/favicon-32.png +0 -0
- package/ui/public/assets/icon-192.png +0 -0
- package/ui/public/assets/icon-512.png +0 -0
- package/ui/src/App.tsx +27 -0
- package/ui/src/api/client.ts +1503 -0
- package/ui/src/components/EndpointUsageGuide.tsx +361 -0
- package/ui/src/components/Layout.tsx +124 -0
- package/ui/src/components/MessageContent.tsx +365 -0
- package/ui/src/components/ToolCallMessage.tsx +179 -0
- package/ui/src/components/ToolPicker.tsx +442 -0
- package/ui/src/components/messageContentParser.test.ts +41 -0
- package/ui/src/components/messageContentParser.ts +73 -0
- package/ui/src/components/thinkingPreview.test.ts +27 -0
- package/ui/src/components/thinkingPreview.ts +15 -0
- package/ui/src/components/toMermaidSankey.test.ts +78 -0
- package/ui/src/components/toMermaidSankey.ts +56 -0
- package/ui/src/components/ui/button.tsx +58 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/textarea.tsx +21 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +9 -0
- package/ui/src/pages/AgentPlayground.tsx +2010 -0
- package/ui/src/pages/Benchmark.tsx +988 -0
- package/ui/src/pages/Dashboard.tsx +581 -0
- package/ui/src/pages/Peek.tsx +962 -0
- package/ui/src/pages/Settings.tsx +2013 -0
- package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
- package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
- package/ui/src/pages/agentThinkingContent.test.ts +50 -0
- package/ui/src/pages/agentThinkingContent.ts +57 -0
- package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
- package/ui/src/pages/dashboardTokenUsage.ts +36 -0
- package/ui/src/pages/imageUpload.test.ts +39 -0
- package/ui/src/pages/imageUpload.ts +71 -0
- package/ui/src/pages/peekFilters.test.ts +29 -0
- package/ui/src/pages/peekFilters.ts +13 -0
- package/ui/src/pages/peekMedia.test.ts +58 -0
- package/ui/src/pages/peekMedia.ts +148 -0
- package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
- package/ui/src/pages/sessionAutoTitle.ts +106 -0
- package/ui/src/stores/settings.ts +58 -0
- package/ui/src/styles/globals.css +223 -0
- package/ui/src/vite-env.d.ts +8 -0
- package/ui/tailwind.config.js +106 -0
- package/ui/tsconfig.json +32 -0
- package/ui/vite.config.ts +37 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
import { aggregateStats, readStatsForWindow } from "../storage/statsRepository";
|
|
3
|
+
import { StoragePaths } from "../storage/files";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stats API Routes
|
|
7
|
+
*
|
|
8
|
+
* Provides endpoints for querying request statistics:
|
|
9
|
+
* - GET /admin/stats - aggregated statistics for time window
|
|
10
|
+
* - GET /admin/stats/raw - raw stats entries for detailed analysis
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface StatsQuery {
|
|
14
|
+
window?: string; // e.g., "1h", "24h", "7d"
|
|
15
|
+
timeZone?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function registerStatsRoutes(
|
|
19
|
+
app: FastifyInstance,
|
|
20
|
+
paths: StoragePaths
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
// GET /admin/stats - aggregated statistics
|
|
23
|
+
app.get("/admin/stats", async (req: FastifyRequest<{ Querystring: StatsQuery }>, reply: FastifyReply) => {
|
|
24
|
+
const windowMs = parseWindow(req.query.window ?? "24h");
|
|
25
|
+
const timeZone = normalizeTimeZone(req.query.timeZone);
|
|
26
|
+
|
|
27
|
+
if (windowMs === null) {
|
|
28
|
+
reply.code(400).send({
|
|
29
|
+
error: {
|
|
30
|
+
message: "Invalid window format. Use format like '1h', '24h', '7d'"
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const stats = await aggregateStats(paths, windowMs);
|
|
38
|
+
reply.send({
|
|
39
|
+
...stats,
|
|
40
|
+
timeZone,
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
app.log.error({ error }, "Failed to aggregate stats");
|
|
44
|
+
reply.code(500).send({ error: { message: "Failed to retrieve statistics" } });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// GET /admin/stats/raw - raw stats entries
|
|
49
|
+
app.get("/admin/stats/raw", async (req: FastifyRequest<{ Querystring: StatsQuery & { limit?: string } }>, reply: FastifyReply) => {
|
|
50
|
+
const windowDays = parseWindowDays(req.query.window ?? "1d");
|
|
51
|
+
const limit = Math.min(parseInt(req.query.limit ?? "1000", 10), 10000);
|
|
52
|
+
|
|
53
|
+
if (windowDays === null) {
|
|
54
|
+
reply.code(400).send({
|
|
55
|
+
error: { message: "Invalid window format" }
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const stats = await readStatsForWindow(paths, windowDays);
|
|
62
|
+
// Return most recent entries up to limit
|
|
63
|
+
const entries = stats.slice(-limit);
|
|
64
|
+
reply.send({
|
|
65
|
+
window: `${windowDays}d`,
|
|
66
|
+
count: entries.length,
|
|
67
|
+
totalInWindow: stats.length,
|
|
68
|
+
entries
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
app.log.error({ error }, "Failed to read raw stats");
|
|
72
|
+
reply.code(500).send({ error: { message: "Failed to retrieve statistics" } });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// GET /admin/stats/latency - latency distribution
|
|
77
|
+
app.get("/admin/stats/latency", async (req: FastifyRequest<{ Querystring: StatsQuery }>, reply: FastifyReply) => {
|
|
78
|
+
const windowMs = parseWindow(req.query.window ?? "7d");
|
|
79
|
+
const timeZone = normalizeTimeZone(req.query.timeZone);
|
|
80
|
+
|
|
81
|
+
if (windowMs === null) {
|
|
82
|
+
reply.code(400).send({ error: { message: "Invalid window format" } });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const stats = await selectStatsForWindow(paths, windowMs);
|
|
88
|
+
const latencies = stats.map((s) => s.latencyMs).sort((a, b) => a - b);
|
|
89
|
+
const window = formatWindowString(windowMs);
|
|
90
|
+
|
|
91
|
+
if (latencies.length === 0) {
|
|
92
|
+
reply.send({
|
|
93
|
+
window,
|
|
94
|
+
timeZone,
|
|
95
|
+
count: 0,
|
|
96
|
+
min: null,
|
|
97
|
+
max: null,
|
|
98
|
+
avg: null,
|
|
99
|
+
p50: null,
|
|
100
|
+
p95: null,
|
|
101
|
+
p99: null,
|
|
102
|
+
histogram: {}
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Create histogram buckets
|
|
108
|
+
const buckets = [50, 100, 200, 500, 1000, 2000, 5000, 10000];
|
|
109
|
+
const histogram: Record<string, number> = {};
|
|
110
|
+
|
|
111
|
+
for (const bucket of buckets) {
|
|
112
|
+
histogram[`<${bucket}ms`] = 0;
|
|
113
|
+
}
|
|
114
|
+
histogram[">10000ms"] = 0;
|
|
115
|
+
|
|
116
|
+
for (const latency of latencies) {
|
|
117
|
+
let assigned = false;
|
|
118
|
+
for (const bucket of buckets) {
|
|
119
|
+
if (latency < bucket) {
|
|
120
|
+
histogram[`<${bucket}ms`]++;
|
|
121
|
+
assigned = true;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!assigned) {
|
|
126
|
+
histogram[">10000ms"]++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
reply.send({
|
|
131
|
+
window,
|
|
132
|
+
timeZone,
|
|
133
|
+
count: latencies.length,
|
|
134
|
+
min: latencies[0],
|
|
135
|
+
max: latencies[latencies.length - 1],
|
|
136
|
+
avg: Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length),
|
|
137
|
+
p50: percentile(latencies, 50),
|
|
138
|
+
p95: percentile(latencies, 95),
|
|
139
|
+
p99: percentile(latencies, 99),
|
|
140
|
+
histogram
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
app.log.error({ error }, "Failed to compute latency distribution");
|
|
144
|
+
reply.code(500).send({ error: { message: "Failed to retrieve statistics" } });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// GET /admin/stats/tokens - token usage over time
|
|
149
|
+
app.get("/admin/stats/tokens", async (req: FastifyRequest<{ Querystring: StatsQuery }>, reply: FastifyReply) => {
|
|
150
|
+
const windowMs = parseWindow(req.query.window ?? "7d");
|
|
151
|
+
const timeZone = normalizeTimeZone(req.query.timeZone);
|
|
152
|
+
|
|
153
|
+
if (windowMs === null) {
|
|
154
|
+
reply.code(400).send({ error: { message: "Invalid window format" } });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const stats = await selectStatsForWindow(paths, windowMs);
|
|
160
|
+
const bucketGranularity = windowMs <= 24 * 60 * 60 * 1000 ? "hour" : "day";
|
|
161
|
+
const byDay: Record<string, {
|
|
162
|
+
count: number;
|
|
163
|
+
tokens: number;
|
|
164
|
+
estimated: number;
|
|
165
|
+
inputTokens: number;
|
|
166
|
+
outputTokens: number;
|
|
167
|
+
splitUnknown: number;
|
|
168
|
+
}> = {};
|
|
169
|
+
let tokenEstimatedCount = 0;
|
|
170
|
+
let splitUnknownCount = 0;
|
|
171
|
+
let totalInputTokens = 0;
|
|
172
|
+
let totalOutputTokens = 0;
|
|
173
|
+
|
|
174
|
+
for (const stat of stats) {
|
|
175
|
+
const bucket = formatTokenBucket(stat.timestamp, bucketGranularity, timeZone);
|
|
176
|
+
if (!byDay[bucket]) {
|
|
177
|
+
byDay[bucket] = {
|
|
178
|
+
count: 0,
|
|
179
|
+
tokens: 0,
|
|
180
|
+
estimated: 0,
|
|
181
|
+
inputTokens: 0,
|
|
182
|
+
outputTokens: 0,
|
|
183
|
+
splitUnknown: 0,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
byDay[bucket].count++;
|
|
187
|
+
if (stat.totalTokens !== null && stat.totalTokens !== undefined) {
|
|
188
|
+
byDay[bucket].tokens += stat.totalTokens;
|
|
189
|
+
} else {
|
|
190
|
+
byDay[bucket].estimated++;
|
|
191
|
+
tokenEstimatedCount += 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const promptTokens = stat.promptTokens;
|
|
195
|
+
const completionTokens = stat.completionTokens;
|
|
196
|
+
const hasSplit =
|
|
197
|
+
promptTokens !== null &&
|
|
198
|
+
promptTokens !== undefined &&
|
|
199
|
+
completionTokens !== null &&
|
|
200
|
+
completionTokens !== undefined;
|
|
201
|
+
if (hasSplit) {
|
|
202
|
+
byDay[bucket].inputTokens += promptTokens;
|
|
203
|
+
byDay[bucket].outputTokens += completionTokens;
|
|
204
|
+
totalInputTokens += promptTokens;
|
|
205
|
+
totalOutputTokens += completionTokens;
|
|
206
|
+
} else {
|
|
207
|
+
byDay[bucket].splitUnknown++;
|
|
208
|
+
splitUnknownCount += 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const days = Object.entries(byDay)
|
|
213
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
214
|
+
.map(([date, data]) => ({ date, ...data }));
|
|
215
|
+
|
|
216
|
+
const totalTokens = stats.reduce((sum, s) => sum + (s.totalTokens ?? 0), 0);
|
|
217
|
+
|
|
218
|
+
reply.send({
|
|
219
|
+
window: formatWindowString(windowMs),
|
|
220
|
+
totalTokens,
|
|
221
|
+
totalInputTokens,
|
|
222
|
+
totalOutputTokens,
|
|
223
|
+
totalRequests: stats.length,
|
|
224
|
+
avgTokensPerRequest: stats.length > 0 ? Math.round(totalTokens / stats.length) : 0,
|
|
225
|
+
byDay: days,
|
|
226
|
+
tokenEstimatedCount,
|
|
227
|
+
tokenEstimatedRate: stats.length > 0 ? tokenEstimatedCount / stats.length : 0,
|
|
228
|
+
splitUnknownCount,
|
|
229
|
+
splitUnknownRate: stats.length > 0 ? splitUnknownCount / stats.length : 0,
|
|
230
|
+
bucketGranularity,
|
|
231
|
+
bucketTimeZone: timeZone,
|
|
232
|
+
});
|
|
233
|
+
} catch (error) {
|
|
234
|
+
app.log.error({ error }, "Failed to compute token usage");
|
|
235
|
+
reply.code(500).send({ error: { message: "Failed to retrieve statistics" } });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseWindow(window: string): number | null {
|
|
241
|
+
const match = window.match(/^(\d+)(h|d|m)$/);
|
|
242
|
+
if (!match) return null;
|
|
243
|
+
|
|
244
|
+
const value = parseInt(match[1], 10);
|
|
245
|
+
const unit = match[2];
|
|
246
|
+
|
|
247
|
+
switch (unit) {
|
|
248
|
+
case "m": return value * 60 * 1000;
|
|
249
|
+
case "h": return value * 60 * 60 * 1000;
|
|
250
|
+
case "d": return value * 24 * 60 * 60 * 1000;
|
|
251
|
+
default: return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseWindowDays(window: string): number | null {
|
|
256
|
+
const ms = parseWindow(window);
|
|
257
|
+
if (ms === null) return null;
|
|
258
|
+
return Math.ceil(ms / (24 * 60 * 60 * 1000));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function percentile(sortedArr: number[], p: number): number {
|
|
262
|
+
if (sortedArr.length === 0) return 0;
|
|
263
|
+
const index = Math.ceil((p / 100) * sortedArr.length) - 1;
|
|
264
|
+
return sortedArr[Math.max(0, index)];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function selectStatsForWindow(paths: StoragePaths, windowMs: number) {
|
|
268
|
+
const windowDays = Math.ceil(windowMs / (24 * 60 * 60 * 1000));
|
|
269
|
+
const stats = await readStatsForWindow(paths, windowDays);
|
|
270
|
+
const cutoff = Date.now() - windowMs;
|
|
271
|
+
return stats.filter((s) => s.timestamp.getTime() >= cutoff);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function formatWindowString(ms: number): string {
|
|
275
|
+
const hours = ms / (60 * 60 * 1000);
|
|
276
|
+
if (hours < 24) return `${Math.round(hours)}h`;
|
|
277
|
+
return `${Math.round(hours / 24)}d`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function formatTokenBucket(timestamp: Date, granularity: "hour" | "day", timeZone: string): string {
|
|
281
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
282
|
+
timeZone,
|
|
283
|
+
year: "numeric",
|
|
284
|
+
month: "2-digit",
|
|
285
|
+
day: "2-digit",
|
|
286
|
+
...(granularity === "hour"
|
|
287
|
+
? {
|
|
288
|
+
hour: "2-digit",
|
|
289
|
+
hourCycle: "h23" as const,
|
|
290
|
+
}
|
|
291
|
+
: {}),
|
|
292
|
+
});
|
|
293
|
+
const parts = formatter.formatToParts(timestamp);
|
|
294
|
+
const year = parts.find((part) => part.type === "year")?.value ?? "0000";
|
|
295
|
+
const month = parts.find((part) => part.type === "month")?.value ?? "00";
|
|
296
|
+
const day = parts.find((part) => part.type === "day")?.value ?? "00";
|
|
297
|
+
if (granularity === "day") {
|
|
298
|
+
return `${year}-${month}-${day}`;
|
|
299
|
+
}
|
|
300
|
+
const hour = parts.find((part) => part.type === "hour")?.value ?? "00";
|
|
301
|
+
return `${year}-${month}-${day}T${hour}:00`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function normalizeTimeZone(input: string | undefined): string {
|
|
305
|
+
if (!input) return "UTC";
|
|
306
|
+
try {
|
|
307
|
+
new Intl.DateTimeFormat("en-US", { timeZone: input });
|
|
308
|
+
return input;
|
|
309
|
+
} catch {
|
|
310
|
+
return "UTC";
|
|
311
|
+
}
|
|
312
|
+
}
|
package/src/routes/ui.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { FastifyInstance } from "fastify";
|
|
2
|
+
import fastifyStatic from "@fastify/static";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register UI routes to serve the React frontend.
|
|
8
|
+
*
|
|
9
|
+
* - Serves static files from ui/dist at /ui/*
|
|
10
|
+
* - Provides SPA fallback for client-side routing
|
|
11
|
+
*/
|
|
12
|
+
export async function registerUiRoutes(app: FastifyInstance): Promise<void> {
|
|
13
|
+
// When running from dist/src/routes/ui.js, we need to go up to project root
|
|
14
|
+
// __dirname = dist/src/routes -> go up 3 levels to project root, then into ui/dist
|
|
15
|
+
const uiDistPath = path.join(__dirname, "..", "..", "..", "ui", "dist");
|
|
16
|
+
|
|
17
|
+
// Check if UI is built
|
|
18
|
+
const uiExists = await checkUiExists(uiDistPath);
|
|
19
|
+
|
|
20
|
+
if (!uiExists) {
|
|
21
|
+
// UI not built - serve a placeholder
|
|
22
|
+
app.get("/ui", async (_req, reply) => {
|
|
23
|
+
reply.type("text/html").send(`
|
|
24
|
+
<!DOCTYPE html>
|
|
25
|
+
<html>
|
|
26
|
+
<head>
|
|
27
|
+
<title>Waypoi UI</title>
|
|
28
|
+
<style>
|
|
29
|
+
body {
|
|
30
|
+
font-family: monospace;
|
|
31
|
+
background: #0a0a0c;
|
|
32
|
+
color: #e5e2d9;
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: center;
|
|
36
|
+
height: 100vh;
|
|
37
|
+
margin: 0;
|
|
38
|
+
}
|
|
39
|
+
.container { text-align: center; }
|
|
40
|
+
h1 { color: #eab308; }
|
|
41
|
+
code { background: #1a1a1e; padding: 4px 8px; border-radius: 4px; }
|
|
42
|
+
</style>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<div class="container">
|
|
46
|
+
<h1>Waypoi UI</h1>
|
|
47
|
+
<p>UI not built. Run:</p>
|
|
48
|
+
<p><code>cd ui && npm install && npm run build</code></p>
|
|
49
|
+
<p>Then restart the server.</p>
|
|
50
|
+
</div>
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
53
|
+
`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.get("/ui/*", async (_req, reply) => {
|
|
57
|
+
reply.redirect("/ui");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Register static file serving
|
|
64
|
+
await app.register(fastifyStatic, {
|
|
65
|
+
root: uiDistPath,
|
|
66
|
+
prefix: "/ui/",
|
|
67
|
+
decorateReply: false,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// SPA fallback - serve index.html for all /ui/* routes that don't match a file
|
|
71
|
+
app.setNotFoundHandler(async (req, reply) => {
|
|
72
|
+
if (req.url.startsWith("/ui")) {
|
|
73
|
+
const indexPath = path.join(uiDistPath, "index.html");
|
|
74
|
+
try {
|
|
75
|
+
const html = await fs.readFile(indexPath, "utf8");
|
|
76
|
+
reply.type("text/html").send(html);
|
|
77
|
+
} catch {
|
|
78
|
+
reply.code(404).send({ error: { message: "UI not found" } });
|
|
79
|
+
}
|
|
80
|
+
} else if (req.url === "/" && req.headers.accept?.includes("text/html")) {
|
|
81
|
+
reply.redirect("/ui");
|
|
82
|
+
} else {
|
|
83
|
+
reply.code(404).send({ error: { message: "Not found" } });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function checkUiExists(distPath: string): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
const indexPath = path.join(distPath, "index.html");
|
|
91
|
+
await fs.access(indexPath);
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { logRequest } from "../storage/repositories";
|
|
4
|
+
import { RequestLog, VideoGenerationRequest } from "../types";
|
|
5
|
+
import { StoragePaths } from "../storage/files";
|
|
6
|
+
import { resolveVideoGenerationModel, runVideoGeneration } from "../services/videoGeneration";
|
|
7
|
+
import { setCaptureError, setCaptureRouting } from "../middleware/requestCapture";
|
|
8
|
+
|
|
9
|
+
export async function registerVideoRoutes(app: FastifyInstance, paths: StoragePaths): Promise<void> {
|
|
10
|
+
app.post("/v1/videos/generations", async (req: FastifyRequest, reply: FastifyReply) => {
|
|
11
|
+
const body = req.body as VideoGenerationRequest | undefined;
|
|
12
|
+
|
|
13
|
+
if (!body?.prompt) {
|
|
14
|
+
reply.code(400).send({ error: { message: "prompt is required" } });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const model = await resolveVideoGenerationModel(paths, body.model);
|
|
19
|
+
if (!model) {
|
|
20
|
+
reply.code(400).send({ error: { message: "No video generation model available. Add or enable a provider model." } });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const requestId = randomUUID();
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
|
|
28
|
+
req.raw.on("close", () => controller.abort());
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const generated = await runVideoGeneration(
|
|
32
|
+
paths,
|
|
33
|
+
{ ...body, model },
|
|
34
|
+
req.headers as Record<string, string | string[] | undefined>,
|
|
35
|
+
controller.signal
|
|
36
|
+
);
|
|
37
|
+
setHeaders(reply, generated.headers);
|
|
38
|
+
reply.code(generated.statusCode).send(generated.payload);
|
|
39
|
+
setCaptureRouting(reply, {
|
|
40
|
+
publicModel: model,
|
|
41
|
+
endpointId: generated.route.endpointId,
|
|
42
|
+
endpointName: generated.route.endpointName,
|
|
43
|
+
upstreamModel: generated.route.upstreamModel,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await logRequest(paths, buildLog(
|
|
47
|
+
requestId,
|
|
48
|
+
model,
|
|
49
|
+
{
|
|
50
|
+
attempt: {
|
|
51
|
+
endpoint: {
|
|
52
|
+
id: generated.route.endpointId,
|
|
53
|
+
name: generated.route.endpointName,
|
|
54
|
+
},
|
|
55
|
+
upstreamModel: generated.route.upstreamModel,
|
|
56
|
+
response: {
|
|
57
|
+
statusCode: generated.statusCode,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
Date.now() - start,
|
|
62
|
+
false
|
|
63
|
+
));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const errorType = (error as { type?: string }).type ?? (error as Error).name;
|
|
66
|
+
setCaptureError(reply, { type: errorType, message: (error as Error).message });
|
|
67
|
+
await logRequest(paths, {
|
|
68
|
+
requestId,
|
|
69
|
+
ts: new Date(),
|
|
70
|
+
route: { publicModel: model },
|
|
71
|
+
request: { stream: false },
|
|
72
|
+
result: {
|
|
73
|
+
errorType,
|
|
74
|
+
errorMessage: (error as Error).message
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
if (errorType === "invalid_request") {
|
|
78
|
+
reply.code(400).send({ error: { message: (error as Error).message } });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (errorType === "tls_verify_failed") {
|
|
82
|
+
reply.code(502).send({ error: { message: (error as Error).message, type: errorType } });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const status =
|
|
86
|
+
errorType === "no_endpoints" ||
|
|
87
|
+
errorType === "protocol_stream_unsupported" ||
|
|
88
|
+
errorType === "unsupported_protocol" ||
|
|
89
|
+
errorType === "invalid_protocol_config"
|
|
90
|
+
? 400
|
|
91
|
+
: errorType === "rate_limited"
|
|
92
|
+
? 429
|
|
93
|
+
: 502;
|
|
94
|
+
reply.code(status).send({ error: { message: "Video generation unavailable", type: errorType } });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function setHeaders(reply: FastifyReply, headers: Record<string, string | string[]>): void {
|
|
100
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
reply.header(key.toLowerCase(), value.join(", "));
|
|
103
|
+
} else {
|
|
104
|
+
reply.header(key.toLowerCase(), value);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildLog(
|
|
110
|
+
requestId: string,
|
|
111
|
+
model: string,
|
|
112
|
+
outcome: { attempt: { endpoint: { id: string; name: string }; upstreamModel: string; response: { statusCode: number } } },
|
|
113
|
+
latencyMs: number,
|
|
114
|
+
stream: boolean
|
|
115
|
+
): RequestLog {
|
|
116
|
+
return {
|
|
117
|
+
requestId,
|
|
118
|
+
ts: new Date(),
|
|
119
|
+
route: {
|
|
120
|
+
publicModel: model,
|
|
121
|
+
endpointId: outcome.attempt.endpoint.id,
|
|
122
|
+
endpointName: outcome.attempt.endpoint.name,
|
|
123
|
+
upstreamModel: outcome.attempt.upstreamModel
|
|
124
|
+
},
|
|
125
|
+
request: { stream },
|
|
126
|
+
result: {
|
|
127
|
+
statusCode: outcome.attempt.response.statusCode,
|
|
128
|
+
latencyMs,
|
|
129
|
+
totalTokens: null
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|