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.
This commit is contained in:
Alexander
2026-04-09 21:05:32 +02:00
commit c4c1d4daa4
17 changed files with 1417 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+4
View File
@@ -0,0 +1,4 @@
.go/
.direnv/
anthropic-proxy
result
Generated
+61
View File
@@ -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
}
+47
View File
@@ -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"
'';
};
}
);
}
+48
View File
@@ -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
)
+122
View File
@@ -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=
+109
View File
@@ -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
}
+79
View File
@@ -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))
}
}
}
}
+46
View File
@@ -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
}
+147
View File
@@ -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
}
+90
View File
@@ -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
}
+107
View File
@@ -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)
}
}
+193
View File
@@ -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,
}
}
+111
View File
@@ -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
}
+105
View File
@@ -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
}
+87
View File
@@ -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()
}
}
+60
View File
@@ -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)
}
}