161 lines
6.3 KiB
Go
161 lines
6.3 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"
|
|||
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
|||
|
|
"github.com/stretchr/testify/require"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func TestProbeOpenAIAPIKeyResponsesSupportUsesCodexProbeHeaders(t *testing.T) {
|
|||
|
|
updateCalls := make(chan map[string]any, 1)
|
|||
|
|
account := Account{
|
|||
|
|
ID: 96,
|
|||
|
|
Platform: PlatformOpenAI,
|
|||
|
|
Type: AccountTypeAPIKey,
|
|||
|
|
Concurrency: 1,
|
|||
|
|
Credentials: map[string]any{
|
|||
|
|
"api_key": "sk-test",
|
|||
|
|
"base_url": "https://compat-upstream.example/v1",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
repo := &snapshotUpdateAccountRepo{
|
|||
|
|
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
|||
|
|
updateExtraCalls: updateCalls,
|
|||
|
|
}
|
|||
|
|
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
|||
|
|
StatusCode: http.StatusOK,
|
|||
|
|
Header: make(http.Header),
|
|||
|
|
Body: io.NopCloser(strings.NewReader(`{"output":[{"type":"function_call","name":"probe_ping"}]}`)),
|
|||
|
|
}}
|
|||
|
|
svc := &AccountTestService{
|
|||
|
|
accountRepo: repo,
|
|||
|
|
httpUpstream: upstream,
|
|||
|
|
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID)
|
|||
|
|
|
|||
|
|
require.NotNil(t, upstream.lastReq)
|
|||
|
|
require.Equal(t, "https://compat-upstream.example/v1/responses", upstream.lastReq.URL.String())
|
|||
|
|
requireOpenAICodexProbeHeaders(t, upstream.lastReq.Header)
|
|||
|
|
updates := <-updateCalls
|
|||
|
|
require.Equal(t, true, updates[openai_compat.ExtraKeyResponsesSupported])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestProbeOpenAIAPIKeyResponsesSupportCNProviders(t *testing.T) {
|
|||
|
|
tests := []struct {
|
|||
|
|
name string
|
|||
|
|
id int64
|
|||
|
|
platform string
|
|||
|
|
protocol string
|
|||
|
|
wantSupport bool
|
|||
|
|
wantMode string
|
|||
|
|
}{
|
|||
|
|
{name: "deepseek adaptive supports responses", id: 201, platform: PlatformDeepseek, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)},
|
|||
|
|
{name: "deepseek chat clears forced responses", id: 202, platform: PlatformDeepseek, protocol: APIProtocolChatCompletions, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)},
|
|||
|
|
{name: "kimi adaptive falls back to chat", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)},
|
|||
|
|
{name: "zhipu adaptive falls back to chat", id: 204, platform: PlatformZhipu, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, tc := range tests {
|
|||
|
|
t.Run(tc.name, func(t *testing.T) {
|
|||
|
|
updateCalls := make(chan map[string]any, 1)
|
|||
|
|
account := Account{
|
|||
|
|
ID: tc.id, Platform: tc.platform, Type: AccountTypeAPIKey,
|
|||
|
|
Credentials: map[string]any{"api_key": "sk-test", "api_protocol": tc.protocol},
|
|||
|
|
Extra: map[string]any{
|
|||
|
|
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses),
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
repo := &snapshotUpdateAccountRepo{
|
|||
|
|
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
|||
|
|
updateExtraCalls: updateCalls,
|
|||
|
|
}
|
|||
|
|
svc := &AccountTestService{accountRepo: repo}
|
|||
|
|
|
|||
|
|
svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID)
|
|||
|
|
|
|||
|
|
updates := <-updateCalls
|
|||
|
|
require.Equal(t, tc.wantSupport, updates[openai_compat.ExtraKeyResponsesSupported])
|
|||
|
|
require.Equal(t, tc.wantMode, updates[openai_compat.ExtraKeyResponsesMode])
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestDecideResponsesProbeSupport(t *testing.T) {
|
|||
|
|
fnCall := []byte(`{"output":[{"type":"reasoning"},{"type":"function_call","name":"probe_ping"}]}`)
|
|||
|
|
reasoningOnly := []byte(`{"output":[{"type":"reasoning"}]}`)
|
|||
|
|
|
|||
|
|
cases := []struct {
|
|||
|
|
name string
|
|||
|
|
status int
|
|||
|
|
body []byte
|
|||
|
|
want bool
|
|||
|
|
}{
|
|||
|
|
// Endpoint clearly absent on third-party OpenAI-compatible upstreams.
|
|||
|
|
{"404 endpoint absent", 404, fnCall, false},
|
|||
|
|
{"405 method not allowed", 405, fnCall, false},
|
|||
|
|
// 2xx: tool capability is judged by presence of a function_call output item.
|
|||
|
|
{"200 with function_call", 200, fnCall, true},
|
|||
|
|
// Volcengine Ark coding/v3 × kimi-k2.6: reasoning only, no function_call.
|
|||
|
|
{"200 reasoning only", 200, reasoningOnly, false},
|
|||
|
|
{"200 invalid json", 200, []byte("not-json"), false},
|
|||
|
|
{"200 no output field", 200, []byte(`{"status":"completed"}`), false},
|
|||
|
|
// Non-2xx (other than 404/405): endpoint exists, capability undecidable -> conservative true.
|
|||
|
|
{"400 conservative true", 400, reasoningOnly, true},
|
|||
|
|
{"401 conservative true", 401, nil, true},
|
|||
|
|
{"500 conservative true", 500, nil, true},
|
|||
|
|
}
|
|||
|
|
for _, tc := range cases {
|
|||
|
|
t.Run(tc.name, func(t *testing.T) {
|
|||
|
|
require.Equal(t, tc.want, decideResponsesProbeSupport(tc.status, tc.body))
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestResponsesProbeBodyHasFunctionCall(t *testing.T) {
|
|||
|
|
require.True(t, responsesProbeBodyHasFunctionCall([]byte(`{"output":[{"type":"function_call"}]}`)))
|
|||
|
|
require.True(t, responsesProbeBodyHasFunctionCall([]byte(`{"output":[{"type":"reasoning"},{"type":"function_call"}]}`)))
|
|||
|
|
require.False(t, responsesProbeBodyHasFunctionCall([]byte(`{"output":[{"type":"reasoning"}]}`)))
|
|||
|
|
require.False(t, responsesProbeBodyHasFunctionCall([]byte(`{"output":[]}`)))
|
|||
|
|
require.False(t, responsesProbeBodyHasFunctionCall([]byte(`{}`)))
|
|||
|
|
require.False(t, responsesProbeBodyHasFunctionCall([]byte(`garbage`)))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSelectResponsesProbeModel(t *testing.T) {
|
|||
|
|
// No model_mapping -> fall back to DefaultTestModel (OpenAI official APIKey).
|
|||
|
|
require.Equal(t, openai.DefaultTestModel, selectResponsesProbeModel(&Account{}))
|
|||
|
|
|
|||
|
|
// model_mapping values are upstream models; pick first by sort for reproducibility.
|
|||
|
|
acct := &Account{Credentials: map[string]any{
|
|||
|
|
"model_mapping": map[string]any{
|
|||
|
|
"client-b": "zeta-model",
|
|||
|
|
"client-a": "alpha-model",
|
|||
|
|
},
|
|||
|
|
}}
|
|||
|
|
require.Equal(t, "alpha-model", selectResponsesProbeModel(acct))
|
|||
|
|
|
|||
|
|
// Wildcard / blank upstream values are skipped.
|
|||
|
|
acctWild := &Account{Credentials: map[string]any{
|
|||
|
|
"model_mapping": map[string]any{
|
|||
|
|
"a": "*",
|
|||
|
|
"b": " ",
|
|||
|
|
"c": "real-model",
|
|||
|
|
},
|
|||
|
|
}}
|
|||
|
|
require.Equal(t, "real-model", selectResponsesProbeModel(acctWild))
|
|||
|
|
|
|||
|
|
// Only wildcard mappings -> DefaultTestModel.
|
|||
|
|
acctAllWild := &Account{Credentials: map[string]any{
|
|||
|
|
"model_mapping": map[string]any{"a": "gpt-*"},
|
|||
|
|
}}
|
|||
|
|
require.Equal(t, openai.DefaultTestModel, selectResponsesProbeModel(acctAllWild))
|
|||
|
|
}
|