models

package
v0.10.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Index

Constants

View Source
const (
	// BlockGroupTarget 是一组攒到多少原始字节就封口。256 KiB 是实测选的:
	// 它比 64 KiB 省 11% 的体积,比 1 MiB 读起来快 73%(读 P95 22.6ms vs 39.2ms)。
	BlockGroupTarget = 256 << 10
	// BlockChunkAvg 是内容定义分块的平均块大小。
	// **这个值一旦有数据落库就不能改**:改了切点全错位,块表当场作废。
	BlockChunkAvg = compress.DefaultChunkAvg
)

块表:内容寻址的跨行去重。

为什么需要它:编码类 agent 每轮把整段对话历史原样重发,`input` 列里 97% 的 字节是同一批内容重复出现了几百遍(实测 5.64 GiB → 唯一内容 192 MiB)。 省字节的唯一办法不是"压得更狠",而是"同一段内容只存一份"。

与逐行压缩的本质区别:逐行压缩每行是一个独立的压缩窗口,看不见别的行, 重复内容一份都省不到。实测逐行最强也只到 3.83x,而这里是 94.9x(见 docs/db-compression-phase0.md)。

View Source
const (
	KeyAnthropicCountTokens = "anthropic_count_tokens"
	KeyLogCleanupPolicy     = "log_cleanup_policy"
	// KeyLogCompressPolicy / KeyLogCompressState 是数据库压缩的迁移策略与进度。
	// 进度落在 Config 里而不是新开一张表:迁移状态就是一行 JSON,而新表要走
	// AutoMigrate——在 7 GB 的库上,能不加表就不加表。
	KeyLogCompressPolicy  = "log_compress_policy"
	KeyLogCompressState   = "log_compress_state"
	KeyLogDecompressState = "log_decompress_state"
	KeyLogCompressBackup  = "log_compress_backup"
	// KeyLogReclaimState 记的是**空间回收**(增量回收 / 启动转换)上一次干了什么。
	// 与迁移分开:回收不动数据,只动文件,而且它每次都跑完(不像迁移有水位)。
	KeyLogReclaimState = "log_reclaim_state"
	// KeyLogReclaimPolicy 是空间回收的**自动推进**策略(要不要定时回收、多大才值得动)。
	// 它独立于迁移策略:迁移是"改写数据形态",回收是"把文件缩小",两者的
	// 触发时机完全不是一回事——迁移完一次就不再跑了,回收要跟着删除量一直跑。
	KeyLogReclaimPolicy = "log_reclaim_policy"
	// KeyModelAutofillPolicy 是「模型能力与价格自动填写」的策略。
	// 见 ModelAutofillPolicy。
	KeyModelAutofillPolicy = "model_autofill_policy"
	// KeySelfHealPolicy 是"上游拒收请求体参数时的同家删参重试"策略。
	// 见 SelfHealPolicy。
	KeySelfHealPolicy = "self_heal_policy"
)
View Source
const (
	// ReclaimKindReclaim 是增量回收:一个事务里连打若干条 `PRAGMA incremental_vacuum`,
	// 一条放一页,慢慢把洞收掉。不需要额外磁盘,也不必停服务。
	ReclaimKindReclaim = "reclaim"
	// ReclaimKindVacuum 是重整:整库重写一遍,一次把 freelist 还干净。
	// 快得多,但要 2× 库大小的空闲磁盘和一个全程排他的窗口。
	ReclaimKindVacuum = "vacuum"
)

LogReclaimState.Kind 的两个取值。只在 Go 侧用,前端拿到的仍是字符串—— 加一个没见过的取值不该让老前端崩掉。

View Source
const (
	DateOverrideWork = "work"
	DateOverrideRest = "rest"
)

日期覆盖的取值。

View Source
const (
	// AutoVacuumNone 是 SQLite 的默认值:文件只会涨,freelist 里的页永不归还。
	AutoVacuumNone = 0
	// AutoVacuumFull 每次提交都整理并截断。**对 llmio 是错的**:每个请求都写一行
	// 日志,等于每个请求都付一次整库整理的代价。
	AutoVacuumFull = 1
	// AutoVacuumIncremental 维护 ptrmap,只在被要求时截断。这是要的那个。
	AutoVacuumIncremental = 2
)

auto_vacuum 的三个取值就是 SQLite PRAGMA 的定义,别改。

View Source
const DBVacuumEnv = "DB_VACUUM"

DBVacuumEnv 是既有的"启动时 VACUUM"开关,语义保持不变。

View Source
const DefaultBlockCacheBytes = 32 << 20

DefaultBlockCacheBytes 是组缓存的默认容量。

32 MiB 是 Phase 0 实测选的:顺序浏览(日志界面真正在做的事)命中率 98.7%、 P50 延迟归零。但要清楚它保的是顺序浏览——随机访问时组表本身就有 57–64 MiB, 32 MiB 只覆盖得了一半。

View Source
const KeyPeakCalendar = "peak_calendar"

KeyPeakCalendar 工作日日历在 configs 表中的键。

View Source
const KeyPeakPricingLegacy = "peak_pricing"

KeyPeakPricingLegacy 旧的全局分时段计费配置键。

峰谷条款现在挂在每条「模型 × 上游」关联上(ModelWithProvider.Peak), 全局只剩一份工作日日历。这个键只在升级时读一次,用来把旧配置拆成 日历(搬进 KeyPeakCalendar)与各条的条款(抄到关联行上)。

View Source
const StorageRebuildEnv = "DB_AUTO_VACUUM_REBUILD"

StorageRebuildEnv 控制"既有库要不要在启动时重建以启用增量回收"。

off  —— 一概不碰 auto_vacuum
auto —— (默认)只在**空库**上设。既有库一个字节都不动
on   —— 既有库也在启动时重建转换(一次 VACUUM,分钟级、要 2× 库大小的空闲)

默认 auto 而不是 on,是被"最老的库"这件事逼出来的:一份 7 GiB 的老库启动时 突然做起 60 秒的 VACUUM,既占 2× 磁盘又让服务晚 60 秒可用,而它可能只是想 重启一下。**转换是一次运维动作,不是一次启动副作用。**

Variables

View Source
var DB *gorm.DB
View Source
var DBPath string

DBPath 是当前库文件的位置。压缩状态页要报库文件大小与 freelist, 前者只能从文件系统量(SQLite 的 page_count*page_size 不含 WAL 与页头开销)。

Functions

func DecodeRefs added in v0.9.1

func DecodeRefs(frame []byte) ([]uint32, error)

DecodeRefs 解出引用序列。非帧返回 compress.ErrNotFrame,坏帧返回 compress.ErrCorrupt。

func DecodeRefsFrame added in v0.9.1

func DecodeRefsFrame(f compress.Frame) ([]uint32, error)

DecodeRefsFrame 是已经拆过帧头的版本。读路径先看帧头再决定怎么办, 这里省掉一次重复的拆头。

func DropChatLogsDeletedAtIndex added in v0.10.3

func DropChatLogsDeletedAtIndex(ctx context.Context) error

DropChatLogsDeletedAtIndex 摘掉 chat_logs.deleted_at 上的索引。可重复调用。

