From 2994d8bee41994f7f87fe0eca69b67480e158132 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 27 Feb 2026 16:28:42 +0800 Subject: [PATCH 1/8] Embed frontend assets into Go binary for single-process deployment Replace the two-process architecture (Go proxy + Remix SSR server) with a single Go binary that serves embedded frontend assets. This simplifies deployment, removes the Node.js runtime dependency in production, and eliminates the need for a docker-entrypoint script. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 + Dockerfile | 58 +++++------ Makefile | 18 +++- proxy/cmd/proxy/main.go | 39 +++++++- proxy/frontend/embed.go | 6 ++ proxy/internal/handler/handlers.go | 13 --- web/app/entry.server.tsx | 140 --------------------------- web/app/routes/api.conversations.tsx | 26 ----- web/app/routes/api.grade-prompt.tsx | 33 ------- web/app/routes/api.requests.tsx | 61 ------------ web/vite.config.ts | 7 +- 11 files changed, 83 insertions(+), 322 deletions(-) create mode 100644 proxy/frontend/embed.go delete mode 100644 web/app/entry.server.tsx delete mode 100644 web/app/routes/api.conversations.tsx delete mode 100644 web/app/routes/api.grade-prompt.tsx delete mode 100644 web/app/routes/api.requests.tsx diff --git a/.gitignore b/.gitignore index 759a7302c..378e3557e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ bin/ dist/ *.exe +# Embedded frontend assets (populated by build) +proxy/frontend/dist/* +!proxy/frontend/dist/.gitkeep + # IDE and system files .DS_Store .vscode/ diff --git a/Dockerfile b/Dockerfile index 6891deda1..bff936644 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,18 @@ # Multi-stage Dockerfile for Claude Code Proxy -# Builds both Go proxy server and Remix frontend in a single container +# Builds a single Go binary with embedded frontend assets -# Stage 1: Build Go Backend +# Stage 1: Build Node.js Frontend +FROM node:20-alpine AS node-builder + +WORKDIR /app/web + +COPY web/package*.json ./ +RUN npm ci + +COPY web/ ./ +RUN npm run build + +# Stage 2: Build Go Binary with embedded frontend FROM golang:1.21-alpine AS go-builder WORKDIR /app @@ -16,28 +27,15 @@ RUN go mod download # Copy Go source code COPY proxy/ ./ -# Build with CGO enabled for SQLite support -RUN 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 - -WORKDIR /app - -# Copy package files -COPY web/package*.json ./web/ -WORKDIR /app/web -RUN npm ci -# Copy web source code and build -COPY web/ ./ -RUN npm run build +# Copy built frontend assets into embed directory +COPY --from=node-builder /app/web/build/client ./frontend/dist -# Clean up dev dependencies after build -RUN npm ci --only=production && npm cache clean --force +# Build with CGO enabled for SQLite support +RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o /app/bin/proxy cmd/proxy/main.go # Stage 3: Production Runtime -FROM node:20-alpine +FROM alpine:3.19 WORKDIR /app @@ -48,25 +46,15 @@ RUN apk add --no-cache sqlite wget RUN addgroup -g 1001 -S appgroup && \ adduser -S appuser -u 1001 -G appgroup -# Copy built Go binary +# Copy built Go binary (includes embedded frontend) COPY --from=go-builder /app/bin/proxy ./bin/proxy RUN chmod +x ./bin/proxy -# Copy built Remix application -COPY --from=node-builder /app/web/build ./web/build -COPY --from=node-builder /app/web/package*.json ./web/ -COPY --from=node-builder /app/web/node_modules ./web/node_modules - # Create data directory for SQLite database RUN mkdir -p /app/data && chown -R appuser:appgroup /app -# Copy startup script -COPY docker-entrypoint.sh ./ -RUN chmod +x docker-entrypoint.sh - # Environment variables with defaults ENV PORT=3001 -ENV WEB_PORT=5173 ENV READ_TIMEOUT=600 ENV WRITE_TIMEOUT=600 ENV IDLE_TIMEOUT=600 @@ -75,8 +63,8 @@ ENV ANTHROPIC_VERSION=2023-06-01 ENV ANTHROPIC_MAX_RETRIES=3 ENV DB_PATH=/app/data/requests.db -# Expose ports -EXPOSE 3001 5173 +# Expose single port +EXPOSE 3001 # Switch to app user USER appuser @@ -85,5 +73,5 @@ USER appuser HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD wget -qO- http://localhost:3001/health > /dev/null || exit 1 -# Start both services -CMD ["./docker-entrypoint.sh"] \ No newline at end of file +# Start single binary +CMD ["./bin/proxy"] diff --git a/Makefile b/Makefile index e4d75c50f..186d4db4b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build run clean install dev +.PHONY: all build run clean install dev # Default target all: install build @@ -10,17 +10,22 @@ install: @echo "📦 Installing Node dependencies..." cd web && npm install -# Build both services -build: build-proxy build-web +# Build single binary (frontend → copy → Go) +build: build-web copy-frontend build-proxy build-proxy: @echo "🔨 Building proxy server..." - cd proxy && go build -o ../bin/proxy cmd/proxy/main.go + cd proxy && CGO_ENABLED=1 go build -o ../bin/proxy cmd/proxy/main.go build-web: @echo "🔨 Building web interface..." cd web && npm run build +copy-frontend: + @echo "📋 Copying frontend assets into Go embed directory..." + rm -rf proxy/frontend/dist + cp -r web/build/client proxy/frontend/dist + # Run in development mode dev: @echo "🚀 Starting development servers..." @@ -40,6 +45,9 @@ clean: rm -rf bin/ rm -rf web/build/ rm -rf web/.cache/ + rm -rf proxy/frontend/dist + mkdir -p proxy/frontend/dist + touch proxy/frontend/dist/.gitkeep rm -f requests.db rm -rf requests/ @@ -53,7 +61,7 @@ db-reset: help: @echo "Claude Code Monitor - Available targets:" @echo " make install - Install all dependencies" - @echo " make build - Build both services" + @echo " make build - Build single binary (frontend + Go)" @echo " make dev - Run in development mode" @echo " make run-proxy - Run proxy server only" @echo " make run-web - Run web interface only" diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go index 8623203b6..223276748 100644 --- a/proxy/cmd/proxy/main.go +++ b/proxy/cmd/proxy/main.go @@ -2,16 +2,19 @@ package main import ( "context" + "io/fs" "log" "net/http" "os" "os/signal" + "strings" "syscall" "time" "github.com/gorilla/handlers" "github.com/gorilla/mux" + "github.com/seifghazi/claude-code-monitor/frontend" "github.com/seifghazi/claude-code-monitor/internal/config" "github.com/seifghazi/claude-code-monitor/internal/handler" "github.com/seifghazi/claude-code-monitor/internal/middleware" @@ -62,15 +65,45 @@ func main() { 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) + // Serve embedded frontend assets + distFS, err := fs.Sub(frontend.Assets, "dist") + if err != nil { + logger.Fatalf("❌ Failed to create sub filesystem: %v", err) + } + fileServer := http.FileServer(http.FS(distFS)) + + // Serve static assets (JS, CSS, fonts, etc.) + r.PathPrefix("/assets/").Handler(fileServer) + + // SPA fallback: serve index.html for all non-API, non-asset paths + r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Let API routes return 404 normally + if strings.HasPrefix(req.URL.Path, "/api/") || strings.HasPrefix(req.URL.Path, "/v1/") { + h.NotFound(w, req) + return + } + // Try to serve the file directly first (e.g. favicon.ico) + f, err := distFS.Open(strings.TrimPrefix(req.URL.Path, "/")) + if err == nil { + f.Close() + fileServer.ServeHTTP(w, req) + return + } + // Fall back to index.html for SPA routing + indexHTML, err := fs.ReadFile(distFS, "index.html") + if err != nil { + h.NotFound(w, req) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(indexHTML) + }) srv := &http.Server{ Addr: ":" + cfg.Server.Port, diff --git a/proxy/frontend/embed.go b/proxy/frontend/embed.go new file mode 100644 index 000000000..fccd9d5de --- /dev/null +++ b/proxy/frontend/embed.go @@ -0,0 +1,6 @@ +package frontend + +import "embed" + +//go:embed all:dist +var Assets embed.FS diff --git a/proxy/internal/handler/handlers.go b/proxy/internal/handler/handlers.go index 05d1360b7..65fc51bcc 100644 --- a/proxy/internal/handler/handlers.go +++ b/proxy/internal/handler/handlers.go @@ -10,7 +10,6 @@ import ( "io" "log" "net/http" - "os" "sort" "strconv" "strings" @@ -150,18 +149,6 @@ func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { writeJSONResponse(w, response) } -func (h *Handler) UI(w http.ResponseWriter, r *http.Request) { - htmlContent, err := os.ReadFile("index.html") - if err != nil { - // Error reading index.html - http.Error(w, "UI not available", http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "text/html") - w.Write(htmlContent) -} - func (h *Handler) GetRequests(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { diff --git a/web/app/entry.server.tsx b/web/app/entry.server.tsx deleted file mode 100644 index 45db3229c..000000000 --- a/web/app/entry.server.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/** - * By default, Remix will handle generating the HTTP Response for you. - * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ - * For more information, see https://remix.run/file-conventions/entry.server - */ - -import { PassThrough } from "node:stream"; - -import type { AppLoadContext, EntryContext } from "@remix-run/node"; -import { createReadableStreamFromReadable } from "@remix-run/node"; -import { RemixServer } from "@remix-run/react"; -import { isbot } from "isbot"; -import { renderToPipeableStream } from "react-dom/server"; - -const ABORT_DELAY = 5_000; - -export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, - // This is ignored so we can keep it in the template for visibility. Feel - // free to delete this parameter in your app if you're not using it! - // eslint-disable-next-line @typescript-eslint/no-unused-vars - loadContext: AppLoadContext -) { - return isbot(request.headers.get("user-agent") || "") - ? handleBotRequest( - request, - responseStatusCode, - responseHeaders, - remixContext - ) - : handleBrowserRequest( - request, - responseStatusCode, - responseHeaders, - remixContext - ); -} - -function handleBotRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { pipe, abort } = renderToPipeableStream( - , - { - onAllReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }) - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - } - ); - - setTimeout(abort, ABORT_DELAY); - }); -} - -function handleBrowserRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { pipe, abort } = renderToPipeableStream( - , - { - onShellReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }) - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - } - ); - - setTimeout(abort, ABORT_DELAY); - }); -} diff --git a/web/app/routes/api.conversations.tsx b/web/app/routes/api.conversations.tsx deleted file mode 100644 index 988a84cbc..000000000 --- a/web/app/routes/api.conversations.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { LoaderFunction } from "@remix-run/node"; -import { json } from "@remix-run/node"; - -export const loader: LoaderFunction = async ({ request }) => { - try { - const url = new URL(request.url); - const modelFilter = url.searchParams.get("model"); - - const backendUrl = new URL('http://localhost:3001/api/conversations'); - if (modelFilter) { - backendUrl.searchParams.append('model', modelFilter); - } - - const response = await fetch(backendUrl.toString()); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return json(data); - } catch (error) { - console.error('Failed to fetch conversations:', error); - return json({ conversations: [] }); - } -}; \ No newline at end of file diff --git a/web/app/routes/api.grade-prompt.tsx b/web/app/routes/api.grade-prompt.tsx deleted file mode 100644 index 4a84253d5..000000000 --- a/web/app/routes/api.grade-prompt.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { ActionFunction } from "@remix-run/node"; -import { json } from "@remix-run/node"; - -export const action: ActionFunction = async ({ request }) => { - if (request.method !== "POST") { - return json({ error: 'Method not allowed' }, { status: 405 }); - } - - try { - const body = await request.json(); - - // Forward the request to the Go backend - const response = await fetch('http://localhost:3001/api/grade-prompt', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return json(data); - } catch (error) { - console.error('Failed to grade prompt:', error); - return json({ - error: 'Failed to grade prompt. Please ensure the backend is running and has a valid Anthropic API key.' - }, { status: 500 }); - } -}; \ No newline at end of file diff --git a/web/app/routes/api.requests.tsx b/web/app/routes/api.requests.tsx deleted file mode 100644 index 7f2b9cf52..000000000 --- a/web/app/routes/api.requests.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { ActionFunction, LoaderFunction } from "@remix-run/node"; -import { json } from "@remix-run/node"; - -export const loader: LoaderFunction = async ({ request }) => { - try { - const url = new URL(request.url); - const modelFilter = url.searchParams.get("model"); - const page = url.searchParams.get("page"); - const limit = url.searchParams.get("limit"); - - // Forward the request to the Go backend - const backendUrl = new URL('http://localhost:3001/api/requests'); - if (modelFilter) { - backendUrl.searchParams.append('model', modelFilter); - } - if (page) { - backendUrl.searchParams.append('page', page); - } - if (limit) { - backendUrl.searchParams.append('limit', limit); - } - - const response = await fetch(backendUrl.toString()); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return json(data); - } catch (error) { - console.error('Failed to fetch requests:', error); - - // Return empty array if backend is not available - return json({ requests: [] }); - } -}; - -export const action: ActionFunction = async ({ request }) => { - const method = request.method; - - if (method === "DELETE") { - try { - // Forward the DELETE request to the Go backend - const response = await fetch('http://localhost:3001/api/requests', { - method: 'DELETE' - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return json({ success: true }); - } catch (error) { - console.error('Failed to clear requests:', error); - return json({ success: false, error: 'Failed to clear requests' }, { status: 500 }); - } - } - - return json({ error: 'Method not allowed' }, { status: 405 }); -}; \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts index ef6d1387a..1d0c9370c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,15 +2,10 @@ import { vitePlugin as remix } from "@remix-run/dev"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; -declare module "@remix-run/node" { - interface Future { - v3_singleFetch: true; - } -} - export default defineConfig({ plugins: [ remix({ + ssr: false, future: { v3_fetcherPersist: true, v3_relativeSplatPath: true, From 2933e64516a768f5962fe59e87f1a900963253be Mon Sep 17 00:00:00 2001 From: john Date: Fri, 27 Feb 2026 17:27:19 +0800 Subject: [PATCH 2/8] Add proxy detection logging and Claude Code usage hint on startup Log detected HTTP/HTTPS proxy environment variables at startup to help diagnose connectivity issues, and print a quick-start command for connecting Claude Code to the proxy server. Co-Authored-By: Claude Opus 4.6 --- proxy/cmd/proxy/main.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go index 223276748..4eb2bddd8 100644 --- a/proxy/cmd/proxy/main.go +++ b/proxy/cmd/proxy/main.go @@ -113,6 +113,26 @@ func main() { IdleTimeout: cfg.Server.IdleTimeout, } + // Detect HTTP/HTTPS proxy from environment + proxyEnvVars := []string{"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"} + var detectedProxy bool + for _, env := range proxyEnvVars { + if val := os.Getenv(env); val != "" { + if !detectedProxy { + logger.Println("⚠️ HTTP/HTTPS proxy detected! All outbound API requests will be routed through:") + detectedProxy = true + } + logger.Printf(" %s=%s", env, val) + } + } + if !detectedProxy { + logger.Println("⚠️ No HTTP/HTTPS proxy detected. If you are behind a firewall, API calls may fail!") + logger.Println(" Set proxy environment variables before starting:") + logger.Printf(" export HTTP_PROXY=http://your-proxy:port") + logger.Printf(" export HTTPS_PROXY=http://your-proxy:port") + logger.Printf(" export NO_PROXY=localhost,127.0.0.1") + } + go func() { logger.Printf("🚀 Claude Code Monitor Server running on http://localhost:%s", cfg.Server.Port) logger.Printf("📡 API endpoints available at:") @@ -122,6 +142,11 @@ func main() { logger.Printf("🎨 Web UI available at:") logger.Printf(" - GET http://localhost:%s/ (Request Visualizer)", cfg.Server.Port) logger.Printf(" - GET http://localhost:%s/api/requests (Request API)", cfg.Server.Port) + logger.Println("") + logger.Println("👉 To use with Claude Code, run:") + logger.Printf(" export ANTHROPIC_BASE_URL=http://localhost:%s", cfg.Server.Port) + logger.Println(" claude") + logger.Println("") if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Fatalf("❌ Server failed to start: %v", err) From 030719c544d63d24cd2f2d8c093932c1f9067164 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 27 Feb 2026 17:50:41 +0800 Subject: [PATCH 3/8] Add GoReleaser config and GitHub Actions release workflow Automates cross-platform binary builds (linux/darwin, amd64/arm64) on tag push using goreleaser-cross-action for CGO cross-compilation (needed by mattn/go-sqlite3). The workflow builds frontend assets and embeds them before compiling. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 44 ++++++++++++++++++++++++++++ .goreleaser.yml | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..41434a151 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install frontend dependencies + run: cd web && npm ci + + - name: Build frontend + run: cd web && npm run build + + - name: Copy frontend assets to proxy embed directory + run: | + rm -rf proxy/frontend/dist + cp -r web/build/client proxy/frontend/dist + + - name: Run GoReleaser + uses: goreleaser/goreleaser-cross-action@v5 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 000000000..23b6e1058 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,55 @@ +version: 2 + +project_name: claude-code-proxy + +before: + hooks: + - go mod tidy + +builds: + - id: claude-code-proxy + dir: proxy + main: ./cmd/proxy + binary: claude-code-proxy + env: + - CGO_ENABLED=1 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + overrides: + - goos: linux + goarch: amd64 + env: + - CC=x86_64-linux-gnu-gcc + - goos: linux + goarch: arm64 + env: + - CC=aarch64-linux-gnu-gcc + - goos: darwin + goarch: amd64 + env: + - CC=o64-clang + - goos: darwin + goarch: arm64 + env: + - CC=oa64-clang + +archives: + - formats: + - tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^chore:" From f75e216e9d785ee39cb76eb58b433977c307b73e Mon Sep 17 00:00:00 2001 From: john Date: Fri, 27 Feb 2026 17:59:50 +0800 Subject: [PATCH 4/8] Fix release workflow: use goreleaser-cross Docker image directly goreleaser-cross-action does not exist as a GitHub Action. Use the goreleaser-cross Docker image (ghcr.io/goreleaser/goreleaser-cross:v1.22.7) directly via docker run, matching the official example pattern. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41434a151..364ebc6fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,10 +35,14 @@ jobs: rm -rf proxy/frontend/dist cp -r web/build/client proxy/frontend/dist - - name: Run GoReleaser - uses: goreleaser/goreleaser-cross-action@v5 - with: - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run GoReleaser with goreleaser-cross + run: | + docker run \ + --rm \ + -e CGO_ENABLED=1 \ + -e GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v ${{ github.workspace }}:/go/src/github.com/${{ github.repository }} \ + -w /go/src/github.com/${{ github.repository }} \ + ghcr.io/goreleaser/goreleaser-cross:v1.22.7 \ + release --clean From 3bba346c021292de244a48b9d427cdf5981a7e64 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 27 Feb 2026 18:05:55 +0800 Subject: [PATCH 5/8] Upgrade goreleaser-cross to v1.23.6 for GoReleaser v2 support The v1.22.7 image ships GoReleaser v1 which doesn't support the version 2 config format (formats field). Go 1.23+ images bundle GoReleaser v2. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 364ebc6fc..b1b897773 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,5 +44,5 @@ jobs: -v /var/run/docker.sock:/var/run/docker.sock \ -v ${{ github.workspace }}:/go/src/github.com/${{ github.repository }} \ -w /go/src/github.com/${{ github.repository }} \ - ghcr.io/goreleaser/goreleaser-cross:v1.22.7 \ + ghcr.io/goreleaser/goreleaser-cross:v1.23.6 \ release --clean From 586c292d5a2f79abb53e0c020d62a0791365ef5a Mon Sep 17 00:00:00 2001 From: john Date: Fri, 27 Feb 2026 18:10:05 +0800 Subject: [PATCH 6/8] Remove before hook: go.mod is in proxy/ subdirectory The go mod tidy hook runs from the project root where there is no go.mod. The build config already sets dir: proxy to handle this. Co-Authored-By: Claude Opus 4.6 --- .goreleaser.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 23b6e1058..25add9481 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -2,10 +2,6 @@ version: 2 project_name: claude-code-proxy -before: - hooks: - - go mod tidy - builds: - id: claude-code-proxy dir: proxy From d218fdb740f0d150aa76b646b4206be2f0365326 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 28 Feb 2026 09:26:14 +0800 Subject: [PATCH 7/8] Use separate builds per platform instead of overrides The override approach caused linux/arm64 to use the wrong assembler. Switch to separate build entries per goos/goarch with explicit CC and CXX env vars, matching the official goreleaser-cross example pattern. Co-Authored-By: Claude Opus 4.6 --- .goreleaser.yml | 58 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 25add9481..a37439e2c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,35 +3,57 @@ version: 2 project_name: claude-code-proxy builds: - - id: claude-code-proxy + - id: linux-amd64 dir: proxy main: ./cmd/proxy binary: claude-code-proxy + goos: + - linux + goarch: + - amd64 env: - CGO_ENABLED=1 + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + + - id: linux-arm64 + dir: proxy + main: ./cmd/proxy + binary: claude-code-proxy goos: - linux + goarch: + - arm64 + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + + - id: darwin-amd64 + dir: proxy + main: ./cmd/proxy + binary: claude-code-proxy + goos: - darwin goarch: - amd64 + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + + - id: darwin-arm64 + dir: proxy + main: ./cmd/proxy + binary: claude-code-proxy + goos: + - darwin + goarch: - arm64 - overrides: - - goos: linux - goarch: amd64 - env: - - CC=x86_64-linux-gnu-gcc - - goos: linux - goarch: arm64 - env: - - CC=aarch64-linux-gnu-gcc - - goos: darwin - goarch: amd64 - env: - - CC=o64-clang - - goos: darwin - goarch: arm64 - env: - - CC=oa64-clang + env: + - CGO_ENABLED=1 + - CC=oa64-clang + - CXX=oa64-clang++ archives: - formats: From bfdf667092e54cf82c98d5068d6308c21e986b1e Mon Sep 17 00:00:00 2001 From: jiahua Date: Mon, 9 Mar 2026 11:29:07 +0800 Subject: [PATCH 8/8] Handle system field as both string and array in AnthropicRequest The Anthropic API accepts the system parameter as either a plain string or an array of system message objects. Add custom UnmarshalJSON to handle both formats, and log raw request body on JSON parse errors for debugging. Co-Authored-By: Claude Opus 4.6 --- proxy/internal/handler/handlers.go | 1 + proxy/internal/model/models.go | 35 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/proxy/internal/handler/handlers.go b/proxy/internal/handler/handlers.go index 65fc51bcc..a9bc9bc78 100644 --- a/proxy/internal/handler/handlers.go +++ b/proxy/internal/handler/handlers.go @@ -59,6 +59,7 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) { var req model.AnthropicRequest if err := json.Unmarshal(bodyBytes, &req); err != nil { log.Printf("❌ Error parsing JSON: %v", err) + log.Printf("📋 Raw request body: %s", string(bodyBytes)) writeErrorResponse(w, "Invalid JSON", http.StatusBadRequest) return } diff --git a/proxy/internal/model/models.go b/proxy/internal/model/models.go index a5d03c058..4a86aec37 100644 --- a/proxy/internal/model/models.go +++ b/proxy/internal/model/models.go @@ -147,6 +147,41 @@ type AnthropicRequest struct { ToolChoice interface{} `json:"tool_choice,omitempty"` } +func (r *AnthropicRequest) UnmarshalJSON(data []byte) error { + // Use an alias to avoid infinite recursion + type Alias AnthropicRequest + aux := &struct { + System json.RawMessage `json:"system,omitempty"` + *Alias + }{ + Alias: (*Alias)(r), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + if len(aux.System) == 0 { + return nil + } + + // Try as array first + var systemMessages []AnthropicSystemMessage + if err := json.Unmarshal(aux.System, &systemMessages); err == nil { + r.System = systemMessages + return nil + } + + // Try as string + var systemStr string + if err := json.Unmarshal(aux.System, &systemStr); err == nil { + r.System = []AnthropicSystemMessage{{Type: "text", Text: systemStr}} + return nil + } + + return json.Unmarshal(aux.System, &r.System) +} + type ModelsResponse struct { Object string `json:"object"` Data []ModelInfo `json:"data"`