原生 Chat/Responses 改为透明转发,保留标准工具结构并保护调用方显式参数。补齐 Responses 到 Chat 的兼容转换、协议路由边界、完整响应和流式事件,并同步更新 Swagger、回归测试与真实验收脚本。 验证: - cd apps/api && env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1 - pnpm openapi - pnpm lint - pnpm test - pnpm build - gofmt -l 无输出 - git diff --check 通过 风险: - Chat 回退无法等价表达的 Responses 原生能力现在会返回 unsupported_response_parameter - 真实供应商 E2E 因本地没有已启用的平台模型候选而未完成
169 lines
8.6 KiB
Go
169 lines
8.6 KiB
Go
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{}, map[string]any{"input": "hello"})
|
|
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}, map[string]any{"input": "hello"})
|
|
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}, map[string]any{"input": "hello"})
|
|
if clients.ErrorCode(err) != "response_chain_unavailable" {
|
|
t.Fatalf("expected response_chain_unavailable, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepareResponseCandidatesExcludesChatForNativeOnlyParameters(t *testing.T) {
|
|
candidates := []store.RuntimeModelCandidate{
|
|
{PlatformModelID: "chat", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)},
|
|
{PlatformModelID: "native", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIResponses)},
|
|
}
|
|
prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{}, map[string]any{"input": "hello", "background": true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(prepared) != 1 || prepared[0].PlatformModelID != "native" || prepared[0].ResponseProtocol != clients.ProtocolOpenAIResponses {
|
|
t.Fatalf("native-only request reached Chat candidates: %+v", prepared)
|
|
}
|
|
|
|
_, err = prepareResponseCandidates(candidates[:1], responseExecutionContext{}, map[string]any{"input": "hello", "prompt": map[string]any{"id": "pmpt_1"}})
|
|
if clients.ErrorCode(err) != "unsupported_response_parameter" || clients.ErrorParam(err) != "prompt" {
|
|
t.Fatalf("Chat-only routing must return precise incompatibility, got %v param=%q", err, clients.ErrorParam(err))
|
|
}
|
|
|
|
_, err = prepareResponseCandidates(candidates[:1], responseExecutionContext{}, map[string]any{
|
|
"input": []any{map[string]any{"type": "mcp_call", "name": "remote"}},
|
|
})
|
|
if clients.ErrorCode(err) != "unsupported_response_parameter" || clients.ErrorParam(err) != "input[0].type" {
|
|
t.Fatalf("native-only input item must exclude Chat with a precise path, got %v param=%q", err, clients.ErrorParam(err))
|
|
}
|
|
}
|
|
|
|
func TestPrepareResponseCandidatesRejectsNativeOnlyParameterOnPinnedChatChain(t *testing.T) {
|
|
chain := store.ResponseChain{PlatformModelID: "pinned", UpstreamProtocol: clients.ProtocolOpenAIChatCompletions}
|
|
candidates := []store.RuntimeModelCandidate{{
|
|
PlatformModelID: "pinned", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses),
|
|
}}
|
|
_, err := prepareResponseCandidates(candidates, responseExecutionContext{PreviousChain: &chain}, map[string]any{
|
|
"input": "hello", "reasoning": map[string]any{"summary": "auto"},
|
|
})
|
|
if clients.ErrorCode(err) != "unsupported_response_parameter" || clients.ErrorParam(err) != "reasoning.summary" {
|
|
t.Fatalf("pinned Chat chain must return precise incompatibility, got %v param=%q", err, clients.ErrorParam(err))
|
|
}
|
|
}
|
|
|
|
func TestPrepareResponseCandidatesRejectsAnthropicOnly(t *testing.T) {
|
|
_, err := prepareResponseCandidates([]store.RuntimeModelCandidate{{Capabilities: responseProtocolCapability(clients.ProtocolAnthropicMessages)}}, responseExecutionContext{}, map[string]any{"input": "hello"})
|
|
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{}, map[string]any{"input": "hello"}); 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}}
|
|
}
|