From 3618ebe65d9245d4afb876aedb40714abef82bd6 Mon Sep 17 00:00:00 2001 From: xkm Date: Mon, 9 Mar 2026 19:10:06 +0800 Subject: [PATCH] feat: support response api --- main.go | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 151 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 17f7405..934bd6d 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,23 @@ type chatCompletionReq struct { Stream bool `json:"stream"` } +type ResponseReasoning struct { + Effort string `json:"effort"` + Summary string `json:"summary,omitempty"` // auto, concise, detailed +} +type chatResponseReq struct { + Model string `json:"model"` + Input []ChatMessage `json:"input"` + Temperature *float64 `json:"temperature,omitempty"` + Reasoning ResponseReasoning `json:"reasoning,omitempty"` + Stream bool `json:"stream"` +} + +type responseStreamEvent struct { + Type string `json:"type"` + Delta string `json:"delta"` +} + type streamEvent struct { Choices []struct { Delta struct { @@ -43,7 +60,7 @@ type streamEvent struct { } `json:"error,omitempty"` } -func streamChatText( +func streamChatCompletions( ctx context.Context, client *http.Client, baseURL string, @@ -176,11 +193,137 @@ func streamChatText( return pr, nil } +func streamChatResponses( + ctx context.Context, + client *http.Client, + baseURL string, + apiKey string, + model string, + reasoningEffort string, + temperature *float64, + msgs []ChatMessage, +) (io.ReadCloser, error) { + if msgs == nil || len(msgs) == 0 { + return nil, errors.New("missing messages") + } + endpoint := strings.TrimRight(baseURL, "/") + "/responses" + + body := chatResponseReq{ + Model: model, + Input: msgs, + Temperature: temperature, + Reasoning: ResponseReasoning{Effort: reasoningEffort}, + Stream: true, + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, err + } + + pr, pw := io.Pipe() + + go func() { + defer func(pw *io.PipeWriter) { + err := pw.Close() + if err != nil { + log.Println(err) + } + }(pw) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + _ = pw.CloseWithError(err) + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + _ = pw.CloseWithError(err) + return + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + log.Println(err) + } + }(resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + b, _ := io.ReadAll(resp.Body) + _ = pw.CloseWithError(fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))) + return + } + + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) + + var dataLines []string + flushEvent := func() bool { + if len(dataLines) == 0 { + _ = pw.Close() + return true + } + data := strings.Join(dataLines, "\n") + dataLines = dataLines[:0] + + var evt responseStreamEvent + if err := json.Unmarshal([]byte(data), &evt); err != nil { + _ = pw.CloseWithError(fmt.Errorf("failed to unmarshal event: %w, data=%q", err, data)) + return false + } + if evt.Type == "response.output_text.delta" { + if _, err := io.WriteString(pw, evt.Delta); err != nil { + _ = pw.CloseWithError(err) + return false + } + } + return true + } + + for sc.Scan() { + line := sc.Text() + + if line == "" { + if ok := flushEvent(); !ok { + return + } + continue + } + + if strings.HasPrefix(line, "data:") { + v := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + dataLines = append(dataLines, v) + } + if strings.HasPrefix(line, "event: response.completed") { + break + } + } + + if len(dataLines) > 0 { + _ = flushEvent() + return + } + + if err := sc.Err(); err != nil { + _ = pw.CloseWithError(err) + return + } + }() + + return pr, nil +} + // Translate src to lang func Translate( ctx context.Context, client *http.Client, baseURL string, + responseApi bool, apiKey string, model string, reasoningEffort string, @@ -201,12 +344,17 @@ func Translate( Content: src, }, } - return streamChatText(ctx, client, baseURL, apiKey, model, reasoningEffort, temperature, msgs) + if responseApi { + return streamChatResponses(ctx, client, baseURL, apiKey, model, reasoningEffort, temperature, msgs) + } + + return streamChatCompletions(ctx, client, baseURL, apiKey, model, reasoningEffort, temperature, msgs) } var cli struct { Config string `short:"c" default:"~/.config/translate.toml" help:"Path to config file"` BaseURL string `short:"b" help:"LLM API base URL" default:"https://api.openai.com/v1"` + ResponseApi bool `default:"false" help:"Use /v1/responses or /v1/chat/completions"` ApiKey string `short:"k" help:"LLM API Key"` Model string `short:"m" help:"LLM model" default:"gpt-5-nano"` ReasoningEffort string `help:"LLM reasoning effort (note that some LLMs may not support certain settings)" default:"minimal"` @@ -239,7 +387,7 @@ func main() { } } - stream, err := Translate(context.Background(), http.DefaultClient, cli.BaseURL, cli.ApiKey, cli.Model, + stream, err := Translate(context.Background(), http.DefaultClient, cli.BaseURL, cli.ResponseApi, cli.ApiKey, cli.Model, cli.ReasoningEffort, cli.Temperature, src, lang) if err != nil {