package app import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) const ( aiResponseLimit = 512 << 10 aiProbePrompt = "用一句不超过 20 个字的中文打个招呼。" ) // Vendors are many but wire protocols are few, so adapters are written per // protocol. Anything speaking the OpenAI chat format needs no new code, only a // new row in ai_models. var aiProtocols = []string{"openai", "anthropic", "gemini", "webhook", "debug"} type aiChatMessage struct { Role string // user 或 assistant Text string } type aiChatRequest struct { System string Messages []aiChatMessage MaxTokens int Temperature float64 } type aiChatResult struct { Text string Reasoning string PromptTokens int CompletionTokens int FinishReason string RequestID string } // The model ran out of budget mid-thought. For a reasoning model that usually // means the whole ceiling went on the chain of thought and Text is empty; for // any model it can also mean the answer itself was cut off. Both deserve one // retry with room to finish rather than a blank or half-finished message. func (r aiChatResult) truncated() bool { return r.FinishReason == "length" || (r.Text == "" && r.Reasoning != "") } type aiProvider interface { Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) } type aiModel struct { ID int64 `json:"id"` Name string `json:"name"` Protocol string `json:"protocol"` BaseURL string `json:"baseUrl"` ModelName string `json:"modelName"` Temperature float64 `json:"temperature"` MaxTokens int `json:"maxTokens"` TimeoutMS int `json:"timeoutMs"` Status int `json:"status"` IsDefault bool `json:"isDefault"` APIKey string `json:"-"` HasAPIKey bool `json:"hasApiKey"` // Provider-specific request fields, merged into the call body. This is how a // model that thinks by default is asked not to, without a code change: // {"thinking":{"type":"disabled"}}. Extra map[string]any `json:"extra,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } func aiProtocolName(protocol string) string { switch protocol { case "openai": return "OpenAI 兼容" case "anthropic": return "Anthropic Messages" case "gemini": return "Google Gemini" case "webhook": return "自定义 Webhook" case "debug": return "本地调试" } return protocol } // The endpoint is operator-supplied, so it is validated the same way the SMS // endpoints are: no credentials, no query string, no fragment. func parseAIBaseURL(protocol, raw string, production bool) (*url.URL, error) { trimmed := strings.TrimSpace(raw) if protocol == "debug" { return nil, nil } if trimmed == "" { return nil, fmt.Errorf("%s 需要填写接口地址", aiProtocolName(protocol)) } parsed, err := url.Parse(trimmed) if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return nil, fmt.Errorf("接口地址必须是无账号、查询参数和片段的有效地址") } if parsed.Scheme != "https" && !(parsed.Scheme == "http" && !production) { return nil, fmt.Errorf("接口地址必须使用 HTTPS") } return parsed, nil } func aiEndpoint(base *url.URL, suffix string) string { trimmed := strings.TrimRight(base.String(), "/") if suffix == "" || strings.HasSuffix(trimmed, suffix) { return trimmed } return trimmed + suffix } func newAIProvider(model aiModel, production bool) (aiProvider, error) { if !containsString(aiProtocols, model.Protocol) { return nil, fmt.Errorf("不支持的模型协议 %q", model.Protocol) } if model.Protocol == "debug" { if production { return nil, fmt.Errorf("生产环境禁止使用本地调试模型") } return debugAIProvider{}, nil } base, err := parseAIBaseURL(model.Protocol, model.BaseURL, production) if err != nil { return nil, err } if strings.TrimSpace(model.APIKey) == "" && model.Protocol != "webhook" { return nil, fmt.Errorf("%s 需要填写密钥", aiProtocolName(model.Protocol)) } if strings.TrimSpace(model.ModelName) == "" && model.Protocol != "webhook" { return nil, fmt.Errorf("请填写模型名称") } timeout := time.Duration(model.TimeoutMS) * time.Millisecond if timeout < time.Second || timeout > time.Minute { timeout = 15 * time.Second } client := &http.Client{Timeout: timeout} switch model.Protocol { case "openai": return openAIProvider{model: model, base: base, client: client}, nil case "anthropic": return anthropicProvider{model: model, base: base, client: client}, nil case "gemini": return geminiProvider{model: model, base: base, client: client}, nil } return webhookAIProvider{model: model, base: base, client: client}, nil } func aiRequestTemperature(req aiChatRequest, model aiModel) float64 { if req.Temperature > 0 { return req.Temperature } return model.Temperature } func aiRequestMaxTokens(req aiChatRequest, model aiModel) int { if req.MaxTokens > 0 { return req.MaxTokens } if model.MaxTokens > 0 { return model.MaxTokens } return 256 } // aiWithExtra adds the model's configured protocol fields to a request body. // It never overwrites what the caller set: the structural fields and the budget // this code reasons about stay under this code's control. func aiWithExtra(model aiModel, body map[string]any) map[string]any { for key, value := range model.Extra { if _, taken := body[key]; taken { continue } body[key] = value } return body } // One retry, with enough room for the thinking plus a short answer, and a cap // so a misconfigured model cannot bill an unbounded completion. func aiRetryMaxTokens(current int) int { const floor, ceiling = 1024, 4096 raised := current * 4 if raised < floor { raised = floor } if raised > ceiling { raised = ceiling } return raised } func aiPostJSON(ctx context.Context, client *http.Client, endpoint string, headers map[string]string, payload any) ([]byte, error) { body, err := json.Marshal(payload) if err != nil { return nil, err } request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, err } request.Header.Set("Content-Type", "application/json") request.Header.Set("Accept", "application/json") for key, value := range headers { request.Header.Set(key, value) } response, err := client.Do(request) if err != nil { return nil, fmt.Errorf("模型服务请求失败:%w", err) } defer response.Body.Close() raw, err := io.ReadAll(io.LimitReader(response.Body, aiResponseLimit)) if err != nil { return nil, fmt.Errorf("读取模型响应失败:%w", err) } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("模型服务返回 HTTP %d:%s", response.StatusCode, aiErrorSnippet(raw)) } return raw, nil } // Provider error bodies are quoted back to the operator, so they are trimmed to // something a config page can display. func aiErrorSnippet(raw []byte) string { text := strings.TrimSpace(string(raw)) text = strings.Join(strings.Fields(text), " ") if len([]rune(text)) > 200 { return string([]rune(text)[:200]) + "…" } if text == "" { return "无响应内容" } return text } type openAIProvider struct { model aiModel base *url.URL client *http.Client } func (p openAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) { messages := make([]map[string]string, 0, len(req.Messages)+1) if strings.TrimSpace(req.System) != "" { messages = append(messages, map[string]string{"role": "system", "content": req.System}) } for _, message := range req.Messages { messages = append(messages, map[string]string{"role": message.Role, "content": message.Text}) } raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, "/chat/completions"), map[string]string{"Authorization": "Bearer " + p.model.APIKey}, aiWithExtra(p.model, map[string]any{ "model": p.model.ModelName, "messages": messages, "max_tokens": aiRequestMaxTokens(req, p.model), "temperature": aiRequestTemperature(req, p.model), "stream": false, })) if err != nil { return aiChatResult{}, err } var payload struct { ID string `json:"id"` Choices []struct { Message struct { Content string `json:"content"` // Reasoning models put their chain of thought here and the answer // in Content. Spending the whole budget on the former leaves the // latter empty, which is indistinguishable from a broken model // unless this field is read. ReasoningContent string `json:"reasoning_content"` } `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` Usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` } `json:"usage"` Error struct { Message string `json:"message"` } `json:"error"` } if err = json.Unmarshal(raw, &payload); err != nil { return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON:%s", aiErrorSnippet(raw)) } if payload.Error.Message != "" { return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message) } if len(payload.Choices) == 0 { return aiChatResult{}, fmt.Errorf("模型没有返回任何内容") } return aiChatResult{ Text: strings.TrimSpace(payload.Choices[0].Message.Content), Reasoning: strings.TrimSpace(payload.Choices[0].Message.ReasoningContent), PromptTokens: payload.Usage.PromptTokens, CompletionTokens: payload.Usage.CompletionTokens, FinishReason: payload.Choices[0].FinishReason, RequestID: payload.ID, }, nil } type anthropicProvider struct { model aiModel base *url.URL client *http.Client } func (p anthropicProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) { messages := make([]map[string]any, 0, len(req.Messages)) for _, message := range req.Messages { messages = append(messages, map[string]any{ "role": message.Role, "content": []map[string]string{{"type": "text", "text": message.Text}}, }) } payloadBody := map[string]any{ "model": p.model.ModelName, "messages": messages, "max_tokens": aiRequestMaxTokens(req, p.model), "temperature": aiRequestTemperature(req, p.model), } // Anthropic carries the system prompt outside the message list. if strings.TrimSpace(req.System) != "" { payloadBody["system"] = req.System } raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, "/v1/messages"), map[string]string{"x-api-key": p.model.APIKey, "anthropic-version": "2023-06-01"}, payloadBody) if err != nil { return aiChatResult{}, err } var payload struct { ID string `json:"id"` Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` StopReason string `json:"stop_reason"` Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } `json:"usage"` Error struct { Message string `json:"message"` } `json:"error"` } if err = json.Unmarshal(raw, &payload); err != nil { return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON:%s", aiErrorSnippet(raw)) } if payload.Error.Message != "" { return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message) } text := "" for _, block := range payload.Content { if block.Type == "text" { text += block.Text } } if strings.TrimSpace(text) == "" { return aiChatResult{}, fmt.Errorf("模型没有返回任何内容") } return aiChatResult{ Text: strings.TrimSpace(text), PromptTokens: payload.Usage.InputTokens, CompletionTokens: payload.Usage.OutputTokens, FinishReason: payload.StopReason, RequestID: payload.ID, }, nil } type geminiProvider struct { model aiModel base *url.URL client *http.Client } func (p geminiProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) { contents := make([]map[string]any, 0, len(req.Messages)) for _, message := range req.Messages { role := "user" if message.Role == "assistant" { role = "model" } contents = append(contents, map[string]any{ "role": role, "parts": []map[string]string{{"text": message.Text}}, }) } payloadBody := map[string]any{ "contents": contents, "generationConfig": map[string]any{ "temperature": aiRequestTemperature(req, p.model), "maxOutputTokens": aiRequestMaxTokens(req, p.model), }, } if strings.TrimSpace(req.System) != "" { payloadBody["systemInstruction"] = map[string]any{"parts": []map[string]string{{"text": req.System}}} } endpoint := fmt.Sprintf("%s/v1beta/models/%s:generateContent", strings.TrimRight(p.base.String(), "/"), url.PathEscape(p.model.ModelName)) // The key travels in a header rather than the query string so it stays out // of proxy logs; Gemini accepts both. raw, err := aiPostJSON(ctx, p.client, endpoint, map[string]string{"x-goog-api-key": p.model.APIKey}, payloadBody) if err != nil { return aiChatResult{}, err } var payload struct { Candidates []struct { Content struct { Parts []struct { Text string `json:"text"` } `json:"parts"` } `json:"content"` FinishReason string `json:"finishReason"` } `json:"candidates"` UsageMetadata struct { PromptTokenCount int `json:"promptTokenCount"` CandidatesTokenCount int `json:"candidatesTokenCount"` } `json:"usageMetadata"` Error struct { Message string `json:"message"` } `json:"error"` } if err = json.Unmarshal(raw, &payload); err != nil { return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON:%s", aiErrorSnippet(raw)) } if payload.Error.Message != "" { return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message) } if len(payload.Candidates) == 0 { return aiChatResult{}, fmt.Errorf("模型没有返回任何内容") } text := "" for _, part := range payload.Candidates[0].Content.Parts { text += part.Text } return aiChatResult{ Text: strings.TrimSpace(text), PromptTokens: payload.UsageMetadata.PromptTokenCount, CompletionTokens: payload.UsageMetadata.CandidatesTokenCount, FinishReason: payload.Candidates[0].FinishReason, }, nil } // Orchestration platforms (Coze, Dify, self-hosted flows) each have their own // shape, so they get one normalised contract instead of one adapter each. type webhookAIProvider struct { model aiModel base *url.URL client *http.Client } func (p webhookAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) { messages := make([]map[string]string, 0, len(req.Messages)) for _, message := range req.Messages { messages = append(messages, map[string]string{"role": message.Role, "content": message.Text}) } headers := map[string]string{} if strings.TrimSpace(p.model.APIKey) != "" { headers["Authorization"] = "Bearer " + p.model.APIKey } raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, ""), headers, map[string]any{ "model": p.model.ModelName, "system": req.System, "messages": messages, "maxTokens": aiRequestMaxTokens(req, p.model), "temperature": aiRequestTemperature(req, p.model), }) if err != nil { return aiChatResult{}, err } var payload struct { Text string `json:"text"` Reply string `json:"reply"` Content string `json:"content"` Usage struct { PromptTokens int `json:"promptTokens"` CompletionTokens int `json:"completionTokens"` } `json:"usage"` Error string `json:"error"` } if err = json.Unmarshal(raw, &payload); err != nil { return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON:%s", aiErrorSnippet(raw)) } if payload.Error != "" { return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error) } text := payload.Text if text == "" { text = payload.Reply } if text == "" { text = payload.Content } if strings.TrimSpace(text) == "" { return aiChatResult{}, fmt.Errorf("Webhook 必须返回 text 字段") } return aiChatResult{ Text: strings.TrimSpace(text), PromptTokens: payload.Usage.PromptTokens, CompletionTokens: payload.Usage.CompletionTokens, }, nil } // Deterministic and offline: local development and tests exercise the whole // pipeline without credentials or network access. type debugAIProvider struct{} func (debugAIProvider) Chat(_ context.Context, req aiChatRequest) (aiChatResult, error) { last := "" for index := len(req.Messages) - 1; index >= 0; index-- { if req.Messages[index].Role == "user" { last = strings.TrimSpace(req.Messages[index].Text) break } } reply := "在的,刚看到消息" if last != "" { runes := []rune(last) if len(runes) > 12 { runes = runes[:12] } reply = "收到:" + string(runes) } return aiChatResult{Text: reply, PromptTokens: len([]rune(last)), CompletionTokens: len([]rune(reply)), FinishReason: "stop"}, nil }