diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c2e5d3a..d4aecac6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: go vet ./... - name: Audit Go dependencies - run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./... - name: Run Go vet run: go vet ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58e31d81..30f1c54c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -174,7 +174,7 @@ jobs: go vet ./... - name: Audit Go dependencies - run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./... - name: Run Go vet run: go vet ./... diff --git a/Dockerfile b/Dockerfile index e4201a95..4ba310ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ COPY web ./web RUN pnpm --dir web run build -FROM --platform=$BUILDPLATFORM golang:1.26.6-alpine3.24@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df AS go-builder +FROM --platform=$BUILDPLATFORM golang:1.27.0-alpine3.24@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS go-builder ARG VERSION=2.0.0-dev ARG TARGETOS diff --git a/Makefile b/Makefile index ec63d0c5..4f7d0805 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ test: ## Run Go unit tests .PHONY: check check: _web-deps ## Run source checks and build - @formatted_files="$$(gofmt -l .)"; test -z "$${formatted_files}" + @go_root="$$($(GO) env GOROOT)"; formatted_files="$$("$${go_root}/bin/gofmt" -l .)"; test -z "$${formatted_files}" $(GO) mod tidy -diff $(GO) vet ./... $(PNPM) --dir $(WEB_DIR) run lint diff --git a/README.md b/README.md index 342b5524..93315d6a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ English · [中文](README_CN.md) · [日本語](README_JP.md) [![Release](https://img.shields.io/github/v/release/tbphp/gpt-load)](https://github.com/tbphp/gpt-load/releases) [![Docker](https://img.shields.io/badge/Docker-ghcr.io%2Ftbphp%2Fgpt--load%3A2-2496ED?logo=docker&logoColor=white)](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load) -[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](go.mod) +[![Go](https://img.shields.io/badge/Go-1.27-00ADD8?logo=go&logoColor=white)](go.mod) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) tbphp/gpt-load | Trendshift diff --git a/README_CN.md b/README_CN.md index 17b90596..c8f15889 100644 --- a/README_CN.md +++ b/README_CN.md @@ -12,7 +12,7 @@ [![Release](https://img.shields.io/github/v/release/tbphp/gpt-load)](https://github.com/tbphp/gpt-load/releases) [![Docker](https://img.shields.io/badge/Docker-ghcr.io%2Ftbphp%2Fgpt--load%3A2-2496ED?logo=docker&logoColor=white)](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load) -[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](go.mod) +[![Go](https://img.shields.io/badge/Go-1.27-00ADD8?logo=go&logoColor=white)](go.mod) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) tbphp/gpt-load | Trendshift diff --git a/README_JP.md b/README_JP.md index ec43c281..10110208 100644 --- a/README_JP.md +++ b/README_JP.md @@ -12,7 +12,7 @@ API キー、サブスクリプションアカウント、トラフィック制 [![Release](https://img.shields.io/github/v/release/tbphp/gpt-load)](https://github.com/tbphp/gpt-load/releases) [![Docker](https://img.shields.io/badge/Docker-ghcr.io%2Ftbphp%2Fgpt--load%3A2-2496ED?logo=docker&logoColor=white)](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load) -[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](go.mod) +[![Go](https://img.shields.io/badge/Go-1.27-00ADD8?logo=go&logoColor=white)](go.mod) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) tbphp/gpt-load | Trendshift diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b1016abd..bfc000f1 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,7 +8,7 @@ SBOM (`bom.cdx.json`) inventorying the resolved Go module graph. ## Bifrost Core - Module: `github.com/maximhq/bifrost/core` -- Version: `v1.7.11` +- Version: `v1.8.4` - Copyright: 2025 H3 Labs Inc. - License: Apache License 2.0 @@ -23,7 +23,7 @@ The complete Apache License 2.0 text is distributed in ## CLIProxyAPI - Module: `github.com/router-for-me/CLIProxyAPI/v7` -- Version: `v7.2.135` +- Version: `v7.2.143` - Copyright: 2025-2005.9 Luis Pater; 2025.9-present Router-For.ME - License: MIT License diff --git a/go.mod b/go.mod index 652cccdc..423220d2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module gpt-load -go 1.26.6 +go 1.27.0 require ( github.com/andybalholm/brotli v1.2.2 @@ -8,11 +8,13 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.19.2 - github.com/maximhq/bifrost/core v1.7.11 + github.com/maximhq/bifrost/core v1.8.4 github.com/nicksnyder/go-i18n/v2 v2.6.1 + github.com/router-for-me/CLIProxyAPI/v7/gptload-embedded v0.0.0 github.com/sirupsen/logrus v1.9.4 go.uber.org/dig v1.19.0 golang.org/x/crypto v0.54.0 + golang.org/x/net v0.57.0 golang.org/x/sys v0.47.0 golang.org/x/text v0.40.0 gorm.io/driver/mysql v1.6.0 @@ -20,19 +22,6 @@ require ( gorm.io/gorm v1.31.2 ) -require ( - github.com/dlclark/regexp2/v2 v2.5.1 // indirect - github.com/gorilla/websocket v1.5.3 // indirect - github.com/pierrec/xxHash v0.1.5 // indirect - github.com/redis/go-redis/v9 v9.19.0 // indirect - github.com/refraction-networking/utls v1.8.2 // indirect - github.com/router-for-me/CLIProxyAPI/v7 v7.2.135 // indirect - github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect - github.com/tiktoken-go/tokenizer v0.8.1 // indirect - go.uber.org/atomic v1.11.0 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect -) - require ( cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect @@ -63,10 +52,11 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.2.0 // indirect github.com/bytedance/gopkg v0.1.4 // indirect - github.com/bytedance/sonic v1.15.2 // indirect - github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/bytedance/sonic v1.15.3-0.20260730064818-2a36d6da63e2 // indirect + github.com/bytedance/sonic/loader v0.5.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.7 // indirect + github.com/dlclark/regexp2/v2 v2.5.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fasthttp/websocket v1.5.12 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect @@ -80,6 +70,7 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -99,18 +90,23 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/xxHash v0.1.5 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.1 // indirect + github.com/redis/go-redis/v9 v9.19.0 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/router-for-me/CLIProxyAPI/v7/gptload-embedded v0.0.0 + github.com/router-for-me/CLIProxyAPI/v7 v7.2.143 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 // indirect + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/tiktoken-go/tokenizer v0.8.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect @@ -119,11 +115,12 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/net v0.57.0 golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index b345ad32..80afcccc 100644 --- a/go.sum +++ b/go.sum @@ -68,10 +68,10 @@ github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6 github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= -github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= -github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= -github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= -github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.3-0.20260730064818-2a36d6da63e2 h1:XTVMfgtmKUmmdvQux2e0MjKVqbaJ7Yw8ngYXw5S6zYY= +github.com/bytedance/sonic v1.15.3-0.20260730064818-2a36d6da63e2/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc= +github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= +github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= @@ -174,8 +174,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/maximhq/bifrost/core v1.7.11 h1:9dhHNM9IN3DPM7q+Xhg06LW+WGITz9d9m0RyeVHOQ4Y= -github.com/maximhq/bifrost/core v1.7.11/go.mod h1:ftRe80M+N8GTP7OvEG2G4yNK0jr/s/T4XFSA/YuuOTA= +github.com/maximhq/bifrost/core v1.8.4 h1:GWvGPxsC56xP8hCa5f/wKOZdilhd1eh91zxu5jYZ5xk= +github.com/maximhq/bifrost/core v1.8.4/go.mod h1:mGZ8gaIiJsZkLkncbBjS2zImMN2tZRp2cq1MpiI5SLA= 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= @@ -207,8 +207,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= -github.com/router-for-me/CLIProxyAPI/v7 v7.2.135 h1:Vzc5xJwNWq319t5++7AXCx/CnQ3U94xOjteeFtusSsk= -github.com/router-for-me/CLIProxyAPI/v7 v7.2.135/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ= +github.com/router-for-me/CLIProxyAPI/v7 v7.2.143 h1:bBIxyHsq8WLuVwliWJQ/fYixzXXZs64DpElgTx1f4xk= +github.com/router-for-me/CLIProxyAPI/v7 v7.2.143/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= diff --git a/internal/dialect/openai.go b/internal/dialect/openai.go index ea9168e9..27292475 100644 --- a/internal/dialect/openai.go +++ b/internal/dialect/openai.go @@ -29,7 +29,10 @@ func (d *OpenAI) InspectRequest(req *ParsedRequest) (RequestMetadata, error) { } metadata.ObserveUsage = true metadata.AffinityPrefix = inspectPromptAffinityPrefix(d.Protocol(), req.Body) - metadata.PricingMode, metadata.UsageDiagnostics = openAIRequestPricing(req.Body) + metadata.PricingMode, metadata.UsageDiagnostics, err = openAIRequestPricing(req.Body) + if err != nil { + return RequestMetadata{}, fmt.Errorf("inspect %s request pricing: %w", d.Protocol(), err) + } metadata.Reasoning = inspectOpenAICompletionsReasoning(req.Body) metadata.Operation, metadata.RouteRequirement = chatExecutionMetadata( d.Protocol(), diff --git a/internal/dialect/openai_pricing_modes.go b/internal/dialect/openai_pricing_modes.go index 1847e2c1..34f070e9 100644 --- a/internal/dialect/openai_pricing_modes.go +++ b/internal/dialect/openai_pricing_modes.go @@ -2,24 +2,34 @@ package dialect import ( "encoding/json" + "fmt" "strings" "gpt-load/internal/pricing" "gpt-load/internal/usage" ) -func openAIRequestPricing(body []byte) (pricing.Mode, usage.Diagnostics) { +func openAIRequestPricing(body []byte) (pricing.Mode, usage.Diagnostics, error) { diagnostics := usage.Diagnostics{} root, err := decodeJSONObject(body) if err != nil { - return "", diagnostics + return "", diagnostics, nil + } + for field := range root { + if strings.EqualFold(field, "service_tier") && field != "service_tier" { + return "", diagnostics, fmt.Errorf("service_tier field must use lowercase service_tier") + } + } + if value, ok := jsonString(root, "service_tier"); ok && + strings.EqualFold(strings.TrimSpace(value), "ultrafast") { + return "", diagnostics, fmt.Errorf("service_tier ultrafast is not supported") } mode, supported := openAIRequestedPricingMode(root) if !supported || jsonStringEquals(root, "speed", "fast") || openAIUnsupportedReasoningMode(root) { diagnostics.Add(usage.DiagnosticUnsupportedBillableDetail) } - return mode, diagnostics + return mode, diagnostics, nil } func openAIRequestedPricingMode(root map[string]json.RawMessage) (pricing.Mode, bool) { diff --git a/internal/dialect/openai_responses.go b/internal/dialect/openai_responses.go index 4036f309..6f8c8083 100644 --- a/internal/dialect/openai_responses.go +++ b/internal/dialect/openai_responses.go @@ -57,7 +57,12 @@ func (d *OpenAIResponses) InspectRequest(req *ParsedRequest) (RequestMetadata, e (req.Path == openAIResponsesPath || req.Path == openAIResponsesCompactPath) if len(req.Body) > 0 { metadata.AffinityPrefix = inspectPromptAffinityPrefix(d.Protocol(), req.Body) - metadata.PricingMode, metadata.UsageDiagnostics = openAIRequestPricing(req.Body) + pricingMode, diagnostics, err := openAIRequestPricing(req.Body) + if err != nil { + return RequestMetadata{}, fmt.Errorf("inspect %s request pricing: %w", d.Protocol(), err) + } + metadata.PricingMode = pricingMode + metadata.UsageDiagnostics = diagnostics metadata.Reasoning = inspectOpenAIResponsesReasoning(req.Body) } metadata.Operation, metadata.RouteRequirement = responsesExecutionMetadata(req) diff --git a/internal/execution/bifrost/compatible_matrix_test.go b/internal/execution/bifrost/compatible_matrix_test.go index 5bba4186..6037ceaa 100644 --- a/internal/execution/bifrost/compatible_matrix_test.go +++ b/internal/execution/bifrost/compatible_matrix_test.go @@ -246,9 +246,13 @@ func TestOpenAICompatibleNonV1PrefixKeepsListModelsAndProbeFunctional(t *testing body, err := io.ReadAll(request.Body) if err != nil { t.Errorf("read probe body: %v", err) - } else if !bytes.Contains(body, []byte(`"max_tokens": 1`)) || - bytes.Contains(body, []byte(`"max_completion_tokens"`)) { - t.Errorf("compatible probe token limit = %s, want legacy max_tokens only", body) + } else { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + t.Errorf("decode probe body: %v", err) + } else if string(payload["max_tokens"]) != "1" || payload["max_completion_tokens"] != nil { + t.Errorf("compatible probe token limit = %s, want legacy max_tokens only", body) + } } _, _ = io.WriteString(writer, `{"id":"chat_1","object":"chat.completion","created":1,"model":"served","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) default: diff --git a/internal/execution/bifrost/converted_protocols_test.go b/internal/execution/bifrost/converted_protocols_test.go index 86953fc2..78cc0369 100644 --- a/internal/execution/bifrost/converted_protocols_test.go +++ b/internal/execution/bifrost/converted_protocols_test.go @@ -912,6 +912,49 @@ func TestConvertedResponsesStreamHonorsCancellationAndUpstreamError(t *testing.T } assertNoPrivateLeak(t, result, testAPIKey, "gptload-custom-") }) + + t.Run("HTTP 200 response failed event", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/responses" { + t.Errorf("request path = %q, want /v1/responses", request.URL.Path) + } + _, _ = io.Copy(io.Discard, request.Body) + writer.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(writer, + "data: {\"type\":\"response.in_progress\",\"sequence_number\":0,\"response\":{\"id\":\"resp_failed\",\"object\":\"response\",\"created_at\":123,\"status\":\"in_progress\",\"model\":\"gpt-upstream\",\"output\":[]}}\n\n"+ + "data: {\"type\":\"response.failed\",\"sequence_number\":1,\"response\":{\"id\":\"resp_failed\",\"object\":\"response\",\"created_at\":123,\"status\":\"failed\",\"model\":\"gpt-upstream\",\"output\":[],\"error\":{\"type\":\"invalid_request_error\",\"code\":\"context_length_exceeded\",\"message\":\"input is too large\"}}}\n\n", + ) + })) + defer server.Close() + + runtime := newProtocolTestRuntime(t, testRuntimeOptions{allowPrivateNetwork: true, openAIBaseURL: server.URL}) + spec := convertedSpec( + channel.OpenAI, + protocol.Anthropic, + execution.OperationChatCompletion, + "/v1/messages", + []byte(`{"model":"client-model","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`), + ) + var events []execution.StreamEvent + result := runtime.ExecuteStream(context.Background(), spec, func(event execution.StreamEvent) error { + events = append(events, event.Clone()) + return nil + }) + + if result.DispatchState != execution.DispatchMaybeSent || !result.ResponseStarted || + result.StatusCode != http.StatusOK || result.Error == nil || + result.Error.Type != "invalid_request_error" || + result.Error.Code != "context_length_exceeded" || + result.Error.Summary != "input is too large" || + result.Error.Hint != execution.FailureHintRequestRejected || + result.Error.OriginHint != execution.ErrorOriginClient || + result.Error.ScopeHint != execution.ErrorScopeRequest { + t.Fatalf("response.failed result = %+v evidence=%+v events=%+v", result, result.Error, events) + } + if len(events) != 1 || events[0].Kind != execution.StreamEventReady { + t.Fatalf("response.failed events = %+v, want ready metadata only", events) + } + }) } func convertedSpec(channelID channel.ID, clientProtocol protocol.Protocol, operation execution.Operation, path string, body []byte) execution.AttemptSpec { diff --git a/internal/execution/bifrost/list_models.go b/internal/execution/bifrost/list_models.go index fed0412f..e9ea105f 100644 --- a/internal/execution/bifrost/list_models.go +++ b/internal/execution/bifrost/list_models.go @@ -103,10 +103,31 @@ func sanitizeListModelsResponse(provider schemas.ModelProvider, response *schema clone := *response clone.Data = append([]schemas.Model(nil), response.Data...) prefix := string(provider) + "/" - for index := range clone.Data { - clone.Data[index].ID = strings.TrimPrefix(clone.Data[index].ID, prefix) + filtered := clone.Data[:0] + for _, model := range clone.Data { + if provider == schemas.OpenRouter && embeddingOnlyOutputModel(model) { + continue + } + model.ID = strings.TrimPrefix(model.ID, prefix) + filtered = append(filtered, model) } + clear(clone.Data[len(filtered):]) + clone.Data = filtered clone.ExtraFields = schemas.BifrostResponseExtraFields{} clone.KeyStatuses = nil return &clone } + +func embeddingOnlyOutputModel(model schemas.Model) bool { + if model.Architecture == nil || len(model.Architecture.OutputModalities) == 0 { + return false + } + for _, modality := range model.Architecture.OutputModalities { + switch strings.ToLower(strings.TrimSpace(modality)) { + case "embedding", "embeddings": + default: + return false + } + } + return true +} diff --git a/internal/execution/bifrost/list_models_test.go b/internal/execution/bifrost/list_models_test.go new file mode 100644 index 00000000..2dbe4150 --- /dev/null +++ b/internal/execution/bifrost/list_models_test.go @@ -0,0 +1,44 @@ +package bifrost + +import ( + "reflect" + "testing" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestSanitizeListModelsResponseFiltersOnlyOpenRouterEmbeddingOnlyModels(t *testing.T) { + t.Parallel() + + response := &schemas.BifrostListModelsResponse{Data: []schemas.Model{ + {ID: "openrouter/text"}, + {ID: "openrouter/embedding-singular", Architecture: &schemas.Architecture{OutputModalities: []string{" Embedding "}}}, + {ID: "openrouter/embedding-plural", Architecture: &schemas.Architecture{OutputModalities: []string{"EMBEDDINGS"}}}, + {ID: "openrouter/mixed", Architecture: &schemas.Architecture{OutputModalities: []string{"text", "embeddings"}}}, + {ID: "openrouter/unknown", Architecture: &schemas.Architecture{}}, + }} + + got := sanitizeListModelsResponse(schemas.OpenRouter, response) + gotIDs := make([]string, 0, len(got.Data)) + for _, model := range got.Data { + gotIDs = append(gotIDs, model.ID) + } + if want := []string{"text", "mixed", "unknown"}; !reflect.DeepEqual(gotIDs, want) { + t.Fatalf("sanitized OpenRouter models = %v, want %v", gotIDs, want) + } + if response.Data[0].ID != "openrouter/text" || len(response.Data) != 5 { + t.Fatalf("sanitize mutated source response: %#v", response.Data) + } + for index, model := range got.Data[len(got.Data):cap(got.Data)] { + if !reflect.DeepEqual(model, schemas.Model{}) { + t.Fatalf("filtered model tail at index %d was retained: %#v", index, model) + } + } + + other := sanitizeListModelsResponse(schemas.OpenAI, &schemas.BifrostListModelsResponse{Data: []schemas.Model{{ + ID: "openai/embedding-only", Architecture: &schemas.Architecture{OutputModalities: []string{"embeddings"}}, + }}}) + if len(other.Data) != 1 || other.Data[0].ID != "embedding-only" { + t.Fatalf("non-OpenRouter embedding model was filtered: %#v", other.Data) + } +} diff --git a/internal/execution/bifrost/mapping.go b/internal/execution/bifrost/mapping.go index 931cf021..20f9516c 100644 --- a/internal/execution/bifrost/mapping.go +++ b/internal/execution/bifrost/mapping.go @@ -311,6 +311,12 @@ func neutralFailureHint(status int, values ...string) execution.FailureHint { "authentication failed", "invalid credential", "api key not valid"): return execution.FailureHintInvalidCredential + case containsAnyMarker(markers, + "invalid_request_error", "context_length_exceeded"): + return execution.FailureHintRequestRejected + case containsAnyMarker(markers, + "server_is_overloaded", "service_unavailable", "server overloaded"): + return execution.FailureHintHostError case status >= http.StatusInternalServerError && status <= 599: return execution.FailureHintHostError default: @@ -339,6 +345,7 @@ func annotateBifrostErrorEvidence(evidence *execution.ErrorEvidence) { execution.FailureHintReauthorizationRequired: evidence.ScopeHint = execution.ErrorScopeCredential case execution.FailureHintRequestRejected: + evidence.OriginHint = execution.ErrorOriginClient evidence.ScopeHint = execution.ErrorScopeRequest case execution.FailureHintCandidateUnavailable, execution.FailureHintModelUnavailable: diff --git a/internal/execution/bifrost/mapping_test.go b/internal/execution/bifrost/mapping_test.go index ff926cd8..91890d27 100644 --- a/internal/execution/bifrost/mapping_test.go +++ b/internal/execution/bifrost/mapping_test.go @@ -97,6 +97,57 @@ func TestPassthroughHTTPErrorProducesNeutralFailureHints(t *testing.T) { } } +func TestSDKInBandResponseErrorsProduceNeutralFailureHints(t *testing.T) { + tests := []struct { + name string + errorType string + code string + wantHint execution.FailureHint + wantOrigin execution.ErrorOrigin + wantScope execution.ErrorScope + }{ + { + name: "request rejected", + errorType: "invalid_request_error", + code: "context_length_exceeded", + wantHint: execution.FailureHintRequestRejected, + wantOrigin: execution.ErrorOriginClient, + wantScope: execution.ErrorScopeRequest, + }, + { + name: "server overloaded", + errorType: "server_error", + code: "server_is_overloaded", + wantHint: execution.FailureHintHostError, + wantOrigin: execution.ErrorOriginUpstream, + wantScope: execution.ErrorScopeGroup, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := streamErrorResult( + &schemas.BifrostError{Error: &schemas.ErrorField{ + Type: schemas.Ptr(test.errorType), Code: schemas.Ptr(test.code), Message: "safe provider error", + }}, + schemas.NewBifrostContext(context.Background(), schemas.NoDeadline), + nil, + true, + http.StatusOK, + nil, + "model", + nil, + ) + if result.DispatchState != execution.DispatchMaybeSent || !result.ResponseStarted || + result.StatusCode != http.StatusOK || result.Error == nil || + result.Error.Type != test.errorType || result.Error.Code != test.code || + result.Error.Hint != test.wantHint || result.Error.OriginHint != test.wantOrigin || + result.Error.ScopeHint != test.wantScope { + t.Fatalf("stream error result = %+v", result) + } + }) + } +} + func TestPassthroughHTTPErrorRetainsSanitizedErrorMessage(t *testing.T) { const secret = "provider-secret-value" evidence := passthroughHTTPError( diff --git a/internal/execution/bifrost/runtime_manager_test.go b/internal/execution/bifrost/runtime_manager_test.go index 4aa30015..f9aa5fa6 100644 --- a/internal/execution/bifrost/runtime_manager_test.go +++ b/internal/execution/bifrost/runtime_manager_test.go @@ -1161,13 +1161,20 @@ func TestProductionRuntimeManagerUsesNativeProviderListModelsPaths(t *testing.T) var calls atomic.Int64 server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { calls.Add(1) + writer.Header().Set("Content-Type", "application/json") + if test.channelID == channel.OpenRouter && request.URL.Path == "/v1/embeddings/models" { + if request.URL.RawQuery != "" { + t.Errorf("embedding models query = %q, want empty", request.URL.RawQuery) + } + _, _ = io.WriteString(writer, `{"object":"list","data":[{"id":"embedding-only","architecture":{"output_modalities":["embeddings"]}}]}`) + return + } if request.URL.Path != test.wantPath { t.Errorf("path = %q, want %q", request.URL.Path, test.wantPath) } if request.URL.RawQuery != "cursor=%2F&limit=10" { t.Errorf("query = %q", request.URL.RawQuery) } - writer.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(writer, `{"object":"list","data":[{"id":"model-one","object":"model"}]}`) })) defer server.Close() @@ -1187,7 +1194,12 @@ func TestProductionRuntimeManagerUsesNativeProviderListModelsPaths(t *testing.T) if validationErr := result.Validate(); validationErr != nil || result.Error != nil { t.Fatalf("Execute() = %+v validation=%v", result, validationErr) } - if calls.Load() != 1 || !bytes.Contains(result.Body, []byte(`"id":"model-one"`)) { + wantCalls := int64(1) + if test.channelID == channel.OpenRouter { + wantCalls = 2 + } + if calls.Load() != wantCalls || !bytes.Contains(result.Body, []byte(`"id":"model-one"`)) || + bytes.Contains(result.Body, []byte(`"id":"embedding-only"`)) { t.Fatalf("calls/body = %d/%s", calls.Load(), result.Body) } }) diff --git a/internal/gateway/handler_test.go b/internal/gateway/handler_test.go index c860803f..02223115 100644 --- a/internal/gateway/handler_test.go +++ b/internal/gateway/handler_test.go @@ -1422,6 +1422,49 @@ func TestHandlerRejectsCaseCollidingModelBeforeAttempt(t *testing.T) { } } +func TestHandlerRejectsUltrafastServiceTierBeforeAttempt(t *testing.T) { + for _, test := range []struct { + name string + path string + body string + }{ + {name: "chat completions", path: "/v1/chat/completions", body: `{"model":"gpt-4o","service_tier":"ultrafast"}`}, + {name: "chat completions uppercase", path: "/v1/chat/completions", body: `{"model":"gpt-4o","SERVICE_TIER":"ultrafast"}`}, + {name: "chat completions collision", path: "/v1/chat/completions", body: `{"model":"gpt-4o","service_tier":"default","SERVICE_TIER":"ultrafast"}`}, + {name: "responses", path: "/v1/responses", body: `{"model":"gpt-4o","service_tier":"ultrafast"}`}, + {name: "responses uppercase", path: "/v1/responses", body: `{"model":"gpt-4o","SERVICE_TIER":"ultrafast"}`}, + {name: "responses collision", path: "/v1/responses", body: `{"model":"gpt-4o","service_tier":"default","SERVICE_TIER":"ultrafast"}`}, + } { + t.Run(test.name, func(t *testing.T) { + forwarder := &scriptedForwarder{} + handler, _, _ := newHandlerForTest(t, forwarder, "sk-one") + handler.dialects = dialect.NewSet( + dialect.NewOpenAI(), + dialect.NewOpenAIResponses(), + ) + engine := gin.New() + bindGatewayRoutesForTest(t, engine, handler) + + request := httptest.NewRequest( + http.MethodPost, + test.path, + bytes.NewBufferString(test.body), + ) + request.Header.Set("Authorization", "Bearer gl-client") + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusBadRequest || + !strings.Contains(recorder.Body.String(), `"code":"invalid_protocol_request"`) { + t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String()) + } + if len(forwarder.inputs)+len(forwarder.streamInputs) != 0 { + t.Fatal("ultrafast request reached upstream") + } + }) + } +} + func TestHandlerEnforcesModelUTF8ByteLimitBeforeAttempt(t *testing.T) { tests := []struct { name string diff --git a/internal/subscription/runtime/runtime_test.go b/internal/subscription/runtime/runtime_test.go index ea1c017f..03908b31 100644 --- a/internal/subscription/runtime/runtime_test.go +++ b/internal/subscription/runtime/runtime_test.go @@ -48,22 +48,22 @@ func (testBrowserDriver) LocalCallback() (LocalCallbackSpec, bool) { return Loc func completeTestRuntimeImplementations() ([]Driver, []ModelDiscovery, []QuotaObservation, []ResetCreditAction) { return []Driver{ - testBrowserDriver{duplicateDriver{id: modules.CodexSubscriptionDriver}}, - testBrowserDriver{duplicateDriver{id: modules.ClaudeSubscriptionDriver}}, - testBrowserDriver{duplicateDriver{id: modules.AntigravitySubscriptionDriver}}, - duplicateDriver{id: modules.GrokSubscriptionDriver}, - }, []ModelDiscovery{ - testDiscovery{id: modules.CodexModelDiscovery}, - testDiscovery{id: modules.ClaudeModelDiscovery}, - testDiscovery{id: modules.AntigravityModelDiscovery}, - testDiscovery{id: modules.GrokModelDiscovery}, - }, []QuotaObservation{ - testObservation{id: modules.CodexQuotaObservation}, - testObservation{id: modules.ClaudeQuotaObservation}, - testObservation{id: modules.AntigravityQuotaObservation}, - }, []ResetCreditAction{ - testResetCredit{id: modules.CodexResetCreditAction}, - } + testBrowserDriver{duplicateDriver{id: modules.CodexSubscriptionDriver}}, + testBrowserDriver{duplicateDriver{id: modules.ClaudeSubscriptionDriver}}, + testBrowserDriver{duplicateDriver{id: modules.AntigravitySubscriptionDriver}}, + duplicateDriver{id: modules.GrokSubscriptionDriver}, + }, []ModelDiscovery{ + testDiscovery{id: modules.CodexModelDiscovery}, + testDiscovery{id: modules.ClaudeModelDiscovery}, + testDiscovery{id: modules.AntigravityModelDiscovery}, + testDiscovery{id: modules.GrokModelDiscovery}, + }, []QuotaObservation{ + testObservation{id: modules.CodexQuotaObservation}, + testObservation{id: modules.ClaudeQuotaObservation}, + testObservation{id: modules.AntigravityQuotaObservation}, + }, []ResetCreditAction{ + testResetCredit{id: modules.CodexResetCreditAction}, + } } func TestRuntimeRejectsDeviceOAuthWithoutDriverSupport(t *testing.T) { diff --git a/internal/webui/workflow_test.go b/internal/webui/workflow_test.go index 7c918528..d4fab6c1 100644 --- a/internal/webui/workflow_test.go +++ b/internal/webui/workflow_test.go @@ -90,7 +90,7 @@ func TestBranchAndReleaseWorkflowsRunRaceInParallelGates(t *testing.T) { run string }{ {name: "Check module graph", run: "go mod tidy -diff"}, - {name: "Audit Go dependencies", run: "go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./..."}, + {name: "Audit Go dependencies", run: "go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./..."}, {name: "Run Go vet", run: "go vet ./..."}, {name: "Check repository invariants", run: "git diff --check"}, } { @@ -1960,7 +1960,7 @@ func TestDockerfilePinsBuildAndRuntimeImagesByVersionAndDigest(t *testing.T) { for _, required := range []string{ "node:24.18.0-alpine3.24@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd", "pnpm@11.17.0", - "golang:1.26.6-alpine3.24@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df", + "golang:1.27.0-alpine3.24@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc", "alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b", } { if !strings.Contains(content, required) { diff --git a/third_party/cpaembedded/README.md b/third_party/cpaembedded/README.md index 14d348f3..8aae474c 100644 --- a/third_party/cpaembedded/README.md +++ b/third_party/cpaembedded/README.md @@ -38,7 +38,7 @@ watcher, WebSocket/Auto executors, fallback, and internal retry loops. ## Pinned upstream - Module: `github.com/router-for-me/CLIProxyAPI/v7` -- Version: `v7.2.135` +- Version: `v7.2.143` The root module consumes this bridge through a local `replace`; releases still resolve CPA itself at the exact version recorded in both `go.mod` files and diff --git a/third_party/cpaembedded/embedded/claude_protocol_contract_test.go b/third_party/cpaembedded/embedded/claude_protocol_contract_test.go new file mode 100644 index 00000000..30cf7516 --- /dev/null +++ b/third_party/cpaembedded/embedded/claude_protocol_contract_test.go @@ -0,0 +1,251 @@ +package embedded + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +const claudeWebSearchSSE = "event: message_start\n" + + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg-search\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":2,\"output_tokens\":0}}}\n\n" + + "event: content_block_start\n" + + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"server_tool_use\",\"id\":\"srvtoolu_search\",\"name\":\"web_search\",\"input\":{}}}\n\n" + + "event: content_block_delta\n" + + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"query\\\":\\\"GPT-Load\\\"}\"}}\n\n" + + "event: content_block_stop\n" + + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + + "event: content_block_start\n" + + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"web_search_tool_result\",\"tool_use_id\":\"srvtoolu_search\",\"content\":[{\"type\":\"web_search_result\",\"title\":\"GPT-Load\",\"url\":\"https://example.com/gpt-load\",\"encrypted_content\":\"ENC_SEARCH\"}]}}\n\n" + + "event: content_block_stop\n" + + "data: {\"type\":\"content_block_stop\",\"index\":1}\n\n" + + "event: content_block_delta\n" + + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"web_search_result_location\",\"cited_text\":\"GPT-Load\",\"url\":\"https://example.com/gpt-load\",\"title\":\"GPT-Load\",\"encrypted_index\":\"IDX_SEARCH\"}}}\n\n" + + "event: content_block_start\n" + + "data: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" + + "event: content_block_delta\n" + + "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"text_delta\",\"text\":\"GPT-Load source.\"}}\n\n" + + "event: content_block_stop\n" + + "data: {\"type\":\"content_block_stop\",\"index\":2}\n\n" + + "event: message_delta\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n" + + "event: message_stop\n" + + "data: {\"type\":\"message_stop\"}\n\n" + +func TestClaudeHTTPExecutorPreservesResponsesWebSearchCitationAndReplay(t *testing.T) { + requestPayload := []byte(`{ + "model":"claude-sonnet-4-5", + "tools":[{"type":"web_search","max_uses":1}], + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Find GPT-Load"}]}] + }`) + var upstreamBodies [][]byte + transport := claudeRoundTripperFunc(func(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + upstreamBodies = append(upstreamBodies, bytes.Clone(body)) + responseBody := claudeWebSearchSSE + if len(upstreamBodies) > 1 { + responseBody = claudeExecutionSSE + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + Request: request, + }, nil + }) + ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", http.RoundTripper(transport)) + executor := NewClaudeHTTPExecutor() + response, err := executor.ExecuteCanonical(ctx, "credential-one", testClaudeExecutionCredential(), ExecuteRequest{ + Model: "claude-sonnet-4-5", Payload: requestPayload, Format: "openai-response", OriginalRequest: requestPayload, + }) + if err != nil { + t.Fatal(err) + } + + var firstUpstream struct { + Tools []struct { + Type string `json:"type"` + Name string `json:"name"` + } `json:"tools"` + } + if err := json.Unmarshal(upstreamBodies[0], &firstUpstream); err != nil { + t.Fatalf("decode first upstream request: %v; body=%s", err, upstreamBodies[0]) + } + if len(firstUpstream.Tools) != 1 || firstUpstream.Tools[0].Type != "web_search_20250305" || firstUpstream.Tools[0].Name != "web_search" { + t.Fatalf("web search upstream tools = %s", upstreamBodies[0]) + } + + var firstResponse struct { + Output []json.RawMessage `json:"output"` + } + if err := json.Unmarshal(response.Payload, &firstResponse); err != nil { + t.Fatalf("decode first Responses payload: %v; body=%s", err, response.Payload) + } + if len(firstResponse.Output) != 2 { + t.Fatalf("Responses output = %s", response.Payload) + } + var searchItem struct { + Type string `json:"type"` + Results []struct { + EncryptedContent string `json:"encrypted_content"` + } `json:"results"` + } + if err := json.Unmarshal(firstResponse.Output[0], &searchItem); err != nil { + t.Fatal(err) + } + var messageItem struct { + Type string `json:"type"` + Content []struct { + Text string `json:"text"` + Annotations []struct { + EncryptedIndex string `json:"encrypted_index"` + } `json:"annotations"` + } `json:"content"` + } + if err := json.Unmarshal(firstResponse.Output[1], &messageItem); err != nil { + t.Fatal(err) + } + if searchItem.Type != "web_search_call" || len(searchItem.Results) != 1 || searchItem.Results[0].EncryptedContent != "ENC_SEARCH" || + messageItem.Type != "message" || len(messageItem.Content) != 1 || messageItem.Content[0].Text != "GPT-Load source." || + len(messageItem.Content[0].Annotations) != 1 || messageItem.Content[0].Annotations[0].EncryptedIndex != "IDX_SEARCH" { + t.Fatalf("web search Responses payload = %s", response.Payload) + } + + replayInput := append([]json.RawMessage(nil), firstResponse.Output...) + replayInput = append(replayInput, json.RawMessage(`{"type":"message","role":"user","content":[{"type":"input_text","text":"Continue"}]}`)) + replayPayload, err := json.Marshal(map[string]any{ + "model": "claude-sonnet-4-5", + "tools": []map[string]any{{"type": "web_search", "max_uses": 1}}, + "input": replayInput, + }) + if err != nil { + t.Fatal(err) + } + if _, err := executor.ExecuteCanonical(ctx, "credential-one", testClaudeExecutionCredential(), ExecuteRequest{ + Model: "claude-sonnet-4-5", Payload: replayPayload, Format: "openai-response", OriginalRequest: replayPayload, + }); err != nil { + t.Fatal(err) + } + if len(upstreamBodies) != 2 { + t.Fatalf("upstream requests = %d, want 2", len(upstreamBodies)) + } + assertClaudeWebSearchReplay(t, upstreamBodies[1]) +} + +func assertClaudeWebSearchReplay(t *testing.T, payload []byte) { + t.Helper() + var upstream struct { + Messages []struct { + Role string `json:"role"` + Content any `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(payload, &upstream); err != nil { + t.Fatalf("decode replay upstream request: %v; body=%s", err, payload) + } + var assistantParts []map[string]any + for _, message := range upstream.Messages { + if message.Role != "assistant" { + continue + } + parts, ok := message.Content.([]any) + if !ok { + continue + } + for _, rawPart := range parts { + if part, ok := rawPart.(map[string]any); ok { + assistantParts = append(assistantParts, part) + } + } + } + if len(assistantParts) != 3 || assistantParts[0]["type"] != "server_tool_use" || + assistantParts[1]["type"] != "web_search_tool_result" || assistantParts[2]["type"] != "text" { + t.Fatalf("replayed assistant parts = %s", payload) + } + results, ok := assistantParts[1]["content"].([]any) + if !ok || len(results) != 1 { + t.Fatalf("replayed search results = %s", payload) + } + result, ok := results[0].(map[string]any) + if !ok || result["encrypted_content"] != "ENC_SEARCH" { + t.Fatalf("replayed encrypted search result = %s", payload) + } + citations, ok := assistantParts[2]["citations"].([]any) + if !ok || len(citations) != 1 { + t.Fatalf("replayed citations = %s", payload) + } + citation, ok := citations[0].(map[string]any) + if !ok || citation["encrypted_index"] != "IDX_SEARCH" { + t.Fatalf("replayed encrypted citation = %s", payload) + } +} + +func TestClaudeHTTPExecutorStreamsSequentialOpenAIToolCallIndices(t *testing.T) { + const responseSSE = "event: message_start\n" + + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg-tools\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":2,\"output_tokens\":0}}}\n\n" + + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Checking.\"}}\n\n" + + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"tool-one\",\"name\":\"first_tool\",\"input\":{}}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{}\"}}\n\n" + + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\n" + + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"tool_use\",\"id\":\"tool-two\",\"name\":\"second_tool\",\"input\":{}}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{}\"}}\n\n" + + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\n" + + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + transport := claudeRoundTripperFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(responseSSE)), + Request: request, + }, nil + }) + ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", http.RoundTripper(transport)) + requestPayload := []byte(`{ + "model":"claude-sonnet-4-5", + "messages":[{"role":"user","content":"Use both tools"}], + "tools":[ + {"type":"function","function":{"name":"first_tool","parameters":{"type":"object"}}}, + {"type":"function","function":{"name":"second_tool","parameters":{"type":"object"}}} + ], + "stream":true + }`) + stream, err := NewClaudeHTTPExecutor().ExecuteStreamCanonical(ctx, "credential-one", testClaudeExecutionCredential(), ExecuteRequest{ + Model: "claude-sonnet-4-5", Payload: requestPayload, Format: "openai", OriginalRequest: requestPayload, + }) + if err != nil { + t.Fatal(err) + } + type toolCall struct { + Index *int `json:"index"` + ID string `json:"id"` + } + var calls []toolCall + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + var envelope struct { + Choices []struct { + Delta struct { + ToolCalls []toolCall `json:"tool_calls"` + } `json:"delta"` + } `json:"choices"` + } + if json.Unmarshal(chunk.Payload, &envelope) == nil && len(envelope.Choices) == 1 { + calls = append(calls, envelope.Choices[0].Delta.ToolCalls...) + } + } + if len(calls) != 2 || calls[0].Index == nil || *calls[0].Index != 0 || calls[0].ID != "tool-one" || + calls[1].Index == nil || *calls[1].Index != 1 || calls[1].ID != "tool-two" { + t.Fatalf("streamed tool calls = %#v, want zero-based sequential indices", calls) + } +} diff --git a/third_party/cpaembedded/embedded/claude_test.go b/third_party/cpaembedded/embedded/claude_test.go index ff3e9372..7d1d4c27 100644 --- a/third_party/cpaembedded/embedded/claude_test.go +++ b/third_party/cpaembedded/embedded/claude_test.go @@ -328,8 +328,9 @@ func TestClaudeHTTPExecutorPreservesRichOpenAIChatSemantics(t *testing.T) { var downstream struct { Choices []struct { Message struct { - Reasoning string `json:"reasoning"` - ToolCalls []struct { + ReasoningContent string `json:"reasoning_content"` + LegacyReasoning json.RawMessage `json:"reasoning"` + ToolCalls []struct { Function struct { Name string `json:"name"` Arguments string `json:"arguments"` @@ -346,7 +347,8 @@ func TestClaudeHTTPExecutorPreservesRichOpenAIChatSemantics(t *testing.T) { if err := json.Unmarshal(response.Payload, &downstream); err != nil { t.Fatalf("decode downstream response: %v; body=%s", err, response.Payload) } - if len(downstream.Choices) != 1 || downstream.Choices[0].Message.Reasoning != "considering" || + if len(downstream.Choices) != 1 || downstream.Choices[0].Message.ReasoningContent != "considering" || + len(downstream.Choices[0].Message.LegacyReasoning) != 0 || len(downstream.Choices[0].Message.ToolCalls) != 1 || downstream.Choices[0].Message.ToolCalls[0].Function.Name != "inspect_image" || downstream.Choices[0].Message.ToolCalls[0].Function.Arguments != `{"detail":"ok"}` || @@ -388,7 +390,9 @@ func TestClaudeHTTPExecutorHonorsRequestCancellation(t *testing.T) { func TestClaudeHTTPExecutorSanitizesAndClassifiesFastModeEntitlement429(t *testing.T) { const providerSecret = "provider-secret-body" + var requests atomic.Int32 transport := claudeRoundTripperFunc(func(request *http.Request) (*http.Response, error) { + requests.Add(1) return &http.Response{ StatusCode: http.StatusTooManyRequests, Header: http.Header{"Content-Type": {"application/json"}, "Retry-After": {"17"}}, @@ -408,7 +412,7 @@ func TestClaudeHTTPExecutorSanitizesAndClassifiesFastModeEntitlement429(t *testi !executionErr.IsRequestScoped() || executionErr.ErrorType() != "rate_limit_error" || executionErr.ErrorCode() != "fast_mode_credits" || executionErr.RetryAfter() == nil || *executionErr.RetryAfter() < 17*time.Second || *executionErr.RetryAfter() > 2*time.Minute || - strings.Contains(err.Error(), providerSecret) { + strings.Contains(err.Error(), providerSecret) || requests.Load() != 1 { t.Fatalf("Claude execution error = %#v / %v", executionErr, err) } } diff --git a/third_party/cpaembedded/go.mod b/third_party/cpaembedded/go.mod index 190bb068..b5d02ffc 100644 --- a/third_party/cpaembedded/go.mod +++ b/third_party/cpaembedded/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( github.com/andybalholm/brotli v1.0.6 github.com/google/uuid v1.6.0 - github.com/router-for-me/CLIProxyAPI/v7 v7.2.135 + github.com/router-for-me/CLIProxyAPI/v7 v7.2.143 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 ) diff --git a/third_party/cpaembedded/go.sum b/third_party/cpaembedded/go.sum index 901429fb..6466c75c 100644 --- a/third_party/cpaembedded/go.sum +++ b/third_party/cpaembedded/go.sum @@ -71,8 +71,8 @@ github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthO github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= 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/router-for-me/CLIProxyAPI/v7 v7.2.135 h1:Vzc5xJwNWq319t5++7AXCx/CnQ3U94xOjteeFtusSsk= -github.com/router-for-me/CLIProxyAPI/v7 v7.2.135/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ= +github.com/router-for-me/CLIProxyAPI/v7 v7.2.143 h1:bBIxyHsq8WLuVwliWJQ/fYixzXXZs64DpElgTx1f4xk= +github.com/router-for-me/CLIProxyAPI/v7 v7.2.143/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=