Documentation
¶
Index ¶
- Constants
- Variables
- func DecodeRefs(frame []byte) ([]uint32, error)
- func DecodeRefsFrame(f compress.Frame) ([]uint32, error)
- func DropChatLogsDeletedAtIndex(ctx context.Context) error
- func EncodeRefs(refs []uint32) []byte
- func EnsureChatIOLogIDIndex(ctx context.Context) error
- func EnsureConfigIfMissing(ctx context.Context, key string, value any) error
- func Init(ctx context.Context, path string)
- func IsEmptyDB(ctx context.Context) bool
- func MigratePeakTermsToAssociations(ctx context.Context) error
- func PackBodies(ctx context.Context, tx *gorm.DB, plains [][]byte) ([][]byte, error)
- func PackColumnValue(plain []byte) []byte
- func PrepareEmptyStorage(db *gorm.DB) (applied bool, err error)
- func ReadAutoVacuum(db *gorm.DB) (int64, error)
- func ReconcileInterruptedLogs(ctx context.Context) (int, error)
- func SaveConfigValue(ctx context.Context, key, value string) error
- func StorageRebuildMode() string
- func ToDateOverrides(days []HolidayDay) map[string]string
- func TrimLogCleanupRecords(ctx context.Context, limit int)
- func UnpackBody(ctx context.Context, db *gorm.DB, stored []byte) ([]byte, error)
- func UnpackColumnValue(stored []byte) ([]byte, error)
- type AnthropicConfig
- type AnthropicCountTokens
- type AuthKey
- type Block
- type BlockGroup
- type BlockStore
- func (s *BlockStore) Get(ctx context.Context, db *gorm.DB, refs []uint32) ([]byte, error)
- func (s *BlockStore) Put(ctx context.Context, tx *gorm.DB, plain []byte) ([]uint32, error)
- func (s *BlockStore) PutBatch(ctx context.Context, tx *gorm.DB, plains [][]byte) ([][]uint32, error)
- func (s *BlockStore) Reset()
- type BodyBytes
- type ChatIO
- type ChatLog
- type Config
- type HolidayDay
- type LogCleanupPolicy
- type LogCleanupRecord
- type LogCompressBackup
- type LogCompressPolicy
- type LogCompressState
- type LogReclaimPolicy
- type LogReclaimState
- type Model
- type ModelAutofillPolicy
- type ModelWithProvider
- type OutputUnion
- type PeakCalendar
- type PeakPeriod
- type PeakTerms
- type PromptTokensDetails
- type Provider
- type ReqMeta
- type SelfHealPolicy
- type Usage
Constants ¶
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)。
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" )
const ( // ReclaimKindReclaim 是增量回收:一个事务里连打若干条 `PRAGMA incremental_vacuum`, // 一条放一页,慢慢把洞收掉。不需要额外磁盘,也不必停服务。 ReclaimKindReclaim = "reclaim" // ReclaimKindVacuum 是重整:整库重写一遍,一次把 freelist 还干净。 // 快得多,但要 2× 库大小的空闲磁盘和一个全程排他的窗口。 ReclaimKindVacuum = "vacuum" )
LogReclaimState.Kind 的两个取值。只在 Go 侧用,前端拿到的仍是字符串—— 加一个没见过的取值不该让老前端崩掉。
const ( DateOverrideWork = "work" DateOverrideRest = "rest" )
日期覆盖的取值。
const ( // AutoVacuumNone 是 SQLite 的默认值:文件只会涨,freelist 里的页永不归还。 AutoVacuumNone = 0 // AutoVacuumFull 每次提交都整理并截断。**对 llmio 是错的**:每个请求都写一行 // 日志,等于每个请求都付一次整库整理的代价。 AutoVacuumFull = 1 // AutoVacuumIncremental 维护 ptrmap,只在被要求时截断。这是要的那个。 AutoVacuumIncremental = 2 )
auto_vacuum 的三个取值就是 SQLite PRAGMA 的定义,别改。
const DBVacuumEnv = "DB_VACUUM"
DBVacuumEnv 是既有的"启动时 VACUUM"开关,语义保持不变。
const DefaultBlockCacheBytes = 32 << 20
DefaultBlockCacheBytes 是组缓存的默认容量。
32 MiB 是 Phase 0 实测选的:顺序浏览(日志界面真正在做的事)命中率 98.7%、 P50 延迟归零。但要清楚它保的是顺序浏览——随机访问时组表本身就有 57–64 MiB, 32 MiB 只覆盖得了一半。
const KeyPeakCalendar = "peak_calendar"
KeyPeakCalendar 工作日日历在 configs 表中的键。
const KeyPeakPricingLegacy = "peak_pricing"
KeyPeakPricingLegacy 旧的全局分时段计费配置键。
峰谷条款现在挂在每条「模型 × 上游」关联上(ModelWithProvider.Peak), 全局只剩一份工作日日历。这个键只在升级时读一次,用来把旧配置拆成 日历(搬进 KeyPeakCalendar)与各条的条款(抄到关联行上)。
const StorageRebuildEnv = "DB_AUTO_VACUUM_REBUILD"
StorageRebuildEnv 控制"既有库要不要在启动时重建以启用增量回收"。
off —— 一概不碰 auto_vacuum auto —— (默认)只在**空库**上设。既有库一个字节都不动 on —— 既有库也在启动时重建转换(一次 VACUUM,分钟级、要 2× 库大小的空闲)
默认 auto 而不是 on,是被"最老的库"这件事逼出来的:一份 7 GiB 的老库启动时 突然做起 60 秒的 VACUUM,既占 2× 磁盘又让服务晚 60 秒可用,而它可能只是想 重启一下。**转换是一次运维动作,不是一次启动副作用。**
Variables ¶
var DB *gorm.DB
var DBPath string
DBPath 是当前库文件的位置。压缩状态页要报库文件大小与 freelist, 前者只能从文件系统量(SQLite 的 page_count*page_size 不含 WAL 与页头开销)。
Functions ¶
func DecodeRefs ¶ added in v0.9.1
DecodeRefs 解出引用序列。非帧返回 compress.ErrNotFrame,坏帧返回 compress.ErrCorrupt。
func DecodeRefsFrame ¶ added in v0.9.1
DecodeRefsFrame 是已经拆过帧头的版本。读路径先看帧头再决定怎么办, 这里省掉一次重复的拆头。
func DropChatLogsDeletedAtIndex ¶ added in v0.10.3
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
EncodeRefs 把引用序列编成可以直接落库的帧。
存的是块 id 而不是摘要:一行平均 92.7 条引用,id 是 4 字节、摘要 16 字节, 差了 4 倍。id 只在库内有意义,而引用本来就只在库内用。
自检不过返回 nil(而不是半个帧),调用方据此退回明文存储—— 与 compress 包"压不了就存明文"的规矩一致。
func EnsureChatIOLogIDIndex ¶ added in v0.10.2
EnsureChatIOLogIDIndex 给 chat_ios.log_id 建索引。可重复调用。
**为什么它不是一条 `gorm:"index"` 标签。** 先纠正一个容易想当然的点: AutoMigrate 对索引走的是 HasIndex → CreateIndex(gorm v1.30 migrator), 不会触发 SQLite 的"建新表-拷数据-删旧表-改名"——那只发生在列类型 / 可空性 / 主键变化时。所以标签本身并不危险。真正的差别有两条:
- 标签建索引失败会让 AutoMigrate 返回错误,而 Init 对迁移错误是 panic。 一条索引没建上只该让回填变慢,不该让服务起不来——这里失败只记日志。
- 标签只能表达"名字存在就跳过",管不了**同名但定义不同**的存量索引 (例如早先手工建的 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
EnsureConfigIfMissing 在 key 缺行时把 value 序列化后写进去,已存在就**什么都不做**。
它与 SaveConfigValue 的差别只在"已存在"那一支:那个是覆盖写,这个是补缺。 种子必须用这个——用户把某个开关打开之后重启服务,覆盖写会把它改回默认值。
为什么要落默认行而不是靠读侧回默认:读侧的回退是正确的**行为**,但调度器 会按周期重复读这些策略(压缩 30 秒、回收 1 小时),而 self_heal 的读在 **每个聊天请求**的转发循环里。落一行之后同一句话变成一次命中。
**副作用要记住:默认值从这一刻起被固化了。** 以后再改代码里的 DefaultLogCompressPolicy 等默认值,对已经有这一行的存量安装不再生效 (用户读的是库里的值)。这是"策略一旦落库就是用户的事实"的必然结果, 不是疏漏;真要调整存量安装,得走显式的迁移。
写入是一条 `INSERT ... WHERE NOT EXISTS`,不是 Count + Create:后者在 两个实例同时启动时都能看到 0 行,各插一行,而 Config.Key 上没有唯一约束 能拦。单条语句在 SQLite 的写锁内完成判定与插入。
失败即返回错误,由调用方决定是 panic 还是记日志——启动期的种子策略 与运行期的配置不该共用同一个错误口径。
func IsEmptyDB ¶ added in v0.9.4
IsEmptyDB 判断这个库是不是"全新、还没人用过"。
判据取三张核心表:只要其中任何一张有行,这个库就有主人的数据,不算空。 之所以不只看供应商表,是因为演示数据(见 main 包的 demoSeed)要能安全地 幂等:若上次灌到一半、或使用者自己建了模型又重启,都不该被当成空库重来一遍。
只读,不开事务:调用点在启动路径上,此刻还没有并发者。
func MigratePeakTermsToAssociations ¶ added in v0.9.1
MigratePeakTermsToAssociations 把旧的全局峰谷配置拆成「全局日历 + 每条关联的条款」。
迁移完成后旧键即被删除,因此:
- 可重复执行(第二次找不到键,直接返回);
- 新建的关联不会被一份陈年旧配置反复盖条款。
只有旧配置**启用过**才把条款抄给当时的每条关联:那是它此前实际生效的范围, 升级后成本数字不该因此变化。没启用的旧配置本来就没在计费,抄下去等于把 一份示例模板盖到所有关联上,反而制造出"配过"的假象。
func PackBodies ¶ added in v0.9.1
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
PackColumnValue 把一列的明文编成帧,压不动返回 nil。
它是"明文 → 帧"这件事的**唯一实现**,序列化器(写路径)与迁移(回填历史行) 共用同一份——两处各写一遍的话,"压不动就存明文"这类规则迟早会在其中一侧漂移, 而漂移的方向是数据损坏。
调用方看到 nil 时该做什么,两种场景不同,所以这里不替它们决定:
- 序列化器:退回**明文**(`string(plain)`),因为它的职责是把值写下去;
- 迁移:**这一列不动**,因为列里此刻的字节本来就是可读的。
空值与"已经是帧"都返回 nil。迁移的候选判据已经把这两种排除在外了, 但保留这两道自己判:`BeforeSave` 那条路曾经因为"假设调用方已经判过" 而把零值写回过(见 body.go 顶部),同一类假设不值得再赌一次。
`len(frame) >= len(plain)` 这条与 `PackBodies` 里那句**逐字同源**:压完更大 就不压。flate 对一小段随机字节会输出比原文还长的流(光头部就十来字节), 不挡的话库里会凭空多出一批"越压越大"的行。压缩是纯优化,不划算就不做。
func PrepareEmptyStorage ¶ added in v0.9.1
PrepareEmptyStorage 只对**空库**设 auto_vacuum=INCREMENTAL。
为什么它必须在 AutoMigrate **之前**被调用:pragma 要在第一张表建出来之前 生效,ptrmap 才会跟着表一起长出来;表建完再设就晚了。
而"晚了"这件事比想象的严重得多,实测过:**非空库上设这一句会被静默忽略** ——同一条连接上立刻读回来还是 0,换一条连接读也是 0,连文件头都没写。 也就是说,它没有任何"设错了"的风险,只是没生效而已;真正能转换的只有 VACUUM(见 service.PrepareStorage)。这条实测同时是**老库的安全保证**: 就算这段代码哪天被误放到既有库上,也只是白跑一句 SQL。
返回值只用于日志与测试,启动流程不因它失败——存储层的优化没有资格 让服务起不来。
func ReadAutoVacuum ¶ added in v0.9.1
ReadAutoVacuum 读当前库的 auto_vacuum。
func ReconcileInterruptedLogs ¶ added in v0.9.6
ReconcileInterruptedLogs 把上次进程留下的"在途"日志收尾。
status=running 意味着"请求开始了、结束时的 UPDATE 没落库"。进程重启之后,那些 请求的 goroutine 已经不存在,它们的日志不可能再被更新——不清理的话,界面上 它们会永远显示在途,用户看到的是"一直有请求在跑"的假象。
2026-10-17 线上就是这个形状:一次 COMMIT 撞锁失败之后连接带着未结束的事务回到 池子,整库的读写全部等满 busy_timeout 报 SQLITE_BUSY,结束时的 UPDATE 成片失败, 于是错的那批请求停在 running;直到进程重启(SQLite 用残留的 -journal 回滚) 才恢复。这里补的是"重启之后把上一轮的残局扫掉"——重启之后数据库事实是干净的, 这些行的事实是"被中断",把它写回库里比留在界面上更重要。
只在**启动路径**调用:此刻进程里没有任何在途请求,判"被中断"是确定的事实。
func SaveConfigValue ¶ added in v0.9.1
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
TrimLogCleanupRecords 保留最近 limit 条记录,删除多余的旧记录
func UnpackBody ¶ added in v0.9.1
UnpackBody 把库里存的字节还原成明文,是读路径的无模型版本。
明文原样返回(历史行、以及压不动而留了明文的行),空值返回 nil。 是帧但解不开则**明确报错**,绝不退回明文——把帧字节当明文交给调用方, 就是一次静默的数据损坏。
func UnpackColumnValue ¶ added in v0.9.1
UnpackColumnValue 把一列此刻的字节还原成明文,**不是帧就返回 nil**。
nil 的含义是"这一列不用动",与出错区分得很开:历史明文行是正常情况, 调用方只有拿到 error 才该停下来。这条区分是回滚路径的安全阀——把明文 当帧解、或者把帧当明文写回去,两种都是静默损坏,而它们在这里各有一个 明确的返回值形态挡着。
Types ¶
type AnthropicConfig ¶
type AnthropicCountTokens ¶ added in v0.5.32
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
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
Get 按引用序列还原原文。
只对"缓存里没有的组"发查询——这才是真实读路径的形状(见 Phase 0)。 取回后按组号排序一次,把 90 多次随机读压成近似顺序读。
func (*BlockStore) Put ¶ added in v0.9.1
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) Value ¶ added in v0.9.1
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
AfterFind 在查出后把引用还原成原始 body。
历史明文行不经任何处理直接放行,所以迁移可以随时中断、分批做,不需要 一次性切换。
func (*ChatIO) BeforeCreate ¶ added in v0.9.1
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:按模型下钻
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 LogCleanupRecord ¶ added in v0.8.4
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 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:*)与失败行, 可观测、可追溯,关掉即回到现状。