这条索引不是"可有可无",是**有害**的。chat_logs 的每一条删除路径都是 Unscoped 硬删除(service/log_cleanup.go 的定时清理、handler/api.go 的按 id 清理),deleted_at 因此恒为 NULL。于是这条索引:

  • 对任何查询都命中整张表,窄化不了任何东西;
  • 却因为"看起来选择性很高"稳居查询计划器首选——GORM 会给每条 chat_logs 读语句自动补 `deleted_at IS NULL`,分析页那条按时间窗取数的查询因此放着 现成的 idx_chat_logs_created_at 不用,改成"扫完 deleted_at 索引 + 临时 B 树排序",两条筛选下拉的 DISTINCT 也一起退化成全表扫。

实测(20 万行、无 sqlite_stat1,即线上真实状态;临时库上跑 EXPLAIN QUERY PLAN):

按时间窗取数           114ms → 16ms(改走 idx_chat_logs_created_at,临时 B 树消失)
DISTINCT provider_model  100ms →  50ms(改走同名覆盖索引)
DISTINCT user_agent       58ms →  47ms(同上)

线上 1.34 GB 的库上第一条是 2.07 秒。回滚日志模式下读事务挡写,这 2 秒就是 写请求的排队时间:INSERT 等满 busy_timeout 报 SQLITE_BUSY、请求积压,最终把 128 MiB 的容器顶到 OOMKilled(2026-10-29 的事故:容器重启 2 次,cgroup 峰值 134,172,672 B,占上限 99.97%)。 少一条索引顺带省掉每一次 chat_logs 写入的索引维护成本——这一列既然恒为 NULL, 写进去的每一个索引项都是白写。

时序前提与 EnsureChatIOLogIDIndex 相同:调用点在 Init 里、监听端口之前, 此刻没有在途请求。DROP INDEX 只改 B 树指针,对大表同样是瞬时操作。

func EncodeRefs added in v0.9.1

func EncodeRefs(refs []uint32) []byte

EncodeRefs 把引用序列编成可以直接落库的帧。

存的是块 id 而不是摘要:一行平均 92.7 条引用,id 是 4 字节、摘要 16 字节, 差了 4 倍。id 只在库内有意义,而引用本来就只在库内用。

自检不过返回 nil(而不是半个帧),调用方据此退回明文存储—— 与 compress 包"压不了就存明文"的规矩一致。

func EnsureChatIOLogIDIndex added in v0.10.2

func EnsureChatIOLogIDIndex(ctx context.Context) error

EnsureChatIOLogIDIndex 给 chat_ios.log_id 建索引。可重复调用。

**为什么它不是一条 `gorm:"index"` 标签。** 先纠正一个容易想当然的点: AutoMigrate 对索引走的是 HasIndex → CreateIndex(gorm v1.30 migrator), 不会触发 SQLite 的"建新表-拷数据-删旧表-改名"——那只发生在列类型 / 可空性 / 主键变化时。所以标签本身并不危险。真正的差别有两条:

  1. 标签建索引失败会让 AutoMigrate 返回错误,而 Init 对迁移错误是 panic。 一条索引没建上只该让回填变慢,不该让服务起不来——这里失败只记日志。
  2. 标签只能表达"名字存在就跳过",管不了**同名但定义不同**的存量索引 (例如早先手工建的 partial 索引):那种索引会被当成"已存在"直接放行, 线上那条 UPDATE 继续全表扫,而且没有任何告警。这里会核对索引列与 partial 标记,不符就删掉重建。

为什么它仍然在启动路径上:这条 UPDATE 跑在**每个开了 io_log 的请求**的 响应回填里(service.chat.updateChatIOOutput)。没有索引时 SQLite 会挑 `idx_chat_ios_deleted_at`——而软删极少发生,那个索引命中整张表, 真机单次实测 **0.9 秒**(5895ms 那条 SLOW SQL 就来自这里), 有索引后是同一条语句 0.000 秒。把它留到"以后某次维护"就是让每个请求 继续等 0.9 秒换一次启动期 2.9 秒,这笔账不成立。

建索引期间持写锁。调用点在 Init 里(监听端口之前),此刻没有在途请求—— 与 service.PrepareStorage 的 VACUUM 是同一条时序前提。

func EnsureConfigIfMissing added in v0.10.2

func EnsureConfigIfMissing(ctx context.Context, key string, value any) error

EnsureConfigIfMissing 在 key 缺行时把 value 序列化后写进去,已存在就**什么都不做**。

它与 SaveConfigValue 的差别只在"已存在"那一支:那个是覆盖写,这个是补缺。 种子必须用这个——用户把某个开关打开之后重启服务,覆盖写会把它改回默认值。

为什么要落默认行而不是靠读侧回默认:读侧的回退是正确的**行为**,但调度器 会按周期重复读这些策略(压缩 30 秒、回收 1 小时),而 self_heal 的读在 **每个聊天请求**的转发循环里。落一行之后同一句话变成一次命中。

**副作用要记住:默认值从这一刻起被固化了。** 以后再改代码里的 DefaultLogCompressPolicy 等默认值,对已经有这一行的存量安装不再生效 (用户读的是库里的值)。这是"策略一旦落库就是用户的事实"的必然结果, 不是疏漏;真要调整存量安装,得走显式的迁移。

写入是一条 `INSERT ... WHERE NOT EXISTS`,不是 Count + Create:后者在 两个实例同时启动时都能看到 0 行,各插一行,而 Config.Key 上没有唯一约束 能拦。单条语句在 SQLite 的写锁内完成判定与插入。

失败即返回错误,由调用方决定是 panic 还是记日志——启动期的种子策略 与运行期的配置不该共用同一个错误口径。

func Init

func Init(ctx context.Context, path string)

func IsEmptyDB added in v0.9.4

func IsEmptyDB(ctx context.Context) bool

IsEmptyDB 判断这个库是不是"全新、还没人用过"。

判据取三张核心表:只要其中任何一张有行,这个库就有主人的数据,不算空。 之所以不只看供应商表,是因为演示数据(见 main 包的 demoSeed)要能安全地 幂等:若上次灌到一半、或使用者自己建了模型又重启,都不该被当成空库重来一遍。

只读,不开事务:调用点在启动路径上,此刻还没有并发者。

func MigratePeakTermsToAssociations added in v0.9.1

func MigratePeakTermsToAssociations(ctx context.Context) error

MigratePeakTermsToAssociations 把旧的全局峰谷配置拆成「全局日历 + 每条关联的条款」。

迁移完成后旧键即被删除,因此:

  • 可重复执行(第二次找不到键,直接返回);
  • 新建的关联不会被一份陈年旧配置反复盖条款。

只有旧配置**启用过**才把条款抄给当时的每条关联:那是它此前实际生效的范围, 升级后成本数字不该因此变化。没启用的旧配置本来就没在计费,抄下去等于把 一份示例模板盖到所有关联上,反而制造出"配过"的假象。

func PackBodies added in v0.9.1

func PackBodies(ctx context.Context, tx *gorm.DB, plains [][]byte) ([][]byte, error)

PackBodies 把一批 body 逐个变成"该落库的字节",与输入一一对应。

返回的每一项有三种可能:

  • nil 原样存明文(空、已是帧、压不动、压完不比原文小)
  • 引用帧 走了块表
  • 逐行帧 body 小于 inputBlockMin,或分不出块

判定与 packBody 完全一致,所以**迁移写出来的形态与线上写路径逐字节同形**: 迁完之后库里不该出现任何"线上永远写不出来"的形态。

