feat: 升级 Go 与 SDK 版本 (#485)
* feat(gateway): 升级 SDK 并封口兼容合同 * fix: 修正升级后的格式与模型过滤 * fix(ci): 更新 Go 漏洞扫描器 * fix(gateway): 拒绝非标准服务层级字段
This commit is contained in:
1 parent
17dbcb592c
commit
d4bbb9193a
28 files changed
+569
-74
No files matched your search
@@ -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 ./...
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
<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
@@ -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)
|
||||
|
||||
<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
@@ -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)
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
)
|
||||
|
||||
Vendored
+2
-2
@@ -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=
|
||||
|
||||
Reference in new issue
Block a user