From 2517b4f7308f5352cfc6fd62ae007d9754719283 Mon Sep 17 00:00:00 2001 From: m1saka Date: Thu, 20 Aug 2026 10:02:11 +0800 Subject: [PATCH] Add OpenAI Responses API (/v1/responses) endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 26 +- internal/openai/responses.go | 347 ++++++++++++++++++ internal/openai/responses_test.go | 305 +++++++++++++++ internal/server/responses.go | 591 ++++++++++++++++++++++++++++++ internal/server/responses_test.go | 304 +++++++++++++++ internal/server/server.go | 7 +- 6 files changed, 1576 insertions(+), 4 deletions(-) create mode 100644 internal/openai/responses.go create mode 100644 internal/openai/responses_test.go create mode 100644 internal/server/responses.go create mode 100644 internal/server/responses_test.go diff --git a/README.md b/README.md index a49359a..c095830 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 当前实现包含: - 登录页支持插件默认的移动云手机号验证码登录:验证码校验后按插件流程调用 `/api/acepilot/zhanlu/v1/login` 获取用户资料,再通过 SM2 签名调用 `/user/api/v2/external/key/get-or-create` 换取模型 API Key,凭据自动保存到本地 JSON。 -- OpenAI 兼容接口:`/v1/models`(优先从 `/gateway/v1/model/info` 拉取模型列表)、`/v1/chat/completions`(携带 `Authorization: Bearer ` 请求 `{modelBaseUrl}/chat/completions`)。 +- OpenAI 兼容接口:`/v1/models`(优先从 `/gateway/v1/model/info` 拉取模型列表)、`/v1/chat/completions`(携带 `Authorization: Bearer ` 请求 `{modelBaseUrl}/chat/completions`)、`/v1/responses`(Responses API,自动转换请求/响应格式,兼容新版 SDK 与 AI 编码工具)。 - 湛卢登录签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`,用于 v1.4.2 的 v1/login 认证。 - 模型 API Key 换取签名逻辑:SM3 摘要 + SM2 签名(`X-Auth-Signature`/`X-Auth-Timestamp`/`X-Auth-Nonce`)。 - 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。 @@ -169,6 +169,30 @@ curl -N http://127.0.0.1:8080/v1/chat/completions ` 请求中的 `tools`、`tool_choice` 会传给湛卢模型。流式响应返回增量 `delta.tool_calls`;非流式响应会把分片聚合为完整的 `message.tool_calls`,并保留 `finish_reason: "tool_calls"`。执行工具后,将 assistant 的 `tool_calls` 和 `role: "tool"` 结果放回 `messages` 再发起请求即可得到最终回答。 +### Responses API + +代理还兼容 OpenAI Responses API(`POST /v1/responses`),支持使用该 API 的客户端(如新版 OpenAI SDK、Cursor 等 AI 编码工具)直接对接。代理会将 Responses API 请求格式转换为上游 chat/completions 格式,再将响应转回 Responses 格式。 + +请求中的 `input`(字符串或消息数组)、`instructions`(系统提示词)、`max_output_tokens`、`temperature`、`top_p`、`tools`、`tool_choice`、`response_format` 等参数均会翻译并透传给上游。`tools` 格式自动从 Responses 扁平结构转换为 Chat Completions 嵌套 `{function:{…}}` 结构。多轮对话中的 `function_call` 和 `function_call_output` 输入项也会自动转换为对应的 assistant `tool_calls` 和 `role: tool` 消息。 + +非流式: + +```powershell +curl http://127.0.0.1:8080/v1/responses ` + -H "Content-Type: application/json" ` + -d '{"model":"zhanlu/auto","input":"hello","stream":false}' +``` + +流式: + +```powershell +curl -N http://127.0.0.1:8080/v1/responses ` + -H "Content-Type: application/json" ` + -d '{"model":"zhanlu/auto","input":"hello","stream":true}' +``` + +流式响应返回标准 Responses API SSE 事件序列(`response.created` → `response.output_item.added` → `response.output_text.delta` → `response.output_text.done` → `response.output_item.done` → `response.completed`),思考模型额外包含 `response.reasoning_summary_text.delta` 事件,工具调用包含 `response.function_call_arguments.delta` 事件。Token 统计同样记录在 SQLite 中。 + 如果设置了本地 OpenAI 兼容 API Key,需要带 `Authorization`: ```powershell diff --git a/internal/openai/responses.go b/internal/openai/responses.go new file mode 100644 index 0000000..a2a22a7 --- /dev/null +++ b/internal/openai/responses.go @@ -0,0 +1,347 @@ +package openai + +import ( + "encoding/json" + "errors" +) + +// --- Responses API request parsing & conversion --- + +// ParseResponsesRequest converts a raw OpenAI Responses API (POST /v1/responses) +// request body into a ChatCompletionRequest suitable for the upstream gateway. +// It translates input→messages, instructions→system message, max_output_tokens→ +// max_tokens, and reshapes tools from the Responses flat format to the Chat +// Completions nested {function:{…}} format. +func ParseResponsesRequest(body []byte) (ChatCompletionRequest, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return ChatCompletionRequest{}, err + } + + var req ChatCompletionRequest + if v, ok := raw["model"]; ok { + _ = json.Unmarshal(v, &req.Model) + } + if v, ok := raw["stream"]; ok { + _ = json.Unmarshal(v, &req.Stream) + } + + // Build messages from instructions + input. + messages := make([]map[string]any, 0, 4) + if v, ok := raw["instructions"]; ok { + var s string + if json.Unmarshal(v, &s) == nil && s != "" { + messages = append(messages, map[string]any{"role": "system", "content": s}) + } + } + if v, ok := raw["input"]; ok { + msgs, err := responsesInputToMessages(v) + if err != nil { + return req, err + } + messages = append(messages, msgs...) + } + req.Messages = messages + + // Extra: renamed + pass-through fields sent to upstream as-is. + extra := map[string]json.RawMessage{} + if v, ok := raw["max_output_tokens"]; ok { + extra["max_tokens"] = v + } + for _, key := range []string{ + "temperature", "top_p", "top_k", "frequency_penalty", + "presence_penalty", "stop", "seed", "user", + } { + if v, ok := raw[key]; ok { + extra[key] = v + } + } + if v, ok := raw["tools"]; ok { + if translated, err := translateResponsesTools(v); err == nil { + extra["tools"] = translated + } else { + extra["tools"] = v // fall back to pass-through + } + } + if v, ok := raw["tool_choice"]; ok { + extra["tool_choice"] = v + } + // Structured Outputs: Responses API uses text.format instead of + // response_format. Translate text.format → response_format for the + // upstream Chat Completions endpoint. + if v, ok := raw["text"]; ok { + if rf, err := translateTextFormat(v); err == nil { + extra["response_format"] = rf + } + } + req.Extra = extra + return req, nil +} + +// translateTextFormat converts the Responses API "text" field (containing a +// "format" sub-object) into a Chat Completions response_format value. +// - {text:{format:{type:"json_object"}}} → {type:"json_object"} +// - {text:{format:{type:"json_schema",name,schema,strict}}} → +// {type:"json_schema",json_schema:{name,schema,strict}} +func translateTextFormat(textRaw json.RawMessage) (json.RawMessage, error) { + var text struct { + Format map[string]any `json:"format"` + } + if err := json.Unmarshal(textRaw, &text); err != nil { + return nil, err + } + if len(text.Format) == 0 { + return nil, errors.New("empty text.format") + } + t, _ := text.Format["type"].(string) + switch t { + case "json_object": + return json.Marshal(map[string]any{"type": "json_object"}) + case "json_schema": + js := map[string]any{} + for _, k := range []string{"name", "schema", "strict"} { + if v, ok := text.Format[k]; ok { + js[k] = v + } + } + return json.Marshal(map[string]any{"type": "json_schema", "json_schema": js}) + default: + return json.Marshal(text.Format) // pass through unknown types + } +} + +// ResponsesMeta holds fields from the Responses API request that should be +// echoed back in the response object (the OpenAI SDK requires them). +type ResponsesMeta struct { + ParallelToolCalls any + ToolChoice any + Tools any + Temperature any + TopP any + MaxOutputTokens any +} + +// ParseResponsesMeta extracts echo-back fields from the raw request body. +func ParseResponsesMeta(body []byte) ResponsesMeta { + var m map[string]any + _ = json.Unmarshal(body, &m) + meta := ResponsesMeta{ + ParallelToolCalls: false, + ToolChoice: "auto", + Tools: []any{}, + } + if v, ok := m["parallel_tool_calls"]; ok { + meta.ParallelToolCalls = v + } + if v, ok := m["tool_choice"]; ok { + meta.ToolChoice = v + } + // Tools are echoed back in the Responses API flat format (not the + // translated Chat Completions format). + if v, ok := m["tools"]; ok { + meta.Tools = v + } else { + meta.Tools = []any{} + } + if v, ok := m["temperature"]; ok { + meta.Temperature = v + } + if v, ok := m["top_p"]; ok { + meta.TopP = v + } + if v, ok := m["max_output_tokens"]; ok { + meta.MaxOutputTokens = v + } + return meta +} + +// responsesInputToMessages converts the Responses API "input" field (which may +// be a plain string or an array of input items) into Chat Completions messages. +func responsesInputToMessages(input json.RawMessage) ([]map[string]any, error) { + // Case 1: input is a plain string. + var s string + if json.Unmarshal(input, &s) == nil { + return []map[string]any{{"role": "user", "content": s}}, nil + } + + // Case 2: input is an array of items. + var items []map[string]json.RawMessage + if err := json.Unmarshal(input, &items); err != nil { + return nil, err + } + + messages := make([]map[string]any, 0, len(items)) + for _, item := range items { + // Determine the type — most items are message-like with a role. + var role string + if v, ok := item["role"]; ok { + _ = json.Unmarshal(v, &role) + } + var itemType string + if v, ok := item["type"]; ok { + _ = json.Unmarshal(v, &itemType) + } + + switch { + case itemType == "function_call": + // An assistant tool call from a previous turn. + msg := map[string]any{"role": "assistant"} + tc := map[string]any{"type": "function"} + inner := map[string]any{} + if v, ok := item["name"]; ok { + var name string + _ = json.Unmarshal(v, &name) + inner["name"] = name + } + if v, ok := item["arguments"]; ok { + var args string + _ = json.Unmarshal(v, &args) + inner["arguments"] = args + } + if v, ok := item["call_id"]; ok { + var id string + _ = json.Unmarshal(v, &id) + tc["id"] = id + } + tc["function"] = inner + msg["tool_calls"] = []any{tc} + messages = append(messages, msg) + case itemType == "function_call_output": + // A tool result from a previous turn. + msg := map[string]any{"role": "tool"} + if v, ok := item["call_id"]; ok { + var id string + _ = json.Unmarshal(v, &id) + msg["tool_call_id"] = id + } + if v, ok := item["output"]; ok { + var out string + _ = json.Unmarshal(v, &out) + msg["content"] = out + } + messages = append(messages, msg) + default: + // Standard message item with role + content. + if role == "" { + role = "user" + } + // "developer" maps to "system" for broad upstream compatibility. + if role == "developer" { + role = "system" + } + msg := map[string]any{"role": role} + if v, ok := item["content"]; ok { + msg["content"] = convertContentParts(v) + } else { + msg["content"] = "" + } + messages = append(messages, msg) + } + } + return messages, nil +} + +// convertContentParts converts a Responses API content field (string or array +// of content parts) into the Chat Completions content format. +func convertContentParts(raw json.RawMessage) any { + // Content is a plain string. + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + // Content is an array of parts. + var parts []map[string]any + if json.Unmarshal(raw, &parts) != nil { + return string(raw) // fallback + } + result := make([]map[string]any, 0, len(parts)) + for _, p := range parts { + pt, _ := p["type"].(string) + switch pt { + case "input_text", "output_text", "text": + result = append(result, map[string]any{"type": "text", "text": p["text"]}) + case "input_image": + img := map[string]any{"type": "image_url"} + if url, ok := p["image_url"]; ok { + img["image_url"] = map[string]any{"url": url} + } else if d, ok := p["image"]; ok { + img["image_url"] = map[string]any{"url": d} + } + result = append(result, img) + default: + // Pass through unknown part types as-is. + result = append(result, p) + } + } + if len(result) == 0 { + return "" + } + return result +} + +// translateResponsesTools converts tools from the Responses API flat format +// to the Chat Completions nested {type:"function",function:{…}} format. +func translateResponsesTools(raw json.RawMessage) (json.RawMessage, error) { + var tools []map[string]any + if err := json.Unmarshal(raw, &tools); err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(tools)) + for _, t := range tools { + tt, _ := t["type"].(string) + if tt != "function" && tt != "" { + // Non-function tool types (web_search, file_search, etc.) — + // pass through as-is; upstream may or may not support them. + out = append(out, t) + continue + } + fn := map[string]any{} + for _, key := range []string{"name", "description", "parameters", "strict"} { + if v, ok := t[key]; ok { + fn[key] = v + } + } + out = append(out, map[string]any{"type": "function", "function": fn}) + } + return json.Marshal(out) +} + +// --- Responses API response building --- + +// UsageToResponses converts a Chat Completions usage value (as decoded by +// json.Unmarshal into any) to the Responses API usage field names +// (input_tokens / output_tokens instead of prompt_tokens / completion_tokens). +func UsageToResponses(v any) any { + if v == nil { + return nil + } + b, err := json.Marshal(v) + if err != nil { + return v + } + var m map[string]any + if json.Unmarshal(b, &m) != nil { + return v + } + out := map[string]any{} + if pt, ok := m["prompt_tokens"]; ok { + out["input_tokens"] = pt + } + if ct, ok := m["completion_tokens"]; ok { + out["output_tokens"] = ct + } + if tt, ok := m["total_tokens"]; ok { + out["total_tokens"] = tt + } + if d, ok := m["prompt_tokens_details"]; ok { + out["input_tokens_details"] = d + } else if d, ok := m["input_tokens_details"]; ok { + out["input_tokens_details"] = d + } + if d, ok := m["completion_tokens_details"]; ok { + out["output_tokens_details"] = d + } else if d, ok := m["output_tokens_details"]; ok { + out["output_tokens_details"] = d + } + return out +} diff --git a/internal/openai/responses_test.go b/internal/openai/responses_test.go new file mode 100644 index 0000000..7b7d69f --- /dev/null +++ b/internal/openai/responses_test.go @@ -0,0 +1,305 @@ +package openai + +import ( + "encoding/json" + "testing" +) + +func TestParseResponsesRequest_StringInput(t *testing.T) { + body := `{"model":"GLM-4.7","input":"hello world","stream":false}` + req, err := ParseResponsesRequest([]byte(body)) + if err != nil { + t.Fatal(err) + } + if req.Model != "GLM-4.7" { + t.Fatalf("model = %q", req.Model) + } + if len(req.Messages) != 1 { + t.Fatalf("messages = %d items", len(req.Messages)) + } + if req.Messages[0]["role"] != "user" { + t.Fatalf("role = %v", req.Messages[0]["role"]) + } + if req.Messages[0]["content"] != "hello world" { + t.Fatalf("content = %v", req.Messages[0]["content"]) + } +} + +func TestParseResponsesRequest_Instructions(t *testing.T) { + body := `{"model":"GLM-4.7","instructions":"be helpful","input":"hi"}` + req, err := ParseResponsesRequest([]byte(body)) + if err != nil { + t.Fatal(err) + } + if len(req.Messages) != 2 { + t.Fatalf("messages = %d items", len(req.Messages)) + } + if req.Messages[0]["role"] != "system" { + t.Fatalf("first role = %v", req.Messages[0]["role"]) + } + if req.Messages[0]["content"] != "be helpful" { + t.Fatalf("first content = %v", req.Messages[0]["content"]) + } + if req.Messages[1]["role"] != "user" { + t.Fatalf("second role = %v", req.Messages[1]["role"]) + } +} + +func TestParseResponsesRequest_ArrayInput(t *testing.T) { + body := `{"model":"GLM-4.7","input":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"hi there"}, + {"role":"user","content":"how are you?"} + ]}` + req, err := ParseResponsesRequest([]byte(body)) + if err != nil { + t.Fatal(err) + } + if len(req.Messages) != 3 { + t.Fatalf("messages = %d items", len(req.Messages)) + } + if req.Messages[0]["role"] != "user" || req.Messages[0]["content"] != "hello" { + t.Fatalf("msg[0] = %v", req.Messages[0]) + } + if req.Messages[1]["role"] != "assistant" || req.Messages[1]["content"] != "hi there" { + t.Fatalf("msg[1] = %v", req.Messages[1]) + } + if req.Messages[2]["role"] != "user" || req.Messages[2]["content"] != "how are you?" { + t.Fatalf("msg[2] = %v", req.Messages[2]) + } +} + +func TestParseResponsesRequest_ContentParts(t *testing.T) { + body := `{"model":"GLM-4.7","input":[ + {"role":"user","content":[ + {"type":"input_text","text":"describe this"}, + {"type":"input_image","image_url":"data:image/png;base64,abc"} + ]} + ]}` + req, err := ParseResponsesRequest([]byte(body)) + if err != nil { + t.Fatal(err) + } + if len(req.Messages) != 1 { + t.Fatalf("messages = %d items", len(req.Messages)) + } + content, ok := req.Messages[0]["content"].([]map[string]any) + if !ok { + t.Fatalf("content type = %T", req.Messages[0]["content"]) + } + if len(content) != 2 { + t.Fatalf("content parts = %d", len(content)) + } + if content[0]["type"] != "text" || content[0]["text"] != "describe this" { + t.Fatalf("content[0] = %v", content[0]) + } + if content[1]["type"] != "image_url" { + t.Fatalf("content[1] type = %v", content[1]) + } +} + +func TestParseResponsesRequest_MaxOutputTokens(t *testing.T) { + body := `{"model":"GLM-4.7","input":"hi","max_output_tokens":500}` + req, err := ParseResponsesRequest([]byte(body)) + if err != nil { + t.Fatal(err) + } + if v, ok := req.Extra["max_tokens"]; !ok { + t.Fatal("max_tokens not in Extra") + } else { + var n int + _ = json.Unmarshal(v, &n) + if n != 500 { + t.Fatalf("max_tokens = %d", n) + } + } +} + +func TestParseResponsesRequest_Tools(t *testing.T) { + body := `{"model":"GLM-4.7","input":"hi","tools":[ + {"type":"function","name":"get_weather","description":"get weather","parameters":{"type":"object"}} + ]}` + req, err := ParseResponsesRequest([]byte(body)) + if err != nil { + t.Fatal(err) + } + raw, ok := req.Extra["tools"] + if !ok { + t.Fatal("tools not in Extra") + } + var tools []map[string]any + if err := json.Unmarshal(raw, &tools); err != nil { + t.Fatal(err) + } + if len(tools) != 1 { + t.Fatalf("tools = %d", len(tools)) + } + if tools[0]["type"] != "function" { + t.Fatalf("tool type = %v", tools[0]["type"]) + } + fn, ok := tools[0]["function"].(map[string]any) + if !ok { + t.Fatalf("function type = %T", tools[0]["function"]) + } + if fn["name"] != "get_weather" { + t.Fatalf("function name = %v", fn["name"]) + } +} + +func TestParseResponsesRequest_FunctionCallInput(t *testing.T) { + body := `{"model":"GLM-4.7","input":[ + {"role":"user","content":"what's the weather?"}, + {"type":"function_call","call_id":"call_123","name":"get_weather","arguments":"{\"city\":\"NYC\"}"}, + {"type":"function_call_output","call_id":"call_123","output":"sunny 72F"} + ]}` + req, err := ParseResponsesRequest([]byte(body)) + if err != nil { + t.Fatal(err) + } + if len(req.Messages) != 3 { + t.Fatalf("messages = %d items", len(req.Messages)) + } + // First message is user text + if req.Messages[0]["role"] != "user" { + t.Fatalf("msg[0] role = %v", req.Messages[0]["role"]) + } + // Second message is assistant with tool_calls + if req.Messages[1]["role"] != "assistant" { + t.Fatalf("msg[1] role = %v", req.Messages[1]["role"]) + } + tcs, ok := req.Messages[1]["tool_calls"].([]any) + if !ok || len(tcs) != 1 { + t.Fatalf("msg[1] tool_calls = %v", req.Messages[1]["tool_calls"]) + } + tc, _ := tcs[0].(map[string]any) + if tc["id"] != "call_123" { + t.Fatalf("tool call id = %v", tc["id"]) + } + fn, _ := tc["function"].(map[string]any) + if fn["name"] != "get_weather" { + t.Fatalf("function name = %v", fn["name"]) + } + // Third message is tool result + if req.Messages[2]["role"] != "tool" { + t.Fatalf("msg[2] role = %v", req.Messages[2]["role"]) + } + if req.Messages[2]["tool_call_id"] != "call_123" { + t.Fatalf("msg[2] tool_call_id = %v", req.Messages[2]["tool_call_id"]) + } + if req.Messages[2]["content"] != "sunny 72F" { + t.Fatalf("msg[2] content = %v", req.Messages[2]["content"]) + } +} + +func TestUsageToResponses(t *testing.T) { + usage := map[string]any{ + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30, + "prompt_tokens_details": map[string]any{"cached_tokens": 4}, + "completion_tokens_details": map[string]any{"reasoning_tokens": 5}, + } + result := UsageToResponses(usage) + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("result type = %T", result) + } + if m["input_tokens"] != float64(10) { + t.Fatalf("input_tokens = %v", m["input_tokens"]) + } + if m["output_tokens"] != float64(20) { + t.Fatalf("output_tokens = %v", m["output_tokens"]) + } + if m["total_tokens"] != float64(30) { + t.Fatalf("total_tokens = %v", m["total_tokens"]) + } + if d, ok := m["input_tokens_details"].(map[string]any); !ok || d["cached_tokens"] != float64(4) { + t.Fatalf("input_tokens_details = %v", m["input_tokens_details"]) + } + if d, ok := m["output_tokens_details"].(map[string]any); !ok || d["reasoning_tokens"] != float64(5) { + t.Fatalf("output_tokens_details = %v", m["output_tokens_details"]) + } +} + +func TestParseResponsesRequest_TextFormat(t *testing.T) { + body := `{"model":"GLM-4.7","input":"Jane, 54","text":{"format":{"type":"json_schema","name":"person","strict":true,"schema":{"type":"object"}}}}` + req, err := ParseResponsesRequest([]byte(body)) + if err != nil { + t.Fatal(err) + } + rf, ok := req.Extra["response_format"] + if !ok { + t.Fatal("response_format not in Extra") + } + var m map[string]any + if err := json.Unmarshal(rf, &m); err != nil { + t.Fatal(err) + } + if m["type"] != "json_schema" { + t.Fatalf("type = %v", m["type"]) + } + js, ok := m["json_schema"].(map[string]any) + if !ok { + t.Fatalf("json_schema = %v", m["json_schema"]) + } + if js["name"] != "person" { + t.Fatalf("name = %v", js["name"]) + } + if js["strict"] != true { + t.Fatalf("strict = %v", js["strict"]) + } +} + +func TestParseResponsesRequest_TextFormatJsonObject(t *testing.T) { + body := `{"model":"GLM-4.7","input":"hi","text":{"format":{"type":"json_object"}}}` + req, err := ParseResponsesRequest([]byte(body)) + if err != nil { + t.Fatal(err) + } + rf, ok := req.Extra["response_format"] + if !ok { + t.Fatal("response_format not in Extra") + } + var m map[string]any + if err := json.Unmarshal(rf, &m); err != nil { + t.Fatal(err) + } + if m["type"] != "json_object" { + t.Fatalf("type = %v", m["type"]) + } +} + +func TestParseResponsesMeta_Defaults(t *testing.T) { + meta := ParseResponsesMeta([]byte(`{"model":"GLM-4.7","input":"hi"}`)) + if meta.ParallelToolCalls != false { + t.Fatalf("parallel_tool_calls = %v", meta.ParallelToolCalls) + } + if meta.ToolChoice != "auto" { + t.Fatalf("tool_choice = %v", meta.ToolChoice) + } + tools, ok := meta.Tools.([]any) + if !ok || len(tools) != 0 { + t.Fatalf("tools = %v", meta.Tools) + } +} + +func TestParseResponsesMeta_EchoBack(t *testing.T) { + body := `{"model":"GLM-4.7","input":"hi","parallel_tool_calls":true,"tool_choice":"required","tools":[{"type":"function","name":"get_weather"}],"temperature":0.7,"max_output_tokens":500}` + meta := ParseResponsesMeta([]byte(body)) + if meta.ParallelToolCalls != true { + t.Fatalf("parallel_tool_calls = %v", meta.ParallelToolCalls) + } + if meta.ToolChoice != "required" { + t.Fatalf("tool_choice = %v", meta.ToolChoice) + } + tools, ok := meta.Tools.([]any) + if !ok || len(tools) != 1 { + t.Fatalf("tools = %v", meta.Tools) + } + if meta.Temperature != 0.7 { + t.Fatalf("temperature = %v", meta.Temperature) + } + if meta.MaxOutputTokens != float64(500) { + t.Fatalf("max_output_tokens = %v", meta.MaxOutputTokens) + } +} diff --git a/internal/server/responses.go b/internal/server/responses.go new file mode 100644 index 0000000..284737b --- /dev/null +++ b/internal/server/responses.go @@ -0,0 +1,591 @@ +package server + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "git.misaka.ren/M1saka/zhanlu_proxy/internal/openai" +) + +// responses handles POST /v1/responses — the OpenAI Responses API. It +// translates the request to a chat-completions request for the upstream +// gateway, then translates the response back into the Responses format. +func (s *Server) responses(w http.ResponseWriter, r *http.Request) { + creds, err := s.currentCredentials() + if err != nil { + writeOpenAIError(w, http.StatusUnauthorized, "zhanlu credentials are not configured; open /login first", "auth_error", "missing_credentials") + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body") + return + } + + chatReq, err := openai.ParseResponsesRequest(body) + if err != nil { + writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_json") + return + } + meta := openai.ParseResponsesMeta(body) + if chatReq.Model == "" { + chatReq.Model = "zhanlu/auto" + } + + start := time.Now() + clientWantsStream := chatReq.Stream + chatReq.Stream = true // upstream always streams + upstreamBody, err := chatReq.MarshalForUpstream() + if err != nil { + writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body") + return + } + + if !creds.HasAPIKey() { + creds, err = s.provisionCredentials(r.Context(), creds) + if err != nil { + writeOpenAIError(w, http.StatusBadGateway, "zhanlu api key provisioning failed: "+err.Error(), "auth_error", "zhanlu_provision_failed") + return + } + s.cfg.Credentials = creds + _ = s.st.SaveCredentials(creds) + } + modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL) + client, err := s.zhanluClientWithBase(modelBaseURL) + if err != nil { + writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed") + return + } + resp, err := client.ChatCompletions(r.Context(), creds.APIKey, upstreamBody) + if err != nil { + msg := "zhanlu upstream request failed" + if s.cfg.Debug { + msg = redactSensitive(err.Error()) + } + writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed") + s.record(chatReq.Model, clientWantsStream, nil, "upstream_error", start) + return + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + msg := fmt.Sprintf("zhanlu upstream returned %d", resp.StatusCode) + if s.cfg.Debug && len(b) > 0 { + msg += ": " + string(b) + } + writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status") + s.record(chatReq.Model, clientWantsStream, nil, "upstream_error", start) + return + } + + if clientWantsStream { + usage, status := s.proxyResponsesStream(w, resp, chatReq.Model, meta) + s.record(chatReq.Model, true, usage, status, start) + return + } + usage, status := s.aggregateResponsesStream(w, resp, chatReq.Model, meta) + s.record(chatReq.Model, false, usage, status, start) +} + +// ---------------------------------------------------------------- non-streaming + +// aggregateResponsesStream reads the upstream SSE, aggregates it (same logic +// as aggregateStream), then emits a Responses API JSON object. +func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Response, model string, meta openai.ResponsesMeta) (any, string) { + var content, reasoning, id string + var usage any + finishReason := "stop" + type toolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } + toolCalls := map[int]*toolCall{} + err := forEachSSEChunk(resp.Body, func(chunk []byte) error { + var event struct { + ID string `json:"id"` + Choices []struct { + Delta struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + Reasoning string `json:"reasoning"` + ToolCalls []struct { + Index int `json:"index"` + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + Usage any `json:"usage"` + } + if err := json.Unmarshal(chunk, &event); err != nil { + return err + } + if event.ID != "" { + id = event.ID + } + if event.Usage != nil { + usage = event.Usage + } + if len(event.Choices) > 0 { + content += event.Choices[0].Delta.Content + reasoning += event.Choices[0].Delta.ReasoningContent + event.Choices[0].Delta.Reasoning + for _, part := range event.Choices[0].Delta.ToolCalls { + call := toolCalls[part.Index] + if call == nil { + call = &toolCall{Type: "function"} + toolCalls[part.Index] = call + } + if part.ID != "" { + call.ID = part.ID + } + if part.Type != "" { + call.Type = part.Type + } + call.Function.Name += part.Function.Name + call.Function.Arguments += part.Function.Arguments + } + if event.Choices[0].FinishReason != nil { + finishReason = *event.Choices[0].FinishReason + } + } + return nil + }) + if err != nil { + writeOpenAIError(w, http.StatusBadGateway, err.Error(), "upstream_error", "zhanlu_stream_error") + return nil, "upstream_error" + } + + _ = id + _ = finishReason + respID := "resp_" + randomRequestID() + output := []any{} + + if reasoning != "" { + output = append(output, map[string]any{ + "type": "reasoning", "id": "rs_" + randomRequestID(), "status": "completed", + "content": []any{}, + "summary": []map[string]any{{"type": "summary_text", "text": reasoning}}, + }) + } + + // Message output item — included when there is text content. + if content != "" { + output = append(output, map[string]any{ + "type": "message", "id": "msg_" + randomRequestID(), "status": "completed", + "role": "assistant", + "content": []map[string]any{{ + "type": "output_text", "text": content, + "annotations": []any{}, "logprobs": []any{}, + }}, + }) + } + + if len(toolCalls) > 0 { + ordered := make([]*toolCall, 0, len(toolCalls)) + for i := 0; i < len(toolCalls); i++ { + if call := toolCalls[i]; call != nil { + ordered = append(ordered, call) + } + } + for _, tc := range ordered { + output = append(output, map[string]any{ + "type": "function_call", "id": "fc_" + randomRequestID(), + "call_id": tc.ID, "name": tc.Function.Name, + "arguments": tc.Function.Arguments, "status": "completed", + }) + } + } + + // If there was no content and no tool calls, still emit an empty message + // so the response always has at least one output item. + if len(output) == 0 { + output = append(output, map[string]any{ + "type": "message", "id": "msg_" + randomRequestID(), "status": "completed", + "role": "assistant", "content": []map[string]any{}, + }) + } + + result := map[string]any{ + "id": respID, "object": "response", "created_at": time.Now().Unix(), + "model": model, "status": "completed", "output": output, + "parallel_tool_calls": meta.ParallelToolCalls, + "tool_choice": meta.ToolChoice, + "tools": meta.Tools, + } + if usage != nil { + result["usage"] = openai.UsageToResponses(usage) + } + writeJSON(w, http.StatusOK, result) + return usage, "success" +} + +// ------------------------------------------------------------------- streaming + +// responsesStreamState tracks the lifecycle of output items while converting +// Chat Completions SSE into Responses API SSE events. +type responsesStreamState struct { + w http.ResponseWriter + flusher http.Flusher + respID string + msgID string + rsID string + meta openai.ResponsesMeta + + outputIdx int + reasoningOn bool + reasonPartOn bool + messageOn bool + partOn bool + fullContent string + fullReasoning string + usage any + status string + + // tool call tracking + tools map[int]*streamToolCall + toolOrder []int +} + +type streamToolCall struct { + itemID string + callID string + name string + args string + started bool + doneIdx int // assigned output index when started +} + +func newResponsesStreamState(w http.ResponseWriter, model string, meta openai.ResponsesMeta) *responsesStreamState { + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + st := &responsesStreamState{ + w: w, + flusher: flusher, + respID: "resp_" + randomRequestID(), + msgID: "msg_" + randomRequestID(), + rsID: "rs_" + randomRequestID(), + meta: meta, + tools: map[int]*streamToolCall{}, + status: "success", + } + baseResp := func(st2 string) map[string]any { + return map[string]any{ + "id": st.respID, "object": "response", "created_at": time.Now().Unix(), + "model": model, "status": st2, "output": []any{}, + "parallel_tool_calls": meta.ParallelToolCalls, + "tool_choice": meta.ToolChoice, + "tools": meta.Tools, + } + } + st.emit("response.created", map[string]any{"type": "response.created", "response": baseResp("in_progress")}) + st.emit("response.in_progress", map[string]any{"type": "response.in_progress", "response": baseResp("in_progress")}) + return st +} + +func (st *responsesStreamState) emit(event string, data any) { + b, _ := json.Marshal(data) + fmt.Fprintf(st.w, "event: %s\ndata: %s\n\n", event, b) + if st.flusher != nil { + st.flusher.Flush() + } +} + +// closeReasoning closes the reasoning output item if it is open. +func (st *responsesStreamState) closeReasoning() { + if !st.reasoningOn { + return + } + if st.reasonPartOn { + st.emit("response.reasoning_summary_text.done", map[string]any{ + "type": "response.reasoning_summary_text.done", "item_id": st.rsID, + "output_index": st.outputIdx, "summary_index": 0, "text": st.fullReasoning, + }) + st.emit("response.reasoning_summary_part.done", map[string]any{ + "type": "response.reasoning_summary_part.done", "item_id": st.rsID, + "output_index": st.outputIdx, "summary_index": 0, + "part": map[string]any{"type": "summary_text", "text": st.fullReasoning}, + }) + st.reasonPartOn = false + } + st.emit("response.output_item.done", map[string]any{ + "type": "response.output_item.done", "output_index": st.outputIdx, + "item": map[string]any{ + "type": "reasoning", "id": st.rsID, "status": "completed", + "content": []any{}, + "summary": []map[string]any{{"type": "summary_text", "text": st.fullReasoning}}, + }, + }) + st.reasoningOn = false + st.outputIdx++ +} + +// closeMessage closes the message output item if it is open. +func (st *responsesStreamState) closeMessage() { + if !st.messageOn { + return + } + if st.partOn { + st.emit("response.output_text.done", map[string]any{ + "type": "response.output_text.done", "item_id": st.msgID, + "output_index": st.outputIdx, "content_index": 0, "text": st.fullContent, + }) + st.emit("response.content_part.done", map[string]any{ + "type": "response.content_part.done", "item_id": st.msgID, + "output_index": st.outputIdx, "content_index": 0, + "part": map[string]any{"type": "output_text", "text": st.fullContent, "annotations": []any{}, "logprobs": []any{}}, + }) + st.partOn = false + } + st.emit("response.output_item.done", map[string]any{ + "type": "response.output_item.done", "output_index": st.outputIdx, + "item": map[string]any{ + "type": "message", "id": st.msgID, "status": "completed", "role": "assistant", + "content": []map[string]any{{"type": "output_text", "text": st.fullContent, "annotations": []any{}, "logprobs": []any{}}}, + }, + }) + st.messageOn = false + st.outputIdx++ +} + +// handleReasoning processes a reasoning content delta. +func (st *responsesStreamState) handleReasoning(delta string) { + if !st.reasoningOn { + st.reasoningOn = true + st.emit("response.output_item.added", map[string]any{ + "type": "response.output_item.added", "output_index": st.outputIdx, + "item": map[string]any{ + "type": "reasoning", "id": st.rsID, "status": "in_progress", + "content": []any{}, "summary": []any{}, + }, + }) + st.emit("response.reasoning_summary_part.added", map[string]any{ + "type": "response.reasoning_summary_part.added", "item_id": st.rsID, + "output_index": st.outputIdx, "summary_index": 0, + "part": map[string]any{"type": "summary_text", "text": ""}, + }) + st.reasonPartOn = true + } + st.fullReasoning += delta + st.emit("response.reasoning_summary_text.delta", map[string]any{ + "type": "response.reasoning_summary_text.delta", "item_id": st.rsID, + "output_index": st.outputIdx, "summary_index": 0, "delta": delta, + }) +} + +// handleContent processes a text content delta. +func (st *responsesStreamState) handleContent(delta string) { + // Close reasoning if open — text content comes after reasoning. + st.closeReasoning() + if !st.messageOn { + st.messageOn = true + st.emit("response.output_item.added", map[string]any{ + "type": "response.output_item.added", "output_index": st.outputIdx, + "item": map[string]any{ + "type": "message", "id": st.msgID, "status": "in_progress", "role": "assistant", "content": []any{}, + }, + }) + st.emit("response.content_part.added", map[string]any{ + "type": "response.content_part.added", "item_id": st.msgID, + "output_index": st.outputIdx, "content_index": 0, + "part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}, "logprobs": []any{}}, + }) + st.partOn = true + } + st.fullContent += delta + st.emit("response.output_text.delta", map[string]any{ + "type": "response.output_text.delta", "item_id": st.msgID, + "output_index": st.outputIdx, "content_index": 0, "delta": delta, + }) +} + +// handleToolCall processes a tool call delta from the chat completion stream. +func (st *responsesStreamState) handleToolCall(index int, id, name, args string) { + // Close reasoning/message if open — tool calls are separate output items. + st.closeReasoning() + st.closeMessage() + + call := st.tools[index] + if call == nil { + call = &streamToolCall{itemID: "fc_" + randomRequestID()} + st.tools[index] = call + st.toolOrder = append(st.toolOrder, index) + } + if id != "" { + call.callID = id + } + if name != "" { + call.name += name + } + if !call.started { + call.started = true + call.doneIdx = st.outputIdx + st.emit("response.output_item.added", map[string]any{ + "type": "response.output_item.added", "output_index": st.outputIdx, + "item": map[string]any{ + "type": "function_call", "id": call.itemID, "call_id": call.callID, + "name": call.name, "arguments": "", "status": "in_progress", + }, + }) + st.outputIdx++ + } + if args != "" { + call.args += args + st.emit("response.function_call_arguments.delta", map[string]any{ + "type": "response.function_call_arguments.delta", "item_id": call.itemID, + "output_index": call.doneIdx, "delta": args, + }) + } +} + +// finish closes all open items and sends the response.completed event. +func (st *responsesStreamState) finish(model string) { + st.closeReasoning() + st.closeMessage() + for _, idx := range st.toolOrder { + call := st.tools[idx] + st.emit("response.function_call_arguments.done", map[string]any{ + "type": "response.function_call_arguments.done", "item_id": call.itemID, + "output_index": call.doneIdx, "arguments": call.args, + }) + st.emit("response.output_item.done", map[string]any{ + "type": "response.output_item.done", "output_index": call.doneIdx, + "item": map[string]any{ + "type": "function_call", "id": call.itemID, "call_id": call.callID, + "name": call.name, "arguments": call.args, "status": "completed", + }, + }) + } + + // Build the final output array for the completed event. + finalOutput := []any{} + if st.fullReasoning != "" { + finalOutput = append(finalOutput, map[string]any{ + "type": "reasoning", "id": st.rsID, "status": "completed", + "content": []any{}, + "summary": []map[string]any{{"type": "summary_text", "text": st.fullReasoning}}, + }) + } + if st.fullContent != "" { + finalOutput = append(finalOutput, map[string]any{ + "type": "message", "id": st.msgID, "status": "completed", "role": "assistant", + "content": []map[string]any{{"type": "output_text", "text": st.fullContent, "annotations": []any{}, "logprobs": []any{}}}, + }) + } + for _, idx := range st.toolOrder { + call := st.tools[idx] + finalOutput = append(finalOutput, map[string]any{ + "type": "function_call", "id": call.itemID, "call_id": call.callID, + "name": call.name, "arguments": call.args, "status": "completed", + }) + } + // Ensure at least one output item exists. + if len(finalOutput) == 0 { + finalOutput = append(finalOutput, map[string]any{ + "type": "message", "id": st.msgID, "status": "completed", "role": "assistant", + "content": []map[string]any{}, + }) + } + + completedResp := map[string]any{ + "id": st.respID, "object": "response", "created_at": time.Now().Unix(), + "model": model, "status": "completed", "output": finalOutput, + "parallel_tool_calls": st.meta.ParallelToolCalls, + "tool_choice": st.meta.ToolChoice, + "tools": st.meta.Tools, + } + if st.usage != nil { + completedResp["usage"] = openai.UsageToResponses(st.usage) + } + st.emit("response.completed", map[string]any{"type": "response.completed", "response": completedResp}) +} + +// proxyResponsesStream reads the upstream Chat Completions SSE and emits +// Responses API SSE events via a responsesStreamState state machine. +func (s *Server) proxyResponsesStream(w http.ResponseWriter, resp *http.Response, model string, meta openai.ResponsesMeta) (any, string) { + st := newResponsesStreamState(w, model, meta) + + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadString('\n') + if line != "" { + payload := sseDataPayload(line) + if payload != "" && payload != "[DONE]" { + var evt struct { + State string `json:"state"` + ErrorMessage string `json:"errorMessage"` + Usage any `json:"usage"` + Choices []struct { + Delta struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + Reasoning string `json:"reasoning"` + ToolCalls []struct { + Index int `json:"index"` + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + } `json:"choices"` + } + if jErr := json.Unmarshal([]byte(payload), &evt); jErr == nil { + if evt.State == "ERROR" { + st.status = "upstream_error" + } + if evt.Usage != nil { + st.usage = evt.Usage + } + if len(evt.Choices) > 0 { + delta := evt.Choices[0].Delta + reasoningDelta := delta.ReasoningContent + delta.Reasoning + if reasoningDelta != "" { + st.handleReasoning(reasoningDelta) + } + if delta.Content != "" { + st.handleContent(delta.Content) + } + for _, tc := range delta.ToolCalls { + st.handleToolCall(tc.Index, tc.ID, tc.Function.Name, tc.Function.Arguments) + } + } + } + } + } + if err != nil { + if err != io.EOF { + b, _ := json.Marshal(map[string]any{ + "message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error", + }) + fmt.Fprintf(w, "data: %s\n\n", b) + if st.flusher != nil { + st.flusher.Flush() + } + st.status = "upstream_error" + } + break + } + } + + st.finish(model) + return st.usage, st.status +} diff --git a/internal/server/responses_test.go b/internal/server/responses_test.go new file mode 100644 index 0000000..d63edcc --- /dev/null +++ b/internal/server/responses_test.go @@ -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: "dev@example.com", + } + 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: "dev@example.com", + } + 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: "dev@example.com", + } + _ = 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: "dev@example.com", + } + _ = 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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 5498af9..c7a4195 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -65,6 +65,7 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel)) s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models)) s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions)) + s.mux.HandleFunc("POST /v1/responses", s.withAPIKey(s.responses)) } func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { @@ -704,9 +705,9 @@ func (s *Server) testModel(w http.ResponseWriter, r *http.Request) { payload := sseDataPayload(line) if payload != "" && payload != "[DONE]" { var evt struct { - State string `json:"state"` - ErrorMessage string `json:"errorMessage"` - Choices []struct { + State string `json:"state"` + ErrorMessage string `json:"errorMessage"` + Choices []struct { Delta struct { Content string `json:"content"` } `json:"delta"`