Files
zhanlu_proxy/internal/server/responses_test.go
T
m1saka 2517b4f730 Add OpenAI Responses API (/v1/responses) endpoint
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
2026-08-20 10:02:11 +08:00

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)
}
}