与 packBody 唯一的区别是分组:这一批新产生的块攒在一起按 BlockGroupTarget 封口,而不是每行各封各的。这不是优化,是迁移能不能达到设计容量的前提—— 线上每行各封各,组平均只有 7.70 KiB(阶段 3 真机实测),远低于 flate 的 32 KiB 窗口,白省 35.9%。详见 BlockStore.PutBatch。

**不受 compressionEnabled() 影响**:那个开关管的是"新写入的行压不压", 而迁移是运维动作(L1 降级后仍然要能把历史行迁完、或者反过来还原), 拿写路径的开关去管它,会让"关掉压缩"和"无法收尾"捆在一起。

func PackColumnValue added in v0.9.1

func PackColumnValue(plain []byte) []byte

PackColumnValue 把一列的明文编成帧,压不动返回 nil。

它是"明文 → 帧"这件事的**唯一实现**,序列化器(写路径)与迁移(回填历史行) 共用同一份——两处各写一遍的话,"压不动就存明文"这类规则迟早会在其中一侧漂移, 而漂移的方向是数据损坏。

调用方看到 nil 时该做什么,两种场景不同,所以这里不替它们决定:

  • 序列化器:退回**明文**(`string(plain)`),因为它的职责是把值写下去;
  • 迁移:**这一列不动**,因为列里此刻的字节本来就是可读的。

空值与"已经是帧"都返回 nil。迁移的候选判据已经把这两种排除在外了, 但保留这两道自己判:`BeforeSave` 那条路曾经因为"假设调用方已经判过" 而把零值写回过(见 body.go 顶部),同一类假设不值得再赌一次。

`len(frame) >= len(plain)` 这条与 `PackBodies` 里那句**逐字同源**:压完更大 就不压。flate 对一小段随机字节会输出比原文还长的流(光头部就十来字节), 不挡的话库里会凭空多出一批"越压越大"的行。压缩是纯优化,不划算就不做。

func PrepareEmptyStorage added in v0.9.1

func PrepareEmptyStorage(db *gorm.DB) (applied bool, err error)

PrepareEmptyStorage 只对**空库**设 auto_vacuum=INCREMENTAL。

为什么它必须在 AutoMigrate **之前**被调用:pragma 要在第一张表建出来之前 生效,ptrmap 才会跟着表一起长出来;表建完再设就晚了。

而"晚了"这件事比想象的严重得多,实测过:**非空库上设这一句会被静默忽略** ——同一条连接上立刻读回来还是 0,换一条连接读也是 0,连文件头都没写。 也就是说,它没有任何"设错了"的风险,只是没生效而已;真正能转换的只有 VACUUM(见 service.PrepareStorage)。这条实测同时是**老库的安全保证**: 就算这段代码哪天被误放到既有库上,也只是白跑一句 SQL。

返回值只用于日志与测试,启动流程不因它失败——存储层的优化没有资格 让服务起不来。

func ReadAutoVacuum added in v0.9.1

func ReadAutoVacuum(db *gorm.DB) (int64, error)

ReadAutoVacuum 读当前库的 auto_vacuum。

func ReconcileInterruptedLogs added in v0.9.6

func ReconcileInterruptedLogs(ctx context.Context) (int, error)

ReconcileInterruptedLogs 把上次进程留下的"在途"日志收尾。

status=running 意味着"请求开始了、结束时的 UPDATE 没落库"。进程重启之后,那些 请求的 goroutine 已经不存在,它们的日志不可能再被更新——不清理的话,界面上 它们会永远显示在途,用户看到的是"一直有请求在跑"的假象。

2026-10-17 线上就是这个形状:一次 COMMIT 撞锁失败之后连接带着未结束的事务回到 池子,整库的读写全部等满 busy_timeout 报 SQLITE_BUSY,结束时的 UPDATE 成片失败, 于是错的那批请求停在 running;直到进程重启(SQLite 用残留的 -journal 回滚) 才恢复。这里补的是"重启之后把上一轮的残局扫掉"——重启之后数据库事实是干净的, 这些行的事实是"被中断",把它写回库里比留在界面上更重要。

只在**启动路径**调用:此刻进程里没有任何在途请求,判"被中断"是确定的事实。

func SaveConfigValue added in v0.9.1

func SaveConfigValue(ctx context.Context, key, value string) error

SaveConfigValue 按 key 写入配置值,不存在则新建。

func StorageRebuildMode added in v0.9.1

func StorageRebuildMode() string

StorageRebuildMode 读出重建模式,非法值一律当 auto(保守)。

func ToDateOverrides added in v0.9.1

func ToDateOverrides(days []HolidayDay) map[string]string

ToDateOverrides 把节假日数据转换为日期覆盖表。

func TrimLogCleanupRecords added in v0.8.4

func TrimLogCleanupRecords(ctx context.Context, limit int)

TrimLogCleanupRecords 保留最近 limit 条记录,删除多余的旧记录

func UnpackBody added in v0.9.1

func UnpackBody(ctx context.Context, db *gorm.DB, stored []byte) ([]byte, error)

UnpackBody 把库里存的字节还原成明文,是读路径的无模型版本。

明文原样返回(历史行、以及压不动而留了明文的行),空值返回 nil。 是帧但解不开则**明确报错**,绝不退回明文——把帧字节当明文交给调用方, 就是一次静默的数据损坏。

func UnpackColumnValue added in v0.9.1

func UnpackColumnValue(stored []byte) ([]byte, error)

UnpackColumnValue 把一列此刻的字节还原成明文,**不是帧就返回 nil**。

nil 的含义是"这一列不用动",与出错区分得很开:历史明文行是正常情况, 调用方只有拿到 error 才该停下来。这条区分是回滚路径的安全阀——把明文 当帧解、或者把帧当明文写回去,两种都是静默损坏,而它们在这里各有一个 明确的返回值形态挡着。

Types

type AnthropicConfig

type AnthropicConfig struct {
	BaseUrl string `json:"base_url"`
	ApiKey  string `json:"api_key"`
	Version string `json:"version"`
}

type AnthropicCountTokens added in v0.5.32

type AnthropicCountTokens struct {
	BaseURL string `json:"base_url"`
	APIKey  string `json:"api_key"`
	Version string `json:"version"`
}

type AuthKey added in v0.6.0

type AuthKey struct {
	gorm.Model
	Name       string // 项目名称
	Key        string
	Status     *bool      // 是否启用
	IOLog      *bool      // 是否记录IO
	AllowAll   *bool      // 是否允许所有模型
	Models     []string   `gorm:"serializer:json"` // 允许的模型列表
	ExpiresAt  *time.Time // nil=永不过期,有值=具体过期时间
	UsageCount int64      // 使用次数统计
	LastUsedAt *time.Time // 最后使用时间
}

type Block added in v0.9.1

type Block struct {
	ID uint `gorm:"primarykey"`
	// Digest 是 SHA-256 截断到 128 位的内容标识。唯一索引既是去重的依据,
	// 也是并发写入的安全网:两个事务同时插入同一个块时,后来的那个撞索引,
	// 回查即可,不会写出两份。
	Digest  []byte `gorm:"uniqueIndex;size:16"`
	Size    int
	GroupID uint `gorm:"index"` // 所在的组
	Off     int  // 在组内的偏移
}

Block 是一个唯一块。同一份内容全局只有一行。

type BlockGroup added in v0.9.1

