package runner import ( "context" "testing" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) func TestCandidateSupportedProtocolsDefaultsToChat(t *testing.T) { candidate := store.RuntimeModelCandidate{Capabilities: map[string]any{"text_generate": map[string]any{"supportTool": true}}} protocols := candidateSupportedProtocols(candidate) if len(protocols) != 1 || protocols[0] != clients.ProtocolOpenAIChatCompletions { t.Fatalf("legacy capability must default to Chat: %+v", protocols) } } func TestPrepareResponseCandidatesPrioritizesNativeAndKeepsGroupOrder(t *testing.T) { candidates := []store.RuntimeModelCandidate{ {PlatformModelID: "chat-1", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)}, {PlatformModelID: "native-1", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses)}, {PlatformModelID: "native-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIResponses)}, {PlatformModelID: "chat-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)}, } prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{}) if err != nil { t.Fatal(err) } got := []string{prepared[0].PlatformModelID, prepared[1].PlatformModelID, prepared[2].PlatformModelID, prepared[3].PlatformModelID} want := []string{"native-1", "native-2", "chat-1", "chat-2"} for index := range want { if got[index] != want[index] { t.Fatalf("unexpected protocol ordering got=%v want=%v", got, want) } } if prepared[0].ResponseProtocol != clients.ProtocolOpenAIResponses || prepared[2].ResponseProtocol != clients.ProtocolOpenAIChatCompletions { t.Fatalf("protocol assignment mismatch: %+v", prepared) } } func TestPrepareResponseCandidatesPinsPreviousPlatformModelAndProtocol(t *testing.T) { chain := store.ResponseChain{PlatformModelID: "pinned", UpstreamProtocol: clients.ProtocolOpenAIChatCompletions} candidates := []store.RuntimeModelCandidate{ {PlatformModelID: "other", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)}, {PlatformModelID: "pinned", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses)}, } prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{PreviousChain: &chain}) if err != nil { t.Fatal(err) } if len(prepared) != 1 || prepared[0].PlatformModelID != "pinned" || prepared[0].ResponseProtocol != clients.ProtocolOpenAIChatCompletions { t.Fatalf("previous chain was not pinned: %+v", prepared) } _, err = prepareResponseCandidates(candidates[:1], responseExecutionContext{PreviousChain: &chain}) if clients.ErrorCode(err) != "response_chain_unavailable" { t.Fatalf("expected response_chain_unavailable, got %v", err) } } func TestPrepareResponseCandidatesRejectsAnthropicOnly(t *testing.T) { _, err := prepareResponseCandidates([]store.RuntimeModelCandidate{{Capabilities: responseProtocolCapability(clients.ProtocolAnthropicMessages)}}, responseExecutionContext{}) if clients.ErrorCode(err) != "unsupported_model_protocol" { t.Fatalf("expected unsupported_model_protocol, got %v", err) } } func TestGatewayResponseIDAndExpiryPolicy(t *testing.T) { id := gatewayResponseID("01234567-89ab-cdef-0123-456789abcdef") if id != "resp_0123456789abcdef0123456789abcdef" { t.Fatalf("unexpected public response id: %s", id) } now := time.Now() chatExpiry := responseChainExpiry(map[string]any{}, clients.ProtocolOpenAIChatCompletions) if chatExpiry.Before(now.Add(responseChainMaxTTL-time.Minute)) || chatExpiry.After(now.Add(responseChainMaxTTL+time.Minute)) { t.Fatalf("Chat chain must use the 30-day retention policy: %v", chatExpiry) } upstreamExpiry := now.Add(24 * time.Hour).Unix() nativeExpiry := responseChainExpiry(map[string]any{"expire_at": float64(upstreamExpiry)}, clients.ProtocolOpenAIResponses) if nativeExpiry.Unix() != upstreamExpiry { t.Fatalf("native chain must honor an earlier upstream expiry: %v", nativeExpiry) } } func TestResponseModelAndDepthValidation(t *testing.T) { if !sameResponseModel("DeepSeek-V4-Pro", "deepseek-v4-pro") || sameResponseModel("Qwen3.7-Plus", "DeepSeek-V4-Pro") { t.Fatal("response continuation model matching is not strict") } history := make([]store.ResponseChain, responseChainMaxDepth) if _, err := responseHistoryDepth(history); clients.ErrorCode(err) != "response_chain_too_deep" { t.Fatalf("expected response_chain_too_deep, got %v", err) } } func TestCandidateProtocolOverrideAndExplicitEmpty(t *testing.T) { candidate := store.RuntimeModelCandidate{ Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses), CapabilityOverride: responseProtocolCapability(clients.ProtocolAnthropicMessages), } protocols := candidateSupportedProtocols(candidate) if len(protocols) != 1 || protocols[0] != clients.ProtocolAnthropicMessages { t.Fatalf("platform capability override was not applied: %v", protocols) } empty := store.RuntimeModelCandidate{Capabilities: responseProtocolCapability()} if protocols := candidateSupportedProtocols(empty); len(protocols) != 0 { t.Fatalf("an explicitly empty protocol list must not become legacy Chat: %v", protocols) } if _, err := prepareResponseCandidates([]store.RuntimeModelCandidate{empty}, responseExecutionContext{}); clients.ErrorCode(err) != "unsupported_model_protocol" { t.Fatalf("expected unsupported_model_protocol, got %v", err) } } func TestPersistResponseChainSkipsStoreFalse(t *testing.T) { service := &Service{} err := service.persistResponseChain(context.Background(), store.GatewayTask{Kind: "responses"}, nil, store.RuntimeModelCandidate{}, responseExecutionContext{Store: false}, clients.Response{}) if err != nil { t.Fatalf("store:false must not require a response-chain store: %v", err) } } func responseProtocolCapability(protocols ...string) map[string]any { items := make([]any, len(protocols)) for index, protocol := range protocols { items[index] = protocol } return map[string]any{"text_generate": map[string]any{"supportedApiProtocols": items}} }