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 + + + + +
+
+

Kilo Code API Docs

+

Swagger UI generated from the allowlisted tRPC OpenAPI document.

+
+ Open JSON +
+
+ + + +`; +} + +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