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:
parent
b9da198e1f
commit
8e550b9785
152 changed files with 19227 additions and 19463 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
22
.env.example
22
.env.example
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
152
Dockerfile
152
Dockerfile
|
|
@ -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 []
|
||||||
|
|
|
||||||
53
Makefile
53
Makefile
|
|
@ -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
103
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
86
docker-entrypoint.dev.sh
Executable 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
61
docker-entrypoint.sh
Normal file → Executable 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 "========================================="
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
193
proxy/cmd/proxy/main_test.go
Normal file
193
proxy/cmd/proxy/main_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
287
proxy/internal/handler/handlers_conversations_test.go
Normal file
287
proxy/internal/handler/handlers_conversations_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
630
proxy/internal/handler/handlers_dashboard_test.go
Normal file
630
proxy/internal/handler/handlers_dashboard_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
302
proxy/internal/handler/handlers_proxy_test.go
Normal file
302
proxy/internal/handler/handlers_proxy_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
209
proxy/internal/handler/handlers_settings_test.go
Normal file
209
proxy/internal/handler/handlers_settings_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
900
proxy/internal/handler/openapi.go
Normal file
900
proxy/internal/handler/openapi.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 != "" {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
33
proxy/internal/middleware/dashboard_auth.go
Normal file
33
proxy/internal/middleware/dashboard_auth.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
41
proxy/internal/middleware/dashboard_auth_protocol_test.go
Normal file
41
proxy/internal/middleware/dashboard_auth_protocol_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
proxy/internal/middleware/dashboard_auth_test.go
Normal file
105
proxy/internal/middleware/dashboard_auth_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
22
proxy/internal/middleware/inflight.go
Normal file
22
proxy/internal/middleware/inflight.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
34
proxy/internal/runtime/runtime.go
Normal file
34
proxy/internal/runtime/runtime.go
Normal 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) }
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
64
proxy/internal/service/storage_aggregation_test.go
Normal file
64
proxy/internal/service/storage_aggregation_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
163
proxy/internal/service/storage_analytics.go
Normal file
163
proxy/internal/service/storage_analytics.go
Normal 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
|
||||||
|
}
|
||||||
83
proxy/internal/service/storage_analytics_test.go
Normal file
83
proxy/internal/service/storage_analytics_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
217
proxy/internal/service/storage_contract_test.go
Normal file
217
proxy/internal/service/storage_contract_test.go
Normal 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
|
||||||
|
}
|
||||||
46
proxy/internal/service/storage_decode.go
Normal file
46
proxy/internal/service/storage_decode.go
Normal 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
|
||||||
|
}
|
||||||
104
proxy/internal/service/storage_decode_test.go
Normal file
104
proxy/internal/service/storage_decode_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
25
proxy/internal/service/storage_migrations.go
Normal file
25
proxy/internal/service/storage_migrations.go
Normal 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")
|
||||||
|
}
|
||||||
203
proxy/internal/service/storage_payload.go
Normal file
203
proxy/internal/service/storage_payload.go
Normal 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
|
||||||
|
}
|
||||||
163
proxy/internal/service/storage_payload_test.go
Normal file
163
proxy/internal/service/storage_payload_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
1074
proxy/internal/service/storage_postgres.go
Normal file
1074
proxy/internal/service/storage_postgres.go
Normal file
File diff suppressed because it is too large
Load diff
60
proxy/internal/service/storage_postgres_contract_test.go
Normal file
60
proxy/internal/service/storage_postgres_contract_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
proxy/internal/service/storage_query_helpers.go
Normal file
38
proxy/internal/service/storage_query_helpers.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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(×tamp, &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(×tamp, &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(×tamp)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
proxy/internal/service/storage_sqlite_contract_test.go
Normal file
19
proxy/internal/service/storage_sqlite_contract_test.go
Normal 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
26
run.sh
|
|
@ -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 "========================================="
|
||||||
|
|
|
||||||
13
shared/frontend/backend.ts
Normal file
13
shared/frontend/backend.ts
Normal 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();
|
||||||
|
}
|
||||||
382
shared/frontend/formatters.ts
Normal file
382
shared/frontend/formatters.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
307
shared/frontend/types.ts
Normal 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;
|
||||||
|
}
|
||||||
49
shared/server/dashboard_auth.ts
Normal file
49
shared/server/dashboard_auth.ts
Normal 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
2851
svelte/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
svelte/package.json
Normal file
32
svelte/package.json
Normal 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
6
svelte/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
333
svelte/src/app.css
Normal file
333
svelte/src/app.css
Normal 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
20
svelte/src/app.html
Normal 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>
|
||||||
16
svelte/src/hooks.server.ts
Normal file
16
svelte/src/hooks.server.ts
Normal 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
225
svelte/src/lib/api.ts
Normal 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 || [];
|
||||||
|
}
|
||||||
32
svelte/src/lib/auth.server.ts
Normal file
32
svelte/src/lib/auth.server.ts
Normal 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());
|
||||||
|
}
|
||||||
10
svelte/src/lib/backend.server.ts
Normal file
10
svelte/src/lib/backend.server.ts
Normal 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);
|
||||||
|
}
|
||||||
101
svelte/src/lib/chat-formatters.ts
Normal file
101
svelte/src/lib/chat-formatters.ts
Normal 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' });
|
||||||
|
}
|
||||||
242
svelte/src/lib/chat-utils.ts
Normal file
242
svelte/src/lib/chat-utils.ts
Normal 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 };
|
||||||
|
}
|
||||||
84
svelte/src/lib/components/ChartCanvas.svelte
Normal file
84
svelte/src/lib/components/ChartCanvas.svelte
Normal 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>
|
||||||
104
svelte/src/lib/components/ChatMessage.svelte
Normal file
104
svelte/src/lib/components/ChatMessage.svelte
Normal 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}
|
||||||
68
svelte/src/lib/components/ChatOutsideBlock.svelte
Normal file
68
svelte/src/lib/components/ChatOutsideBlock.svelte
Normal 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>
|
||||||
444
svelte/src/lib/components/ChatRequestDetail.svelte
Normal file
444
svelte/src/lib/components/ChatRequestDetail.svelte
Normal 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">·</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">·</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>
|
||||||
140
svelte/src/lib/components/ChatSidebar.svelte
Normal file
140
svelte/src/lib/components/ChatSidebar.svelte
Normal 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>
|
||||||
182
svelte/src/lib/components/ChatToolBlock.svelte
Normal file
182
svelte/src/lib/components/ChatToolBlock.svelte
Normal 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">→</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>
|
||||||
65
svelte/src/lib/components/CodeDiff.svelte
Normal file
65
svelte/src/lib/components/CodeDiff.svelte
Normal 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>
|
||||||
169
svelte/src/lib/components/CodeViewer.svelte
Normal file
169
svelte/src/lib/components/CodeViewer.svelte
Normal 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}
|
||||||
138
svelte/src/lib/components/ConversationThread.svelte
Normal file
138
svelte/src/lib/components/ConversationThread.svelte
Normal 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 • {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 • {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}
|
||||||
90
svelte/src/lib/components/ImageContent.svelte
Normal file
90
svelte/src/lib/components/ImageContent.svelte
Normal 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}
|
||||||
226
svelte/src/lib/components/MessageContent.svelte
Normal file
226
svelte/src/lib/components/MessageContent.svelte
Normal 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}
|
||||||
182
svelte/src/lib/components/MessageFlow.svelte
Normal file
182
svelte/src/lib/components/MessageFlow.svelte
Normal 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>
|
||||||
232
svelte/src/lib/components/Nav.svelte
Normal file
232
svelte/src/lib/components/Nav.svelte
Normal 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 > Models > 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}
|
||||||
687
svelte/src/lib/components/RequestDetailContent.svelte
Normal file
687
svelte/src/lib/components/RequestDetailContent.svelte
Normal 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>
|
||||||
105
svelte/src/lib/components/RichText.svelte
Normal file
105
svelte/src/lib/components/RichText.svelte
Normal 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>
|
||||||
79
svelte/src/lib/components/RichTextInline.svelte
Normal file
79
svelte/src/lib/components/RichTextInline.svelte
Normal 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}
|
||||||
20
svelte/src/lib/components/ThemeToggle.svelte
Normal file
20
svelte/src/lib/components/ThemeToggle.svelte
Normal 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>
|
||||||
92
svelte/src/lib/components/TodoList.svelte
Normal file
92
svelte/src/lib/components/TodoList.svelte
Normal 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}
|
||||||
182
svelte/src/lib/components/ToolResult.svelte
Normal file
182
svelte/src/lib/components/ToolResult.svelte
Normal 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>
|
||||||
173
svelte/src/lib/components/ToolUse.svelte
Normal file
173
svelte/src/lib/components/ToolUse.svelte
Normal 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>
|
||||||
87
svelte/src/lib/components/XmlBlock.svelte
Normal file
87
svelte/src/lib/components/XmlBlock.svelte
Normal 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"><{tag}></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>
|
||||||
1
svelte/src/lib/formatters.ts
Normal file
1
svelte/src/lib/formatters.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from '../../../shared/frontend/formatters';
|
||||||
29
svelte/src/lib/models.ts
Normal file
29
svelte/src/lib/models.ts
Normal 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
118
svelte/src/lib/pricing.ts
Normal 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
170
svelte/src/lib/rich-text.ts
Normal 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;
|
||||||
|
}
|
||||||
45
svelte/src/lib/theme.svelte.ts
Normal file
45
svelte/src/lib/theme.svelte.ts
Normal 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
1
svelte/src/lib/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from '../../../shared/frontend/types';
|
||||||
19
svelte/src/routes/+error.svelte
Normal file
19
svelte/src/routes/+error.svelte
Normal 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>
|
||||||
25
svelte/src/routes/+layout.server.ts
Normal file
25
svelte/src/routes/+layout.server.ts
Normal 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` };
|
||||||
|
}
|
||||||
18
svelte/src/routes/+layout.svelte
Normal file
18
svelte/src/routes/+layout.svelte
Normal 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()}
|
||||||
1
svelte/src/routes/+page.server.ts
Normal file
1
svelte/src/routes/+page.server.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// proxyUrl is now provided by +layout.server.ts
|
||||||
318
svelte/src/routes/+page.svelte
Normal file
318
svelte/src/routes/+page.svelte
Normal 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>
|
||||||
980
svelte/src/routes/analytics/+page.svelte
Normal file
980
svelte/src/routes/analytics/+page.svelte
Normal 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, Mon–Sun)
|
||||||
|
{: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 ·
|
||||||
|
{((item.inputTokens + item.outputTokens) / 1000).toFixed(0)}k tokens
|
||||||
|
{#if item.cacheTokens > 0}
|
||||||
|
· {(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
Loading…
Add table
Add a link
Reference in a new issue