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,23 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseModelRef } from "../cli/modelRef.ts";
|
|
4
|
+
|
|
5
|
+
test("parseModelRef parses provider/model format", () => {
|
|
6
|
+
const parsed = parseModelRef("pcai/gpt-4o");
|
|
7
|
+
assert.equal(parsed.providerId, "pcai");
|
|
8
|
+
assert.equal(parsed.modelId, "gpt-4o");
|
|
9
|
+
assert.equal(parsed.canonical, "pcai/gpt-4o");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("parseModelRef leaves model-only refs unchanged", () => {
|
|
13
|
+
const parsed = parseModelRef("gpt-4o");
|
|
14
|
+
assert.equal(parsed.providerId, undefined);
|
|
15
|
+
assert.equal(parsed.modelId, "gpt-4o");
|
|
16
|
+
assert.equal(parsed.canonical, undefined);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("parseModelRef treats malformed refs as model-only", () => {
|
|
20
|
+
const parsed = parseModelRef("pcai/");
|
|
21
|
+
assert.equal(parsed.providerId, undefined);
|
|
22
|
+
assert.equal(parsed.modelId, "pcai/");
|
|
23
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
import Fastify from "fastify";
|
|
6
|
+
import { registerModelsRoutes } from "../src/routes/models";
|
|
7
|
+
import type { StoragePaths } from "../src/storage/files";
|
|
8
|
+
|
|
9
|
+
function makePaths(baseDir: string): StoragePaths {
|
|
10
|
+
return {
|
|
11
|
+
baseDir,
|
|
12
|
+
configPath: path.join(baseDir, "config.yaml"),
|
|
13
|
+
healthPath: path.join(baseDir, "health.json"),
|
|
14
|
+
providerHealthPath: path.join(baseDir, "providers_health.json"),
|
|
15
|
+
requestLogPath: path.join(baseDir, "request_logs.jsonl"),
|
|
16
|
+
providersPath: path.join(baseDir, "providers.json"),
|
|
17
|
+
poolsPath: path.join(baseDir, "pools.json"),
|
|
18
|
+
poolStatePath: path.join(baseDir, "pool_state.json"),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function makeWorkspaceTempDir(prefix: string): Promise<string> {
|
|
23
|
+
const base = path.join(process.cwd(), "tmp");
|
|
24
|
+
await fs.mkdir(base, { recursive: true });
|
|
25
|
+
return fs.mkdtemp(path.join(base, prefix));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function writeProvidersFixture(paths: StoragePaths): Promise<void> {
|
|
29
|
+
const providers = {
|
|
30
|
+
version: 3,
|
|
31
|
+
updatedAt: new Date().toISOString(),
|
|
32
|
+
providers: [
|
|
33
|
+
{
|
|
34
|
+
id: "prov-a",
|
|
35
|
+
name: "Provider A",
|
|
36
|
+
protocol: "openai",
|
|
37
|
+
baseUrl: "http://example-a.test",
|
|
38
|
+
enabled: true,
|
|
39
|
+
supportsRouting: true,
|
|
40
|
+
importedAt: new Date().toISOString(),
|
|
41
|
+
models: [
|
|
42
|
+
{
|
|
43
|
+
providerModelId: "prov-a/model-up",
|
|
44
|
+
providerId: "prov-a",
|
|
45
|
+
modelId: "model-up",
|
|
46
|
+
upstreamModel: "model-up",
|
|
47
|
+
enabled: true,
|
|
48
|
+
free: false,
|
|
49
|
+
modalities: ["text"],
|
|
50
|
+
endpointType: "llm",
|
|
51
|
+
capabilities: { input: ["text"], output: ["text"] },
|
|
52
|
+
aliases: [],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
providerModelId: "prov-a/model-down",
|
|
56
|
+
providerId: "prov-a",
|
|
57
|
+
modelId: "model-down",
|
|
58
|
+
upstreamModel: "model-down",
|
|
59
|
+
enabled: true,
|
|
60
|
+
free: false,
|
|
61
|
+
modalities: ["text"],
|
|
62
|
+
endpointType: "llm",
|
|
63
|
+
capabilities: { input: ["text"], output: ["text"] },
|
|
64
|
+
aliases: [],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
providerModelId: "prov-a/model-unknown",
|
|
68
|
+
providerId: "prov-a",
|
|
69
|
+
modelId: "model-unknown",
|
|
70
|
+
upstreamModel: "model-unknown",
|
|
71
|
+
enabled: true,
|
|
72
|
+
free: false,
|
|
73
|
+
modalities: ["text"],
|
|
74
|
+
endpointType: "llm",
|
|
75
|
+
capabilities: { input: ["text"], output: ["text"] },
|
|
76
|
+
aliases: [],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await fs.writeFile(paths.providersPath, JSON.stringify(providers, null, 2), "utf8");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeProviderHealthFixture(paths: StoragePaths): Promise<void> {
|
|
87
|
+
const health = {
|
|
88
|
+
models: {
|
|
89
|
+
"prov-a/model-up": {
|
|
90
|
+
status: "up",
|
|
91
|
+
consecutiveFailures: 0,
|
|
92
|
+
lastCheckedAt: "2026-03-06T12:00:00.000Z",
|
|
93
|
+
latencyMsEwma: 123.4,
|
|
94
|
+
},
|
|
95
|
+
"prov-a/model-down": {
|
|
96
|
+
status: "down",
|
|
97
|
+
consecutiveFailures: 4,
|
|
98
|
+
lastCheckedAt: "2026-03-06T12:01:00.000Z",
|
|
99
|
+
lastError: "status 503",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
await fs.writeFile(paths.providerHealthPath, JSON.stringify(health, null, 2), "utf8");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test("/v1/models includes additive waypoi_health metadata", async () => {
|
|
107
|
+
const baseDir = await makeWorkspaceTempDir("waypoi-models-test-");
|
|
108
|
+
const paths = makePaths(baseDir);
|
|
109
|
+
await writeProvidersFixture(paths);
|
|
110
|
+
await writeProviderHealthFixture(paths);
|
|
111
|
+
|
|
112
|
+
const app = Fastify();
|
|
113
|
+
await registerModelsRoutes(app, paths);
|
|
114
|
+
|
|
115
|
+
const res = await app.inject({ method: "GET", url: "/v1/models" });
|
|
116
|
+
assert.equal(res.statusCode, 200);
|
|
117
|
+
const json = res.json() as {
|
|
118
|
+
data: Array<{ id: string; waypoi_health?: { status: "up" | "down" | "unknown"; consecutiveFailures?: number } }>;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const up = json.data.find((entry) => entry.id === "prov-a/model-up");
|
|
122
|
+
const down = json.data.find((entry) => entry.id === "prov-a/model-down");
|
|
123
|
+
const unknown = json.data.find((entry) => entry.id === "prov-a/model-unknown");
|
|
124
|
+
assert.ok(up);
|
|
125
|
+
assert.ok(down);
|
|
126
|
+
assert.ok(unknown);
|
|
127
|
+
assert.equal(up.waypoi_health?.status, "up");
|
|
128
|
+
assert.equal(down.waypoi_health?.status, "down");
|
|
129
|
+
assert.equal(down.waypoi_health?.consecutiveFailures, 4);
|
|
130
|
+
assert.equal(unknown.waypoi_health?.status, "unknown");
|
|
131
|
+
|
|
132
|
+
await app.close();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("/v1/models?available_only=true excludes down but keeps up and unknown", async () => {
|
|
136
|
+
const baseDir = await makeWorkspaceTempDir("waypoi-models-test-");
|
|
137
|
+
const paths = makePaths(baseDir);
|
|
138
|
+
await writeProvidersFixture(paths);
|
|
139
|
+
await writeProviderHealthFixture(paths);
|
|
140
|
+
|
|
141
|
+
const app = Fastify();
|
|
142
|
+
await registerModelsRoutes(app, paths);
|
|
143
|
+
|
|
144
|
+
const res = await app.inject({ method: "GET", url: "/v1/models?available_only=true" });
|
|
145
|
+
assert.equal(res.statusCode, 200);
|
|
146
|
+
const json = res.json() as { data: Array<{ id: string }> };
|
|
147
|
+
const ids = json.data.map((entry) => entry.id);
|
|
148
|
+
|
|
149
|
+
assert.ok(ids.includes("prov-a/model-up"));
|
|
150
|
+
assert.ok(ids.includes("prov-a/model-unknown"));
|
|
151
|
+
assert.ok(!ids.includes("prov-a/model-down"));
|
|
152
|
+
|
|
153
|
+
await app.close();
|
|
154
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { promises as fs } from 'fs'
|
|
5
|
+
import {
|
|
6
|
+
createSession,
|
|
7
|
+
addMessage,
|
|
8
|
+
deleteSession,
|
|
9
|
+
getSession,
|
|
10
|
+
resolveSessionsDir,
|
|
11
|
+
} from '../src/storage/sessionRepository'
|
|
12
|
+
import {
|
|
13
|
+
getCacheStats,
|
|
14
|
+
getMediaPath,
|
|
15
|
+
getMediaRefCount,
|
|
16
|
+
storeMedia,
|
|
17
|
+
} from '../src/storage/imageCache'
|
|
18
|
+
import type { StoragePaths } from '../src/storage/files'
|
|
19
|
+
|
|
20
|
+
function makePaths(baseDir: string): StoragePaths {
|
|
21
|
+
return {
|
|
22
|
+
baseDir,
|
|
23
|
+
configPath: path.join(baseDir, 'config.yaml'),
|
|
24
|
+
healthPath: path.join(baseDir, 'health.json'),
|
|
25
|
+
providerHealthPath: path.join(baseDir, 'providers_health.json'),
|
|
26
|
+
requestLogPath: path.join(baseDir, 'request_logs.jsonl'),
|
|
27
|
+
providersPath: path.join(baseDir, 'providers.json'),
|
|
28
|
+
poolsPath: path.join(baseDir, 'pools.json'),
|
|
29
|
+
poolStatePath: path.join(baseDir, 'pool_state.json'),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function makeWorkspaceTempDir(prefix: string): Promise<string> {
|
|
34
|
+
const base = path.join(process.cwd(), 'tmp')
|
|
35
|
+
await fs.mkdir(base, { recursive: true })
|
|
36
|
+
return fs.mkdtemp(path.join(base, prefix))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DATA_URL_A = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5P6n4AAAAASUVORK5CYII='
|
|
40
|
+
const DATA_URL_B = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAQAAABLbSncAAAADElEQVR42mP8z/CfAQADgwGfWQ36KQAAAABJRU5ErkJggg=='
|
|
41
|
+
|
|
42
|
+
function toHashFromAdminUrl(url: string): string {
|
|
43
|
+
const match = url.match(/^\/admin\/media\/([a-f0-9]{16})$/)
|
|
44
|
+
if (!match) {
|
|
45
|
+
throw new Error(`Expected /admin/media/<hash> url, got ${url}`)
|
|
46
|
+
}
|
|
47
|
+
return match[1]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('session message persistence normalizes image refs to local cache urls', async () => {
|
|
51
|
+
const baseDir = await makeWorkspaceTempDir('waypoi-session-media-')
|
|
52
|
+
const paths = makePaths(baseDir)
|
|
53
|
+
|
|
54
|
+
const session = await createSession(paths, { name: 'Media Session' })
|
|
55
|
+
await addMessage(paths, session.id, {
|
|
56
|
+
role: 'user',
|
|
57
|
+
content: [
|
|
58
|
+
{ type: 'text', text: 'hello' },
|
|
59
|
+
{ type: 'image_url', image_url: { url: DATA_URL_A } },
|
|
60
|
+
],
|
|
61
|
+
images: [DATA_URL_A],
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const loaded = await getSession(paths, session.id)
|
|
65
|
+
assert.ok(loaded)
|
|
66
|
+
assert.equal(loaded?.storageVersion, 2)
|
|
67
|
+
const msg = loaded?.messages[0]
|
|
68
|
+
assert.ok(msg)
|
|
69
|
+
assert.ok(Array.isArray(msg?.images))
|
|
70
|
+
const imageRef = msg?.images?.[0] ?? ''
|
|
71
|
+
assert.match(imageRef, /^\/admin\/media\/[a-f0-9]{16}$/)
|
|
72
|
+
|
|
73
|
+
const contentImage = Array.isArray(msg?.content)
|
|
74
|
+
? msg?.content.find((part) => part.type === 'image_url')
|
|
75
|
+
: null
|
|
76
|
+
assert.ok(contentImage && contentImage.type === 'image_url')
|
|
77
|
+
assert.match(contentImage.image_url.url, /^\/admin\/media\/[a-f0-9]{16}$/)
|
|
78
|
+
|
|
79
|
+
const hash = toHashFromAdminUrl(contentImage.image_url.url)
|
|
80
|
+
const mediaPath = await getMediaPath(paths, hash)
|
|
81
|
+
assert.ok(mediaPath)
|
|
82
|
+
const refCount = await getMediaRefCount(paths, hash)
|
|
83
|
+
assert.equal(refCount, 1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('loading legacy inline-image session lazily migrates to local refs and bumps storageVersion', async () => {
|
|
87
|
+
const baseDir = await makeWorkspaceTempDir('waypoi-session-media-')
|
|
88
|
+
const paths = makePaths(baseDir)
|
|
89
|
+
|
|
90
|
+
const sessionId = 'legacy-session'
|
|
91
|
+
const sessionsDir = resolveSessionsDir(paths)
|
|
92
|
+
await fs.mkdir(sessionsDir, { recursive: true })
|
|
93
|
+
const filePath = path.join(sessionsDir, `${sessionId}.json`)
|
|
94
|
+
const now = new Date().toISOString()
|
|
95
|
+
await fs.writeFile(
|
|
96
|
+
filePath,
|
|
97
|
+
JSON.stringify(
|
|
98
|
+
{
|
|
99
|
+
id: sessionId,
|
|
100
|
+
name: 'Legacy',
|
|
101
|
+
storageVersion: 1,
|
|
102
|
+
messages: [
|
|
103
|
+
{
|
|
104
|
+
id: 'm1',
|
|
105
|
+
role: 'assistant',
|
|
106
|
+
content: [{ type: 'image_url', image_url: { url: DATA_URL_B } }],
|
|
107
|
+
images: [DATA_URL_B],
|
|
108
|
+
createdAt: now,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
createdAt: now,
|
|
112
|
+
updatedAt: now,
|
|
113
|
+
},
|
|
114
|
+
null,
|
|
115
|
+
2
|
|
116
|
+
),
|
|
117
|
+
'utf8'
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const loaded = await getSession(paths, sessionId)
|
|
121
|
+
assert.ok(loaded)
|
|
122
|
+
assert.equal(loaded?.storageVersion, 2)
|
|
123
|
+
|
|
124
|
+
const migratedImage = Array.isArray(loaded?.messages[0]?.content)
|
|
125
|
+
? loaded?.messages[0]?.content.find((part) => part.type === 'image_url')
|
|
126
|
+
: null
|
|
127
|
+
assert.ok(migratedImage && migratedImage.type === 'image_url')
|
|
128
|
+
assert.match(migratedImage.image_url.url, /^\/admin\/media\/[a-f0-9]{16}$/)
|
|
129
|
+
|
|
130
|
+
const reReadRaw = JSON.parse(await fs.readFile(filePath, 'utf8')) as { storageVersion: number }
|
|
131
|
+
assert.equal(reReadRaw.storageVersion, 2)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('eviction preserves referenced media and reports blocked evictions when only referenced remain', async () => {
|
|
135
|
+
const baseDir = await makeWorkspaceTempDir('waypoi-session-media-')
|
|
136
|
+
const paths = makePaths(baseDir)
|
|
137
|
+
|
|
138
|
+
const session = await createSession(paths, { name: 'Ref Test' })
|
|
139
|
+
await addMessage(paths, session.id, {
|
|
140
|
+
role: 'user',
|
|
141
|
+
content: [{ type: 'image_url', image_url: { url: DATA_URL_A } }],
|
|
142
|
+
images: [DATA_URL_A],
|
|
143
|
+
})
|
|
144
|
+
await addMessage(paths, session.id, {
|
|
145
|
+
role: 'assistant',
|
|
146
|
+
content: [{ type: 'image_url', image_url: { url: DATA_URL_B } }],
|
|
147
|
+
images: [DATA_URL_B],
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const before = await getCacheStats(paths)
|
|
151
|
+
assert.equal(before.referencedCount, 2)
|
|
152
|
+
|
|
153
|
+
// Force cache pressure with tiny limit; unreferenced candidate gets evicted first,
|
|
154
|
+
// then eviction is blocked when only referenced entries remain.
|
|
155
|
+
await storeMedia(paths, Buffer.from('unreferenced-bytes', 'utf8'), {
|
|
156
|
+
maxSizeBytes: 1,
|
|
157
|
+
mimeType: 'image/png',
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const after = await getCacheStats(paths)
|
|
161
|
+
assert.equal(after.referencedCount, 2)
|
|
162
|
+
assert.ok(after.evictionBlockedCount >= 1)
|
|
163
|
+
|
|
164
|
+
await deleteSession(paths, session.id)
|
|
165
|
+
const finalStats = await getCacheStats(paths)
|
|
166
|
+
assert.equal(finalStats.referencedCount, 0)
|
|
167
|
+
})
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { promises as fs } from 'fs'
|
|
5
|
+
import Fastify from 'fastify'
|
|
6
|
+
import { registerStatsRoutes } from '../src/routes/stats'
|
|
7
|
+
import { appendStats } from '../src/storage/statsRepository'
|
|
8
|
+
import type { RequestStats } from '../src/types'
|
|
9
|
+
import type { StoragePaths } from '../src/storage/files'
|
|
10
|
+
|
|
11
|
+
function makePaths(baseDir: string): StoragePaths {
|
|
12
|
+
return {
|
|
13
|
+
baseDir,
|
|
14
|
+
configPath: path.join(baseDir, 'config.yaml'),
|
|
15
|
+
healthPath: path.join(baseDir, 'health.json'),
|
|
16
|
+
providerHealthPath: path.join(baseDir, 'providers_health.json'),
|
|
17
|
+
requestLogPath: path.join(baseDir, 'request_logs.jsonl'),
|
|
18
|
+
providersPath: path.join(baseDir, 'providers.json'),
|
|
19
|
+
poolsPath: path.join(baseDir, 'pools.json'),
|
|
20
|
+
poolStatePath: path.join(baseDir, 'pool_state.json'),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function makeWorkspaceTempDir(prefix: string): Promise<string> {
|
|
25
|
+
const base = path.join(process.cwd(), 'tmp')
|
|
26
|
+
await fs.mkdir(base, { recursive: true })
|
|
27
|
+
return fs.mkdtemp(path.join(base, prefix))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildStat(overrides: Partial<RequestStats>): RequestStats {
|
|
31
|
+
return {
|
|
32
|
+
requestId: overrides.requestId ?? `req-${Math.random().toString(36).slice(2)}`,
|
|
33
|
+
timestamp: overrides.timestamp ?? new Date(),
|
|
34
|
+
route: overrides.route ?? '/v1/chat/completions',
|
|
35
|
+
method: overrides.method ?? 'POST',
|
|
36
|
+
publicModel: overrides.publicModel ?? 'gpt-4o-mini',
|
|
37
|
+
endpointId: overrides.endpointId ?? 'ep-default',
|
|
38
|
+
endpointName: overrides.endpointName ?? 'default',
|
|
39
|
+
upstreamModel: overrides.upstreamModel ?? 'upstream',
|
|
40
|
+
requestBytes: overrides.requestBytes ?? 128,
|
|
41
|
+
responseBytes: overrides.responseBytes ?? 512,
|
|
42
|
+
latencyMs: overrides.latencyMs ?? 150,
|
|
43
|
+
statusCode: overrides.statusCode ?? 200,
|
|
44
|
+
errorType: overrides.errorType,
|
|
45
|
+
totalTokens: overrides.totalTokens ?? 100,
|
|
46
|
+
promptTokens: overrides.promptTokens === undefined ? 50 : overrides.promptTokens,
|
|
47
|
+
completionTokens: overrides.completionTokens === undefined ? 50 : overrides.completionTokens,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('stats routes honor exact 1h window across aggregate/latency/tokens', async () => {
|
|
52
|
+
const baseDir = await makeWorkspaceTempDir('waypoi-stats-test-')
|
|
53
|
+
const paths = makePaths(baseDir)
|
|
54
|
+
const app = Fastify()
|
|
55
|
+
await registerStatsRoutes(app, paths)
|
|
56
|
+
|
|
57
|
+
const now = Date.now()
|
|
58
|
+
await appendStats(
|
|
59
|
+
paths,
|
|
60
|
+
buildStat({ requestId: 'old', timestamp: new Date(now - 2 * 60 * 60 * 1000), endpointId: 'ep-1', totalTokens: 40 })
|
|
61
|
+
)
|
|
62
|
+
await appendStats(
|
|
63
|
+
paths,
|
|
64
|
+
buildStat({ requestId: 'recent', timestamp: new Date(now - 10 * 60 * 1000), endpointId: 'ep-1', totalTokens: 80 })
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const statsRes = await app.inject({ method: 'GET', url: '/admin/stats?window=1h' })
|
|
68
|
+
assert.equal(statsRes.statusCode, 200)
|
|
69
|
+
const statsJson = statsRes.json() as {
|
|
70
|
+
window: string
|
|
71
|
+
timeZone: string
|
|
72
|
+
total: number
|
|
73
|
+
byEndpoint: Record<string, { count: number; errors: number }>
|
|
74
|
+
}
|
|
75
|
+
assert.equal(statsJson.window, '1h')
|
|
76
|
+
assert.equal(statsJson.timeZone, 'UTC')
|
|
77
|
+
assert.equal(statsJson.total, 1)
|
|
78
|
+
|
|
79
|
+
const latencyRes = await app.inject({ method: 'GET', url: '/admin/stats/latency?window=1h' })
|
|
80
|
+
assert.equal(latencyRes.statusCode, 200)
|
|
81
|
+
const latencyJson = latencyRes.json() as { window: string; timeZone: string; count: number }
|
|
82
|
+
assert.equal(latencyJson.window, '1h')
|
|
83
|
+
assert.equal(latencyJson.timeZone, 'UTC')
|
|
84
|
+
assert.equal(latencyJson.count, 1)
|
|
85
|
+
|
|
86
|
+
const tokenRes = await app.inject({ method: 'GET', url: '/admin/stats/tokens?window=1h' })
|
|
87
|
+
assert.equal(tokenRes.statusCode, 200)
|
|
88
|
+
const tokenJson = tokenRes.json() as {
|
|
89
|
+
window: string
|
|
90
|
+
totalRequests: number
|
|
91
|
+
bucketGranularity: string
|
|
92
|
+
totalInputTokens: number
|
|
93
|
+
totalOutputTokens: number
|
|
94
|
+
splitUnknownCount: number
|
|
95
|
+
splitUnknownRate: number
|
|
96
|
+
}
|
|
97
|
+
assert.equal(tokenJson.window, '1h')
|
|
98
|
+
assert.equal(tokenJson.totalRequests, 1)
|
|
99
|
+
assert.equal(tokenJson.bucketGranularity, 'hour')
|
|
100
|
+
assert.equal(tokenJson.totalInputTokens, 50)
|
|
101
|
+
assert.equal(tokenJson.totalOutputTokens, 50)
|
|
102
|
+
assert.equal(tokenJson.splitUnknownCount, 0)
|
|
103
|
+
assert.equal(tokenJson.splitUnknownRate, 0)
|
|
104
|
+
|
|
105
|
+
await app.close()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('stats and latency endpoints honor requested timezone with UTC fallback', async () => {
|
|
109
|
+
const baseDir = await makeWorkspaceTempDir('waypoi-stats-test-')
|
|
110
|
+
const paths = makePaths(baseDir)
|
|
111
|
+
const app = Fastify()
|
|
112
|
+
await registerStatsRoutes(app, paths)
|
|
113
|
+
|
|
114
|
+
await appendStats(paths, buildStat({ requestId: 'tz-meta', timestamp: new Date(), totalTokens: 10 }))
|
|
115
|
+
|
|
116
|
+
const localStatsRes = await app.inject({ method: 'GET', url: '/admin/stats?window=1h&timeZone=America/Chicago' })
|
|
117
|
+
assert.equal(localStatsRes.statusCode, 200)
|
|
118
|
+
const localStats = localStatsRes.json() as { timeZone: string }
|
|
119
|
+
assert.equal(localStats.timeZone, 'America/Chicago')
|
|
120
|
+
|
|
121
|
+
const invalidStatsRes = await app.inject({ method: 'GET', url: '/admin/stats?window=1h&timeZone=Not/A_Zone' })
|
|
122
|
+
assert.equal(invalidStatsRes.statusCode, 200)
|
|
123
|
+
const invalidStats = invalidStatsRes.json() as { timeZone: string }
|
|
124
|
+
assert.equal(invalidStats.timeZone, 'UTC')
|
|
125
|
+
|
|
126
|
+
const localLatencyRes = await app.inject({ method: 'GET', url: '/admin/stats/latency?window=1h&timeZone=America/Chicago' })
|
|
127
|
+
assert.equal(localLatencyRes.statusCode, 200)
|
|
128
|
+
const localLatency = localLatencyRes.json() as { timeZone: string }
|
|
129
|
+
assert.equal(localLatency.timeZone, 'America/Chicago')
|
|
130
|
+
|
|
131
|
+
const invalidLatencyRes = await app.inject({ method: 'GET', url: '/admin/stats/latency?window=1h&timeZone=Not/A_Zone' })
|
|
132
|
+
assert.equal(invalidLatencyRes.statusCode, 200)
|
|
133
|
+
const invalidLatency = invalidLatencyRes.json() as { timeZone: string }
|
|
134
|
+
assert.equal(invalidLatency.timeZone, 'UTC')
|
|
135
|
+
|
|
136
|
+
await app.close()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('latency route returns normalized empty payload shape', async () => {
|
|
140
|
+
const baseDir = await makeWorkspaceTempDir('waypoi-stats-test-')
|
|
141
|
+
const paths = makePaths(baseDir)
|
|
142
|
+
const app = Fastify()
|
|
143
|
+
await registerStatsRoutes(app, paths)
|
|
144
|
+
|
|
145
|
+
const res = await app.inject({ method: 'GET', url: '/admin/stats/latency?window=1h' })
|
|
146
|
+
assert.equal(res.statusCode, 200)
|
|
147
|
+
|
|
148
|
+
const json = res.json() as {
|
|
149
|
+
count: number
|
|
150
|
+
min: number | null
|
|
151
|
+
max: number | null
|
|
152
|
+
avg: number | null
|
|
153
|
+
p50: number | null
|
|
154
|
+
p95: number | null
|
|
155
|
+
p99: number | null
|
|
156
|
+
histogram: Record<string, number>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
assert.equal(json.count, 0)
|
|
160
|
+
assert.equal(json.min, null)
|
|
161
|
+
assert.equal(json.max, null)
|
|
162
|
+
assert.equal(json.avg, null)
|
|
163
|
+
assert.equal(json.p50, null)
|
|
164
|
+
assert.equal(json.p95, null)
|
|
165
|
+
assert.equal(json.p99, null)
|
|
166
|
+
assert.deepEqual(json.histogram, {})
|
|
167
|
+
|
|
168
|
+
await app.close()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('token usage exposes hourly vs daily bucket granularity', async () => {
|
|
172
|
+
const baseDir = await makeWorkspaceTempDir('waypoi-stats-test-')
|
|
173
|
+
const paths = makePaths(baseDir)
|
|
174
|
+
const app = Fastify()
|
|
175
|
+
await registerStatsRoutes(app, paths)
|
|
176
|
+
|
|
177
|
+
const now = Date.now()
|
|
178
|
+
await appendStats(paths, buildStat({ requestId: 'h1', timestamp: new Date(now - 90 * 60 * 1000), totalTokens: 25 }))
|
|
179
|
+
await appendStats(paths, buildStat({ requestId: 'h2', timestamp: new Date(now - 5 * 60 * 1000), totalTokens: 50 }))
|
|
180
|
+
|
|
181
|
+
const hourly = await app.inject({ method: 'GET', url: '/admin/stats/tokens?window=24h' })
|
|
182
|
+
assert.equal(hourly.statusCode, 200)
|
|
183
|
+
const hourlyJson = hourly.json() as {
|
|
184
|
+
bucketGranularity: string
|
|
185
|
+
byDay: Array<{ date: string; inputTokens: number; outputTokens: number; splitUnknown: number }>
|
|
186
|
+
}
|
|
187
|
+
assert.equal(hourlyJson.bucketGranularity, 'hour')
|
|
188
|
+
assert.ok(hourlyJson.byDay.every((row) => row.date.includes('T')))
|
|
189
|
+
assert.ok(hourlyJson.byDay.every((row) => typeof row.inputTokens === 'number'))
|
|
190
|
+
assert.ok(hourlyJson.byDay.every((row) => typeof row.outputTokens === 'number'))
|
|
191
|
+
assert.ok(hourlyJson.byDay.every((row) => typeof row.splitUnknown === 'number'))
|
|
192
|
+
|
|
193
|
+
const daily = await app.inject({ method: 'GET', url: '/admin/stats/tokens?window=7d' })
|
|
194
|
+
assert.equal(daily.statusCode, 200)
|
|
195
|
+
const dailyJson = daily.json() as {
|
|
196
|
+
bucketGranularity: string
|
|
197
|
+
byDay: Array<{ date: string; inputTokens: number; outputTokens: number; splitUnknown: number }>
|
|
198
|
+
}
|
|
199
|
+
assert.equal(dailyJson.bucketGranularity, 'day')
|
|
200
|
+
assert.ok(dailyJson.byDay.every((row) => !row.date.includes('T')))
|
|
201
|
+
assert.ok(dailyJson.byDay.every((row) => typeof row.inputTokens === 'number'))
|
|
202
|
+
assert.ok(dailyJson.byDay.every((row) => typeof row.outputTokens === 'number'))
|
|
203
|
+
assert.ok(dailyJson.byDay.every((row) => typeof row.splitUnknown === 'number'))
|
|
204
|
+
|
|
205
|
+
await app.close()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('token usage marks unknown split when only total tokens are present', async () => {
|
|
209
|
+
const baseDir = await makeWorkspaceTempDir('waypoi-stats-test-')
|
|
210
|
+
const paths = makePaths(baseDir)
|
|
211
|
+
const app = Fastify()
|
|
212
|
+
await registerStatsRoutes(app, paths)
|
|
213
|
+
|
|
214
|
+
await appendStats(
|
|
215
|
+
paths,
|
|
216
|
+
buildStat({
|
|
217
|
+
requestId: 'split-unknown',
|
|
218
|
+
timestamp: new Date(),
|
|
219
|
+
totalTokens: 42,
|
|
220
|
+
promptTokens: null,
|
|
221
|
+
completionTokens: null,
|
|
222
|
+
})
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
const res = await app.inject({ method: 'GET', url: '/admin/stats/tokens?window=1h' })
|
|
226
|
+
assert.equal(res.statusCode, 200)
|
|
227
|
+
const json = res.json() as {
|
|
228
|
+
totalTokens: number
|
|
229
|
+
totalRequests: number
|
|
230
|
+
totalInputTokens: number
|
|
231
|
+
totalOutputTokens: number
|
|
232
|
+
splitUnknownCount: number
|
|
233
|
+
splitUnknownRate: number
|
|
234
|
+
byDay: Array<{ tokens: number; inputTokens: number; outputTokens: number; splitUnknown: number }>
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
assert.equal(json.totalTokens, 42)
|
|
238
|
+
assert.equal(json.totalRequests, 1)
|
|
239
|
+
assert.equal(json.totalInputTokens, 0)
|
|
240
|
+
assert.equal(json.totalOutputTokens, 0)
|
|
241
|
+
assert.equal(json.splitUnknownCount, 1)
|
|
242
|
+
assert.equal(json.splitUnknownRate, 1)
|
|
243
|
+
assert.equal(json.byDay.length, 1)
|
|
244
|
+
assert.equal(json.byDay[0].tokens, 42)
|
|
245
|
+
assert.equal(json.byDay[0].inputTokens, 0)
|
|
246
|
+
assert.equal(json.byDay[0].outputTokens, 0)
|
|
247
|
+
assert.equal(json.byDay[0].splitUnknown, 1)
|
|
248
|
+
|
|
249
|
+
await app.close()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('token usage buckets follow requested timezone instead of UTC', async () => {
|
|
253
|
+
const baseDir = await makeWorkspaceTempDir('waypoi-stats-test-')
|
|
254
|
+
const paths = makePaths(baseDir)
|
|
255
|
+
const app = Fastify()
|
|
256
|
+
await registerStatsRoutes(app, paths)
|
|
257
|
+
|
|
258
|
+
await appendStats(
|
|
259
|
+
paths,
|
|
260
|
+
buildStat({
|
|
261
|
+
requestId: 'tz-shift',
|
|
262
|
+
timestamp: new Date('2026-01-01T01:30:00.000Z'),
|
|
263
|
+
totalTokens: 42,
|
|
264
|
+
})
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const utcRes = await app.inject({ method: 'GET', url: '/admin/stats/tokens?window=365d&timeZone=UTC' })
|
|
268
|
+
assert.equal(utcRes.statusCode, 200)
|
|
269
|
+
const utcJson = utcRes.json() as {
|
|
270
|
+
bucketTimeZone: string
|
|
271
|
+
byDay: Array<{ date: string }>
|
|
272
|
+
}
|
|
273
|
+
assert.equal(utcJson.bucketTimeZone, 'UTC')
|
|
274
|
+
|
|
275
|
+
const localRes = await app.inject({
|
|
276
|
+
method: 'GET',
|
|
277
|
+
url: '/admin/stats/tokens?window=365d&timeZone=America/Chicago',
|
|
278
|
+
})
|
|
279
|
+
assert.equal(localRes.statusCode, 200)
|
|
280
|
+
const localJson = localRes.json() as {
|
|
281
|
+
bucketTimeZone: string
|
|
282
|
+
byDay: Array<{ date: string }>
|
|
283
|
+
}
|
|
284
|
+
assert.equal(localJson.bucketTimeZone, 'America/Chicago')
|
|
285
|
+
assert.notEqual(localJson.byDay[0]?.date, utcJson.byDay[0]?.date)
|
|
286
|
+
|
|
287
|
+
await app.close()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('stats aggregate keeps byEndpoint error counts', async () => {
|
|
291
|
+
const baseDir = await makeWorkspaceTempDir('waypoi-stats-test-')
|
|
292
|
+
const paths = makePaths(baseDir)
|
|
293
|
+
const app = Fastify()
|
|
294
|
+
await registerStatsRoutes(app, paths)
|
|
295
|
+
|
|
296
|
+
const now = Date.now()
|
|
297
|
+
await appendStats(
|
|
298
|
+
paths,
|
|
299
|
+
buildStat({ requestId: 'ok', timestamp: new Date(now - 2 * 60 * 1000), endpointId: 'ep-chat', statusCode: 200, totalTokens: 90 })
|
|
300
|
+
)
|
|
301
|
+
await appendStats(
|
|
302
|
+
paths,
|
|
303
|
+
buildStat({
|
|
304
|
+
requestId: 'err',
|
|
305
|
+
timestamp: new Date(now - 1 * 60 * 1000),
|
|
306
|
+
endpointId: 'ep-chat',
|
|
307
|
+
statusCode: 500,
|
|
308
|
+
errorType: 'upstream_error',
|
|
309
|
+
totalTokens: 30,
|
|
310
|
+
})
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
const res = await app.inject({ method: 'GET', url: '/admin/stats?window=1h' })
|
|
314
|
+
assert.equal(res.statusCode, 200)
|
|
315
|
+
const json = res.json() as {
|
|
316
|
+
byEndpoint: Record<string, { count: number; errors: number }>
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
assert.equal(json.byEndpoint['ep-chat'].count, 2)
|
|
320
|
+
assert.equal(json.byEndpoint['ep-chat'].errors, 1)
|
|
321
|
+
|
|
322
|
+
await app.close()
|
|
323
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "CommonJS",
|
|
5
|
+
"moduleResolution": "Node",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": ".",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src", "cli", "scripts"],
|
|
14
|
+
"exclude": ["node_modules", "dist", "archive"]
|
|
15
|
+
}
|