Files
Cyrene_v2/internal/runtime/model/openai.go
T
AskaEth be38651ba4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
- 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 侧在请求上打标,留列后接
2026-10-04 22:16:26 +08:00

256 lines
8.0 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 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(),
})
}