diff --git a/.gitignore b/.gitignore
index b54c998ca7..5ed20b41ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,6 +81,7 @@ docs/superpowers/
.kilocode/
# generated assets
+/apps/web/public/api-docs/
/apps/storybook/stories/generated/
/apps/storybook/public/screenshots/
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
index 14d389ed06..6ef852ff8a 100644
--- a/apps/web/next.config.mjs
+++ b/apps/web/next.config.mjs
@@ -131,6 +131,15 @@ const nextConfig = {
// Security headers
async headers() {
return [
+ {
+ source: '/api-docs/swagger-ui/:path*',
+ headers: [
+ {
+ key: 'Cache-Control',
+ value: 'public, max-age=31536000, immutable',
+ },
+ ],
+ },
{
// Apply to all routes
source: '/(.*)',
diff --git a/apps/web/package.json b/apps/web/package.json
index 98005b3306..550804431e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -3,9 +3,10 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "bash ../../scripts/dev.sh",
- "dev:prod-db": "USE_PRODUCTION_DB=true bash ../../scripts/dev.sh",
- "build": "next build",
+ "copy:swagger-ui-assets": "node scripts/copy-swagger-ui-assets.mjs",
+ "dev": "pnpm run copy:swagger-ui-assets && bash ../../scripts/dev.sh",
+ "dev:prod-db": "pnpm run copy:swagger-ui-assets && USE_PRODUCTION_DB=true bash ../../scripts/dev.sh",
+ "build": "pnpm run copy:swagger-ui-assets && next build",
"start": "next start",
"stripe": "stripe listen --forward-to http://localhost:${STRIPE_FORWARD_PORT:-$(cat ../../.dev-port 2>/dev/null || echo 3000)}/api/stripe/webhook",
"lint": "pnpm -w exec oxlint --config .oxlintrc.json apps/web/src",
@@ -162,6 +163,7 @@
"sonner": "2.0.7",
"stripe": "catalog:",
"stytch": "12.43.1",
+ "swagger-ui-dist": "5.32.6",
"tailwind-merge": "3.5.0",
"tldts": "7.0.30",
"ulid": "catalog:",
diff --git a/apps/web/scripts/copy-swagger-ui-assets.mjs b/apps/web/scripts/copy-swagger-ui-assets.mjs
new file mode 100644
index 0000000000..c326781b92
--- /dev/null
+++ b/apps/web/scripts/copy-swagger-ui-assets.mjs
@@ -0,0 +1,23 @@
+import { copyFile, mkdir } from 'node:fs/promises';
+import { createRequire } from 'node:module';
+import { join } from 'node:path';
+
+const require = createRequire(import.meta.url);
+const swaggerUiDist = require('swagger-ui-dist');
+
+const swaggerUiVersion = '5.32.6';
+const sourceDir = swaggerUiDist.getAbsoluteFSPath();
+const destinationDir = new URL(
+ `../public/api-docs/swagger-ui/${swaggerUiVersion}/`,
+ import.meta.url
+);
+
+await mkdir(destinationDir, { recursive: true });
+
+await Promise.all([
+ copyFile(
+ join(sourceDir, 'swagger-ui-bundle.js'),
+ new URL('swagger-ui-bundle.js', destinationDir)
+ ),
+ copyFile(join(sourceDir, 'swagger-ui.css'), new URL('swagger-ui.css', destinationDir)),
+]);
diff --git a/apps/web/src/app/api/docs/route.test.ts b/apps/web/src/app/api/docs/route.test.ts
new file mode 100644
index 0000000000..e0081f7c0d
--- /dev/null
+++ b/apps/web/src/app/api/docs/route.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from '@jest/globals';
+import { GET } from './route';
+
+describe('GET /api/docs', () => {
+ test('renders Swagger UI as read-only without submit controls', async () => {
+ const response = GET();
+ const body = await response.text();
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8');
+ expect(body).toContain('supportedSubmitMethods: []');
+ expect(body).toContain('tryItOutEnabled: false');
+ expect(body).toContain('.swagger-ui .auth-wrapper');
+ expect(body).toContain('.swagger-ui .authorization__btn');
+ expect(body).toContain('display: none !important');
+ });
+});
diff --git a/apps/web/src/app/api/docs/route.ts b/apps/web/src/app/api/docs/route.ts
new file mode 100644
index 0000000000..6e98255c98
--- /dev/null
+++ b/apps/web/src/app/api/docs/route.ts
@@ -0,0 +1,122 @@
+import { randomBytes } from 'node:crypto';
+
+const swaggerUiAssetBaseUrl = '/api-docs/swagger-ui/5.32.6';
+
+function swaggerUiHtml(nonce: string) {
+ return `
+
+
+
+
+ Kilo Code API Docs
+
+
+
+
+
+
+
+
+
+`;
+}
+
+function contentSecurityPolicy(nonce: string) {
+ return [
+ "default-src 'none'",
+ "base-uri 'none'",
+ "connect-src 'self'",
+ "font-src 'self'",
+ "frame-ancestors 'none'",
+ "img-src 'self' data:",
+ `script-src 'self' 'nonce-${nonce}'`,
+ `style-src 'self' 'nonce-${nonce}'`,
+ ].join('; ');
+}
+
+export function GET() {
+ const nonce = randomBytes(16).toString('base64');
+
+ return new Response(swaggerUiHtml(nonce), {
+ headers: {
+ 'content-security-policy': contentSecurityPolicy(nonce),
+ 'content-type': 'text/html; charset=utf-8',
+ },
+ });
+}
diff --git a/apps/web/src/app/api/openapi.json/route.ts b/apps/web/src/app/api/openapi.json/route.ts
new file mode 100644
index 0000000000..39548e6630
--- /dev/null
+++ b/apps/web/src/app/api/openapi.json/route.ts
@@ -0,0 +1,11 @@
+import { generateTrpcOpenApiDocument } from '@/lib/openapi/trpc-openapi';
+
+const openApiDocument = generateTrpcOpenApiDocument();
+
+export function GET() {
+ return Response.json(openApiDocument, {
+ headers: {
+ 'cache-control': 'public, max-age=3600',
+ },
+ });
+}
diff --git a/apps/web/src/lib/openapi/trpc-openapi.test.ts b/apps/web/src/lib/openapi/trpc-openapi.test.ts
new file mode 100644
index 0000000000..55da2672f2
--- /dev/null
+++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it } from '@jest/globals';
+import { TRPCError, initTRPC } from '@trpc/server';
+import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
+import * as z from 'zod';
+import { publicTrpcOpenApiProcedures } from '@/lib/openapi/trpc-registry';
+import { generateTrpcOpenApiDocument } from '@/lib/openapi/trpc-openapi';
+import {
+ TrpcErrorResponseSchema,
+ UpstreamApiError,
+ trpcErrorFormatter,
+ trpcSuccessResponseSchema,
+} from '@/lib/trpc/transport';
+
+const t = initTRPC.create({ errorFormatter: trpcErrorFormatter });
+
+const verificationRouter = t.router({
+ greeting: t.procedure
+ .input(z.object({ name: z.string() }))
+ .query(({ input }) => ({ greeting: `Hello, ${input.name}` })),
+ upstreamFailure: t.procedure.query(() => {
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'Config was modified',
+ cause: new UpstreamApiError('etag_mismatch'),
+ });
+ }),
+});
+
+async function callVerificationProcedure(path: string, input?: unknown): Promise {
+ const url = new URL(`http://localhost/api/trpc/${path}`);
+ if (input !== undefined) url.searchParams.set('input', JSON.stringify(input));
+
+ const response = await fetchRequestHandler({
+ endpoint: '/api/trpc',
+ req: new Request(url, { method: 'GET' }),
+ router: verificationRouter,
+ createContext: async () => ({}),
+ });
+
+ return response.json() as Promise;
+}
+
+describe('generateTrpcOpenApiDocument', () => {
+ it('documents only the allowlisted tRPC procedures', () => {
+ const document = generateTrpcOpenApiDocument();
+
+ expect(Object.keys(document.paths).sort()).toEqual(
+ publicTrpcOpenApiProcedures.map(procedure => `/api/trpc/${procedure.procedurePath}`).sort()
+ );
+ expect(document.paths['/api/trpc/usageAnalytics.getTable']?.get).toMatchObject({
+ operationId: 'usageAnalytics_getTable',
+ summary: 'Return aggregated tabular usage rows',
+ tags: ['Usage Analytics'],
+ });
+ expect(document.paths).not.toHaveProperty('/api/trpc/admin');
+ });
+
+ it('documents bearer auth metadata for protected procedures', () => {
+ const document = generateTrpcOpenApiDocument();
+
+ expect(document.components.securitySchemes.bearerAuth).toEqual({
+ type: 'http',
+ scheme: 'bearer',
+ });
+
+ for (const pathItem of Object.values(document.paths)) {
+ for (const operation of Object.values(pathItem)) {
+ expect(operation).toHaveProperty('security', [{ bearerAuth: [] }]);
+ }
+ }
+ });
+
+ it('generates request and response schemas for usageAnalytics.getTable', () => {
+ const document = generateTrpcOpenApiDocument();
+ const operation = document.paths['/api/trpc/usageAnalytics.getTable']?.get;
+
+ expect(operation).toMatchObject({
+ parameters: [
+ {
+ name: 'input',
+ in: 'query',
+ required: true,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ required: expect.arrayContaining([
+ 'startDate',
+ 'endDate',
+ 'granularity',
+ 'groupBy',
+ ]),
+ properties: {
+ groupBy: {
+ type: 'array',
+ items: { enum: ['feature', 'model', 'mode', 'user', 'provider', 'project'] },
+ },
+ limit: { default: 1000 },
+ },
+ },
+ },
+ },
+ },
+ ],
+ responses: {
+ '200': {
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ required: ['result'],
+ properties: {
+ result: {
+ type: 'object',
+ required: ['data'],
+ properties: {
+ data: {
+ type: 'object',
+ required: ['rows', 'effectiveGranularity'],
+ properties: {
+ rows: { type: 'array' },
+ effectiveGranularity: { enum: ['hour', 'day', 'week', 'month'] },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ '400': {
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ required: ['error'],
+ properties: {
+ error: {
+ type: 'object',
+ required: ['message', 'code', 'data'],
+ properties: {
+ data: {
+ type: 'object',
+ required: expect.arrayContaining(['code', 'httpStatus', 'zodError']),
+ properties: {
+ code: { type: 'string' },
+ httpStatus: { type: 'number' },
+ zodError: {
+ anyOf: expect.arrayContaining([{ type: 'null' }]),
+ },
+ upstreamCode: { type: 'string' },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+ });
+
+ it('matches the actual tRPC success and error transport envelopes', async () => {
+ const success = trpcSuccessResponseSchema(z.object({ greeting: z.string() })).parse(
+ await callVerificationProcedure('greeting', { name: 'Ada' })
+ );
+ expect(success.result.data).toEqual({ greeting: 'Hello, Ada' });
+
+ const badInput = TrpcErrorResponseSchema.parse(
+ await callVerificationProcedure('greeting', { name: 123 })
+ );
+ expect(badInput.error.data.code).toBe('BAD_REQUEST');
+ expect(badInput.error.data.httpStatus).toBe(400);
+ expect(badInput.error.data.zodError?.fieldErrors).toHaveProperty('name');
+
+ const upstreamFailure = TrpcErrorResponseSchema.parse(
+ await callVerificationProcedure('upstreamFailure')
+ );
+ expect(upstreamFailure.error.data.code).toBe('CONFLICT');
+ expect(upstreamFailure.error.data.httpStatus).toBe(409);
+ expect(upstreamFailure.error.data.zodError).toBeNull();
+ expect(upstreamFailure.error.data.upstreamCode).toBe('etag_mismatch');
+ });
+});
diff --git a/apps/web/src/lib/openapi/trpc-openapi.ts b/apps/web/src/lib/openapi/trpc-openapi.ts
new file mode 100644
index 0000000000..66ddcc20b1
--- /dev/null
+++ b/apps/web/src/lib/openapi/trpc-openapi.ts
@@ -0,0 +1,141 @@
+import * as z from 'zod';
+import {
+ publicTrpcOpenApiProcedures,
+ type TrpcOpenApiProcedure,
+} from '@/lib/openapi/trpc-registry';
+import { TrpcErrorResponseSchema, trpcSuccessResponseJsonSchema } from '@/lib/trpc/transport';
+
+type JsonSchema = Record;
+
+type OpenApiDocument = {
+ openapi: '3.1.0';
+ info: {
+ title: string;
+ version: string;
+ };
+ servers: { url: string }[];
+ tags: { name: string }[];
+ components: {
+ securitySchemes: {
+ bearerAuth: {
+ type: 'http';
+ scheme: 'bearer';
+ };
+ };
+ };
+ paths: Record>;
+};
+
+function zodToJsonSchema(schema: z.ZodType): JsonSchema {
+ return z.toJSONSchema(schema, { target: 'draft-7' }) as JsonSchema;
+}
+
+function pathForProcedure(procedure: TrpcOpenApiProcedure): `/api/trpc/${string}` {
+ return `/api/trpc/${procedure.procedurePath}`;
+}
+
+function successResponseSchema(data: JsonSchema): JsonSchema {
+ return trpcSuccessResponseJsonSchema(data);
+}
+
+function errorResponse(description: string) {
+ return {
+ description,
+ content: {
+ 'application/json': {
+ schema: zodToJsonSchema(TrpcErrorResponseSchema),
+ },
+ },
+ };
+}
+
+function requestShapeForProcedure(procedure: TrpcOpenApiProcedure) {
+ const schema = zodToJsonSchema(procedure.input);
+
+ if (procedure.method === 'get') {
+ return {
+ parameters: [
+ {
+ name: 'input',
+ in: 'query',
+ required: true,
+ description: 'URL-encoded JSON tRPC input payload.',
+ content: {
+ 'application/json': {
+ schema,
+ },
+ },
+ },
+ ],
+ };
+ }
+
+ return {
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema,
+ },
+ },
+ },
+ };
+}
+
+function operationForProcedure(procedure: TrpcOpenApiProcedure) {
+ return {
+ operationId: procedure.procedurePath.replaceAll('.', '_'),
+ tags: procedure.tags,
+ summary: procedure.summary,
+ description: procedure.description,
+ security: [{ bearerAuth: [] }],
+ ...requestShapeForProcedure(procedure),
+ responses: {
+ '200': {
+ description: 'Successful tRPC response',
+ content: {
+ 'application/json': {
+ schema: successResponseSchema(zodToJsonSchema(procedure.output)),
+ },
+ },
+ },
+ '400': errorResponse('Invalid request'),
+ '401': errorResponse('Authentication required'),
+ '403': errorResponse('Access denied'),
+ '500': errorResponse('Unexpected server error'),
+ },
+ };
+}
+
+export function generateTrpcOpenApiDocument(): OpenApiDocument {
+ const paths: OpenApiDocument['paths'] = {};
+ const tagNames = new Set();
+
+ for (const procedure of publicTrpcOpenApiProcedures) {
+ for (const tag of procedure.tags) tagNames.add(tag);
+ const path = pathForProcedure(procedure);
+ paths[path] = {
+ ...paths[path],
+ [procedure.method]: operationForProcedure(procedure),
+ };
+ }
+
+ return {
+ openapi: '3.1.0',
+ info: {
+ title: 'Kilo Code tRPC API',
+ version: '1.0.0',
+ },
+ servers: [{ url: '/' }],
+ tags: [...tagNames].sort().map(name => ({ name })),
+ components: {
+ securitySchemes: {
+ bearerAuth: {
+ type: 'http',
+ scheme: 'bearer',
+ },
+ },
+ },
+ paths,
+ };
+}
diff --git a/apps/web/src/lib/openapi/trpc-registry.ts b/apps/web/src/lib/openapi/trpc-registry.ts
new file mode 100644
index 0000000000..0770b36ee7
--- /dev/null
+++ b/apps/web/src/lib/openapi/trpc-registry.ts
@@ -0,0 +1,81 @@
+import type { inferRouterInputs } from '@trpc/server';
+import type * as z from 'zod';
+import type { usageAnalyticsRouter } from '@/routers/usage-analytics-router';
+import {
+ BreakdownInputSchema,
+ BreakdownOutputSchema,
+ SummaryOutputSchema,
+ TableInputSchema,
+ TableOutputSchema,
+ TimeseriesInputSchema,
+ TimeseriesOutputSchema,
+ UsageAnalyticsFiltersSchema,
+} from '@/routers/usage-analytics-schemas';
+
+export type TrpcOpenApiProcedure = {
+ procedurePath: string;
+ method: 'get' | 'post';
+ tags: string[];
+ summary: string;
+ description?: string;
+ input: z.ZodType;
+ output: z.ZodType;
+};
+
+type UsageAnalyticsProcedureKey = Extract<
+ keyof inferRouterInputs,
+ string
+>;
+type UsageAnalyticsOpenApiProcedure =
+ TrpcOpenApiProcedure & {
+ procedurePath: `usageAnalytics.${Key}`;
+ };
+
+function usageAnalyticsProcedure(
+ procedure: UsageAnalyticsOpenApiProcedure
+) {
+ return procedure;
+}
+
+export const publicTrpcOpenApiProcedures = [
+ usageAnalyticsProcedure({
+ procedurePath: 'usageAnalytics.getSummary',
+ method: 'get',
+ tags: ['Usage Analytics'],
+ summary: 'Return aggregate KPI metrics',
+ description:
+ 'Returns aggregate KPI metrics for the authenticated user or an accessible organization. Use this for summary cards and high-level totals.',
+ input: UsageAnalyticsFiltersSchema,
+ output: SummaryOutputSchema,
+ }),
+ usageAnalyticsProcedure({
+ procedurePath: 'usageAnalytics.getTimeseries',
+ method: 'get',
+ tags: ['Usage Analytics'],
+ summary: 'Return usage analytics grouped into time buckets',
+ description:
+ 'Returns usage analytics grouped into time buckets. Use this for trend charts and optional split-by series views.',
+ input: TimeseriesInputSchema,
+ output: TimeseriesOutputSchema,
+ }),
+ usageAnalyticsProcedure({
+ procedurePath: 'usageAnalytics.getBreakdown',
+ method: 'get',
+ tags: ['Usage Analytics'],
+ summary: 'Return top usage values grouped by dimension',
+ description:
+ 'Returns top usage values grouped by a single selected dimension. Use this for dedicated breakdown charts such as features, models, projects, or users.',
+ input: BreakdownInputSchema,
+ output: BreakdownOutputSchema,
+ }),
+ usageAnalyticsProcedure({
+ procedurePath: 'usageAnalytics.getTable',
+ method: 'get',
+ tags: ['Usage Analytics'],
+ summary: 'Return aggregated tabular usage rows',
+ description:
+ 'Returns aggregated tabular usage rows grouped by time bucket and optional dimensions. Use this for the detailed breakdown table and CSV export view.',
+ input: TableInputSchema,
+ output: TableOutputSchema,
+ }),
+];
diff --git a/apps/web/src/lib/trpc/init.ts b/apps/web/src/lib/trpc/init.ts
index 2b1a40c523..a13329eb08 100644
--- a/apps/web/src/lib/trpc/init.ts
+++ b/apps/web/src/lib/trpc/init.ts
@@ -2,9 +2,11 @@ import 'server-only';
import { getUserFromAuth } from '@/lib/user/server';
import { initTRPC, TRPCError } from '@trpc/server';
import type { User } from '@kilocode/db/schema';
-import * as z from 'zod';
import { setTag, trpcMiddleware } from '@sentry/nextjs';
import { userCanManageCredits } from '@/lib/admin/credit-management';
+import { trpcErrorFormatter } from '@/lib/trpc/transport';
+
+export { UpstreamApiError } from '@/lib/trpc/transport';
// Define the context type
export type TRPCContext = {
user: User;
@@ -44,29 +46,8 @@ export const createTRPCContext = async (): Promise => {
*
* The client then sees `err.data.upstreamCode === 'etag_mismatch'`.
*/
-export class UpstreamApiError extends Error {
- constructor(public readonly upstreamCode: string) {
- super(upstreamCode);
- this.name = 'UpstreamApiError';
- }
-}
-
const t = initTRPC.context().create({
- errorFormatter(opts) {
- const { shape, error } = opts;
- return {
- ...shape,
- data: {
- ...shape.data,
- zodError:
- error.code === 'BAD_REQUEST' && error.cause instanceof z.ZodError
- ? z.flattenError(error.cause)
- : null,
- upstreamCode:
- error.cause instanceof UpstreamApiError ? error.cause.upstreamCode : undefined,
- },
- };
- },
+ errorFormatter: trpcErrorFormatter,
});
const sentryMiddleware = t.middleware(
diff --git a/apps/web/src/lib/trpc/transport.ts b/apps/web/src/lib/trpc/transport.ts
new file mode 100644
index 0000000000..64fb533086
--- /dev/null
+++ b/apps/web/src/lib/trpc/transport.ts
@@ -0,0 +1,83 @@
+import type { TRPCDefaultErrorShape, TRPCErrorFormatter } from '@trpc/server';
+import * as z from 'zod';
+
+type JsonSchema = Record;
+
+export class UpstreamApiError extends Error {
+ constructor(public readonly upstreamCode: string) {
+ super(upstreamCode);
+ this.name = 'UpstreamApiError';
+ }
+}
+
+export const TrpcZodFlattenedErrorSchema = z.object({
+ formErrors: z.array(z.string()),
+ fieldErrors: z.record(z.string(), z.array(z.string())),
+});
+
+export const TrpcErrorDataSchema = z
+ .object({
+ code: z.string(),
+ httpStatus: z.number(),
+ stack: z.string().optional(),
+ path: z.string().optional(),
+ zodError: TrpcZodFlattenedErrorSchema.nullable(),
+ upstreamCode: z.string().optional(),
+ })
+ .passthrough();
+
+export const TrpcErrorResponseSchema = z.object({
+ error: z.object({
+ message: z.string(),
+ code: z.number(),
+ data: TrpcErrorDataSchema,
+ }),
+});
+
+export function trpcSuccessResponseSchema(dataSchema: DataSchema) {
+ return z
+ .object({
+ result: z
+ .object({
+ data: dataSchema,
+ })
+ .passthrough(),
+ })
+ .passthrough();
+}
+
+export function trpcSuccessResponseJsonSchema(dataSchema: JsonSchema): JsonSchema {
+ return {
+ type: 'object',
+ properties: {
+ result: {
+ type: 'object',
+ properties: {
+ data: dataSchema,
+ },
+ required: ['data'],
+ additionalProperties: true,
+ },
+ },
+ required: ['result'],
+ additionalProperties: true,
+ };
+}
+
+export type KiloTrpcErrorData = TRPCDefaultErrorShape['data'] & z.infer;
+
+export type KiloTrpcErrorShape = Omit & {
+ data: KiloTrpcErrorData;
+};
+
+export const trpcErrorFormatter = (({ shape, error }) => ({
+ ...shape,
+ data: {
+ ...shape.data,
+ zodError:
+ error.code === 'BAD_REQUEST' && error.cause instanceof z.ZodError
+ ? z.flattenError(error.cause)
+ : null,
+ upstreamCode: error.cause instanceof UpstreamApiError ? error.cause.upstreamCode : undefined,
+ },
+})) satisfies TRPCErrorFormatter;
diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts
index 6804827e20..35858b2b13 100644
--- a/apps/web/src/routers/usage-analytics-router.ts
+++ b/apps/web/src/routers/usage-analytics-router.ts
@@ -12,68 +12,45 @@ import {
import { kilocode_users, organization_memberships, user_auth_provider } from '@kilocode/db/schema';
import type { AuthProviderId } from '@kilocode/db/schema-types';
import { ensureOrganizationAccess } from '@/routers/organizations/utils';
-
-export const GranularitySchema = z.enum(['hour', 'day', 'week', 'month']);
-export type Granularity = z.infer;
-
-export const CostSourceSchema = z.enum(['cost', 'market']);
-export type CostSource = z.infer;
-
-export const DimensionSchema = z.enum(['feature', 'model', 'mode', 'user', 'provider', 'project']);
-export type Dimension = z.infer;
-
-export const MetricSchema = z.enum([
- 'cost',
- 'requests',
- 'tokens',
- 'inputTokens',
- 'outputTokens',
- 'errorRate',
- 'avgLatencyMs',
- 'avgGenerationTimeMs',
- 'costPerRequest',
- 'tokensPerRequest',
- 'cacheHitRatio',
- 'outputInputRatio',
-]);
-export type Metric = z.infer;
-
-const FiltersShape = {
- startDate: z.iso.datetime(),
- endDate: z.iso.datetime(),
- granularity: GranularitySchema,
- costSource: CostSourceSchema.default('cost'),
- organizationId: z.uuid().optional(),
- /**
- * Personal-scope narrowing:
- * - 'personal-only' (default) → organization_id = '' in Snowflake rollups
- * - 'include-orgs' → any organization (including personal)
- * Ignored when `organizationId` is set (org scope always filters by that org).
- */
- personalScope: z.enum(['personal-only', 'include-orgs']).default('personal-only'),
- /**
- * Org-scope narrowing when `organizationId` is set:
- * - 'self' (default) → restricts to ctx.user.id within the organization
- * - 'org-wide' → all users in the org; requires owner/billing_manager
- * Ignored when `organizationId` is not set.
- */
- viewAs: z.enum(['self', 'org-wide']).default('self'),
- features: z.array(z.string()).optional(),
- models: z.array(z.string()).optional(),
- modes: z.array(z.string()).optional(),
- userIds: z.array(z.string()).optional(),
- providers: z.array(z.string()).optional(),
- projects: z.array(z.string()).optional(),
- excludedFeatures: z.array(z.string()).optional(),
- excludedModels: z.array(z.string()).optional(),
- excludedModes: z.array(z.string()).optional(),
- excludedUserIds: z.array(z.string()).optional(),
- excludedProviders: z.array(z.string()).optional(),
- excludedProjects: z.array(z.string()).optional(),
-} as const;
-
-export const UsageAnalyticsFiltersSchema = z.object(FiltersShape);
-export type UsageAnalyticsFilters = z.infer;
+import {
+ BreakdownInputSchema,
+ BreakdownOutputSchema,
+ SummaryOutputSchema,
+ TableInputSchema,
+ TableOutputSchema,
+ TimeseriesInputSchema,
+ TimeseriesOutputSchema,
+ UsageAnalyticsFiltersSchema,
+ type CostSource,
+ type Dimension,
+ type Granularity,
+ type Metric,
+ type SummaryOutput,
+ type UsageAnalyticsFilters,
+} from '@/routers/usage-analytics-schemas';
+
+export {
+ BreakdownInputSchema,
+ BreakdownOutputSchema,
+ CostSourceSchema,
+ DimensionSchema,
+ GranularitySchema,
+ MetricSchema,
+ SummaryOutputSchema,
+ TableInputSchema,
+ TableOutputSchema,
+ TimeseriesInputSchema,
+ TimeseriesOutputSchema,
+ UsageAnalyticsFiltersSchema,
+} from '@/routers/usage-analytics-schemas';
+export type {
+ CostSource,
+ Dimension,
+ Granularity,
+ Metric,
+ SummaryOutput,
+ UsageAnalyticsFilters,
+} from '@/routers/usage-analytics-schemas';
// ---------------------------------------------------------------------------
// Table / tier resolution
@@ -532,35 +509,6 @@ async function timedSnowflakeQuery(
// getSummary
// ---------------------------------------------------------------------------
-const SummaryOutputSchema = z.object({
- costMicrodollars: z.number(),
- requestCount: z.number(),
- inputTokens: z.number(),
- outputTokens: z.number(),
- cacheWriteTokens: z.number(),
- cacheHitTokens: z.number(),
- errorCount: z.number(),
- cancelledCount: z.number(),
- freeRequestCount: z.number(),
- byokRequestCount: z.number(),
- totalLatencyMs: z.number(),
- totalGenerationTimeMs: z.number(),
- latencyCount: z.number(),
- generationTimeCount: z.number(),
- totalTokens: z.number(),
- distinctUsers: z.number(),
- errorRate: z.number(),
- avgLatencyMs: z.number(),
- avgGenerationTimeMs: z.number(),
- costPerRequest: z.number(),
- tokensPerRequest: z.number(),
- cacheHitRatio: z.number(),
- outputInputRatio: z.number(),
- effectiveGranularity: GranularitySchema,
-});
-
-type SummaryOutput = z.infer;
-
function ratioSafe(numerator: number, denominator: number): number {
if (denominator === 0) return 0;
return numerator / denominator;
@@ -582,75 +530,6 @@ function toSafeNumber(value: unknown): number {
return n;
}
-// ---------------------------------------------------------------------------
-// Timeseries
-// ---------------------------------------------------------------------------
-
-const TimeseriesInputSchema = UsageAnalyticsFiltersSchema.extend({
- metric: MetricSchema,
- splitBy: DimensionSchema.optional(),
-});
-
-const TimeseriesPointSchema = z.object({
- datetime: z.string(),
- value: z.number(),
- label: z.string().optional(),
-});
-
-const TimeseriesOutputSchema = z.object({
- timeseries: z.array(TimeseriesPointSchema),
- effectiveGranularity: GranularitySchema,
-});
-
-// ---------------------------------------------------------------------------
-// Breakdown
-// ---------------------------------------------------------------------------
-
-const BreakdownInputSchema = UsageAnalyticsFiltersSchema.extend({
- dimension: DimensionSchema,
- metric: z.enum(['cost', 'requests', 'tokens']),
- limit: z.number().int().min(1).max(100).default(15),
-});
-
-const BreakdownItemSchema = z.object({
- key: z.string(),
- label: z.string(),
- value: z.number(),
- percentage: z.number(),
-});
-
-const BreakdownOutputSchema = z.object({
- breakdown: z.array(BreakdownItemSchema),
- totalValue: z.number(),
- effectiveGranularity: GranularitySchema,
-});
-
-// ---------------------------------------------------------------------------
-// Table
-// ---------------------------------------------------------------------------
-
-const TableInputSchema = UsageAnalyticsFiltersSchema.extend({
- groupBy: z.array(DimensionSchema).max(3),
- limit: z.number().int().min(1).max(10_000).default(1000),
-});
-
-const TableRowSchema = z.object({
- datetime: z.string(),
- dimensions: z.record(z.string(), z.string()),
- costMicrodollars: z.number(),
- requestCount: z.number(),
- inputTokens: z.number(),
- outputTokens: z.number(),
- cacheWriteTokens: z.number(),
- cacheHitTokens: z.number(),
- errorCount: z.number(),
-});
-
-const TableOutputSchema = z.object({
- rows: z.array(TableRowSchema),
- effectiveGranularity: GranularitySchema,
-});
-
// ---------------------------------------------------------------------------
// User list (for org context)
// ---------------------------------------------------------------------------
diff --git a/apps/web/src/routers/usage-analytics-schemas.ts b/apps/web/src/routers/usage-analytics-schemas.ts
new file mode 100644
index 0000000000..0c38fb1f06
--- /dev/null
+++ b/apps/web/src/routers/usage-analytics-schemas.ts
@@ -0,0 +1,136 @@
+import * as z from 'zod';
+
+export const GranularitySchema = z.enum(['hour', 'day', 'week', 'month']);
+export type Granularity = z.infer;
+
+export const CostSourceSchema = z.enum(['cost', 'market']);
+export type CostSource = z.infer;
+
+export const DimensionSchema = z.enum(['feature', 'model', 'mode', 'user', 'provider', 'project']);
+export type Dimension = z.infer;
+
+export const MetricSchema = z.enum([
+ 'cost',
+ 'requests',
+ 'tokens',
+ 'inputTokens',
+ 'outputTokens',
+ 'errorRate',
+ 'avgLatencyMs',
+ 'avgGenerationTimeMs',
+ 'costPerRequest',
+ 'tokensPerRequest',
+ 'cacheHitRatio',
+ 'outputInputRatio',
+]);
+export type Metric = z.infer;
+
+const FiltersShape = {
+ startDate: z.iso.datetime(),
+ endDate: z.iso.datetime(),
+ granularity: GranularitySchema,
+ costSource: CostSourceSchema.default('cost'),
+ organizationId: z.uuid().optional(),
+ personalScope: z.enum(['personal-only', 'include-orgs']).default('personal-only'),
+ viewAs: z.enum(['self', 'org-wide']).default('self'),
+ features: z.array(z.string()).optional(),
+ models: z.array(z.string()).optional(),
+ modes: z.array(z.string()).optional(),
+ userIds: z.array(z.string()).optional(),
+ providers: z.array(z.string()).optional(),
+ projects: z.array(z.string()).optional(),
+ excludedFeatures: z.array(z.string()).optional(),
+ excludedModels: z.array(z.string()).optional(),
+ excludedModes: z.array(z.string()).optional(),
+ excludedUserIds: z.array(z.string()).optional(),
+ excludedProviders: z.array(z.string()).optional(),
+ excludedProjects: z.array(z.string()).optional(),
+} as const;
+
+export const UsageAnalyticsFiltersSchema = z.object(FiltersShape);
+export type UsageAnalyticsFilters = z.infer;
+
+export const SummaryOutputSchema = z.object({
+ costMicrodollars: z.number(),
+ requestCount: z.number(),
+ inputTokens: z.number(),
+ outputTokens: z.number(),
+ cacheWriteTokens: z.number(),
+ cacheHitTokens: z.number(),
+ errorCount: z.number(),
+ cancelledCount: z.number(),
+ freeRequestCount: z.number(),
+ byokRequestCount: z.number(),
+ totalLatencyMs: z.number(),
+ totalGenerationTimeMs: z.number(),
+ latencyCount: z.number(),
+ generationTimeCount: z.number(),
+ totalTokens: z.number(),
+ distinctUsers: z.number(),
+ errorRate: z.number(),
+ avgLatencyMs: z.number(),
+ avgGenerationTimeMs: z.number(),
+ costPerRequest: z.number(),
+ tokensPerRequest: z.number(),
+ cacheHitRatio: z.number(),
+ outputInputRatio: z.number(),
+ effectiveGranularity: GranularitySchema,
+});
+export type SummaryOutput = z.infer;
+
+export const TimeseriesInputSchema = UsageAnalyticsFiltersSchema.extend({
+ metric: MetricSchema,
+ splitBy: DimensionSchema.optional(),
+});
+
+const TimeseriesPointSchema = z.object({
+ datetime: z.string(),
+ value: z.number(),
+ label: z.string().optional(),
+});
+
+export const TimeseriesOutputSchema = z.object({
+ timeseries: z.array(TimeseriesPointSchema),
+ effectiveGranularity: GranularitySchema,
+});
+
+export const BreakdownInputSchema = UsageAnalyticsFiltersSchema.extend({
+ dimension: DimensionSchema,
+ metric: z.enum(['cost', 'requests', 'tokens']),
+ limit: z.number().int().min(1).max(100).default(15),
+});
+
+const BreakdownItemSchema = z.object({
+ key: z.string(),
+ label: z.string(),
+ value: z.number(),
+ percentage: z.number(),
+});
+
+export const BreakdownOutputSchema = z.object({
+ breakdown: z.array(BreakdownItemSchema),
+ totalValue: z.number(),
+ effectiveGranularity: GranularitySchema,
+});
+
+export const TableInputSchema = UsageAnalyticsFiltersSchema.extend({
+ groupBy: z.array(DimensionSchema).max(3),
+ limit: z.number().int().min(1).max(10_000).default(1000),
+});
+
+const TableRowSchema = z.object({
+ datetime: z.string(),
+ dimensions: z.record(z.string(), z.string()),
+ costMicrodollars: z.number(),
+ requestCount: z.number(),
+ inputTokens: z.number(),
+ outputTokens: z.number(),
+ cacheWriteTokens: z.number(),
+ cacheHitTokens: z.number(),
+ errorCount: z.number(),
+});
+
+export const TableOutputSchema = z.object({
+ rows: z.array(TableRowSchema),
+ effectiveGranularity: GranularitySchema,
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0291c1ea00..46acb4dbb2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -893,6 +893,9 @@ importers:
stytch:
specifier: 12.43.1
version: 12.43.1
+ swagger-ui-dist:
+ specifier: 5.32.6
+ version: 5.32.6
tailwind-merge:
specifier: 3.5.0
version: 3.5.0
@@ -7587,6 +7590,9 @@ packages:
resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
+ '@scarf/scarf@1.4.0':
+ resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
+
'@segment/analytics-core@1.7.0':
resolution: {integrity: sha512-0DHSriS/oAB/2bIgOMv3fFV9/ivp39ibdOTTf+dDOhf+vlciBv0+MHw47k/6PRobbuls27cKkKZAKc4DDC2+gw==}
@@ -15381,6 +15387,9 @@ packages:
svg-parser@2.0.4:
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
+ swagger-ui-dist@5.32.6:
+ resolution: {integrity: sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==}
+
synckit@0.11.12:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -22212,6 +22221,8 @@ snapshots:
'@sapphire/snowflake@3.5.3': {}
+ '@scarf/scarf@1.4.0': {}
+
'@segment/analytics-core@1.7.0':
dependencies:
'@lukeed/uuid': 2.0.1
@@ -32111,6 +32122,10 @@ snapshots:
svg-parser@2.0.4: {}
+ swagger-ui-dist@5.32.6:
+ dependencies:
+ '@scarf/scarf': 1.4.0
+
synckit@0.11.12:
dependencies:
'@pkgr/core': 0.2.9
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 0189b9f384..298d1892c8 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -135,3 +135,4 @@ allowBuilds:
openclaw: false
'@openrouter/sdk': false
tree-sitter-bash: false
+ '@scarf/scarf': false
diff --git a/scripts/prepare.sh b/scripts/prepare.sh
index 598123a923..771bcf0e5c 100755
--- a/scripts/prepare.sh
+++ b/scripts/prepare.sh
@@ -10,3 +10,4 @@ fi
husky
pnpm --filter @kilocode/trpc run build
+pnpm --filter web run copy:swagger-ui-assets