ituoke/internal/agent/model.go

206 lines
5.9 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 agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)
type modelMessage struct {
ReasoningContent string `json:"reasoning_content,omitempty"`
Role string `json:"role"`
Content any `json:"content"`
ToolCalls []modelToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type modelToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type ModelTestResult struct {
OK bool `json:"ok"`
Message string `json:"message"`
ElapsedMs int64 `json:"elapsedMs"`
}
func openModelRequest(ctx context.Context, c ModelConfig, key, method, path string, body any) (*http.Response, error) {
var raw []byte
var err error
if body != nil {
raw, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("请求参数无效")
}
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("模型请求地址无效")
}
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
client := &http.Client{Timeout: time.Duration(c.TimeoutSeconds) * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
res, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("无法连接模型服务,请检查地址、网络或超时设置")
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
defer res.Body.Close()
data, _ := io.ReadAll(io.LimitReader(res.Body, 64*1024))
return nil, modelHTTPError(res.StatusCode, data, key)
}
return res, nil
}
func modelRequest(c ModelConfig, key, method, path string, body any, result any) error {
res, err := openModelRequest(context.Background(), c, key, method, path, body)
if err != nil {
return err
}
defer res.Body.Close()
data, err := io.ReadAll(io.LimitReader(res.Body, 4*1024*1024+1))
if err != nil || len(data) > 4*1024*1024 {
return fmt.Errorf("模型响应读取失败或内容过大")
}
if err := json.Unmarshal(data, result); err != nil {
return fmt.Errorf("服务返回了不兼容的响应,请确认使用 Chat Completions 兼容接口")
}
return nil
}
func modelHTTPError(status int, data []byte, key string) error {
var detail struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
_ = json.Unmarshal(data, &detail)
msg := detail.Error.Message
if key != "" {
msg = strings.ReplaceAll(msg, key, "[已隐藏密钥]")
}
if len(msg) > 500 {
msg = msg[:500]
}
switch status {
case 401, 403:
return fmt.Errorf("模型鉴权失败(HTTP %d),请检查 API Key 和模型权限", status)
case 404:
return fmt.Errorf("模型或接口不存在(HTTP 404),请检查基础地址及模型名称")
case 429:
return fmt.Errorf("模型服务限流或额度不足(HTTP 429),请检查服务商账户")
}
if msg == "" {
msg = "请检查服务地址和模型参数"
}
return fmt.Errorf("模型请求失败(HTTP %d):%s", status, msg)
}
func completionBody(c ModelConfig, messages []modelMessage, stream bool) map[string]any {
body := map[string]any{"model": c.Model, "messages": messages, "stream": stream}
if c.UseTemperature {
body["temperature"] = c.Temperature
}
if c.MaxTokens > 0 {
field := "max_tokens"
if c.Provider == "openai" {
field = "max_completion_tokens"
}
body[field] = c.MaxTokens
}
return body
}
func complete(c ModelConfig, key string, messages []modelMessage, onReasoning ...func(string)) (string, error) {
body := completionBody(c, messages, false)
var result struct {
Choices []struct {
Message struct {
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
Content string `json:"content"`
Refusal string `json:"refusal"`
} `json:"message"`
} `json:"choices"`
}
if err := modelRequest(c, key, http.MethodPost, "/chat/completions", body, &result); err != nil {
return "", err
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("模型没有返回回答,请检查模型是否支持对话")
}
thought := result.Choices[0].Message.ReasoningContent
if thought == "" {
thought = result.Choices[0].Message.Reasoning
}
if thought != "" {
for _, notify := range onReasoning {
if notify != nil {
notify(thought)
}
}
}
text := strings.TrimSpace(result.Choices[0].Message.Content)
if text == "" {
text = strings.TrimSpace(result.Choices[0].Message.Refusal)
}
if text == "" {
return "", fmt.Errorf("模型回答为空,请检查输出上限或模型能力")
}
return text, nil
}
// TestModel uses the draft without persisting credentials or chat history.
func (s *Service) TestModel(input ModelConfigInput) ModelTestResult {
start := time.Now()
c, key, err := s.requestConfig(input, true)
if err == nil {
_, err = complete(c, key, []modelMessage{{Role: "user", Content: "请只回复 OK。"}})
}
result := ModelTestResult{OK: err == nil, ElapsedMs: time.Since(start).Milliseconds(), Message: "连接成功,所选模型已实际返回回答"}
if err != nil {
result.Message = err.Error()
}
return result
}
func (s *Service) ListModels(input ModelConfigInput) ([]string, error) {
c, key, err := s.requestConfig(input, false)
if err != nil {
return nil, err
}
var result struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err = modelRequest(c, key, http.MethodGet, "/models", nil, &result); err != nil {
return nil, err
}
models := make([]string, 0, len(result.Data))
seen := make(map[string]bool)
for _, m := range result.Data {
if m.ID != "" && !seen[m.ID] {
models = append(models, m.ID)
seen[m.ID] = true
}
}
sort.Strings(models)
return models, nil
}