Files
kefu/im/backend/internal/app/ai_providers.go
T
Your NameandClaude Opus 5 151834caac 模型支持协议扩展参数,用于关闭思考模式
DeepSeek 现役的 v4 系列全部默认开启思考模式,没有单独的非思考型模型
可换;官方给的关闭方式是在请求里带 thinking={"type":"disabled"}。

ai_models 表早有 extra_json 字段但从未接线,这里接通:模型配置里的
JSON 对象会合并进每次调用的请求体,因此任何协议级参数都能在管理端
配置,不必改代码。合并不会覆盖 model/messages/max_tokens/temperature/
stream —— 预算和结构由代码掌握,重试逻辑才不会被配置绕过。
写入前校验 JSON,避免非法值存进 JSON 列后每次调用才报错。

线上模型已配置为关闭思考:同一会话下由 10133 ms / 1024 输出 tokens、
回复被截断,变为 669 ms / 10 tokens、回复完整。max_tokens 相应回调至 512。

管理端模型表单新增「协议参数」输入框(该目录尚未纳入版本库,仅已发布)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:19:17 +08:00

535 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}