ccpoad / internal /protocol /builtin /request_prompt_test.go
anyalerob's picture
Upload folder using huggingface_hub
2986042 verified
Raw
History Blame Contribute Delete
17.9 kB
package builtin
import (
"encoding/json"
"testing"
"ccLoad/internal/protocol"
"github.com/bytedance/sonic"
)
func TestNormalizeOpenAIConversation_StructuredContent(t *testing.T) {
t.Parallel()
req := openAIChatRequest{
Model: "gpt-4o",
Messages: []openAIChatMessage{
{Role: "system", Content: "be careful"},
{Role: "user", Content: []any{
map[string]any{"type": "text", "text": "hello"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/a.png", "detail": "high"}},
}},
{Role: "assistant", Content: []any{map[string]any{"type": "text", "text": "calling tool"}}, ToolCalls: []openAIChatToolCall{{ID: "call_1", Type: "function", Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: "search", Arguments: `{"query":"go"}`}}}},
{Role: "tool", ToolCallID: "call_1", Content: "done"},
},
Tools: json.RawMessage(`[{"type":"function","function":{"name":"search","description":"lookup","parameters":{"type":"object"}}}]`),
ToolChoice: json.RawMessage(`{"type":"function","function":{"name":"search"}}`),
}
conv, err := normalizeOpenAIConversation(req)
if err != nil {
t.Fatalf("normalizeOpenAIConversation failed: %v", err)
}
if len(conv.Tools) != 1 || conv.Tools[0].Name != "search" {
t.Fatalf("unexpected tools: %+v", conv.Tools)
}
if conv.ToolChoice.Mode != "named" || conv.ToolChoice.Name != "search" {
t.Fatalf("unexpected tool choice: %+v", conv.ToolChoice)
}
if len(conv.Turns) != 4 {
t.Fatalf("expected 4 turns, got %d", len(conv.Turns))
}
if len(conv.Turns[1].Parts) != 2 || conv.Turns[1].Parts[1].Kind != partKindImage {
t.Fatalf("expected user image part, got %+v", conv.Turns[1].Parts)
}
if got := conv.Turns[2].Parts[1].Kind; got != partKindToolCall {
t.Fatalf("expected assistant tool_call part, got %s", got)
}
toolResult := conv.Turns[3].Parts[0].ToolResult
if toolResult == nil || toolResult.CallID != "call_1" || toolResult.Name != "search" {
t.Fatalf("unexpected tool result: %+v", toolResult)
}
}
func TestNormalizeAnthropicConversation_StructuredContent(t *testing.T) {
t.Parallel()
req := anthropicMessagesRequest{
Model: "claude-3-5-sonnet",
System: []any{map[string]any{"type": "text", "text": "keep format"}},
Messages: []anthropicMessageContent{
{Role: "assistant", Content: []any{map[string]any{"type": "tool_use", "id": "toolu_1", "name": "lookup", "input": map[string]any{"query": "go"}}}},
{Role: "user", Content: []any{
map[string]any{"type": "text", "text": "hello"},
map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": "https://example.com/a.png", "media_type": "image/png"}},
map[string]any{"type": "document", "source": map[string]any{"type": "base64", "media_type": "application/pdf", "data": "cGRm"}, "title": "doc.pdf"},
map[string]any{"type": "tool_result", "tool_use_id": "toolu_1", "content": "done"},
}},
},
Tools: json.RawMessage(`[{"name":"lookup","description":"lookup","input_schema":{"type":"object"}}]`),
ToolChoice: json.RawMessage(`{"type":"tool","name":"lookup"}`),
}
conv, err := normalizeAnthropicConversation(req)
if err != nil {
t.Fatalf("normalizeAnthropicConversation failed: %v", err)
}
if len(conv.Turns) != 3 {
t.Fatalf("expected 3 turns, got %d", len(conv.Turns))
}
userParts := conv.Turns[2].Parts
if len(userParts) != 4 || userParts[1].Kind != partKindImage || userParts[2].Kind != partKindFile || userParts[3].Kind != partKindToolResult {
t.Fatalf("unexpected user parts: %+v", userParts)
}
if toolResult := userParts[3].ToolResult; toolResult == nil || toolResult.Name != "lookup" {
t.Fatalf("expected resolved tool result name, got %+v", toolResult)
}
}
func TestEncodeCodexRequest_DropsAnthropicToolResultIsError(t *testing.T) {
t.Parallel()
req := anthropicMessagesRequest{
Model: "gpt-5-codex",
Messages: []anthropicMessageContent{
{Role: "assistant", Content: []any{
map[string]any{
"type": "tool_use",
"id": "toolu_1",
"name": "lookup",
"input": map[string]any{"query": "go"},
},
}},
{Role: "user", Content: []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "toolu_1",
"is_error": true,
"content": "quota exceeded",
},
}},
},
}
conv, err := normalizeAnthropicConversation(req)
if err != nil {
t.Fatalf("normalizeAnthropicConversation failed: %v", err)
}
raw, err := encodeCodexRequest("gpt-5-codex", conv, false)
if err != nil {
t.Fatalf("encodeCodexRequest failed: %v", err)
}
var encoded codexRequest
if err := sonic.Unmarshal(raw, &encoded); err != nil {
t.Fatalf("unmarshal codex request failed: %v", err)
}
if len(encoded.Input) != 2 {
t.Fatalf("expected 2 input items, got %d", len(encoded.Input))
}
var toolResult map[string]any
if err := sonic.Unmarshal(encoded.Input[1], &toolResult); err != nil {
t.Fatalf("unmarshal tool result item failed: %v", err)
}
if toolResult["type"] != "function_call_output" {
t.Fatalf("expected function_call_output, got %+v", toolResult)
}
if _, ok := toolResult["is_error"]; ok {
t.Fatalf("expected codex tool result without is_error, got %+v", toolResult)
}
if toolResult["output"] != "quota exceeded" {
t.Fatalf("unexpected tool result output: %+v", toolResult)
}
}
func TestEncodeCodexRequest_AssistantTextUsesOutputText(t *testing.T) {
t.Parallel()
conv := conversation{
Turns: []conversationTurn{{
Role: "assistant",
Parts: []conversationPart{{
Kind: partKindText,
Text: "hello",
}},
}},
}
raw, err := encodeCodexRequest("gpt-5-codex", conv, false)
if err != nil {
t.Fatalf("encodeCodexRequest failed: %v", err)
}
var encoded codexRequest
if err := sonic.Unmarshal(raw, &encoded); err != nil {
t.Fatalf("unmarshal codex request failed: %v", err)
}
if len(encoded.Input) != 1 {
t.Fatalf("expected 1 input item, got %d", len(encoded.Input))
}
var message map[string]any
if err := sonic.Unmarshal(encoded.Input[0], &message); err != nil {
t.Fatalf("unmarshal assistant message failed: %v", err)
}
if message["type"] != "message" || message["role"] != "assistant" {
t.Fatalf("unexpected assistant message: %+v", message)
}
content, ok := message["content"].([]any)
if !ok || len(content) != 1 {
t.Fatalf("unexpected assistant content: %+v", message["content"])
}
part, ok := content[0].(map[string]any)
if !ok || part["type"] != "output_text" || part["text"] != "hello" {
t.Fatalf("unexpected assistant content part: %+v", content[0])
}
}
func TestNormalizeCodexConversation_StructuredContent(t *testing.T) {
t.Parallel()
req := codexRequest{
Model: "gpt-5-codex",
Instructions: "be careful",
Input: []json.RawMessage{
json.RawMessage(`{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"},{"type":"input_image","image_url":"https://example.com/a.png"},{"type":"input_file","file_id":"file_123","filename":"doc.pdf"}]}`),
json.RawMessage(`{"type":"function_call","call_id":"call_1","name":"search","arguments":{"query":"go"}}`),
json.RawMessage(`{"type":"function_call_output","call_id":"call_1","output":"done"}`),
},
Tools: json.RawMessage(`[{"type":"function","name":"search","description":"lookup","parameters":{"type":"object"}}]`),
ToolChoice: json.RawMessage(`"required"`),
}
conv, err := normalizeCodexConversation(req)
if err != nil {
t.Fatalf("normalizeCodexConversation failed: %v", err)
}
if len(conv.Turns) != 4 {
t.Fatalf("expected 4 turns, got %d", len(conv.Turns))
}
if len(conv.Turns[1].Parts) != 3 || conv.Turns[1].Parts[1].Kind != partKindImage || conv.Turns[1].Parts[2].Kind != partKindFile {
t.Fatalf("unexpected codex user parts: %+v", conv.Turns[1].Parts)
}
if conv.Turns[2].Parts[0].Kind != partKindToolCall {
t.Fatalf("expected tool_call turn, got %+v", conv.Turns[2].Parts)
}
if toolResult := conv.Turns[3].Parts[0].ToolResult; toolResult == nil || toolResult.Name != "search" {
t.Fatalf("expected resolved tool result, got %+v", toolResult)
}
}
func TestNormalizeGeminiConversation_StructuredContent(t *testing.T) {
t.Parallel()
req := geminiRequestPayload{
SystemInstruction: &geminiSystemInstruction{
Parts: []geminiPart{{Text: "be careful"}},
},
Tools: []geminiTool{{
FunctionDeclarations: []geminiFunctionDeclaration{{
Name: "search",
Description: "lookup",
Parameters: map[string]any{"type": "object"},
}},
}},
ToolConfig: &geminiToolConfig{
FunctionCallingConfig: geminiFunctionCallingConfig{
Mode: "ANY",
AllowedFunctionNames: []string{"search"},
},
},
Contents: []geminiContent{
{
Role: "model",
Parts: []geminiPart{
{Text: "calling tool"},
{FunctionCall: &geminiFunctionCall{Name: "search", Args: map[string]any{"query": "go"}}},
},
},
{
Role: "user",
Parts: []geminiPart{
{Text: "hello"},
{FileData: &geminiFileData{MIMEType: "image/png", FileURI: "https://example.com/a.png"}},
{InlineData: &geminiInlineData{MIMEType: "application/pdf", Data: "cGRm"}},
{FunctionResponse: &geminiFunctionResponse{Name: "search", Response: map[string]any{"call_id": "call_1", "content": "done"}}},
},
},
},
}
conv, err := normalizeGeminiConversation(req)
if err != nil {
t.Fatalf("normalizeGeminiConversation failed: %v", err)
}
if len(conv.Turns) != 3 {
t.Fatalf("expected 3 turns, got %d", len(conv.Turns))
}
if len(conv.Tools) != 1 || conv.Tools[0].Name != "search" {
t.Fatalf("unexpected tools: %+v", conv.Tools)
}
if conv.ToolChoice.Mode != "named" || conv.ToolChoice.Name != "search" {
t.Fatalf("unexpected tool choice: %+v", conv.ToolChoice)
}
assistantParts := conv.Turns[1].Parts
if len(assistantParts) != 2 || assistantParts[1].Kind != partKindToolCall {
t.Fatalf("unexpected assistant parts: %+v", assistantParts)
}
if call := assistantParts[1].ToolCall; call == nil || call.ID != "call_1" {
t.Fatalf("expected matched tool call id call_1, got %+v", call)
}
userParts := conv.Turns[2].Parts
if len(userParts) != 4 || userParts[1].Kind != partKindImage || userParts[2].Kind != partKindFile || userParts[3].Kind != partKindToolResult {
t.Fatalf("unexpected user parts: %+v", userParts)
}
if toolResult := userParts[3].ToolResult; toolResult == nil || toolResult.CallID != "call_1" || toolResult.Name != "search" {
t.Fatalf("unexpected gemini tool result: %+v", toolResult)
}
}
func TestNormalizeConversation_RejectsUnknownBlockType(t *testing.T) {
t.Parallel()
_, err := normalizeOpenAIConversation(openAIChatRequest{
Model: "gpt-4o",
Messages: []openAIChatMessage{{
Role: "user",
Content: []any{map[string]any{"type": "mystery", "value": true}},
}},
})
if err == nil {
t.Fatal("expected unsupported block error")
}
}
func TestNormalizeConversation_BuiltinToolsAndChoices(t *testing.T) {
t.Parallel()
openAIConv, err := normalizeOpenAIConversation(openAIChatRequest{
Model: "gpt-4o",
Tools: json.RawMessage(`[{"type":"web_search","search_context_size":"high"}]`),
ToolChoice: json.RawMessage(`{"type":"web_search"}`),
Messages: []openAIChatMessage{{
Role: "user",
Content: "hello",
}},
})
if err != nil {
t.Fatalf("normalizeOpenAIConversation failed: %v", err)
}
if len(openAIConv.Tools) != 1 || openAIConv.Tools[0].Type != "web_search" || openAIConv.Tools[0].Options["search_context_size"] != "high" {
t.Fatalf("unexpected openai builtin tools: %+v", openAIConv.Tools)
}
if openAIConv.ToolChoice.Mode != "named" || openAIConv.ToolChoice.ToolType != "web_search" {
t.Fatalf("unexpected openai builtin tool choice: %+v", openAIConv.ToolChoice)
}
codexConv, err := normalizeCodexConversation(codexRequest{
Model: "gpt-5-codex",
Tools: json.RawMessage(`[{"type":"web_search","user_location":{"type":"approximate","country":"US"}}]`),
ToolChoice: json.RawMessage(`{"type":"web_search"}`),
Input: []json.RawMessage{
json.RawMessage(`{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}`),
},
})
if err != nil {
t.Fatalf("normalizeCodexConversation failed: %v", err)
}
if len(codexConv.Tools) != 1 || codexConv.Tools[0].Type != "web_search" {
t.Fatalf("unexpected codex builtin tools: %+v", codexConv.Tools)
}
location, ok := codexConv.Tools[0].Options["user_location"].(map[string]any)
if !ok || location["country"] != "US" {
t.Fatalf("unexpected codex builtin tool options: %+v", codexConv.Tools[0].Options)
}
if codexConv.ToolChoice.Mode != "named" || codexConv.ToolChoice.ToolType != "web_search" {
t.Fatalf("unexpected codex builtin tool choice: %+v", codexConv.ToolChoice)
}
}
func TestNormalizeConversationCoverage_SupportedSources(t *testing.T) {
t.Parallel()
cases := []struct {
name string
protocol protocol.Protocol
normalize func() (conversation, error)
}{
{
name: "openai",
protocol: protocol.OpenAI,
normalize: func() (conversation, error) {
return normalizeOpenAIConversation(openAIChatRequest{
Model: "gpt-4o",
Messages: []openAIChatMessage{{
Role: "user",
Content: "hello",
}},
})
},
},
{
name: "anthropic",
protocol: protocol.Anthropic,
normalize: func() (conversation, error) {
return normalizeAnthropicConversation(anthropicMessagesRequest{
Model: "claude-3-5-sonnet",
Messages: []anthropicMessageContent{{
Role: "user",
Content: []any{map[string]any{"type": "text", "text": "hello"}},
}},
})
},
},
{
name: "codex",
protocol: protocol.Codex,
normalize: func() (conversation, error) {
return normalizeCodexConversation(codexRequest{
Model: "gpt-5-codex",
Input: []json.RawMessage{
json.RawMessage(`{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}`),
},
})
},
},
}
if len(cases) != 3 {
t.Fatalf("expected exactly three covered request-source normalizers, got %d", len(cases))
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
conv, err := tc.normalize()
if err != nil {
t.Fatalf("%s normalize failed: %v", tc.protocol, err)
}
if len(conv.Turns) != 1 {
t.Fatalf("%s normalize expected 1 turn, got %d", tc.protocol, len(conv.Turns))
}
if conv.Turns[0].Role != "user" {
t.Fatalf("%s normalize expected user role, got %s", tc.protocol, conv.Turns[0].Role)
}
if len(conv.Turns[0].Parts) != 1 || conv.Turns[0].Parts[0].Kind != partKindText || conv.Turns[0].Parts[0].Text != "hello" {
t.Fatalf("%s normalize expected one text part, got %+v", tc.protocol, conv.Turns[0].Parts)
}
})
}
}
func TestNormalizeConversationCoverage_GeminiSourceHasRequestTransforms(t *testing.T) {
t.Parallel()
if got := protocol.DetectRequestFamily("/v1beta/models/gemini-2.5-pro:generateContent"); got != protocol.RequestFamilyGenerateContent {
t.Fatalf("expected generate_content family for Gemini request, got %s", got)
}
for _, upstream := range []protocol.Protocol{protocol.OpenAI, protocol.Anthropic, protocol.Codex} {
if !protocol.SupportsTransform(protocol.Gemini, upstream) {
t.Fatalf("expected Gemini source to be supported for upstream %s", upstream)
}
if !protocol.SupportsTransformFamily(protocol.Gemini, upstream, protocol.RequestFamilyGenerateContent) {
t.Fatalf("expected Gemini generate_content source family to be supported for upstream %s", upstream)
}
}
}
// 覆盖 bug:OpenAI→Codex 转换时 tool_choice="auto"/"none"/"required" 必须以字符串形式传给
// Responses API;若包装为 {"type":"auto"} 对象会被上游拒绝(Responses API 对 tool_choice.type
// 的对象形态只接受 builtin 工具类型如 file_search)。
func TestEncodeCodexRequest_ToolChoiceStringModes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
mode string
}{
{"auto", "auto"},
{"none", "none"},
{"required", "required"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
conv := conversation{
Turns: []conversationTurn{{Role: "user", Parts: []conversationPart{{Kind: partKindText, Text: "hi"}}}},
ToolChoice: conversationToolChoice{Mode: tc.mode},
}
raw, err := encodeCodexRequest("gpt-5-codex", conv, false)
if err != nil {
t.Fatalf("encodeCodexRequest failed: %v", err)
}
var out map[string]any
if err := sonic.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
got, ok := out["tool_choice"].(string)
if !ok {
t.Fatalf("expected tool_choice to be string %q, got %#v", tc.mode, out["tool_choice"])
}
if got != tc.mode {
t.Fatalf("expected tool_choice %q, got %q", tc.mode, got)
}
})
}
}
func TestEncodeCodexRequest_ToolChoiceNamedFunctionRemainsObject(t *testing.T) {
t.Parallel()
conv := conversation{
Turns: []conversationTurn{{Role: "user", Parts: []conversationPart{{Kind: partKindText, Text: "hi"}}}},
Tools: []conversationTool{{Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object"}`)}},
ToolChoice: conversationToolChoice{
Mode: "named",
Name: "get_weather",
ToolType: "function",
},
}
raw, err := encodeCodexRequest("gpt-5-codex", conv, false)
if err != nil {
t.Fatalf("encodeCodexRequest failed: %v", err)
}
var out map[string]any
if err := sonic.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
choice, ok := out["tool_choice"].(map[string]any)
if !ok {
t.Fatalf("expected tool_choice to be object, got %#v", out["tool_choice"])
}
if choice["type"] != "function" || choice["name"] != "get_weather" {
t.Fatalf("unexpected named tool_choice: %#v", choice)
}
}