Local fork: hardening + ops improvements (timeout knob, demotion, /livez, drain)

This commit captures both the prior accumulated work-in-progress
(framework migration web/→svelte/, postgres storage, conversation
viewer, dashboard auth, OpenAPI spec, integration tests) AND today's
operational improvements layered on top. History wasn't checkpointed
incrementally; happy to split it via interactive rebase if a reviewer
wants smaller commits.

Today's changes (in addition to the older WIP):

1. Configurable upstream response-header timeout
   - ANTHROPIC_RESPONSE_HEADER_TIMEOUT env (default 300s)
   - Replaces hardcoded 300s in provider/anthropic.go that was firing
     on opus + 1M-context + extended thinking non-streaming requests
   - Files: internal/config/config.go, internal/provider/anthropic.go

2. Structured forward-error diagnostic logging
   - When a forward to Anthropic fails, log a single key=value line
     with request_id, model, stream, body_bytes, has_thinking,
     anthropic_beta, query, elapsed, ctx_err — alongside the existing
     human-readable error line for back-compat
   - Files: internal/handler/handlers.go (logForwardFailure)

3. Full SSE protocol passthrough + Flusher fix
   - handler/handlers.go: forward all SSE lines verbatim (event:, id:,
     retry:, : comments, blank-line terminators), not only data:.
     Previous code produced malformed SSE for strict parsers.
   - middleware/logging.go: explicit Flush() method on responseWriter.
     Embedding http.ResponseWriter (interface) does not auto-promote
     Flush(), so every w.(http.Flusher) check in the streaming
     handler was returning ok=false and SSE writes buffered in net/http
     until the body closed.

4. Non-streaming → streaming demotion (feature-flagged)
   - ANTHROPIC_DEMOTE_NONSTREAMING env (default false)
   - When enabled and the routed provider is anthropic, force stream=true
     upstream for clients that asked for stream=false. Receive SSE,
     accumulate via accumulateSSEToMessage (handles text, tool_use with
     partial_json reassembly, thinking, signature, citations_delta,
     usage merge), and synthesize a single non-streaming JSON response.
   - Eliminates the ResponseHeaderTimeout class of failure entirely.
   - Body rewrite uses json.Decoder + UseNumber() to preserve integer
     precision in unknown nested fields (tool inputs from prior turns).
   - Files: internal/config/config.go, internal/handler/handlers.go,
     cmd/proxy/main.go, cmd/proxy/main_test.go

5. Live operational state: /livez gauge + graceful drain
   - New internal/runtime package: atomic in-flight counter + draining flag
   - New middleware/inflight.go: increments runtime gauge, applied to
     /v1/* subrouter so Messages, ChatCompletions, and ProxyPassthrough
     are all counted
   - /v1/* moved to a gorilla/mux subrouter so the InFlight middleware
     applies surgically; /health, /livez, /openapi.* remain on parent
     router (unauthenticated, uncounted)
   - Health handler returns 503 draining when runtime.IsDraining() is
     true, so Traefik stops routing to a slot before drain begins
   - New /livez handler returns {status, in_flight, draining, timestamp}
   - SIGTERM handler in main.go: SetDraining(true), poll for in_flight==0
     with 32-min ceiling and 1s tick (logs every 10s), then srv.Shutdown
   - Auth bypass list extended with /livez
   - Files: internal/runtime/runtime.go (new),
     internal/middleware/inflight.go (new),
     internal/middleware/auth.go,
     internal/handler/handlers.go (Health, Livez, runtime import),
     cmd/proxy/main.go (subrouter, drain loop)

6. OpenAPI spec updates
   - Document Health 503 response and new DrainingResponse schema
   - Add /livez path with LivezResponse schema
   - Files: internal/handler/openapi.go

Verified: go build ./... clean, go test ./... all pass, go vet clean.
Three rounds of codex peer review across changes 1-5; all feedback
addressed (citations_delta, json.Number precision, drain-loop logging
via lastLog timestamp, PathPrefix tightened to "/v1/").
This commit is contained in:
sid 2026-05-02 15:15:58 -06:00
parent b9da198e1f
commit 8e550b9785
152 changed files with 19227 additions and 19463 deletions

View file

@ -69,6 +69,12 @@ web/npm-debug.log*
web/yarn-debug.log* web/yarn-debug.log*
web/yarn-error.log* web/yarn-error.log*
# Svelte specific
svelte/.svelte-kit/
svelte/build/
svelte/node_modules/
svelte/npm-debug.log*
# Go specific # Go specific
proxy/vendor/ proxy/vendor/
*.test *.test

View file

@ -1,4 +1,4 @@
# Claude Code Monitor Configuration # Claude Code Proxy Configuration
# Server Configuration # Server Configuration
SERVER_HOST=127.0.0.1 SERVER_HOST=127.0.0.1
@ -25,7 +25,24 @@ ANTHROPIC_MAX_RETRIES=3
# AUTH_API_KEY_HEADER=x-api-key # AUTH_API_KEY_HEADER=x-api-key
# AUTH_ALLOW_LOCALHOST_BYPASS=true # AUTH_ALLOW_LOCALHOST_BYPASS=true
# Dashboard Auth (protects web UI and data endpoints with HTTP basic auth)
# When set, accessing the dashboard or /api/* data routes requires user "admin"
# with this password. Proxy endpoints (/v1/messages, /health) are NOT affected.
# DASHBOARD_PASSWORD=change-me-to-a-strong-password
# Reverse-proxy deployments
# Set this to true when the proxy itself binds publicly but is only reachable
# through a trusted reverse proxy such as Traefik.
# TRUST_PROXY=true
# Storage Configuration # Storage Configuration
# DB_TYPE=sqlite
# DATABASE_URL=postgresql://user:password@localhost:5432/claude_code_proxy?sslmode=disable
# TEST_POSTGRES_DSN=postgresql://user:password@localhost:5432/claude_code_proxy_test?sslmode=disable
# TEST_POSTGRES_USER=test
# TEST_POSTGRES_PASSWORD=test
# TEST_POSTGRES_DB=claude_code_proxy_test
# TEST_POSTGRES_PORT=5434
DB_PATH=requests.db DB_PATH=requests.db
STORAGE_CAPTURE_REQUEST_BODY=true STORAGE_CAPTURE_REQUEST_BODY=true
STORAGE_CAPTURE_RESPONSE_BODY=true STORAGE_CAPTURE_RESPONSE_BODY=true
@ -34,6 +51,7 @@ STORAGE_RETENTION_DAYS=0
# STORAGE_REDACTED_FIELDS=api_key,authorization,token,password,secret,access_token,refresh_token,client_secret # STORAGE_REDACTED_FIELDS=api_key,authorization,token,password,secret,access_token,refresh_token,client_secret
# CORS Configuration (comma-separated values) # CORS Configuration (comma-separated values)
# CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173 # Defaults are permissive. Set these explicitly if you want tighter browser access.
# CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:5174,http://127.0.0.1:5174
# CORS_ALLOWED_METHODS=GET,POST,DELETE,OPTIONS # CORS_ALLOWED_METHODS=GET,POST,DELETE,OPTIONS
# CORS_ALLOWED_HEADERS=Accept,Authorization,Content-Type,Anthropic-Version,Anthropic-Beta,X-API-Key,X-Requested-With # CORS_ALLOWED_HEADERS=Accept,Authorization,Content-Type,Anthropic-Version,Anthropic-Beta,X-API-Key,X-Requested-With

5
.gitignore vendored
View file

@ -1,7 +1,11 @@
# Dependencies # Dependencies
node_modules/ node_modules/
/web/node_modules/
/svelte/node_modules/
/web/build/ /web/build/
/web/.cache/ /web/.cache/
/svelte/.svelte-kit/
/svelte/build/
# Environment files # Environment files
.env .env
@ -18,6 +22,7 @@ proxy.log
bin/ bin/
dist/ dist/
*.exe *.exe
proxy/proxy
# IDE and system files # IDE and system files
.DS_Store .DS_Store

View file

@ -1,64 +1,132 @@
# syntax=docker/dockerfile:1
# Multi-stage Dockerfile for Claude Code Proxy # Multi-stage Dockerfile for Claude Code Proxy
# Builds both Go proxy server and Remix frontend in a single container # Builds both Go proxy server and SvelteKit frontend in a single container
#
# Targets:
# - (default): Production runtime image
# - dev: Development image with hot-reload tooling
# Stage 1: Build Go Backend # ============================================================================
FROM golang:1.21-alpine AS go-builder # Stage: go-builder — compile Go proxy binary
# ============================================================================
FROM golang:1.26-alpine AS go-builder
WORKDIR /app WORKDIR /app/proxy
# Install build dependencies including gcc for CGO # Install build dependencies including gcc for CGO (sqlite)
RUN apk add --no-cache git gcc musl-dev sqlite-dev RUN apk add --no-cache git gcc musl-dev sqlite-dev
# Copy Go modules # Copy Go modules first (cache layer)
COPY proxy/go.mod proxy/go.sum ./proxy/ COPY proxy/go.mod proxy/go.sum ./
WORKDIR /app/proxy RUN --mount=type=cache,target=/go/pkg/mod \
RUN go mod download go mod download
# Copy Go source code # Copy Go source code and build
COPY proxy/ ./ COPY proxy/ ./
# Build with CGO enabled for SQLite support RUN --mount=type=cache,target=/go/pkg/mod \
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o /app/bin/proxy cmd/proxy/main.go --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o /app/bin/proxy cmd/proxy/main.go
# Stage 2: Build Node.js Frontend # ============================================================================
FROM node:20-alpine AS node-builder # Stage: svelte-deps — install SvelteKit dependencies (cached)
# ============================================================================
FROM node:20-alpine AS svelte-deps
WORKDIR /app/svelte
COPY svelte/package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
# ============================================================================
# Stage: svelte-builder — build SvelteKit frontend
# ============================================================================
FROM svelte-deps AS svelte-builder
COPY svelte/ ./
# shared/ is referenced by vite.config.ts (../shared/frontend/backend)
COPY shared/ /app/shared/
RUN npm run build
# ============================================================================
# Stage: svelte-prod — production SvelteKit deps only
# ============================================================================
FROM node:20-alpine AS svelte-prod
WORKDIR /app/svelte
COPY svelte/package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# ============================================================================
# Stage: dev — development image with hot-reload
# ============================================================================
# CGO is required for mattn/go-sqlite3. To avoid slow first-build times
# at container start, we pre-build the binary (and warm the build cache)
# during image build. CompileDaemon then does fast incremental rebuilds.
FROM golang:1.26-alpine AS dev
# Copy Node.js 20 from official image (Alpine's repos ship Node 24 which has
# breaking module-resolution changes that break SvelteKit's virtual modules)
COPY --from=node:20-alpine /usr/local/bin/node /usr/local/bin/node
COPY --from=node:20-alpine /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
# Install build deps and runtime tools
RUN apk add --no-cache \
libstdc++ \
git gcc musl-dev sqlite-dev \
wget su-exec postgresql-client
# Install CompileDaemon for Go hot-reload
RUN go install github.com/githubnemo/CompileDaemon@latest
WORKDIR /app WORKDIR /app
# Copy package files # Pre-install Go dependencies and do initial build to warm cgo cache
COPY web/package*.json ./web/ COPY proxy/go.mod proxy/go.sum ./proxy/
WORKDIR /app/web RUN cd proxy && go mod download
RUN npm ci
# Copy web source code and build COPY proxy/ ./proxy/
COPY web/ ./ RUN cd proxy && CGO_ENABLED=1 go build -o /tmp/proxy-bin/proxy cmd/proxy/main.go
RUN npm run build
# Clean up dev dependencies after build # Pre-install Node dependencies for svelte (layer cache)
RUN npm ci --only=production && npm cache clean --force COPY svelte/package*.json ./svelte/
RUN cd svelte && npm install
# Stage 3: Production Runtime # Copy the dev entrypoint
COPY docker-entrypoint.dev.sh ./
RUN chmod +x docker-entrypoint.dev.sh
ENV PORT=3001
ENV SVELTE_PORT=5174
ENV CGO_ENABLED=1
EXPOSE 3001 5174
ENTRYPOINT ["./docker-entrypoint.dev.sh"]
# ============================================================================
# Stage: (default) — production runtime
# ============================================================================
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# Install runtime dependencies # Install runtime dependencies (sqlite for legacy, postgresql-client for healthcheck)
RUN apk add --no-cache sqlite wget RUN apk add --no-cache sqlite wget su-exec postgresql-client
# Create app user for security
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
# Copy built Go binary # Copy built Go binary
COPY --from=go-builder /app/bin/proxy ./bin/proxy COPY --from=go-builder /app/bin/proxy ./bin/proxy
RUN chmod +x ./bin/proxy RUN chmod +x ./bin/proxy
# Copy built Remix application # Copy built SvelteKit application with production deps
COPY --from=node-builder /app/web/build ./web/build COPY --from=svelte-builder /app/svelte/build ./svelte/build
COPY --from=node-builder /app/web/package*.json ./web/ COPY --from=svelte-prod /app/svelte/package*.json ./svelte/
COPY --from=node-builder /app/web/node_modules ./web/node_modules COPY --from=svelte-prod /app/svelte/node_modules ./svelte/node_modules
# Create data directory for SQLite database # Create data directory for SQLite database
RUN mkdir -p /app/data && chown -R appuser:appgroup /app RUN mkdir -p /app/data && chown -R node:node /app
# Copy startup script # Copy startup script
COPY docker-entrypoint.sh ./ COPY docker-entrypoint.sh ./
@ -66,7 +134,7 @@ RUN chmod +x docker-entrypoint.sh
# Environment variables with defaults # Environment variables with defaults
ENV PORT=3001 ENV PORT=3001
ENV WEB_PORT=5173 ENV SVELTE_PORT=5174
ENV READ_TIMEOUT=600 ENV READ_TIMEOUT=600
ENV WRITE_TIMEOUT=600 ENV WRITE_TIMEOUT=600
ENV IDLE_TIMEOUT=600 ENV IDLE_TIMEOUT=600
@ -75,15 +143,11 @@ ENV ANTHROPIC_VERSION=2023-06-01
ENV ANTHROPIC_MAX_RETRIES=3 ENV ANTHROPIC_MAX_RETRIES=3
ENV DB_PATH=/app/data/requests.db ENV DB_PATH=/app/data/requests.db
# Expose ports EXPOSE 3001 5174
EXPOSE 3001 5173
# Switch to app user
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3001/health > /dev/null || exit 1 CMD wget -qO- http://localhost:3001/health > /dev/null || exit 1
# Start both services # Entrypoint handles privilege drop — compose overrides user to root at start
CMD ["./docker-entrypoint.sh"] ENTRYPOINT ["./docker-entrypoint.sh"]
CMD []

View file

@ -1,4 +1,4 @@
.PHONY: all build run clean install dev .PHONY: all build run clean install dev test test-proxy test-proxy-postgres-up test-proxy-postgres-down test-proxy-postgres-contract test-proxy-postgres
# Default target # Default target
all: install build all: install build
@ -8,18 +8,18 @@ install:
@echo "📦 Installing Go dependencies..." @echo "📦 Installing Go dependencies..."
cd proxy && go mod download cd proxy && go mod download
@echo "📦 Installing Node dependencies..." @echo "📦 Installing Node dependencies..."
cd web && npm install cd svelte && npm install
# Build both services # Build both services
build: build-proxy build-web build: build-proxy build-svelte
build-proxy: build-proxy:
@echo "🔨 Building proxy server..." @echo "🔨 Building proxy server..."
cd proxy && go build -o ../bin/proxy cmd/proxy/main.go cd proxy && go build -o ../bin/proxy cmd/proxy/main.go
build-web: build-svelte:
@echo "🔨 Building web interface..." @echo "🔨 Building svelte interface..."
cd web && npm run build cd svelte && npm run build
# Run in development mode # Run in development mode
dev: dev:
@ -30,16 +30,40 @@ dev:
run-proxy: run-proxy:
cd proxy && go run cmd/proxy/main.go cd proxy && go run cmd/proxy/main.go
# Run Go tests for the proxy
test-proxy:
cd proxy && go test ./...
# Run only the env-gated Postgres contract suite
test-proxy-postgres-contract:
cd proxy && go test ./internal/service -run TestPostgresStorageContract -count=1
# Start disposable Postgres for contract tests
test-proxy-postgres-up:
docker compose -f ../docker-compose.test.yml up -d --wait --remove-orphans postgres-test
# Stop disposable Postgres for contract tests
test-proxy-postgres-down:
docker compose -f ../docker-compose.test.yml down -v --remove-orphans
# Start disposable Postgres, run contract suite, then stop it
test-proxy-postgres:
@set -e; \
trap 'docker compose -f ../docker-compose.test.yml down -v --remove-orphans >/dev/null 2>&1 || true' EXIT; \
docker compose -f ../docker-compose.test.yml up -d --wait --remove-orphans postgres-test; \
TEST_POSTGRES_DSN=$${TEST_POSTGRES_DSN:-postgresql://$${TEST_POSTGRES_USER:-test}:$${TEST_POSTGRES_PASSWORD:-test}@localhost:$${TEST_POSTGRES_PORT:-5434}/$${TEST_POSTGRES_DB:-claude_code_proxy_test}?sslmode=disable} \
$(MAKE) test-proxy-postgres-contract
# Run web only # Run web only
run-web: run-svelte:
cd web && npm run dev cd svelte && npm run dev
# Clean build artifacts # Clean build artifacts
clean: clean:
@echo "🧹 Cleaning build artifacts..." @echo "🧹 Cleaning build artifacts..."
rm -rf bin/ rm -rf bin/
rm -rf web/build/ rm -rf svelte/build/
rm -rf web/.cache/ rm -rf svelte/.svelte-kit/
rm -f requests.db rm -f requests.db
rm -rf requests/ rm -rf requests/
@ -51,12 +75,17 @@ db-reset:
# Help # Help
help: help:
@echo "Claude Code Monitor - Available targets:" @echo "Claude Code Proxy - Available targets:"
@echo " make install - Install all dependencies" @echo " make install - Install all dependencies"
@echo " make build - Build both services" @echo " make build - Build both services"
@echo " make dev - Run in development mode" @echo " make dev - Run in development mode"
@echo " make run-proxy - Run proxy server only" @echo " make run-proxy - Run proxy server only"
@echo " make run-web - Run web interface only" @echo " make test-proxy - Run all Go proxy tests"
@echo " make test-proxy-postgres-up - Start disposable Postgres for contract tests"
@echo " make test-proxy-postgres-down - Stop disposable Postgres for contract tests"
@echo " make test-proxy-postgres-contract - Run Postgres storage contract test (requires TEST_POSTGRES_DSN)"
@echo " make test-proxy-postgres - Start disposable Postgres, run contract test, stop it"
@echo " make run-svelte - Run svelte interface only"
@echo " make clean - Clean build artifacts" @echo " make clean - Clean build artifacts"
@echo " make db-reset - Reset database" @echo " make db-reset - Reset database"
@echo " make help - Show this help message" @echo " make help - Show this help message"

103
README.md
View file

@ -23,10 +23,11 @@ Claude Code Proxy serves three main purposes:
## Security Defaults ## Security Defaults
- The proxy binds to `127.0.0.1` by default for local-only access. - The proxy currently defaults to `0.0.0.0:3001`, but startup validation refuses non-loopback binds unless you either set `AUTH_ENABLED=true` with `AUTH_TOKEN` or explicitly opt into `TRUST_PROXY=true` for reverse-proxy deployments.
- CORS defaults are restricted to localhost origins. - CORS is configurable and currently defaults to permissive values unless you override it.
- If you want to expose the proxy on a public interface, you must set `AUTH_ENABLED=true` and provide `AUTH_TOKEN`. - If you expose the proxy directly on a public interface, enable auth and provide a token.
- When auth is enabled, the proxy accepts either `Authorization: Bearer <token>` or `X-API-Key: <token>`. - When auth is enabled, the proxy accepts either `Authorization: Bearer <token>` or `X-API-Key: <token>`.
- Dashboard routes can be protected separately with `DASHBOARD_PASSWORD`, which enables HTTP basic auth for the web UI and dashboard data endpoints.
## Quick Start ## Quick Start
@ -86,7 +87,7 @@ Claude Code Proxy serves three main purposes:
docker run claude-code-proxy docker run claude-code-proxy
# Run with published ports # Run with published ports
docker run -p 3001:3001 -p 5173:5173 \ docker run -p 3001:3001 -p 5174:5174 \
-e SERVER_HOST=0.0.0.0 \ -e SERVER_HOST=0.0.0.0 \
-e AUTH_ENABLED=true \ -e AUTH_ENABLED=true \
-e AUTH_TOKEN=change-me \ -e AUTH_TOKEN=change-me \
@ -101,13 +102,13 @@ Claude Code Proxy serves three main purposes:
# Option 1: Run with config file (recommended) # Option 1: Run with config file (recommended)
# If you expose the container with `-p`, set server.host to 0.0.0.0 # If you expose the container with `-p`, set server.host to 0.0.0.0
# and enable auth in the mounted config file. # and enable auth in the mounted config file.
docker run -p 3001:3001 -p 5173:5173 \ docker run -p 3001:3001 -p 5174:5174 \
-v ./data:/app/data \ -v ./data:/app/data \
-v ./config.yaml:/app/config.yaml:ro \ -v ./config.yaml:/app/config.yaml:ro \
claude-code-proxy claude-code-proxy
# Option 2: Run with environment variables # Option 2: Run with environment variables
docker run -p 3001:3001 -p 5173:5173 \ docker run -p 3001:3001 -p 5174:5174 \
-v ./data:/app/data \ -v ./data:/app/data \
-e SERVER_HOST=0.0.0.0 \ -e SERVER_HOST=0.0.0.0 \
-e ANTHROPIC_FORWARD_URL=https://api.anthropic.com \ -e ANTHROPIC_FORWARD_URL=https://api.anthropic.com \
@ -126,7 +127,7 @@ Claude Code Proxy serves three main purposes:
build: . build: .
ports: ports:
- "3001:3001" - "3001:3001"
- "5173:5173" - "5174:5174"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./config.yaml:/app/config.yaml:ro # Mount config file - ./config.yaml:/app/config.yaml:ro # Mount config file
@ -153,7 +154,7 @@ Then launch Claude Code using the `claude` command.
This will route Claude Code's requests through the proxy for monitoring. This will route Claude Code's requests through the proxy for monitoring.
### Access Points ### Access Points
- **Web Dashboard**: http://localhost:5173 - **Web Dashboard**: http://localhost:5174
- **API Proxy**: http://localhost:3001 - **API Proxy**: http://localhost:3001
- **Health Check**: http://localhost:3001/health - **Health Check**: http://localhost:3001/health
@ -167,8 +168,8 @@ If you need to run services independently:
# Run proxy only # Run proxy only
make run-proxy make run-proxy
# Run web interface only (in another terminal) # Run Svelte dashboard only (in another terminal)
make run-web make run-svelte
``` ```
### Available Make Commands ### Available Make Commands
@ -177,11 +178,71 @@ make run-web
make install # Install all dependencies make install # Install all dependencies
make build # Build both services make build # Build both services
make dev # Run in development mode make dev # Run in development mode
make test-proxy # Run Go proxy tests
make clean # Clean build artifacts make clean # Clean build artifacts
make db-reset # Reset database make db-reset # Reset database
make help # Show all commands make help # Show all commands
``` ```
### Running Regression Tests
The proxy test suite lives under `build/proxy`:
```bash
cd build/proxy
go test ./...
```
Or from the `build/` directory:
```bash
make test-proxy
```
### Running Postgres Storage Contract Tests
The storage layer has a backend-agnostic contract suite. SQLite runs in the normal Go test path, and PostgreSQL can be exercised by setting `TEST_POSTGRES_DSN`:
```bash
cd build/proxy
TEST_POSTGRES_DSN='postgresql://user:password@localhost:5432/dbname?sslmode=disable' \
go test ./internal/service -run TestPostgresStorageContract -count=1
```
Or from `build/`:
```bash
TEST_POSTGRES_DSN='postgresql://user:password@localhost:5432/dbname?sslmode=disable' \
make test-proxy-postgres-contract
```
The test resets the `requests` and `settings` tables between runs, so point it at a disposable database.
### Disposable Postgres Test Database
The repo also includes a dedicated Compose file for contract tests:
```bash
cd build
make test-proxy-postgres
```
That target:
- starts `../docker-compose.test.yml`
- points `TEST_POSTGRES_DSN` at the disposable database by default
- runs `TestPostgresStorageContract`
- tears the database down automatically
- removes orphaned test-compose containers for a clean rerun
If you want to manage the database lifecycle yourself:
```bash
cd build
make test-proxy-postgres-up
make test-proxy-postgres-contract
make test-proxy-postgres-down
```
## Configuration ## Configuration
### Basic Setup ### Basic Setup
@ -207,6 +268,8 @@ auth:
token: "" token: ""
``` ```
If you set `server.host` to a non-loopback address such as `0.0.0.0`, the proxy will refuse to start unless you also enable auth or explicitly set `TRUST_PROXY=true` for a reverse-proxy deployment.
### Auth ### Auth
To expose the proxy beyond localhost, enable auth and provide a token: To expose the proxy beyond localhost, enable auth and provide a token:
@ -289,12 +352,17 @@ Use case: Different specialists for different tasks, optimizing for speed/cost/q
Override config via environment: Override config via environment:
- `PORT` - Server port - `PORT` - Server port
- `SERVER_HOST` - Server bind host - `SERVER_HOST` - Server bind host
- `TRUST_PROXY` - Skip direct-bind auth enforcement when running behind Traefik or another reverse proxy
- `AUTH_ENABLED` - Enable auth for non-health endpoints - `AUTH_ENABLED` - Enable auth for non-health endpoints
- `AUTH_TOKEN` - Shared auth secret - `AUTH_TOKEN` - Shared auth secret
- `AUTH_API_KEY_HEADER` - Header name for API key auth - `AUTH_API_KEY_HEADER` - Header name for API key auth
- `AUTH_ALLOW_LOCALHOST_BYPASS` - Allow localhost requests to bypass auth - `AUTH_ALLOW_LOCALHOST_BYPASS` - Allow localhost requests to bypass auth
- `DASHBOARD_PASSWORD` - Protect the dashboard and dashboard data APIs with HTTP basic auth
- `OPENAI_API_KEY` - OpenAI API key - `OPENAI_API_KEY` - OpenAI API key
- `DB_TYPE` - Storage backend (`sqlite` or `postgres`)
- `DATABASE_URL` - PostgreSQL connection string when `DB_TYPE=postgres`
- `DB_PATH` - Database path - `DB_PATH` - Database path
- `PROXY_PUBLIC_URL` - Public proxy URL shown in dashboard setup instructions
- `SUBAGENT_MAPPINGS` - Comma-separated mappings (e.g., `"code-reviewer:gpt-4o,data-analyst:o3"`) - `SUBAGENT_MAPPINGS` - Comma-separated mappings (e.g., `"code-reviewer:gpt-4o,data-analyst:o3"`)
### Docker Environment Variables ### Docker Environment Variables
@ -303,23 +371,29 @@ All environment variables can be configured when running the Docker container:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `SERVER_HOST` | `127.0.0.1` | Proxy bind host | | `SERVER_HOST` | `0.0.0.0` | Proxy bind host |
| `PORT` | `3001` | Proxy server port | | `PORT` | `3001` | Proxy server port |
| `SVELTE_PORT` | `5174` | Dashboard server port |
| `READ_TIMEOUT` | `600` | Server read timeout (seconds) | | `READ_TIMEOUT` | `600` | Server read timeout (seconds) |
| `WRITE_TIMEOUT` | `600` | Server write timeout (seconds) | | `WRITE_TIMEOUT` | `600` | Server write timeout (seconds) |
| `IDLE_TIMEOUT` | `600` | Server idle timeout (seconds) | | `IDLE_TIMEOUT` | `600` | Server idle timeout (seconds) |
| `ANTHROPIC_FORWARD_URL` | `https://api.anthropic.com` | Target Anthropic API URL | | `ANTHROPIC_FORWARD_URL` | `https://api.anthropic.com` | Target Anthropic API URL |
| `ANTHROPIC_VERSION` | `2023-06-01` | Anthropic API version | | `ANTHROPIC_VERSION` | `2023-06-01` | Anthropic API version |
| `ANTHROPIC_MAX_RETRIES` | `3` | Maximum retry attempts | | `ANTHROPIC_MAX_RETRIES` | `3` | Maximum retry attempts |
| `TRUST_PROXY` | `false` | Allow reverse-proxy deployments without direct auth on the Go bind |
| `AUTH_ENABLED` | `false` | Enable auth for non-health endpoints | | `AUTH_ENABLED` | `false` | Enable auth for non-health endpoints |
| `AUTH_TOKEN` | `""` | Shared auth token | | `AUTH_TOKEN` | `""` | Shared auth token |
| `AUTH_API_KEY_HEADER` | `x-api-key` | Header name for API-key style auth | | `AUTH_API_KEY_HEADER` | `x-api-key` | Header name for API-key style auth |
| `AUTH_ALLOW_LOCALHOST_BYPASS` | `true` | Allow loopback requests to bypass auth | | `AUTH_ALLOW_LOCALHOST_BYPASS` | `true` | Allow loopback requests to bypass auth |
| `DASHBOARD_PASSWORD` | `""` | HTTP basic auth password for the dashboard |
| `DB_TYPE` | `sqlite` | Storage backend |
| `DATABASE_URL` | `""` | PostgreSQL connection string |
| `DB_PATH` | `/app/data/requests.db` | SQLite database path | | `DB_PATH` | `/app/data/requests.db` | SQLite database path |
| `PROXY_PUBLIC_URL` | `""` | Public proxy URL shown by the Svelte dashboard |
Example with custom configuration: Example with custom configuration:
```bash ```bash
docker run -p 3001:3001 -p 5173:5173 \ docker run -p 3001:3001 -p 5174:5174 \
-v ./data:/app/data \ -v ./data:/app/data \
-e SERVER_HOST=0.0.0.0 \ -e SERVER_HOST=0.0.0.0 \
-e AUTH_ENABLED=true \ -e AUTH_ENABLED=true \
@ -338,9 +412,10 @@ claude-code-proxy/
│ ├── cmd/ # Application entry points │ ├── cmd/ # Application entry points
│ ├── internal/ # Internal packages │ ├── internal/ # Internal packages
│ └── go.mod # Go dependencies │ └── go.mod # Go dependencies
├── web/ # React Remix frontend ├── svelte/ # SvelteKit dashboard
│ ├── app/ # Remix application │ ├── src/ # Svelte application
│ └── package.json # Node dependencies │ └── package.json # Node dependencies
├── shared/ # Shared TypeScript modules used by the dashboard
├── run.sh # Start script ├── run.sh # Start script
├── .env.example # Environment template ├── .env.example # Environment template
└── README.md # This file └── README.md # This file

View file

@ -5,7 +5,9 @@
# Server configuration # Server configuration
server: server:
# Bind host for the proxy server. # Bind host for the proxy server.
# Defaults to 127.0.0.1 for local-only access. # Example local-only value. The current built-in default is 0.0.0.0, but
# startup validation rejects public binds unless auth is enabled or
# TRUST_PROXY=true is set for a reverse-proxy deployment.
host: 127.0.0.1 host: 127.0.0.1
# Port to listen on (default: 3001) # Port to listen on (default: 3001)
@ -53,13 +55,14 @@ providers:
# CORS Configuration # CORS Configuration
# Controls Cross-Origin Resource Sharing for the web UI # Controls Cross-Origin Resource Sharing for the web UI
cors: cors:
# Allowed origins. Defaults are localhost-only. # Allowed origins. The built-in defaults are permissive, so set these
# explicitly if you want tighter browser access.
# Can also be set via CORS_ALLOWED_ORIGINS environment variable (comma-separated) # Can also be set via CORS_ALLOWED_ORIGINS environment variable (comma-separated)
allowed_origins: allowed_origins:
- "http://localhost:3000" - "http://localhost:3000"
- "http://127.0.0.1:3000" - "http://127.0.0.1:3000"
- "http://localhost:5173" - "http://localhost:5174"
- "http://127.0.0.1:5173" - "http://127.0.0.1:5174"
# Allowed HTTP methods # Allowed HTTP methods
# Can also be set via CORS_ALLOWED_METHODS environment variable (comma-separated) # Can also be set via CORS_ALLOWED_METHODS environment variable (comma-separated)
@ -96,11 +99,25 @@ auth:
# Allow requests from localhost to bypass auth when enabled # Allow requests from localhost to bypass auth when enabled
allow_localhost_bypass: true allow_localhost_bypass: true
# Optional dashboard-only password. When set, the Svelte dashboard and
# dashboard data endpoints require HTTP basic auth with username "admin".
dashboard_password: ""
# Set to true when running behind a trusted reverse proxy and you want to
# skip the public-bind auth requirement enforced at startup.
trust_proxy: false
# Storage configuration # Storage configuration
storage: storage:
# Storage backend. Supported values: sqlite, postgres
db_type: "sqlite"
# SQLite database path for storing request history # SQLite database path for storing request history
db_path: "requests.db" db_path: "requests.db"
# PostgreSQL connection string used when db_type=postgres
database_url: ""
# Keep request bodies in storage. Disable for metadata-only tracking. # Keep request bodies in storage. Disable for metadata-only tracking.
capture_request_body: true capture_request_body: true
@ -172,8 +189,12 @@ subagents:
# AUTH_TOKEN - Shared secret for bearer / API-key auth # AUTH_TOKEN - Shared secret for bearer / API-key auth
# AUTH_API_KEY_HEADER - Header name for API-key style auth # AUTH_API_KEY_HEADER - Header name for API-key style auth
# AUTH_ALLOW_LOCALHOST_BYPASS - Allow loopback requests to bypass auth (true/false) # AUTH_ALLOW_LOCALHOST_BYPASS - Allow loopback requests to bypass auth (true/false)
# DASHBOARD_PASSWORD - Dashboard HTTP basic auth password
# TRUST_PROXY - Skip public-bind auth enforcement behind a reverse proxy
# #
# Storage: # Storage:
# DB_TYPE - Storage backend (sqlite/postgres)
# DATABASE_URL - PostgreSQL connection string
# DB_PATH - Database file path # DB_PATH - Database file path
# STORAGE_CAPTURE_REQUEST_BODY - Keep request bodies (true/false) # STORAGE_CAPTURE_REQUEST_BODY - Keep request bodies (true/false)
# STORAGE_CAPTURE_RESPONSE_BODY - Keep response bodies (true/false) # STORAGE_CAPTURE_RESPONSE_BODY - Keep response bodies (true/false)

86
docker-entrypoint.dev.sh Executable file
View file

@ -0,0 +1,86 @@
#!/bin/sh
# Dev entrypoint - runs all services with hot-reload
set -e
PUID=${PUID:-1000}
PGID=${PGID:-1000}
if [ "$(id -u)" = "0" ]; then
if [ "$PUID" != "1000" ] || [ "$PGID" != "1000" ]; then
deluser node 2>/dev/null || true
delgroup node 2>/dev/null || true
addgroup -g "$PGID" -S node
adduser -S -u "$PUID" -G node -h /home/node -s /bin/sh node
fi
# Fix data dir ownership
chown "$PUID:$PGID" /app/data 2>/dev/null || true
chown "$PUID:$PGID" /app/data/requests.db* 2>/dev/null || true
# Install/update deps as root (named volumes are root-owned)
cd /app/proxy && go mod download
cd /app/svelte && npm install --loglevel=warn 2>&1 || true
cd /app
# Fix ownership on everything the node user needs to write to
chown -R "$PUID:$PGID" /app/svelte/node_modules 2>/dev/null || true
# Pre-create .svelte-kit and fix ownership so vite dev can write type definitions
mkdir -p /app/svelte/.svelte-kit/types
chown -R "$PUID:$PGID" /app/svelte/.svelte-kit /app/svelte/build 2>/dev/null || true
# Ensure Go cache/tmp dirs are writable by the node user
# Use a dedicated dir for the proxy binary so CompileDaemon can overwrite it
mkdir -p /home/node/.cache/go-build /tmp/go-build /tmp/proxy-bin
chown -R "$PUID:$PGID" /home/node/.cache /tmp/go-build /tmp/proxy-bin /go/pkg 2>/dev/null || true
# Re-exec this script as the node user
exec su-exec "$PUID:$PGID" "$0" "$@"
fi
echo "=== DEV MODE (uid=$(id -u)) ==="
echo "Proxy Server: http://0.0.0.0:${PORT} (CompileDaemon hot-reload)"
echo "Svelte Dashboard: http://0.0.0.0:${SVELTE_PORT} (vite HMR)"
echo "================"
cleanup() {
echo "Shutting down dev services..."
kill $PROXY_PID $SVELTE_PID 2>/dev/null || true
exit 0
}
trap cleanup SIGTERM SIGINT
# Start Go proxy with CompileDaemon for hot-reload
cd /app/proxy
HOME=/home/node CompileDaemon -build="go build -o /tmp/proxy-bin/proxy cmd/proxy/main.go" -command="/tmp/proxy-bin/proxy" -graceful-kill=true -graceful-timeout=10 -pattern="(.+\\.go|.+\\.yaml)$" &
PROXY_PID=$!
cd /app
# Wait for Go proxy to be ready (up to 120s for first compile)
echo "Waiting for proxy to be ready..."
i=0
while [ $i -lt 120 ]; do
if wget -qO /dev/null "http://localhost:${PORT}/health" 2>/dev/null; then
echo "Proxy is ready!"
break
fi
sleep 2
i=$((i + 2))
done
if [ $i -ge 120 ]; then
echo "Warning: proxy not ready after 120s, starting frontends anyway"
fi
# Start SvelteKit dev server (Vite HMR)
cd /app/svelte
PORT=${SVELTE_PORT} HOST=0.0.0.0 DASHBOARD_PASSWORD="${DASHBOARD_PASSWORD}" npm run dev -- --host 0.0.0.0 --port ${SVELTE_PORT} &
SVELTE_PID=$!
cd /app
echo ""
echo "All dev services started. Watching for file changes..."
echo ""
wait

61
docker-entrypoint.sh Normal file → Executable file
View file

@ -1,10 +1,30 @@
#!/bin/sh #!/bin/sh
# Docker entrypoint script for Claude Code Proxy # Docker entrypoint script for Claude Code Proxy
# Starts both the Go proxy server and Remix frontend # Starts both the Go proxy server and SvelteKit frontend
set -e set -e
# Support user-supplied UID/GID via PUID/PGID env vars (default: 1000).
PUID=${PUID:-1000}
PGID=${PGID:-1000}
# When running as root, fix data dir ownership and drop to the target user.
if [ "$(id -u)" = "0" ]; then
# Update the node user/group to match requested UID/GID
if [ "$PUID" != "1000" ] || [ "$PGID" != "1000" ]; then
deluser node 2>/dev/null || true
delgroup node 2>/dev/null || true
addgroup -g "$PGID" -S node
adduser -S -u "$PUID" -G node -h /home/node -s /bin/sh node
fi
# Fix ownership of data dir and sqlite files (not postgres subdir)
chown "$PUID:$PGID" /app/data 2>/dev/null || true
chown "$PUID:$PGID" /app/data/requests.db* 2>/dev/null || true
exec su-exec node "$0" "$@"
fi
echo "🚀 Starting Claude Code Proxy services..." echo "🚀 Starting Claude Code Proxy services..."
echo "=========================================" echo "========================================="
@ -12,7 +32,7 @@ echo "========================================="
cleanup() { cleanup() {
echo "" echo ""
echo "🛑 Shutting down services..." echo "🛑 Shutting down services..."
kill $PROXY_PID $WEB_PID 2>/dev/null || true kill $PROXY_PID $SVELTE_PID 2>/dev/null || true
exit 0 exit 0
} }
@ -21,8 +41,13 @@ trap cleanup SIGTERM SIGINT
echo "📊 Configuration:" echo "📊 Configuration:"
echo " - Proxy Server: http://0.0.0.0:${PORT}" echo " - Proxy Server: http://0.0.0.0:${PORT}"
echo " - Web Dashboard: http://0.0.0.0:${WEB_PORT}" echo " - Svelte Dashboard: http://0.0.0.0:${SVELTE_PORT}"
echo " - Database type: ${DB_TYPE:-sqlite}"
if [ "${DB_TYPE}" = "postgres" ] || [ "${DB_TYPE}" = "postgresql" ]; then
echo " - Database: PostgreSQL (via DATABASE_URL)"
else
echo " - Database: ${DB_PATH}" echo " - Database: ${DB_PATH}"
fi
echo " - Anthropic API: ${ANTHROPIC_FORWARD_URL}" echo " - Anthropic API: ${ANTHROPIC_FORWARD_URL}"
echo "=========================================" echo "========================================="
@ -35,24 +60,38 @@ IDLE_TIMEOUT=${IDLE_TIMEOUT}s \
ANTHROPIC_FORWARD_URL=${ANTHROPIC_FORWARD_URL} \ ANTHROPIC_FORWARD_URL=${ANTHROPIC_FORWARD_URL} \
ANTHROPIC_VERSION=${ANTHROPIC_VERSION} \ ANTHROPIC_VERSION=${ANTHROPIC_VERSION} \
ANTHROPIC_MAX_RETRIES=${ANTHROPIC_MAX_RETRIES} \ ANTHROPIC_MAX_RETRIES=${ANTHROPIC_MAX_RETRIES} \
DB_TYPE=${DB_TYPE:-sqlite} \
DB_PATH=${DB_PATH} \ DB_PATH=${DB_PATH} \
DATABASE_URL=${DATABASE_URL:-} \
./bin/proxy & ./bin/proxy &
PROXY_PID=$! PROXY_PID=$!
# Wait for proxy to start # Wait for proxy to be ready
sleep 3 echo "⏳ Waiting for proxy to be ready..."
i=0
while [ $i -lt 30 ]; do
if wget -qO /dev/null "http://localhost:${PORT}/health" 2>/dev/null; then
echo "✅ Proxy is ready!"
break
fi
sleep 1
i=$((i + 1))
done
if [ $i -ge 30 ]; then
echo "⚠️ Warning: proxy not ready after 30s, starting frontends anyway"
fi
# Start web server # Start SvelteKit server
echo "🔄 Starting web server..." echo "🔄 Starting SvelteKit server..."
cd web cd svelte
PORT=${WEB_PORT} HOST=0.0.0.0 NODE_ENV=production npx remix-serve build/server/index.js & PORT=${SVELTE_PORT} HOST=0.0.0.0 NODE_ENV=production DASHBOARD_PASSWORD="${DASHBOARD_PASSWORD}" node build/index.js &
WEB_PID=$! SVELTE_PID=$!
cd .. cd ..
echo "" echo ""
echo "✨ All services started successfully!" echo "✨ All services started successfully!"
echo "=========================================" echo "========================================="
echo "📊 Web Dashboard: http://localhost:${WEB_PORT}" echo "📊 Web Dashboard: http://localhost:${SVELTE_PORT}"
echo "🔌 API Proxy: http://localhost:${PORT}" echo "🔌 API Proxy: http://localhost:${PORT}"
echo "💚 Health Check: http://localhost:${PORT}/health" echo "💚 Health Check: http://localhost:${PORT}/health"
echo "=========================================" echo "========================================="

View file

@ -17,6 +17,7 @@ import (
"github.com/seifghazi/claude-code-monitor/internal/handler" "github.com/seifghazi/claude-code-monitor/internal/handler"
"github.com/seifghazi/claude-code-monitor/internal/middleware" "github.com/seifghazi/claude-code-monitor/internal/middleware"
"github.com/seifghazi/claude-code-monitor/internal/provider" "github.com/seifghazi/claude-code-monitor/internal/provider"
"github.com/seifghazi/claude-code-monitor/internal/runtime"
"github.com/seifghazi/claude-code-monitor/internal/service" "github.com/seifghazi/claude-code-monitor/internal/service"
) )
@ -36,65 +37,45 @@ func main() {
// Initialize model router // Initialize model router
modelRouter := service.NewModelRouter(cfg, providers, logger) modelRouter := service.NewModelRouter(cfg, providers, logger)
// Use legacy anthropic service for backward compatibility // Initialize storage based on DB_TYPE
anthropicService := service.NewAnthropicService(&cfg.Anthropic) var storageService service.StorageService
switch cfg.Storage.DBType {
// Use SQLite storage case "postgres", "postgresql":
storageService, err := service.NewSQLiteStorageService(&cfg.Storage) if cfg.Storage.DatabaseURL == "" {
logger.Fatalf("❌ DATABASE_URL is required when DB_TYPE=postgres")
}
storageService, err = service.NewPostgresStorageService(&cfg.Storage)
if err != nil {
logger.Fatalf("❌ Failed to initialize PostgreSQL storage: %v", err)
}
logger.Println("🐘 PostgreSQL database ready")
default:
storageService, err = service.NewSQLiteStorageService(&cfg.Storage)
if err != nil { if err != nil {
logger.Fatalf("❌ Failed to initialize SQLite storage: %v", err) logger.Fatalf("❌ Failed to initialize SQLite storage: %v", err)
} }
logger.Println("🗿 SQLite database ready") logger.Println("🗿 SQLite database ready")
}
h := handler.New(anthropicService, storageService, logger, modelRouter) h := handler.New(storageService, logger, modelRouter, providers["anthropic"], cfg.Providers.Anthropic.DemoteNonstreaming)
r := mux.NewRouter()
corsHandler := handlers.CORS(
handlers.AllowedOrigins(cfg.CORS.AllowedOrigins),
handlers.AllowedMethods(cfg.CORS.AllowedMethods),
handlers.AllowedHeaders(cfg.CORS.AllowedHeaders),
)
r.Use(middleware.Logging)
r.Use(middleware.Auth(cfg.Auth))
r.HandleFunc("/v1/chat/completions", h.ChatCompletions).Methods("POST")
r.HandleFunc("/v1/messages", h.Messages).Methods("POST")
r.HandleFunc("/v1/models", h.Models).Methods("GET")
r.HandleFunc("/health", h.Health).Methods("GET")
r.HandleFunc("/", h.UI).Methods("GET")
r.HandleFunc("/ui", h.UI).Methods("GET")
r.HandleFunc("/api/requests", h.GetRequests).Methods("GET")
r.HandleFunc("/api/requests", h.DeleteRequests).Methods("DELETE")
r.HandleFunc("/api/conversations", h.GetConversations).Methods("GET")
r.HandleFunc("/api/conversations/{id}", h.GetConversationByID).Methods("GET")
r.HandleFunc("/api/conversations/project", h.GetConversationsByProject).Methods("GET")
r.NotFoundHandler = http.HandlerFunc(h.NotFound)
srv := &http.Server{ srv := &http.Server{
Addr: net.JoinHostPort(cfg.Server.Host, cfg.Server.Port), Addr: net.JoinHostPort(cfg.Server.Host, cfg.Server.Port),
Handler: corsHandler(r), Handler: buildHandler(cfg, h),
ReadTimeout: cfg.Server.ReadTimeout, ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout, WriteTimeout: cfg.Server.WriteTimeout,
IdleTimeout: cfg.Server.IdleTimeout, IdleTimeout: cfg.Server.IdleTimeout,
} }
go func() { go func() {
logger.Printf("🚀 Claude Code Monitor Server running on http://%s", srv.Addr) logger.Println("🚀 Claude Code Proxy started")
logger.Printf("📡 API endpoints available at:") logger.Printf(" upstream: %s", cfg.Providers.Anthropic.BaseURL)
logger.Printf(" - POST http://%s/v1/messages (Anthropic format)", srv.Addr) logger.Printf(" storage: %s", cfg.Storage.DBType)
logger.Printf(" - GET http://%s/v1/models", srv.Addr)
logger.Printf(" - GET http://%s/health", srv.Addr)
logger.Printf("🎨 Web UI available at:")
logger.Printf(" - GET http://%s/ (Request Visualizer)", srv.Addr)
logger.Printf(" - GET http://%s/api/requests (Request API)", srv.Addr)
if cfg.Auth.Enabled { if cfg.Auth.Enabled {
logger.Printf("🔐 Auth enabled using bearer token or %s", cfg.Auth.APIKeyHeader) logger.Printf(" auth: bearer / %s", cfg.Auth.APIKeyHeader)
} else { }
logger.Printf("🔓 Auth disabled for local-only access") if cfg.Auth.DashboardPassword != "" {
logger.Printf(" dashboard: password protected")
} }
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@ -104,10 +85,41 @@ func main() {
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit sig := <-quit
logger.Println("🛑 Shutting down server...") // Drain ceiling: must accommodate the 30-min forward context in
// handlers.Messages plus a small buffer. Container-level
// stop_grace_period must be set just above this in docker-compose so
// docker doesn't SIGKILL us mid-drain.
const drainCeiling = 32 * time.Minute
const drainPoll = 1 * time.Second
logger.Printf("🛑 Received %s — draining (ceiling %s)...", sig, drainCeiling)
runtime.SetDraining(true)
drainStart := time.Now()
lastLog := drainStart
for {
n := runtime.InFlight()
if n == 0 {
logger.Printf("✅ Drain complete (%s elapsed, in-flight=0)", time.Since(drainStart).Round(time.Second))
break
}
if time.Since(drainStart) >= drainCeiling {
logger.Printf("⏰ Drain ceiling hit — proceeding with shutdown (in_flight=%d)", n)
break
}
if time.Since(lastLog) >= 10*time.Second {
logger.Printf("⏳ Draining: in_flight=%d elapsed=%s", n, time.Since(drainStart).Round(time.Second))
lastLog = time.Now()
}
time.Sleep(drainPoll)
}
// Server.Shutdown stops accepting new connections and waits for active
// handlers to return. Use a short ceiling here — the long wait already
// happened in the drain loop above. If a handler is somehow still
// running past the drain ceiling, give it 30s before forced close.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@ -123,3 +135,57 @@ func main() {
logger.Println("✅ Server exited") logger.Println("✅ Server exited")
} }
func buildHandler(cfg *config.Config, h *handler.Handler) http.Handler {
r := mux.NewRouter()
corsHandler := handlers.CORS(
handlers.AllowedOrigins(cfg.CORS.AllowedOrigins),
handlers.AllowedMethods(cfg.CORS.AllowedMethods),
handlers.AllowedHeaders(cfg.CORS.AllowedHeaders),
)
r.Use(middleware.Logging)
r.Use(middleware.Auth(cfg.Auth))
// Liveness/readiness — must remain unauthenticated and uncounted.
r.HandleFunc("/health", h.Health).Methods("GET")
r.HandleFunc("/livez", h.Livez).Methods("GET")
r.HandleFunc("/openapi.json", h.OpenAPIJSON).Methods("GET")
r.HandleFunc("/openapi.yaml", h.OpenAPIYAML).Methods("GET")
// Proxy endpoints — no dashboard auth, but tracked by the in-flight
// gauge so graceful shutdown can wait for them to drain.
// Trailing slash on the prefix keeps the boundary tight: /v10 etc. won't match.
v1 := r.PathPrefix("/v1/").Subrouter()
v1.Use(middleware.InFlight)
v1.HandleFunc("/chat/completions", h.ChatCompletions).Methods("POST")
v1.HandleFunc("/messages", h.Messages).Methods("POST")
// Catch-all: proxy any other /v1/* requests to Anthropic (config, quota, batches, etc.)
v1.PathPrefix("/").HandlerFunc(h.ProxyPassthrough)
// Dashboard/data endpoints — protected by DASHBOARD_PASSWORD basic auth
dashboard := r.PathPrefix("/").Subrouter()
dashboard.Use(middleware.DashboardAuth(cfg.Auth.DashboardPassword))
dashboard.HandleFunc("/", h.UI).Methods("GET")
dashboard.HandleFunc("/ui", h.UI).Methods("GET")
dashboard.HandleFunc("/api/requests", h.GetRequests).Methods("GET")
dashboard.HandleFunc("/api/requests", h.DeleteRequests).Methods("DELETE")
dashboard.HandleFunc("/api/requests/summary", h.GetRequestsSummary).Methods("GET")
dashboard.HandleFunc("/api/requests/latest-date", h.GetLatestRequestDate).Methods("GET")
dashboard.HandleFunc("/api/requests/{id}", h.GetRequestByID).Methods("GET")
dashboard.HandleFunc("/api/stats", h.GetStats).Methods("GET")
dashboard.HandleFunc("/api/stats/dashboard", h.GetDashboardStats).Methods("GET")
dashboard.HandleFunc("/api/stats/hourly", h.GetHourlyStats).Methods("GET")
dashboard.HandleFunc("/api/stats/models", h.GetModelStats).Methods("GET")
dashboard.HandleFunc("/api/stats/organizations", h.GetOrganizations).Methods("GET")
dashboard.HandleFunc("/api/conversations", h.GetConversations).Methods("GET")
dashboard.HandleFunc("/api/conversations/{id}", h.GetConversationByID).Methods("GET")
dashboard.HandleFunc("/api/conversations/project", h.GetConversationsByProject).Methods("GET")
dashboard.HandleFunc("/api/settings", h.GetSettings).Methods("GET")
dashboard.HandleFunc("/api/settings", h.SaveSettings).Methods("PUT")
r.NotFoundHandler = http.HandlerFunc(h.NotFound)
return corsHandler(r)
}

View file

@ -0,0 +1,193 @@
package main
import (
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/seifghazi/claude-code-monitor/internal/config"
httphandler "github.com/seifghazi/claude-code-monitor/internal/handler"
"github.com/seifghazi/claude-code-monitor/internal/model"
"github.com/seifghazi/claude-code-monitor/internal/service"
)
type routerStorageStub struct {
getUsageStatsFn func(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error)
}
func (s *routerStorageStub) SaveRequest(*model.RequestLog) (string, error) { panic("unexpected call") }
func (s *routerStorageStub) GetRequests(int, int, string) ([]model.RequestLog, int, error) {
panic("unexpected call")
}
func (s *routerStorageStub) GetAllRequests(string) ([]*model.RequestLog, error) {
panic("unexpected call")
}
func (s *routerStorageStub) GetRequestByShortID(string) (*model.RequestLog, string, error) {
panic("unexpected call")
}
func (s *routerStorageStub) ClearRequests() (int, error) { panic("unexpected call") }
func (s *routerStorageStub) UpdateRequestWithGrading(string, *model.PromptGrade) error {
panic("unexpected call")
}
func (s *routerStorageStub) UpdateRequestWithResponse(*model.RequestLog) error {
panic("unexpected call")
}
func (s *routerStorageStub) DeleteRequestsOlderThan(time.Duration) (int, error) {
panic("unexpected call")
}
func (s *routerStorageStub) GetDatabaseStats() (map[string]interface{}, error) {
panic("unexpected call")
}
func (s *routerStorageStub) GetUsageStats(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error) {
if s.getUsageStatsFn != nil {
return s.getUsageStatsFn(startDate, endDate, modelFilter, orgFilter)
}
return &model.UsageStats{}, nil
}
func (s *routerStorageStub) GetRequestsSummary(string) ([]*model.RequestSummary, error) {
panic("unexpected call")
}
func (s *routerStorageStub) GetRequestsSummaryPaginated(string, string, string, int, int) ([]*model.RequestSummary, int, error) {
panic("unexpected call")
}
func (s *routerStorageStub) GetStats(string, string, string) (*model.DashboardStats, error) {
panic("unexpected call")
}
func (s *routerStorageStub) GetHourlyStats(string, string, int, string) (*model.HourlyStatsResponse, error) {
panic("unexpected call")
}
func (s *routerStorageStub) GetModelStats(string, string, string) (*model.ModelStatsResponse, error) {
panic("unexpected call")
}
func (s *routerStorageStub) GetLatestRequestDate() (*time.Time, error) { panic("unexpected call") }
func (s *routerStorageStub) GetDistinctOrganizations() ([]string, error) {
panic("unexpected call")
}
func (s *routerStorageStub) GetSettings() (*model.ProxySettings, error) {
panic("unexpected call")
}
func (s *routerStorageStub) SaveSettings(*model.ProxySettings) error { panic("unexpected call") }
func (s *routerStorageStub) GetConfig() *config.StorageConfig { return &config.StorageConfig{} }
func (s *routerStorageStub) EnsureDirectoryExists() error { return nil }
func (s *routerStorageStub) Close() error { return nil }
var _ service.StorageService = (*routerStorageStub)(nil)
func newRouterUnderTest(t *testing.T, cfg *config.Config, storage service.StorageService) http.Handler {
t.Helper()
h := httphandler.New(storage, log.New(io.Discard, "", 0), nil, nil, false)
return buildHandler(cfg, h)
}
func TestBuildHandlerKeepsDiscoveryRoutesPublicWhileProtectingDashboard(t *testing.T) {
cfg := &config.Config{
Auth: config.AuthConfig{
Enabled: true,
Token: "proxy-secret",
APIKeyHeader: "X-API-Key",
DashboardPassword: "dashboard-secret",
},
CORS: config.CORSConfig{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "X-API-Key"},
},
}
router := newRouterUnderTest(t, cfg, &routerStorageStub{
getUsageStatsFn: func(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error) {
return &model.UsageStats{TotalRequests: 11}, nil
},
})
for _, path := range []string{"/health", "/openapi.json", "/openapi.yaml"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.RemoteAddr = "10.0.0.5:12345"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected %s to stay public, got %d", path, rr.Code)
}
}
req := httptest.NewRequest(http.MethodGet, "/api/stats", nil)
req.RemoteAddr = "10.0.0.5:12345"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected dashboard endpoint to require auth, got %d", rr.Code)
}
}
func TestBuildHandlerRequiresBothProxyAndDashboardCredentialsForDashboardAPI(t *testing.T) {
cfg := &config.Config{
Auth: config.AuthConfig{
Enabled: true,
Token: "proxy-secret",
APIKeyHeader: "X-API-Key",
DashboardPassword: "dashboard-secret",
},
CORS: config.CORSConfig{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "X-API-Key"},
},
}
router := newRouterUnderTest(t, cfg, &routerStorageStub{
getUsageStatsFn: func(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error) {
return &model.UsageStats{TotalRequests: 5}, nil
},
})
testCases := []struct {
name string
setup func(*http.Request)
wantStatus int
}{
{
name: "proxy auth only",
setup: func(r *http.Request) {
r.Header.Set("X-API-Key", "proxy-secret")
},
wantStatus: http.StatusUnauthorized,
},
{
name: "dashboard auth only",
setup: func(r *http.Request) {
r.SetBasicAuth("admin", "dashboard-secret")
},
wantStatus: http.StatusUnauthorized,
},
{
name: "both auth layers",
setup: func(r *http.Request) {
r.Header.Set("X-API-Key", "proxy-secret")
r.SetBasicAuth("admin", "dashboard-secret")
},
wantStatus: http.StatusOK,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/stats", nil)
req.RemoteAddr = "10.0.0.5:12345"
tc.setup(req)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != tc.wantStatus {
t.Fatalf("expected status %d, got %d", tc.wantStatus, rr.Code)
}
})
}
}

View file

@ -6,6 +6,7 @@ require (
github.com/gorilla/handlers v1.5.2 github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.28 github.com/mattn/go-sqlite3 v1.14.28
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

View file

@ -6,6 +6,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View file

@ -20,7 +20,6 @@ type Config struct {
Subagents SubagentsConfig `yaml:"subagents"` Subagents SubagentsConfig `yaml:"subagents"`
Auth AuthConfig `yaml:"auth"` Auth AuthConfig `yaml:"auth"`
CORS CORSConfig `yaml:"cors"` CORS CORSConfig `yaml:"cors"`
Anthropic AnthropicConfig
} }
type CORSConfig struct { type CORSConfig struct {
@ -54,6 +53,8 @@ type AnthropicProviderConfig struct {
BaseURL string `yaml:"base_url"` BaseURL string `yaml:"base_url"`
Version string `yaml:"version"` Version string `yaml:"version"`
MaxRetries int `yaml:"max_retries"` MaxRetries int `yaml:"max_retries"`
ResponseHeaderTimeout time.Duration `yaml:"response_header_timeout"`
DemoteNonstreaming bool `yaml:"demote_nonstreaming"`
} }
type OpenAIProviderConfig struct { type OpenAIProviderConfig struct {
@ -68,17 +69,15 @@ type AuthConfig struct {
Token string `yaml:"token"` Token string `yaml:"token"`
APIKeyHeader string `yaml:"api_key_header"` APIKeyHeader string `yaml:"api_key_header"`
AllowLocalhostBypass bool `yaml:"allow_localhost_bypass"` AllowLocalhostBypass bool `yaml:"allow_localhost_bypass"`
} DashboardPassword string `yaml:"dashboard_password"`
TrustProxy bool `yaml:"trust_proxy"` // Skip bind-address auth check (for Docker / reverse-proxy setups)
type AnthropicConfig struct {
BaseURL string
Version string
MaxRetries int
} }
type StorageConfig struct { type StorageConfig struct {
RequestsDir string `yaml:"requests_dir"` RequestsDir string `yaml:"requests_dir"`
DBType string `yaml:"db_type"`
DBPath string `yaml:"db_path"` DBPath string `yaml:"db_path"`
DatabaseURL string `yaml:"database_url"`
CaptureRequestBody bool `yaml:"capture_request_body"` CaptureRequestBody bool `yaml:"capture_request_body"`
CaptureResponseBody bool `yaml:"capture_response_body"` CaptureResponseBody bool `yaml:"capture_response_body"`
MetadataOnly bool `yaml:"metadata_only"` MetadataOnly bool `yaml:"metadata_only"`
@ -136,6 +135,12 @@ func Load() (*Config, error) {
if envRetries := os.Getenv("ANTHROPIC_MAX_RETRIES"); envRetries != "" { if envRetries := os.Getenv("ANTHROPIC_MAX_RETRIES"); envRetries != "" {
cfg.Providers.Anthropic.MaxRetries = getInt("ANTHROPIC_MAX_RETRIES", cfg.Providers.Anthropic.MaxRetries) cfg.Providers.Anthropic.MaxRetries = getInt("ANTHROPIC_MAX_RETRIES", cfg.Providers.Anthropic.MaxRetries)
} }
if envTimeout := os.Getenv("ANTHROPIC_RESPONSE_HEADER_TIMEOUT"); envTimeout != "" {
cfg.Providers.Anthropic.ResponseHeaderTimeout = getDuration("ANTHROPIC_RESPONSE_HEADER_TIMEOUT", cfg.Providers.Anthropic.ResponseHeaderTimeout)
}
if os.Getenv("ANTHROPIC_DEMOTE_NONSTREAMING") != "" {
cfg.Providers.Anthropic.DemoteNonstreaming = envBool("ANTHROPIC_DEMOTE_NONSTREAMING")
}
// Override OpenAI settings // Override OpenAI settings
if envURL := os.Getenv("OPENAI_BASE_URL"); envURL != "" { if envURL := os.Getenv("OPENAI_BASE_URL"); envURL != "" {
@ -144,16 +149,16 @@ func Load() (*Config, error) {
if envKey := os.Getenv("OPENAI_API_KEY"); envKey != "" { if envKey := os.Getenv("OPENAI_API_KEY"); envKey != "" {
cfg.Providers.OpenAI.APIKey = envKey cfg.Providers.OpenAI.APIKey = envKey
} }
if envAllow := os.Getenv("OPENAI_ALLOW_CLIENT_API_KEY"); envAllow != "" { if os.Getenv("OPENAI_ALLOW_CLIENT_API_KEY") != "" {
cfg.Providers.OpenAI.AllowClientAPIKey = envAllow == "true" || envAllow == "1" cfg.Providers.OpenAI.AllowClientAPIKey = envBool("OPENAI_ALLOW_CLIENT_API_KEY")
} }
if envHeader := os.Getenv("OPENAI_CLIENT_API_KEY_HEADER"); envHeader != "" { if envHeader := os.Getenv("OPENAI_CLIENT_API_KEY_HEADER"); envHeader != "" {
cfg.Providers.OpenAI.ClientAPIKeyHeader = envHeader cfg.Providers.OpenAI.ClientAPIKeyHeader = envHeader
} }
// Override auth settings // Override auth settings
if envAuthEnabled := os.Getenv("AUTH_ENABLED"); envAuthEnabled != "" { if os.Getenv("AUTH_ENABLED") != "" {
cfg.Auth.Enabled = envAuthEnabled == "true" || envAuthEnabled == "1" cfg.Auth.Enabled = envBool("AUTH_ENABLED")
} }
if envAuthToken := os.Getenv("AUTH_TOKEN"); envAuthToken != "" { if envAuthToken := os.Getenv("AUTH_TOKEN"); envAuthToken != "" {
cfg.Auth.Token = envAuthToken cfg.Auth.Token = envAuthToken
@ -161,22 +166,34 @@ func Load() (*Config, error) {
if envAPIKeyHeader := os.Getenv("AUTH_API_KEY_HEADER"); envAPIKeyHeader != "" { if envAPIKeyHeader := os.Getenv("AUTH_API_KEY_HEADER"); envAPIKeyHeader != "" {
cfg.Auth.APIKeyHeader = envAPIKeyHeader cfg.Auth.APIKeyHeader = envAPIKeyHeader
} }
if envLocalBypass := os.Getenv("AUTH_ALLOW_LOCALHOST_BYPASS"); envLocalBypass != "" { if os.Getenv("AUTH_ALLOW_LOCALHOST_BYPASS") != "" {
cfg.Auth.AllowLocalhostBypass = envLocalBypass == "true" || envLocalBypass == "1" cfg.Auth.AllowLocalhostBypass = envBool("AUTH_ALLOW_LOCALHOST_BYPASS")
}
if envDashPass := os.Getenv("DASHBOARD_PASSWORD"); envDashPass != "" {
cfg.Auth.DashboardPassword = envDashPass
}
if os.Getenv("TRUST_PROXY") != "" {
cfg.Auth.TrustProxy = envBool("TRUST_PROXY")
} }
// Override storage settings // Override storage settings
if envDBType := os.Getenv("DB_TYPE"); envDBType != "" {
cfg.Storage.DBType = envDBType
}
if envPath := os.Getenv("DB_PATH"); envPath != "" { if envPath := os.Getenv("DB_PATH"); envPath != "" {
cfg.Storage.DBPath = envPath cfg.Storage.DBPath = envPath
} }
if envCaptureReq := os.Getenv("STORAGE_CAPTURE_REQUEST_BODY"); envCaptureReq != "" { if envDatabaseURL := os.Getenv("DATABASE_URL"); envDatabaseURL != "" {
cfg.Storage.CaptureRequestBody = envCaptureReq == "true" || envCaptureReq == "1" cfg.Storage.DatabaseURL = envDatabaseURL
} }
if envCaptureResp := os.Getenv("STORAGE_CAPTURE_RESPONSE_BODY"); envCaptureResp != "" { if os.Getenv("STORAGE_CAPTURE_REQUEST_BODY") != "" {
cfg.Storage.CaptureResponseBody = envCaptureResp == "true" || envCaptureResp == "1" cfg.Storage.CaptureRequestBody = envBool("STORAGE_CAPTURE_REQUEST_BODY")
} }
if envMetadataOnly := os.Getenv("STORAGE_METADATA_ONLY"); envMetadataOnly != "" { if os.Getenv("STORAGE_CAPTURE_RESPONSE_BODY") != "" {
cfg.Storage.MetadataOnly = envMetadataOnly == "true" || envMetadataOnly == "1" cfg.Storage.CaptureResponseBody = envBool("STORAGE_CAPTURE_RESPONSE_BODY")
}
if os.Getenv("STORAGE_METADATA_ONLY") != "" {
cfg.Storage.MetadataOnly = envBool("STORAGE_METADATA_ONLY")
} }
if envRetentionDays := os.Getenv("STORAGE_RETENTION_DAYS"); envRetentionDays != "" { if envRetentionDays := os.Getenv("STORAGE_RETENTION_DAYS"); envRetentionDays != "" {
cfg.Storage.RetentionDays = getInt("STORAGE_RETENTION_DAYS", cfg.Storage.RetentionDays) cfg.Storage.RetentionDays = getInt("STORAGE_RETENTION_DAYS", cfg.Storage.RetentionDays)
@ -201,13 +218,6 @@ func Load() (*Config, error) {
cfg.CORS.AllowedHeaders = splitAndTrim(envHeaders) cfg.CORS.AllowedHeaders = splitAndTrim(envHeaders)
} }
// Sync legacy Anthropic config
cfg.Anthropic = AnthropicConfig{
BaseURL: cfg.Providers.Anthropic.BaseURL,
Version: cfg.Providers.Anthropic.Version,
MaxRetries: cfg.Providers.Anthropic.MaxRetries,
}
// After loading from file, apply any timeout conversions if needed // After loading from file, apply any timeout conversions if needed
if cfg.Server.Timeouts.Read != "" { if cfg.Server.Timeouts.Read != "" {
if duration, err := time.ParseDuration(cfg.Server.Timeouts.Read); err == nil { if duration, err := time.ParseDuration(cfg.Server.Timeouts.Read); err == nil {
@ -225,13 +235,6 @@ func Load() (*Config, error) {
} }
} }
// Sync legacy Anthropic config with new structure
cfg.Anthropic = AnthropicConfig{
BaseURL: cfg.Providers.Anthropic.BaseURL,
Version: cfg.Providers.Anthropic.Version,
MaxRetries: cfg.Providers.Anthropic.MaxRetries,
}
if err := validateSecurity(cfg); err != nil { if err := validateSecurity(cfg); err != nil {
return nil, err return nil, err
} }
@ -251,7 +254,7 @@ func (c *Config) loadFromFile(path string) error {
func defaultConfig() *Config { func defaultConfig() *Config {
return &Config{ return &Config{
Server: ServerConfig{ Server: ServerConfig{
Host: "127.0.0.1", Host: "0.0.0.0",
Port: "3001", Port: "3001",
ReadTimeout: 600 * time.Second, ReadTimeout: 600 * time.Second,
WriteTimeout: 600 * time.Second, WriteTimeout: 600 * time.Second,
@ -262,6 +265,7 @@ func defaultConfig() *Config {
BaseURL: "https://api.anthropic.com", BaseURL: "https://api.anthropic.com",
Version: "2023-06-01", Version: "2023-06-01",
MaxRetries: 3, MaxRetries: 3,
ResponseHeaderTimeout: 300 * time.Second,
}, },
OpenAI: OpenAIProviderConfig{ OpenAI: OpenAIProviderConfig{
BaseURL: "https://api.openai.com", BaseURL: "https://api.openai.com",
@ -271,6 +275,7 @@ func defaultConfig() *Config {
}, },
}, },
Storage: StorageConfig{ Storage: StorageConfig{
DBType: "sqlite",
DBPath: "requests.db", DBPath: "requests.db",
CaptureRequestBody: true, CaptureRequestBody: true,
CaptureResponseBody: true, CaptureResponseBody: true,
@ -298,12 +303,7 @@ func defaultConfig() *Config {
AllowLocalhostBypass: true, AllowLocalhostBypass: true,
}, },
CORS: CORSConfig{ CORS: CORSConfig{
AllowedOrigins: []string{ AllowedOrigins: []string{"*"},
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173",
"http://127.0.0.1:5173",
},
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
AllowedHeaders: []string{ AllowedHeaders: []string{
"Accept", "Accept",
@ -341,11 +341,17 @@ func candidateConfigPaths() []string {
func validateSecurity(cfg *Config) error { func validateSecurity(cfg *Config) error {
if cfg.Server.Host == "" { if cfg.Server.Host == "" {
cfg.Server.Host = "127.0.0.1" cfg.Server.Host = "0.0.0.0"
}
// When behind a reverse proxy (Docker/Traefik), skip the bind-address auth requirement.
// The proxy is not directly exposed; the reverse proxy handles access control.
if cfg.Auth.TrustProxy {
return nil
} }
if !isLoopbackHost(cfg.Server.Host) && !cfg.Auth.Enabled { if !isLoopbackHost(cfg.Server.Host) && !cfg.Auth.Enabled {
return fmt.Errorf("refusing to bind to %q without auth enabled; set AUTH_ENABLED=true and AUTH_TOKEN for public access", cfg.Server.Host) return fmt.Errorf("refusing to bind to %q without auth enabled; set AUTH_ENABLED=true and AUTH_TOKEN, or TRUST_PROXY=true for reverse-proxy setups", cfg.Server.Host)
} }
if cfg.Auth.Enabled && cfg.Auth.Token == "" && !isLoopbackHost(cfg.Server.Host) { if cfg.Auth.Enabled && cfg.Auth.Token == "" && !isLoopbackHost(cfg.Server.Host) {
@ -386,6 +392,11 @@ func loadFirstAvailableConfig(cfg *Config, paths []string) error {
return nil return nil
} }
func envBool(key string) bool {
v := strings.ToLower(os.Getenv(key))
return v == "true" || v == "1" || v == "yes"
}
func getEnv(key, defaultValue string) string { func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" { if value := os.Getenv(key); value != "" {
return value return value

View file

@ -93,35 +93,30 @@ func TestLoadFirstAvailableConfigSkipsMissingFiles(t *testing.T) {
} }
} }
func TestDefaultConfigUsesLoopbackAndLocalCors(t *testing.T) { func TestDefaultConfigUsesPublicBindAndWildcardCors(t *testing.T) {
cfg := defaultConfig() cfg := defaultConfig()
if cfg.Server.Host != "127.0.0.1" { if cfg.Server.Host != "0.0.0.0" {
t.Fatalf("expected loopback host, got %q", cfg.Server.Host) t.Fatalf("expected 0.0.0.0 host, got %q", cfg.Server.Host)
} }
if cfg.Auth.Enabled { if cfg.Auth.Enabled {
t.Fatal("expected auth to be disabled by default for local development") t.Fatal("expected auth to be disabled by default")
} }
if len(cfg.CORS.AllowedOrigins) == 0 { if len(cfg.CORS.AllowedOrigins) != 1 || cfg.CORS.AllowedOrigins[0] != "*" {
t.Fatal("expected local CORS origins to be configured") t.Fatalf("expected default CORS origins to be [*], got %v", cfg.CORS.AllowedOrigins)
}
for _, origin := range cfg.CORS.AllowedOrigins {
if origin == "*" {
t.Fatal("expected wildcard origin to be removed from defaults")
}
} }
} }
func TestValidateSecurityRejectsPublicBindWithoutAuth(t *testing.T) { func TestValidateSecurityRejectsPublicBindWithoutAuthOrTrustProxy(t *testing.T) {
cfg := defaultConfig() cfg := defaultConfig()
cfg.Server.Host = "0.0.0.0" cfg.Server.Host = "0.0.0.0"
cfg.Auth.Enabled = false cfg.Auth.Enabled = false
cfg.Auth.TrustProxy = false
if err := validateSecurity(cfg); err == nil { if err := validateSecurity(cfg); err == nil {
t.Fatal("expected validation error for public bind without auth") t.Fatal("expected validation error for public bind without auth or trust_proxy")
} }
} }
@ -135,3 +130,218 @@ func TestValidateSecurityAllowsPublicBindWithAuthToken(t *testing.T) {
t.Fatalf("expected public bind with auth token to be allowed, got %v", err) t.Fatalf("expected public bind with auth token to be allowed, got %v", err)
} }
} }
func TestValidateSecurityAllowsPublicBindWithTrustProxy(t *testing.T) {
cfg := defaultConfig()
cfg.Server.Host = "0.0.0.0"
cfg.Auth.Enabled = false
cfg.Auth.TrustProxy = true
if err := validateSecurity(cfg); err != nil {
t.Fatalf("expected public bind with trust_proxy to be allowed, got %v", err)
}
}
func TestEnvBool(t *testing.T) {
tests := []struct {
name string
value string
want bool
}{
{"true lowercase", "true", true},
{"true uppercase", "TRUE", true},
{"true mixed case", "True", true},
{"one", "1", true},
{"yes lowercase", "yes", true},
{"yes uppercase", "YES", true},
{"false", "false", false},
{"zero", "0", false},
{"no", "no", false},
{"empty", "", false},
{"random string", "maybe", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key := "TEST_ENV_BOOL_" + tt.name
if tt.value != "" {
os.Setenv(key, tt.value)
defer os.Unsetenv(key)
} else {
os.Unsetenv(key)
}
got := envBool(key)
if got != tt.want {
t.Errorf("envBool(%q) with value %q = %v, want %v", key, tt.value, got, tt.want)
}
})
}
}
func TestCORSEnvOverride(t *testing.T) {
tests := []struct {
name string
envKey string
envVal string
check func(*Config) bool
desc string
}{
{
name: "origins override",
envKey: "CORS_ALLOWED_ORIGINS",
envVal: "https://example.com, https://other.com",
check: func(c *Config) bool {
return len(c.CORS.AllowedOrigins) == 2 &&
c.CORS.AllowedOrigins[0] == "https://example.com" &&
c.CORS.AllowedOrigins[1] == "https://other.com"
},
desc: "should split and trim comma-separated origins",
},
{
name: "methods override",
envKey: "CORS_ALLOWED_METHODS",
envVal: "GET,POST",
check: func(c *Config) bool {
return len(c.CORS.AllowedMethods) == 2 &&
c.CORS.AllowedMethods[0] == "GET" &&
c.CORS.AllowedMethods[1] == "POST"
},
desc: "should split comma-separated methods",
},
{
name: "headers override",
envKey: "CORS_ALLOWED_HEADERS",
envVal: "Authorization",
check: func(c *Config) bool {
return len(c.CORS.AllowedHeaders) == 1 &&
c.CORS.AllowedHeaders[0] == "Authorization"
},
desc: "should accept single header value",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set the env var under test; clear all others to avoid cross-talk
os.Setenv(tt.envKey, tt.envVal)
defer os.Unsetenv(tt.envKey)
// Also set TRUST_PROXY so validateSecurity doesn't reject default config
os.Setenv("TRUST_PROXY", "true")
defer os.Unsetenv("TRUST_PROXY")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !tt.check(cfg) {
t.Fatalf("%s: check failed; origins=%v methods=%v headers=%v",
tt.desc, cfg.CORS.AllowedOrigins, cfg.CORS.AllowedMethods, cfg.CORS.AllowedHeaders)
}
})
}
}
func TestBoolEnvOverrides(t *testing.T) {
tests := []struct {
name string
envKey string
envVal string
check func(*Config) bool
}{
{
name: "AUTH_ENABLED true",
envKey: "AUTH_ENABLED",
envVal: "true",
check: func(c *Config) bool { return c.Auth.Enabled },
},
{
name: "AUTH_ENABLED yes",
envKey: "AUTH_ENABLED",
envVal: "yes",
check: func(c *Config) bool { return c.Auth.Enabled },
},
{
name: "AUTH_ENABLED 1",
envKey: "AUTH_ENABLED",
envVal: "1",
check: func(c *Config) bool { return c.Auth.Enabled },
},
{
name: "TRUST_PROXY true",
envKey: "TRUST_PROXY",
envVal: "true",
check: func(c *Config) bool { return c.Auth.TrustProxy },
},
{
name: "STORAGE_METADATA_ONLY true",
envKey: "STORAGE_METADATA_ONLY",
envVal: "true",
check: func(c *Config) bool { return c.Storage.MetadataOnly },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Setenv(tt.envKey, tt.envVal)
defer os.Unsetenv(tt.envKey)
// Need TRUST_PROXY or AUTH_TOKEN for security validation when AUTH_ENABLED
if tt.envKey == "AUTH_ENABLED" {
os.Setenv("AUTH_TOKEN", "test-token")
defer os.Unsetenv("AUTH_TOKEN")
}
if tt.envKey != "TRUST_PROXY" {
os.Setenv("TRUST_PROXY", "true")
defer os.Unsetenv("TRUST_PROXY")
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !tt.check(cfg) {
t.Fatalf("expected %s=%s to set config field to true", tt.envKey, tt.envVal)
}
})
}
}
func TestSplitAndTrim(t *testing.T) {
tests := []struct {
input string
want []string
}{
{"a,b,c", []string{"a", "b", "c"}},
{" a , b , c ", []string{"a", "b", "c"}},
{"single", []string{"single"}},
{"a,,b", []string{"a", "b"}},
{"", []string{}},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := splitAndTrim(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("splitAndTrim(%q) = %v, want %v", tt.input, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
}
}
})
}
}
func TestNoAnthropicConfigField(t *testing.T) {
// Verify the top-level Anthropic field was removed from Config.
// The canonical location is cfg.Providers.Anthropic.
cfg := defaultConfig()
if cfg.Providers.Anthropic.BaseURL == "" {
t.Fatal("expected Providers.Anthropic.BaseURL to have a default value")
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,287 @@
package handler
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seifghazi/claude-code-monitor/internal/service"
)
type conversationServiceStub struct {
getConversationsFn func() (map[string][]*service.Conversation, error)
getConversationFn func(projectPath, sessionID string) (*service.Conversation, error)
getConversationsByProjectFn func(projectPath string) ([]*service.Conversation, error)
}
func (s *conversationServiceStub) GetConversations() (map[string][]*service.Conversation, error) {
if s.getConversationsFn != nil {
return s.getConversationsFn()
}
panic("unexpected call")
}
func (s *conversationServiceStub) GetConversation(projectPath, sessionID string) (*service.Conversation, error) {
if s.getConversationFn != nil {
return s.getConversationFn(projectPath, sessionID)
}
panic("unexpected call")
}
func (s *conversationServiceStub) GetConversationsByProject(projectPath string) ([]*service.Conversation, error) {
if s.getConversationsByProjectFn != nil {
return s.getConversationsByProjectFn(projectPath)
}
panic("unexpected call")
}
func newConversationHandler(stub *conversationServiceStub) *Handler {
return &Handler{
conversationService: stub,
logger: log.New(io.Discard, "", 0),
}
}
func rawMessage(t *testing.T, v interface{}) json.RawMessage {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("failed to marshal test raw message: %v", err)
}
return data
}
func TestConversationModelMatchesFilter(t *testing.T) {
tests := []struct {
name string
model string
filter string
wantMatch bool
}{
{name: "all matches any model", model: "claude-opus-4-6", filter: "all", wantMatch: true},
{name: "opus matches opus tier", model: "claude-opus-4-6", filter: "opus", wantMatch: true},
{name: "sonnet does not match opus tier", model: "claude-sonnet-4-5", filter: "opus", wantMatch: false},
{name: "empty model does not match tier filter", model: "", filter: "haiku", wantMatch: false},
{name: "substring filter still works", model: "claude-opus-4-6", filter: "opus-4", wantMatch: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := conversationModelMatchesFilter(tt.model, tt.filter); got != tt.wantMatch {
t.Fatalf("conversationModelMatchesFilter(%q, %q) = %v, want %v", tt.model, tt.filter, got, tt.wantMatch)
}
})
}
}
func TestGetConversationsAppliesFilterSortAndPagination(t *testing.T) {
oldest := time.Date(2026, 3, 18, 9, 0, 0, 0, time.UTC)
middle := time.Date(2026, 3, 19, 9, 0, 0, 0, time.UTC)
newest := time.Date(2026, 3, 20, 9, 0, 0, 0, time.UTC)
stub := &conversationServiceStub{
getConversationsFn: func() (map[string][]*service.Conversation, error) {
return map[string][]*service.Conversation{
"proj-a": {
{
SessionID: "old-opus",
ProjectPath: "proj-a",
ProjectName: "Proj A",
Model: "claude-opus-4-6",
StartTime: oldest.Add(-5 * time.Minute),
EndTime: oldest,
MessageCount: 1,
Messages: []*service.ConversationMessage{
{Type: "user", Message: rawMessage(t, "short prompt")},
},
},
{
SessionID: "new-sonnet",
ProjectPath: "proj-a",
ProjectName: "Proj A",
Model: "claude-sonnet-4-5",
StartTime: newest.Add(-2 * time.Minute),
EndTime: newest,
MessageCount: 2,
Messages: []*service.ConversationMessage{
{Type: "user", Message: rawMessage(t, []map[string]string{{"type": "text", "text": "newest prompt"}})},
},
},
},
"proj-b": {
{
SessionID: "mid-opus",
ProjectPath: "proj-b",
ProjectName: "Proj B",
Model: "claude-opus-4-6",
StartTime: middle.Add(-3 * time.Minute),
EndTime: middle,
MessageCount: 3,
Messages: []*service.ConversationMessage{
{Type: "user", Message: rawMessage(t, map[string]string{"content": "middle prompt"})},
},
},
},
}, nil
},
}
req := httptest.NewRequest(http.MethodGet, "/api/conversations?model=opus&page=1&limit=1", nil)
rr := httptest.NewRecorder()
newConversationHandler(stub).GetConversations(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response struct {
Conversations []map[string]interface{} `json:"conversations"`
HasMore bool `json:"hasMore"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
decodeJSONBody(t, rr, &response)
if response.Total != 2 || !response.HasMore || response.Page != 1 || response.Limit != 1 {
t.Fatalf("unexpected pagination metadata: %#v", response)
}
if len(response.Conversations) != 1 {
t.Fatalf("expected one paginated conversation, got %d", len(response.Conversations))
}
first := response.Conversations[0]
if first["id"] != "mid-opus" {
t.Fatalf("expected newest matching opus conversation first, got %#v", first)
}
if first["firstMessage"] != "middle prompt" {
t.Fatalf("expected extracted first message, got %#v", first["firstMessage"])
}
}
func TestGetConversationsReturnsInternalServerErrorOnServiceFailure(t *testing.T) {
stub := &conversationServiceStub{
getConversationsFn: func() (map[string][]*service.Conversation, error) {
return nil, errors.New("boom")
},
}
req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil)
rr := httptest.NewRecorder()
newConversationHandler(stub).GetConversations(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rr.Code)
}
var response struct {
Error string `json:"error"`
}
decodeJSONBody(t, rr, &response)
if response.Error != "Failed to get conversations" {
t.Fatalf("unexpected error response: %#v", response)
}
}
func TestGetConversationByIDRequiresProjectAndReturnsConversation(t *testing.T) {
t.Run("missing project", func(t *testing.T) {
req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/api/conversations/session-1", nil), map[string]string{"id": "session-1"})
rr := httptest.NewRecorder()
newConversationHandler(&conversationServiceStub{}).GetConversationByID(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
var response struct {
Error string `json:"error"`
}
decodeJSONBody(t, rr, &response)
if response.Error != "Project path is required" {
t.Fatalf("unexpected error response: %#v", response)
}
})
t.Run("success", func(t *testing.T) {
stub := &conversationServiceStub{
getConversationFn: func(projectPath, sessionID string) (*service.Conversation, error) {
if projectPath != "team/app" || sessionID != "session-1" {
t.Fatalf("unexpected conversation lookup %q %q", projectPath, sessionID)
}
return &service.Conversation{
SessionID: "session-1",
ProjectPath: "team/app",
ProjectName: "app",
Model: "claude-opus-4-6",
StartTime: time.Date(2026, 3, 20, 8, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 3, 20, 8, 5, 0, 0, time.UTC),
MessageCount: 2,
}, nil
},
}
req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/api/conversations/session-1?project=team/app", nil), map[string]string{"id": "session-1"})
rr := httptest.NewRecorder()
newConversationHandler(stub).GetConversationByID(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response service.Conversation
decodeJSONBody(t, rr, &response)
if response.SessionID != "session-1" || response.ProjectPath != "team/app" {
t.Fatalf("unexpected conversation payload: %#v", response)
}
})
}
func TestGetConversationsByProjectRequiresProjectAndHandlesFailure(t *testing.T) {
t.Run("missing project", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/conversations/project", nil)
rr := httptest.NewRecorder()
newConversationHandler(&conversationServiceStub{}).GetConversationsByProject(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
})
t.Run("service failure", func(t *testing.T) {
stub := &conversationServiceStub{
getConversationsByProjectFn: func(projectPath string) ([]*service.Conversation, error) {
if projectPath != "team/app" {
t.Fatalf("unexpected project path %q", projectPath)
}
return nil, errors.New("boom")
},
}
req := httptest.NewRequest(http.MethodGet, "/api/conversations/project?project=team/app", nil)
rr := httptest.NewRecorder()
newConversationHandler(stub).GetConversationsByProject(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rr.Code)
}
var response struct {
Error string `json:"error"`
}
decodeJSONBody(t, rr, &response)
if response.Error != "Failed to get project conversations" {
t.Fatalf("unexpected error response: %#v", response)
}
})
}

View file

@ -0,0 +1,630 @@
package handler
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
type dashboardStorageStub struct {
getRequestsFn func(page, limit int, modelFilter string) ([]model.RequestLog, int, error)
getUsageStatsFn func(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error)
getRequestsSummaryPaginatedFn func(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error)
getRequestByShortIDFn func(shortID string) (*model.RequestLog, string, error)
getStatsFn func(startDate, endDate, orgFilter string) (*model.DashboardStats, error)
getHourlyStatsFn func(startTime, endTime string, bucketMinutes int, orgFilter string) (*model.HourlyStatsResponse, error)
getModelStatsFn func(startTime, endTime, orgFilter string) (*model.ModelStatsResponse, error)
getDistinctOrganizationsFn func() ([]string, error)
getLatestRequestDateFn func() (*time.Time, error)
getSettingsFn func() (*model.ProxySettings, error)
saveSettingsFn func(settings *model.ProxySettings) error
clearRequestsFn func() (int, error)
}
func (s *dashboardStorageStub) SaveRequest(*model.RequestLog) (string, error) {
panic("unexpected call")
}
func (s *dashboardStorageStub) GetRequests(page, limit int, modelFilter string) ([]model.RequestLog, int, error) {
if s.getRequestsFn != nil {
return s.getRequestsFn(page, limit, modelFilter)
}
panic("unexpected call")
}
func (s *dashboardStorageStub) GetAllRequests(string) ([]*model.RequestLog, error) {
panic("unexpected call")
}
func (s *dashboardStorageStub) GetRequestByShortID(shortID string) (*model.RequestLog, string, error) {
if s.getRequestByShortIDFn != nil {
return s.getRequestByShortIDFn(shortID)
}
panic("unexpected call")
}
func (s *dashboardStorageStub) ClearRequests() (int, error) {
if s.clearRequestsFn != nil {
return s.clearRequestsFn()
}
panic("unexpected call")
}
func (s *dashboardStorageStub) UpdateRequestWithGrading(string, *model.PromptGrade) error {
panic("unexpected call")
}
func (s *dashboardStorageStub) UpdateRequestWithResponse(*model.RequestLog) error {
panic("unexpected call")
}
func (s *dashboardStorageStub) DeleteRequestsOlderThan(time.Duration) (int, error) {
panic("unexpected call")
}
func (s *dashboardStorageStub) GetDatabaseStats() (map[string]interface{}, error) {
panic("unexpected call")
}
func (s *dashboardStorageStub) GetUsageStats(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error) {
if s.getUsageStatsFn != nil {
return s.getUsageStatsFn(startDate, endDate, modelFilter, orgFilter)
}
panic("unexpected call")
}
func (s *dashboardStorageStub) GetRequestsSummary(string) ([]*model.RequestSummary, error) {
panic("unexpected call")
}
func (s *dashboardStorageStub) GetRequestsSummaryPaginated(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error) {
if s.getRequestsSummaryPaginatedFn != nil {
return s.getRequestsSummaryPaginatedFn(modelFilter, startTime, endTime, offset, limit)
}
panic("unexpected call")
}
func (s *dashboardStorageStub) GetStats(startDate, endDate, orgFilter string) (*model.DashboardStats, error) {
if s.getStatsFn != nil {
return s.getStatsFn(startDate, endDate, orgFilter)
}
panic("unexpected call")
}
func (s *dashboardStorageStub) GetHourlyStats(startTime, endTime string, bucketMinutes int, orgFilter string) (*model.HourlyStatsResponse, error) {
if s.getHourlyStatsFn != nil {
return s.getHourlyStatsFn(startTime, endTime, bucketMinutes, orgFilter)
}
panic("unexpected call")
}
func (s *dashboardStorageStub) GetModelStats(startTime, endTime, orgFilter string) (*model.ModelStatsResponse, error) {
if s.getModelStatsFn != nil {
return s.getModelStatsFn(startTime, endTime, orgFilter)
}
panic("unexpected call")
}
func (s *dashboardStorageStub) GetLatestRequestDate() (*time.Time, error) {
if s.getLatestRequestDateFn != nil {
return s.getLatestRequestDateFn()
}
panic("unexpected call")
}
func (s *dashboardStorageStub) GetDistinctOrganizations() ([]string, error) {
if s.getDistinctOrganizationsFn != nil {
return s.getDistinctOrganizationsFn()
}
panic("unexpected call")
}
func (s *dashboardStorageStub) GetSettings() (*model.ProxySettings, error) {
if s.getSettingsFn != nil {
return s.getSettingsFn()
}
return &model.ProxySettings{}, nil
}
func (s *dashboardStorageStub) SaveSettings(settings *model.ProxySettings) error {
if s.saveSettingsFn != nil {
return s.saveSettingsFn(settings)
}
return nil
}
func (s *dashboardStorageStub) GetConfig() *config.StorageConfig { return &config.StorageConfig{} }
func (s *dashboardStorageStub) EnsureDirectoryExists() error { return nil }
func (s *dashboardStorageStub) Close() error { return nil }
func newTestHandler(storage *dashboardStorageStub) *Handler {
return &Handler{
storageService: storage,
logger: log.New(io.Discard, "", 0),
}
}
func decodeJSONBody(t *testing.T, rr *httptest.ResponseRecorder, dest interface{}) {
t.Helper()
if err := json.NewDecoder(rr.Body).Decode(dest); err != nil {
t.Fatalf("failed decoding JSON response: %v", err)
}
}
func TestGetRequestsUsesDefaultPaginationAndModelFilter(t *testing.T) {
storage := &dashboardStorageStub{
getRequestsFn: func(page, limit int, modelFilter string) ([]model.RequestLog, int, error) {
if page != defaultPage {
t.Fatalf("expected page %d, got %d", defaultPage, page)
}
if limit != defaultPageLimit {
t.Fatalf("expected limit %d, got %d", defaultPageLimit, limit)
}
if modelFilter != "all" {
t.Fatalf("expected model filter all, got %q", modelFilter)
}
return []model.RequestLog{{RequestID: "req-1"}}, 7, nil
},
}
req := httptest.NewRequest(http.MethodGet, "/api/requests", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).GetRequests(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response struct {
Requests []model.RequestLog `json:"requests"`
Total int `json:"total"`
}
decodeJSONBody(t, rr, &response)
if len(response.Requests) != 1 || response.Requests[0].RequestID != "req-1" {
t.Fatalf("unexpected requests payload: %#v", response.Requests)
}
if response.Total != 7 {
t.Fatalf("expected total 7, got %d", response.Total)
}
}
func TestGetRequestsReturnsInternalServerErrorOnStorageFailure(t *testing.T) {
storage := &dashboardStorageStub{
getRequestsFn: func(page, limit int, modelFilter string) ([]model.RequestLog, int, error) {
return nil, 0, errors.New("boom")
},
}
req := httptest.NewRequest(http.MethodGet, "/api/requests?page=2&limit=25&model=opus", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).GetRequests(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rr.Code)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error != "Failed to get requests" {
t.Fatalf("unexpected error response: %#v", response)
}
}
func TestDeleteRequestsReturnsDeletedCountAndStorageErrors(t *testing.T) {
t.Run("success", func(t *testing.T) {
storage := &dashboardStorageStub{
clearRequestsFn: func() (int, error) {
return 12, nil
},
}
req := httptest.NewRequest(http.MethodDelete, "/api/requests", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).DeleteRequests(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response struct {
Message string `json:"message"`
Deleted int `json:"deleted"`
}
decodeJSONBody(t, rr, &response)
if response.Message != "Request history cleared" || response.Deleted != 12 {
t.Fatalf("unexpected delete response: %#v", response)
}
})
t.Run("storage error", func(t *testing.T) {
storage := &dashboardStorageStub{
clearRequestsFn: func() (int, error) {
return 0, errors.New("boom")
},
}
req := httptest.NewRequest(http.MethodDelete, "/api/requests", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).DeleteRequests(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rr.Code)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error != "Error clearing request history" {
t.Fatalf("unexpected error response: %#v", response)
}
})
}
func TestGetRequestsSummaryNormalizesPaginationInputs(t *testing.T) {
storage := &dashboardStorageStub{
getRequestsSummaryPaginatedFn: func(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error) {
if modelFilter != "all" {
t.Fatalf("expected default model filter all, got %q", modelFilter)
}
if startTime != "2026-03-01T00:00:00Z" {
t.Fatalf("unexpected start time %q", startTime)
}
if endTime != "2026-03-02T00:00:00Z" {
t.Fatalf("unexpected end time %q", endTime)
}
if offset != 0 {
t.Fatalf("expected invalid negative offset to normalize to 0, got %d", offset)
}
if limit != 0 {
t.Fatalf("expected invalid oversize limit to normalize to 0, got %d", limit)
}
return []*model.RequestSummary{{RequestID: "req-summary-1"}}, 1, nil
},
}
req := httptest.NewRequest(http.MethodGet, "/api/requests/summary?start=2026-03-01T00:00:00Z&end=2026-03-02T00:00:00Z&offset=-4&limit=100001", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).GetRequestsSummary(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response struct {
Requests []*model.RequestSummary `json:"requests"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
decodeJSONBody(t, rr, &response)
if len(response.Requests) != 1 || response.Requests[0].RequestID != "req-summary-1" {
t.Fatalf("unexpected summaries payload: %#v", response.Requests)
}
if response.Total != 1 || response.Offset != 0 || response.Limit != 0 {
t.Fatalf("unexpected summary metadata: %#v", response)
}
}
func TestGetRequestByIDHandlesMissingAndNotFoundIDs(t *testing.T) {
t.Run("missing id", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/requests/", nil)
rr := httptest.NewRecorder()
newTestHandler(&dashboardStorageStub{}).GetRequestByID(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error != "Request ID is required" {
t.Fatalf("unexpected error response: %#v", response)
}
})
t.Run("not found", func(t *testing.T) {
storage := &dashboardStorageStub{
getRequestByShortIDFn: func(shortID string) (*model.RequestLog, string, error) {
if shortID != "abc123" {
t.Fatalf("expected short ID abc123, got %q", shortID)
}
return nil, "", nil
},
}
req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/api/requests/abc123", nil), map[string]string{"id": "abc123"})
rr := httptest.NewRecorder()
newTestHandler(storage).GetRequestByID(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rr.Code)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error != "Request not found" {
t.Fatalf("unexpected error response: %#v", response)
}
})
}
func TestGetRequestByIDReturnsRequestPayloadAndStorageErrors(t *testing.T) {
t.Run("success", func(t *testing.T) {
storage := &dashboardStorageStub{
getRequestByShortIDFn: func(shortID string) (*model.RequestLog, string, error) {
if shortID != "abc123" {
t.Fatalf("expected short ID abc123, got %q", shortID)
}
return &model.RequestLog{RequestID: "full-request-id", Model: "claude-opus-4-6"}, "full-request-id", nil
},
}
req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/api/requests/abc123", nil), map[string]string{"id": "abc123"})
rr := httptest.NewRecorder()
newTestHandler(storage).GetRequestByID(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response struct {
Request *model.RequestLog `json:"request"`
FullID string `json:"fullId"`
}
decodeJSONBody(t, rr, &response)
if response.Request == nil || response.Request.RequestID != "full-request-id" || response.FullID != "full-request-id" {
t.Fatalf("unexpected request payload: %#v", response)
}
})
t.Run("storage error", func(t *testing.T) {
storage := &dashboardStorageStub{
getRequestByShortIDFn: func(shortID string) (*model.RequestLog, string, error) {
return nil, "", errors.New("boom")
},
}
req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/api/requests/abc123", nil), map[string]string{"id": "abc123"})
rr := httptest.NewRecorder()
newTestHandler(storage).GetRequestByID(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rr.Code)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error != "Failed to get request" {
t.Fatalf("unexpected error response: %#v", response)
}
})
}
func TestGetDashboardStatsFallsBackToLastSevenDays(t *testing.T) {
storage := &dashboardStorageStub{
getStatsFn: func(startDate, endDate, orgFilter string) (*model.DashboardStats, error) {
if orgFilter != "org-1" {
t.Fatalf("expected org filter org-1, got %q", orgFilter)
}
start, err := time.Parse(time.RFC3339, startDate)
if err != nil {
t.Fatalf("expected RFC3339 start date, got %q: %v", startDate, err)
}
end, err := time.Parse(time.RFC3339, endDate)
if err != nil {
t.Fatalf("expected RFC3339 end date, got %q: %v", endDate, err)
}
diff := end.Sub(start)
if diff < (7*24*time.Hour-time.Second) || diff > (7*24*time.Hour+time.Second) {
t.Fatalf("expected ~7 day fallback window, got %v", diff)
}
return &model.DashboardStats{
DailyStats: []model.DailyTokens{{Date: "2026-03-20", Tokens: 42, Requests: 2}},
}, nil
},
}
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/stats?org=org-1", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).GetDashboardStats(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response model.DashboardStats
decodeJSONBody(t, rr, &response)
if len(response.DailyStats) != 1 || response.DailyStats[0].Tokens != 42 {
t.Fatalf("unexpected dashboard stats payload: %#v", response)
}
}
func TestGetStatsPassesQueryFiltersThrough(t *testing.T) {
storage := &dashboardStorageStub{
getUsageStatsFn: func(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error) {
if startDate != "2026-03-01" {
t.Fatalf("expected start date 2026-03-01, got %q", startDate)
}
if endDate != "2026-03-07" {
t.Fatalf("expected end date 2026-03-07, got %q", endDate)
}
if modelFilter != "claude-sonnet-4-5" {
t.Fatalf("expected model filter claude-sonnet-4-5, got %q", modelFilter)
}
if orgFilter != "org-usage" {
t.Fatalf("expected org filter org-usage, got %q", orgFilter)
}
return &model.UsageStats{
TotalRequests: 3,
RequestsByModel: map[string]model.ModelStats{
"claude-sonnet-4-5": {RequestCount: 3},
},
}, nil
},
}
req := httptest.NewRequest(http.MethodGet, "/api/stats?start_date=2026-03-01&end_date=2026-03-07&model=claude-sonnet-4-5&org=org-usage", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).GetStats(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response model.UsageStats
decodeJSONBody(t, rr, &response)
if response.TotalRequests != 3 {
t.Fatalf("expected total requests 3, got %d", response.TotalRequests)
}
if response.RequestsByModel["claude-sonnet-4-5"].RequestCount != 3 {
t.Fatalf("unexpected usage stats payload: %#v", response)
}
}
func TestGetHourlyStatsValidatesRangeAndDefaultsBucket(t *testing.T) {
t.Run("missing range", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/hourly?start=2026-03-01T00:00:00Z", nil)
rr := httptest.NewRecorder()
newTestHandler(&dashboardStorageStub{}).GetHourlyStats(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error != "start and end parameters are required" {
t.Fatalf("unexpected error response: %#v", response)
}
})
t.Run("invalid bucket falls back to default", func(t *testing.T) {
storage := &dashboardStorageStub{
getHourlyStatsFn: func(startTime, endTime string, bucketMinutes int, orgFilter string) (*model.HourlyStatsResponse, error) {
if startTime != "2026-03-01T00:00:00Z" || endTime != "2026-03-01T12:00:00Z" {
t.Fatalf("unexpected time range %q - %q", startTime, endTime)
}
if bucketMinutes != defaultBucketMinutes {
t.Fatalf("expected default bucket %d, got %d", defaultBucketMinutes, bucketMinutes)
}
if orgFilter != "org-2" {
t.Fatalf("expected org filter org-2, got %q", orgFilter)
}
return &model.HourlyStatsResponse{
HourlyStats: []model.HourlyTokens{{Hour: 9, Tokens: 123, Requests: 3}},
}, nil
},
}
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/hourly?start=2026-03-01T00:00:00Z&end=2026-03-01T12:00:00Z&bucket=bad&org=org-2", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).GetHourlyStats(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response model.HourlyStatsResponse
decodeJSONBody(t, rr, &response)
if len(response.HourlyStats) != 1 || response.HourlyStats[0].Tokens != 123 {
t.Fatalf("unexpected hourly stats payload: %#v", response)
}
})
}
func TestGetModelStatsRejectsMissingRange(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/models?end=2026-03-01T12:00:00Z", nil)
rr := httptest.NewRecorder()
newTestHandler(&dashboardStorageStub{}).GetModelStats(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error != "start and end parameters are required" {
t.Fatalf("unexpected error response: %#v", response)
}
}
func TestGetModelStatsPassesFiltersThrough(t *testing.T) {
storage := &dashboardStorageStub{
getModelStatsFn: func(startTime, endTime, orgFilter string) (*model.ModelStatsResponse, error) {
if startTime != "2026-03-01T00:00:00Z" || endTime != "2026-03-01T12:00:00Z" {
t.Fatalf("unexpected range %q - %q", startTime, endTime)
}
if orgFilter != "org-models" {
t.Fatalf("expected org filter org-models, got %q", orgFilter)
}
return &model.ModelStatsResponse{
ModelStats: []model.ModelTokens{{Model: "claude-opus-4-6", Tokens: 321, Requests: 4}},
}, nil
},
}
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/models?start=2026-03-01T00:00:00Z&end=2026-03-01T12:00:00Z&org=org-models", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).GetModelStats(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response model.ModelStatsResponse
decodeJSONBody(t, rr, &response)
if len(response.ModelStats) != 1 || response.ModelStats[0].Model != "claude-opus-4-6" {
t.Fatalf("unexpected model stats payload: %#v", response)
}
}
func TestGetOrganizationsReturnsEmptySliceWhenStorageReturnsNil(t *testing.T) {
storage := &dashboardStorageStub{
getDistinctOrganizationsFn: func() ([]string, error) {
return nil, nil
},
}
req := httptest.NewRequest(http.MethodGet, "/api/organizations", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).GetOrganizations(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response struct {
Organizations []string `json:"organizations"`
}
decodeJSONBody(t, rr, &response)
if len(response.Organizations) != 0 {
t.Fatalf("expected empty organizations list, got %#v", response.Organizations)
}
}
func TestGetLatestRequestDateReturnsNullWhenStorageHasNoData(t *testing.T) {
storage := &dashboardStorageStub{
getLatestRequestDateFn: func() (*time.Time, error) {
return nil, nil
},
}
req := httptest.NewRequest(http.MethodGet, "/api/latest-request-date", nil)
rr := httptest.NewRecorder()
newTestHandler(storage).GetLatestRequestDate(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response struct {
LatestDate *time.Time `json:"latestDate"`
}
decodeJSONBody(t, rr, &response)
if response.LatestDate != nil {
t.Fatalf("expected latestDate to be null, got %#v", response.LatestDate)
}
}

View file

@ -0,0 +1,302 @@
package handler
import (
"bytes"
"context"
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
"github.com/seifghazi/claude-code-monitor/internal/provider"
)
type passthroughStorageStub struct {
saveRequestFn func(*model.RequestLog) (string, error)
updateRequestFn func(*model.RequestLog) error
getSettingsFn func() (*model.ProxySettings, error)
savedRequests int
updatedRequests int
}
func (s *passthroughStorageStub) SaveRequest(request *model.RequestLog) (string, error) {
s.savedRequests++
if s.saveRequestFn != nil {
return s.saveRequestFn(request)
}
return "req-1", nil
}
func (s *passthroughStorageStub) GetRequests(int, int, string) ([]model.RequestLog, int, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetAllRequests(string) ([]*model.RequestLog, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetRequestByShortID(string) (*model.RequestLog, string, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) ClearRequests() (int, error) { panic("unexpected call") }
func (s *passthroughStorageStub) UpdateRequestWithGrading(string, *model.PromptGrade) error {
panic("unexpected call")
}
func (s *passthroughStorageStub) UpdateRequestWithResponse(request *model.RequestLog) error {
s.updatedRequests++
if s.updateRequestFn != nil {
return s.updateRequestFn(request)
}
return nil
}
func (s *passthroughStorageStub) DeleteRequestsOlderThan(time.Duration) (int, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetDatabaseStats() (map[string]interface{}, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetUsageStats(string, string, string, string) (*model.UsageStats, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetRequestsSummary(string) ([]*model.RequestSummary, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetRequestsSummaryPaginated(string, string, string, int, int) ([]*model.RequestSummary, int, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetStats(string, string, string) (*model.DashboardStats, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetHourlyStats(string, string, int, string) (*model.HourlyStatsResponse, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetModelStats(string, string, string) (*model.ModelStatsResponse, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetLatestRequestDate() (*time.Time, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetDistinctOrganizations() ([]string, error) {
panic("unexpected call")
}
func (s *passthroughStorageStub) GetSettings() (*model.ProxySettings, error) {
if s.getSettingsFn != nil {
return s.getSettingsFn()
}
return &model.ProxySettings{}, nil
}
func (s *passthroughStorageStub) SaveSettings(*model.ProxySettings) error { panic("unexpected call") }
func (s *passthroughStorageStub) GetConfig() *config.StorageConfig { return &config.StorageConfig{} }
func (s *passthroughStorageStub) EnsureDirectoryExists() error { return nil }
func (s *passthroughStorageStub) Close() error { return nil }
type passthroughProviderStub struct {
forwardRequestFn func(context.Context, *http.Request) (*http.Response, error)
}
func (p *passthroughProviderStub) Name() string { return "stub" }
func (p *passthroughProviderStub) ForwardRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
if p.forwardRequestFn != nil {
return p.forwardRequestFn(ctx, req)
}
panic("unexpected call")
}
var _ provider.Provider = (*passthroughProviderStub)(nil)
func TestChatCompletionsReturnsHelpfulError(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
rr := httptest.NewRecorder()
(&Handler{}).ChatCompletions(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error == "" {
t.Fatal("expected non-empty error message")
}
}
func TestModelsReturnsEmptyList(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
rr := httptest.NewRecorder()
(&Handler{}).Models(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response model.ModelsResponse
decodeJSONBody(t, rr, &response)
if response.Object != "list" || len(response.Data) != 0 {
t.Fatalf("unexpected models response: %#v", response)
}
}
func TestHealthReturnsHealthyStatus(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
(&Handler{}).Health(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var response model.HealthResponse
decodeJSONBody(t, rr, &response)
if response.Status != "healthy" {
t.Fatalf("unexpected health response: %#v", response)
}
if response.Timestamp.IsZero() {
t.Fatalf("expected non-zero timestamp: %#v", response)
}
}
func TestOpenAPIEndpointsExposeDiscoveryFormats(t *testing.T) {
t.Run("json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/openapi.json", nil)
rr := httptest.NewRecorder()
(&Handler{}).OpenAPIJSON(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("expected json content type, got %q", ct)
}
if rr.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("expected CORS header, got %#v", rr.Header())
}
var payload map[string]interface{}
decodeJSONBody(t, rr, &payload)
if payload["openapi"] == nil || payload["paths"] == nil {
t.Fatalf("unexpected openapi json payload: %#v", payload)
}
})
t.Run("yaml", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil)
rr := httptest.NewRecorder()
(&Handler{}).OpenAPIYAML(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
if ct := rr.Header().Get("Content-Type"); ct != "application/x-yaml" {
t.Fatalf("expected yaml content type, got %q", ct)
}
if body := rr.Body.String(); !bytes.Contains([]byte(body), []byte("openapi:")) {
t.Fatalf("expected yaml body to contain openapi header, got %q", body)
}
})
}
func TestProxyPassthroughForwardsResponseAndPersistsMetadata(t *testing.T) {
storage := &passthroughStorageStub{}
upstreamResponse := `{"ok":true}`
provider := &passthroughProviderStub{
forwardRequestFn: func(ctx context.Context, req *http.Request) (*http.Response, error) {
if req.Header.Get("X-Added") != "from-test" {
t.Fatalf("expected request header rule to be applied, got headers %#v", req.Header)
}
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed reading forwarded request body: %v", err)
}
if string(bodyBytes) != `{"input":"hello"}` {
t.Fatalf("unexpected forwarded body %q", string(bodyBytes))
}
resp := &http.Response{
StatusCode: http.StatusAccepted,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-Upstream": []string{"yes"},
"Connection": []string{"keep-alive"},
"Anthropic-Organization-Id": []string{"org-123"},
},
Body: io.NopCloser(bytes.NewBufferString(upstreamResponse)),
}
return resp, nil
},
}
storage.saveRequestFn = func(request *model.RequestLog) (string, error) {
if request.Method != http.MethodPost || request.Endpoint != "/v1/quota" {
t.Fatalf("unexpected saved request metadata: %#v", request)
}
if request.ContentType != "application/json" {
t.Fatalf("unexpected content type %q", request.ContentType)
}
bodyMap, ok := request.Body.(map[string]interface{})
if !ok || bodyMap["input"] != "hello" {
t.Fatalf("expected parsed request body, got %#v", request.Body)
}
if authValues := request.Headers["Authorization"]; len(authValues) != 1 || authValues[0] == "Bearer secret" || authValues[0] == "" {
t.Fatalf("expected sanitized authorization header, got %#v", request.Headers)
}
return "req-1", nil
}
storage.updateRequestFn = func(request *model.RequestLog) error {
if request.OrganizationID != "org-123" {
t.Fatalf("expected organization id extracted, got %#v", request)
}
if request.Response == nil || request.Response.StatusCode != http.StatusAccepted {
t.Fatalf("expected response metadata recorded, got %#v", request.Response)
}
if string(request.Response.Body) != upstreamResponse {
t.Fatalf("expected json response body stored, got %#v", request.Response)
}
if connectionValues := request.Response.Headers["Connection"]; len(connectionValues) != 0 {
t.Fatalf("expected sanitized stored headers to exclude hop-by-hop data, got %#v", request.Response.Headers)
}
return nil
}
h := &Handler{
storageService: storage,
anthropicProvider: provider,
logger: log.New(io.Discard, "", 0),
cachedSettings: &model.ProxySettings{
RequestHeaderRules: []model.HeaderRule{{Header: "X-Added", Action: "set", Value: "from-test", Enabled: true}},
ResponseHeaderRules: []model.HeaderRule{{Header: "X-Proxy", Action: "set", Value: "applied", Enabled: true}},
},
}
req := httptest.NewRequest(http.MethodPost, "/v1/quota", bytes.NewBufferString(`{"input":"hello"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer secret")
rr := httptest.NewRecorder()
h.ProxyPassthrough(rr, req)
if rr.Code != http.StatusAccepted {
t.Fatalf("expected 202, got %d", rr.Code)
}
if rr.Body.String() != upstreamResponse {
t.Fatalf("unexpected client body %q", rr.Body.String())
}
if rr.Header().Get("X-Upstream") != "yes" {
t.Fatalf("expected upstream header to be forwarded, got %#v", rr.Header())
}
if rr.Header().Get("X-Proxy") != "applied" {
t.Fatalf("expected response header rule to be applied, got %#v", rr.Header())
}
if rr.Header().Get("Connection") != "" {
t.Fatalf("expected hop-by-hop header to be stripped, got %#v", rr.Header())
}
if storage.savedRequests != 1 || storage.updatedRequests != 1 {
t.Fatalf("expected request save/update pair, got saves=%d updates=%d", storage.savedRequests, storage.updatedRequests)
}
}

View file

@ -0,0 +1,209 @@
package handler
import (
"bytes"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
type stubStorage struct {
getSettingsFn func() (*model.ProxySettings, error)
saveSettingsFn func(settings *model.ProxySettings) error
getCalls int
saveCalls int
}
func (s *stubStorage) SaveRequest(*model.RequestLog) (string, error) { panic("unexpected call") }
func (s *stubStorage) GetRequests(int, int, string) ([]model.RequestLog, int, error) {
panic("unexpected call")
}
func (s *stubStorage) GetAllRequests(string) ([]*model.RequestLog, error) { panic("unexpected call") }
func (s *stubStorage) GetRequestByShortID(string) (*model.RequestLog, string, error) {
panic("unexpected call")
}
func (s *stubStorage) ClearRequests() (int, error) { panic("unexpected call") }
func (s *stubStorage) UpdateRequestWithGrading(string, *model.PromptGrade) error {
panic("unexpected call")
}
func (s *stubStorage) UpdateRequestWithResponse(*model.RequestLog) error { panic("unexpected call") }
func (s *stubStorage) DeleteRequestsOlderThan(time.Duration) (int, error) { panic("unexpected call") }
func (s *stubStorage) GetDatabaseStats() (map[string]interface{}, error) { panic("unexpected call") }
func (s *stubStorage) GetUsageStats(string, string, string, string) (*model.UsageStats, error) {
panic("unexpected call")
}
func (s *stubStorage) GetRequestsSummary(string) ([]*model.RequestSummary, error) {
panic("unexpected call")
}
func (s *stubStorage) GetRequestsSummaryPaginated(string, string, string, int, int) ([]*model.RequestSummary, int, error) {
panic("unexpected call")
}
func (s *stubStorage) GetStats(string, string, string) (*model.DashboardStats, error) {
panic("unexpected call")
}
func (s *stubStorage) GetHourlyStats(string, string, int, string) (*model.HourlyStatsResponse, error) {
panic("unexpected call")
}
func (s *stubStorage) GetModelStats(string, string, string) (*model.ModelStatsResponse, error) {
panic("unexpected call")
}
func (s *stubStorage) GetLatestRequestDate() (*time.Time, error) { panic("unexpected call") }
func (s *stubStorage) GetDistinctOrganizations() ([]string, error) { panic("unexpected call") }
func (s *stubStorage) GetSettings() (*model.ProxySettings, error) {
s.getCalls++
if s.getSettingsFn != nil {
return s.getSettingsFn()
}
return &model.ProxySettings{}, nil
}
func (s *stubStorage) SaveSettings(settings *model.ProxySettings) error {
s.saveCalls++
if s.saveSettingsFn != nil {
return s.saveSettingsFn(settings)
}
return nil
}
func (s *stubStorage) GetConfig() *config.StorageConfig { return &config.StorageConfig{} }
func (s *stubStorage) EnsureDirectoryExists() error { return nil }
func (s *stubStorage) Close() error { return nil }
func TestGetCachedSettingsCachesFirstLoad(t *testing.T) {
storage := &stubStorage{
getSettingsFn: func() (*model.ProxySettings, error) {
return &model.ProxySettings{
RequestHeaderRules: []model.HeaderRule{{Header: "X-Test", Action: "set", Value: "1", Enabled: true}},
}, nil
},
}
h := &Handler{
storageService: storage,
logger: log.New(io.Discard, "", 0),
}
first := h.GetCachedSettings()
second := h.GetCachedSettings()
if storage.getCalls != 1 {
t.Fatalf("expected one storage read, got %d", storage.getCalls)
}
if len(first.RequestHeaderRules) != 1 || len(second.RequestHeaderRules) != 1 {
t.Fatalf("expected cached settings to be returned, got %#v %#v", first, second)
}
}
func TestSaveSettingsUpdatesCache(t *testing.T) {
storage := &stubStorage{}
h := &Handler{
storageService: storage,
logger: log.New(io.Discard, "", 0),
}
body := []byte(`{"requestHeaderRules":[{"header":"X-New","action":"set","value":"abc","enabled":true}]}`)
req := httptest.NewRequest(http.MethodPut, "/api/settings", bytes.NewReader(body))
rr := httptest.NewRecorder()
h.SaveSettings(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
if storage.saveCalls != 1 {
t.Fatalf("expected one settings save, got %d", storage.saveCalls)
}
cached := h.GetCachedSettings()
if storage.getCalls != 0 {
t.Fatalf("expected cached settings to avoid storage read, got %d reads", storage.getCalls)
}
if len(cached.RequestHeaderRules) != 1 || cached.RequestHeaderRules[0].Header != "X-New" {
t.Fatalf("expected cache updated from save, got %#v", cached)
}
}
func TestGetSettingsReturnsStorageError(t *testing.T) {
storage := &stubStorage{
getSettingsFn: func() (*model.ProxySettings, error) {
return nil, errors.New("boom")
},
}
h := &Handler{
storageService: storage,
logger: log.New(io.Discard, "", 0),
}
req := httptest.NewRequest(http.MethodGet, "/api/settings", nil)
rr := httptest.NewRecorder()
h.GetSettings(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rr.Code)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error != "Failed to get settings" {
t.Fatalf("unexpected error response: %#v", response)
}
}
func TestSaveSettingsRejectsInvalidBodyAndStorageFailures(t *testing.T) {
t.Run("invalid json", func(t *testing.T) {
h := &Handler{
storageService: &stubStorage{},
logger: log.New(io.Discard, "", 0),
}
req := httptest.NewRequest(http.MethodPut, "/api/settings", bytes.NewBufferString(`{"requestHeaderRules":`))
rr := httptest.NewRecorder()
h.SaveSettings(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error != "Invalid request body" {
t.Fatalf("unexpected error response: %#v", response)
}
})
t.Run("storage error", func(t *testing.T) {
storage := &stubStorage{
saveSettingsFn: func(settings *model.ProxySettings) error {
return errors.New("boom")
},
}
h := &Handler{
storageService: storage,
logger: log.New(io.Discard, "", 0),
}
req := httptest.NewRequest(http.MethodPut, "/api/settings", bytes.NewBufferString(`{"responseHeaderRules":[{"header":"X-Test","action":"set","value":"1","enabled":true}]}`))
rr := httptest.NewRecorder()
h.SaveSettings(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rr.Code)
}
if storage.saveCalls != 1 {
t.Fatalf("expected one settings save attempt, got %d", storage.saveCalls)
}
var response model.ErrorResponse
decodeJSONBody(t, rr, &response)
if response.Error != "Failed to save settings" {
t.Fatalf("unexpected error response: %#v", response)
}
})
}

View file

@ -0,0 +1,900 @@
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
}
}

View file

@ -2,55 +2,15 @@ package handler
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time"
"github.com/seifghazi/claude-code-monitor/internal/model" "github.com/seifghazi/claude-code-monitor/internal/model"
) )
// Headers that should be forwarded from upstream responses to clients var hopByHopHeaders = map[string]bool{
var forwardableResponseHeaders = []string{
// Anthropic rate limit headers
"anthropic-ratelimit-requests-limit",
"anthropic-ratelimit-requests-remaining",
"anthropic-ratelimit-requests-reset",
"anthropic-ratelimit-tokens-limit",
"anthropic-ratelimit-tokens-remaining",
"anthropic-ratelimit-tokens-reset",
// Standard rate limit headers
"x-ratelimit-limit",
"x-ratelimit-remaining",
"x-ratelimit-reset",
"retry-after",
// Request tracking
"x-request-id",
"request-id",
// Anthropic specific
"anthropic-organization-id",
// OpenAI specific
"openai-organization",
"openai-processing-ms",
"openai-version",
"x-request-id",
}
// ForwardResponseHeaders copies important headers from upstream response to client response
func ForwardResponseHeaders(w http.ResponseWriter, resp *http.Response) {
for _, header := range forwardableResponseHeaders {
if values := resp.Header.Values(header); len(values) > 0 {
for _, value := range values {
w.Header().Add(header, value)
}
}
}
}
// CopyAllResponseHeaders copies all non-hop-by-hop headers from upstream to client
func CopyAllResponseHeaders(w http.ResponseWriter, resp *http.Response) {
hopByHopHeaders := map[string]bool{
"connection": true, "connection": true,
"keep-alive": true, "keep-alive": true,
"proxy-authenticate": true, "proxy-authenticate": true,
@ -63,6 +23,39 @@ func CopyAllResponseHeaders(w http.ResponseWriter, resp *http.Response) {
"content-length": true, // May change after decompression "content-length": true, // May change after decompression
} }
// ApplyHeaderRules applies block/set/replace rules to an http.Header in-place.
func ApplyHeaderRules(headers http.Header, rules []model.HeaderRule) {
for _, rule := range rules {
if !rule.Enabled {
continue
}
key := http.CanonicalHeaderKey(rule.Header)
switch rule.Action {
case "block":
headers.Del(key)
case "set":
headers.Set(key, rule.Value)
case "replace":
if rule.Find == "" {
continue
}
for i, v := range headers.Values(key) {
if strings.Contains(v, rule.Find) {
replaced := strings.ReplaceAll(v, rule.Find, rule.Value)
if i == 0 {
headers.Set(key, replaced)
} else {
headers.Add(key, replaced)
}
}
}
}
}
}
// CopyAllResponseHeaders forwards all upstream response headers to the client,
// stripping only hop-by-hop headers that must not be forwarded by a proxy.
func CopyAllResponseHeaders(w http.ResponseWriter, resp *http.Response) {
for key, values := range resp.Header { for key, values := range resp.Header {
if hopByHopHeaders[strings.ToLower(key)] { if hopByHopHeaders[strings.ToLower(key)] {
continue continue
@ -73,6 +66,115 @@ func CopyAllResponseHeaders(w http.ResponseWriter, resp *http.Response) {
} }
} }
// SanitizeResponseHeaders strips hop-by-hop proxy headers before applying the
// generic sensitive-header sanitization used for stored metadata.
func SanitizeResponseHeaders(headers http.Header) http.Header {
filtered := make(http.Header)
for key, values := range headers {
if hopByHopHeaders[strings.ToLower(key)] {
continue
}
copiedValues := append([]string(nil), values...)
filtered[key] = copiedValues
}
return SanitizeHeaders(filtered)
}
// ExtractRateLimitInfo parses rate limit headers from the upstream response
func ExtractRateLimitInfo(headers http.Header) *model.RateLimitInfo {
info := &model.RateLimitInfo{}
found := false
// Organization ID
if v := headers.Get("anthropic-organization-id"); v != "" {
info.OrganizationID = v
found = true
}
// Unified quota system (current Anthropic model)
if v := headers.Get("anthropic-ratelimit-unified-status"); v != "" {
info.UnifiedStatus = v
found = true
}
if v := headers.Get("anthropic-ratelimit-unified-5h-utilization"); v != "" {
info.UnifiedUtilization5h, _ = strconv.ParseFloat(v, 64)
found = true
}
if v := headers.Get("anthropic-ratelimit-unified-5h-reset"); v != "" {
info.UnifiedReset5h = v
found = true
}
if v := headers.Get("anthropic-ratelimit-unified-7d-utilization"); v != "" {
info.UnifiedUtilization7d, _ = strconv.ParseFloat(v, 64)
found = true
}
if v := headers.Get("anthropic-ratelimit-unified-7d-reset"); v != "" {
info.UnifiedReset7d = v
found = true
}
if v := headers.Get("anthropic-ratelimit-unified-fallback-percentage"); v != "" {
info.UnifiedFallbackPercentage, _ = strconv.ParseFloat(v, 64)
found = true
}
if v := headers.Get("anthropic-ratelimit-unified-overage-status"); v != "" {
info.UnifiedOverageStatus = v
found = true
}
if v := headers.Get("anthropic-ratelimit-unified-representative-claim"); v != "" {
info.UnifiedRepresentativeClaim = v
found = true
}
// Legacy per-resource rate limits
if v := headers.Get("anthropic-ratelimit-requests-limit"); v != "" {
info.RequestsLimit, _ = strconv.Atoi(v)
found = true
}
if v := headers.Get("anthropic-ratelimit-requests-remaining"); v != "" {
info.RequestsRemaining, _ = strconv.Atoi(v)
found = true
}
if v := headers.Get("anthropic-ratelimit-requests-reset"); v != "" {
info.RequestsReset = v
found = true
}
if v := headers.Get("anthropic-ratelimit-tokens-limit"); v != "" {
info.TokensLimit, _ = strconv.Atoi(v)
found = true
}
if v := headers.Get("anthropic-ratelimit-tokens-remaining"); v != "" {
info.TokensRemaining, _ = strconv.Atoi(v)
found = true
}
if v := headers.Get("anthropic-ratelimit-tokens-reset"); v != "" {
info.TokensReset = v
found = true
}
// Fall back to standard rate limit headers
if !found {
if v := headers.Get("x-ratelimit-limit"); v != "" {
info.RequestsLimit, _ = strconv.Atoi(v)
found = true
}
if v := headers.Get("x-ratelimit-remaining"); v != "" {
info.RequestsRemaining, _ = strconv.Atoi(v)
found = true
}
if v := headers.Get("x-ratelimit-reset"); v != "" {
info.RequestsReset = v
found = true
}
}
if !found {
return nil
}
return info
}
// SanitizeHeaders removes sensitive headers before logging/storage // SanitizeHeaders removes sensitive headers before logging/storage
func SanitizeHeaders(headers http.Header) http.Header { func SanitizeHeaders(headers http.Header) http.Header {
sanitized := make(http.Header) sanitized := make(http.Header)
@ -112,222 +214,3 @@ func SanitizeHeaders(headers http.Header) http.Header {
return sanitized return sanitized
} }
// ConversationDiffAnalyzer analyzes conversation flows to identify new vs repeated content
type ConversationDiffAnalyzer struct{}
// NewConversationDiffAnalyzer creates a new conversation diff analyzer
func NewConversationDiffAnalyzer() *ConversationDiffAnalyzer {
return &ConversationDiffAnalyzer{}
}
// ConversationFlowData represents the flow analysis of a conversation
type ConversationFlowData struct {
TotalMessages int `json:"totalMessages"`
NewMessages []int `json:"newMessages"` // Indices of new messages
DuplicateMessages []int `json:"duplicateMessages"` // Indices of duplicate messages
MessageHashes []string `json:"messageHashes"` // Content hashes for deduplication
ConversationHash string `json:"conversationHash"` // Hash of entire conversation
PreviousHash string `json:"previousHash"` // Hash of previous conversation state
Changes []ConversationChange `json:"changes"` // Detailed changes
FlowMetadata map[string]interface{} `json:"flowMetadata"` // Additional metadata
}
// ConversationChange represents a specific change in the conversation
type ConversationChange struct {
Type string `json:"type"` // "added", "modified", "context"
MessageIdx int `json:"messageIdx"` // Index of the message
Role string `json:"role"` // Role of the message
ContentHash string `json:"contentHash"` // Hash of the content
Preview string `json:"preview"` // Short preview of content
Timestamp string `json:"timestamp"` // When this change was detected
}
// AnalyzeConversationFlow analyzes a conversation to identify what's new vs repeated
func (c *ConversationDiffAnalyzer) AnalyzeConversationFlow(messages []model.AnthropicMessage, previousConversation []model.AnthropicMessage) *ConversationFlowData {
totalMessages := len(messages)
// Create hashes for current conversation
currentHashes := make([]string, totalMessages)
for i, msg := range messages {
currentHashes[i] = c.hashMessage(msg)
}
// Create hashes for previous conversation (if any)
var previousHashes []string
if previousConversation != nil {
previousHashes = make([]string, len(previousConversation))
for i, msg := range previousConversation {
previousHashes[i] = c.hashMessage(msg)
}
}
// Identify new vs duplicate messages
newMessages := []int{}
duplicateMessages := []int{}
changes := []ConversationChange{}
// Simple approach: messages that appear after the previous conversation length are new
previousLength := len(previousHashes)
for i, msg := range messages {
isNew := i >= previousLength
// More sophisticated check: compare hashes
if !isNew && i < len(previousHashes) {
isNew = currentHashes[i] != previousHashes[i]
}
if isNew {
newMessages = append(newMessages, i)
changes = append(changes, ConversationChange{
Type: "added",
MessageIdx: i,
Role: msg.Role,
ContentHash: currentHashes[i],
Preview: c.getMessagePreview(msg),
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
})
} else {
duplicateMessages = append(duplicateMessages, i)
changes = append(changes, ConversationChange{
Type: "context",
MessageIdx: i,
Role: msg.Role,
ContentHash: currentHashes[i],
Preview: c.getMessagePreview(msg),
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
})
}
}
// If no previous conversation, consider a reasonable threshold of "new" vs "context"
if previousConversation == nil && totalMessages > 1 {
// Heuristic: last 30% of messages are "new", rest is context
newThreshold := max(1, int(float64(totalMessages)*0.3))
contextEnd := totalMessages - newThreshold
newMessages = []int{}
duplicateMessages = []int{}
changes = []ConversationChange{}
for i := 0; i < totalMessages; i++ {
if i >= contextEnd {
newMessages = append(newMessages, i)
changes = append(changes, ConversationChange{
Type: "added",
MessageIdx: i,
Role: messages[i].Role,
ContentHash: currentHashes[i],
Preview: c.getMessagePreview(messages[i]),
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
})
} else {
duplicateMessages = append(duplicateMessages, i)
changes = append(changes, ConversationChange{
Type: "context",
MessageIdx: i,
Role: messages[i].Role,
ContentHash: currentHashes[i],
Preview: c.getMessagePreview(messages[i]),
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
})
}
}
}
// Generate conversation hashes
conversationHash := c.hashConversation(messages)
previousHash := ""
if previousConversation != nil {
previousHash = c.hashConversation(previousConversation)
}
return &ConversationFlowData{
TotalMessages: totalMessages,
NewMessages: newMessages,
DuplicateMessages: duplicateMessages,
MessageHashes: currentHashes,
ConversationHash: conversationHash,
PreviousHash: previousHash,
Changes: changes,
FlowMetadata: map[string]interface{}{
"newCount": len(newMessages),
"duplicateCount": len(duplicateMessages),
"analyzeTime": time.Now().Format(time.RFC3339),
},
}
}
// hashMessage creates a hash of a message for deduplication
func (c *ConversationDiffAnalyzer) hashMessage(msg model.AnthropicMessage) string {
// Create a stable representation of the message
content := c.normalizeMessageContent(msg.Content)
data := fmt.Sprintf("%s|%s", msg.Role, content)
hash := sha256.Sum256([]byte(data))
return fmt.Sprintf("%x", hash[:8]) // Use first 8 bytes for shorter hash
}
// hashConversation creates a hash of the entire conversation
func (c *ConversationDiffAnalyzer) hashConversation(messages []model.AnthropicMessage) string {
var parts []string
for _, msg := range messages {
parts = append(parts, c.hashMessage(msg))
}
conversationData := strings.Join(parts, "|")
hash := sha256.Sum256([]byte(conversationData))
return fmt.Sprintf("%x", hash[:16]) // Use first 16 bytes for conversation hash
}
// normalizeMessageContent converts message content to a normalized string
func (c *ConversationDiffAnalyzer) normalizeMessageContent(content interface{}) string {
switch v := content.(type) {
case string:
return strings.TrimSpace(v)
case []interface{}:
var parts []string
for _, item := range v {
if block, ok := item.(map[string]interface{}); ok {
if text, hasText := block["text"].(string); hasText {
parts = append(parts, strings.TrimSpace(text))
} else if blockType, hasType := block["type"].(string); hasType {
// Handle different content types (tool_use, etc.)
switch blockType {
case "tool_use":
if name, hasName := block["name"].(string); hasName {
parts = append(parts, fmt.Sprintf("TOOL:%s", name))
}
case "tool_result":
parts = append(parts, "TOOL_RESULT")
default:
parts = append(parts, fmt.Sprintf("CONTENT:%s", blockType))
}
}
}
}
return strings.Join(parts, " ")
default:
// Convert to JSON and back for normalization
jsonBytes, _ := json.Marshal(content)
return string(jsonBytes)
}
}
// getMessagePreview creates a short preview of a message
func (c *ConversationDiffAnalyzer) getMessagePreview(msg model.AnthropicMessage) string {
content := c.normalizeMessageContent(msg.Content)
if len(content) > 100 {
return content[:100] + "..."
}
return content
}
// max returns the maximum of two integers
func max(a, b int) int {
if a > b {
return a
}
return b
}

View file

@ -9,10 +9,16 @@ import (
"github.com/seifghazi/claude-code-monitor/internal/config" "github.com/seifghazi/claude-code-monitor/internal/config"
) )
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func Auth(cfg config.AuthConfig) func(http.Handler) http.Handler { func Auth(cfg config.AuthConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions || r.URL.Path == "/health" { if r.Method == http.MethodOptions || isPublicBypassPath(r.URL.Path) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@ -33,15 +39,22 @@ func Auth(cfg config.AuthConfig) func(http.Handler) http.Handler {
} }
w.Header().Set("WWW-Authenticate", `Bearer realm="claude-code-proxy"`) w.Header().Set("WWW-Authenticate", `Bearer realm="claude-code-proxy"`)
w.Header().Set("Content-Type", "application/json") writeJSON(w, http.StatusUnauthorized, map[string]string{
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "unauthorized", "error": "unauthorized",
}) })
}) })
} }
} }
func isPublicBypassPath(path string) bool {
switch path {
case "/health", "/livez", "/openapi.json", "/openapi.yaml":
return true
default:
return false
}
}
func extractAuthToken(r *http.Request, cfg config.AuthConfig) (string, bool) { func extractAuthToken(r *http.Request, cfg config.AuthConfig) (string, bool) {
authHeader := strings.TrimSpace(r.Header.Get("Authorization")) authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if authHeader != "" { if authHeader != "" {

View file

@ -102,7 +102,7 @@ func TestAuthAcceptsBearerAndAPIKey(t *testing.T) {
} }
} }
func TestAuthSkipsHealthAndOptions(t *testing.T) { func TestAuthSkipsPublicDiscoveryRoutesAndOptions(t *testing.T) {
handler := Auth(config.AuthConfig{ handler := Auth(config.AuthConfig{
Enabled: true, Enabled: true,
Token: "secret", Token: "secret",
@ -110,15 +110,18 @@ func TestAuthSkipsHealthAndOptions(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
req := httptest.NewRequest(http.MethodGet, "http://example.local/health", nil) publicPaths := []string{"/health", "/openapi.json", "/openapi.yaml"}
for _, path := range publicPaths {
req := httptest.NewRequest(http.MethodGet, "http://example.local"+path, nil)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("expected health request to bypass auth, got %d", rr.Code) t.Fatalf("expected %s request to bypass auth, got %d", path, rr.Code)
}
} }
req = httptest.NewRequest(http.MethodOptions, "http://example.local/v1/messages", nil) req := httptest.NewRequest(http.MethodOptions, "http://example.local/v1/messages", nil)
rr = httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("expected OPTIONS request to bypass auth, got %d", rr.Code) t.Fatalf("expected OPTIONS request to bypass auth, got %d", rr.Code)

View file

@ -0,0 +1,33 @@
package middleware
import (
"crypto/subtle"
"net/http"
)
// DashboardAuth returns middleware that protects dashboard/data routes with
// HTTP Basic Auth. If password is empty, the middleware is a no-op (disabled).
// The username is always "admin".
func DashboardAuth(password string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if password == "" {
next.ServeHTTP(w, r)
return
}
user, pass, ok := r.BasicAuth()
if !ok ||
subtle.ConstantTimeCompare([]byte(user), []byte("admin")) != 1 ||
subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="Claude Code Proxy"`)
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "unauthorized",
})
return
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -0,0 +1,41 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestDashboardAuthSetsWWWAuthenticateHeader(t *testing.T) {
handler := DashboardAuth("secret")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Header().Get("WWW-Authenticate"); got != `Basic realm="Claude Code Proxy"` {
t.Fatalf("expected basic auth challenge header, got %q", got)
}
}
func TestDashboardAuthRejectsWrongUsername(t *testing.T) {
called := false
handler := DashboardAuth("secret")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
req.SetBasicAuth("not-admin", "secret")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if called {
t.Fatal("expected handler not to be called with wrong username")
}
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}

View file

@ -0,0 +1,105 @@
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestDashboardAuthDisabledWhenEmpty(t *testing.T) {
called := false
handler := DashboardAuth("")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if !called {
t.Fatal("expected handler to be called when password is empty")
}
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
}
func TestDashboardAuthRejectsNoCredentials(t *testing.T) {
handler := DashboardAuth("secret")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
// Verify JSON response body
var body map[string]string
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
t.Fatalf("expected JSON response, got error: %v", err)
}
if body["error"] != "unauthorized" {
t.Fatalf("expected error=unauthorized, got %q", body["error"])
}
}
func TestDashboardAuthRejectsWrongPassword(t *testing.T) {
handler := DashboardAuth("secret")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
req.SetBasicAuth("admin", "wrong")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestDashboardAuthAcceptsValidCredentials(t *testing.T) {
called := false
handler := DashboardAuth("secret")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
req.SetBasicAuth("admin", "secret")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if !called {
t.Fatal("expected handler to be called with valid credentials")
}
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
}
func TestWriteJSONSetsContentType(t *testing.T) {
rr := httptest.NewRecorder()
writeJSON(rr, http.StatusForbidden, map[string]string{"error": "forbidden"})
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("expected Content-Type application/json, got %q", ct)
}
if rr.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d", rr.Code)
}
var body map[string]string
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
t.Fatalf("expected valid JSON, got error: %v", err)
}
if body["error"] != "forbidden" {
t.Fatalf("expected error=forbidden, got %q", body["error"])
}
}

View file

@ -0,0 +1,22 @@
package middleware
import (
"net/http"
"github.com/seifghazi/claude-code-monitor/internal/runtime"
)
// InFlight tracks the number of requests currently being served by the
// wrapped handler. Apply it to the /v1/* subrouter only — the gauge is
// meant to drive deploy-time draining decisions and shouldn't be polluted
// by fast dashboard or health-probe traffic.
//
// The decrement runs in a defer so a panicking handler can't strand the
// counter.
func InFlight(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
runtime.IncInFlight()
defer runtime.DecInFlight()
next.ServeHTTP(w, r)
})
}

View file

@ -3,10 +3,12 @@ package middleware
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/seifghazi/claude-code-monitor/internal/model" "github.com/seifghazi/claude-code-monitor/internal/model"
@ -40,17 +42,174 @@ func Logging(next http.Handler) http.Handler {
duration := time.Since(start) duration := time.Since(start)
statusColor := getStatusColor(wrapped.statusCode) statusColor := getStatusColor(wrapped.statusCode)
log.Printf("%s %s %s%d%s %s (%s)", // Build a richer log line for proxy requests
if isProxyRequest(r.URL.Path) {
details := buildProxyLogDetails(r, bodyBytes, wrapped, duration)
log.Printf("%s%s%s %s",
statusColor, details, colorReset,
colorDim+formatDuration(duration)+colorReset)
} else {
log.Printf("%s %s %s%d%s (%s)",
r.Method, r.Method,
r.URL.Path, r.URL.Path,
statusColor, statusColor,
wrapped.statusCode, wrapped.statusCode,
colorReset, colorReset,
http.StatusText(wrapped.statusCode),
formatDuration(duration)) formatDuration(duration))
}
}) })
} }
// isProxyRequest returns true for /v1/* API proxy paths
func isProxyRequest(path string) bool {
return strings.HasPrefix(path, "/v1/")
}
// buildProxyLogDetails creates a rich single-line log for proxy requests
func buildProxyLogDetails(r *http.Request, bodyBytes []byte, w *responseWriter, duration time.Duration) string {
var parts []string
// Status
parts = append(parts, fmt.Sprintf("%d", w.statusCode))
// Method + path
parts = append(parts, fmt.Sprintf("%s %s", r.Method, r.URL.Path))
// Extract model and stream flag from request body
if len(bodyBytes) > 0 {
var body struct {
Model string `json:"model"`
Stream bool `json:"stream"`
}
if err := json.Unmarshal(bodyBytes, &body); err == nil {
if body.Model != "" {
// Shorten model name for readability
modelShort := shortenModel(body.Model)
parts = append(parts, colorCyan+modelShort+colorReset)
}
if body.Stream {
parts = append(parts, "stream")
}
}
}
// Client info — use X-Forwarded-For if behind proxy, else RemoteAddr
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.Header.Get("X-Real-Ip")
}
if clientIP == "" {
clientIP = r.RemoteAddr
}
// Strip port from IP
if host, _, err := splitHostPort(clientIP); err == nil {
clientIP = host
}
// User-Agent — extract just the tool/client name
ua := r.Header.Get("User-Agent")
if clientName := extractClientName(ua); clientName != "" {
parts = append(parts, colorDim+clientName+colorReset)
}
if clientIP != "" {
parts = append(parts, colorDim+clientIP+colorReset)
}
return strings.Join(parts, " ")
}
// shortenModel turns "claude-sonnet-4-20250514" into "sonnet-4"
func shortenModel(model string) string {
lower := strings.ToLower(model)
for _, family := range []string{"opus", "sonnet", "haiku"} {
if strings.Contains(lower, family) {
// Find version number after family name
idx := strings.Index(lower, family)
rest := lower[idx+len(family):]
// Extract version like "-4" or "-4-20250514"
rest = strings.TrimLeft(rest, "-")
if dashIdx := strings.Index(rest, "-"); dashIdx > 0 {
// Keep just the version number (e.g. "4" from "4-20250514")
return family + "-" + rest[:dashIdx]
}
if rest != "" {
return family + "-" + rest
}
return family
}
}
// For non-Claude models, take first two segments
segs := strings.SplitN(model, "-", 3)
if len(segs) >= 2 {
return segs[0] + "-" + segs[1]
}
return model
}
// extractClientName pulls a recognizable client name from User-Agent
func extractClientName(ua string) string {
if ua == "" {
return ""
}
lower := strings.ToLower(ua)
switch {
case strings.Contains(lower, "claude-code"):
return "claude-code"
case strings.Contains(lower, "cursor"):
return "cursor"
case strings.Contains(lower, "continue"):
return "continue"
case strings.Contains(lower, "anthropic-sdk"):
return "sdk"
case strings.Contains(lower, "python"):
return "python"
case strings.Contains(lower, "node"):
return "node"
case strings.Contains(lower, "curl"):
return "curl"
default:
// Take first token
if spaceIdx := strings.IndexByte(ua, ' '); spaceIdx > 0 {
first := ua[:spaceIdx]
if len(first) > 20 {
return first[:20]
}
return first
}
if len(ua) > 20 {
return ua[:20]
}
return ua
}
}
// splitHostPort is a simple wrapper that handles IPs without ports
func splitHostPort(addr string) (string, string, error) {
// If there's no colon or it's an IPv6 without port, just return the addr
if !strings.Contains(addr, ":") {
return addr, "", nil
}
// Handle comma-separated X-Forwarded-For
if commaIdx := strings.IndexByte(addr, ','); commaIdx > 0 {
addr = strings.TrimSpace(addr[:commaIdx])
}
host, port, err := splitAddr(addr)
return host, port, err
}
func splitAddr(addr string) (string, string, error) {
if strings.Count(addr, ":") > 1 {
// IPv6 — may or may not have brackets
return addr, "", nil
}
idx := strings.LastIndexByte(addr, ':')
if idx < 0 {
return addr, "", nil
}
return addr[:idx], addr[idx+1:], nil
}
type responseWriter struct { type responseWriter struct {
http.ResponseWriter http.ResponseWriter
statusCode int statusCode int
@ -61,6 +220,16 @@ func (rw *responseWriter) WriteHeader(code int) {
rw.ResponseWriter.WriteHeader(code) rw.ResponseWriter.WriteHeader(code)
} }
// Flush propagates to the underlying http.Flusher. Without this, embedding
// http.ResponseWriter (an interface) silently drops Flush(), so SSE writes
// buffer in net/http until the body closes — breaking token-by-token
// streaming UX.
func (rw *responseWriter) Flush() {
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// ANSI color codes // ANSI color codes
const ( const (
colorReset = "\033[0m" colorReset = "\033[0m"
@ -69,6 +238,7 @@ const (
colorRed = "\033[31m" colorRed = "\033[31m"
colorBlue = "\033[34m" colorBlue = "\033[34m"
colorCyan = "\033[36m" colorCyan = "\033[36m"
colorDim = "\033[2m"
) )
func getStatusColor(status int) string { func getStatusColor(status int) string {

View file

@ -9,6 +9,22 @@ type ContextKey string
const BodyBytesKey ContextKey = "bodyBytes" const BodyBytesKey ContextKey = "bodyBytes"
// ProxySettings holds dynamic proxy configuration (persisted in DB)
type ProxySettings struct {
RequestHeaderRules []HeaderRule `json:"requestHeaderRules"`
ResponseHeaderRules []HeaderRule `json:"responseHeaderRules"`
}
// HeaderRule defines an action to take on a specific header.
// Actions: "block" (remove), "set" (override value), "replace" (find & replace in value)
type HeaderRule struct {
Header string `json:"header"` // Header name (case-insensitive match)
Action string `json:"action"` // "block", "set", "replace"
Value string `json:"value,omitempty"` // For "set": the new value. For "replace": the replacement string.
Find string `json:"find,omitempty"` // For "replace": the string to find in the header value
Enabled bool `json:"enabled"` // Toggle without deleting
}
type PromptGrade struct { type PromptGrade struct {
Score int `json:"score"` Score int `json:"score"`
MaxScore int `json:"maxScore"` MaxScore int `json:"maxScore"`
@ -38,6 +54,9 @@ type RequestLog struct {
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
PromptGrade *PromptGrade `json:"promptGrade,omitempty"` PromptGrade *PromptGrade `json:"promptGrade,omitempty"`
Response *ResponseLog `json:"response,omitempty"` Response *ResponseLog `json:"response,omitempty"`
ConversationHash string `json:"conversationHash,omitempty"`
MessageCount int `json:"messageCount,omitempty"`
OrganizationID string `json:"organizationId,omitempty"`
} }
type ResponseLog struct { type ResponseLog struct {
@ -48,8 +67,42 @@ type ResponseLog struct {
StreamError string `json:"streamError,omitempty"` StreamError string `json:"streamError,omitempty"`
ResponseTime int64 `json:"responseTime"` ResponseTime int64 `json:"responseTime"`
StreamingChunks []string `json:"streamingChunks,omitempty"` StreamingChunks []string `json:"streamingChunks,omitempty"`
ChunkTimings []ChunkTiming `json:"chunkTimings,omitempty"`
IsStreaming bool `json:"isStreaming"` IsStreaming bool `json:"isStreaming"`
CompletedAt string `json:"completedAt"` CompletedAt string `json:"completedAt"`
RateLimit *RateLimitInfo `json:"rateLimit,omitempty"`
}
// ChunkTiming records when each SSE chunk arrived during streaming
type ChunkTiming struct {
Index int `json:"index"`
Timestamp string `json:"timestamp"`
ByteSize int `json:"byteSize"`
ElapsedMs int64 `json:"elapsedMs"`
}
// RateLimitInfo captures rate limit / quota data from upstream response headers
type RateLimitInfo struct {
// Organization
OrganizationID string `json:"organizationId,omitempty"`
// Legacy per-resource rate limits
RequestsLimit int `json:"requestsLimit,omitempty"`
RequestsRemaining int `json:"requestsRemaining,omitempty"`
RequestsReset string `json:"requestsReset,omitempty"`
TokensLimit int `json:"tokensLimit,omitempty"`
TokensRemaining int `json:"tokensRemaining,omitempty"`
TokensReset string `json:"tokensReset,omitempty"`
// Unified quota system (Anthropic's current model)
UnifiedStatus string `json:"unifiedStatus,omitempty"`
UnifiedUtilization5h float64 `json:"unifiedUtilization5h,omitempty"`
UnifiedReset5h string `json:"unifiedReset5h,omitempty"`
UnifiedUtilization7d float64 `json:"unifiedUtilization7d,omitempty"`
UnifiedReset7d string `json:"unifiedReset7d,omitempty"`
UnifiedFallbackPercentage float64 `json:"unifiedFallbackPercentage,omitempty"`
UnifiedOverageStatus string `json:"unifiedOverageStatus,omitempty"`
UnifiedRepresentativeClaim string `json:"unifiedRepresentativeClaim,omitempty"`
} }
type ChatMessage struct { type ChatMessage struct {
@ -132,7 +185,7 @@ type Tool struct {
} }
type InputSchema struct { type InputSchema struct {
Type string `json:"type"` Type interface{} `json:"type"`
Properties map[string]interface{} `json:"properties"` Properties map[string]interface{} `json:"properties"`
Required []string `json:"required,omitempty"` Required []string `json:"required,omitempty"`
} }
@ -176,6 +229,85 @@ type ErrorResponse struct {
Details string `json:"details,omitempty"` Details string `json:"details,omitempty"`
} }
// UsageStats represents aggregated token usage statistics
type UsageStats struct {
TotalRequests int `json:"total_requests"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheTokens int64 `json:"total_cache_tokens"`
RequestsByModel map[string]ModelStats `json:"requests_by_model"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
}
// ModelStats represents per-model usage statistics
type ModelStats struct {
RequestCount int `json:"request_count"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheTokens int64 `json:"cache_tokens"`
}
// RequestSummary is a lightweight version of RequestLog for fast list views
type RequestSummary struct {
RequestID string `json:"requestId"`
Timestamp string `json:"timestamp"`
Method string `json:"method"`
Endpoint string `json:"endpoint"`
Model string `json:"model,omitempty"`
OriginalModel string `json:"originalModel,omitempty"`
RoutedModel string `json:"routedModel,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
ResponseTime int64 `json:"responseTime,omitempty"`
Usage *AnthropicUsage `json:"usage,omitempty"`
ConversationHash string `json:"conversationHash,omitempty"`
MessageCount int `json:"messageCount,omitempty"`
StopReason string `json:"stopReason,omitempty"`
}
// Dashboard stats structures
type DashboardStats struct {
DailyStats []DailyTokens `json:"dailyStats"`
}
type HourlyStatsResponse struct {
HourlyStats []HourlyTokens `json:"hourlyStats"`
TodayTokens int64 `json:"todayTokens"`
TodayRequests int `json:"todayRequests"`
AvgResponseTime int64 `json:"avgResponseTime"`
}
type ModelStatsResponse struct {
ModelStats []ModelTokens `json:"modelStats"`
}
type DailyTokens struct {
Date string `json:"date"`
Tokens int64 `json:"tokens"`
Requests int `json:"requests"`
Models map[string]DailyModelStat `json:"models,omitempty"`
}
type HourlyTokens struct {
Hour int `json:"hour"`
Label string `json:"label,omitempty"`
Tokens int64 `json:"tokens"`
Requests int `json:"requests"`
Models map[string]DailyModelStat `json:"models,omitempty"`
}
// DailyModelStat represents per-model stats for dashboard aggregation
type DailyModelStat struct {
Tokens int64 `json:"tokens"`
Requests int `json:"requests"`
}
type ModelTokens struct {
Model string `json:"model"`
Tokens int64 `json:"tokens"`
Requests int `json:"requests"`
}
type StreamingEvent struct { type StreamingEvent struct {
Type string `json:"type"` Type string `json:"type"`
Index *int `json:"index,omitempty"` Index *int `json:"index,omitempty"`

View file

@ -5,6 +5,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
@ -20,9 +21,29 @@ type AnthropicProvider struct {
} }
func NewAnthropicProvider(cfg *config.AnthropicProviderConfig) Provider { func NewAnthropicProvider(cfg *config.AnthropicProviderConfig) Provider {
respHeaderTimeout := cfg.ResponseHeaderTimeout
if respHeaderTimeout <= 0 {
respHeaderTimeout = 300 * time.Second
}
return &AnthropicProvider{ return &AnthropicProvider{
client: &http.Client{ client: &http.Client{
Timeout: 300 * time.Second, // 5 minutes timeout // No Client.Timeout: a global timeout would cancel long streaming
// responses mid-flight. Per-phase timeouts on the Transport plus the
// 30-min context in handlers.Messages bound the request instead.
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
// Tunable via ANTHROPIC_RESPONSE_HEADER_TIMEOUT — opus + extended
// thinking on large contexts can take longer than the 300s default.
ResponseHeaderTimeout: respHeaderTimeout,
ExpectContinueTimeout: 1 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}, },
config: cfg, config: cfg,
} }

View file

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -25,7 +26,26 @@ type OpenAIProvider struct {
func NewOpenAIProvider(cfg *config.OpenAIProviderConfig) Provider { func NewOpenAIProvider(cfg *config.OpenAIProviderConfig) Provider {
return &OpenAIProvider{ return &OpenAIProvider{
client: &http.Client{ client: &http.Client{
Timeout: 300 * time.Second, // 5 minutes timeout // No timeout set here - we rely on context cancellation for timeouts.
// Setting Timeout here would apply to the entire request+response cycle,
// which causes "context canceled" errors for long-running streaming
// requests with large "thinking" content blocks.
// The server's WriteTimeout handles individual write operations,
// and the context passed to ForwardRequest controls the overall timeout.
Transport: &http.Transport{
// Connection timeouts
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 300 * time.Second, // Time to wait for response headers (high for 1M context)
ExpectContinueTimeout: 1 * time.Second,
// Connection pooling
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}, },
config: cfg, config: cfg,
} }
@ -183,33 +203,90 @@ func (p *OpenAIProvider) ForwardRequest(ctx context.Context, originalReq *http.R
return resp, nil return resp, nil
} }
func convertAnthropicToOpenAI(req *model.AnthropicRequest) map[string]interface{} { // extractSystemMessages combines all system messages into a single string for OpenAI.
messages := []map[string]interface{}{} func extractSystemMessages(system []model.AnthropicSystemMessage) string {
if len(system) == 0 {
// Combine all system messages into a single system message for OpenAI return ""
if len(req.System) > 0 {
systemContent := ""
for i, sysMsg := range req.System {
if i > 0 {
systemContent += "\n\n"
} }
systemContent += sysMsg.Text var parts []string
for _, sysMsg := range system {
parts = append(parts, sysMsg.Text)
} }
messages = append(messages, map[string]interface{}{ return strings.Join(parts, "\n\n")
"role": "system", }
"content": systemContent,
}) // convertToolResultContent converts the various formats of tool result content to a string.
func convertToolResultContent(content interface{}) string {
if content == nil {
return ""
}
switch v := content.(type) {
case string:
return v
case []interface{}:
var result string
for _, c := range v {
if contentMap, ok := c.(map[string]interface{}); ok {
if contentMap["type"] == "text" {
if text, ok := contentMap["text"].(string); ok {
result += text + "\n"
}
} else if text, hasText := contentMap["text"]; hasText {
result += fmt.Sprintf("%v\n", text)
} else {
if jsonBytes, err := json.Marshal(contentMap); err == nil {
result += string(jsonBytes) + "\n"
} else {
result += fmt.Sprintf("%v\n", contentMap)
}
}
}
}
return result
case map[string]interface{}:
if v["type"] == "text" {
if text, ok := v["text"].(string); ok {
return text
}
}
if jsonBytes, err := json.Marshal(v); err == nil {
return string(jsonBytes)
}
return fmt.Sprintf("%v", v)
default:
if jsonBytes, err := json.Marshal(content); err == nil {
return string(jsonBytes)
}
return fmt.Sprintf("%v", content)
}
}
// convertMessageContent converts an Anthropic message's content to a plain text string for OpenAI.
// It handles both content arrays (with possible tool results) and simple string content.
func convertMessageContent(msg model.AnthropicMessage) string {
contentArray, ok := msg.Content.([]interface{})
if !ok {
// Handle simple string content via GetContentBlocks
contentBlocks := msg.GetContentBlocks()
var parts []string
for _, block := range contentBlocks {
if block.Type == "text" {
parts = append(parts, block.Text)
}
}
content := strings.Join(parts, "\n")
if content == "" {
content = "..."
}
return content
} }
// Add conversation messages
for _, msg := range req.Messages {
// Handle messages with raw content that may contain tool results
if contentArray, ok := msg.Content.([]interface{}); ok {
// Check if this message contains tool results // Check if this message contains tool results
hasToolResults := false hasToolResults := false
for _, item := range contentArray { for _, item := range contentArray {
if block, ok := item.(map[string]interface{}); ok { if block, ok := item.(map[string]interface{}); ok {
if blockType, hasType := block["type"].(string); hasType && blockType == "tool_result" { if blockType, _ := block["type"].(string); blockType == "tool_result" {
hasToolResults = true hasToolResults = true
break break
} }
@ -217,203 +294,87 @@ func convertAnthropicToOpenAI(req *model.AnthropicRequest) map[string]interface{
} }
if hasToolResults { if hasToolResults {
return convertContentArrayWithToolResults(contentArray)
}
return convertRegularContentArray(contentArray)
}
// convertContentArrayWithToolResults handles content arrays that contain tool_result blocks.
func convertContentArrayWithToolResults(contentArray []interface{}) string {
textContent := "" textContent := ""
for _, item := range contentArray { for _, item := range contentArray {
if block, ok := item.(map[string]interface{}); ok { block, ok := item.(map[string]interface{})
if blockType, hasType := block["type"].(string); hasType { if !ok {
if blockType == "text" { continue
if text, hasText := block["text"].(string); hasText { }
blockType, _ := block["type"].(string)
switch blockType {
case "text":
if text, ok := block["text"].(string); ok {
textContent += text + "\n" textContent += text + "\n"
} }
} else if blockType == "tool_result" { case "tool_result":
// Extract tool ID
toolID := "" toolID := ""
if id, hasID := block["tool_use_id"].(string); hasID { if id, ok := block["tool_use_id"].(string); ok {
toolID = id toolID = id
} }
resultContent := convertToolResultContent(block["content"])
// Handle different formats of tool result content
resultContent := ""
if content, hasContent := block["content"]; hasContent {
if contentStr, ok := content.(string); ok {
resultContent = contentStr
} else if contentList, ok := content.([]interface{}); ok {
// If content is a list of blocks, extract text from each
for _, c := range contentList {
if contentMap, ok := c.(map[string]interface{}); ok {
if contentMap["type"] == "text" {
if text, ok := contentMap["text"].(string); ok {
resultContent += text + "\n"
}
} else if text, hasText := contentMap["text"]; hasText {
// Handle any dict by trying to extract text
resultContent += fmt.Sprintf("%v\n", text)
} else {
// Try to JSON serialize
if jsonBytes, err := json.Marshal(contentMap); err == nil {
resultContent += string(jsonBytes) + "\n"
} else {
resultContent += fmt.Sprintf("%v\n", contentMap)
}
}
}
}
} else if contentDict, ok := content.(map[string]interface{}); ok {
// Handle dictionary content
if contentDict["type"] == "text" {
if text, ok := contentDict["text"].(string); ok {
resultContent = text
}
} else {
// Try to JSON serialize
if jsonBytes, err := json.Marshal(contentDict); err == nil {
resultContent = string(jsonBytes)
} else {
resultContent = fmt.Sprintf("%v", contentDict)
}
}
} else {
// Handle any other type by converting to string
if jsonBytes, err := json.Marshal(content); err == nil {
resultContent = string(jsonBytes)
} else {
resultContent = fmt.Sprintf("%v", content)
}
}
}
// In OpenAI format, tool results come from the user (matching Python behavior)
textContent += fmt.Sprintf("Tool result for %s:\n%s\n", toolID, resultContent) textContent += fmt.Sprintf("Tool result for %s:\n%s\n", toolID, resultContent)
} }
} }
}
}
// Add as a single user message with all the content
if textContent == "" { if textContent == "" {
textContent = "..." return "..."
}
return strings.TrimSpace(textContent)
} }
messages = append(messages, map[string]interface{}{
"role": msg.Role,
"content": strings.TrimSpace(textContent),
})
} else {
// Handle regular messages with content blocks
content := ""
// convertRegularContentArray handles content arrays with only text blocks.
func convertRegularContentArray(contentArray []interface{}) string {
var parts []string
for _, item := range contentArray { for _, item := range contentArray {
if block, ok := item.(map[string]interface{}); ok { if block, ok := item.(map[string]interface{}); ok {
if blockType, hasType := block["type"].(string); hasType && blockType == "text" { if blockType, _ := block["type"].(string); blockType == "text" {
if text, hasText := block["text"].(string); hasText { if text, ok := block["text"].(string); ok {
if content != "" { parts = append(parts, text)
content += "\n"
}
content += text
} }
} }
} }
} }
content := strings.Join(parts, "\n")
// Ensure content is never empty
if content == "" { if content == "" {
content = "..." content = "..."
} }
return content
messages = append(messages, map[string]interface{}{
"role": msg.Role,
"content": content,
})
}
} else {
// Handle simple string content
contentBlocks := msg.GetContentBlocks()
content := ""
// Concatenate all text blocks
for _, block := range contentBlocks {
if block.Type == "text" {
if content != "" {
content += "\n"
}
content += block.Text
}
} }
// Ensure content is never empty // convertToolsToOpenAI converts Anthropic tool definitions to OpenAI format.
if content == "" { func convertToolsToOpenAI(tools []model.Tool) []map[string]interface{} {
content = "..." result := make([]map[string]interface{}, 0, len(tools))
} for _, tool := range tools {
messages = append(messages, map[string]interface{}{
"role": msg.Role,
"content": content,
})
}
}
// Get model-specific max token limit
// Let the API handle validation for unknown models rather than using arbitrary caps
maxTokensLimit := getModelMaxTokens(req.Model)
if maxTokensLimit > 0 && req.MaxTokens > maxTokensLimit {
req.MaxTokens = maxTokensLimit
}
// All OpenAI models now use max_completion_tokens instead of deprecated max_tokens
openAIReq := map[string]interface{}{
"model": req.Model,
"messages": messages,
"stream": req.Stream,
"max_completion_tokens": req.MaxTokens,
}
// If streaming is enabled, request usage data to be included in the final chunk
if req.Stream {
openAIReq["stream_options"] = map[string]interface{}{
"include_usage": true,
}
}
// Check if this is an o-series model (they don't support temperature)
isOSeriesModel := strings.HasPrefix(req.Model, "o1") || strings.HasPrefix(req.Model, "o3")
// Only include temperature for non-o-series models
if !isOSeriesModel {
openAIReq["temperature"] = req.Temperature
}
// Convert Anthropic tools to OpenAI format
if len(req.Tools) > 0 {
tools := make([]map[string]interface{}, 0, len(req.Tools))
for _, tool := range req.Tools {
// Ensure tool has required fields
if tool.Name == "" { if tool.Name == "" {
// Skip tools with empty names
continue continue
} }
// Build parameters with error checking
parameters := make(map[string]interface{}) parameters := make(map[string]interface{})
if tool.InputSchema.Type != nil {
parameters["type"] = tool.InputSchema.Type parameters["type"] = tool.InputSchema.Type
if parameters["type"] == "" { } else {
parameters["type"] = "object" // Default to object type parameters["type"] = "object"
} }
// Handle properties safely with array validation
if tool.InputSchema.Properties != nil { if tool.InputSchema.Properties != nil {
// Fix array properties that are missing items field
fixedProperties := make(map[string]interface{}) fixedProperties := make(map[string]interface{})
for propName, propValue := range tool.InputSchema.Properties { for propName, propValue := range tool.InputSchema.Properties {
if prop, ok := propValue.(map[string]interface{}); ok { if prop, ok := propValue.(map[string]interface{}); ok {
// Check if this is an array type missing items
if propType, hasType := prop["type"]; hasType && propType == "array" { if propType, hasType := prop["type"]; hasType && propType == "array" {
if _, hasItems := prop["items"]; !hasItems { if _, hasItems := prop["items"]; !hasItems {
// Add default items definition for arrays
// Add default items for array properties missing them
prop["items"] = map[string]interface{}{"type": "string"} prop["items"] = map[string]interface{}{"type": "string"}
} }
} }
fixedProperties[propName] = prop fixedProperties[propName] = prop
} else { } else {
// Keep non-map properties as-is
fixedProperties[propName] = propValue fixedProperties[propName] = propValue
} }
} }
@ -422,55 +383,107 @@ func convertAnthropicToOpenAI(req *model.AnthropicRequest) map[string]interface{
parameters["properties"] = make(map[string]interface{}) parameters["properties"] = make(map[string]interface{})
} }
// Handle required fields
if len(tool.InputSchema.Required) > 0 { if len(tool.InputSchema.Required) > 0 {
parameters["required"] = tool.InputSchema.Required parameters["required"] = tool.InputSchema.Required
} }
// Build function definition
functionDef := map[string]interface{}{ functionDef := map[string]interface{}{
"name": tool.Name, "name": tool.Name,
"parameters": parameters, "parameters": parameters,
} }
// Add description if present
if tool.Description != "" { if tool.Description != "" {
functionDef["description"] = tool.Description functionDef["description"] = tool.Description
} }
openAITool := map[string]interface{}{ result = append(result, map[string]interface{}{
"type": "function", "type": "function",
"function": functionDef, "function": functionDef,
})
} }
tools = append(tools, openAITool) return result
} }
openAIReq["tools"] = tools
// Handle tool_choice if present // convertToolChoice converts Anthropic tool_choice to OpenAI format.
if req.ToolChoice != nil { func convertToolChoice(toolChoice interface{}) interface{} {
// Convert Anthropic tool_choice to OpenAI format if toolChoice == nil {
if toolChoiceMap, ok := req.ToolChoice.(map[string]interface{}); ok { return nil
choiceType := toolChoiceMap["type"] }
switch choiceType { toolChoiceMap, ok := toolChoice.(map[string]interface{})
if !ok {
return nil
}
switch toolChoiceMap["type"] {
case "auto": case "auto":
openAIReq["tool_choice"] = "auto" return "auto"
case "any": case "any":
openAIReq["tool_choice"] = "required" return "required"
case "tool": case "tool":
// Specific tool choice if name, ok := toolChoiceMap["name"].(string); ok {
if name, hasName := toolChoiceMap["name"].(string); hasName { return map[string]interface{}{
openAIReq["tool_choice"] = map[string]interface{}{
"type": "function", "type": "function",
"function": map[string]interface{}{ "function": map[string]interface{}{
"name": name, "name": name,
}, },
} }
} }
return "auto"
default: default:
// Default to auto if we can't determine return "auto"
openAIReq["tool_choice"] = "auto"
} }
} }
func convertAnthropicToOpenAI(req *model.AnthropicRequest) map[string]interface{} {
messages := []map[string]interface{}{}
// Add system message if present
if systemContent := extractSystemMessages(req.System); systemContent != "" {
messages = append(messages, map[string]interface{}{
"role": "system",
"content": systemContent,
})
}
// Convert conversation messages
for _, msg := range req.Messages {
messages = append(messages, map[string]interface{}{
"role": msg.Role,
"content": convertMessageContent(msg),
})
}
// Get model-specific max token limit
maxTokensLimit := getModelMaxTokens(req.Model)
if maxTokensLimit > 0 && req.MaxTokens > maxTokensLimit {
req.MaxTokens = maxTokensLimit
}
openAIReq := map[string]interface{}{
"model": req.Model,
"messages": messages,
"stream": req.Stream,
"max_completion_tokens": req.MaxTokens,
}
if req.Stream {
openAIReq["stream_options"] = map[string]interface{}{
"include_usage": true,
}
}
// o-series models don't support temperature
isOSeriesModel := strings.HasPrefix(req.Model, "o1") || strings.HasPrefix(req.Model, "o3")
if !isOSeriesModel {
openAIReq["temperature"] = req.Temperature
}
// Convert tools and tool_choice
if len(req.Tools) > 0 {
openAIReq["tools"] = convertToolsToOpenAI(req.Tools)
if req.ToolChoice != nil {
if choice := convertToolChoice(req.ToolChoice); choice != nil {
openAIReq["tool_choice"] = choice
}
} }
} }
@ -485,13 +498,6 @@ func getMapKeys(m map[string]interface{}) []string {
return keys return keys
} }
func min(a, b int) int {
if a < b {
return a
}
return b
}
// getModelMaxTokens returns the max output tokens for known models // getModelMaxTokens returns the max output tokens for known models
// Returns 0 for unknown models, letting the API handle validation // Returns 0 for unknown models, letting the API handle validation
func getModelMaxTokens(model string) int { func getModelMaxTokens(model string) int {

View file

@ -0,0 +1,34 @@
// Package runtime exposes process-level operational state shared between
// HTTP middleware, handlers, and the shutdown loop in main: a live in-flight
// request gauge for /livez, and a draining flag that flips on SIGTERM so
// /health goes non-ready before we wait for in-flight requests to drain.
//
// All state is package-level + atomic so callers don't need to plumb a struct
// through every middleware/handler. The gauge is decremented in a deferred
// call regardless of panics, so a misbehaving handler can't strand the count.
package runtime
import "sync/atomic"
var (
inFlight atomic.Int64
draining atomic.Bool
)
// IncInFlight is called when a tracked request begins. Returns the new count.
func IncInFlight() int64 { return inFlight.Add(1) }
// DecInFlight is called when a tracked request completes. Returns the new count.
func DecInFlight() int64 { return inFlight.Add(-1) }
// InFlight returns the current number of tracked requests in progress.
func InFlight() int64 { return inFlight.Load() }
// IsDraining reports whether the process is shutting down. Used by /health
// to advertise non-ready state so Traefik (or any LB doing health-based
// routing) stops sending new requests to this slot before drain begins.
func IsDraining() bool { return draining.Load() }
// SetDraining flips the draining flag. Idempotent — safe to call from a
// signal handler.
func SetDraining(v bool) { draining.Store(v) }

View file

@ -1,122 +0,0 @@
package service
import (
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/seifghazi/claude-code-monitor/internal/config"
)
type AnthropicService interface {
ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error)
}
type anthropicService struct {
client *http.Client
config *config.AnthropicConfig
}
func NewAnthropicService(cfg *config.AnthropicConfig) AnthropicService {
return &anthropicService{
client: &http.Client{
Timeout: 300 * time.Second, // Increased timeout to 5 minutes
},
config: cfg,
}
}
func (s *anthropicService) ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
proxyReq := originalReq.Clone(ctx)
// Parse the configured base URL
baseURL, err := url.Parse(s.config.BaseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse base URL '%s': %w", s.config.BaseURL, err)
}
if baseURL.Scheme == "" || baseURL.Host == "" {
return nil, fmt.Errorf("invalid base URL, scheme and host are required: %s", s.config.BaseURL)
}
// Update the destination URL
proxyReq.URL.Scheme = baseURL.Scheme
proxyReq.URL.Host = baseURL.Host
proxyReq.URL.Path = path.Join(baseURL.Path, "/v1/messages")
// Preserve query parameters from original request
proxyReq.URL.RawQuery = originalReq.URL.RawQuery
// Clear fields that can't be set in client requests
proxyReq.RequestURI = "" // This is set by the server and must be cleared
proxyReq.Host = "" // Let Go set this from the URL
// Forward the request with all original headers intact
resp, err := s.client.Do(proxyReq)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
// Handle gzip decompression
if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") {
decompressedResp, err := s.decompressGzipResponse(resp)
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("failed to decompress gzip response: %w", err)
}
return decompressedResp, nil
}
return resp, nil
}
func (s *anthropicService) decompressGzipResponse(resp *http.Response) (*http.Response, error) {
// Create a gzip reader
gzipReader, err := gzip.NewReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
}
// Read the decompressed data
decompressedData, err := io.ReadAll(gzipReader)
if err != nil {
gzipReader.Close()
return nil, fmt.Errorf("failed to read decompressed data: %w", err)
}
// Close the gzip reader and original body
gzipReader.Close()
resp.Body.Close()
// Create a new response with decompressed body
newResp := &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Proto: resp.Proto,
ProtoMajor: resp.ProtoMajor,
ProtoMinor: resp.ProtoMinor,
Header: resp.Header.Clone(),
ContentLength: int64(len(decompressedData)),
TransferEncoding: resp.TransferEncoding,
Close: resp.Close,
Uncompressed: true,
Trailer: resp.Trailer,
Request: resp.Request,
TLS: resp.TLS,
}
// Remove Content-Encoding header since we've decompressed
newResp.Header.Del("Content-Encoding")
// Set the decompressed body
newResp.Body = io.NopCloser(strings.NewReader(string(decompressedData)))
return newResp, nil
}

View file

@ -48,6 +48,7 @@ type Conversation struct {
SessionID string `json:"sessionId"` SessionID string `json:"sessionId"`
ProjectPath string `json:"projectPath"` ProjectPath string `json:"projectPath"`
ProjectName string `json:"projectName"` ProjectName string `json:"projectName"`
Model string `json:"model,omitempty"`
Messages []*ConversationMessage `json:"messages"` Messages []*ConversationMessage `json:"messages"`
StartTime time.Time `json:"startTime"` StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"` EndTime time.Time `json:"endTime"`
@ -313,6 +314,7 @@ func (cs *conversationService) parseConversationFile(filePath, projectPath strin
var messages []*ConversationMessage var messages []*ConversationMessage
var parseErrors int var parseErrors int
lineNum := 0 lineNum := 0
conversationModel := ""
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
@ -354,6 +356,15 @@ func (cs *conversationService) parseConversationFile(filePath, projectPath strin
} }
messages = append(messages, &msg) messages = append(messages, &msg)
// Claude conversation JSONL records the assistant model inside the nested message object.
// Track the latest model we see so the list view can filter by the active model tier.
var messageMeta struct {
Model string `json:"model"`
}
if err := json.Unmarshal(msg.Message, &messageMeta); err == nil && messageMeta.Model != "" {
conversationModel = messageMeta.Model
}
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
@ -382,6 +393,7 @@ func (cs *conversationService) parseConversationFile(filePath, projectPath strin
SessionID: sessionID, SessionID: sessionID,
ProjectPath: projectPath, ProjectPath: projectPath,
ProjectName: projectName, ProjectName: projectName,
Model: conversationModel,
Messages: messages, Messages: messages,
StartTime: time.Time{}, StartTime: time.Time{},
EndTime: time.Time{}, EndTime: time.Time{},
@ -425,6 +437,7 @@ func (cs *conversationService) parseConversationFile(filePath, projectPath strin
SessionID: sessionID, SessionID: sessionID,
ProjectPath: projectPath, ProjectPath: projectPath,
ProjectName: projectName, ProjectName: projectName,
Model: conversationModel,
Messages: messages, Messages: messages,
StartTime: startTime, StartTime: startTime,
EndTime: endTime, EndTime: endTime,

View file

@ -14,7 +14,10 @@ func TestConversationServiceAllowsNestedProjectPaths(t *testing.T) {
} }
sessionPath := filepath.Join(projectDir, "session.jsonl") sessionPath := filepath.Join(projectDir, "session.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"timestamp":"2026-03-19T12:00:00Z","type":"user","message":"hello"}`+"\n"), 0o600); err != nil { if err := os.WriteFile(sessionPath, []byte(
`{"timestamp":"2026-03-19T12:00:00Z","type":"user","message":"hello"}`+"\n"+
`{"timestamp":"2026-03-19T12:00:01Z","type":"assistant","message":{"model":"claude-opus-4-6","role":"assistant","content":[{"type":"text","text":"hi"}]}}`+"\n",
), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }
@ -33,8 +36,12 @@ func TestConversationServiceAllowsNestedProjectPaths(t *testing.T) {
t.Fatalf("expected project path %q, got %q", "team/app", conversation.ProjectPath) t.Fatalf("expected project path %q, got %q", "team/app", conversation.ProjectPath)
} }
if len(conversation.Messages) != 1 { if len(conversation.Messages) != 2 {
t.Fatalf("expected 1 message, got %d", len(conversation.Messages)) t.Fatalf("expected 2 messages, got %d", len(conversation.Messages))
}
if conversation.Model != "claude-opus-4-6" {
t.Fatalf("expected model %q, got %q", "claude-opus-4-6", conversation.Model)
} }
conversations, err := svc.GetConversationsByProject("team/app") conversations, err := svc.GetConversationsByProject("team/app")

View file

@ -24,6 +24,22 @@ type StorageService interface {
DeleteRequestsOlderThan(age time.Duration) (int, error) DeleteRequestsOlderThan(age time.Duration) (int, error)
GetDatabaseStats() (map[string]interface{}, error) GetDatabaseStats() (map[string]interface{}, error)
// Usage statistics
GetUsageStats(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error)
// Dashboard statistics (fast aggregated queries)
GetRequestsSummary(modelFilter string) ([]*model.RequestSummary, error)
GetRequestsSummaryPaginated(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error)
GetStats(startDate, endDate, orgFilter string) (*model.DashboardStats, error)
GetHourlyStats(startTime, endTime string, bucketMinutes int, orgFilter string) (*model.HourlyStatsResponse, error)
GetModelStats(startTime, endTime, orgFilter string) (*model.ModelStatsResponse, error)
GetLatestRequestDate() (*time.Time, error)
GetDistinctOrganizations() ([]string, error)
// Settings (dynamic proxy config)
GetSettings() (*model.ProxySettings, error)
SaveSettings(settings *model.ProxySettings) error
// Configuration // Configuration
GetConfig() *config.StorageConfig GetConfig() *config.StorageConfig
EnsureDirectoryExists() error EnsureDirectoryExists() error

View file

@ -0,0 +1,64 @@
package service
import (
"testing"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
func TestAggregationHelpers(t *testing.T) {
t.Parallel()
t.Run("addDailyTokens accumulates by date and model", func(t *testing.T) {
t.Parallel()
dailyMap := make(map[string]*model.DailyTokens)
addDailyTokens(dailyMap, "2026-03-20", "claude", 10)
addDailyTokens(dailyMap, "2026-03-20", "claude", 5)
addDailyTokens(dailyMap, "2026-03-20", "gpt", 7)
day := dailyMap["2026-03-20"]
if day == nil || day.Tokens != 22 || day.Requests != 3 {
t.Fatalf("unexpected daily aggregate: %#v", day)
}
if day.Models["claude"].Tokens != 15 || day.Models["claude"].Requests != 2 {
t.Fatalf("unexpected claude daily model aggregate: %#v", day.Models["claude"])
}
if day.Models["gpt"].Tokens != 7 || day.Models["gpt"].Requests != 1 {
t.Fatalf("unexpected gpt daily model aggregate: %#v", day.Models["gpt"])
}
})
t.Run("addHourlyTokens accumulates by bucket and model", func(t *testing.T) {
t.Parallel()
bucketMap := make(map[string]*model.HourlyTokens)
addHourlyTokens(bucketMap, "09", "09:00", "claude", 4)
addHourlyTokens(bucketMap, "09", "09:00", "claude", 6)
addHourlyTokens(bucketMap, "09", "09:00", "gpt", 2)
bucket := bucketMap["09"]
if bucket == nil || bucket.Tokens != 12 || bucket.Requests != 3 || bucket.Label != "09:00" {
t.Fatalf("unexpected hourly aggregate: %#v", bucket)
}
if bucket.Models["claude"].Tokens != 10 || bucket.Models["claude"].Requests != 2 {
t.Fatalf("unexpected claude hourly model aggregate: %#v", bucket.Models["claude"])
}
if bucket.Models["gpt"].Tokens != 2 || bucket.Models["gpt"].Requests != 1 {
t.Fatalf("unexpected gpt hourly model aggregate: %#v", bucket.Models["gpt"])
}
})
t.Run("addModelTokens accumulates by model", func(t *testing.T) {
t.Parallel()
modelMap := make(map[string]*model.ModelTokens)
addModelTokens(modelMap, "claude", 8)
addModelTokens(modelMap, "claude", 12)
got := modelMap["claude"]
if got == nil || got.Tokens != 20 || got.Requests != 2 {
t.Fatalf("unexpected model aggregate: %#v", got)
}
})
}

View file

@ -0,0 +1,163 @@
package service
import (
"database/sql"
"encoding/json"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
type responseBodySummary struct {
Usage *model.AnthropicUsage `json:"usage"`
StopReason string `json:"stop_reason"`
}
func decodeStoredResponse(responseJSON sql.NullString) (*model.ResponseLog, bool) {
if !responseJSON.Valid || responseJSON.String == "" {
return nil, false
}
var resp model.ResponseLog
if err := json.Unmarshal([]byte(responseJSON.String), &resp); err != nil {
return nil, false
}
return &resp, true
}
func decodeResponseBodySummary(body json.RawMessage) (*responseBodySummary, bool) {
if len(body) == 0 {
return nil, false
}
var summary responseBodySummary
if err := json.Unmarshal(body, &summary); err != nil {
return nil, false
}
return &summary, true
}
func totalTokensFromUsage(usage *model.AnthropicUsage) int64 {
if usage == nil {
return 0
}
return int64(
usage.InputTokens +
usage.OutputTokens +
usage.CacheReadInputTokens +
usage.CacheCreationInputTokens)
}
func totalTokensFromStoredResponse(responseJSON sql.NullString) int64 {
input, output, cache, ok := usageCountsFromStoredResponse(responseJSON)
if !ok {
return 0
}
return input + output + cache
}
func responseTimeFromStoredResponse(responseJSON sql.NullString) int64 {
resp, ok := decodeStoredResponse(responseJSON)
if !ok {
return 0
}
return resp.ResponseTime
}
func applyStoredResponseToSummary(summary *model.RequestSummary, responseJSON sql.NullString) {
resp, ok := decodeStoredResponse(responseJSON)
if !ok {
return
}
summary.StatusCode = resp.StatusCode
summary.ResponseTime = resp.ResponseTime
bodySummary, ok := decodeResponseBodySummary(resp.Body)
if !ok {
return
}
summary.Usage = bodySummary.Usage
summary.StopReason = bodySummary.StopReason
}
func usageCountsFromStoredResponse(responseJSON sql.NullString) (input, output, cache int64, ok bool) {
resp, ok := decodeStoredResponse(responseJSON)
if !ok {
return 0, 0, 0, false
}
bodySummary, ok := decodeResponseBodySummary(resp.Body)
if !ok || bodySummary.Usage == nil {
return 0, 0, 0, false
}
return int64(bodySummary.Usage.InputTokens),
int64(bodySummary.Usage.OutputTokens),
int64(bodySummary.Usage.CacheCreationInputTokens + bodySummary.Usage.CacheReadInputTokens),
true
}
func addDailyTokens(dailyMap map[string]*model.DailyTokens, date, modelName string, tokens int64) {
if daily, ok := dailyMap[date]; ok {
daily.Tokens += tokens
daily.Requests++
daily.Models = addDailyModelStat(daily.Models, modelName, tokens)
return
}
dailyMap[date] = &model.DailyTokens{
Date: date,
Tokens: tokens,
Requests: 1,
Models: addDailyModelStat(nil, modelName, tokens),
}
}
func addHourlyTokens(bucketMap map[string]*model.HourlyTokens, bucketKey, bucketLabel, modelName string, tokens int64) {
if bucket, ok := bucketMap[bucketKey]; ok {
bucket.Tokens += tokens
bucket.Requests++
bucket.Models = addDailyModelStat(bucket.Models, modelName, tokens)
return
}
bucketMap[bucketKey] = &model.HourlyTokens{
Hour: 0,
Label: bucketLabel,
Tokens: tokens,
Requests: 1,
Models: addDailyModelStat(nil, modelName, tokens),
}
}
func addModelTokens(modelMap map[string]*model.ModelTokens, modelName string, tokens int64) {
if modelStat, ok := modelMap[modelName]; ok {
modelStat.Tokens += tokens
modelStat.Requests++
return
}
modelMap[modelName] = &model.ModelTokens{
Model: modelName,
Tokens: tokens,
Requests: 1,
}
}
func addDailyModelStat(models map[string]model.DailyModelStat, modelName string, tokens int64) map[string]model.DailyModelStat {
if models == nil {
models = make(map[string]model.DailyModelStat)
}
modelStat := models[modelName]
modelStat.Tokens += tokens
modelStat.Requests++
models[modelName] = modelStat
return models
}

View file

@ -0,0 +1,83 @@
package service
import (
"database/sql"
"encoding/json"
"testing"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
func TestApplyStoredResponseToSummary(t *testing.T) {
t.Parallel()
summary := &model.RequestSummary{}
applyStoredResponseToSummary(summary, sql.NullString{
Valid: true,
String: `{"statusCode":201,"responseTime":42,"body":{"usage":{"input_tokens":10,"output_tokens":5},"stop_reason":"end_turn"}}`,
})
if summary.StatusCode != 201 {
t.Fatalf("expected status code 201, got %d", summary.StatusCode)
}
if summary.ResponseTime != 42 {
t.Fatalf("expected response time 42, got %d", summary.ResponseTime)
}
if summary.Usage == nil || summary.Usage.InputTokens != 10 || summary.Usage.OutputTokens != 5 {
t.Fatalf("expected usage decoded, got %#v", summary.Usage)
}
if summary.StopReason != "end_turn" {
t.Fatalf("expected stop reason end_turn, got %q", summary.StopReason)
}
}
func TestApplyStoredResponseToSummaryIgnoresInvalidPayload(t *testing.T) {
t.Parallel()
summary := &model.RequestSummary{}
applyStoredResponseToSummary(summary, sql.NullString{Valid: true, String: `{not-json`})
if summary.StatusCode != 0 || summary.ResponseTime != 0 || summary.Usage != nil || summary.StopReason != "" {
t.Fatalf("expected invalid payload ignored, got %#v", summary)
}
}
func TestDecodeResponseBodySummary(t *testing.T) {
t.Parallel()
body, _ := json.Marshal(map[string]interface{}{
"usage": map[string]interface{}{
"input_tokens": 7,
"output_tokens": 3,
"cache_read_input_tokens": 2,
"cache_creation_input_tokens": 1,
},
"stop_reason": "tool_use",
})
summary, ok := decodeResponseBodySummary(body)
if !ok {
t.Fatal("expected response body summary to decode")
}
if total := totalTokensFromUsage(summary.Usage); total != 13 {
t.Fatalf("expected total tokens 13, got %d", total)
}
if summary.StopReason != "tool_use" {
t.Fatalf("expected stop reason tool_use, got %q", summary.StopReason)
}
}
func TestUsageCountsFromStoredResponse(t *testing.T) {
t.Parallel()
input, output, cache, ok := usageCountsFromStoredResponse(sql.NullString{
Valid: true,
String: `{"body":{"usage":{"input_tokens":7,"output_tokens":3,"cache_read_input_tokens":2,"cache_creation_input_tokens":1}}}`,
})
if !ok {
t.Fatal("expected usage counts to decode")
}
if input != 7 || output != 3 || cache != 3 {
t.Fatalf("unexpected usage counts: input=%d output=%d cache=%d", input, output, cache)
}
}

View file

@ -0,0 +1,217 @@
package service
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
type storageFactory struct {
name string
new func(t *testing.T, cfg config.StorageConfig) StorageService
}
func runStorageContractTests(t *testing.T, factory storageFactory) {
t.Helper()
t.Run("save and fetch by short id", func(t *testing.T) {
storage := factory.new(t, config.StorageConfig{
DBPath: filepath.Join(t.TempDir(), factory.name+".db"),
})
req := newContractRequest("fetch-123")
mustSaveRequest(t, storage, req)
got := mustGetByShortID(t, storage, "123")
if got.RequestID != req.RequestID {
t.Fatalf("expected request id %q, got %q", req.RequestID, got.RequestID)
}
if got.Method != req.Method || got.Endpoint != req.Endpoint || got.Model != req.Model {
t.Fatalf("unexpected fetched request: %#v", got)
}
})
t.Run("update response persists status and usage metadata", func(t *testing.T) {
storage := factory.new(t, config.StorageConfig{
DBPath: filepath.Join(t.TempDir(), factory.name+".db"),
})
req := newContractRequest("response-123")
mustSaveRequest(t, storage, req)
req.Response = newContractResponse()
if err := storage.UpdateRequestWithResponse(req); err != nil {
t.Fatalf("UpdateRequestWithResponse() error = %v", err)
}
got := mustGetByShortID(t, storage, "123")
if got.Response == nil || got.Response.StatusCode != 200 {
t.Fatalf("expected stored response, got %#v", got.Response)
}
})
t.Run("redaction survives round trip", func(t *testing.T) {
storage := factory.new(t, config.StorageConfig{
DBPath: filepath.Join(t.TempDir(), factory.name+".db"),
CaptureRequestBody: true,
CaptureResponseBody: true,
RedactedFields: []string{"api_key", "secret"},
})
req := newContractRequest("redact-123")
req.Body = map[string]interface{}{
"api_key": "abc123",
"nested": map[string]interface{}{
"secret": "top-secret",
"keep": "ok",
},
}
req.Response = &model.ResponseLog{
StatusCode: httpStatusOK,
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: json.RawMessage(`{"secret":"response-secret","visible":"yes"}`),
ResponseTime: 12,
CompletedAt: time.Now().UTC().Format(time.RFC3339),
}
mustSaveRequest(t, storage, req)
if err := storage.UpdateRequestWithResponse(req); err != nil {
t.Fatalf("UpdateRequestWithResponse() error = %v", err)
}
got := mustGetByShortID(t, storage, "123")
body := got.Body.(map[string]interface{})
if body["api_key"] != redactionPlaceholder {
t.Fatalf("expected api_key redacted, got %#v", body["api_key"])
}
nested := body["nested"].(map[string]interface{})
if nested["secret"] != redactionPlaceholder || nested["keep"] != "ok" {
t.Fatalf("unexpected nested redaction result: %#v", nested)
}
})
t.Run("body suppression semantics", func(t *testing.T) {
storage := factory.new(t, config.StorageConfig{
DBPath: filepath.Join(t.TempDir(), factory.name+".db"),
CaptureRequestBody: false,
CaptureResponseBody: false,
})
req := newContractRequest("suppress-123")
req.Body = map[string]interface{}{"message": "do not store me"}
req.Response = &model.ResponseLog{
StatusCode: httpStatusOK,
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: json.RawMessage(`{"answer":"do not store me"}`),
BodyText: "sensitive text",
StreamingChunks: []string{"data: chunk-1"},
ResponseTime: 10,
CompletedAt: time.Now().UTC().Format(time.RFC3339),
}
mustSaveRequest(t, storage, req)
if err := storage.UpdateRequestWithResponse(req); err != nil {
t.Fatalf("UpdateRequestWithResponse() error = %v", err)
}
got := mustGetByShortID(t, storage, "123")
body := got.Body.(map[string]interface{})
if body["_storage_mode"] != "request_body_disabled" {
t.Fatalf("expected request body placeholder, got %#v", body)
}
if got.Response == nil || len(got.Response.Body) != 0 || got.Response.BodyText != "" || len(got.Response.StreamingChunks) != 0 {
t.Fatalf("expected suppressed response body fields, got %#v", got.Response)
}
})
t.Run("retention cleanup on write", func(t *testing.T) {
storage := factory.new(t, config.StorageConfig{
DBPath: filepath.Join(t.TempDir(), factory.name+".db"),
RetentionDays: 1,
})
oldReq := newContractRequest("old-123")
oldReq.Timestamp = time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339)
mustSaveRequest(t, storage, oldReq)
recentReq := newContractRequest("recent-123")
mustSaveRequest(t, storage, recentReq)
got, err := storage.GetAllRequests("all")
if err != nil {
t.Fatalf("GetAllRequests() error = %v", err)
}
if len(got) != 1 || got[0].RequestID != "recent-123" {
t.Fatalf("expected only recent request to remain, got %#v", got)
}
})
t.Run("clear requests removes all rows", func(t *testing.T) {
storage := factory.new(t, config.StorageConfig{
DBPath: filepath.Join(t.TempDir(), factory.name+".db"),
})
mustSaveRequest(t, storage, newContractRequest("clear-123"))
mustSaveRequest(t, storage, newContractRequest("clear-456"))
deleted, err := storage.ClearRequests()
if err != nil {
t.Fatalf("ClearRequests() error = %v", err)
}
if deleted != 2 {
t.Fatalf("expected 2 deleted rows, got %d", deleted)
}
got, err := storage.GetAllRequests("all")
if err != nil {
t.Fatalf("GetAllRequests() error = %v", err)
}
if len(got) != 0 {
t.Fatalf("expected no remaining requests, got %d", len(got))
}
})
}
func newContractRequest(id string) *model.RequestLog {
return &model.RequestLog{
RequestID: id,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Method: "POST",
Endpoint: "/v1/messages",
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: map[string]interface{}{"message": "hello"},
Model: "claude-3-5-sonnet",
UserAgent: "test",
ContentType: "application/json",
}
}
func newContractResponse() *model.ResponseLog {
return &model.ResponseLog{
StatusCode: httpStatusOK,
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: json.RawMessage(`{"usage":{"input_tokens":11,"output_tokens":22},"stop_reason":"end_turn"}`),
ResponseTime: 17,
CompletedAt: time.Now().UTC().Format(time.RFC3339),
}
}
func mustSaveRequest(t *testing.T, storage StorageService, req *model.RequestLog) {
t.Helper()
if _, err := storage.SaveRequest(req); err != nil {
t.Fatalf("SaveRequest() error = %v", err)
}
}
func mustGetByShortID(t *testing.T, storage StorageService, shortID string) *model.RequestLog {
t.Helper()
got, _, err := storage.GetRequestByShortID(shortID)
if err != nil {
t.Fatalf("GetRequestByShortID(%q) error = %v", shortID, err)
}
return got
}

View file

@ -0,0 +1,46 @@
package service
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
func unmarshalStoredRequestFields(logger *log.Logger, req *model.RequestLog, headersJSON, bodyJSON string, promptGradeJSON, responseJSON sql.NullString) error {
if err := json.Unmarshal([]byte(headersJSON), &req.Headers); err != nil {
return fmt.Errorf("failed to unmarshal headers: %w", err)
}
var body interface{}
if err := json.Unmarshal([]byte(bodyJSON), &body); err != nil {
return fmt.Errorf("failed to unmarshal body: %w", err)
}
req.Body = body
if promptGradeJSON.Valid {
var grade model.PromptGrade
if err := json.Unmarshal([]byte(promptGradeJSON.String), &grade); err != nil {
if logger != nil {
logger.Printf("Warning: failed to unmarshal prompt grade: %v", err)
}
} else {
req.PromptGrade = &grade
}
}
if responseJSON.Valid {
var resp model.ResponseLog
if err := json.Unmarshal([]byte(responseJSON.String), &resp); err != nil {
if logger != nil {
logger.Printf("Warning: failed to unmarshal response: %v", err)
}
} else {
req.Response = &resp
}
}
return nil
}

View file

@ -0,0 +1,104 @@
package service
import (
"database/sql"
"io"
"log"
"testing"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
func TestUnmarshalStoredRequestFields(t *testing.T) {
t.Parallel()
logger := log.New(io.Discard, "", 0)
t.Run("decodes all stored fields", func(t *testing.T) {
t.Parallel()
req := &model.RequestLog{}
err := unmarshalStoredRequestFields(
logger,
req,
`{"content-type":["application/json"]}`,
`{"messages":[{"role":"user","content":"hi"}]}`,
sql.NullString{String: `{"score":8,"maxScore":10}`, Valid: true},
sql.NullString{String: `{"statusCode":200,"body":{"ok":true}}`, Valid: true},
)
if err != nil {
t.Fatalf("unmarshalStoredRequestFields() error = %v", err)
}
if got := req.Headers["content-type"][0]; got != "application/json" {
t.Fatalf("expected header decoded, got %q", got)
}
body := req.Body.(map[string]interface{})
messages := body["messages"].([]interface{})
if len(messages) != 1 {
t.Fatalf("expected one message, got %#v", body)
}
if req.PromptGrade == nil || req.PromptGrade.Score != 8 {
t.Fatalf("expected prompt grade decoded, got %#v", req.PromptGrade)
}
if req.Response == nil || req.Response.StatusCode != 200 {
t.Fatalf("expected response decoded, got %#v", req.Response)
}
})
t.Run("returns error for invalid headers json", func(t *testing.T) {
t.Parallel()
req := &model.RequestLog{}
err := unmarshalStoredRequestFields(
logger,
req,
`{not-json`,
`{"ok":true}`,
sql.NullString{},
sql.NullString{},
)
if err == nil {
t.Fatal("expected invalid headers json error")
}
})
t.Run("returns error for invalid body json", func(t *testing.T) {
t.Parallel()
req := &model.RequestLog{}
err := unmarshalStoredRequestFields(
logger,
req,
`{"accept":["application/json"]}`,
`{not-json`,
sql.NullString{},
sql.NullString{},
)
if err == nil {
t.Fatal("expected invalid body json error")
}
})
t.Run("ignores corrupt optional grade and response", func(t *testing.T) {
t.Parallel()
req := &model.RequestLog{}
err := unmarshalStoredRequestFields(
logger,
req,
`{"accept":["application/json"]}`,
`{"ok":true}`,
sql.NullString{String: `{"score":`, Valid: true},
sql.NullString{String: `{"statusCode":`, Valid: true},
)
if err != nil {
t.Fatalf("unmarshalStoredRequestFields() unexpected error = %v", err)
}
if req.PromptGrade != nil {
t.Fatalf("expected corrupt prompt grade ignored, got %#v", req.PromptGrade)
}
if req.Response != nil {
t.Fatalf("expected corrupt response ignored, got %#v", req.Response)
}
})
}

View file

@ -0,0 +1,25 @@
package service
import (
"database/sql"
"fmt"
"strings"
)
func runMigrations(db *sql.DB, statements []string, ignoreErr func(statement string, err error) bool) error {
for _, stmt := range statements {
if _, err := db.Exec(stmt); err != nil {
if ignoreErr != nil && ignoreErr(stmt, err) {
continue
}
return fmt.Errorf("failed to run migration %q: %w", stmt, err)
}
}
return nil
}
func ignoreSQLiteDuplicateColumn(statement string, err error) bool {
return strings.Contains(statement, "ALTER TABLE requests ADD COLUMN") &&
strings.Contains(strings.ToLower(err.Error()), "duplicate column name")
}

View file

@ -0,0 +1,203 @@
package service
import (
"encoding/json"
"log"
"strings"
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
const redactionPlaceholder = "[REDACTED]"
// maxStoredBodyBytes is the maximum serialized size of a request body stored in the DB.
// Bodies larger than this (e.g. 1M context payloads) are replaced with a metadata summary.
const maxStoredBodyBytes = 512 * 1024 // 512 KB
func prepareRequestBodyForStorage(cfg *config.StorageConfig, body interface{}) (interface{}, error) {
if shouldSuppressBodies(cfg) {
return storageBodyPlaceholder("metadata_only"), nil
}
if cfg != nil && !cfg.CaptureRequestBody {
return storageBodyPlaceholder("request_body_disabled"), nil
}
normalized, err := normalizeJSONValue(body)
if err != nil {
return nil, err
}
redacted := redactJSONValue(normalized, redactedFieldSet(redactedFields(cfg)))
// Check serialized size; if too large, store a lightweight summary instead
data, err := json.Marshal(redacted)
if err != nil {
return redacted, nil
}
if len(data) > maxStoredBodyBytes {
return truncatedBodySummary(redacted, len(data)), nil
}
return redacted, nil
}
// truncatedBodySummary extracts key metadata from an oversized request body
// so the DB row stays small while retaining useful diagnostic info.
func truncatedBodySummary(body interface{}, originalBytes int) map[string]interface{} {
summary := map[string]interface{}{
"_truncated": true,
"_original_bytes": originalBytes,
}
if m, ok := body.(map[string]interface{}); ok {
if v, ok := m["model"]; ok {
summary["model"] = v
}
if v, ok := m["stream"]; ok {
summary["stream"] = v
}
if v, ok := m["max_tokens"]; ok {
summary["max_tokens"] = v
}
if msgs, ok := m["messages"].([]interface{}); ok {
summary["message_count"] = len(msgs)
}
if sys, ok := m["system"].([]interface{}); ok {
summary["system_count"] = len(sys)
}
if tools, ok := m["tools"].([]interface{}); ok {
summary["tool_count"] = len(tools)
}
}
return summary
}
func prepareResponseForStorage(cfg *config.StorageConfig, logger *log.Logger, response *model.ResponseLog) (*model.ResponseLog, error) {
if response == nil {
return nil, nil
}
clone := *response
if shouldSuppressBodies(cfg) || (cfg != nil && !cfg.CaptureResponseBody) {
clone.Body = nil
clone.BodyText = ""
clone.StreamingChunks = nil
clone.ChunkTimings = nil
return &clone, nil
}
if len(clone.Body) > 0 {
sanitizedBody, err := sanitizeRawJSON(clone.Body, redactedFieldSet(redactedFields(cfg)))
if err != nil {
if logger != nil {
logger.Printf("Warning: failed to redact response body: %v", err)
}
} else {
clone.Body = sanitizedBody
}
// Cap stored response body size
if len(clone.Body) > maxStoredBodyBytes {
clone.Body = json.RawMessage(`{"_truncated":true}`)
}
}
// Cap stored streaming chunks to avoid huge DB rows on long streams
const maxStoredChunks = 500
if len(clone.StreamingChunks) > maxStoredChunks {
clone.StreamingChunks = clone.StreamingChunks[:maxStoredChunks]
}
if len(clone.ChunkTimings) > maxStoredChunks {
clone.ChunkTimings = clone.ChunkTimings[:maxStoredChunks]
}
return &clone, nil
}
func shouldSuppressBodies(cfg *config.StorageConfig) bool {
return cfg != nil && cfg.MetadataOnly
}
func redactedFields(cfg *config.StorageConfig) []string {
if cfg == nil {
return nil
}
return cfg.RedactedFields
}
func normalizeJSONValue(value interface{}) (interface{}, error) {
if value == nil {
return nil, nil
}
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var normalized interface{}
if err := json.Unmarshal(data, &normalized); err != nil {
return nil, err
}
return normalized, nil
}
func sanitizeRawJSON(raw json.RawMessage, redacted map[string]struct{}) (json.RawMessage, error) {
if len(raw) == 0 {
return raw, nil
}
var value interface{}
if err := json.Unmarshal(raw, &value); err != nil {
return raw, err
}
sanitized := redactJSONValue(value, redacted)
data, err := json.Marshal(sanitized)
if err != nil {
return raw, err
}
return json.RawMessage(data), nil
}
func redactJSONValue(value interface{}, redacted map[string]struct{}) interface{} {
switch typed := value.(type) {
case map[string]interface{}:
result := make(map[string]interface{}, len(typed))
for key, child := range typed {
if _, ok := redacted[strings.ToLower(key)]; ok {
result[key] = redactionPlaceholder
continue
}
result[key] = redactJSONValue(child, redacted)
}
return result
case []interface{}:
result := make([]interface{}, len(typed))
for i, child := range typed {
result[i] = redactJSONValue(child, redacted)
}
return result
default:
return value
}
}
func storageBodyPlaceholder(mode string) map[string]interface{} {
return map[string]interface{}{
"_storage_mode": mode,
}
}
func redactedFieldSet(fields []string) map[string]struct{} {
set := make(map[string]struct{}, len(fields))
for _, field := range fields {
field = strings.TrimSpace(strings.ToLower(field))
if field == "" {
continue
}
set[field] = struct{}{}
}
return set
}

View file

@ -0,0 +1,163 @@
package service
import (
"encoding/json"
"io"
"log"
"testing"
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
func TestPrepareRequestBodyForStorage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg *config.StorageConfig
body interface{}
assert func(t *testing.T, got interface{})
}{
{
name: "metadata only returns placeholder",
cfg: &config.StorageConfig{
MetadataOnly: true,
},
body: map[string]interface{}{"secret": "value"},
assert: func(t *testing.T, got interface{}) {
t.Helper()
body, ok := got.(map[string]interface{})
if !ok || body["_storage_mode"] != "metadata_only" {
t.Fatalf("expected metadata_only placeholder, got %#v", got)
}
},
},
{
name: "request capture disabled returns placeholder",
cfg: &config.StorageConfig{
CaptureRequestBody: false,
},
body: map[string]interface{}{"secret": "value"},
assert: func(t *testing.T, got interface{}) {
t.Helper()
body, ok := got.(map[string]interface{})
if !ok || body["_storage_mode"] != "request_body_disabled" {
t.Fatalf("expected request_body_disabled placeholder, got %#v", got)
}
},
},
{
name: "redacts nested fields",
cfg: &config.StorageConfig{
CaptureRequestBody: true,
RedactedFields: []string{"authorization", "password"},
},
body: map[string]interface{}{
"authorization": "top-secret",
"nested": map[string]interface{}{
"password": "hide-me",
"keep": "visible",
},
},
assert: func(t *testing.T, got interface{}) {
t.Helper()
body := got.(map[string]interface{})
if body["authorization"] != redactionPlaceholder {
t.Fatalf("expected top-level field redacted, got %#v", body["authorization"])
}
nested := body["nested"].(map[string]interface{})
if nested["password"] != redactionPlaceholder {
t.Fatalf("expected nested field redacted, got %#v", nested["password"])
}
if nested["keep"] != "visible" {
t.Fatalf("expected keep field preserved, got %#v", nested["keep"])
}
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := prepareRequestBodyForStorage(tt.cfg, tt.body)
if err != nil {
t.Fatalf("prepareRequestBodyForStorage() error = %v", err)
}
tt.assert(t, got)
})
}
}
func TestPrepareResponseForStorage(t *testing.T) {
t.Parallel()
logger := log.New(io.Discard, "", 0)
t.Run("metadata only strips body fields", func(t *testing.T) {
t.Parallel()
got, err := prepareResponseForStorage(&config.StorageConfig{MetadataOnly: true}, logger, &model.ResponseLog{
Body: json.RawMessage(`{"secret":"value"}`),
BodyText: "raw",
StreamingChunks: []string{"chunk"},
ChunkTimings: []model.ChunkTiming{{Index: 0}},
})
if err != nil {
t.Fatalf("prepareResponseForStorage() error = %v", err)
}
if got == nil {
t.Fatal("expected response clone")
}
if got.Body != nil || got.BodyText != "" || got.StreamingChunks != nil || got.ChunkTimings != nil {
t.Fatalf("expected body fields stripped, got %#v", got)
}
})
t.Run("redacts json response bodies", func(t *testing.T) {
t.Parallel()
got, err := prepareResponseForStorage(&config.StorageConfig{
CaptureResponseBody: true,
RedactedFields: []string{"api_key"},
}, logger, &model.ResponseLog{
Body: json.RawMessage(`{"api_key":"secret","nested":{"keep":"ok"}}`),
})
if err != nil {
t.Fatalf("prepareResponseForStorage() error = %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(got.Body, &body); err != nil {
t.Fatalf("unmarshal redacted body: %v", err)
}
if body["api_key"] != redactionPlaceholder {
t.Fatalf("expected api_key redacted, got %#v", body["api_key"])
}
nested := body["nested"].(map[string]interface{})
if nested["keep"] != "ok" {
t.Fatalf("expected nested field preserved, got %#v", nested["keep"])
}
})
t.Run("preserves non json body bytes", func(t *testing.T) {
t.Parallel()
original := json.RawMessage(`not-json`)
got, err := prepareResponseForStorage(&config.StorageConfig{
CaptureResponseBody: true,
RedactedFields: []string{"token"},
}, logger, &model.ResponseLog{
Body: original,
})
if err != nil {
t.Fatalf("prepareResponseForStorage() error = %v", err)
}
if string(got.Body) != string(original) {
t.Fatalf("expected original non-json body preserved, got %q", string(got.Body))
}
})
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
package service
import (
"os"
"testing"
"github.com/seifghazi/claude-code-monitor/internal/config"
)
func TestPostgresStorageContract(t *testing.T) {
dsn := os.Getenv("TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("TEST_POSTGRES_DSN not set")
}
runStorageContractTests(t, storageFactory{
name: "postgres",
new: func(t *testing.T, cfg config.StorageConfig) StorageService {
t.Helper()
cfg.DBType = "postgres"
cfg.DatabaseURL = dsn
return newTestPostgresStorage(t, cfg)
},
})
}
func newTestPostgresStorage(t *testing.T, cfg config.StorageConfig) *postgresStorageService {
t.Helper()
storage, err := NewPostgresStorageService(&cfg)
if err != nil {
t.Fatalf("NewPostgresStorageService() error = %v", err)
}
pgStorage, ok := storage.(*postgresStorageService)
if !ok {
t.Fatalf("unexpected storage type %T", storage)
}
resetPostgresTestStorage(t, pgStorage)
t.Cleanup(func() {
resetPostgresTestStorage(t, pgStorage)
if err := pgStorage.Close(); err != nil {
t.Errorf("Close() error = %v", err)
}
})
return pgStorage
}
func resetPostgresTestStorage(t *testing.T, storage *postgresStorageService) {
t.Helper()
if _, err := storage.db.Exec("TRUNCATE TABLE requests"); err != nil {
t.Fatalf("TRUNCATE requests error = %v", err)
}
if _, err := storage.db.Exec("DELETE FROM settings"); err != nil {
t.Fatalf("DELETE settings error = %v", err)
}
}

View file

@ -0,0 +1,38 @@
package service
import (
"strings"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
func modelFilterPattern(modelFilter string, escaper func(string) string) (string, bool) {
normalized := strings.TrimSpace(strings.ToLower(modelFilter))
if normalized == "" || normalized == "all" {
return "", false
}
return "%" + escaper(normalized) + "%", true
}
func addUsageStats(stats *model.UsageStats, modelName string, usage *model.AnthropicUsage) {
if stats == nil || usage == nil {
return
}
inputTokens := int64(usage.InputTokens)
outputTokens := int64(usage.OutputTokens)
cacheTokens := int64(usage.CacheCreationInputTokens + usage.CacheReadInputTokens)
stats.TotalRequests++
stats.TotalInputTokens += inputTokens
stats.TotalOutputTokens += outputTokens
stats.TotalCacheTokens += cacheTokens
modelStats := stats.RequestsByModel[modelName]
modelStats.RequestCount++
modelStats.InputTokens += inputTokens
modelStats.OutputTokens += outputTokens
modelStats.CacheTokens += cacheTokens
stats.RequestsByModel[modelName] = modelStats
}

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"sort"
"strings" "strings"
"time" "time"
@ -115,35 +116,44 @@ func (s *sqliteStorageService) createTables() error {
} }
// Run migrations // Run migrations
s.migrateSchema() if err := s.migrateSchema(); err != nil {
return err
}
return nil return nil
} }
func (s *sqliteStorageService) migrateSchema() { func (s *sqliteStorageService) migrateSchema() error {
// Ensure WAL mode is enabled (in case opened without connection string params) // Ensure WAL mode is enabled (in case opened without connection string params)
_, err := s.db.Exec("PRAGMA journal_mode=WAL") _, err := s.db.Exec("PRAGMA journal_mode=WAL")
if err != nil { if err != nil {
s.logger.Printf("Warning: failed to set WAL mode: %v", err) return fmt.Errorf("failed to set WAL mode: %w", err)
} }
// Drop old redundant index if it exists (we renamed to idx_requests_timestamp) return runMigrations(s.db, []string{
s.db.Exec("DROP INDEX IF EXISTS idx_timestamp") "DROP INDEX IF EXISTS idx_timestamp",
"ALTER TABLE requests ADD COLUMN conversation_hash TEXT",
"ALTER TABLE requests ADD COLUMN message_count INTEGER DEFAULT 0",
"CREATE INDEX IF NOT EXISTS idx_requests_conversation_hash ON requests(conversation_hash)",
"ALTER TABLE requests ADD COLUMN organization_id TEXT",
"CREATE INDEX IF NOT EXISTS idx_requests_organization_id ON requests(organization_id)",
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
}, ignoreSQLiteDuplicateColumn)
} }
func (s *sqliteStorageService) prepareStatements() error { func (s *sqliteStorageService) prepareStatements() error {
var err error var err error
s.stmtInsertRequest, err = s.db.Prepare(` s.stmtInsertRequest, err = s.db.Prepare(`
INSERT INTO requests (id, timestamp, method, endpoint, headers, body, user_agent, content_type, model, original_model, routed_model) INSERT INTO requests (id, timestamp, method, endpoint, headers, body, user_agent, content_type, model, original_model, routed_model, conversation_hash, message_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`) `)
if err != nil { if err != nil {
return fmt.Errorf("failed to prepare insert statement: %w", err) return fmt.Errorf("failed to prepare insert statement: %w", err)
} }
s.stmtUpdateResponse, err = s.db.Prepare(` s.stmtUpdateResponse, err = s.db.Prepare(`
UPDATE requests SET response = ? WHERE id = ? UPDATE requests SET response = ?, organization_id = COALESCE(NULLIF(?, ''), organization_id) WHERE id = ?
`) `)
if err != nil { if err != nil {
return fmt.Errorf("failed to prepare update response statement: %w", err) return fmt.Errorf("failed to prepare update response statement: %w", err)
@ -198,7 +208,7 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e
return "", fmt.Errorf("failed to marshal headers: %w", err) return "", fmt.Errorf("failed to marshal headers: %w", err)
} }
bodyForStorage, err := s.prepareRequestBodyForStorage(request.Body) bodyForStorage, err := prepareRequestBodyForStorage(s.config, request.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to prepare body for storage: %w", err) return "", fmt.Errorf("failed to prepare body for storage: %w", err)
} }
@ -220,6 +230,8 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e
request.Model, request.Model,
request.OriginalModel, request.OriginalModel,
request.RoutedModel, request.RoutedModel,
request.ConversationHash,
request.MessageCount,
) )
if err != nil { if err != nil {
@ -238,11 +250,8 @@ func (s *sqliteStorageService) GetRequests(page, limit int, modelFilter string)
countArgs := []interface{}{} countArgs := []interface{}{}
queryArgs := []interface{}{} queryArgs := []interface{}{}
if modelFilter != "" && modelFilter != "all" { if filterValue, ok := modelFilterPattern(modelFilter, escapeLikePattern); ok {
// Escape LIKE special characters to prevent pattern injection
escapedFilter := escapeLikePattern(strings.ToLower(modelFilter))
whereClause = " WHERE LOWER(model) LIKE ? ESCAPE '\\'" whereClause = " WHERE LOWER(model) LIKE ? ESCAPE '\\'"
filterValue := "%" + escapedFilter + "%"
countArgs = append(countArgs, filterValue) countArgs = append(countArgs, filterValue)
queryArgs = append(queryArgs, filterValue) queryArgs = append(queryArgs, filterValue)
} }
@ -323,7 +332,7 @@ func (s *sqliteStorageService) UpdateRequestWithGrading(requestID string, grade
} }
func (s *sqliteStorageService) UpdateRequestWithResponse(request *model.RequestLog) error { func (s *sqliteStorageService) UpdateRequestWithResponse(request *model.RequestLog) error {
responseForStorage, err := s.prepareResponseForStorage(request.Response) responseForStorage, err := prepareResponseForStorage(s.config, s.logger, request.Response)
if err != nil { if err != nil {
return fmt.Errorf("failed to prepare response for storage: %w", err) return fmt.Errorf("failed to prepare response for storage: %w", err)
} }
@ -333,7 +342,8 @@ func (s *sqliteStorageService) UpdateRequestWithResponse(request *model.RequestL
return fmt.Errorf("failed to marshal response: %w", err) return fmt.Errorf("failed to marshal response: %w", err)
} }
result, err := s.stmtUpdateResponse.Exec(string(responseJSON), request.RequestID) orgID := request.OrganizationID
result, err := s.stmtUpdateResponse.Exec(string(responseJSON), orgID, request.RequestID)
if err != nil { if err != nil {
return fmt.Errorf("failed to update request with response: %w", err) return fmt.Errorf("failed to update request with response: %w", err)
} }
@ -359,7 +369,7 @@ func (s *sqliteStorageService) SaveRequestWithResponse(request *model.RequestLog
return fmt.Errorf("failed to marshal headers: %w", err) return fmt.Errorf("failed to marshal headers: %w", err)
} }
bodyForStorage, err := s.prepareRequestBodyForStorage(request.Body) bodyForStorage, err := prepareRequestBodyForStorage(s.config, request.Body)
if err != nil { if err != nil {
return fmt.Errorf("failed to prepare body for storage: %w", err) return fmt.Errorf("failed to prepare body for storage: %w", err)
} }
@ -382,6 +392,8 @@ func (s *sqliteStorageService) SaveRequestWithResponse(request *model.RequestLog
request.Model, request.Model,
request.OriginalModel, request.OriginalModel,
request.RoutedModel, request.RoutedModel,
request.ConversationHash,
request.MessageCount,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to insert request: %w", err) return fmt.Errorf("failed to insert request: %w", err)
@ -389,7 +401,7 @@ func (s *sqliteStorageService) SaveRequestWithResponse(request *model.RequestLog
// Update with response if present // Update with response if present
if request.Response != nil { if request.Response != nil {
responseForStorage, err := s.prepareResponseForStorage(request.Response) responseForStorage, err := prepareResponseForStorage(s.config, s.logger, request.Response)
if err != nil { if err != nil {
return fmt.Errorf("failed to prepare response for storage: %w", err) return fmt.Errorf("failed to prepare response for storage: %w", err)
} }
@ -460,7 +472,7 @@ func (s *sqliteStorageService) GetRequestByShortID(shortID string) (*model.Reque
return nil, "", fmt.Errorf("failed to query request: %w", err) return nil, "", fmt.Errorf("failed to query request: %w", err)
} }
if err := s.unmarshalRequestFields(&req, headersJSON, bodyJSON, promptGradeJSON, responseJSON); err != nil { if err := unmarshalStoredRequestFields(s.logger, &req, headersJSON, bodyJSON, promptGradeJSON, responseJSON); err != nil {
return nil, "", err return nil, "", err
} }
@ -480,16 +492,14 @@ func (s *sqliteStorageService) GetAllRequestsWithLimit(modelFilter string, limit
var query string var query string
args := []interface{}{} args := []interface{}{}
if modelFilter != "" && modelFilter != "all" { if filterValue, ok := modelFilterPattern(modelFilter, escapeLikePattern); ok {
// Escape LIKE special characters
escapedFilter := escapeLikePattern(strings.ToLower(modelFilter))
query = ` query = `
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response, original_model, routed_model SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response, original_model, routed_model
FROM requests FROM requests
WHERE LOWER(model) LIKE ? ESCAPE '\' WHERE LOWER(model) LIKE ? ESCAPE '\'
ORDER BY timestamp DESC ORDER BY timestamp DESC
` `
args = append(args, "%"+escapedFilter+"%") args = append(args, filterValue)
} else { } else {
query = ` query = `
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response, original_model, routed_model SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response, original_model, routed_model
@ -615,8 +625,6 @@ func (s *sqliteStorageService) Close() error {
// Helper functions // Helper functions
const redactionPlaceholder = "[REDACTED]"
// escapeLikePattern escapes special characters in LIKE patterns // escapeLikePattern escapes special characters in LIKE patterns
func escapeLikePattern(s string) string { func escapeLikePattern(s string) string {
// Escape \, %, and _ characters // Escape \, %, and _ characters
@ -655,7 +663,7 @@ func (s *sqliteStorageService) scanRequestRows(rows *sql.Rows) ([]model.RequestL
continue continue
} }
if err := s.unmarshalRequestFields(&req, headersJSON, bodyJSON, promptGradeJSON, responseJSON); err != nil { if err := unmarshalStoredRequestFields(s.logger, &req, headersJSON, bodyJSON, promptGradeJSON, responseJSON); err != nil {
s.logger.Printf("Warning: failed to unmarshal request fields: %v", err) s.logger.Printf("Warning: failed to unmarshal request fields: %v", err)
continue continue
} }
@ -695,46 +703,13 @@ func (s *sqliteStorageService) scanSingleRow(rows *sql.Rows) (*model.RequestLog,
return nil, fmt.Errorf("failed to scan row: %w", err) return nil, fmt.Errorf("failed to scan row: %w", err)
} }
if err := s.unmarshalRequestFields(&req, headersJSON, bodyJSON, promptGradeJSON, responseJSON); err != nil { if err := unmarshalStoredRequestFields(s.logger, &req, headersJSON, bodyJSON, promptGradeJSON, responseJSON); err != nil {
return nil, err return nil, err
} }
return &req, nil return &req, nil
} }
// unmarshalRequestFields unmarshals JSON fields into a RequestLog
func (s *sqliteStorageService) unmarshalRequestFields(req *model.RequestLog, headersJSON, bodyJSON string, promptGradeJSON, responseJSON sql.NullString) error {
if err := json.Unmarshal([]byte(headersJSON), &req.Headers); err != nil {
return fmt.Errorf("failed to unmarshal headers: %w", err)
}
var body interface{}
if err := json.Unmarshal([]byte(bodyJSON), &body); err != nil {
return fmt.Errorf("failed to unmarshal body: %w", err)
}
req.Body = body
if promptGradeJSON.Valid {
var grade model.PromptGrade
if err := json.Unmarshal([]byte(promptGradeJSON.String), &grade); err != nil {
s.logger.Printf("Warning: failed to unmarshal prompt grade: %v", err)
} else {
req.PromptGrade = &grade
}
}
if responseJSON.Valid {
var resp model.ResponseLog
if err := json.Unmarshal([]byte(responseJSON.String), &resp); err != nil {
s.logger.Printf("Warning: failed to unmarshal response: %v", err)
} else {
req.Response = &resp
}
}
return nil
}
func (s *sqliteStorageService) cleanupExpiredRequests() error { func (s *sqliteStorageService) cleanupExpiredRequests() error {
if s.config == nil || s.config.RetentionDays <= 0 { if s.config == nil || s.config.RetentionDays <= 0 {
return nil return nil
@ -744,136 +719,498 @@ func (s *sqliteStorageService) cleanupExpiredRequests() error {
return err return err
} }
func (s *sqliteStorageService) prepareRequestBodyForStorage(body interface{}) (interface{}, error) { // GetUsageStats returns aggregated token usage statistics
if s.shouldSuppressBodies() { func (s *sqliteStorageService) GetUsageStats(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error) {
return storageBodyPlaceholder("metadata_only"), nil stats := &model.UsageStats{
} RequestsByModel: make(map[string]model.ModelStats),
if s.config != nil && !s.config.CaptureRequestBody {
return storageBodyPlaceholder("request_body_disabled"), nil
} }
normalized, err := normalizeJSONValue(body) // Build query with optional filters
whereClause := "WHERE response IS NOT NULL"
args := []interface{}{}
if startDate != "" {
whereClause += " AND timestamp >= ?"
args = append(args, startDate)
stats.StartDate = startDate
}
if endDate != "" {
whereClause += " AND timestamp <= ?"
args = append(args, endDate)
stats.EndDate = endDate
}
if filterValue, ok := modelFilterPattern(modelFilter, escapeLikePattern); ok {
whereClause += " AND LOWER(model) LIKE ? ESCAPE '\\'"
args = append(args, filterValue)
}
if orgFilter != "" {
whereClause += " AND organization_id = ?"
args = append(args, orgFilter)
}
// Query all responses and aggregate token usage
query := `
SELECT model, response
FROM requests
` + whereClause
rows, err := s.db.Query(query, args...)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to query usage stats: %w", err)
} }
defer rows.Close()
fields := []string{} for rows.Next() {
if s.config != nil { var modelName string
fields = s.config.RedactedFields var responseJSON sql.NullString
}
return redactJSONValue(normalized, redactedFieldSet(fields)), nil if err := rows.Scan(&modelName, &responseJSON); err != nil {
} s.logger.Printf("Warning: failed to scan usage row: %v", err)
func (s *sqliteStorageService) prepareResponseForStorage(response *model.ResponseLog) (*model.ResponseLog, error) {
if response == nil {
return nil, nil
}
clone := *response
if s.shouldSuppressBodies() || (s.config != nil && !s.config.CaptureResponseBody) {
clone.Body = nil
clone.BodyText = ""
clone.StreamingChunks = nil
return &clone, nil
}
if len(clone.Body) > 0 {
fields := []string{}
if s.config != nil {
fields = s.config.RedactedFields
}
sanitizedBody, err := sanitizeRawJSON(clone.Body, redactedFieldSet(fields))
if err != nil {
// Preserve the original payload if it cannot be parsed as JSON.
s.logger.Printf("Warning: failed to redact response body: %v", err)
} else {
clone.Body = sanitizedBody
}
}
return &clone, nil
}
func (s *sqliteStorageService) shouldSuppressBodies() bool {
return s.config != nil && s.config.MetadataOnly
}
func normalizeJSONValue(value interface{}) (interface{}, error) {
if value == nil {
return nil, nil
}
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var normalized interface{}
if err := json.Unmarshal(data, &normalized); err != nil {
return nil, err
}
return normalized, nil
}
func sanitizeRawJSON(raw json.RawMessage, redacted map[string]struct{}) (json.RawMessage, error) {
if len(raw) == 0 {
return raw, nil
}
var value interface{}
if err := json.Unmarshal(raw, &value); err != nil {
return raw, err
}
sanitized := redactJSONValue(value, redacted)
data, err := json.Marshal(sanitized)
if err != nil {
return raw, err
}
return json.RawMessage(data), nil
}
func redactJSONValue(value interface{}, redacted map[string]struct{}) interface{} {
switch typed := value.(type) {
case map[string]interface{}:
result := make(map[string]interface{}, len(typed))
for key, child := range typed {
if _, ok := redacted[strings.ToLower(key)]; ok {
result[key] = redactionPlaceholder
continue continue
} }
result[key] = redactJSONValue(child, redacted)
}
return result
case []interface{}:
result := make([]interface{}, len(typed))
for i, child := range typed {
result[i] = redactJSONValue(child, redacted)
}
return result
default:
return value
}
}
func storageBodyPlaceholder(mode string) map[string]interface{} { resp, ok := decodeStoredResponse(responseJSON)
return map[string]interface{}{ if !ok {
"_storage_mode": mode,
}
}
func redactedFieldSet(fields []string) map[string]struct{} {
set := make(map[string]struct{}, len(fields))
for _, field := range fields {
field = strings.TrimSpace(strings.ToLower(field))
if field == "" {
continue continue
} }
set[field] = struct{}{} bodySummary, ok := decodeResponseBodySummary(resp.Body)
if !ok || bodySummary.Usage == nil {
continue
} }
return set
addUsageStats(stats, modelName, bodySummary.Usage)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating usage rows: %w", err)
}
// Get date range if not specified
if stats.StartDate == "" || stats.EndDate == "" {
var oldest, newest sql.NullString
err := s.db.QueryRow("SELECT MIN(timestamp), MAX(timestamp) FROM requests WHERE response IS NOT NULL").Scan(&oldest, &newest)
if err == nil {
if stats.StartDate == "" && oldest.Valid {
stats.StartDate = oldest.String
}
if stats.EndDate == "" && newest.Valid {
stats.EndDate = newest.String
}
}
}
return stats, nil
}
// GetRequestsSummary returns minimal data for list view - no body/headers, only usage from response
func (s *sqliteStorageService) GetRequestsSummary(modelFilter string) ([]*model.RequestSummary, error) {
query := `
SELECT id, timestamp, method, endpoint, model, original_model, routed_model, response, COALESCE(conversation_hash, ''), COALESCE(message_count, 0)
FROM requests
`
args := []interface{}{}
if filterValue, ok := modelFilterPattern(modelFilter, escapeLikePattern); ok {
query += " WHERE LOWER(model) LIKE ? ESCAPE '\\'"
args = append(args, filterValue)
}
query += " ORDER BY timestamp DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query requests: %w", err)
}
defer rows.Close()
var summaries []*model.RequestSummary
for rows.Next() {
var summary model.RequestSummary
var responseJSON sql.NullString
err := rows.Scan(
&summary.RequestID,
&summary.Timestamp,
&summary.Method,
&summary.Endpoint,
&summary.Model,
&summary.OriginalModel,
&summary.RoutedModel,
&responseJSON,
&summary.ConversationHash,
&summary.MessageCount,
)
if err != nil {
s.logger.Printf("Warning: failed to scan summary row: %v", err)
continue
}
// Only parse response to extract usage and status
applyStoredResponseToSummary(&summary, responseJSON)
summaries = append(summaries, &summary)
}
return summaries, nil
}
// GetRequestsSummaryPaginated returns minimal data for list view with pagination
func (s *sqliteStorageService) GetRequestsSummaryPaginated(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error) {
// Build WHERE clauses
whereClauses := []string{}
args := []interface{}{}
if filterValue, ok := modelFilterPattern(modelFilter, escapeLikePattern); ok {
whereClauses = append(whereClauses, "LOWER(model) LIKE ? ESCAPE '\\'")
args = append(args, filterValue)
}
if startTime != "" && endTime != "" {
whereClauses = append(whereClauses, "datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)")
args = append(args, startTime, endTime)
}
whereClause := ""
if len(whereClauses) > 0 {
whereClause = " WHERE " + strings.Join(whereClauses, " AND ")
}
// Get total count
var total int
countQuery := "SELECT COUNT(*) FROM requests" + whereClause
countArgs := make([]interface{}, len(args))
copy(countArgs, args)
if err := s.db.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
}
// Get the requested page
query := `
SELECT id, timestamp, method, endpoint, model, original_model, routed_model, response, COALESCE(conversation_hash, ''), COALESCE(message_count, 0)
FROM requests
` + whereClause + " ORDER BY timestamp DESC"
// Add pagination
if limit > 0 {
query += " LIMIT ? OFFSET ?"
args = append(args, limit, offset)
} else if offset > 0 {
query += " OFFSET ?"
args = append(args, offset)
}
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to query requests: %w", err)
}
defer rows.Close()
var summaries []*model.RequestSummary
for rows.Next() {
var summary model.RequestSummary
var responseJSON sql.NullString
err := rows.Scan(
&summary.RequestID,
&summary.Timestamp,
&summary.Method,
&summary.Endpoint,
&summary.Model,
&summary.OriginalModel,
&summary.RoutedModel,
&responseJSON,
&summary.ConversationHash,
&summary.MessageCount,
)
if err != nil {
s.logger.Printf("Warning: failed to scan summary row: %v", err)
continue
}
// Only parse response to extract usage and status
applyStoredResponseToSummary(&summary, responseJSON)
summaries = append(summaries, &summary)
}
s.logger.Printf("📊 GetRequestsSummaryPaginated: returned %d requests (total: %d, limit: %d, offset: %d)", len(summaries), total, limit, offset)
return summaries, total, nil
}
// GetStats returns aggregated statistics for the dashboard - daily token usage
func (s *sqliteStorageService) GetStats(startDate, endDate, orgFilter string) (*model.DashboardStats, error) {
stats := &model.DashboardStats{
DailyStats: make([]model.DailyTokens, 0),
}
query := `
SELECT timestamp, COALESCE(model, 'unknown') as model, response
FROM requests
WHERE datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)
`
args := []interface{}{startDate, endDate}
if orgFilter != "" {
query += ` AND organization_id = ?`
args = append(args, orgFilter)
}
query += ` ORDER BY timestamp`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query stats: %w", err)
}
defer rows.Close()
// Aggregate data in memory
dailyMap := make(map[string]*model.DailyTokens)
for rows.Next() {
var timestamp, modelName string
var responseJSON sql.NullString
if err := rows.Scan(&timestamp, &modelName, &responseJSON); err != nil {
continue
}
// Extract date from timestamp (format: 2025-11-28T13:03:29-08:00)
date := strings.Split(timestamp, "T")[0]
// Parse response to get usage
tokens := int64(0)
if resp, ok := decodeStoredResponse(responseJSON); ok {
if bodySummary, ok := decodeResponseBodySummary(resp.Body); ok {
tokens = totalTokensFromUsage(bodySummary.Usage)
}
}
addDailyTokens(dailyMap, date, modelName, tokens)
}
// Convert map to slice
for _, v := range dailyMap {
stats.DailyStats = append(stats.DailyStats, *v)
}
return stats, nil
}
// GetHourlyStats returns time-bucketed breakdown for a specific time range.
// bucketMinutes controls the granularity (e.g. 5, 15, 30, 60).
func (s *sqliteStorageService) GetHourlyStats(startTime, endTime string, bucketMinutes int, orgFilter string) (*model.HourlyStatsResponse, error) {
if bucketMinutes <= 0 {
bucketMinutes = 60
}
query := `
SELECT timestamp, COALESCE(model, 'unknown') as model, response
FROM requests
WHERE datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)
`
args := []interface{}{startTime, endTime}
if orgFilter != "" {
query += ` AND organization_id = ?`
args = append(args, orgFilter)
}
query += ` ORDER BY timestamp`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query hourly stats: %w", err)
}
defer rows.Close()
bucketMap := make(map[string]*model.HourlyTokens)
var totalTokens int64
var totalRequests int
var totalResponseTime int64
var responseCount int
for rows.Next() {
var timestamp, modelName string
var responseJSON sql.NullString
if err := rows.Scan(&timestamp, &modelName, &responseJSON); err != nil {
continue
}
// Compute bucket key from timestamp
bucketKey := ""
bucketLabel := ""
if t, err := time.Parse(time.RFC3339, timestamp); err == nil {
// Always use absolute time buckets so multi-day ranges show per-slot data
minuteOfDay := t.Hour()*60 + t.Minute()
bucketStart := (minuteOfDay / bucketMinutes) * bucketMinutes
bucketTime := time.Date(t.Year(), t.Month(), t.Day(), bucketStart/60, bucketStart%60, 0, 0, t.Location())
bucketKey = bucketTime.Format("2006-01-02T15:04")
bucketLabel = bucketTime.Format("Jan 2 15:04")
}
// Parse response to get usage and response time
tokens := int64(0)
responseTime := int64(0)
if resp, ok := decodeStoredResponse(responseJSON); ok {
responseTime = resp.ResponseTime
if bodySummary, ok := decodeResponseBodySummary(resp.Body); ok {
tokens = totalTokensFromUsage(bodySummary.Usage)
}
}
totalTokens += tokens
totalRequests++
// Track response time
if responseTime > 0 {
totalResponseTime += responseTime
responseCount++
}
addHourlyTokens(bucketMap, bucketKey, bucketLabel, modelName, tokens)
}
// Convert map to sorted slice
keys := make([]string, 0, len(bucketMap))
for k := range bucketMap {
keys = append(keys, k)
}
sort.Strings(keys)
hourlyStats := make([]model.HourlyTokens, 0, len(keys))
for _, k := range keys {
hourlyStats = append(hourlyStats, *bucketMap[k])
}
// Calculate average response time
avgResponseTime := int64(0)
if responseCount > 0 {
avgResponseTime = totalResponseTime / int64(responseCount)
}
return &model.HourlyStatsResponse{
HourlyStats: hourlyStats,
TodayTokens: totalTokens,
TodayRequests: totalRequests,
AvgResponseTime: avgResponseTime,
}, nil
}
// GetModelStats returns model breakdown for a specific time range
func (s *sqliteStorageService) GetModelStats(startTime, endTime, orgFilter string) (*model.ModelStatsResponse, error) {
query := `
SELECT COALESCE(model, 'unknown') as model, response
FROM requests
WHERE datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)
`
args := []interface{}{startTime, endTime}
if orgFilter != "" {
query += ` AND organization_id = ?`
args = append(args, orgFilter)
}
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query model stats: %w", err)
}
defer rows.Close()
modelMap := make(map[string]*model.ModelTokens)
for rows.Next() {
var modelName string
var responseJSON sql.NullString
if err := rows.Scan(&modelName, &responseJSON); err != nil {
continue
}
// Parse response to get usage
tokens := int64(0)
if resp, ok := decodeStoredResponse(responseJSON); ok {
if bodySummary, ok := decodeResponseBodySummary(resp.Body); ok {
tokens = totalTokensFromUsage(bodySummary.Usage)
}
}
addModelTokens(modelMap, modelName, tokens)
}
// Convert map to slice
modelStats := make([]model.ModelTokens, 0)
for _, v := range modelMap {
modelStats = append(modelStats, *v)
}
return &model.ModelStatsResponse{
ModelStats: modelStats,
}, nil
}
// GetLatestRequestDate returns the timestamp of the most recent request
func (s *sqliteStorageService) GetLatestRequestDate() (*time.Time, error) {
var timestamp string
err := s.db.QueryRow("SELECT timestamp FROM requests ORDER BY timestamp DESC LIMIT 1").Scan(&timestamp)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to query latest request: %w", err)
}
t, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
return nil, fmt.Errorf("failed to parse timestamp: %w", err)
}
return &t, nil
}
func (s *sqliteStorageService) GetSettings() (*model.ProxySettings, error) {
var value string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = 'proxy_settings'").Scan(&value)
if err == sql.ErrNoRows {
return &model.ProxySettings{}, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get settings: %w", err)
}
var settings model.ProxySettings
if err := json.Unmarshal([]byte(value), &settings); err != nil {
return nil, fmt.Errorf("failed to parse settings: %w", err)
}
return &settings, nil
}
func (s *sqliteStorageService) SaveSettings(settings *model.ProxySettings) error {
data, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("failed to marshal settings: %w", err)
}
_, err = s.db.Exec(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('proxy_settings', ?)",
string(data),
)
if err != nil {
return fmt.Errorf("failed to save settings: %w", err)
}
return nil
}
func (s *sqliteStorageService) GetDistinctOrganizations() ([]string, error) {
rows, err := s.db.Query(`SELECT DISTINCT organization_id FROM requests WHERE organization_id IS NOT NULL AND organization_id != '' ORDER BY organization_id`)
if err != nil {
return nil, fmt.Errorf("failed to query organizations: %w", err)
}
defer rows.Close()
var orgs []string
for rows.Next() {
var org string
if err := rows.Scan(&org); err != nil {
continue
}
orgs = append(orgs, org)
}
return orgs, nil
} }

View file

@ -0,0 +1,19 @@
package service
import (
"testing"
"github.com/seifghazi/claude-code-monitor/internal/config"
)
func TestSQLiteStorageContract(t *testing.T) {
runStorageContractTests(t, storageFactory{
name: "sqlite",
new: newTestSQLiteStorageService,
})
}
func newTestSQLiteStorageService(t *testing.T, cfg config.StorageConfig) StorageService {
t.Helper()
return newTestSQLiteStorage(t, cfg)
}

26
run.sh
View file

@ -1,10 +1,10 @@
#!/bin/bash #!/bin/bash
# Claude Code Monitor - Build and Run Script # Claude Code Proxy - Build and Run Script
set -e set -e
echo "🚀 Claude Code Monitor - Starting Services" echo "🚀 Claude Code Proxy - Starting Services"
echo "=========================================" echo "========================================="
# Colors for output # Colors for output
@ -40,7 +40,7 @@ fi
# Function to cleanup on exit # Function to cleanup on exit
cleanup() { cleanup() {
echo -e "\n${YELLOW}Shutting down services...${NC}" echo -e "\n${YELLOW}Shutting down services...${NC}"
kill $PROXY_PID $WEB_PID 2>/dev/null || true kill $PROXY_PID $SVELTE_PID 2>/dev/null || true
exit exit
} }
@ -55,13 +55,13 @@ cd ..
echo -e "${GREEN}✅ Proxy server built${NC}" echo -e "${GREEN}✅ Proxy server built${NC}"
# Install web dependencies if needed # Install svelte dependencies if needed
if [ ! -d "web/node_modules" ]; then if [ ! -d "svelte/node_modules" ]; then
echo -e "\n${BLUE}📦 Installing web dependencies...${NC}" echo -e "\n${BLUE}📦 Installing svelte dependencies...${NC}"
cd web cd svelte
npm install npm install
cd .. cd ..
echo -e "${GREEN}Web dependencies installed${NC}" echo -e "${GREEN}Svelte dependencies installed${NC}"
fi fi
# Start proxy server # Start proxy server
@ -72,16 +72,16 @@ PROXY_PID=$!
# Wait for proxy to start # Wait for proxy to start
sleep 2 sleep 2
# Start web server # Start svelte server
echo -e "${BLUE}🚀 Starting web interface on port 5173...${NC}" echo -e "${BLUE}🚀 Starting Svelte Dashboard on port 5174...${NC}"
cd web cd svelte
npm run dev & npm run dev &
WEB_PID=$! SVELTE_PID=$!
cd .. cd ..
echo -e "\n${GREEN}✨ All services started!${NC}" echo -e "\n${GREEN}✨ All services started!${NC}"
echo "=========================================" echo "========================================="
echo -e "📊 Web Dashboard: ${BLUE}http://localhost:5173${NC}" echo -e "📊 Svelte Dashboard: ${BLUE}http://localhost:5174${NC}"
echo -e "🔌 API Proxy: ${BLUE}http://localhost:3001${NC}" echo -e "🔌 API Proxy: ${BLUE}http://localhost:3001${NC}"
echo -e "💚 Health Check: ${BLUE}http://localhost:3001/health${NC}" echo -e "💚 Health Check: ${BLUE}http://localhost:3001/health${NC}"
echo "=========================================" echo "========================================="

View file

@ -0,0 +1,13 @@
export const DEFAULT_BACKEND_ORIGIN = 'http://localhost:3001';
export function resolveBackendOrigin(env: Record<string, string | undefined>): string {
return (env.BACKEND_URL || env.PROXY_BACKEND_URL || DEFAULT_BACKEND_ORIGIN).replace(/\/$/, '');
}
export function buildBackendURL(origin: string, path: string, searchParams?: URLSearchParams): string {
const url = new URL(path, `${origin}/`);
if (searchParams) {
url.search = searchParams.toString();
}
return url.toString();
}

View file

@ -0,0 +1,382 @@
import type { MessageContent, TextContentBlock } from './types';
/**
* Utility functions for formatting and displaying data
*/
/**
* Safely converts unknown values to a formatted string for display
*/
export function formatValue(value: unknown): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
/**
* Formats JSON with proper indentation and returns a formatted string
* Set maxLength to 0 or Infinity for no truncation
*/
export function formatJSON(obj: unknown, maxLength: number = 50000): string {
try {
const jsonString = JSON.stringify(obj, null, 2);
if (maxLength > 0 && maxLength < Infinity && jsonString.length > maxLength) {
return jsonString.substring(0, maxLength) + '\n... (truncated - ' + jsonString.length.toLocaleString() + ' total chars)';
}
return jsonString;
} catch {
return String(obj);
}
}
/**
* Formats JSON without truncation
*/
export function formatJSONFull(obj: unknown): string {
try {
return JSON.stringify(obj, null, 2);
} catch {
return String(obj);
}
}
/**
* Escapes HTML characters to prevent XSS.
*/
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Formats large text with proper line breaks and structure.
* Supports markdown-like syntax: headings, bold, italic, inline code,
* fenced code blocks, bullet/numbered lists, horizontal rules, and URLs.
*/
export function formatLargeText(text: string): string {
if (!text) return '';
const codeBlocks: string[] = [];
const withPlaceholders = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, _lang, code) => {
const idx = codeBlocks.length;
codeBlocks.push(
`<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 text-sm font-mono overflow-x-auto my-3 border border-gray-700"><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`
);
return `\x00CODEBLOCK_${idx}\x00`;
});
const escaped = escapeHtml(withPlaceholders);
const lines = escaped.split('\n');
const outputLines: string[] = [];
let inList = false;
let listType: 'ul' | 'ol' = 'ul';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const cbMatch = line.match(/\x00CODEBLOCK_(\d+)\x00/);
if (cbMatch) {
if (inList) {
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
}
outputLines.push(codeBlocks[parseInt(cbMatch[1], 10)]);
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
if (inList) {
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
}
outputLines.push('<hr class="my-4 border-gray-300">');
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
if (inList) {
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
}
const level = headingMatch[1].length;
const headingText = headingMatch[2];
const sizes: Record<number, string> = {
1: 'text-xl font-bold text-gray-900 mt-5 mb-2',
2: 'text-lg font-bold text-gray-900 mt-4 mb-2',
3: 'text-base font-semibold text-gray-800 mt-3 mb-1',
4: 'text-sm font-semibold text-gray-800 mt-2 mb-1',
5: 'text-sm font-medium text-gray-700 mt-2 mb-1',
6: 'text-xs font-medium text-gray-700 mt-2 mb-1',
};
outputLines.push(`<div class="${sizes[level] || sizes[3]}">${applyInlineFormatting(headingText)}</div>`);
continue;
}
const bulletMatch = line.match(/^(\s*)[-*+]\s+(.+)$/);
if (bulletMatch) {
if (!inList || listType !== 'ul') {
if (inList) outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
outputLines.push('<ul class="list-disc list-inside space-y-1 my-2 text-gray-700">');
inList = true;
listType = 'ul';
}
outputLines.push(`<li class="leading-relaxed">${applyInlineFormatting(bulletMatch[2])}</li>`);
continue;
}
const numMatch = line.match(/^(\s*)\d+[.)]\s+(.+)$/);
if (numMatch) {
if (!inList || listType !== 'ol') {
if (inList) outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
outputLines.push('<ol class="list-decimal list-inside space-y-1 my-2 text-gray-700">');
inList = true;
listType = 'ol';
}
outputLines.push(`<li class="leading-relaxed">${applyInlineFormatting(numMatch[2])}</li>`);
continue;
}
if (inList && line.trim() !== '') {
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
}
if (line.trim() === '') {
outputLines.push('<div class="my-3"></div>');
continue;
}
outputLines.push(`<div class="leading-relaxed">${applyInlineFormatting(line)}</div>`);
}
if (inList) outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
return outputLines.join('\n');
}
function applyInlineFormatting(text: string): string {
return text
.replace(/\*\*([^*]+)\*\*/g, '<strong class="font-semibold text-gray-900">$1</strong>')
.replace(/\*([^*]+)\*/g, '<em class="italic text-gray-700">$1</em>')
.replace(/`([^`]+)`/g, '<code class="bg-gray-100 text-gray-800 px-1 py-0.5 rounded text-[0.85em] font-mono border border-gray-200">$1</code>')
.replace(/\bhttps?:\/\/[^\s<&]+/g, (url) => {
return `<a href="${url}" class="text-blue-600 hover:text-blue-800 underline underline-offset-2 decoration-blue-300 hover:decoration-blue-500 transition-colors font-medium" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
}
export function isComplexObject(value: unknown): boolean {
return value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length > 0;
}
export function truncateText(text: string, maxLength: number = 2000): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '... (' + (text.length - maxLength).toLocaleString() + ' more chars)';
}
export function formatTimestamp(timestamp: string | Date): string {
try {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
} catch {
return String(timestamp);
}
}
/**
* Format a duration in milliseconds to a human-readable short string (e.g. "3s", "5m", "2h")
*/
export function formatDuration(milliseconds: number): string {
if (milliseconds < 60000) return `${Math.round(milliseconds / 1000)}s`;
if (milliseconds < 3600000) return `${Math.round(milliseconds / 60000)}m`;
return `${Math.round(milliseconds / 3600000)}h`;
}
/**
* Format a timestamp string to a short time (e.g. "02:30 PM")
*/
export function formatTime(timestamp: string): string {
try {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true });
} catch {
return timestamp;
}
}
/**
* Format a timestamp to date string using toLocaleDateString()
*/
export function formatDate(timestamp: string | Date): string {
try {
return new Date(timestamp).toLocaleDateString();
} catch {
return String(timestamp);
}
}
/**
* Format a timestamp to time string using toLocaleTimeString()
*/
export function formatTimeOfDay(timestamp: string | Date): string {
try {
return new Date(timestamp).toLocaleTimeString();
} catch {
return String(timestamp);
}
}
export function formatFileSize(bytes: number): string {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
export interface XmlSegment {
type: 'text' | 'xml';
content: string;
tag?: string;
innerContent?: string;
}
export function parseXmlBlocks(text: string): XmlSegment[] {
if (!text) return [];
const result: XmlSegment[] = [];
const tagPattern = /<([a-z][a-z0-9_-]*(?:\s[^>]*)?)>/gi;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = tagPattern.exec(text)) !== null) {
const fullOpenTag = match[0];
const tagContent = match[1];
const tagName = tagContent.split(/\s/)[0];
const openPos = match.index;
const closeTag = `</${tagName}>`;
let depth = 1;
let searchPos = openPos + fullOpenTag.length;
while (depth > 0 && searchPos < text.length) {
const nextOpen = text.indexOf(`<${tagName}`, searchPos);
const nextClose = text.indexOf(closeTag, searchPos);
if (nextClose === -1) break;
if (nextOpen !== -1 && nextOpen < nextClose) {
const charAfterName = text[nextOpen + tagName.length + 1];
if (charAfterName === '>' || charAfterName === ' ' || charAfterName === '\n') {
depth++;
}
searchPos = nextOpen + tagName.length + 2;
} else {
depth--;
if (depth === 0) {
const innerStart = openPos + fullOpenTag.length;
const innerEnd = nextClose;
const blockEnd = nextClose + closeTag.length;
if (openPos > lastIndex) {
const preceding = text.substring(lastIndex, openPos).trim();
if (preceding) result.push({ type: 'text', content: preceding });
}
result.push({
type: 'xml',
content: text.substring(openPos, blockEnd),
tag: tagName,
innerContent: text.substring(innerStart, innerEnd)
});
lastIndex = blockEnd;
tagPattern.lastIndex = blockEnd;
} else {
searchPos = nextClose + closeTag.length;
}
}
}
if (depth > 0) {
tagPattern.lastIndex = openPos + fullOpenTag.length;
}
}
if (lastIndex < text.length) {
const remaining = text.substring(lastIndex).trim();
if (remaining) result.push({ type: 'text', content: remaining });
}
return result;
}
export function hasCustomXmlBlocks(text: string): boolean {
return /<[a-z][a-z0-9_-]*(?:\s[^>]*)?>[\s\S]*?<\/[a-z][a-z0-9_-]*>/i.test(text);
}
export function getXmlTagStyle(tag: string): { bg: string; border: string; headerBg: string; text: string; icon: string } {
if (/^(system-reminder|thinking_mode|reasoning_effort|antml_thinking_mode|fast_mode_info|claude[A-Z_-])/.test(tag)) {
return { bg: 'bg-amber-50', border: 'border-amber-200', headerBg: 'bg-amber-100', text: 'text-amber-800', icon: 'settings' };
}
if (/^(functions?|function_calls?|antml_function|antml_invoke|antml_parameter|tool)/.test(tag)) {
return { bg: 'bg-emerald-50', border: 'border-emerald-200', headerBg: 'bg-emerald-100', text: 'text-emerald-800', icon: 'wrench' };
}
if (/^(local-command|command-|user-prompt)/.test(tag)) {
return { bg: 'bg-blue-50', border: 'border-blue-200', headerBg: 'bg-blue-100', text: 'text-blue-800', icon: 'terminal' };
}
if (/^(types?|examples?|skills?|context|references?)/.test(tag)) {
return { bg: 'bg-purple-50', border: 'border-purple-200', headerBg: 'bg-purple-100', text: 'text-purple-800', icon: 'database' };
}
return { bg: 'bg-gray-50', border: 'border-gray-200', headerBg: 'bg-gray-100', text: 'text-gray-700', icon: 'code' };
}
function isTextContentBlock(value: unknown): value is TextContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'text' && 'text' in value && typeof value.text === 'string';
}
export function createContentPreview(content: MessageContent | unknown, maxLength: number = 100): string {
if (typeof content === 'string') {
return content.length > maxLength ? content.substring(0, maxLength) + '...' : content;
}
if (Array.isArray(content)) {
const textContent = content.find((item) => isTextContentBlock(item))?.text || '';
if (textContent) {
return textContent.length > maxLength ? textContent.substring(0, maxLength) + '...' : textContent;
}
return `${content.length} content blocks`;
}
if (content && typeof content === 'object') {
if ('text' in content && typeof content.text === 'string') {
return content.text.length > maxLength ? content.text.substring(0, maxLength) + '...' : content.text;
}
return 'Complex content';
}
return 'No content';
}

307
shared/frontend/types.ts Normal file
View file

@ -0,0 +1,307 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
export type JsonObject = { [key: string]: JsonValue };
export interface HeaderRule {
header: string;
action: 'block' | 'set' | 'replace';
value?: string;
find?: string;
enabled: boolean;
}
export interface ProxySettings {
requestHeaderRules: HeaderRule[];
responseHeaderRules: HeaderRule[];
}
export interface CacheControl {
type: string;
}
export interface SystemMessage {
text: string;
type: string;
cache_control?: CacheControl;
}
export interface ToolInput extends Record<string, unknown> {
file_path?: string;
old_string?: string;
new_string?: string;
command?: string;
description?: string;
offset?: number;
limit?: number;
replace_all?: boolean;
content?: string;
pattern?: string;
glob?: string;
path?: string;
prompt?: string;
todos?: TodoItem[];
}
export interface ToolParameterSchema extends Record<string, unknown> {
type?: string | string[];
properties?: Record<string, Record<string, unknown>>;
required?: string[];
}
export interface ToolDefinition {
name: string;
description: string;
input_schema?: ToolParameterSchema;
parameters?: ToolParameterSchema;
}
export interface TodoItem extends Record<string, unknown> {
task?: string;
description?: string;
content?: string;
title?: string;
text?: string;
priority: 'high' | 'medium' | 'low';
status: 'pending' | 'in_progress' | 'completed';
}
interface BaseContentBlock extends Record<string, unknown> {
type: string;
text?: string;
name?: string;
id?: string;
input?: ToolInput;
thinking?: string;
content?: unknown;
tool_use_id?: string;
tool_call_id?: string;
is_error?: boolean;
}
export interface TextContentBlock extends BaseContentBlock {
type: 'text';
text: string;
}
export interface ToolUseContentBlock extends BaseContentBlock {
type: 'tool_use';
id?: string;
name?: string;
input?: ToolInput;
text?: string;
}
export interface ToolResultContentBlock extends BaseContentBlock {
type: 'tool_result';
id?: string;
tool_use_id?: string;
tool_call_id?: string;
content?: unknown;
text?: string;
is_error?: boolean;
}
export interface ImageContentBlock extends BaseContentBlock {
type: 'image';
source?: {
type: string;
media_type: string;
data: string;
};
data?: string;
media_type?: string;
}
export interface ThinkingContentBlock extends BaseContentBlock {
type: 'thinking';
thinking?: string;
}
export interface GenericContentBlock extends BaseContentBlock {
type: string;
}
export type ContentBlock =
| TextContentBlock
| ToolUseContentBlock
| ToolResultContentBlock
| ImageContentBlock
| ThinkingContentBlock
| GenericContentBlock;
export type MessageContent = string | ContentBlock | ContentBlock[] | Record<string, unknown>;
export interface RequestMessage {
role: string;
content: MessageContent;
}
export interface PromptGrade {
score: number;
maxScore?: number;
feedback: string;
improvedPrompt: string;
criteria: Record<string, { score: number; feedback: string }>;
gradingTimestamp: string;
isProcessing?: boolean;
}
export interface Request {
id: string;
conversationId?: string;
turnNumber?: number;
isRoot?: boolean;
timestamp: string;
method: string;
endpoint: string;
headers: Record<string, string[]>;
originalModel?: string;
routedModel?: string;
body?: {
model?: string;
messages?: RequestMessage[];
system?: SystemMessage[];
tools?: ToolDefinition[];
max_tokens?: number;
temperature?: number;
stream?: boolean;
};
response?: {
statusCode: number;
headers: Record<string, string[]>;
body?: {
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
service_tier?: string;
};
content?: MessageContent;
[key: string]: unknown;
};
bodyText?: string;
responseTime: number;
streamingChunks?: string[];
isStreaming: boolean;
completedAt: string;
};
promptGrade?: PromptGrade;
}
export interface ConversationSummary {
id: string;
requestCount: number;
startTime: string;
lastActivity: string;
duration: number;
firstMessage: string;
lastMessage: string;
projectPath: string;
projectName: string;
}
export interface Conversation {
sessionId: string;
projectPath: string;
projectName: string;
messages: Array<{
parentUuid: string | null;
isSidechain: boolean;
userType: string;
cwd: string;
sessionId: string;
version: string;
type: 'user' | 'assistant' | 'system';
message: unknown;
uuid: string;
timestamp: string;
}>;
startTime: string;
endTime: string;
messageCount: number;
}
export interface RequestSummary {
requestId: string;
timestamp: string;
method: string;
endpoint: string;
model?: string;
originalModel?: string;
routedModel?: string;
statusCode?: number;
responseTime?: number;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
service_tier?: string;
};
conversationHash?: string;
messageCount?: number;
stopReason?: string;
}
export interface ConversationGroup {
conversationHash: string;
latestRequest: RequestSummary;
turnCount: number;
totalTokens: number;
totalResponseTime: number;
firstTimestamp: string;
lastTimestamp: string;
requestIds: string[];
}
export interface DashboardStats {
dailyStats: DailyTokens[];
}
export interface DailyTokens {
date: string;
tokens: number;
requests: number;
models?: Record<string, { tokens: number; requests: number }>;
}
export interface HourlyStatsResponse {
hourlyStats: HourlyTokens[];
todayTokens: number;
todayRequests: number;
avgResponseTime: number;
}
export interface HourlyTokens {
hour: number;
label?: string;
tokens: number;
requests: number;
models?: Record<string, { tokens: number; requests: number }>;
}
export interface ModelStatsResponse {
modelStats: ModelTokens[];
}
export interface ModelTokens {
model: string;
tokens: number;
requests: number;
}
export interface UsageStats {
total_requests: number;
total_input_tokens: number;
total_output_tokens: number;
total_cache_tokens: number;
requests_by_model: Record<string, {
request_count: number;
input_tokens: number;
output_tokens: number;
cache_tokens: number;
}>;
start_date?: string;
end_date?: string;
}

View file

@ -0,0 +1,49 @@
const DASHBOARD_USER = 'admin';
const REALM = 'Claude Code Proxy';
function decodeBasicAuthHeader(authHeader: string): { user: string; pass: string } | null {
if (!authHeader.startsWith('Basic ')) {
return null;
}
try {
const decoded = atob(authHeader.slice(6));
const colonIndex = decoded.indexOf(':');
if (colonIndex === -1) {
return null;
}
return {
user: decoded.slice(0, colonIndex),
pass: decoded.slice(colonIndex + 1),
};
} catch {
return null;
}
}
export function isDashboardAuthorized(authHeader: string, password: string): boolean {
if (!password) {
return true;
}
const credentials = decodeBasicAuthHeader(authHeader);
return credentials !== null && credentials.user === DASHBOARD_USER && credentials.pass === password;
}
export function backendAuthHeaders(password: string): Record<string, string> {
if (!password) {
return {};
}
const credentials = btoa(`${DASHBOARD_USER}:${password}`);
return { Authorization: `Basic ${credentials}` };
}
export function dashboardUnauthorizedResponse(): Response {
return new Response('Unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': `Basic realm="${REALM}"`,
},
});
}

2851
svelte/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
svelte/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "claude-code-proxy-svelte",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "svelte-kit sync && vite dev",
"build": "svelte-kit sync && vite build",
"preview": "svelte-kit sync && vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.21.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/node": "^25.5.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"svelte": "^5.33.0",
"svelte-check": "^4.2.1",
"tailwindcss": "^3.4.4",
"typescript": "^5.1.6",
"vite": "^6.0.0"
},
"dependencies": {
"chart.js": "^4.5.1",
"lucide-svelte": "^0.522.0"
},
"type": "module",
"engines": {
"node": ">=20.0.0"
}
}

6
svelte/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

333
svelte/src/app.css Normal file
View file

@ -0,0 +1,333 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
* {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
background-color: #fafafa;
color: #111;
}
html.dark body {
background-color: #0f172a;
color: #e2e8f0;
}
@layer utilities {
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
.code-block {
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
font-size: 0.75rem;
line-height: 1.5;
background: #f5f5f5;
border: 1px solid #e5e5e5;
border-radius: 4px;
}
html.dark .code-block {
background: #1e293b;
border-color: #334155;
}
.scrollbar-custom {
scrollbar-width: thin;
scrollbar-color: #ddd #f5f5f5;
}
html.dark .scrollbar-custom {
scrollbar-color: #475569 #1e293b;
}
.scrollbar-custom::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-custom::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 3px;
}
html.dark .scrollbar-custom::-webkit-scrollbar-track {
background: #1e293b;
}
.scrollbar-custom::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 3px;
}
html.dark .scrollbar-custom::-webkit-scrollbar-thumb {
background: #475569;
}
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
html.dark .scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* --- Nav active/inactive states (immune to dark mode CSS overrides) --- */
.nav-active {
background-color: #111827; /* gray-900 */
color: #fff;
}
html.dark .nav-active {
background-color: #e5e7eb; /* gray-200 */
color: #111827;
}
.nav-inactive {
color: #4b5563; /* gray-600 */
}
.nav-inactive:hover {
background-color: #f3f4f6; /* gray-100 */
}
html.dark .nav-inactive {
color: #9ca3af; /* gray-400 */
}
html.dark .nav-inactive:hover {
background-color: #1f2937; /* gray-800 */
}
/* ============================================================================
Dark Mode Overrides
These override Tailwind utility classes when html.dark is present.
This approach avoids adding dark: variants to every component.
============================================================================ */
/* --- Background colors --- */
html.dark .bg-white { background-color: #1e293b; }
html.dark .bg-gray-50 { background-color: #0f172a; }
html.dark .bg-gray-100 { background-color: #1e293b; }
html.dark .bg-gray-200 { background-color: #334155; }
/* Accent backgrounds - subtle dark variants */
html.dark .bg-blue-50 { background-color: rgba(59, 130, 246, 0.1); }
html.dark .bg-blue-100 { background-color: rgba(59, 130, 246, 0.2); }
html.dark .bg-indigo-50 { background-color: rgba(99, 102, 241, 0.1); }
html.dark .bg-indigo-100 { background-color: rgba(99, 102, 241, 0.15); }
html.dark .bg-purple-50 { background-color: rgba(147, 51, 234, 0.1); }
html.dark .bg-purple-100 { background-color: rgba(147, 51, 234, 0.15); }
html.dark .bg-green-50 { background-color: rgba(34, 197, 94, 0.1); }
html.dark .bg-green-100 { background-color: rgba(34, 197, 94, 0.15); }
html.dark .bg-emerald-50 { background-color: rgba(16, 185, 129, 0.1); }
html.dark .bg-emerald-100 { background-color: rgba(16, 185, 129, 0.15); }
html.dark .bg-red-50 { background-color: rgba(239, 68, 68, 0.1); }
html.dark .bg-red-100 { background-color: rgba(239, 68, 68, 0.15); }
html.dark .bg-yellow-50 { background-color: rgba(234, 179, 8, 0.1); }
html.dark .bg-yellow-100 { background-color: rgba(234, 179, 8, 0.15); }
html.dark .bg-amber-50 { background-color: rgba(245, 158, 11, 0.1); }
html.dark .bg-amber-100 { background-color: rgba(245, 158, 11, 0.15); }
html.dark .bg-orange-100 { background-color: rgba(249, 115, 22, 0.15); }
html.dark .bg-teal-50 { background-color: rgba(20, 184, 166, 0.1); }
html.dark .bg-slate-50 { background-color: rgba(100, 116, 139, 0.1); }
/* Gradient backgrounds */
html.dark .bg-gradient-to-r.from-blue-50 { --tw-gradient-from: rgba(59, 130, 246, 0.1); }
html.dark .bg-gradient-to-r.to-indigo-50 { --tw-gradient-to: rgba(99, 102, 241, 0.1); }
html.dark .bg-gradient-to-r.from-indigo-50 { --tw-gradient-from: rgba(99, 102, 241, 0.1); }
html.dark .bg-gradient-to-r.to-purple-50 { --tw-gradient-to: rgba(147, 51, 234, 0.1); }
html.dark .bg-gradient-to-r.from-purple-50 { --tw-gradient-from: rgba(147, 51, 234, 0.1); }
html.dark .bg-gradient-to-r.to-blue-50 { --tw-gradient-to: rgba(59, 130, 246, 0.1); }
html.dark .bg-gradient-to-r.from-emerald-50 { --tw-gradient-from: rgba(16, 185, 129, 0.1); }
html.dark .bg-gradient-to-r.to-green-50 { --tw-gradient-to: rgba(34, 197, 94, 0.1); }
html.dark .bg-gradient-to-r.from-red-50 { --tw-gradient-from: rgba(239, 68, 68, 0.1); }
html.dark .bg-gradient-to-r.to-pink-50 { --tw-gradient-to: rgba(236, 72, 153, 0.1); }
/* --- Text colors --- */
html.dark .text-gray-900 { color: #f1f5f9; }
html.dark .text-gray-800 { color: #e2e8f0; }
html.dark .text-gray-700 { color: #cbd5e1; }
html.dark .text-gray-600 { color: #94a3b8; }
html.dark .text-gray-500 { color: #64748b; }
html.dark .text-gray-400 { color: #64748b; }
/* Accent text colors - brighten for dark backgrounds */
html.dark .text-blue-700 { color: #93c5fd; }
html.dark .text-blue-600 { color: #60a5fa; }
html.dark .text-indigo-900 { color: #c7d2fe; }
html.dark .text-indigo-700 { color: #a5b4fc; }
html.dark .text-indigo-600 { color: #818cf8; }
html.dark .text-purple-900 { color: #e9d5ff; }
html.dark .text-purple-700 { color: #c084fc; }
html.dark .text-purple-600 { color: #a855f7; }
html.dark .text-green-900 { color: #bbf7d0; }
html.dark .text-green-800 { color: #86efac; }
html.dark .text-green-700 { color: #4ade80; }
html.dark .text-green-600 { color: #22c55e; }
html.dark .text-emerald-900 { color: #a7f3d0; }
html.dark .text-emerald-700 { color: #34d399; }
html.dark .text-emerald-600 { color: #10b981; }
html.dark .text-red-900 { color: #fecaca; }
html.dark .text-red-800 { color: #fca5a5; }
html.dark .text-red-700 { color: #f87171; }
html.dark .text-red-600 { color: #ef4444; }
html.dark .text-yellow-900 { color: #fef08a; }
html.dark .text-yellow-700 { color: #facc15; }
html.dark .text-yellow-600 { color: #eab308; }
html.dark .text-amber-900 { color: #fde68a; }
html.dark .text-amber-800 { color: #fbbf24; }
html.dark .text-amber-700 { color: #f59e0b; }
html.dark .text-amber-600 { color: #d97706; }
html.dark .text-teal-600 { color: #2dd4bf; }
html.dark .text-blue-900 { color: #bfdbfe; }
html.dark .text-slate-800 { color: #e2e8f0; }
html.dark .text-slate-700 { color: #cbd5e1; }
html.dark .text-slate-600 { color: #94a3b8; }
/* --- Border colors --- */
html.dark .border-gray-200 { border-color: #334155; }
html.dark .border-gray-100 { border-color: #1e293b; }
html.dark .border-gray-300 { border-color: #475569; }
html.dark .border-blue-200 { border-color: rgba(59, 130, 246, 0.3); }
html.dark .border-indigo-200 { border-color: rgba(99, 102, 241, 0.3); }
html.dark .border-purple-200 { border-color: rgba(147, 51, 234, 0.3); }
html.dark .border-green-200 { border-color: rgba(34, 197, 94, 0.3); }
html.dark .border-green-100 { border-color: rgba(34, 197, 94, 0.15); }
html.dark .border-emerald-200 { border-color: rgba(16, 185, 129, 0.3); }
html.dark .border-emerald-100 { border-color: rgba(16, 185, 129, 0.15); }
html.dark .border-red-200 { border-color: rgba(239, 68, 68, 0.3); }
html.dark .border-yellow-200 { border-color: rgba(234, 179, 8, 0.3); }
html.dark .border-amber-200 { border-color: rgba(245, 158, 11, 0.3); }
html.dark .border-orange-200 { border-color: rgba(249, 115, 22, 0.3); }
html.dark .border-slate-200 { border-color: #334155; }
html.dark .border-slate-100 { border-color: #1e293b; }
html.dark .border-blue-100 { border-color: rgba(59, 130, 246, 0.15); }
html.dark .border-gray-700 { border-color: #475569; }
/* Left-accent borders */
html.dark .border-l-blue-500 { border-left-color: #3b82f6; }
html.dark .border-l-gray-500 { border-left-color: #64748b; }
html.dark .border-l-amber-500 { border-left-color: #f59e0b; }
html.dark .border-l-emerald-500 { border-left-color: #10b981; }
html.dark .border-l-red-500 { border-left-color: #ef4444; }
html.dark .border-l-blue-500 { border-left-color: #3b82f6; }
/* --- Divide colors --- */
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #334155; }
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1e293b; }
html.dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1e293b; }
/* --- Hover overrides --- */
html.dark .hover\:bg-gray-50:hover { background-color: #1e293b; }
html.dark .hover\:bg-gray-100:hover { background-color: #334155; }
html.dark .hover\:bg-gray-200:hover { background-color: #475569; }
html.dark .hover\:bg-blue-50:hover { background-color: rgba(59, 130, 246, 0.15); }
html.dark .hover\:bg-red-50:hover { background-color: rgba(239, 68, 68, 0.15); }
html.dark .hover\:bg-purple-50:hover { background-color: rgba(147, 51, 234, 0.15); }
html.dark .hover\:bg-white\/50:hover { background-color: rgba(30, 41, 59, 0.5); }
html.dark .hover\:bg-slate-100\/50:hover { background-color: rgba(30, 41, 59, 0.5); }
html.dark .hover\:bg-amber-100\/50:hover { background-color: rgba(245, 158, 11, 0.15); }
html.dark .hover\:bg-emerald-100\/50:hover { background-color: rgba(16, 185, 129, 0.15); }
html.dark .hover\:text-gray-600:hover { color: #94a3b8; }
html.dark .hover\:text-gray-700:hover { color: #cbd5e1; }
html.dark .hover\:text-gray-800:hover { color: #e2e8f0; }
html.dark .hover\:text-gray-900:hover { color: #f1f5f9; }
html.dark .hover\:text-blue-800:hover { color: #93c5fd; }
html.dark .hover\:text-indigo-800:hover { color: #a5b4fc; }
html.dark .hover\:text-amber-800:hover { color: #fcd34d; }
html.dark .hover\:text-emerald-800:hover { color: #6ee7b7; }
html.dark .hover\:text-red-800:hover { color: #fca5a5; }
html.dark .hover\:shadow-md:hover { --tw-shadow-color: rgba(0, 0, 0, 0.3); }
/* --- Ring overrides --- */
html.dark .ring-blue-200\/30 { --tw-ring-color: rgba(59, 130, 246, 0.15); }
/* --- Focus overrides --- */
html.dark .focus\:ring-blue-500:focus { --tw-ring-color: #3b82f6; }
html.dark .focus\:ring-emerald-500:focus { --tw-ring-color: #10b981; }
/* --- Shadow adjustments --- */
html.dark .shadow-sm { --tw-shadow-color: rgba(0, 0, 0, 0.2); }
html.dark .shadow-md { --tw-shadow-color: rgba(0, 0, 0, 0.3); }
html.dark .shadow-2xl { --tw-shadow-color: rgba(0, 0, 0, 0.5); }
/* --- Modal backdrops --- */
html.dark .bg-gray-900\/70 { background-color: rgba(0, 0, 0, 0.8); }
/* --- Chat page specific --- */
html.dark .bg-blue-500 { background-color: #2563eb; }
html.dark .bg-gray-200.text-gray-900 { background-color: #334155; color: #e2e8f0; }
/* Chat bubble colors */
html.dark .bg-blue-100 { background-color: rgba(59, 130, 246, 0.2); }
/* --- Prose / formatted content --- */
html.dark .prose { color: #cbd5e1; }
html.dark .prose pre { background-color: #0f172a; border-color: #334155; }
/* --- Form elements --- */
html.dark input,
html.dark select,
html.dark textarea {
background-color: #1e293b;
border-color: #475569;
color: #e2e8f0;
}
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #64748b;
}
html.dark option {
background-color: #1e293b;
color: #e2e8f0;
}
/* --- Code blocks (already dark, keep them) --- */
html.dark .bg-gray-900 { background-color: #0f172a; }
html.dark .bg-gray-800 { background-color: #1e293b; }
/* --- Misc --- */
html.dark .bg-black { background-color: #000; }
html.dark .brightness-95 { filter: brightness(1.05); }
/* Hover on table rows */
html.dark .hover\:bg-gray-800\/50:hover { background-color: rgba(30, 41, 59, 0.5); }
html.dark .hover\:bg-gray-100:hover { background-color: #334155; }
html.dark .hover\:bg-gray-50:hover { background-color: #1e293b; }
/* Sticky headers */
html.dark .bg-gray-50.sticky { background-color: #0f172a; }
/* Active/selected states for model filter buttons */
html.dark .bg-transparent { background-color: transparent; }

20
svelte/src/app.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<script>
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,16 @@
import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { dashboardUnauthorizedResponse, isDashboardAuthorized } from '../../shared/server/dashboard_auth';
export const handle: Handle = async ({ event, resolve }) => {
const password = env.DASHBOARD_PASSWORD || '';
if (password) {
const authHeader = event.request.headers.get('Authorization') || '';
if (!isDashboardAuthorized(authHeader, password)) {
return dashboardUnauthorizedResponse();
}
}
return resolve(event);
};

225
svelte/src/lib/api.ts Normal file
View file

@ -0,0 +1,225 @@
import type {
Request, ConversationSummary, Conversation,
RequestSummary, DashboardStats, HourlyStatsResponse,
ModelStatsResponse, UsageStats, ProxySettings,
PromptGrade, RequestMessage, SystemMessage
} from './types';
const API_BASE = '/api';
type RequestListItem = Omit<Request, 'id'> & { id?: string; requestId?: string };
type RequestsResponse = { requests?: RequestListItem[]; total?: number };
type ConversationsResponse = { conversations: ConversationSummary[]; hasMore?: boolean; total?: number };
type RequestSummaryResponse = { requests: RequestSummary[]; total: number };
type RequestDetailResponse = { request: Request; fullId: string };
type LatestRequestDateResponse = { latestDate: string | null };
type OrganizationsResponse = { organizations?: string[] };
export async function fetchRequests(
page: number = 1,
limit: number = 50,
model: string = 'all'
): Promise<{ requests: Request[]; hasMore: boolean; total: number }> {
const url = new URL(`${API_BASE}/requests`, window.location.origin);
url.searchParams.append('page', page.toString());
url.searchParams.append('limit', limit.toString());
if (model !== 'all') {
url.searchParams.append('model', model);
}
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data: RequestsResponse = await response.json();
const fetchedRequests = data.requests || [];
const mappedRequests: Request[] = fetchedRequests.map((req) => ({
...req,
id: req.id || req.requestId || req.timestamp
}));
return { requests: mappedRequests, hasMore: mappedRequests.length === limit, total: data.total || 0 };
}
export async function fetchConversations(
page: number = 1,
limit: number = 50,
model: string = 'all'
): Promise<{ conversations: ConversationSummary[]; hasMore: boolean; total?: number }> {
const url = new URL(`${API_BASE}/conversations`, window.location.origin);
url.searchParams.append('page', page.toString());
url.searchParams.append('limit', limit.toString());
if (model !== 'all') {
url.searchParams.append('model', model);
}
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data: ConversationsResponse = await response.json();
return {
conversations: data.conversations,
hasMore: typeof data.hasMore === 'boolean' ? data.hasMore : data.conversations.length === limit,
total: typeof data.total === 'number' ? data.total : undefined
};
}
export async function fetchConversationDetail(
conversationId: string,
projectPath: string
): Promise<Conversation> {
const response = await fetch(
`${API_BASE}/conversations/${encodeURIComponent(conversationId)}?project=${encodeURIComponent(projectPath)}`
);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
export async function deleteRequests(): Promise<void> {
const response = await fetch(`${API_BASE}/requests`, { method: 'DELETE' });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
}
export async function gradePrompt(
messages: RequestMessage[],
systemMessages: SystemMessage[],
requestId: string
): Promise<PromptGrade> {
const response = await fetch(`${API_BASE}/grade-prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, systemMessages, requestId })
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// New summary endpoint — lightweight request list for fast rendering
export async function fetchRequestsSummary(
model: string = 'all',
startTime?: string,
endTime?: string,
offset: number = 0,
limit: number = 0
): Promise<{ requests: RequestSummary[]; total: number }> {
const url = new URL(`${API_BASE}/requests/summary`, window.location.origin);
if (model !== 'all') url.searchParams.append('model', model);
if (startTime) url.searchParams.append('start', startTime);
if (endTime) url.searchParams.append('end', endTime);
if (offset > 0) url.searchParams.append('offset', offset.toString());
if (limit > 0) url.searchParams.append('limit', limit.toString());
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json() as Promise<RequestSummaryResponse>;
}
// Fetch a single request by ID (full detail)
export async function fetchRequestById(
requestId: string
): Promise<{ request: Request; fullId: string }> {
const response = await fetch(`${API_BASE}/requests/${encodeURIComponent(requestId)}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json() as Promise<RequestDetailResponse>;
}
// Get the latest request date
export async function fetchLatestRequestDate(): Promise<{ latestDate: string | null }> {
const response = await fetch(`${API_BASE}/requests/latest-date`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json() as Promise<LatestRequestDateResponse>;
}
// Usage stats with date range and model filter
export async function fetchUsageStats(
startDate?: string,
endDate?: string,
model?: string,
org?: string
): Promise<UsageStats> {
const url = new URL(`${API_BASE}/stats`, window.location.origin);
if (startDate) url.searchParams.append('start_date', startDate);
if (endDate) url.searchParams.append('end_date', endDate);
if (model && model !== 'all') url.searchParams.append('model', model);
if (org) url.searchParams.append('org', org);
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// Dashboard stats — daily token usage
export async function fetchDashboardStats(
startTime?: string,
endTime?: string,
org?: string
): Promise<DashboardStats> {
const url = new URL(`${API_BASE}/stats/dashboard`, window.location.origin);
if (startTime) url.searchParams.append('start', startTime);
if (endTime) url.searchParams.append('end', endTime);
if (org) url.searchParams.append('org', org);
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// Hourly stats for a date range
export async function fetchHourlyStats(
startTime: string,
endTime: string,
bucketMinutes: number = 60,
org?: string
): Promise<HourlyStatsResponse> {
const url = new URL(`${API_BASE}/stats/hourly`, window.location.origin);
url.searchParams.append('start', startTime);
url.searchParams.append('end', endTime);
if (bucketMinutes !== 60) {
url.searchParams.append('bucket', bucketMinutes.toString());
}
if (org) url.searchParams.append('org', org);
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// Settings
export async function fetchSettings(): Promise<ProxySettings> {
const response = await fetch(`${API_BASE}/settings`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
export async function saveSettings(settings: ProxySettings): Promise<ProxySettings> {
const response = await fetch(`${API_BASE}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// Model breakdown for a date range
export async function fetchModelStats(
startTime: string,
endTime: string,
org?: string
): Promise<ModelStatsResponse> {
const url = new URL(`${API_BASE}/stats/models`, window.location.origin);
url.searchParams.append('start', startTime);
url.searchParams.append('end', endTime);
if (org) url.searchParams.append('org', org);
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// Get distinct organization IDs
export async function fetchOrganizations(): Promise<string[]> {
const response = await fetch(`${API_BASE}/stats/organizations`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data: OrganizationsResponse = await response.json();
return data.organizations || [];
}

View file

@ -0,0 +1,32 @@
import { env } from '$env/dynamic/private';
import { error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import {
backendAuthHeaders as buildSharedBackendAuthHeaders,
isDashboardAuthorized
} from '../../../shared/server/dashboard_auth';
function getDashboardPassword(): string {
return env.DASHBOARD_PASSWORD || '';
}
/**
* Check basic auth on the incoming request.
* Throws a 401 error if auth is required and invalid.
*/
export function requireDashboardAuth(event: RequestEvent): void {
const password = getDashboardPassword();
const authHeader = event.request.headers.get('Authorization') || '';
if (isDashboardAuthorized(authHeader, password)) {
return;
}
throw error(401, 'Unauthorized');
}
/**
* Returns headers to forward basic auth to the Go backend.
*/
export function backendAuthHeaders(): Record<string, string> {
return buildSharedBackendAuthHeaders(getDashboardPassword());
}

View file

@ -0,0 +1,10 @@
import { env } from '$env/dynamic/private';
import { buildBackendURL as buildSharedBackendURL, resolveBackendOrigin } from '../../../shared/frontend/backend';
export function getBackendOrigin(): string {
return resolveBackendOrigin(env);
}
export function buildBackendURL(path: string, searchParams?: URLSearchParams): string {
return buildSharedBackendURL(getBackendOrigin(), path, searchParams);
}

View file

@ -0,0 +1,101 @@
/**
* Date/time formatting utilities for the chat view.
*
* These provide iMessage-style and relative timestamp formatting used by the
* chat page and its sub-components. The shared `$lib/formatters` module has
* generic helpers (`formatTimestamp`, `formatTime`, etc.) but nothing that
* matches the specific "Today 3:42 PM" / "Yesterday 10:15 AM" / "Mon 2:30 PM"
* style needed here, so we keep these separate.
*/
import type { RequestMessage } from '$lib/types';
// ---------------------------------------------------------------------------
// Core helpers
// ---------------------------------------------------------------------------
/** Calendar-day difference (accounts for date boundaries, not raw 24h). */
export function calendarDayDiff(date: Date, now: Date): number {
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const n = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round((n.getTime() - d.getTime()) / 86400000);
}
// ---------------------------------------------------------------------------
// iMessage-style timestamp
// ---------------------------------------------------------------------------
export function formatImessageTimestamp(ts: string): string {
const date = new Date(ts);
const now = new Date();
const diffDays = calendarDayDiff(date, now);
const timeStr = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (diffDays === 0) return `Today ${timeStr}`;
if (diffDays === 1) return `Yesterday ${timeStr}`;
if (diffDays < 7) {
const day = date.toLocaleDateString(undefined, { weekday: 'long' });
return `${day} ${timeStr}`;
}
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: diffDays > 365 ? 'numeric' : undefined }) + ' ' + timeStr;
}
// ---------------------------------------------------------------------------
// Relative timestamp ("2 min ago", "Yesterday 3:42 PM")
// ---------------------------------------------------------------------------
export function formatRelativeTimestamp(ts: string): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDays = calendarDayDiff(date, now);
const timeStr = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} min ago`;
if (diffDays === 0 && diffHr < 2) return `${diffHr} hr ago`;
if (diffDays === 0) return `Today ${timeStr}`;
if (diffDays === 1) return `Yesterday ${timeStr}`;
if (diffDays < 7) {
return date.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' + timeStr;
}
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + timeStr;
}
// ---------------------------------------------------------------------------
// Turn/timestamp helpers for message arrays
// ---------------------------------------------------------------------------
/** Whether a timestamp separator should be shown before message at index idx. */
export function shouldShowTimestamp(messages: RequestMessage[], idx: number): boolean {
if (idx === 0) return true;
const prev = messages[idx - 1];
const curr = messages[idx];
if (prev.role === 'assistant' && curr.role === 'user') return true;
return false;
}
/** Estimate a 1-based turn number for a message based on user->assistant pairs. */
export function getTurnNumber(messages: RequestMessage[], idx: number): number {
let turn = 0;
for (let i = 0; i <= idx; i++) {
if (messages[i].role === 'user' && (i === 0 || messages[i - 1]?.role === 'assistant')) {
turn++;
}
}
return turn;
}
/** Total turns in the message array. */
export function getTotalTurns(messages: RequestMessage[]): number {
return getTurnNumber(messages, messages.length - 1);
}
/** Format a short time string (HH:MM:SS). */
export function formatTimeFull(ts: string): string {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}

View file

@ -0,0 +1,242 @@
/**
* Shared utilities for the chat view components.
*
* Contains type-guards, content-splitting logic, and label/icon/color helpers
* that are used across ChatMessage, ChatToolBlock, and the main chat page.
*/
import { parseXmlBlocks, getXmlTagStyle } from '$lib/formatters';
import type {
MessageContent as RenderableMessageContent,
RequestMessage,
ToolUseContentBlock,
ToolResultContentBlock,
TextContentBlock,
ContentBlock,
GenericContentBlock
} from '$lib/types';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface XmlOutsideBlock {
type: 'xml-block';
tag: string;
content: string;
raw: string;
}
export type OutsideItem = Exclude<ContentBlock, TextContentBlock> | XmlOutsideBlock | GenericContentBlock;
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function isTextBlock(value: unknown): value is TextContentBlock {
return isRecord(value) && value.type === 'text' && typeof value.text === 'string';
}
export function isToolUseBlock(value: unknown): value is ToolUseContentBlock {
return isRecord(value) && value.type === 'tool_use';
}
export function isToolResultBlock(value: unknown): value is ToolResultContentBlock {
return isRecord(value) && value.type === 'tool_result';
}
export function isXmlOutsideBlock(value: unknown): value is XmlOutsideBlock {
return isRecord(value) && value.type === 'xml-block' && typeof value.tag === 'string' && typeof value.content === 'string';
}
// ---------------------------------------------------------------------------
// Content splitting
// ---------------------------------------------------------------------------
/**
* Split content: plain text goes in chat bubble, everything else outside.
* Parses XML-like blocks out of text so they render as outside items.
*/
export function splitContent(content: RenderableMessageContent | undefined): { chat: string | null; outside: OutsideItem[] } {
if (typeof content === 'string') {
return splitTextContent(content);
}
if (!Array.isArray(content)) {
if (isTextBlock(content)) {
return splitTextContent(content.text || '');
}
return content ? { chat: null, outside: [content as OutsideItem] } : { chat: null, outside: [] };
}
let chatParts: string[] = [];
const outside: OutsideItem[] = [];
for (const item of content) {
if (isTextBlock(item)) {
const result = splitTextContent(item.text);
outside.push(...result.outside);
if (result.chat) chatParts.push(result.chat);
} else {
outside.push(item as OutsideItem);
}
}
const chatText = chatParts.join('\n\n').trim();
return { chat: chatText || null, outside };
}
function splitTextContent(text: string): { chat: string | null; outside: OutsideItem[] } {
const outside: OutsideItem[] = [];
const segments = parseXmlBlocks(text);
if (segments.length === 0 || (segments.length === 1 && segments[0].type === 'text')) {
return { chat: text.trim() || null, outside: [] };
}
const textParts: string[] = [];
for (const seg of segments) {
if (seg.type === 'text') {
const trimmed = seg.content.trim();
if (trimmed) textParts.push(trimmed);
} else if (seg.type === 'xml' && seg.tag) {
outside.push({
type: 'xml-block',
tag: seg.tag,
content: seg.innerContent || '',
raw: seg.content
});
}
}
return { chat: textParts.join('\n\n').trim() || null, outside };
}
// ---------------------------------------------------------------------------
// Labels / icons / colors for outside blocks
// ---------------------------------------------------------------------------
export function outsideLabel(item: OutsideItem): string {
if (isXmlOutsideBlock(item)) return item.tag || 'block';
if (isToolUseBlock(item)) return typeof item.name === 'string' ? item.name : 'tool call';
if (isToolResultBlock(item)) return item.is_error ? 'error' : 'result';
if (item.type === 'thinking') return `thought (${(typeof item.thinking === 'string' ? item.thinking.length : 0).toLocaleString()} chars)`;
return typeof item.type === 'string' ? item.type : 'block';
}
export function outsideIconName(item: OutsideItem): string {
if (isXmlOutsideBlock(item) && item.tag) {
const style = getXmlTagStyle(item.tag);
return style.icon;
}
switch (item.type) {
case 'thinking': return 'brain';
case 'tool_use': return 'terminal';
case 'tool_result': return item.is_error ? 'alert-circle' : 'check-circle';
default: return 'code';
}
}
export function outsideColor(item: OutsideItem): string {
if (isXmlOutsideBlock(item) && item.tag) {
const style = getXmlTagStyle(item.tag);
return style.text.replace('text-', 'text-').replace('-800', '-400') + ' hover:' + style.text;
}
switch (item.type) {
case 'thinking': return 'text-amber-400 hover:text-amber-600';
case 'tool_use': return 'text-indigo-400 hover:text-indigo-600';
case 'tool_result': return item.is_error ? 'text-red-400 hover:text-red-600' : 'text-emerald-400 hover:text-emerald-600';
default: return 'text-gray-400 hover:text-gray-600';
}
}
// ---------------------------------------------------------------------------
// Model helpers
// ---------------------------------------------------------------------------
export function getModelLabel(model?: string): { label: string; color: string } {
if (!model) return { label: 'API', color: 'text-gray-600' };
if (model.includes('opus')) return { label: 'Opus', color: 'text-purple-600' };
if (model.includes('sonnet')) return { label: 'Sonnet', color: 'text-indigo-600' };
if (model.includes('haiku')) return { label: 'Haiku', color: 'text-teal-600' };
if (model.includes('gpt')) return { label: 'GPT', color: 'text-green-600' };
return { label: model.split('-')[0], color: 'text-gray-700' };
}
export function getStatusBadge(code?: number): string {
if (!code) return 'bg-gray-100 text-gray-500';
if (code >= 200 && code < 300) return 'bg-green-100 text-green-700';
if (code >= 400) return 'bg-red-100 text-red-700';
return 'bg-yellow-100 text-yellow-700';
}
// ---------------------------------------------------------------------------
// Tool helpers
// ---------------------------------------------------------------------------
export function buildToolResultMap(messages: RequestMessage[]): Map<string, ToolResultContentBlock> {
const map = new Map<string, ToolResultContentBlock>();
if (!messages) return map;
for (const msg of messages) {
const content = msg.content;
if (!Array.isArray(content)) continue;
for (const item of content) {
if (isToolResultBlock(item) && item.tool_use_id) {
map.set(item.tool_use_id, item);
}
}
}
return map;
}
export function toolInputSummary(item: ToolUseContentBlock): string {
if (!item?.input) return '';
const input = item.input;
switch (item.name) {
case 'Read': return input.file_path || '';
case 'Edit': return input.file_path || '';
case 'Write': return input.file_path || '';
case 'Bash': return (input.command || '').slice(0, 80);
case 'Grep': return input.pattern ? `"${input.pattern}"` : '';
case 'Glob': return input.pattern || '';
case 'Agent': return (input.prompt || '').slice(0, 80);
default: {
const first = Object.values(input).find((v) => typeof v === 'string');
return typeof first === 'string' ? first.slice(0, 80) : '';
}
}
}
export function getToolResultContent(result: ToolResultContentBlock | undefined): string {
if (!result) return '';
let content = result.content ?? result.text ?? '';
if (Array.isArray(content)) {
return content
.map((c) => {
if (typeof c === 'string') return c;
if (isRecord(c) && typeof c.text === 'string') return c.text;
if (isRecord(c) && typeof c.content === 'string') return c.content;
return '';
})
.filter(Boolean)
.join('\n');
}
if (isRecord(content)) {
if (typeof content.text === 'string') return content.text;
if (typeof content.content === 'string') return content.content;
return JSON.stringify(content, null, 2);
}
return String(content);
}
export function toolResultBrief(result: ToolResultContentBlock | undefined): { text: string; isError: boolean; chars: number } {
const isError = result?.is_error || false;
const content = getToolResultContent(result);
const chars = content.length;
if (isError) return { text: `error (${chars.toLocaleString()} chars)`, isError, chars };
return { text: `${chars.toLocaleString()} chars`, isError, chars };
}

View file

@ -0,0 +1,84 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables, type ChartData, type ChartOptions, type ChartType } from 'chart.js';
Chart.register(...registerables);
interface Props {
type: ChartType;
data: ChartData;
options?: ChartOptions;
height?: string;
/** Enable horizontal scrolling when data is too dense to fit comfortably */
scrollable?: boolean;
}
let { type, data, options = {}, height = '300px', scrollable = false }: Props = $props();
// Only scroll when there are enough points that they'd be unreadably dense.
// Target ~6px per point as the threshold where scrolling kicks in,
// and give ~8px per point when it does.
let containerEl: HTMLDivElement;
let scrollMinWidth = $derived.by(() => {
if (!scrollable || !data?.labels?.length) return '100%';
const count = data.labels.length;
// Assume ~500px available width; only scroll if points would be <6px apart
if (count <= 80) return '100%';
return `${count * 8}px`;
});
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
function isDark() {
return typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
}
function createChart() {
if (chart) chart.destroy();
if (!canvas) return;
const dark = isDark();
const darkOverrides = dark ? {
scales: {
...options?.scales,
x: { ...options?.scales?.x, ticks: { ...options?.scales?.x?.ticks, color: '#94a3b8' }, grid: { color: 'rgba(51, 65, 85, 0.5)' } },
y: { ...options?.scales?.y, ticks: { ...options?.scales?.y?.ticks, color: '#94a3b8' }, grid: { color: 'rgba(51, 65, 85, 0.5)' } },
},
plugins: {
...options?.plugins,
legend: { ...options?.plugins?.legend, labels: { ...options?.plugins?.legend?.labels, color: '#94a3b8' } },
},
} : {};
chart = new Chart(canvas, {
type,
data,
options: {
responsive: true,
maintainAspectRatio: false,
...options,
...darkOverrides,
},
});
}
onMount(() => {
createChart();
});
$effect(() => {
// Re-create chart when data or type changes
void data;
void type;
if (canvas) createChart();
});
onDestroy(() => {
if (chart) chart.destroy();
});
</script>
<div style="height: {height}; overflow-x: auto; overflow-y: hidden; position: relative;">
<div style="min-width: {scrollMinWidth}; height: 100%; position: relative;">
<canvas bind:this={canvas}></canvas>
</div>
</div>

View file

@ -0,0 +1,104 @@
<script lang="ts">
import {
User, Bot, Settings, Eye, EyeOff, Code
} from 'lucide-svelte';
import RichText from '$lib/components/RichText.svelte';
import ChatToolBlock from '$lib/components/ChatToolBlock.svelte';
import ChatOutsideBlock from '$lib/components/ChatOutsideBlock.svelte';
import { formatJSON } from '$lib/formatters';
import {
splitContent,
isToolResultBlock,
isToolUseBlock,
type OutsideItem
} from '$lib/chat-utils';
import type { RequestMessage, ToolResultContentBlock } from '$lib/types';
interface Props {
/** The message to render. */
message: RequestMessage;
/** Index of this message in the messages array. */
idx: number;
/** The full messages array (needed to check next message for "Delivered"). */
messages: RequestMessage[];
/** Map of tool_use_id -> tool_result for pairing. */
toolResultMap: Map<string, ToolResultContentBlock>;
/** Expanded raw sections state. */
expandedRawSections: Record<string, boolean>;
/** Toggle a raw section. */
onToggleRaw: (key: string) => void;
}
let { message, idx, messages, toolResultMap, expandedRawSections, onToggleRaw }: Props = $props();
let isUser = $derived(message.role === 'user');
let isAssistant = $derived(message.role === 'assistant');
let split = $derived(splitContent(message.content));
</script>
<!-- Outside-bubble blocks: everything that isn't plain text -->
{#each split.outside as item, oi}
{#if isToolResultBlock(item) && item.tool_use_id && toolResultMap.has(item.tool_use_id)}
<!-- Paired tool result -- already rendered with its tool_use -->
{:else if isToolUseBlock(item) && item.id && toolResultMap.has(item.id)}
<ChatToolBlock
{item}
result={toolResultMap.get(item.id)}
rawKey={`out-${idx}-${oi}`}
{expandedRawSections}
{onToggleRaw}
/>
{:else}
<ChatOutsideBlock
{item}
rawKey={`out-${idx}-${oi}`}
{expandedRawSections}
{onToggleRaw}
/>
{/if}
{/each}
<!-- Chat bubble -- only plain text content -->
{#if split.chat}
<div class="flex {isUser ? 'justify-end' : 'justify-start'}">
<div class="max-w-[85%]">
<div class="flex items-start space-x-2 {isUser ? 'flex-row-reverse space-x-reverse' : ''}">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center {isUser ? 'bg-blue-100' : isAssistant ? 'bg-gray-100' : 'bg-amber-100'}">
{#if isUser}
<User class="w-4 h-4 text-blue-600" />
{:else if isAssistant}
<Bot class="w-4 h-4 text-gray-600" />
{:else}
<Settings class="w-4 h-4 text-amber-600" />
{/if}
</div>
<div class="min-w-0">
<div class="{isUser ? 'bg-blue-500 text-white' : isAssistant ? 'bg-gray-200 text-gray-900' : 'bg-amber-50 border border-amber-200 text-gray-900'} rounded-2xl {isUser ? 'rounded-tr-sm' : 'rounded-tl-sm'} px-4 py-3 shadow-sm">
<RichText text={split.chat} variant={isUser ? 'inverse' : 'default'} />
</div>
{#if isUser && idx < messages.length - 1 && messages[idx + 1]?.role === 'assistant'}
<div class="flex justify-end mt-0.5 px-1">
<span class="text-[10px] text-gray-400">Delivered</span>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Raw view -- rendered outside the bubble layout so it doesn't resize it -->
<div class="px-10 {isUser ? 'text-right' : ''}">
<button
onclick={() => onToggleRaw(`msg-${idx}`)}
class="text-[10px] text-gray-400 hover:text-gray-600 inline-flex items-center space-x-1 transition-colors mt-0.5"
>
{#if expandedRawSections[`msg-${idx}`]}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Eye class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
{#if expandedRawSections[`msg-${idx}`]}
<pre class="mt-1 text-[10px] bg-gray-900 text-gray-300 rounded-lg p-3 overflow-x-auto max-h-48 font-mono text-left">{formatJSON(message, 0)}</pre>
{/if}
</div>
{/if}

View file

@ -0,0 +1,68 @@
<script lang="ts">
import {
Brain, Terminal, Code, Settings, Wrench, FileText,
CheckCircle, AlertCircle, EyeOff
} from 'lucide-svelte';
import { formatJSON } from '$lib/formatters';
import MessageContent from '$lib/components/MessageContent.svelte';
import RichText from '$lib/components/RichText.svelte';
import {
outsideLabel,
outsideIconName,
outsideColor,
isXmlOutsideBlock,
type OutsideItem
} from '$lib/chat-utils';
interface Props {
item: OutsideItem;
rawKey: string;
expandedRawSections: Record<string, boolean>;
onToggleRaw: (key: string) => void;
}
let { item, rawKey, expandedRawSections, onToggleRaw }: Props = $props();
const iconMap: Record<string, typeof Code> = {
brain: Brain,
terminal: Terminal,
code: Code,
settings: Settings,
wrench: Wrench,
database: FileText,
'check-circle': CheckCircle,
'alert-circle': AlertCircle,
};
let ItemIcon = $derived(iconMap[outsideIconName(item)] || Code);
</script>
<div class="px-10">
<details class="group cursor-pointer">
<summary class="inline-flex items-center space-x-1.5 text-[10px] {outsideColor(item)} transition-colors select-none">
<ItemIcon class="w-3 h-3" />
<span>{outsideLabel(item)}</span>
</summary>
<div class="mt-1 bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
<div class="p-3 text-xs text-gray-700 leading-relaxed max-h-64 overflow-y-auto">
{#if isXmlOutsideBlock(item)}
<RichText text={item.content} size="xs" />
{:else}
<MessageContent content={item} />
{/if}
</div>
<div class="border-t border-gray-200 px-3 py-1 bg-gray-100/50">
<button onclick={() => onToggleRaw(rawKey)} class="text-[10px] text-gray-400 hover:text-gray-600 inline-flex items-center space-x-1">
{#if expandedRawSections[rawKey]}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Code class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
</div>
{#if expandedRawSections[rawKey]}
<pre class="p-3 text-[10px] bg-gray-900 text-gray-300 font-mono overflow-x-auto max-h-48 whitespace-pre-wrap">{isXmlOutsideBlock(item) ? item.raw : formatJSON(item, 0)}</pre>
{/if}
</div>
</details>
</div>

View file

@ -0,0 +1,444 @@
<script lang="ts">
import {
Brain, Sparkles, Zap, Hash,
Activity, ChevronRight, ChevronDown,
Layers, Settings, Wrench, Code, Eye, EyeOff
} from 'lucide-svelte';
import RichText from '$lib/components/RichText.svelte';
import MessageContent from '$lib/components/MessageContent.svelte';
import ChatMessage from '$lib/components/ChatMessage.svelte';
import ChatOutsideBlock from '$lib/components/ChatOutsideBlock.svelte';
import { formatJSON } from '$lib/formatters';
import { formatImessageTimestamp, shouldShowTimestamp, getTurnNumber, getTotalTurns } from '$lib/chat-formatters';
import {
getModelLabel, getStatusBadge, buildToolResultMap, splitContent
} from '$lib/chat-utils';
import type { Request, RequestSummary } from '$lib/types';
interface Props {
/** The fully-loaded request object. */
request: Request;
/** The currently selected request id. */
selectedId: string;
/** Turn ids for the selected conversation group (oldest first). */
selectedGroupTurnIds: string[];
/** Map of requestId -> summary for timestamps. */
summaryMap: Map<string, RequestSummary>;
/** Expanded raw sections state. */
expandedRawSections: Record<string, boolean>;
/** Toggle a raw section by key. */
onToggleRaw: (key: string) => void;
/** Navigate to a different request. */
onSelectRequest: (id: string) => void;
}
let {
request: req,
selectedId,
selectedGroupTurnIds,
summaryMap,
expandedRawSections,
onToggleRaw,
onSelectRequest,
}: Props = $props();
function getModelIcon(model?: string) {
if (!model) return Hash;
if (model.includes('opus')) return Brain;
if (model.includes('sonnet')) return Sparkles;
if (model.includes('haiku')) return Zap;
return Hash;
}
function getRequestIdForTurn(turnNum: number): string | null {
if (!selectedGroupTurnIds.length) return null;
return selectedGroupTurnIds[turnNum - 1] || null;
}
function getTimestampForTurn(turnNum: number): string | null {
const reqId = getRequestIdForTurn(turnNum);
if (!reqId) return null;
return summaryMap.get(reqId)?.timestamp || null;
}
let model = $derived(req.routedModel || req.body?.model || req.originalModel || '');
let ml = $derived(getModelLabel(model));
let ModelIcon = $derived(getModelIcon(model));
</script>
<div class="max-w-4xl mx-auto px-6 py-6 space-y-4">
<!-- Request metadata bar -->
<div class="bg-white border border-gray-200 rounded-xl p-4 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
<ModelIcon class="w-4 h-4 text-white" />
</div>
<div>
<div class="flex items-center space-x-2">
<span class="font-semibold {ml.color}">{ml.label}</span>
{#if model}
<span class="text-xs text-gray-400 font-mono">{model}</span>
{/if}
</div>
<div class="text-xs text-gray-500 flex items-center space-x-3">
<span>{new Date(req.timestamp).toLocaleString()}</span>
{#if req.response?.responseTime}
<span class="flex items-center space-x-1">
<Activity class="w-3 h-3" />
<span>{(req.response.responseTime / 1000).toFixed(2)}s</span>
</span>
{/if}
{#if req.response?.statusCode}
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium {getStatusBadge(req.response.statusCode)}">{req.response.statusCode}</span>
{/if}
</div>
</div>
</div>
<div class="text-right text-xs text-gray-500">
{#if req.response?.body?.usage}
{@const u = req.response.body.usage}
<div class="space-y-0.5">
<div><span class="text-gray-400">In:</span> <span class="font-medium text-gray-700">{(u.input_tokens || 0).toLocaleString()}</span></div>
<div><span class="text-gray-400">Out:</span> <span class="font-medium text-gray-700">{(u.output_tokens || 0).toLocaleString()}</span></div>
{#if u.cache_read_input_tokens}
<div><span class="text-gray-400">Cache:</span> <span class="font-medium text-green-600">{u.cache_read_input_tokens.toLocaleString()}</span></div>
{/if}
</div>
{/if}
</div>
</div>
</div>
<!-- Request & Response Headers (collapsed) -->
{#if req.headers || req.response?.headers}
<div class="bg-slate-50 border border-slate-200 rounded-xl overflow-hidden">
<button
onclick={() => onToggleRaw('headers')}
class="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-100/50 transition-colors"
>
<div class="flex items-center space-x-2">
<Layers class="w-4 h-4 text-slate-600" />
<span class="text-sm font-medium text-slate-800">Headers</span>
</div>
{#if expandedRawSections['headers']}
<ChevronDown class="w-4 h-4 text-slate-500" />
{:else}
<ChevronRight class="w-4 h-4 text-slate-500" />
{/if}
</button>
{#if expandedRawSections['headers']}
<div class="px-4 pb-4 space-y-3">
<!-- Request Headers -->
{#if req.headers && Object.keys(req.headers).length > 0}
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-slate-600 uppercase">Request Headers</span>
<button onclick={() => onToggleRaw('headers-req-raw')} class="text-[10px] text-slate-400 hover:text-slate-600 inline-flex items-center space-x-1 transition-colors">
{#if expandedRawSections['headers-req-raw']}
<Code class="w-3 h-3" /><span>Formatted</span>
{:else}
<Code class="w-3 h-3" /><span>Raw</span>
{/if}
</button>
</div>
{#if expandedRawSections['headers-req-raw']}
<pre class="text-[10px] bg-gray-900 text-gray-300 rounded-lg p-3 overflow-x-auto max-h-64 font-mono">{formatJSON(req.headers, 0)}</pre>
{:else}
<div class="bg-white rounded-lg border border-slate-200 divide-y divide-slate-100 max-h-64 overflow-y-auto">
{#each Object.entries(req.headers) as [key, values]}
<div class="px-3 py-1.5 flex items-start gap-2">
<span class="text-[11px] font-mono font-medium text-slate-700 flex-shrink-0">{key}</span>
<span class="text-[11px] font-mono text-slate-500 break-all">{Array.isArray(values) ? values.join(', ') : values}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Response Headers -->
{#if req.response?.headers && Object.keys(req.response.headers).length > 0}
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-slate-600 uppercase">Response Headers</span>
<button onclick={() => onToggleRaw('headers-res-raw')} class="text-[10px] text-slate-400 hover:text-slate-600 inline-flex items-center space-x-1 transition-colors">
{#if expandedRawSections['headers-res-raw']}
<Code class="w-3 h-3" /><span>Formatted</span>
{:else}
<Code class="w-3 h-3" /><span>Raw</span>
{/if}
</button>
</div>
{#if expandedRawSections['headers-res-raw']}
<pre class="text-[10px] bg-gray-900 text-gray-300 rounded-lg p-3 overflow-x-auto max-h-64 font-mono">{formatJSON(req.response.headers, 0)}</pre>
{:else}
<div class="bg-white rounded-lg border border-slate-200 divide-y divide-slate-100 max-h-64 overflow-y-auto">
{#each Object.entries(req.response.headers) as [key, values]}
<div class="px-3 py-1.5 flex items-start gap-2">
<span class="text-[11px] font-mono font-medium text-slate-700 flex-shrink-0">{key}</span>
<span class="text-[11px] font-mono text-slate-500 break-all">{Array.isArray(values) ? values.join(', ') : values}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- System prompt (collapsed) -->
{#if req.body?.system && req.body.system.length > 0}
<div class="bg-amber-50 border border-amber-200 rounded-xl overflow-hidden">
<button
onclick={() => onToggleRaw('system')}
class="w-full px-4 py-3 flex items-center justify-between hover:bg-amber-100/50 transition-colors"
>
<div class="flex items-center space-x-2">
<Settings class="w-4 h-4 text-amber-600" />
<span class="text-sm font-medium text-amber-800">System Prompt</span>
<span class="text-xs text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full">{req.body.system.length} block{req.body.system.length > 1 ? 's' : ''}</span>
</div>
{#if expandedRawSections['system']}
<ChevronDown class="w-4 h-4 text-amber-500" />
{:else}
<ChevronRight class="w-4 h-4 text-amber-500" />
{/if}
</button>
{#if expandedRawSections['system']}
<div class="px-4 pb-4 space-y-2">
{#each req.body.system as sys, si}
<div class="bg-white rounded-lg border border-amber-200 overflow-hidden">
<div class="p-4 text-sm text-gray-700 leading-relaxed max-h-96 overflow-y-auto">
<RichText text={sys.text || ''} />
</div>
<div class="border-t border-amber-200 px-3 py-1.5 bg-amber-50 flex items-center justify-between">
<button onclick={() => onToggleRaw(`sys-raw-${si}`)} class="text-[10px] text-amber-500 hover:text-amber-700 inline-flex items-center space-x-1">
{#if expandedRawSections[`sys-raw-${si}`]}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Code class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
{#if sys.cache_control}
<span class="text-[10px] text-amber-500">cache: {sys.cache_control.type}</span>
{/if}
</div>
{#if expandedRawSections[`sys-raw-${si}`]}
<pre class="p-3 text-[10px] bg-gray-900 text-gray-300 font-mono overflow-x-auto max-h-64 whitespace-pre-wrap">{formatJSON(sys, 0)}</pre>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Tools (collapsed) -->
{#if req.body?.tools && req.body.tools.length > 0}
<div class="bg-emerald-50 border border-emerald-200 rounded-xl overflow-hidden">
<button
onclick={() => onToggleRaw('tools')}
class="w-full px-4 py-3 flex items-center justify-between hover:bg-emerald-100/50 transition-colors"
>
<div class="flex items-center space-x-2">
<Wrench class="w-4 h-4 text-emerald-600" />
<span class="text-sm font-medium text-emerald-800">Tools</span>
<span class="text-xs text-emerald-600 bg-emerald-100 px-1.5 py-0.5 rounded-full">{req.body.tools.length} available</span>
</div>
{#if expandedRawSections['tools']}
<ChevronDown class="w-4 h-4 text-emerald-500" />
{:else}
<ChevronRight class="w-4 h-4 text-emerald-500" />
{/if}
</button>
{#if expandedRawSections['tools']}
<div class="px-4 pb-4">
<div class="space-y-2 max-h-96 overflow-y-auto">
{#each req.body.tools as tool}
<details class="bg-white rounded-lg border border-emerald-200 group">
<summary class="px-3 py-2 flex items-center justify-between cursor-pointer hover:bg-emerald-50/50 transition-colors">
<div class="flex items-center space-x-2">
<Wrench class="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" />
<span class="font-mono text-xs font-semibold text-emerald-700">{tool.name}</span>
</div>
{#if tool.input_schema?.properties}
<span class="text-[10px] text-gray-400">{Object.keys(tool.input_schema.properties).length} params</span>
{/if}
</summary>
<div class="px-3 pb-2.5 pt-1 border-t border-emerald-100">
{#if tool.description}
<RichText text={tool.description} size="xs" variant="muted" />
{/if}
{#if tool.input_schema?.properties}
<div class="mt-2 flex flex-wrap gap-1">
{#each Object.entries(tool.input_schema.properties) as [name, prop]}
<span class="text-[10px] font-mono bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded border border-gray-200">
{name}{#if tool.input_schema?.required?.includes(name)}<span class="text-red-400">*</span>{/if}
</span>
{/each}
</div>
{/if}
</div>
</details>
{/each}
</div>
<div class="mt-2 pt-2 border-t border-emerald-200">
<button onclick={() => onToggleRaw('tools-raw')} class="text-[10px] text-emerald-500 hover:text-emerald-700 inline-flex items-center space-x-1">
{#if expandedRawSections['tools-raw']}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Code class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
</div>
{#if expandedRawSections['tools-raw']}
<pre class="mt-2 text-[10px] bg-gray-900 text-gray-300 rounded-lg p-3 overflow-x-auto max-h-64 font-mono">{formatJSON(req.body.tools, 0)}</pre>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Chat messages -->
{#if req.body?.messages}
{@const msgs = req.body.messages}
{@const totalTurns = getTotalTurns(msgs)}
{@const trMap = buildToolResultMap(msgs)}
<div class="space-y-3">
{#each msgs as message, idx}
<!-- iMessage-style timestamp separator -->
{#if shouldShowTimestamp(msgs, idx)}
{@const turn = getTurnNumber(msgs, idx)}
<div class="flex justify-center py-2">
{#if idx === 0}
<span class="text-[11px] text-gray-400 font-medium">
{formatImessageTimestamp(req.timestamp)}
</span>
{:else}
{@const turnReqId = getRequestIdForTurn(turn)}
{@const turnTs = getTimestampForTurn(turn)}
{@const prevTurnReqId = getRequestIdForTurn(turn - 1)}
{@const prevTurnResponseTime = prevTurnReqId ? summaryMap.get(prevTurnReqId)?.responseTime : null}
<div class="flex flex-col items-center">
{#if turnTs}
<span class="text-[11px] text-gray-400 font-medium">
{formatImessageTimestamp(turnTs)}
{#if prevTurnResponseTime}
<span class="text-gray-300 mx-1">&middot;</span>
<span>{(prevTurnResponseTime / 1000).toFixed(1)}s</span>
{/if}
</span>
{/if}
{#if turnReqId && turnReqId !== selectedId}
<button
onclick={() => onSelectRequest(turnReqId)}
class="text-[10px] text-blue-400 hover:text-blue-600 font-medium transition-colors cursor-pointer"
title="View turn {turn} request"
>
Turn {turn} of {totalTurns}
</button>
{:else}
<span class="text-[10px] text-gray-400 font-medium">
Turn {turn} of {totalTurns}
</span>
{/if}
</div>
{/if}
</div>
{/if}
<ChatMessage
{message}
{idx}
messages={msgs}
toolResultMap={trMap}
{expandedRawSections}
onToggleRaw={onToggleRaw}
/>
{/each}
</div>
{/if}
<!-- Response timestamp -->
{#if req.response?.completedAt}
<div class="flex justify-center py-2">
<span class="text-[11px] text-gray-400 font-medium">
{formatImessageTimestamp(req.response.completedAt)}
{#if req.response.responseTime}
<span class="text-gray-300 mx-1">&middot;</span>
<span>{(req.response.responseTime / 1000).toFixed(1)}s</span>
{/if}
</span>
</div>
{/if}
<!-- Response content -->
{#if req.response?.body?.content}
{@const respSplit = splitContent(req.response.body.content)}
<!-- Outside-bubble response blocks -->
{#each respSplit.outside as item, oi}
<ChatOutsideBlock
{item}
rawKey={`resp-out-${oi}`}
{expandedRawSections}
onToggleRaw={onToggleRaw}
/>
{/each}
<!-- Response chat bubble -->
{#if respSplit.chat}
<div class="flex justify-start">
<div class="max-w-[85%]">
<div class="flex items-start space-x-2">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center bg-gradient-to-br from-purple-100 to-indigo-100">
<ModelIcon class="w-4 h-4 text-purple-600" />
</div>
<div class="min-w-0">
<div class="bg-gray-200 text-gray-900 rounded-2xl rounded-tl-sm px-4 py-3 shadow-sm">
<RichText text={respSplit.chat} />
</div>
{#if req.response.isStreaming}
<div class="flex mt-0.5 px-1">
<span class="text-[10px] text-gray-400">Streamed</span>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Raw view for the full response body -->
<div class="px-10">
<button
onclick={() => onToggleRaw('response')}
class="text-[10px] text-gray-400 hover:text-gray-600 inline-flex items-center space-x-1 transition-colors mt-0.5"
>
{#if expandedRawSections['response']}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Eye class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
{#if expandedRawSections['response']}
<pre class="mt-1 text-[10px] bg-gray-900 text-gray-300 rounded-lg p-3 overflow-x-auto max-h-64 font-mono">{formatJSON(req.response.body, 0)}</pre>
{/if}
</div>
{/if}
{/if}
<!-- Error response -->
{#if req.response?.bodyText && !req.response?.body?.content}
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
<div class="flex items-center space-x-2 mb-2">
<Code class="w-4 h-4 text-red-600" />
<span class="text-sm font-medium text-red-700">Error Response</span>
</div>
<pre class="text-xs text-red-800 bg-white rounded p-3 overflow-x-auto border border-red-200">{req.response.bodyText}</pre>
</div>
{/if}
<!-- Bottom spacer -->
<div class="h-8"></div>
</div>

View file

@ -0,0 +1,140 @@
<script lang="ts">
import {
ChevronRight, ChevronDown, Loader2, Layers
} from 'lucide-svelte';
import { formatRelativeTimestamp, formatTimeFull } from '$lib/chat-formatters';
import { getModelLabel, getStatusBadge } from '$lib/chat-utils';
import type { ConversationGroup, RequestSummary } from '$lib/types';
interface Props {
/** Conversations grouped by display date label (e.g. "Mon, Mar 20"). */
groupedByDate: Record<string, ConversationGroup[]>;
/** Map from requestId to its summary for quick lookup. */
summaryMap: Map<string, RequestSummary>;
/** Currently selected request id (if any). */
selectedId: string | null;
/** Set of conversation hashes whose turn-lists are expanded. */
expandedGroups: Set<string>;
/** Whether the initial list is still loading. */
isLoading: boolean;
/** Total number of conversation groups (for the empty-state check). */
totalGroups: number;
/** Called when the user clicks a conversation / turn. */
onSelectRequest: (id: string) => void;
/** Called when the user toggles the expand chevron on a multi-turn group. */
onToggleGroup: (hash: string) => void;
}
let {
groupedByDate,
summaryMap,
selectedId,
expandedGroups,
isLoading,
totalGroups,
onSelectRequest,
onToggleGroup,
}: Props = $props();
</script>
<aside class="w-80 flex-shrink-0 bg-white border-r border-gray-200 flex flex-col min-h-0">
<div class="flex-1 overflow-y-auto">
{#if isLoading}
<div class="flex items-center justify-center py-12">
<Loader2 class="w-5 h-5 animate-spin text-gray-400" />
</div>
{:else if totalGroups === 0}
<div class="p-6 text-center text-gray-500 text-sm">No requests yet</div>
{:else}
{#each Object.entries(groupedByDate) as [date, groups]}
<div class="sticky top-0 bg-gray-50 px-3 py-1.5 border-b border-gray-100 z-[1]">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">{date}</span>
</div>
{#each groups as group}
{@const s = group.latestRequest}
{@const ml = getModelLabel(s.model)}
{@const isSelected = group.requestIds.includes(selectedId || '')}
{@const isExpanded = expandedGroups.has(group.conversationHash)}
<div class="border-b border-gray-100">
<div class="flex">
<button
onclick={() => onSelectRequest(s.requestId)}
class="flex-1 text-left px-3 py-2.5 transition-colors hover:bg-gray-50 {isSelected ? 'bg-blue-50 border-l-2 border-l-blue-500' : ''}"
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center space-x-1.5">
<span class="text-xs font-semibold {ml.color}">{ml.label}</span>
{#if s.statusCode}
<span class="text-[10px] font-medium px-1 py-0.5 rounded {getStatusBadge(s.statusCode)}">{s.statusCode}</span>
{/if}
{#if group.turnCount > 1}
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 flex items-center space-x-0.5">
<Layers class="w-2.5 h-2.5" />
<span>{group.turnCount} turns</span>
</span>
{/if}
</div>
<span class="text-[10px] text-gray-400">{formatTimeFull(s.timestamp)}</span>
</div>
<div class="flex items-center justify-between text-[11px] text-gray-500">
<div class="flex items-center space-x-2">
{#if group.totalTokens > 0}
<span>{group.totalTokens.toLocaleString()} tok</span>
{/if}
{#if s.messageCount}
<span>{s.messageCount} msgs</span>
{/if}
{#if s.responseTime}
<span>{(s.responseTime / 1000).toFixed(1)}s</span>
{/if}
</div>
<span class="text-gray-400 font-mono text-[10px]">#{s.requestId.slice(-6)}</span>
</div>
</button>
{#if group.turnCount > 1}
<button
onclick={() => onToggleGroup(group.conversationHash)}
class="flex-shrink-0 px-2 flex items-center hover:bg-gray-100 transition-colors border-l border-gray-100"
title="{isExpanded ? 'Collapse' : 'Expand'} turns"
>
{#if isExpanded}
<ChevronDown class="w-3.5 h-3.5 text-gray-400" />
{:else}
<ChevronRight class="w-3.5 h-3.5 text-gray-400" />
{/if}
</button>
{/if}
</div>
{#if isExpanded && group.turnCount > 1}
<div class="bg-gray-50 border-t border-gray-100">
{#each group.requestIds as turnId, ti}
{@const turnSummary = summaryMap.get(turnId)}
{@const isTurnSelected = turnId === selectedId}
{#if turnSummary}
<button
onclick={() => onSelectRequest(turnId)}
class="w-full text-left pl-8 pr-3 py-1.5 flex items-center justify-between transition-colors hover:bg-blue-50 {isTurnSelected ? 'bg-blue-100 text-blue-700' : 'text-gray-500'}"
>
<div class="flex items-center space-x-2">
<span class="text-[10px] font-medium {isTurnSelected ? 'text-blue-700' : 'text-gray-500'}">Turn {group.turnCount - ti}</span>
<span class="text-[10px] {isTurnSelected ? 'text-blue-500' : 'text-gray-400'}">{formatRelativeTimestamp(turnSummary.timestamp)}</span>
</div>
<div class="flex items-center space-x-2 text-[10px] {isTurnSelected ? 'text-blue-500' : 'text-gray-400'}">
{#if turnSummary.usage}
<span>{((turnSummary.usage.input_tokens || 0) + (turnSummary.usage.output_tokens || 0)).toLocaleString()} tok</span>
{/if}
{#if turnSummary.responseTime}
<span>{(turnSummary.responseTime / 1000).toFixed(1)}s</span>
{/if}
</div>
</button>
{/if}
{/each}
</div>
{/if}
</div>
{/each}
{/each}
{/if}
</div>
</aside>

View file

@ -0,0 +1,182 @@
<script lang="ts">
import {
Terminal, FileText, Code, EyeOff, CheckCircle, AlertCircle
} from 'lucide-svelte';
import { formatJSON, truncateText } from '$lib/formatters';
import {
toolInputSummary,
toolResultBrief,
getToolResultContent,
type OutsideItem
} from '$lib/chat-utils';
import type { ToolUseContentBlock, ToolResultContentBlock } from '$lib/types';
interface Props {
/** The tool_use content block. */
item: ToolUseContentBlock;
/** The matching tool_result (if found via tool_use_id). */
result: ToolResultContentBlock | undefined;
/** Unique key for raw-section toggling, e.g. `out-${idx}-${oi}`. */
rawKey: string;
/** Current expanded-raw state record. */
expandedRawSections: Record<string, boolean>;
/** Callback to toggle a raw section. */
onToggleRaw: (key: string) => void;
}
let { item, result, rawKey, expandedRawSections, onToggleRaw }: Props = $props();
let summary = $derived(toolInputSummary(item));
let brief = $derived(toolResultBrief(result));
let resultContent = $derived(getToolResultContent(result));
</script>
<div class="px-10">
<details class="group cursor-pointer">
<summary class="inline-flex items-center gap-1.5 text-[10px] transition-colors select-none">
<Terminal class="w-3 h-3 text-indigo-400" />
<span class="font-mono font-medium text-indigo-500">{item.name}</span>
{#if summary}
<span class="text-gray-400 font-mono truncate max-w-xs" title={summary}>{summary.length > 50 ? summary.slice(0, 50) + '\u2026' : summary}</span>
{/if}
<span class="text-gray-300">&rarr;</span>
{#if brief.isError}
<AlertCircle class="w-2.5 h-2.5 text-red-400" />
<span class="text-red-400">{brief.text}</span>
{:else}
<CheckCircle class="w-2.5 h-2.5 text-emerald-400" />
<span class="text-emerald-400">{brief.text}</span>
{/if}
</summary>
<div class="mt-1 rounded-lg overflow-hidden border border-gray-700 bg-gray-900">
{#if item.name === 'Bash'}
<!-- Terminal-style rendering for Bash -->
{#if item.input?.description}
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700 text-[10px] text-gray-400 italic">{item.input.description}</div>
{/if}
<div class="px-3 py-2 border-b border-gray-800">
<div class="flex items-start gap-1.5">
<span class="text-green-400 text-[11px] font-mono font-bold select-none flex-shrink-0">$</span>
<pre class="text-[11px] text-gray-100 font-mono whitespace-pre-wrap break-all">{item.input?.command || ''}</pre>
</div>
</div>
{#if resultContent}
<div class="max-h-64 overflow-auto">
<pre class="px-3 py-2 text-[10px] font-mono whitespace-pre-wrap break-all {brief.isError ? 'text-red-300' : 'text-gray-400'}">{truncateText(resultContent, 8000)}</pre>
</div>
{/if}
{:else if item.name === 'Read'}
<!-- File read rendering -->
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700 flex items-center gap-2">
<FileText class="w-3 h-3 text-blue-400 flex-shrink-0" />
<span class="text-[11px] text-gray-200 font-mono truncate">{item.input?.file_path || 'file'}</span>
{#if item.input?.offset}
<span class="text-[9px] text-gray-500">L{item.input.offset}{item.input.limit ? `-${item.input.offset + item.input.limit}` : ''}</span>
{/if}
</div>
{#if resultContent}
<div class="max-h-64 overflow-auto">
<pre class="px-3 py-2 text-[10px] text-gray-300 font-mono whitespace-pre-wrap">{truncateText(resultContent, 8000)}</pre>
</div>
{/if}
{:else if item.name === 'Edit'}
<!-- Edit rendering -->
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700 flex items-center gap-2">
<FileText class="w-3 h-3 text-amber-400 flex-shrink-0" />
<span class="text-[11px] text-gray-200 font-mono truncate">{item.input?.file_path || 'file'}</span>
{#if item.input?.replace_all}
<span class="text-[9px] text-amber-500 bg-amber-900/30 px-1 py-0.5 rounded">replace all</span>
{/if}
</div>
{#if item.input?.old_string !== undefined && item.input?.new_string !== undefined}
<div class="max-h-48 overflow-auto border-b border-gray-800">
<div class="px-3 py-1.5">
<div class="text-[9px] text-red-400 font-medium mb-1 select-none">- old</div>
<pre class="text-[10px] text-red-300/80 font-mono whitespace-pre-wrap">{truncateText(item.input.old_string, 2000)}</pre>
</div>
<div class="px-3 py-1.5 border-t border-gray-800">
<div class="text-[9px] text-green-400 font-medium mb-1 select-none">+ new</div>
<pre class="text-[10px] text-green-300/80 font-mono whitespace-pre-wrap">{truncateText(item.input.new_string, 2000)}</pre>
</div>
</div>
{/if}
{#if resultContent}
<pre class="px-3 py-1.5 text-[10px] text-gray-400 font-mono">{truncateText(resultContent, 1000)}</pre>
{/if}
{:else if item.name === 'Write'}
<!-- Write rendering -->
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700 flex items-center gap-2">
<FileText class="w-3 h-3 text-green-400 flex-shrink-0" />
<span class="text-[11px] text-gray-200 font-mono truncate">{item.input?.file_path || 'file'}</span>
{#if item.input?.content}
<span class="text-[9px] text-gray-500">{item.input.content.split('\n').length} lines</span>
{/if}
</div>
{#if item.input?.content}
<div class="max-h-48 overflow-auto">
<pre class="px-3 py-2 text-[10px] text-gray-300 font-mono whitespace-pre-wrap">{truncateText(item.input.content, 5000)}</pre>
</div>
{/if}
{#if resultContent}
<pre class="px-3 py-1.5 text-[10px] text-gray-400 font-mono border-t border-gray-800">{truncateText(resultContent, 500)}</pre>
{/if}
{:else if item.name === 'Grep'}
<!-- Grep rendering -->
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700">
<div class="flex items-center gap-1.5">
<span class="text-green-400 text-[11px] font-mono font-bold select-none flex-shrink-0">$</span>
<span class="text-[11px] text-gray-100 font-mono">rg {item.input?.pattern ? `"${item.input.pattern}"` : ''}{item.input?.glob ? ` --glob "${item.input.glob}"` : ''}{item.input?.path ? ` ${item.input.path}` : ''}</span>
</div>
</div>
{#if resultContent}
<div class="max-h-64 overflow-auto">
<pre class="px-3 py-2 text-[10px] text-gray-400 font-mono whitespace-pre-wrap">{truncateText(resultContent, 8000)}</pre>
</div>
{/if}
{:else if item.name === 'Glob'}
<!-- Glob rendering -->
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700">
<div class="flex items-center gap-1.5">
<span class="text-green-400 text-[11px] font-mono font-bold select-none flex-shrink-0">$</span>
<span class="text-[11px] text-gray-100 font-mono">find {item.input?.pattern || ''}{item.input?.path ? ` in ${item.input.path}` : ''}</span>
</div>
</div>
{#if resultContent}
<div class="max-h-64 overflow-auto">
<pre class="px-3 py-2 text-[10px] text-gray-400 font-mono whitespace-pre-wrap">{truncateText(resultContent, 8000)}</pre>
</div>
{/if}
{:else}
<!-- Generic tool rendering -->
{#if item.input && Object.keys(item.input).length > 0}
<div class="px-3 py-2 border-b border-gray-800 max-h-48 overflow-auto">
{#each Object.entries(item.input) as [key, val]}
<div class="flex gap-2 py-0.5">
<span class="text-[10px] text-indigo-400 font-mono flex-shrink-0">{key}:</span>
<pre class="text-[10px] text-gray-300 font-mono whitespace-pre-wrap break-all">{typeof val === 'string' ? truncateText(val, 500) : JSON.stringify(val)}</pre>
</div>
{/each}
</div>
{/if}
{#if resultContent}
<div class="max-h-64 overflow-auto">
<pre class="px-3 py-2 text-[10px] {brief.isError ? 'text-red-300' : 'text-gray-400'} font-mono whitespace-pre-wrap">{truncateText(resultContent, 5000)}</pre>
</div>
{/if}
{/if}
<!-- Raw toggle -->
<div class="px-3 py-1 bg-gray-800/50 border-t border-gray-800">
<button onclick={() => onToggleRaw(rawKey)} class="text-[10px] text-gray-500 hover:text-gray-300 inline-flex items-center space-x-1">
{#if expandedRawSections[rawKey]}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Code class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
</div>
{#if expandedRawSections[rawKey]}
<pre class="p-3 text-[10px] bg-black text-gray-400 font-mono overflow-x-auto max-h-48 whitespace-pre-wrap">{formatJSON({ tool_use: item, tool_result: result }, 0)}</pre>
{/if}
</div>
</details>
</div>

View file

@ -0,0 +1,65 @@
<script lang="ts">
interface Props {
oldCode: string;
newCode: string;
fileName?: string;
}
let { oldCode, newCode, fileName }: Props = $props();
let diffLines = $derived.by(() => {
const oldLines = oldCode.split('\n');
const newLines = newCode.split('\n');
let start = 0;
let oldEnd = oldLines.length - 1;
let newEnd = newLines.length - 1;
while (start <= oldEnd && start <= newEnd && oldLines[start] === newLines[start]) start++;
while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
oldEnd--;
newEnd--;
}
const lines: Array<{ type: 'unchanged' | 'removed' | 'added'; content: string; lineNum?: number }> = [];
for (let i = 0; i < start; i++) lines.push({ type: 'unchanged', content: oldLines[i], lineNum: i + 1 });
for (let i = start; i <= oldEnd; i++) lines.push({ type: 'removed', content: oldLines[i] });
for (let i = start; i <= newEnd; i++) lines.push({ type: 'added', content: newLines[i], lineNum: i + 1 });
for (let i = oldEnd + 1; i < oldLines.length; i++) lines.push({ type: 'unchanged', content: oldLines[i], lineNum: i + 1 + (newEnd - oldEnd) });
return lines;
});
</script>
<div class="rounded-lg border border-gray-700 bg-gray-900 overflow-hidden">
{#if fileName}
<div class="px-4 py-2 bg-gray-800 border-b border-gray-700 text-sm text-gray-300">{fileName}</div>
{/if}
<div class="overflow-x-auto">
<table class="w-full text-sm font-mono">
<tbody>
{#each diffLines as line, idx}
<tr class={line.type === 'removed' ? 'bg-red-900/20' : line.type === 'added' ? 'bg-green-900/20' : ''}>
<td class="px-2 py-0.5 text-right text-gray-500 select-none w-12">
{line.type === 'removed' ? '-' : line.lineNum || ''}
</td>
<td class="px-2 py-0.5 text-right text-gray-500 select-none w-12">
{line.type === 'added' ? '+' : line.type === 'unchanged' ? line.lineNum || '' : ''}
</td>
<td class="px-1 py-0.5 select-none w-6 text-center">
<span class={line.type === 'removed' ? 'text-red-400' : line.type === 'added' ? 'text-green-400' : 'text-gray-600'}>
{line.type === 'removed' ? '-' : line.type === 'added' ? '+' : ' '}
</span>
</td>
<td class="px-2 py-0.5 whitespace-pre overflow-x-auto">
<span class={line.type === 'removed' ? 'text-red-300' : line.type === 'added' ? 'text-green-300' : 'text-gray-300'}>
{line.content}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,169 @@
<script lang="ts">
import { Copy, Check, FileCode, Download, Maximize2, X } from 'lucide-svelte';
interface Props {
code: string;
fileName?: string;
language?: string;
}
let { code, fileName, language }: Props = $props();
let copied = $state(false);
let isFullscreen = $state(false);
const languageMap: Record<string, string> = {
js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java',
cpp: 'cpp', c: 'c', h: 'c', hpp: 'cpp', cs: 'csharp', php: 'php',
swift: 'swift', kt: 'kotlin', scala: 'scala', r: 'r',
sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', ps1: 'powershell',
sql: 'sql', html: 'html', htm: 'html', xml: 'xml', css: 'css',
scss: 'scss', sass: 'sass', less: 'less', json: 'json',
yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown', mdx: 'markdown',
tex: 'latex', dockerfile: 'dockerfile', makefile: 'makefile',
lua: 'lua', dart: 'dart', elixir: 'elixir', elm: 'elm',
haskell: 'haskell', julia: 'julia', perl: 'perl', ocaml: 'ocaml',
clj: 'clojure', cljs: 'clojure', cljc: 'clojure'
};
let detectedLanguage = $derived(
language || (() => {
if (!fileName) return 'text';
const ext = fileName.split('.').pop()?.toLowerCase() || '';
return languageMap[ext] || 'text';
})()
);
let lines = $derived(code.split('\n'));
let lineCount = $derived(lines.length);
function highlightCode(line: string): Array<{ text: string; className?: string }> {
const segments: Array<{ text: string; className?: string }> = [];
const tokenPatterns: Array<{ regex: RegExp; className: string }> = [
{ regex: /(["'`])(?:(?=(\\?))\2.)*?\1/, className: 'text-green-400' },
{ regex: /\/\/.*$/, className: 'text-gray-500 italic' },
{ regex: /\/\*[\s\S]*?\*\//, className: 'text-gray-500 italic' },
{ regex: /#.*$/, className: 'text-gray-500 italic' },
{ regex: /\b(?:function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default)\b/, className: 'text-blue-400' },
{ regex: /\b(?:true|false|null|undefined|nil|None|True|False)\b/, className: 'text-orange-400' },
{ regex: /\b\d+\.?\d*\b/, className: 'text-purple-400' },
];
let remaining = line;
while (remaining.length > 0) {
let earliest: { index: number; length: number; className: string; matched: string } | null = null;
for (const { regex, className } of tokenPatterns) {
const m = remaining.match(regex);
if (m && m.index !== undefined) {
if (earliest === null || m.index < earliest.index) {
earliest = { index: m.index, length: m[0].length, className, matched: m[0] };
}
}
}
if (earliest === null) {
segments.push({ text: remaining });
break;
}
if (earliest.index > 0) {
segments.push({ text: remaining.substring(0, earliest.index) });
}
segments.push({ text: earliest.matched, className: earliest.className });
remaining = remaining.substring(earliest.index + earliest.length);
}
return segments;
}
async function handleCopy() {
try {
await navigator.clipboard.writeText(code);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
}
function handleDownload() {
const blob = new Blob([code], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName || 'code.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
{#snippet codeDisplay(inModal: boolean)}
<div class="rounded-lg border border-gray-700 bg-gray-900 overflow-hidden {inModal ? '' : 'max-h-[600px]'}">
<div class="px-4 py-2 bg-gray-800 border-b border-gray-700 flex items-center justify-between">
<div class="flex items-center space-x-3">
<FileCode class="w-4 h-4 text-blue-400" />
<span class="text-sm text-gray-300 font-mono">{fileName || 'Untitled'}</span>
<span class="text-xs text-gray-500 bg-gray-700 px-2 py-1 rounded">{detectedLanguage}</span>
<span class="text-xs text-gray-500">{lineCount} lines</span>
</div>
<div class="flex items-center space-x-2">
<button onclick={handleDownload} class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors" title="Download file">
<Download class="w-4 h-4" />
</button>
{#if !inModal}
<button onclick={() => (isFullscreen = true)} class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors" title="View fullscreen">
<Maximize2 class="w-4 h-4" />
</button>
{/if}
<button onclick={handleCopy} class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors" title="Copy code">
{#if copied}
<Check class="w-4 h-4 text-green-400" />
{:else}
<Copy class="w-4 h-4" />
{/if}
</button>
</div>
</div>
<div class="overflow-auto {inModal ? 'max-h-[80vh]' : 'max-h-[500px]'}">
<table class="w-full text-sm font-mono">
<tbody>
{#each lines as line, idx (`line-${idx}`)}
<tr class="hover:bg-gray-800/50">
<td class="px-4 py-0.5 text-right text-gray-500 select-none w-12 align-top">{idx + 1}</td>
<td class="px-4 py-0.5 whitespace-pre text-gray-300">
{#each highlightCode(line) as segment, segmentIndex (`${idx}-${segmentIndex}`)}
{#if segment.className}
<span class={segment.className}>{segment.text}</span>
{:else}
<span>{segment.text}</span>
{/if}
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/snippet}
{@render codeDisplay(false)}
{#if isFullscreen}
<div class="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label="Code viewer">
<button type="button" class="absolute inset-0 w-full h-full bg-transparent border-0 cursor-default" onclick={() => (isFullscreen = false)} aria-label="Close fullscreen">
</button>
<div class="relative max-w-[90vw] w-full max-h-[90vh]">
<button onclick={() => (isFullscreen = false)} class="absolute -top-10 right-0 p-2 text-white hover:text-gray-300 transition-colors" title="Close">
<X class="w-6 h-6" />
</button>
{@render codeDisplay(true)}
</div>
</div>
{/if}

View file

@ -0,0 +1,138 @@
<script lang="ts">
import { MessageCircle, Clock, Sparkles, ChevronDown, ChevronRight, GitBranch } from 'lucide-svelte';
import MessageFlow from './MessageFlow.svelte';
import type { Conversation, MessageContent } from '$lib/types';
interface ConversationMessage {
role: 'user' | 'assistant' | 'system';
content: MessageContent;
timestamp: string;
turnNumber?: number;
isNewInTurn?: boolean;
isDuplicate?: boolean;
}
interface Props {
conversation: Conversation;
}
let { conversation }: Props = $props();
let expandedSections = $state(new Set(['flow']));
function toggleSection(section: string) {
const newSet = new Set(expandedSections);
if (newSet.has(section)) newSet.delete(section);
else newSet.add(section);
expandedSections = newSet;
}
let messages: ConversationMessage[] = $derived.by(() => {
const all: ConversationMessage[] = [];
if (!conversation.messages || !Array.isArray(conversation.messages)) return all;
for (const msg of conversation.messages) {
let parsedMessage: unknown;
try {
parsedMessage = typeof msg.message === 'string' ? JSON.parse(msg.message) : msg.message;
} catch (error) { console.error('Failed to parse conversation message:', error); parsedMessage = msg.message; }
let role: 'user' | 'assistant' | 'system' = 'user';
if (msg.type === 'assistant') role = 'assistant';
else if (msg.type === 'system') role = 'system';
let content: MessageContent | null = null;
if (parsedMessage && typeof parsedMessage === 'object') {
if ('content' in parsedMessage) {
const raw = (parsedMessage as Record<string, unknown>).content;
if (typeof raw === 'string' || Array.isArray(raw) || (raw && typeof raw === 'object')) {
content = raw as MessageContent;
}
} else if ('text' in parsedMessage && typeof (parsedMessage as Record<string, unknown>).text === 'string') {
content = (parsedMessage as Record<string, unknown>).text as string;
} else if (Array.isArray(parsedMessage)) {
content = parsedMessage;
} else {
content = parsedMessage as Record<string, unknown>;
}
} else if (typeof parsedMessage === 'string') {
content = parsedMessage;
}
if (content) {
all.push({ role, content, timestamp: msg.timestamp, isNewInTurn: true });
}
}
return all;
});
</script>
{#if messages.length === 0}
<div class="text-center py-12">
<div class="w-20 h-20 bg-gray-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<MessageCircle class="w-10 h-10 text-gray-400" />
</div>
<h3 class="text-lg font-medium text-gray-600 mb-2">No messages found</h3>
<p class="text-sm text-gray-500">This conversation appears to be empty</p>
</div>
{:else}
<div class="space-y-6">
<!-- Header -->
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<div class="flex items-center justify-between cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('flow')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('flow'); } }}>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
<GitBranch class="w-5 h-5 text-white" />
</div>
<div>
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
<span>Conversation Flow</span>
<div class="flex items-center space-x-2 text-sm">
<Sparkles class="w-4 h-4 text-purple-500" />
<span class="text-gray-600">Conversation processed - <span class="font-semibold text-purple-700">{messages.length}</span> messages</span>
</div>
</h4>
<p class="text-sm text-gray-600">{messages.length} messages &bull; {conversation.messageCount} total</p>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">{new Date(messages[messages.length - 1]?.timestamp).toLocaleTimeString()}</span>
{#if expandedSections.has('flow')}
<ChevronDown class="w-5 h-5 text-gray-400" />
{:else}
<ChevronRight class="w-5 h-5 text-gray-400" />
{/if}
</div>
</div>
</div>
<!-- Messages -->
{#if expandedSections.has('flow')}
<div class="space-y-1">
{#each messages as message, index}
<MessageFlow {message} {index} isLast={index === messages.length - 1} totalMessages={messages.length} />
{/each}
<div class="mt-8 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<Sparkles class="w-4 h-4 text-blue-600" />
</div>
<div>
<div class="text-sm font-medium text-blue-900">Conversation Summary</div>
<div class="text-xs text-blue-700">{messages.length} messages &bull; {conversation.messageCount} total messages</div>
</div>
</div>
<div class="text-right text-xs text-blue-700">
<div class="flex items-center space-x-1">
<Clock class="w-3 h-3" />
<span>Latest: {new Date(messages[messages.length - 1]?.timestamp).toLocaleTimeString()}</span>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { Image as ImageIcon, Download, Maximize2, X } from 'lucide-svelte';
interface Props {
content: {
source?: { type: string; media_type: string; data: string };
data?: string;
media_type?: string;
};
}
let { content }: Props = $props();
let isFullscreen = $state(false);
let imageError = $state(false);
let imageData = $derived(content.source?.data ?? content.data);
let mediaType = $derived(content.source?.media_type ?? content.media_type ?? 'image/png');
let dataUri = $derived(
imageData
? imageData.startsWith('data:')
? imageData
: `data:${mediaType};base64,${imageData}`
: ''
);
function handleDownload() {
const link = document.createElement('a');
link.href = dataUri;
link.download = `image-${Date.now()}.${mediaType?.split('/')[1] || 'png'}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
</script>
{#if !imageData}
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div class="flex items-center space-x-2">
<ImageIcon class="w-4 h-4 text-amber-600" />
<span class="text-amber-700 font-medium text-sm">No image data available</span>
</div>
</div>
{:else if imageError}
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center space-x-2">
<ImageIcon class="w-4 h-4 text-red-600" />
<span class="text-red-700 font-medium text-sm">Failed to load image</span>
</div>
<details class="mt-2 cursor-pointer">
<summary class="text-xs text-red-600 hover:text-red-800 underline transition-colors">Show raw data</summary>
<pre class="mt-2 text-xs overflow-x-auto bg-white rounded p-3 border border-red-200 font-mono">{JSON.stringify(content, null, 2)}</pre>
</details>
</div>
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-2">
<ImageIcon class="w-4 h-4 text-blue-600" />
<span class="text-gray-700 font-medium text-sm">Image ({mediaType || 'unknown type'})</span>
</div>
<div class="flex items-center space-x-2">
<button onclick={handleDownload} class="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded transition-colors" title="Download image">
<Download class="w-4 h-4" />
</button>
<button onclick={() => (isFullscreen = true)} class="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded transition-colors" title="View fullscreen">
<Maximize2 class="w-4 h-4" />
</button>
</div>
</div>
<div class="bg-white rounded border border-gray-200 p-2">
<button type="button" class="block w-full p-0 border-0 bg-transparent cursor-pointer" onclick={() => (isFullscreen = true)}>
<img src={dataUri} alt="Content" class="max-w-full h-auto rounded" onerror={() => (imageError = true)} />
</button>
</div>
</div>
{#if isFullscreen}
<div class="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label="Fullscreen image">
<button type="button" class="absolute inset-0 w-full h-full bg-transparent border-0 cursor-default" onclick={() => (isFullscreen = false)} aria-label="Close fullscreen">
</button>
<div class="relative max-w-[90vw] max-h-[90vh]">
<button onclick={(e) => { e.stopPropagation(); isFullscreen = false; }} class="absolute -top-10 right-0 p-2 text-white hover:text-gray-300 transition-colors" title="Close">
<X class="w-6 h-6" />
</button>
<img src={dataUri} alt="Content (fullscreen)" class="max-w-full max-h-full object-contain" />
</div>
</div>
{/if}
{/if}

View file

@ -0,0 +1,226 @@
<script lang="ts">
import { Wrench, Code, FileText, Database, Brain } from 'lucide-svelte';
import ToolResult from './ToolResult.svelte';
import ToolUse from './ToolUse.svelte';
import ImageContent from './ImageContent.svelte';
import MessageContent from './MessageContent.svelte';
import RichText from './RichText.svelte';
import XmlBlock from './XmlBlock.svelte';
import { formatJSON, parseXmlBlocks, hasCustomXmlBlocks } from '$lib/formatters';
import type {
MessageContent as RenderableMessageContent,
TextContentBlock,
ToolUseContentBlock,
ToolResultContentBlock,
ThinkingContentBlock,
ImageContentBlock,
ToolDefinition
} from '$lib/types';
interface Props {
content: RenderableMessageContent | unknown;
}
let { content }: Props = $props();
function parseSystemReminders(text: string) {
const result: Array<{ type: 'text' | 'reminder'; content: string }> = [];
const regex = /<system-reminder>([\s\S]*?)<\/system-reminder>/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
const textPart = text.substring(lastIndex, match.index).trim();
if (textPart) result.push({ type: 'text', content: textPart });
}
result.push({ type: 'reminder', content: match[1].trim() });
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
const textPart = text.substring(lastIndex).trim();
if (textPart) result.push({ type: 'text', content: textPart });
}
return result;
}
function isTextBlock(value: unknown): value is TextContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'text' && 'text' in value && typeof value.text === 'string';
}
function isToolUseBlock(value: unknown): value is ToolUseContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'tool_use';
}
function isToolResultBlock(value: unknown): value is ToolResultContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'tool_result';
}
function isImageBlock(value: unknown): value is ImageContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'image';
}
function isThinkingBlock(value: unknown): value is ThinkingContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'thinking';
}
function parseFunctions(text: string): { beforeFunctions: string; afterFunctions: string; tools: Array<ToolDefinition | null> } | null {
const functionsMatch = text.match(/<functions>([\s\S]*?)<\/functions>/);
if (!functionsMatch) return null;
const beforeFunctions = text.substring(0, functionsMatch.index!);
const afterFunctions = text.substring(functionsMatch.index! + functionsMatch[0].length);
const functionMatches = [...functionsMatch[1].matchAll(/<function>([\s\S]*?)<\/function>/g)];
const tools = functionMatches.map((m) => {
try { return JSON.parse(m[1]) as ToolDefinition; } catch (error) { console.error('Failed to parse tool definition:', error); return null; }
});
return { beforeFunctions, afterFunctions, tools };
}
</script>
{#if typeof content === 'string'}
{#if hasCustomXmlBlocks(content)}
{@const segments = parseXmlBlocks(content)}
<div class="space-y-2">
{#each segments as segment, index (`${segment.type}-${segment.tag ?? 'text'}-${index}`)}
{#if segment.type === 'xml' && segment.tag && segment.innerContent !== undefined}
<XmlBlock tag={segment.tag} innerContent={segment.innerContent} startCollapsed={true} />
{:else if segment.type === 'text' && segment.content.trim()}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={segment.content} />
</div>
{/if}
{/each}
</div>
{:else}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={content} />
</div>
{/if}
{:else if Array.isArray(content)}
<div class="space-y-4">
{#each content as item, index (`${typeof item === 'object' && item && 'type' in item && typeof item.type === 'string' ? item.type : 'item'}-${index}`)}
<div class="content-block">
<MessageContent content={item} />
</div>
{/each}
</div>
{:else if content && typeof content === 'object'}
{#if isTextBlock(content)}
{#if content.text && content.text.includes('<functions>')}
{@const parsed = parseFunctions(content.text)}
{#if parsed}
<div class="space-y-4">
{#if parsed.beforeFunctions.trim()}
<div class="max-h-64 overflow-y-auto bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={parsed.beforeFunctions} />
</div>
{/if}
<details class="bg-gradient-to-r from-emerald-50 to-green-50 border border-emerald-200 rounded-xl p-5 shadow-sm cursor-pointer">
<summary class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-emerald-500 to-green-600 rounded-xl flex items-center justify-center shadow-sm">
<Wrench class="w-5 h-5 text-white" />
</div>
<div>
<div class="flex items-center space-x-2">
<span class="text-emerald-900 font-semibold text-base">Available Tools</span>
<Database class="w-4 h-4 text-emerald-600" />
</div>
<div class="text-sm text-emerald-700">{parsed.tools.length} tools defined for this conversation</div>
</div>
</div>
</summary>
<div class="space-y-3 max-h-96 overflow-y-auto mt-4">
{#each parsed.tools as toolDef, index (toolDef?.name ?? `invalid-${index}`)}
{#if toolDef}
{@const paramCount = toolDef.parameters?.properties ? Object.keys(toolDef.parameters.properties).length : 0}
{@const requiredParams = toolDef.parameters?.required || []}
<div class="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div class="flex items-center space-x-3 mb-3">
<div class="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center">
<Wrench class="w-4 h-4 text-emerald-600" />
</div>
<div>
<span class="text-emerald-700 font-mono text-sm font-semibold">{toolDef.name}</span>
<div class="flex items-center space-x-2 mt-1">
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full border border-gray-200">{paramCount} params</span>
{#if requiredParams.length > 0}
<span class="text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded-full border border-orange-200">{requiredParams.length} required</span>
{/if}
</div>
</div>
</div>
<div class="text-gray-600 text-sm mb-3 leading-relaxed">{toolDef.description || 'No description available'}</div>
<details class="cursor-pointer pt-3 border-t border-gray-200">
<summary class="text-xs text-gray-600 hover:text-gray-800 underline transition-colors">Show raw definition</summary>
<pre class="mt-2 p-3 bg-gray-50 border border-gray-200 rounded text-xs overflow-x-auto font-mono">{JSON.stringify(toolDef, null, 2)}</pre>
</details>
</div>
{:else}
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2">
<Code class="w-4 h-4 text-red-600" />
<span class="text-red-700 font-medium text-sm">Invalid Tool Definition #{index + 1}</span>
</div>
</div>
{/if}
{/each}
</div>
</details>
{#if parsed.afterFunctions.trim()}
<div class="max-h-64 overflow-y-auto bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={parsed.afterFunctions} />
</div>
{/if}
</div>
{:else}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={content.text} />
</div>
{/if}
{:else if content.text && hasCustomXmlBlocks(content.text)}
<MessageContent content={content.text} />
{:else}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={content.text || ''} />
</div>
{/if}
{:else if isToolUseBlock(content)}
<ToolUse name={content.name || 'Unknown Tool'} id={content.id || 'unknown'} input={content.input || {}} text={content.text} />
{:else if isToolResultBlock(content)}
<ToolResult content={content.text || content.content || content} toolId={content.tool_call_id || content.id} isError={content.is_error || false} />
{:else if isImageBlock(content)}
<ImageContent content={content} />
{:else if isThinkingBlock(content) && content.thinking && content.thinking.trim()}
<details class="group cursor-pointer inline-block">
<summary class="inline-flex items-center space-x-1 text-[11px] text-gray-400 hover:text-amber-600 transition-colors select-none">
<Brain class="w-3 h-3" />
<span>thought for {Math.ceil(content.thinking.length / 300)}s</span>
</summary>
<pre class="mt-2 whitespace-pre-wrap text-xs text-gray-600 leading-relaxed max-h-[400px] overflow-y-auto bg-gray-50 rounded-lg p-3 border border-gray-200">{content.thinking}</pre>
</details>
{:else if isThinkingBlock(content)}
<div class="hidden" aria-hidden="true"></div>
{:else}
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2">
<Code class="w-4 h-4 text-amber-600" />
<span class="text-amber-700 font-medium text-sm">Unknown content type: {'type' in content && typeof content.type === 'string' ? content.type : 'unknown'}</span>
</div>
<details class="cursor-pointer">
<summary class="text-xs text-amber-600 hover:text-amber-800 underline transition-colors">Show raw content</summary>
<pre class="mt-2 text-xs overflow-x-auto bg-white rounded p-3 border border-amber-200 font-mono">{JSON.stringify(content, null, 2)}</pre>
</details>
</div>
{/if}
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2">
<FileText class="w-4 h-4 text-gray-500" />
<span class="text-gray-600 font-medium text-sm">Unable to render content</span>
</div>
<details class="cursor-pointer">
<summary class="text-xs text-blue-600 hover:text-blue-800 underline transition-colors">Show raw content</summary>
<pre class="mt-2 text-xs overflow-x-auto bg-white rounded p-3 border border-gray-200 font-mono">{JSON.stringify(content, null, 2)}</pre>
</details>
</div>
{/if}

View file

@ -0,0 +1,182 @@
<script lang="ts">
import { User, Bot, Settings, ChevronDown, ChevronRight, Clock, ArrowDown } from 'lucide-svelte';
import MessageContent from './MessageContent.svelte';
import { formatTime } from '$lib/formatters';
import type { MessageContent as RenderableMessageContent, TextContentBlock } from '$lib/types';
interface ConversationMessage {
role: 'user' | 'assistant' | 'system';
content: RenderableMessageContent;
timestamp: string;
turnNumber?: number;
isNewInTurn?: boolean;
isDuplicate?: boolean;
}
interface Props {
message: ConversationMessage;
index: number;
isLast: boolean;
totalMessages: number;
}
let { message, index, isLast, totalMessages }: Props = $props();
let isExpanded = $state(false);
let roleConfig = $derived((() => {
switch (message.role) {
case 'user':
return { bgColor: 'bg-blue-50', borderColor: 'border-blue-200', accentColor: 'border-l-blue-500', titleColor: 'text-blue-700', name: 'User', iconColor: 'text-blue-600' };
case 'assistant':
return { bgColor: 'bg-gray-50', borderColor: 'border-gray-200', accentColor: 'border-l-gray-500', titleColor: 'text-gray-700', name: 'Assistant', iconColor: 'text-gray-600' };
case 'system':
return { bgColor: 'bg-amber-50', borderColor: 'border-amber-200', accentColor: 'border-l-amber-500', titleColor: 'text-amber-700', name: 'System', iconColor: 'text-amber-600' };
default:
return { bgColor: 'bg-gray-50', borderColor: 'border-gray-200', accentColor: 'border-l-gray-500', titleColor: 'text-gray-700', name: 'Unknown', iconColor: 'text-gray-600' };
}
})());
function isSystemReminder(text: string) {
return text.includes('<system-reminder>') || text.includes('</system-reminder>');
}
function extractNonSystemContent(c: string) {
return c.split(/<system-reminder>[\s\S]*?<\/system-reminder>/g).filter((part) => part.trim()).join(' ').trim();
}
function isTextBlock(block: unknown): block is TextContentBlock {
return !!block && typeof block === 'object' && 'type' in block && block.type === 'text' && 'text' in block && typeof block.text === 'string';
}
let contentPreview = $derived((() => {
if (typeof message.content === 'string') {
const nonSystem = extractNonSystemContent(message.content);
if (!nonSystem && isSystemReminder(message.content)) return '[System reminder]';
return nonSystem.length > 300 ? nonSystem.substring(0, 300) + '...' : nonSystem;
}
if (Array.isArray(message.content)) {
const allText = message.content
.filter(isTextBlock)
.map((c) => extractNonSystemContent(c.text))
.filter(Boolean)
.join('\n');
if (!allText) {
if (message.content.some((c) => !!c && typeof c === 'object' && 'type' in c && c.type === 'tool_use')) return '[Tool call]';
if (message.content.some((c) => isTextBlock(c) && isSystemReminder(c.text))) return '[System reminder]';
return '[Context message]';
}
return allText.length > 300 ? allText.substring(0, 300) + '...' : allText;
}
if (message.content && typeof message.content === 'object' && 'type' in message.content && typeof message.content.type === 'string') {
return `[${message.content.type.replace('_', ' ')}]`;
}
try {
const str = JSON.stringify(message.content, null, 2);
return str.length > 300 ? str.substring(0, 300) + '...' : str;
} catch (error) { console.error('Failed to serialize message content:', error); return '[Complex content]'; }
})());
let shouldShowExpander = $derived((() => {
if (typeof message.content === 'string') return message.content.length > 300 || isSystemReminder(message.content);
if (Array.isArray(message.content)) {
const allText = message.content.filter(isTextBlock).map((c) => c.text).join('\n');
return allText.length > 300 || message.content.length > 1;
}
return true;
})());
</script>
<div class="relative">
{#if !isLast}
<div class="absolute left-5 top-16 w-0.5 h-8 bg-gray-200"></div>
{/if}
<div class="relative {message.isNewInTurn ? 'animate-in slide-in-from-left-2' : ''}">
{#if message.isNewInTurn}
<div class="absolute -left-2 top-0 w-1 h-full bg-gradient-to-b from-blue-500 to-transparent rounded-full opacity-60"></div>
{/if}
<div class="{roleConfig.bgColor} {roleConfig.borderColor} {roleConfig.accentColor} border border-l-4 rounded-xl p-5 {message.isNewInTurn ? 'ring-2 ring-blue-200/30 shadow-md' : 'shadow-sm'} transition-all duration-200 hover:shadow-md">
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-white rounded-xl flex items-center justify-center border-2 border-gray-200 shadow-sm">
{#if message.role === 'user'}
<User class="w-5 h-5 {roleConfig.iconColor}" />
{:else if message.role === 'system'}
<Settings class="w-5 h-5 {roleConfig.iconColor}" />
{:else}
<Bot class="w-5 h-5 {roleConfig.iconColor}" />
{/if}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<span class="font-semibold text-lg {roleConfig.titleColor}">{roleConfig.name}</span>
{#if message.isNewInTurn}
<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full border border-green-200 font-medium">NEW</span>
{/if}
<span class="text-xs text-gray-500 bg-white px-2 py-1 rounded-full border border-gray-200">#{index + 1}</span>
{#if message.turnNumber}
<span class="text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded-full border border-purple-200">Turn {message.turnNumber}</span>
{/if}
</div>
<div class="flex items-center space-x-1 text-xs text-gray-500">
<Clock class="w-3 h-3" />
<span>{formatTime(message.timestamp)}</span>
</div>
</div>
<div class="space-y-4">
{#if shouldShowExpander && !isExpanded}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<div class="text-sm text-gray-700 leading-relaxed">
{#if typeof message.content === 'string'}
<div class="whitespace-pre-wrap">{contentPreview}</div>
{:else}
<div class="space-y-2">
<div class="text-gray-600 font-medium">
{Array.isArray(message.content) ? `Message contains ${message.content.length} content blocks` : 'Complex content'}
</div>
{#if Array.isArray(message.content)}
<div class="text-xs text-gray-500 pl-2 border-l-2 border-gray-200">
{message.content.map((item) => typeof item === 'object' && item && 'type' in item && typeof item.type === 'string' ? item.type : 'unknown').join(' → ')}
</div>
{/if}
<div class="text-xs text-gray-500 mt-1 italic">{contentPreview}</div>
</div>
{/if}
</div>
<button onclick={() => (isExpanded = true)} class="mt-3 flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 transition-colors">
<ChevronRight class="w-4 h-4" />
<span>Show full content</span>
</button>
</div>
{:else}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
{#if shouldShowExpander && isExpanded}
<div class="mb-3 pb-3 border-b border-gray-200">
<button onclick={() => (isExpanded = false)} class="flex items-center space-x-2 text-sm text-gray-600 hover:text-gray-800 transition-colors">
<ChevronDown class="w-4 h-4" />
<span>Collapse</span>
</button>
</div>
{/if}
<MessageContent content={message.content} />
</div>
{/if}
</div>
</div>
</div>
</div>
{#if !isLast}
<div class="flex items-center justify-center py-2">
<ArrowDown class="w-4 h-4 text-gray-400" />
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,232 @@
<script lang="ts">
import { page } from '$app/stores';
import {
BarChart3, MessageCircle, List, Settings, HelpCircle,
X, Copy, Check, Brain, Zap, Sparkles, Wrench, FileText
} from 'lucide-svelte';
import ThemeToggle from './ThemeToggle.svelte';
let currentPath = $derived($page.url.pathname);
let proxyUrl = $derived($page.data.proxyUrl);
let showSetupModal = $state(false);
let copiedSetup: Record<string, boolean> = $state({});
async function copySetupCommand(text: string, key: string) {
try {
await navigator.clipboard.writeText(text);
copiedSetup = { ...copiedSetup, [key]: true };
setTimeout(() => { copiedSetup = { ...copiedSetup, [key]: false }; }, 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
}
</script>
<header class="sticky top-0 z-40 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-6 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<a href="/" class="text-lg font-semibold text-gray-900 dark:text-gray-100 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">Claude Code Proxy</a>
</div>
<nav class="flex items-center space-x-1">
<a
href="/"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5
{currentPath === '/' ? 'nav-active' : 'nav-inactive'}"
>
<List class="w-3.5 h-3.5" />
<span>Requests</span>
</a>
<a
href="/conversations"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5
{currentPath === '/conversations' ? 'nav-active' : 'nav-inactive'}"
>
<MessageCircle class="w-3.5 h-3.5" />
<span>Conversations</span>
</a>
<a
href="/analytics"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5
{currentPath === '/analytics' ? 'bg-indigo-600 text-white' : 'nav-inactive'}"
>
<BarChart3 class="w-3.5 h-3.5" />
<span>Analytics</span>
</a>
<span class="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1"></span>
<a
href="/chat"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5
{currentPath === '/chat' ? 'bg-purple-600 text-white' : 'text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/30'}"
>
<MessageCircle class="w-3.5 h-3.5" />
<span>Chat</span>
</a>
<a
href="/settings"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5
{currentPath === '/settings' ? 'nav-active' : 'nav-inactive'}"
>
<Settings class="w-3.5 h-3.5" />
<span>Settings</span>
</a>
<button onclick={() => (showSetupModal = true)} class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30" title="Setup Instructions">
<HelpCircle class="w-3.5 h-3.5" />
<span>Setup</span>
</button>
<ThemeToggle />
</nav>
</div>
</div>
</header>
<!-- Setup Instructions Modal -->
{#if showSetupModal}
<div class="fixed inset-0 bg-gray-900/70 backdrop-blur-sm z-50 flex items-center justify-center p-6" role="dialog" aria-modal="true" aria-label="Setup instructions">
<div class="bg-white rounded-xl max-w-3xl w-full max-h-[90vh] overflow-hidden shadow-2xl">
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-4 border-b border-blue-200">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<Settings class="w-5 h-5 text-blue-600" />
<h3 class="text-lg font-semibold text-gray-900">Proxy Setup Instructions</h3>
</div>
<button onclick={() => (showSetupModal = false)} class="text-gray-400 hover:text-gray-600 transition-colors p-2 hover:bg-white/50 rounded-lg">
<X class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-100px)] space-y-6">
<!-- Proxy URL -->
<div class="bg-gray-50 border border-gray-200 rounded-xl p-4">
<h4 class="text-sm font-semibold text-gray-900 mb-2">Your Proxy URL</h4>
<div class="flex items-center space-x-2">
<code class="flex-1 bg-white px-3 py-2 rounded-lg border border-gray-300 font-mono text-sm text-blue-700">{proxyUrl}</code>
<button onclick={() => copySetupCommand(proxyUrl, 'proxyUrl')} class="p-2 text-gray-500 hover:text-gray-700 bg-white rounded-lg border border-gray-300 transition-colors">
{#if copiedSetup.proxyUrl}<Check class="w-4 h-4 text-green-600" />{:else}<Copy class="w-4 h-4" />{/if}
</button>
</div>
</div>
<!-- Claude Code CLI -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="bg-purple-50 px-4 py-3 border-b border-purple-200">
<h4 class="text-sm font-semibold text-purple-900 flex items-center space-x-2">
<Brain class="w-4 h-4" />
<span>Claude Code CLI</span>
</h4>
</div>
<div class="p-4 space-y-3">
<p class="text-sm text-gray-600">Set the environment variable before running Claude Code:</p>
<div class="relative">
<pre class="bg-gray-900 text-gray-100 rounded-lg p-3 text-sm font-mono overflow-x-auto">export ANTHROPIC_BASE_URL={proxyUrl}</pre>
<button onclick={() => copySetupCommand(`export ANTHROPIC_BASE_URL=${proxyUrl}`, 'claudeCli')} class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800 rounded transition-colors">
{#if copiedSetup.claudeCli}<Check class="w-3.5 h-3.5 text-green-400" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
</div>
<p class="text-xs text-gray-500">Or add to your <code class="bg-gray-100 px-1 rounded">~/.bashrc</code> / <code class="bg-gray-100 px-1 rounded">~/.zshrc</code> for persistence.</p>
</div>
</div>
<!-- Cursor IDE -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="bg-blue-50 px-4 py-3 border-b border-blue-200">
<h4 class="text-sm font-semibold text-blue-900 flex items-center space-x-2">
<Zap class="w-4 h-4" />
<span>Cursor IDE</span>
</h4>
</div>
<div class="p-4 space-y-3">
<p class="text-sm text-gray-600">Add to Cursor settings (<code class="bg-gray-100 px-1 rounded">Settings &gt; Models &gt; OpenAI API Key</code>):</p>
<ol class="text-sm text-gray-600 list-decimal list-inside space-y-1">
<li>Open Settings (<code class="bg-gray-100 px-1 rounded">Cmd/Ctrl + ,</code>)</li>
<li>Search for "OpenAI Base URL"</li>
<li>Set the base URL to: <code class="bg-gray-100 px-1 rounded">{proxyUrl}/v1</code></li>
</ol>
</div>
</div>
<!-- Continue.dev -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="bg-green-50 px-4 py-3 border-b border-green-200">
<h4 class="text-sm font-semibold text-green-900 flex items-center space-x-2">
<Sparkles class="w-4 h-4" />
<span>Continue.dev (VS Code)</span>
</h4>
</div>
<div class="p-4 space-y-3">
<p class="text-sm text-gray-600">Add to your <code class="bg-gray-100 px-1 rounded">~/.continue/config.json</code>:</p>
<div class="relative">
<pre class="bg-gray-900 text-gray-100 rounded-lg p-3 text-sm font-mono overflow-x-auto">{`{
"models": [{
"provider": "anthropic",
"model": "claude-sonnet-4-20250514",
"apiBase": "${proxyUrl}"
}]
}`}</pre>
<button onclick={() => copySetupCommand(`{\n "models": [{\n "provider": "anthropic",\n "model": "claude-sonnet-4-20250514",\n "apiBase": "${proxyUrl}"\n }]\n}`, 'continue')} class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800 rounded transition-colors">
{#if copiedSetup.continue}<Check class="w-3.5 h-3.5 text-green-400" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
</div>
</div>
</div>
<!-- Python SDK -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="bg-yellow-50 px-4 py-3 border-b border-yellow-200">
<h4 class="text-sm font-semibold text-yellow-900 flex items-center space-x-2">
<FileText class="w-4 h-4" />
<span>Python (anthropic SDK)</span>
</h4>
</div>
<div class="p-4 space-y-3">
<div class="relative">
<pre class="bg-gray-900 text-gray-100 rounded-lg p-3 text-sm font-mono overflow-x-auto">{`import anthropic
client = anthropic.Anthropic(
base_url="${proxyUrl}"
)`}</pre>
<button onclick={() => copySetupCommand(`import anthropic\n\nclient = anthropic.Anthropic(\n base_url="${proxyUrl}"\n)`, 'python')} class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800 rounded transition-colors">
{#if copiedSetup.python}<Check class="w-3.5 h-3.5 text-green-400" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
</div>
</div>
</div>
<!-- cURL -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="bg-gray-100 px-4 py-3 border-b border-gray-200">
<h4 class="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<Wrench class="w-4 h-4" />
<span>cURL / Direct API</span>
</h4>
</div>
<div class="p-4 space-y-3">
<div class="relative">
<pre class="bg-gray-900 text-gray-100 rounded-lg p-3 text-sm font-mono overflow-x-auto whitespace-pre-wrap">{`curl ${proxyUrl}/v1/messages \\
-H "Content-Type: application/json" \\
-H "x-api-key: $ANTHROPIC_API_KEY" \\
-H "anthropic-version: 2023-06-01" \\
-d '{"model": "claude-sonnet-4-20250514", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello!"}]}'`}</pre>
<button onclick={() => copySetupCommand(`curl ${proxyUrl}/v1/messages \\\n -H "Content-Type: application/json" \\\n -H "x-api-key: $ANTHROPIC_API_KEY" \\\n -H "anthropic-version: 2023-06-01" \\\n -d '{"model": "claude-sonnet-4-20250514", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello!"}]}'`, 'curl')} class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800 rounded transition-colors">
{#if copiedSetup.curl}<Check class="w-3.5 h-3.5 text-green-400" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
</div>
</div>
</div>
<!-- Health Check -->
<div class="bg-emerald-50 border border-emerald-200 rounded-xl p-4">
<h4 class="text-sm font-semibold text-emerald-900 mb-2">Verify Connection</h4>
<p class="text-sm text-emerald-700">Test that the proxy is working:</p>
<div class="mt-2 relative">
<pre class="bg-emerald-900 text-emerald-100 rounded-lg p-3 text-sm font-mono">curl {proxyUrl}/health</pre>
<button onclick={() => copySetupCommand(`curl ${proxyUrl}/health`, 'health')} class="absolute top-2 right-2 p-1.5 text-emerald-300 hover:text-white bg-emerald-800 rounded transition-colors">
{#if copiedSetup.health}<Check class="w-3.5 h-3.5 text-green-400" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
</div>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,687 @@
<script lang="ts">
import {
ChevronDown, Info, Settings, Cpu, MessageCircle, Brain,
User, Bot, Copy, Check, ArrowLeftRight, Activity, Clock,
Wifi, Calendar, List, FileText, Wrench
} from 'lucide-svelte';
import MessageContent from './MessageContent.svelte';
import { formatJSON, formatJSONFull } from '$lib/formatters';
import { getChatCompletionsEndpoint, getProviderName } from '$lib/models';
import type { Request } from '$lib/types';
interface Props {
request: Request;
onGrade: () => void;
}
let { request, onGrade }: Props = $props();
let expandedSections: Record<string, boolean> = $state({ overview: true });
let copied: Record<string, boolean> = $state({});
let headerViewMode: Record<string, 'pretty' | 'raw'> = $state({ request: 'pretty', response: 'pretty' });
function formatHeadersRaw(headers: Record<string, string[]>): string {
return Object.entries(headers)
.map(([key, values]) => values.map(v => `${key}: ${v}`).join('\n'))
.join('\n');
}
function toggleSection(section: string) {
expandedSections = { ...expandedSections, [section]: !expandedSections[section] };
}
async function handleCopy(content: string, key: string) {
try {
await navigator.clipboard.writeText(content);
copied = { ...copied, [key]: true };
setTimeout(() => { copied = { ...copied, [key]: false }; }, 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
}
function getMethodColor(method: string) {
const colors: Record<string, string> = {
GET: 'bg-green-50 text-green-700 border border-green-200',
POST: 'bg-blue-50 text-blue-700 border border-blue-200',
PUT: 'bg-yellow-50 text-yellow-700 border border-yellow-200',
DELETE: 'bg-red-50 text-red-700 border border-red-200'
};
return colors[method] || 'bg-gray-50 text-gray-700 border border-gray-200';
}
function getStatusColor(statusCode: number) {
if (statusCode >= 200 && statusCode < 300) return { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', icon: 'text-green-600' };
if (statusCode >= 400 && statusCode < 500) return { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-700', icon: 'text-yellow-600' };
if (statusCode >= 500) return { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', icon: 'text-red-600' };
return { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-700', icon: 'text-gray-600' };
}
function parseStreamingResponse(chunks: string[]) {
let assembledText = '';
let rawData = chunks.join('');
try {
const lines = rawData.split('\n').filter((line) => line.trim());
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.substring(6).trim();
if (!jsonStr.startsWith('{')) continue;
try {
const eventData = JSON.parse(jsonStr);
if (eventData.type === 'content_block_delta' && eventData.delta?.type === 'text_delta' && typeof eventData.delta.text === 'string') {
assembledText += eventData.delta.text;
}
} catch { continue; }
}
}
if (assembledText.trim().length > 0) return { finalText: assembledText, isFormatted: true, rawData };
const textMatches = rawData.match(/"text":"([^"]+)"/g);
if (textMatches) {
let fallbackText = '';
for (const match of textMatches) {
const text = match.match(/"text":"([^"]+)"/)?.[1];
if (text) fallbackText += text.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
if (fallbackText.trim()) return { finalText: fallbackText, isFormatted: true, rawData };
}
} catch (error) {
console.warn('Error parsing streaming response:', error);
}
return { finalText: rawData, isFormatted: false, rawData };
}
</script>
<div class="space-y-6">
<!-- Request Overview -->
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Info class="w-5 h-5 text-blue-600" />
<span>Request Overview</span>
</h4>
</div>
<div class="grid grid-cols-2 gap-6 text-sm">
<div class="space-y-3">
<div class="flex items-center space-x-3">
<span class="text-gray-500 font-medium min-w-[80px]">Method:</span>
<span class="px-3 py-1.5 rounded-lg text-xs font-semibold uppercase tracking-wide {getMethodColor(request.method)}">{request.method}</span>
</div>
<div class="flex items-center space-x-3">
<span class="text-gray-500 font-medium min-w-[80px]">Endpoint:</span>
<code class="text-blue-600 bg-blue-50 px-2 py-1 rounded font-mono text-xs border border-blue-200">{getChatCompletionsEndpoint(request.routedModel, request.endpoint)}</code>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center space-x-3">
<span class="text-gray-500 font-medium min-w-[80px]">Timestamp:</span>
<span class="text-gray-900">{new Date(request.timestamp).toLocaleString()}</span>
</div>
<div class="flex items-center space-x-3">
<span class="text-gray-500 font-medium min-w-[80px]">User Agent:</span>
<span class="text-gray-600 text-xs">{request.headers['User-Agent']?.[0] || 'N/A'}</span>
</div>
</div>
</div>
</div>
<!-- Headers -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('headers')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('headers'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Settings class="w-5 h-5 text-blue-600" />
<span>Request Headers</span>
<span class="text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded-full">{Object.keys(request.headers).length}</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.headers ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.headers}
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
<button
onclick={() => headerViewMode = { ...headerViewMode, request: 'pretty' }}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors {headerViewMode.request === 'pretty' ? 'bg-blue-100 text-blue-700 border border-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'}"
>Pretty</button>
<button
onclick={() => headerViewMode = { ...headerViewMode, request: 'raw' }}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors {headerViewMode.request === 'raw' ? 'bg-blue-100 text-blue-700 border border-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'}"
>Raw HTTP</button>
</div>
<button onclick={() => handleCopy(headerViewMode.request === 'raw' ? formatHeadersRaw(request.headers) : formatJSON(request.headers), 'headers')} class="p-1.5 text-gray-500 hover:text-gray-700 transition-colors rounded border border-gray-200 bg-white" title="Copy headers">
{#if copied.headers}<Check class="w-4 h-4 text-green-600" />{:else}<Copy class="w-4 h-4" />{/if}
</button>
</div>
{#if headerViewMode.request === 'pretty'}
<div class="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-100 border-b border-gray-200">
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide w-1/3">Header</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide">Value</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each Object.entries(request.headers) as [key, values]}
<tr class="hover:bg-gray-100 transition-colors">
<td class="px-4 py-2 font-mono text-xs text-blue-700 font-medium align-top">{key}</td>
<td class="px-4 py-2 font-mono text-xs text-gray-700 break-all">
{#each values as value, i}
<div class={i > 0 ? 'mt-1 pt-1 border-t border-gray-100' : ''}>{value}</div>
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<pre class="text-sm text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">{formatHeadersRaw(request.headers)}</pre>
</div>
{/if}
</div>
{/if}
</div>
{#if request.body}
<!-- System Messages -->
{#if request.body.system}
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('system')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('system'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Cpu class="w-5 h-5 text-yellow-600" />
<span>System Instructions</span>
<span class="text-xs bg-yellow-50 text-yellow-700 px-2 py-1 rounded-full border border-yellow-200">{request.body.system.length} items</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.system ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.system}
<div class="p-6 space-y-4">
{#each request.body.system as sys, index}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-yellow-700 font-medium text-sm">System Message #{index + 1}</span>
{#if sys.cache_control}
<span class="text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded-full border border-orange-200">Cache: {sys.cache_control.type}</span>
{/if}
</div>
<div class="bg-white rounded p-3 border border-gray-200">
<MessageContent content={{ type: 'text', text: sys.text }} />
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Tools -->
{#if request.body.tools && request.body.tools.length > 0}
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('tools')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('tools'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Wrench class="w-5 h-5 text-indigo-600" />
<span>Available Tools</span>
<span class="text-xs bg-indigo-50 text-indigo-700 px-2 py-1 rounded-full border border-indigo-200">{request.body.tools.length} tools</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.tools ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.tools}
<div class="p-6 space-y-4">
{#each request.body.tools as tool, index}
{@const isLongDesc = tool.description.length > 300}
<div class="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div class="p-5">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-white rounded-lg flex items-center justify-center border border-gray-200 shadow-sm">
<Wrench class="w-5 h-5 text-gray-600" />
</div>
<div>
<h5 class="text-lg font-bold text-gray-900">{tool.name}</h5>
<span class="text-xs text-gray-500">Tool #{index + 1}</span>
</div>
</div>
</div>
{#if isLongDesc}
<details class="cursor-pointer">
<summary class="text-sm text-gray-700 leading-relaxed">{tool.description.slice(0, 300)}...</summary>
<div class="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap mt-2">{tool.description}</div>
</details>
{:else}
<div class="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">{tool.description}</div>
{/if}
{#if tool.input_schema}
<details class="mt-4 cursor-pointer">
<summary class="text-xs font-semibold text-gray-700 flex items-center space-x-2">
<Settings class="w-3.5 h-3.5" />
<span>Input Schema</span>
</summary>
<div class="mt-2 bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="p-3">
<pre class="text-xs text-gray-700 overflow-x-auto font-mono">{formatJSON(tool.input_schema)}</pre>
</div>
</div>
</details>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Conversation -->
{#if request.body.messages}
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('conversation')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('conversation'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<MessageCircle class="w-5 h-5 text-blue-600" />
<span>Conversation</span>
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded-full border border-blue-200">{request.body.messages.length} messages</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.conversation ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.conversation}
<div class="p-6 space-y-4 max-h-[600px] overflow-y-auto">
{#each request.body.messages as message, index}
{@const roleColors: Record<string, string> = { user: 'bg-blue-50 border border-blue-200', assistant: 'bg-gray-50 border border-gray-200', system: 'bg-yellow-50 border border-yellow-200' }}
{@const roleIconColors: Record<string, string> = { user: 'text-blue-600', assistant: 'text-gray-600', system: 'text-yellow-600' }}
<div class="rounded-lg p-4 {roleColors[message.role] || 'bg-gray-50 border border-gray-200'}">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center border border-gray-200">
{#if message.role === 'user'}
<User class="w-4 h-4 {roleIconColors[message.role] || 'text-gray-600'}" />
{:else if message.role === 'system'}
<Settings class="w-4 h-4 {roleIconColors[message.role] || 'text-gray-600'}" />
{:else}
<Bot class="w-4 h-4 {roleIconColors[message.role] || 'text-gray-600'}" />
{/if}
</div>
<span class="font-medium capitalize text-gray-900">{message.role}</span>
<span class="text-xs text-gray-500 bg-white px-2 py-1 rounded-full border border-gray-200">#{index + 1}</span>
</div>
</div>
<div>
<MessageContent content={message.content} />
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Model Configuration -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('model')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('model'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Brain class="w-5 h-5 text-purple-600" />
<span>Model Configuration</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.model ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.model}
<div class="p-6 space-y-4">
{#if request.routedModel && request.routedModel !== request.originalModel}
<div class="bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-200 rounded-xl p-4">
<div class="flex items-center space-x-4">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-2">
<span class="text-sm font-semibold text-purple-700">Requested Model</span>
<code class="text-xs bg-white px-2 py-1 rounded font-mono border border-purple-200">{request.originalModel || request.body.model}</code>
</div>
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-2">
<ArrowLeftRight class="w-4 h-4 text-purple-600" />
<span class="text-xs text-purple-600 font-medium">Routed to</span>
</div>
<code class="text-sm bg-white px-3 py-1.5 rounded font-mono font-semibold border border-blue-200 text-blue-700">{request.routedModel}</code>
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">{getProviderName(request.routedModel)}</span>
</div>
</div>
<div class="text-right">
<div class="text-xs text-gray-500 mb-1">Target Endpoint</div>
<code class="text-xs bg-white px-2 py-1 rounded font-mono border border-gray-200">{getChatCompletionsEndpoint(request.routedModel)}</code>
</div>
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-4">
{#if !request.routedModel || request.routedModel === request.originalModel}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div class="text-xs text-gray-500 mb-1">Model</div>
<div class="text-sm font-medium text-gray-900">{request.originalModel || request.body.model || 'N/A'}</div>
</div>
{/if}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div class="text-xs text-gray-500 mb-1">Max Tokens</div>
<div class="text-sm font-medium text-gray-900">{request.body.max_tokens?.toLocaleString() || 'N/A'}</div>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div class="text-xs text-gray-500 mb-1">Temperature</div>
<div class="text-sm font-medium text-gray-900">{request.body.temperature ?? 'N/A'}</div>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div class="text-xs text-gray-500 mb-1">Stream</div>
<div class="text-sm font-medium text-gray-900">{request.body.stream ? 'Yes' : 'No'}</div>
</div>
</div>
</div>
{/if}
</div>
{/if}
<!-- API Response -->
{#if request.response}
{@const response = request.response}
{@const statusColors = getStatusColor(response.statusCode)}
{@const completedAt = response.completedAt ? new Date(response.completedAt).toLocaleString() : 'Unknown'}
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm border-l-4 border-l-blue-500">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('responseOverview')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('responseOverview'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<ArrowLeftRight class="w-5 h-5 text-blue-600" />
<span>API Response</span>
<span class="text-xs px-2 py-1 rounded-full border {statusColors.bg} {statusColors.text} {statusColors.border}">{response.statusCode}</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.responseOverview ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.responseOverview}
<div class="p-6 space-y-6">
<!-- Response overview grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="{statusColors.bg} border {statusColors.border} rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Activity class="w-4 h-4 {statusColors.icon}" /><span class="text-xs font-medium {statusColors.text}">Status</span></div>
<div class="text-lg font-bold {statusColors.text}">{response.statusCode}</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Clock class="w-4 h-4 text-blue-600" /><span class="text-xs font-medium text-blue-700">Response Time</span></div>
<div class="text-lg font-bold text-blue-700">{response.responseTime}ms</div>
</div>
<div class="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Wifi class="w-4 h-4 text-purple-600" /><span class="text-xs font-medium text-purple-700">Type</span></div>
<div class="text-lg font-bold text-purple-700">{response.isStreaming ? 'Stream' : 'Single'}</div>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Calendar class="w-4 h-4 text-gray-600" /><span class="text-xs font-medium text-gray-700">Completed</span></div>
<div class="text-sm font-bold text-gray-700">{completedAt.split(' ')[1] || 'N/A'}</div>
</div>
</div>
<!-- Token Usage -->
{#if response.body?.usage}
{@const usage = response.body.usage}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Brain class="w-4 h-4 text-indigo-600" /><span class="text-xs font-medium text-indigo-700">Input Tokens</span></div>
<div class="text-lg font-bold text-indigo-700">{usage.input_tokens?.toLocaleString() || '0'}</div>
</div>
<div class="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><MessageCircle class="w-4 h-4 text-emerald-600" /><span class="text-xs font-medium text-emerald-700">Output Tokens</span></div>
<div class="text-lg font-bold text-emerald-700">{usage.output_tokens?.toLocaleString() || '0'}</div>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Cpu class="w-4 h-4 text-amber-600" /><span class="text-xs font-medium text-amber-700">Total Tokens</span></div>
<div class="text-lg font-bold text-amber-700">{((usage.input_tokens || 0) + (usage.output_tokens || 0)).toLocaleString()}</div>
</div>
{#if usage.cache_read_input_tokens}
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Bot class="w-4 h-4 text-green-600" /><span class="text-xs font-medium text-green-700">Cached Tokens</span></div>
<div class="text-lg font-bold text-green-700">{usage.cache_read_input_tokens.toLocaleString()}</div>
</div>
{/if}
</div>
{/if}
<!-- Response Headers -->
{#if response.headers}
<div class="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('responseHeaders')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('responseHeaders'); } }}>
<div class="flex items-center justify-between">
<h5 class="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<List class="w-4 h-4 text-gray-600" />
<span>Response Headers</span>
<span class="text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded-full">{Object.keys(response.headers).length}</span>
</h5>
<ChevronDown class="w-4 h-4 text-gray-500 transition-transform {expandedSections.responseHeaders ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.responseHeaders}
<div class="px-4 py-4 space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<button
onclick={() => headerViewMode = { ...headerViewMode, response: 'pretty' }}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors {headerViewMode.response === 'pretty' ? 'bg-blue-100 text-blue-700 border border-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'}"
>Pretty</button>
<button
onclick={() => headerViewMode = { ...headerViewMode, response: 'raw' }}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors {headerViewMode.response === 'raw' ? 'bg-blue-100 text-blue-700 border border-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'}"
>Raw HTTP</button>
</div>
<button onclick={() => handleCopy(headerViewMode.response === 'raw' ? formatHeadersRaw(response.headers) : formatJSON(response.headers), 'responseHeaders')} class="p-1.5 text-gray-500 hover:text-gray-700 transition-colors rounded border border-gray-200 bg-white" title="Copy headers">
{#if copied.responseHeaders}<Check class="w-4 h-4 text-green-600" />{:else}<Copy class="w-4 h-4" />{/if}
</button>
</div>
{#if headerViewMode.response === 'pretty'}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-100 border-b border-gray-200">
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide w-1/3">Header</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide">Value</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each Object.entries(response.headers) as [key, values]}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-2 font-mono text-xs text-blue-700 font-medium align-top">{key}</td>
<td class="px-4 py-2 font-mono text-xs text-gray-700 break-all">
{#if Array.isArray(values)}
{#each values as value, i}
<div class={i > 0 ? 'mt-1 pt-1 border-t border-gray-100' : ''}>{value}</div>
{/each}
{:else}
{values}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<pre class="text-xs text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">{formatHeadersRaw(response.headers)}</pre>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Response Body - Structured Display -->
{#if response.body || response.bodyText}
<div class="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('responseBody')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('responseBody'); } }}>
<div class="flex items-center justify-between">
<h5 class="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<FileText class="w-4 h-4 text-gray-600" />
<span>Response Content</span>
{#if response.body?.content && Array.isArray(response.body.content)}
<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full border border-green-200">{response.body.content.length} blocks</span>
{/if}
{#if response.body?.stop_reason}
<span class="text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded-full">{response.body.stop_reason}</span>
{/if}
</h5>
<ChevronDown class="w-4 h-4 text-gray-500 transition-transform {expandedSections.responseBody ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.responseBody}
<div class="px-4 pb-4 space-y-4">
{#if response.body}
<!-- Response Metadata -->
{#if response.body.id || response.body.model}
<div class="flex flex-wrap gap-2 text-xs">
{#if response.body.id}
<span class="bg-gray-200 text-gray-700 px-2 py-1 rounded font-mono">{response.body.id}</span>
{/if}
{#if response.body.model}
<span class="bg-purple-100 text-purple-700 px-2 py-1 rounded border border-purple-200">{response.body.model}</span>
{/if}
{#if response.body.role}
<span class="bg-blue-100 text-blue-700 px-2 py-1 rounded border border-blue-200">{response.body.role}</span>
{/if}
</div>
{/if}
<!-- Content Blocks -->
{#if response.body.content && Array.isArray(response.body.content)}
<div class="space-y-3">
{#each response.body.content as block, idx}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-3 py-2 bg-gray-100 border-b border-gray-200 flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="text-xs font-medium text-gray-600">#{idx + 1}</span>
<span class="text-xs font-semibold px-2 py-0.5 rounded {block.type === 'text' ? 'bg-blue-100 text-blue-700' : block.type === 'tool_use' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-700'}">{block.type}</span>
{#if block.name}
<span class="text-xs font-mono text-gray-600">{block.name}</span>
{/if}
</div>
{#if block.id}
<span class="text-xs font-mono text-gray-400">{block.id}</span>
{/if}
</div>
<div class="p-3">
{#if block.type === 'text' && block.text}
<div class="prose prose-sm max-w-none">
<pre class="whitespace-pre-wrap text-sm text-gray-800 font-sans leading-relaxed bg-gray-50 rounded p-3 border border-gray-100 max-h-[500px] overflow-y-auto">{block.text}</pre>
</div>
{:else if block.type === 'tool_use'}
<div class="space-y-2">
{#if block.input}
<details class="cursor-pointer" open>
<summary class="text-xs font-medium text-gray-600 mb-1">Tool Input</summary>
<pre class="text-xs text-gray-700 bg-gray-50 rounded p-2 border border-gray-200 overflow-x-auto max-h-64 overflow-y-auto font-mono">{formatJSON(block.input)}</pre>
</details>
{/if}
</div>
{:else if block.type === 'thinking' && block.thinking}
<div class="bg-amber-50 rounded p-3 border border-amber-200">
<div class="flex items-center space-x-2 mb-2">
<Brain class="w-4 h-4 text-amber-600" />
<span class="text-xs font-semibold text-amber-700">Thinking</span>
</div>
<pre class="whitespace-pre-wrap text-sm text-amber-900 leading-relaxed max-h-[400px] overflow-y-auto">{block.thinking}</pre>
</div>
{:else}
<pre class="text-xs text-gray-700 overflow-x-auto font-mono">{formatJSON(block)}</pre>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<!-- Fallback to raw JSON for non-standard responses -->
<details class="cursor-pointer">
<summary class="text-sm font-medium text-gray-700">Raw Response Body</summary>
<pre class="text-xs text-gray-700 overflow-x-auto max-h-96 overflow-y-auto mt-2 bg-white rounded p-3 border border-gray-200 font-mono">{formatJSON(response.body)}</pre>
</details>
{/if}
<!-- Raw JSON toggle -->
<details class="cursor-pointer mt-4">
<summary class="text-xs font-medium text-gray-500 hover:text-gray-700">View Raw JSON</summary>
<div class="mt-2 relative">
<button
onclick={() => handleCopy(formatJSONFull(response.body), 'responseBodyRaw')}
class="absolute top-2 right-2 p-1.5 bg-white rounded border border-gray-300 text-gray-500 hover:text-gray-700 transition-colors z-10"
title="Copy JSON"
>
{#if copied.responseBodyRaw}<Check class="w-3.5 h-3.5 text-green-600" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
<pre class="text-xs text-gray-700 overflow-x-auto max-h-96 overflow-y-auto bg-white rounded p-3 border border-gray-200 font-mono">{formatJSON(response.body)}</pre>
</div>
</details>
{:else if response.bodyText}
<pre class="text-sm text-gray-700 whitespace-pre-wrap bg-white rounded p-3 border border-gray-200">{response.bodyText}</pre>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Streaming Response -->
{#if response.isStreaming && response.streamingChunks && response.streamingChunks.length > 0}
{@const parsed = parseStreamingResponse(response.streamingChunks)}
<div class="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('streamingResponse')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('streamingResponse'); } }}>
<div class="flex items-center justify-between">
<h5 class="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<Wifi class="w-4 h-4 text-gray-600" />
<span>Streaming Response</span>
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">{response.streamingChunks.length} chunks</span>
{#if parsed.isFormatted}
<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full border border-green-200">Parsed</span>
{/if}
</h5>
<ChevronDown class="w-4 h-4 text-gray-500 transition-transform {expandedSections.streamingResponse ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.streamingResponse}
<div class="px-4 pb-4 space-y-3">
{#if parsed.isFormatted}
<div class="bg-white rounded-lg p-4 border border-green-200">
<h6 class="text-sm font-semibold text-green-900 flex items-center space-x-2 mb-3">
<Check class="w-4 h-4" />
<span>Final Response (Clean)</span>
</h6>
<pre class="text-sm text-gray-900 whitespace-pre-wrap leading-relaxed bg-gray-50 rounded p-3 border border-gray-200">{parsed.finalText}</pre>
</div>
{/if}
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="px-3 py-2 cursor-pointer text-sm font-medium text-gray-700">Raw Streaming Data</summary>
<div class="px-3 pb-3">
<pre class="text-xs text-gray-600 overflow-x-auto max-h-64 overflow-y-auto bg-gray-100 rounded p-2 font-mono">{parsed.rawData}</pre>
</div>
</details>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Prompt Grading Results -->
{#if request.promptGrade}
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h4 class="text-lg font-semibold text-gray-900 mb-4">Prompt Quality Analysis</h4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-700">Overall Score:</span>
<span class="text-2xl font-bold text-blue-600">{request.promptGrade.score}/5</span>
</div>
<div class="text-sm text-gray-600"><p>{request.promptGrade.feedback}</p></div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import RichTextInline from './RichTextInline.svelte';
import { parseRichText } from '$lib/rich-text';
interface Props {
text: string;
variant?: 'default' | 'inverse' | 'muted';
size?: 'xs' | 'sm';
className?: string;
}
let { text, variant = 'default', size = 'sm', className = '' }: Props = $props();
let blocks = $derived(parseRichText(text));
function textClass() {
const sizeClass = size === 'xs' ? 'text-xs' : 'text-sm';
switch (variant) {
case 'inverse':
return `${sizeClass} text-white`;
case 'muted':
return `${sizeClass} text-gray-600`;
default:
return `${sizeClass} text-gray-700`;
}
}
function headingClass(level: number) {
const tone =
variant === 'inverse'
? 'text-white'
: variant === 'muted'
? 'text-gray-700'
: 'text-gray-900';
switch (level) {
case 1:
return `text-xl font-bold ${tone}`;
case 2:
return `text-lg font-bold ${tone}`;
case 3:
return `text-base font-semibold ${tone}`;
case 4:
return `text-sm font-semibold ${tone}`;
default:
return `text-xs font-semibold ${tone}`;
}
}
function codeBlockClass() {
if (variant === 'inverse') {
return 'bg-blue-600/40 text-white border border-blue-300/30';
}
return 'bg-gray-900 text-gray-100 border border-gray-700';
}
function listClass(type: 'ul' | 'ol') {
return `${textClass()} ${type === 'ul' ? 'list-disc' : 'list-decimal'} list-inside space-y-1 pl-1`;
}
function headingTag(level: number): keyof HTMLElementTagNameMap {
switch (level) {
case 1:
return 'h1';
case 2:
return 'h2';
case 3:
return 'h3';
case 4:
return 'h4';
case 5:
return 'h5';
default:
return 'h6';
}
}
</script>
<div class={`space-y-3 ${className}`.trim()}>
{#each blocks as block, index (`${block.type}-${index}`)}
{#if block.type === 'paragraph'}
<p class={`${textClass()} leading-relaxed break-words`}>
<RichTextInline segments={block.content} variant={variant} />
</p>
{:else if block.type === 'heading'}
<svelte:element this={headingTag(block.level)} class={headingClass(block.level)}>
<RichTextInline segments={block.content} variant={variant} />
</svelte:element>
{:else if block.type === 'ul' || block.type === 'ol'}
<svelte:element this={block.type} class={listClass(block.type)}>
{#each block.items as item, itemIndex (`item-${itemIndex}`)}
<li class="leading-relaxed">
<RichTextInline segments={item} variant={variant} />
</li>
{/each}
</svelte:element>
{:else if block.type === 'code_block'}
<pre class={`${codeBlockClass()} rounded-lg p-4 overflow-x-auto font-mono ${size === 'xs' ? 'text-xs' : 'text-sm'}`}><code>{block.code}</code></pre>
{:else if block.type === 'hr'}
<hr class={variant === 'inverse' ? 'border-blue-200/30' : 'border-gray-300'} />
{:else}
<div class={size === 'xs' ? 'h-2' : 'h-3'}></div>
{/if}
{/each}
</div>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import type { RichTextInline } from '$lib/rich-text';
interface Props {
segments: RichTextInline[];
variant?: 'default' | 'inverse' | 'muted';
}
let { segments, variant = 'default' }: Props = $props();
function textClass() {
switch (variant) {
case 'inverse':
return 'text-white';
case 'muted':
return 'text-gray-600';
default:
return 'text-gray-700';
}
}
function strongClass() {
switch (variant) {
case 'inverse':
return 'font-semibold text-white';
case 'muted':
return 'font-semibold text-gray-700';
default:
return 'font-semibold text-gray-900';
}
}
function emClass() {
switch (variant) {
case 'inverse':
return 'italic text-blue-100';
case 'muted':
return 'italic text-gray-600';
default:
return 'italic text-gray-700';
}
}
function codeClass() {
switch (variant) {
case 'inverse':
return 'bg-blue-400/30 border border-blue-300/30 text-white px-1 py-0.5 rounded text-[0.85em] font-mono';
case 'muted':
return 'bg-gray-100 text-gray-700 px-1 py-0.5 rounded text-[0.85em] font-mono border border-gray-200';
default:
return 'bg-gray-100 text-gray-800 px-1 py-0.5 rounded text-[0.85em] font-mono border border-gray-200';
}
}
function linkClass() {
switch (variant) {
case 'inverse':
return 'text-blue-200 hover:text-white underline underline-offset-2';
case 'muted':
return 'text-blue-600 hover:text-blue-800 underline underline-offset-2';
default:
return 'text-blue-600 hover:text-blue-800 underline underline-offset-2';
}
}
</script>
{#each segments as segment, index (`${segment.type}-${index}`)}
{#if segment.type === 'text'}
<span class={textClass()}>{segment.text}</span>
{:else if segment.type === 'strong'}
<strong class={strongClass()}>{segment.text}</strong>
{:else if segment.type === 'em'}
<em class={emClass()}>{segment.text}</em>
{:else if segment.type === 'code'}
<code class={codeClass()}>{segment.text}</code>
{:else}
<a href={segment.href} target="_blank" rel="noopener noreferrer" class={linkClass()}>{segment.text}</a>
{/if}
{/each}

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Sun, Moon, Monitor } from 'lucide-svelte';
import { getTheme, cycleTheme } from '$lib/theme.svelte';
let current = $derived(getTheme());
</script>
<button
onclick={cycleTheme}
class="p-1.5 rounded transition-colors text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
title="Theme: {current}"
>
{#if current === 'light'}
<Sun class="w-3.5 h-3.5" />
{:else if current === 'dark'}
<Moon class="w-3.5 h-3.5" />
{:else}
<Monitor class="w-3.5 h-3.5" />
{/if}
</button>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import { CheckSquare, Square, Clock, AlertCircle, ListTodo } from 'lucide-svelte';
import type { TodoItem } from '$lib/types';
interface Props {
todos: TodoItem[];
}
let { todos }: Props = $props();
let groupedTodos = $derived({
in_progress: todos.filter((t) => t.status === 'in_progress'),
pending: todos.filter((t) => t.status === 'pending'),
completed: todos.filter((t) => t.status === 'completed')
});
function getTaskText(todo: TodoItem): string {
return (
todo.task || todo.description || todo.content || todo.title || todo.text ||
(Object.entries(todo).find(([key, value]) => typeof value === 'string' && !['priority', 'status'].includes(key))?.[1] as string | undefined) ||
'No task description'
);
}
function getPriorityColor(priority: string) {
switch (priority) {
case 'high': return 'text-red-600 bg-red-50 border-red-200';
case 'medium': return 'text-yellow-600 bg-yellow-50 border-yellow-200';
case 'low': return 'text-green-600 bg-green-50 border-green-200';
default: return 'text-gray-600 bg-gray-50 border-gray-200';
}
}
function getStatusColor(status: string) {
switch (status) {
case 'completed': return 'bg-green-50 border-green-200';
case 'in_progress': return 'bg-blue-50 border-blue-200';
default: return 'bg-gray-50 border-gray-200';
}
}
</script>
{#if !todos || todos.length === 0}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<ListTodo class="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p class="text-sm text-gray-600">No tasks in the todo list</p>
</div>
{:else}
<div class="space-y-3">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
<ListTodo class="w-4 h-4 text-indigo-600" />
<span class="text-sm font-semibold text-gray-900">Todo List</span>
</div>
<div class="flex items-center space-x-2 text-xs">
{#if groupedTodos.in_progress.length > 0}
<span class="px-2 py-1 bg-blue-100 text-blue-700 rounded-full border border-blue-200">{groupedTodos.in_progress.length} in progress</span>
{/if}
{#if groupedTodos.pending.length > 0}
<span class="px-2 py-1 bg-gray-100 text-gray-700 rounded-full border border-gray-200">{groupedTodos.pending.length} pending</span>
{/if}
{#if groupedTodos.completed.length > 0}
<span class="px-2 py-1 bg-green-100 text-green-700 rounded-full border border-green-200">{groupedTodos.completed.length} completed</span>
{/if}
</div>
</div>
<div class="space-y-2">
{#each groupedTodos.in_progress as todo}
<div class="flex items-start space-x-3 p-3 rounded-lg border {getStatusColor(todo.status)} transition-all duration-200">
<div class="flex-shrink-0 mt-0.5"><Clock class="w-4 h-4 text-blue-600 animate-pulse" /></div>
<div class="flex-1 min-w-0"><p class="text-sm text-gray-900">{getTaskText(todo)}</p></div>
<div class="flex-shrink-0"><span class="text-xs px-2 py-1 rounded-full border font-medium {getPriorityColor(todo.priority)}">{todo.priority}</span></div>
</div>
{/each}
{#each groupedTodos.pending as todo}
<div class="flex items-start space-x-3 p-3 rounded-lg border {getStatusColor(todo.status)} transition-all duration-200">
<div class="flex-shrink-0 mt-0.5"><Square class="w-4 h-4 text-gray-400" /></div>
<div class="flex-1 min-w-0"><p class="text-sm text-gray-900">{getTaskText(todo)}</p></div>
<div class="flex-shrink-0"><span class="text-xs px-2 py-1 rounded-full border font-medium {getPriorityColor(todo.priority)}">{todo.priority}</span></div>
</div>
{/each}
{#each groupedTodos.completed as todo}
<div class="flex items-start space-x-3 p-3 rounded-lg border {getStatusColor(todo.status)} transition-all duration-200">
<div class="flex-shrink-0 mt-0.5"><CheckSquare class="w-4 h-4 text-green-600" /></div>
<div class="flex-1 min-w-0"><p class="text-sm line-through text-gray-500">{getTaskText(todo)}</p></div>
<div class="flex-shrink-0"><span class="text-xs px-2 py-1 rounded-full border font-medium {getPriorityColor(todo.priority)}">{todo.priority}</span></div>
</div>
{/each}
</div>
</div>
{/if}

View file

@ -0,0 +1,182 @@
<script lang="ts">
import { ChevronDown, ChevronRight, CheckCircle, AlertCircle, FileText, Database, Clock } from 'lucide-svelte';
import { formatValue, formatJSON, isComplexObject, truncateText } from '$lib/formatters';
import CodeViewer from './CodeViewer.svelte';
interface Props {
content: unknown;
toolId?: string;
isError?: boolean;
}
let { content, toolId, isError = false }: Props = $props();
let isExpanded = $state(false);
function isCodeContent(c: string): boolean {
if (typeof c !== 'string') return false;
const hasLineNumbers = /^\s*\d+→/m.test(c);
const hasCodePatterns =
c.includes('function') || c.includes('const ') || c.includes('let ') ||
c.includes('var ') || c.includes('import ') || c.includes('export ') ||
c.includes('class ') || c.includes('interface ') || c.includes('type ') ||
c.includes('def ') || c.includes('if (') || c.includes('for (') ||
c.includes('while (') || (c.includes('{') && c.includes('}'));
return hasLineNumbers || (hasCodePatterns && c.length > 100);
}
function extractCodeFromCatN(c: string): { code: string; fileName?: string } {
if (typeof c !== 'string') return { code: c };
if (!/^\s*\d+→/m.test(c)) return { code: c };
const lines = c.split('\n');
const codeLines = lines.map((line) => {
const match = line.match(/^\s*\d+→(.*)$/);
return match ? match[1] : line;
});
return { code: codeLines.join('\n') };
}
function getDisplayContent(): string {
if (typeof content === 'string') return content;
if (content && typeof content === 'object') {
const obj = content as Record<string, unknown>;
if (typeof obj.text === 'string') return obj.text;
if (typeof obj.content === 'string') return obj.content;
}
if (Array.isArray(content)) return content.map((item) => formatValue(item)).join('\n');
if (isComplexObject(content)) return formatJSON(content);
return formatValue(content);
}
let displayContent = $derived(getDisplayContent());
let isLargeContent = $derived(displayContent.length > 2000);
let shouldTruncate = $derived(isLargeContent && !isExpanded);
let truncatedContent = $derived(shouldTruncate ? truncateText(displayContent, 2000) : displayContent);
let isJSONContent = $derived(isComplexObject(content) || (typeof content === 'string' && content.startsWith('{')));
let isCode = $derived(isCodeContent(displayContent));
let extractedCode = $derived(isCode ? extractCodeFromCatN(displayContent).code : displayContent);
let config = $derived(
isError
? {
bgColor: 'bg-gradient-to-r from-red-50 to-pink-50',
borderColor: 'border-red-200',
accentColor: 'border-l-red-500',
iconBg: 'bg-red-100',
iconColor: 'text-red-600',
titleColor: 'text-red-900',
title: 'Tool Error',
dotColor: 'bg-red-500',
statusText: 'Execution failed'
}
: {
bgColor: 'bg-gradient-to-r from-emerald-50 to-green-50',
borderColor: 'border-emerald-200',
accentColor: 'border-l-emerald-500',
iconBg: 'bg-emerald-100',
iconColor: 'text-emerald-600',
titleColor: 'text-emerald-900',
title: 'Tool Result',
dotColor: 'bg-emerald-500',
statusText: 'Execution completed'
}
);
</script>
<div class="{config.bgColor} {config.borderColor} {config.accentColor} border border-l-4 rounded-xl p-5 shadow-sm hover:shadow-md transition-all duration-200">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 {config.iconBg} rounded-xl flex items-center justify-center shadow-sm">
<div class={config.iconColor}>
{#if isError}
<AlertCircle class="w-5 h-5" />
{:else}
<CheckCircle class="w-5 h-5" />
{/if}
</div>
</div>
<div>
<div class="flex items-center space-x-2">
<span class="font-semibold text-base {config.titleColor}">{config.title}</span>
<Database class="w-4 h-4 text-gray-500" />
</div>
{#if toolId}
<div class="flex items-center space-x-2 mt-1">
<FileText class="w-3 h-3 text-gray-500" />
<span class="text-xs text-gray-500 font-mono bg-white px-2 py-1 rounded-md border border-gray-200">{toolId}</span>
</div>
{/if}
</div>
</div>
{#if isLargeContent}
<button onclick={() => (isExpanded = !isExpanded)} class="flex items-center space-x-2 text-xs text-gray-600 hover:text-gray-800 bg-white hover:bg-gray-50 px-3 py-2 rounded-lg border border-gray-200 transition-all duration-200">
{#if isExpanded}
<ChevronDown class="w-3 h-3" />
{:else}
<ChevronRight class="w-3 h-3" />
{/if}
<span>{isExpanded ? 'Collapse' : 'Expand'}</span>
</button>
{/if}
</div>
<!-- Content -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm">
<div class="p-4">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-gray-100">
<div class="flex items-center space-x-2 text-xs text-gray-600">
<Clock class="w-3 h-3" />
<span>Result received</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
{isCode ? 'Code' : isJSONContent ? 'JSON' : 'Text'}
</span>
{#if !isCode}
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">{displayContent.length} chars</span>
{/if}
</div>
</div>
{#if isCode}
<CodeViewer code={extractedCode} fileName={content && typeof content === 'object' && 'fileName' in content && typeof content.fileName === 'string' ? content.fileName : undefined} />
{:else if isJSONContent}
<pre class="text-sm text-gray-700 whitespace-pre-wrap font-mono overflow-x-auto bg-gray-50 rounded-lg p-3 border border-gray-200">{truncatedContent}</pre>
{:else}
<pre class="text-sm text-gray-700 whitespace-pre-wrap break-words leading-relaxed">{truncatedContent}</pre>
{/if}
{#if shouldTruncate && !isCode}
<div class="mt-3 pt-3 border-t border-gray-200">
<button onclick={() => (isExpanded = true)} class="text-xs text-blue-600 hover:text-blue-800 underline transition-colors">
Show full content ({displayContent.length.toLocaleString()} characters)
</button>
</div>
{/if}
</div>
</div>
<!-- Metadata -->
{#if content && typeof content === 'object' && Object.keys(content).length > 1}
<div class="mt-3">
<details class="cursor-pointer group">
<summary class="text-xs text-gray-600 hover:text-gray-800 transition-colors flex items-center space-x-1">
<ChevronRight class="w-3 h-3 group-open:rotate-90 transition-transform" />
<span>Show raw data structure</span>
</summary>
<div class="mt-2 bg-white rounded-lg border border-gray-200 p-3">
<pre class="text-xs overflow-x-auto font-mono text-gray-700 bg-gray-50 rounded p-2">{formatJSON(content)}</pre>
</div>
</details>
</div>
{/if}
<!-- Result indicator -->
<div class="mt-4 pt-3 border-t border-gray-200">
<div class="flex items-center space-x-2 text-xs {config.titleColor}">
<div class="w-2 h-2 rounded-full {config.dotColor}"></div>
<span>{config.statusText}</span>
</div>
</div>
</div>

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { Wrench, ChevronDown, ChevronRight, Copy, Check, Terminal, Zap } from 'lucide-svelte';
import { formatValue, formatJSON, isComplexObject } from '$lib/formatters';
import type { ToolInput } from '$lib/types';
import CodeDiff from './CodeDiff.svelte';
import TodoList from './TodoList.svelte';
interface Props {
name: string;
id: string;
input?: ToolInput;
text?: string;
}
let { name, id, input = {}, text }: Props = $props();
let isParamsExpanded = $state(false);
let copied = $state(false);
async function handleCopy() {
try {
await navigator.clipboard.writeText(formatJSON({ name, id, input }));
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
}
function objectKeyCount(value: unknown): number {
return value && typeof value === 'object' ? Object.keys(value).length : 0;
}
let inputKeys = $derived(Object.keys(input));
</script>
<div class="bg-gradient-to-r from-indigo-50 to-blue-50 border border-indigo-200 rounded-xl p-5 shadow-sm hover:shadow-md transition-all duration-200">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-blue-600 rounded-xl flex items-center justify-center shadow-sm">
<Wrench class="w-5 h-5 text-white" />
</div>
<div>
<div class="flex items-center space-x-2">
<span class="text-indigo-900 font-semibold text-base">Tool Execution</span>
<Zap class="w-4 h-4 text-indigo-600" />
</div>
<div class="flex items-center space-x-2 mt-1">
<Terminal class="w-3 h-3 text-indigo-600" />
<span class="font-mono text-sm text-indigo-700 bg-white px-2 py-1 rounded-md border border-indigo-200 font-medium">{name}</span>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500 font-mono bg-white px-2 py-1 rounded-md border border-gray-200">{id}</span>
<button onclick={handleCopy} class="p-2 text-gray-500 hover:text-indigo-600 hover:bg-white transition-all duration-200 rounded-lg border border-transparent hover:border-indigo-200" title="Copy tool call details">
{#if copied}
<Check class="w-4 h-4 text-green-600" />
{:else}
<Copy class="w-4 h-4" />
{/if}
</button>
</div>
</div>
<!-- Edit tool - code diff -->
{#if name === 'Edit' && input.old_string && input.new_string}
<div class="mb-4">
<div class="text-sm font-semibold text-indigo-900 mb-3">Code Changes</div>
<CodeDiff oldCode={input.old_string} newCode={input.new_string} fileName={input.file_path} />
</div>
{/if}
<!-- Read tool -->
{#if name === 'Read' && input.file_path}
<div class="mb-4">
<div class="text-sm font-semibold text-indigo-900 mb-3">File Contents</div>
<div class="text-xs text-gray-600 mb-2">Reading: <span class="font-mono">{input.file_path}</span></div>
</div>
{/if}
<!-- TodoWrite tool -->
{#if name === 'TodoWrite' && input.todos && Array.isArray(input.todos)}
<div class="mb-4">
<div class="text-sm font-semibold text-indigo-900 mb-3">Task Management</div>
<TodoList todos={input.todos} />
</div>
{/if}
<!-- Parameters -->
{#if inputKeys.length > 0}
<div class="mb-4">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-semibold text-indigo-900 flex items-center space-x-2">
<span>Parameters</span>
<span class="text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full border border-indigo-200">{inputKeys.length}</span>
</div>
{#if inputKeys.length > 2}
<button onclick={() => (isParamsExpanded = !isParamsExpanded)} class="flex items-center space-x-1 text-xs text-indigo-600 hover:text-indigo-800 transition-colors">
{#if isParamsExpanded}
<ChevronDown class="w-3 h-3" />
{:else}
<ChevronRight class="w-3 h-3" />
{/if}
<span>{isParamsExpanded ? 'Collapse' : 'Expand'}</span>
</button>
{/if}
</div>
{#if name !== 'Edit' && name !== 'TodoWrite'}
<div class="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div class="space-y-3 {!isParamsExpanded && inputKeys.length > 2 ? 'max-h-32 overflow-hidden' : ''}">
{#each Object.entries(input) as [key, value] (key)}
<div class="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg border border-gray-100">
<span class="font-mono text-sm text-indigo-600 pt-0.5 min-w-0 flex-shrink-0 font-medium">{key}:</span>
<div class="flex-1 min-w-0">
{#if typeof value === 'string'}
{#if value.length > 200 || value.includes('\n')}
<details class="cursor-pointer">
<summary class="text-xs text-indigo-600 hover:text-indigo-800 underline transition-colors">Show large parameter</summary>
<pre class="mt-2 bg-gray-50 border border-gray-200 p-3 rounded-lg text-xs max-h-64 overflow-auto font-mono whitespace-pre-wrap">{value}</pre>
</details>
{:else}
<span class="text-gray-700 text-sm break-all font-mono">{value}</span>
{/if}
{:else if Array.isArray(value)}
<details class="cursor-pointer">
<summary class="text-xs text-indigo-600 hover:text-indigo-800 underline transition-colors">Show array ({value.length} items)</summary>
<pre class="mt-2 bg-gray-50 border border-gray-200 p-3 rounded-lg text-xs overflow-auto font-mono">{formatJSON(value)}</pre>
</details>
{:else if isComplexObject(value)}
<details class="cursor-pointer">
<summary class="text-xs text-indigo-600 hover:text-indigo-800 underline transition-colors">Show object ({objectKeyCount(value)} properties)</summary>
<pre class="mt-2 bg-gray-50 border border-gray-200 p-3 rounded-lg text-xs overflow-auto font-mono">{formatJSON(value)}</pre>
</details>
{:else}
<span class="text-gray-700 text-sm font-mono">{formatValue(value)}</span>
{/if}
</div>
</div>
{/each}
</div>
{#if !isParamsExpanded && inputKeys.length > 2}
<div class="mt-3 pt-3 border-t border-gray-200">
<button onclick={() => (isParamsExpanded = true)} class="text-xs text-indigo-600 hover:text-indigo-800 underline transition-colors">
Show all {inputKeys.length} parameters
</button>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Additional text -->
{#if text}
<div class="bg-white rounded-lg p-3 border border-gray-200 shadow-sm">
<div class="text-xs text-gray-600 mb-1 font-medium">Additional Information:</div>
<div class="text-sm text-gray-700">{text}</div>
</div>
{/if}
<!-- Tool execution indicator -->
<div class="mt-4 pt-3 border-t border-indigo-200">
<div class="flex items-center space-x-2 text-xs text-indigo-700">
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-pulse"></div>
<span>Tool execution initiated</span>
</div>
</div>
</div>

View file

@ -0,0 +1,87 @@
<script lang="ts">
import { Settings, Wrench, Terminal, Database, Code, ChevronRight, ChevronDown } from 'lucide-svelte';
import { parseXmlBlocks, getXmlTagStyle, type XmlSegment } from '$lib/formatters';
import XmlBlock from './XmlBlock.svelte';
import RichText from './RichText.svelte';
interface Props {
tag: string;
innerContent: string;
startCollapsed?: boolean;
}
let { tag, innerContent, startCollapsed = true }: Props = $props();
let manualExpanded = $state<boolean | null>(null);
let isExpanded = $derived(manualExpanded ?? !startCollapsed);
const iconMap: Record<string, typeof Settings> = {
settings: Settings,
wrench: Wrench,
terminal: Terminal,
database: Database,
code: Code
};
let style = $derived.by(() => getXmlTagStyle(tag));
let IconComponent = $derived.by(() => iconMap[style.icon] || Code);
// Parse inner content for nested XML blocks
let innerSegments = $derived(parseXmlBlocks(innerContent));
let hasNestedXml = $derived(innerSegments.some((s: XmlSegment) => s.type === 'xml'));
// For short content without nested XML, show inline
let isShortContent = $derived(!hasNestedXml && innerContent.trim().length < 200);
function formatTagName(name: string): string {
return name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function toggleExpanded() {
manualExpanded = !isExpanded;
}
</script>
<div class="{style.bg} {style.border} border rounded-lg overflow-hidden">
<button
onclick={toggleExpanded}
class="w-full px-3 py-2 flex items-center justify-between hover:brightness-95 transition-all {style.headerBg}"
>
<div class="flex items-center space-x-2 min-w-0">
<IconComponent class="w-3.5 h-3.5 {style.text} flex-shrink-0" />
<span class="text-xs font-semibold {style.text} truncate">{formatTagName(tag)}</span>
<code class="text-[10px] text-gray-400 font-mono hidden sm:inline">&lt;{tag}&gt;</code>
{#if isShortContent && !isExpanded}
<span class="text-[11px] text-gray-500 truncate max-w-[300px]">{innerContent.trim()}</span>
{/if}
</div>
<div class="flex items-center space-x-1 flex-shrink-0">
{#if innerContent.trim().length > 0}
<span class="text-[10px] text-gray-400">{innerContent.trim().length.toLocaleString()} chars</span>
{/if}
{#if isExpanded}
<ChevronDown class="w-3.5 h-3.5 text-gray-400" />
{:else}
<ChevronRight class="w-3.5 h-3.5 text-gray-400" />
{/if}
</div>
</button>
{#if isExpanded}
<div class="px-3 py-2.5 border-t {style.border}">
{#if hasNestedXml}
<div class="space-y-2">
{#each innerSegments as segment, index (`${segment.type}-${segment.tag ?? 'text'}-${index}`)}
{#if segment.type === 'xml' && segment.tag && segment.innerContent !== undefined}
<XmlBlock tag={segment.tag} innerContent={segment.innerContent} startCollapsed={true} />
{:else if segment.type === 'text' && segment.content.trim()}
<RichText text={segment.content} size="xs" />
{/if}
{/each}
</div>
{:else}
<div class="text-xs text-gray-700 leading-relaxed overflow-x-auto whitespace-pre-wrap font-mono">{innerContent.trim()}</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1 @@
export * from '../../../shared/frontend/formatters';

29
svelte/src/lib/models.ts Normal file
View file

@ -0,0 +1,29 @@
/**
* Utility functions for model-related operations
*/
export function isOpenAIModel(model: string | null | undefined): boolean {
if (!model) return false;
return model.startsWith('gpt-') || /^o[0-9]/.test(model);
}
export function getProviderName(model: string | null | undefined): 'OpenAI' | 'Anthropic' {
return isOpenAIModel(model) ? 'OpenAI' : 'Anthropic';
}
export function getChatCompletionsEndpoint(model: string | null | undefined, defaultEndpoint?: string): string {
return isOpenAIModel(model) ? '/v1/chat/completions' : (defaultEndpoint || '/v1/messages');
}
/**
* Get a short display label and color class for a model string.
*/
export function getModelDisplay(model: string): { label: string; colorClass: string } {
if (!model) return { label: 'API', colorClass: 'text-gray-900' };
const m = model.toLowerCase();
if (m.includes('opus')) return { label: 'Opus', colorClass: 'text-purple-600' };
if (m.includes('sonnet')) return { label: 'Sonnet', colorClass: 'text-indigo-600' };
if (m.includes('haiku')) return { label: 'Haiku', colorClass: 'text-teal-600' };
if (isOpenAIModel(model)) return { label: model.includes('gpt-4o') ? 'GPT-4o' : model.split('-')[0].toUpperCase(), colorClass: 'text-green-600' };
return { label: model.split('-')[0], colorClass: 'text-gray-900' };
}

118
svelte/src/lib/pricing.ts Normal file
View file

@ -0,0 +1,118 @@
import { isOpenAIModel } from './models';
/**
* Anthropic API pricing (per million tokens) as of March 2026
* https://docs.anthropic.com/en/docs/about-claude/pricing
*/
export interface ModelPricing {
inputPerMTok: number;
outputPerMTok: number;
cacheReadPerMTok: number;
cacheWritePerMTok: number;
label: string;
tier: 'opus' | 'sonnet' | 'haiku' | 'unknown';
}
const PRICING: Record<string, ModelPricing> = {
// Opus 4 family
'claude-opus-4': { inputPerMTok: 15, outputPerMTok: 75, cacheReadPerMTok: 1.50, cacheWritePerMTok: 18.75, label: 'Opus 4', tier: 'opus' },
// Sonnet 4 family
'claude-sonnet-4': { inputPerMTok: 3, outputPerMTok: 15, cacheReadPerMTok: 0.30, cacheWritePerMTok: 3.75, label: 'Sonnet 4', tier: 'sonnet' },
// Haiku 3.5
'claude-haiku-3': { inputPerMTok: 0.80, outputPerMTok: 4, cacheReadPerMTok: 0.08, cacheWritePerMTok: 1, label: 'Haiku 3.5', tier: 'haiku' },
};
// Subscription plans for comparison
export interface SubscriptionPlan {
name: string;
monthlyPrice: number;
description: string;
}
export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [
{ name: 'Claude Pro', monthlyPrice: 20, description: 'Standard usage limits' },
{ name: 'Claude Max 5x', monthlyPrice: 100, description: '5x Pro usage' },
{ name: 'Claude Max 20x', monthlyPrice: 200, description: '20x Pro usage' },
];
/**
* Match a model string to its pricing tier
*/
export function getModelPricing(model: string): ModelPricing {
if (isOpenAIModel(model)) {
return { ...PRICING['claude-sonnet-4'], label: model.split('-').slice(0, 2).join('-'), tier: 'unknown' };
}
const m = model.toLowerCase();
if (m.includes('opus')) return PRICING['claude-opus-4'];
if (m.includes('sonnet')) return PRICING['claude-sonnet-4'];
if (m.includes('haiku')) return PRICING['claude-haiku-3'];
// Default to sonnet pricing for unknown models
return { ...PRICING['claude-sonnet-4'], label: model.split('-').slice(0, 2).join('-'), tier: 'unknown' };
}
/**
* Calculate cost for a given token count and model
*/
export function calculateCost(
inputTokens: number,
outputTokens: number,
cacheReadTokens: number,
cacheWriteTokens: number,
model: string
): number {
const pricing = getModelPricing(model);
return (
(inputTokens / 1_000_000) * pricing.inputPerMTok +
(outputTokens / 1_000_000) * pricing.outputPerMTok +
(cacheReadTokens / 1_000_000) * pricing.cacheReadPerMTok +
(cacheWriteTokens / 1_000_000) * pricing.cacheWritePerMTok
);
}
/**
* Calculate costs from usage stats broken down by model
*/
export function calculateTotalCostFromStats(
requestsByModel: Record<string, { request_count: number; input_tokens: number; output_tokens: number; cache_tokens: number }>
): { totalCost: number; costByModel: Array<{ model: string; label: string; cost: number; inputTokens: number; outputTokens: number; cacheTokens: number; requests: number }> } {
let totalCost = 0;
const costByModel: Array<{ model: string; label: string; cost: number; inputTokens: number; outputTokens: number; cacheTokens: number; requests: number }> = [];
for (const [model, stats] of Object.entries(requestsByModel)) {
const pricing = getModelPricing(model);
// Treat cache_tokens as cache reads (most common case)
const cost = calculateCost(stats.input_tokens, stats.output_tokens, stats.cache_tokens, 0, model);
totalCost += cost;
costByModel.push({
model,
label: pricing.label,
cost,
inputTokens: stats.input_tokens,
outputTokens: stats.output_tokens,
cacheTokens: stats.cache_tokens,
requests: stats.request_count,
});
}
// Sort by cost descending
costByModel.sort((a, b) => b.cost - a.cost);
return { totalCost, costByModel };
}
/**
* Format a dollar amount for display
*/
export function formatCost(cost: number): string {
if (cost < 0.01) return `$${cost.toFixed(4)}`;
if (cost < 1) return `$${cost.toFixed(3)}`;
return `$${cost.toFixed(2)}`;
}
/**
* Project monthly cost from a daily rate
*/
export function projectMonthlyCost(dailyCost: number): number {
return dailyCost * 30;
}

170
svelte/src/lib/rich-text.ts Normal file
View file

@ -0,0 +1,170 @@
export type RichTextInline =
| { type: 'text'; text: string }
| { type: 'strong'; text: string }
| { type: 'em'; text: string }
| { type: 'code'; text: string }
| { type: 'link'; text: string; href: string };
export type RichTextBlock =
| { type: 'paragraph'; content: RichTextInline[] }
| { type: 'heading'; level: number; content: RichTextInline[] }
| { type: 'ul'; items: RichTextInline[][] }
| { type: 'ol'; items: RichTextInline[][] }
| { type: 'code_block'; code: string }
| { type: 'hr' }
| { type: 'spacer' };
const INLINE_TOKEN_PATTERN = /(https?:\/\/[^\s<]+|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g;
function pushText(segments: RichTextInline[], text: string) {
if (!text) return;
segments.push({ type: 'text', text });
}
function splitTrailingPunctuation(url: string): { href: string; trailing: string } {
const trailingMatch = url.match(/[),.!?;:]+$/);
if (!trailingMatch) return { href: url, trailing: '' };
const trailing = trailingMatch[0];
return {
href: url.slice(0, url.length - trailing.length),
trailing
};
}
export function parseInlineRichText(text: string): RichTextInline[] {
if (!text) return [];
const segments: RichTextInline[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = INLINE_TOKEN_PATTERN.exec(text)) !== null) {
if (match.index > lastIndex) {
pushText(segments, text.slice(lastIndex, match.index));
}
const token = match[0];
if (token.startsWith('**') && token.endsWith('**')) {
segments.push({ type: 'strong', text: token.slice(2, -2) });
} else if (token.startsWith('*') && token.endsWith('*')) {
segments.push({ type: 'em', text: token.slice(1, -1) });
} else if (token.startsWith('`') && token.endsWith('`')) {
segments.push({ type: 'code', text: token.slice(1, -1) });
} else {
const { href, trailing } = splitTrailingPunctuation(token);
segments.push({ type: 'link', text: href, href });
pushText(segments, trailing);
}
lastIndex = match.index + token.length;
}
if (lastIndex < text.length) {
pushText(segments, text.slice(lastIndex));
}
return segments;
}
export function parseRichText(text: string): RichTextBlock[] {
if (!text) return [];
const lines = text.split('\n');
const blocks: RichTextBlock[] = [];
let index = 0;
let activeListType: 'ul' | 'ol' | null = null;
let activeListItems: RichTextInline[][] = [];
function flushList() {
if (!activeListType || activeListItems.length === 0) return;
blocks.push({
type: activeListType,
items: activeListItems
});
activeListType = null;
activeListItems = [];
}
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (/^```/.test(trimmed)) {
flushList();
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !/^```/.test(lines[index].trim())) {
codeLines.push(lines[index]);
index += 1;
}
blocks.push({ type: 'code_block', code: codeLines.join('\n') });
if (index < lines.length) index += 1;
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
flushList();
blocks.push({ type: 'hr' });
index += 1;
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushList();
blocks.push({
type: 'heading',
level: headingMatch[1].length,
content: parseInlineRichText(headingMatch[2])
});
index += 1;
continue;
}
const bulletMatch = line.match(/^\s*[-*+]\s+(.+)$/);
if (bulletMatch) {
if (activeListType !== 'ul') {
flushList();
activeListType = 'ul';
}
activeListItems.push(parseInlineRichText(bulletMatch[1]));
index += 1;
continue;
}
const numberMatch = line.match(/^\s*\d+[.)]\s+(.+)$/);
if (numberMatch) {
if (activeListType !== 'ol') {
flushList();
activeListType = 'ol';
}
activeListItems.push(parseInlineRichText(numberMatch[1]));
index += 1;
continue;
}
if (trimmed === '') {
flushList();
if (blocks[blocks.length - 1]?.type !== 'spacer') {
blocks.push({ type: 'spacer' });
}
index += 1;
continue;
}
flushList();
blocks.push({
type: 'paragraph',
content: parseInlineRichText(line)
});
index += 1;
}
flushList();
while (blocks[0]?.type === 'spacer') blocks.shift();
while (blocks[blocks.length - 1]?.type === 'spacer') blocks.pop();
return blocks;
}

View file

@ -0,0 +1,45 @@
export type ThemeMode = 'light' | 'dark' | 'system';
let mode = $state<ThemeMode>('system');
function getSystemPreference(): boolean {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function applyTheme(m: ThemeMode) {
if (typeof document === 'undefined') return;
const isDark = m === 'dark' || (m === 'system' && getSystemPreference());
document.documentElement.classList.toggle('dark', isDark);
}
export function initTheme() {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem('theme') as ThemeMode | null;
if (stored && ['light', 'dark', 'system'].includes(stored)) {
mode = stored;
}
applyTheme(mode);
// Listen for system theme changes
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', () => {
if (mode === 'system') applyTheme('system');
});
}
export function getTheme(): ThemeMode {
return mode;
}
export function setTheme(m: ThemeMode) {
mode = m;
localStorage.setItem('theme', m);
applyTheme(m);
}
export function cycleTheme() {
const order: ThemeMode[] = ['system', 'light', 'dark'];
const next = order[(order.indexOf(mode) + 1) % order.length];
setTheme(next);
}

1
svelte/src/lib/types.ts Normal file
View file

@ -0,0 +1 @@
export * from '../../../shared/frontend/types';

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { page } from '$app/stores';
import { AlertTriangle, RefreshCw } from 'lucide-svelte';
</script>
<div class="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center p-6">
<div class="max-w-md w-full bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-8 text-center space-y-4">
<AlertTriangle class="w-8 h-8 mx-auto text-amber-500" />
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{$page.status}</h1>
<p class="text-sm text-gray-600 dark:text-gray-400">{$page.error?.message}</p>
<button
onclick={() => location.reload()}
class="mt-4 inline-flex items-center space-x-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<RefreshCw class="w-4 h-4" />
<span>Reload Page</span>
</button>
</div>
</div>

View file

@ -0,0 +1,25 @@
import type { ServerLoadEvent } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export function load({ request }: ServerLoadEvent) {
// Explicit override takes priority
if (env.PROXY_PUBLIC_URL) {
return { proxyUrl: env.PROXY_PUBLIC_URL.replace(/\/$/, '') };
}
// Derive from reverse-proxy forwarded headers (Traefik sets these)
const forwardedProto = request.headers.get('x-forwarded-proto') || 'https';
const forwardedHost = request.headers.get('x-forwarded-host') || '';
if (forwardedHost) {
const proxyHost = forwardedHost
.replace(/^claude-code-proxy-svelte\./, 'claude-code-proxy.')
.replace(/^claude-code-proxy-web\./, 'claude-code-proxy.');
return { proxyUrl: `${forwardedProto}://${proxyHost}` };
}
// Local dev fallback
const host = request.headers.get('host') || 'localhost:3001';
const hostname = host.split(':')[0];
return { proxyUrl: `http://${hostname}:3001` };
}

View file

@ -0,0 +1,18 @@
<script>
import '../app.css';
import { onMount } from 'svelte';
import { initTheme } from '$lib/theme.svelte';
let { children } = $props();
onMount(() => {
initTheme();
});
</script>
<svelte:head>
<title>Claude Code Proxy</title>
<meta name="description" content="Claude Code Proxy - Real-time API request visualization" />
</svelte:head>
{@render children()}

View file

@ -0,0 +1 @@
// proxyUrl is now provided by +layout.server.ts

View file

@ -0,0 +1,318 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
RefreshCw, Trash2, FileText, X, ArrowLeftRight,
Zap, Brain,
Sparkles, Loader2
} from 'lucide-svelte';
import Nav from '$lib/components/Nav.svelte';
import RequestDetailContent from '$lib/components/RequestDetailContent.svelte';
import { getChatCompletionsEndpoint, getProviderName, getModelDisplay } from '$lib/models';
import {
fetchRequests, deleteRequests, gradePrompt, fetchRequestById, fetchUsageStats
} from '$lib/api';
import { formatDate, formatTimeOfDay } from '$lib/formatters';
import type { Request, UsageStats } from '$lib/types';
let requests: Request[] = $state([]);
let selectedRequest: Request | null = $state(null);
let filter = $state('all');
let isModalOpen = $state(false);
let modelFilter = $state('all');
let isFetching = $state(false);
let requestsCurrentPage = $state(1);
let hasMoreRequests = $state(true);
let totalRequests = $state(0);
let usageStats: UsageStats | null = $state(null);
const itemsPerPage = 50;
async function loadRequests(currentModelFilter?: string, loadMore = false, currentPage = 1) {
isFetching = true;
const pageToFetch = loadMore ? currentPage + 1 : 1;
try {
const filterToUse = currentModelFilter ?? modelFilter;
const result = await fetchRequests(pageToFetch, itemsPerPage, filterToUse);
if (loadMore) {
requests = [...requests, ...result.requests];
} else {
requests = result.requests;
}
requestsCurrentPage = pageToFetch;
hasMoreRequests = result.hasMore;
totalRequests = result.total;
} catch (error) {
console.error('Failed to load requests:', error);
requests = [];
totalRequests = 0;
hasMoreRequests = false;
} finally {
isFetching = false;
}
}
async function loadUsageStats(currentModelFilter?: string) {
try {
usageStats = await fetchUsageStats(undefined, undefined, currentModelFilter ?? modelFilter);
} catch (error) {
console.error('Failed to load usage stats:', error);
usageStats = null;
}
}
async function refreshRequestsAndStats(currentModelFilter: string = modelFilter) {
await Promise.all([
loadRequests(currentModelFilter),
loadUsageStats(currentModelFilter)
]);
}
async function clearRequests() {
try {
await deleteRequests();
requests = [];
requestsCurrentPage = 1;
hasMoreRequests = true;
totalRequests = 0;
usageStats = null;
selectedRequest = null;
isModalOpen = false;
} catch (error) {
console.error('Failed to clear requests:', error);
}
}
function filterRequests(f: string) {
if (f === 'all') return requests;
return requests.filter((req) => {
switch (f) {
case 'messages': return req.endpoint.includes('/messages');
case 'completions': return req.endpoint.includes('/completions');
case 'models': return req.endpoint.includes('/models');
default: return true;
}
});
}
async function showRequestDetails(requestId: string) {
const request = requests.find((r) => r.id === requestId);
if (request) {
selectedRequest = request;
isModalOpen = true;
return;
}
try {
const result = await fetchRequestById(requestId);
if (result.request) {
selectedRequest = { ...result.request, id: requestId };
isModalOpen = true;
}
} catch (error) {
console.error('Failed to load request details:', error);
}
}
function closeModal() {
isModalOpen = false;
selectedRequest = null;
}
async function gradeRequest(requestId: string) {
const request = requests.find((r) => r.id === requestId);
if (!request || !request.body?.messages?.some((msg) => msg.role === 'user') || !request.endpoint.includes('/messages')) return;
try {
const promptGradeResult = await gradePrompt(request.body!.messages, request.body!.system || [], request.timestamp);
requests = requests.map((r) => (r.id === requestId ? { ...r, promptGrade: promptGradeResult } : r));
} catch (error) {
console.error('Failed to grade prompt:', error);
}
}
function handleModelFilterChange(newFilter: string) {
modelFilter = newFilter;
refreshRequestsAndStats(newFilter);
}
let filteredRequests = $derived(filterRequests(filter));
onMount(() => {
refreshRequestsAndStats(modelFilter);
function handleEscapeKey(event: KeyboardEvent) {
if (event.key === 'Escape' && isModalOpen) closeModal();
}
window.addEventListener('keydown', handleEscapeKey);
return () => window.removeEventListener('keydown', handleEscapeKey);
});
</script>
<div class="min-h-screen bg-gray-50">
<Nav />
<!-- Sub-header: actions + model filter -->
<div class="bg-white border-b border-gray-100">
<div class="max-w-7xl mx-auto px-6 py-2 flex items-center justify-between">
<div class="flex items-center space-x-2">
<button onclick={() => refreshRequestsAndStats(modelFilter)} class="p-1.5 text-gray-600 hover:bg-gray-100 rounded transition-colors" title="Refresh">
<RefreshCw class="w-4 h-4" />
</button>
<button onclick={clearRequests} class="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors" title="Clear all requests">
<Trash2 class="w-4 h-4" />
</button>
</div>
<div class="inline-flex items-center bg-gray-100 rounded p-0.5 space-x-0.5">
<button onclick={() => handleModelFilterChange('all')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 {modelFilter === 'all' ? 'bg-white text-gray-900 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
All Models
</button>
<button onclick={() => handleModelFilterChange('opus')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 {modelFilter === 'opus' ? 'bg-white text-purple-600 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
<Brain class="w-3 h-3" /><span>Opus</span>
</button>
<button onclick={() => handleModelFilterChange('sonnet')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 {modelFilter === 'sonnet' ? 'bg-white text-indigo-600 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
<Sparkles class="w-3 h-3" /><span>Sonnet</span>
</button>
<button onclick={() => handleModelFilterChange('haiku')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 {modelFilter === 'haiku' ? 'bg-white text-teal-600 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
<Zap class="w-3 h-3" /><span>Haiku</span>
</button>
</div>
</div>
</div>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-6 py-8 space-y-8">
<!-- Stats Grid -->
<div class="mb-6">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Total Requests</p>
<p class="text-2xl font-semibold text-gray-900 mt-1">{totalRequests > 0 ? totalRequests : requests.length}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Input Tokens</p>
<p class="text-2xl font-semibold text-indigo-600 mt-1">{usageStats?.total_input_tokens?.toLocaleString() || '0'}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Output Tokens</p>
<p class="text-2xl font-semibold text-emerald-600 mt-1">{usageStats?.total_output_tokens?.toLocaleString() || '0'}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Cached Tokens</p>
<p class="text-2xl font-semibold text-green-600 mt-1">{usageStats?.total_cache_tokens?.toLocaleString() || '0'}</p>
</div>
</div>
{#if usageStats?.requests_by_model && Object.keys(usageStats.requests_by_model).length > 0}
<div class="mt-4 bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Usage by Model</p>
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3">
{#each Object.entries(usageStats.requests_by_model) as [model, stats]}
{@const provider = getProviderName(model)}
<div class="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div class="text-xs font-medium text-gray-700 truncate" title={model}>
{model.includes('opus') ? 'Opus' : model.includes('sonnet') ? 'Sonnet' : model.includes('haiku') ? 'Haiku' : provider === 'OpenAI' ? model.split('-').slice(0, 2).join('-') : model.split('-').slice(0, 2).join('-')}
</div>
<div class="text-sm font-semibold text-gray-900 mt-1">{stats.request_count} requests</div>
<div class="text-xs text-gray-500">{((stats.input_tokens + stats.output_tokens) || 0).toLocaleString()} tokens</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<!-- Request History -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-900 uppercase tracking-wider">Request History</h2>
</div>
<div class="divide-y divide-gray-200">
{#if isFetching && requestsCurrentPage === 1}
<div class="p-8 text-center">
<Loader2 class="w-6 h-6 mx-auto animate-spin text-gray-400" />
<p class="mt-2 text-xs text-gray-500">Loading requests...</p>
</div>
{:else if filteredRequests.length === 0}
<div class="p-8 text-center text-gray-500">
<h3 class="text-sm font-medium text-gray-600 mb-1">No requests found</h3>
<p class="text-xs text-gray-500">Make sure you have set <code class="font-mono bg-gray-100 px-1 py-0.5 rounded">ANTHROPIC_BASE_URL</code> to point at the proxy</p>
</div>
{:else}
{#each filteredRequests as request}
{@const model = request.routedModel || request.body?.model || ''}
{@const modelDisplay = getModelDisplay(model)}
<div class="px-4 py-3 hover:bg-gray-50 transition-colors cursor-pointer border-b border-gray-100 last:border-b-0" role="button" tabindex="0" onclick={() => showRequestDetails(request.id)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showRequestDetails(request.id); } }}>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0 mr-4">
<div class="flex items-center space-x-3 mb-1">
<h3 class="text-sm font-medium">
<span class="{modelDisplay.colorClass} font-semibold">{modelDisplay.label}</span>
</h3>
{#if request.routedModel && request.routedModel !== request.originalModel}
<span class="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded font-medium flex items-center space-x-1">
<ArrowLeftRight class="w-3 h-3" /><span>routed</span>
</span>
{/if}
{#if request.response?.statusCode}
<span class="text-xs font-medium px-1.5 py-0.5 rounded {request.response.statusCode >= 200 && request.response.statusCode < 300 ? 'bg-green-100 text-green-700' : request.response.statusCode >= 300 && request.response.statusCode < 400 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}">
{request.response.statusCode}
</span>
{/if}
{#if request.conversationId}
<span class="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded font-medium">Turn {request.turnNumber}</span>
{/if}
</div>
<div class="text-xs text-gray-600 font-mono mb-1">{getChatCompletionsEndpoint(request.routedModel, request.endpoint)}</div>
<div class="flex items-center space-x-3 text-xs">
{#if request.response?.body?.usage}
<span class="font-mono text-gray-600">
<span class="font-medium text-gray-900">{((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.output_tokens || 0)).toLocaleString()}</span> tokens
</span>
{#if request.response.body.usage.cache_read_input_tokens}
<span class="font-mono bg-green-50 text-green-700 px-1.5 py-0.5 rounded">{request.response.body.usage.cache_read_input_tokens.toLocaleString()} cached</span>
{/if}
{/if}
{#if request.response?.responseTime}
<span class="font-mono text-gray-600"><span class="font-medium text-gray-900">{(request.response.responseTime / 1000).toFixed(2)}</span>s</span>
{/if}
</div>
</div>
<div class="flex-shrink-0 text-right">
<div class="text-xs text-gray-500">{formatDate(request.timestamp)}</div>
<div class="text-xs text-gray-400">{formatTimeOfDay(request.timestamp)}</div>
</div>
</div>
</div>
{/each}
{#if hasMoreRequests}
<div class="p-3 text-center border-t border-gray-100">
<button onclick={() => loadRequests(modelFilter, true, requestsCurrentPage)} disabled={isFetching} class="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded hover:bg-gray-200 disabled:opacity-50 transition-colors">
{isFetching ? 'Loading...' : 'Load More'}
</button>
</div>
{/if}
{/if}
</div>
</div>
</main>
<!-- Request Detail Modal -->
{#if isModalOpen && selectedRequest}
<div class="fixed inset-0 bg-gray-900/70 backdrop-blur-sm z-50 flex items-center justify-center p-6" role="dialog" aria-modal="true" aria-label="Request details">
<div class="bg-white rounded-xl max-w-6xl w-full max-h-[90vh] overflow-hidden shadow-2xl">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<FileText class="w-5 h-5 text-blue-600" />
<h3 class="text-lg font-semibold text-gray-900">Request Details</h3>
</div>
<button onclick={closeModal} class="text-gray-400 hover:text-gray-600 transition-colors p-2 hover:bg-gray-100 rounded-lg">
<X class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-100px)]">
<RequestDetailContent request={selectedRequest} onGrade={() => gradeRequest(selectedRequest!.id)} />
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,980 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
BarChart3, TrendingUp, DollarSign, Clock, Zap, Brain, Sparkles,
Loader2, Calendar, Activity, AlertCircle, CheckCircle, Square, Wrench
} from 'lucide-svelte';
import Nav from '$lib/components/Nav.svelte';
import ChartCanvas from '$lib/components/ChartCanvas.svelte';
import {
fetchDashboardStats, fetchHourlyStats, fetchModelStats,
fetchUsageStats, fetchRequestsSummary, fetchOrganizations
} from '$lib/api';
import {
calculateTotalCostFromStats, formatCost, projectMonthlyCost,
SUBSCRIPTION_PLANS, getModelPricing
} from '$lib/pricing';
import type {
DashboardStats, HourlyStatsResponse, ModelStatsResponse,
UsageStats, RequestSummary
} from '$lib/types';
let dashboardStats = $state<DashboardStats | null>(null);
let hourlyStats = $state<HourlyStatsResponse | null>(null);
let modelStats = $state<ModelStatsResponse | null>(null);
let usageStats = $state<UsageStats | null>(null);
let recentSummaries = $state<RequestSummary[]>([]);
let isLoading = $state(true);
let isRefreshing = $state(false);
let dateRange = $state(7);
let bucketMinutes = $state(60);
let orgFilter = $state('');
let organizations = $state<string[]>([]);
let loadSequence = 0;
let startTime = $derived(new Date(Date.now() - dateRange * 86400000).toISOString());
let endTime = $derived(new Date().toISOString());
async function loadAllStats() {
const loadId = ++loadSequence;
const isInitial = isLoading;
if (!isInitial) isRefreshing = true;
try {
const org = orgFilter || undefined;
const summariesPromise = org
? Promise.resolve({ requests: [] as RequestSummary[] })
: fetchRequestsSummary('all', startTime, endTime, 0, 500);
const [dashboard, hourly, models, usage, summaries] = await Promise.all([
fetchDashboardStats(startTime, endTime, org),
fetchHourlyStats(startTime, endTime, bucketMinutes, org),
fetchModelStats(startTime, endTime, org),
fetchUsageStats(startTime, endTime, undefined, org),
summariesPromise,
]);
if (loadId !== loadSequence) return;
dashboardStats = dashboard;
hourlyStats = hourly;
modelStats = models;
usageStats = usage;
recentSummaries = summaries.requests;
} catch (error) {
if (loadId !== loadSequence) return;
console.error('Failed to load analytics:', error);
} finally {
if (loadId !== loadSequence) return;
isLoading = false;
isRefreshing = false;
}
}
$effect(() => {
void dateRange;
void bucketMinutes;
void orgFilter;
loadAllStats();
});
onMount(async () => {
try {
organizations = await fetchOrganizations();
} catch (error) {
console.error('Failed to load organizations:', error);
}
});
// Cost calculations
let costData = $derived(
usageStats?.requests_by_model
? calculateTotalCostFromStats(usageStats.requests_by_model)
: null
);
let dailyAvgCost = $derived(
costData && dashboardStats?.dailyStats?.length
? costData.totalCost / Math.max(dashboardStats.dailyStats.length, 1)
: 0
);
let projectedMonthly = $derived(projectMonthlyCost(dailyAvgCost));
// --- Response metadata analytics ---
// Stop reason distribution
let stopReasonDistribution = $derived.by(() => {
const counts: Record<string, number> = {};
for (const s of recentSummaries) {
const reason = s.stopReason || 'unknown';
counts[reason] = (counts[reason] || 0) + 1;
}
return Object.entries(counts).sort((a, b) => b[1] - a[1]);
});
// Service tier distribution
let serviceTierDistribution = $derived.by(() => {
const counts: Record<string, number> = {};
for (const s of recentSummaries) {
const tier = s.usage?.service_tier || 'unknown';
counts[tier] = (counts[tier] || 0) + 1;
}
return Object.entries(counts).sort((a, b) => b[1] - a[1]);
});
// Cache token breakdown
let cacheBreakdown = $derived.by(() => {
let totalCacheRead = 0;
let totalCacheCreation = 0;
let totalInput = 0;
let totalOutput = 0;
let requestsWithCache = 0;
for (const s of recentSummaries) {
if (!s.usage) continue;
totalInput += s.usage.input_tokens || 0;
totalOutput += s.usage.output_tokens || 0;
if (s.usage.cache_read_input_tokens) {
totalCacheRead += s.usage.cache_read_input_tokens;
requestsWithCache++;
}
if (s.usage.cache_creation_input_tokens) {
totalCacheCreation += s.usage.cache_creation_input_tokens;
}
}
const cacheHitRate = totalInput > 0
? ((totalCacheRead / (totalInput + totalCacheCreation)) * 100)
: 0;
return { totalCacheRead, totalCacheCreation, totalInput, totalOutput, requestsWithCache, cacheHitRate };
});
// Response time percentiles
let responseTimeStats = $derived.by(() => {
const times = recentSummaries
.filter(s => s.responseTime && s.responseTime > 0)
.map(s => s.responseTime!);
if (times.length === 0) return null;
times.sort((a, b) => a - b);
const p50 = times[Math.floor(times.length * 0.5)];
const p90 = times[Math.floor(times.length * 0.9)];
const p99 = times[Math.floor(times.length * 0.99)];
const avg = times.reduce((a, b) => a + b, 0) / times.length;
return { p50, p90, p99, avg, count: times.length };
});
// Stop reason chart data
let stopReasonChartData = $derived((() => {
if (stopReasonDistribution.length === 0) return null;
const colorMap: Record<string, string> = {
'end_turn': 'rgba(16, 185, 129, 0.7)',
'max_tokens': 'rgba(245, 158, 11, 0.7)',
'tool_use': 'rgba(99, 102, 241, 0.7)',
'stop_sequence': 'rgba(147, 51, 234, 0.7)',
'unknown': 'rgba(156, 163, 175, 0.7)',
};
return {
labels: stopReasonDistribution.map(([reason]) => reason),
datasets: [{
data: stopReasonDistribution.map(([, count]) => count),
backgroundColor: stopReasonDistribution.map(([reason]) => colorMap[reason] || 'rgba(156, 163, 175, 0.7)'),
borderWidth: 2,
borderColor: '#fff',
}],
};
})());
// Chart data
let dailyChartData = $derived((() => {
if (!dashboardStats?.dailyStats?.length) return null;
const stats = dashboardStats.dailyStats;
return {
labels: stats.map(d => new Date(d.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
datasets: [{
label: 'Tokens',
data: stats.map(d => d.tokens),
backgroundColor: 'rgba(99, 102, 241, 0.5)',
borderColor: 'rgb(99, 102, 241)',
borderWidth: 1,
borderRadius: 4,
}],
};
})());
let dailyRequestsChartData = $derived((() => {
if (!dashboardStats?.dailyStats?.length) return null;
const stats = dashboardStats.dailyStats;
return {
labels: stats.map(d => new Date(d.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
datasets: [{
label: 'Requests',
data: stats.map(d => d.requests),
backgroundColor: 'rgba(16, 185, 129, 0.5)',
borderColor: 'rgb(16, 185, 129)',
borderWidth: 2,
tension: 0.3,
fill: true,
}],
};
})());
// Short time label: "12a", "9a", "3p", "11p" for hours; "9:15a", "2:30p" for sub-hour
function shortTime(d: Date): string {
const h = d.getHours();
const m = d.getMinutes();
const suffix = h < 12 ? 'a' : 'p';
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return m === 0 ? `${h12}${suffix}` : `${h12}:${m.toString().padStart(2, '0')}${suffix}`;
}
let hourlyChartData = $derived((() => {
if (!hourlyStats) return null;
const stats = hourlyStats.hourlyStats || [];
// Build a lookup of existing data by backend label key (format: "Jan 2 15:04")
const dataByKey = new Map<string, number>();
for (const h of stats) {
const key = h.label || `${h.hour.toString().padStart(2, '0')}:00`;
dataByKey.set(key, (dataByKey.get(key) || 0) + h.tokens);
}
const labels: string[] = [];
const data: (number | null)[] = [];
const now = new Date();
// Generate every N-minute slot across the full range
const start = new Date(startTime);
const end = new Date(endTime);
const cursor = new Date(start);
const minuteOfDay = cursor.getHours() * 60 + cursor.getMinutes();
const bs = Math.floor(minuteOfDay / bucketMinutes) * bucketMinutes;
cursor.setHours(Math.floor(bs / 60), bs % 60, 0, 0);
let lastDate = '';
while (cursor <= end) {
// Backend key format: "Jan 2 15:04"
const backendKey = cursor.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
+ ' ' + cursor.getHours().toString().padStart(2, '0')
+ ':' + cursor.getMinutes().toString().padStart(2, '0');
// Short time label, prepend date at day boundaries for multi-day ranges
const dateStr = cursor.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const timeStr = shortTime(cursor);
labels.push(dateStr !== lastDate && dateRange > 1 ? `${dateStr} ${timeStr}` : timeStr);
lastDate = dateStr;
// Future slots get null (renders as gap)
if (cursor > now) {
data.push(null);
} else {
data.push(dataByKey.get(backendKey) || 0);
}
cursor.setTime(cursor.getTime() + bucketMinutes * 60000);
}
return {
labels,
datasets: [{
label: 'Tokens',
data,
backgroundColor: 'rgba(245, 158, 11, 0.1)',
borderColor: 'rgb(245, 158, 11)',
borderWidth: 2,
tension: 0.3,
fill: true,
spanGaps: false,
pointRadius: data.length > 48 ? 0 : 3,
pointHoverRadius: 5,
}],
};
})());
// --- Patterns & Comparisons ---
let comparisonTab = $state<'dow' | 'wow' | 'hod'>('dow');
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const DOW_COLORS = [
'rgba(156,163,175,0.6)', 'rgba(99,102,241,0.6)', 'rgba(16,185,129,0.6)',
'rgba(245,158,11,0.6)', 'rgba(147,51,234,0.6)', 'rgba(236,72,153,0.6)', 'rgba(156,163,175,0.6)'
];
// Day-of-week aggregation from dailyStats
let dowChartData = $derived.by(() => {
if (!dashboardStats?.dailyStats?.length) return null;
const totals = Array(7).fill(0);
const counts = Array(7).fill(0);
for (const d of dashboardStats.dailyStats) {
const dow = new Date(d.date + 'T12:00:00').getDay();
totals[dow] += d.tokens;
counts[dow]++;
}
const avgTokens = totals.map((t, i) => counts[i] > 0 ? Math.round(t / counts[i]) : 0);
return {
labels: DOW_LABELS,
datasets: [{
label: 'Avg Tokens',
data: avgTokens,
backgroundColor: DOW_COLORS,
borderColor: DOW_COLORS.map(c => c.replace('0.6', '1')),
borderWidth: 2,
borderRadius: 4,
}],
};
});
let dowRequestsChartData = $derived.by(() => {
if (!dashboardStats?.dailyStats?.length) return null;
const totals = Array(7).fill(0);
const counts = Array(7).fill(0);
for (const d of dashboardStats.dailyStats) {
const dow = new Date(d.date + 'T12:00:00').getDay();
totals[dow] += d.requests;
counts[dow]++;
}
const avgRequests = totals.map((t, i) => counts[i] > 0 ? Math.round(t / counts[i]) : 0);
return {
labels: DOW_LABELS,
datasets: [{
label: 'Avg Requests',
data: avgRequests,
backgroundColor: DOW_COLORS,
borderColor: DOW_COLORS.map(c => c.replace('0.6', '1')),
borderWidth: 2,
borderRadius: 4,
}],
};
});
// Week-over-week comparison from dailyStats
const WOW_PALETTE = [
{ bg: 'rgba(99,102,241,0.15)', border: 'rgb(99,102,241)' },
{ bg: 'rgba(16,185,129,0.15)', border: 'rgb(16,185,129)' },
{ bg: 'rgba(245,158,11,0.15)', border: 'rgb(245,158,11)' },
{ bg: 'rgba(147,51,234,0.15)', border: 'rgb(147,51,234)' },
{ bg: 'rgba(236,72,153,0.15)', border: 'rgb(236,72,153)' },
];
let wowChartData = $derived.by(() => {
if (!dashboardStats?.dailyStats?.length) return null;
// Group daily stats into calendar weeks (Mon-Sun)
const weekMap = new Map<string, { tokens: number[]; labels: string[] }>();
for (const d of dashboardStats.dailyStats) {
const date = new Date(d.date + 'T12:00:00');
// ISO week start (Monday)
const dayOfWeek = (date.getDay() + 6) % 7; // 0=Mon, 6=Sun
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - dayOfWeek);
const weekKey = weekStart.toISOString().slice(0, 10);
if (!weekMap.has(weekKey)) {
weekMap.set(weekKey, { tokens: Array(7).fill(0), labels: [] });
}
const week = weekMap.get(weekKey)!;
week.tokens[dayOfWeek] = d.tokens;
}
const weeks = [...weekMap.entries()].sort((a, b) => b[0].localeCompare(a[0]));
if (weeks.length === 0) return null;
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const datasets = weeks.slice(0, 5).map(([weekKey, week], i) => {
const weekDate = new Date(weekKey + 'T12:00:00');
const label = weekDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const color = WOW_PALETTE[i % WOW_PALETTE.length];
return {
label: `Wk of ${label}`,
data: week.tokens,
borderColor: color.border,
backgroundColor: i === 0 ? color.bg : 'transparent',
borderWidth: i === 0 ? 2.5 : 1.5,
borderDash: i === 0 ? [] : [4, 3],
tension: 0.3,
fill: i === 0,
pointRadius: 3,
pointHoverRadius: 5,
};
});
return { labels: dayLabels, datasets };
});
// Hour-of-day aggregation from hourlyStats
let hodChartData = $derived.by(() => {
if (!hourlyStats?.hourlyStats?.length) return null;
const totals = Array(24).fill(0);
const counts = Array(24).fill(0);
for (const h of hourlyStats.hourlyStats) {
// Parse the label to get the hour (backend label format: "Jan 2 15:04")
const match = h.label?.match(/(\d{2}):\d{2}$/);
const hour = match ? parseInt(match[1], 10) : h.hour;
if (hour >= 0 && hour < 24) {
totals[hour] += h.tokens;
counts[hour]++;
}
}
const daysInRange = Math.max(dateRange, 1);
const avgTokens = totals.map(t => Math.round(t / daysInRange));
const labels = Array.from({ length: 24 }, (_, i) => {
const d = new Date(); d.setHours(i, 0, 0, 0);
return shortTime(d);
});
return {
labels,
datasets: [{
label: 'Avg Tokens',
data: avgTokens,
backgroundColor: 'rgba(147, 51, 234, 0.1)',
borderColor: 'rgb(147, 51, 234)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 3,
pointHoverRadius: 5,
}],
};
});
let modelChartData = $derived((() => {
if (!modelStats?.modelStats?.length) return null;
const stats = modelStats.modelStats;
const colors = stats.map(m => {
const p = getModelPricing(m.model);
if (p.tier === 'opus') return { bg: 'rgba(147, 51, 234, 0.7)', border: 'rgb(147, 51, 234)' };
if (p.tier === 'sonnet') return { bg: 'rgba(99, 102, 241, 0.7)', border: 'rgb(99, 102, 241)' };
if (p.tier === 'haiku') return { bg: 'rgba(20, 184, 166, 0.7)', border: 'rgb(20, 184, 166)' };
return { bg: 'rgba(156, 163, 175, 0.7)', border: 'rgb(156, 163, 175)' };
});
return {
labels: stats.map(m => getModelPricing(m.model).label),
datasets: [{
data: stats.map(m => m.tokens),
backgroundColor: colors.map(c => c.bg),
borderColor: colors.map(c => c.border),
borderWidth: 2,
}],
};
})());
let costChartData = $derived((() => {
if (!costData?.costByModel?.length) return null;
const colors = costData.costByModel.map(m => {
const p = getModelPricing(m.model);
if (p.tier === 'opus') return 'rgba(147, 51, 234, 0.7)';
if (p.tier === 'sonnet') return 'rgba(99, 102, 241, 0.7)';
if (p.tier === 'haiku') return 'rgba(20, 184, 166, 0.7)';
return 'rgba(156, 163, 175, 0.7)';
});
return {
labels: costData.costByModel.map(m => m.label),
datasets: [{
label: 'Cost ($)',
data: costData.costByModel.map(m => m.cost),
backgroundColor: colors,
borderRadius: 4,
}],
};
})());
function stopReasonIcon(reason: string) {
if (reason === 'end_turn') return CheckCircle;
if (reason === 'max_tokens') return AlertCircle;
if (reason === 'tool_use') return Wrench;
if (reason === 'stop_sequence') return Square;
return Activity;
}
function stopReasonColor(reason: string) {
if (reason === 'end_turn') return 'text-emerald-600';
if (reason === 'max_tokens') return 'text-amber-600';
if (reason === 'tool_use') return 'text-indigo-600';
if (reason === 'stop_sequence') return 'text-purple-600';
return 'text-gray-500';
}
</script>
<svelte:head>
<title>Analytics - Claude Code Proxy</title>
</svelte:head>
<div class="min-h-screen bg-gray-50">
<Nav />
<main class="max-w-7xl mx-auto px-6 py-8 space-y-6">
<!-- Date Range Selector -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
<BarChart3 class="w-5 h-5 text-indigo-600" />
<span>Analytics</span>
{#if recentSummaries.length > 0}
<span class="text-xs text-gray-400 font-normal ml-2">{recentSummaries.length} requests in period</span>
{/if}
</h2>
<div class="flex items-center space-x-4">
{#if organizations.length > 0}
<select
bind:value={orgFilter}
class="text-xs border border-gray-200 rounded px-2 py-1.5 bg-white text-gray-700"
>
<option value="">All Orgs</option>
{#each organizations as org}
<option value={org}>{org.slice(0, 12)}...</option>
{/each}
</select>
{/if}
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 text-gray-400" />
{#each [{ d: 1, l: '24h' }, { d: 7, l: '7d' }, { d: 14, l: '14d' }, { d: 30, l: '30d' }] as { d, l }}
<button
onclick={() => (dateRange = d)}
class="px-3 py-1.5 text-xs font-medium rounded transition-all {dateRange === d ? 'bg-indigo-600 text-white shadow-sm' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
{l}
</button>
{/each}
</div>
</div>
</div>
{#if isLoading}
<div class="flex items-center justify-center py-20">
<Loader2 class="w-8 h-8 animate-spin text-indigo-400" />
</div>
{:else}
<!-- Cost Overview Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center space-x-2 mb-2">
<DollarSign class="w-4 h-4 text-green-600" />
<span class="text-xs font-medium text-gray-500 uppercase">Total API Cost</span>
</div>
<div class="text-2xl font-bold text-gray-900">{costData ? formatCost(costData.totalCost) : '$0.00'}</div>
<div class="text-xs text-gray-500 mt-1">all time</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center space-x-2 mb-2">
<TrendingUp class="w-4 h-4 text-blue-600" />
<span class="text-xs font-medium text-gray-500 uppercase">Daily Average</span>
</div>
<div class="text-2xl font-bold text-gray-900">{formatCost(dailyAvgCost)}</div>
<div class="text-xs text-gray-500 mt-1">/day</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center space-x-2 mb-2">
<TrendingUp class="w-4 h-4 text-purple-600" />
<span class="text-xs font-medium text-gray-500 uppercase">Projected Monthly</span>
</div>
<div class="text-2xl font-bold text-gray-900">{formatCost(projectedMonthly)}</div>
<div class="text-xs text-gray-500 mt-1">/month (30d projection)</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center space-x-2 mb-2">
<Clock class="w-4 h-4 text-amber-600" />
<span class="text-xs font-medium text-gray-500 uppercase">Avg Response</span>
</div>
<div class="text-2xl font-bold text-gray-900">
{responseTimeStats ? `${(responseTimeStats.avg / 1000).toFixed(1)}s` : 'N/A'}
</div>
<div class="text-xs text-gray-500 mt-1">response time</div>
</div>
</div>
<!-- Response Metadata Cards -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Stop Reason Distribution -->
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center space-x-2">
<Activity class="w-4 h-4 text-indigo-600" />
<span>Stop Reasons</span>
</h3>
{#if stopReasonDistribution.length > 0}
<div class="space-y-2.5">
{#each stopReasonDistribution as [reason, count]}
{@const pct = recentSummaries.length > 0 ? ((count / recentSummaries.length) * 100).toFixed(1) : '0'}
{@const Icon = stopReasonIcon(reason)}
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<Icon class="w-3.5 h-3.5 {stopReasonColor(reason)}" />
<span class="text-sm font-mono text-gray-700">{reason}</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-24 bg-gray-100 rounded-full h-1.5">
<div class="h-1.5 rounded-full bg-indigo-500" style="width: {pct}%"></div>
</div>
<span class="text-xs text-gray-500 w-16 text-right">{count} ({pct}%)</span>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-sm text-gray-400 text-center py-4">No data</div>
{/if}
</div>
<!-- Service Tier Distribution -->
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center space-x-2">
<Zap class="w-4 h-4 text-amber-600" />
<span>Service Tier</span>
</h3>
{#if serviceTierDistribution.length > 0}
<div class="space-y-2.5">
{#each serviceTierDistribution as [tier, count]}
{@const pct = recentSummaries.length > 0 ? ((count / recentSummaries.length) * 100).toFixed(1) : '0'}
<div class="flex items-center justify-between">
<span class="text-sm font-mono text-gray-700">{tier}</span>
<div class="flex items-center space-x-2">
<div class="w-24 bg-gray-100 rounded-full h-1.5">
<div class="h-1.5 rounded-full bg-amber-500" style="width: {pct}%"></div>
</div>
<span class="text-xs text-gray-500 w-16 text-right">{count} ({pct}%)</span>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-sm text-gray-400 text-center py-4">No data</div>
{/if}
</div>
<!-- Cache Performance -->
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center space-x-2">
<Activity class="w-4 h-4 text-emerald-600" />
<span>Cache Performance</span>
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 uppercase">Hit Rate</span>
<span class="text-lg font-bold text-emerald-600">{cacheBreakdown.cacheHitRate.toFixed(1)}%</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2">
<div class="h-2 rounded-full bg-emerald-500" style="width: {Math.min(cacheBreakdown.cacheHitRate, 100)}%"></div>
</div>
<div class="grid grid-cols-2 gap-3 pt-2">
<div class="bg-green-50 rounded-lg p-3 border border-green-100">
<div class="text-xs text-green-600 font-medium">Cache Read</div>
<div class="text-sm font-semibold text-green-800">{(cacheBreakdown.totalCacheRead / 1000).toFixed(0)}k</div>
</div>
<div class="bg-blue-50 rounded-lg p-3 border border-blue-100">
<div class="text-xs text-blue-600 font-medium">Cache Write</div>
<div class="text-sm font-semibold text-blue-800">{(cacheBreakdown.totalCacheCreation / 1000).toFixed(0)}k</div>
</div>
<div class="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div class="text-xs text-gray-500 font-medium">Input Tokens</div>
<div class="text-sm font-semibold text-gray-800">{(cacheBreakdown.totalInput / 1000).toFixed(0)}k</div>
</div>
<div class="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div class="text-xs text-gray-500 font-medium">Output Tokens</div>
<div class="text-sm font-semibold text-gray-800">{(cacheBreakdown.totalOutput / 1000).toFixed(0)}k</div>
</div>
</div>
<div class="text-xs text-gray-400 text-center">{cacheBreakdown.requestsWithCache} requests used cache</div>
</div>
</div>
</div>
<!-- Response Time Percentiles -->
{#if responseTimeStats}
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center space-x-2">
<Clock class="w-4 h-4 text-blue-600" />
<span>Response Time Distribution</span>
<span class="text-xs text-gray-400 font-normal">({responseTimeStats.count} requests)</span>
</h3>
<div class="grid grid-cols-4 gap-4">
<div class="text-center">
<div class="text-xs text-gray-500 uppercase mb-1">Average</div>
<div class="text-xl font-bold text-gray-900">{(responseTimeStats.avg / 1000).toFixed(1)}s</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 uppercase mb-1">P50</div>
<div class="text-xl font-bold text-blue-600">{(responseTimeStats.p50 / 1000).toFixed(1)}s</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 uppercase mb-1">P90</div>
<div class="text-xl font-bold text-amber-600">{(responseTimeStats.p90 / 1000).toFixed(1)}s</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 uppercase mb-1">P99</div>
<div class="text-xl font-bold text-red-600">{(responseTimeStats.p99 / 1000).toFixed(1)}s</div>
</div>
</div>
</div>
{/if}
<!-- Subscription Comparison -->
{#if costData && costData.totalCost > 0}
<div class="bg-gradient-to-r from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-6 shadow-sm">
<h3 class="text-sm font-semibold text-indigo-900 mb-4 flex items-center space-x-2">
<DollarSign class="w-4 h-4" />
<span>API vs Subscription Comparison</span>
</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white rounded-lg p-4 border border-indigo-200">
<div class="text-xs font-medium text-indigo-600 uppercase mb-1">Your API Usage</div>
<div class="text-xl font-bold text-indigo-700">{formatCost(projectedMonthly)}<span class="text-sm font-normal">/mo</span></div>
<div class="text-xs text-gray-500 mt-1">Pay-as-you-go</div>
</div>
{#each SUBSCRIPTION_PLANS as plan}
{@const isCheaper = projectedMonthly < plan.monthlyPrice}
<div class="bg-white rounded-lg p-4 border {isCheaper ? 'border-green-200' : 'border-red-200'}">
<div class="text-xs font-medium {isCheaper ? 'text-green-600' : 'text-red-600'} uppercase mb-1">{plan.name}</div>
<div class="text-xl font-bold {isCheaper ? 'text-green-700' : 'text-red-700'}">${plan.monthlyPrice}<span class="text-sm font-normal">/mo</span></div>
<div class="text-xs mt-1 {isCheaper ? 'text-green-600' : 'text-red-600'}">
{isCheaper
? `API saves you ${formatCost(Math.abs(plan.monthlyPrice - projectedMonthly))}/mo`
: `Sub saves you ${formatCost(Math.abs(plan.monthlyPrice - projectedMonthly))}/mo`}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Charts Row 1: Daily Usage + Stop Reasons -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Daily Token Usage</h3>
{#if dailyChartData}
<ChartCanvas type="bar" data={dailyChartData} height="250px" options={{
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { callback: (v: string | number) => { const n = Number(v); return n >= 1000000 ? `${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(0)}k` : n; } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[250px] text-gray-400 text-sm">No data for this period</div>
{/if}
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Stop Reason Distribution</h3>
{#if stopReasonChartData}
<ChartCanvas type="doughnut" data={stopReasonChartData} height="250px" options={{
plugins: {
legend: { position: 'right', labels: { usePointStyle: true, padding: 16, font: { size: 12 } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[250px] text-gray-400 text-sm">No data for this period</div>
{/if}
</div>
</div>
<!-- Charts Row 2: Daily Requests + Hourly Activity -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Daily Requests</h3>
{#if dailyRequestsChartData}
<ChartCanvas type="line" data={dailyRequestsChartData} height="250px" options={{
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } }
}} />
{:else}
<div class="flex items-center justify-center h-[250px] text-gray-400 text-sm">No data for this period</div>
{/if}
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-semibold text-gray-900">Activity</h3>
<div class="flex items-center space-x-1">
{#each [{ m: 5, l: '5m' }, { m: 15, l: '15m' }, { m: 30, l: '30m' }, { m: 60, l: '1h' }] as { m, l }}
<button
onclick={() => { bucketMinutes = m; }}
class="px-2 py-0.5 text-xs font-medium rounded transition-all {bucketMinutes === m ? 'bg-amber-500 text-white shadow-sm' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}"
>
{l}
</button>
{/each}
</div>
</div>
<p class="text-xs text-gray-400 mb-3">
{new Date(startTime).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} {new Date(endTime).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
{#if hourlyChartData}
<ChartCanvas type="line" data={hourlyChartData} height="250px"
scrollable
options={{
plugins: { legend: { display: false } },
scales: {
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 24 } },
y: { beginAtZero: true, ticks: { callback: (v: string | number) => { const n = Number(v); return n >= 1000 ? `${(n / 1000).toFixed(0)}k` : n; } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[250px] text-gray-400 text-sm">No data for this period</div>
{/if}
</div>
</div>
<!-- Patterns & Comparisons -->
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-semibold text-gray-900">Patterns</h3>
<div class="flex items-center space-x-1">
{#each [
{ key: 'dow' as const, label: 'By Day' },
{ key: 'wow' as const, label: 'Week / Week' },
{ key: 'hod' as const, label: 'By Hour' },
] as { key, label }}
<button
onclick={() => { comparisonTab = key; }}
class="px-2.5 py-1 text-xs font-medium rounded transition-all {comparisonTab === key ? 'bg-indigo-600 text-white shadow-sm' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}"
>
{label}
</button>
{/each}
</div>
</div>
<p class="text-xs text-gray-400 mb-3">
{#if comparisonTab === 'dow'}
Average tokens & requests per day of week over the selected period
{:else if comparisonTab === 'wow'}
Token usage overlaid by week (most recent weeks, MonSun)
{:else}
Average tokens per hour of day over the selected period
{/if}
</p>
{#if comparisonTab === 'dow'}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{#if dowChartData}
<div>
<p class="text-xs text-gray-500 mb-2 font-medium">Tokens</p>
<ChartCanvas type="bar" data={dowChartData} height="220px" options={{
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { callback: (v: string | number) => { const n = Number(v); return n >= 1000 ? `${(n / 1000).toFixed(0)}k` : n; } } }
}
}} />
</div>
{/if}
{#if dowRequestsChartData}
<div>
<p class="text-xs text-gray-500 mb-2 font-medium">Requests</p>
<ChartCanvas type="bar" data={dowRequestsChartData} height="220px" options={{
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } }
}} />
</div>
{/if}
{#if !dowChartData}
<div class="flex items-center justify-center h-[220px] text-gray-400 text-sm col-span-2">No data for this period</div>
{/if}
</div>
{:else if comparisonTab === 'wow'}
{#if wowChartData}
<ChartCanvas type="line" data={wowChartData} height="280px" options={{
plugins: { legend: { position: 'bottom', labels: { usePointStyle: true, padding: 12, font: { size: 11 } } } },
scales: {
y: { beginAtZero: true, ticks: { callback: (v: string | number) => { const n = Number(v); return n >= 1000 ? `${(n / 1000).toFixed(0)}k` : n; } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[280px] text-gray-400 text-sm">Need at least 2 weeks of data</div>
{/if}
{:else}
{#if hodChartData}
<ChartCanvas type="line" data={hodChartData} height="280px" options={{
plugins: { legend: { display: false } },
scales: {
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 } },
y: { beginAtZero: true, ticks: { callback: (v: string | number) => { const n = Number(v); return n >= 1000 ? `${(n / 1000).toFixed(0)}k` : n; } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[280px] text-gray-400 text-sm">No data for this period</div>
{/if}
{/if}
</div>
<!-- Charts Row 3: Model Distribution -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Model Distribution</h3>
{#if modelChartData}
<ChartCanvas type="doughnut" data={modelChartData} height="250px" options={{
plugins: {
legend: { position: 'right', labels: { usePointStyle: true, padding: 16, font: { size: 12 } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[250px] text-gray-400 text-sm">No data for this period</div>
{/if}
</div>
<!-- Cost by Model -->
{#if costChartData}
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Cost by Model</h3>
<ChartCanvas type="bar" data={costChartData} height="250px" options={{
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true, ticks: { callback: (v: string | number) => `$${Number(v).toFixed(2)}` } } }
}} />
</div>
{/if}
</div>
<!-- Detailed Cost Breakdown -->
{#if costData && costData.costByModel.length > 0}
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Detailed Cost Breakdown</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{#each costData.costByModel as item}
{@const pricing = getModelPricing(item.model)}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100">
<div>
<div class="text-sm font-medium text-gray-900 flex items-center space-x-2">
{#if pricing.tier === 'opus'}
<Brain class="w-3.5 h-3.5 text-purple-600" />
{:else if pricing.tier === 'sonnet'}
<Sparkles class="w-3.5 h-3.5 text-indigo-600" />
{:else if pricing.tier === 'haiku'}
<Zap class="w-3.5 h-3.5 text-teal-600" />
{/if}
<span>{item.label}</span>
</div>
<div class="text-xs text-gray-500 mt-0.5">
{item.requests} requests &middot;
{((item.inputTokens + item.outputTokens) / 1000).toFixed(0)}k tokens
{#if item.cacheTokens > 0}
&middot; {(item.cacheTokens / 1000).toFixed(0)}k cached
{/if}
</div>
</div>
<div class="text-right">
<div class="text-sm font-semibold text-gray-900">{formatCost(item.cost)}</div>
<div class="text-xs text-gray-500">${pricing.inputPerMTok}/${pricing.outputPerMTok} per MTok</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Today's Stats -->
{#if hourlyStats}
<div class="grid grid-cols-3 gap-4">
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm text-center">
<div class="text-xs font-medium text-gray-500 uppercase mb-1">Today's Tokens</div>
<div class="text-2xl font-bold text-indigo-600">{hourlyStats.todayTokens?.toLocaleString() || '0'}</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm text-center">
<div class="text-xs font-medium text-gray-500 uppercase mb-1">Today's Requests</div>
<div class="text-2xl font-bold text-emerald-600">{hourlyStats.todayRequests || 0}</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm text-center">
<div class="text-xs font-medium text-gray-500 uppercase mb-1">Avg Response Time</div>
<div class="text-2xl font-bold text-amber-600">{hourlyStats.avgResponseTime ? `${(hourlyStats.avgResponseTime / 1000).toFixed(1)}s` : 'N/A'}</div>
</div>
</div>
{/if}
{/if}
</main>
</div>

Some files were not shown because too many files have changed in this diff Show more