mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b628b4d6f | |||
| 2e3d5b79ad | |||
| 2ebc01e03b | |||
| 11776024e9 | |||
| bf1932c6c5 | |||
| 22ddf5a411 | |||
| 3698546d14 | |||
| ed05cccb72 | |||
| c309448414 | |||
| 5b6d6fc90d | |||
| 3ddae41f0c | |||
| 2378bc00ba | |||
| 882288f67f | |||
| 813de19bd0 | |||
| 25d8bd8632 | |||
| 3e27f4db24 | |||
| 23705042ee | |||
| 02cb8c0e33 | |||
| 6575058556 | |||
| d42c02b15d | |||
| df9aeed5fb | |||
| 21cd0429a2 | |||
| 2ed1f0ecde | |||
| 624fd74b45 | |||
| 94cbcb6771 | |||
| a5384d9fb6 | |||
| ab20f1be48 | |||
| 8334cffbc9 | |||
| a42ff62d06 | |||
| 66585549f1 | |||
| 747dff5b76 | |||
| 5d045f97a8 | |||
| e48baa1bb3 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -12,11 +12,17 @@ cmd/gui/bin/
|
||||
cmd/gui/build/gui-dist/
|
||||
cmd/build/
|
||||
cmd/gui/*.log
|
||||
cmd/gui/.cache/
|
||||
# workspace (AI-assisted coding local state)
|
||||
.pi/
|
||||
.pi-glla/
|
||||
.codegraph/
|
||||
.omo/
|
||||
/build/
|
||||
|
||||
# local ops scripts (machine-specific, not for sharing)
|
||||
scripts/
|
||||
cmd/gui/dist/
|
||||
|
||||
# local design/working notes (not part of the shipped repo)
|
||||
/plan.md
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"sessionID": "ses_fcda7f5e1ffe9g78A7lVoLMxn7",
|
||||
"updatedAt": "2026-08-24T11:19:43.949Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-08-24T11:19:43.949Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Makefile
18
Makefile
@ -23,6 +23,14 @@ build:
|
||||
CGO_ENABLED=1 go build -tags luajit -trimpath -o $(BUILD_DIR)/$(BINARY) ./cmd/llmsproxy
|
||||
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
|
||||
|
||||
# ---- core distribution packages (deb + rpm + tar.gz) -----
|
||||
# Server admins install these with dpkg/rpm; unlike the GUI they are the bare
|
||||
# gateway binary + systemd unit (with memory tuning) + Lua adapters.
|
||||
core-dist:
|
||||
./packaging/core-dist.sh $(VERSION)
|
||||
|
||||
.PHONY: core-dist
|
||||
|
||||
# ---- GUI (Electron) ----
|
||||
gui:
|
||||
@cd $(GUI_DIR) && npm start
|
||||
@ -31,11 +39,12 @@ gui-dev:
|
||||
@cd $(GUI_DIR) && npm run dev
|
||||
|
||||
# build the embedded core into the GUI, then run electron-builder
|
||||
# targets: deb + AppImage (linux), rpm needs system rpmbuild
|
||||
# targets: deb + rpm + AppImage (linux); rpm needs system rpmbuild
|
||||
# NOTE: run in a clean env (no LD_LIBRARY_PATH pollution) so the packaged
|
||||
# binary does not link against host-only libs and crash on user machines.
|
||||
gui-dist:
|
||||
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) npm run dist:linux
|
||||
./packaging/verify-dist.sh
|
||||
|
||||
# deb only (for local sharing), rpm needs rpmbuild
|
||||
gui-deb:
|
||||
@ -48,6 +57,7 @@ gui-win:
|
||||
# windows nsis installer: full cross-build in docker (self-sufficient, no host mingw)
|
||||
gui-win-docker:
|
||||
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) ./scripts/dist-win-docker.sh
|
||||
./packaging/verify-dist.sh
|
||||
|
||||
gui-dist-dir:
|
||||
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) npm run dist:dir
|
||||
@ -56,15 +66,17 @@ test:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) cmd/gui/build/gui-dist cmd/gui/bin
|
||||
rm -rf $(BUILD_DIR) cmd/build cmd/gui/dist cmd/gui/build/gui-dist cmd/gui/bin
|
||||
|
||||
help:
|
||||
@echo "Targets:"
|
||||
@echo " make build build core binary (luajit)"
|
||||
@echo " make core-dist build core packages: deb + rpm + tar.gz (needs nfpm)"
|
||||
@echo " make gui run desktop GUI (dev)"
|
||||
@echo " make gui-dev run GUI with devtools"
|
||||
@echo " make gui-dist build deb + AppImage (linux)"
|
||||
@echo " make gui-dist build deb + rpm + AppImage (linux)"
|
||||
@echo " make gui-deb build deb only"
|
||||
@echo " make gui-win build Windows nsis (needs host mingw + wine)"
|
||||
@echo " make gui-win-docker build Windows nsis all-in-docker (no host mingw needed)"
|
||||
@echo " make test run go tests"
|
||||
@echo " make clean remove build dirs and packaged installers (build/, cmd/build/, cmd/gui/dist/)"
|
||||
|
||||
176
README.md
176
README.md
@ -15,8 +15,9 @@
|
||||
|
||||
### 极致轻量
|
||||
|
||||
- **单二进制**:编译后约 10MB,零运行时依赖(仅依赖系统 libc),部署即用
|
||||
- **极低内存占用**:空闲状态仅 ~15MB RSS,满载并发 100+ 请求时峰值 < 100MB
|
||||
- **单二进制**:编译后 8~12MB(`-s -w` strip 后约 8MB),零运行时依赖(仅依赖系统 libc),部署即用
|
||||
- **低内存占用**:与源数量相关,非与运行时长相关——单源 ~10MB、16 源生产实例 ~32–35MB([实测分解与调优](#内存占用实测与调优))
|
||||
- **日志按需加载**:统计数字来自**全量**审计日志(启动时流式扫一遍即释放,29MB/22 万行约 260ms),但原始记录不常驻内存——默认只加载首屏,下滚自动分页,CSV 导出流式写出(O(1) 内存),离页即释放
|
||||
- **零运行时依赖**:纯 Go + LuaJIT 静态链接,无需安装 Python/Node/Java 等运行时
|
||||
- **启动极快**:冷启动 < 200ms,热重载配置 < 10ms
|
||||
|
||||
@ -24,6 +25,7 @@
|
||||
|
||||
- **多密钥多租户**:支持无限密钥,每个密钥独立角色、模型范围、Token 配额、重置周期
|
||||
- **AUTO 智能调度**:基于优先级档位的分级调度,同优先级源自动轮询负载均衡,故障自动毫秒级故障转移
|
||||
- **自愈冷却**:冷却上限 5 分钟,过半后放行 1 个探测请求,上游/额度恢复即刻回归轮询,无需等满冷却窗口
|
||||
- **Token 配额管理**:精确到模型级别的 Token 配额控制,支持小时/周/月/自定义小时周期自动重置
|
||||
- **同优先级源负载均衡**:同一优先级档位的多个源,请求自动 Round-Robin 均匀分发,故障毫秒级故障转移
|
||||
- **Source-Model 前缀路由**:支持 `source-model`/`source:model`/`source/model` 精确指定上游源
|
||||
@ -37,7 +39,7 @@
|
||||
|
||||
### 灵活的协议适配
|
||||
|
||||
- **LuaJIT VM**:每个适配器独立 VM + worker 池,安全并发,Lua 脚本热加载无需重启
|
||||
- **LuaJIT VM**:每个适配器独立 VM + **弹性 worker 池**(按需扩缩容,空闲时自动回收),安全并发,Lua 脚本热加载无需重启
|
||||
- **Lua 适配器协议**:`transform_request` / `transform_response` / `transform_stream_chunk` 双向转换
|
||||
- **动态请求头钩子**:`build_headers(meta)` 支持 HMAC 签名、动态 Header 注入
|
||||
- **多模态透传**:`image_url` 等多模态内容在多源间无损透传,Anthropic/Gemini/Ollama 自动转换
|
||||
@ -60,6 +62,12 @@ Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换
|
||||
- **生图**:`POST /v1/images/generations`,`kind: image` 的模型独立路由。
|
||||
- **多模态**:`content` 数组(`image_url` 等)在多源间无损透传,Anthropic/Gemini/Ollama 自动转换。
|
||||
- **LuaJIT VM**:基于 golua 绑定的 LuaJIT,每个适配器独立 VM + worker 池,安全并发。
|
||||
池是 **弹性** 的:`Σ max_concurrent` 只是上限而非预分配,状态按需创建、空闲时自动回收,
|
||||
因此空载网关几乎不持有 Lua 状态(启动 0 个)。
|
||||
- 扩容步长由适配器最大并发决定:`clamp(ceil(max/8), 1, 8)`,且仅在**真实并发争用**
|
||||
(所有现有状态均已占用)时批量预热,顺序流量始终只用 1 个状态。
|
||||
- 缩容步长由当前连接数决定:`clamp(ceil(冗余/(1+占用)), 1, 冗余)`——无连接时一轮收到
|
||||
常驻下限,忙时每轮只释放 1 个,保护热路径。WebUI「适配器」页展示实时池指标。
|
||||
- **disable_thinking**:请求 `disable_thinking:true`(或上游对应字段)动态开关推理。
|
||||
- **Lua 适配协议**:每个源挂一个 `.lua` 适配器,完成 `transform_request` /
|
||||
`transform_response` / `transform_stream_chunk` 双向转换,协议差异全在 Lua 层。
|
||||
@ -184,16 +192,51 @@ sources:
|
||||
|
||||
### AUTO 链(优先级页)
|
||||
|
||||
AUTO 调度**只**由优先级页保存的规则(持久化到 `runtime_file` 的 `auto` 字段)决定,
|
||||
源配置里的 `models[].priority` 数字不再参与调度、也不再显示。
|
||||
AUTO 调度**只**由优先级页保存的规则决定,源配置里的 `models[].priority` 数字不再
|
||||
参与调度、也不再显示。**聊天与生图是两套独立的链**,在优先级页面通过「聊天 / 生图」
|
||||
按钮切换编辑:
|
||||
|
||||
- **聊天链**(`runtime_file` 的 `auto` 字段):服务 `POST /v1/chat/completions`
|
||||
的 `model: AUTO` 请求;只接受 `kind: chat` 模型,配置在链里的生图槽位会被忽略。
|
||||
- **生图链**(`runtime_file` 的 `auto_image` 字段):服务 `POST /v1/images/generations`
|
||||
的 `model: AUTO` 请求;只接受 `kind: image` 模型。未配置生图链时,AUTO 生图回落
|
||||
到「所有含生图模型的源、按注册顺序」的旧行为,兼容旧配置。
|
||||
|
||||
每条规则 = 一个「槽位」:`{ model, source, tier, token_quota, period, hours }`。
|
||||
|
||||
- 每条规则 = 一个「槽位」:`{ model, source, tier, token_quota, period, hours }`。
|
||||
- `tier` 表示优先级档位:同一档的模型并排、共享该优先级;档位从上到下递减。
|
||||
- 同一模型可配置多个槽位(如 A 源低配 → B 源低配 → A 源高配 → B 源高配),按档位顺延。
|
||||
- `token_quota` > 0 时该槽位在重置周期内用满即顺延到下一槽位;`period` 支持
|
||||
`hour` / `week` / `month` / `nhour`(配合 `hours`),空 = 不限。
|
||||
- 生图模型(`kind: image`)不参与 AUTO 链;生图走 `POST /v1/images/generations`
|
||||
的独立路径。
|
||||
- 同 tier 内按「偏好分」排序,失败槽位冷却后自动跳过;冷却 / 配额耗尽 / hard
|
||||
错误会顺延到下一 tier,全部失败时返回 503 并附上每档失败摘要。
|
||||
|
||||
### 冷却与半冷却探测(自愈调度)
|
||||
|
||||
槽位失败后进入指数退避冷却(5s → 10s → 20s …,上限 **5 分钟**)。冷却不是全时段
|
||||
硬闸:
|
||||
|
||||
- **前半段完全静默**:刚失败的槽位不接任何流量,避免对故障上游持续施压。
|
||||
- **后半段放行 1 个探测**:窗口过半后,同一 `(源, 模型)` 最多允许 **一个** 请求作为
|
||||
探测通过。探测槽数 = `clamp(max_concurrent / 10, 1, 2)`——`max_concurrent: 10`
|
||||
的源恰好放行 1 个探测请求。
|
||||
- **探测排在最后**:同 tier 内探测候选永远排在健康候选之后,只有没有正常槽可用时
|
||||
才承接真实流量,因此探测不会抢走可用容量。
|
||||
- **探测即真实请求**:成功则立即清零失败计数与冷却,槽位当场回到正常轮询——
|
||||
上游恢复/额度恢复后不必等满整个冷却窗口。失败则重开一个新窗口,下次探测顺延到
|
||||
新窗口的中点,不会连续重试。
|
||||
|
||||
分类冷却策略:
|
||||
|
||||
| 情况 | 冷却 | 说明 |
|
||||
|---|---|---|
|
||||
| 传输错误 / 5xx | 5s→10s→20s…,上限 5min | 指数退避 |
|
||||
| 401 / 403 凭据错误 | 10min | 换 key 后由下一次探测自动接回 |
|
||||
| 429 限流 | 30s 固定 | 「太快」不等于「坏了」,不进指数阶梯 |
|
||||
| 配额耗尽(`insufficient_quota` / `余额不足` 等) | 对齐配额窗口,上限 30min | **不进指数阶梯、不涨失败计数**,额度恢复即可用 |
|
||||
|
||||
WebUI「优先级」页的槽位健康标签会区分 `冷却` / `待探测` / `探测中`,悬停可看窗口
|
||||
起点、探测放行时刻与完全恢复时刻。
|
||||
|
||||
### 源(Source)与适配器(Adapter)的关系
|
||||
|
||||
@ -212,7 +255,7 @@ AUTO 调度**只**由优先级页保存的规则(持久化到 `runtime_file`
|
||||
2. `config.Load` 读取 `config.yaml`,`config.NewStore(runtime_file)` 读 WebUI 改动的
|
||||
运行时源,二者按名称合并成完整源列表。
|
||||
3. `rebuildRegistry` 为每个源创建 `provider.Provider`(持有目标适配器),并按源的
|
||||
并发上限配置适配器 worker 池大小。
|
||||
并发上限配置适配器 worker 池**上限**(不预分配,见下)。
|
||||
4. 请求进来时 `Registry` 按 `model` 路由到 Provider,Provider 调适配器
|
||||
`transform_request` → HTTP 发送 → `transform_response` / `transform_stream_chunk`。
|
||||
|
||||
@ -224,6 +267,70 @@ WebUI 上的"新增/编辑源"、"上传 Lua 适配器"、"改 AUTO 优先级链
|
||||
`config.yaml` 中定义的基源无法改写配置文件,采用删除标记隐藏(重启后仍隐藏),
|
||||
在 UI 里重新添加同名源即可恢复。
|
||||
|
||||
### 源模板(多 key 负载均衡)
|
||||
|
||||
多个上游 key 共享同一套配置(URL / 适配器 / 模型列表 / 并发 / rpm)时,逐一复制
|
||||
整段 source 太冗余。**源模板**保存除 `name` 和 `api_key` 以外的全部源字段,
|
||||
从模板一键创建多个仅 key 不同的源,天然支持多 key 负载均衡(每个展开源独立调度、
|
||||
独立冷却、独立健康状态)。
|
||||
|
||||
- WebUI「源」页右上角「**模板管理**」按钮:列出全部模板,可编辑 / 删除 / 新建。
|
||||
- 添加源弹窗右上角两个按钮:
|
||||
- **从模板创建** → 选模板 → 表单自动填充(name + key 仍需你填);
|
||||
- **存为模板** → 把当前表单非 key/name 字段存为新模板。
|
||||
- 模板持久化在 `runtime_file` 的 `source_templates` 字段,与运行时源一样热管理,
|
||||
改完即时生效。模板本身不含密钥,可以安全共享 / 纳管。
|
||||
|
||||
模板不会自己变成源——它只是「配方」。真正承担流量的是从模板创建出来的、带 key
|
||||
的具体源。
|
||||
|
||||
### 内存占用(实测与调优)
|
||||
|
||||
内存不是一个定数:它随**配置的源数量**增长(每个源一个 `http.Transport` 与连接池、
|
||||
一组健康状态),与运行时长无关。本机实测(Linux x86_64,12 核):
|
||||
|
||||
| 部署形态 | 启动 RSS | 稳态 RSS |
|
||||
|---|---|---|
|
||||
| 1 源 / 1 适配器(最小配置) | ~4 MB | ~10 MB |
|
||||
| 1 源 + 29 MB 历史审计日志 | ~19 MB | ~20 MB |
|
||||
| **16 源 / 13 适配器 / 59 模型(本机生产)** | ~28 MB | **~32–35 MB** |
|
||||
|
||||
> 历史参考:本项优化前同一生产配置为 **~105 MB**。降幅来自三处:审计日志的**原始记录**
|
||||
> 不再常驻内存(约 25 MB;统计聚合仍扫全量,但扫完即释放)、Lua 状态池不再单调增长、
|
||||
> 以及下面两个运行时开关。
|
||||
|
||||
内存构成(生产实例分段测量,`/proc/<pid>/smaps`):
|
||||
|
||||
| 区域 | RSS | 说明 |
|
||||
|---|---|---|
|
||||
| Go 堆 | ~14 MB | provider/registry/scheduler 结构 + 连接池缓冲 |
|
||||
| 其他匹名(线程栈 / LuaJIT chunk / runtime) | ~12 MB | 与线程数、已加载适配器数相关 |
|
||||
| 二进制 text+rodata | ~8 MB | 映射的可执行文件页(只读、可被内核回收) |
|
||||
| Go runtime 预留 | ~4 MB | VSZ 上看到的 2 GB+ 是地址空间预留,不占物理内存 |
|
||||
| 共享库 | ~3 MB | libc / libluajit / libm |
|
||||
|
||||
**两个推荐的部署开关**(只适用于环境变量,无需改代码):
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/llmsproxy.service
|
||||
Environment=GOGC=50
|
||||
Environment=MALLOC_ARENA_MAX=2
|
||||
```
|
||||
|
||||
- `MALLOC_ARENA_MAX=2`:LuaJIT 的分配走 cgo → glibc malloc,glibc 默认允许 `8×nproc`
|
||||
个 per-thread arena,每个碰到 malloc 的 OS 线程会占用一个(各约 1 MB,且**不归还给系统**)。
|
||||
实测从 8–15 个 arena(约 7–12 MB)降到 **0**。
|
||||
- `GOGC=50`:把 Go 堆增长目标减半。**单独使用无效**(省下的堆会立即被更多 glibc
|
||||
arena 吃掉,实测 20.3 → 21.5 MB 反而变大),必须与 `MALLOC_ARENA_MAX` 配合,
|
||||
两者同时开启才降 **~19%**。网关是 I/O 密集型(本机 9 小时仅消耗 1min10s CPU),
|
||||
多出的 GC 周期与它的空闲 CPU 相比可忽略。
|
||||
|
||||
> 桌面版(Electron)已在 `cmd/gui/main.js` 里默认为内嵌核心注入这两个开关;手动 export 同名
|
||||
> 环境变量可覆盖。如果你用自己的 systemd unit / 容器,建议照上面加上。
|
||||
|
||||
可选:`GOMEMLIMIT=48MiB` 作软上限,实测再省 ~1 MB;代价是逐近上限时 GC 转激进,
|
||||
温和场景不必开。
|
||||
|
||||
### disable_thinking
|
||||
|
||||
请求体带 `"disable_thinking": true` 时,网关透传给各适配器;DeepSeek 适配器将其
|
||||
@ -296,14 +403,24 @@ return {
|
||||
一键启停、系统托盘常驻、开机自启、静默启动,内容区一比一内嵌完整 WebUI
|
||||
(状态/对话/密钥/优先级/源/适配器 六个页面,免登录)。服务器用户继续使用纯 Go 二进制。
|
||||
|
||||
### 两种安装包:Headless 与 Desktop
|
||||
|
||||
发布到 Release 的安装包分为两类,面向不同用户:
|
||||
|
||||
- **Headless(服务器版)**:纯核心二进制(无 Electron),配置驱动,适合服务器、
|
||||
容器、systemd 等无人值守场景。发布包含 `deb` + `rpm`(含 systemd unit、内存调优
|
||||
与示例配置)与 `tar.gz`(二进制 + `config.example.yaml` + `adapters/` + README),
|
||||
Windows 为 `.zip`。
|
||||
- **Desktop(桌面版)**:Electron GUI 安装包,内嵌核心,适合个人桌面日常使用。
|
||||
|
||||
### 选择 GUI 还是纯后端
|
||||
|
||||
| 场景 | 推荐 | 理由 |
|
||||
| ------ | ------ | ------ |
|
||||
| 服务器 / 内网网关 / 无人值守常驻 | **纯后端**(单二进制) | 极轻量(~10MB、~15MB RSS),零依赖单进程,直接跑在 systemd/任意容器里,远程管理 |
|
||||
| 个人桌面日常使用 / 多设备内网共享 | **GUI**(Electron) | 免登录内嵌 WebUI、系统托盘一键启停、开机自启、静默后台,适合不懂命令行的使用者 |
|
||||
| Windows 桌面 | **GUI** | 纯后端在 Windows 上需自行注册服务,GUI 提供原生托盘/自启体验 |
|
||||
| CI 一键出三平台安装包 | **GUI 打包脚本** | `make gui-*` 系列出 deb/AppImage/NSIS,可进发行流水线 |
|
||||
| 服务器 / 内网网关 / 无人值守常驻 | **Headless**(单二进制) | 极轻量(二进制 ~8MB;RSS 随源数量,单源 ~10MB、16 源 ~32–35MB,见[内存占用](#内存占用实测与调优)),零依赖单进程,直接跑在 systemd/任意容器里,远程管理 |
|
||||
| 个人桌面日常使用 / 多设备内网共享 | **Desktop**(GUI) | 免登录内嵌 WebUI、系统托盘一键启停、开机自启、静默后台,适合不懂命令行的使用者 |
|
||||
| Windows 桌面 | **Desktop** | 纯后端在 Windows 上需自行注册服务,GUI 提供原生托盘/自启体验 |
|
||||
| CI 一键出三平台安装包 | **Desktop 打包脚本** | `make gui-*` 系列出 deb/AppImage/NSIS,可进发行流水线 |
|
||||
|
||||
两者完全同源:GUI 内嵌的就是纯后端的同一份 `llmsproxy` 核心(LuaJIT 版),
|
||||
配置/适配器格式完全一致,可随时互换。
|
||||
@ -315,27 +432,36 @@ cd cmd/gui
|
||||
npm install
|
||||
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/llmsproxy)
|
||||
make gui-deb # 本机构建 deb(分享给其它 Linux 用户)
|
||||
make gui-dist # 构建 deb + AppImage(linux)
|
||||
make gui-dist # 构建 deb + rpm + AppImage(linux,rpm 需系统 rpmbuild)
|
||||
make core-dist # 构建核心 deb + rpm + tar.gz(服务器版,需 nfpm)
|
||||
make gui-win # 构建 Windows nsis 安装包(本机 mingw + wine)
|
||||
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,无需本机 mingw)
|
||||
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,含 wine)
|
||||
make gui-dist-dir # 仅解包产物(调试用,不产安装包)
|
||||
```
|
||||
|
||||
产物在 `cmd/build/gui-dist/`:`ModelRouter-1.0.0.AppImage`、
|
||||
`modelrouter-gui_1.0.0_amd64.deb`、`ModelRouter Setup 1.0.0.exe`(win)。rpm 需系统 `rpmbuild`。
|
||||
产物在 `cmd/build/gui-dist/`:`ModelRouter-<ver>.AppImage`、
|
||||
`modelrouter-gui_<ver>_amd64.deb`、`ModelRouter Setup <ver>.exe`(win);
|
||||
核心包在 `cmd/build/dist/`:`llmsproxy_<ver>_amd64.deb`、`llmsproxy-<ver>.x86_64.rpm`。
|
||||
rpm 需系统 `rpmbuild`。每次打包后都会跑 `packaging/verify-dist.sh` 产物体检
|
||||
(大小下限门禁——1.3.0 曾因宿主 wine 损坏产出 264KB 的残缺 exe 却没有被拦住)。
|
||||
发布时会把产物重命名为 `ModelRouter-Desktop-*`(GUI)与 `ModelRouter-Headless-*`(纯后端)
|
||||
两类上传到 GitCode Release。
|
||||
|
||||
#### Windows 打包(docker 固化方案,推荐)
|
||||
|
||||
`make gui-win-docker` 一步完成:交叉编译核心 + NSIS 打包,宿主机**无需安装 mingw**。
|
||||
`make gui-win-docker` 一步完成:交叉编译核心 + NSIS 打包**全在 docker 内**,宿主机
|
||||
**无需 mingw、无需 wine、无需 node**。镜像内置 wine(含 i386 运行时),修复了
|
||||
此前宿主 wine 损坏(缺 syswow64 → `c0000135`)导致产出 264KB 残缺 exe 的问题。
|
||||
|
||||
- 构建镜像:`cmd/gui/docker/win-builder/Dockerfile`(`golang:1.25` + mingw-w64 +
|
||||
预编译 LuaJIT windows 版 `lua51.dll` 与 import lib)。LuaJIT 源码取自 gitcode
|
||||
镜像(国内可达),github 兜底;Go 依赖走 `https://goproxy.cn`。
|
||||
- 核心编译:`cmd/gui/scripts/win-core-docker.sh` —— 在容器内产出
|
||||
`cmd/gui/bin/{llmsproxy.exe,lua51.dll}`。
|
||||
- 一键发布:`cmd/gui/scripts/dist-win-docker.sh` —— 核心编译后调用
|
||||
electron-builder(宿主需有 `wine` 用于 NSIS)。
|
||||
- 首次运行自动构建 `modelrouter/win-builder` 镜像;已存在则直接复用。
|
||||
node + wine32/wine64 + 预编译 LuaJIT windows 版 `lua51.dll` 与 import lib)。
|
||||
LuaJIT 源码取自 gitcode 镜像(国内可达),github 兜底;Go 依赖走 `https://goproxy.cn`。
|
||||
- 一键发布:`cmd/gui/scripts/dist-win-docker.sh` —— 核心编译 + electron-builder
|
||||
NSIS 全部在容器内执行(`USE_SYSTEM_WINE=true` 走镜像内的 wine);electron-builder
|
||||
的 ~400MB 工具链缓存挂到 docker volume,二次构建增量复用。
|
||||
- 首次运行自动构建 `modelrouter/win-builder` 镜像(约 5–10 分钟);已存在则直接复用。
|
||||
- 打包完成后自动跑 `packaging/verify-dist.sh` 门禁:exe 低于 5MB / deb·rpm·7z 低于
|
||||
10MB 即失败——残缺安装包不可能再溜进 Release。
|
||||
|
||||
```bash
|
||||
# 全 Windows 发布(核心重编 + NSIS):
|
||||
|
||||
182
README_EN.md
182
README_EN.md
@ -11,7 +11,7 @@ KimiCode…) behind a single endpoint. Pick a specific model or use **AUTO** mod
|
||||
which routes to the best healthy upstream by configured priority.
|
||||
|
||||
> **Lightweight, no recompile to add sources**: the gateway is a single Go
|
||||
> binary (~10 MB, zero runtime dependencies). Adding or switching an upstream is
|
||||
> binary (8-12 MB, ~8 MB stripped; zero runtime dependencies). Adding or switching an upstream is
|
||||
> just a `sources` entry in `config.yaml` (or via the WebUI) or a `.lua`
|
||||
> adapter — **no Go changes, no recompile**. Changes submitted through the
|
||||
> WebUI take effect immediately (hot reload); editing `config.yaml` or a `.lua`
|
||||
@ -41,7 +41,23 @@ Extracted and independently evolved from the multi-source LLM adapter layer of
|
||||
- **Multimodal**: `content` arrays (`image_url` etc.) pass through losslessly;
|
||||
Anthropic/Gemini/Ollama are translated automatically.
|
||||
- **LuaJIT VM**: golua-binding LuaJIT; each adapter has its own VM + worker
|
||||
pool for safe concurrency.
|
||||
pool for safe concurrency. Pools are **elastic**: the sum of `max_concurrent`
|
||||
over an adapter's sources is a ceiling, not a preallocation — states are booted
|
||||
on demand and reclaimed when demand drops, so an idle gateway holds close to
|
||||
zero Lua states. The grow step follows the adapter's max concurrency
|
||||
(`clamp(ceil(max/8), 1, 8)`, and never exceeds the number of queued callers);
|
||||
the shrink step follows the live connection count
|
||||
(`clamp(ceil(slack/(1+in_use)), 1, slack)`), so an idle pool collapses in one
|
||||
round while a busy one gives up a single state at a time.
|
||||
- **On-demand request logs**: dashboard totals are computed from the **full**
|
||||
audit history (streamed once at startup and released — 29 MB / 221k lines in
|
||||
~260 ms), while the raw records are never held in memory: the dashboard loads
|
||||
one screen, scrolling pages the rest straight off disk, CSV export streams in
|
||||
O(1) memory, and leaving the page releases everything.
|
||||
- **Self-healing cooldown**: cooldown is capped at 5 minutes and, past the
|
||||
window's midpoint, exactly one probe request is allowed through; a recovered
|
||||
upstream (or a reset quota) returns to full rotation on that probe instead of
|
||||
waiting out the window.
|
||||
- **disable_thinking**: `disable_thinking: true` toggles reasoning per-request.
|
||||
- **Lua adapter protocol**: each source mounts a `.lua` adapter with
|
||||
`transform_request` / `transform_response` / `transform_stream_chunk` — all
|
||||
@ -176,12 +192,20 @@ tiered AUTO chain.
|
||||
|
||||
### AUTO chain (Priority page)
|
||||
|
||||
AUTO scheduling is driven **only** by the rules saved on the Priority page
|
||||
(persisted as the `auto` field of the runtime file). The numeric
|
||||
`models[].priority` in source configs no longer participates in scheduling and
|
||||
is no longer shown.
|
||||
AUTO scheduling is driven **only** by the rules saved on the Priority page. The
|
||||
numeric `models[].priority` in source configs no longer participates in
|
||||
scheduling and is no longer shown. **Chat and image generation are two
|
||||
independent chains**, switched in the Priority page via the `Chat / Image` toggle:
|
||||
|
||||
- **Chat chain** (`auto` field): serves `POST /v1/chat/completions` with
|
||||
`model: AUTO`; only `kind: chat` models are honored (image slots are skipped).
|
||||
- **Image chain** (`auto_image` field): serves `POST /v1/images/generations` with
|
||||
`model: AUTO`; only `kind: image` models are honored. With no image chain
|
||||
configured, AUTO image generation falls back to legacy discovery (all sources
|
||||
exposing an image model, in registry order) for backward compatibility.
|
||||
|
||||
Each rule is one "slot": `{ model, source, tier, token_quota, period, hours }`.
|
||||
|
||||
- Each rule is one "slot": `{ model, source, tier, token_quota, period, hours }`.
|
||||
- `tier` is the priority tier: models in the same tier sit side by side and
|
||||
share it; tiers run high → low.
|
||||
- The same model may appear in several slots (e.g. A low → B low → A high →
|
||||
@ -189,8 +213,9 @@ is no longer shown.
|
||||
- `token_quota` > 0 means the slot is skipped once its tokens within the reset
|
||||
window are exhausted; `period` supports `hour` / `week` / `month` / `nhour`
|
||||
(with `hours`); empty = unlimited.
|
||||
- Image models (`kind: image`) are kept out of the chain and are served by the
|
||||
separate `POST /v1/images/generations` path.
|
||||
- Within a tier, slots run by preference score; cooling / quota-exhausted /
|
||||
hard-failed slots fall through to the next tier, and total failure returns
|
||||
503 with a per-tier summary.
|
||||
|
||||
### Sources vs. adapters
|
||||
|
||||
@ -227,6 +252,82 @@ sources and `.lua` files; base sources defined in `config.yaml` cannot rewrite
|
||||
that file, so they are hidden via a deletion tombstone (still hidden after
|
||||
restart) and can be restored by re-adding the same name in the UI.
|
||||
|
||||
### Source templates (multi-key balancing)
|
||||
|
||||
When several upstream keys share one config (URL / adapter / model list /
|
||||
concurrency / rpm), copying the whole source block N times is wasteful. A
|
||||
**source template** stores every source field except `name` and `api_key`, so you
|
||||
create N key-only-different sources from one shared skeleton — natural multi-key
|
||||
load balancing (each expanded source schedules, cools down, and reports health
|
||||
independently).
|
||||
|
||||
- WebUI Sources page, top-right **Templates** button: list all templates with
|
||||
edit / delete / create.
|
||||
- Add-source dialog has two header buttons:
|
||||
- **From template** → pick a template → form auto-fills (name + key still
|
||||
yours to enter);
|
||||
- **As template** → save the current form's non-key/name fields as a template.
|
||||
- Templates persist in the runtime file's `source_templates` field alongside
|
||||
runtime sources (hot-managed, apply immediately). Templates hold no secret
|
||||
keys, so they're safe to share / version-control.
|
||||
|
||||
A template is just a recipe — it doesn't become a source by itself. Only the
|
||||
key-bearing sources created from it carry real traffic.
|
||||
|
||||
### Memory footprint (measured and tuned)
|
||||
|
||||
Memory is not a constant: it scales with the **number of configured sources**
|
||||
(each source owns an `http.Transport` with its connection pool plus per-model
|
||||
health state), not with uptime. Measured on this machine (Linux x86_64, 12 cores):
|
||||
|
||||
| Deployment shape | Startup RSS | Settled RSS |
|
||||
|---|---|---|
|
||||
| 1 source / 1 adapter (minimal) | ~4 MB | ~10 MB |
|
||||
| 1 source + a 29 MB audit history | ~19 MB | ~20 MB |
|
||||
| **16 sources / 13 adapters / 59 models (this host)** | ~28 MB | **~32-35 MB** |
|
||||
|
||||
> For reference, the same production config used **~105 MB** before this round of
|
||||
> work. The reduction comes from three places: raw audit records are no longer kept
|
||||
> resident (~25 MB — the aggregates still scan the full history, but the scan
|
||||
> releases as it goes), Lua state pools no longer grow monotonically, and the two
|
||||
> runtime knobs below.
|
||||
|
||||
Breakdown of the production instance (per-region, from `/proc/<pid>/smaps`):
|
||||
|
||||
| Region | RSS | Notes |
|
||||
|---|---|---|
|
||||
| Go heap | ~14 MB | provider/registry/scheduler state + connection-pool buffers |
|
||||
| Other anonymous (thread stacks / LuaJIT chunks / runtime) | ~12 MB | scales with thread count and loaded adapters |
|
||||
| Binary text+rodata | ~8 MB | mapped executable pages (read-only, reclaimable by the kernel) |
|
||||
| Go runtime reservations | ~4 MB | the 2 GB+ you see in VSZ is address space, not physical memory |
|
||||
| Shared libraries | ~3 MB | libc / libluajit / libm |
|
||||
|
||||
**Two recommended deployment knobs** (environment only, no code change):
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/llmsproxy.service
|
||||
Environment=GOGC=50
|
||||
Environment=MALLOC_ARENA_MAX=2
|
||||
```
|
||||
|
||||
- `MALLOC_ARENA_MAX=2`: LuaJIT allocates through cgo into glibc malloc, and glibc
|
||||
allows up to `8 x nproc` per-thread arenas. Every OS thread that touches malloc
|
||||
claims one (~1 MB each) and **never returns it to the OS**. Measured: 8-15
|
||||
arenas (7-12 MB) down to **0**.
|
||||
- `GOGC=50`: halves the Go heap growth target. **It does nothing on its own** —
|
||||
the heap it saves is immediately consumed by additional glibc arenas (measured
|
||||
20.3 -> 21.5 MB, i.e. slightly worse) — so it must be paired with
|
||||
`MALLOC_ARENA_MAX`. Together they cut settled RSS by **~19%**. The gateway is
|
||||
I/O bound (1min10s of CPU per 9 hours here), so the extra GC cycles are free.
|
||||
|
||||
> The desktop build (Electron) already injects both when spawning the embedded
|
||||
> core (`cmd/gui/main.js`); exporting either variable yourself overrides it. If you
|
||||
> write your own systemd unit or container spec, add them there.
|
||||
|
||||
Optional: `GOMEMLIMIT=48MiB` as a soft ceiling saves roughly 1 MB more, at the
|
||||
cost of GC turning aggressive as the limit approaches. Not worth it for a relaxed
|
||||
deployment.
|
||||
|
||||
### disable_thinking
|
||||
|
||||
With `"disable_thinking": true` in the request body, the gateway passes it to
|
||||
@ -308,14 +409,25 @@ tray, autostart, silent launch, and a pixel-for-pixel embedded full WebUI
|
||||
(Status/Chat/Keys/Priority/Sources/Adapters — no login needed). Server users
|
||||
keep using the plain Go binary.
|
||||
|
||||
### Two installer kinds: Headless and Desktop
|
||||
|
||||
Release installers ship in two flavors for different audiences:
|
||||
|
||||
- **Headless (server)**: the plain core binary (no Electron), config-driven,
|
||||
for servers / containers / systemd / unattended runs. Ships as `deb` + `rpm`
|
||||
(systemd unit with memory tuning + example config) and `tar.gz` (binary +
|
||||
`config.example.yaml` + `adapters/` + README), Windows as `.zip`.
|
||||
- **Desktop (desktop app)**: the Electron GUI installer with an embedded core,
|
||||
for personal daily desktop use.
|
||||
|
||||
### GUI vs. plain backend
|
||||
|
||||
| Scenario | Pick | Why |
|
||||
| ---- | ---- | ---- |
|
||||
| Server / intranet gateway / unattended long-running | **plain backend** (single binary) | ~10 MB, ~15 MB RSS, zero-dep single process — drop it into systemd or any container, remote admin |
|
||||
| Personal desktop daily use / multi-device intranet sharing | **GUI** (Electron) | no-login embedded WebUI, tray one-click, autostart, silent background — for non-CLI users |
|
||||
| Windows desktop | **GUI** | plain backend needs manual service registration; GUI ships native tray/autostart |
|
||||
| CI one-shot 3-platform installers | **GUI packaging scripts** | `make gui-*` emits deb / AppImage / NSIS, drops straight into a release pipeline |
|
||||
| Server / intranet gateway / unattended long-running | **Headless** (single binary) | ~8 MB binary; RSS scales with the number of sources (~10 MB for one, ~32-35 MB for 16 — see [Memory footprint](#memory-footprint-measured-and-tuned)), zero-dep single process — drop it into systemd or any container, remote admin |
|
||||
| Personal desktop daily use / multi-device intranet sharing | **Desktop** (GUI) | no-login embedded WebUI, tray one-click, autostart, silent background — for non-CLI users |
|
||||
| Windows desktop | **Desktop** | plain backend needs manual service registration; GUI ships native tray/autostart |
|
||||
| CI one-shot 3-platform installers | **Desktop packaging scripts** | `make gui-*` emits deb / AppImage / NSIS, drops straight into a release pipeline |
|
||||
|
||||
Both share the exact same `llmsproxy` core (LuaJIT build) — configs and
|
||||
adapters are fully compatible, freely interchangeable.
|
||||
@ -327,30 +439,42 @@ cd cmd/gui
|
||||
npm install
|
||||
make gui # or npm run dev — dev run (needs make build first for bin/llmsproxy)
|
||||
make gui-deb # local deb (share with other Linux users)
|
||||
make gui-dist # deb + AppImage (linux)
|
||||
make gui-dist # deb + rpm + AppImage (linux; rpm needs system rpmbuild)
|
||||
make core-dist # core deb + rpm + tar.gz (server edition; needs nfpm)
|
||||
make gui-win # Windows NSIS (host mingw + wine)
|
||||
make gui-win-docker # Windows NSIS, fully dockerized (no host mingw needed)
|
||||
make gui-win-docker # Windows NSIS, fully dockerized (wine in image; no host mingw/node/wine)
|
||||
```
|
||||
|
||||
Artifacts land in `cmd/build/gui-dist/`: `ModelRouter-1.0.0.AppImage`,
|
||||
`modelrouter-gui_1.0.0_amd64.deb`, `ModelRouter Setup 1.0.0.exe` (win). rpm
|
||||
needs system `rpmbuild`.
|
||||
Artifacts land in `cmd/build/gui-dist/`: `ModelRouter-<ver>.AppImage`,
|
||||
`modelrouter-gui_<ver>_amd64.deb`, `ModelRouter Setup <ver>.exe` (win); core
|
||||
packages land in `cmd/build/dist/`: `llmsproxy_<ver>_amd64.deb`,
|
||||
`llmsproxy-<ver>.x86_64.rpm`. Both target families run
|
||||
`packaging/verify-dist.sh` afterwards — a size floor that rejects degenerate
|
||||
installers (1.3.0 shipped a 264 KB NSIS stub because the host wine was broken
|
||||
and nothing caught it). At release time artifacts are renamed into the
|
||||
`ModelRouter-Desktop-*` (GUI) and `ModelRouter-Headless-*` (plain backend)
|
||||
families before upload to GitCode Releases.
|
||||
|
||||
#### Windows packaging (dockerized, recommended)
|
||||
|
||||
`make gui-win-docker` does cross-compile + NSIS in one step; the host needs
|
||||
**no mingw**.
|
||||
`make gui-win-docker` does cross-compile + NSIS **entirely in docker**; the host
|
||||
needs **no mingw, no wine, no node**. Wine (with the i386 runtime) lives inside
|
||||
the image, fixing the broken-host-wine failure that produced the 264 KB stub
|
||||
installer.
|
||||
|
||||
- Image: `cmd/gui/docker/win-builder/Dockerfile` (`golang:1.25` + mingw-w64 +
|
||||
prebuilt LuaJIT for Windows: `lua51.dll` + import lib). LuaJIT source comes
|
||||
from the gitcode mirror (reachable in CN) with github fallback; Go modules go
|
||||
through `https://goproxy.cn`.
|
||||
- Core build: `cmd/gui/scripts/win-core-docker.sh` — emits
|
||||
`cmd/gui/bin/{llmsproxy.exe,lua51.dll}` inside the container.
|
||||
- One-shot release: `cmd/gui/scripts/dist-win-docker.sh` — after the core,
|
||||
runs electron-builder (host needs `wine` for NSIS).
|
||||
- The `modelrouter/win-builder` image is auto-built on first run and reused
|
||||
afterwards.
|
||||
node + wine32/wine64 + prebuilt LuaJIT for Windows: `lua51.dll` + import lib).
|
||||
LuaJIT source comes from the gitcode mirror (reachable in CN) with github
|
||||
fallback; Go modules go through `https://goproxy.cn`.
|
||||
- One-shot release: `cmd/gui/scripts/dist-win-docker.sh` — core build + NSIS
|
||||
packaging run inside the container (`USE_SYSTEM_WINE=true` uses the image's
|
||||
wine); electron-builder's ~400 MB toolchain cache lives in a docker volume so
|
||||
later builds reuse it.
|
||||
- The `modelrouter/win-builder` image is auto-built on first run (~5-10 min)
|
||||
and reused afterwards.
|
||||
- After packaging, `packaging/verify-dist.sh` runs as a gate: an exe under
|
||||
5 MB (or a deb/rpm/7z under 10 MB) fails the build — a broken installer can
|
||||
never reach a Release again.
|
||||
|
||||
```bash
|
||||
# Full Windows release (rebuild core + NSIS):
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
# ModelRouter GUI Windows builder
|
||||
# Self-sufficient cross-compilation environment for the embedded llmsproxy core.
|
||||
# Produces llmsproxy.exe (Go, windows/amd64, luajit tag) + lua51.dll (LuaJIT mingw build)
|
||||
# inside a mounted workspace: cmd/gui/bin/{llmsproxy.exe,lua51.dll}
|
||||
# Self-sufficient cross-compilation environment for the embedded llmsproxy core
|
||||
# AND for packaging the Windows NSIS installer.
|
||||
#
|
||||
# Produces inside a mounted workspace:
|
||||
# - llmsproxy.exe (Go, windows/amd64, luajit tag) + lua51.dll
|
||||
# - ModelRouter Setup <ver>.exe (electron-builder --win nsis; requires wine
|
||||
# so the uninstaller is generated — set USE_SYSTEM_WINE=true)
|
||||
#
|
||||
# Build: docker build -t modelrouter/win-builder cmd/gui/docker/win-builder
|
||||
# Usage: make gui-win-docker (or cmd/gui/scripts/dist-win-docker.sh)
|
||||
#
|
||||
# This image is the SINGLE wine provider for the NSIS step, so a host that has
|
||||
# no / broken wine (e.g. amd64-only Debian: no syswow64 -> c0000135) can still
|
||||
# produce a real installer. The NSIS step MUST run inside this image; running it
|
||||
# on the host was how the 1.3.0 264 KB degenerate exe got shipped.
|
||||
#
|
||||
# LuaJIT source is fetched from gitcode.com (CN-reachable mirror) with github
|
||||
# fallback; pass --build-arg LUAJIT_REPO to override.
|
||||
|
||||
@ -13,11 +22,26 @@ FROM golang:1.25
|
||||
|
||||
ARG LUAJIT_REPO=https://gitcode.com/openresty/luajit2.git
|
||||
|
||||
RUN apt-get update -qq \
|
||||
# i386 arch is required for wine32 (the NSIS uninstaller step runs a win32 exe
|
||||
# through wine's wow64 path, which needs the 32-bit runtime under /usr/lib/i386).
|
||||
RUN dpkg --add-architecture i386 \
|
||||
&& apt-get update -qq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||
gcc-mingw-w64-x86-64 make ca-certificates git \
|
||||
# NSIS step needs node + the electron-builder toolchain
|
||||
nodejs npm \
|
||||
# wine (32+64) for generating the NSIS uninstaller
|
||||
wine64 wine32:i386 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# electron-builder resolves the uninstaller via the system wine when
|
||||
# USE_SYSTEM_WINE=true (or by default on Linux without a toolset). Pin it.
|
||||
# The toolchain cache lives in $HOME/.cache (mounted as a volume by
|
||||
# dist-win-docker.sh), so it is reused across builds and never pollutes the
|
||||
# mounted workspace.
|
||||
ENV USE_SYSTEM_WINE=true
|
||||
ENV WINEDEBUG=-all
|
||||
|
||||
# LuaJIT cross-compiled for Windows; keeps lua51.dll and the cgo import lib
|
||||
# (libluajit-5.1.dll.a -> libluajit-5.1.a in the mingw lib dir).
|
||||
# NOTE: $LUAJIT_REPO deliberately unquoted in "sh -c" — quoting the ARG would
|
||||
|
||||
@ -198,6 +198,17 @@ function startCore() {
|
||||
cwd: PROFILE_DIR,
|
||||
env: Object.assign({}, process.env, {
|
||||
LLMS_PROXY_GUI: "1",
|
||||
// Memory tuning, measured on a 16-source deployment (see README 内存占用):
|
||||
// MALLOC_ARENA_MAX caps glibc per-thread malloc arenas. LuaJIT allocates
|
||||
// through cgo -> glibc malloc, and glibc defaults to 8*nproc arenas, so
|
||||
// every OS thread touching malloc reserves its own ~1 MB arena that is
|
||||
// never returned to the OS.
|
||||
// GOGC halves the Go heap growth target. On its own it does not help (the
|
||||
// saved heap is immediately eaten by extra glibc arenas); only the pair
|
||||
// lowers settled RSS. The core is I/O bound, so the extra GC is free.
|
||||
// Both are overridable: a user who exports them keeps their own values.
|
||||
GOGC: process.env.GOGC || "50",
|
||||
MALLOC_ARENA_MAX: process.env.MALLOC_ARENA_MAX || "2",
|
||||
}),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
6
cmd/gui/package-lock.json
generated
6
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.2",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
@ -4045,4 +4045,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.2",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
@ -10,7 +10,7 @@
|
||||
"dev": "electron . --no-sandbox --dev",
|
||||
"core:linux": "node scripts/prepare-core.js linux",
|
||||
"core:win": "node scripts/prepare-core.js win",
|
||||
"dist:linux": "npm run core:linux && electron-builder --linux deb AppImage",
|
||||
"dist:linux": "npm run core:linux && electron-builder --linux deb rpm AppImage",
|
||||
"dist:debian": "npm run core:linux && electron-builder --linux deb",
|
||||
"dist:win": "npm run core:win && electron-builder --win nsis",
|
||||
"dist:dir": "npm run core:linux && electron-builder --dir"
|
||||
@ -22,7 +22,7 @@
|
||||
"build": {
|
||||
"appId": "com.modelrouter.gui",
|
||||
"productName": "ModelRouter",
|
||||
"linux": {
|
||||
"linux": {
|
||||
"target": [
|
||||
"deb",
|
||||
"rpm",
|
||||
@ -32,9 +32,9 @@
|
||||
"icon": "build/icon.png",
|
||||
"maintainer": "ModelRouter",
|
||||
"synopsis": "ModelRouter Desktop",
|
||||
"desktop": {
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"StartupWMClass": "modelrouter-gui"
|
||||
"StartupWMClass": "modelrouter-gui"
|
||||
}
|
||||
},
|
||||
"extraResources": [
|
||||
|
||||
@ -1,23 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot Windows release build for the GUI:
|
||||
# One-shot Windows release build for the GUI — ENTIRELY inside docker.
|
||||
#
|
||||
# 1) cross-compile the embedded core in docker (llmsproxy.exe + lua51.dll)
|
||||
# 2) host-side electron-builder NSIS packaging (wine required on host)
|
||||
# 2) electron-builder NSIS packaging ALSO inside docker (wine for the
|
||||
# uninstaller step lives in the image — see Dockerfile). This is the fix
|
||||
# for the 1.3.0/1.4.0 broken installers: the host had amd64-only wine
|
||||
# (no syswow64 -> c0000135), which made electron-builder silently emit a
|
||||
# 264 KB degenerate exe without its 86 MB payload.
|
||||
#
|
||||
# Outputs: cmd/build/gui-dist/"ModelRouter Setup <version>.exe"
|
||||
# cmd/build/gui-dist/modelrouter-gui-<version>-x64.nsis.7z
|
||||
#
|
||||
# Needs: docker (any machine; no host mingw/wine/node required)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
cd "$ROOT/cmd/gui"
|
||||
IMAGE="${WIN_BUILDER_IMAGE:-modelrouter/win-builder}"
|
||||
PROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||||
CACHE_VOL="modelrouter-eb-cache"
|
||||
|
||||
# 1) core (docker)
|
||||
"$ROOT/cmd/gui/scripts/win-core-docker.sh"
|
||||
command -v docker >/dev/null || { echo "docker required"; exit 1; }
|
||||
|
||||
# 2) packaging (host, needs wine for electron-builder's signtool/nsis)
|
||||
command -v wine >/dev/null || { echo "wine required on host for NSIS packaging"; exit 1; }
|
||||
if ! command -v npx >/dev/null; then echo "node/npm required"; exit 1; fi
|
||||
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
echo "[dist-win-docker] building image $IMAGE (first run, ~5-10 min)..."
|
||||
docker build -t "$IMAGE" "$ROOT/cmd/gui/docker/win-builder"
|
||||
fi
|
||||
|
||||
echo "[dist-win] electron-builder --win nsis ..."
|
||||
# bin artifacts already refreshed by win-core-docker.sh; skip the prepare step
|
||||
npx electron-builder --win nsis
|
||||
docker volume create "$CACHE_VOL" >/dev/null 2>&1 || true
|
||||
|
||||
ls -la "$ROOT/cmd/build/gui-dist/"*.exe 2>/dev/null
|
||||
echo "[dist-win] done"
|
||||
# 1) embedded core + 2) NSIS packaging — both inside the image so wine and node
|
||||
# are the image's, never the host's. electron-builder downloads electron + nsis
|
||||
# toolchains into ~/.cache on first run; the mounted volume keeps that warm so
|
||||
# subsequent builds are incremental instead of re-fetching ~400 MB.
|
||||
echo "[dist-win-docker] cross-compiling core + electron-builder NSIS in docker ..."
|
||||
docker run --rm \
|
||||
-v "$ROOT":/workspace \
|
||||
-v "$CACHE_VOL":/root/.cache \
|
||||
-e GOPROXY="$PROXY" \
|
||||
-w /workspace \
|
||||
"$IMAGE" bash -c '
|
||||
set -euo pipefail
|
||||
cp /opt/luajit2/src/lua51.dll /workspace/cmd/gui/bin/lua51.dll
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
|
||||
go build -buildvcs=false -tags luajit -trimpath \
|
||||
-o /workspace/cmd/gui/bin/llmsproxy.exe ./cmd/llmsproxy
|
||||
echo " -> core built"
|
||||
# wine needs one prefix initialization so its wow64 runtime (syswow64/ntdll)
|
||||
# exists — the exact thing that was missing on the broken host. Use a shared
|
||||
# prefix in the cache volume so it is initialized only on the first run.
|
||||
export WINEPREFIX=/root/.cache/wineprefix
|
||||
if [ ! -f "$WINEPREFIX/drive_c/windows/syswow64/ntdll.dll" ]; then
|
||||
echo " -> initializing wine prefix (first run)"
|
||||
WINEDEBUG=-all wineboot -i || true
|
||||
fi
|
||||
cd /workspace/cmd/gui
|
||||
# node_modules ships in the repo (host npm install already ran); reuse it.
|
||||
npx electron-builder --win nsis
|
||||
'
|
||||
|
||||
echo "[dist-win-docker] verifying artifacts ..."
|
||||
"$ROOT/packaging/verify-dist.sh" "$ROOT/cmd/build/gui-dist"
|
||||
|
||||
echo "[dist-win-docker] done"
|
||||
@ -88,4 +88,4 @@ func main() {
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
log.Printf("[llmsproxy] shutting down")
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,4 +326,3 @@ func statusOf(resp *http.Response) int {
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
|
||||
@ -24,8 +24,9 @@ type Config struct {
|
||||
TLSKeyFile string `yaml:"tls_key_file,omitempty"` // PEM private key
|
||||
PublicBaseURL string `yaml:"public_base_url,omitempty"` // external base for generated config snippets; default inferred from request
|
||||
Sources []Source `yaml:"sources"`
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑)
|
||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
||||
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
|
||||
}
|
||||
|
||||
// Defaults applied to any source (YAML or runtime) that leaves a field unset.
|
||||
@ -313,11 +314,32 @@ func (c *Config) ApplyDefaults() error {
|
||||
|
||||
// RuntimeConfig is the legacy runtime file format (kept for migration only).
|
||||
type RuntimeConfig struct {
|
||||
Sources []Source `json:"sources,omitempty"`
|
||||
DeletedSources []string `json:"deleted_sources,omitempty"`
|
||||
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
|
||||
Keys []GWKey `json:"keys,omitempty"`
|
||||
Auto []ModelScope `json:"auto,omitempty"`
|
||||
Sources []Source `json:"sources,omitempty"`
|
||||
SourceTemplates []SourceTemplate `json:"source_templates,omitempty"`
|
||||
DeletedSources []string `json:"deleted_sources,omitempty"`
|
||||
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
|
||||
PresetTemplates []string `json:"preset_templates,omitempty"` // preset names the user has seen (or deleted) — never re-seeded
|
||||
Keys []GWKey `json:"keys,omitempty"`
|
||||
Auto []ModelScope `json:"auto,omitempty"`
|
||||
AutoImage []ModelScope `json:"auto_image,omitempty"`
|
||||
}
|
||||
|
||||
// SourceTemplate stores reusable source configuration (everything except
|
||||
// name and api_key) so the WebUI can spin up multiple key-bearing sources
|
||||
// from one shared template.
|
||||
type SourceTemplate struct {
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Adapter string `json:"adapter"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
ImageEndpoint string `json:"image_endpoint,omitempty"`
|
||||
Models []Model `json:"models"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Meta map[string]interface{} `json:"meta,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
MaxConcurrent int `json:"max_concurrent,omitempty"`
|
||||
RPM int `json:"rpm,omitempty"`
|
||||
}
|
||||
|
||||
// GWKey is a gateway API key persisted in the config file. Role is "admin"
|
||||
|
||||
@ -318,3 +318,55 @@ sources:
|
||||
t.Fatalf("after no-op: %v %v", len(cfg.Sources), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedTemplatesOnce verifies preset templates are seeded exactly once:
|
||||
// a user's edit survives a restart, and a deliberately deleted preset does
|
||||
// not silently come back.
|
||||
func TestSeedTemplatesOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "runtime.json")
|
||||
presets := []SourceTemplate{
|
||||
{Name: "DeepSeek", BaseURL: "https://api.deepseek.com", Adapter: "deepseek",
|
||||
Models: []Model{{ID: "deepseek-chat", Kind: "chat"}}},
|
||||
{Name: "OpenAI", BaseURL: "https://api.openai.com/v1", Adapter: "openai",
|
||||
Models: []Model{{ID: "gpt-4o", Kind: "chat"}}},
|
||||
}
|
||||
|
||||
// first run: both presets land in the store
|
||||
s1 := NewStore(path)
|
||||
if err := s1.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.SeedTemplates(presets); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if got := len(s1.ListTemplates()); got != 2 {
|
||||
t.Fatalf("after seed len = %d, want 2", got)
|
||||
}
|
||||
|
||||
// user edits one preset, deletes the other
|
||||
edited := presets[0]
|
||||
edited.BaseURL = "https://my-proxy.internal/v1"
|
||||
if err := s1.UpsertTemplate(edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s1.RemoveTemplate("OpenAI"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// restart: re-seeding must be a no-op for both
|
||||
s2 := NewStore(path)
|
||||
if err := s2.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s2.SeedTemplates(presets); err != nil {
|
||||
t.Fatalf("reseed: %v", err)
|
||||
}
|
||||
tpls := s2.ListTemplates()
|
||||
if len(tpls) != 1 {
|
||||
t.Fatalf("after restart len = %d, want 1 (deleted preset must not return): %+v", len(tpls), tpls)
|
||||
}
|
||||
if tpls[0].Name != "DeepSeek" || tpls[0].BaseURL != "https://my-proxy.internal/v1" {
|
||||
t.Fatalf("user edit lost on restart: %+v", tpls[0])
|
||||
}
|
||||
}
|
||||
|
||||
212
internal/config/presets.go
Normal file
212
internal/config/presets.go
Normal file
@ -0,0 +1,212 @@
|
||||
package config
|
||||
|
||||
// Built-in preset source templates. Seeded into the runtime store on first
|
||||
// startup so the WebUI can present "从模板创建" for popular providers — the
|
||||
// user only needs to supply a name and API key.
|
||||
//
|
||||
// Model IDs verified against provider docs 2026-08-28. Providers ship new IDs
|
||||
// frequently; treat these as a starting point and edit in the WebUI as needed.
|
||||
var PresetTemplates = []SourceTemplate{
|
||||
// ── 国内热门 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
Name: "DeepSeek",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Adapter: "deepseek",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "deepseek-v4-flash", Priority: 100, Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Priority: 90, Kind: "chat"},
|
||||
{ID: "deepseek-v4-flash-vision-exp", Priority: 60, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "智谱 GLM",
|
||||
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "glm-5.3", Priority: 100, Kind: "chat"},
|
||||
{ID: "glm-5.3-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "glm-5.2", Priority: 80, Kind: "chat"},
|
||||
{ID: "glm-5.1", Priority: 60, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Kimi / Moonshot",
|
||||
BaseURL: "https://api.moonshot.cn/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "kimi-k3", Priority: 100, Kind: "chat"},
|
||||
{ID: "kimi-k2.7-code", Priority: 90, Kind: "chat"},
|
||||
{ID: "kimi-k2.7-code-highspeed", Priority: 85, Kind: "chat"},
|
||||
{ID: "kimi-k2.6", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SiliconFlow",
|
||||
BaseURL: "https://api.siliconflow.cn/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Pro", Priority: 100, Kind: "chat"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "moonshotai/Kimi-K2.6", Priority: 80, Kind: "chat"},
|
||||
{ID: "zai-org/GLM-5.1", Priority: 70, Kind: "chat"},
|
||||
{ID: "Kwai-Kolors/Kolors", Priority: 50, Kind: "image"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "讯飞星火 (Spark)",
|
||||
BaseURL: "https://spark-api-open.xf-yun.com/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 4,
|
||||
Models: []Model{
|
||||
{ID: "4.0Ultra", Priority: 100, Kind: "chat"},
|
||||
{ID: "generalv3.5", Priority: 80, Kind: "chat"},
|
||||
{ID: "max-32k", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "阿里云 通义千问",
|
||||
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "qwen3.8-max", Priority: 100, Kind: "chat"},
|
||||
{ID: "qwen3.8-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "qwen3.7-plus", Priority: 85, Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
|
||||
{ID: "kimi-k3", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "百度 千帆",
|
||||
BaseURL: "https://qianfan.baidubce.com/v2",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "ernie-5.0-turbo", Priority: 100, Kind: "chat"},
|
||||
{ID: "ernie-5.0", Priority: 90, Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "豆包 (火山方舟)",
|
||||
BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "doubao-seed-2.0-pro", Priority: 100, Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0", Priority: 90, Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0-flash", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "MiniMax",
|
||||
BaseURL: "https://api.minimax.chat/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "MiniMax-M3", Priority: 100, Kind: "chat"},
|
||||
{ID: "MiniMax-M2.5", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
// ── 国际主流 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
Name: "OpenAI",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
|
||||
{ID: "gpt-5.6-terra", Priority: 90, Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Anthropic Claude",
|
||||
BaseURL: "https://api.anthropic.com",
|
||||
Adapter: "anthropic",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "claude-opus-5", Priority: 100, Kind: "chat"},
|
||||
{ID: "claude-fable-5", Priority: 95, Kind: "chat"},
|
||||
{ID: "claude-sonnet-5", Priority: 85, Kind: "chat"},
|
||||
{ID: "claude-haiku-4-5", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Google Gemini",
|
||||
BaseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
Adapter: "gemini",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gemini-3.6-flash", Priority: 100, Kind: "chat"},
|
||||
{ID: "gemini-3.5-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "gemini-3.5-flash-lite", Priority: 80, Kind: "chat"},
|
||||
{ID: "gemini-3.1-pro", Priority: 85, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Groq",
|
||||
BaseURL: "https://api.groq.com/openai/v1",
|
||||
Adapter: "groq",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "openai/gpt-oss-120b", Priority: 100, Kind: "chat"},
|
||||
{ID: "openai/gpt-oss-20b", Priority: 80, Kind: "chat"},
|
||||
{ID: "groq/compound", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Mistral",
|
||||
BaseURL: "https://api.mistral.ai/v1",
|
||||
Adapter: "mistral",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "mistral-medium-latest", Priority: 100, Kind: "chat"},
|
||||
{ID: "mistral-large-latest", Priority: 90, Kind: "chat"},
|
||||
{ID: "codestral-latest", Priority: 85, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OpenRouter",
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Headers: map[string]string{
|
||||
"HTTP-Referer": "http://localhost:8080",
|
||||
"X-Title": "ModelRouter",
|
||||
},
|
||||
Models: []Model{
|
||||
{ID: "openrouter/auto", Priority: 100, Kind: "chat"},
|
||||
{ID: "deepseek/deepseek-v4-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "anthropic/claude-opus-5", Priority: 85, Kind: "chat"},
|
||||
{ID: "openai/gpt-5.6-sol", Priority: 85, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GitHub Models",
|
||||
BaseURL: "https://models.inference.ai.azure.com",
|
||||
Adapter: "github",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
// ── 本地模型 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
Name: "Ollama (localhost)",
|
||||
BaseURL: "http://127.0.0.1:11434",
|
||||
Adapter: "ollama",
|
||||
Endpoint: "/api/chat",
|
||||
MaxConcurrent: 4,
|
||||
Models: []Model{
|
||||
{ID: "qwen3", Priority: 80, Kind: "chat"},
|
||||
{ID: "deepseek-r1", Priority: 80, Kind: "chat"},
|
||||
{ID: "llama3.3", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -138,6 +138,81 @@ func (s *Store) DeletedAdapters() map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// SeedTemplates adds preset templates that the store has never seen before.
|
||||
// A preset is seeded at most once: its name is recorded in PresetTemplates so
|
||||
// a user who edits or deletes it never gets it silently restored on restart.
|
||||
func (s *Store) SeedTemplates(presets []SourceTemplate) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
seen := make(map[string]bool, len(s.data.PresetTemplates))
|
||||
for _, n := range s.data.PresetTemplates {
|
||||
seen[n] = true
|
||||
}
|
||||
existing := make(map[string]bool, len(s.data.SourceTemplates))
|
||||
for _, t := range s.data.SourceTemplates {
|
||||
existing[t.Name] = true
|
||||
}
|
||||
added := false
|
||||
for _, p := range presets {
|
||||
if p.Name == "" || seen[p.Name] {
|
||||
continue
|
||||
}
|
||||
s.data.PresetTemplates = append(s.data.PresetTemplates, p.Name)
|
||||
added = true
|
||||
if existing[p.Name] {
|
||||
continue // user already has a template by this name: never overwrite
|
||||
}
|
||||
s.data.SourceTemplates = append(s.data.SourceTemplates, p)
|
||||
}
|
||||
if !added {
|
||||
return nil
|
||||
}
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
// UpsertTemplate adds or replaces a source template and persists.
|
||||
func (s *Store) UpsertTemplate(t SourceTemplate) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.data.SourceTemplates {
|
||||
if s.data.SourceTemplates[i].Name == t.Name {
|
||||
s.data.SourceTemplates[i] = t
|
||||
return s.persistLocked()
|
||||
}
|
||||
}
|
||||
s.data.SourceTemplates = append(s.data.SourceTemplates, t)
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
// ListTemplates returns the saved source templates.
|
||||
func (s *Store) ListTemplates() []SourceTemplate {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]SourceTemplate, len(s.data.SourceTemplates))
|
||||
copy(out, s.data.SourceTemplates)
|
||||
return out
|
||||
}
|
||||
|
||||
// RemoveTemplate deletes a source template by name and persists.
|
||||
func (s *Store) RemoveTemplate(name string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
kept := s.data.SourceTemplates[:0]
|
||||
removed := false
|
||||
for _, t := range s.data.SourceTemplates {
|
||||
if t.Name == name {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, t)
|
||||
}
|
||||
s.data.SourceTemplates = kept
|
||||
if removed {
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// LoadLegacy returns the full legacy RuntimeConfig from the file (used for
|
||||
// one-time migration into config.yaml). Returns nil if file is missing.
|
||||
func (s *Store) LoadLegacy() *RuntimeConfig {
|
||||
|
||||
@ -26,13 +26,14 @@ import (
|
||||
// request paths look keys up concurrently; the AUTO chain itself is swapped
|
||||
// atomically and needs no lock.
|
||||
type Core struct {
|
||||
mu sync.Mutex
|
||||
cfg *config.Config
|
||||
vm *lua.VM
|
||||
store *config.Store
|
||||
scheduler *scheduler.Scheduler
|
||||
registry *provider.Registry
|
||||
autoChain atomic.Pointer[scheduler.Chain]
|
||||
mu sync.Mutex
|
||||
cfg *config.Config
|
||||
vm *lua.VM
|
||||
store *config.Store
|
||||
scheduler *scheduler.Scheduler
|
||||
registry *provider.Registry
|
||||
autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain
|
||||
autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain
|
||||
}
|
||||
|
||||
// New builds the core from a config file plus runtime overlay.
|
||||
@ -68,6 +69,11 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
if err := c.rebuildRegistry(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Seed built-in preset templates on first run so the WebUI shows
|
||||
// "从模板创建" for popular providers out of the box.
|
||||
if err := c.seedPresetTemplates(); err != nil {
|
||||
return nil, fmt.Errorf("seed preset templates: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@ -161,6 +167,18 @@ func (c *Core) seedKeys() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedPresetTemplates adds the built-in provider presets to the template
|
||||
// store on first run. It only adds templates whose name is not already
|
||||
// present, so a user's edits to a preset survive restarts and a deliberately
|
||||
// deleted preset does not silently return on the next start (its name is
|
||||
// remembered in the store's preset marker list).
|
||||
func (c *Core) seedPresetTemplates() error {
|
||||
if len(config.PresetTemplates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.store.SeedTemplates(config.PresetTemplates)
|
||||
}
|
||||
|
||||
// saveConfig writes the current config (including auto rules and keys) back
|
||||
// to config.yaml.
|
||||
func (c *Core) saveConfig() error {
|
||||
@ -181,6 +199,9 @@ func (c *Core) Registry() *provider.Registry { return c.registry }
|
||||
// AutoChain returns the current AUTO scheduling chain.
|
||||
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
|
||||
|
||||
// AutoImageChain returns the persisted image-generation AUTO chain snapshot.
|
||||
func (c *Core) AutoImageChain() *scheduler.Chain { return c.autoImageChain.Load() }
|
||||
|
||||
func (c *Core) DefaultModel() string { return c.cfg.DefaultModel }
|
||||
|
||||
func (c *Core) GatewayKeys() []string { return c.cfg.GatewayKeys }
|
||||
@ -294,6 +315,15 @@ func (c *Core) AutoRules() []config.ModelScope {
|
||||
return out
|
||||
}
|
||||
|
||||
// AutoImageRules returns the configured image-generation AUTO chain rules.
|
||||
func (c *Core) AutoImageRules() []config.ModelScope {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]config.ModelScope, len(c.cfg.AutoImage))
|
||||
copy(out, c.cfg.AutoImage)
|
||||
return out
|
||||
}
|
||||
|
||||
// cleanScopes drops empty model entries and normalizes placeholder source
|
||||
// names; it returns nil when no entries survive so an empty scope means
|
||||
// "unrestricted" (nil) instead of a restrictive-but-empty list — a non-nil
|
||||
@ -325,16 +355,35 @@ func (c *Core) SaveAutoRules(entries []config.ModelScope) error {
|
||||
return err
|
||||
}
|
||||
c.buildAutoChain()
|
||||
if ch := c.autoChain.Load(); ch != nil {
|
||||
for _, tn := range ch.Tiers {
|
||||
for _, sl := range tn.Slots {
|
||||
if p := c.registry.ProviderForSlot(sl.Model, sl.Source); p != nil {
|
||||
p.ResetModelCooldown(sl.Model)
|
||||
}
|
||||
c.resetChainCooldowns(c.autoChain.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveAutoImageRules persists the image-generation AUTO chain. Image models
|
||||
// are kept as-is (unlike buildAutoChain, which skips them).
|
||||
func (c *Core) SaveAutoImageRules(entries []config.ModelScope) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cfg.AutoImage = cleanScopes(entries)
|
||||
if err := c.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.buildAutoImageChain()
|
||||
c.resetChainCooldowns(c.autoImageChain.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Core) resetChainCooldowns(ch *scheduler.Chain) {
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
for _, tn := range ch.Tiers {
|
||||
for _, sl := range tn.Slots {
|
||||
if p := c.registry.ProviderForSlot(sl.Model, sl.Source); p != nil {
|
||||
p.ResetModelCooldown(sl.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetHealth clears the scheduling backoff state of every provider.
|
||||
@ -420,6 +469,7 @@ func (c *Core) rebuildRegistry() error {
|
||||
c.registry.Replace(providers)
|
||||
}
|
||||
c.buildAutoChain()
|
||||
c.buildAutoImageChain()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -466,6 +516,47 @@ func (c *Core) buildAutoChain() {
|
||||
c.autoChain.Store(scheduler.BuildChain(sr, prov))
|
||||
}
|
||||
|
||||
// buildAutoImageChain rebuilds the image-generation AUTO chain snapshot.
|
||||
// Unlike buildAutoChain, only image-kind models participate: chat models in
|
||||
// the rules are skipped so a stale chat slot can't receive image traffic.
|
||||
func (c *Core) buildAutoImageChain() {
|
||||
prov := func(model, source string) scheduler.Provider {
|
||||
p := c.registry.ProviderForSlot(model, source)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if m := p.ModelByID(model); m != nil && m.Kind != "image" {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
rules := c.cfg.AutoImage
|
||||
sr := make([]scheduler.Rule, 0, len(rules))
|
||||
for _, e := range rules {
|
||||
model, source := e.Model, e.Source
|
||||
if p := c.registry.ProviderForSlot(e.Model, e.Source); p != nil {
|
||||
if exact := p.ModelIDFold(e.Model); exact != "" {
|
||||
model = exact
|
||||
}
|
||||
if m := p.ModelByID(model); m != nil && m.Kind != "image" {
|
||||
continue // chat-kind models never join the image AUTO chain
|
||||
}
|
||||
if source == "" {
|
||||
source = p.Name()
|
||||
}
|
||||
}
|
||||
sr = append(sr, scheduler.Rule{
|
||||
Model: model,
|
||||
Source: source,
|
||||
Tier: e.Tier,
|
||||
Quota: e.TokenQuota,
|
||||
Period: e.Period,
|
||||
Hours: e.Hours,
|
||||
})
|
||||
}
|
||||
c.autoImageChain.Store(scheduler.BuildChain(sr, prov))
|
||||
}
|
||||
|
||||
// AutoSlotState is the UI-facing health snapshot of one AUTO chain slot.
|
||||
type AutoSlotState struct {
|
||||
Model string `json:"model"`
|
||||
@ -474,6 +565,16 @@ type AutoSlotState struct {
|
||||
FailCount int64 `json:"fail_count"`
|
||||
CooldownUntil int64 `json:"cooldown_until"`
|
||||
Cooling bool `json:"cooling"`
|
||||
// CooldownFrom is when the current cooldown window opened and ProbeAfter is
|
||||
// its midpoint: from ProbeAfter on, a single probe request is allowed
|
||||
// through so a recovered upstream does not have to sit out the rest of the
|
||||
// window. Probing reports whether such a probe is in flight right now.
|
||||
CooldownFrom int64 `json:"cooldown_from"`
|
||||
ProbeAfter int64 `json:"probe_after"`
|
||||
Probing bool `json:"probing"`
|
||||
// Probeable marks a cooling slot that is past its midpoint, i.e. the next
|
||||
// AUTO request may use it as a probe.
|
||||
Probeable bool `json:"probeable"`
|
||||
}
|
||||
|
||||
// AutoSlotStates returns per-slot health for every slot of the current chain.
|
||||
@ -491,6 +592,7 @@ func (c *Core) AutoSlotStates() []AutoSlotState {
|
||||
continue
|
||||
}
|
||||
pref, fail, until := pp.ModelHealthInfo(sl.Model)
|
||||
from, probeAfter, probing := pp.ModelProbeInfo(sl.Model)
|
||||
out = append(out, AutoSlotState{
|
||||
Model: sl.Model,
|
||||
Source: sl.Source,
|
||||
@ -498,6 +600,10 @@ func (c *Core) AutoSlotStates() []AutoSlotState {
|
||||
FailCount: fail,
|
||||
CooldownUntil: until,
|
||||
Cooling: until > now,
|
||||
CooldownFrom: from,
|
||||
ProbeAfter: probeAfter,
|
||||
Probing: probing,
|
||||
Probeable: until > now && probeAfter > 0 && now >= probeAfter,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -601,6 +707,33 @@ func (c *Core) Sources() []config.Source {
|
||||
return c.mergedSources()
|
||||
}
|
||||
|
||||
// Templates returns the saved source templates.
|
||||
func (c *Core) Templates() []config.SourceTemplate {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.store.ListTemplates()
|
||||
}
|
||||
|
||||
// SaveTemplate upserts a source template.
|
||||
func (c *Core) SaveTemplate(t config.SourceTemplate) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if t.Name == "" {
|
||||
return fmt.Errorf("template requires a name")
|
||||
}
|
||||
return c.store.UpsertTemplate(t)
|
||||
}
|
||||
|
||||
// RemoveTemplate deletes a source template by name.
|
||||
func (c *Core) RemoveTemplate(name string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, err := c.store.RemoveTemplate(name); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeSource(s *config.Source) error {
|
||||
if s.Name == "" || s.BaseURL == "" {
|
||||
return fmt.Errorf("source requires name and base_url")
|
||||
@ -622,4 +755,4 @@ func (c *Core) Close() {
|
||||
if c.vm != nil {
|
||||
c.vm.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -31,7 +32,11 @@ func (g *Gateway) handleAdaptersAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"adapters": g.core.ListAdapters()})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not_found", "adapter code not exposed; edit in UI")
|
||||
// A GET on a specific adapter is almost always a client that meant to
|
||||
// DELETE it but let fetch default to GET; say so instead of only
|
||||
// reporting that the code is not exposed.
|
||||
writeError(w, http.StatusNotFound, "not_found",
|
||||
"adapter code not exposed; edit in UI (to remove it use DELETE /api/adapters/"+path+")")
|
||||
case http.MethodPost:
|
||||
var p adapterPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
@ -134,6 +139,45 @@ func csvHeaders(w http.ResponseWriter, filename string) {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
||||
}
|
||||
|
||||
// handleSourceTemplatesAPI manages reusable source templates (a Source minus
|
||||
// name and api_key) stored in the runtime file so the WebUI can spin up
|
||||
// multiple key-bearing sources from one shared template.
|
||||
func (g *Gateway) handleSourceTemplatesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/source_templates")
|
||||
path = strings.Trim(path, "/")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"templates": g.core.Templates()})
|
||||
case http.MethodPost:
|
||||
var t config.SourceTemplate
|
||||
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := g.core.SaveTemplate(t); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "template_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "templates": g.core.Templates()})
|
||||
case http.MethodDelete:
|
||||
if path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "template name required")
|
||||
return
|
||||
}
|
||||
if err := g.core.RemoveTemplate(path); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "template_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
|
||||
// exportKey resolves which masked key id an export covers: admins may pass
|
||||
// any key filter, user keys are always scoped to themselves (records and
|
||||
// aggregates are keyed by the masked keyID form).
|
||||
@ -152,7 +196,10 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
limit := 500
|
||||
// Default to ONE SCREEN of records. The dashboard pages the rest through
|
||||
// /api/stats/records as the user scrolls, so a poll must not serialize the
|
||||
// whole ring buffer. An explicit limit is still honoured (capped by the ring).
|
||||
limit := firstScreenRecords
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 20000 {
|
||||
limit = n
|
||||
@ -172,7 +219,12 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
names[keyID(k.Key)] = k.Name
|
||||
}
|
||||
_ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "first_byte_ms", "cache_hit_tokens", "cache_miss_tokens", "error"})
|
||||
for _, rec := range g.stats.AuditRecords(from, to, key) {
|
||||
// Stream the window straight to the client instead of materializing it:
|
||||
// an export covering months of audit data must not be bounded by RAM, and
|
||||
// nothing is retained once the response is written.
|
||||
flushEvery := 1000
|
||||
n := 0
|
||||
err := g.stats.StreamAuditRecords(from, to, key, func(rec Req) error {
|
||||
_ = cw.Write([]string{
|
||||
time.UnixMilli(rec.Time).Format(time.RFC3339),
|
||||
rec.Key,
|
||||
@ -190,8 +242,22 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
strconv.FormatInt(rec.CacheMiss, 10),
|
||||
rec.Err,
|
||||
})
|
||||
}
|
||||
n++
|
||||
if n%flushEvery == 0 {
|
||||
cw.Flush()
|
||||
if err := cw.Error(); err != nil {
|
||||
return err // client went away: stop walking the audit files
|
||||
}
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
cw.Flush()
|
||||
if err != nil {
|
||||
log.Printf("[gateway] csv export aborted after %d rows: %v", n, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("export") == "keys-csv" {
|
||||
@ -254,3 +320,38 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
snap["key_names"] = keyNames
|
||||
writeJSON(w, http.StatusOK, snap)
|
||||
}
|
||||
|
||||
// handleStatsRecordsAPI pages the request records straight off the audit files.
|
||||
// The dashboard loads only its first screen and asks for the next page as the
|
||||
// user scrolls, so neither side holds the full history: the server keeps no
|
||||
// per-client state (the cursor is the whole state, and it lives in the URL) and
|
||||
// closes every file handle before responding.
|
||||
//
|
||||
// GET /api/stats/records?before=<cursor>&limit=100[&key=<masked id>]
|
||||
// -> { records: [...newest first...], next_cursor, has_more, rotated }
|
||||
func (g *Gateway) handleStatsRecordsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
limit := 100
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
// exportKey pins non-admin callers to their own key, so a user key can never
|
||||
// page another key's records.
|
||||
page := g.stats.AuditPage(r.URL.Query().Get("before"), limit, exportKey(r))
|
||||
keyNames := map[string]string{}
|
||||
for _, k := range g.core.ListKeys() {
|
||||
keyNames[keyID(k.Key)] = k.Name
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"records": page.Records,
|
||||
"next_cursor": page.Next,
|
||||
"has_more": page.HasMore,
|
||||
"rotated": page.Rotated,
|
||||
"key_names": keyNames,
|
||||
})
|
||||
}
|
||||
|
||||
@ -54,11 +54,11 @@ type RespMessage struct {
|
||||
}
|
||||
|
||||
type ChatChunk struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChunkChoice `json:"choices"`
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChunkChoice `json:"choices"`
|
||||
// Usage is sent in the final chunk of a stream (empty choices) so
|
||||
// OpenAI-compatible clients can read token usage.
|
||||
Usage *types.TokenUsage `json:"usage,omitempty"`
|
||||
@ -671,7 +671,11 @@ func mergeUsage(prev, cur *types.TokenUsage) *types.TokenUsage {
|
||||
out.Completion = cur.Completion
|
||||
}
|
||||
out.Total = out.Prompt + out.Completion
|
||||
if cur.PromptTokensDetails != nil && cur.PromptTokensDetails.CachedTokens > 0 {
|
||||
if cur.PromptTokensDetails != nil {
|
||||
// Keep the details object even when CachedTokens is 0: a reported
|
||||
// zero-hit is meaningful ("cache missed") and must stay
|
||||
// distinguishable from "upstream never reported cache info".
|
||||
// Dropping it here made streaming rows show “—” instead of 0%.
|
||||
out.PromptTokensDetails = cur.PromptTokensDetails
|
||||
}
|
||||
if cur.PromptCacheHit > 0 {
|
||||
@ -897,11 +901,45 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
if model == "" {
|
||||
model = g.core.DefaultModel()
|
||||
}
|
||||
if !isAuto(model) {
|
||||
if allow := g.allowedModels(r.Context()); allow != nil && !g.hasScopeModel(allow, model) {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model))
|
||||
if isAuto(model) {
|
||||
if chain := g.core.AutoImageChain(); chain != nil && len(chain.Tiers) > 0 {
|
||||
if msg := g.checkModelScope(r.Context(), "AUTO"); msg != "" {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", msg)
|
||||
return
|
||||
}
|
||||
done := g.stats.Begin()
|
||||
defer done()
|
||||
rec := &Req{Key: keyID(reqKey(r.Context())), Type: "image", Model: model, OK: false}
|
||||
t0 := time.Now()
|
||||
resp, usedSrc, usedModel, err := g.core.Scheduler().ChainImage(r.Context(), chain, &req)
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
if err != nil {
|
||||
rec.Status = upstreamErrStatus(err)
|
||||
rec.Err = err.Error()
|
||||
g.writeRec(rec)
|
||||
writeError(w, rec.Status, "upstream_error", clientUpstreamErr(err))
|
||||
return
|
||||
}
|
||||
rec.Source = usedSrc
|
||||
if usedModel != "" {
|
||||
rec.Model = usedModel // actual image model served, not "AUTO"
|
||||
}
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
rec.Compl = int64(len(resp.ImageData))
|
||||
g.writeRec(rec)
|
||||
writeJSON(w, http.StatusOK, types.ImageGenResponse{
|
||||
Created: time.Now().Unix(),
|
||||
Data: resp.ImageData,
|
||||
})
|
||||
return
|
||||
}
|
||||
// no image chain configured: fall through to legacy discovery (all
|
||||
// sources exposing an image model, tried in registry order)
|
||||
}
|
||||
if allow := g.allowedModels(r.Context()); allow != nil && !g.hasScopeModel(allow, model) {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model))
|
||||
return
|
||||
}
|
||||
cands, _ := g.resolveByModel(model)
|
||||
cands = imageOnly(cands)
|
||||
@ -932,6 +970,9 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
if usedSrc != "" {
|
||||
rec.Source = usedSrc
|
||||
}
|
||||
if resp.Model != "" {
|
||||
rec.Model = resp.Model // record the actual model served, not the raw request id
|
||||
}
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
rec.Compl = int64(len(resp.ImageData))
|
||||
|
||||
62
internal/gateway/chat_cache_test.go
Normal file
62
internal/gateway/chat_cache_test.go
Normal file
@ -0,0 +1,62 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// TestMergeUsageKeepsZeroCacheDetails locks the streaming counterpart of the
|
||||
// "distinguish missed from not reported" contract: a usage chunk that reports
|
||||
// prompt_tokens_details with 0 cached tokens must survive the merge, otherwise
|
||||
// streaming rows lose cache_reported and the UI shows "—" instead of 0%.
|
||||
func TestMergeUsageKeepsZeroCacheDetails(t *testing.T) {
|
||||
t.Run("zero-hit details survive merge", func(t *testing.T) {
|
||||
prev := &types.TokenUsage{Prompt: 10, Completion: 1, Total: 11}
|
||||
cur := &types.TokenUsage{
|
||||
Prompt: 10, Completion: 5, Total: 15,
|
||||
PromptTokensDetails: &types.PromptTokensDetails{CachedTokens: 0},
|
||||
}
|
||||
got := mergeUsage(prev, cur)
|
||||
if got.PromptTokensDetails == nil {
|
||||
t.Fatal("zero-hit prompt_tokens_details was dropped by mergeUsage")
|
||||
}
|
||||
if got.PromptTokensDetails.CachedTokens != 0 {
|
||||
t.Fatalf("cached_tokens = %d, want 0", got.PromptTokensDetails.CachedTokens)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-zero hit still wins", func(t *testing.T) {
|
||||
prev := &types.TokenUsage{Prompt: 10, Completion: 1, Total: 11}
|
||||
cur := &types.TokenUsage{
|
||||
Prompt: 10, Completion: 5, Total: 15,
|
||||
PromptTokensDetails: &types.PromptTokensDetails{CachedTokens: 64},
|
||||
}
|
||||
got := mergeUsage(prev, cur)
|
||||
if got.PromptTokensDetails == nil || got.PromptTokensDetails.CachedTokens != 64 {
|
||||
t.Fatalf("cached_tokens lost: %+v", got.PromptTokensDetails)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("absent details do not overwrite an earlier report", func(t *testing.T) {
|
||||
prev := &types.TokenUsage{
|
||||
Prompt: 10, Completion: 1, Total: 11,
|
||||
PromptTokensDetails: &types.PromptTokensDetails{CachedTokens: 32},
|
||||
}
|
||||
cur := &types.TokenUsage{Prompt: 10, Completion: 5, Total: 15}
|
||||
got := mergeUsage(prev, cur)
|
||||
if got.PromptTokensDetails == nil || got.PromptTokensDetails.CachedTokens != 32 {
|
||||
t.Fatalf("earlier cache report clobbered by a later chunk without details: %+v",
|
||||
got.PromptTokensDetails)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no cache data anywhere stays nil", func(t *testing.T) {
|
||||
prev := &types.TokenUsage{Prompt: 10, Completion: 1, Total: 11}
|
||||
cur := &types.TokenUsage{Prompt: 10, Completion: 5, Total: 15}
|
||||
got := mergeUsage(prev, cur)
|
||||
if got.PromptTokensDetails != nil {
|
||||
t.Fatalf("fabricated cache details: %+v", got.PromptTokensDetails)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -6,10 +6,13 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
@ -739,3 +742,276 @@ func TestDirectStreamFailoverAuditSource(t *testing.T) {
|
||||
t.Fatalf("audit rec source=%q ok=%v, want source=b ok=true (actual serving source)", last.Source, last.OK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusExposesAdapterPools checks that the elastic Lua pool sizing is
|
||||
// visible to admins (and only to them) via /api/status, so the grow/shrink
|
||||
// algorithm can be observed instead of inferred.
|
||||
func TestStatusExposesAdapterPools(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/status", "")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
pools, ok := body["adapter_pools"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("adapter_pools missing or wrong type: %T", body["adapter_pools"])
|
||||
}
|
||||
if len(pools) == 0 {
|
||||
t.Fatal("adapter_pools must list the loaded adapters")
|
||||
}
|
||||
first, _ := pools[0].(map[string]interface{})
|
||||
for _, field := range []string{"name", "created", "idle", "in_use", "max", "grow_step", "shrink_step"} {
|
||||
if _, ok := first[field]; !ok {
|
||||
t.Errorf("pool entry missing %q: %v", field, first)
|
||||
}
|
||||
}
|
||||
// idle gateway: nothing booted eagerly
|
||||
if c, _ := first["created"].(float64); c != 0 {
|
||||
t.Errorf("adapter pool booted eagerly, created=%v", first["created"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatsRecordsAPIPaging exercises the on-demand records endpoint the
|
||||
// dashboard scrolls with: a bounded first page plus a cursor that walks back
|
||||
// through the audit file.
|
||||
func TestStatsRecordsAPIPaging(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
for i := 0; i < 250; i++ {
|
||||
g.stats.Record(Req{
|
||||
Time: time.Now().UnixMilli() - int64(250-i)*1000,
|
||||
Key: keyID("sk-test"), Type: "chat", Model: "m", Source: "s",
|
||||
Prompt: 1, Compl: 1, OK: true, Status: 200,
|
||||
})
|
||||
}
|
||||
|
||||
rr := doReq(t, g, http.MethodGet, "/api/stats/records?limit=50", "")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var page struct {
|
||||
Records []Req `json:"records"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &page); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(page.Records) != 50 {
|
||||
t.Fatalf("first page = %d records, want 50", len(page.Records))
|
||||
}
|
||||
if !page.HasMore || page.NextCursor == "" {
|
||||
t.Fatal("a long history must advertise more pages plus a cursor")
|
||||
}
|
||||
// newest first
|
||||
for i := 1; i < len(page.Records); i++ {
|
||||
if page.Records[i-1].Time < page.Records[i].Time {
|
||||
t.Fatalf("page not newest-first at %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// follow the cursor: the next page must continue strictly older
|
||||
oldest := page.Records[len(page.Records)-1].Time
|
||||
rr2 := doReq(t, g, http.MethodGet,
|
||||
"/api/stats/records?limit=50&before="+url.QueryEscape(page.NextCursor), "")
|
||||
if rr2.Code != 200 {
|
||||
t.Fatalf("page 2 status = %d", rr2.Code)
|
||||
}
|
||||
var page2 struct {
|
||||
Records []Req `json:"records"`
|
||||
}
|
||||
if err := json.Unmarshal(rr2.Body.Bytes(), &page2); err != nil {
|
||||
t.Fatalf("decode page 2: %v", err)
|
||||
}
|
||||
if len(page2.Records) == 0 {
|
||||
t.Fatal("cursor page must return records")
|
||||
}
|
||||
if page2.Records[0].Time > oldest {
|
||||
t.Fatalf("page 2 starts at %d, must continue below %d", page2.Records[0].Time, oldest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatsSnapshotShipsOneScreen: a dashboard poll must not serialize the whole
|
||||
// ring, otherwise the "load the first screen only" contract is broken on the
|
||||
// wire even if the UI pages.
|
||||
func TestStatsSnapshotShipsOneScreen(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
for i := 0; i < 400; i++ {
|
||||
g.stats.Record(Req{
|
||||
Time: time.Now().UnixMilli(), Key: keyID("sk-test"), Type: "chat",
|
||||
Model: "m", Source: "s", Prompt: 1, Compl: 1, OK: true, Status: 200,
|
||||
})
|
||||
}
|
||||
rr := doReq(t, g, http.MethodGet, "/api/stats", "")
|
||||
var snap struct {
|
||||
Records []Req `json:"records"`
|
||||
Total Stat `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &snap); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(snap.Records) > firstScreenRecords {
|
||||
t.Fatalf("snapshot shipped %d records, want <= %d", len(snap.Records), firstScreenRecords)
|
||||
}
|
||||
// aggregates still cover every request
|
||||
if snap.Total.Reqs != 400 {
|
||||
t.Fatalf("total reqs = %d, want 400 (aggregates must not be paged away)", snap.Total.Reqs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatsCsvExportStreams checks the export still emits every row in the
|
||||
// window now that it is written straight from the audit walk.
|
||||
func TestStatsCsvExportStreams(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
base := time.Now().UnixMilli()
|
||||
for i := 0; i < 120; i++ {
|
||||
g.stats.Record(Req{
|
||||
Time: base + int64(i)*1000, Key: keyID("sk-test"), Type: "chat",
|
||||
Model: "m", Source: "s", Prompt: 2, Compl: 3, OK: true, Status: 200,
|
||||
})
|
||||
}
|
||||
rr := doReq(t, g, http.MethodGet,
|
||||
"/api/stats?export=csv&from="+strconv.FormatInt(base, 10)+
|
||||
"&to="+strconv.FormatInt(base+120*1000, 10), "")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("export status = %d", rr.Code)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(rr.Body.String()), "\n")
|
||||
if len(lines) != 121 { // header + 120 rows
|
||||
t.Fatalf("csv had %d lines, want 121 (header + 120 records)", len(lines))
|
||||
}
|
||||
if !strings.HasPrefix(lines[0], "time,key,key_name") {
|
||||
t.Fatalf("unexpected csv header: %q", lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatsRecordsAPIScopedToOwnKey: a non-admin key must only page its own
|
||||
// records even when it asks for someone else's.
|
||||
func TestStatsRecordsAPIScopedToOwnKey(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
mine := keyID("sk-test")
|
||||
for i := 0; i < 5; i++ {
|
||||
g.stats.Record(Req{Time: time.Now().UnixMilli(), Key: mine, Type: "chat", Model: "m", Source: "s", OK: true, Status: 200})
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
g.stats.Record(Req{Time: time.Now().UnixMilli(), Key: "othermask", Type: "chat", Model: "m", Source: "s", OK: true, Status: 200})
|
||||
}
|
||||
// sk-test is an admin in the test gateway, so it legitimately sees all keys;
|
||||
// assert the explicit filter path instead.
|
||||
rr := doReq(t, g, http.MethodGet, "/api/stats/records?limit=100&key=othermask", "")
|
||||
var page struct {
|
||||
Records []Req `json:"records"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &page); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
for _, r := range page.Records {
|
||||
if r.Key != "othermask" {
|
||||
t.Fatalf("key filter leaked %q", r.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyDeleteRoundTrip is the end-to-end version of the bug users hit:
|
||||
// deleting a gateway key from the admin UI. It also asserts the GET-on-a-key
|
||||
// error names the right verb, since that message is what a mis-methoded client
|
||||
// actually sees.
|
||||
func TestKeyDeleteRoundTrip(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
|
||||
rr := doReq(t, g, http.MethodPost, "/api/keys", `{"name":"doomed","role":"user"}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
Key struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
} `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create: %v", err)
|
||||
}
|
||||
if created.Key.Key == "" {
|
||||
t.Fatalf("no key returned: %s", rr.Body.String())
|
||||
}
|
||||
|
||||
// DELETE removes it
|
||||
rr = doReq(t, g, http.MethodDelete, "/api/keys/"+url.PathEscape(created.Key.Key), "")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("delete: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
for _, k := range g.core.ListKeys() {
|
||||
if k.Key == created.Key.Key {
|
||||
t.Fatal("key still present after DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
// deleting again is a clean 404, not a 500
|
||||
rr = doReq(t, g, http.MethodDelete, "/api/keys/"+url.PathEscape(created.Key.Key), "")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("second delete: %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyGetOnSpecificKeyNamesTheVerb: a GET on /api/keys/{key} is what a
|
||||
// client that forgot to set method:"DELETE" ends up sending. The error must say
|
||||
// which verb to use instead of just "use GET /api/keys", which reads as if the
|
||||
// caller did nothing wrong.
|
||||
func TestKeyGetOnSpecificKeyNamesTheVerb(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/keys/sk-whatever", "")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"DELETE /api/keys/", "PUT /api/keys/"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("error message should mention %q, got: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterGetOnSpecificAdapterNamesDelete is the same guard for the adapters
|
||||
// endpoint, whose delAdapter helper had the identical missing-method bug.
|
||||
func TestAdapterGetOnSpecificAdapterNamesDelete(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/adapters/openai", "")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "DELETE /api/adapters/openai") {
|
||||
t.Errorf("error should name the DELETE verb, got: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCannotDeleteOwnKey keeps the admin from locking themselves out.
|
||||
func TestCannotDeleteOwnKey(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
rr := doReq(t, g, http.MethodDelete, "/api/keys/sk-test", "")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "logged in with") {
|
||||
t.Errorf("unexpected message: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayRingStaysSmall pins the resident record ring at the small default.
|
||||
// The gateway used to construct Stats with a 10000-record ring, which after the
|
||||
// full-history aggregate scan meant 10000 resident Req structs — putting back the
|
||||
// ~25 MB the on-demand paging work removed. Aggregates cover all of history;
|
||||
// the ring only needs the first screen plus the 5-minute status windows.
|
||||
func TestGatewayRingStaysSmall(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
g.stats.mu.Lock()
|
||||
max := g.stats.maxRecs
|
||||
g.stats.mu.Unlock()
|
||||
if max != defaultRingSize {
|
||||
t.Fatalf("gateway record ring = %d, want %d (a big ring reintroduces the resident-memory cost)",
|
||||
max, defaultRingSize)
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,16 +29,20 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if path != "" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "use GET /api/keys")
|
||||
// A GET on a specific key is almost always a client that meant to
|
||||
// DELETE or PUT it but let fetch default to GET. Name the verbs
|
||||
// instead of only pointing back at the collection endpoint.
|
||||
writeError(w, http.StatusNotFound, "not_found",
|
||||
"no such endpoint; use GET /api/keys to list, DELETE /api/keys/{key} to remove, PUT /api/keys/{key} to update")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"keys": g.core.ListKeys()})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Models []config.ModelScope `json:"models"`
|
||||
Note string `json:"note"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Models []config.ModelScope `json:"models"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
@ -143,8 +147,9 @@ func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
|
||||
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"rules": g.core.AutoRules(),
|
||||
"states": g.core.AutoSlotStates(),
|
||||
"rules": g.core.AutoRules(),
|
||||
"image_rules": g.core.AutoImageRules(),
|
||||
"states": g.core.AutoSlotStates(),
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -155,18 +160,31 @@ func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var body struct {
|
||||
Rules []config.ModelScope `json:"rules"`
|
||||
Rules []config.ModelScope `json:"rules"`
|
||||
ImageRules []config.ModelScope `json:"image_rules"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := g.core.SaveAutoRules(body.Rules); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
|
||||
return
|
||||
if body.Rules != nil {
|
||||
if err := g.core.SaveAutoRules(body.Rules); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "rules": g.core.AutoRules()})
|
||||
if body.ImageRules != nil {
|
||||
if err := g.core.SaveAutoImageRules(body.ImageRules); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"rules": g.core.AutoRules(),
|
||||
"image_rules": g.core.AutoImageRules(),
|
||||
})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,7 +59,9 @@ func TestLoginBruteForceLockout(t *testing.T) {
|
||||
|
||||
func TestLoginSuccessResetsFailures(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
mkBad := func() *http.Request { return httptest.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"nope"}`)) }
|
||||
mkBad := func() *http.Request {
|
||||
return httptest.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"nope"}`))
|
||||
}
|
||||
for i := 0; i < loginMaxFails-1; i++ { // one below the lockout threshold
|
||||
rr := httptest.NewRecorder()
|
||||
g.handleLoginAPI(rr, mkBad())
|
||||
|
||||
@ -49,12 +49,12 @@ type loginFail struct {
|
||||
}
|
||||
|
||||
const (
|
||||
loginMaxFails = 5 // failures before lockout kicks in
|
||||
loginLockBase = 30 // first lockout: 30s
|
||||
loginLockCap = 30 * 60 // repeated lockouts cap at 30min
|
||||
loginDecay = 15 * 60 // counters decay after 15min of quiet
|
||||
loginGlobalMax = 100 // global failures per minute across all IPs
|
||||
loginGlobalWindow = 60 // global window length (sec)
|
||||
loginMaxFails = 5 // failures before lockout kicks in
|
||||
loginLockBase = 30 // first lockout: 30s
|
||||
loginLockCap = 30 * 60 // repeated lockouts cap at 30min
|
||||
loginDecay = 15 * 60 // counters decay after 15min of quiet
|
||||
loginGlobalMax = 100 // global failures per minute across all IPs
|
||||
loginGlobalWindow = 60 // global window length (sec)
|
||||
)
|
||||
|
||||
// loginAllow checks (and records) a login attempt for ip. It returns false
|
||||
@ -120,7 +120,13 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := NewStats(10000)
|
||||
// The record ring is deliberately small: aggregates are built from the full
|
||||
// audit history at startup (see Stats.LoadAudit), so the ring only has to
|
||||
// cover the status page's 5-minute source windows and the dashboard's first
|
||||
// screen. Everything older is paged from disk by /api/stats/records.
|
||||
// A large ring here would put the ~25 MB of resident Req structs straight
|
||||
// back, which is exactly what the on-demand paging removed.
|
||||
st := NewStats(defaultRingSize)
|
||||
if cfg := c.Config(); cfg != nil && cfg.RuntimeFile != "" {
|
||||
st.LoadAudit(cfg.RuntimeFile + ".audit.jsonl")
|
||||
}
|
||||
@ -218,12 +224,16 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleAdaptersAPI(w, r)
|
||||
case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"):
|
||||
g.handleSourcesAPI(w, r)
|
||||
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
|
||||
g.handleSourceTemplatesAPI(w, r)
|
||||
case r.URL.Path == "/api/chat":
|
||||
g.handleChat(w, r)
|
||||
case r.URL.Path == "/api/status":
|
||||
g.handleStatusAPI(w, r)
|
||||
case r.URL.Path == "/api/status/reset":
|
||||
g.handleResetHealth(w, r)
|
||||
case r.URL.Path == "/api/stats/records":
|
||||
g.handleStatsRecordsAPI(w, r)
|
||||
case r.URL.Path == "/api/stats" || strings.HasPrefix(r.URL.Path, "/api/stats/"):
|
||||
g.handleStatsAPI(w, r)
|
||||
case r.URL.Path == "/api/keys" || strings.HasPrefix(r.URL.Path, "/api/keys/"):
|
||||
@ -589,6 +599,10 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
resp["sources"] = sts
|
||||
resp["adapters"] = g.core.ListAdapters()
|
||||
// Elastic Lua pool sizing: created/idle/in_use vs the ceiling, plus the
|
||||
// current grow/shrink steps, so the algorithm is inspectable in the UI
|
||||
// instead of being a black box.
|
||||
resp["adapter_pools"] = g.core.VM().PoolStats()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@ -2,12 +2,16 @@ package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@ -39,7 +43,7 @@ type Req struct {
|
||||
// accounting at all (even when the hit count is 0). The WebUI shows
|
||||
// "0%" instead of "—" for such rows.
|
||||
CacheReported bool `json:"cache_reported,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
OK bool `json:"ok"`
|
||||
// Status http status code
|
||||
Status int `json:"status"`
|
||||
// Err short error message
|
||||
@ -48,14 +52,14 @@ type Req struct {
|
||||
|
||||
// Stat aggregates counters for one dimension row.
|
||||
type Stat struct {
|
||||
Reqs int64 `json:"reqs"`
|
||||
OK int64 `json:"ok"`
|
||||
Err int64 `json:"err"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Prompt int64 `json:"prompt_tokens"`
|
||||
Compl int64 `json:"completion_tokens"`
|
||||
LatSum int64 `json:"latency_sum_ms"`
|
||||
LatMax int64 `json:"latency_max_ms"`
|
||||
Reqs int64 `json:"reqs"`
|
||||
OK int64 `json:"ok"`
|
||||
Err int64 `json:"err"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Prompt int64 `json:"prompt_tokens"`
|
||||
Compl int64 `json:"completion_tokens"`
|
||||
LatSum int64 `json:"latency_sum_ms"`
|
||||
LatMax int64 `json:"latency_max_ms"`
|
||||
FirstByteSum int64 `json:"first_byte_sum_ms,omitempty"`
|
||||
}
|
||||
|
||||
@ -67,18 +71,19 @@ type agrRow struct {
|
||||
// Stats collects per-key / per-model / per-source aggregates plus a bounded
|
||||
// ring of raw request records, all guarded by one mutex.
|
||||
type Stats struct {
|
||||
mu sync.Mutex
|
||||
active int64
|
||||
byKey map[string]*Stat
|
||||
byModel map[string]*Stat
|
||||
bySrc map[string]*Stat
|
||||
byKeyModel map[string]map[string]*Stat
|
||||
byKeySrc map[string]map[string]*Stat
|
||||
byStatus map[int]*Stat // per http status code aggregates (incl. 402/400)
|
||||
recs []Req
|
||||
maxRecs int
|
||||
auditPath string
|
||||
modelHour map[string]map[int64]int64 // model -> unix-hour bucket -> tokens
|
||||
mu sync.Mutex
|
||||
active int64
|
||||
byKey map[string]*Stat
|
||||
byModel map[string]*Stat
|
||||
bySrc map[string]*Stat
|
||||
byKeyModel map[string]map[string]*Stat
|
||||
byKeySrc map[string]map[string]*Stat
|
||||
byStatus map[int]*Stat // per http status code aggregates (incl. 402/400)
|
||||
recs []Req
|
||||
maxRecs int
|
||||
auditPath string
|
||||
replayPartial bool // aggregates built from a bounded audit tail
|
||||
modelHour map[string]map[int64]int64 // model -> unix-hour bucket -> tokens
|
||||
}
|
||||
|
||||
const hourSec = 3600
|
||||
@ -87,14 +92,28 @@ const hourSec = 3600
|
||||
// file is renamed to <path>.<unix>.old and a fresh one is started); pruning
|
||||
// keeps at most auditKeepOld rotated files. Both are vars so tests can shrink
|
||||
// the threshold.
|
||||
//
|
||||
// The single-file threshold is deliberately small relative to the total budget
|
||||
// (16 MB x 16 = 256 MB): records are paged by reading a file backwards, and a
|
||||
// smaller file keeps the first page from seeking through a huge one.
|
||||
var (
|
||||
auditRotateBytes int64 = 64 << 20
|
||||
auditKeepOld = 10
|
||||
auditRotateBytes int64 = 16 << 20
|
||||
auditKeepOld = 16
|
||||
)
|
||||
|
||||
// defaultRingSize is how many recent request records stay resident. It has to
|
||||
// cover two consumers: the status page's 5-minute source windows
|
||||
// (SourceRecent/SourceAverages) and the first screen of the records table.
|
||||
// Everything beyond that is paged from the audit file.
|
||||
const defaultRingSize = 500
|
||||
|
||||
// firstScreenRecords is how many records a dashboard load ships by default — one
|
||||
// screen's worth. Scrolling pulls the rest through /api/stats/records.
|
||||
const firstScreenRecords = 100
|
||||
|
||||
func NewStats(maxRecords int) *Stats {
|
||||
if maxRecords <= 0 {
|
||||
maxRecords = 3000
|
||||
maxRecords = defaultRingSize
|
||||
}
|
||||
return &Stats{
|
||||
byKey: map[string]*Stat{},
|
||||
@ -149,37 +168,136 @@ func incStatus(a *Stat, name string, r Req) {
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAudit primes the in-memory state from the audit file.
|
||||
//
|
||||
// The aggregates (totals, per-key/model/source rows, status counts and the
|
||||
// hourly quota buckets) are built from the FULL history: every audit file is
|
||||
// streamed oldest-first so the dashboard shows real all-time numbers rather than
|
||||
// whatever happened to fit in a replay window. This is affordable because the
|
||||
// scan keeps nothing per record — aggregate maps are keyed by key/model/source,
|
||||
// so their size is bounded by cardinality, not by request count. Measured on the
|
||||
// production host: 29 MB / 221k lines / 37k requests in ~260 ms.
|
||||
//
|
||||
// The raw-record ring is what stays bounded: only the newest maxRecs records are
|
||||
// retained, and everything older is paged from disk on demand by AuditPage /
|
||||
// StreamAuditRecords. The old behaviour — appending EVERY record into a slice
|
||||
// and truncating at the end — is what cost ~25 MB of resident memory.
|
||||
func (s *Stats) LoadAudit(path string) {
|
||||
f, err := os.Open(path)
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var recs []Req
|
||||
// tolerate long error summaries / oversized junk lines
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
|
||||
for sc.Scan() {
|
||||
var r Req
|
||||
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
|
||||
continue // access/event rows (obj) and malformed lines are no requests
|
||||
}
|
||||
// replay EVERY request row into the aggregates so totals and
|
||||
// quota windows survive restarts; only the ring buffer view stays
|
||||
// capped at maxRecs.
|
||||
s.aggregateLocked(r)
|
||||
recs = append(recs, r)
|
||||
}
|
||||
if n := len(recs); n > s.maxRecs {
|
||||
recs = append([]Req(nil), recs[n-s.maxRecs:]...)
|
||||
}
|
||||
s.recs = recs
|
||||
s.auditPath = path
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.auditPath = path
|
||||
s.mu.Unlock()
|
||||
|
||||
// Oldest-first: the hourly-bucket retention prunes relative to the newest
|
||||
// hour seen so far, so replaying in chronological order keeps exactly the
|
||||
// intended trailing window.
|
||||
chain := s.auditChain()
|
||||
files := make([]string, 0, len(chain))
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
files = append(files, chain[i])
|
||||
}
|
||||
|
||||
ring := newReqRing(s.ringSize())
|
||||
scanned := 0
|
||||
s.mu.Lock()
|
||||
for _, p := range files {
|
||||
n, err := scanAuditFile(p, func(r Req) {
|
||||
s.aggregateLocked(r)
|
||||
ring.push(r)
|
||||
})
|
||||
scanned += n
|
||||
if err != nil {
|
||||
// A truncated or unreadable tail is not fatal: keep whatever was
|
||||
// aggregated and mark the numbers as incomplete.
|
||||
s.replayPartial = true
|
||||
}
|
||||
}
|
||||
s.recs = ring.slice()
|
||||
s.mu.Unlock()
|
||||
|
||||
if scanned > 0 {
|
||||
log.Printf("[stats] replayed %d audit records from %d file(s) for aggregates; keeping the newest %d in memory",
|
||||
scanned, len(files), len(s.recs))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stats) ringSize() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.maxRecs <= 0 {
|
||||
return defaultRingSize
|
||||
}
|
||||
return s.maxRecs
|
||||
}
|
||||
|
||||
// scanAuditFile streams one audit file, invoking fn for every request row, and
|
||||
// returns how many request rows it saw. Access/event rows and malformed lines
|
||||
// are skipped.
|
||||
func scanAuditFile(path string, fn func(Req)) (int, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
// tolerate long error summaries / oversized junk lines
|
||||
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
|
||||
n := 0
|
||||
for sc.Scan() {
|
||||
var r Req
|
||||
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
|
||||
continue
|
||||
}
|
||||
fn(r)
|
||||
n++
|
||||
}
|
||||
return n, sc.Err()
|
||||
}
|
||||
|
||||
// reqRing keeps the newest n records seen, in chronological order, without
|
||||
// growing with the number of records pushed through it.
|
||||
type reqRing struct {
|
||||
buf []Req
|
||||
next int
|
||||
full bool
|
||||
limit int
|
||||
}
|
||||
|
||||
func newReqRing(n int) *reqRing {
|
||||
if n <= 0 {
|
||||
n = defaultRingSize
|
||||
}
|
||||
return &reqRing{buf: make([]Req, n), limit: n}
|
||||
}
|
||||
|
||||
func (r *reqRing) push(rec Req) {
|
||||
r.buf[r.next] = rec
|
||||
r.next++
|
||||
if r.next == r.limit {
|
||||
r.next = 0
|
||||
r.full = true
|
||||
}
|
||||
}
|
||||
|
||||
// slice returns the retained records oldest-first.
|
||||
func (r *reqRing) slice() []Req {
|
||||
if !r.full {
|
||||
out := make([]Req, r.next)
|
||||
copy(out, r.buf[:r.next])
|
||||
return out
|
||||
}
|
||||
out := make([]Req, 0, r.limit)
|
||||
out = append(out, r.buf[r.next:]...)
|
||||
out = append(out, r.buf[:r.next]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// ReplayPartial reports whether the resident aggregates are known to be
|
||||
// incomplete (an audit file could not be read in full). Under normal operation
|
||||
// the aggregates cover the entire audit history, so this is false.
|
||||
func (s *Stats) ReplayPartial() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.replayPartial
|
||||
}
|
||||
|
||||
// Record appends a finished request to the aggregates and ring buffer.
|
||||
@ -399,9 +517,64 @@ type StatsRow struct {
|
||||
|
||||
// AuditRecords returns every request row from the audit file plus its rotated
|
||||
// .old files within the [from,to] unix-millisecond window, optionally for one
|
||||
// masked key id. Unlike Records it is not bounded by the in-memory ring, so it
|
||||
// yields a complete history for CSV export regardless of restarts or churn.
|
||||
// masked key id, sorted oldest-first.
|
||||
//
|
||||
// It materializes the whole window, so it is only for tests and small windows.
|
||||
// Production paths must use StreamAuditRecords (O(1) memory, for CSV export) or
|
||||
// AuditPage (bounded pages, for the records view).
|
||||
func (s *Stats) AuditRecords(from, to int64, key string) []Req {
|
||||
var out []Req
|
||||
_ = s.StreamAuditRecords(from, to, key, func(r Req) error {
|
||||
out = append(out, r)
|
||||
return nil
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Time < out[j].Time })
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- on-demand audit paging ----
|
||||
//
|
||||
// The records view is paged straight off disk instead of being held in memory:
|
||||
// the newest page is read from the tail of the newest audit file backwards, and
|
||||
// each response hands back a cursor for the next page. Nothing is cached between
|
||||
// requests, so "release when the user leaves the page" is guaranteed by never
|
||||
// retaining anything in the first place.
|
||||
|
||||
// auditCursor points at a byte offset inside one audit file. Paging walks
|
||||
// backwards, so the cursor means "continue reading BEFORE this offset".
|
||||
type auditCursor struct {
|
||||
File string
|
||||
Offset int64
|
||||
}
|
||||
|
||||
// String encodes the cursor for the wire as "<file>:<offset>".
|
||||
func (c auditCursor) String() string {
|
||||
if c.File == "" {
|
||||
return ""
|
||||
}
|
||||
return c.File + ":" + strconv.FormatInt(c.Offset, 10)
|
||||
}
|
||||
|
||||
// parseAuditCursor decodes a wire cursor. An empty or malformed value means
|
||||
// "start at the newest record".
|
||||
func parseAuditCursor(s string) (auditCursor, bool) {
|
||||
if s == "" {
|
||||
return auditCursor{}, false
|
||||
}
|
||||
i := strings.LastIndex(s, ":")
|
||||
if i <= 0 || i == len(s)-1 {
|
||||
return auditCursor{}, false
|
||||
}
|
||||
off, err := strconv.ParseInt(s[i+1:], 10, 64)
|
||||
if err != nil || off < 0 {
|
||||
return auditCursor{}, false
|
||||
}
|
||||
return auditCursor{File: s[:i], Offset: off}, true
|
||||
}
|
||||
|
||||
// auditChain lists the audit files newest-first: the live file, then the rotated
|
||||
// ones in descending timestamp order.
|
||||
func (s *Stats) auditChain() []string {
|
||||
s.mu.Lock()
|
||||
path := s.auditPath
|
||||
s.mu.Unlock()
|
||||
@ -410,36 +583,216 @@ func (s *Stats) AuditRecords(from, to int64, key string) []Req {
|
||||
}
|
||||
files := []string{path}
|
||||
if olds, err := filepath.Glob(path + ".*.old"); err == nil {
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(olds)))
|
||||
files = append(files, olds...)
|
||||
}
|
||||
var out []Req
|
||||
for _, p := range files {
|
||||
f, err := os.Open(p)
|
||||
return files
|
||||
}
|
||||
|
||||
// AuditPageResult is one page of records plus the cursor to continue from.
|
||||
type AuditPageResult struct {
|
||||
Records []Req `json:"records"`
|
||||
Next string `json:"next_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
// Rotated marks that the requested cursor's file no longer exists (the
|
||||
// audit log rotated under the reader), so the client should restart from
|
||||
// the newest page instead of silently skipping records.
|
||||
Rotated bool `json:"rotated,omitempty"`
|
||||
}
|
||||
|
||||
// revChunk is how much is read per backwards seek. Lines are far shorter than
|
||||
// this, so a page is typically satisfied by one or two chunks.
|
||||
const revChunk = 64 * 1024
|
||||
|
||||
// AuditPage returns up to limit request records ending at cursor, newest first.
|
||||
// It reads the audit files backwards and stops as soon as the page is full, so
|
||||
// cost and memory are proportional to limit rather than to the file size. The
|
||||
// file handle is closed before returning: no state is kept between calls.
|
||||
func (s *Stats) AuditPage(cursor string, limit int, key string) AuditPageResult {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
chain := s.auditChain()
|
||||
if len(chain) == 0 {
|
||||
return AuditPageResult{Records: []Req{}}
|
||||
}
|
||||
|
||||
start := 0
|
||||
var offset int64 = -1 // -1 = start at EOF
|
||||
rotated := false
|
||||
if c, ok := parseAuditCursor(cursor); ok {
|
||||
idx := -1
|
||||
for i, f := range chain {
|
||||
if f == c.File {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
// the cursor's file rotated away: restart from the newest page and
|
||||
// tell the client so it can reset its view
|
||||
rotated = true
|
||||
} else {
|
||||
start, offset = idx, c.Offset
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Req, 0, limit)
|
||||
for i := start; i < len(chain); i++ {
|
||||
next, err := s.pageFile(chain[i], offset, limit, key, &out)
|
||||
offset = -1 // subsequent files always start at their EOF
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
|
||||
for sc.Scan() {
|
||||
var r Req
|
||||
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
|
||||
continue
|
||||
if len(out) >= limit {
|
||||
res := AuditPageResult{Records: out, Rotated: rotated}
|
||||
if next > 0 {
|
||||
res.Next = auditCursor{File: chain[i], Offset: next}.String()
|
||||
res.HasMore = true
|
||||
} else if i+1 < len(chain) {
|
||||
res.Next = auditCursor{File: chain[i+1], Offset: fileSize(chain[i+1])}.String()
|
||||
res.HasMore = true
|
||||
}
|
||||
if key != "" && r.Key != key {
|
||||
continue
|
||||
}
|
||||
if from > 0 && r.Time < from {
|
||||
continue
|
||||
}
|
||||
if to > 0 && r.Time > to {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
return res
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Time < out[j].Time })
|
||||
return out
|
||||
return AuditPageResult{Records: out, HasMore: false, Rotated: rotated}
|
||||
}
|
||||
|
||||
func fileSize(path string) int64 {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return fi.Size()
|
||||
}
|
||||
|
||||
// pageFile reads one audit file backwards from end (or EOF when end < 0),
|
||||
// appending matching records to out (newest first) until it holds limit entries.
|
||||
// It returns the offset to continue from within this file, or 0 when the file is
|
||||
// exhausted.
|
||||
func (s *Stats) pageFile(path string, end int64, limit int, key string, out *[]Req) (int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close() // released before the response is written: nothing is retained
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if end < 0 || end > fi.Size() {
|
||||
end = fi.Size()
|
||||
}
|
||||
|
||||
buf := make([]byte, 0, revChunk)
|
||||
var tail []byte // bytes of a line whose start was not in this chunk
|
||||
pos := end
|
||||
for pos > 0 && len(*out) < limit {
|
||||
size := int64(revChunk)
|
||||
if size > pos {
|
||||
size = pos
|
||||
}
|
||||
pos -= size
|
||||
buf = buf[:size]
|
||||
if _, err := f.ReadAt(buf, pos); err != nil && err != io.EOF {
|
||||
return 0, err
|
||||
}
|
||||
chunk := buf
|
||||
if len(tail) > 0 {
|
||||
chunk = append(append(make([]byte, 0, len(buf)+len(tail)), buf...), tail...)
|
||||
tail = tail[:0]
|
||||
}
|
||||
// walk the chunk's complete lines from the end towards the start
|
||||
lineEnd := len(chunk)
|
||||
for i := len(chunk) - 1; i >= 0; i-- {
|
||||
if chunk[i] != '\n' {
|
||||
continue
|
||||
}
|
||||
if rec, ok := decodeAuditLine(chunk[i+1:lineEnd], key); ok {
|
||||
*out = append(*out, rec)
|
||||
if len(*out) >= limit {
|
||||
// resume at the newline we just consumed
|
||||
return pos + int64(i) + 1, nil
|
||||
}
|
||||
}
|
||||
lineEnd = i
|
||||
}
|
||||
// chunk[:lineEnd] is an incomplete line: carry it into the next chunk
|
||||
if lineEnd > 0 {
|
||||
tail = append(tail[:0], chunk[:lineEnd]...)
|
||||
}
|
||||
if pos == 0 {
|
||||
// start of file: whatever is carried is a complete first line
|
||||
if rec, ok := decodeAuditLine(tail, key); ok {
|
||||
*out = append(*out, rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
if pos <= 0 {
|
||||
return 0, nil // file exhausted
|
||||
}
|
||||
return pos, nil
|
||||
}
|
||||
|
||||
// decodeAuditLine parses one audit line as a request record, filtering by key.
|
||||
// Access/event rows and malformed lines are rejected.
|
||||
func decodeAuditLine(line []byte, key string) (Req, bool) {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
return Req{}, false
|
||||
}
|
||||
var r Req
|
||||
if json.Unmarshal(line, &r) != nil || r.Type == "" {
|
||||
return Req{}, false
|
||||
}
|
||||
if key != "" && r.Key != key {
|
||||
return Req{}, false
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// StreamAuditRecords walks every request record in [from, to] (unix millis,
|
||||
// 0 = unbounded) newest file first and hands each one to fn. Nothing is
|
||||
// accumulated: an export of an arbitrarily long period costs O(1) memory, and
|
||||
// the file handles are closed as the walk proceeds. fn returning an error stops
|
||||
// the walk (used to abort on a broken client connection).
|
||||
func (s *Stats) StreamAuditRecords(from, to int64, key string, fn func(Req) error) error {
|
||||
for _, path := range s.auditChain() {
|
||||
if err := streamAuditFile(path, from, to, key, fn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamAuditFile(path string, from, to int64, key string, fn func(Req) error) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil // a rotated-away file is not an export failure
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
|
||||
for sc.Scan() {
|
||||
var r Req
|
||||
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
|
||||
continue
|
||||
}
|
||||
if key != "" && r.Key != key {
|
||||
continue
|
||||
}
|
||||
if from > 0 && r.Time < from {
|
||||
continue
|
||||
}
|
||||
if to > 0 && r.Time > to {
|
||||
continue
|
||||
}
|
||||
if err := fn(r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SourceRecent counts real gateway requests per source within the last
|
||||
@ -530,10 +883,18 @@ func (s *Stats) SourceAverages(windowSec int64) map[string]SourceAvg {
|
||||
|
||||
// Snapshot returns the whole dashboard payload; when key != "" the records
|
||||
// and aggregate views are restricted to that gateway key.
|
||||
// Snapshot returns the aggregate rows plus the FIRST SCREEN of request records
|
||||
// (newest last, matching the ring order). limit bounds the record slice only;
|
||||
// older records are not included here at all — the dashboard pages them from
|
||||
// /api/stats/records as the user scrolls, so a dashboard load never serializes
|
||||
// the whole ring. limit <= 0 means the default first screen.
|
||||
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if limit <= 0 {
|
||||
limit = firstScreenRecords
|
||||
}
|
||||
if limit > s.maxRecs {
|
||||
limit = s.maxRecs
|
||||
}
|
||||
start := 0
|
||||
@ -581,12 +942,13 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||
return ci < cj
|
||||
})
|
||||
return map[string]interface{}{
|
||||
"active": s.active,
|
||||
"total": total,
|
||||
"by_key": rows(byKey),
|
||||
"by_model": rows(byModel),
|
||||
"by_source": rows(bySrc),
|
||||
"by_status": bs,
|
||||
"records": append([]Req(nil), recs...),
|
||||
"active": s.active,
|
||||
"total": total,
|
||||
"by_key": rows(byKey),
|
||||
"by_model": rows(byModel),
|
||||
"by_source": rows(bySrc),
|
||||
"by_status": bs,
|
||||
"records": append([]Req(nil), recs...),
|
||||
"replay_partial": s.replayPartial,
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,4 +156,407 @@ func TestLoadAuditFullReplay(t *testing.T) {
|
||||
if s.byStatus[200].Err != 0 || s.byStatus[503] == nil || s.byStatus[503].Reqs != 1 {
|
||||
t.Fatalf("by_status wrong: %#v", s.byStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- on-demand log loading (plan 阶段 3) ----
|
||||
|
||||
// writeAuditFile writes n synthetic request rows (plus some non-request event
|
||||
// rows, which must be skipped) and returns the path.
|
||||
func writeAuditFile(t *testing.T, path string, n int, key string, startMs int64) {
|
||||
t.Helper()
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create audit: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
for i := 0; i < n; i++ {
|
||||
fmt.Fprintf(f, `{"time":%d,"key":%q,"type":"chat","model":"m","source":"s","prompt_tokens":1,"completion_tokens":1,"latency_ms":5,"ok":true,"status":200}`+"\n",
|
||||
startMs+int64(i)*1000, key)
|
||||
if i%10 == 0 {
|
||||
// an access-log/event row: must never appear as a record
|
||||
fmt.Fprintf(f, `{"obj":"access","time":%d,"path":"/v1/models"}`+"\n", startMs+int64(i)*1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAuditAggregatesFullHistory is the correctness half of the log work:
|
||||
// the dashboard's totals must cover EVERY audit record, however large the file,
|
||||
// while the in-memory record ring stays bounded.
|
||||
func TestLoadAuditAggregatesFullHistory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
const n = 20000
|
||||
writeAuditFile(t, path, n, "k1", 1_700_000_000_000)
|
||||
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
if s.ReplayPartial() {
|
||||
t.Fatal("a readable history must not be reported as partial")
|
||||
}
|
||||
|
||||
snap := s.Snapshot(0, "")
|
||||
total, _ := snap["total"].(Stat)
|
||||
if total.Reqs != n {
|
||||
t.Fatalf("total reqs = %d, want %d (aggregates must cover the whole file)", total.Reqs, n)
|
||||
}
|
||||
if total.Tokens != int64(n)*2 { // 1 prompt + 1 completion per row
|
||||
t.Fatalf("total tokens = %d, want %d", total.Tokens, n*2)
|
||||
}
|
||||
|
||||
// ...while the resident ring is capped and the snapshot ships one screen
|
||||
recs, _ := snap["records"].([]Req)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("the newest records must stay resident")
|
||||
}
|
||||
if len(recs) > firstScreenRecords {
|
||||
t.Fatalf("snapshot shipped %d records, must be capped at the first screen (%d)",
|
||||
len(recs), firstScreenRecords)
|
||||
}
|
||||
s.mu.Lock()
|
||||
ring := len(s.recs)
|
||||
s.mu.Unlock()
|
||||
if ring > defaultRingSize {
|
||||
t.Fatalf("resident ring holds %d records, must be capped at %d", ring, defaultRingSize)
|
||||
}
|
||||
|
||||
// the ring must hold the NEWEST records, not the first ones scanned
|
||||
newest := recs[len(recs)-1]
|
||||
wantNewest := int64(1_700_000_000_000) + int64(n-1)*1000
|
||||
if newest.Time != wantNewest {
|
||||
t.Fatalf("newest resident record time = %d, want %d", newest.Time, wantNewest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAuditAggregatesAcrossRotatedFiles: history split over rotated .old
|
||||
// files must still add up, and must be replayed oldest-first so the hourly
|
||||
// quota buckets keep the intended trailing window.
|
||||
func TestLoadAuditAggregatesAcrossRotatedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
writeAuditFile(t, path, 40, "k1", 1_700_000_500_000) // newest
|
||||
writeAuditFile(t, path+".100.old", 40, "k1", 1_700_000_300_000) // older
|
||||
writeAuditFile(t, path+".050.old", 40, "k1", 1_700_000_100_000) // oldest
|
||||
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
snap := s.Snapshot(0, "")
|
||||
total, _ := snap["total"].(Stat)
|
||||
if total.Reqs != 120 {
|
||||
t.Fatalf("total reqs = %d, want 120 across three files", total.Reqs)
|
||||
}
|
||||
recs, _ := snap["records"].([]Req)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("expected resident records")
|
||||
}
|
||||
// oldest-first replay + newest-last ring => the last resident record is the
|
||||
// newest one in the live file
|
||||
if got := recs[len(recs)-1].Time; got != 1_700_000_500_000+39*1000 {
|
||||
t.Fatalf("newest resident record time = %d, want the live file's last row", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReqRingKeepsNewest pins the ring semantics used by the full-history scan:
|
||||
// bounded size, newest retained, oldest-first output.
|
||||
func TestReqRingKeepsNewest(t *testing.T) {
|
||||
r := newReqRing(3)
|
||||
for i := 1; i <= 7; i++ {
|
||||
r.push(Req{Time: int64(i)})
|
||||
}
|
||||
got := r.slice()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("ring len = %d, want 3", len(got))
|
||||
}
|
||||
for i, want := range []int64{5, 6, 7} {
|
||||
if got[i].Time != want {
|
||||
t.Fatalf("ring[%d] = %d, want %d (oldest-first, newest retained)", i, got[i].Time, want)
|
||||
}
|
||||
}
|
||||
|
||||
// a partially filled ring returns exactly what was pushed
|
||||
r2 := newReqRing(5)
|
||||
r2.push(Req{Time: 1})
|
||||
r2.push(Req{Time: 2})
|
||||
if got := r2.slice(); len(got) != 2 || got[0].Time != 1 || got[1].Time != 2 {
|
||||
t.Fatalf("partial ring = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAuditFullFileWhenSmall: a small file is loaded completely and is not
|
||||
// flagged partial.
|
||||
func TestLoadAuditFullFileWhenSmall(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.jsonl")
|
||||
writeAuditFile(t, path, 50, "k1", 1_700_000_000_000)
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
if s.ReplayPartial() {
|
||||
t.Fatal("a small file must be replayed in full, not flagged partial")
|
||||
}
|
||||
snap := s.Snapshot(0, "")
|
||||
total, _ := snap["total"].(Stat)
|
||||
if total.Reqs != 50 {
|
||||
t.Fatalf("total reqs = %d, want 50", total.Reqs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaWindowsSurviveBoundedRing is the regression guard the plan calls out:
|
||||
// capping the resident record ring must not break token-quota accounting, which
|
||||
// reads the hour buckets rather than the ring.
|
||||
func TestQuotaWindowsSurviveBoundedRing(t *testing.T) {
|
||||
s := NewStats(10) // deliberately tiny ring
|
||||
now := time.Now().UnixMilli()
|
||||
for i := 0; i < 100; i++ {
|
||||
s.Record(Req{Time: now, Key: "k", Model: "m", Source: "s", Prompt: 2, Compl: 3, OK: true, Status: 200})
|
||||
}
|
||||
if got := s.WindowTokens("m", "s", 24*hourSec); got != 500 {
|
||||
t.Fatalf("window tokens = %d, want 500 (quota accounting must not depend on the ring)", got)
|
||||
}
|
||||
if got := s.ModelTokens("k")["m"]; got != 500 {
|
||||
t.Fatalf("model tokens = %d, want 500", got)
|
||||
}
|
||||
if got := s.KeyTokens("k"); got != 500 {
|
||||
t.Fatalf("key tokens = %d, want 500", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditPagePagesBackwards walks the whole history one page at a time and
|
||||
// asserts strict newest-first order with no gaps or repeats.
|
||||
func TestAuditPagePagesBackwards(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.jsonl")
|
||||
const n = 250
|
||||
writeAuditFile(t, path, n, "k1", 1_700_000_000_000)
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
|
||||
var seen []Req
|
||||
cursor := ""
|
||||
for i := 0; i < 20; i++ { // bounded loop guards against a cursor that never advances
|
||||
page := s.AuditPage(cursor, 40, "")
|
||||
if len(page.Records) == 0 {
|
||||
break
|
||||
}
|
||||
seen = append(seen, page.Records...)
|
||||
if !page.HasMore {
|
||||
break
|
||||
}
|
||||
if page.Next == cursor {
|
||||
t.Fatal("cursor did not advance")
|
||||
}
|
||||
cursor = page.Next
|
||||
}
|
||||
if len(seen) != n {
|
||||
t.Fatalf("paged %d records, want %d", len(seen), n)
|
||||
}
|
||||
for i := 1; i < len(seen); i++ {
|
||||
if seen[i-1].Time < seen[i].Time {
|
||||
t.Fatalf("records not newest-first at %d: %d then %d", i, seen[i-1].Time, seen[i].Time)
|
||||
}
|
||||
}
|
||||
// newest record first, oldest last
|
||||
if seen[0].Time != 1_700_000_000_000+int64(n-1)*1000 {
|
||||
t.Fatalf("first record time = %d, want the newest", seen[0].Time)
|
||||
}
|
||||
if seen[len(seen)-1].Time != 1_700_000_000_000 {
|
||||
t.Fatalf("last record time = %d, want the oldest", seen[len(seen)-1].Time)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditPageFirstScreenIsSmall: the first page must be exactly one screen,
|
||||
// independent of how large the audit file is.
|
||||
func TestAuditPageFirstScreenIsSmall(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.jsonl")
|
||||
writeAuditFile(t, path, 5000, "k1", 1_700_000_000_000)
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
page := s.AuditPage("", 100, "")
|
||||
if len(page.Records) != 100 {
|
||||
t.Fatalf("first page = %d records, want 100", len(page.Records))
|
||||
}
|
||||
if !page.HasMore || page.Next == "" {
|
||||
t.Fatal("a large file must report more pages plus a cursor")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditPageFiltersByKey: a user key may only page its own records.
|
||||
func TestAuditPageFiltersByKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
writeAuditFile(t, path, 30, "mine", 1_700_000_000_000)
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 30; i++ {
|
||||
fmt.Fprintf(f, `{"time":%d,"key":"other","type":"chat","model":"m","source":"s","ok":true,"status":200}`+"\n",
|
||||
1_700_000_100_000+int64(i)*1000)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
page := s.AuditPage("", 1000, "mine")
|
||||
if len(page.Records) != 30 {
|
||||
t.Fatalf("filtered page = %d records, want 30", len(page.Records))
|
||||
}
|
||||
for _, r := range page.Records {
|
||||
if r.Key != "mine" {
|
||||
t.Fatalf("key filter leaked record for %q", r.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditPageSpansRotatedFiles: paging must continue into .old files instead
|
||||
// of stopping at the live file's start.
|
||||
func TestAuditPageSpansRotatedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
writeAuditFile(t, path, 40, "k1", 1_700_000_500_000) // newest
|
||||
writeAuditFile(t, path+".100.old", 40, "k1", 1_700_000_300_000) // older
|
||||
writeAuditFile(t, path+".050.old", 40, "k1", 1_700_000_100_000) // oldest
|
||||
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
var seen []Req
|
||||
cursor := ""
|
||||
for i := 0; i < 30; i++ {
|
||||
page := s.AuditPage(cursor, 25, "")
|
||||
seen = append(seen, page.Records...)
|
||||
if !page.HasMore {
|
||||
break
|
||||
}
|
||||
cursor = page.Next
|
||||
}
|
||||
if len(seen) != 120 {
|
||||
t.Fatalf("paged %d records across rotated files, want 120", len(seen))
|
||||
}
|
||||
for i := 1; i < len(seen); i++ {
|
||||
if seen[i-1].Time < seen[i].Time {
|
||||
t.Fatalf("rotation boundary broke the ordering at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditPageRotatedCursor: a cursor whose file rotated away must restart from
|
||||
// the newest page and say so, rather than silently returning nothing.
|
||||
func TestAuditPageRotatedCursor(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.jsonl")
|
||||
writeAuditFile(t, path, 20, "k1", 1_700_000_000_000)
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
page := s.AuditPage("/gone/audit.jsonl:12345", 10, "")
|
||||
if !page.Rotated {
|
||||
t.Fatal("a stale cursor must be reported as rotated")
|
||||
}
|
||||
if len(page.Records) == 0 {
|
||||
t.Fatal("a stale cursor must fall back to the newest page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditPageEmptyAndNoFile cover the degenerate inputs.
|
||||
func TestAuditPageEmptyAndNoFile(t *testing.T) {
|
||||
s := NewStats(0)
|
||||
if page := s.AuditPage("", 10, ""); len(page.Records) != 0 || page.HasMore {
|
||||
t.Fatalf("no audit path must yield an empty page, got %+v", page)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "audit.jsonl")
|
||||
if err := os.WriteFile(path, nil, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := NewStats(0)
|
||||
s2.LoadAudit(path)
|
||||
if page := s2.AuditPage("", 10, ""); len(page.Records) != 0 || page.HasMore {
|
||||
t.Fatalf("empty audit file must yield an empty page, got %+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditPageHandlesLongLines: a record larger than the reverse-read chunk
|
||||
// must still be decoded (the reader carries partial lines across chunks).
|
||||
func TestAuditPageHandlesLongLines(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.jsonl")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
big := strings.Repeat("e", revChunk+1024) // one record spanning >1 chunk
|
||||
fmt.Fprintf(f, `{"time":1,"key":"k","type":"chat","model":"m","source":"s","ok":false,"status":500,"error":%q}`+"\n", big)
|
||||
fmt.Fprintf(f, `{"time":2,"key":"k","type":"chat","model":"m","source":"s","ok":true,"status":200}`+"\n")
|
||||
f.Close()
|
||||
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
page := s.AuditPage("", 10, "")
|
||||
if len(page.Records) != 2 {
|
||||
t.Fatalf("got %d records, want 2 (a >chunk record must survive reverse reads)", len(page.Records))
|
||||
}
|
||||
if page.Records[0].Time != 2 || page.Records[1].Time != 1 {
|
||||
t.Fatalf("ordering broken: %d then %d", page.Records[0].Time, page.Records[1].Time)
|
||||
}
|
||||
if len(page.Records[1].Err) != len(big) {
|
||||
t.Fatalf("oversized field truncated: %d bytes, want %d", len(page.Records[1].Err), len(big))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamAuditRecordsIsWindowed: export streaming must honour the time window
|
||||
// and the key filter, and must hand records over one at a time.
|
||||
func TestStreamAuditRecordsIsWindowed(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.jsonl")
|
||||
writeAuditFile(t, path, 100, "k1", 1_700_000_000_000)
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
|
||||
from := int64(1_700_000_010_000)
|
||||
to := int64(1_700_000_019_000)
|
||||
var got []Req
|
||||
if err := s.StreamAuditRecords(from, to, "", func(r Req) error {
|
||||
got = append(got, r)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("stream: %v", err)
|
||||
}
|
||||
if len(got) != 10 {
|
||||
t.Fatalf("streamed %d records, want 10 in [%d,%d]", len(got), from, to)
|
||||
}
|
||||
for _, r := range got {
|
||||
if r.Time < from || r.Time > to {
|
||||
t.Fatalf("record outside the window: %d", r.Time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamAuditRecordsAbortStops: a callback error (client disconnect) must
|
||||
// stop the walk immediately instead of reading the rest of the history.
|
||||
func TestStreamAuditRecordsAbortStops(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.jsonl")
|
||||
writeAuditFile(t, path, 500, "k1", 1_700_000_000_000)
|
||||
s := NewStats(0)
|
||||
s.LoadAudit(path)
|
||||
|
||||
seen := 0
|
||||
stop := fmt.Errorf("client gone")
|
||||
err := s.StreamAuditRecords(0, 0, "", func(Req) error {
|
||||
seen++
|
||||
if seen == 5 {
|
||||
return stop
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != stop {
|
||||
t.Fatalf("abort must surface the callback error, got %v", err)
|
||||
}
|
||||
if seen != 5 {
|
||||
t.Fatalf("walk continued after abort: %d records", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditRotationThresholds pins the smaller-file/more-files trade-off that
|
||||
// makes reverse paging cheap while keeping the same total budget.
|
||||
func TestAuditRotationThresholds(t *testing.T) {
|
||||
if auditRotateBytes != 16<<20 {
|
||||
t.Fatalf("auditRotateBytes = %d, want 16 MiB", auditRotateBytes)
|
||||
}
|
||||
if auditKeepOld != 16 {
|
||||
t.Fatalf("auditKeepOld = %d, want 16", auditKeepOld)
|
||||
}
|
||||
if total := auditRotateBytes * int64(auditKeepOld); total != 256<<20 {
|
||||
t.Fatalf("total audit budget = %d, want 256 MiB", total)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
349
internal/gateway/ui_contract_test.go
Normal file
349
internal/gateway/ui_contract_test.go
Normal file
@ -0,0 +1,349 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The WebUI talks to this package through a tiny `api(path, options)` helper
|
||||
// that wraps fetch(). fetch() defaults to GET when no method is given, so an
|
||||
// options object without `method` silently turns a DELETE/PUT into a GET —
|
||||
// the request hits the wrong branch of the handler and the user sees a
|
||||
// confusing error like "use GET /api/keys" while nothing is deleted.
|
||||
//
|
||||
// This has now bitten twice: ab20f1b fixed delSource/delTemplate, and the very
|
||||
// same pattern was still live in delKey (deleting a gateway key was impossible)
|
||||
// and delAdapter. Reviewing 4000+ lines of inline JS by eye clearly does not
|
||||
// catch it, so it is pinned here instead.
|
||||
|
||||
// apiCallRe finds the start of every api(...) call in the UI source.
|
||||
var apiCallRe = regexp.MustCompile(`\bapi\(`)
|
||||
|
||||
// uiSource returns the embedded WebUI document.
|
||||
func uiSource(t *testing.T) string {
|
||||
t.Helper()
|
||||
b, err := uiFS.ReadFile("ui/index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded ui: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// extractCall returns the source of the balanced api(...) call starting at the
|
||||
// opening parenthesis index, plus the line number it starts on.
|
||||
func extractCall(src string, openIdx int) (string, bool) {
|
||||
depth := 0
|
||||
for i := openIdx; i < len(src); i++ {
|
||||
switch src[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return src[openIdx : i+1], true
|
||||
}
|
||||
case '\n':
|
||||
// A call spanning more than ~25 lines is not one of ours; bail out
|
||||
// rather than scanning the rest of the document.
|
||||
if i-openIdx > 4000 {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func lineOf(src string, idx int) int {
|
||||
return strings.Count(src[:idx], "\n") + 1
|
||||
}
|
||||
|
||||
// TestUIAPICallsDeclareMethod asserts that every api() call passing an options
|
||||
// object also declares an HTTP method (or is a GET that only passes an
|
||||
// AbortSignal). Without this, fetch defaults to GET and mutating endpoints are
|
||||
// never reached.
|
||||
func TestUIAPICallsDeclareMethod(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
var offenders []string
|
||||
for _, m := range apiCallRe.FindAllStringIndex(src, -1) {
|
||||
open := m[1] - 1 // index of '('
|
||||
call, ok := extractCall(src, open)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// No options object at all => a plain GET, which is fine.
|
||||
if !strings.Contains(call, "{") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(call, "method:") {
|
||||
continue
|
||||
}
|
||||
// A read that only carries an AbortSignal is a deliberate GET.
|
||||
if strings.Contains(call, "signal:") && !strings.Contains(call, "body:") {
|
||||
continue
|
||||
}
|
||||
offenders = append(offenders, fmt.Sprintf("line %d: %s", lineOf(src, open), oneLine(call)))
|
||||
}
|
||||
if len(offenders) > 0 {
|
||||
t.Fatalf("api() called with an options object but no method (fetch will send GET):\n %s",
|
||||
strings.Join(offenders, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIDeleteHelpersUseDelete pins the specific helpers that remove things.
|
||||
// Each must issue a DELETE; a GET here is the exact bug users reported as
|
||||
// "cannot delete key: use GET /api/keys".
|
||||
func TestUIDeleteHelpersUseDelete(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
for _, fn := range []string{"delKey", "delAdapter", "delSource", "delTemplate"} {
|
||||
body, ok := jsFunctionBody(src, fn)
|
||||
if !ok {
|
||||
t.Errorf("helper %s() not found in the WebUI", fn)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(body, "api(") {
|
||||
t.Errorf("%s() does not call api()", fn)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(body, `method: "DELETE"`) {
|
||||
t.Errorf("%s() must issue a DELETE, got:\n%s", fn, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIMutatingHelpersUseWriteMethods covers the write (non-delete) helpers:
|
||||
// each must declare an explicit POST/PUT/PATCH.
|
||||
func TestUIMutatingHelpersUseWriteMethods(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
want := map[string][]string{
|
||||
"createKey": {`method: "POST"`},
|
||||
"putScope": {`method: "PUT"`},
|
||||
"uploadAdapter": {`method: "POST"`},
|
||||
"saveAuto": {`method: "PUT"`, `method: "POST"`},
|
||||
}
|
||||
for fn, methods := range want {
|
||||
body, ok := jsFunctionBody(src, fn)
|
||||
if !ok {
|
||||
continue // helper renamed or absent: the method test above still guards it
|
||||
}
|
||||
found := false
|
||||
for _, m := range methods {
|
||||
if strings.Contains(body, m) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s() must declare one of %v", fn, methods)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsFunctionBody returns the source of `function name(` / `async function name(`
|
||||
// up to its closing brace, using brace balancing.
|
||||
func jsFunctionBody(src, name string) (string, bool) {
|
||||
for _, prefix := range []string{"async function " + name + "(", "function " + name + "("} {
|
||||
idx := strings.Index(src, prefix)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
open := strings.Index(src[idx:], "{")
|
||||
if open < 0 {
|
||||
continue
|
||||
}
|
||||
open += idx
|
||||
depth := 0
|
||||
for i := open; i < len(src); i++ {
|
||||
switch src[i] {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return src[idx : i+1], true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func oneLine(s string) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if len(s) > 140 {
|
||||
return s[:140] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestUIRecordsPagingWiring guards the record table's paging plumbing. Two
|
||||
// separate bugs made "scroll to the bottom" silently stop loading:
|
||||
//
|
||||
// 1. paintRecords rebuilt the whole table on every 5s poll whenever the row
|
||||
// count did not exceed the first screen, wiping loaded pages and resetting
|
||||
// scroll position.
|
||||
// 2. IntersectionObserver only fires on TRANSITIONS. With a short list — or a
|
||||
// page whose rows all duplicated the first screen — the sentinel stayed
|
||||
// visible and never fired again.
|
||||
//
|
||||
// The fixes are a build-once flag, a scroll-position fallback, an initial
|
||||
// viewport fill, and chaining when a page yields no new rows. None of that is
|
||||
// reachable from Go, so the wiring is asserted structurally.
|
||||
func TestUIRecordsPagingWiring(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
|
||||
paint, ok := jsFunctionBody(src, "paintRecords")
|
||||
if !ok {
|
||||
t.Fatal("paintRecords() not found")
|
||||
}
|
||||
if !strings.Contains(paint, "recsState.built") {
|
||||
t.Error("paintRecords must build the table once (recsState.built) instead of rebuilding on every poll")
|
||||
}
|
||||
if !strings.Contains(paint, "afterbegin") {
|
||||
t.Error("paintRecords must PREPEND newer rows on later polls, not rebuild the table")
|
||||
}
|
||||
if !strings.Contains(paint, "fillRecordsViewport") {
|
||||
t.Error("paintRecords must fill the viewport so the sentinel can transition")
|
||||
}
|
||||
|
||||
attach, ok := jsFunctionBody(src, "attachRecsObserver")
|
||||
if !ok {
|
||||
t.Fatal("attachRecsObserver() not found")
|
||||
}
|
||||
if !strings.Contains(attach, "IntersectionObserver") {
|
||||
t.Error("attachRecsObserver must still use IntersectionObserver")
|
||||
}
|
||||
if !strings.Contains(attach, `addEventListener("scroll"`) {
|
||||
t.Error("attachRecsObserver needs a scroll fallback: an already-visible sentinel never fires again")
|
||||
}
|
||||
|
||||
load, ok := jsFunctionBody(src, "loadMoreRecords")
|
||||
if !ok {
|
||||
t.Fatal("loadMoreRecords() not found")
|
||||
}
|
||||
if !strings.Contains(load, "maxChain") {
|
||||
t.Error("loadMoreRecords must chain when a page adds no new rows (the first page overlaps the first screen)")
|
||||
}
|
||||
if !strings.Contains(load, "signal: ctrl.signal") {
|
||||
t.Error("loadMoreRecords must remain abortable")
|
||||
}
|
||||
|
||||
fill, ok := jsFunctionBody(src, "fillRecordsViewport")
|
||||
if !ok {
|
||||
t.Fatal("fillRecordsViewport() not found")
|
||||
}
|
||||
for _, want := range []string{"scrollHeight", "clientHeight"} {
|
||||
if !strings.Contains(fill, want) {
|
||||
t.Errorf("fillRecordsViewport must compare %s to decide whether the list overflows", want)
|
||||
}
|
||||
}
|
||||
|
||||
rel, ok := jsFunctionBody(src, "releaseRecords")
|
||||
if !ok {
|
||||
t.Fatal("releaseRecords() not found")
|
||||
}
|
||||
for _, want := range []string{"observer.disconnect()", "removeEventListener", "abort()", "recsState.built = false"} {
|
||||
if !strings.Contains(rel, want) {
|
||||
t.Errorf("releaseRecords must clean up %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIThemeMatrix pins the theme system: every accent must define both a light
|
||||
// and a dark variable block, so switching light/dark can never leave a theme
|
||||
// half-defined (which shows up as unreadable text rather than as an error).
|
||||
func TestUIThemeMatrix(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
|
||||
accents := []string{"sakura", "ocean", "violet", "mono"}
|
||||
for _, a := range accents {
|
||||
light := fmt.Sprintf(`html[data-accent=%q]{`, a)
|
||||
dark := fmt.Sprintf(`html[data-theme="dark"][data-accent=%q]{`, a)
|
||||
if !strings.Contains(src, light) {
|
||||
t.Errorf("accent %q has no light-mode variable block (%s)", a, light)
|
||||
}
|
||||
// sakura is the default palette: its light values live in :root, and the
|
||||
// generic dark block covers it.
|
||||
if a != "sakura" && !strings.Contains(src, dark) {
|
||||
t.Errorf("accent %q has no dark-mode variable block (%s)", a, dark)
|
||||
}
|
||||
if !strings.Contains(src, fmt.Sprintf(`data-accent="%s" title=`, a)) {
|
||||
t.Errorf("accent %q has no picker button", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIMonoThemeIsFlat asserts the mono theme really is pure white / pure black:
|
||||
// the surfaces must be opaque and the glass/blob decoration must be switched off,
|
||||
// otherwise "#ffffff" renders as a grey translucent card over a gradient mesh.
|
||||
func TestUIMonoThemeIsFlat(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
|
||||
lightBlock, ok := cssBlock(src, `html[data-accent="mono"]{`)
|
||||
if !ok {
|
||||
t.Fatal("mono light block not found")
|
||||
}
|
||||
for _, want := range []string{"--bg-s1:#ffffff", "--card:#ffffff", "--glass:0px"} {
|
||||
if !strings.Contains(lightBlock, want) {
|
||||
t.Errorf("mono light theme must set %s, got:\n%s", want, lightBlock)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(lightBlock, "--blob1:transparent") {
|
||||
t.Error("mono light theme must disable the gradient blobs")
|
||||
}
|
||||
|
||||
darkBlock, ok := cssBlock(src, `html[data-theme="dark"][data-accent="mono"]{`)
|
||||
if !ok {
|
||||
t.Fatal("mono dark block not found")
|
||||
}
|
||||
for _, want := range []string{"--bg-s1:#000000", "--glass:0px"} {
|
||||
if !strings.Contains(darkBlock, want) {
|
||||
t.Errorf("mono dark theme must set %s, got:\n%s", want, darkBlock)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(darkBlock, "--blob1:transparent") {
|
||||
t.Error("mono dark theme must disable the gradient blobs")
|
||||
}
|
||||
|
||||
// the flat-surface overrides must exist, or backdrop-filter turns #fff grey
|
||||
for _, want := range []string{
|
||||
`html[data-accent="mono"] #bgfx .blob{display:none}`,
|
||||
`backdrop-filter:none`,
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("mono theme needs the flat-surface override %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUICanvasColorsResolveVars guards a class of silent theme bug: Canvas 2D
|
||||
// does not understand CSS var(), so assigning "var(--x)" to strokeStyle is a
|
||||
// no-op that leaves the previous colour (often black) in place — invisible on a
|
||||
// dark background. Theme-driven canvas colours must go through cssVar().
|
||||
func TestUICanvasColorsResolveVars(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
if !strings.Contains(src, "function cssVar(") {
|
||||
t.Fatal("cssVar() helper missing: canvas colours cannot resolve CSS variables without it")
|
||||
}
|
||||
for _, prop := range []string{"strokeStyle", "fillStyle"} {
|
||||
bad := fmt.Sprintf(`ctx.%s = "var(--`, prop)
|
||||
if strings.Contains(src, bad) {
|
||||
t.Errorf("ctx.%s assigned a raw CSS var(): Canvas ignores it silently, use cssVar()", prop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cssBlock returns the text of the CSS rule starting at selector, up to its
|
||||
// closing brace.
|
||||
func cssBlock(src, selector string) (string, bool) {
|
||||
i := strings.Index(src, selector)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
j := strings.Index(src[i:], "}")
|
||||
if j < 0 {
|
||||
return "", false
|
||||
}
|
||||
return src[i : i+j+1], true
|
||||
}
|
||||
@ -1,143 +1,301 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "anthropic"
|
||||
adapter.version = "2.0.0"
|
||||
adapter.version = "3.0.0"
|
||||
adapter.endpoint = "/v1/messages"
|
||||
adapter.headers = {
|
||||
["anthropic-version"] = "2023-06-01"
|
||||
["anthropic-version"] = "2023-06-01",
|
||||
}
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- ============================================================
|
||||
-- helpers
|
||||
-- ============================================================
|
||||
|
||||
-- 将 OpenAI 风格 content(字符串或 [{type:*}] 数组)拆成文本/图片块
|
||||
local function collect_blocks(content)
|
||||
if type(content) == "string" then
|
||||
return { { type = "text", text = content } }
|
||||
end
|
||||
local blocks = {}
|
||||
for _, p in ipairs(content or {}) do
|
||||
local ROLE_WHITELIST = {
|
||||
system = true, user = true, assistant = true, tool = true,
|
||||
}
|
||||
|
||||
local function collect_blocks(content)
|
||||
if type(content) == "string" then
|
||||
if content == "" then return {} end
|
||||
return { { type = "text", text = content } }
|
||||
end
|
||||
if type(content) ~= "table" then return {} end
|
||||
local blocks = {}
|
||||
for _, p in ipairs(content) do
|
||||
if type(p) == "string" then
|
||||
table.insert(blocks, { type = "text", text = p })
|
||||
elseif type(p) == "table" then
|
||||
if p.type == "text" then
|
||||
table.insert(blocks, { type = "text", text = p.text })
|
||||
table.insert(blocks, { type = "text", text = p.text or "" })
|
||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
|
||||
local url = p.image_url.url
|
||||
local mt, b64 = string.match(url, "^data:([^,]+);base64,(.+)$")
|
||||
if b64 then
|
||||
table.insert(blocks, { type = "image", source = { type = "base64", media_type = mt or "image/png", data = b64 } })
|
||||
else
|
||||
table.insert(blocks, { type = "image", source = { type = "url", url = p.image_url.url } })
|
||||
table.insert(blocks, { type = "image", source = { type = "url", url = url } })
|
||||
end
|
||||
else
|
||||
-- Unknown content type: preserve for forward compatibility
|
||||
table.insert(blocks, p)
|
||||
end
|
||||
end
|
||||
return blocks
|
||||
end
|
||||
local function text_of(content)
|
||||
if type(content) == "string" then return content end
|
||||
local t = ""
|
||||
for _, p in ipairs(content or {}) do
|
||||
if p.type == "text" and p.text then t = t .. p.text end
|
||||
return blocks
|
||||
end
|
||||
|
||||
local function text_of(content)
|
||||
if type(content) == "string" then return content end
|
||||
local t = ""
|
||||
for _, p in ipairs(content or {}) do
|
||||
if type(p) == "string" then
|
||||
t = t .. p
|
||||
elseif type(p) == "table" and p.type == "text" and p.text then
|
||||
t = t .. p.text
|
||||
end
|
||||
return t
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
--- Append blocks to the last user message (merge) or create a new one.
|
||||
local function append_user(msgs, blocks)
|
||||
if #msgs > 0 and msgs[#msgs].role == "user" then
|
||||
for _, b in ipairs(blocks) do
|
||||
table.insert(msgs[#msgs].content, b)
|
||||
end
|
||||
else
|
||||
table.insert(msgs, { role = "user", content = blocks })
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- transform_request (OpenAI → Anthropic)
|
||||
-- ============================================================
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok or type(req) ~= "table" then return raw_body end
|
||||
|
||||
local msgs = {}
|
||||
local system = ""
|
||||
local pending_tool = {} -- accumulated tool_result blocks
|
||||
|
||||
for _, m in ipairs(req.messages or {}) do
|
||||
if m.role == "system" then
|
||||
local role = m.role or "user"
|
||||
|
||||
if role == "system" then
|
||||
system = system .. text_of(m.content) .. "\n"
|
||||
|
||||
elseif role == "assistant" then
|
||||
-- Build content array: text/image blocks from content + tool_use from tool_calls
|
||||
local blocks = collect_blocks(m.content)
|
||||
|
||||
if type(m.tool_calls) == "table" then
|
||||
for _, tc in ipairs(m.tool_calls) do
|
||||
if type(tc) == "table" then
|
||||
local fn = tc["function"] or {}
|
||||
local args = fn.arguments
|
||||
local input = {}
|
||||
if type(args) == "string" and args ~= "" then
|
||||
local ok2, parsed = pcall(json.decode, args)
|
||||
if ok2 and type(parsed) == "table" then input = parsed end
|
||||
elseif type(args) == "table" then
|
||||
input = args
|
||||
end
|
||||
table.insert(blocks, {
|
||||
type = "tool_use",
|
||||
id = tc.id or "",
|
||||
name = fn.name or "",
|
||||
input = input,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(msgs, { role = "assistant", content = blocks })
|
||||
|
||||
elseif role == "tool" then
|
||||
-- Accumulate consecutive tool results; will be flushed as one
|
||||
-- user message with tool_result content blocks.
|
||||
table.insert(pending_tool, {
|
||||
type = "tool_result",
|
||||
tool_use_id = m.tool_call_id or "",
|
||||
content = text_of(m.content),
|
||||
})
|
||||
|
||||
else
|
||||
table.insert(msgs, { role = m.role, content = collect_blocks(m.content) })
|
||||
-- user / any other role: flush pending tool results first
|
||||
if #pending_tool > 0 then
|
||||
append_user(msgs, pending_tool)
|
||||
pending_tool = {}
|
||||
end
|
||||
append_user(msgs, collect_blocks(m.content))
|
||||
end
|
||||
end
|
||||
|
||||
local anthropic_req = {
|
||||
model = req.model or "claude-sonnet-4-20250514",
|
||||
max_tokens = req.max_tokens or 4096,
|
||||
messages = msgs,
|
||||
stream = req.stream or false,
|
||||
}
|
||||
|
||||
if not req.disable_thinking then
|
||||
anthropic_req.thinking = { type = "enabled", budget_tokens = 4096 }
|
||||
-- Flush any trailing tool results
|
||||
if #pending_tool > 0 then
|
||||
append_user(msgs, pending_tool)
|
||||
end
|
||||
|
||||
-- ── Build Anthropic request ──────────────────────────────
|
||||
local anthropic_req = {
|
||||
model = req.model or "claude-sonnet-4-20250514",
|
||||
max_tokens = req.max_tokens or 4096,
|
||||
messages = msgs,
|
||||
stream = req.stream or false,
|
||||
}
|
||||
|
||||
-- ── tools ────────────────────────────────────────────────
|
||||
local has_tools = false
|
||||
if req.tools and type(req.tools) == "table" then
|
||||
local tools = {}
|
||||
for _, t in ipairs(req.tools) do
|
||||
if type(t) == "table" and t.type == "function"
|
||||
and type(t["function"]) == "table" then
|
||||
local fn = t["function"]
|
||||
table.insert(tools, {
|
||||
name = fn.name or "",
|
||||
description = fn.description or "",
|
||||
input_schema = fn.parameters or { type = "object", properties = {} },
|
||||
})
|
||||
end
|
||||
end
|
||||
if #tools > 0 then
|
||||
anthropic_req.tools = tools
|
||||
has_tools = true
|
||||
end
|
||||
end
|
||||
|
||||
-- ── tool_choice ──────────────────────────────────────────
|
||||
-- OpenAI → Anthropic mapping:
|
||||
-- "auto" → {type:"auto"}
|
||||
-- "none" → remove tools entirely (Anthropic has no "none")
|
||||
-- "required" → {type:"any"}
|
||||
-- {type:"function", function:{name:"X"}} → {type:"tool", name:"X"}
|
||||
if has_tools and req.tool_choice then
|
||||
local tc = req.tool_choice
|
||||
if type(tc) == "string" then
|
||||
if tc == "auto" then
|
||||
anthropic_req.tool_choice = { type = "auto" }
|
||||
elseif tc == "none" then
|
||||
anthropic_req.tools = nil
|
||||
anthropic_req.tool_choice = nil
|
||||
elseif tc == "required" then
|
||||
anthropic_req.tool_choice = { type = "any" }
|
||||
end
|
||||
elseif type(tc) == "table" and tc.type == "function" then
|
||||
local fn = tc["function"] or {}
|
||||
anthropic_req.tool_choice = { type = "tool", name = fn.name or "" }
|
||||
end
|
||||
end
|
||||
|
||||
-- ── thinking (opt-in via extra_body) ──────────────────────
|
||||
-- Default: OFF. Client sends extra_body.thinking to enable.
|
||||
-- Example: {"extra_body": {"thinking": {"type": "enabled", "budget_tokens": 4096}}}
|
||||
if type(req.extra_body) == "table" and type(req.extra_body.thinking) == "table" then
|
||||
anthropic_req.thinking = req.extra_body.thinking
|
||||
end
|
||||
|
||||
-- ── system ───────────────────────────────────────────────
|
||||
if system ~= "" then
|
||||
anthropic_req.system = system
|
||||
-- Strip trailing newline from accumulation
|
||||
anthropic_req.system = string.match(system, "^(.-)\n*$")
|
||||
end
|
||||
|
||||
return json.encode(anthropic_req)
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- transform_response (Anthropic → OpenAI, non-streaming)
|
||||
-- ============================================================
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
if not ok or resp == nil then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 },
|
||||
}
|
||||
|
||||
if resp.usage then
|
||||
unified.token_usage.prompt = resp.usage.input_tokens or 0
|
||||
unified.token_usage.prompt = resp.usage.input_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.output_tokens or 0
|
||||
unified.token_usage.total = (resp.usage.input_tokens or 0) + (resp.usage.output_tokens or 0)
|
||||
-- Anthropic reports cache_read_input_tokens; normalize into
|
||||
-- OpenAI-standard prompt_tokens_details.cached_tokens so clients
|
||||
-- (dsh) see the cache hit count.
|
||||
local cacheRead = resp.usage.cache_read_input_tokens or 0
|
||||
if cacheRead > 0 then
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = cacheRead }
|
||||
unified.token_usage.total = (resp.usage.input_tokens or 0) + (resp.usage.output_tokens or 0)
|
||||
-- Emit details whenever Anthropic reports the field, even at 0, so a
|
||||
-- reported cache miss stays distinguishable from "not reported".
|
||||
if resp.usage.cache_read_input_tokens ~= nil then
|
||||
unified.token_usage.prompt_tokens_details = {
|
||||
cached_tokens = resp.usage.cache_read_input_tokens
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if resp.content and #resp.content > 0 then
|
||||
local tcs = {}
|
||||
for _, block in ipairs(resp.content) do
|
||||
if block.type == "text" then
|
||||
unified.content = unified.content .. (block.text or "")
|
||||
elseif block.type == "thinking" and block.thinking then
|
||||
unified.reasoning_content = (unified.reasoning_content or "") .. block.thinking
|
||||
elseif block.type == "tool_use" then
|
||||
table.insert(tcs, {
|
||||
id = block.id or "",
|
||||
type = "function",
|
||||
name = block.name or "",
|
||||
arguments = block.input or {},
|
||||
})
|
||||
end
|
||||
end
|
||||
if #tcs > 0 then unified.tool_calls = tcs end
|
||||
end
|
||||
|
||||
unified.finish_reason = resp.stop_reason or ""
|
||||
if unified.finish_reason == "tool_use" then
|
||||
unified.finish_reason = "tool_calls"
|
||||
elseif unified.finish_reason == "max_tokens" then
|
||||
unified.finish_reason = "length"
|
||||
elseif unified.finish_reason == "end_turn"
|
||||
or unified.finish_reason == "stop_sequence" then
|
||||
unified.finish_reason = "stop"
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- transform_stream_chunk (Anthropic SSE → OpenAI SSE delta)
|
||||
-- ============================================================
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
-- ── message_start: initial usage ─────────────────────────
|
||||
if chunk.type == "message_start" then
|
||||
local uses = nil
|
||||
if chunk.message and type(chunk.message.usage) == "table" then
|
||||
local u = chunk.message.usage
|
||||
local p = u.input_tokens or 0
|
||||
local c = u.output_tokens or 0
|
||||
if p > 0 or c > 0 then
|
||||
uses = { prompt = p, completion = c, total = p + c }
|
||||
local cacheRead = u.cache_read_input_tokens or 0
|
||||
if cacheRead > 0 then
|
||||
uses.prompt_tokens_details = { cached_tokens = cacheRead }
|
||||
local uses = { prompt = p, completion = c, total = p + c }
|
||||
if u.cache_read_input_tokens ~= nil then
|
||||
uses.prompt_tokens_details = {
|
||||
cached_tokens = u.cache_read_input_tokens
|
||||
}
|
||||
end
|
||||
return json.encode({ usage = uses, done = false })
|
||||
end
|
||||
end
|
||||
if uses ~= nil then
|
||||
return json.encode({ usage = uses, done = false })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
-- ── message_delta: stop_reason + final usage ─────────────
|
||||
if chunk.type == "message_delta" then
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
local u = chunk.usage
|
||||
local p = u.input_tokens or 0
|
||||
local c = u.output_tokens or 0
|
||||
if p > 0 or c > 0 then
|
||||
uses = { prompt = p, completion = c, total = p + c }
|
||||
end
|
||||
end
|
||||
local finish = nil
|
||||
if chunk.delta and chunk.delta.stop_reason ~= nil then
|
||||
-- Anthropic stop_reason -> OpenAI finish_reason
|
||||
local sr = chunk.delta.stop_reason
|
||||
if sr == "max_tokens" then
|
||||
finish = "length"
|
||||
@ -147,58 +305,95 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
finish = "stop"
|
||||
end
|
||||
end
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
local u = chunk.usage
|
||||
local p = u.input_tokens or 0
|
||||
local c = u.output_tokens or 0
|
||||
if p > 0 or c > 0 then
|
||||
uses = { prompt = p, completion = c, total = p + c }
|
||||
end
|
||||
end
|
||||
if uses ~= nil then
|
||||
-- completion is final here; prompt is merged from message_start
|
||||
return json.encode({ content = "", done = (finish ~= nil), finish_reason = finish, usage = uses })
|
||||
end
|
||||
return json.encode({ content = "", done = (finish ~= nil), finish_reason = finish })
|
||||
end
|
||||
if chunk.type == "content_block_start" and chunk.content_block
|
||||
and chunk.content_block.type == "tool_use" then
|
||||
-- first fragment of a tool call: emit index + id + name, empty args
|
||||
return json.encode({
|
||||
content = "", done = false,
|
||||
tool_calls = { {
|
||||
index = chunk.index or 0,
|
||||
id = chunk.content_block.id or "",
|
||||
type = "function",
|
||||
["function"] = { name = chunk.content_block.name or "", arguments = "" }
|
||||
} }
|
||||
})
|
||||
|
||||
-- ── content_block_start: begin text / thinking / tool_use ─
|
||||
if chunk.type == "content_block_start" and chunk.content_block then
|
||||
local cb = chunk.content_block
|
||||
if cb.type == "tool_use" then
|
||||
-- Pass Anthropic content_block index through; Go-side rewrites
|
||||
-- to sequential OpenAI tool_call ordinal for multi-tool streams.
|
||||
return json.encode({
|
||||
content = "", done = false,
|
||||
tool_calls = { {
|
||||
index = chunk.index or 0,
|
||||
id = cb.id or "",
|
||||
type = "function",
|
||||
["function"] = { name = cb.name or "", arguments = "" },
|
||||
} },
|
||||
})
|
||||
end
|
||||
return "" -- text / thinking block start: no OpenAI equivalent
|
||||
end
|
||||
|
||||
-- ── content_block_delta: incremental content ──────────────
|
||||
if chunk.type == "content_block_delta" and chunk.delta then
|
||||
if chunk.delta.type == "input_json_delta" then
|
||||
-- incremental JSON fragment; clients accumulate across chunks
|
||||
local unified = { content = "", done = false, tool_calls = { {
|
||||
index = chunk.index or 0,
|
||||
id = "",
|
||||
type = "function",
|
||||
["function"] = { name = "", arguments = chunk.delta.partial_json or "" }
|
||||
} } }
|
||||
return json.encode(unified)
|
||||
local d = chunk.delta
|
||||
if d.type == "input_json_delta" then
|
||||
-- Tool call argument fragment; Go-side rewrites index.
|
||||
return json.encode({
|
||||
content = "", done = false,
|
||||
tool_calls = { {
|
||||
index = chunk.index or 0,
|
||||
id = "",
|
||||
type = "function",
|
||||
["function"] = { name = "", arguments = d.partial_json or "" },
|
||||
} },
|
||||
})
|
||||
end
|
||||
if chunk.delta.type == "thinking_delta" and chunk.delta.thinking then
|
||||
return json.encode({ content = "", done = false, reasoning_content = chunk.delta.thinking })
|
||||
if d.type == "thinking_delta" and d.thinking then
|
||||
return json.encode({ content = "", done = false, reasoning_content = d.thinking })
|
||||
end
|
||||
if d.type == "text_delta" and d.text then
|
||||
return json.encode({ content = d.text, done = false })
|
||||
end
|
||||
return json.encode({ content = chunk.delta.text or "", done = false })
|
||||
end
|
||||
|
||||
-- ── content_block_stop / message_stop ─────────────────────
|
||||
if chunk.type == "message_stop" then
|
||||
return json.encode({ content = "", done = true })
|
||||
end
|
||||
if chunk.type == "content_block_stop" then
|
||||
return json.encode({ content = "", done = false })
|
||||
-- The message_delta event already emitted the true finish_reason.
|
||||
-- Do NOT emit done=true here: an empty finish_reason would
|
||||
-- overwrite the real one (tool_calls) in the Go gateway's
|
||||
-- lastFinish tracker, causing the final SSE chunk to say
|
||||
-- finish_reason=stop instead of tool_calls.
|
||||
return ""
|
||||
end
|
||||
|
||||
return ""
|
||||
end
|
||||
|
||||
-- 错误收敛:Anthropic 信封 {type:"error", error:{type, message}}
|
||||
-- ============================================================
|
||||
-- transform_error (Anthropic error → human-readable string)
|
||||
-- ============================================================
|
||||
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
|
||||
-- Anthropic envelope: {type:"error", error:{type, message}}
|
||||
if resp.type == "error" and type(resp.error) == "table"
|
||||
and type(resp.error.message) == "string" then
|
||||
return resp.error.message
|
||||
end
|
||||
|
||||
-- Flat envelope: {error: {message: "..."}}
|
||||
if type(resp.error) == "table" and type(resp.error.message) == "string" then
|
||||
return resp.error.message
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
@ -68,20 +68,55 @@ function adapter.transform_response(raw_body)
|
||||
unified.token_usage.prompt = resp.usageMetadata.promptTokenCount or 0
|
||||
unified.token_usage.completion = resp.usageMetadata.candidatesTokenCount or 0
|
||||
unified.token_usage.total = resp.usageMetadata.totalTokenCount or 0
|
||||
-- Gemini reports context-cache reads as cachedContentTokenCount;
|
||||
-- normalize into OpenAI-standard prompt_tokens_details.cached_tokens
|
||||
-- so clients and the audit trail see the hit count. Emitted even when
|
||||
-- 0 so a reported miss stays distinguishable from "not reported".
|
||||
if resp.usageMetadata.cachedContentTokenCount ~= nil then
|
||||
unified.token_usage.prompt_tokens_details = {
|
||||
cached_tokens = resp.usageMetadata.cachedContentTokenCount
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if resp.candidates and #resp.candidates > 0 then
|
||||
local cand = resp.candidates[1]
|
||||
local tools = {}
|
||||
if cand.content and cand.content.parts then
|
||||
for _, part in ipairs(cand.content.parts) do
|
||||
if part.text then
|
||||
unified.content = unified.content .. part.text
|
||||
elseif part.functionCall then
|
||||
-- Non-streaming tool calls used to be dropped here while
|
||||
-- transform_stream_chunk handled them, so a non-streaming
|
||||
-- agent turn looked like a plain text answer and the tool
|
||||
-- loop died. Gemini's args are already an object.
|
||||
local args = part.functionCall.args
|
||||
if type(args) == "string" then
|
||||
local aok, decoded = pcall(json.decode, args)
|
||||
args = aok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
table.insert(tools, {
|
||||
id = part.functionCall.id or ("call_" .. #tools),
|
||||
type = "function",
|
||||
name = part.functionCall.name or "",
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
if cand.finishReason then
|
||||
unified.finish_reason = cand.finishReason
|
||||
end
|
||||
if #tools > 0 then
|
||||
unified.tool_calls = tools
|
||||
-- Gemini reports finishReason "STOP" even when it emitted a
|
||||
-- functionCall; clients keyed on finish_reason would treat that as
|
||||
-- a completed answer and never run the tool.
|
||||
unified.finish_reason = "tool_calls"
|
||||
end
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
@ -100,9 +135,12 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
local t = chunk.usageMetadata.totalTokenCount or 0
|
||||
if p > 0 or c > 0 or t > 0 then
|
||||
uses = { prompt = p, completion = c, total = t }
|
||||
local cacheRead = chunk.usageMetadata.cachedContentTokenCount or 0
|
||||
if cacheRead > 0 then
|
||||
uses.prompt_tokens_details = { cached_tokens = cacheRead }
|
||||
-- Emit details whenever the field is present, even at 0, so a
|
||||
-- reported cache miss stays distinguishable from "not reported".
|
||||
if chunk.usageMetadata.cachedContentTokenCount ~= nil then
|
||||
uses.prompt_tokens_details = {
|
||||
cached_tokens = chunk.usageMetadata.cachedContentTokenCount
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -58,13 +58,39 @@ function adapter.transform_response(raw_body)
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = resp.done_reason or "",
|
||||
tool_calls = {},
|
||||
-- key must be token_usage to match Go's UnifiedResponse json tag
|
||||
token_usage = { prompt = p, completion = c, total = p + c }
|
||||
}
|
||||
|
||||
if resp.message then
|
||||
unified.content = resp.message.content or ""
|
||||
-- Non-streaming tool calls were previously dropped: the field was
|
||||
-- initialized to an empty table and never filled, while
|
||||
-- transform_stream_chunk handled them. A non-streaming agent turn thus
|
||||
-- looked like a plain answer and the tool loop stopped.
|
||||
if type(resp.message.tool_calls) == "table" and #resp.message.tool_calls > 0 then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(resp.message.tool_calls) do
|
||||
local fn = tc["function"] or {}
|
||||
-- Ollama sends arguments as an object already
|
||||
local args = fn.arguments
|
||||
if type(args) == "string" then
|
||||
local aok, decoded = pcall(json.decode, args)
|
||||
args = aok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
table.insert(tcs, {
|
||||
id = tc.id or ("call_" .. #tcs),
|
||||
type = tc.type or "function",
|
||||
name = fn.name or "",
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
-- Ollama reports done_reason "stop" alongside tool calls
|
||||
unified.finish_reason = "tool_calls"
|
||||
end
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
|
||||
@ -136,13 +136,43 @@ end
|
||||
|
||||
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
-- Non-JSON body (nginx HTML error pages, plain text): extract a short
|
||||
-- human-readable reason instead of letting the raw body reach the log.
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
if not ok or type(resp) ~= "table" then
|
||||
-- HTML error page: pull the <title> text (e.g. "413 Request Entity Too Large")
|
||||
local title = string.match(body or "", "<title>(.-)</title>")
|
||||
if title and title ~= "" then return title end
|
||||
-- Bare text: first non-empty line, capped
|
||||
local line = string.match(body or "", "^%s*([^\r\n]+)")
|
||||
if line and line ~= "" and not string.match(line, "^<") then
|
||||
return string.sub(line, 1, 200)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Standard OpenAI envelope: {error:{message,...}} or {error:"..."}
|
||||
local e = resp.error
|
||||
if type(e) == "table" and type(e.message) == "string" then
|
||||
return e.message
|
||||
end
|
||||
if type(e) == "string" then return e end
|
||||
|
||||
-- Flat envelope used by many OpenAI-compatible gateways:
|
||||
-- {"code":20012,"message":"Model does not exist..."}
|
||||
-- {"code":"INVALID_API_KEY","message":"Invalid API key"}
|
||||
if type(resp.message) == "string" and resp.message ~= "" then
|
||||
if resp.code ~= nil then
|
||||
return tostring(resp.code) .. ": " .. resp.message
|
||||
end
|
||||
return resp.message
|
||||
end
|
||||
|
||||
-- Some gateways use {detail:"..."} (FastAPI style)
|
||||
if type(resp.detail) == "string" and resp.detail ~= "" then
|
||||
return resp.detail
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
@ -57,6 +57,37 @@ function adapter.transform_response(raw_body)
|
||||
unified.content = ch.message.content or ""
|
||||
if ch.message.reasoning_content then
|
||||
unified.reasoning_content = ch.message.reasoning_content
|
||||
elseif ch.message.reasoning then
|
||||
-- sensenova-6.8-flash-lite 用 reasoning 字段而不是 reasoning_content
|
||||
unified.reasoning_content = ch.message.reasoning
|
||||
end
|
||||
-- Tool calls MUST be forwarded. Dropping them while keeping
|
||||
-- finish_reason="tool_calls" makes the client replay an assistant
|
||||
-- message whose function name/arguments are empty, and sensenova
|
||||
-- then rejects the next turn with
|
||||
-- 400 invalid tool_call function, function/name/arguments cannot be empty
|
||||
-- i.e. a tool-using conversation dies on its second request.
|
||||
if type(ch.message.tool_calls) == "table" and #ch.message.tool_calls > 0 then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local fn = tc["function"] or {}
|
||||
-- arguments arrives as a JSON *string* on the wire; the
|
||||
-- unified shape expects a decoded object.
|
||||
local args = fn.arguments
|
||||
if type(args) == "string" then
|
||||
local args_ok, decoded = pcall(json.decode, args)
|
||||
args = args_ok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = fn.name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
@ -97,10 +128,11 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
|
||||
-- Sensenova: delta has reasoning_content but no content
|
||||
-- Sensenova: delta has reasoning_content (deepseek-v4-flash) or reasoning
|
||||
-- (sensenova-6.8-flash-lite) but no content field
|
||||
local content = delta.content or ""
|
||||
if content == "" and delta.reasoning_content then
|
||||
content = delta.reasoning_content
|
||||
if content == "" and (delta.reasoning_content or delta.reasoning) then
|
||||
content = delta.reasoning_content or delta.reasoning
|
||||
end
|
||||
|
||||
-- Sensenova: finish_reason is "" on every chunk, "stop" on last
|
||||
|
||||
269
internal/lua/adapters/trae.lua
Normal file
269
internal/lua/adapters/trae.lua
Normal file
@ -0,0 +1,269 @@
|
||||
-- trae 源适配器(trae-local-api 的 OpenAI 兼容代理)
|
||||
-- trae-local-api 运行在 http://127.0.0.1:19900,接 Trae CN 账号的云资源。
|
||||
-- 协议:OpenAI /v1/chat/completions
|
||||
-- 特性:
|
||||
-- - 排队:上游 busy 时排队,可能长时间无响应
|
||||
-- - 非流式请求偶发返回 SSE 数据(upstream bug)
|
||||
-- - 支持 thinking,由上游模型自动决定是否开启
|
||||
|
||||
local adapter = {}
|
||||
adapter.name = "trae"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/v1/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
req.disable_thinking = nil
|
||||
req.extra_body = nil
|
||||
if req.messages then
|
||||
for _, msg in ipairs(req.messages) do
|
||||
msg.reasoning_content = nil
|
||||
end
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- parse_text_tool_calls extracts tool calls that an upstream emitted as PLAIN
|
||||
-- TEXT instead of using the OpenAI tool_calls field.
|
||||
--
|
||||
-- trae-local-api's OpenAI endpoint (/v1/chat/completions) does not read the
|
||||
-- request's `tools` array at all, so the relayed model is never told the tool
|
||||
-- schema; it falls back to printing
|
||||
-- <tool_call>
|
||||
-- {"name": "get_weather", "arguments": {"location": "北京"}}
|
||||
-- </tool_call>
|
||||
-- into message.content, leaves message.tool_calls null, and reports
|
||||
-- finish_reason="stop". A client following the OpenAI contract therefore never
|
||||
-- sees a tool call: the agent loop terminates unexpectedly mid-conversation
|
||||
-- (and a hand-written replay produces an empty function name next turn).
|
||||
--
|
||||
-- Both tag spellings are accepted: the same codebase's Anthropic endpoint
|
||||
-- instructs models to emit <toolcall>, and models mix the two. Key names vary
|
||||
-- too (arguments / params / input), so all are tried.
|
||||
--
|
||||
-- Returns (tool_calls_array_or_nil, content_with_blocks_removed).
|
||||
local function parse_text_tool_calls(content)
|
||||
if type(content) ~= "string" or content == "" then return nil, content end
|
||||
if not (content:find("<tool_call", 1, true) or content:find("<toolcall", 1, true)) then
|
||||
return nil, content
|
||||
end
|
||||
|
||||
local tcs = {}
|
||||
local idx = 0
|
||||
|
||||
local function collect(pattern)
|
||||
for payload in content:gmatch(pattern) do
|
||||
local ok, obj = pcall(json.decode, payload)
|
||||
if ok and type(obj) == "table" then
|
||||
-- some models wrap it as {"function":{"name":..,"arguments":..}}
|
||||
local fn = obj["function"]
|
||||
local name = obj.name or (type(fn) == "table" and fn.name) or nil
|
||||
if name then
|
||||
local args = obj.arguments or obj.params or obj.input
|
||||
if args == nil and type(fn) == "table" then
|
||||
args = fn.arguments or fn.params
|
||||
end
|
||||
if type(args) == "string" then
|
||||
local aok, decoded = pcall(json.decode, args)
|
||||
args = aok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
idx = idx + 1
|
||||
table.insert(tcs, {
|
||||
id = obj.id or ("call_text_" .. idx),
|
||||
type = "function",
|
||||
name = name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- `<tag ...>` allows attributes; %s* handles the "</tool_call >" spacing
|
||||
-- that trae-local-api's own prompt example uses.
|
||||
collect("<tool_call[^>]*>%s*(.-)%s*</tool_call%s*>")
|
||||
collect("<toolcall[^>]*>%s*(.-)%s*</toolcall%s*>")
|
||||
|
||||
if #tcs == 0 then return nil, content end
|
||||
|
||||
-- drop the blocks from user-visible content; keep any surrounding prose
|
||||
local stripped = content:gsub("<tool_call[^>]*>%s*.-%s*</tool_call%s*>", "")
|
||||
stripped = stripped:gsub("<toolcall[^>]*>%s*.-%s*</toolcall%s*>", "")
|
||||
stripped = stripped:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
return tcs, stripped
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- trae-local-api 偶发在非流式请求中返回 SSE 格式数据,
|
||||
-- 表现为多个 data: {...} 行或混合了 reasoning_chunk 等。
|
||||
-- 剥掉 data: 前缀,取最后一个完整的 JSON 块(通常是
|
||||
-- 最终的 stop chunk 或 usage chunk)。
|
||||
local use_json = raw_body
|
||||
if raw_body:match("^data:") or raw_body:match("\ndata:") then
|
||||
-- 取最后一个 data: 行的 JSON
|
||||
local last = nil
|
||||
for line in raw_body:gmatch("data: ([^\n\r]+)") do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
if trimmed and trimmed ~= "[DONE]" then
|
||||
local ok2, obj = pcall(json.decode, trimmed)
|
||||
if ok2 and type(obj) == "table" then
|
||||
last = obj
|
||||
end
|
||||
end
|
||||
end
|
||||
if last then
|
||||
use_json = json.encode(last)
|
||||
end
|
||||
-- 如果解析失败,回退到原始 body
|
||||
end
|
||||
|
||||
local ok, resp = pcall(json.decode, use_json)
|
||||
if not ok or resp == nil then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||||
}
|
||||
|
||||
if type(resp.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
if ch.message.reasoning_content then
|
||||
unified.reasoning_content = ch.message.reasoning_content
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" and #ch.message.tool_calls > 0 then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local fn = tc["function"] or {}
|
||||
local args = fn.arguments
|
||||
if type(args) == "string" then
|
||||
local args_ok, decoded = pcall(json.decode, args)
|
||||
args = args_ok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = fn.name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
|
||||
-- Fallback: trae-local-api's OpenAI endpoint drops the request's
|
||||
-- `tools` array entirely, so the relayed model is never told the tool
|
||||
-- schema and instead PRINTS a <tool_call>{...}</tool_call> block into
|
||||
-- content, leaving message.tool_calls null and finish_reason="stop".
|
||||
-- A client following the OpenAI contract then sees a normal completion
|
||||
-- and its agent loop terminates mid-conversation. Recover the
|
||||
-- structured call so the loop can continue.
|
||||
if unified.tool_calls == nil then
|
||||
local recovered, cleaned = parse_text_tool_calls(unified.content)
|
||||
if recovered then
|
||||
unified.tool_calls = recovered
|
||||
unified.content = cleaned
|
||||
unified.finish_reason = "tool_calls"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
uses = {
|
||||
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
|
||||
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
|
||||
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
if uses ~= nil then
|
||||
return json.encode({ usage = uses, done = false })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if delta.tool_calls then
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then
|
||||
-- Non-JSON body: extract title from HTML or first line
|
||||
local title = string.match(body or "", "<title>(.-)</title>")
|
||||
if title and title ~= "" then return title end
|
||||
local line = string.match(body or "", "^%s*([^\r\n]+)")
|
||||
if line and line ~= "" and not string.match(line, "^<") then
|
||||
return string.sub(line, 1, 200)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local e = resp.error
|
||||
if type(e) == "table" and type(e.message) == "string" then
|
||||
-- trae quota / rate limit annotations
|
||||
if e.code then
|
||||
return tostring(e.code) .. ": " .. e.message
|
||||
end
|
||||
return e.message
|
||||
end
|
||||
if type(e) == "string" then return e end
|
||||
if type(resp.message) == "string" and resp.message ~= "" then
|
||||
if resp.code ~= nil then
|
||||
return tostring(resp.code) .. ": " .. resp.message
|
||||
end
|
||||
return resp.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -4,9 +4,15 @@
|
||||
//
|
||||
// The runtime uses LuaJIT (github.com/aarzilli/golua) so every adapter runs on
|
||||
// an independent interpreter state. Because a lua.State is NOT goroutine-safe,
|
||||
// each adapter owns a pool of workers (states); the pool size equals the sum
|
||||
// of max_concurrent over all sources that use the adapter, and a worker is
|
||||
// checked out for the duration of a single hook call.
|
||||
// each adapter owns a pool of workers (states) and a worker is checked out for
|
||||
// the duration of a single hook call.
|
||||
//
|
||||
// Pools are ELASTIC: the sum of max_concurrent over the sources using an adapter
|
||||
// is a ceiling, not a preallocation. States are booted on demand in batches
|
||||
// sized by that ceiling, and reclaimed by a single background janitor at a rate
|
||||
// scaled inversely to the adapter's live connection count. An idle gateway
|
||||
// therefore holds close to zero Lua states regardless of how much aggregate
|
||||
// concurrency is configured. See adapterPool for the sizing rules.
|
||||
package lua
|
||||
|
||||
import (
|
||||
@ -23,6 +29,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
// golua exposes the LuaJIT C API. Build with -tags luajit so the cgo
|
||||
// LDFLAGS resolve to -lluajit-5.1 (see golua's lua.go).
|
||||
@ -64,7 +71,20 @@ type staticInfo struct {
|
||||
}
|
||||
|
||||
// adapterPool owns the worker states for one adapter. Idle workers are held in
|
||||
// a slice guarded by a cond; up to target workers are created lazily.
|
||||
// a slice guarded by a cond; workers are created lazily on demand and reclaimed
|
||||
// by the VM janitor once demand drops.
|
||||
//
|
||||
// Sizing is elastic rather than fixed (plan 阶段 2). Three inputs drive it:
|
||||
//
|
||||
// - maxW — the adapter's maximum concurrency (Σ max_concurrent of every source
|
||||
// using it). It is the hard ceiling AND sets the growth step, so a hot,
|
||||
// high-concurrency adapter ramps in batches while a low-concurrency one
|
||||
// creeps up one state at a time.
|
||||
// - created / len(idle) — how many states already exist, i.e. how much room is
|
||||
// left to grow and how much is available to reclaim.
|
||||
// - inUse — how many states are checked out right now, i.e. the live
|
||||
// connection count for this adapter. It sets the shrink step: the fewer
|
||||
// connections, the more aggressively idle states are released.
|
||||
type adapterPool struct {
|
||||
name string
|
||||
script string
|
||||
@ -73,24 +93,164 @@ type adapterPool struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
idle []*worker
|
||||
created int
|
||||
target int
|
||||
created int // live states: len(idle) + inUse (+ states being booted)
|
||||
inUse int // states checked out right now == live connections
|
||||
waiting int // goroutines blocked waiting for a free state
|
||||
maxW int // hard ceiling = Σ max_concurrent of the sources using this adapter
|
||||
used bool
|
||||
closed bool
|
||||
|
||||
lastGrow time.Time
|
||||
idleRounds int // consecutive janitor rounds that saw reclaimable slack
|
||||
peakInUse int // high-water mark of inUse, for observability
|
||||
}
|
||||
|
||||
const (
|
||||
// growDivisor turns the adapter's max concurrency into the growth step CAP:
|
||||
// step <= clamp(ceil(maxW / growDivisor), 1, growStepCap). The actual batch
|
||||
// is additionally bounded by how many callers are queued (see growPlanLocked),
|
||||
// so a wide adapter may ramp in big jumps but never warms states nobody is
|
||||
// waiting for.
|
||||
growDivisor = 8
|
||||
growStepCap = 8
|
||||
// growCooldown keeps a burst of misses from batching repeatedly while the
|
||||
// previous batch is still booting.
|
||||
growCooldown = time.Second
|
||||
// residentWorkers is how many states an adapter keeps warm once it has
|
||||
// served at least one request. Booting is milliseconds, but keeping one warm
|
||||
// removes that from the critical path of the next request. Adapters that
|
||||
// were never used keep nothing.
|
||||
residentWorkers = 1
|
||||
// idleHeadroom is the spare idle state kept above the live connection count,
|
||||
// so the next concurrent request does not have to boot.
|
||||
idleHeadroom = 1
|
||||
// shrinkInterval is how often the VM janitor reclaims idle states.
|
||||
shrinkInterval = 30 * time.Second
|
||||
// shrinkGraceRounds is how many consecutive janitor rounds must see slack
|
||||
// before anything is released. It guards against tearing a pool down between
|
||||
// two bursts — NOT against ordinary traffic: a pool serving one request at a
|
||||
// time still has reclaimable slack, and requiring "no traffic at all" would
|
||||
// pin a burst's leftover states forever on any busy gateway. The counter is
|
||||
// therefore reset by GROWTH (real contention), not by a mere checkout.
|
||||
shrinkGraceRounds = 2
|
||||
)
|
||||
|
||||
// PoolStats is a snapshot of one adapter pool's elastic sizing, surfaced by the
|
||||
// status API so the algorithm is observable instead of a black box.
|
||||
type PoolStats struct {
|
||||
Name string `json:"name"`
|
||||
Created int `json:"created"`
|
||||
Idle int `json:"idle"`
|
||||
InUse int `json:"in_use"`
|
||||
Waiting int `json:"waiting"`
|
||||
Max int `json:"max"`
|
||||
Resident int `json:"resident"`
|
||||
GrowStep int `json:"grow_step"`
|
||||
ShrinkStep int `json:"shrink_step"`
|
||||
PeakInUse int `json:"peak_in_use"`
|
||||
}
|
||||
|
||||
func newAdapterPool(name, script string, static staticInfo) *adapterPool {
|
||||
p := &adapterPool{name: name, script: script, static: static, target: 1}
|
||||
p := &adapterPool{name: name, script: script, static: static, maxW: 1}
|
||||
p.cond = sync.NewCond(&p.mu)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *adapterPool) setTarget(n int) {
|
||||
// setMax updates the pool's hard ceiling (Σ max_concurrent of its sources).
|
||||
// Lowering it does not kill live states: the janitor reclaims the excess.
|
||||
func (p *adapterPool) setMax(n int) {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.target = n
|
||||
p.maxW = n
|
||||
p.cond.Broadcast()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func ceilDiv(a, b int) int {
|
||||
if b <= 0 {
|
||||
return a
|
||||
}
|
||||
return (a + b - 1) / b
|
||||
}
|
||||
|
||||
// growStepLocked is how many states to warm on a miss, derived from the
|
||||
// adapter's maximum concurrency. Caller holds p.mu.
|
||||
func (p *adapterPool) growStepLocked() int {
|
||||
step := ceilDiv(p.maxW, growDivisor)
|
||||
if step < 1 {
|
||||
return 1
|
||||
}
|
||||
if step > growStepCap {
|
||||
return growStepCap
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
// residentLocked is the floor the janitor will not shrink below. Caller holds p.mu.
|
||||
func (p *adapterPool) residentLocked() int {
|
||||
if !p.used {
|
||||
return 0
|
||||
}
|
||||
if residentWorkers > p.maxW {
|
||||
return p.maxW
|
||||
}
|
||||
return residentWorkers
|
||||
}
|
||||
|
||||
// shrinkStepLocked is how many idle states to release this round. The step is
|
||||
// inversely proportional to the live connection count: with no connections the
|
||||
// slack collapses in one round, while a busy adapter gives up one state at a
|
||||
// time so the hot path keeps its warm states. Caller holds p.mu.
|
||||
func (p *adapterPool) shrinkStepLocked() int {
|
||||
excess := p.created - p.keepLocked()
|
||||
if excess <= 0 {
|
||||
return 0
|
||||
}
|
||||
step := ceilDiv(excess, 1+p.inUse)
|
||||
if step > excess {
|
||||
step = excess
|
||||
}
|
||||
if step > len(p.idle) {
|
||||
step = len(p.idle)
|
||||
}
|
||||
if step < 0 {
|
||||
return 0
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
// keepLocked is the number of live states this pool should retain right now.
|
||||
// Caller holds p.mu.
|
||||
func (p *adapterPool) keepLocked() int {
|
||||
keep := p.residentLocked()
|
||||
if h := p.inUse + idleHeadroom; h > keep {
|
||||
keep = h
|
||||
}
|
||||
if keep > p.maxW {
|
||||
keep = p.maxW
|
||||
}
|
||||
return keep
|
||||
}
|
||||
|
||||
func (p *adapterPool) stats() PoolStats {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return PoolStats{
|
||||
Name: p.name,
|
||||
Created: p.created,
|
||||
Idle: len(p.idle),
|
||||
InUse: p.inUse,
|
||||
Waiting: p.waiting,
|
||||
Max: p.maxW,
|
||||
Resident: p.residentLocked(),
|
||||
GrowStep: p.growStepLocked(),
|
||||
ShrinkStep: p.shrinkStepLocked(),
|
||||
PeakInUse: p.peakInUse,
|
||||
}
|
||||
}
|
||||
|
||||
// boot creates a fresh LuaJIT state, registers the shared globals and executes
|
||||
// the adapter script, storing the returned adapter table in the state.
|
||||
func (p *adapterPool) boot() (*worker, error) {
|
||||
@ -110,6 +270,96 @@ func (p *adapterPool) boot() (*worker, error) {
|
||||
return &worker{L: L}, nil
|
||||
}
|
||||
|
||||
// growPlanLocked reserves capacity for a batch prewarm and returns how many
|
||||
// EXTRA states to boot in the background (the caller already reserved one for
|
||||
// itself).
|
||||
//
|
||||
// Two independent limits apply, and BOTH matter:
|
||||
//
|
||||
// - the adapter's max concurrency caps the step (growStepLocked), so a
|
||||
// high-concurrency adapter is allowed to ramp in bigger jumps than a
|
||||
// narrow one;
|
||||
// - the number of goroutines actually BLOCKED waiting for a state bounds it
|
||||
// to real demand.
|
||||
//
|
||||
// Sizing on the ceiling alone over-provisions badly: an adapter with
|
||||
// max_concurrent=76 has a step of 8, so two concurrent requests would warm 8
|
||||
// states, and the janitor would throw 7 of them away a minute later — boot,
|
||||
// discard, repeat, with RSS oscillating for no benefit. Prewarming only for
|
||||
// goroutines that are genuinely queued keeps the ramp cheap without the churn.
|
||||
//
|
||||
// Caller holds p.mu.
|
||||
func (p *adapterPool) growPlanLocked(contended bool) int {
|
||||
if !contended || p.waiting <= 0 {
|
||||
return 0
|
||||
}
|
||||
if time.Since(p.lastGrow) < growCooldown {
|
||||
return 0
|
||||
}
|
||||
extra := p.growStepLocked() - 1
|
||||
if extra > p.waiting {
|
||||
extra = p.waiting // never warm more than the queue needs
|
||||
}
|
||||
if extra <= 0 {
|
||||
return 0
|
||||
}
|
||||
if room := p.maxW - p.created; extra > room {
|
||||
extra = room
|
||||
}
|
||||
if extra <= 0 {
|
||||
return 0
|
||||
}
|
||||
// Reserve immediately so concurrent acquires respect the ceiling while the
|
||||
// batch is still booting.
|
||||
p.created += extra
|
||||
p.lastGrow = time.Now()
|
||||
return extra
|
||||
}
|
||||
|
||||
// prewarm boots n reserved states and publishes them as idle. A boot failure
|
||||
// releases its reservation; it is not fatal, since the synchronous path
|
||||
// surfaces adapter errors already.
|
||||
func (p *adapterPool) prewarm(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
p.mu.Lock()
|
||||
closed := p.closed
|
||||
p.mu.Unlock()
|
||||
if closed {
|
||||
p.releaseReservation(n - i)
|
||||
return
|
||||
}
|
||||
w, err := p.boot()
|
||||
if err != nil {
|
||||
p.releaseReservation(n - i)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.created--
|
||||
p.mu.Unlock()
|
||||
w.close()
|
||||
p.releaseReservation(n - i - 1)
|
||||
return
|
||||
}
|
||||
p.idle = append(p.idle, w)
|
||||
p.cond.Signal()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *adapterPool) releaseReservation(n int) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.created -= n
|
||||
if p.created < 0 {
|
||||
p.created = 0
|
||||
}
|
||||
p.cond.Broadcast()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *adapterPool) acquire() (*worker, error) {
|
||||
p.mu.Lock()
|
||||
for {
|
||||
@ -120,32 +370,68 @@ func (p *adapterPool) acquire() (*worker, error) {
|
||||
if n := len(p.idle); n > 0 {
|
||||
w := p.idle[n-1]
|
||||
p.idle = p.idle[:n-1]
|
||||
p.checkoutLocked()
|
||||
p.mu.Unlock()
|
||||
return w, nil
|
||||
}
|
||||
if p.created < p.target {
|
||||
if p.created < p.maxW {
|
||||
// Every existing state busy on a miss = real concurrency, not a
|
||||
// sequential caller reusing one warm state.
|
||||
contended := p.created > 0 && p.inUse >= p.created
|
||||
p.created++
|
||||
break
|
||||
// Growing is the signal that capacity is genuinely short, so any
|
||||
// pending shrink decision is stale.
|
||||
p.idleRounds = 0
|
||||
extra := p.growPlanLocked(contended)
|
||||
p.mu.Unlock()
|
||||
if extra > 0 {
|
||||
go p.prewarm(extra)
|
||||
}
|
||||
w, err := p.boot()
|
||||
if err != nil {
|
||||
p.mu.Lock()
|
||||
p.created--
|
||||
p.cond.Signal()
|
||||
p.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.checkoutLocked()
|
||||
p.mu.Unlock()
|
||||
return w, nil
|
||||
}
|
||||
p.waiting++
|
||||
p.cond.Wait()
|
||||
p.waiting--
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
w, err := p.boot()
|
||||
if err != nil {
|
||||
p.mu.Lock()
|
||||
p.created--
|
||||
p.cond.Signal()
|
||||
p.mu.Unlock()
|
||||
return nil, err
|
||||
// checkoutLocked accounts one state as handed out. Caller holds p.mu.
|
||||
func (p *adapterPool) checkoutLocked() {
|
||||
p.inUse++
|
||||
p.used = true
|
||||
if p.inUse > p.peakInUse {
|
||||
p.peakInUse = p.inUse
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (p *adapterPool) release(w *worker) {
|
||||
w.L.SetTop(0)
|
||||
p.mu.Lock()
|
||||
if p.inUse > 0 {
|
||||
p.inUse--
|
||||
}
|
||||
if p.closed {
|
||||
p.created--
|
||||
p.mu.Unlock()
|
||||
w.close()
|
||||
return
|
||||
}
|
||||
// A lowered ceiling is honoured on the spot rather than waiting for the
|
||||
// janitor: returning a state the pool may no longer keep closes it.
|
||||
if p.created > p.maxW {
|
||||
p.created--
|
||||
p.cond.Signal()
|
||||
p.mu.Unlock()
|
||||
w.close()
|
||||
return
|
||||
@ -155,11 +441,42 @@ func (p *adapterPool) release(w *worker) {
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// reclaim releases idle states down to the current keep target, using a step
|
||||
// scaled by the live connection count. It returns how many states were closed.
|
||||
func (p *adapterPool) reclaim() int {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return 0
|
||||
}
|
||||
step := p.shrinkStepLocked()
|
||||
if step <= 0 {
|
||||
p.idleRounds = 0
|
||||
p.mu.Unlock()
|
||||
return 0
|
||||
}
|
||||
p.idleRounds++
|
||||
if p.idleRounds < shrinkGraceRounds {
|
||||
p.mu.Unlock()
|
||||
return 0
|
||||
}
|
||||
victims := append([]*worker(nil), p.idle[len(p.idle)-step:]...)
|
||||
p.idle = p.idle[:len(p.idle)-step]
|
||||
p.created -= step
|
||||
p.idleRounds = 0
|
||||
p.mu.Unlock()
|
||||
for _, w := range victims {
|
||||
w.close()
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
func (p *adapterPool) shutdown() {
|
||||
p.mu.Lock()
|
||||
p.closed = true
|
||||
idle := p.idle
|
||||
p.idle = nil
|
||||
p.created -= len(idle)
|
||||
p.cond.Broadcast()
|
||||
p.mu.Unlock()
|
||||
for _, w := range idle {
|
||||
@ -173,13 +490,84 @@ type VM struct {
|
||||
mu sync.RWMutex
|
||||
dir string
|
||||
pools map[string]*adapterPool
|
||||
|
||||
// janitor drives elastic shrinking. One goroutine serves every pool (not
|
||||
// one per adapter), so an idle gateway costs a single sleeping goroutine.
|
||||
janitorOnce sync.Once
|
||||
janitorStop chan struct{}
|
||||
janitorDone chan struct{}
|
||||
}
|
||||
|
||||
func NewVM(dir string) *VM {
|
||||
return &VM{dir: dir, pools: map[string]*adapterPool{}}
|
||||
return &VM{
|
||||
dir: dir,
|
||||
pools: map[string]*adapterPool{},
|
||||
janitorStop: make(chan struct{}),
|
||||
janitorDone: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// startJanitor launches the single shrink loop. Idempotent.
|
||||
func (v *VM) startJanitor() {
|
||||
v.janitorOnce.Do(func() {
|
||||
go v.janitorLoop(shrinkInterval)
|
||||
})
|
||||
}
|
||||
|
||||
func (v *VM) janitorLoop(every time.Duration) {
|
||||
defer close(v.janitorDone)
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-v.janitorStop:
|
||||
return
|
||||
case <-t.C:
|
||||
v.reclaimIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reclaimIdle runs one shrink round over every pool. Exported behaviour is
|
||||
// tested through ReclaimIdleNow.
|
||||
func (v *VM) reclaimIdle() int {
|
||||
v.mu.RLock()
|
||||
pools := make([]*adapterPool, 0, len(v.pools))
|
||||
for _, p := range v.pools {
|
||||
pools = append(pools, p)
|
||||
}
|
||||
v.mu.RUnlock()
|
||||
n := 0
|
||||
for _, p := range pools {
|
||||
n += p.reclaim()
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ReclaimIdleNow forces one shrink round and reports how many worker states
|
||||
// were closed. Intended for tests and for an explicit "release idle memory"
|
||||
// action; normal operation relies on the janitor.
|
||||
func (v *VM) ReclaimIdleNow() int { return v.reclaimIdle() }
|
||||
|
||||
// PoolStats returns the elastic sizing snapshot of every loaded adapter,
|
||||
// sorted by name.
|
||||
func (v *VM) PoolStats() []PoolStats {
|
||||
v.mu.RLock()
|
||||
pools := make([]*adapterPool, 0, len(v.pools))
|
||||
for _, p := range v.pools {
|
||||
pools = append(pools, p)
|
||||
}
|
||||
v.mu.RUnlock()
|
||||
out := make([]PoolStats, 0, len(pools))
|
||||
for _, p := range pools {
|
||||
out = append(out, p.stats())
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func (v *VM) Start() error {
|
||||
v.startJanitor()
|
||||
if v.dir == "" {
|
||||
return nil
|
||||
}
|
||||
@ -214,6 +602,12 @@ func (v *VM) Start() error {
|
||||
}
|
||||
|
||||
func (v *VM) Stop() {
|
||||
select {
|
||||
case <-v.janitorStop:
|
||||
// already stopped
|
||||
default:
|
||||
close(v.janitorStop)
|
||||
}
|
||||
v.mu.Lock()
|
||||
pools := v.pools
|
||||
v.pools = map[string]*adapterPool{}
|
||||
@ -223,17 +617,17 @@ func (v *VM) Stop() {
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureConcurrency sets each adapter's worker target to the sum of
|
||||
// max_concurrent of every source using it. Values below one are clamped to 1.
|
||||
// ConfigureConcurrency sets each adapter's worker CEILING to the sum of
|
||||
// max_concurrent of every source using it. It is a ceiling, not a preallocation:
|
||||
// states are booted on demand and reclaimed when demand drops, so a config with
|
||||
// a large aggregate concurrency no longer implies a large resident pool.
|
||||
// Values below one are clamped to 1.
|
||||
func (v *VM) ConfigureConcurrency(adapterConcurrency map[string]int) {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
for name, n := range adapterConcurrency {
|
||||
if p, ok := v.pools[name]; ok {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
p.setTarget(n)
|
||||
p.setMax(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -260,16 +654,19 @@ func (v *VM) LoadAdapter(path string) error {
|
||||
}
|
||||
|
||||
// LoadAdapterSource registers an adapter from source code. Replacing an
|
||||
// existing adapter shuts the old pool down and re-boots lazily.
|
||||
// existing adapter shuts the old pool down and re-boots lazily, carrying the
|
||||
// configured ceiling over so a hot-reload does not reset elastic sizing.
|
||||
func (v *VM) LoadAdapterSource(name, code string) error {
|
||||
static, err := inspectScript(name, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := 1
|
||||
maxW := 1
|
||||
v.mu.RLock()
|
||||
if p, ok := v.pools[static.name]; ok {
|
||||
target = p.target
|
||||
p.mu.Lock()
|
||||
maxW = p.maxW
|
||||
p.mu.Unlock()
|
||||
}
|
||||
v.mu.RUnlock()
|
||||
|
||||
@ -278,9 +675,10 @@ func (v *VM) LoadAdapterSource(name, code string) error {
|
||||
p.shutdown()
|
||||
}
|
||||
p := newAdapterPool(static.name, code, *static)
|
||||
p.setTarget(target)
|
||||
p.setMax(maxW)
|
||||
v.pools[static.name] = p
|
||||
v.mu.Unlock()
|
||||
v.startJanitor()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -13,6 +13,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@ -33,21 +34,83 @@ import (
|
||||
// cooldown always expires and any success resets the state, so a fixed
|
||||
// upstream recovers on its own.
|
||||
type ModelState struct {
|
||||
pref atomic.Int64 // +1 per success / -5 per failure, clamped
|
||||
pref atomic.Int64 // +prefSuccessStep per success / -prefFailStep per failure, clamped
|
||||
failCount atomic.Int64
|
||||
cooldownUntil atomic.Int64 // unix seconds; 0 = schedulable
|
||||
// prefTouched is the unix second pref was last moved by an outcome. It is
|
||||
// the reference point for decay: a negative score with no traffic since
|
||||
// prefDecayAfter drifts one step toward 0 per elapsed interval, so a slot
|
||||
// cannot stay written off forever just because nothing selected it.
|
||||
prefTouched atomic.Int64
|
||||
// cooldownFrom is the unix second the current cooldown window opened. It
|
||||
// is the reference point for the half-cooldown probe gate: probing is
|
||||
// allowed only in the second half of [cooldownFrom, cooldownUntil), so a
|
||||
// freshly failed slot stays fully silent for a while instead of being
|
||||
// hammered immediately.
|
||||
cooldownFrom atomic.Int64
|
||||
// probeInFlight is the single-token probe permit. At most one probe
|
||||
// request may be in flight per (source, model) while cooling; the token is
|
||||
// released by ProbeDone regardless of outcome.
|
||||
probeInFlight atomic.Bool
|
||||
}
|
||||
|
||||
const (
|
||||
// prefFailStep is the penalty for a REAL failure (5xx, transport error,
|
||||
// unusable adapter output): something is wrong with the upstream or the
|
||||
// model, so two of them are enough to push a slot to the back of its tier.
|
||||
prefFailStep = 5
|
||||
prefMin = -20
|
||||
prefMax = 20
|
||||
backoffBase = 5 * time.Second
|
||||
backoffCap = 30 * time.Minute
|
||||
// prefQuotaStep is the penalty for running out of allowance (quota/TPM
|
||||
// exhausted). Deliberately much smaller than prefFailStep: an exhausted
|
||||
// quota means "no budget right now", not "this model is bad", and for
|
||||
// allowance-metered sources it is an everyday event rather than a fault.
|
||||
// Charging it the full failure penalty made such sources drift to a deeply
|
||||
// negative score with ZERO visible failures - RecordQuotaExhausted
|
||||
// intentionally does not touch failCount, so the WebUI showed fails=0 and
|
||||
// cooling=false while the score sat at -10.
|
||||
prefQuotaStep = 1
|
||||
// prefSuccessStep is the reward for a successful request. Recovery used to
|
||||
// be +1 against a -5 penalty, a 5:1 asymmetry that made a slot at -10 need
|
||||
// ten consecutive successes just to reach neutral. Rewarding +2 keeps
|
||||
// failures meaningful (a real failure still costs more than one success
|
||||
// earns) while letting a recovered slot return to normal rotation quickly.
|
||||
prefSuccessStep = 2
|
||||
prefMin = -20
|
||||
prefMax = 20
|
||||
// prefDecayAfter is how long a slot may sit at a negative score without any
|
||||
// traffic before the score drifts back toward neutral. Without this a slot
|
||||
// that was penalised and then simply not selected again (the score itself
|
||||
// makes it unattractive) stays penalised indefinitely: it has no way to earn
|
||||
// the successes that would rehabilitate it. Applied lazily on read, so there
|
||||
// are no timers.
|
||||
prefDecayAfter = 2 * time.Minute
|
||||
backoffBase = 5 * time.Second
|
||||
// backoffCap bounds the exponential schedule. It used to be 30min, which
|
||||
// in practice meant "a slot whose upstream blipped is unusable for half an
|
||||
// hour even though its quota recovered in seconds". Combined with the
|
||||
// half-cooldown probe gate below a much shorter cap is safe: a still-dead
|
||||
// upstream costs one probe request per cap/2, while a recovered one is back
|
||||
// in full rotation on its first probe success.
|
||||
backoffCap = 5 * time.Minute
|
||||
// failures at/above this count back off at the capped duration
|
||||
backoffCapN = 10
|
||||
)
|
||||
|
||||
// authCooldown is the cooldown applied to 401/403 credential failures. A bad
|
||||
// key does not fix itself in seconds, so it cools longer than a transport
|
||||
// blip — but it is still a self-healing cooldown (a rotated key is picked up by
|
||||
// the next probe), never a permanent blacklist.
|
||||
const authCooldown = 10 * time.Minute
|
||||
|
||||
// maxQuotaCooldown bounds a quota-exhausted cooldown. Even when the reported
|
||||
// reset window is huge (monthly quotas), the slot is re-probed at least this
|
||||
// often so a manually topped-up allowance is noticed.
|
||||
const maxQuotaCooldown = 30 * time.Minute
|
||||
|
||||
// quotaDefaultCooldown is used when the source gives no hint about its quota
|
||||
// window length. It is long enough not to hammer an exhausted allowance and
|
||||
// short enough that the half-cooldown probe retries within minutes.
|
||||
const quotaDefaultCooldown = 10 * time.Minute
|
||||
|
||||
// rateLimitCooldown is the fixed cooldown applied on HTTP 429. A rate-limit
|
||||
// rejection means "too fast", not "broken": unlike transport/5xx failures it
|
||||
// must NOT escalate exponentially to backoffCap, or a quota-rich source gets
|
||||
@ -81,31 +144,94 @@ func (s *ModelState) Available() bool {
|
||||
}
|
||||
|
||||
// RecordFailure counts one consecutive failure and schedules exponential
|
||||
// cooldown (5s, 10s, 20s … capped at 30min). auth marks 401/403 credential
|
||||
// failures: it jumps straight to the capped cooldown and doubles the
|
||||
// cooldown (5s, 10s, 20s … capped at backoffCap). auth marks 401/403
|
||||
// credential failures: they get the longer fixed authCooldown and a doubled
|
||||
// preference penalty, but the model still recovers when the cooldown expires.
|
||||
func (s *ModelState) RecordFailure(auth bool) {
|
||||
n := s.failCount.Add(1)
|
||||
if auth && n < backoffCapN {
|
||||
n = backoffCapN
|
||||
s.failCount.Store(n) // persist the cap so FailCount() reports it too
|
||||
}
|
||||
var cd time.Duration
|
||||
if n >= backoffCapN {
|
||||
switch {
|
||||
case auth:
|
||||
// Persist the cap so FailCount() reports "this is a hard problem" and a
|
||||
// following non-auth failure does not restart the ladder from the bottom.
|
||||
if n < backoffCapN {
|
||||
n = backoffCapN
|
||||
s.failCount.Store(n)
|
||||
}
|
||||
cd = authCooldown
|
||||
case n >= backoffCapN:
|
||||
cd = backoffCap
|
||||
} else {
|
||||
default:
|
||||
cd = backoffBase * time.Duration(1<<(n-1))
|
||||
if cd > backoffCap {
|
||||
cd = backoffCap
|
||||
}
|
||||
}
|
||||
s.cooldownUntil.Store(time.Now().Add(cd).Unix())
|
||||
s.openCooldown(cd)
|
||||
pen := int64(prefFailStep)
|
||||
if auth {
|
||||
pen *= 2
|
||||
}
|
||||
s.pref.Add(-pen)
|
||||
s.addPref(-pen)
|
||||
}
|
||||
|
||||
// addPref moves the preference score and stamps prefTouched, which restarts the
|
||||
// decay clock. Every outcome-driven score change must go through here so decay
|
||||
// never fires on a slot that is actively being scored.
|
||||
func (s *ModelState) addPref(delta int64) {
|
||||
s.pref.Add(delta)
|
||||
clampPref(&s.pref, prefMin, prefMax)
|
||||
s.prefTouched.Store(time.Now().Unix())
|
||||
}
|
||||
|
||||
// decayPref drifts a negative score back toward neutral once a slot has been
|
||||
// idle for prefDecayAfter. This closes a starvation loop: a penalised slot is
|
||||
// unattractive, so it stops being selected, so it never earns the successes that
|
||||
// would rehabilitate it, so it stays penalised. One step per elapsed interval,
|
||||
// never past 0, and positive scores are left alone (a proven-good slot should
|
||||
// not be dragged down for being idle).
|
||||
//
|
||||
// Called lazily from Pref(); no timers and no goroutines.
|
||||
func (s *ModelState) decayPref() {
|
||||
cur := s.pref.Load()
|
||||
if cur >= 0 {
|
||||
return
|
||||
}
|
||||
last := s.prefTouched.Load()
|
||||
if last <= 0 {
|
||||
// no reference point yet: start the clock instead of decaying blindly
|
||||
s.prefTouched.CompareAndSwap(0, time.Now().Unix())
|
||||
return
|
||||
}
|
||||
step := int64(prefDecayAfter / time.Second)
|
||||
if step <= 0 {
|
||||
return
|
||||
}
|
||||
elapsed := time.Now().Unix() - last
|
||||
if elapsed < step {
|
||||
return
|
||||
}
|
||||
steps := elapsed / step
|
||||
target := cur + steps
|
||||
if target > 0 {
|
||||
target = 0
|
||||
}
|
||||
// Advance prefTouched by exactly the consumed intervals so leftover time
|
||||
// still counts toward the next step.
|
||||
if s.pref.CompareAndSwap(cur, target) {
|
||||
s.prefTouched.Store(last + steps*step)
|
||||
}
|
||||
}
|
||||
|
||||
// openCooldown starts a fresh cooldown window of length cd. Recording both
|
||||
// ends of the window (not just the deadline) is what makes the half-cooldown
|
||||
// probe gate possible, and resetting cooldownFrom on every new failure means a
|
||||
// failed probe pushes the next probe to the middle of the NEW window instead of
|
||||
// retrying immediately.
|
||||
func (s *ModelState) openCooldown(cd time.Duration) {
|
||||
now := time.Now()
|
||||
s.cooldownFrom.Store(now.Unix())
|
||||
s.cooldownUntil.Store(now.Add(cd).Unix())
|
||||
}
|
||||
|
||||
// RecordRateLimit records an HTTP 429: a short fixed cooldown instead of the
|
||||
@ -114,28 +240,109 @@ func (s *ModelState) RecordFailure(auth bool) {
|
||||
// other slots are preferred while cooling.
|
||||
func (s *ModelState) RecordRateLimit() {
|
||||
s.failCount.Add(1)
|
||||
s.cooldownUntil.Store(time.Now().Add(rateLimitCooldown).Unix())
|
||||
s.pref.Add(-prefFailStep)
|
||||
clampPref(&s.pref, prefMin, prefMax)
|
||||
s.openCooldown(rateLimitCooldown)
|
||||
// A 429 is "too fast", not "broken": charge it like an allowance event, not
|
||||
// like a fault, or a throttled-but-healthy source sinks purely for being
|
||||
// popular.
|
||||
s.addPref(-prefQuotaStep)
|
||||
}
|
||||
|
||||
// RecordSuccess resets the failure counter and cooldown and bumps the
|
||||
// preference score by one.
|
||||
// RecordQuotaExhausted marks the model as out of allowance until its quota
|
||||
// window resets. Unlike a failure this is not "the upstream is broken", so it
|
||||
// must not feed the exponential ladder: the slot is unusable until the window
|
||||
// boundary and immediately usable again after it. resetIn is the time left in
|
||||
// the current quota window; non-positive values fall back to the short
|
||||
// rate-limit window (the caller could not determine a period).
|
||||
func (s *ModelState) RecordQuotaExhausted(resetIn time.Duration) {
|
||||
if resetIn <= 0 {
|
||||
resetIn = rateLimitCooldown
|
||||
}
|
||||
if resetIn > maxQuotaCooldown {
|
||||
resetIn = maxQuotaCooldown
|
||||
}
|
||||
s.openCooldown(resetIn)
|
||||
// No failCount bump: an exhausted quota is not an error streak, and
|
||||
// letting it inflate failCount would make the NEXT real failure jump
|
||||
// straight to the capped backoff.
|
||||
//
|
||||
// The score penalty is prefQuotaStep, not prefFailStep, for the same
|
||||
// reason: cooldown alone already keeps the slot out of rotation until the
|
||||
// window resets, so the score only needs to express a mild preference for
|
||||
// slots with budget left. Charging the full failure penalty here is what
|
||||
// drove allowance-metered sources to -10 and beyond with fails=0.
|
||||
s.addPref(-prefQuotaStep)
|
||||
}
|
||||
|
||||
// RecordSuccess resets the failure counter and cooldown and rewards the
|
||||
// preference score. A single success lifts a slot off the prefMin floor, which
|
||||
// is what lets one successful probe return a written-off slot to normal
|
||||
// rotation.
|
||||
func (s *ModelState) RecordSuccess() {
|
||||
s.failCount.Store(0)
|
||||
s.cooldownUntil.Store(0)
|
||||
s.pref.Add(1)
|
||||
clampPref(&s.pref, prefMin, prefMax)
|
||||
s.cooldownFrom.Store(0)
|
||||
s.addPref(prefSuccessStep)
|
||||
}
|
||||
|
||||
func (s *ModelState) reset() {
|
||||
s.failCount.Store(0)
|
||||
s.cooldownUntil.Store(0)
|
||||
s.cooldownFrom.Store(0)
|
||||
s.probeInFlight.Store(false)
|
||||
s.pref.Store(0)
|
||||
s.prefTouched.Store(0)
|
||||
}
|
||||
|
||||
// Pref is the current preference score (higher = preferred).
|
||||
func (s *ModelState) Pref() int64 { return s.pref.Load() }
|
||||
// TryProbe attempts to claim the single probe permit for a model that normal
|
||||
// scheduling refuses. It returns true only when a probe is both needed and due:
|
||||
//
|
||||
// - cooling: allowed once the window passed its midpoint (the first half stays
|
||||
// completely silent).
|
||||
// - cooldown expired but the preference score sank to prefMin: ModelAvailable
|
||||
// still refuses the slot, so a probe is the ONLY way back. Without this a
|
||||
// slot that failed prefMin/prefFailStep times in a row would be blacklisted
|
||||
// forever despite every cooldown having long expired.
|
||||
//
|
||||
// A healthy, non-cooling model returns false: the caller should schedule it
|
||||
// normally. On true the caller MUST call ProbeDone exactly once.
|
||||
func (s *ModelState) TryProbe() bool {
|
||||
now := time.Now().Unix()
|
||||
until := s.cooldownUntil.Load()
|
||||
if until <= now {
|
||||
s.decayPref() // a naturally-recovered slot needs no probe
|
||||
if s.pref.Load() > prefMin {
|
||||
return false // schedulable normally, no probe needed
|
||||
}
|
||||
// preference floor: probe to earn the way out
|
||||
return s.probeInFlight.CompareAndSwap(false, true)
|
||||
}
|
||||
from := s.cooldownFrom.Load()
|
||||
if from <= 0 || from >= until {
|
||||
return false // no measurable window (legacy/degenerate state)
|
||||
}
|
||||
if now < from+(until-from)/2 {
|
||||
return false // first half of the window: stay silent
|
||||
}
|
||||
return s.probeInFlight.CompareAndSwap(false, true)
|
||||
}
|
||||
|
||||
// ProbeDone releases the probe permit claimed by TryProbe.
|
||||
func (s *ModelState) ProbeDone() { s.probeInFlight.Store(false) }
|
||||
|
||||
// Probing reports whether a probe request currently holds the permit.
|
||||
func (s *ModelState) Probing() bool { return s.probeInFlight.Load() }
|
||||
|
||||
// CooldownFrom is the unix timestamp the current cooldown window opened; 0
|
||||
// when the model is not cooling.
|
||||
func (s *ModelState) CooldownFrom() int64 { return s.cooldownFrom.Load() }
|
||||
|
||||
// Pref is the current preference score (higher = preferred). Reading it also
|
||||
// applies idle decay, so a slot penalised long ago and never selected since
|
||||
// drifts back toward neutral instead of being written off permanently.
|
||||
func (s *ModelState) Pref() int64 {
|
||||
s.decayPref()
|
||||
return s.pref.Load()
|
||||
}
|
||||
|
||||
// FailCount is the number of consecutive failures.
|
||||
func (s *ModelState) FailCount() int64 { return s.failCount.Load() }
|
||||
@ -155,15 +362,15 @@ type Provider struct {
|
||||
client *http.Client
|
||||
stream *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
sem chan struct{}
|
||||
states map[string]*ModelState // key = model id
|
||||
mu sync.Mutex
|
||||
sem chan struct{}
|
||||
states map[string]*ModelState // key = model id
|
||||
// proactive rate limiting: nextOK is the earliest unix-nano time the next
|
||||
// request may leave; throttle() spaces requests 60s/RPM apart. Zero when
|
||||
// cfg.RPM <= 0 (unlimited).
|
||||
rateMu sync.Mutex
|
||||
nextOK int64
|
||||
rpmGap time.Duration
|
||||
rateMu sync.Mutex
|
||||
nextOK int64
|
||||
rpmGap time.Duration
|
||||
lastProbe struct {
|
||||
ok bool
|
||||
err string
|
||||
@ -365,11 +572,60 @@ func (p *Provider) ModelAvailable(model string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if s, ok := p.states[model]; ok {
|
||||
return s.Available()
|
||||
return s.Available() && s.Pref() > prefMin
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ModelSchedulable is the scheduling gate that replaces a bare ModelAvailable
|
||||
// check. It reports whether the model may take a request right now and whether
|
||||
// doing so is a cooldown PROBE rather than normal traffic:
|
||||
//
|
||||
// - (true, false): fully available, schedule normally.
|
||||
// - (true, true): cooling, but past the half-cooldown mark and this caller
|
||||
// claimed the probe permit. The caller MUST call ProbeDone(model) once the
|
||||
// attempt finished, and SHOULD prefer non-probe candidates first.
|
||||
// - (false, false): cooling and not probeable (first half of the window, the
|
||||
// permit is taken, or the slot is persistently bad).
|
||||
//
|
||||
// Probing is what stops a recovered upstream from sitting out a whole cooldown:
|
||||
// the probe is a real request, so success immediately clears the cooldown via
|
||||
// RecordSuccess and the slot is back in full rotation.
|
||||
func (p *Provider) ModelSchedulable(model string) (ok bool, isProbe bool) {
|
||||
p.mu.Lock()
|
||||
s, known := p.states[model]
|
||||
p.mu.Unlock()
|
||||
if !known {
|
||||
return true, false
|
||||
}
|
||||
if s.Available() && s.Pref() > prefMin {
|
||||
return true, false
|
||||
}
|
||||
if s.TryProbe() {
|
||||
return true, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// ProbeDone releases a probe permit claimed through ModelSchedulable.
|
||||
func (p *Provider) ProbeDone(model string) { p.state(model).ProbeDone() }
|
||||
|
||||
// ProbeSlots is how many concurrent probe requests this source tolerates while
|
||||
// a model cools: one per 10 configured concurrency slots, at least 1 and never
|
||||
// more than 2. A source configured for max_concurrent=10 therefore lets exactly
|
||||
// one request through to probe, which is the intended "small-scale probe"
|
||||
// behaviour; a large source still cannot flood a cooling upstream.
|
||||
func (p *Provider) ProbeSlots() int {
|
||||
n := p.cfg.MaxConcurrent / 10
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
if n > 2 {
|
||||
return 2
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Pref returns the adaptive preference score of a model (higher = preferred
|
||||
// within a priority tier). Used by the AUTO chain to order same-tier slots.
|
||||
func (p *Provider) Pref(model string) int64 {
|
||||
@ -393,6 +649,19 @@ func (p *Provider) ModelHealthInfo(model string) (pref, failCount, cooldownUntil
|
||||
return s.Pref(), s.FailCount(), s.CooldownUntil()
|
||||
}
|
||||
|
||||
// ModelProbeInfo exposes the probe-gate state for the web UI: when the current
|
||||
// cooldown window opened, when probing becomes allowed (its midpoint), and
|
||||
// whether a probe is in flight right now. All zero when not cooling.
|
||||
func (p *Provider) ModelProbeInfo(model string) (from, probeAfter int64, probing bool) {
|
||||
s := p.state(model)
|
||||
until := s.CooldownUntil()
|
||||
from = s.CooldownFrom()
|
||||
if until <= 0 || from <= 0 || from >= until {
|
||||
return 0, 0, s.Probing()
|
||||
}
|
||||
return from, from + (until-from)/2, s.Probing()
|
||||
}
|
||||
|
||||
// Probe performs a lightweight reachability + auth check against the source.
|
||||
// It first tries GET <base>/models (fast, ~1s for OpenAI-compatible upstreams)
|
||||
// and only falls back to a 1-token chat call when that endpoint is
|
||||
@ -518,17 +787,33 @@ func (p *Provider) RecordSuccess(model string) {
|
||||
}
|
||||
|
||||
// ReportStatus records an upstream HTTP status for the given model and drives
|
||||
// the (source, model) backoff state. 401/403 → capped self-healing cooldown
|
||||
// the (source, model) backoff state. 401/403 → long self-healing authCooldown
|
||||
// with doubled penalty; 5xx → normal exponential backoff; 429 → short fixed
|
||||
// cooldown (rateLimitCooldown): a rate rejection means "too fast", not
|
||||
// "broken", so the slot must re-enter rotation quickly instead of escalating
|
||||
// to a 30-minute lockout that wastes remaining quota.
|
||||
// to a long lockout that wastes remaining quota.
|
||||
//
|
||||
// Prefer ReportStatusReason when the upstream error text is available: a
|
||||
// quota-exhausted rejection is cooled to its window boundary instead of being
|
||||
// treated as a failure streak.
|
||||
func (p *Provider) ReportStatus(model string, code int) {
|
||||
p.ReportStatusReason(model, code, "")
|
||||
}
|
||||
|
||||
// ReportStatusReason is ReportStatus plus the upstream error text, which lets a
|
||||
// quota/allowance rejection be distinguished from a generic rate limit. Quota
|
||||
// exhaustion is not an error streak: it is cooled until the allowance window
|
||||
// can plausibly have reset, and never inflates the exponential ladder.
|
||||
func (p *Provider) ReportStatusReason(model string, code int, reason string) {
|
||||
if code == 401 || code == 403 {
|
||||
p.RecordFailure(model, code)
|
||||
return
|
||||
}
|
||||
if code == 429 {
|
||||
if code == 429 || code == 402 {
|
||||
if quotaExhaustedReason(reason) {
|
||||
p.state(model).RecordQuotaExhausted(p.quotaResetIn(model))
|
||||
return
|
||||
}
|
||||
p.state(model).RecordRateLimit()
|
||||
return
|
||||
}
|
||||
@ -537,6 +822,82 @@ func (p *Provider) ReportStatus(model string, code int) {
|
||||
}
|
||||
}
|
||||
|
||||
// quotaResetIn estimates how long the model's allowance window still has to
|
||||
// run. Upstreams do not report this, so it is taken from the source's optional
|
||||
// `quota_hours` meta hint when present (aligned to the epoch, matching the
|
||||
// gateway's own quota-window convention) and otherwise falls back to
|
||||
// quotaDefaultCooldown. Either way the value is capped by maxQuotaCooldown, so
|
||||
// a wrong hint can only delay recovery to that bound — and the half-cooldown
|
||||
// probe fires at half of it.
|
||||
func (p *Provider) quotaResetIn(model string) time.Duration {
|
||||
hours := p.quotaHours()
|
||||
if hours <= 0 {
|
||||
return quotaDefaultCooldown
|
||||
}
|
||||
win := int64(hours * float64(time.Hour/time.Second))
|
||||
if win <= 0 {
|
||||
return quotaDefaultCooldown
|
||||
}
|
||||
elapsed := time.Now().Unix() % win
|
||||
return time.Duration(win-elapsed) * time.Second
|
||||
}
|
||||
|
||||
// quotaHours reads the optional per-source `quota_hours` meta hint describing
|
||||
// how long its allowance window is. Absent or unparseable = 0 (unknown).
|
||||
func (p *Provider) quotaHours() float64 {
|
||||
v, ok := p.cfg.Meta["quota_hours"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// quotaKeywords are the upstream phrases that mean "you are out of allowance"
|
||||
// rather than "you are going too fast". Matching is case-insensitive substring
|
||||
// matching against the adapter-normalized error reason.
|
||||
var quotaKeywords = []string{
|
||||
"insufficient_quota",
|
||||
"insufficient quota",
|
||||
"quota exceeded",
|
||||
"quota_exceeded",
|
||||
"exceeded your current quota",
|
||||
"out of credit",
|
||||
"insufficient balance",
|
||||
"insufficient_user_quota",
|
||||
"billing_hard_limit_reached",
|
||||
"credit balance is too low",
|
||||
"余额不足",
|
||||
"额度不足",
|
||||
"额度已用完",
|
||||
}
|
||||
|
||||
func quotaExhaustedReason(reason string) bool {
|
||||
if reason == "" {
|
||||
return false
|
||||
}
|
||||
low := strings.ToLower(reason)
|
||||
for _, k := range quotaKeywords {
|
||||
if strings.Contains(low, k) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResetHealth resets the scheduling state of every model of this source
|
||||
// (cooldown and preference to zero), so the source becomes fully schedulable
|
||||
// again. Called after AUTO-chain edits and from the admin UI.
|
||||
@ -717,8 +1078,9 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
|
||||
return nil, err
|
||||
}
|
||||
if status != 200 {
|
||||
p.ReportStatus(model, status)
|
||||
return nil, fmt.Errorf("%s", p.apiErrReason(status, raw))
|
||||
reason := p.apiErrReason(status, raw)
|
||||
p.ReportStatusReason(model, status, reason)
|
||||
return nil, fmt.Errorf("%s", reason)
|
||||
}
|
||||
unified, err := p.vm.Transform(p.adapter, "transform_response", raw)
|
||||
if err != nil {
|
||||
@ -792,9 +1154,10 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
if sel.resp.StatusCode != 200 {
|
||||
raw, _ := io.ReadAll(sel.resp.Body)
|
||||
sel.resp.Body.Close()
|
||||
p.ReportStatus(model, sel.resp.StatusCode)
|
||||
reason := p.apiErrReason(sel.resp.StatusCode, string(raw))
|
||||
p.ReportStatusReason(model, sel.resp.StatusCode, reason)
|
||||
p.Release()
|
||||
return nil, fmt.Errorf("%s", p.apiErrReason(sel.resp.StatusCode, string(raw)))
|
||||
return nil, fmt.Errorf("%s", reason)
|
||||
}
|
||||
go func() {
|
||||
defer p.Release()
|
||||
@ -806,6 +1169,12 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
var realChunks int
|
||||
var doneSeen bool
|
||||
var doneSent bool
|
||||
// toolIdx maps a per-stream tool fragment's upstream index (Anthropic
|
||||
// content-block ordinal, which is sparse when thinking/text blocks
|
||||
// precede tool_use) to a dense 0-based OpenAI tool_call ordinal so
|
||||
// clients that accumulate fragments by index reassemble multi-tool
|
||||
// responses correctly.
|
||||
toolIdx := newToolIndexRemapper()
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || !strings.HasPrefix(line, "data:") {
|
||||
@ -843,6 +1212,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
if err := json.Unmarshal([]byte(unified), &ck); err != nil {
|
||||
continue
|
||||
}
|
||||
ck.ToolCalls = toolIdx.remap(ck.ToolCalls)
|
||||
if ck.Done {
|
||||
doneSent = true
|
||||
}
|
||||
@ -917,6 +1287,62 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
// tool calls, reasoning text or usage. Standard OpenAI finish reasons are
|
||||
// never classified as errors, so legitimate instant-empty completions
|
||||
// (finish_reason:"stop", no output) still reach the client.
|
||||
|
||||
// toolIndexRemapper maps per-stream tool_call fragment indices from a sparse
|
||||
// upstream ordinal (e.g. Anthropic content_block index, which skips over
|
||||
// thinking/text blocks) to a dense 0-based OpenAI tool_call ordinal.
|
||||
type toolIndexRemapper struct {
|
||||
seen map[int]int // upstream index -> dense ordinal
|
||||
next int // next dense ordinal to assign
|
||||
}
|
||||
|
||||
func newToolIndexRemapper() toolIndexRemapper {
|
||||
return toolIndexRemapper{seen: make(map[int]int)}
|
||||
}
|
||||
|
||||
func (t *toolIndexRemapper) remap(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return raw
|
||||
}
|
||||
// Decode into generic maps so every upstream field is preserved verbatim;
|
||||
// only the index value is rewritten.
|
||||
var frags []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &frags); err != nil || len(frags) == 0 {
|
||||
return raw
|
||||
}
|
||||
changed := false
|
||||
for i := range frags {
|
||||
orig := 0
|
||||
if v, ok := frags[i]["index"]; ok {
|
||||
if err := json.Unmarshal(v, &orig); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
mapped, ok := t.seen[orig]
|
||||
if !ok {
|
||||
mapped = t.next
|
||||
t.next++
|
||||
t.seen[orig] = mapped
|
||||
}
|
||||
if mapped != orig {
|
||||
b, err := json.Marshal(mapped)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
frags[i]["index"] = b
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return raw
|
||||
}
|
||||
out, err := json.Marshal(frags)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func errorOnlyChunk(ck types.UnifiedChunk) bool {
|
||||
if !ck.Done || ck.FinishReason == "" {
|
||||
return false
|
||||
@ -980,14 +1406,16 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
|
||||
return nil, err
|
||||
}
|
||||
if status != 200 {
|
||||
p.ReportStatus(model, status)
|
||||
return nil, fmt.Errorf("%s", p.apiErrReason(status, raw))
|
||||
reason := p.apiErrReason(status, raw)
|
||||
p.ReportStatusReason(model, status, reason)
|
||||
return nil, fmt.Errorf("%s", reason)
|
||||
}
|
||||
var out types.UnifiedResponse
|
||||
// try adapter transform_response; if missing, parse standard openai image format
|
||||
unified, terr := p.vm.Transform(p.adapter+"_image", "transform_response", raw)
|
||||
if terr == nil && unified != raw {
|
||||
if err := json.Unmarshal([]byte(unified), &out); err == nil {
|
||||
out.Model = model // actual model served (AUTO resolved to bestImageModel)
|
||||
p.RecordSuccess(model)
|
||||
return &out, nil
|
||||
}
|
||||
@ -1000,6 +1428,7 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
|
||||
return nil, fmt.Errorf("unmarshal image response: %w", err)
|
||||
}
|
||||
out.ImageData = img.Data
|
||||
out.Model = model // actual model served (AUTO resolved to bestImageModel)
|
||||
p.RecordSuccess(model)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
@ -198,8 +198,8 @@ func TestProviderAuthFailureSelfHeals(t *testing.T) {
|
||||
if st.FailCount() != backoffCapN {
|
||||
t.Fatalf("auth failure must jump to capped count, got %d", st.FailCount())
|
||||
}
|
||||
if until := st.CooldownUntil(); until <= time.Now().Add(25*time.Minute).Unix() {
|
||||
t.Fatalf("auth failure must cool near the cap (until=%d)", until)
|
||||
if until := st.CooldownUntil(); until <= time.Now().Add(authCooldown-time.Minute).Unix() {
|
||||
t.Fatalf("auth failure must cool for authCooldown (until=%d)", until)
|
||||
}
|
||||
if st.Pref() != -2*int64(prefFailStep) {
|
||||
t.Fatalf("auth failure pref penalty must be doubled, got %d", st.Pref())
|
||||
@ -242,8 +242,10 @@ func TestModelStateCooldownAndRecovery(t *testing.T) {
|
||||
if st.FailCount() != 0 {
|
||||
t.Fatalf("fail count after success = %d", st.FailCount())
|
||||
}
|
||||
if st.Pref() != 1-int64(prefFailStep) {
|
||||
t.Fatalf("pref after one failure (-5) then success (+1) = %d, want %d", st.Pref(), 1-int64(prefFailStep))
|
||||
// failure penalties prefFailStep, success rewards prefSuccessStep:
|
||||
// -5 then +2 = -3
|
||||
if st.Pref() != int64(prefSuccessStep)-int64(prefFailStep) {
|
||||
t.Fatalf("pref after one failure then success = %d, want %d", st.Pref(), int64(prefSuccessStep)-int64(prefFailStep))
|
||||
}
|
||||
}
|
||||
|
||||
@ -265,8 +267,8 @@ func TestModelStateRateLimitShortCooldown(t *testing.T) {
|
||||
if st.FailCount() != 15 {
|
||||
t.Fatalf("fail count = %d, want 15 (counted but not escalating)", st.FailCount())
|
||||
}
|
||||
if st.Pref() != -15*int64(prefFailStep) && st.Pref() > int64(prefMin) {
|
||||
t.Fatalf("pref = %d", st.Pref())
|
||||
if st.Pref() != -15*int64(prefQuotaStep) {
|
||||
t.Fatalf("pref after 15 x 429 = %d, want %d (a 429 is a quota-class event, not a fault)", st.Pref(), -15*int64(prefQuotaStep))
|
||||
}
|
||||
if p.ModelAvailable("m") {
|
||||
t.Fatal("model must be cooling right after a 429")
|
||||
@ -598,3 +600,586 @@ func TestUnknownErrorFallbackWithoutHook(t *testing.T) {
|
||||
t.Fatalf("raw body must not leak: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolIndexRemapper verifies sparse upstream tool-call indices are
|
||||
// compacted to dense 0-based OpenAI ordinals (Issue 2). Anthropic emits the
|
||||
// content_block ordinal, so a thinking block at index 0 pushes the first
|
||||
// tool_use to index 1 — OpenAI clients accumulating by index would leave a
|
||||
// hole at 0 and mis-assemble multi-tool responses.
|
||||
func TestToolIndexRemapper(t *testing.T) {
|
||||
t.Run("single tool after thinking block", func(t *testing.T) {
|
||||
r := newToolIndexRemapper()
|
||||
// content_block index 1 (thinking was 0) -> dense 0
|
||||
out := r.remap(json.RawMessage(`[{"index":1,"id":"toolu_a","type":"function","function":{"name":"calc","arguments":""}}]`))
|
||||
var got []map[string]interface{}
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, out)
|
||||
}
|
||||
if got[0]["index"] != float64(0) {
|
||||
t.Fatalf("index = %v, want 0: %s", got[0]["index"], out)
|
||||
}
|
||||
// id/name/arguments must survive verbatim
|
||||
if got[0]["id"] != "toolu_a" {
|
||||
t.Fatalf("id lost: %s", out)
|
||||
}
|
||||
fn := got[0]["function"].(map[string]interface{})
|
||||
if fn["name"] != "calc" {
|
||||
t.Fatalf("function.name lost: %s", out)
|
||||
}
|
||||
// subsequent argument fragments on the SAME upstream index reuse ordinal 0
|
||||
out2 := r.remap(json.RawMessage(`[{"index":1,"id":"","type":"function","function":{"name":"","arguments":"{\"a\":1}"}}]`))
|
||||
var got2 []map[string]interface{}
|
||||
if err := json.Unmarshal(out2, &got2); err != nil {
|
||||
t.Fatalf("unmarshal2: %v", err)
|
||||
}
|
||||
if got2[0]["index"] != float64(0) {
|
||||
t.Fatalf("fragment index = %v, want stable 0: %s", got2[0]["index"], out2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple tools get distinct dense ordinals", func(t *testing.T) {
|
||||
r := newToolIndexRemapper()
|
||||
// thinking=0, tool_use=1, tool_use=2 -> 0, 1
|
||||
a := r.remap(json.RawMessage(`[{"index":1,"id":"t1","type":"function","function":{"name":"f1","arguments":""}}]`))
|
||||
b := r.remap(json.RawMessage(`[{"index":2,"id":"t2","type":"function","function":{"name":"f2","arguments":""}}]`))
|
||||
var ga, gb []map[string]interface{}
|
||||
json.Unmarshal(a, &ga)
|
||||
json.Unmarshal(b, &gb)
|
||||
if ga[0]["index"] != float64(0) {
|
||||
t.Fatalf("first tool index = %v, want 0", ga[0]["index"])
|
||||
}
|
||||
if gb[0]["index"] != float64(1) {
|
||||
t.Fatalf("second tool index = %v, want 1", gb[0]["index"])
|
||||
}
|
||||
// interleaved fragments keep their own ordinals
|
||||
a2 := r.remap(json.RawMessage(`[{"index":1,"id":"","type":"function","function":{"name":"","arguments":"x"}}]`))
|
||||
var ga2 []map[string]interface{}
|
||||
json.Unmarshal(a2, &ga2)
|
||||
if ga2[0]["index"] != float64(0) {
|
||||
t.Fatalf("t1 fragment index = %v, want 0", ga2[0]["index"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already dense indices pass through untouched", func(t *testing.T) {
|
||||
r := newToolIndexRemapper()
|
||||
in := json.RawMessage(`[{"index":0,"id":"t","type":"function","function":{"name":"f","arguments":""}}]`)
|
||||
out := r.remap(in)
|
||||
if string(out) != string(in) {
|
||||
t.Fatalf("dense input was rewritten:\n in=%s\nout=%s", in, out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty and malformed input is safe", func(t *testing.T) {
|
||||
r := newToolIndexRemapper()
|
||||
if got := r.remap(nil); got != nil {
|
||||
t.Fatalf("nil -> %s", got)
|
||||
}
|
||||
bad := json.RawMessage(`not json`)
|
||||
if got := r.remap(bad); string(got) != string(bad) {
|
||||
t.Fatalf("malformed input must pass through: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- half-cooldown probe gate (plan 阶段 1) ----
|
||||
|
||||
// TestProbeGateHalfCooldown pins the core rule: a cooling model is fully silent
|
||||
// in the first half of its cooldown window and probeable exactly once in the
|
||||
// second half.
|
||||
func TestProbeGateHalfCooldown(t *testing.T) {
|
||||
var st ModelState
|
||||
// open a 100s window centred so that "now" sits in the first half
|
||||
now := time.Now().Unix()
|
||||
st.cooldownFrom.Store(now - 10)
|
||||
st.cooldownUntil.Store(now + 90)
|
||||
if st.TryProbe() {
|
||||
t.Fatal("first half of the cooldown window must stay silent")
|
||||
}
|
||||
|
||||
// move into the second half
|
||||
st.cooldownFrom.Store(now - 90)
|
||||
st.cooldownUntil.Store(now + 10)
|
||||
if !st.TryProbe() {
|
||||
t.Fatal("second half of the cooldown window must allow one probe")
|
||||
}
|
||||
if st.TryProbe() {
|
||||
t.Fatal("probe permit must be single-token")
|
||||
}
|
||||
if !st.Probing() {
|
||||
t.Fatal("Probing() must report the claimed permit")
|
||||
}
|
||||
st.ProbeDone()
|
||||
if st.Probing() {
|
||||
t.Fatal("ProbeDone must release the permit")
|
||||
}
|
||||
if !st.TryProbe() {
|
||||
t.Fatal("permit must be reusable after ProbeDone")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeGateNoProbeWhenHealthy: a healthy model needs no probe permit — the
|
||||
// caller schedules it normally.
|
||||
func TestProbeGateNoProbeWhenHealthy(t *testing.T) {
|
||||
var idle ModelState
|
||||
if idle.TryProbe() {
|
||||
t.Fatal("a healthy model must not be probed (schedule it normally)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeGateRescuesPrefFloor documents a fix that comes with the probe gate:
|
||||
// a slot whose preference sank to prefMin used to be refused by ModelAvailable
|
||||
// forever, even long after every cooldown expired — a permanent blacklist. It is
|
||||
// now probeable, so one success brings it back.
|
||||
func TestProbeGateRescuesPrefFloor(t *testing.T) {
|
||||
var dead ModelState
|
||||
dead.pref.Store(prefMin)
|
||||
if !dead.TryProbe() {
|
||||
t.Fatal("a slot stuck at prefMin must be probeable, not blacklisted")
|
||||
}
|
||||
dead.RecordSuccess()
|
||||
dead.ProbeDone()
|
||||
if dead.Pref() <= prefMin {
|
||||
t.Fatalf("one probe success must lift the slot off the floor, pref=%d", dead.Pref())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeSuccessRestoresImmediately is the user-visible payoff: a recovered
|
||||
// upstream must not sit out the rest of its cooldown.
|
||||
func TestProbeSuccessRestoresImmediately(t *testing.T) {
|
||||
var st ModelState
|
||||
for i := 0; i < backoffCapN; i++ {
|
||||
st.RecordFailure(false)
|
||||
}
|
||||
if cd := st.CooldownUntil() - time.Now().Unix(); cd < int64(backoffCap.Seconds())-2 {
|
||||
t.Fatalf("10 failures must cool for backoffCap, got %ds", cd)
|
||||
}
|
||||
// jump into the probe window: the midpoint of [from, until) must be in the
|
||||
// past, so push the window's start back past its full length
|
||||
now := time.Now().Unix()
|
||||
st.cooldownFrom.Store(now - int64(backoffCap.Seconds()) - 10)
|
||||
if !st.TryProbe() {
|
||||
t.Fatal("probe must be allowed past the midpoint")
|
||||
}
|
||||
if !st.Probing() {
|
||||
t.Fatal("probe permit must be held while the probe runs")
|
||||
}
|
||||
st.RecordSuccess()
|
||||
st.ProbeDone()
|
||||
if !st.Available() {
|
||||
t.Fatal("probe success must clear the cooldown immediately")
|
||||
}
|
||||
if st.FailCount() != 0 {
|
||||
t.Fatalf("probe success must reset failCount, got %d", st.FailCount())
|
||||
}
|
||||
if st.CooldownUntil() != 0 || st.CooldownFrom() != 0 {
|
||||
t.Fatal("probe success must clear both cooldown bounds")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeFailureDefersNextProbe: a failed probe must not immediately retry —
|
||||
// the new window's midpoint pushes the next probe out.
|
||||
func TestProbeFailureDefersNextProbe(t *testing.T) {
|
||||
var st ModelState
|
||||
st.RecordFailure(false) // 5s window
|
||||
now := time.Now().Unix()
|
||||
st.cooldownFrom.Store(now - 100)
|
||||
st.cooldownUntil.Store(now + 10)
|
||||
if !st.TryProbe() {
|
||||
t.Fatal("probe must be allowed past the midpoint")
|
||||
}
|
||||
st.RecordFailure(false) // probe failed: a fresh, longer window opens
|
||||
st.ProbeDone()
|
||||
if st.TryProbe() {
|
||||
t.Fatal("a failed probe must defer the next probe to the new midpoint")
|
||||
}
|
||||
if from := st.CooldownFrom(); from < now {
|
||||
t.Fatalf("failed probe must reopen the window (from=%d, now=%d)", from, now)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthCooldownIsSeparateFromCap documents that credential failures cool on
|
||||
// their own (longer) schedule instead of reusing the exponential cap.
|
||||
func TestAuthCooldownIsSeparateFromCap(t *testing.T) {
|
||||
var st ModelState
|
||||
st.RecordFailure(true)
|
||||
cd := st.CooldownUntil() - time.Now().Unix()
|
||||
if cd < int64(authCooldown.Seconds())-2 || cd > int64(authCooldown.Seconds())+2 {
|
||||
t.Fatalf("auth failure must cool for authCooldown (%v), got %ds", authCooldown, cd)
|
||||
}
|
||||
if st.FailCount() != backoffCapN {
|
||||
t.Fatalf("auth failure must persist the capped count, got %d", st.FailCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaExhaustedDoesNotEscalate is the second half of the user's complaint:
|
||||
// running out of allowance must not be treated as a failure streak.
|
||||
func TestQuotaExhaustedDoesNotEscalate(t *testing.T) {
|
||||
var st ModelState
|
||||
st.RecordQuotaExhausted(2 * time.Minute)
|
||||
if st.FailCount() != 0 {
|
||||
t.Fatalf("quota exhaustion must not bump failCount, got %d", st.FailCount())
|
||||
}
|
||||
cd := st.CooldownUntil() - time.Now().Unix()
|
||||
if cd < 118 || cd > 122 {
|
||||
t.Fatalf("quota cooldown must honour the reset window, got %ds", cd)
|
||||
}
|
||||
|
||||
// an unbounded window is clamped so a topped-up allowance is still noticed
|
||||
var big ModelState
|
||||
big.RecordQuotaExhausted(72 * time.Hour)
|
||||
if cd := big.CooldownUntil() - time.Now().Unix(); cd > int64(maxQuotaCooldown.Seconds())+2 {
|
||||
t.Fatalf("quota cooldown must be capped at maxQuotaCooldown, got %ds", cd)
|
||||
}
|
||||
|
||||
// no hint at all falls back to the short window
|
||||
var none ModelState
|
||||
none.RecordQuotaExhausted(0)
|
||||
if cd := none.CooldownUntil() - time.Now().Unix(); cd > int64(rateLimitCooldown.Seconds())+2 {
|
||||
t.Fatalf("unknown reset window must fall back to rateLimitCooldown, got %ds", cd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaReasonRouting checks that a 429 carrying a quota phrase is cooled as
|
||||
// quota exhaustion while a plain rate limit keeps the short fixed window.
|
||||
func TestQuotaReasonRouting(t *testing.T) {
|
||||
p := newTestProvider(t, src("q", "http://127.0.0.1:1", "openai", "m"))
|
||||
p.ReportStatusReason("m", 429, "429 insufficient_quota: you exceeded your current quota")
|
||||
st := p.state("m")
|
||||
if st.FailCount() != 0 {
|
||||
t.Fatalf("quota rejection must not bump failCount, got %d", st.FailCount())
|
||||
}
|
||||
if cd := st.CooldownUntil() - time.Now().Unix(); cd <= int64(rateLimitCooldown.Seconds()) {
|
||||
t.Fatalf("quota rejection must cool longer than a plain 429, got %ds", cd)
|
||||
}
|
||||
|
||||
p2 := newTestProvider(t, src("r", "http://127.0.0.1:1", "openai", "m"))
|
||||
p2.ReportStatusReason("m", 429, "rate limit reached, slow down")
|
||||
st2 := p2.state("m")
|
||||
if cd := st2.CooldownUntil() - time.Now().Unix(); cd > int64(rateLimitCooldown.Seconds())+2 {
|
||||
t.Fatalf("plain 429 must keep the short window, got %ds", cd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelSchedulableProbeHandoff covers the provider-level gate the scheduler
|
||||
// consumes.
|
||||
func TestModelSchedulableProbeHandoff(t *testing.T) {
|
||||
p := newTestProvider(t, src("s", "http://127.0.0.1:1", "openai", "m"))
|
||||
if ok, probe := p.ModelSchedulable("m"); !ok || probe {
|
||||
t.Fatalf("healthy model must be normally schedulable, got ok=%v probe=%v", ok, probe)
|
||||
}
|
||||
st := p.state("m")
|
||||
st.RecordFailure(false)
|
||||
now := time.Now().Unix()
|
||||
st.cooldownFrom.Store(now - 100)
|
||||
st.cooldownUntil.Store(now + 10)
|
||||
|
||||
ok, probe := p.ModelSchedulable("m")
|
||||
if !ok || !probe {
|
||||
t.Fatalf("cooling model past midpoint must be probeable, got ok=%v probe=%v", ok, probe)
|
||||
}
|
||||
if ok2, _ := p.ModelSchedulable("m"); ok2 {
|
||||
t.Fatal("only one probe permit may be outstanding")
|
||||
}
|
||||
p.ProbeDone("m")
|
||||
if ok3, probe3 := p.ModelSchedulable("m"); !ok3 || !probe3 {
|
||||
t.Fatal("permit must be reclaimable after ProbeDone")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeSlotsFollowsMaxConcurrent pins the user's example: max_concurrent=10
|
||||
// yields exactly one probe slot.
|
||||
func TestProbeSlotsFollowsMaxConcurrent(t *testing.T) {
|
||||
for _, tc := range []struct{ mc, want int }{
|
||||
{0, 1}, {1, 1}, {4, 1}, {10, 1}, {20, 2}, {100, 2},
|
||||
} {
|
||||
s := src("p", "http://127.0.0.1:1", "openai", "m")
|
||||
s.MaxConcurrent = tc.mc
|
||||
p := newTestProvider(t, s)
|
||||
if got := p.ProbeSlots(); got != tc.want {
|
||||
t.Fatalf("max_concurrent=%d: probe slots = %d, want %d", tc.mc, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelProbeInfo checks the UI-facing view of the probe window.
|
||||
func TestModelProbeInfo(t *testing.T) {
|
||||
p := newTestProvider(t, src("i", "http://127.0.0.1:1", "openai", "m"))
|
||||
if from, after, probing := p.ModelProbeInfo("m"); from != 0 || after != 0 || probing {
|
||||
t.Fatalf("idle model must report no probe window, got %d %d %v", from, after, probing)
|
||||
}
|
||||
st := p.state("m")
|
||||
now := time.Now().Unix()
|
||||
st.cooldownFrom.Store(now)
|
||||
st.cooldownUntil.Store(now + 100)
|
||||
from, after, _ := p.ModelProbeInfo("m")
|
||||
if from != now || after != now+50 {
|
||||
t.Fatalf("probe window = (%d,%d), want (%d,%d)", from, after, now, now+50)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamRecoveryWithoutWaitingOutCooldown is the end-to-end shape of the
|
||||
// user's complaint: an upstream that 500s ten times lands in the capped
|
||||
// cooldown, and used to be unusable for a full 30 minutes even after it
|
||||
// recovered. With the probe gate the slot is silent for the first half of the
|
||||
// window and then serves the very next request itself, clearing the cooldown.
|
||||
//
|
||||
// Real time is not waited out: the window bounds are moved so the test observes
|
||||
// the same state the scheduler would see at cap/2.
|
||||
func TestUpstreamRecoveryWithoutWaitingOutCooldown(t *testing.T) {
|
||||
var healthy atomic.Bool
|
||||
var hits atomic.Int64
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
if !healthy.Load() {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"choices":[{"message":{"content":"back"},"finish_reason":"stop"}]}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
p := newTestProvider(t, src("recov", up.URL, "openai", "m"))
|
||||
req := func() *types.ChatRequest {
|
||||
return &types.ChatRequest{
|
||||
Model: "m",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
|
||||
}
|
||||
}
|
||||
|
||||
// 1. drive the slot into the capped cooldown
|
||||
for i := 0; i < backoffCapN; i++ {
|
||||
if _, err := p.Chat(context.Background(), req()); err == nil {
|
||||
t.Fatalf("attempt %d: expected upstream failure", i)
|
||||
}
|
||||
}
|
||||
st := p.state("m")
|
||||
if p.ModelAvailable("m") {
|
||||
t.Fatal("10 consecutive 5xx must take the slot out of normal rotation")
|
||||
}
|
||||
cd := st.CooldownUntil() - time.Now().Unix()
|
||||
if cd < int64(backoffCap.Seconds())-2 {
|
||||
t.Fatalf("cooldown = %ds, want ~%v", cd, backoffCap)
|
||||
}
|
||||
// the old 30-minute lockout is gone
|
||||
if cd > int64((6 * time.Minute).Seconds()) {
|
||||
t.Fatalf("cooldown %ds is too long: the cap must be minutes, not half an hour", cd)
|
||||
}
|
||||
|
||||
// 2. upstream recovers, but we are still in the FIRST half of the window:
|
||||
// the slot must stay completely silent (no probe traffic at all).
|
||||
healthy.Store(true)
|
||||
quiet := hits.Load()
|
||||
if ok, probe := p.ModelSchedulable("m"); ok || probe {
|
||||
t.Fatalf("first half of the window must stay silent, got ok=%v probe=%v", ok, probe)
|
||||
}
|
||||
if hits.Load() != quiet {
|
||||
t.Fatal("no request may reach the upstream during the silent half")
|
||||
}
|
||||
|
||||
// 3. advance past the midpoint: exactly one probe is admitted.
|
||||
st.cooldownFrom.Store(time.Now().Unix() - int64(backoffCap.Seconds()) - 1)
|
||||
ok, isProbe := p.ModelSchedulable("m")
|
||||
if !ok || !isProbe {
|
||||
t.Fatalf("past the midpoint one probe must be admitted, got ok=%v probe=%v", ok, isProbe)
|
||||
}
|
||||
if ok2, _ := p.ModelSchedulable("m"); ok2 {
|
||||
t.Fatal("only ONE probe may be in flight while cooling")
|
||||
}
|
||||
|
||||
// 4. the probe is a real request: its success restores the slot fully.
|
||||
resp, err := p.Chat(context.Background(), req())
|
||||
p.ProbeDone("m")
|
||||
if err != nil {
|
||||
t.Fatalf("probe request failed: %v", err)
|
||||
}
|
||||
if resp.Content != "back" {
|
||||
t.Fatalf("probe response = %q, want \"back\"", resp.Content)
|
||||
}
|
||||
if !p.ModelAvailable("m") {
|
||||
t.Fatal("a successful probe must return the slot to normal rotation immediately")
|
||||
}
|
||||
if st.CooldownUntil() != 0 || st.FailCount() != 0 {
|
||||
t.Fatalf("probe success must clear cooldown/failCount, got until=%d fails=%d",
|
||||
st.CooldownUntil(), st.FailCount())
|
||||
}
|
||||
if ok, probe := p.ModelSchedulable("m"); !ok || probe {
|
||||
t.Fatalf("recovered slot must schedule normally, got ok=%v probe=%v", ok, probe)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryProbeBudgetIsBounded quantifies the cost of the shorter cap
|
||||
// against a still-dead upstream: one probe per window, not a retry storm.
|
||||
func TestRecoveryProbeBudgetIsBounded(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(500)
|
||||
}))
|
||||
defer up.Close()
|
||||
p := newTestProvider(t, src("dead", up.URL, "openai", "m"))
|
||||
req := &types.ChatRequest{
|
||||
Model: "m",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
|
||||
}
|
||||
for i := 0; i < backoffCapN; i++ {
|
||||
_, _ = p.Chat(context.Background(), req)
|
||||
}
|
||||
baseline := hits.Load()
|
||||
st := p.state("m")
|
||||
|
||||
// simulate three consecutive probe windows against a dead upstream
|
||||
for i := 0; i < 3; i++ {
|
||||
st.cooldownFrom.Store(time.Now().Unix() - int64(backoffCap.Seconds()) - 1)
|
||||
ok, isProbe := p.ModelSchedulable("m")
|
||||
if !ok || !isProbe {
|
||||
t.Fatalf("window %d: expected a probe permit", i)
|
||||
}
|
||||
// a second caller in the same window gets nothing
|
||||
if ok2, _ := p.ModelSchedulable("m"); ok2 {
|
||||
t.Fatalf("window %d: more than one probe admitted", i)
|
||||
}
|
||||
if _, err := p.Chat(context.Background(), req); err == nil {
|
||||
t.Fatalf("window %d: expected the probe to fail", i)
|
||||
}
|
||||
p.ProbeDone("m")
|
||||
}
|
||||
if extra := hits.Load() - baseline; extra != 3 {
|
||||
t.Fatalf("3 probe windows must cost exactly 3 upstream requests, got %d", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaPenaltyIsLighterThanFailure is the regression for the report
|
||||
// "今天一次失败没看到,分数目前已经 -10 了" on an allowance-metered source
|
||||
// (sensenova). RecordQuotaExhausted deliberately does not touch failCount — the
|
||||
// WebUI therefore showed fails=0 and cooling=false — yet it used to charge the
|
||||
// SAME score penalty as a real failure, so two ordinary quota resets dragged the
|
||||
// slot to -10 with no visible fault. An exhausted allowance means "no budget
|
||||
// right now", not "this model is broken": the cooldown already keeps it out of
|
||||
// rotation, so the score penalty must be mild.
|
||||
func TestQuotaPenaltyIsLighterThanFailure(t *testing.T) {
|
||||
quota := &ModelState{}
|
||||
quota.RecordQuotaExhausted(time.Minute)
|
||||
fail := &ModelState{}
|
||||
fail.RecordFailure(false)
|
||||
|
||||
qp, fp := quota.Pref(), fail.Pref()
|
||||
if qp <= fp {
|
||||
t.Fatalf("a quota reset must cost less score than a real failure: quota=%d failure=%d", qp, fp)
|
||||
}
|
||||
if qp != -prefQuotaStep {
|
||||
t.Errorf("quota penalty = %d, want -%d", qp, prefQuotaStep)
|
||||
}
|
||||
if fp != -prefFailStep {
|
||||
t.Errorf("failure penalty = %d, want -%d", fp, prefFailStep)
|
||||
}
|
||||
|
||||
// The reported symptom: several quota resets in a day must NOT approach the
|
||||
// prefMin floor, since each one is a normal event for a metered source.
|
||||
s := &ModelState{}
|
||||
for i := 0; i < 5; i++ {
|
||||
s.RecordQuotaExhausted(time.Minute)
|
||||
}
|
||||
if got := s.Pref(); got <= prefMin/2 {
|
||||
t.Errorf("5 quota resets sank the score to %d (floor %d); a metered source would be written off", got, prefMin)
|
||||
}
|
||||
if s.FailCount() != 0 {
|
||||
t.Errorf("quota exhaustion must not inflate failCount, got %d", s.FailCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitPenaltyIsMild covers the sibling case: a 429 means "too fast",
|
||||
// not "broken", so a popular-but-healthy slot must not sink for being throttled.
|
||||
func TestRateLimitPenaltyIsMild(t *testing.T) {
|
||||
s := &ModelState{}
|
||||
s.RecordRateLimit()
|
||||
if got := s.Pref(); got != -prefQuotaStep {
|
||||
t.Errorf("429 penalty = %d, want -%d (same class as a quota event)", got, prefQuotaStep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuccessRecoversFasterThanBefore pins the penalty/reward asymmetry. Reward
|
||||
// used to be +1 against a -5 penalty, so a slot at -10 needed TEN consecutive
|
||||
// successes just to reach neutral — in practice it never got there, because a
|
||||
// low score makes the slot unattractive to the scheduler in the first place.
|
||||
func TestSuccessRecoversFasterThanBefore(t *testing.T) {
|
||||
s := &ModelState{}
|
||||
s.RecordFailure(false)
|
||||
s.RecordFailure(false) // -10, the score from the bug report
|
||||
|
||||
n := 0
|
||||
for s.Pref() < 0 {
|
||||
s.RecordSuccess()
|
||||
n++
|
||||
if n > 50 {
|
||||
t.Fatal("score never recovered")
|
||||
}
|
||||
}
|
||||
// with +2 per success, -10 needs 5; the old +1 needed 10
|
||||
if n > 5 {
|
||||
t.Errorf("recovery from -10 took %d successes, want <= 5", n)
|
||||
}
|
||||
// a real failure must still outweigh a single success, or failures stop
|
||||
// meaning anything
|
||||
if prefSuccessStep >= prefFailStep {
|
||||
t.Errorf("success reward %d must stay below the failure penalty %d", prefSuccessStep, prefFailStep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrefDecayRehabilitatesIdleSlot closes the starvation loop: a penalised
|
||||
// slot is unattractive, so the scheduler stops picking it, so it never earns the
|
||||
// successes that would rehabilitate it. Without decay the score stays negative
|
||||
// forever even though the upstream may be perfectly healthy.
|
||||
func TestPrefDecayRehabilitatesIdleSlot(t *testing.T) {
|
||||
s := &ModelState{}
|
||||
s.RecordFailure(false)
|
||||
s.RecordFailure(false)
|
||||
if s.Pref() != -2*prefFailStep {
|
||||
t.Fatalf("setup: pref=%d", s.Pref())
|
||||
}
|
||||
|
||||
// backdate the last touch by three decay intervals
|
||||
step := int64(prefDecayAfter / time.Second)
|
||||
s.prefTouched.Store(time.Now().Unix() - 3*step)
|
||||
|
||||
got := s.Pref()
|
||||
if got != -2*prefFailStep+3 {
|
||||
t.Errorf("after 3 idle intervals pref=%d, want %d", got, -2*prefFailStep+3)
|
||||
}
|
||||
|
||||
// decay must never overshoot into positive territory
|
||||
s.prefTouched.Store(time.Now().Unix() - 1000*step)
|
||||
if got := s.Pref(); got != 0 {
|
||||
t.Errorf("decay overshot to %d, want to stop at 0", got)
|
||||
}
|
||||
|
||||
// a positive score must not be dragged down for being idle
|
||||
good := &ModelState{}
|
||||
good.RecordSuccess()
|
||||
good.RecordSuccess()
|
||||
before := good.Pref()
|
||||
good.prefTouched.Store(time.Now().Unix() - 1000*step)
|
||||
if after := good.Pref(); after != before {
|
||||
t.Errorf("idle decay must not touch a positive score: %d -> %d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecayLiftsSlotOffTheFloor verifies decay actually restores schedulability:
|
||||
// a slot at prefMin is refused by ModelAvailable, and before decay existed only
|
||||
// a probe could rescue it.
|
||||
func TestDecayLiftsSlotOffTheFloor(t *testing.T) {
|
||||
s := &ModelState{}
|
||||
for i := 0; i < 10; i++ {
|
||||
s.RecordFailure(false)
|
||||
}
|
||||
if s.Pref() != prefMin {
|
||||
t.Fatalf("setup: want floor %d, got %d", prefMin, s.Pref())
|
||||
}
|
||||
// clear the cooldown so only the score gates the slot
|
||||
s.cooldownUntil.Store(0)
|
||||
s.cooldownFrom.Store(0)
|
||||
|
||||
step := int64(prefDecayAfter / time.Second)
|
||||
s.prefTouched.Store(time.Now().Unix() - 2*step)
|
||||
if got := s.Pref(); got <= prefMin {
|
||||
t.Errorf("decay failed to lift the slot off the floor: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,12 @@ type Provider interface {
|
||||
Name() string
|
||||
ModelFor(reqModel string) string
|
||||
ModelAvailable(model string) bool
|
||||
// ModelSchedulable is the probe-aware availability gate: ok reports
|
||||
// whether the model may take a request now, isProbe marks that it is only
|
||||
// allowed as a cooldown probe (the caller must release the permit with
|
||||
// ProbeDone once the attempt finished).
|
||||
ModelSchedulable(model string) (ok bool, isProbe bool)
|
||||
ProbeDone(model string)
|
||||
Pref(model string) int64
|
||||
Chat(ctx context.Context, req *types.ChatRequest) (*types.UnifiedResponse, error)
|
||||
ChatStream(ctx context.Context, req *types.ChatRequest) (<-chan types.UnifiedChunk, error)
|
||||
@ -176,18 +182,84 @@ type tierResult struct {
|
||||
hard []TierError // hard failures seen in this pass (nil = none)
|
||||
}
|
||||
|
||||
// candidate is one schedulable slot in a tier pass. probe marks a slot that is
|
||||
// still cooling but past its half-cooldown mark and holding the probe permit:
|
||||
// it is tried only after every normal slot, so probe traffic is what the tier
|
||||
// falls back to instead of what it prefers.
|
||||
type candidate struct {
|
||||
slot *Slot
|
||||
probe bool
|
||||
}
|
||||
|
||||
// collectCands partitions a tier's slots into normal and probe candidates,
|
||||
// normal first. Quota-exhausted slots are dropped outright. Every probe
|
||||
// candidate returned holds a probe permit, so the caller MUST call
|
||||
// releaseProbes on the result exactly once.
|
||||
func collectCands(slots []*Slot, exhausted func(*Slot) bool) []candidate {
|
||||
var normal, probes []candidate
|
||||
for _, sl := range slots {
|
||||
if exhausted != nil && exhausted(sl) {
|
||||
continue
|
||||
}
|
||||
ok, isProbe := sl.Prov.ModelSchedulable(sl.Model)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isProbe {
|
||||
probes = append(probes, candidate{slot: sl, probe: true})
|
||||
continue
|
||||
}
|
||||
normal = append(normal, candidate{slot: sl})
|
||||
}
|
||||
return append(normal, probes...)
|
||||
}
|
||||
|
||||
// releaseProbes hands every claimed probe permit back, whether or not the probe
|
||||
// slot was actually used.
|
||||
func releaseProbes(cands []candidate) {
|
||||
for _, c := range cands {
|
||||
if c.probe {
|
||||
c.slot.Prov.ProbeDone(c.slot.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalCount is how many leading candidates are normal (non-probe). The
|
||||
// round-robin cursor rotates only over those: probe slots are a strictly
|
||||
// ordered tail, never a rotation target.
|
||||
func normalCount(cands []candidate) int {
|
||||
for i, c := range cands {
|
||||
if c.probe {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(cands)
|
||||
}
|
||||
|
||||
// runTier executes one tier pass starting at the round-robin base index.
|
||||
// Cooldown is the only hard skip (re-verified per slot); a busy slot is
|
||||
// skipped without any penalty; a hard failure is recorded and the pass moves
|
||||
// on to the next slot (plan 2.3: "单请求内不重试已失败槽" — the failed slot is
|
||||
// not retried, the others still are). hard == nil and no success means every
|
||||
// candidate was merely busy/cooling, so the caller may wait a bounded time.
|
||||
func runTier(ctx context.Context, tn *TierNode, cands []*Slot, base int64, req *types.ChatRequest, stream bool) tierResult {
|
||||
//
|
||||
// Normal candidates rotate by base; probe candidates form a fixed tail tried
|
||||
// only after every normal slot failed or was busy.
|
||||
func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, req *types.ChatRequest, stream bool) tierResult {
|
||||
n := len(cands)
|
||||
norm := normalCount(cands)
|
||||
var hard []TierError
|
||||
for i := 0; i < n; i++ {
|
||||
sl := cands[(int(base)+i)%n]
|
||||
if !sl.Prov.ModelAvailable(sl.Model) {
|
||||
var c candidate
|
||||
if i < norm {
|
||||
c = cands[(int(base)+i)%norm] // rotate within the normal head
|
||||
} else {
|
||||
c = cands[i] // probe tail keeps its order
|
||||
}
|
||||
sl := c.slot
|
||||
// A probe candidate is intentionally NOT re-checked here: it is cooling
|
||||
// by definition, and its permit was already claimed.
|
||||
if !c.probe && !sl.Prov.ModelAvailable(sl.Model) {
|
||||
continue
|
||||
}
|
||||
r := *req
|
||||
@ -233,76 +305,40 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha
|
||||
}
|
||||
var ce ChainErr
|
||||
for _, tn := range chain.Tiers {
|
||||
// initial filter: quota-exhausted and cooling slots are dropped
|
||||
var cands []*Slot
|
||||
for _, sl := range tn.Slots {
|
||||
if exhausted != nil && exhausted(sl) {
|
||||
continue
|
||||
}
|
||||
if !sl.Prov.ModelAvailable(sl.Model) {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, sl)
|
||||
}
|
||||
// initial filter: quota-exhausted slots are dropped, cooling slots are
|
||||
// dropped unless they qualify as half-cooldown probes (appended last).
|
||||
cands := collectCands(tn.Slots, exhausted)
|
||||
if len(cands) == 0 {
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling or quota exhausted)", tn.Tier))
|
||||
continue
|
||||
}
|
||||
// preference orders a same-tier run; stable so equal prefs keep order
|
||||
sort.SliceStable(cands, func(i, j int) bool {
|
||||
return cands[i].Prov.Pref(cands[i].Model) > cands[j].Prov.Pref(cands[j].Model)
|
||||
})
|
||||
// No Pref sort: load balancing is done by round-robin cursor.
|
||||
// Persistently failing slots are excluded by ModelSchedulable
|
||||
// (which checks Pref > prefMin).
|
||||
base := tn.NextStart()
|
||||
res := runTier(ctx, tn, cands, base, req, stream)
|
||||
if res.resp != nil || res.chunks != nil {
|
||||
releaseProbes(cands)
|
||||
return res.resp, res.chunks, res.src, res.model, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
releaseProbes(cands)
|
||||
return nil, nil, "", "", ctx.Err()
|
||||
}
|
||||
if len(res.hard) > 0 {
|
||||
ce.Tiers = append(ce.Tiers, res.hard...)
|
||||
releaseProbes(cands)
|
||||
continue // hard failures: fall through to the next tier, no waiting
|
||||
}
|
||||
// every candidate was busy or cooling: bounded poll before downgrading
|
||||
deadline := time.Now().Add(busyWait)
|
||||
timer := time.NewTimer(busyPoll)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil, "", "", ctx.Err()
|
||||
case <-timer.C:
|
||||
if err := s.pollBusyTier(ctx, tn, cands, base, req, stream, &ce); err != nil {
|
||||
releaseProbes(cands)
|
||||
if r, ok := err.(*tierSuccess); ok {
|
||||
return r.res.resp, r.res.chunks, r.res.src, r.res.model, nil
|
||||
}
|
||||
done := time.Now().After(deadline)
|
||||
if done {
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
|
||||
break
|
||||
}
|
||||
// refresh candidates: cooldowns may have expired meanwhile
|
||||
var again []*Slot
|
||||
for _, sl := range cands {
|
||||
if sl.Prov.ModelAvailable(sl.Model) {
|
||||
again = append(again, sl)
|
||||
}
|
||||
}
|
||||
if len(again) == 0 {
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
|
||||
break
|
||||
}
|
||||
res = runTier(ctx, tn, again, base, req, stream)
|
||||
if res.resp != nil || res.chunks != nil {
|
||||
return res.resp, res.chunks, res.src, res.model, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, nil, "", "", ctx.Err()
|
||||
}
|
||||
if len(res.hard) > 0 {
|
||||
ce.Tiers = append(ce.Tiers, res.hard...)
|
||||
break // hard failure while waiting: stop waiting, fall through
|
||||
}
|
||||
timer.Reset(busyPoll)
|
||||
return nil, nil, "", "", err
|
||||
}
|
||||
releaseProbes(cands)
|
||||
}
|
||||
if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 {
|
||||
return nil, nil, "", "", fmt.Errorf("no auto slot configured")
|
||||
@ -310,6 +346,57 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha
|
||||
return nil, nil, "", "", &ce
|
||||
}
|
||||
|
||||
// tierSuccess carries a successful result out of pollBusyTier through the error
|
||||
// return. It is never surfaced to callers of chainDrive.
|
||||
type tierSuccess struct{ res tierResult }
|
||||
|
||||
func (t *tierSuccess) Error() string { return "tier success" }
|
||||
|
||||
// pollBusyTier waits a bounded time for a fully-busy tier to free a slot,
|
||||
// retrying the pass while cooldowns expire. It returns nil when the tier should
|
||||
// be abandoned (caller falls through to the next tier), a *tierSuccess when a
|
||||
// retry succeeded, or a context error.
|
||||
func (s *Scheduler) pollBusyTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, req *types.ChatRequest, stream bool, ce *ChainErr) error {
|
||||
deadline := time.Now().Add(busyWait)
|
||||
timer := time.NewTimer(busyPoll)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
|
||||
return nil
|
||||
}
|
||||
// refresh candidates: cooldowns may have expired meanwhile. Probe
|
||||
// candidates keep their already-claimed permit and stay eligible.
|
||||
var again []candidate
|
||||
for _, c := range cands {
|
||||
if c.probe || c.slot.Prov.ModelAvailable(c.slot.Model) {
|
||||
again = append(again, c)
|
||||
}
|
||||
}
|
||||
if len(again) == 0 {
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
|
||||
return nil
|
||||
}
|
||||
res := runTier(ctx, tn, again, base, req, stream)
|
||||
if res.resp != nil || res.chunks != nil {
|
||||
return &tierSuccess{res: res}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if len(res.hard) > 0 {
|
||||
ce.Tiers = append(ce.Tiers, res.hard...)
|
||||
return nil // hard failure while waiting: stop waiting, fall through
|
||||
}
|
||||
timer.Reset(busyPoll)
|
||||
}
|
||||
}
|
||||
|
||||
// ChainChat runs a non-streaming AUTO request down the chain. exhausted, when
|
||||
// non-nil, decides slot token-quota exhaustion. Returns the response, the
|
||||
// serving source and the exact model id used; on total failure a *ChainErr
|
||||
@ -327,6 +414,65 @@ func (s *Scheduler) ChainChatStream(ctx context.Context, chain *Chain, req *type
|
||||
return chunks, src, model, err
|
||||
}
|
||||
|
||||
// ChainImage runs an image-generation AUTO request down the chain: tiers
|
||||
// ascending (tier 1 highest priority), per-tier round-robin, same-tier order
|
||||
// by preference. Each slot's model is pinned to its own image id (ModelFor),
|
||||
// so a fallback switches per source. Cooling-down slots are skipped. Returns
|
||||
// the response, serving source and the exact model id used; on total failure
|
||||
// a *ChainErr summarizing every tier.
|
||||
func (s *Scheduler) ChainImage(ctx context.Context, chain *Chain, req *types.ImageGenRequest) (*types.UnifiedResponse, string, string, error) {
|
||||
if chain == nil || len(chain.Tiers) == 0 {
|
||||
return nil, "", "", fmt.Errorf("no image auto slot configured")
|
||||
}
|
||||
var ce ChainErr
|
||||
for _, tn := range chain.Tiers {
|
||||
cands := collectCands(tn.Slots, nil)
|
||||
if len(cands) == 0 {
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling)", tn.Tier))
|
||||
continue
|
||||
}
|
||||
// No Pref sort: load balancing is done by round-robin cursor.
|
||||
base := tn.NextStart()
|
||||
norm := normalCount(cands)
|
||||
var hard []TierError
|
||||
for i := 0; i < len(cands); i++ {
|
||||
var c candidate
|
||||
if i < norm {
|
||||
c = cands[(int(base)+i)%norm]
|
||||
} else {
|
||||
c = cands[i]
|
||||
}
|
||||
sl := c.slot
|
||||
if !c.probe && !sl.Prov.ModelAvailable(sl.Model) {
|
||||
continue
|
||||
}
|
||||
r := *req
|
||||
r.Model = sl.Model
|
||||
resp, err := sl.Prov.Image(ctx, &r)
|
||||
if ctx.Err() != nil {
|
||||
releaseProbes(cands)
|
||||
return nil, "", "", ctx.Err()
|
||||
}
|
||||
if err == nil {
|
||||
releaseProbes(cands)
|
||||
return resp, sl.Source, sl.Model, nil
|
||||
}
|
||||
if errors.Is(err, types.ErrBusy) {
|
||||
continue
|
||||
}
|
||||
hard = append(hard, TierError{Tier: tn.Tier, Source: sl.Source, Model: sl.Model, Err: err})
|
||||
}
|
||||
releaseProbes(cands)
|
||||
if len(hard) > 0 {
|
||||
ce.Tiers = append(ce.Tiers, hard...)
|
||||
}
|
||||
}
|
||||
if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 {
|
||||
return nil, "", "", fmt.Errorf("no image auto slot configured")
|
||||
}
|
||||
return nil, "", "", &ce
|
||||
}
|
||||
|
||||
// ---- direct scheduling ----
|
||||
|
||||
// Chat runs a chat request across cands, falling back on failure. Each
|
||||
@ -345,11 +491,15 @@ func (s *Scheduler) Chat(ctx context.Context, cands []Provider, req *types.ChatR
|
||||
p := cands[i]
|
||||
r := *req
|
||||
r.Model = p.ModelFor(req.Model)
|
||||
if !p.ModelAvailable(r.Model) {
|
||||
ok, isProbe := p.ModelSchedulable(r.Model)
|
||||
if !ok {
|
||||
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), r.Model)
|
||||
continue
|
||||
}
|
||||
resp, err := p.Chat(ctx, &r)
|
||||
if isProbe {
|
||||
p.ProbeDone(r.Model)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, "", "", ctx.Err()
|
||||
}
|
||||
@ -377,11 +527,15 @@ func (s *Scheduler) ChatStream(ctx context.Context, cands []Provider, req *types
|
||||
p := cands[i]
|
||||
r := *req
|
||||
r.Model = p.ModelFor(req.Model)
|
||||
if !p.ModelAvailable(r.Model) {
|
||||
ok, isProbe := p.ModelSchedulable(r.Model)
|
||||
if !ok {
|
||||
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), r.Model)
|
||||
continue
|
||||
}
|
||||
resp, err := p.ChatStream(ctx, &r)
|
||||
if isProbe {
|
||||
p.ProbeDone(r.Model)
|
||||
}
|
||||
if err == nil {
|
||||
return resp, p.Name(), r.Model, nil
|
||||
}
|
||||
@ -400,11 +554,16 @@ func (s *Scheduler) Image(ctx context.Context, cands []Provider, req *types.Imag
|
||||
var lastErr error
|
||||
for i := 0; i < attempts && i < len(cands); i++ {
|
||||
p := cands[i]
|
||||
if !p.ModelAvailable(p.ModelFor(req.Model)) {
|
||||
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), p.ModelFor(req.Model))
|
||||
im := p.ModelFor(req.Model)
|
||||
ok, isProbe := p.ModelSchedulable(im)
|
||||
if !ok {
|
||||
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), im)
|
||||
continue
|
||||
}
|
||||
resp, err := p.Image(ctx, req)
|
||||
if isProbe {
|
||||
p.ProbeDone(im)
|
||||
}
|
||||
if err == nil {
|
||||
return resp, p.Name(), nil
|
||||
}
|
||||
|
||||
@ -22,6 +22,14 @@ type fakeProvider struct {
|
||||
busy atomic.Bool
|
||||
fail atomic.Bool
|
||||
chatHits atomic.Int64
|
||||
// probeable makes an unavailable provider eligible as a half-cooldown
|
||||
// probe candidate. probePermit is the single-token permit; probeClaims
|
||||
// counts how many times it was handed out and probeDones how many times it
|
||||
// was returned, so tests can assert the permit is always released.
|
||||
probeable atomic.Bool
|
||||
probePermit atomic.Bool
|
||||
probeClaims atomic.Int64
|
||||
probeDones atomic.Int64
|
||||
}
|
||||
|
||||
func fakeProv(name, model string) *fakeProvider {
|
||||
@ -36,6 +44,25 @@ func (f *fakeProvider) ModelFor(reqModel string) string { return f.model }
|
||||
|
||||
func (f *fakeProvider) ModelAvailable(model string) bool { return f.available.Load() }
|
||||
|
||||
func (f *fakeProvider) ModelSchedulable(model string) (bool, bool) {
|
||||
if f.available.Load() {
|
||||
return true, false
|
||||
}
|
||||
if !f.probeable.Load() {
|
||||
return false, false
|
||||
}
|
||||
if f.probePermit.CompareAndSwap(false, true) {
|
||||
f.probeClaims.Add(1)
|
||||
return true, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (f *fakeProvider) ProbeDone(model string) {
|
||||
f.probeDones.Add(1)
|
||||
f.probePermit.Store(false)
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Pref(model string) int64 { return f.pref.Load() }
|
||||
|
||||
func (f *fakeProvider) Chat(ctx context.Context, req *types.ChatRequest) (*types.UnifiedResponse, error) {
|
||||
@ -177,14 +204,21 @@ func TestChainPreferenceSinksButStaysReachable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
// higher pref (good) is tried first and hard-fails; the pass moves on to
|
||||
// the negative-pref slot, which sinks but stays reachable: with the real
|
||||
// provider its success would RecordSuccess (+1 pref, self-heal)
|
||||
// Round-robin starts at index 0 (neg). neg is available (pref=-5 > -20)
|
||||
// and succeeds. good is never tried because neg already won.
|
||||
if src != "neg" || model != "n" || resp.Content != "neg" {
|
||||
t.Fatalf("served src=%q model=%q content=%q", src, model, resp.Content)
|
||||
}
|
||||
if good.chatHits.Load() == 0 {
|
||||
t.Fatal("higher-pref slot must be tried first")
|
||||
if good.chatHits.Load() != 0 {
|
||||
t.Fatal("neg was tried first and succeeded; good must not be attempted")
|
||||
}
|
||||
// Second request: cursor advances. neg wins again (good hard-fails).
|
||||
resp2, src2, _, err2 := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
if err2 != nil {
|
||||
t.Fatalf("second chain: %v", err2)
|
||||
}
|
||||
if src2 != "neg" || resp2.Content != "neg" {
|
||||
t.Fatalf("second req src=%q content=%q", src2, resp2.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@ -315,3 +349,149 @@ func TestChainResetCooldownAfterSwap(t *testing.T) {
|
||||
t.Fatalf("first start = %d, want 0", base)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- half-cooldown probe candidates (plan 阶段 1.3) ----
|
||||
|
||||
// TestChainProbeIsLastResort pins the ordering rule: a probe candidate must not
|
||||
// steal traffic from a healthy slot in the same tier.
|
||||
func TestChainProbeIsLastResort(t *testing.T) {
|
||||
healthy := fakeProv("healthy", "h")
|
||||
cooling := fakeProv("cooling", "c")
|
||||
cooling.available.Store(false)
|
||||
cooling.probeable.Store(true)
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 0, Model: "c", Source: "cooling"}, // configured FIRST on purpose
|
||||
{Tier: 0, Model: "h", Source: "healthy"},
|
||||
}, bySource(healthy, cooling))
|
||||
s := New(0)
|
||||
for i := 0; i < 3; i++ {
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d: %v", i, err)
|
||||
}
|
||||
if src != "healthy" {
|
||||
t.Fatalf("iter %d served by %q; a probe slot must never outrank a healthy one", i, src)
|
||||
}
|
||||
}
|
||||
if cooling.chatHits.Load() != 0 {
|
||||
t.Fatalf("probe slot was hit %d times while a healthy slot existed", cooling.chatHits.Load())
|
||||
}
|
||||
if got := cooling.probeDones.Load(); got != cooling.probeClaims.Load() {
|
||||
t.Fatalf("probe permits leaked: claims=%d dones=%d", cooling.probeClaims.Load(), got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainProbeServesWhenNothingElseCan is the recovery path: with every normal
|
||||
// slot gone, the cooling slot's probe carries the request instead of the tier
|
||||
// failing outright.
|
||||
func TestChainProbeServesWhenNothingElseCan(t *testing.T) {
|
||||
cooling := fakeProv("cooling", "c")
|
||||
cooling.available.Store(false)
|
||||
cooling.probeable.Store(true)
|
||||
ch := BuildChain([]Rule{{Tier: 0, Model: "c", Source: "cooling"}}, bySource(cooling))
|
||||
s := New(0)
|
||||
resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("probe must serve the request: %v", err)
|
||||
}
|
||||
if src != "cooling" || model != "c" {
|
||||
t.Fatalf("served by src=%q model=%q, want cooling/c", src, model)
|
||||
}
|
||||
if resp == nil || resp.Content != "cooling" {
|
||||
t.Fatalf("bad response: %#v", resp)
|
||||
}
|
||||
if cooling.chatHits.Load() != 1 {
|
||||
t.Fatalf("probe must issue exactly one request, got %d", cooling.chatHits.Load())
|
||||
}
|
||||
if cooling.probeClaims.Load() != 1 || cooling.probeDones.Load() != 1 {
|
||||
t.Fatalf("permit accounting: claims=%d dones=%d, want 1/1", cooling.probeClaims.Load(), cooling.probeDones.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainProbePermitReleasedOnFailure: a failed probe must still hand its
|
||||
// permit back, otherwise the slot could never be probed again.
|
||||
func TestChainProbePermitReleasedOnFailure(t *testing.T) {
|
||||
cooling := fakeProv("cooling", "c")
|
||||
cooling.available.Store(false)
|
||||
cooling.probeable.Store(true)
|
||||
cooling.fail.Store(true)
|
||||
ch := BuildChain([]Rule{{Tier: 0, Model: "c", Source: "cooling"}}, bySource(cooling))
|
||||
s := New(0)
|
||||
if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil); err == nil {
|
||||
t.Fatal("expected the failing probe to surface an error")
|
||||
}
|
||||
if cooling.probeClaims.Load() != 1 || cooling.probeDones.Load() != 1 {
|
||||
t.Fatalf("permit accounting: claims=%d dones=%d, want 1/1", cooling.probeClaims.Load(), cooling.probeDones.Load())
|
||||
}
|
||||
// permit is free again for the next attempt
|
||||
if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil); err == nil {
|
||||
t.Fatal("expected the second probe to fail too")
|
||||
}
|
||||
if cooling.probeClaims.Load() != 2 {
|
||||
t.Fatalf("permit must be reclaimable, claims=%d", cooling.probeClaims.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainProbeDoesNotBlockTierFallthrough: a cooling tier-1 slot that is not
|
||||
// probeable must still let the request fall through to tier 2.
|
||||
func TestChainProbeDoesNotBlockTierFallthrough(t *testing.T) {
|
||||
cold := fakeProv("cold", "c")
|
||||
cold.available.Store(false) // cooling and NOT probeable
|
||||
backup := fakeProv("backup", "b")
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 1, Model: "c", Source: "cold"},
|
||||
{Tier: 2, Model: "b", Source: "backup"},
|
||||
}, bySource(cold, backup))
|
||||
s := New(0)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
if err != nil || src != "backup" {
|
||||
t.Fatalf("want fallthrough to backup, got src=%q err=%v", src, err)
|
||||
}
|
||||
if cold.chatHits.Load() != 0 {
|
||||
t.Fatal("non-probeable cooling slot must not be hit")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectProbeReleasesPermit covers the non-AUTO paths (Chat/ChatStream/Image).
|
||||
func TestDirectProbeReleasesPermit(t *testing.T) {
|
||||
cooling := fakeProv("cooling", "m")
|
||||
cooling.available.Store(false)
|
||||
cooling.probeable.Store(true)
|
||||
s := New(0)
|
||||
_, src, _, err := s.Chat(context.Background(), []Provider{cooling}, chatReq())
|
||||
if err != nil || src != "cooling" {
|
||||
t.Fatalf("direct probe must serve: src=%q err=%v", src, err)
|
||||
}
|
||||
if cooling.probeClaims.Load() != 1 || cooling.probeDones.Load() != 1 {
|
||||
t.Fatalf("permit accounting: claims=%d dones=%d", cooling.probeClaims.Load(), cooling.probeDones.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainProbeRoundRobinUnaffected: adding a probe tail must not disturb the
|
||||
// round-robin rotation over the healthy head.
|
||||
func TestChainProbeRoundRobinUnaffected(t *testing.T) {
|
||||
a, b := fakeProv("s1", "a"), fakeProv("s2", "b")
|
||||
cooling := fakeProv("s3", "c")
|
||||
cooling.available.Store(false)
|
||||
cooling.probeable.Store(true)
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 0, Model: "a", Source: "s1"},
|
||||
{Tier: 0, Model: "b", Source: "s2"},
|
||||
{Tier: 0, Model: "c", Source: "s3"},
|
||||
}, bySource(a, b, cooling))
|
||||
s := New(0)
|
||||
var got []string
|
||||
for i := 0; i < 4; i++ {
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d: %v", i, err)
|
||||
}
|
||||
got = append(got, src)
|
||||
}
|
||||
want := []string{"s1", "s2", "s1", "s2"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("rr order = %v, want %v (probe tail must not join the rotation)", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,15 +19,15 @@ var ErrBusy = errors.New("provider busy")
|
||||
// ---- OpenAI wire request (gateway input) ----
|
||||
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []interface{} `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
DisableThinking bool `json:"disable_thinking"`
|
||||
ExtraBody map[string]interface{} `json:"-"`
|
||||
Model string `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []interface{} `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
DisableThinking bool `json:"disable_thinking"`
|
||||
ExtraBody map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
func (r *ChatRequest) MarshalJSON() ([]byte, error) {
|
||||
@ -71,6 +71,7 @@ type ToolCall struct {
|
||||
// ---- Unified internal representation (what adapters produce) ----
|
||||
|
||||
type UnifiedResponse struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
@ -118,23 +119,23 @@ func (t TokenUsage) MarshalJSON() ([]byte, error) {
|
||||
pdetails = &PromptTokensDetails{CachedTokens: t.PromptCacheHit}
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
Prompt int `json:"prompt"`
|
||||
Completion int `json:"completion"`
|
||||
Total int `json:"total"`
|
||||
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
||||
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
|
||||
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
Prompt int `json:"prompt"`
|
||||
Completion int `json:"completion"`
|
||||
Total int `json:"total"`
|
||||
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
||||
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
|
||||
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"`
|
||||
}{
|
||||
PromptTokens: t.Prompt,
|
||||
CompletionTokens: t.Completion,
|
||||
TotalTokens: t.Total,
|
||||
Prompt: t.Prompt,
|
||||
Completion: t.Completion,
|
||||
Total: t.Total,
|
||||
PromptTokensDetails: pdetails,
|
||||
PromptTokens: t.Prompt,
|
||||
CompletionTokens: t.Completion,
|
||||
TotalTokens: t.Total,
|
||||
Prompt: t.Prompt,
|
||||
Completion: t.Completion,
|
||||
Total: t.Total,
|
||||
PromptTokensDetails: pdetails,
|
||||
PromptCacheHitTokens: t.PromptCacheHit,
|
||||
PromptCacheMissTokens: t.PromptCacheMiss,
|
||||
})
|
||||
@ -167,8 +168,8 @@ type ImageGenResponse struct {
|
||||
// streaming tool_calls array (incremental fragments with an index field), which
|
||||
// OpenAI-compatible clients accumulate themselves.
|
||||
type UnifiedChunk struct {
|
||||
Content string `json:"content"`
|
||||
Done bool `json:"done"`
|
||||
Content string `json:"content"`
|
||||
Done bool `json:"done"`
|
||||
// FinishReason carries the upstream finish/stop reason ("tool_calls",
|
||||
// "length", ...) when the adapter provides it; the gateway emits it on
|
||||
// the terminating chunk instead of the default "stop".
|
||||
@ -203,4 +204,4 @@ func OneLine(s string, n int) string {
|
||||
s = s[:n] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
90
packaging/core-dist.sh
Executable file
90
packaging/core-dist.sh
Executable file
@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the llmsproxy CORE distribution packages (deb + rpm + tar.gz).
|
||||
#
|
||||
# Unlike the GUI packages (Electron), the core is a single static-ish binary
|
||||
# that server admins install with dpkg/rpm. The systemd unit bakes in the
|
||||
# memory tuning (GOGC=50, MALLOC_ARENA_MAX=2) measured on the production box.
|
||||
#
|
||||
# Requires: go, nfpm (PATH), dpkg-deb/rpmbuild for verification
|
||||
# Outputs: cmd/build/dist/llmsproxy_<ver>_amd64.deb
|
||||
# cmd/build/dist/llmsproxy-<ver>.x86_64.rpm
|
||||
# cmd/build/dist/llmsproxy-<ver>-linux-amd64.tar.gz
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
VERSION="${1:-$(git describe --tags --dirty 2>/dev/null | sed 's/^v//' || echo 0.1.0)}"
|
||||
DIST="$ROOT/cmd/build/dist"
|
||||
BUILD_BIN="$ROOT/cmd/build/llmsproxy"
|
||||
|
||||
log() { printf '\033[1;34m[core-dist]\033[0m %s\n' "$*"; }
|
||||
|
||||
# nfpm is installed via `go install` -> lives in GOPATH/bin, which may not be
|
||||
# on the caller's PATH (non-interactive shells, cron). Resolve it explicitly.
|
||||
NFPM_BIN="$(command -v nfpm || true)"
|
||||
[ -z "$NFPM_BIN" ] && [ -n "$(go env GOPATH)" ] && {
|
||||
[ -x "$(go env GOPATH)/bin/nfpm" ] && NFPM_BIN="$(go env GOPATH)/bin/nfpm"
|
||||
}
|
||||
[ -n "$NFPM_BIN" ] || {
|
||||
echo "[core-dist] nfpm missing. Install: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.2"
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$DIST"
|
||||
|
||||
# 1) build the core binary (luajit tag, -s -w like deploy.sh)
|
||||
log "building core binary (luajit, stripped)..."
|
||||
CGO_ENABLED=1 go build -tags luajit -trimpath -ldflags="-s -w" -o "$BUILD_BIN" ./cmd/llmsproxy
|
||||
|
||||
# 2) sanity gates: binary must contain the expected symbols and not be degenerate.
|
||||
# NOTE under `set -o pipefail`, `strings | grep -q` is BROKEN: grep -q exits
|
||||
# on first hit and closes the pipe, strings gets SIGPIPE -> pipeline returns
|
||||
# 141 -> the check spuriously fails. Stage strings output in a temp file.
|
||||
SYMS_TMP="$(mktemp)"
|
||||
strings "$BUILD_BIN" > "$SYMS_TMP"
|
||||
for sym in ProbeSlots reqRing shrinkStepLocked mergeUsage; do
|
||||
grep -qF "$sym" "$SYMS_TMP" || {
|
||||
echo "[core-dist] FATAL: binary missing expected symbol '$sym'"; rm -f "$SYMS_TMP"; exit 1
|
||||
}
|
||||
done
|
||||
rm -f "$SYMS_TMP"
|
||||
[ "$(stat -c%s "$BUILD_BIN")" -gt 5000000 ] || {
|
||||
echo "[core-dist] FATAL: core binary implausibly small"; exit 1
|
||||
}
|
||||
log " ok: $(stat -c%s "$BUILD_BIN") bytes, symbols present"
|
||||
|
||||
# 3) deb + rpm via nfpm. nfpm v2 here does not render {{ .Env.VERSION }}, so
|
||||
# stamp the version into a throwaway copy of the config; -t places the
|
||||
# generated package in the dist dir.
|
||||
log "packaging deb + rpm (v$VERSION)..."
|
||||
NFPM_CFG="$(mktemp)" && sed "s/__VERSION__/$VERSION/g" "$ROOT/packaging/nfpm.yaml" > "$NFPM_CFG"
|
||||
"$NFPM_BIN" package --config "$NFPM_CFG" --packager deb -t "$DIST"
|
||||
"$NFPM_BIN" package --config "$NFPM_CFG" --packager rpm -t "$DIST"
|
||||
rm -f "$NFPM_CFG"
|
||||
|
||||
# 4) tar.gz
|
||||
log "packaging tar.gz..."
|
||||
TAR_DIR="$DIST/llmsproxy-$VERSION-linux-amd64"
|
||||
rm -rf "$TAR_DIR"; mkdir -p "$TAR_DIR"
|
||||
cp "$BUILD_BIN" "$TAR_DIR/llmsproxy"
|
||||
cp "$ROOT/config.yaml" "$TAR_DIR/config.example.yaml"
|
||||
cp "$ROOT/packaging/llmsproxy.service" "$TAR_DIR/llmsproxy.service"
|
||||
mkdir -p "$TAR_DIR/adapters"
|
||||
cp "$ROOT"/internal/lua/adapters/*.lua "$TAR_DIR/adapters/"
|
||||
tar -C "$DIST" -czf "$DIST/llmsproxy-$VERSION-linux-amd64.tar.gz" \
|
||||
"$(basename "$TAR_DIR")"
|
||||
rm -rf "$TAR_DIR"
|
||||
|
||||
# 5) verify artifacts are healthy (the same gate that would have caught the
|
||||
# 264KB exe fiasco)
|
||||
log "verifying packages..."
|
||||
for f in "$DIST"/llmsproxy_*.deb "$DIST"/llmsproxy-*.rpm "$DIST"/*.tar.gz; do
|
||||
[ -f "$f" ] || continue
|
||||
sz=$(stat -c%s "$f")
|
||||
[ "$sz" -gt 100000 ] || { echo "[core-dist] FATAL: $f suspiciously small ($sz B)"; exit 1; }
|
||||
log " ✓ $f ($((sz/1024)) KB)"
|
||||
done
|
||||
dpkg-deb -I "$DIST"/llmsproxy_*.deb 2>/dev/null | grep -E 'Package|Version' | head -2
|
||||
|
||||
log "done: $DIST"
|
||||
24
packaging/llmsproxy.service
Normal file
24
packaging/llmsproxy.service
Normal file
@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=LLMSProxy - unified OpenAI-compatible multi-source LLM gateway
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Memory tuning (measured, see README "内存占用"):
|
||||
# MALLOC_ARENA_MAX=2 caps glibc per-thread malloc arenas. LuaJIT allocates
|
||||
# through cgo -> glibc malloc, and glibc defaults to 8*nproc arenas, so every
|
||||
# OS thread that touches malloc reserved its own ~1 MB arena that is never
|
||||
# returned. Measured: 8-12 arenas -> 0.
|
||||
# GOGC=50 halves the Go heap growth target. On its own it does NOT help (the
|
||||
# saved heap is immediately eaten by more glibc arenas); combined with
|
||||
# MALLOC_ARENA_MAX it cut settled RSS by ~19%. This gateway is I/O bound, so
|
||||
# the extra GC cycles are free.
|
||||
Environment=GOGC=50
|
||||
Environment=MALLOC_ARENA_MAX=2
|
||||
ExecStart=/usr/bin/llmsproxy -config /etc/llmsproxy/config.yaml
|
||||
WorkingDirectory=/etc/llmsproxy
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
59
packaging/nfpm.yaml
Normal file
59
packaging/nfpm.yaml
Normal file
@ -0,0 +1,59 @@
|
||||
# nfpm config for the llmsproxy core gateway binary.
|
||||
# The version placeholder below is substituted by packaging/core-dist.sh (nfpm v2
|
||||
# here does not honour {{ .Env.VERSION }} template rendering), so do not edit it
|
||||
# by hand.
|
||||
name: llmsproxy
|
||||
arch: amd64
|
||||
platform: linux
|
||||
version: __VERSION__
|
||||
epoch: "1"
|
||||
section: net
|
||||
priority: optional
|
||||
maintainer: JianFeeeee <dev@modelrouter.local>
|
||||
description: |
|
||||
Unified OpenAI-compatible multi-source LLM gateway.
|
||||
Routes requests across multiple upstream LLM providers with Lua adapters,
|
||||
model scheduling, quota windows, an elastic Lua worker pool, and a Web UI.
|
||||
vendor: ModelRouter
|
||||
homepage: https://gitcode.com/JianFeeeee/ModelRouter
|
||||
license: MIT
|
||||
contents:
|
||||
# binary
|
||||
- src: ./cmd/build/llmsproxy
|
||||
dst: /usr/bin/llmsproxy
|
||||
file_info:
|
||||
mode: 0755
|
||||
# systemd unit (memory tuning baked in)
|
||||
- src: ./packaging/llmsproxy.service
|
||||
dst: /etc/systemd/system/llmsproxy.service
|
||||
file_info:
|
||||
mode: 0644
|
||||
# example config (seeded to /etc/llmsproxy on first install by postinst)
|
||||
- src: ./config.yaml
|
||||
dst: /usr/share/llmsproxy/config.example.yaml
|
||||
file_info:
|
||||
mode: 0644
|
||||
# built-in Lua adapters (deployed to /etc/llmsproxy/adapters)
|
||||
- src: ./internal/lua/adapters/*.lua
|
||||
dst: /usr/share/llmsproxy/adapters/
|
||||
file_info:
|
||||
mode: 0644
|
||||
# README pointer
|
||||
- src: ./README.md
|
||||
dst: /usr/share/doc/llmsproxy/README.md
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
scripts:
|
||||
postinstall: ./packaging/postinst.sh
|
||||
preremove: ./packaging/prerm.sh
|
||||
|
||||
rpm:
|
||||
group: Applications/Internet
|
||||
summary: Unified OpenAI-compatible multi-source LLM gateway
|
||||
compression: gzip
|
||||
|
||||
deb:
|
||||
compression: gzip
|
||||
fields:
|
||||
Recommends: ca-certificates
|
||||
22
packaging/postinst.sh
Executable file
22
packaging/postinst.sh
Executable file
@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# llmsproxy core package postinst
|
||||
set -e
|
||||
|
||||
# Runtime state belongs under /etc/llmsproxy just like the production layout:
|
||||
# runtime.json holds keys/created sources and is NOT touched on reinstall.
|
||||
mkdir -p /etc/llmsproxy/adapters
|
||||
|
||||
if [ -f /etc/llmsproxy/config.yaml ]; then
|
||||
echo "llmsproxy: keeping existing /etc/llmsproxy/config.yaml"
|
||||
else
|
||||
# seed a starter config from the packaged example
|
||||
cp -f /usr/share/llmsproxy/config.example.yaml /etc/llmsproxy/config.yaml 2>/dev/null || true
|
||||
chmod 0644 /etc/llmsproxy/config.yaml
|
||||
fi
|
||||
|
||||
# systemd
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
echo "llmsproxy: use 'systemctl enable --now llmsproxy' to start"
|
||||
fi
|
||||
exit 0
|
||||
14
packaging/prerm.sh
Executable file
14
packaging/prerm.sh
Executable file
@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# llmsproxy core package prerm
|
||||
set -e
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
# stop the service if this package's unit is the one installed
|
||||
if systemctl list-unit-files llmsproxy.service >/dev/null 2>&1 && \
|
||||
systemctl is-active llmsproxy.service >/dev/null 2>&1; then
|
||||
systemctl stop llmsproxy.service >/dev/null 2>&1 || true
|
||||
systemctl disable llmsproxy.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
49
packaging/verify-dist.sh
Executable file
49
packaging/verify-dist.sh
Executable file
@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify packaged GUI artifacts are healthy before a release is declared done.
|
||||
#
|
||||
# Background: on 2026-08-28 (1.3.0) the host wine broke (missing syswow64, so
|
||||
# wine -- the NSIS self-extract step -- failed), and electron-builder silently
|
||||
# wrote a 264 KB installer shell that did NOT embed the 86 MB payload. No gate
|
||||
# caught it, so a broken Windows installer shipped. This script is that gate.
|
||||
#
|
||||
# It enforces a per-artifact minimum size, so a degenerate installer (or a
|
||||
# build that only emitted a shell without its embedded payload) fails loudly.
|
||||
# A size check is the right signal here: a healthy NSIS exe is ~86 MB and the
|
||||
# broken 1.3.0 one was 264 KB (326x) — electron-builder compresses the embedded
|
||||
# app.7z, so a plaintext-payload string probe would be unreliable.
|
||||
set -uo pipefail
|
||||
|
||||
GUI_DIST="${1:-cmd/build/gui-dist}"
|
||||
VERSION="${2:-}"
|
||||
|
||||
log() { printf '[verify-dist] %s\n' "$*"; }
|
||||
fail() { printf '[verify-dist] FATAL: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ -d "$GUI_DIST" ] || fail "no dist dir: $GUI_DIST"
|
||||
|
||||
# (glob, min-bytes) — nsis.7z is NOT a required artifact: with `--win nsis` the
|
||||
# payload is embedded inside the Setup exe and only the exe is the formal product
|
||||
# (the .nsis.7z appears in intermediate/--dir builds only).
|
||||
checks=(
|
||||
"ModelRouter*.AppImage:10000000"
|
||||
"modelrouter-*.deb:10000000"
|
||||
"modelrouter-*.rpm:10000000"
|
||||
"ModelRouter Setup*.exe:5000000"
|
||||
)
|
||||
|
||||
for entry in "${checks[@]}"; do
|
||||
glob="${entry%%:*}"; min="${entry#*:}"
|
||||
# gui-dist accumulates installers from previous versions; check the MOST
|
||||
# RECENT artifact (last in sort) so a stale file never masks a broken build.
|
||||
# `find -name` (not a bare glob) so the space in "ModelRouter Setup *.exe"
|
||||
# is preserved as one file pattern.
|
||||
match="$(find "$GUI_DIST" -maxdepth 1 -name "$glob" -type f | sort -V | tail -1)"
|
||||
[ -z "$match" ] && fail "no artifact matching '$glob' in $GUI_DIST"
|
||||
sz=$(stat -c%s "$match")
|
||||
if [ "$sz" -lt "$min" ]; then
|
||||
fail "$(basename "$match") is only $sz bytes (min $min) — likely a degenerate shell like the 1.3.0 264KB exe"
|
||||
fi
|
||||
log " ✓ $(basename "$match") ($((sz/1024)) KB)"
|
||||
done
|
||||
|
||||
log "all artifacts healthy"
|
||||
Reference in New Issue
Block a user