Files
easyai-ai-gateway/apps/api/internal/clients/openai_passthrough_test.go
T
wangbo fe8dcb40ca 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 因本地没有已启用的平台模型候选而未完成
2026-08-04 19:26:48 +08:00

155 lines
7.4 KiB
Go

package clients
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestOpenAIChatNativeDeepPassthroughPreservesOfficialAndFutureFields(t *testing.T) {
requestBody := completeChatPassthroughBody()
var captured map[string]any
var idempotencyKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idempotencyKey = r.Header.Get("Idempotency-Key")
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "chatcmpl-native", "object": "chat.completion", "model": "provider-chat",
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "ok"}}},
})
}))
defer server.Close()
_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "chat.completions", Model: "public-chat", Body: requestBody, OriginalBody: requestBody,
UpstreamIdempotencyKey: "gateway-task-id",
Candidate: store.RuntimeModelCandidate{
Provider: "openai", BaseURL: server.URL, ProviderModelName: "provider-chat",
Credentials: map[string]any{"apiKey": "test-key"},
},
})
if err != nil {
t.Fatal(err)
}
want := jsonRoundTripMap(t, requestBody)
delete(want, "request_id")
want["model"] = "provider-chat"
if !reflect.DeepEqual(captured, want) {
t.Fatalf("native Chat request changed\n got: %#v\nwant: %#v", captured, want)
}
if idempotencyKey != "gateway-task-id" {
t.Fatalf("missing Gateway task idempotency key: %q", idempotencyKey)
}
}
func TestOpenAIResponsesNativeDeepPassthroughPreservesOfficialAndFutureFields(t *testing.T) {
requestBody := completeResponsesPassthroughBody()
var captured map[string]any
var idempotencyKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idempotencyKey = r.Header.Get("Idempotency-Key")
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "resp-native", "object": "response", "status": "completed", "output": []any{},
})
}))
defer server.Close()
_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "responses", Model: "public-responses", Body: requestBody, OriginalBody: requestBody,
UpstreamProtocol: ProtocolOpenAIResponses, UpstreamPreviousResponseID: "resp_upstream_parent",
UpstreamIdempotencyKey: "gateway-task-id",
Candidate: store.RuntimeModelCandidate{
Provider: "openai", BaseURL: server.URL, ProviderModelName: "provider-responses",
Credentials: map[string]any{"apiKey": "test-key"},
},
})
if err != nil {
t.Fatal(err)
}
want := jsonRoundTripMap(t, requestBody)
delete(want, "request_id")
want["model"] = "provider-responses"
want["previous_response_id"] = "resp_upstream_parent"
if !reflect.DeepEqual(captured, want) {
t.Fatalf("native Responses request changed\n got: %#v\nwant: %#v", captured, want)
}
if idempotencyKey != "gateway-task-id" {
t.Fatalf("missing Gateway task idempotency key: %q", idempotencyKey)
}
}
func completeChatPassthroughBody() map[string]any {
return map[string]any{
"model": "caller-chat", "messages": []any{
map[string]any{"role": "assistant", "content": nil, "function_call": map[string]any{"name": "legacy", "arguments": "{\"x\":1}"}},
map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{
map[string]any{"id": "call_function", "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{\"q\":1}"}},
map[string]any{"id": "call_custom", "type": "custom", "custom": map[string]any{"name": "shell", "input": "pwd"}},
}},
},
"audio": map[string]any{"format": "wav", "voice": "alloy"}, "frequency_penalty": 0.1,
"function_call": map[string]any{"name": "legacy"}, "functions": []any{map[string]any{"name": "legacy", "parameters": map[string]any{"type": "object"}}},
"logit_bias": map[string]any{"1": 2}, "logprobs": true, "max_completion_tokens": 101, "max_tokens": 102,
"metadata": map[string]any{"trace": "1"}, "modalities": []any{"text"}, "moderation": map[string]any{"type": "auto"}, "n": 1,
"parallel_tool_calls": true, "prediction": map[string]any{"type": "content", "content": "answer"}, "presence_penalty": 0.2,
"prompt_cache_key": "cache", "prompt_cache_options": map[string]any{"type": "ephemeral"}, "prompt_cache_retention": "in_memory",
"reasoning_effort": "low", "response_format": map[string]any{"type": "json_object"}, "safety_identifier": "safe", "seed": 7,
"service_tier": "default", "stop": []any{"END"}, "store": false, "stream": false,
"stream_options": map[string]any{"include_usage": true}, "temperature": 0.7,
"tool_choice": map[string]any{"type": "custom", "custom": map[string]any{"name": "shell"}},
"tools": []any{
map[string]any{"type": "function", "function": map[string]any{"name": "lookup", "parameters": map[string]any{"type": "object"}}},
map[string]any{"type": "custom", "custom": map[string]any{"name": "shell", "format": map[string]any{"type": "text"}}},
},
"top_logprobs": 2, "top_p": 0.9, "user": "user-1", "verbosity": "low",
"web_search_options": map[string]any{"search_context_size": "low"},
"future_official_field": map[string]any{"nested": []any{map[string]any{"keep": true}}},
"request_id": "gateway-only",
}
}
func completeResponsesPassthroughBody() map[string]any {
return map[string]any{
"background": false, "context_management": map[string]any{"type": "compaction", "compact_threshold": 2000},
"conversation": "conv_1", "include": []any{"message.output_text.logprobs"},
"input": []any{map[string]any{"type": "message", "role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "hello", "prompt_cache_breakpoint": map[string]any{"type": "ephemeral"}},
}}},
"instructions": "be concise", "max_output_tokens": 200, "max_tool_calls": 3, "metadata": map[string]any{"trace": "1"},
"model": "caller-responses", "moderation": map[string]any{"type": "auto"}, "parallel_tool_calls": true,
"previous_response_id": "resp_caller_parent", "prompt": map[string]any{"id": "pmpt_1", "variables": map[string]any{"x": "y"}},
"prompt_cache_key": "cache", "prompt_cache_options": map[string]any{"type": "ephemeral"}, "prompt_cache_retention": "24h",
"reasoning": map[string]any{"effort": "low", "summary": "auto"}, "safety_identifier": "safe", "service_tier": "default",
"store": true, "stream": false, "stream_options": map[string]any{"include_obfuscation": true}, "temperature": 0.7,
"text": map[string]any{"format": map[string]any{"type": "json_schema", "name": "answer", "schema": map[string]any{"type": "object"}}, "verbosity": "low"},
"tool_choice": map[string]any{"type": "custom", "name": "shell"},
"tools": []any{map[string]any{"type": "custom", "name": "shell", "description": "run", "format": map[string]any{"type": "text"}}},
"top_logprobs": 2, "top_p": 0.9, "truncation": "auto", "user": "user-1",
"future_official_field": map[string]any{"nested": []any{map[string]any{"keep": true}}},
"request_id": "gateway-only",
}
}
func jsonRoundTripMap(t *testing.T, value map[string]any) map[string]any {
t.Helper()
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
return out
}