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

284 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]) + "…"
}