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
177 lines
5.9 KiB
Go
177 lines
5.9 KiB
Go
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/completions,Codex 的 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)))
|
||
})
|
||
}
|
||
}
|