284 lines
8.1 KiB
Go
284 lines
8.1 KiB
Go
|
|
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 Go(kimi/deepseek/glm)reasoning 常先燒 200~1000+ token;
|
|||
|
|
// max_tokens 太小會 finish=length 且 content=null(xAI 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 空 + length:reasoning 模型常把預算燒完;加大後再試一次
|
|||
|
|
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")
|
|||
|
|
// OpenCode(Cloudflare)會擋 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]) + "…"
|
|||
|
|
}
|