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)
[](https://github.com/tbphp/gpt-load/releases)
[](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load)
-[](go.mod)
+[](go.mod)
[](LICENSE)
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 @@
[](https://github.com/tbphp/gpt-load/releases)
[](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load)
-[](go.mod)
+[](go.mod)
[](LICENSE)
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 キー、サブスクリプションアカウント、トラフィック制
[](https://github.com/tbphp/gpt-load/releases)
[](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load)
-[](go.mod)
+[](go.mod)
[](LICENSE)
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=