package handler import ( "encoding/json" "net/http" "gopkg.in/yaml.v3" ) // openAPISpec is the embedded OpenAPI 3.0 specification for the proxy API. var openAPISpec = ` openapi: "3.0.3" info: title: Claude Code Proxy API description: | An Anthropic API proxy that provides request logging, model routing, usage analytics, and a dashboard UI. The proxy exposes two groups of endpoints: **Proxy endpoints** – drop-in replacements for the upstream Anthropic API. Point your Claude Code (or any Anthropic SDK client) at this proxy and all requests are forwarded, logged, and optionally re-routed to a different model or provider. **Dashboard endpoints** – read-only analytics and configuration APIs that power the built-in web dashboard. These are protected by HTTP Basic Auth when DASHBOARD_PASSWORD is set. version: "1.0.0" contact: name: Claude Code Proxy license: name: MIT servers: - url: / description: This proxy instance tags: - name: proxy description: | Drop-in Anthropic API proxy endpoints. Authenticate with the same x-api-key / Authorization header you use for the upstream Anthropic API. - name: dashboard description: | Analytics and configuration endpoints for the web dashboard. Protected by DASHBOARD_PASSWORD basic auth when configured. - name: health description: Health and discovery endpoints (no auth required). paths: # ── Proxy endpoints ──────────────────────────────────────────────────── /v1/messages: post: operationId: createMessage tags: [proxy] summary: Create a message (Anthropic Messages API) description: | Forwards the request to the upstream Anthropic (or routed) provider. Supports both streaming (SSE) and non-streaming responses. The proxy logs the request/response, applies any configured model routing rules and header rules, then returns the upstream response verbatim. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AnthropicRequest" responses: "200": description: Successful message response (non-streaming) content: application/json: schema: $ref: "#/components/schemas/AnthropicResponse" text/event-stream: schema: type: string description: SSE stream of Anthropic streaming events "400": description: Invalid request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Upstream or internal error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /v1/chat/completions: post: operationId: chatCompletions tags: [proxy] summary: Chat completions (OpenAI-compatible – not supported) description: | Returns a 400 error directing callers to use /v1/messages instead. This endpoint exists for compatibility detection only. requestBody: content: application/json: schema: type: object responses: "400": description: Not supported – use /v1/messages content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /v1/models: get: operationId: listModels tags: [proxy] summary: List available models description: | Returns the list of models known to the proxy. The proxy uses pattern-based routing so any model accepted by the upstream provider will work; this endpoint currently returns an empty list. responses: "200": description: Model list content: application/json: schema: $ref: "#/components/schemas/ModelsResponse" # ── Health & discovery ───────────────────────────────────────────────── /health: get: operationId: healthCheck tags: [health] summary: Health check (binary up/ready signal for load balancers) description: | Returns 200 with status=healthy while the process is accepting traffic, and 503 with status=draining once a SIGTERM has been received. Traefik (or any LB doing health-based routing) should treat 503 as "stop sending new requests to this backend", which is the signal the graceful-drain loop relies on. responses: "200": description: Service is healthy content: application/json: schema: $ref: "#/components/schemas/HealthResponse" "503": description: Service is draining (SIGTERM received). Stop routing here. content: application/json: schema: $ref: "#/components/schemas/DrainingResponse" /livez: get: operationId: livenessProbe tags: [health] summary: Live operational state (in-flight gauge + draining flag) description: | Always returns 200 with the current in-flight request count and draining flag. Distinct from /health, which is a binary up/ready signal — /livez is for observability and deploy-time orchestration ("how many requests are still active before I cycle this slot?"). responses: "200": description: Operational state content: application/json: schema: $ref: "#/components/schemas/LivezResponse" /openapi.json: get: operationId: getOpenAPISpec tags: [health] summary: OpenAPI specification (JSON) responses: "200": description: The OpenAPI 3.0 spec for this API content: application/json: schema: type: object /openapi.yaml: get: operationId: getOpenAPISpecYAML tags: [health] summary: OpenAPI specification (YAML) responses: "200": description: The OpenAPI 3.0 spec for this API content: application/x-yaml: schema: type: string # ── Dashboard endpoints ──────────────────────────────────────────────── /api/requests: get: operationId: getRequests tags: [dashboard] summary: List logged requests parameters: - name: page in: query schema: { type: integer, default: 1 } - name: limit in: query schema: { type: integer, default: 10 } - name: model in: query schema: { type: string, default: "all" } description: Filter by model name (substring match) or "all" responses: "200": description: Paginated request list content: application/json: schema: type: object properties: requests: type: array items: $ref: "#/components/schemas/RequestLog" total: type: integer delete: operationId: deleteRequests tags: [dashboard] summary: Clear all logged requests responses: "200": description: Requests cleared content: application/json: schema: type: object properties: message: { type: string } deleted: { type: integer } /api/requests/summary: get: operationId: getRequestsSummary tags: [dashboard] summary: Lightweight request summaries for fast list rendering parameters: - name: model in: query schema: { type: string, default: "all" } - name: start in: query schema: { type: string, format: date-time } description: Start of time range (UTC ISO 8601) - name: end in: query schema: { type: string, format: date-time } description: End of time range (UTC ISO 8601) - name: offset in: query schema: { type: integer, default: 0 } - name: limit in: query schema: { type: integer, default: 0 } description: Max results (0 = unlimited) responses: "200": description: Paginated request summaries content: application/json: schema: type: object properties: requests: type: array items: $ref: "#/components/schemas/RequestSummary" total: { type: integer } offset: { type: integer } limit: { type: integer } /api/requests/latest-date: get: operationId: getLatestRequestDate tags: [dashboard] summary: Date of the most recent logged request responses: "200": content: application/json: schema: type: object properties: latestDate: { type: string, format: date-time } /api/requests/{id}: get: operationId: getRequestByID tags: [dashboard] summary: Get a single request by ID parameters: - name: id in: path required: true schema: { type: string } description: Short or full request ID responses: "200": content: application/json: schema: type: object properties: request: $ref: "#/components/schemas/RequestLog" fullId: { type: string } "404": description: Request not found /api/stats: get: operationId: getStats tags: [dashboard] summary: Aggregated usage statistics parameters: - name: start_date in: query schema: { type: string } - name: end_date in: query schema: { type: string } - name: model in: query schema: { type: string } - name: org in: query schema: { type: string } responses: "200": content: application/json: schema: $ref: "#/components/schemas/UsageStats" /api/stats/dashboard: get: operationId: getDashboardStats tags: [dashboard] summary: Daily token usage for dashboard charts parameters: - name: start in: query schema: { type: string, format: date-time } - name: end in: query schema: { type: string, format: date-time } - name: org in: query schema: { type: string } responses: "200": content: application/json: schema: $ref: "#/components/schemas/DashboardStats" /api/stats/hourly: get: operationId: getHourlyStats tags: [dashboard] summary: Hourly token usage breakdown parameters: - name: start in: query required: true schema: { type: string, format: date-time } - name: end in: query required: true schema: { type: string, format: date-time } - name: bucket in: query schema: { type: integer, default: 60 } description: Bucket size in minutes - name: org in: query schema: { type: string } responses: "200": content: application/json: schema: $ref: "#/components/schemas/HourlyStatsResponse" /api/stats/models: get: operationId: getModelStats tags: [dashboard] summary: Per-model token usage breakdown parameters: - name: start in: query required: true schema: { type: string, format: date-time } - name: end in: query required: true schema: { type: string, format: date-time } - name: org in: query schema: { type: string } responses: "200": content: application/json: schema: $ref: "#/components/schemas/ModelStatsResponse" /api/stats/organizations: get: operationId: getOrganizations tags: [dashboard] summary: List distinct organization IDs responses: "200": content: application/json: schema: type: object properties: organizations: type: array items: { type: string } /api/conversations: get: operationId: getConversations tags: [dashboard] summary: List conversations (grouped by session) parameters: - name: model in: query schema: { type: string, default: "all" } - name: page in: query schema: { type: integer, default: 1 } - name: limit in: query schema: { type: integer, default: 10 } responses: "200": content: application/json: schema: type: object properties: conversations: type: array items: type: object properties: id: { type: string } requestCount: { type: integer } startTime: { type: string, format: date-time } lastActivity: { type: string, format: date-time } duration: { type: integer, description: "Duration in ms" } firstMessage: { type: string } projectPath: { type: string } projectName: { type: string } model: { type: string } hasMore: { type: boolean } total: { type: integer } page: { type: integer } limit: { type: integer } /api/conversations/{id}: get: operationId: getConversationByID tags: [dashboard] summary: Get a single conversation by session ID parameters: - name: id in: path required: true schema: { type: string } - name: project in: query required: true schema: { type: string } description: Project path the conversation belongs to responses: "200": content: application/json: schema: type: object "404": description: Conversation not found /api/conversations/project: get: operationId: getConversationsByProject tags: [dashboard] summary: List conversations for a specific project parameters: - name: project in: query required: true schema: { type: string } responses: "200": content: application/json: schema: type: object /api/settings: get: operationId: getSettings tags: [dashboard] summary: Get current proxy settings responses: "200": content: application/json: schema: $ref: "#/components/schemas/ProxySettings" put: operationId: saveSettings tags: [dashboard] summary: Update proxy settings requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ProxySettings" responses: "200": content: application/json: schema: $ref: "#/components/schemas/ProxySettings" components: securitySchemes: apiKey: type: apiKey in: header name: x-api-key description: Anthropic API key (forwarded to upstream) bearerAuth: type: http scheme: bearer description: Bearer token authentication dashboardBasicAuth: type: http scheme: basic description: Dashboard password (username is ignored) schemas: ErrorResponse: type: object properties: error: { type: string } details: { type: string } HealthResponse: type: object properties: status: { type: string, example: "healthy" } timestamp: { type: string, format: date-time } DrainingResponse: type: object properties: status: { type: string, example: "draining" } timestamp: { type: string, format: date-time } in_flight: { type: integer, example: 3 } LivezResponse: type: object properties: status: { type: string, example: "ok" } timestamp: { type: string, format: date-time } in_flight: { type: integer, example: 0 } draining: { type: boolean, example: false } AnthropicRequest: type: object required: [model, messages, max_tokens] properties: model: type: string description: | Model ID to use. The proxy may re-route this to a different model/provider based on configured routing rules. example: "claude-sonnet-4-5-20250514" messages: type: array items: $ref: "#/components/schemas/AnthropicMessage" max_tokens: type: integer example: 1024 temperature: type: number format: float system: type: array items: $ref: "#/components/schemas/SystemMessage" stream: type: boolean default: false tools: type: array items: $ref: "#/components/schemas/Tool" tool_choice: description: Tool choice configuration AnthropicMessage: type: object required: [role, content] properties: role: type: string enum: [user, assistant] content: description: String or array of content blocks oneOf: - type: string - type: array items: type: object properties: type: { type: string } text: { type: string } SystemMessage: type: object properties: type: { type: string, example: "text" } text: { type: string } cache_control: type: object properties: type: { type: string, example: "ephemeral" } Tool: type: object properties: name: { type: string } description: { type: string } input_schema: type: object properties: type: {} properties: { type: object } required: type: array items: { type: string } AnthropicResponse: type: object properties: id: { type: string } type: { type: string, example: "message" } role: { type: string, example: "assistant" } model: { type: string } stop_reason: { type: string } stop_sequence: { type: string, nullable: true } content: type: array items: type: object properties: type: { type: string } text: { type: string } usage: $ref: "#/components/schemas/AnthropicUsage" AnthropicUsage: type: object properties: input_tokens: { type: integer } output_tokens: { type: integer } cache_creation_input_tokens: { type: integer } cache_read_input_tokens: { type: integer } service_tier: { type: string } ModelsResponse: type: object properties: object: { type: string, example: "list" } data: type: array items: type: object properties: id: { type: string } object: { type: string } created: { type: integer } owned_by: { type: string } RequestLog: type: object properties: requestId: { type: string } timestamp: { type: string, format: date-time } method: { type: string } endpoint: { type: string } model: { type: string } originalModel: { type: string } routedModel: { type: string } userAgent: { type: string } contentType: { type: string } conversationHash: { type: string } messageCount: { type: integer } organizationId: { type: string } response: $ref: "#/components/schemas/ResponseLog" ResponseLog: type: object properties: statusCode: { type: integer } responseTime: { type: integer, description: "Response time in ms" } isStreaming: { type: boolean } completedAt: { type: string, format: date-time } streamError: { type: string } rateLimit: $ref: "#/components/schemas/RateLimitInfo" RateLimitInfo: type: object properties: organizationId: { type: string } requestsLimit: { type: integer } requestsRemaining: { type: integer } requestsReset: { type: string } tokensLimit: { type: integer } tokensRemaining: { type: integer } tokensReset: { type: string } unifiedStatus: { type: string } unifiedUtilization5h: { type: number } unifiedReset5h: { type: string } unifiedUtilization7d: { type: number } unifiedReset7d: { type: string } RequestSummary: type: object properties: requestId: { type: string } timestamp: { type: string, format: date-time } method: { type: string } endpoint: { type: string } model: { type: string } originalModel: { type: string } routedModel: { type: string } statusCode: { type: integer } responseTime: { type: integer } usage: $ref: "#/components/schemas/AnthropicUsage" conversationHash: { type: string } messageCount: { type: integer } stopReason: { type: string } UsageStats: type: object properties: total_requests: { type: integer } total_input_tokens: { type: integer, format: int64 } total_output_tokens: { type: integer, format: int64 } total_cache_tokens: { type: integer, format: int64 } requests_by_model: type: object additionalProperties: type: object properties: request_count: { type: integer } input_tokens: { type: integer, format: int64 } output_tokens: { type: integer, format: int64 } cache_tokens: { type: integer, format: int64 } start_date: { type: string } end_date: { type: string } DashboardStats: type: object properties: dailyStats: type: array items: type: object properties: date: { type: string } tokens: { type: integer, format: int64 } requests: { type: integer } HourlyStatsResponse: type: object properties: hourlyStats: type: array items: type: object properties: hour: { type: integer } label: { type: string } tokens: { type: integer, format: int64 } requests: { type: integer } todayTokens: { type: integer, format: int64 } todayRequests: { type: integer } avgResponseTime: { type: integer, format: int64 } ModelStatsResponse: type: object properties: modelStats: type: array items: type: object properties: model: { type: string } tokens: { type: integer, format: int64 } requests: { type: integer } ProxySettings: type: object properties: requestHeaderRules: type: array items: $ref: "#/components/schemas/HeaderRule" responseHeaderRules: type: array items: $ref: "#/components/schemas/HeaderRule" HeaderRule: type: object properties: header: { type: string, description: "Header name (case-insensitive)" } action: type: string enum: [block, set, replace] value: { type: string } find: { type: string, description: "For replace action: string to find" } enabled: { type: boolean } security: - apiKey: [] - bearerAuth: [] ` // OpenAPIJSON serves the OpenAPI spec as JSON. func (h *Handler) OpenAPIJSON(w http.ResponseWriter, r *http.Request) { var spec interface{} if err := yaml.Unmarshal([]byte(openAPISpec), &spec); err != nil { writeErrorResponse(w, "Failed to parse OpenAPI spec", http.StatusInternalServerError) return } spec = convertYAMLToJSON(spec) w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(spec) } // OpenAPIYAML serves the OpenAPI spec as YAML. func (h *Handler) OpenAPIYAML(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/x-yaml") w.Header().Set("Access-Control-Allow-Origin", "*") w.Write([]byte(openAPISpec)) } // convertYAMLToJSON recursively converts map[string]interface{} (from yaml) to // JSON-compatible types. yaml.v3 uses map[string]interface{} by default so this // mainly handles nested maps. func convertYAMLToJSON(v interface{}) interface{} { switch val := v.(type) { case map[string]interface{}: out := make(map[string]interface{}, len(val)) for k, v2 := range val { out[k] = convertYAMLToJSON(v2) } return out case []interface{}: out := make([]interface{}, len(val)) for i, v2 := range val { out[i] = convertYAMLToJSON(v2) } return out default: return v } }