feat(openai): 完善 Chat 与 Responses 参数转发
原生 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 因本地没有已启用的平台模型候选而未完成
This commit is contained in:
@@ -83,7 +83,8 @@ func (s *Service) prepareResponseExecution(ctx context.Context, task store.Gatew
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, execution responseExecutionContext) ([]store.RuntimeModelCandidate, error) {
|
||||
func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, execution responseExecutionContext, body map[string]any) ([]store.RuntimeModelCandidate, error) {
|
||||
chatCompatibilityErr := clients.ValidateResponsesChatFallback(body)
|
||||
if execution.PreviousChain != nil {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.PlatformModelID != execution.PreviousChain.PlatformModelID {
|
||||
@@ -92,6 +93,9 @@ func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, executi
|
||||
if !candidateSupportsProtocol(candidate, execution.PreviousChain.UpstreamProtocol) {
|
||||
break
|
||||
}
|
||||
if execution.PreviousChain.UpstreamProtocol == clients.ProtocolOpenAIChatCompletions && chatCompatibilityErr != nil {
|
||||
return nil, chatCompatibilityErr
|
||||
}
|
||||
candidate.ResponseProtocol = execution.PreviousChain.UpstreamProtocol
|
||||
return []store.RuntimeModelCandidate{candidate}, nil
|
||||
}
|
||||
@@ -106,6 +110,7 @@ func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, executi
|
||||
items := make([]indexedCandidate, 0, len(candidates))
|
||||
hasAnthropicOnly := false
|
||||
hasDeclaredUnsupported := false
|
||||
hasChatCandidate := false
|
||||
for index, candidate := range candidates {
|
||||
protocols := candidateSupportedProtocols(candidate)
|
||||
group := 1
|
||||
@@ -119,11 +124,19 @@ func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, executi
|
||||
hasAnthropicOnly = true
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
hasChatCandidate = true
|
||||
if chatCompatibilityErr != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
candidate.ResponseProtocol = protocol
|
||||
items = append(items, indexedCandidate{candidate: candidate, index: index, group: group})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if hasChatCandidate && chatCompatibilityErr != nil {
|
||||
return nil, chatCompatibilityErr
|
||||
}
|
||||
if hasAnthropicOnly {
|
||||
return nil, &clients.ClientError{Code: "unsupported_model_protocol", Message: "the selected model only supports Anthropic Messages; the public Anthropic adapter is not available", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestPrepareResponseCandidatesPrioritizesNativeAndKeepsGroupOrder(t *testing
|
||||
{PlatformModelID: "native-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIResponses)},
|
||||
{PlatformModelID: "chat-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)},
|
||||
}
|
||||
prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{})
|
||||
prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{}, map[string]any{"input": "hello"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestPrepareResponseCandidatesPinsPreviousPlatformModelAndProtocol(t *testin
|
||||
{PlatformModelID: "other", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)},
|
||||
{PlatformModelID: "pinned", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses)},
|
||||
}
|
||||
prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{PreviousChain: &chain})
|
||||
prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{PreviousChain: &chain}, map[string]any{"input": "hello"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -54,14 +54,53 @@ func TestPrepareResponseCandidatesPinsPreviousPlatformModelAndProtocol(t *testin
|
||||
t.Fatalf("previous chain was not pinned: %+v", prepared)
|
||||
}
|
||||
|
||||
_, err = prepareResponseCandidates(candidates[:1], responseExecutionContext{PreviousChain: &chain})
|
||||
_, 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{})
|
||||
_, 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)
|
||||
}
|
||||
@@ -107,7 +146,7 @@ func TestCandidateProtocolOverrideAndExplicitEmpty(t *testing.T) {
|
||||
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" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +481,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
return Result{Task: failed, Output: failed.Result}, err
|
||||
}
|
||||
if task.Kind == "responses" {
|
||||
candidates, err = prepareResponseCandidates(candidates, responseExecution)
|
||||
candidates, err = prepareResponseCandidates(candidates, responseExecution, body)
|
||||
if err != nil {
|
||||
code, message := responseExecutionFailure(err)
|
||||
s.recordFailedAttempt(ctx, failedAttemptRecord{Task: task, Body: body, AttemptNo: task.AttemptCount + 1, Code: code, Cause: err, Simulated: task.RunMode == "simulation", Scope: "response_protocol", Reason: code, ModelType: modelType})
|
||||
|
||||
Reference in New Issue
Block a user