feat: 升级 Go 与 SDK 版本 (#485)

* feat(gateway): 升级 SDK 并封口兼容合同

* fix: 修正升级后的格式与模型过滤

* fix(ci): 更新 Go 漏洞扫描器

* fix(gateway): 拒绝非标准服务层级字段
This commit is contained in:
tbphp authored and GitHub committed 2026-08-27 22:48:16 +08:00
1 parent 17dbcb592c
commit d4bbb9193a
28 files changed
+569 -74

No files matched your search

+1 -1
View File
@@ -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 ./...
+1 -1
View File
@@ -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 ./...
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
<a href="https://trendshift.io/repositories/14880" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14880" alt="tbphp/gpt-load | Trendshift" width="220" height="48"/></a>
+1 -1
View File
@@ -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)
<a href="https://trendshift.io/repositories/14880" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14880" alt="tbphp/gpt-load | Trendshift" width="220" height="48"/></a>
+1 -1
View File
@@ -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)
<a href="https://trendshift.io/repositories/14880" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14880" alt="tbphp/gpt-load | Trendshift" width="220" height="48"/></a>
+2 -2
View File
@@ -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
+16 -19
View File
@@ -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
+8 -8
View File
@@ -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=
+4 -1
View File
@@ -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(),
+13 -3
View File
@@ -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) {
+6 -1
View File
@@ -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)
@@ -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:
@@ -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 {
+23 -2
View File
@@ -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
}
@@ -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)
}
}
+7
View File
@@ -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:
@@ -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(
@@ -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)
}
})
+43
View File
@@ -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
+16 -16
View File
@@ -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) {
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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
@@ -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)
}
}
+8 -4
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
)
+2 -2
View File
@@ -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=