type BlockGroup struct {
	ID     uint `gorm:"primarykey"`
	Data   []byte
	RawLen int
}

BlockGroup 是一批块拼起来压成的一个帧。

为什么块要攒成组再压,而不是每块各压各的:块与块之间还有短程重复 (同一段 JSON 骨架的变体),逐块各压就吃不到;而攒成一组一个帧之后, 随机读也只需要解一组,不必解全部。

组是**不可变**的:写下去就不再改。压实(CompactGroups)会另建新组, 旧组留到 GC 确认没人引用为止。这样并发读要么看到旧组、要么看到新组, 不会读到改到一半的东西。

func (BlockGroup) TableName added in v0.9.1

func (BlockGroup) TableName() string

type BlockStore added in v0.9.1

type BlockStore struct {
	// contains filtered or unexported fields
}

BlockStore 把 body 存成"块表 + 引用序列",并负责还原。

它必须拿得到 **gorm 的事务句柄**:写块与写行要在同一个事务里, 否则行插入失败时块会变成孤儿。这正是本方案不能用 GORM serializer 的原因 (serializer 的接口里根本没有 DB 句柄)。

func NewBlockStore added in v0.9.1

func NewBlockStore(cacheBytes int) *BlockStore

NewBlockStore 造一个带组缓存的存储层。cacheBytes<=0 表示不要缓存。

func (*BlockStore) Get added in v0.9.1

func (s *BlockStore) Get(ctx context.Context, db *gorm.DB, refs []uint32) ([]byte, error)

Get 按引用序列还原原文。

只对"缓存里没有的组"发查询——这才是真实读路径的形状(见 Phase 0)。 取回后按组号排序一次,把 90 多次随机读压成近似顺序读。

func (*BlockStore) Put added in v0.9.1

func (s *BlockStore) Put(ctx context.Context, tx *gorm.DB, plain []byte) ([]uint32, error)

Put 把 plain 分块、查表、把新块写进新组,返回这一行的引用序列。

tx 必须是调用方正在用的那个事务。全程不另开连接——那会撞事务锁, 卡满 busy_timeout 之后报 database is locked。

单行 Put 就是"只有一批、这一批只有一行"的 PutBatch,分组行为一致: 每次调用各封各的组(真机实测这样打出来的组平均 7.70 KiB)。

func (*BlockStore) PutBatch added in v0.9.1

func (s *BlockStore) PutBatch(ctx context.Context, tx *gorm.DB, plains [][]byte) ([][]uint32, error)

PutBatch 把一批 body 一起写进块表,返回与输入一一对应的引用序列 (空 body 与分不出块的 body 得到 nil)。

与逐个 Put 的**唯一**区别是分组:这一批新产生的块攒在一起按 BlockGroupTarget 封口,而不是每行各封各的。这不是优化,是迁移能不能达到设计容量的前提—— 真机实测(阶段 3,生产库副本 12,483 行):线上逐行写入的组平均只有 7.70 KiB, 同一批内容按 256 KiB 重打是 88.21 MiB → 56.53 MiB,**差 35.9%**。 历史行迁移必须走这一条,否则迁完仍是那个 7.70 KiB 的形态,白迁一遍。

那是不是越大越好:不是。一批越大,里面"库里还没有"的块越多,一次插进去的组 才越接近 BlockGroupTarget;但整批原文要同时在内存里,事务越大回滚也越贵。 调用方按"行数 + 原文字节"双重封顶。

顺序即组归属:块按**首次出现顺序**攒进组,与 insertBlockGroups、压实、 阶段 0 基准车完全同规则。

func (*BlockStore) Reset added in v0.9.1

func (s *BlockStore) Reset()

Reset 丢掉整个组缓存。

**换库必须调用它。** 缓存只按组 id 索引,而组 id 只在单个库文件内有意义: 另一个库的第 1 组是完全不同的内容。库一换,缓存里的每一条都可能对错门。

生产上库不会中途换,所以这条主要服务于两件事:进程启动(models.Init, 可能是从一个备份恢复的库,rowid 回退了),以及将来重建存储层的那一步 (组会被重新编号)。

type BodyBytes added in v0.9.1

type BodyBytes []byte

BodyBytes 是 body 列的存储形态。

它存在的唯一理由是**让"库里是 TEXT 还是 BLOB"由内容自己决定**:明文写成 TEXT、帧写成 BLOB。这条不变量很值钱——迁移时一句 `WHERE typeof(input)='text'` 就能挑出还没迁的行,而 typeof 只读记录的类型头、不读载荷,全表扫也很便宜。 丢掉它的话,就只能把每一行的载荷前几个字节都读出来比对。

func (*BodyBytes) Scan added in v0.9.1

func (b *BodyBytes) Scan(src any) error

Scan 收驱动给的字节。三种形态都要认:BLOB(帧)、TEXT(历史明文)、NULL。

func (BodyBytes) String added in v0.9.1

func (b BodyBytes) String() string

String 只为日志与调试。它不参与任何存储决策。

func (BodyBytes) Value added in v0.9.1

func (b BodyBytes) Value() (driver.Value, error)

Value 把内存里的字节按"是帧还是明文"分别落成 BLOB / TEXT,空值落 NULL。

注意它**不做压缩**:压不压、怎么压是钩子的决定,这里只管按已经定好的形态 落库。两件事混在一起的话,"值可能被取两次"就不再是幂等的了。

空值写 NULL(而不是零长度的字符串)与 `serializer.go` 是同一条不变量:**这三列里 TEXT 只表示"非空明文"**,`typeof` 因此成为形态的完整判据。 顺带一个用处:迁移的 patch 用 `nil` 表示"这一列不动",于是"写成 NULL" 正好落在空切片上(见 service/log_compress.go 的 columnValue)。

type ChatIO added in v0.5.0

type ChatIO struct {
	gorm.Model
	LogId uint
	// Input 是请求体。它是三列里唯一走**块表**的(见 docs/db-compression-phase0.md
	// 与 models/body.go),因为只有它带着 97% 的跨行重复。
	//
	// `type:text` 与 OutputUnion 同理:钉死列类型,AutoMigrate 才不会重建 7 GB 的表。
	// 钉的是 TEXT,但帧按 BLOB 落进去——SQLite 的列亲和性只转换 TEXT↔数字,
	// 不碰 BLOB,所以 `typeof(input)` 仍然分得清明文与帧。
	Input BodyBytes `gorm:"type:text"`
	// SkipCompress 只对**这一次写入**生效:压不动或撞锁时退回明文存储。
	//
	// 它不是数据库列(gorm:"-"),也不参与读路径——读只认帧头。存在的理由是
	// "压缩是优化,IO 记录是观测":线上并发写把库锁住时,入块表失败被当成整条
	// 记录失败,结果成功的聊天请求被标成 error;撞锁时退回明文重写一次,
	// 代价是多占几百 KB,换的是这条 IO 记录还在、请求状态不撒谎。
	SkipCompress bool `gorm:"-" json:"-"`
	OutputUnion
}

func (*ChatIO) AfterFind added in v0.9.1

func (c *ChatIO) AfterFind(tx *gorm.DB) error

AfterFind 在查出后把引用还原成原始 body。

历史明文行不经任何处理直接放行,所以迁移可以随时中断、分批做,不需要 一次性切换。

func (*ChatIO) BeforeCreate added in v0.9.1

