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>
535 lines
17 KiB
Go
535 lines
17 KiB
Go
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
|
||
}
|