thread-master/apps/backend/internal/module/ai/openai_compatible.go

284 lines
8.1 KiB
Go
Raw Permalink Normal View History

2026-07-13 01:15:30 +00:00
package ai
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)
// OpenAICompatible talks to OpenAI-style /models and /chat/completions.
// xAI: https://api.x.ai/v1
// OpenCode Go: https://opencode.ai/zen/go/v1
type OpenAICompatible struct {
ID string
BaseURL string
Client *http.Client
}
func NewOpenAICompatible(id, baseURL string) *OpenAICompatible {
return &OpenAICompatible{
ID: id,
BaseURL: strings.TrimRight(baseURL, "/"),
// Job worker 可能跑到 4 分鐘HTTP 同步路徑靠 ctx deadline 提早取消
Client: &http.Client{Timeout: 4 * time.Minute},
}
}
func (p *OpenAICompatible) Complete(ctx context.Context, apiKey, model, prompt string) (string, error) {
if strings.TrimSpace(apiKey) == "" {
return "", fmt.Errorf("missing API key for %s", p.ID)
}
if model == "" {
model = "default"
}
// 強制注入系統語言 prompt依 ctx 語言;預設繁中)
sys := SystemPromptForLanguage(ResponseLanguageFrom(ctx))
// OpenCode Gokimi/deepseek/glmreasoning 常先燒 2001000+ token
// max_tokens 太小會 finish=length 且 content=nullxAI grok 較少踩雷)
// 靈感等路徑可用 WithMaxTokens 壓小以加速。
maxTokens := MaxTokensFrom(ctx)
if maxTokens <= 0 {
maxTokens = 4096
if p.ID == ProviderOpenCodeGo {
maxTokens = 8192
}
}
temp := 0.9
if t := TemperatureFrom(ctx); t > 0 {
temp = t
}
text, meta, err := p.doChat(ctx, apiKey, model, sys, prompt, maxTokens, temp)
if err != nil {
return "", err
}
if text != "" {
return text, nil
}
// content 空 + lengthreasoning 模型常把預算燒完;加大後再試一次
if meta.FinishReason == "length" && p.ID == ProviderOpenCodeGo {
retry := 12288
// 靈感等路徑有短預算:不要直接跳 12k更慢但要夠過 reasoning 門檻
if cap := MaxTokensFrom(ctx); cap > 0 {
retry = cap * 3
if retry < 4096 {
retry = 4096
}
if retry > 8192 {
retry = 8192
}
}
text2, _, err2 := p.doChat(ctx, apiKey, model, sys, prompt, retry, 0.7)
if err2 == nil && strings.TrimSpace(text2) != "" {
return strings.TrimSpace(text2), nil
}
}
return "", fmt.Errorf("%s returned empty content (finish=%s model=%s sample=%s)",
p.ID, meta.FinishReason, meta.Model, truncateRunes(meta.RawSnippet, 160))
}
func (p *OpenAICompatible) doChat(ctx context.Context, apiKey, model, sys, prompt string, maxTokens int, temperature float64) (string, chatExtractMeta, error) {
meta := chatExtractMeta{}
body := map[string]any{
"model": model,
"max_tokens": maxTokens,
"temperature": temperature,
"messages": []map[string]string{
{"role": "system", "content": sys},
{"role": "user", "content": prompt},
},
}
raw, err := json.Marshal(body)
if err != nil {
return "", meta, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.BaseURL+"/chat/completions", strings.NewReader(string(raw)))
if err != nil {
return "", meta, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
// OpenCodeCloudflare會擋 Go 預設 UA
req.Header.Set("User-Agent", "HarborDesk/1.0 (+threads-tool; OpenAI-compatible)")
res, err := p.Client.Do(req)
if err != nil {
return "", meta, err
}
defer res.Body.Close()
data, _ := io.ReadAll(io.LimitReader(res.Body, 2<<20))
if res.StatusCode < 200 || res.StatusCode >= 300 {
return "", meta, fmt.Errorf("%s chat failed: HTTP %d %s", p.ID, res.StatusCode, truncateRunes(string(data), 240))
}
text, meta, perr := extractChatContent(data)
if perr != nil {
return "", meta, fmt.Errorf("%s chat parse: %w", p.ID, perr)
}
return strings.TrimSpace(text), meta, nil
}
type chatExtractMeta struct {
FinishReason string
Model string
RawSnippet string
}
// extractChatContent 支援:
// - message.content 字串
// - message.content 多段陣列 [{type,text}]
// - 部分供應商把正文放在 reasoning_content / output_text
func extractChatContent(data []byte) (string, chatExtractMeta, error) {
meta := chatExtractMeta{RawSnippet: string(data)}
var payload struct {
Model string `json:"model"`
Choices []struct {
FinishReason string `json:"finish_reason"`
Message struct {
// content 可能是 string 或 array
Content json.RawMessage `json:"content"`
ReasoningContent string `json:"reasoning_content"`
// OpenCode / 部分模型
Reasoning string `json:"reasoning"`
OutputText string `json:"output_text"`
} `json:"message"`
// 舊式 text 欄位
Text string `json:"text"`
} `json:"choices"`
// 部分 gateway 把錯誤包在 error 物件但仍 200
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return "", meta, err
}
meta.Model = strings.TrimSpace(payload.Model)
if payload.Error != nil && strings.TrimSpace(payload.Error.Message) != "" {
return "", meta, fmt.Errorf("%s", payload.Error.Message)
}
if len(payload.Choices) == 0 {
return "", meta, fmt.Errorf("no choices")
}
ch := payload.Choices[0]
meta.FinishReason = strings.TrimSpace(ch.FinishReason)
if t := decodeMessageContent(ch.Message.Content); t != "" {
return t, meta, nil
}
if t := strings.TrimSpace(ch.Message.OutputText); t != "" {
return t, meta, nil
}
if t := strings.TrimSpace(ch.Text); t != "" {
return t, meta, nil
}
// 不把 reasoning / thinking 當使用者可見正文。
// 舊邏輯會把「我們需要根據最近的對話…」這類思考過程寫進靈感聊天,看起來像壞掉。
// content 空時回空字串,由呼叫端 retry 加大 max_tokens 或回錯誤。
return "", meta, nil
}
func decodeMessageContent(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
// 字串
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return strings.TrimSpace(s)
}
// 陣列OpenAI/xAI 多段 content
var parts []struct {
Type string `json:"type"`
Text string `json:"text"`
// 有些用 output_text
OutputText string `json:"output_text"`
}
if err := json.Unmarshal(raw, &parts); err == nil {
var b strings.Builder
for _, p := range parts {
t := strings.TrimSpace(p.Text)
if t == "" {
t = strings.TrimSpace(p.OutputText)
}
if t == "" {
continue
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(t)
}
return strings.TrimSpace(b.String())
}
// 單一物件 {type,text}
var one struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &one); err == nil && strings.TrimSpace(one.Text) != "" {
return strings.TrimSpace(one.Text)
}
return ""
}
func (p *OpenAICompatible) ListModels(ctx context.Context, apiKey string) ([]string, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("missing API key for %s", p.ID)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.BaseURL+"/models", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "HarborDesk/1.0 (+threads-tool; OpenAI-compatible)")
res, err := p.Client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
data, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("%s models failed: HTTP %d %s", p.ID, res.StatusCode, truncateRunes(string(data), 200))
}
var payload struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, fmt.Errorf("%s models parse: %w", p.ID, err)
}
models := make([]string, 0, len(payload.Data))
for _, item := range payload.Data {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
models = append(models, id)
}
sort.Strings(models)
if len(models) == 0 {
return nil, fmt.Errorf("%s returned empty model list", p.ID)
}
return models, nil
}
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}