Compare commits

...

6 Commits

Author SHA1 Message Date
Alexander fac9578975 feat(ratelimit): track per-window token usage and utilization
Poll /api/oauth/usage every 5 min and extract utilization from
/v1/messages response headers for real-time updates. Track proxy
tokens in/out per rate limit window (5h/7d), resetting on window
change. Expose as OTel observable gauges for Grafana dashboards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:51:31 +02:00
Alexander 76aeeb6be1 fix(auth): add oauth-2025-04-20 beta header + debug logging
Ensure anthropic-beta includes oauth-2025-04-20 when using OAuth tokens,
fixing 401 "OAuth authentication is currently not supported" errors.
Add debug-level logging for upstream requests/responses, sniffed headers,
and token refresh operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:08:08 +02:00
Alexander 9cc052c162 Add telemetry 2026-04-14 10:31:56 +02:00
Alexander 20049881ad Remove duplicate logging 2026-04-11 15:21:18 +02:00
Alexander 3435f5f4c5 Update example 2026-04-10 18:27:29 +02:00
Alexander 807e8ba133 fix(nix): update vendorHash and vendor dir for new deps 2026-04-10 18:25:19 +02:00
19 changed files with 998 additions and 79 deletions
+2
View File
@@ -4,3 +4,5 @@
anthropic-proxy anthropic-proxy
result result
config.yaml config.yaml
vendor/**
+16 -8
View File
@@ -1,4 +1,20 @@
port: 8082 port: 8082
# telemetry:
# endpoint: "localhost:4317" # OTLP gRPC endpoint (omit to disable export)
# insecure: true # disable TLS for local dev
# service_name: "anthropic-proxy"
# headers: # optional auth headers (e.g. Grafana Cloud)
# Authorization: "Basic ..."
logging:
level: debug
file: /home/fujin/.local/log/anthropic-proxy.log
max_size_mb: 100
max_backups: 5
max_age_days: 30
compress: true
api_keys: api_keys:
- "your-proxy-api-key" - "your-proxy-api-key"
claude_binary: "claude" claude_binary: "claude"
@@ -45,11 +61,3 @@ sanitize:
replace: "claude.ai" replace: "claude.ai"
- match: "opencode" - match: "opencode"
replace: "agent" replace: "agent"
logging:
level: info
# file: /var/log/anthropic-proxy.log # omit to log to stderr
# max_size_mb: 100
# max_backups: 5
# max_age_days: 30
# compress: true
+3
View File
@@ -42,6 +42,9 @@
shellHook = '' shellHook = ''
export GOPATH="$PWD/.go" export GOPATH="$PWD/.go"
export PATH="$GOPATH/bin:$PATH" export PATH="$GOPATH/bin:$PATH"
export ANTHROPIC_BASE_URL=http://localhost:8082
export ANTHROPIC_API_KEY=sk-cliproxy-fujin
''; '';
}; };
} }
+39 -14
View File
@@ -5,23 +5,45 @@ go 1.26
require ( require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/refraction-networking/utls v1.8.2
github.com/rs/zerolog v1.35.0
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/log v0.19.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.19.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
golang.org/x/net v0.52.0
google.golang.org/grpc v1.80.0
gopkg.in/lumberjack.v2 v2.0.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/andybalholm/brotli v1.0.6 // indirect github.com/andybalholm/brotli v1.0.6 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/compress v1.17.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -30,22 +52,25 @@ require (
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/rs/zerolog v1.35.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/crypto v0.49.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/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
gopkg.in/lumberjack.v2 v2.0.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
) )
+83 -28
View File
@@ -1,39 +1,54 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 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.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= 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 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.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= 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/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 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/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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 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.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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/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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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/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 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
@@ -55,8 +70,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.0/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
@@ -65,8 +80,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= 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 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -95,34 +110,74 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 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 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0/go.mod h1:MdHW7tLtkeGJnR4TyOrnd5D0zUGZQB1l84uHCe8hRpE=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= 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.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
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 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 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 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 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.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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/lumberjack.v2 v2.0.0 h1:IDj6hi8KbNiPQ5VaYNFZ7dBJLF5LFeKvsFrWHjA5aq4= gopkg.in/lumberjack.v2 v2.0.0 h1:IDj6hi8KbNiPQ5VaYNFZ7dBJLF5LFeKvsFrWHjA5aq4=
gopkg.in/lumberjack.v2 v2.0.0/go.mod h1:bp5nQ2kK/lLQSmTk29azj9+JB6bWci56xFn/lvd5GLI= gopkg.in/lumberjack.v2 v2.0.0/go.mod h1:bp5nQ2kK/lLQSmTk29azj9+JB6bWci56xFn/lvd5GLI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+13
View File
@@ -64,6 +64,13 @@ func RefreshToken(ctx context.Context, cred *Credential) error {
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
log.Debug().
Str("url", tokenEndpoint).
Str("grant_type", "refresh_token").
Str("client_id", clientID).
Str("scope", oauthScopes).
Msg("token refresh request")
resp, err := utlsClient.Do(req) resp, err := utlsClient.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("execute request: %w", err) return fmt.Errorf("execute request: %w", err)
@@ -71,6 +78,12 @@ func RefreshToken(ctx context.Context, cred *Credential) error {
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
log.Debug().
Int("status", resp.StatusCode).
Int("response_size", len(body)).
Msg("token refresh response")
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("refresh returned %d: %s", resp.StatusCode, string(body)) return fmt.Errorf("refresh returned %d: %s", resp.StatusCode, string(body))
} }
+19 -5
View File
@@ -11,11 +11,12 @@ import (
) )
type Config struct { type Config struct {
Port int `yaml:"port"` Port int `yaml:"port"`
APIKeys []string `yaml:"api_keys"` APIKeys []string `yaml:"api_keys"`
ClaudeBinary string `yaml:"claude_binary"` ClaudeBinary string `yaml:"claude_binary"`
Sanitize SanitizeConfig `yaml:"sanitize"` Sanitize SanitizeConfig `yaml:"sanitize"`
Logging LoggingConfig `yaml:"logging"` Logging LoggingConfig `yaml:"logging"`
Telemetry TelemetryConfig `yaml:"telemetry"`
} }
type SanitizeConfig struct { type SanitizeConfig struct {
@@ -34,6 +35,15 @@ type ReplaceRule struct {
Replace string `yaml:"replace"` Replace string `yaml:"replace"`
} }
type TelemetryConfig struct {
Endpoint string `yaml:"endpoint"`
Insecure bool `yaml:"insecure"`
ServiceName string `yaml:"service_name"`
Headers map[string]string `yaml:"headers"`
}
func (t TelemetryConfig) ExportEnabled() bool { return t.Endpoint != "" }
type LoggingConfig struct { type LoggingConfig struct {
Level string `yaml:"level"` Level string `yaml:"level"`
File string `yaml:"file"` File string `yaml:"file"`
@@ -76,6 +86,10 @@ func Load(path string) (*Config, error) {
cfg.Logging.MaxAgeDays = 30 cfg.Logging.MaxAgeDays = 30
} }
if cfg.Telemetry.ServiceName == "" {
cfg.Telemetry.ServiceName = "anthropic-proxy"
}
// Check for deprecated claude_credentials field // Check for deprecated claude_credentials field
var rawCfg map[string]interface{} var rawCfg map[string]interface{}
if err := yaml.Unmarshal(data, &rawCfg); err == nil { if err := yaml.Unmarshal(data, &rawCfg); err == nil {
+20 -5
View File
@@ -3,6 +3,7 @@ package logging
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@@ -28,7 +29,9 @@ type Config struct {
// - File set: JSON → lumberjack rotating file // - File set: JSON → lumberjack rotating file
// - File empty + TTY: colored ConsoleWriter → stderr // - File empty + TTY: colored ConsoleWriter → stderr
// - File empty + not TTY: JSON → stderr (for systemd journal) // - File empty + not TTY: JSON → stderr (for systemd journal)
func Setup(cfg Config) zerolog.Logger { // Extra writers (e.g., OTLP log bridge) are added via io.MultiWriter so logs
// are written to both the primary destination and any extra writers.
func Setup(cfg Config, extraWriters ...io.Writer) zerolog.Logger {
// Parse log level // Parse log level
level, err := zerolog.ParseLevel(cfg.Level) level, err := zerolog.ParseLevel(cfg.Level)
if err != nil || cfg.Level == "" { if err != nil || cfg.Level == "" {
@@ -48,20 +51,32 @@ func Setup(cfg Config) zerolog.Logger {
MaxAge: cfg.MaxAgeDays, MaxAge: cfg.MaxAgeDays,
Compress: cfg.Compress, Compress: cfg.Compress,
} }
logger = zerolog.New(jack).With().Timestamp().Caller().Logger() var w io.Writer = jack
if len(extraWriters) > 0 {
w = io.MultiWriter(append([]io.Writer{jack}, extraWriters...)...)
}
logger = zerolog.New(w).With().Timestamp().Caller().Logger()
} else { } else {
fi, err := os.Stderr.Stat() fi, err := os.Stderr.Stat()
isTTY := err == nil && (fi.Mode()&os.ModeCharDevice) != 0 isTTY := err == nil && (fi.Mode()&os.ModeCharDevice) != 0
if isTTY { if isTTY {
// Dev mode: colored console // Dev mode: colored console (extra writers get JSON, console gets pretty)
cw := zerolog.ConsoleWriter{ cw := zerolog.ConsoleWriter{
Out: os.Stderr, Out: os.Stderr,
TimeFormat: time.RFC3339, TimeFormat: time.RFC3339,
} }
logger = zerolog.New(cw).With().Timestamp().Caller().Logger() var w io.Writer = cw
if len(extraWriters) > 0 {
w = io.MultiWriter(append([]io.Writer{cw}, extraWriters...)...)
}
logger = zerolog.New(w).With().Timestamp().Caller().Logger()
} else { } else {
// Systemd journal: JSON to stderr // Systemd journal: JSON to stderr
logger = zerolog.New(os.Stderr).With().Timestamp().Caller().Logger() var w io.Writer = os.Stderr
if len(extraWriters) > 0 {
w = io.MultiWriter(append([]io.Writer{os.Stderr}, extraWriters...)...)
}
logger = zerolog.New(w).With().Timestamp().Caller().Logger()
} }
} }
+154 -13
View File
@@ -9,12 +9,16 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"github.com/fujin/anthropic-proxy/internal/auth" "github.com/fujin/anthropic-proxy/internal/auth"
"github.com/fujin/anthropic-proxy/internal/logging" "github.com/fujin/anthropic-proxy/internal/logging"
"github.com/fujin/anthropic-proxy/internal/ratelimit"
"github.com/fujin/anthropic-proxy/internal/telemetry"
) )
func HandleMessages(pool *auth.Pool, profile *SniffedProfile, getSanitizer func() *Sanitizer) gin.HandlerFunc { func HandleMessages(pool *auth.Pool, profile *SniffedProfile, getSanitizer func() *Sanitizer, tracker *ratelimit.Tracker) gin.HandlerFunc {
upstream := NewUpstreamClient(profile) upstream := NewUpstreamClient(profile)
return func(c *gin.Context) { return func(c *gin.Context) {
@@ -46,19 +50,25 @@ func HandleMessages(pool *auth.Pool, profile *SniffedProfile, getSanitizer func(
isStream := gjson.GetBytes(body, "stream").Bool() isStream := gjson.GetBytes(body, "stream").Bool()
if isStream { if isStream {
handleStream(c, upstream, san, pool, cred, body, originalBody) handleStream(c, upstream, san, pool, cred, body, originalBody, tracker)
} else { } else {
handleNonStream(c, upstream, san, pool, cred, body, originalBody) handleNonStream(c, upstream, san, pool, cred, body, originalBody, tracker)
} }
} }
} }
func handleNonStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool *auth.Pool, cred *auth.Credential, body []byte, originalBody []byte) { func handleNonStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool *auth.Pool, cred *auth.Credential, body []byte, originalBody []byte, tracker *ratelimit.Tracker) {
startTime := time.Now() startTime := time.Now()
respBody, headers, statusCode, err := upstream.Execute(c.Request.Context(), cred, body) model := gjson.GetBytes(body, "model").String()
ctx := c.Request.Context()
telemetry.RequestBodySize.Record(ctx, int64(len(body)),
metric.WithAttributes(attribute.String("model", model), attribute.Bool("stream", false)))
respBody, headers, statusCode, err := upstream.Execute(ctx, cred, body)
latencyMs := float64(time.Since(startTime).Milliseconds())
if err != nil { if err != nil {
latencyMs := float64(time.Since(startTime).Milliseconds())
model := gjson.GetBytes(body, "model").String()
log.Error(). log.Error().
Err(err). Err(err).
Str("credential", cred.Email). Str("credential", cred.Email).
@@ -69,14 +79,39 @@ func handleNonStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, p
Int("request_body_size", len(body)). Int("request_body_size", len(body)).
Float64("latency_ms", latencyMs). Float64("latency_ms", latencyMs).
Msg("upstream connection error") Msg("upstream connection error")
telemetry.UpstreamErrors.Add(ctx, 1,
metric.WithAttributes(
attribute.String("error_type", "connection"),
attribute.String("credential", cred.Email),
attribute.Int("status_code", http.StatusBadGateway),
))
telemetry.RequestCounter.Add(ctx, 1,
metric.WithAttributes(
attribute.String("model", model),
attribute.Bool("stream", false),
attribute.Int("status_code", http.StatusBadGateway),
))
telemetry.RequestDuration.Record(ctx, latencyMs,
metric.WithAttributes(attribute.String("model", model), attribute.Bool("stream", false), attribute.Int("status_code", http.StatusBadGateway)))
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed"}) c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed"})
return return
} }
attrs := []attribute.KeyValue{
attribute.String("model", model),
attribute.Bool("stream", false),
attribute.Int("status_code", statusCode),
}
telemetry.RequestCounter.Add(ctx, 1, metric.WithAttributes(attrs...))
telemetry.RequestDuration.Record(ctx, latencyMs, metric.WithAttributes(attrs...))
if statusCode >= 400 { if statusCode >= 400 {
pool.MarkFailure(cred, statusCode) pool.MarkFailure(cred, statusCode)
latencyMs := float64(time.Since(startTime).Milliseconds()) telemetry.CredentialCooldowns.Add(ctx, 1,
model := gjson.GetBytes(body, "model").String() metric.WithAttributes(attribute.Int("status_code", statusCode)))
errorType := gjson.GetBytes(respBody, "error.type").String() errorType := gjson.GetBytes(respBody, "error.type").String()
errorMessage := gjson.GetBytes(respBody, "error.message").String() errorMessage := gjson.GetBytes(respBody, "error.message").String()
log.Error(). log.Error().
@@ -94,9 +129,37 @@ func handleNonStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, p
Int("request_body_size", len(body)). Int("request_body_size", len(body)).
Str("request_headers", logging.RedactHeaders(c.Request.Header)). Str("request_headers", logging.RedactHeaders(c.Request.Header)).
Msg("upstream error") Msg("upstream error")
telemetry.UpstreamErrors.Add(ctx, 1,
metric.WithAttributes(
attribute.Int("status_code", statusCode),
attribute.String("error_type", errorType),
attribute.String("credential", cred.Email),
))
} else { } else {
pool.MarkSuccess(cred) pool.MarkSuccess(cred)
respBody = san.DesanitizeResponse(respBody) respBody = san.DesanitizeResponse(respBody)
inputTokens := gjson.GetBytes(respBody, "usage.input_tokens").Int()
outputTokens := gjson.GetBytes(respBody, "usage.output_tokens").Int()
tokenAttrs := metric.WithAttributes(
attribute.String("model", model),
attribute.String("credential", cred.Email),
)
telemetry.TokensInput.Add(ctx, inputTokens, tokenAttrs)
telemetry.TokensOutput.Add(ctx, outputTokens, tokenAttrs)
if tracker != nil {
tracker.RecordTokens(inputTokens, outputTokens)
tracker.UpdateFromHeaders(headers)
}
log.Info().
Int("status", statusCode).
Float64("latency_ms", latencyMs).
Str("model", model).
Int64("input_tokens", inputTokens).
Int64("output_tokens", outputTokens).
Msg("request completed")
} }
for _, h := range []string{"Content-Type", "X-Request-Id"} { for _, h := range []string{"Content-Type", "X-Request-Id"} {
@@ -108,12 +171,18 @@ func handleNonStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, p
c.Data(statusCode, headers.Get("Content-Type"), respBody) c.Data(statusCode, headers.Get("Content-Type"), respBody)
} }
func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool *auth.Pool, cred *auth.Credential, body []byte, originalBody []byte) { func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool *auth.Pool, cred *auth.Credential, body []byte, originalBody []byte, tracker *ratelimit.Tracker) {
startTime := time.Now() startTime := time.Now()
resp, err := upstream.ExecuteStream(c.Request.Context(), cred, body) model := gjson.GetBytes(body, "model").String()
ctx := c.Request.Context()
telemetry.StreamRequests.Add(ctx, 1, metric.WithAttributes(attribute.String("model", model)))
telemetry.RequestBodySize.Record(ctx, int64(len(body)),
metric.WithAttributes(attribute.String("model", model), attribute.Bool("stream", true)))
resp, err := upstream.ExecuteStream(ctx, cred, body)
if err != nil { if err != nil {
latencyMs := float64(time.Since(startTime).Milliseconds()) latencyMs := float64(time.Since(startTime).Milliseconds())
model := gjson.GetBytes(body, "model").String()
log.Error(). log.Error().
Err(err). Err(err).
Str("credential", cred.Email). Str("credential", cred.Email).
@@ -124,6 +193,22 @@ func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool
Int("request_body_size", len(body)). Int("request_body_size", len(body)).
Float64("latency_ms", latencyMs). Float64("latency_ms", latencyMs).
Msg("upstream connection error") Msg("upstream connection error")
telemetry.UpstreamErrors.Add(ctx, 1,
metric.WithAttributes(
attribute.String("error_type", "connection"),
attribute.String("credential", cred.Email),
attribute.Int("status_code", http.StatusBadGateway),
))
telemetry.RequestCounter.Add(ctx, 1,
metric.WithAttributes(
attribute.String("model", model),
attribute.Bool("stream", true),
attribute.Int("status_code", http.StatusBadGateway),
))
telemetry.RequestDuration.Record(ctx, latencyMs,
metric.WithAttributes(attribute.String("model", model), attribute.Bool("stream", true), attribute.Int("status_code", http.StatusBadGateway)))
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream stream request failed"}) c.JSON(http.StatusBadGateway, gin.H{"error": "upstream stream request failed"})
return return
} }
@@ -131,9 +216,10 @@ func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
pool.MarkFailure(cred, resp.StatusCode) pool.MarkFailure(cred, resp.StatusCode)
telemetry.CredentialCooldowns.Add(ctx, 1,
metric.WithAttributes(attribute.Int("status_code", resp.StatusCode)))
respBody, _ := io.ReadAll(resp.Body) respBody, _ := io.ReadAll(resp.Body)
latencyMs := float64(time.Since(startTime).Milliseconds()) latencyMs := float64(time.Since(startTime).Milliseconds())
model := gjson.GetBytes(body, "model").String()
errorType := gjson.GetBytes(respBody, "error.type").String() errorType := gjson.GetBytes(respBody, "error.type").String()
errorMessage := gjson.GetBytes(respBody, "error.message").String() errorMessage := gjson.GetBytes(respBody, "error.message").String()
log.Error(). log.Error().
@@ -151,6 +237,21 @@ func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool
Int("request_body_size", len(body)). Int("request_body_size", len(body)).
Str("request_headers", logging.RedactHeaders(c.Request.Header)). Str("request_headers", logging.RedactHeaders(c.Request.Header)).
Msg("upstream error") Msg("upstream error")
attrs := []attribute.KeyValue{
attribute.String("model", model),
attribute.Bool("stream", true),
attribute.Int("status_code", resp.StatusCode),
}
telemetry.RequestCounter.Add(ctx, 1, metric.WithAttributes(attrs...))
telemetry.RequestDuration.Record(ctx, latencyMs, metric.WithAttributes(attrs...))
telemetry.UpstreamErrors.Add(ctx, 1,
metric.WithAttributes(
attribute.Int("status_code", resp.StatusCode),
attribute.String("error_type", errorType),
attribute.String("credential", cred.Email),
))
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody) c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody)
return return
} }
@@ -169,14 +270,54 @@ func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool
return return
} }
var inputTokens, outputTokens int64
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() { for scanner.Scan() {
line := san.DesanitizeStreamEvent(scanner.Text()) line := san.DesanitizeStreamEvent(scanner.Text())
c.Writer.WriteString(line + "\n") c.Writer.WriteString(line + "\n")
flusher.Flush() flusher.Flush()
// Extract token usage from message_delta event
if len(line) > 5 && line[:5] == "data:" {
data := line[5:]
if gjson.Get(data, "type").String() == "message_delta" {
inputTokens = gjson.Get(data, "usage.input_tokens").Int()
outputTokens = gjson.Get(data, "usage.output_tokens").Int()
}
}
} }
latencyMs := float64(time.Since(startTime).Milliseconds())
attrs := []attribute.KeyValue{
attribute.String("model", model),
attribute.Bool("stream", true),
attribute.Int("status_code", http.StatusOK),
}
telemetry.RequestCounter.Add(ctx, 1, metric.WithAttributes(attrs...))
telemetry.RequestDuration.Record(ctx, latencyMs, metric.WithAttributes(attrs...))
if inputTokens > 0 || outputTokens > 0 {
tokenAttrs := metric.WithAttributes(
attribute.String("model", model),
attribute.String("credential", cred.Email),
)
telemetry.TokensInput.Add(ctx, inputTokens, tokenAttrs)
telemetry.TokensOutput.Add(ctx, outputTokens, tokenAttrs)
if tracker != nil {
tracker.RecordTokens(inputTokens, outputTokens)
tracker.UpdateFromHeaders(resp.Header)
}
}
log.Info().
Float64("latency_ms", latencyMs).
Str("model", model).
Bool("stream", true).
Int64("input_tokens", inputTokens).
Int64("output_tokens", outputTokens).
Msg("stream completed")
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
log.Error().Err(err).Msg("stream scan error") log.Error().Err(err).Msg("stream scan error")
} }
+5
View File
@@ -122,6 +122,11 @@ func SniffClaudeCode(claudeBinary string) (*SniffedProfile, error) {
Int("headers", len(profile.Headers)). Int("headers", len(profile.Headers)).
Int("body_size", len(profile.Body)). Int("body_size", len(profile.Body)).
Msg("sniffed claude-code profile") Msg("sniffed claude-code profile")
for _, h := range profile.Headers {
log.Debug().Str("header", h[0]).Str("value", h[1]).Msg("sniffed header")
}
return profile, nil return profile, nil
} }
+35
View File
@@ -9,8 +9,10 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/fujin/anthropic-proxy/internal/auth" "github.com/fujin/anthropic-proxy/internal/auth"
"github.com/fujin/anthropic-proxy/internal/logging"
) )
const messagesURL = "https://api.anthropic.com/v1/messages?beta=true" const messagesURL = "https://api.anthropic.com/v1/messages?beta=true"
@@ -51,6 +53,15 @@ func (u *UpstreamClient) applyHeaders(req *http.Request, token string, streaming
req.Header.Del("x-api-key") req.Header.Del("x-api-key")
if strings.HasPrefix(token, "sk-ant-oat") { if strings.HasPrefix(token, "sk-ant-oat") {
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
// OAuth tokens require this beta flag — without it the API rejects with 401
existing := req.Header.Get("anthropic-beta")
if !strings.Contains(existing, "oauth-2025-04-20") {
if existing == "" {
req.Header.Set("anthropic-beta", "oauth-2025-04-20")
} else {
req.Header.Set("anthropic-beta", existing+",oauth-2025-04-20")
}
}
} else { } else {
req.Header.Set("x-api-key", token) req.Header.Set("x-api-key", token)
} }
@@ -75,6 +86,12 @@ func (u *UpstreamClient) Execute(ctx context.Context, cred *auth.Credential, bod
} }
u.applyHeaders(req, cred.Token(), false) u.applyHeaders(req, cred.Token(), false)
log.Debug().
Str("url", messagesURL).
Str("upstream_headers", logging.RedactHeaders(req.Header)).
Int("body_size", len(body)).
Msg("upstream request")
resp, err := u.client.Do(req) resp, err := u.client.Do(req)
if err != nil { if err != nil {
return nil, nil, 0, fmt.Errorf("upstream request: %w", err) return nil, nil, 0, fmt.Errorf("upstream request: %w", err)
@@ -85,6 +102,12 @@ func (u *UpstreamClient) Execute(ctx context.Context, cred *auth.Credential, bod
if err != nil { if err != nil {
return nil, nil, resp.StatusCode, fmt.Errorf("read upstream response: %w", err) return nil, nil, resp.StatusCode, fmt.Errorf("read upstream response: %w", err)
} }
log.Debug().
Int("status", resp.StatusCode).
Str("response_headers", logging.RedactHeaders(resp.Header)).
Int("response_size", len(respBody)).
Msg("upstream response")
return respBody, resp.Header, resp.StatusCode, nil return respBody, resp.Header, resp.StatusCode, nil
} }
@@ -97,9 +120,21 @@ func (u *UpstreamClient) ExecuteStream(ctx context.Context, cred *auth.Credentia
} }
u.applyHeaders(req, cred.Token(), true) u.applyHeaders(req, cred.Token(), true)
log.Debug().
Str("url", messagesURL).
Str("upstream_headers", logging.RedactHeaders(req.Header)).
Int("body_size", len(body)).
Msg("upstream stream request")
resp, err := u.client.Do(req) resp, err := u.client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("upstream stream request: %w", err) return nil, fmt.Errorf("upstream stream request: %w", err)
} }
log.Debug().
Int("status", resp.StatusCode).
Str("response_headers", logging.RedactHeaders(resp.Header)).
Msg("upstream stream response")
return resp, nil return resp, nil
} }
+213
View File
@@ -0,0 +1,213 @@
package ratelimit
import (
"context"
"net/http"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog/log"
)
// Window holds per-window usage state.
type Window struct {
Utilization float64 // 0-100 from API
ResetsAt time.Time // when window resets
TokensIn atomic.Int64
TokensOut atomic.Int64
}
// Snapshot is a read-only copy of a Window's state.
type Snapshot struct {
Utilization float64
ResetsAt time.Time
TokensIn int64
TokensOut int64
}
// Tracker polls /api/oauth/usage and tracks per-window token usage.
type Tracker struct {
tokenFn func() string // returns current OAuth access token
mu sync.RWMutex
fiveHour Window
sevenDay Window
sonnet Window
extra ExtraUsage
}
// NewTracker creates a tracker. tokenFn should return the current access token.
func NewTracker(tokenFn func() string) *Tracker {
return &Tracker{tokenFn: tokenFn}
}
// Start begins the background poll loop.
func (t *Tracker) Start(ctx context.Context) {
go func() {
// Poll immediately on start
t.poll(ctx)
for {
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Minute):
t.poll(ctx)
}
}
}()
}
// UpdateFromHeaders extracts rate limit data from /v1/messages response headers.
// This provides real-time utilization updates without polling the usage API.
func (t *Tracker) UpdateFromHeaders(h http.Header) {
t.mu.Lock()
defer t.mu.Unlock()
if v := h.Get("Anthropic-Ratelimit-Unified-5h-Utilization"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
t.fiveHour.Utilization = f * 100 // header is 0-1, we store 0-100
}
}
if v := h.Get("Anthropic-Ratelimit-Unified-5h-Reset"); v != "" {
if ts, err := strconv.ParseInt(v, 10, 64); err == nil {
t.fiveHour.ResetsAt = time.Unix(ts, 0).UTC().Truncate(time.Minute)
}
}
if v := h.Get("Anthropic-Ratelimit-Unified-7d-Utilization"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
t.sevenDay.Utilization = f * 100
}
}
if v := h.Get("Anthropic-Ratelimit-Unified-7d-Reset"); v != "" {
if ts, err := strconv.ParseInt(v, 10, 64); err == nil {
t.sevenDay.ResetsAt = time.Unix(ts, 0).UTC().Truncate(time.Minute)
}
}
}
// RecordTokens adds tokens to all active windows.
func (t *Tracker) RecordTokens(inputTokens, outputTokens int64) {
t.fiveHour.TokensIn.Add(inputTokens)
t.fiveHour.TokensOut.Add(outputTokens)
t.sevenDay.TokensIn.Add(inputTokens)
t.sevenDay.TokensOut.Add(outputTokens)
}
// FiveHour returns a snapshot of the 5-hour window.
func (t *Tracker) FiveHour() Snapshot {
t.mu.RLock()
defer t.mu.RUnlock()
return Snapshot{
Utilization: t.fiveHour.Utilization,
ResetsAt: t.fiveHour.ResetsAt,
TokensIn: t.fiveHour.TokensIn.Load(),
TokensOut: t.fiveHour.TokensOut.Load(),
}
}
// SevenDay returns a snapshot of the 7-day window.
func (t *Tracker) SevenDay() Snapshot {
t.mu.RLock()
defer t.mu.RUnlock()
return Snapshot{
Utilization: t.sevenDay.Utilization,
ResetsAt: t.sevenDay.ResetsAt,
TokensIn: t.sevenDay.TokensIn.Load(),
TokensOut: t.sevenDay.TokensOut.Load(),
}
}
// Sonnet returns a snapshot of the 7-day sonnet window.
func (t *Tracker) Sonnet() Snapshot {
t.mu.RLock()
defer t.mu.RUnlock()
return Snapshot{
Utilization: t.sonnet.Utilization,
ResetsAt: t.sonnet.ResetsAt,
}
}
// Extra returns the current extra usage state.
func (t *Tracker) Extra() ExtraUsage {
t.mu.RLock()
defer t.mu.RUnlock()
return t.extra
}
func (t *Tracker) poll(ctx context.Context) {
token := t.tokenFn()
if token == "" {
return
}
usage, err := fetchUsage(ctx, token)
if err != nil {
log.Warn().Err(err).Msg("usage poll failed")
return
}
t.mu.Lock()
defer t.mu.Unlock()
if usage.FiveHour != nil {
t.updateWindow(&t.fiveHour, usage.FiveHour, "5h")
}
if usage.SevenDay != nil {
t.updateWindow(&t.sevenDay, usage.SevenDay, "7d")
}
if usage.SevenDaySonnet != nil {
t.updateWindow(&t.sonnet, usage.SevenDaySonnet, "7d_sonnet")
}
if usage.ExtraUsage != nil {
t.extra = *usage.ExtraUsage
}
log.Debug().
Float64("5h_util", t.fiveHour.Utilization).
Time("5h_resets", t.fiveHour.ResetsAt).
Int64("5h_tokens_in", t.fiveHour.TokensIn.Load()).
Int64("5h_tokens_out", t.fiveHour.TokensOut.Load()).
Float64("7d_util", t.sevenDay.Utilization).
Time("7d_resets", t.sevenDay.ResetsAt).
Int64("7d_tokens_in", t.sevenDay.TokensIn.Load()).
Int64("7d_tokens_out", t.sevenDay.TokensOut.Load()).
Msg("usage poll")
// Warn on high utilization
if t.fiveHour.Utilization > 80 {
log.Warn().Float64("utilization", t.fiveHour.Utilization).Time("resets_at", t.fiveHour.ResetsAt).Msg("5h window utilization high")
}
if t.sevenDay.Utilization > 80 {
log.Warn().Float64("utilization", t.sevenDay.Utilization).Time("resets_at", t.sevenDay.ResetsAt).Msg("7d window utilization high")
}
}
func (t *Tracker) updateWindow(w *Window, rl *RateLimit, name string) {
if rl.Utilization != nil {
w.Utilization = *rl.Utilization
}
if rl.ResetsAt != nil {
parsed, err := time.Parse(time.RFC3339Nano, *rl.ResetsAt)
if err != nil {
// Fallback to RFC3339 without fractional seconds
parsed, err = time.Parse(time.RFC3339, *rl.ResetsAt)
}
parsed = parsed.UTC().Truncate(time.Minute)
if err == nil && parsed != w.ResetsAt && !w.ResetsAt.IsZero() {
// Window reset detected — zero token counters
log.Info().
Str("window", name).
Int64("prev_tokens_in", w.TokensIn.Load()).
Int64("prev_tokens_out", w.TokensOut.Load()).
Time("old_reset", w.ResetsAt).
Time("new_reset", parsed).
Msg("window reset detected")
w.TokensIn.Store(0)
w.TokensOut.Store(0)
}
if err == nil {
w.ResetsAt = parsed
}
}
}
+62
View File
@@ -0,0 +1,62 @@
package ratelimit
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const usageURL = "https://api.anthropic.com/api/oauth/usage"
type RateLimit struct {
Utilization *float64 `json:"utilization"` // 0-100
ResetsAt *string `json:"resets_at"` // ISO 8601
}
type ExtraUsage struct {
IsEnabled bool `json:"is_enabled"`
MonthlyLimit *float64 `json:"monthly_limit"`
UsedCredits *float64 `json:"used_credits"`
Utilization *float64 `json:"utilization"`
}
type UsageResponse struct {
FiveHour *RateLimit `json:"five_hour"`
SevenDay *RateLimit `json:"seven_day"`
SevenDaySonnet *RateLimit `json:"seven_day_sonnet"`
ExtraUsage *ExtraUsage `json:"extra_usage"`
}
func fetchUsage(ctx context.Context, token string) (*UsageResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("anthropic-beta", "oauth-2025-04-20")
req.Header.Set("User-Agent", "claude-cli/2.1.92")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("usage returned %d: %s", resp.StatusCode, string(body))
}
var usage UsageResponse
if err := json.Unmarshal(body, &usage); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &usage, nil
}
+7 -2
View File
@@ -14,6 +14,8 @@ import (
"github.com/fujin/anthropic-proxy/internal/config" "github.com/fujin/anthropic-proxy/internal/config"
"github.com/fujin/anthropic-proxy/internal/logging" "github.com/fujin/anthropic-proxy/internal/logging"
"github.com/fujin/anthropic-proxy/internal/proxy" "github.com/fujin/anthropic-proxy/internal/proxy"
"github.com/fujin/anthropic-proxy/internal/ratelimit"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
) )
type Server struct { type Server struct {
@@ -24,7 +26,7 @@ type Server struct {
apiKeys atomic.Pointer[map[string]struct{}] apiKeys atomic.Pointer[map[string]struct{}]
} }
func New(cfg *config.Config, pool *auth.Pool, profile *proxy.SniffedProfile) *Server { func New(cfg *config.Config, pool *auth.Pool, profile *proxy.SniffedProfile, tracker *ratelimit.Tracker) *Server {
s := &Server{configPath: "config.yaml"} s := &Server{configPath: "config.yaml"}
san := proxy.NewSanitizer(cfg.Sanitize) san := proxy.NewSanitizer(cfg.Sanitize)
@@ -37,12 +39,15 @@ func New(cfg *config.Config, pool *auth.Pool, profile *proxy.SniffedProfile) *Se
engine := gin.New() engine := gin.New()
engine.Use(gin.Recovery()) engine.Use(gin.Recovery())
engine.Use(corsMiddleware()) engine.Use(corsMiddleware())
if cfg.Telemetry.ExportEnabled() {
engine.Use(otelgin.Middleware(cfg.Telemetry.ServiceName))
}
engine.Use(s.authMiddleware()) engine.Use(s.authMiddleware())
engine.Use(logging.GinRequestLogger()) engine.Use(logging.GinRequestLogger())
handler := proxy.HandleMessages(pool, profile, func() *proxy.Sanitizer { handler := proxy.HandleMessages(pool, profile, func() *proxy.Sanitizer {
return s.sanitizer.Load() return s.sanitizer.Load()
}) }, tracker)
engine.POST("/v1/messages", handler) engine.POST("/v1/messages", handler)
engine.POST("/messages", handler) engine.POST("/messages", handler)
+81
View File
@@ -0,0 +1,81 @@
package telemetry
import (
"context"
"encoding/json"
"time"
otellog "go.opentelemetry.io/otel/log"
sdklog "go.opentelemetry.io/otel/sdk/log"
)
// LogBridge implements io.Writer and forwards zerolog JSON lines to the
// OTel LoggerProvider. It is used as an extra writer in zerolog's MultiWriter
// so that logs go to both file and OTLP.
type LogBridge struct {
provider *sdklog.LoggerProvider
}
func (b *LogBridge) Write(p []byte) (n int, err error) {
var entry map[string]interface{}
if err := json.Unmarshal(p, &entry); err != nil {
return len(p), nil // skip malformed lines
}
logger := b.provider.Logger("zerolog")
var rec otellog.Record
rec.SetTimestamp(time.Now())
if msg, ok := entry["message"].(string); ok {
rec.SetBody(otellog.StringValue(msg))
}
if lvl, ok := entry["level"].(string); ok {
rec.SetSeverity(mapSeverity(lvl))
}
// Forward all fields as attributes
attrs := make([]otellog.KeyValue, 0, len(entry))
for k, v := range entry {
if k == "message" || k == "level" || k == "time" {
continue
}
switch val := v.(type) {
case string:
attrs = append(attrs, otellog.String(k, val))
case float64:
attrs = append(attrs, otellog.Float64(k, val))
case bool:
attrs = append(attrs, otellog.Bool(k, val))
default:
b, _ := json.Marshal(val)
attrs = append(attrs, otellog.String(k, string(b)))
}
}
rec.AddAttributes(attrs...)
logger.Emit(context.Background(), rec)
return len(p), nil
}
func mapSeverity(level string) otellog.Severity {
switch level {
case "trace":
return otellog.SeverityTrace
case "debug":
return otellog.SeverityDebug
case "info":
return otellog.SeverityInfo
case "warn", "warning":
return otellog.SeverityWarn
case "error":
return otellog.SeverityError
case "fatal":
return otellog.SeverityFatal
case "panic":
return otellog.SeverityFatal2
default:
return otellog.SeverityInfo
}
}
+102
View File
@@ -0,0 +1,102 @@
package telemetry
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"github.com/fujin/anthropic-proxy/internal/ratelimit"
)
var (
RequestCounter metric.Int64Counter
RequestDuration metric.Float64Histogram
RequestBodySize metric.Int64Histogram
UpstreamErrors metric.Int64Counter
TokensInput metric.Int64Counter
TokensOutput metric.Int64Counter
CredentialCooldowns metric.Int64Counter
ActiveCredentials metric.Int64UpDownCounter
StreamRequests metric.Int64Counter
)
// InitMetrics creates all metric instruments from the given meter.
// If tracker is non-nil, registers observable gauges for per-window usage.
func InitMetrics(meter metric.Meter, tracker *ratelimit.Tracker) {
RequestCounter, _ = meter.Int64Counter("proxy.request.count",
metric.WithDescription("Total proxy requests"),
)
RequestDuration, _ = meter.Float64Histogram("proxy.request.duration_ms",
metric.WithDescription("Request latency in milliseconds"),
metric.WithUnit("ms"),
)
RequestBodySize, _ = meter.Int64Histogram("proxy.request.body_size_bytes",
metric.WithDescription("Request body size in bytes"),
metric.WithUnit("By"),
)
UpstreamErrors, _ = meter.Int64Counter("proxy.upstream.errors",
metric.WithDescription("Upstream error count"),
)
TokensInput, _ = meter.Int64Counter("proxy.tokens.input",
metric.WithDescription("Input tokens consumed"),
)
TokensOutput, _ = meter.Int64Counter("proxy.tokens.output",
metric.WithDescription("Output tokens consumed"),
)
CredentialCooldowns, _ = meter.Int64Counter("proxy.credential.cooldowns",
metric.WithDescription("Credential cooldown activations"),
)
ActiveCredentials, _ = meter.Int64UpDownCounter("proxy.credential.active",
metric.WithDescription("Currently active (non-cooldown) credentials"),
)
StreamRequests, _ = meter.Int64Counter("proxy.stream.requests",
metric.WithDescription("Streaming request count"),
)
if tracker == nil {
return
}
attr5h := attribute.String("window", "5h")
attr7d := attribute.String("window", "7d")
attrSonnet := attribute.String("window", "7d_sonnet")
meter.Float64ObservableGauge("proxy.usage.utilization",
metric.WithDescription("Current utilization % from API"),
metric.WithFloat64Callback(func(_ context.Context, o metric.Float64Observer) error {
o.Observe(tracker.FiveHour().Utilization, metric.WithAttributes(attr5h))
o.Observe(tracker.SevenDay().Utilization, metric.WithAttributes(attr7d))
o.Observe(tracker.Sonnet().Utilization, metric.WithAttributes(attrSonnet))
return nil
}),
)
meter.Int64ObservableGauge("proxy.usage.resets_at",
metric.WithDescription("Unix seconds when window resets"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(tracker.FiveHour().ResetsAt.Unix(), metric.WithAttributes(attr5h))
o.Observe(tracker.SevenDay().ResetsAt.Unix(), metric.WithAttributes(attr7d))
o.Observe(tracker.Sonnet().ResetsAt.Unix(), metric.WithAttributes(attrSonnet))
return nil
}),
)
meter.Int64ObservableGauge("proxy.usage.window_tokens.input",
metric.WithDescription("Proxy input tokens in current window"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(tracker.FiveHour().TokensIn, metric.WithAttributes(attr5h))
o.Observe(tracker.SevenDay().TokensIn, metric.WithAttributes(attr7d))
return nil
}),
)
meter.Int64ObservableGauge("proxy.usage.window_tokens.output",
metric.WithDescription("Proxy output tokens in current window"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(tracker.FiveHour().TokensOut, metric.WithAttributes(attr5h))
o.Observe(tracker.SevenDay().TokensOut, metric.WithAttributes(attr7d))
return nil
}),
)
}
+108
View File
@@ -0,0 +1,108 @@
package telemetry
import (
"context"
"io"
"github.com/fujin/anthropic-proxy/internal/config"
"github.com/fujin/anthropic-proxy/internal/ratelimit"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
otellog "go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/sdk/log"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
// Setup initializes OpenTelemetry providers. It always creates a MeterProvider
// so metrics can be recorded in-process. When cfg.ExportEnabled(), OTLP gRPC
// exporters are additionally configured to push to the LGTM stack.
// Returns a shutdown function and an optional io.Writer for the log bridge.
func Setup(ctx context.Context, cfg config.TelemetryConfig, tracker *ratelimit.Tracker) (shutdown func(context.Context) error, logWriter io.Writer, err error) {
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(cfg.ServiceName),
),
)
if err != nil {
return nil, nil, err
}
if !cfg.ExportEnabled() {
// No export — set up in-memory meter provider only so metric
// instruments are valid (they just don't export anywhere).
mp := sdkmetric.NewMeterProvider(sdkmetric.WithResource(res))
otel.SetMeterProvider(mp)
InitMetrics(mp.Meter(cfg.ServiceName), tracker)
return func(ctx context.Context) error { return mp.Shutdown(ctx) }, nil, nil
}
// Build exporter options
traceOpts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.Endpoint)}
metricOpts := []otlpmetricgrpc.Option{
otlpmetricgrpc.WithEndpoint(cfg.Endpoint),
otlpmetricgrpc.WithTemporalitySelector(sdkmetric.CumulativeTemporalitySelector),
}
logOpts := []otlploggrpc.Option{otlploggrpc.WithEndpoint(cfg.Endpoint)}
if cfg.Insecure {
traceOpts = append(traceOpts, otlptracegrpc.WithInsecure())
metricOpts = append(metricOpts, otlpmetricgrpc.WithInsecure())
logOpts = append(logOpts, otlploggrpc.WithInsecure())
}
// Trace exporter
traceExp, err := otlptracegrpc.New(ctx, traceOpts...)
if err != nil {
return nil, nil, err
}
tp := trace.NewTracerProvider(
trace.WithBatcher(traceExp),
trace.WithResource(res),
)
otel.SetTracerProvider(tp)
// Metric exporter
metricExp, err := otlpmetricgrpc.New(ctx, metricOpts...)
if err != nil {
return nil, nil, err
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)),
sdkmetric.WithResource(res),
)
otel.SetMeterProvider(mp)
InitMetrics(mp.Meter(cfg.ServiceName), tracker)
// Log exporter
logExp, err := otlploggrpc.New(ctx, logOpts...)
if err != nil {
return nil, nil, err
}
lp := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(logExp)),
log.WithResource(res),
)
otellog.SetLoggerProvider(lp)
bridge := &LogBridge{provider: lp}
shutdownFn := func(ctx context.Context) error {
var firstErr error
if e := tp.Shutdown(ctx); e != nil && firstErr == nil {
firstErr = e
}
if e := mp.Shutdown(ctx); e != nil && firstErr == nil {
firstErr = e
}
if e := lp.Shutdown(ctx); e != nil && firstErr == nil {
firstErr = e
}
return firstErr
}
return shutdownFn, bridge, nil
}
+29 -2
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -13,7 +14,9 @@ import (
"github.com/fujin/anthropic-proxy/internal/config" "github.com/fujin/anthropic-proxy/internal/config"
"github.com/fujin/anthropic-proxy/internal/logging" "github.com/fujin/anthropic-proxy/internal/logging"
"github.com/fujin/anthropic-proxy/internal/proxy" "github.com/fujin/anthropic-proxy/internal/proxy"
"github.com/fujin/anthropic-proxy/internal/ratelimit"
"github.com/fujin/anthropic-proxy/internal/server" "github.com/fujin/anthropic-proxy/internal/server"
"github.com/fujin/anthropic-proxy/internal/telemetry"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -23,6 +26,27 @@ func run() error {
return fmt.Errorf("load config: %w", err) return fmt.Errorf("load config: %w", err)
} }
// Create usage tracker (started later once credential is loaded)
var credForTracker *auth.Credential
tracker := ratelimit.NewTracker(func() string {
if credForTracker == nil {
return ""
}
return credForTracker.Token()
})
// Initialize telemetry (metrics always active; OTLP export when endpoint set)
telemetryShutdown, logBridge, err := telemetry.Setup(context.Background(), cfg.Telemetry, tracker)
if err != nil {
return fmt.Errorf("telemetry setup: %w", err)
}
defer telemetryShutdown(context.Background())
var extraWriters []io.Writer
if logBridge != nil {
extraWriters = append(extraWriters, logBridge)
}
logging.Setup(logging.Config{ logging.Setup(logging.Config{
Level: cfg.Logging.Level, Level: cfg.Logging.Level,
File: cfg.Logging.File, File: cfg.Logging.File,
@@ -30,7 +54,7 @@ func run() error {
MaxBackups: cfg.Logging.MaxBackups, MaxBackups: cfg.Logging.MaxBackups,
MaxAgeDays: cfg.Logging.MaxAgeDays, MaxAgeDays: cfg.Logging.MaxAgeDays,
Compress: cfg.Logging.Compress, Compress: cfg.Logging.Compress,
}) }, extraWriters...)
// Load credentials from ~/.claude/.credentials.json // Load credentials from ~/.claude/.credentials.json
creds, err := config.LoadDefaultCredentials() creds, err := config.LoadDefaultCredentials()
@@ -71,6 +95,8 @@ func run() error {
log.Info().Str("credential", cred.Email).Msg("credential loaded") log.Info().Str("credential", cred.Email).Msg("credential loaded")
credForTracker = cred
pool := auth.NewPool([]*auth.Credential{cred}) pool := auth.NewPool([]*auth.Credential{cred})
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -78,6 +104,7 @@ func run() error {
pool.RefreshExpiring(context.Background()) pool.RefreshExpiring(context.Background())
auth.StartBackgroundRefresh(ctx, pool) auth.StartBackgroundRefresh(ctx, pool)
tracker.Start(ctx)
var profile *proxy.SniffedProfile var profile *proxy.SniffedProfile
if cfg.ClaudeBinary != "" { if cfg.ClaudeBinary != "" {
@@ -89,7 +116,7 @@ func run() error {
} }
log.Info().Int("port", cfg.Port).Msg("starting server") log.Info().Int("port", cfg.Port).Msg("starting server")
srv := server.New(cfg, pool, profile) srv := server.New(cfg, pool, profile, tracker)
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+7 -2
View File
@@ -1,4 +1,9 @@
{ buildGoModule, lib, pkgs, ... }: {
buildGoModule,
lib,
pkgs,
...
}:
buildGoModule rec { buildGoModule rec {
pname = "anthropic-proxy"; pname = "anthropic-proxy";
@@ -6,7 +11,7 @@ buildGoModule rec {
src = ./.; src = ./.;
vendorHash = "sha256-QUb/DIX3x/MBZd3srF0hV2x/o0wf7zNzj2SEhTIpq58="; vendorHash = "sha256-8pq4GYFjOfYcYLcZSuXMWn77RUxVGP18AcyzIJGbKf4=";
meta = with lib; { meta = with lib; {
description = "Reverse proxy that lets OpenCode (and similar tools) use a Claude subscription instead of an API key."; description = "Reverse proxy that lets OpenCode (and similar tools) use a Claude subscription instead of an API key.";