package provider import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "sort" "strings" "time" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/model/ai/domain/enum" domai "haixun-backend/internal/model/ai/domain/usecase" ) type OpenAICompatible struct { id enum.ProviderID baseURL string client *http.Client } func NewOpenAICompatible(id enum.ProviderID, baseURL string) *OpenAICompatible { return &OpenAICompatible{ id: id, baseURL: strings.TrimRight(baseURL, "/"), client: &http.Client{Timeout: 10 * time.Minute}, } } func (p *OpenAICompatible) ID() enum.ProviderID { return p.id } func (p *OpenAICompatible) ListModels(ctx context.Context, credential domai.Credential) ([]string, error) { if strings.TrimSpace(credential.APIKey) == "" { return nil, app.For(code.AI).InputMissingRequired("missing AI provider token") } httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/models", nil) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "Bearer "+credential.APIKey) httpReq.Header.Set("Accept", "application/json") resp, err := p.client.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, providerHTTPError("AI provider models request failed", resp.StatusCode, resp.Status) } var payload openAIModelsResponse if err := json.Unmarshal(data, &payload); err != nil { return nil, app.For(code.AI).SvcThirdParty("failed to parse AI provider models response") } 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) return models, nil } func (p *OpenAICompatible) GenerateText(ctx context.Context, req domai.GenerateRequest) (*domai.GenerateResult, error) { if strings.TrimSpace(req.Credential.APIKey) == "" { return nil, app.For(code.AI).InputMissingRequired("missing AI provider token") } body := p.buildChatCompletionRequest(req, false) payload, err := json.Marshal(body) if err != nil { return nil, err } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(payload)) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "Bearer "+req.Credential.APIKey) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json") resp, err := p.client.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, providerHTTPError("AI provider request failed", resp.StatusCode, resp.Status) } var chatResp openAIChatResponse if err := json.Unmarshal(data, &chatResp); err != nil { return nil, app.For(code.AI).SvcThirdParty("failed to parse AI provider chat response") } if len(chatResp.Choices) == 0 { return nil, app.For(code.AI).SvcThirdParty("AI provider returned no choices") } choice := chatResp.Choices[0] return &domai.GenerateResult{ Text: extractAssistantText(choice.Message), FinishReason: choice.FinishReason, }, nil } func (p *OpenAICompatible) StreamText(ctx context.Context, req domai.GenerateRequest) (<-chan domai.StreamEvent, error) { if strings.TrimSpace(req.Credential.APIKey) == "" { return nil, app.For(code.AI).InputMissingRequired("missing AI provider token") } body := p.buildChatCompletionRequest(req, true) payload, err := json.Marshal(body) if err != nil { return nil, err } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(payload)) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "Bearer "+req.Credential.APIKey) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "text/event-stream") resp, err := p.client.Do(httpReq) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { defer resp.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return nil, providerHTTPError("AI provider request failed", resp.StatusCode, resp.Status) } out := make(chan domai.StreamEvent) go func() { defer close(out) defer resp.Body.Close() parseOpenAIStream(resp.Body, out) }() return out, nil } type chatCompletionRequest struct { Model string `json:"model"` Messages []chatMessage `json:"messages"` Stream bool `json:"stream"` Temperature *float64 `json:"temperature,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"` Thinking *thinkingConfig `json:"thinking,omitempty"` } type thinkingConfig struct { Type string `json:"type,omitempty"` } type assistantMessage struct { Content json.RawMessage `json:"content"` ReasoningContent string `json:"reasoning_content"` Reasoning string `json:"reasoning"` } type openAIChatResponse struct { Choices []struct { Message assistantMessage `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` } func (p *OpenAICompatible) buildChatCompletionRequest(req domai.GenerateRequest, stream bool) chatCompletionRequest { return chatCompletionRequest{ Model: req.Model, Messages: toChatMessages(req), Stream: stream, Temperature: normalizeTemperature(p.id, req.Model, req.Temperature), MaxTokens: normalizeMaxTokens(p.id, req.Model, req.MaxTokens), Thinking: normalizeThinking(p.id, req.Model), } } func extractAssistantText(msg assistantMessage) string { if text := parseMessageContent(msg.Content); strings.TrimSpace(text) != "" { return text } if strings.TrimSpace(msg.ReasoningContent) != "" { return msg.ReasoningContent } return msg.Reasoning } func parseMessageContent(raw json.RawMessage) string { if len(raw) == 0 || string(raw) == "null" { return "" } var text string if err := json.Unmarshal(raw, &text); err == nil { return text } var parts []struct { Text string `json:"text"` } if err := json.Unmarshal(raw, &parts); err == nil { var b strings.Builder for _, part := range parts { if part.Text != "" { b.WriteString(part.Text) } } return b.String() } return "" } type chatMessage struct { Role string `json:"role"` Content string `json:"content"` } func toChatMessages(req domai.GenerateRequest) []chatMessage { messages := make([]chatMessage, 0, len(req.Messages)+1) if strings.TrimSpace(req.System) != "" { messages = append(messages, chatMessage{Role: "system", Content: req.System}) } for _, msg := range req.Messages { messages = append(messages, chatMessage{Role: msg.Role, Content: msg.Content}) } return messages } func normalizeTemperature(provider enum.ProviderID, model string, requested *float64) *float64 { if provider == enum.ProviderOpenCode && strings.HasPrefix(model, "kimi-") { v := 1.0 return &v } return requested } func normalizeMaxTokens(provider enum.ProviderID, model string, requested *int) *int { if provider != enum.ProviderOpenCode || !strings.HasPrefix(model, "deepseek-") { return requested } // DeepSeek V4 thinking can consume the full budget before content appears. const minDeepSeekTokens = 2048 if requested == nil || *requested < minDeepSeekTokens { v := minDeepSeekTokens return &v } return requested } func normalizeThinking(provider enum.ProviderID, model string) *thinkingConfig { // DeepSeek V4 on OpenCode Go defaults to thinking mode and may spend the entire // max_tokens budget on reasoning_content before emitting content. if provider == enum.ProviderOpenCode && strings.HasPrefix(model, "deepseek-") { return &thinkingConfig{Type: "disabled"} } return nil } func streamDeltaText(delta streamDelta) string { if delta.Content != "" { return delta.Content } if delta.Text != "" { return delta.Text } if delta.ReasoningContent != "" { return delta.ReasoningContent } return delta.Reasoning } func parseOpenAIStream(body io.Reader, out chan<- domai.StreamEvent) { scanner := bufio.NewScanner(body) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || !strings.HasPrefix(line, "data:") { continue } data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) if data == "[DONE]" { out <- domai.StreamEvent{Type: "done"} return } var chunk openAIStreamChunk if err := json.Unmarshal([]byte(data), &chunk); err != nil { out <- domai.StreamEvent{Type: "error", Error: "failed to parse AI stream payload"} return } for _, choice := range chunk.Choices { if text := streamDeltaText(choice.Delta); text != "" { out <- domai.StreamEvent{Type: "delta", Text: text} } if choice.FinishReason != "" { out <- domai.StreamEvent{Type: "done", FinishReason: choice.FinishReason} return } } } if err := scanner.Err(); err != nil { out <- domai.StreamEvent{Type: "error", Error: err.Error()} } } type streamDelta struct { Content string `json:"content"` Text string `json:"text"` ReasoningContent string `json:"reasoning_content"` Reasoning string `json:"reasoning"` } type openAIStreamChunk struct { Choices []struct { Delta streamDelta `json:"delta"` FinishReason string `json:"finish_reason"` } `json:"choices"` } type openAIModelsResponse struct { Data []struct { ID string `json:"id"` } `json:"data"` } func providerHTTPError(prefix string, statusCode int, statusLine string) error { return app.For(code.AI).SvcThirdParty(fmt.Sprintf("%s: HTTP %d %s", prefix, statusCode, statusLine)) }