func (c *ChatIO) BeforeCreate(tx *gorm.DB) error

BeforeCreate 在插入前把 body 换成"引用序列"或"逐行帧"。

**必须是 BeforeCreate,不能是 BeforeSave。** 写路径的第二半是 `service/chat.go` 里的

Updates(ctx, models.ChatIO{OutputUnion: *output})

它只为补上响应体。BeforeSave 在这里也会触发,而钩子拿到的 Input 是零值—— 若把"零值"当成"这份 body 没有可去重的内容"写回去,就会**把已经存好的请求体 覆盖掉**。GORM 的 Updates(struct) 靠零值跳过恰好挡住了这一下,但那是 GORM 的实现细节,不该拿来做数据安全的前提。BeforeCreate 只在插入时触发, 这一类问题从定义上就不存在。

type ChatLog

type ChatLog struct {
	ID        uint      `gorm:"primarykey"`
	CreatedAt time.Time `` /* 145-byte string literal not displayed */
	UpdatedAt time.Time
	// DeletedAt 保留这一列以兼容历史库,但**故意不建索引**:chat_logs 的每一条
	// 删除路径都是 Unscoped 硬删除(定时清理、按 id 清理),这一列恒为 NULL。
	// 那条索引命中整张表,却因为"看起来选择性很高"永远排在查询计划器首选,
	// 把按时间窗取数的那类查询从索引范围扫描逼成全表扫 + 临时 B 树排序。
	// 存量库上的旧索引由 DropChatLogsDeletedAtIndex 在启动时摘掉。
	DeletedAt gorm.DeletedAt

	Name          string `gorm:"index;index:idx_chat_logs_name_created,priority:1"`
	TraceID       string `gorm:"index"`
	ProviderModel string `gorm:"index"`
	ProviderName  string `gorm:"index"`
	Status        string `gorm:"index;index:idx_chat_logs_status_created,priority:1"` // error or success
	Style         string // 类型(客户端协议)
	// UpstreamStyle 实际使用的上游协议。直连时与 Style 相同;不同即说明这次转发经过了
	// 一层协议翻译(见 service/bridge.go)。
	UpstreamStyle string `gorm:"index" json:"upstream_style,omitempty"`
	// BridgeNotes 这次协议翻译"改了什么",逗号分隔的短码(见 bridge.Note)。
	// 只作排查用:丢了哪个参数、补了什么默认值,客户端从响应里看不出来,这里留个线索。
	BridgeNotes string `json:"bridge_notes,omitempty"`
	UserAgent   string `gorm:"index"` // 用户代理
	RemoteIP    string // 访问ip
	AuthKeyID   uint   `gorm:"index;index:idx_chat_logs_key_created,priority:1"` // 使用的AuthKey ID
	SessionID   string `gorm:"index"`                                            // 请求体中的session_id
	ChatIO      bool   // 是否开启IO记录
	// Backup 本次响应由备用关联提供:主关联在本请求内全部失败出局后降级接管。
	// 非指针 bool 是后加列,AutoMigrate 不给存量行填默认值,NULL 由 init.go 回填 false。
	Backup bool

	Error          string        // if status is error, this field will be set
	Retry          int           // 重试次数
	ProxyTime      time.Duration // 代理耗时
	FirstChunkTime time.Duration // 首个chunk耗时
	ChunkTime      time.Duration // chunk耗时
	Tps            float64
	Size           int // 响应大小 字节
	Usage
	InputPrice     float64 `json:"input_price"`
	CacheReadPrice float64 `json:"cache_read_price"`
	OutputPrice    float64 `json:"output_price"`
	Currency       string  `json:"currency"`
	// PeakPeriod 记录本条请求命中的计费时段名,空表示按基础价计费。
	// 只作展示与排查用——价格本身已按实际生效值快照到上面三个字段,
	// 因此成本计算不依赖本字段,改了时段配置也不会影响历史成本。
	PeakPeriod string `json:"peak_period,omitempty"`
}

ChatLog 请求日志,也是分析聚合的事实表。

此处刻意不内嵌 gorm.Model:分析聚合全部按 created_at 过滤与分桶, 需要 created_at 参与复合索引,而内嵌结构的字段无法单独打标签。 字段集合与 gorm.Model 等价(ID / CreatedAt / UpdatedAt / DeletedAt), 因此对外仍以 ID、CreatedAt、DeletedAt 访问,JSON 形态不变。

索引按聚合的实际查询形态设计:

  • created_at 单列:范围过滤 + ORDER BY
  • status, created_at:趋势与成功率
  • auth_key_id, created_at:按 Key 下钻
  • name, created_at:按模型下钻

func (ChatLog) WithError

func (l ChatLog) WithError(err error) ChatLog

type Config added in v0.5.32

type Config struct {
	gorm.Model
	Key   string // 配置类型
	Value string // 配置内容
}

type HolidayDay added in v0.9.1

type HolidayDay struct {
	Date string `json:"date"`
	Name string `json:"name"`
	// IsOffDay 为 true 表示放假(休息日),false 表示调休上班(工作日)。
	IsOffDay bool `json:"isOffDay"`
}

HolidayDay 是节假日数据源的一条记录。

type LogCleanupPolicy added in v0.7.11

type LogCleanupPolicy struct {
	Enabled       bool `json:"enabled"`
	RetentionDays int  `json:"retention_days"`
}

type LogCleanupRecord added in v0.8.4

type LogCleanupRecord struct {
	gorm.Model
	RetentionDays int    `json:"RetentionDays"`
	DeletedCount  int64  `json:"DeletedCount"`
	DurationMs    int64  `json:"DurationMs"`
	Source        string `json:"Source"`
	Type          string `json:"Type"`
}

type LogCompressBackup added in v0.9.1

type LogCompressBackup struct {
	Path  string `json:"path"`
	Size  int64  `json:"size"`
	MTime string `json:"mtime"`
	At    string `json:"at"`
	// Source 是**探出来的**结论,不是操作员说的:
	//   manual  —— 探到备份,且不比库小
	//   stale   —— 探到备份,但比库小(多半是迁移前的旧备份,盖回去会丢数据)
	//   missing —— 路径上什么都没有
	//   forced  —— 没探到,但操作员明确确认了要继续
	Source string `json:"source"`
}

LogCompressBackup 记下迁移开始前的备份存证。

为什么要有这一条:迁移是不可逆的形态改写(要还原得走 decompress)。 出问题时最省事的路是"把备份盖回去",而那要求**备份确实存在、且确实是迁移前 那一刻的库**。所以进状态页的东西必须是自己量出来的(大小 + mtime), 不是操作员口头保证的。

type LogCompressPolicy added in v0.9.1

