原生 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 因本地没有已启用的平台模型候选而未完成
641 lines
32 KiB
Go
641 lines
32 KiB
Go
package clients
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
func TestOpenAIResponsesNativeUsesResponsesEndpointAndPreservesVendorIDs(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/responses" {
|
|
t.Fatalf("expected /responses, got %s", r.URL.Path)
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["messages"] != nil {
|
|
t.Fatalf("native Responses request must not contain messages: %+v", body)
|
|
}
|
|
input, _ := body["input"].([]any)
|
|
if len(input) != 1 {
|
|
t.Fatalf("native Responses request must translate controlled messages to input: %+v", body)
|
|
}
|
|
if body["previous_response_id"] != "resp_upstream_parent" {
|
|
t.Fatalf("expected translated upstream previous id, got %+v", body["previous_response_id"])
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "resp_upstream_child", "object": "response", "status": "completed", "previous_response_id": "resp_upstream_parent",
|
|
"output": []any{
|
|
map[string]any{
|
|
"type": "message",
|
|
"content": []any{map[string]any{"type": "output_text", "text": "ok"}},
|
|
},
|
|
},
|
|
"usage": map[string]any{"input_tokens": 2, "output_tokens": 1, "total_tokens": 3},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
|
Kind: "responses", Model: "Demo",
|
|
Body: map[string]any{"messages": []any{map[string]any{"role": "user", "content": "hello"}}},
|
|
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
|
UpstreamProtocol: ProtocolOpenAIResponses, PublicResponseID: "resp_12345678901234567890123456789012",
|
|
PublicPreviousResponseID: "resp_abcdefghijklmnopqrstuvwxyz123456", UpstreamPreviousResponseID: "resp_upstream_parent",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.Result["id"] != "resp_upstream_child" || response.Result["previous_response_id"] != "resp_upstream_parent" {
|
|
t.Fatalf("vendor response ids were not preserved: %+v", response.Result)
|
|
}
|
|
if response.PublicResponseID != "resp_upstream_child" || response.UpstreamResponseID != "resp_upstream_child" || response.UpstreamEndpoint != "/responses" || response.ResponseConverted {
|
|
t.Fatalf("unexpected native response metadata: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIResponsesChatFallbackPreservesHistoryToolsUsageAndReasoningInternally(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/chat/completions" {
|
|
t.Fatalf("expected /chat/completions, got %s", r.URL.Path)
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
messages, _ := body["messages"].([]any)
|
|
if len(messages) != 4 {
|
|
t.Fatalf("expected prior user/assistant plus current system/user/tool messages, got %+v", messages)
|
|
}
|
|
if body["max_completion_tokens"] != float64(128) || body["reasoning_effort"] != "high" {
|
|
t.Fatalf("expected mapped max/reasoning fields: %+v", body)
|
|
}
|
|
tools, _ := body["tools"].([]any)
|
|
if len(tools) != 1 {
|
|
t.Fatalf("expected mapped function tool: %+v", body["tools"])
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "chatcmpl-upstream", "object": "chat.completion", "created": 1710000000, "model": "demo",
|
|
"choices": []any{map[string]any{"index": 0, "finish_reason": "tool_calls", "message": map[string]any{
|
|
"role": "assistant", "content": "visible", "reasoning_content": "hidden reasoning",
|
|
"tool_calls": []any{map[string]any{"id": "call_stable", "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{\"x\":1}"}}},
|
|
}}},
|
|
"usage": map[string]any{"prompt_tokens": 20, "completion_tokens": 5, "total_tokens": 25, "prompt_tokens_details": map[string]any{"cached_tokens": 7}},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
|
Kind: "responses", Model: "Demo", Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
|
UpstreamProtocol: ProtocolOpenAIChatCompletions, PublicResponseID: "resp_12345678901234567890123456789012",
|
|
PreviousResponseTurns: []ResponseTurn{{
|
|
Request: map[string]any{"input": "prior user", "instructions": "must not be replayed"},
|
|
Internal: map[string]any{
|
|
"choices": []any{map[string]any{
|
|
"message": map[string]any{"role": "assistant", "content": "prior assistant"},
|
|
}},
|
|
},
|
|
}},
|
|
Body: map[string]any{
|
|
"instructions": "current instruction", "previous_response_id": "resp_parent", "input": []any{map[string]any{"type": "function_call_output", "call_id": "call_prior", "output": "done"}},
|
|
"max_output_tokens": 128, "reasoning": map[string]any{"effort": "high"},
|
|
"tools": []any{map[string]any{"type": "function", "name": "lookup", "description": "demo", "parameters": map[string]any{"type": "object"}}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !response.ResponseConverted || response.UpstreamProtocol != ProtocolOpenAIChatCompletions || response.UpstreamEndpoint != "/chat/completions" {
|
|
t.Fatalf("unexpected conversion metadata: %+v", response)
|
|
}
|
|
if response.Result["previous_response_id"] != "resp_parent" {
|
|
t.Fatalf("Chat fallback response lost previous_response_id: %+v", response.Result)
|
|
}
|
|
output, _ := response.Result["output"].([]any)
|
|
if len(output) != 2 {
|
|
t.Fatalf("expected message plus function call: %+v", response.Result)
|
|
}
|
|
call, _ := output[1].(map[string]any)
|
|
if call["call_id"] != "call_stable" || call["arguments"] != "{\"x\":1}" {
|
|
t.Fatalf("function call continuity lost: %+v", call)
|
|
}
|
|
if strings.Contains(string(mustJSON(t, response.Result)), "hidden reasoning") {
|
|
t.Fatalf("visible Responses result leaked reasoning: %+v", response.Result)
|
|
}
|
|
if !strings.Contains(string(mustJSON(t, response.InternalResult)), "hidden reasoning") {
|
|
t.Fatalf("internal snapshot must retain reasoning for continuation: %+v", response.InternalResult)
|
|
}
|
|
usage, _ := response.Result["usage"].(map[string]any)
|
|
details, _ := usage["input_tokens_details"].(map[string]any)
|
|
if details["cached_tokens"] != 7 {
|
|
t.Fatalf("cached usage was not preserved: %+v", usage)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIResponsesNativeStreamForwardsEventsWithoutChatAggregation(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_upstream\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n"))
|
|
_, _ = w.Write([]byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"response_id\":\"resp_upstream\",\"delta\":\"hello\"}\n\n"))
|
|
_, _ = w.Write([]byte("event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_upstream\",\"object\":\"response\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"))
|
|
}))
|
|
defer server.Close()
|
|
events := make([]StreamDeltaEvent, 0)
|
|
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
|
Kind: "responses", Model: "Demo", Body: map[string]any{"input": "hello", "stream": true}, Stream: true,
|
|
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
|
UpstreamProtocol: ProtocolOpenAIResponses, PublicResponseID: "resp_12345678901234567890123456789012",
|
|
StreamDelta: func(event StreamDeltaEvent) error { events = append(events, event); return nil },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(events) != 3 || events[0].Event["type"] != "response.created" || events[1].Event["type"] != "response.output_text.delta" || events[2].Event["type"] != "response.completed" {
|
|
t.Fatalf("unexpected forwarded events: %+v", events)
|
|
}
|
|
if response.Result["object"] != "response" || response.Result["choices"] != nil || response.UpstreamResponseID != "resp_upstream" {
|
|
t.Fatalf("native stream was rewritten as Chat: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIResponsesNativeStreamAcceptsIncompleteTerminalEvent(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("event: response.incomplete\ndata: {\"type\":\"response.incomplete\",\"response\":{\"id\":\"resp_incomplete\",\"object\":\"response\",\"status\":\"incomplete\",\"output\":[],\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n"))
|
|
}))
|
|
defer server.Close()
|
|
events := make([]StreamDeltaEvent, 0, 1)
|
|
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
|
Kind: "responses", Model: "Demo", Body: map[string]any{"input": "hello", "stream": true}, Stream: true,
|
|
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
|
UpstreamProtocol: ProtocolOpenAIResponses,
|
|
StreamDelta: func(event StreamDeltaEvent) error { events = append(events, event); return nil },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(events) != 1 || events[0].Event["type"] != "response.incomplete" || response.Result["status"] != "incomplete" || response.UpstreamResponseID != "resp_incomplete" {
|
|
t.Fatalf("native incomplete terminal event was not preserved: response=%+v events=%+v", response, events)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIResponsesChatFallbackStreamsFunctionArgumentFragments(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/chat/completions" {
|
|
t.Fatalf("expected chat fallback endpoint, got %s", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tool\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_stream_0\",\"type\":\"function\",\"function\":{\"name\":\"lookup_x\",\"arguments\":\"{\\\"x\\\":\"}},{\"index\":1,\"id\":\"call_stream_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup_y\",\"arguments\":\"{\\\"y\\\":\"}}]},\"finish_reason\":null}]}\n\n"))
|
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tool\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"1}\"}},{\"index\":1,\"function\":{\"arguments\":\"2}\"}}]},\"finish_reason\":null}]}\n\n"))
|
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tool\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}\n\n"))
|
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
events := make([]StreamDeltaEvent, 0)
|
|
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
|
Kind: "responses", Model: "Demo", Body: map[string]any{
|
|
"input": "call it", "stream": true,
|
|
"tools": []any{map[string]any{"type": "function", "name": "lookup", "parameters": map[string]any{"type": "object"}}},
|
|
}, Stream: true,
|
|
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
|
UpstreamProtocol: ProtocolOpenAIChatCompletions, PublicResponseID: "resp_12345678901234567890123456789012",
|
|
StreamDelta: func(event StreamDeltaEvent) error { events = append(events, event); return nil },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
types := make([]string, 0, len(events))
|
|
fragments := map[string]string{}
|
|
outputIndexes := map[int]bool{}
|
|
for _, event := range events {
|
|
eventType := stringFromAny(event.Event["type"])
|
|
types = append(types, eventType)
|
|
if sequence := intFromAny(event.Event["sequence_number"]); sequence != len(types)-1 {
|
|
t.Fatalf("converted event sequence is not contiguous at %d: %+v", len(types)-1, event.Event)
|
|
}
|
|
if eventType == "response.function_call_arguments.delta" {
|
|
itemID := stringFromAny(event.Event["item_id"])
|
|
fragments[itemID] += stringFromAny(event.Event["delta"])
|
|
outputIndexes[intFromAny(event.Event["output_index"])] = true
|
|
}
|
|
}
|
|
if !containsTestString(types, "response.created") || !containsTestString(types, "response.in_progress") || !containsTestString(types, "response.output_item.added") || !containsTestString(types, "response.function_call_arguments.done") || len(fragments) != 2 || len(outputIndexes) != 2 {
|
|
t.Fatalf("unexpected converted stream events types=%v fragments=%v indexes=%v", types, fragments, outputIndexes)
|
|
}
|
|
output, _ := response.Result["output"].([]any)
|
|
if len(output) != 2 {
|
|
t.Fatalf("expected two final function calls: %+v", response.Result)
|
|
}
|
|
first, _ := output[0].(map[string]any)
|
|
second, _ := output[1].(map[string]any)
|
|
if first["call_id"] != "call_stream_0" || first["arguments"] != "{\"x\":1}" || second["call_id"] != "call_stream_1" || second["arguments"] != "{\"y\":2}" {
|
|
t.Fatalf("parallel streamed calls were not aggregated: %+v", output)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIResponsesChatFallbackStreamsCustomRefusalAndLogprobsEndToEnd(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-custom\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\",\"refusal\":\"no\",\"annotations\":[{\"type\":\"url_citation\"}],\"tool_calls\":[{\"index\":0,\"id\":\"call_custom\",\"type\":\"custom\",\"custom\":{\"name\":\"shell\",\"input\":\"pw\"}}]},\"logprobs\":{\"content\":[{\"token\":\"hi\",\"logprob\":-0.1}]},\"finish_reason\":null}]}\n\n"))
|
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-custom\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"type\":\"custom\",\"custom\":{\"input\":\"d\"}}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}\n\n"))
|
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
events := make([]StreamDeltaEvent, 0)
|
|
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
|
Kind: "responses", Model: "Demo", Body: map[string]any{
|
|
"input": "call it", "stream": true, "include": []any{"message.output_text.logprobs"},
|
|
"tools": []any{map[string]any{"type": "custom", "name": "shell", "format": map[string]any{"type": "text"}}},
|
|
}, Stream: true,
|
|
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
|
UpstreamProtocol: ProtocolOpenAIChatCompletions, PublicResponseID: "resp_12345678901234567890123456789012",
|
|
StreamDelta: func(event StreamDeltaEvent) error { events = append(events, event); return nil },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
output, _ := response.Result["output"].([]any)
|
|
if len(output) != 2 {
|
|
t.Fatalf("expected message and custom tool call: %+v", response.Result)
|
|
}
|
|
message, _ := output[0].(map[string]any)
|
|
parts, _ := message["content"].([]any)
|
|
text, _ := parts[0].(map[string]any)
|
|
refusal, _ := parts[1].(map[string]any)
|
|
custom, _ := output[1].(map[string]any)
|
|
if custom["type"] != "custom_tool_call" || custom["call_id"] != "call_custom" || custom["input"] != "pwd" {
|
|
t.Fatalf("custom tool stream was not aggregated: %+v", output)
|
|
}
|
|
if refusal["refusal"] != "no" || len(text["logprobs"].([]any)) != 1 || len(text["annotations"].([]any)) != 1 {
|
|
t.Fatalf("refusal/logprobs were not aggregated: %+v", message)
|
|
}
|
|
types := make([]string, 0, len(events))
|
|
for _, event := range events {
|
|
types = append(types, stringFromAny(event.Event["type"]))
|
|
}
|
|
for _, required := range []string{"response.custom_tool_call_input.delta", "response.custom_tool_call_input.done", "response.refusal.delta", "response.refusal.done", "response.completed"} {
|
|
if !containsTestString(types, required) {
|
|
t.Fatalf("missing %s in converted stream: %v", required, types)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChatResultToResponseMapsIncompleteFinishReason(t *testing.T) {
|
|
response := ChatResultToResponse(map[string]any{
|
|
"choices": []any{map[string]any{"finish_reason": "length", "message": map[string]any{"role": "assistant", "content": "partial"}}},
|
|
}, "resp_12345678901234567890123456789012", "demo", map[string]any{})
|
|
if response["status"] != "incomplete" {
|
|
t.Fatalf("expected incomplete response, got %+v", response)
|
|
}
|
|
details, _ := response["incomplete_details"].(map[string]any)
|
|
if details["reason"] != "max_output_tokens" {
|
|
t.Fatalf("finish reason was not mapped: %+v", details)
|
|
}
|
|
}
|
|
|
|
func TestChatResultToResponseConvertsLegacyFunctionCall(t *testing.T) {
|
|
response := ChatResultToResponse(map[string]any{
|
|
"choices": []any{map[string]any{"finish_reason": "function_call", "message": map[string]any{
|
|
"role": "assistant", "content": nil,
|
|
"function_call": map[string]any{"name": "legacy_lookup", "arguments": "{\"city\":\"Paris\"}"},
|
|
}}},
|
|
}, "resp_12345678901234567890123456789012", "demo", map[string]any{})
|
|
output, _ := response["output"].([]any)
|
|
if len(output) != 1 {
|
|
t.Fatalf("legacy function_call was lost: %+v", response)
|
|
}
|
|
call, _ := output[0].(map[string]any)
|
|
if call["type"] != "function_call" || call["name"] != "legacy_lookup" || call["arguments"] != "{\"city\":\"Paris\"}" {
|
|
t.Fatalf("legacy function_call was not converted: %+v", call)
|
|
}
|
|
}
|
|
|
|
func TestNativeResponsesStreamSupportsMultilineDataFrames(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("event: response.completed\n"))
|
|
_, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\n"))
|
|
_, _ = w.Write([]byte("data: \"response\":{\"id\":\"resp_vendor-with-dash\",\"object\":\"response\",\"status\":\"completed\",\"output\":[]}}\n\n"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
|
Kind: "responses", Model: "Demo", Body: map[string]any{"input": "hello", "stream": true}, Stream: true,
|
|
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
|
UpstreamProtocol: ProtocolOpenAIResponses,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.Result["id"] != "resp_vendor-with-dash" {
|
|
t.Fatalf("multiline frame was not decoded: %+v", response.Result)
|
|
}
|
|
}
|
|
|
|
func TestChatResponsesStreamAdapterEmitsTextItemLifecycle(t *testing.T) {
|
|
adapter := newChatResponsesStreamAdapter("resp_12345678901234567890123456789012", "demo")
|
|
events := make([]string, 0)
|
|
onDelta := func(event StreamDeltaEvent) error {
|
|
events = append(events, stringFromAny(event.Event["type"]))
|
|
return nil
|
|
}
|
|
err := adapter.delta(StreamDeltaEvent{Event: map[string]any{
|
|
"choices": []any{map[string]any{"delta": map[string]any{"content": "hello"}}},
|
|
}}, onDelta)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := ChatResultToResponse(map[string]any{
|
|
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "hello"}}},
|
|
}, adapter.publicID, "demo", map[string]any{})
|
|
if err := adapter.done(result, onDelta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{
|
|
"response.created", "response.in_progress", "response.output_item.added", "response.content_part.added",
|
|
"response.output_text.delta", "response.output_text.done", "response.content_part.done", "response.output_item.done",
|
|
"response.completed",
|
|
}
|
|
if strings.Join(events, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("unexpected text event lifecycle got=%v want=%v", events, want)
|
|
}
|
|
}
|
|
|
|
func TestResponsesChatFallbackRejectsBuiltInToolsAndUnknownParameters(t *testing.T) {
|
|
_, err := ResponsesRequestToChat(map[string]any{"input": "hello", "tools": []any{map[string]any{"type": "web_search_preview"}}}, nil)
|
|
if ErrorCode(err) != "unsupported_response_parameter" || ErrorParam(err) != "tools[0].type" {
|
|
t.Fatalf("expected precise unsupported_response_parameter, got %v param=%q", err, ErrorParam(err))
|
|
}
|
|
_, err = ResponsesRequestToChat(map[string]any{"input": "hello", "conversation": "conv_1"}, nil)
|
|
if ErrorCode(err) != "unsupported_response_parameter" {
|
|
t.Fatalf("expected unsupported_response_parameter, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResponsesChatFallbackKeepsClientManagedStateAuthoritative(t *testing.T) {
|
|
input := []any{
|
|
map[string]any{"type": "message", "role": "user", "content": "first"},
|
|
map[string]any{"type": "message", "role": "assistant", "content": "second"},
|
|
map[string]any{"type": "message", "role": "user", "content": "third"},
|
|
}
|
|
body, err := ResponsesRequestToChat(map[string]any{"input": input}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
messages, _ := body["messages"].([]any)
|
|
if len(messages) != len(input) {
|
|
t.Fatalf("client-managed input was supplemented by Gateway history: %+v", messages)
|
|
}
|
|
for index, raw := range messages {
|
|
message, _ := raw.(map[string]any)
|
|
expected, _ := input[index].(map[string]any)
|
|
if message["role"] != expected["role"] || message["content"] != expected["content"] {
|
|
t.Fatalf("client-managed message %d changed: got=%+v want=%+v", index, message, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResponsesChatFallbackMapsMultimodalCustomToolsAndCallHistory(t *testing.T) {
|
|
body, err := ResponsesRequestToChat(map[string]any{
|
|
"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"}},
|
|
map[string]any{"type": "input_image", "image_url": "https://example.com/a.png", "detail": "high"},
|
|
map[string]any{"type": "input_file", "file_id": "file_1", "filename": "a.pdf"},
|
|
map[string]any{"type": "input_audio", "input_audio": map[string]any{"data": "AAAA", "format": "wav"}},
|
|
}},
|
|
map[string]any{"type": "function_call", "call_id": "call_fn", "name": "lookup", "arguments": "{\"x\":1}"},
|
|
map[string]any{"type": "function_call_output", "call_id": "call_fn", "output": map[string]any{"ok": true}},
|
|
map[string]any{"type": "custom_tool_call", "call_id": "call_custom", "name": "shell", "input": "pwd"},
|
|
map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "done"},
|
|
},
|
|
"tools": []any{
|
|
map[string]any{"type": "function", "name": "lookup", "parameters": map[string]any{"type": "object"}, "strict": true},
|
|
map[string]any{"type": "custom", "name": "shell", "description": "run", "format": map[string]any{"type": "text"}},
|
|
},
|
|
"tool_choice": map[string]any{"type": "custom", "name": "shell"},
|
|
"include": []any{"message.output_text.logprobs"}, "max_output_tokens": 256,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["max_completion_tokens"] != 256 || body["logprobs"] != true {
|
|
t.Fatalf("token/logprobs mapping is incomplete: %+v", body)
|
|
}
|
|
messages, _ := body["messages"].([]any)
|
|
if len(messages) != 5 {
|
|
t.Fatalf("call history conversion changed item ownership: %+v", messages)
|
|
}
|
|
first, _ := messages[0].(map[string]any)
|
|
parts, _ := first["content"].([]any)
|
|
if len(parts) != 4 {
|
|
t.Fatalf("multimodal content was dropped: %+v", parts)
|
|
}
|
|
text, _ := parts[0].(map[string]any)
|
|
image, _ := parts[1].(map[string]any)
|
|
imageURL, _ := image["image_url"].(map[string]any)
|
|
file, _ := parts[2].(map[string]any)
|
|
fileValue, _ := file["file"].(map[string]any)
|
|
if text["prompt_cache_breakpoint"] == nil || imageURL["detail"] != "high" || fileValue["file_id"] != "file_1" || fileValue["filename"] != "a.pdf" {
|
|
t.Fatalf("multimodal nested fields changed: %+v", parts)
|
|
}
|
|
customMessage, _ := messages[3].(map[string]any)
|
|
customCalls, _ := customMessage["tool_calls"].([]any)
|
|
customCall, _ := customCalls[0].(map[string]any)
|
|
custom, _ := customCall["custom"].(map[string]any)
|
|
if customCall["type"] != "custom" || custom["name"] != "shell" || custom["input"] != "pwd" {
|
|
t.Fatalf("custom call history changed: %+v", customMessage)
|
|
}
|
|
tools, _ := body["tools"].([]any)
|
|
customTool, _ := tools[1].(map[string]any)
|
|
if customTool["type"] != "custom" || customTool["custom"] == nil {
|
|
t.Fatalf("custom tool definition changed: %+v", tools)
|
|
}
|
|
choice, _ := body["tool_choice"].(map[string]any)
|
|
if choice["type"] != "custom" || choice["custom"] == nil {
|
|
t.Fatalf("custom tool choice changed: %+v", choice)
|
|
}
|
|
}
|
|
|
|
func TestResponsesChatFallbackMapsTextToolOutputAndRejectsNativeOnlyToolOutputContent(t *testing.T) {
|
|
body, err := ResponsesRequestToChat(map[string]any{"input": []any{map[string]any{
|
|
"type": "function_call_output", "call_id": "call_1", "output": []any{map[string]any{
|
|
"type": "input_text", "text": "done", "prompt_cache_breakpoint": map[string]any{"mode": "explicit"},
|
|
}},
|
|
}}}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
messages, _ := body["messages"].([]any)
|
|
message, _ := messages[0].(map[string]any)
|
|
parts, _ := message["content"].([]any)
|
|
part, _ := parts[0].(map[string]any)
|
|
if part["type"] != "text" || part["text"] != "done" || part["prompt_cache_breakpoint"] == nil {
|
|
t.Fatalf("text tool output was not preserved: %+v", message)
|
|
}
|
|
|
|
err = ValidateResponsesChatFallback(map[string]any{"input": []any{map[string]any{
|
|
"type": "function_call_output", "call_id": "call_1", "output": []any{map[string]any{
|
|
"type": "input_image", "image_url": "https://example.com/a.png", "detail": "high",
|
|
}},
|
|
}}})
|
|
if ErrorCode(err) != "unsupported_response_parameter" || ErrorParam(err) != "input[0].output[0].type" {
|
|
t.Fatalf("native-only tool output must be rejected precisely, got %v param=%q", err, ErrorParam(err))
|
|
}
|
|
}
|
|
|
|
func TestResponsesChatFallbackAcceptsOnlyNoopNativeFields(t *testing.T) {
|
|
if err := ValidateResponsesChatFallback(map[string]any{
|
|
"input": "hello", "background": false, "context_management": map[string]any{}, "conversation": "",
|
|
"prompt": nil, "truncation": "disabled", "reasoning": map[string]any{"summary": nil},
|
|
}); err != nil {
|
|
t.Fatalf("no-op native fields should be accepted: %v", err)
|
|
}
|
|
for param, value := range map[string]any{
|
|
"background": true, "conversation": "conv_1", "max_tool_calls": 2, "prompt": map[string]any{"id": "pmpt_1"}, "truncation": "auto",
|
|
} {
|
|
err := ValidateResponsesChatFallback(map[string]any{"input": "hello", param: value})
|
|
if ErrorCode(err) != "unsupported_response_parameter" || ErrorParam(err) != param {
|
|
t.Fatalf("expected precise rejection for %s, got %v param=%q", param, err, ErrorParam(err))
|
|
}
|
|
}
|
|
err := ValidateResponsesChatFallback(map[string]any{"input": []any{map[string]any{
|
|
"type": "custom_tool_call", "call_id": "call_1", "name": "tool", "input": "x", "namespace": "native-only",
|
|
}}})
|
|
if ErrorCode(err) != "unsupported_response_parameter" || ErrorParam(err) != "input[0].namespace" {
|
|
t.Fatalf("custom call namespace must be rejected precisely, got %v param=%q", err, ErrorParam(err))
|
|
}
|
|
}
|
|
|
|
func TestChatResultToResponseBuildsCompleteResponseWithRefusalLogprobsAndCustomTool(t *testing.T) {
|
|
request := map[string]any{
|
|
"background": false, "conversation": nil, "instructions": "be concise", "max_output_tokens": 300,
|
|
"max_tool_calls": nil, "metadata": map[string]any{"trace": "1"}, "moderation": map[string]any{"type": "auto"},
|
|
"parallel_tool_calls": true, "previous_response_id": "resp_parent", "prompt": nil, "prompt_cache_key": "cache",
|
|
"prompt_cache_options": map[string]any{"type": "ephemeral"}, "prompt_cache_retention": "in_memory",
|
|
"reasoning": map[string]any{"effort": "low"}, "safety_identifier": "safe", "service_tier": "default",
|
|
"temperature": 0.4, "text": map[string]any{"verbosity": "low"}, "tool_choice": "auto", "tools": []any{},
|
|
"top_logprobs": 2, "top_p": 0.9, "truncation": "disabled", "user": "user-1",
|
|
}
|
|
response := ChatResultToResponse(map[string]any{
|
|
"created": 1710000000, "service_tier": "priority", "moderation": map[string]any{"flagged": false},
|
|
"choices": []any{map[string]any{
|
|
"finish_reason": "stop", "logprobs": map[string]any{"content": []any{map[string]any{"token": "hello", "logprob": -0.1}}},
|
|
"message": map[string]any{
|
|
"role": "assistant", "content": "hello", "refusal": "cannot continue", "annotations": []any{map[string]any{"type": "url_citation"}},
|
|
"tool_calls": []any{
|
|
map[string]any{"id": "call_fn", "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{\"x\":1}"}},
|
|
map[string]any{"id": "call_custom", "type": "custom", "custom": map[string]any{"name": "shell", "input": "pwd"}},
|
|
},
|
|
},
|
|
}},
|
|
"usage": map[string]any{"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5},
|
|
}, "resp_12345678901234567890123456789012", "demo", request)
|
|
for _, key := range []string{
|
|
"id", "object", "created_at", "status", "completed_at", "error", "incomplete_details", "instructions", "metadata", "model", "output",
|
|
"parallel_tool_calls", "temperature", "tool_choice", "tools", "top_p", "background", "conversation", "max_output_tokens", "max_tool_calls",
|
|
"moderation", "output_text", "previous_response_id", "prompt", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention", "reasoning",
|
|
"safety_identifier", "service_tier", "text", "top_logprobs", "truncation", "usage", "user",
|
|
} {
|
|
if _, ok := response[key]; !ok {
|
|
t.Fatalf("complete fallback Response omitted %q: %+v", key, response)
|
|
}
|
|
}
|
|
output, _ := response["output"].([]any)
|
|
if len(output) != 3 {
|
|
t.Fatalf("expected message, function, and custom output items: %+v", output)
|
|
}
|
|
message, _ := output[0].(map[string]any)
|
|
parts, _ := message["content"].([]any)
|
|
text, _ := parts[0].(map[string]any)
|
|
refusal, _ := parts[1].(map[string]any)
|
|
custom, _ := output[2].(map[string]any)
|
|
if len(text["logprobs"].([]any)) != 1 || len(text["annotations"].([]any)) != 1 || refusal["type"] != "refusal" || custom["type"] != "custom_tool_call" || custom["input"] != "pwd" {
|
|
t.Fatalf("Response content/tool details changed: %+v", output)
|
|
}
|
|
if response["service_tier"] != "priority" {
|
|
t.Fatalf("upstream response service tier was not preserved: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestChatResponsesStreamAdapterEmitsRefusalLogprobsAndCustomToolLifecycle(t *testing.T) {
|
|
adapter := newChatResponsesStreamAdapter("resp_12345678901234567890123456789012", "demo")
|
|
events := make([]map[string]any, 0)
|
|
onDelta := func(event StreamDeltaEvent) error {
|
|
events = append(events, event.Event)
|
|
return nil
|
|
}
|
|
if err := adapter.delta(StreamDeltaEvent{Event: map[string]any{"choices": []any{map[string]any{
|
|
"logprobs": map[string]any{"content": []any{map[string]any{"token": "hi", "logprob": -0.1}}},
|
|
"delta": map[string]any{
|
|
"content": "hi", "refusal": "no",
|
|
"tool_calls": []any{map[string]any{"index": 0, "id": "call_custom", "type": "custom", "custom": map[string]any{"name": "shell", "input": "pw"}}},
|
|
},
|
|
}}}}, onDelta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := adapter.delta(StreamDeltaEvent{Event: map[string]any{"choices": []any{map[string]any{
|
|
"delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "type": "custom", "custom": map[string]any{"input": "d"}}}},
|
|
}}}}, onDelta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := ChatResultToResponse(map[string]any{"choices": []any{map[string]any{
|
|
"message": map[string]any{"role": "assistant", "content": "hi", "refusal": "no", "tool_calls": []any{
|
|
map[string]any{"id": "call_custom", "type": "custom", "custom": map[string]any{"name": "shell", "input": "pwd"}},
|
|
}},
|
|
}}}, adapter.publicID, "demo", map[string]any{})
|
|
if err := adapter.done(result, onDelta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
types := make([]string, 0, len(events))
|
|
var sawLogprobs bool
|
|
for index, event := range events {
|
|
types = append(types, stringFromAny(event["type"]))
|
|
if intFromAny(event["sequence_number"]) != index {
|
|
t.Fatalf("non-contiguous sequence at %d: %+v", index, event)
|
|
}
|
|
if event["type"] == "response.output_text.delta" {
|
|
logprobs, _ := event["logprobs"].([]any)
|
|
sawLogprobs = len(logprobs) == 1
|
|
}
|
|
}
|
|
for _, required := range []string{
|
|
"response.refusal.delta", "response.refusal.done", "response.custom_tool_call_input.delta",
|
|
"response.custom_tool_call_input.done", "response.output_item.done", "response.completed",
|
|
} {
|
|
if !containsTestString(types, required) {
|
|
t.Fatalf("missing %s lifecycle event: %v", required, types)
|
|
}
|
|
}
|
|
if !sawLogprobs {
|
|
t.Fatalf("streamed output_text delta lost logprobs: %+v", events)
|
|
}
|
|
}
|
|
|
|
func mustJSON(t *testing.T, value any) []byte {
|
|
t.Helper()
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func containsTestString(values []string, expected string) bool {
|
|
for _, value := range values {
|
|
if value == expected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|