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)) }