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