Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// assertChatInvariants enforces the DeepSeek / OpenAI Chat Completions message
|
||||
// invariants that, when violated, surface as upstream 400s. Used to validate the
|
||||
// request-direction converter against golden codex request shapes.
|
||||
func assertChatInvariants(t *testing.T, messages []ChatMessage) {
|
||||
t.Helper()
|
||||
for i, m := range messages {
|
||||
// Every assistant tool_calls message must be immediately followed by one
|
||||
// tool message per tool_call_id, in order.
|
||||
if len(m.ToolCalls) > 0 {
|
||||
for j, tc := range m.ToolCalls {
|
||||
k := i + 1 + j
|
||||
require.Lessf(t, k, len(messages), "tool_call %s has no following tool message", tc.ID)
|
||||
require.Equalf(t, "tool", messages[k].Role, "tool_call %s not followed by a tool message", tc.ID)
|
||||
require.Equalf(t, tc.ID, messages[k].ToolCallID, "tool reply order mismatch for %s", tc.ID)
|
||||
}
|
||||
}
|
||||
// No two consecutive assistant messages.
|
||||
if i > 0 && m.Role == "assistant" && messages[i-1].Role == "assistant" {
|
||||
t.Fatalf("consecutive assistant messages at %d", i)
|
||||
}
|
||||
// No orphan tool replies.
|
||||
if m.Role == "tool" {
|
||||
require.NotEmptyf(t, m.ToolCallID, "tool message without tool_call_id at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func convertGolden(t *testing.T, input string) []ChatMessage {
|
||||
t.Helper()
|
||||
msgs, err := responsesInputToChatMessages("You are a helpful assistant.", json.RawMessage(input))
|
||||
require.NoError(t, err)
|
||||
return msgs
|
||||
}
|
||||
|
||||
// Golden sample: a single tool-call turn (codex runs one shell/curl command),
|
||||
// the shape that produced the original "no response" / 400.
|
||||
func TestGolden_SingleToolCall(t *testing.T) {
|
||||
msgs := convertGolden(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"latest sha?"}]},
|
||||
{"type":"reasoning","summary":[{"type":"summary_text","text":"need to run curl"}]},
|
||||
{"type":"function_call","call_id":"call_a","name":"exec_command","arguments":"{\"cmd\":\"curl x\"}"},
|
||||
{"type":"function_call_output","call_id":"call_a","output":"deadbeef"}
|
||||
]`)
|
||||
assertChatInvariants(t, msgs)
|
||||
// reasoning_content must ride on the assistant tool-call message.
|
||||
var asst *ChatMessage
|
||||
for i := range msgs {
|
||||
if len(msgs[i].ToolCalls) > 0 {
|
||||
asst = &msgs[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, asst)
|
||||
require.Equal(t, "need to run curl", asst.ReasoningContent)
|
||||
}
|
||||
|
||||
// Golden sample: parallel tool calls (codex runs git log + git tag at once).
|
||||
func TestGolden_ParallelToolCalls(t *testing.T) {
|
||||
msgs := convertGolden(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"features?"}]},
|
||||
{"type":"reasoning","summary":[{"type":"summary_text","text":"inspect repo"}]},
|
||||
{"type":"function_call","call_id":"c0","name":"exec_command","arguments":"{\"cmd\":\"git log\"}"},
|
||||
{"type":"function_call","call_id":"c1","name":"exec_command","arguments":"{\"cmd\":\"git tag\"}"},
|
||||
{"type":"function_call_output","call_id":"c0","output":"log"},
|
||||
{"type":"function_call_output","call_id":"c1","output":"tags"}
|
||||
]`)
|
||||
assertChatInvariants(t, msgs)
|
||||
// Both parallel calls share ONE assistant message.
|
||||
var toolMsgs int
|
||||
for _, m := range msgs {
|
||||
if len(m.ToolCalls) == 2 {
|
||||
require.Equal(t, "c0", m.ToolCalls[0].ID)
|
||||
require.Equal(t, "c1", m.ToolCalls[1].ID)
|
||||
}
|
||||
if m.Role == "tool" {
|
||||
toolMsgs++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 2, toolMsgs)
|
||||
}
|
||||
|
||||
// Golden sample: an unknown item type (web_search_call from a 联网查询) sitting
|
||||
// between a function_call and its output must not break tool↔reply adjacency.
|
||||
func TestGolden_UnknownItemBetweenToolCallAndOutput(t *testing.T) {
|
||||
msgs := convertGolden(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"search"}]},
|
||||
{"type":"reasoning","summary":[{"type":"summary_text","text":"let me search"}]},
|
||||
{"type":"function_call","call_id":"c0","name":"exec_command","arguments":"{}"},
|
||||
{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"x"}},
|
||||
{"type":"function_call_output","call_id":"c0","output":"result"}
|
||||
]`)
|
||||
assertChatInvariants(t, msgs)
|
||||
}
|
||||
|
||||
// Sequential tool calls (a tool reply between two calls) must stay in distinct
|
||||
// assistant messages.
|
||||
func TestRequest_SequentialToolCallsStaySeparate(t *testing.T) {
|
||||
msgs := convertGolden(t, `[
|
||||
{"type":"function_call","call_id":"c1","name":"exec","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"c1","output":"r1"},
|
||||
{"type":"function_call","call_id":"c2","name":"exec","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"c2","output":"r2"}
|
||||
]`)
|
||||
assertChatInvariants(t, msgs)
|
||||
assistants := 0
|
||||
for _, m := range msgs {
|
||||
if len(m.ToolCalls) == 1 {
|
||||
assistants++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 2, assistants)
|
||||
}
|
||||
|
||||
// Golden sample: codex injects a message (e.g. an "Approved command prefix
|
||||
// saved" notice) between a function_call and its output. The intervening message
|
||||
// must be moved after the tool reply so the assistant tool_calls is immediately
|
||||
// followed by its reply.
|
||||
func TestGolden_MessageBetweenToolCallAndOutput(t *testing.T) {
|
||||
msgs := convertGolden(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"do it"}]},
|
||||
{"type":"reasoning","summary":[{"type":"summary_text","text":"run cmd"}]},
|
||||
{"type":"function_call","call_id":"A","name":"exec","arguments":"{}"},
|
||||
{"type":"message","role":"developer","content":[{"type":"input_text","text":"Approved command prefix saved"}]},
|
||||
{"type":"function_call_output","call_id":"A","output":"ok"}
|
||||
]`)
|
||||
assertChatInvariants(t, msgs)
|
||||
// The assistant tool_calls message is immediately followed by its tool reply.
|
||||
for i, m := range msgs {
|
||||
if len(m.ToolCalls) > 0 {
|
||||
require.Equal(t, "tool", msgs[i+1].Role)
|
||||
require.Equal(t, "A", msgs[i+1].ToolCallID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Golden sample: a parallel tool call where one sibling's output is missing
|
||||
// (codex interrupted/reconnected mid-execution). The unanswered tool_call must
|
||||
// be dropped so the remaining assistant tool_calls are all answered.
|
||||
func TestGolden_PartialParallelDropsUnansweredCall(t *testing.T) {
|
||||
msgs := convertGolden(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
|
||||
{"type":"reasoning","summary":[{"type":"summary_text","text":"r"}]},
|
||||
{"type":"function_call","call_id":"A","name":"exec","arguments":"{}"},
|
||||
{"type":"function_call","call_id":"B","name":"exec","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"A","output":"oa"}
|
||||
]`)
|
||||
assertChatInvariants(t, msgs)
|
||||
for _, m := range msgs {
|
||||
for _, tc := range m.ToolCalls {
|
||||
require.NotEqual(t, "B", tc.ID, "unanswered tool_call B should have been dropped")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Golden sample: a dangling tool_call at the end of the history (no output yet).
|
||||
// The assistant message holding only that call must be dropped entirely.
|
||||
func TestGolden_DanglingToolCallDropped(t *testing.T) {
|
||||
msgs := convertGolden(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
|
||||
{"type":"reasoning","summary":[{"type":"summary_text","text":"r"}]},
|
||||
{"type":"function_call","call_id":"A","name":"exec","arguments":"{}"}
|
||||
]`)
|
||||
assertChatInvariants(t, msgs)
|
||||
for _, m := range msgs {
|
||||
require.Empty(t, m.ToolCalls, "dangling unanswered tool_call should have been dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeChatMessages drops an orphan tool reply whose tool_call was never
|
||||
// announced.
|
||||
func TestNormalize_DropsOrphanToolReply(t *testing.T) {
|
||||
msgs := convertGolden(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
|
||||
{"type":"function_call_output","call_id":"ghost","output":"orphan"}
|
||||
]`)
|
||||
for _, m := range msgs {
|
||||
require.NotEqualf(t, "tool", m.Role, "orphan tool reply should have been dropped")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user