type LogCompressPolicy struct {
	// Enabled 管的是**后台自动推进**。手动触发(POST .../run)不看它——
	// 与日志清理的语义保持一致。
	Enabled bool `json:"enabled"`
	// BatchRows / BatchBytes 双重封顶。只按行数封顶会让"一批"的字节数随
	// body 大小失控(这份语料平均 470 KiB/行,64 行就是 30 MiB),
	// 而 PutBatch 要求整批原文同时在内存里。
	BatchRows  int   `json:"batch_rows"`
	BatchBytes int64 `json:"batch_bytes"`
	// QuiesceSec 是冷静期:updated_at 比它新的行不碰。它挡的是"正在被写的那一行"
	// ——Create 之后、补响应体的 Updates 之前,那一行是半成品。
	QuiesceSec int `json:"quiesce_sec"`
	// BatchIntervalMs 是批与批之间的**主动停顿**,0 表示不停。
	//
	// 它不改变任何一批的行为,改的是**迁移占用写锁的时间比例**。一批持锁约
	// 185 ms(真机 12,483 行 / 36s / 批 64 行折算),默认 0 时这批接着那批,
	// 迁移期间库有八成时间在写事务里——请求不会报错(busy_timeout 罩得住),
	// 但每次日志 INSERT 都要排队。
	//
	// 所以这是个**占用率旋钮**,不是性能旋钮:调大它迁移更慢、但库更闲。
	// "迁移该跑多凶"取决于这台机器同时在干什么,是运维判断而不是技术判断,
	// 所以它可配。
	BatchIntervalMs int `json:"batch_interval_ms"`
}

LogCompressPolicy 是历史行迁移的策略。前三个参数是"每批多大"、第四个是 "批与批之间歇多久",都不是"压多狠"——压多狠(分块大小、组大小)是 **写死在代码里的**,见 BlockChunkAvg 旁边的注释:那三个数一旦有数据落库就 不能改,改成可配置只会给人一个"改一下试试"的机会。

type LogCompressState added in v0.9.1

type LogCompressState struct {
	// Status: idle / running / paused / done / failed。
	// 启动时读到 running 就是"上次被中断了",直接续跑(水位在库里)。
	Status string `json:"status"`
	// LastID 是水位:id <= LastID 的行已经扫过。
	LastID uint `json:"last_id"`
	// MaxID 是本轮起始时的 id 快照。迁移期间新产生的行由写路径直接以新形态
	// 落库,不归这一轮管(否则水位会被不断推着往前跑,永远追不上)。
	MaxID     uint  `json:"max_id"`
	TotalRows int64 `json:"total_rows"` // 快照时的候选行数,进度条分母
	Scanned   int64 `json:"scanned"`
	Packed    int64 `json:"packed"`  // 真正改写了形态的行数
	Skipped   int64 `json:"skipped"` // 扫到但无需改动的行数
	// BytesBefore / BytesAfter 只统计**扫过的行**在这一列上的字节数,
	// 所以两者之差恰好是 input 列被省掉的字节——不含组表,组表要另算。
	BytesBefore int64 `json:"bytes_before"`
	BytesAfter  int64 `json:"bytes_after"`
	// BytesTotal 是**全表**原文在这一列上的合计,只在"从水位 0 起跑"那一次量。
	//
	// 它存在的唯一理由是当压缩比的分子。用 BytesBefore 当分子是错的:
	// 那个数只覆盖**扫过的行**,而分母(真正落库)是全表——半程时分子是全表
	// 的一半、分母是全表,算出来的比值恰好是真值的一半,一个看起来很专业的
	// 错误数字。两个数必须盖住同一批行。
	//
	// 它同时是"实时"的来源:从起跑那一刻起分子就固定了,分母随着行改形态
	// 一路缩,比值肉眼可见地往上爬;而 BytesBefore 要等跑完才等于它。
	//
	// 续跑时**保留**不重算:水位之后还有明文行不代表全表都还是明文,
	// 已经压过的行的原文已经不在了,量不回来。所以它的定义是"起跑时那一量"。
	BytesTotal int64  `json:"bytes_total"`
	Attempts   int    `json:"attempts"` // 连续失败次数,成功一批就清零
	LastError  string `json:"last_error"`
	StartedAt  string `json:"started_at"`
	FinishedAt string `json:"finished_at"`
}

LogCompressState 是迁移进度。水位与它写在**同一个事务**里(INV-1 的推广): 中断点只有两个结果——整批连水位一起回滚,或者整批连水位一起生效。

type LogReclaimPolicy added in v0.9.1

type LogReclaimPolicy struct {
	// Enabled 打开后,服务会按 CheckIntervalSec 定期看一眼,够条件就自动跑一趟。
	// 手动点「回收」不看它(与迁移、日志清理的语义一致)。
	Enabled bool `json:"enabled"`
	// MinBytes 是"值得跑一趟"的门槛。没有它,一个刚回收过、只删了几行的库
	// 会在每个周期被拉起来占一次写锁,换回来几 MiB——收益与打扰完全不成比例。
	MinBytes int64 `json:"min_bytes"`
	// CheckIntervalSec 是多久看一次。看一次很便宜(一次 freelist 计数),
	// 所以它管的是"发现有空闲页之后多久动手",不是性能旋钮。
	CheckIntervalSec int `json:"check_interval_sec"`
}

LogReclaimPolicy 是**空间回收**的自动推进策略。

默认全关(Enabled=false),这不是保守,是这条动作的性质决定的:回收**全程持 写锁**,那是一段写请求会被排队的真实时间。"这台机器什么时候可以占用库"是运维 判断而不是技术判断,默认替用户做主,就等于把一个会被感知到的动作变成默认行为。

与迁移策略不同,这里没有"每批多大"这一档:回收的批次大小是**被 busy_timeout 推出来的**,而且现在由 service.nextBatchSize 按每批的实测耗时自己走 (16–512 条语句/事务)。真机上就栽在"把 512 定死"上:换一份形态更散的库, 同样 512 条要 8.9 秒,越过 5 秒的写请求耐心,成片 500。它不是能按偏好调的旋钮。

type LogReclaimState added in v0.9.1

