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
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user