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:
@@ -5,7 +5,7 @@
|
|||||||
当前实现包含:
|
当前实现包含:
|
||||||
|
|
||||||
- 登录页支持插件默认的移动云手机号验证码登录:验证码校验后按插件流程调用 `/api/acepilot/zhanlu/v1/login` 获取用户资料,再通过 SM2 签名调用 `/user/api/v2/external/key/get-or-create` 换取模型 API Key,凭据自动保存到本地 JSON。
|
- 登录页支持插件默认的移动云手机号验证码登录:验证码校验后按插件流程调用 `/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 <apiKey>` 请求 `{modelBaseUrl}/chat/completions`)。
|
- OpenAI 兼容接口:`/v1/models`(优先从 `/gateway/v1/model/info` 拉取模型列表)、`/v1/chat/completions`(携带 `Authorization: Bearer <apiKey>` 请求 `{modelBaseUrl}/chat/completions`)、`/v1/responses`(Responses API,自动转换请求/响应格式,兼容新版 SDK 与 AI 编码工具)。
|
||||||
- 湛卢登录签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`,用于 v1.4.2 的 v1/login 认证。
|
- 湛卢登录签名逻辑: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`)。
|
- 模型 API Key 换取签名逻辑:SM3 摘要 + SM2 签名(`X-Auth-Signature`/`X-Auth-Timestamp`/`X-Auth-Nonce`)。
|
||||||
- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
|
- 上游 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` 再发起请求即可得到最终回答。
|
请求中的 `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`:
|
如果设置了本地 OpenAI 兼容 API Key,需要带 `Authorization`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel))
|
s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel))
|
||||||
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
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/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) {
|
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)
|
payload := sseDataPayload(line)
|
||||||
if payload != "" && payload != "[DONE]" {
|
if payload != "" && payload != "[DONE]" {
|
||||||
var evt struct {
|
var evt struct {
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
ErrorMessage string `json:"errorMessage"`
|
ErrorMessage string `json:"errorMessage"`
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Delta struct {
|
Delta struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
|
|||||||
Reference in New Issue
Block a user