type LogReclaimState struct {
	// Status: idle / running / done / failed。`done` 里再分跑完没跑完,见 StopReason。
	Status string `json:"status"`
	// Kind 是这一次**做的是什么**:
	//   reclaim —— 增量回收:一条语句放一页,慢慢把洞收掉
	//   vacuum  —— 重整:整库重写一遍,把 freelist 一次还干净
	//   ""      —— 加这个字段之前写下的记录,一律当 reclaim(那时只有那一条路)
	//
	// 与 Source 正交,两者都需要:Source 说"谁发起",Kind 说"做了什么"。
	// 同样是 startup 发起的,一次转换(VACUUM)和它之后可能接着跑的增量回收
	// 是两件事,界面上要给两套说法;手动那条路上更明显——「立即重整」和
	// 「开始回收」在记录里必须分得开。
	Kind string `json:"kind"`
	// Source 是这一次是谁发起的:
	//   startup   —— 启动期转换/VACUUM(在监听端口之前跑)
	//   manual    —— 用户在控制台点的
	//   scheduled —— 定时回收(见 LogReclaimPolicy)自己起来的
	Source string `json:"source"`
	// Continuous 记这一次是不是"持续到放完"。
	//
	// 它与 Rounds 一起构成回执:`Rounds=1` 的 done/budget 是"点了一下、跑满一段",
	// 而 `Rounds=37` 才是"一路放到底"。不记这两项,两种完全不同的运维动作
	// 在记录里长得一模一样。
	Continuous bool `json:"continuous"`
	// Rounds 是跑了几轮。单轮模式恒为 1;持续模式下一轮一批(见
	// service.reclaimContinuousPause 的"一批一松手"),所以它同时是
	// "打了几批"的另一种写法。
	Rounds int `json:"rounds"`
	// PageSize 是页大小,用来把页数折成字节(界面上要说"放掉了 5.4 GiB")。
	PageSize int64 `json:"page_size"`
	// FreedPages / FreedBytes 是这一次放掉的页数与字节数。
	FreedPages int64 `json:"freed_pages"`
	FreedBytes int64 `json:"freed_bytes"`
	// FileSizeBefore / FileSizeAfter 是文件大小的收尾。**回收的意义全在这两个数上**
	// ——freelist 少了不等于文件小了(auto_vacuum=0 时文件一字节都不会缩)。
	FileSizeBefore int64 `json:"file_size_before"`
	FileSizeAfter  int64 `json:"file_size_after"`
	FreelistBefore int64 `json:"freelist_before"`
	FreelistAfter  int64 `json:"freelist_after"`
	// Calls 是这一次打了几条 `PRAGMA incremental_vacuum`。它是**页数的同义词**:
	// 生产驱动忽略参数、每条恰好放一页(见 service.reclaimBatchMax)。
	Calls int `json:"calls"`
	// BatchSize 是这一趟**最后收敛到的**批量(一个事务里连打几条语句)。
	//
	// 它是诊断读数而不是配置:批量由 service.nextBatchSize 按每批实测耗时自己
	// 走,起点与边界见那边的 reclaimBatch*。记下来是因为"这台机器上一批能收到
	// 多少"直接决定"收完这个洞要几轮",而它随库的形态走,事先算不出来。
	BatchSize  int    `json:"batch_size"`
	DurationMs int64  `json:"duration_ms"`
	StartedAt  string `json:"started_at"`
	FinishedAt string `json:"finished_at"`
	// StopReason 是收尾时的判定。
	// 增量回收(manual / scheduled):
	//   empty          —— freelist 空了,这一趟把能放的全放了
	//   budget         —— 到点了(本轮时间预算用完),还剩着没放,可以再点一次
	//   stopped        —— 用户按了「停止」(只在持续模式里可能出现)
	//   busy           —— 持续模式的轮间松手之后,维护锁被别的任务抢走了,让给它
	//   no_auto_vacuum —— 库的 auto_vacuum 不是 INCREMENTAL,这句 PRAGMA 是空操作
	//   stalled        —— 放了一批 freelist 却没少(引擎行为反常),主动收工并记 LastError
	// 重整(vacuum,见 Kind):
	//   vacuumed           —— 整库重写了一遍
	//   converted          —— 顺带把 auto_vacuum 转成了 INCREMENTAL(转换本身就是一次 VACUUM)
	//   insufficient_space —— 磁盘不够,**跳过**(不是失败:启动期服务照常起)
	//
	// 三条对 manual 与 startup 都成立,只有 `insufficient_space` 例外:手动那次
	// 走的是 StartVacuum 的**预检**,磁盘不够时压根不会开始(见 ErrVacuumNoSpace),
	// 不会留下一条"跑了但跳过"的记录。
	//
	// 两者共有:failed —— 出错。
	StopReason string `json:"stop_reason"`
	LastError  string `json:"last_error"`
}

LogReclaimState 是**空间回收**的运行记录。

与迁移的状态机不同,它没有水位:回收是一次一次独立的动作,跑到哪算哪, 中断了也没有"一半的形态"可言——文件该多大还是多大,只是没缩到位。 所以这里记的是**结果**,不是进度。

type Model

type Model struct {
	gorm.Model
	Name         string
	Remark       string
	MaxRetry     int    // 重试次数限制
	TimeOut      int    // 超时时间 单位秒
	Strategy     string // 负载均衡策略 默认 lottery
	Breaker      *bool  // 是否开启熔断
	DisplayOrder int    // 模型展示顺序,值越大越靠前
	// PreferDirect 优先匹配相同协议:候选池里先挑说客户端协议的提供商,一个都没有
	// 才退回可转换的那些。关掉则整池按权重摇(见 service.poolFor)。
	// nil 表示没配过(迁移上来的老行),按**开**处理——转换功能上线前候选池只有
	// 本协议的,这样老模型的行为不会因为多出一列而改变。
	PreferDirect *bool
}

type ModelAutofillPolicy added in v0.9.4

type ModelAutofillPolicy struct {
	// Enabled 是总开关:关闭后关联弹窗不再自动预填。
	Enabled bool `json:"enabled"`
	// Overwrite 决定预填是否改写已有值。默认 false:只补空。
	//
	// 默认 false 是调研的直接结论:聚合源存在可测缺口(models.dev 的
	// structured_output 只有 55.8%,LiteLLM 的 supports_vision 只覆盖 42%
	// 的对话条目),而覆盖用户**已经确认过**的值风险高于收益——把手工设成
	// true 的 ToolCall 改写成 false,会让这个上游从工具调用请求的候选池里
	// 直接消失(§3.5.2)。
	Overwrite bool `json:"overwrite"`
	// AllowDeprecated 是否给已废弃的模型建议。默认 false。
	// models.dev 的 8,389 个模型里有 261 个 deprecated、71 个 beta。
	AllowDeprecated bool `json:"allow_deprecated"`
	// Sources 是按序尝试的来源,留空用默认 ["models.dev", "litellm"]。
	Sources []string `json:"sources"`
}

ModelAutofillPolicy 是「模型 × 上游」关联弹窗里自动填写模型能力与价格的策略。

**Enabled 默认 true**(默认启用)。这不是随手定的:与 LogReclaimPolicy 那类 「默认关闭」不同,自动填写是**纯前端的、幂等的、可在表单里逐项复核的**动作 ——它只往用户正看着的表单里预填值,不落库、不占锁、不改变运行中的服务, 用户点保存前始终有机会改。回收/VACUUM 那种「一开就占写锁」的动作才需要 默认关。理由与配套的三条约束见 docs/model-capability-autofill-plan.md §4.3。

**保存路径完全不读这个策略**:写入永远沿用既有的 Create/UpdateModelProvider, 服务端不做任何补值(§4.2)。所以这里的 Enabled 只管"弹窗要不要预填"。

type ModelWithProvider

type ModelWithProvider struct {
	gorm.Model
	ModelID          uint
	ProviderModel    string
	ProviderID       uint
	ToolCall         *bool // 能否接受带有工具调用的请求
	StructuredOutput *bool // 能否接受带有结构化输出的请求
	Image            *bool // 能否接受带有图片的请求(视觉)
	WithHeader       *bool // 是否透传header
	Status           *bool // 是否启用
	// Backup 是否为备用关联:不参与日常轮询,仅当主关联在本请求内全部不可用后接管。
	// nil(迁移上来的老行)按 false 处理,见 models/init.go 的回填。
	Backup          *bool
	CustomerHeaders map[string]string `gorm:"serializer:json"` // 自定义headers
	ExtraBody       map[string]any    `gorm:"serializer:json"` // 额外请求体参数
	Weight          int
	InputPrice      *float64
	CacheReadPrice  *float64
	OutputPrice     *float64
	Currency        string
	// Peak 这一条关联的峰谷计费条款,nil 表示没配(按基础价计费)。
	//
	// 与三档单价同处一行是刻意的:乘数就是乘在它们上面的,
	// 拆到别处就会出现"价格改了、条款还指着旧的那套"的可能。
	Peak *PeakTerms `gorm:"serializer:json"`
}

type OutputUnion added in v0.5.0

type OutputUnion struct {
	OfString      string   `gorm:"type:text;serializer:compress"`
	OfStringArray []string `gorm:"type:text;serializer:compress"`
}

