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,251 @@
|
|
|
1
|
+
import { FastifyInstance, FastifyRequest, FastifyReply, HookHandlerDoneFunction } from "fastify";
|
|
2
|
+
import { loadConfig, StoragePaths } from "../storage/files";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Auth Middleware (No-op Implementation)
|
|
6
|
+
*
|
|
7
|
+
* This middleware is a placeholder for future authentication.
|
|
8
|
+
* When authEnabled is false (default), it passes through all requests.
|
|
9
|
+
*
|
|
10
|
+
* Extension points for implementing auth:
|
|
11
|
+
* 1. JWT validation
|
|
12
|
+
* 2. API key verification
|
|
13
|
+
* 3. OAuth2/OIDC integration
|
|
14
|
+
* 4. Basic auth for simple deployments
|
|
15
|
+
*
|
|
16
|
+
* The middleware adds req.user typing for downstream handlers.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
declare module "fastify" {
|
|
20
|
+
interface FastifyRequest {
|
|
21
|
+
user?: {
|
|
22
|
+
id: string;
|
|
23
|
+
email?: string;
|
|
24
|
+
roles?: string[];
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AuthConfig {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
// Future config options:
|
|
32
|
+
// jwtSecret?: string;
|
|
33
|
+
// apiKeys?: string[];
|
|
34
|
+
// oauthProvider?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let authConfig: AuthConfig = {
|
|
38
|
+
enabled: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load auth configuration from the main config file.
|
|
43
|
+
*/
|
|
44
|
+
export async function loadAuthConfig(paths: StoragePaths): Promise<AuthConfig> {
|
|
45
|
+
try {
|
|
46
|
+
const config = await loadConfig(paths);
|
|
47
|
+
return {
|
|
48
|
+
enabled: config.authEnabled ?? false,
|
|
49
|
+
};
|
|
50
|
+
} catch {
|
|
51
|
+
return { enabled: false };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Update auth config (e.g., after config hot-reload).
|
|
57
|
+
*/
|
|
58
|
+
export function updateAuthConfig(config: AuthConfig): void {
|
|
59
|
+
authConfig = config;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get current auth config.
|
|
64
|
+
*/
|
|
65
|
+
export function getAuthConfig(): AuthConfig {
|
|
66
|
+
return authConfig;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Auth guard for protected routes.
|
|
71
|
+
*
|
|
72
|
+
* When auth is disabled: passes through all requests.
|
|
73
|
+
* When auth is enabled: checks for valid authentication.
|
|
74
|
+
*/
|
|
75
|
+
export function authGuard(
|
|
76
|
+
req: FastifyRequest,
|
|
77
|
+
reply: FastifyReply,
|
|
78
|
+
done: HookHandlerDoneFunction
|
|
79
|
+
): void {
|
|
80
|
+
// Auth disabled - pass through
|
|
81
|
+
if (!authConfig.enabled) {
|
|
82
|
+
done();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
87
|
+
// Auth enabled - implement your auth logic here
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
// Example: Check for Authorization header
|
|
91
|
+
const authHeader = req.headers.authorization;
|
|
92
|
+
|
|
93
|
+
if (!authHeader) {
|
|
94
|
+
reply.status(401).send({
|
|
95
|
+
error: {
|
|
96
|
+
message: "Authentication required",
|
|
97
|
+
type: "auth_error",
|
|
98
|
+
code: "missing_auth",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Placeholder validation - replace with real auth logic
|
|
105
|
+
// For now, any Bearer token is accepted
|
|
106
|
+
if (authHeader.startsWith("Bearer ")) {
|
|
107
|
+
const token = authHeader.slice(7);
|
|
108
|
+
|
|
109
|
+
// TODO: Validate token (JWT decode, database lookup, etc.)
|
|
110
|
+
// For now, we just set a placeholder user
|
|
111
|
+
req.user = {
|
|
112
|
+
id: "placeholder",
|
|
113
|
+
email: undefined,
|
|
114
|
+
roles: ["user"],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
done();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
reply.status(401).send({
|
|
122
|
+
error: {
|
|
123
|
+
message: "Invalid authentication",
|
|
124
|
+
type: "auth_error",
|
|
125
|
+
code: "invalid_auth",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Optional: API key auth guard for simpler use cases.
|
|
132
|
+
*/
|
|
133
|
+
export function apiKeyGuard(validKeys: Set<string>) {
|
|
134
|
+
return (
|
|
135
|
+
req: FastifyRequest,
|
|
136
|
+
reply: FastifyReply,
|
|
137
|
+
done: HookHandlerDoneFunction
|
|
138
|
+
): void => {
|
|
139
|
+
if (!authConfig.enabled) {
|
|
140
|
+
done();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const authHeader = req.headers.authorization;
|
|
145
|
+
const apiKey = req.headers["x-api-key"] as string | undefined;
|
|
146
|
+
|
|
147
|
+
// Check X-API-Key header
|
|
148
|
+
if (apiKey && validKeys.has(apiKey)) {
|
|
149
|
+
req.user = { id: "api-key-user", roles: ["api"] };
|
|
150
|
+
done();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check Bearer token as API key
|
|
155
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
156
|
+
const key = authHeader.slice(7);
|
|
157
|
+
if (validKeys.has(key)) {
|
|
158
|
+
req.user = { id: "api-key-user", roles: ["api"] };
|
|
159
|
+
done();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
reply.status(401).send({
|
|
165
|
+
error: {
|
|
166
|
+
message: "Invalid API key",
|
|
167
|
+
type: "auth_error",
|
|
168
|
+
code: "invalid_api_key",
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Register auth hooks on protected route prefixes.
|
|
176
|
+
*
|
|
177
|
+
* Usage:
|
|
178
|
+
* await registerAuthHooks(app, paths, ["/admin", "/ui"]);
|
|
179
|
+
*/
|
|
180
|
+
export async function registerAuthHooks(
|
|
181
|
+
app: FastifyInstance,
|
|
182
|
+
paths: StoragePaths,
|
|
183
|
+
protectedPrefixes: string[] = ["/admin", "/ui"]
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
// Load initial config
|
|
186
|
+
authConfig = await loadAuthConfig(paths);
|
|
187
|
+
|
|
188
|
+
app.log.info(
|
|
189
|
+
{ authEnabled: authConfig.enabled, protectedPrefixes },
|
|
190
|
+
"Auth middleware initialized"
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Add hook for protected routes
|
|
194
|
+
app.addHook("onRequest", (req, reply, done) => {
|
|
195
|
+
const isProtected = protectedPrefixes.some((prefix) =>
|
|
196
|
+
req.url.startsWith(prefix)
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (isProtected) {
|
|
200
|
+
authGuard(req, reply, done);
|
|
201
|
+
} else {
|
|
202
|
+
done();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Middleware factory for route-level auth.
|
|
209
|
+
*
|
|
210
|
+
* Usage in route handlers:
|
|
211
|
+
* app.get("/admin/something", { preHandler: [requireAuth()] }, handler);
|
|
212
|
+
*/
|
|
213
|
+
export function requireAuth() {
|
|
214
|
+
return (
|
|
215
|
+
req: FastifyRequest,
|
|
216
|
+
reply: FastifyReply,
|
|
217
|
+
done: HookHandlerDoneFunction
|
|
218
|
+
): void => {
|
|
219
|
+
authGuard(req, reply, done);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Check if a user has a specific role.
|
|
225
|
+
*/
|
|
226
|
+
export function requireRole(role: string) {
|
|
227
|
+
return (
|
|
228
|
+
req: FastifyRequest,
|
|
229
|
+
reply: FastifyReply,
|
|
230
|
+
done: HookHandlerDoneFunction
|
|
231
|
+
): void => {
|
|
232
|
+
// First check auth
|
|
233
|
+
if (authConfig.enabled) {
|
|
234
|
+
if (!req.user) {
|
|
235
|
+
reply.status(401).send({
|
|
236
|
+
error: { message: "Authentication required", type: "auth_error" },
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!req.user.roles?.includes(role)) {
|
|
242
|
+
reply.status(403).send({
|
|
243
|
+
error: { message: "Insufficient permissions", type: "auth_error" },
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
done();
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { StoragePaths } from "../storage/files";
|
|
4
|
+
import { CaptureRoutingInfo, isCaptureEnabled, persistCaptureRecord } from "../storage/captureRepository";
|
|
5
|
+
|
|
6
|
+
interface CaptureContext {
|
|
7
|
+
id: string;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
requestBody?: unknown;
|
|
11
|
+
responseBody?: unknown;
|
|
12
|
+
responseHeaders?: Record<string, string | string[] | undefined>;
|
|
13
|
+
routing?: CaptureRoutingInfo;
|
|
14
|
+
derivedRequest?: Record<string, unknown>;
|
|
15
|
+
error?: { type?: string; message?: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CaptureStreamBody {
|
|
19
|
+
$type: "stream";
|
|
20
|
+
contentType: string;
|
|
21
|
+
bytes: number;
|
|
22
|
+
text?: string;
|
|
23
|
+
note?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const captureContexts = new WeakMap<FastifyRequest, CaptureContext>();
|
|
27
|
+
|
|
28
|
+
interface ReplyCaptureMeta {
|
|
29
|
+
captureRouting?: CaptureRoutingInfo;
|
|
30
|
+
captureDerivedRequest?: Record<string, unknown>;
|
|
31
|
+
captureResponseOverride?: {
|
|
32
|
+
body: unknown;
|
|
33
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
34
|
+
};
|
|
35
|
+
captureError?: { type?: string; message?: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function meta(reply: FastifyReply): ReplyCaptureMeta {
|
|
39
|
+
return reply as unknown as ReplyCaptureMeta;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function registerRequestCaptureMiddleware(
|
|
43
|
+
app: FastifyInstance,
|
|
44
|
+
paths: StoragePaths
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
app.addHook("onRequest", async (req: FastifyRequest) => {
|
|
47
|
+
if (!req.url.startsWith("/v1/")) return;
|
|
48
|
+
let enabled = false;
|
|
49
|
+
try {
|
|
50
|
+
enabled = await isCaptureEnabled(paths);
|
|
51
|
+
} catch {
|
|
52
|
+
enabled = false;
|
|
53
|
+
}
|
|
54
|
+
captureContexts.set(req, {
|
|
55
|
+
id: randomUUID(),
|
|
56
|
+
startedAt: Date.now(),
|
|
57
|
+
enabled,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
app.addHook("preHandler", async (req: FastifyRequest) => {
|
|
62
|
+
const context = captureContexts.get(req);
|
|
63
|
+
if (!context?.enabled) return;
|
|
64
|
+
context.requestBody = safeClone(req.body);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
app.addHook("onSend", async (req: FastifyRequest, reply: FastifyReply, payload: unknown) => {
|
|
68
|
+
const context = captureContexts.get(req);
|
|
69
|
+
if (!context?.enabled) return payload;
|
|
70
|
+
if (!meta(reply).captureResponseOverride) {
|
|
71
|
+
context.responseBody = payloadToBody(payload);
|
|
72
|
+
context.responseHeaders = reply.getHeaders() as Record<string, string | string[] | undefined>;
|
|
73
|
+
}
|
|
74
|
+
return payload;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
app.addHook("onResponse", async (req: FastifyRequest, reply: FastifyReply) => {
|
|
78
|
+
const context = captureContexts.get(req);
|
|
79
|
+
if (!context) return;
|
|
80
|
+
try {
|
|
81
|
+
if (!context.enabled) return;
|
|
82
|
+
const replyMeta = meta(reply);
|
|
83
|
+
context.routing = replyMeta.captureRouting;
|
|
84
|
+
context.derivedRequest = replyMeta.captureDerivedRequest;
|
|
85
|
+
context.error = replyMeta.captureError;
|
|
86
|
+
if (replyMeta.captureResponseOverride) {
|
|
87
|
+
context.responseBody = replyMeta.captureResponseOverride.body;
|
|
88
|
+
context.responseHeaders = replyMeta.captureResponseOverride.headers;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await persistCaptureRecord(paths, {
|
|
92
|
+
route: req.url,
|
|
93
|
+
method: req.method,
|
|
94
|
+
statusCode: reply.statusCode,
|
|
95
|
+
latencyMs: Date.now() - context.startedAt,
|
|
96
|
+
requestHeaders: req.headers as Record<string, string | string[] | undefined>,
|
|
97
|
+
responseHeaders:
|
|
98
|
+
context.responseHeaders ??
|
|
99
|
+
(reply.getHeaders() as Record<string, string | string[] | undefined>),
|
|
100
|
+
requestBody: context.requestBody,
|
|
101
|
+
responseBody: context.responseBody,
|
|
102
|
+
derivedRequest: context.derivedRequest,
|
|
103
|
+
routing: context.routing,
|
|
104
|
+
error: context.error,
|
|
105
|
+
});
|
|
106
|
+
} catch (error) {
|
|
107
|
+
app.log.warn({ err: error }, "Failed to persist request capture");
|
|
108
|
+
} finally {
|
|
109
|
+
captureContexts.delete(req);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function setCaptureRouting(reply: FastifyReply, routing: CaptureRoutingInfo): void {
|
|
115
|
+
meta(reply).captureRouting = routing;
|
|
116
|
+
const context = captureContexts.get(reply.request);
|
|
117
|
+
if (context?.enabled) {
|
|
118
|
+
context.routing = routing;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function setCaptureDerivedRequest(reply: FastifyReply, payload: Record<string, unknown>): void {
|
|
123
|
+
meta(reply).captureDerivedRequest = payload;
|
|
124
|
+
const context = captureContexts.get(reply.request);
|
|
125
|
+
if (context?.enabled) {
|
|
126
|
+
context.derivedRequest = payload;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function setCaptureResponseOverride(
|
|
131
|
+
reply: FastifyReply,
|
|
132
|
+
body: unknown,
|
|
133
|
+
headers?: Record<string, string | string[] | undefined>
|
|
134
|
+
): void {
|
|
135
|
+
meta(reply).captureResponseOverride = { body, headers };
|
|
136
|
+
const context = captureContexts.get(reply.request);
|
|
137
|
+
if (context?.enabled) {
|
|
138
|
+
context.responseBody = body;
|
|
139
|
+
if (headers) {
|
|
140
|
+
context.responseHeaders = headers;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function setCaptureError(reply: FastifyReply, error: { type?: string; message?: string }): void {
|
|
146
|
+
meta(reply).captureError = error;
|
|
147
|
+
const context = captureContexts.get(reply.request);
|
|
148
|
+
if (context?.enabled) {
|
|
149
|
+
context.error = error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function startCaptureStreamResponse(
|
|
154
|
+
reply: FastifyReply,
|
|
155
|
+
headers: Record<string, string | string[] | undefined>,
|
|
156
|
+
contentType: string,
|
|
157
|
+
note?: string
|
|
158
|
+
): void {
|
|
159
|
+
const body: CaptureStreamBody = {
|
|
160
|
+
$type: "stream",
|
|
161
|
+
contentType,
|
|
162
|
+
bytes: 0,
|
|
163
|
+
};
|
|
164
|
+
if (note) {
|
|
165
|
+
body.note = note;
|
|
166
|
+
}
|
|
167
|
+
setCaptureResponseOverride(reply, body, headers);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function appendCaptureStreamChunk(
|
|
171
|
+
reply: FastifyReply,
|
|
172
|
+
chunk: Buffer,
|
|
173
|
+
options?: {
|
|
174
|
+
contentType?: string;
|
|
175
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
176
|
+
}
|
|
177
|
+
): void {
|
|
178
|
+
const context = captureContexts.get(reply.request);
|
|
179
|
+
if (!context?.enabled) return;
|
|
180
|
+
const body = ensureCaptureStreamBody(context, options?.contentType);
|
|
181
|
+
body.bytes += chunk.byteLength;
|
|
182
|
+
if (isTextLikeStream(body.contentType)) {
|
|
183
|
+
body.text = (body.text ?? "") + chunk.toString("utf8");
|
|
184
|
+
}
|
|
185
|
+
context.responseBody = body;
|
|
186
|
+
if (options?.headers) {
|
|
187
|
+
context.responseHeaders = options.headers;
|
|
188
|
+
}
|
|
189
|
+
meta(reply).captureResponseOverride = {
|
|
190
|
+
body,
|
|
191
|
+
headers: context.responseHeaders,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function safeClone<T>(value: T): T {
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
198
|
+
} catch {
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function payloadToBody(payload: unknown): unknown {
|
|
204
|
+
if (payload === null || payload === undefined) return payload;
|
|
205
|
+
if (Buffer.isBuffer(payload)) {
|
|
206
|
+
return { $type: "buffer", base64: payload.toString("base64"), bytes: payload.byteLength };
|
|
207
|
+
}
|
|
208
|
+
if (typeof payload === "string") {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(payload);
|
|
211
|
+
} catch {
|
|
212
|
+
return payload;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return payload;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function ensureCaptureStreamBody(
|
|
219
|
+
context: CaptureContext,
|
|
220
|
+
contentType?: string
|
|
221
|
+
): CaptureStreamBody {
|
|
222
|
+
const existing = context.responseBody as CaptureStreamBody | undefined;
|
|
223
|
+
if (existing?.$type === "stream") {
|
|
224
|
+
if (contentType) {
|
|
225
|
+
existing.contentType = contentType;
|
|
226
|
+
}
|
|
227
|
+
return existing;
|
|
228
|
+
}
|
|
229
|
+
const body: CaptureStreamBody = {
|
|
230
|
+
$type: "stream",
|
|
231
|
+
contentType: contentType ?? "application/octet-stream",
|
|
232
|
+
bytes: 0,
|
|
233
|
+
};
|
|
234
|
+
context.responseBody = body;
|
|
235
|
+
return body;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isTextLikeStream(contentType: string): boolean {
|
|
239
|
+
return (
|
|
240
|
+
contentType.includes("text/") ||
|
|
241
|
+
contentType.includes("json") ||
|
|
242
|
+
contentType.includes("xml") ||
|
|
243
|
+
contentType.includes("event-stream")
|
|
244
|
+
);
|
|
245
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { RequestStats } from "../types";
|
|
4
|
+
import { appendStats } from "../storage/statsRepository";
|
|
5
|
+
import { StoragePaths } from "../storage/files";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Request Statistics Middleware
|
|
9
|
+
*
|
|
10
|
+
* Captures metrics for all /v1/* requests:
|
|
11
|
+
* - Latency (start/end timestamps)
|
|
12
|
+
* - Request/response sizes
|
|
13
|
+
* - Token usage (from upstream response or estimated)
|
|
14
|
+
* - Error classification
|
|
15
|
+
*
|
|
16
|
+
* Does NOT break streaming responses.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface RequestContext {
|
|
20
|
+
requestId: string;
|
|
21
|
+
startTime: number;
|
|
22
|
+
requestBytes: number;
|
|
23
|
+
route: string;
|
|
24
|
+
method: string;
|
|
25
|
+
publicModel?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// WeakMap to store request context without polluting request object
|
|
29
|
+
const requestContexts = new WeakMap<FastifyRequest, RequestContext>();
|
|
30
|
+
|
|
31
|
+
export async function registerRequestStatsMiddleware(
|
|
32
|
+
app: FastifyInstance,
|
|
33
|
+
paths: StoragePaths
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
// Decorate request with stats context
|
|
36
|
+
app.decorateRequest("statsContext", null);
|
|
37
|
+
|
|
38
|
+
// Hook: onRequest - capture start time and request size
|
|
39
|
+
app.addHook("onRequest", async (req: FastifyRequest) => {
|
|
40
|
+
// Only track /v1/* routes
|
|
41
|
+
if (!req.url.startsWith("/v1/")) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const context: RequestContext = {
|
|
46
|
+
requestId: randomUUID(),
|
|
47
|
+
startTime: Date.now(),
|
|
48
|
+
requestBytes: 0,
|
|
49
|
+
route: req.url,
|
|
50
|
+
method: req.method
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Estimate request size from content-length header
|
|
54
|
+
const contentLength = req.headers["content-length"];
|
|
55
|
+
if (contentLength) {
|
|
56
|
+
context.requestBytes = parseInt(contentLength, 10) || 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
requestContexts.set(req, context);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Hook: preHandler - extract model from parsed body
|
|
63
|
+
app.addHook("preHandler", async (req: FastifyRequest) => {
|
|
64
|
+
const context = requestContexts.get(req);
|
|
65
|
+
if (!context) return;
|
|
66
|
+
|
|
67
|
+
const body = req.body as { model?: string } | undefined;
|
|
68
|
+
if (body?.model) {
|
|
69
|
+
context.publicModel = body.model;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Hook: onResponse - log the stats
|
|
74
|
+
app.addHook("onResponse", async (req: FastifyRequest, reply: FastifyReply) => {
|
|
75
|
+
const context = requestContexts.get(req);
|
|
76
|
+
if (!context) return;
|
|
77
|
+
|
|
78
|
+
const latencyMs = Date.now() - context.startTime;
|
|
79
|
+
const statusCode = reply.statusCode;
|
|
80
|
+
|
|
81
|
+
// Try to get response size from content-length header
|
|
82
|
+
let responseBytes = 0;
|
|
83
|
+
const respContentLength = reply.getHeader("content-length");
|
|
84
|
+
if (respContentLength) {
|
|
85
|
+
responseBytes = typeof respContentLength === "number"
|
|
86
|
+
? respContentLength
|
|
87
|
+
: parseInt(String(respContentLength), 10) || 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Determine if there was an error
|
|
91
|
+
let errorType: string | undefined;
|
|
92
|
+
if (statusCode >= 400) {
|
|
93
|
+
if (statusCode >= 500) {
|
|
94
|
+
errorType = "server_error";
|
|
95
|
+
} else if (statusCode === 429) {
|
|
96
|
+
errorType = "rate_limit";
|
|
97
|
+
} else if (statusCode === 401 || statusCode === 403) {
|
|
98
|
+
errorType = "auth_error";
|
|
99
|
+
} else {
|
|
100
|
+
errorType = "client_error";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Extract token info from reply (if stored during route handling)
|
|
105
|
+
const statsPayload = (reply as unknown as { statsPayload?: StatsPayload }).statsPayload;
|
|
106
|
+
|
|
107
|
+
const stats: RequestStats = {
|
|
108
|
+
requestId: context.requestId,
|
|
109
|
+
timestamp: new Date(),
|
|
110
|
+
route: context.route,
|
|
111
|
+
method: context.method,
|
|
112
|
+
publicModel: context.publicModel,
|
|
113
|
+
endpointId: statsPayload?.endpointId,
|
|
114
|
+
endpointName: statsPayload?.endpointName,
|
|
115
|
+
upstreamModel: statsPayload?.upstreamModel,
|
|
116
|
+
requestBytes: context.requestBytes,
|
|
117
|
+
responseBytes,
|
|
118
|
+
latencyMs,
|
|
119
|
+
statusCode,
|
|
120
|
+
errorType,
|
|
121
|
+
totalTokens: statsPayload?.totalTokens ?? estimateTokens(context.requestBytes, responseBytes),
|
|
122
|
+
promptTokens: statsPayload?.promptTokens ?? null,
|
|
123
|
+
completionTokens: statsPayload?.completionTokens ?? null
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Append stats asynchronously (don't block response)
|
|
127
|
+
appendStats(paths, stats).catch((err) => {
|
|
128
|
+
app.log.error({ err }, "Failed to append request stats");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Cleanup
|
|
132
|
+
requestContexts.delete(req);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface StatsPayload {
|
|
137
|
+
endpointId?: string;
|
|
138
|
+
endpointName?: string;
|
|
139
|
+
upstreamModel?: string;
|
|
140
|
+
totalTokens?: number | null;
|
|
141
|
+
promptTokens?: number | null;
|
|
142
|
+
completionTokens?: number | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Helper to set stats payload from route handlers
|
|
147
|
+
*/
|
|
148
|
+
export function setStatsPayload(reply: FastifyReply, payload: StatsPayload): void {
|
|
149
|
+
(reply as unknown as { statsPayload?: StatsPayload }).statsPayload = payload;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Estimate token count from byte sizes when actual usage is not available.
|
|
154
|
+
* Uses rough approximation: ~4 characters per token, ~1 byte per character for English.
|
|
155
|
+
* This is intentionally conservative.
|
|
156
|
+
*/
|
|
157
|
+
function estimateTokens(requestBytes: number, responseBytes: number): number | null {
|
|
158
|
+
if (requestBytes === 0 && responseBytes === 0) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
// Rough estimate: 4 bytes per token average
|
|
162
|
+
return Math.ceil((requestBytes + responseBytes) / 4);
|
|
163
|
+
}
|