feat(chat): 完善 Chat Completions 兼容层
This commit is contained in:
@@ -151,6 +151,187 @@ func TestOpenAIClientChatContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClientChatRequestNormalizesToolContext(t *testing.T) {
|
||||
var captured map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-normalized-request",
|
||||
"object": "chat.completion",
|
||||
"model": captured["model"],
|
||||
"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: "openai:gpt-4o-mini",
|
||||
Body: map[string]any{
|
||||
"model": "openai:gpt-4o-mini",
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "assistant",
|
||||
"functionCall": map[string]any{
|
||||
"name": "lookup",
|
||||
"arguments": map[string]any{"q": "weather"},
|
||||
},
|
||||
},
|
||||
map[string]any{"role": "tool", "toolCallId": "call_0", "content": "sunny"},
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "keep this"},
|
||||
map[string]any{"type": "tool_result", "tool_use_id": "toolu_1", "content": map[string]any{"ok": true}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ProviderModelName: "openai-compatible-gpt-4o-mini",
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run openai client: %v", err)
|
||||
}
|
||||
messages, _ := captured["messages"].([]any)
|
||||
if len(messages) != 4 {
|
||||
t.Fatalf("unexpected normalized messages: %+v", messages)
|
||||
}
|
||||
assistant, _ := messages[0].(map[string]any)
|
||||
if _, ok := assistant["functionCall"]; ok {
|
||||
t.Fatalf("functionCall should be converted away: %+v", assistant)
|
||||
}
|
||||
toolCalls, _ := assistant["tool_calls"].([]any)
|
||||
toolCall, _ := toolCalls[0].(map[string]any)
|
||||
function, _ := toolCall["function"].(map[string]any)
|
||||
if function["name"] != "lookup" || function["arguments"] != `{"q":"weather"}` {
|
||||
t.Fatalf("unexpected normalized tool call: %+v", assistant)
|
||||
}
|
||||
toolMessage, _ := messages[1].(map[string]any)
|
||||
if toolMessage["tool_call_id"] != "call_0" || toolMessage["toolCallId"] != nil {
|
||||
t.Fatalf("tool message was not normalized: %+v", toolMessage)
|
||||
}
|
||||
keptUser, _ := messages[2].(map[string]any)
|
||||
convertedToolResult, _ := messages[3].(map[string]any)
|
||||
if keptUser["content"] != "keep this" || convertedToolResult["role"] != "tool" || convertedToolResult["tool_call_id"] != "toolu_1" || convertedToolResult["content"] != `{"ok":true}` {
|
||||
t.Fatalf("tool_result block was not restored: user=%+v tool=%+v", keptUser, convertedToolResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClientChatResponseNormalizesReasoning(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-reasoning",
|
||||
"object": "chat.completion",
|
||||
"model": "openrouter-test",
|
||||
"choices": []any{map[string]any{
|
||||
"message": map[string]any{
|
||||
"role": "assistant",
|
||||
"reasoning_details": []any{
|
||||
map[string]any{"type": "reasoning.text", "text": "detail-"},
|
||||
map[string]any{"type": "reasoning.summary", "summary": "summary"},
|
||||
map[string]any{"type": "reasoning.encrypted", "data": "secret"},
|
||||
},
|
||||
"content": "<think>tagged</think>answer",
|
||||
},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "chat.completions",
|
||||
Model: "OpenRouter-Test",
|
||||
Body: map[string]any{"model": "OpenRouter-Test", "messages": []any{map[string]any{"role": "user", "content": "ping"}}},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ModelName: "openrouter-test",
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run openai client: %v", err)
|
||||
}
|
||||
choices, _ := response.Result["choices"].([]any)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
message, _ := choice["message"].(map[string]any)
|
||||
if message["reasoning_content"] != "detail-summarytagged" || message["content"] != "answer" {
|
||||
t.Fatalf("reasoning was not normalized: %+v", response.Result)
|
||||
}
|
||||
if _, ok := message["reasoning_details"]; ok {
|
||||
t.Fatalf("reasoning_details should be converted away: %+v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClientChatResponseNormalizesToolCallFormats(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-tools",
|
||||
"object": "chat.completion",
|
||||
"model": "tool-format-test",
|
||||
"choices": []any{map[string]any{
|
||||
"message": map[string]any{
|
||||
"role": "assistant",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "calling tools"},
|
||||
map[string]any{"type": "tool_use", "id": "toolu_1", "name": "anthropic_lookup", "input": map[string]any{"city": "Boston"}},
|
||||
},
|
||||
"toolCalls": []any{map[string]any{"id": "call_camel", "functionCall": map[string]any{"name": "camel_lookup", "args": map[string]any{"city": "SF"}}}},
|
||||
"function_call": map[string]any{"name": "legacy_lookup", "arguments": "{\"city\":\"NYC\"}"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "chat.completions",
|
||||
Model: "Tool-Format-Test",
|
||||
Body: map[string]any{"model": "Tool-Format-Test", "messages": []any{map[string]any{"role": "user", "content": "ping"}}},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ModelName: "tool-format-test",
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run openai client: %v", err)
|
||||
}
|
||||
choices, _ := response.Result["choices"].([]any)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
message, _ := choice["message"].(map[string]any)
|
||||
if message["content"] != "calling tools" {
|
||||
t.Fatalf("tool_use block should be removed from content: %+v", message)
|
||||
}
|
||||
for _, key := range []string{"toolCalls", "function_call"} {
|
||||
if _, ok := message[key]; ok {
|
||||
t.Fatalf("%s should be converted away: %+v", key, message)
|
||||
}
|
||||
}
|
||||
toolCalls, _ := message["tool_calls"].([]any)
|
||||
if len(toolCalls) != 3 {
|
||||
t.Fatalf("expected 3 normalized tool calls, got %+v", message)
|
||||
}
|
||||
assertToolCall := func(index int, id string, name string, arguments string) {
|
||||
t.Helper()
|
||||
toolCall, _ := toolCalls[index].(map[string]any)
|
||||
function, _ := toolCall["function"].(map[string]any)
|
||||
if toolCall["id"] != id || toolCall["type"] != "function" || function["name"] != name || function["arguments"] != arguments {
|
||||
t.Fatalf("unexpected tool call[%d]: %+v", index, toolCall)
|
||||
}
|
||||
}
|
||||
assertToolCall(0, "call_camel", "camel_lookup", "{\"city\":\"SF\"}")
|
||||
assertToolCall(1, "call_1", "legacy_lookup", "{\"city\":\"NYC\"}")
|
||||
assertToolCall(2, "toolu_1", "anthropic_lookup", "{\"city\":\"Boston\"}")
|
||||
}
|
||||
|
||||
func TestOpenAIClientChatStreamContract(t *testing.T) {
|
||||
var gotStream bool
|
||||
var gotIncludeUsage bool
|
||||
@@ -204,6 +385,133 @@ func TestOpenAIClientChatStreamContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClientChatStreamPreservesStructuredDeltas(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("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"openrouter-reasoner\",\"choices\":[{\"delta\":{\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"detail-\"},{\"type\":\"reasoning.summary\",\"summary\":\"summary\"},{\"type\":\"reasoning.encrypted\",\"data\":\"secret\"}]}}],\"usage\":null}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"openrouter-reasoner\",\"choices\":[{\"delta\":{\"content\":\"<think>tagged</think>answer\"}}],\"usage\":null}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"q\\\":\"}}]}}],\"usage\":null}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"weather\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
captured := make([]StreamDeltaEvent, 0)
|
||||
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "chat.completions",
|
||||
Model: "DeepSeek-V4",
|
||||
Body: map[string]any{
|
||||
"model": "DeepSeek-V4",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
||||
"stream": true,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ModelName: "deepseek-v4",
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
StreamDelta: func(event StreamDeltaEvent) error {
|
||||
captured = append(captured, event)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run openai structured stream client: %v", err)
|
||||
}
|
||||
if len(captured) != 4 || captured[0].ReasoningContent != "detail-summary" || captured[1].ReasoningContent != "tagged" || captured[1].Text != "answer" || captured[2].Event == nil {
|
||||
t.Fatalf("structured stream events were not preserved: %+v", captured)
|
||||
}
|
||||
firstChoices, _ := captured[0].Event["choices"].([]any)
|
||||
firstChoice, _ := firstChoices[0].(map[string]any)
|
||||
firstDelta, _ := firstChoice["delta"].(map[string]any)
|
||||
if firstDelta["reasoning_content"] != "detail-summary" {
|
||||
t.Fatalf("reasoning_details were not converted in stream event: %+v", captured[0].Event)
|
||||
}
|
||||
if _, ok := firstDelta["reasoning_details"]; ok {
|
||||
t.Fatalf("reasoning_details should be removed from stream event: %+v", captured[0].Event)
|
||||
}
|
||||
choices, _ := response.Result["choices"].([]any)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
message, _ := choice["message"].(map[string]any)
|
||||
if message["reasoning_content"] != "detail-summarytagged" || message["content"] != "answer" || choice["finish_reason"] != "tool_calls" {
|
||||
t.Fatalf("reasoning or finish reason missing from aggregated result: %+v", response.Result)
|
||||
}
|
||||
toolCalls, _ := message["tool_calls"].([]any)
|
||||
toolCall, _ := toolCalls[0].(map[string]any)
|
||||
function, _ := toolCall["function"].(map[string]any)
|
||||
if function["arguments"] != "{\"q\":\"weather\"}" {
|
||||
t.Fatalf("tool call arguments were not aggregated: %+v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClientChatStreamNormalizesToolCallFormats(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("data: {\"id\":\"chatcmpl-tools-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"tool-format-test\",\"choices\":[{\"delta\":{\"function_call\":{\"name\":\"legacy_lookup\",\"arguments\":\"{\\\"city\\\":\"}}}],\"usage\":null}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tools-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"tool-format-test\",\"choices\":[{\"delta\":{\"functionCall\":{\"arguments\":\"\\\"Boston\\\"}\"}}}],\"usage\":null}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tools-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"tool-format-test\",\"choices\":[{\"delta\":{\"toolCall\":{\"index\":1,\"id\":\"call_camel\",\"functionCall\":{\"name\":\"camel_lookup\",\"args\":{\"city\":\"SF\"}}}},\"finish_reason\":\"tool_calls\"}],\"usage\":null}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
captured := make([]StreamDeltaEvent, 0)
|
||||
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "chat.completions",
|
||||
Model: "Tool-Format-Test",
|
||||
Body: map[string]any{
|
||||
"model": "Tool-Format-Test",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
||||
"stream": true,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ModelName: "tool-format-test",
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
StreamDelta: func(event StreamDeltaEvent) error {
|
||||
captured = append(captured, event)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run openai stream client: %v", err)
|
||||
}
|
||||
if len(captured) != 3 {
|
||||
t.Fatalf("unexpected captured events: %+v", captured)
|
||||
}
|
||||
for _, event := range captured {
|
||||
choices, _ := event.Event["choices"].([]any)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
delta, _ := choice["delta"].(map[string]any)
|
||||
if _, ok := delta["function_call"]; ok {
|
||||
t.Fatalf("function_call should be converted away: %+v", event.Event)
|
||||
}
|
||||
if _, ok := delta["functionCall"]; ok {
|
||||
t.Fatalf("functionCall should be converted away: %+v", event.Event)
|
||||
}
|
||||
if _, ok := delta["toolCall"]; ok {
|
||||
t.Fatalf("toolCall should be converted away: %+v", event.Event)
|
||||
}
|
||||
}
|
||||
choices, _ := response.Result["choices"].([]any)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
message, _ := choice["message"].(map[string]any)
|
||||
toolCalls, _ := message["tool_calls"].([]any)
|
||||
if len(toolCalls) != 2 || choice["finish_reason"] != "tool_calls" {
|
||||
t.Fatalf("unexpected normalized stream result: %+v", response.Result)
|
||||
}
|
||||
legacyCall, _ := toolCalls[0].(map[string]any)
|
||||
legacyFunction, _ := legacyCall["function"].(map[string]any)
|
||||
if legacyFunction["name"] != "legacy_lookup" || legacyFunction["arguments"] != "{\"city\":\"Boston\"}" {
|
||||
t.Fatalf("legacy function_call was not aggregated: %+v", response.Result)
|
||||
}
|
||||
camelCall, _ := toolCalls[1].(map[string]any)
|
||||
camelFunction, _ := camelCall["function"].(map[string]any)
|
||||
if camelCall["id"] != "call_camel" || camelFunction["name"] != "camel_lookup" || camelFunction["arguments"] != "{\"city\":\"SF\"}" {
|
||||
t.Fatalf("camel toolCall was not normalized: %+v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiClientChatContract(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotKey string
|
||||
@@ -261,6 +569,92 @@ func TestGeminiClientChatContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiClientChatRestoresToolContext(t *testing.T) {
|
||||
var captured map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []any{map[string]any{
|
||||
"content": map[string]any{"parts": []any{map[string]any{"text": "gemini ok"}}},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "chat.completions",
|
||||
Model: "gemini:gemini-2.5-flash",
|
||||
Body: map[string]any{
|
||||
"model": "gemini:gemini-2.5-flash",
|
||||
"messages": []any{
|
||||
map[string]any{"role": "user", "content": "weather?"},
|
||||
map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "checking",
|
||||
"tool_calls": []any{map[string]any{
|
||||
"id": "call_weather",
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": "get_weather",
|
||||
"arguments": `{"city":"SF"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
map[string]any{"role": "tool", "tool_call_id": "call_weather", "content": `{"temperature":"72F"}`},
|
||||
},
|
||||
"tools": []any{map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": "get_weather",
|
||||
"description": "lookup weather",
|
||||
"parameters": map[string]any{"type": "object"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ProviderModelName: "gemini-2.5-flash",
|
||||
ModelType: "chat",
|
||||
Credentials: map[string]any{"apiKey": "gemini-key"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run gemini client: %v", err)
|
||||
}
|
||||
contents, _ := captured["contents"].([]any)
|
||||
if len(contents) != 3 {
|
||||
t.Fatalf("unexpected Gemini contents: %+v", captured)
|
||||
}
|
||||
modelTurn, _ := contents[1].(map[string]any)
|
||||
if modelTurn["role"] != "model" {
|
||||
t.Fatalf("assistant turn should become Gemini model turn: %+v", modelTurn)
|
||||
}
|
||||
modelParts, _ := modelTurn["parts"].([]any)
|
||||
callPart, _ := modelParts[1].(map[string]any)
|
||||
functionCall, _ := callPart["functionCall"].(map[string]any)
|
||||
args, _ := functionCall["args"].(map[string]any)
|
||||
if functionCall["name"] != "get_weather" || args["city"] != "SF" {
|
||||
t.Fatalf("tool call was not restored for Gemini: %+v", modelTurn)
|
||||
}
|
||||
toolTurn, _ := contents[2].(map[string]any)
|
||||
toolParts, _ := toolTurn["parts"].([]any)
|
||||
responsePart, _ := toolParts[0].(map[string]any)
|
||||
functionResponse, _ := responsePart["functionResponse"].(map[string]any)
|
||||
response, _ := functionResponse["response"].(map[string]any)
|
||||
if toolTurn["role"] != "user" || functionResponse["name"] != "get_weather" || response["temperature"] != "72F" {
|
||||
t.Fatalf("tool result was not restored for Gemini: %+v", toolTurn)
|
||||
}
|
||||
tools, _ := captured["tools"].([]any)
|
||||
declarationGroup, _ := tools[0].(map[string]any)
|
||||
declarations, _ := declarationGroup["functionDeclarations"].([]any)
|
||||
declaration, _ := declarations[0].(map[string]any)
|
||||
if declaration["name"] != "get_weather" || declaration["description"] != "lookup weather" {
|
||||
t.Fatalf("tool declaration was not converted for Gemini: %+v", captured["tools"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiURLAcceptsVersionedBaseURL(t *testing.T) {
|
||||
got := geminiURL("https://generativelanguage.googleapis.com/v1beta", "gemini-2.5-flash", "test-key")
|
||||
want := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key"
|
||||
|
||||
Reference in New Issue
Block a user