Files
sub2api/backend/internal/service/openai_apikey_responses_probe_verdict_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

177 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/stretchr/testify/require"
)
func newResponsesProbeAccount(id int64) Account {
return Account{
ID: id,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Name: "compat-upstream",
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "https://compat-upstream.example/v1",
},
}
}
// runResponsesProbe 跑一次探测,返回落库的 extra 更新;未落库时返回 nil。
func runResponsesProbe(t *testing.T, status int, body string) map[string]any {
t.Helper()
account := newResponsesProbeAccount(4200)
// 带缓冲且不阻塞:探测决定不落标时通道应保持为空。
updateCalls := make(chan map[string]any, 1)
repo := &snapshotUpdateAccountRepo{
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
updateExtraCalls: updateCalls,
}
svc := &AccountTestService{
accountRepo: repo,
httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}},
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
}
svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID)
select {
case updates := <-updateCalls:
return updates
default:
return nil
}
}
// issue #5371:账号一旦被落标为「不支持 Responses」,网关就长期改走
// /v1/chat/completionsCodex 的 prompt 缓存前缀被打散;而探测只在账号创建/更新时
// 跑一次,标记不会自动恢复。因此判据不成立的响应绝不能落标。
func TestProbeOpenAIAPIKeyResponsesSupport_InconclusiveResponseKeepsUnknown(t *testing.T) {
cases := []struct {
name string
body string
}{
{
// 探测请求自带 openaiResponsesProbeMaxOutputTokens 预算,推理型模型可能
// 把预算烧在 reasoning 上就被截断——没有 function_call 是预算不足所致。
name: "incomplete_max_output_tokens",
body: `{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},` +
`"output":[{"type":"reasoning","summary":[]}]}`,
},
{
// HTTP 200 携带的失败响应是上游瞬时故障,不构成工具能力证据。
name: "failed_status_on_http_200",
body: `{"status":"failed","error":{"code":"server_error","message":"upstream hiccup"},"output":[]}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.Nil(t, runResponsesProbe(t, http.StatusOK, tc.body),
"判据不成立时必须保持 unknown,不得落标")
})
}
}
// 对照不变式:能下结论的响应仍要落标,否则「不落标」写宽了就等于把整个探测废掉。
func TestProbeOpenAIAPIKeyResponsesSupport_ConclusiveResponsesStillPersist(t *testing.T) {
cases := []struct {
name string
status int
body string
want bool
}{
{
name: "completed_with_function_call",
status: http.StatusOK,
body: `{"status":"completed","output":[{"type":"function_call","name":"probe_ping"}]}`,
want: true,
},
{
// 火山方舟 coding/v3 × kimi-k2.6:端点在、跑完了、就是不产出 function_call。
// 这正是探测要抓的目标,必须继续落标为不支持。
name: "completed_reasoning_only",
status: http.StatusOK,
body: `{"status":"completed","output":[{"type":"reasoning"}]}`,
want: false,
},
{
// 非 max_output_tokens 的截断(如内容过滤)不在放行范围内,维持原判定。
name: "incomplete_other_reason",
status: http.StatusOK,
body: `{"status":"incomplete","incomplete_details":{"reason":"content_filter"},"output":[]}`,
want: false,
},
{
// 响应体没有 status 字段(第三方兼容上游常见)时维持既有行为。
name: "no_status_field",
status: http.StatusOK,
body: `{"output":[{"type":"function_call","name":"probe_ping"}]}`,
want: true,
},
{
name: "endpoint_absent_404",
status: http.StatusNotFound,
body: `{"error":{"message":"Not Found"}}`,
want: false,
},
{
// 非 2xx 的结论只看状态码:body 里的 status=failed 不该让它变成"不下结论"。
name: "server_error_stays_conservative_true",
status: http.StatusInternalServerError,
body: `{"status":"failed"}`,
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
updates := runResponsesProbe(t, tc.status, tc.body)
require.NotNil(t, updates, "能下结论的响应必须落标")
require.Equal(t, tc.want, updates[openai_compat.ExtraKeyResponsesSupported])
})
}
}
func TestResponsesProbeVerdictIsConclusive(t *testing.T) {
cases := []struct {
name string
status int
body string
want bool
}{
{"200_completed", 200, `{"status":"completed","output":[]}`, true},
{"200_incomplete_max_output_tokens", 200,
`{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}`, false},
{"200_incomplete_content_filter", 200,
`{"status":"incomplete","incomplete_details":{"reason":"content_filter"}}`, true},
{"200_incomplete_without_reason", 200, `{"status":"incomplete"}`, true},
{"200_failed", 200, `{"status":"failed"}`, false},
{"200_no_status_field", 200, `{"output":[]}`, true},
{"200_non_json", 200, `not-json`, true},
{"200_empty_body", 200, ``, true},
// 非 2xx 只看状态码,不读 body。
{"404_ignores_body_status", 404, `{"status":"failed"}`, true},
{"500_ignores_body_status", 500, `{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}`, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, responsesProbeVerdictIsConclusive(tc.status, []byte(tc.body)))
})
}
}