Translate Responses API requests (input→messages, instructions→system,
max_output_tokens→max_tokens, text.format→response_format, flat tools→nested
{function:{…}}) to upstream chat/completions, then convert responses back to
Responses format (streaming SSE event lifecycle + non-streaming JSON).
Verified against OpenAI migration guide and Python SDK Response model:
- Echo back required fields parallel_tool_calls/tool_choice/tools
- Include content:[] in reasoning items, logprobs:[] in output_text parts
- Support function_call/function_call_output multi-turn input items
- Map usage fields prompt_tokens→input_tokens, completion_tokens→output_tokens
305 lines
9.3 KiB
Go
305 lines
9.3 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
|
|
)
|
|
|
|
// TestResponsesNonStreaming verifies POST /v1/responses with stream:false
|
|
// returns a properly formatted Responses API JSON object.
|
|
func TestResponsesNonStreaming(t *testing.T) {
|
|
upstream, proxy, st := setupTestServer(t)
|
|
defer upstream.Close()
|
|
defer proxy.Close()
|
|
|
|
creds := auth.Credentials{
|
|
AccessKey: "AK", SecretKey: "SK", Token: "TOKEN",
|
|
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
|
|
}
|
|
if err := st.SaveCredentials(creds); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body := `{"model":"GLM-4.7","input":"hi","stream":false}`
|
|
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status %d: %s", resp.StatusCode, string(b))
|
|
}
|
|
var result map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result["object"] != "response" {
|
|
t.Fatalf("object = %v", result["object"])
|
|
}
|
|
if result["status"] != "completed" {
|
|
t.Fatalf("status = %v", result["status"])
|
|
}
|
|
output, ok := result["output"].([]any)
|
|
if !ok || len(output) == 0 {
|
|
t.Fatalf("output = %v", result["output"])
|
|
}
|
|
msg, ok := output[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("output[0] type = %T", output[0])
|
|
}
|
|
if msg["type"] != "message" {
|
|
t.Fatalf("output[0] type = %v", msg["type"])
|
|
}
|
|
if msg["role"] != "assistant" {
|
|
t.Fatalf("output[0] role = %v", msg["role"])
|
|
}
|
|
content, ok := msg["content"].([]any)
|
|
if !ok || len(content) == 0 {
|
|
t.Fatalf("content = %v", msg["content"])
|
|
}
|
|
part, ok := content[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("content[0] type = %T", content[0])
|
|
}
|
|
if part["type"] != "output_text" {
|
|
t.Fatalf("content[0] type = %v", part["type"])
|
|
}
|
|
if part["text"] != "hello" {
|
|
t.Fatalf("content[0] text = %v", part["text"])
|
|
}
|
|
// Verify required SDK fields are present.
|
|
if _, ok := result["parallel_tool_calls"]; !ok {
|
|
t.Fatal("missing parallel_tool_calls in response")
|
|
}
|
|
if _, ok := result["tool_choice"]; !ok {
|
|
t.Fatal("missing tool_choice in response")
|
|
}
|
|
if _, ok := result["tools"]; !ok {
|
|
t.Fatal("missing tools in response")
|
|
}
|
|
// Verify output_text has logprobs field.
|
|
if _, ok := part["logprobs"]; !ok {
|
|
t.Fatal("missing logprobs in output_text content part")
|
|
}
|
|
usage, ok := result["usage"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("usage = %v", result["usage"])
|
|
}
|
|
if totNum(usage["input_tokens"]) != 10 {
|
|
t.Fatalf("input_tokens = %v", usage["input_tokens"])
|
|
}
|
|
if totNum(usage["output_tokens"]) != 20 {
|
|
t.Fatalf("output_tokens = %v", usage["output_tokens"])
|
|
}
|
|
if totNum(usage["total_tokens"]) != 30 {
|
|
t.Fatalf("total_tokens = %v", usage["total_tokens"])
|
|
}
|
|
}
|
|
|
|
// TestResponsesStreaming verifies POST /v1/responses with stream:true
|
|
// emits proper Responses API SSE events.
|
|
func TestResponsesStreaming(t *testing.T) {
|
|
upstream, proxy, st := setupTestServer(t)
|
|
defer upstream.Close()
|
|
defer proxy.Close()
|
|
|
|
creds := auth.Credentials{
|
|
AccessKey: "AK", SecretKey: "SK", Token: "TOKEN",
|
|
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
|
|
}
|
|
if err := st.SaveCredentials(creds); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body := `{"model":"GLM-4.7","input":"hi","stream":true}`
|
|
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status %d: %s", resp.StatusCode, string(b))
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
s := string(raw)
|
|
|
|
// Check key lifecycle events are present.
|
|
checks := []string{
|
|
"event: response.created",
|
|
"event: response.in_progress",
|
|
"event: response.output_item.added",
|
|
"event: response.content_part.added",
|
|
"event: response.output_text.delta",
|
|
"event: response.output_text.done",
|
|
"event: response.content_part.done",
|
|
"event: response.output_item.done",
|
|
"event: response.completed",
|
|
}
|
|
for _, c := range checks {
|
|
if !strings.Contains(s, c) {
|
|
t.Fatalf("missing %q in SSE body:\n%s", c, s)
|
|
}
|
|
}
|
|
|
|
// Verify the text delta contains "hello".
|
|
if !strings.Contains(s, `"delta":"hello"`) {
|
|
t.Fatalf("text delta missing 'hello' in SSE body:\n%s", s)
|
|
}
|
|
|
|
// Verify required SDK fields are present in completed event.
|
|
if !strings.Contains(s, `"parallel_tool_calls"`) {
|
|
t.Fatalf("missing parallel_tool_calls in SSE body:\n%s", s)
|
|
}
|
|
if !strings.Contains(s, `"tool_choice"`) {
|
|
t.Fatalf("missing tool_choice in SSE body:\n%s", s)
|
|
}
|
|
// Verify logprobs field in output_text.
|
|
if !strings.Contains(s, `"logprobs"`) {
|
|
t.Fatalf("missing logprobs in SSE body:\n%s", s)
|
|
}
|
|
|
|
// Verify usage in the completed event has input_tokens.
|
|
if !strings.Contains(s, `"input_tokens":`) {
|
|
t.Fatalf("usage missing input_tokens in SSE body:\n%s", s)
|
|
}
|
|
}
|
|
|
|
// TestResponsesStringInput verifies the proxy correctly translates a string
|
|
// "input" to a chat-completions "messages" array before forwarding upstream.
|
|
func TestResponsesStringInput(t *testing.T) {
|
|
var capturedBody []byte
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/chat/completions" {
|
|
capturedBody, _ = io.ReadAll(r.Body)
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("data: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
cfg := config.Config{
|
|
MobileLoginBaseURL: upstream.URL,
|
|
MobileModelBaseURL: upstream.URL,
|
|
UpstreamPath: "/chat/completions",
|
|
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
|
|
PublicKeyPEM: defaultTestPublicKey,
|
|
PhonePublicKeyPEM: defaultTestPublicKey,
|
|
SM2PrivateKey: testSM2Key,
|
|
PluginVersion: "1.4.2",
|
|
}
|
|
h := New(cfg, st)
|
|
proxy := httptest.NewServer(h)
|
|
defer proxy.Close()
|
|
|
|
creds := auth.Credentials{
|
|
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
|
|
}
|
|
_ = st.SaveCredentials(creds)
|
|
|
|
body := `{"model":"GLM-4.7","input":"hello world","stream":false}`
|
|
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
io.Copy(io.Discard, resp.Body)
|
|
|
|
if len(capturedBody) == 0 {
|
|
t.Fatal("no upstream request body captured")
|
|
}
|
|
var upstreamReq map[string]any
|
|
if err := json.Unmarshal(capturedBody, &upstreamReq); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
messages, ok := upstreamReq["messages"].([]any)
|
|
if !ok || len(messages) != 1 {
|
|
t.Fatalf("messages = %v", upstreamReq["messages"])
|
|
}
|
|
msg, _ := messages[0].(map[string]any)
|
|
if msg["role"] != "user" || msg["content"] != "hello world" {
|
|
t.Fatalf("upstream message = %v", msg)
|
|
}
|
|
}
|
|
|
|
// TestResponsesInstructions verifies instructions are prepended as a system
|
|
// message in the upstream request.
|
|
func TestResponsesInstructions(t *testing.T) {
|
|
var capturedBody []byte
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/chat/completions" {
|
|
capturedBody, _ = io.ReadAll(r.Body)
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("data: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
cfg := config.Config{
|
|
MobileLoginBaseURL: upstream.URL,
|
|
MobileModelBaseURL: upstream.URL,
|
|
UpstreamPath: "/chat/completions",
|
|
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
|
|
PublicKeyPEM: defaultTestPublicKey,
|
|
PhonePublicKeyPEM: defaultTestPublicKey,
|
|
SM2PrivateKey: testSM2Key,
|
|
PluginVersion: "1.4.2",
|
|
}
|
|
h := New(cfg, st)
|
|
proxy := httptest.NewServer(h)
|
|
defer proxy.Close()
|
|
|
|
creds := auth.Credentials{
|
|
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
|
|
}
|
|
_ = st.SaveCredentials(creds)
|
|
|
|
body := `{"model":"GLM-4.7","instructions":"be concise","input":"hello","stream":false}`
|
|
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
io.Copy(io.Discard, resp.Body)
|
|
|
|
if len(capturedBody) == 0 {
|
|
t.Fatal("no upstream request body captured")
|
|
}
|
|
var upstreamReq map[string]any
|
|
_ = json.Unmarshal(capturedBody, &upstreamReq)
|
|
messages, _ := upstreamReq["messages"].([]any)
|
|
if len(messages) != 2 {
|
|
t.Fatalf("messages = %d items", len(messages))
|
|
}
|
|
sys, _ := messages[0].(map[string]any)
|
|
if sys["role"] != "system" || sys["content"] != "be concise" {
|
|
t.Fatalf("system message = %v", sys)
|
|
}
|
|
}
|