原生 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 因本地没有已启用的平台模型候选而未完成
155 lines
7.4 KiB
Go
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
|
|
}
|