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

349 lines
10 KiB
Go
Raw 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
}
2026-07-20 06:33:14 +00:00
if text != "" && meta.FinishReason != "length" {
2026-07-13 01:15:30 +00:00
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
}
}
2026-07-20 06:33:14 +00:00
text2, meta2, err2 := p.doChat(ctx, apiKey, model, sys, prompt, retry, 0.7)
2026-07-13 01:15:30 +00:00
if err2 == nil && strings.TrimSpace(text2) != "" {
2026-07-20 06:33:14 +00:00
text = strings.TrimSpace(text2)
meta = meta2
maxTokens = retry
if meta.FinishReason != "length" {
return text, nil
}
2026-07-13 01:15:30 +00:00
}
}
2026-07-20 06:33:14 +00:00
if text != "" && meta.FinishReason == "length" {
return p.continueCompletion(ctx, apiKey, model, sys, prompt, text, maxTokens, temp)
}
2026-07-13 01:15:30 +00:00
return "", fmt.Errorf("%s returned empty content (finish=%s model=%s sample=%s)",
p.ID, meta.FinishReason, meta.Model, truncateRunes(meta.RawSnippet, 160))
}
2026-07-20 06:33:14 +00:00
const maxContinuationRounds = 4
func (p *OpenAICompatible) continueCompletion(ctx context.Context, apiKey, model, sys, prompt, partial string, maxTokens int, temperature float64) (string, error) {
full := strings.TrimSpace(partial)
for range maxContinuationRounds {
messages := completionContinuationMessages(sys, prompt, full)
next, meta, err := p.doChatMessages(ctx, apiKey, model, messages, maxTokens, temperature)
if err != nil {
return "", err
}
next = strings.TrimSpace(next)
if next == "" {
return "", fmt.Errorf("%s returned empty continuation (finish=%s model=%s)", p.ID, meta.FinishReason, meta.Model)
}
full = appendWithoutOverlap(full, next)
if meta.FinishReason != "length" {
return full, nil
}
}
return "", fmt.Errorf("%s could not finish content after continuation retries", p.ID)
}
func completionContinuationMessages(sys, prompt, partial string) []map[string]string {
return []map[string]string{
{"role": "system", "content": sys},
{"role": "user", "content": prompt},
{"role": "assistant", "content": partial},
{"role": "user", "content": "請從剛才中斷的位置直接繼續正文,完成尚未講完的內容。不要重寫、不要摘要、不要重複已輸出的句子,也不要加任何說明。"},
}
}
func appendWithoutOverlap(existing, continuation string) string {
existing = strings.TrimSpace(existing)
continuation = strings.TrimSpace(continuation)
max := len([]rune(existing))
if n := len([]rune(continuation)); n < max {
max = n
}
if max > 200 {
max = 200
}
er, cr := []rune(existing), []rune(continuation)
for n := max; n > 0; n-- {
if string(er[len(er)-n:]) == string(cr[:n]) {
return existing + string(cr[n:])
}
}
if existing == "" {
return continuation
}
return existing + continuation
}
2026-07-13 01:15:30 +00:00
func (p *OpenAICompatible) doChat(ctx context.Context, apiKey, model, sys, prompt string, maxTokens int, temperature float64) (string, chatExtractMeta, error) {
2026-07-20 06:33:14 +00:00
return p.doChatMessages(ctx, apiKey, model, []map[string]string{
{"role": "system", "content": sys},
{"role": "user", "content": prompt},
}, maxTokens, temperature)
}
func (p *OpenAICompatible) doChatMessages(ctx context.Context, apiKey, model string, messages []map[string]string, maxTokens int, temperature float64) (string, chatExtractMeta, error) {
2026-07-13 01:15:30 +00:00
meta := chatExtractMeta{}
body := map[string]any{
"model": model,
"max_tokens": maxTokens,
"temperature": temperature,
2026-07-20 06:33:14 +00:00
"messages": messages,
2026-07-13 01:15:30 +00:00
}
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]) + "…"
}