152 lines
3.9 KiB
Go
152 lines
3.9 KiB
Go
package ai
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
)
|
||
|
||
// CompleteStream streams chat completion deltas via onDelta; returns full text.
|
||
// onDelta may be called many times; return error to abort.
|
||
func (p *OpenAICompatible) CompleteStream(ctx context.Context, apiKey, model, prompt string, onDelta func(chunk string) error) (string, error) {
|
||
if strings.TrimSpace(apiKey) == "" {
|
||
return "", fmt.Errorf("missing API key for %s", p.ID)
|
||
}
|
||
if model == "" {
|
||
model = "default"
|
||
}
|
||
sys := SystemPromptForLanguage(ResponseLanguageFrom(ctx))
|
||
maxTokens := MaxTokensFrom(ctx)
|
||
if maxTokens <= 0 {
|
||
maxTokens = 2048
|
||
if p.ID == ProviderOpenCodeGo {
|
||
// 預設 stream 預算;靈感會再透過 WithMaxTokens 壓更小
|
||
maxTokens = 1536
|
||
}
|
||
}
|
||
temp := 0.9
|
||
if t := TemperatureFrom(ctx); t > 0 {
|
||
temp = t
|
||
}
|
||
|
||
body := map[string]any{
|
||
"model": model,
|
||
"max_tokens": maxTokens,
|
||
"temperature": temp,
|
||
"stream": true,
|
||
"messages": []map[string]string{
|
||
{"role": "system", "content": sys},
|
||
{"role": "user", "content": prompt},
|
||
},
|
||
}
|
||
raw, err := json.Marshal(body)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.BaseURL+"/chat/completions", bytes.NewReader(raw))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Accept", "text/event-stream")
|
||
req.Header.Set("User-Agent", "HarborDesk/1.0 (+threads-tool; OpenAI-compatible)")
|
||
|
||
res, err := p.Client.Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer res.Body.Close()
|
||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||
data, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
|
||
return "", fmt.Errorf("%s stream failed: HTTP %d %s", p.ID, res.StatusCode, truncateRunes(string(data), 240))
|
||
}
|
||
|
||
var full strings.Builder
|
||
sc := bufio.NewScanner(res.Body)
|
||
// SSE 行可能較長
|
||
buf := make([]byte, 0, 64*1024)
|
||
sc.Buffer(buf, 1024*1024)
|
||
|
||
for sc.Scan() {
|
||
line := sc.Text()
|
||
if line == "" {
|
||
continue
|
||
}
|
||
if !strings.HasPrefix(line, "data:") {
|
||
continue
|
||
}
|
||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||
if payload == "[DONE]" {
|
||
break
|
||
}
|
||
chunk, ok := extractStreamDelta(payload)
|
||
if !ok || chunk == "" {
|
||
continue
|
||
}
|
||
full.WriteString(chunk)
|
||
if onDelta != nil {
|
||
if err := onDelta(chunk); err != nil {
|
||
return full.String(), err
|
||
}
|
||
}
|
||
}
|
||
if err := sc.Err(); err != nil && ctx.Err() == nil {
|
||
return full.String(), err
|
||
}
|
||
text := strings.TrimSpace(full.String())
|
||
if text == "" {
|
||
// 部分供應商 stream 只吐 reasoning、content 在非 stream 才有;
|
||
// 或 max_tokens 太小 content=null。降級 Complete(內含 length 再試)。
|
||
return p.Complete(ctx, apiKey, model, prompt)
|
||
}
|
||
return text, nil
|
||
}
|
||
|
||
func extractStreamDelta(payload string) (string, bool) {
|
||
var obj struct {
|
||
Choices []struct {
|
||
Delta struct {
|
||
Content json.RawMessage `json:"content"`
|
||
ReasoningContent string `json:"reasoning_content"`
|
||
} `json:"delta"`
|
||
// 少數 gateway 用 message
|
||
Message struct {
|
||
Content json.RawMessage `json:"content"`
|
||
} `json:"message"`
|
||
} `json:"choices"`
|
||
}
|
||
if err := json.Unmarshal([]byte(payload), &obj); err != nil {
|
||
return "", false
|
||
}
|
||
if len(obj.Choices) == 0 {
|
||
return "", false
|
||
}
|
||
d := obj.Choices[0].Delta
|
||
if t := decodeMessageContent(d.Content); t != "" {
|
||
return t, true
|
||
}
|
||
// 不把 reasoning 當正文 stream 出去(避免滿屏思考)
|
||
if t := decodeMessageContent(obj.Choices[0].Message.Content); t != "" {
|
||
return t, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// CompleteStream on FakeClient — 一次吐出(測試)
|
||
func (f *FakeClient) CompleteStream(ctx context.Context, apiKey, model, prompt string, onDelta func(string) error) (string, error) {
|
||
text, err := f.Complete(ctx, apiKey, model, prompt)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if onDelta != nil {
|
||
_ = onDelta(text)
|
||
}
|
||
return text, nil
|
||
}
|