OutputUnion 是响应体的两种形态,互斥(见 service/process.go)。

两列都走 compress 序列化器:库里存压缩帧,Go 侧仍是 string / []string, 前端契约(OfStringArray 必须是真数组)完全不受影响。

`type:text` 是**必须的**,不是顺手写的:它把列类型钉死在 TEXT, AutoMigrate 在 7 GB 表上才不会去做"建新表-拷数据-删旧表"那套重建。 详见 docs/db-compression-safety.md §1.1 C1。

type PeakCalendar added in v0.9.1

type PeakCalendar struct {
	// Timezone 用于判定"此刻是本地的几点/星期几"。为空时按 server 本地时区。
	// 必须显式配置的原因:上游的峰谷窗口按供应商所在时区定义,
	// 而部署机可能是任意时区,二者不一致会让时段整体错位。
	Timezone string `json:"timezone"`
	// Weekdays 工作日定义(0=周日 … 6=周六)。为空时默认周一至周五。
	Weekdays []int `json:"weekdays,omitempty"`
	// DateOverrides 按日期覆盖工作日判定,键为 "2006-01-02",值为 "work" 或 "rest"。
	//
	// 这一项是法定节假日与调休的落点:中国的调休制度会让某个周六成为工作日,
	// 单靠星期几无法表达,必须能按具体日期覆盖。
	//
	// 刻意不加 omitempty:空 map 也要落库为 {},否则配置文件里看不到这个字段,
	// 手工编辑时不知道有它可填。
	DateOverrides map[string]string `json:"dateOverrides"`
	// HolidaySyncedAt 最近一次同步节假日数据的 unix 秒,0 表示从未同步。
	HolidaySyncedAt int64 `json:"holidaySyncedAt,omitempty"`
	// HolidaySource 最近一次同步的数据来源,供界面展示来源与可信度。
	HolidaySource string `json:"holidaySource,omitempty"`
}

PeakCalendar 全局工作日日历。

留在全局而不是跟着条款走:这是一份**日历事实**(哪天放假、哪天调休、 按哪个时区算"今天"),对所有上游是同一个答案。每个关联各存一份的话, 同步一次节假日要写 N 遍,N 份之间还会不一致。

func DefaultPeakCalendar added in v0.9.1

func DefaultPeakCalendar() PeakCalendar

DefaultPeakCalendar 返回默认工作日日历:东八区、周一至周五、无节假日数据。

func (PeakCalendar) EffectiveTime added in v0.9.1

func (c PeakCalendar) EffectiveTime(t time.Time) time.Time

EffectiveTime 返回用于时段判定的时间,已转换到配置的时区。 时区无法解析时退回原时间,调用方不应因此失败。

type PeakPeriod added in v0.9.1

type PeakPeriod struct {
	Name string `json:"name"`
	// Start/End 为一天中的 "HH:MM",允许 "24:00"。Start > End 表示跨零点。
	Start string `json:"start"`
	End   string `json:"end"`
	// Multiplier 为相对基础价的乘数,1 表示不加价不减价。
	Multiplier float64 `json:"multiplier"`
	// Days 限定星期几(0=周日 … 6=周六)。为空表示不限制。
	Days []int `json:"days,omitempty"`
	// Workday 限定是否只在工作日/休息日生效。nil 表示不限制。
	Workday *bool `json:"workday,omitempty"`
}

PeakPeriod 一个计费时段。

定价方式是**相对基础价的乘数**,而非另存一套绝对价: 上游的峰谷策略基本都是"标准价的某个比例"(如夜间优惠 ×0.25), 用乘数可以同时表达涨价与降价,且不会让 ModelWithProvider 的价格字段翻三倍。

type PeakTerms added in v0.9.1

type PeakTerms struct {
	Enabled bool `json:"enabled"`
	// Periods 时段列表,顺序即优先级。
	Periods []PeakPeriod `json:"periods"`
}

PeakTerms 某个上游的峰谷计费条款。

时段的匹配顺序即优先级,**首个命中者胜出**:这样"先特例后一般"可以 直接靠数组顺序表达,不需要额外的优先级字段。 全部未命中时回落到乘数 1(基础价)。

条款而不是全局配置:峰谷窗口是**上游的商务条款**,同一时间点上 A 家中转在打折、B 家已进入峰时是完全正常的,一份全局配置必然有一半是错的。 落点是 ModelWithProvider——价格(InputPrice 那三档)本来就挂在那里, 乘数作用于哪套基础价,就该和那套基础价待在同一行。

func DefaultPeakTerms added in v0.9.1

func DefaultPeakTerms() PeakTerms

DefaultPeakTerms 返回一份新的峰谷条款模板:**关闭**状态,三段示例时段。

默认关闭是刻意的——分时段计费会改变成本数字的语义, 在用户明确配置前不应静默启用。

时段照 **DeepSeek 现行峰谷方案** 出的模板(2026-08-16 起生效;官方文档 api-docs.deepseek.com/quick_start/pricing,2026-10-24 核对):峰时为工作日 (法定节假日除外)UTC 01:00–04:00 与 06:00–10:00,即东八区 09:00–12:00 与 14:00–18:00,其余全部为谷时。乘数的方向以**基础价的来源**为准:关联行上的 三档价由自动填充(models.dev 一类聚合源)写入,那是官方标价 = **谷价**, 因此峰时 ×2、谷时 ×1——不是谷价 ×0.5。

三段而不是只给两个峰时窗的理由:谷时段虽然乘数与"未命中回落 ×1"相同, 但它让 peak_period 记得出「谷时」这个名字,且用户改乘数时 scaffold 就位; 段放最后,峰时未命中的时刻(无论哪天)都落到它头上,无需跨零点。

type PromptTokensDetails added in v0.5.18

type PromptTokensDetails struct {
	CachedTokens int64 `json:"cached_tokens"`
	AudioTokens  int64 `json:"audio_tokens"`
}

type Provider

type Provider struct {
	gorm.Model
	Name         string
	Type         string
	Config       string
	Console      string // 控制台地址
	Proxy        string // HTTP 代理地址
	ErrorMatcher string // 响应体错误识别规则,多行或分号分隔 sample
}

type ReqMeta added in v0.5.23

type ReqMeta struct {
	UserAgent string // 用户代理
	RemoteIP  string // 访问ip
	Header    http.Header
}

type SelfHealPolicy added in v0.10.0

type SelfHealPolicy struct {
	Enabled bool `json:"enabled"`
	// MaxDrops 是一家上游最多删几个参数(多发自几次请求)。<=0 视为未配置,
	// 消费侧归一到 service.SelfHealMaxDrops。
	MaxDrops int `json:"max_drops"`
}

SelfHealPolicy 是"上游拒收请求体参数时的同家删参重试"策略。

Enabled 默认 true:自愈只发生在"上游明确拒收"之后,替代方案是整单失败 (全换家后 500);每次删参都落记账短码(self_heal_dropped:*)与失败行, 可观测、可追溯,关掉即回到现状。

type Usage

type Usage struct {
	PromptTokens        int64               `json:"prompt_tokens"`
	CompletionTokens    int64               `json:"completion_tokens"`
	TotalTokens         int64               `json:"total_tokens"`
	PromptTokensDetails PromptTokensDetails `json:"prompt_tokens_details" gorm:"serializer:json"`
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL