From c4c1d4daa4c5ad087192a5edf3d74e952ec58add Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 9 Apr 2026 21:05:32 +0200 Subject: [PATCH] Anthropic API proxy with OAuth credential rotation and Claude Code fingerprinting Sniffs a real Claude Code request on startup to capture exact HTTP headers, then replays them for all proxied requests. Injects the billing header with per-request SHA256 fingerprint into the system prompt. Uses utls with Chrome TLS fingerprint to pass Cloudflare's bot detection on api.anthropic.com. Supports both streaming (SSE) and non-streaming modes, round-robin credential selection with automatic failover, and loading OAuth tokens from both cli-proxy-api auth files and native ~/.claude/.credentials.json. --- .envrc | 1 + .gitignore | 4 + flake.lock | 61 ++++++++++++ flake.nix | 47 +++++++++ go.mod | 48 +++++++++ go.sum | 122 +++++++++++++++++++++++ internal/auth/refresh.go | 109 ++++++++++++++++++++ internal/auth/selector.go | 79 +++++++++++++++ internal/auth/types.go | 46 +++++++++ internal/config/config.go | 147 +++++++++++++++++++++++++++ internal/proxy/billing.go | 90 +++++++++++++++++ internal/proxy/handler.go | 107 ++++++++++++++++++++ internal/proxy/sniff.go | 193 ++++++++++++++++++++++++++++++++++++ internal/proxy/transport.go | 111 +++++++++++++++++++++ internal/proxy/upstream.go | 105 ++++++++++++++++++++ internal/server/server.go | 87 ++++++++++++++++ main.go | 60 +++++++++++ 17 files changed, 1417 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/refresh.go create mode 100644 internal/auth/selector.go create mode 100644 internal/auth/types.go create mode 100644 internal/config/config.go create mode 100644 internal/proxy/billing.go create mode 100644 internal/proxy/handler.go create mode 100644 internal/proxy/sniff.go create mode 100644 internal/proxy/transport.go create mode 100644 internal/proxy/upstream.go create mode 100644 internal/server/server.go create mode 100644 main.go diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b06117 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.go/ +.direnv/ +anthropic-proxy +result diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..88e712f --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1775423009, + "narHash": "sha256-vPKLpjhIVWdDrfiUM8atW6YkIggCEKdSAlJPzzhkQlw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "68d8aa3d661f0e6bd5862291b5bb263b2a6595c9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9e749ff --- /dev/null +++ b/flake.nix @@ -0,0 +1,47 @@ +{ + description = "Anthropic API proxy with OAuth key rotation"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + config.allowUnfreePredicate = + pkg: + builtins.elem (pkgs.lib.getName pkg) [ + "claude-code" + ]; + }; + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + go + gopls + gotools + go-tools + delve + curl + jq + claude-code + ]; + + shellHook = '' + export GOPATH="$PWD/.go" + export PATH="$GOPATH/bin:$PATH" + ''; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c57abb6 --- /dev/null +++ b/go.mod @@ -0,0 +1,48 @@ +module github.com/fujin/anthropic-proxy + +go 1.26 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/google/uuid v1.6.0 + github.com/tidwall/gjson v1.18.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6a6129f --- /dev/null +++ b/go.sum @@ -0,0 +1,122 @@ +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/refresh.go b/internal/auth/refresh.go new file mode 100644 index 0000000..700a23a --- /dev/null +++ b/internal/auth/refresh.go @@ -0,0 +1,109 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +const ( + tokenEndpoint = "https://api.anthropic.com/v1/oauth/token" + clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" +) + +type tokenRequest struct { + ClientID string `json:"client_id"` + GrantType string `json:"grant_type"` + RefreshToken string `json:"refresh_token"` +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + Account struct { + EmailAddress string `json:"email_address"` + } `json:"account"` +} + +type authFileJSON struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Email string `json:"email"` + Expired string `json:"expired"` + Type string `json:"type"` +} + +// RefreshToken performs an OAuth token refresh for the given credential. +func RefreshToken(ctx context.Context, cred *Credential) error { + reqBody := tokenRequest{ + ClientID: clientID, + GrantType: "refresh_token", + RefreshToken: cred.RefreshToken, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal refresh request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create refresh request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("execute refresh request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("refresh failed with status %d", resp.StatusCode) + } + + var tokenResp tokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return fmt.Errorf("decode refresh response: %w", err) + } + + cred.mu.Lock() + cred.AccessToken = tokenResp.AccessToken + cred.RefreshToken = tokenResp.RefreshToken + cred.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + if tokenResp.Account.EmailAddress != "" { + cred.Email = tokenResp.Account.EmailAddress + } + cred.mu.Unlock() + + return persistCredential(cred) +} + +func persistCredential(cred *Credential) error { + cred.mu.Lock() + data := authFileJSON{ + AccessToken: cred.AccessToken, + RefreshToken: cred.RefreshToken, + Email: cred.Email, + Expired: cred.ExpiresAt.Format(time.RFC3339), + Type: "claude", + } + filePath := cred.FilePath + cred.mu.Unlock() + + out, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("marshal auth file: %w", err) + } + + if err := os.WriteFile(filePath, out, 0600); err != nil { + return fmt.Errorf("write auth file %s: %w", filePath, err) + } + + return nil +} diff --git a/internal/auth/selector.go b/internal/auth/selector.go new file mode 100644 index 0000000..cb34eca --- /dev/null +++ b/internal/auth/selector.go @@ -0,0 +1,79 @@ +package auth + +import ( + "context" + "fmt" + "log" + "sync" + "time" +) + +type Pool struct { + creds []*Credential + cursor int + mu sync.Mutex +} + +func NewPool(creds []*Credential) *Pool { + return &Pool{creds: creds} +} + +func (p *Pool) Pick() (*Credential, error) { + p.mu.Lock() + defer p.mu.Unlock() + + n := len(p.creds) + if n == 0 { + return nil, fmt.Errorf("no credentials available") + } + + for i := 0; i < n; i++ { + idx := (p.cursor + i) % n + cred := p.creds[idx] + if !cred.IsOnCooldown() { + p.cursor = (idx + 1) % n + return cred, nil + } + } + + return nil, fmt.Errorf("all %d credentials are on cooldown", n) +} + +func (p *Pool) MarkFailure(cred *Credential, statusCode int) { + switch { + case statusCode == 429: + cred.SetCooldown(30 * time.Second) + case statusCode >= 500: + cred.SetCooldown(5 * time.Second) + } +} + +func (p *Pool) MarkSuccess(cred *Credential) { + cred.mu.Lock() + defer cred.mu.Unlock() + cred.CooldownUntil = time.Time{} +} + +func (p *Pool) RefreshExpiring(ctx context.Context) { + p.mu.Lock() + creds := make([]*Credential, len(p.creds)) + copy(creds, p.creds) + p.mu.Unlock() + + threshold := time.Now().Add(5 * time.Minute) + for _, cred := range creds { + cred.mu.Lock() + needsRefresh := cred.ExpiresAt.Before(threshold) + email := cred.Email + cred.mu.Unlock() + + if needsRefresh { + log.Printf("refreshing token for %s (expires %s)", email, cred.ExpiresAt.Format(time.RFC3339)) + if err := RefreshToken(ctx, cred); err != nil { + log.Printf("failed to refresh token for %s: %v", email, err) + } else { + log.Printf("refreshed token for %s, new expiry %s", email, cred.ExpiresAt.Format(time.RFC3339)) + } + } + } +} diff --git a/internal/auth/types.go b/internal/auth/types.go new file mode 100644 index 0000000..c6d8707 --- /dev/null +++ b/internal/auth/types.go @@ -0,0 +1,46 @@ +package auth + +import ( + "sync" + "time" +) + +// Credential represents an Anthropic API credential loaded from a JSON file. +type Credential struct { + ID string + Email string + AccessToken string + RefreshToken string + ExpiresAt time.Time + FilePath string + CooldownUntil time.Time + mu sync.Mutex +} + +// IsExpired returns true if the credential's access token has expired. +func (c *Credential) IsExpired() bool { + c.mu.Lock() + defer c.mu.Unlock() + return time.Now().After(c.ExpiresAt) +} + +// IsOnCooldown returns true if the credential is currently on cooldown. +func (c *Credential) IsOnCooldown() bool { + c.mu.Lock() + defer c.mu.Unlock() + return time.Now().Before(c.CooldownUntil) +} + +// SetCooldown puts the credential on cooldown for the given duration. +func (c *Credential) SetCooldown(duration time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.CooldownUntil = time.Now().Add(duration) +} + +// Token returns the current access token. +func (c *Credential) Token() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.AccessToken +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..359ab5d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,147 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/fujin/anthropic-proxy/internal/auth" + "gopkg.in/yaml.v3" +) + +type Config struct { + Port int `yaml:"port"` + APIKeys []string `yaml:"api_keys"` + AuthDir string `yaml:"auth_dir"` + ClaudeCredentials string `yaml:"claude_credentials"` + ClaudeBinary string `yaml:"claude_binary"` +} + +type authFileJSON struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Email string `json:"email"` + Expired string `json:"expired"` + Type string `json:"type"` +} + +type claudeCredentialsJSON struct { + ClaudeAiOauth struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + SubscriptionType string `json:"subscriptionType"` + } `json:"claudeAiOauth"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config %s: %w", path, err) + } + + cfg := &Config{Port: 8080} + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + return cfg, nil +} + +func LoadCredentials(cfg *Config) ([]*auth.Credential, error) { + var creds []*auth.Credential + + if cfg.ClaudeCredentials != "" { + cred, err := loadClaudeCredentials(cfg.ClaudeCredentials) + if err != nil { + return nil, fmt.Errorf("load claude credentials: %w", err) + } + creds = append(creds, cred) + } + + if cfg.AuthDir != "" { + dirCreds, err := loadAuthDir(cfg.AuthDir) + if err != nil { + return nil, fmt.Errorf("load auth dir: %w", err) + } + creds = append(creds, dirCreds...) + } + + return creds, nil +} + +func loadClaudeCredentials(path string) (*auth.Credential, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cf claudeCredentialsJSON + if err := json.Unmarshal(data, &cf); err != nil { + return nil, err + } + + oauth := cf.ClaudeAiOauth + if oauth.AccessToken == "" { + return nil, fmt.Errorf("no access token in %s", path) + } + + return &auth.Credential{ + ID: "claude-native", + Email: oauth.SubscriptionType, + AccessToken: oauth.AccessToken, + RefreshToken: oauth.RefreshToken, + ExpiresAt: time.UnixMilli(oauth.ExpiresAt), + FilePath: path, + }, nil +} + +func loadAuthDir(authDir string) ([]*auth.Credential, error) { + pattern := filepath.Join(authDir, "*.json") + files, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("glob auth files: %w", err) + } + + var creds []*auth.Credential + for _, f := range files { + cred, err := loadAuthFile(f) + if err != nil { + return nil, fmt.Errorf("load auth file %s: %w", f, err) + } + creds = append(creds, cred) + } + + return creds, nil +} + +func loadAuthFile(path string) (*auth.Credential, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var af authFileJSON + if err := json.Unmarshal(data, &af); err != nil { + return nil, err + } + + expiresAt, err := time.Parse(time.RFC3339, af.Expired) + if err != nil { + expiresAt, err = time.Parse("2006-01-02T15:04:05", af.Expired) + if err != nil { + expiresAt = time.Now() + } + } + + return &auth.Credential{ + ID: filepath.Base(path), + Email: af.Email, + AccessToken: af.AccessToken, + RefreshToken: af.RefreshToken, + ExpiresAt: expiresAt, + FilePath: path, + }, nil +} diff --git a/internal/proxy/billing.go b/internal/proxy/billing.go new file mode 100644 index 0000000..de39b4b --- /dev/null +++ b/internal/proxy/billing.go @@ -0,0 +1,90 @@ +package proxy + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "unicode/utf16" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const fingerprintSalt = "59cf53e54c78" + +func computeFingerprint(firstUserMessage string, version string) string { + indices := []int{4, 7, 20} + runes := utf16.Encode([]rune(firstUserMessage)) + var chars string + for _, i := range indices { + if i < len(runes) { + chars += string(rune(runes[i])) + } else { + chars += "0" + } + } + input := fingerprintSalt + chars + version + hash := sha256.Sum256([]byte(input)) + return hex.EncodeToString(hash[:])[:3] +} + +func extractFirstUserMessage(body []byte) string { + messages := gjson.GetBytes(body, "messages") + if !messages.Exists() || !messages.IsArray() { + return "" + } + for _, msg := range messages.Array() { + if msg.Get("role").String() != "user" { + continue + } + content := msg.Get("content") + if content.Type == gjson.String { + return content.String() + } + if content.IsArray() { + for _, block := range content.Array() { + if block.Get("type").String() == "text" { + return block.Get("text").String() + } + } + } + break + } + return "" +} + +func buildBillingHeader(body []byte, version string) string { + userMsg := extractFirstUserMessage(body) + fp := computeFingerprint(userMsg, version) + return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=00000;", version, fp) +} + +func injectBillingHeader(body []byte, version string) []byte { + header := buildBillingHeader(body, version) + billingBlock := map[string]interface{}{"type": "text", "text": header} + billingJSON, _ := json.Marshal(billingBlock) + + existing := gjson.GetBytes(body, "system") + if !existing.Exists() { + body, _ = sjson.SetRawBytes(body, "system", []byte("["+string(billingJSON)+"]")) + return body + } + + if existing.IsArray() { + items := make([]json.RawMessage, 0, len(existing.Array())+1) + items = append(items, billingJSON) + for _, item := range existing.Array() { + items = append(items, json.RawMessage(item.Raw)) + } + systemJSON, _ := json.Marshal(items) + body, _ = sjson.SetRawBytes(body, "system", systemJSON) + return body + } + + origText := existing.String() + origBlock := map[string]string{"type": "text", "text": origText} + origJSON, _ := json.Marshal(origBlock) + body, _ = sjson.SetRawBytes(body, "system", []byte("["+string(billingJSON)+","+string(origJSON)+"]")) + return body +} diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go new file mode 100644 index 0000000..98b4e07 --- /dev/null +++ b/internal/proxy/handler.go @@ -0,0 +1,107 @@ +package proxy + +import ( + "bufio" + "io" + "log" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + + "github.com/fujin/anthropic-proxy/internal/auth" +) + +func HandleMessages(pool *auth.Pool, profile *SniffedProfile) gin.HandlerFunc { + upstream := NewUpstreamClient(profile) + + return func(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) + return + } + + cred, err := pool.Pick() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + + isStream := gjson.GetBytes(body, "stream").Bool() + + if isStream { + handleStream(c, upstream, pool, cred, body) + } else { + handleNonStream(c, upstream, pool, cred, body) + } + } +} + +func handleNonStream(c *gin.Context, upstream *UpstreamClient, pool *auth.Pool, cred *auth.Credential, body []byte) { + respBody, headers, statusCode, err := upstream.Execute(c.Request.Context(), cred, body) + if err != nil { + log.Printf("upstream error for %s: %v", cred.Email, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed"}) + return + } + + if statusCode >= 400 { + pool.MarkFailure(cred, statusCode) + log.Printf("upstream %d for %s", statusCode, cred.Email) + } else { + pool.MarkSuccess(cred) + } + + for _, h := range []string{"Content-Type", "X-Request-Id"} { + if v := headers.Get(h); v != "" { + c.Header(h, v) + } + } + + c.Data(statusCode, headers.Get("Content-Type"), respBody) +} + +func handleStream(c *gin.Context, upstream *UpstreamClient, pool *auth.Pool, cred *auth.Credential, body []byte) { + resp, err := upstream.ExecuteStream(c.Request.Context(), cred, body) + if err != nil { + log.Printf("upstream stream error for %s: %v", cred.Email, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream stream request failed"}) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + pool.MarkFailure(cred, resp.StatusCode) + log.Printf("upstream stream %d for %s", resp.StatusCode, cred.Email) + respBody, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody) + return + } + + pool.MarkSuccess(cred) + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Status(http.StatusOK) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + log.Printf("response writer does not support flushing") + c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"}) + return + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + c.Writer.WriteString(line + "\n") + flusher.Flush() + } + + if err := scanner.Err(); err != nil { + log.Printf("stream scan error: %v", err) + } +} diff --git a/internal/proxy/sniff.go b/internal/proxy/sniff.go new file mode 100644 index 0000000..c29b304 --- /dev/null +++ b/internal/proxy/sniff.go @@ -0,0 +1,193 @@ +package proxy + +import ( + "fmt" + "io" + "log" + "net" + "net/http" + "os/exec" + "strings" + "sync" + "time" + + "github.com/tidwall/gjson" +) + +// SniffedProfile holds everything captured from a real Claude Code request. +// The proxy replays these verbatim — no hardcoded values needed. +type SniffedProfile struct { + // Raw headers exactly as Claude Code sent them (name→value). + // Excludes only host, content-length, and auth (we substitute our own token). + Headers [][2]string + + // The full request body with system prompt, tools, metadata, thinking config, etc. + // We swap out model + messages from the incoming client request. + Body []byte + + // Parsed from User-Agent for billing header fingerprint computation. + Version string +} + +var skipHeaders = map[string]bool{ + "host": true, + "content-length": true, + "authorization": true, + "x-api-key": true, + "connection": true, +} + +func SniffClaudeCode(claudeBinary string) (*SniffedProfile, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen: %w", err) + } + port := listener.Addr().(*net.TCPAddr).Port + + var profile *SniffedProfile + var mu sync.Mutex + captured := make(chan struct{}, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.WriteHeader(200) + return + } + if r.Method != "POST" || !strings.Contains(r.URL.Path, "/v1/messages") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + fmt.Fprint(w, `{"id":"msg_fake","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-sonnet-4-6","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`) + return + } + + body, _ := io.ReadAll(r.Body) + + mu.Lock() + if profile == nil { + profile = extractProfile(r, body) + select { + case captured <- struct{}{}: + default: + } + } + mu.Unlock() + + if strings.Contains(string(body), `"stream":true`) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + fmt.Fprint(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_fake\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n") + fmt.Fprint(w, "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n") + fmt.Fprint(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\n") + fmt.Fprint(w, "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n") + fmt.Fprint(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n") + fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") + } else { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + fmt.Fprint(w, `{"id":"msg_fake","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-sonnet-4-6","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`) + } + }) + + srv := &http.Server{Handler: mux} + go srv.Serve(listener) + defer srv.Close() + + cmd := exec.Command(claudeBinary, "--print", "say hi") + cmd.Env = append(cmd.Environ(), fmt.Sprintf("ANTHROPIC_BASE_URL=http://127.0.0.1:%d", port)) + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start claude: %w", err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + select { + case <-captured: + cmd.Process.Kill() + case err := <-done: + if err != nil && profile == nil { + return nil, fmt.Errorf("claude exited: %w", err) + } + case <-time.After(30 * time.Second): + cmd.Process.Kill() + return nil, fmt.Errorf("sniff timed out after 30s") + } + + if profile == nil { + return nil, fmt.Errorf("no API request captured") + } + + log.Printf("sniffed claude-code: version=%s headers=%d body=%d bytes", + profile.Version, len(profile.Headers), len(profile.Body)) + return profile, nil +} + +func extractProfile(r *http.Request, body []byte) *SniffedProfile { + // Capture raw headers preserving original casing and order. + var headers [][2]string + for i := 0; i < len(r.Header); i++ { + for name, vals := range r.Header { + if skipHeaders[strings.ToLower(name)] { + continue + } + for _, v := range vals { + headers = append(headers, [2]string{name, v}) + } + } + break + } + + // Deduplicate and strip subscription-specific betas. + seen := map[string]bool{} + var deduped [][2]string + for _, h := range headers { + key := strings.ToLower(h[0]) + if seen[key] { + continue + } + seen[key] = true + if key == "anthropic-beta" { + var filtered []string + for _, b := range strings.Split(h[1], ",") { + if !strings.Contains(b, "context-1m") { + filtered = append(filtered, b) + } + } + h[1] = strings.Join(filtered, ",") + } + deduped = append(deduped, h) + } + + ua := r.Header.Get("User-Agent") + version := "" + if i := strings.Index(ua, "/"); i > 0 { + rest := ua[i+1:] + if j := strings.IndexByte(rest, ' '); j > 0 { + version = rest[:j] + } else { + version = rest + } + } + + // Extract the system prompt template from the body (everything except the billing header block). + // The billing header is the first system block starting with "x-anthropic-billing-header:". + systemBlocks := gjson.GetBytes(body, "system") + var templateSystem []string + if systemBlocks.IsArray() { + for _, block := range systemBlocks.Array() { + text := block.Get("text").String() + if strings.HasPrefix(text, "x-anthropic-billing-header:") { + continue + } + templateSystem = append(templateSystem, block.Raw) + } + } + _ = templateSystem // stored in body for now + + return &SniffedProfile{ + Headers: deduped, + Body: body, + Version: version, + } +} diff --git a/internal/proxy/transport.go b/internal/proxy/transport.go new file mode 100644 index 0000000..c78c94c --- /dev/null +++ b/internal/proxy/transport.go @@ -0,0 +1,111 @@ +package proxy + +import ( + "log" + "net" + "net/http" + "sync" + + tls "github.com/refraction-networking/utls" + "golang.org/x/net/http2" +) + +type utlsRoundTripper struct { + mu sync.Mutex + connections map[string]*http2.ClientConn + pending map[string]*sync.Cond +} + +func newUtlsRoundTripper() *utlsRoundTripper { + return &utlsRoundTripper{ + connections: make(map[string]*http2.ClientConn), + pending: make(map[string]*sync.Cond), + } +} + +func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.ClientConn, error) { + t.mu.Lock() + + if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() { + t.mu.Unlock() + return h2Conn, nil + } + + if cond, ok := t.pending[host]; ok { + cond.Wait() + if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() { + t.mu.Unlock() + return h2Conn, nil + } + } + + cond := sync.NewCond(&t.mu) + t.pending[host] = cond + t.mu.Unlock() + + h2Conn, err := t.createConnection(host, addr) + + t.mu.Lock() + defer t.mu.Unlock() + + delete(t.pending, host) + cond.Broadcast() + + if err != nil { + return nil, err + } + + t.connections[host] = h2Conn + return h2Conn, nil +} + +func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) { + conn, err := net.Dial("tcp", addr) + if err != nil { + return nil, err + } + + tlsConfig := &tls.Config{ServerName: host} + tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto) + + if err := tlsConn.Handshake(); err != nil { + conn.Close() + return nil, err + } + + tr := &http2.Transport{} + h2Conn, err := tr.NewClientConn(tlsConn) + if err != nil { + tlsConn.Close() + return nil, err + } + + return h2Conn, nil +} + +func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + hostname := req.URL.Hostname() + port := req.URL.Port() + if port == "" { + port = "443" + } + addr := net.JoinHostPort(hostname, port) + log.Printf("utls: RoundTrip to %s (Chrome TLS fingerprint, HTTP/2)", addr) + + h2Conn, err := t.getOrCreateConnection(hostname, addr) + if err != nil { + return nil, err + } + + resp, err := h2Conn.RoundTrip(req) + if err != nil { + t.mu.Lock() + if cached, ok := t.connections[hostname]; ok && cached == h2Conn { + delete(t.connections, hostname) + } + t.mu.Unlock() + return nil, err + } + + return resp, nil +} diff --git a/internal/proxy/upstream.go b/internal/proxy/upstream.go new file mode 100644 index 0000000..3086efd --- /dev/null +++ b/internal/proxy/upstream.go @@ -0,0 +1,105 @@ +package proxy + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/fujin/anthropic-proxy/internal/auth" +) + +const messagesURL = "https://api.anthropic.com/v1/messages?beta=true" + +type UpstreamClient struct { + client http.Client + sessionID string + profile *SniffedProfile +} + +func NewUpstreamClient(profile *SniffedProfile) *UpstreamClient { + return &UpstreamClient{ + client: http.Client{ + Timeout: 0, + Transport: newUtlsRoundTripper(), + }, + sessionID: uuid.New().String(), + profile: profile, + } +} + +func (u *UpstreamClient) version() string { + if u.profile != nil && u.profile.Version != "" { + return u.profile.Version + } + return "2.1.92" +} + +// applyHeaders replays sniffed headers, substituting auth + per-request IDs + accept. +func (u *UpstreamClient) applyHeaders(req *http.Request, token string, streaming bool) { + if u.profile != nil { + for _, h := range u.profile.Headers { + req.Header.Set(h[0], h[1]) + } + } + + req.Header.Del("Authorization") + req.Header.Del("x-api-key") + if strings.HasPrefix(token, "sk-ant-oat") { + req.Header.Set("Authorization", "Bearer "+token) + } else { + req.Header.Set("x-api-key", token) + } + + req.Header.Set("X-Claude-Code-Session-Id", u.sessionID) + req.Header.Set("x-client-request-id", uuid.New().String()) + + if streaming { + req.Header.Set("Accept", "text/event-stream") + } else { + req.Header.Set("Accept", "application/json") + } + req.Header.Set("Accept-Encoding", "identity") +} + +func (u *UpstreamClient) Execute(ctx context.Context, cred *auth.Credential, body []byte) ([]byte, http.Header, int, error) { + body = injectBillingHeader(body, u.version()) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, messagesURL, bytes.NewReader(body)) + if err != nil { + return nil, nil, 0, fmt.Errorf("build upstream request: %w", err) + } + u.applyHeaders(req, cred.Token(), false) + + resp, err := u.client.Do(req) + if err != nil { + return nil, nil, 0, fmt.Errorf("upstream request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, resp.StatusCode, fmt.Errorf("read upstream response: %w", err) + } + return respBody, resp.Header, resp.StatusCode, nil +} + +func (u *UpstreamClient) ExecuteStream(ctx context.Context, cred *auth.Credential, body []byte) (*http.Response, error) { + body = injectBillingHeader(body, u.version()) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, messagesURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build upstream stream request: %w", err) + } + u.applyHeaders(req, cred.Token(), true) + + resp, err := u.client.Do(req) + if err != nil { + return nil, fmt.Errorf("upstream stream request: %w", err) + } + return resp, nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..1cb7da1 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,87 @@ +package server + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/fujin/anthropic-proxy/internal/auth" + "github.com/fujin/anthropic-proxy/internal/config" + "github.com/fujin/anthropic-proxy/internal/proxy" +) + +type Server struct { + engine *gin.Engine + port int +} + +func New(cfg *config.Config, pool *auth.Pool, profile *proxy.SniffedProfile) *Server { + gin.SetMode(gin.ReleaseMode) + engine := gin.New() + engine.Use(gin.Recovery()) + engine.Use(corsMiddleware()) + engine.Use(authMiddleware(cfg.APIKeys)) + + engine.POST("/v1/messages", proxy.HandleMessages(pool, profile)) + engine.GET("/healthz", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + return &Server{engine: engine, port: cfg.Port} +} + +func (s *Server) Start() error { + addr := fmt.Sprintf(":%d", s.port) + return s.engine.Run(addr) +} + +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, x-api-key") + + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} + +func authMiddleware(apiKeys []string) gin.HandlerFunc { + keySet := make(map[string]struct{}, len(apiKeys)) + for _, k := range apiKeys { + keySet[k] = struct{}{} + } + + return func(c *gin.Context) { + if c.Request.URL.Path == "/healthz" { + c.Next() + return + } + + token := "" + if authHeader := c.GetHeader("Authorization"); authHeader != "" { + token = strings.TrimPrefix(authHeader, "Bearer ") + } + if token == "" { + token = c.GetHeader("x-api-key") + } + + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authentication"}) + return + } + + if _, ok := keySet[token]; !ok { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid api key"}) + return + } + + c.Next() + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7d62205 --- /dev/null +++ b/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/fujin/anthropic-proxy/internal/auth" + "github.com/fujin/anthropic-proxy/internal/config" + "github.com/fujin/anthropic-proxy/internal/proxy" + "github.com/fujin/anthropic-proxy/internal/server" +) + +func run() error { + log.SetFlags(log.LstdFlags) + + cfg, err := config.Load("config.yaml") + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + creds, err := config.LoadCredentials(cfg) + if err != nil { + return fmt.Errorf("load credentials: %w", err) + } + + if len(creds) == 0 { + return fmt.Errorf("no credentials found") + } + + log.Printf("loaded %d credentials", len(creds)) + + pool := auth.NewPool(creds) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + pool.RefreshExpiring(ctx) + + var profile *proxy.SniffedProfile + if cfg.ClaudeBinary != "" { + log.Printf("sniffing claude-code at %s...", cfg.ClaudeBinary) + profile, err = proxy.SniffClaudeCode(cfg.ClaudeBinary) + if err != nil { + log.Printf("warning: sniff failed, using defaults: %v", err) + } + } + + log.Printf("starting server on port %d", cfg.Port) + srv := server.New(cfg, pool, profile) + return srv.Start() +} + +func main() { + if err := run(); err != nil { + log.Printf("error: %v", err) + os.Exit(1) + } +}