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.ts b/apps/web/src/app/api/docs/route.ts
new file mode 100644
index 0000000000..de56ddfb28
--- /dev/null
+++ b/apps/web/src/app/api/docs/route.ts
@@ -0,0 +1,115 @@
+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..301c4a13fb
--- /dev/null
+++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from '@jest/globals';
+import { publicTrpcOpenApiProcedures } from '@/lib/openapi/trpc-registry';
+import { generateTrpcOpenApiDocument } from '@/lib/openapi/trpc-openapi';
+
+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']?.post).toMatchObject({
+ operationId: 'usageAnalytics_getTable',
+ summary: 'Get tabular usage analytics',
+ tags: ['Usage Analytics'],
+ });
+ expect(document.paths).not.toHaveProperty('/api/trpc/admin');
+ });
+
+ it('generates request and response schemas for usageAnalytics.getTable', () => {
+ const document = generateTrpcOpenApiDocument();
+ const operation = document.paths['/api/trpc/usageAnalytics.getTable']?.post;
+
+ expect(operation).toMatchObject({
+ requestBody: {
+ 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'] },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+ });
+});
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..5c5f5f2850
--- /dev/null
+++ b/apps/web/src/lib/openapi/trpc-openapi.ts
@@ -0,0 +1,147 @@
+import * as z from 'zod';
+import {
+ publicTrpcOpenApiProcedures,
+ type TrpcOpenApiProcedure,
+} from '@/lib/openapi/trpc-registry';
+
+type JsonSchema = Record;
+
+type OpenApiDocument = {
+ openapi: '3.1.0';
+ info: {
+ title: string;
+ version: string;
+ };
+ servers: { url: string }[];
+ tags: { name: string }[];
+ components: {
+ securitySchemes: {
+ apiKey: {
+ 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 {
+ type: 'object',
+ properties: {
+ result: {
+ type: 'object',
+ properties: {
+ data,
+ },
+ required: ['data'],
+ additionalProperties: true,
+ },
+ },
+ required: ['result'],
+ additionalProperties: true,
+ };
+}
+
+function errorResponse(description: string) {
+ return {
+ description,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ error: {
+ type: 'object',
+ properties: {
+ message: { type: 'string' },
+ code: { type: 'number' },
+ data: {
+ type: 'object',
+ additionalProperties: true,
+ },
+ },
+ required: ['message', 'code'],
+ additionalProperties: true,
+ },
+ },
+ required: ['error'],
+ additionalProperties: true,
+ },
+ },
+ },
+ };
+}
+
+function operationForProcedure(procedure: TrpcOpenApiProcedure) {
+ return {
+ operationId: procedure.procedurePath.replaceAll('.', '_'),
+ tags: procedure.tags,
+ summary: procedure.summary,
+ description: procedure.description,
+ security: procedure.security === 'apiKey' ? [{ apiKey: [] }] : undefined,
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: zodToJsonSchema(procedure.input),
+ },
+ },
+ },
+ 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: {
+ apiKey: {
+ 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..6a7a7aa42b
--- /dev/null
+++ b/apps/web/src/lib/openapi/trpc-registry.ts
@@ -0,0 +1,86 @@
+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: 'post';
+ tags: string[];
+ summary: string;
+ description?: string;
+ input: z.ZodType;
+ output: z.ZodType;
+ security: 'apiKey';
+};
+
+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: 'post',
+ tags: ['Usage Analytics'],
+ summary: 'Get aggregate usage 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,
+ security: 'apiKey',
+ }),
+ usageAnalyticsProcedure({
+ procedurePath: 'usageAnalytics.getTimeseries',
+ method: 'post',
+ tags: ['Usage Analytics'],
+ summary: 'Get usage over time',
+ description:
+ 'Returns usage analytics grouped into time buckets. Use this for trend charts and optional split-by series views.',
+ input: TimeseriesInputSchema,
+ output: TimeseriesOutputSchema,
+ security: 'apiKey',
+ }),
+ usageAnalyticsProcedure({
+ procedurePath: 'usageAnalytics.getBreakdown',
+ method: 'post',
+ tags: ['Usage Analytics'],
+ summary: 'Get usage breakdown',
+ 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,
+ security: 'apiKey',
+ }),
+ usageAnalyticsProcedure({
+ procedurePath: 'usageAnalytics.getTable',
+ method: 'post',
+ tags: ['Usage Analytics'],
+ summary: 'Get tabular usage analytics',
+ 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,
+ security: 'apiKey',
+ }),
+];
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