- model 层新增可选 UsageSink(nil ⇒ 逐字不变,既有用例零改动) - 组合根注入 UsageWriter:有界队列 1024 + 单写者 + 批量事务(128/批、2s 兜底), 队列满丢弃 + 限流 WARN,绝不阻塞模型调用;sink 挂 RuntimeLLM(热重载不断) - 迁移 0017:表 llm_usage(13 列,session_id 可空)+ idx_created_at + idx_session_id - 新端点 GET /api/v1/admin/llm/usage(owner|admin 读;缺省近 24h、上限 90 天、 超限 400 不静默裁剪;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0; 绝不含凭据与正文;未接线/查库失败 ⇒ 500 usage_unavailable) - LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理):启动清一次 + 每小时 - 面板 LLM 页「用量(近 24h)」小卡 + Docs 两份同步 v0.64 - 已知缺口(如实登记):session_id 恒 NULL,需 loop 侧在请求上打标,留列后接
256 lines
8.0 KiB
Go
256 lines
8.0 KiB
Go
package model
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// OpenAI 兼容 /chat/completions 协议的线格式约束。
|
||
const (
|
||
// sseDataPrefix 标记携带 JSON 负载的 SSE 事件行。
|
||
sseDataPrefix = "data: "
|
||
// sseDone 是终止用的 SSE 哨兵。
|
||
sseDone = "[DONE]"
|
||
// sseBufInit 是 bufio.Scanner 的初始读缓冲大小。
|
||
sseBufInit = 64 * 1024
|
||
// maxSSELineBytes 限制单条 SSE 事件行的长度。
|
||
maxSSELineBytes = 1024 * 1024
|
||
// errBodyCap 限制为取出消息而读取的错误响应体大小。
|
||
errBodyCap = 512
|
||
// defaultHTTPTimeout 在 Options.Timeout 为 0 时约束单次提供商调用。
|
||
defaultHTTPTimeout = 180 * time.Second
|
||
)
|
||
|
||
// OpenAIChatAdapter 调用 OpenAI 兼容的 /chat/completions 端点。它由
|
||
// config.LLMConfig 构建,同时实现非流式与 token 级 SSE 流式的
|
||
// Adapter。
|
||
type OpenAIChatAdapter struct {
|
||
name string
|
||
baseURL string
|
||
apiKey string
|
||
model string
|
||
client *http.Client
|
||
recorder Recorder
|
||
// usageSink 是可选的用量落库出口(切片 U1)。为 nil 时只记结构化日志,
|
||
// 行为逐字等于引入落库之前 —— 这也是所有既有测试与未接线调用方的形态。
|
||
usageSink UsageSink
|
||
// reasoningKind 是本端点已配置的推理开关形状
|
||
// (models.json 组的 reasoning.kind / LLM_REASONING_KIND,裁决 03.4.2/08.4.1)。
|
||
// 为空表示 "auto":按模型名/base URL 推断厂商,
|
||
// 形状含糊时什么都不发。
|
||
reasoningKind string
|
||
// log 上报 reasoning_effort 校验与被忽略的思考指令。它
|
||
// 永不为 nil:调用方未提供时,会装入一个带有本适配器来源
|
||
// 标记的丢弃式日志器,因此每个决定都可追溯。
|
||
log *slog.Logger
|
||
}
|
||
|
||
// OpenAIOption 用于配置 OpenAIChatAdapter。
|
||
type OpenAIOption func(*OpenAIChatAdapter)
|
||
|
||
// WithHTTPClient 覆盖默认 HTTP 客户端(用于在测试与生产中
|
||
// 注入 mock 或调优过的 transport)。
|
||
func WithHTTPClient(c *http.Client) OpenAIOption {
|
||
return func(a *OpenAIChatAdapter) { a.client = c }
|
||
}
|
||
|
||
// WithRecorder 装入按调用计账的 Recorder,用于 CallRecord 统计。
|
||
func WithRecorder(r Recorder) OpenAIOption {
|
||
return func(a *OpenAIChatAdapter) { a.recorder = r }
|
||
}
|
||
|
||
// WithUsageSink 装入用量落库出口(切片 U1)。nil 会被忽略,从而保留
|
||
// 「只记日志」的默认行为;实现方必须非阻塞(见 UsageSink)。
|
||
func WithUsageSink(s UsageSink) OpenAIOption {
|
||
return func(a *OpenAIChatAdapter) { a.usageSink = s }
|
||
}
|
||
|
||
// WithModel 覆盖适配器的默认模型名。
|
||
func WithModel(m string) OpenAIOption {
|
||
return func(a *OpenAIChatAdapter) { a.model = m }
|
||
}
|
||
|
||
// WithOpenAILogger 挂上用于上报 reasoning_effort 校验与
|
||
// 被忽略思考指令的日志器。nil 日志器会被忽略,
|
||
// 从而保留丢弃式的默认值。
|
||
func WithOpenAILogger(l *slog.Logger) OpenAIOption {
|
||
return func(a *OpenAIChatAdapter) {
|
||
if l != nil {
|
||
a.log = l
|
||
}
|
||
}
|
||
}
|
||
|
||
// NewOpenAIChat 由供应商配置构建适配器。baseURL 会先去掉
|
||
// 末尾斜杠,再拼接 /chat/completions 路径。
|
||
// 端点已配置的推理形状(cfg.ReasoningKind)随
|
||
// 适配器一起走,因此由同一份配置构建的每条链——组链、
|
||
// 它的按角色链以及本地级——都以
|
||
// 相同方式解出自己的推理开关。
|
||
func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapter {
|
||
a := &OpenAIChatAdapter{
|
||
name: "openai",
|
||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||
apiKey: cfg.APIKey,
|
||
model: cfg.Model,
|
||
reasoningKind: strings.TrimSpace(cfg.ReasoningKind),
|
||
client: &http.Client{
|
||
Transport: http.DefaultTransport,
|
||
Timeout: defaultHTTPTimeout,
|
||
},
|
||
log: discardLogger("runtime/model/openai"),
|
||
}
|
||
for _, o := range opts {
|
||
o(a)
|
||
}
|
||
return a
|
||
}
|
||
|
||
// Name 返回适配器标识。
|
||
func (a *OpenAIChatAdapter) Name() string { return a.name }
|
||
|
||
// Chat 执行一次非流式补全。
|
||
func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
|
||
start := time.Now()
|
||
if a.baseURL == "" {
|
||
return Response{}, ErrUpstream
|
||
}
|
||
ctx, cancel := withCallTimeout(ctx, opts)
|
||
defer cancel()
|
||
model, err := a.resolveModel(req)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
req.Model = model
|
||
body, err := a.buildRequest(req, false)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
|
||
if err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
|
||
}
|
||
resp, err := a.doRequest(httpReq, false)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return Response{}, a.upstreamError(resp)
|
||
}
|
||
var wire openAIChatResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: decode response: %v", ErrUpstream, err)
|
||
}
|
||
out := responseFromWire(&wire)
|
||
a.record(req, start, out)
|
||
return out, nil
|
||
}
|
||
|
||
// ChatStream 通过 onEvent 流出结构化事件,并在提供商于
|
||
// 末块上报用量时返回带用量的聚合响应。
|
||
func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
|
||
start := time.Now()
|
||
if a.baseURL == "" {
|
||
return Response{}, ErrUpstream
|
||
}
|
||
model, err := a.resolveModel(req)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
req.Model = model
|
||
body, err := a.buildRequest(req, true)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
|
||
if err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
|
||
}
|
||
resp, err := a.doRequest(httpReq, true)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return Response{}, a.upstreamError(resp)
|
||
}
|
||
out, err := scanSSE(resp.Body, onEvent)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
out.Model = model
|
||
a.record(req, start, out)
|
||
return out, nil
|
||
}
|
||
|
||
// withCallTimeout 在 opts.Timeout 为正时用该超时包裹 ctx。
|
||
func withCallTimeout(parent context.Context, opts Options) (context.Context, context.CancelFunc) {
|
||
if opts.Timeout > 0 {
|
||
return context.WithTimeout(parent, opts.Timeout)
|
||
}
|
||
return parent, func() {}
|
||
}
|
||
|
||
// resolveModel 返回实际生效的模型名。
|
||
func (a *OpenAIChatAdapter) resolveModel(req Request) (string, error) {
|
||
if req.Model != "" {
|
||
return req.Model, nil
|
||
}
|
||
if a.model != "" {
|
||
return a.model, nil
|
||
}
|
||
return "", ErrNoModel
|
||
}
|
||
|
||
// endpoint 返回完整的 /chat/completions URL。
|
||
func (a *OpenAIChatAdapter) endpoint() string {
|
||
return a.baseURL + "/chat/completions"
|
||
}
|
||
|
||
func (a *OpenAIChatAdapter) doRequest(httpReq *http.Request, stream bool) (*http.Response, error) {
|
||
if a.apiKey != "" {
|
||
httpReq.Header.Set("Authorization", "Bearer "+a.apiKey)
|
||
}
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
if stream {
|
||
httpReq.Header.Set("Accept", "text/event-stream")
|
||
} else {
|
||
httpReq.Header.Set("Accept", "application/json")
|
||
}
|
||
resp, err := a.client.Do(httpReq)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, err)
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
func (a *OpenAIChatAdapter) upstreamError(resp *http.Response) error {
|
||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyCap))
|
||
return fmt.Errorf("model: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||
}
|
||
|
||
// record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率,
|
||
// 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。
|
||
func (a *OpenAIChatAdapter) record(req Request, start time.Time, resp Response) {
|
||
logUsage(a.log, a.usageSink, a.name, req, resp, time.Since(start))
|
||
if a.recorder == nil {
|
||
return
|
||
}
|
||
a.recorder(CallRecord{
|
||
Model: req.Model,
|
||
Purpose: req.Purpose,
|
||
Tokens: resp.TotalTokens,
|
||
TookMs: time.Since(start).Milliseconds(),
|
||
})
|
||
}
|