Files
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

167 lines
6.6 KiB
Go
Raw Permalink 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 (
"errors"
"fmt"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 提供商感知工厂接受的提供商名。这是工厂目前理解的仅有的
// 两种协议;新增厂商意味着在这里加一个 case
// 再加一个配套适配器,而不必改动包内其它部分。
const (
// ProviderOpenAI 选择 OpenAI 兼容的 /chat/completions 协议。
ProviderOpenAI = "openai"
// ProviderAnthropic 选择 Anthropic Messages 的 /v1/messages 协议。
ProviderAnthropic = "anthropic"
)
// 配置的提供商名无法识别时,由提供商感知工厂返回
// ErrUnknownProvider。
var ErrUnknownProvider = errors.New("model: unknown provider")
// NewChatAdapter 按 cfg.Provider 构建单个提供商适配器。
// 提供商为空时为向后兼容默认用 openai。无法识别的
// 提供商返回 ErrUnknownProvider。
func NewChatAdapter(cfg config.LLMConfig) (Adapter, error) {
switch cfg.Provider {
case ProviderOpenAI, "":
return NewOpenAIChat(cfg), nil
case ProviderAnthropic:
return NewAnthropicChat(cfg), nil
default:
return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider)
}
}
// NewChatChain 为配置的提供商构建降级 Chain:先是主
// 模型,再是提供商已设置且不同的降级模型。每一步
// 都由所选协议的适配器服务。提供商为空
// 时默认用 openai。无法识别的提供商返回 ErrUnknownProvider。
func NewChatChain(cfg config.LLMConfig) (*Chain, error) {
return NewChatChainWithLog(cfg, nil)
}
// NewChatChainWithLog 是 NewChatChain,并为它构建的每个适配器
// 挂上日志器,好让提供商层面的推理决策(为容纳
// max_tokens 而抬高的思考预算、被降级或忽略的 reasoning_effort 值)
// 带着模块来源上报。nil 日志器则保留适配器的丢弃式默认值。
//
// 它也是唯一会追加可选本地第三级(local rung)的构建器
// (裁决 03.4.3,primary → fallback → local),因为那一级可能说
// 与生效组不同的协议,因此需要原始日志器来
// 构建自己的适配器。只有当本地级已启用且完全
// 配置好时才会追加;否则链保持两级形状,并
// 上报原因。
func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) {
return newChainWith(cfg, log, nil)
}
// newChainWith 是 NewChatChainWithLog 加上用量 sink(切片 U1)。sink 为 nil 时
// 逐字等于后者:适配器只记日志。组合根经 RuntimeLLM 注入真实写入器。
func newChainWith(cfg config.LLMConfig, log *slog.Logger, sink UsageSink) (*Chain, error) {
var chain *Chain
var err error
switch cfg.Provider {
case ProviderOpenAI, "":
chain, err = newOpenAIChain(cfg, WithOpenAILogger(log), WithUsageSink(sink))
case ProviderAnthropic:
chain, err = newAnthropicChain(cfg, WithAnthropicLogger(log), WithAnthropicUsageSink(sink))
default:
return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider)
}
if err != nil {
return nil, err
}
if step, ok := localChainStep(cfg, log, sink); ok {
chain.steps = append(chain.steps, step)
}
return chain, nil
}
// localChainStep 把本地第三级降级构造成一个链步骤。
// 当该级关闭、未配置或其提供商未知时,返回 ok=false
// (若该级被要求但配置不全,则附一条点出缺失字段的 WARN)
// ——一个半配置的步骤只会失败,那还不如没有
// 它。用量 sink 照旧透传:本地级也是模型调用,同样要计成本。
func localChainStep(cfg config.LLMConfig, log *slog.Logger, sink UsageSink) (Step, bool) {
local := cfg.Local
// Configured() 本身已要求 Enabled,所以该级默认关闭,
// 被禁用的级永远不会成为链步骤。只有当该级确实被
// 要求、但配置不全时才发出该警告。
if !local.Configured() {
if local.Enabled {
warn(log, "local model rung enabled but not configured; skipping it",
"base_url_set", strings.TrimSpace(local.BaseURL) != "",
"model_set", strings.TrimSpace(local.Model) != "")
}
return Step{}, false
}
localCfg := config.LLMConfig{
Provider: local.Provider,
BaseURL: local.BaseURL,
APIKey: local.APIKey,
Model: local.Model,
}
switch local.Provider {
case ProviderAnthropic:
return Step{Model: local.Model, Adapter: NewAnthropicChat(localCfg, WithAnthropicLogger(log), WithAnthropicUsageSink(sink))}, true
case ProviderOpenAI, "":
return Step{Model: local.Model, Adapter: NewOpenAIChat(localCfg, WithOpenAILogger(log), WithUsageSink(sink))}, true
default:
warn(log, "local model rung skipped: unknown provider",
"provider", local.Provider, "field", "LLM_LOCAL_PROVIDER")
return Step{}, false
}
}
// newOpenAIChain 由 OpenAI 兼容配置块构建降级链。
// 每一步都是针对该端点的 OpenAIChatAdapter。
func newOpenAIChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) {
if cfg.BaseURL == "" || cfg.Model == "" {
return nil, ErrNoModel
}
steps := []Step{{Model: cfg.Model, Adapter: NewOpenAIChat(cfg, opts...)}}
if cfg.Fallback != "" && cfg.Fallback != cfg.Model {
steps = append(steps, Step{Model: cfg.Fallback, Adapter: NewOpenAIChat(cfg, opts...)})
}
return NewChain(steps...), nil
}
// newAnthropicChain 由 Anthropic 配置块构建降级链。每一步
// 都是针对该 /v1/messages 端点的 AnthropicChatAdapter。
func newAnthropicChain(cfg config.LLMConfig, opts ...AnthropicOption) (*Chain, error) {
if cfg.AnthropicBaseURL == "" || cfg.AnthropicModel == "" {
return nil, ErrNoModel
}
steps := []Step{{Model: cfg.AnthropicModel, Adapter: NewAnthropicChat(cfg, opts...)}}
if cfg.AnthropicFallback != "" && cfg.AnthropicFallback != cfg.AnthropicModel {
steps = append(steps, Step{Model: cfg.AnthropicFallback, Adapter: NewAnthropicChat(cfg, opts...)})
}
return NewChain(steps...), nil
}
// DefaultModelForProvider 返回配置提供商的主模型名:
// "openai" 取扁平配置里的 OpenAI Model,"anthropic" 取 Anthropic 模型。
// 调用方(如 cmd/cyrene)用它播种 Router 的默认值。
func DefaultModelForProvider(cfg config.LLMConfig) string {
if cfg.Provider == ProviderAnthropic {
return cfg.AnthropicModel
}
return cfg.Model
}
// warn 通过注入的日志器上报,降级到一个仍带有模块来源的丢弃式
// 日志器,因此绝不会产生无来源日志
// (项目铁律 §10)。它之所以存在,是因为链可能在没有日志器
// 的情况下构建(NewChatChain),而被跳过的级仍须上报。
func warn(log *slog.Logger, msg string, args ...any) {
if log == nil {
log = discardLogger("model")
}
log.Warn(msg, args...)
}