# PyroDash
**Repository Path**: cm_min/pyro-dash
## Basic Information
- **Project Name**: PyroDash
- **Description**: No description available
- **Primary Language**: Unknown
- **License**: MIT
- **Default Branch**: feat/v0.1-gateway
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-09-18
- **Last Updated**: 2026-09-18
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# PyroDash
私有化部署的模型网关平台:本地小模型自主决定何时把推理交接给外部大模型,同时把每一次调用的
token 用量、缓存命中与金额完整记录下来。
## 能力
- **协同推理(offload)**:本机 llama.cpp 输出 `<|llm_offload|>N` 控制 token 时中断本地生成,
把 `{原始 query + 部分推理}` 交接给外部大模型续写,客户端感知不到切换。
- **静默降级**:外部不可用时自动续读本地流,不中断服务。
- **用量监控**:输入 / 输出 / 缓存命中 / 缓存写入四类 token 分开计量,并支持按 Provider、
模型、日期、协议四个维度聚合。
- **金额监控**:能查余额的厂商(如 DeepSeek `/user/balance`)展示厂商真值;其余厂商由本地
用量账本推算已用金额,并在接口中标记 `source` 以区分真值与推算。
- **灵活配置**:Provider 通过管理 API 增删改查,**改动即时生效**,无需重启;API Key 支持
`env:VAR_NAME` 间接引用,密钥不落库。
- **offload 开箱即用**:控制 token 是模型的**行为约定**而非固有知识,客户端提示词里没有它,
小模型永远不会主动求助。网关会**只对本地请求**补上这条约定(客户端已自带则跳过),
给外部大模型的请求不含它。可用 `PyroDash:LocalOffloadHintEnabled=false` 关闭。
- **三套入站协议**:`/v1/chat/completions`(标准)、`/v1/messages`(Claude 专用)、
`/v1/responses`(Codex 专用)。
- **工具调用(function calling)**:三套协议都支持端到端透传——客户端的 `tools` 与工具结果
原样转发给上游,上游的 `tool_calls` 逐帧回传,结束原因落成 `tool_calls` / `tool_use`。
依赖函数调用的 agent(Claude Code / Codex / Cline 等)可经网关工作。
- **多模态**:文本 + 图片(`image/png`、`image/jpeg`、`image/webp`、`image/gif`)。
只记录图片张数与字节数,**图片内容不落库**;远程图片 URL 默认拒绝(SSRF 防护)。
- **跨平台 AOT**:单个自包含可执行文件,覆盖 win-x64 / linux-x64 / linux-arm64 / osx-arm64,
监控面板已内嵌其中。
## 快速开始
```bash
# 1. 构建(前端产物会被内嵌进二进制)
export PATH="$HOME/.dotnet:$PATH"
cd src/PyroDash.Web && npm ci && npm run build && cd ../..
./scripts/publish-aot.sh linux-x64
# 2. 启动(无需任何环境变量,配置全部读自同目录的 appsettings.json)
cd publish/linux-x64
./pyrodash --urls http://127.0.0.1:8080
```
**首次启动会自动生成两枚随机令牌并写回配置文件**,日志里会打印(这是唯一一次打印它们):
```
已生成管理令牌并写入 /opt/pyrodash/appsettings.json:<32 位随机令牌>
已生成网关密钥并写入 /opt/pyrodash/appsettings.json:<32 位随机密钥>
管理令牌来源:本次生成
网关密钥来源:本次生成
```
从第二次启动起,凭据直接读自配置文件,日志只报来源、不再打印值:
```
管理令牌来源:配置文件
网关密钥来源:配置文件
```
把这两枚值填进客户端即可:
- 管理面板 :右上角「管理令牌」填 `PyroDash.AdminToken`
- 推理接口 `/v1/**`:`Authorization: Bearer `
发布目录不是字面单文件:`pyrodash` 旁边还有 `libe_sqlite3.so`(Windows 为 `e_sqlite3.dll`)
与 `appsettings.json`,迁移时请整体拷贝(**令牌就在 appsettings.json 里**,一起拷走才不会换新)。
## 配置
### 配置文件优先(严格)
`appsettings.json` 的 `PyroDash` 段是**随包分发、运维直接编辑**的配置载体。**文件里写了什么就跑什么**:
只要某个键写了一个具体值,它就是这个进程的运行值,环境变量不再有发言权——
旧 shell 里残留的、镜像里写死的、CI 注入的 `PYRODASH_*`,不可能再静默改掉运维写下的配置。
每个键都出现在文件里,值写 `null` 表示**未指定**:回退到环境变量,再回退到代码默认值。
**一旦在文件里写了具体值,就只有编辑文件才能改变它。**
优先级(从高到低):
| 顺序 | 来源 | 生效条件 |
| --- | --- | --- |
| 1 | `appsettings.json` 的 `PyroDash:*` | 键存在且不是「未指定」——**显式值即最终值** |
| 2 | `PYRODASH_*` 环境变量 | 文件里该键未指定时(容器 / CI / 只读卷的注入手段) |
| 3 | 旧版扁平键(`DatabasePath`、`MaxImagesPerRequest` …) | 仅为兼容历史写法保留 |
| 4 | 代码默认值 | 兜底 |
「未指定」的判定(对所有类型一致):**键缺失、JSON `null`、字符串为空或全空白、数组为 `null` 或 `[]`**。
其余都算显式值——特别是**布尔 `false` 与数字 `0` 是合法且必须生效的值**,不会被环境变量顶掉。
```jsonc
{
"Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } },
"AllowedHosts": "*",
"PyroDash": {
"DatabasePath": null, // null = 未指定 → 用 PYRODASH_DB,再不然用代码默认值 pyrodash.db
"LocalBaseUrl": null,
"LocalModelId": null,
"LocalVisionEnabled": null, // 写成 false 就成了显式值:PYRODASH_LOCAL_VISION=true 不再生效
"AdminToken": null, // 未指定 → 首次启动生成并写回,此后文件里的真值压过环境变量
"GatewayKeys": null, // null 或 [] 都算未指定 → 首次启动生成一枚
"BalancePollSeconds": null,
"DefaultMaxTokens": null,
"UpstreamTimeoutSeconds": null,
"Media": {
"MaxImagesPerRequest": null,
"MaxImageBytes": null,
"AllowRemoteImageUrls": null
}
}
}
```
同一份环境变量(`PYRODASH_LOCAL_VISION=true`)下,文件里三种写法的结果:
| 文件里的写法 | 结果 |
| --- | --- |
| `"LocalVisionEnabled": null` | 环境变量生效 → 本地视觉**开启**(容器 / CI 正是靠这个注入) |
| 键不存在 | 同 `null`:未指定 |
| `"LocalVisionEnabled": false` | 显式关闭,环境变量被忽略——写什么跑什么 |
### 首次启动自动供给(为什么不是写死一个默认令牌)
仓库推送远端,**写死令牌会进 git 历史并被所有部署共用**,等于全网一个密码。
因此文件里把这两个键留成未指定(`null` / `""` / `[]`)时,首次启动现场生成令牌并写回
**应用实际加载的那个** `appsettings.json`:
- 令牌是 24 字节密码学随机数(`RandomNumberGenerator`)的 base64,32 字符、URL 安全
- 只改这两个字段,文件里其余内容原样保留
- 写回之后文件里就是**真值**,于是从下一次启动起它压过环境变量里的任何值(严格文件优先)
- 写不进去(只读卷、权限不足)**不阻断启动**:把值打进日志并明确提示「未被持久化」,
运维照抄即可,绝不会因为文件只读就把人锁在门外
- 幂等:配置文件已填好的第二次启动既不重新生成也不重写文件(连 mtime 都不变)
### 逃生开关
```bash
PYRODASH_DISABLE_TOKEN_PROVISIONING=true
```
置真后完全不生成、不写任何文件,适合自管凭据的运维与需要密闭环境的测试。
### 安全语义(与空值有关,务必看清)
「空」在**取值**上是「未指定」(回退到环境变量 / 代码默认值),在**运行语义**上是:
- `AdminToken` 为空 → **管理接口一律 401**
- `GatewayKeys` 为空(`[]`)→ **`/v1` 放行所有请求**(等同 allow all),只适合本机开发。
启动时会为此打印显式告警。
## 客户端接入
| 客户端 | Base URL | 鉴权 |
| --- | --- | --- |
| OpenAI SDK / 任意兼容客户端 | `http://127.0.0.1:8080/v1` | `Authorization: Bearer ` |
| Claude Code / Anthropic SDK | `http://127.0.0.1:8080` | `x-api-key: ` |
| Codex CLI | `http://127.0.0.1:8080/v1` | `Authorization: Bearer ` |
逐客户端的完整填法(Claude Code 环境变量、Codex `config.toml`、GUI 客户端字段、curl 自测,
以及本机实测的坑——例如这是推理模型、`max_tokens` 给少了会被思考过程吃光)见
`docs/handoff/客户端接入与手动测试.md`;本机一键拉起「网关 + 本地小模型」的脚本是
`scripts/start-local.ps1`(模型与转换见 `docs/handoff/本地模型-GGUF转换与运行.md`)。
官方开源的三个 checkpoint 都已转成 **f16 GGUF(不做任何量化)**,`run.ps1` 默认把 `models\` 里
找到的全部 checkpoint **同时**提供出来:单个 llama-server 以 router 模式运行,
客户端用请求里的 `model` 点名(冷切换,按需加载、同时只驻留一个),**不需要重启任何东西**。
| `model` 取值 | 定位 |
| --- | --- |
| `PyroDash-4B-SFT` | 冷启动 SFT 版(默认) |
| `PyroDash-4B-GRPO-Lambda-0.05` | GRPO 强化,偏省成本 |
| `PyroDash-4B-GRPO-Lambda-0.6` | GRPO 强化,偏准确度 |
下载、转换、指纹校验与加载验证脚本都在 `scripts/gguf/`(`models/` 只放权重,不入库)。
客户端发别的模型名(`claude-sonnet-4-5`、`gpt-5-codex` 等)会回落到默认 checkpoint,不会 404。
## 配置项
下表是 `PyroDash.*` 配置键、等价的环境变量与代码默认值。
环境变量**只在文件里该键为 `null`(未指定)时生效**;在文件里写具体值即可锁死该键。
| 配置键(`appsettings.json`) | 环境变量(文件里写 `null` 时生效) | 代码默认值 | 说明 |
| --- | --- | --- | --- |
| `PyroDash:DatabasePath` | `PYRODASH_DB` | `pyrodash.db` | SQLite 数据库路径 |
| `PyroDash:LocalBaseUrl` | `PYRODASH_LOCAL_URL` | `http://127.0.0.1:8081` | 本机 llama.cpp 端点 |
| `PyroDash:LocalModelId` | `PYRODASH_LOCAL_MODEL` | `pyrodash-4b` | 本机模型标识;客户端未点名(或点名了本机没有的模型)时用它 |
| `PyroDash:LocalModels` | `PYRODASH_LOCAL_MODELS`(逗号分隔) | 空(= 只有 `LocalModelId` 一个) | 本机端点**同时提供**的模型名白名单。客户端请求里的 `model` 命中白名单才原样转发给本机(router 据此选 checkpoint),未命中回落 `LocalModelId`。该列表同时决定 `GET /v1/models` 里出现的本机模型 |
| `PyroDash:LocalVisionEnabled` | `PYRODASH_LOCAL_VISION` | `false` | 本机模型是否具备视觉能力;`false` 时含图片的请求跳过本地阶段直接走外部 |
| `PyroDash:LocalOffloadHintEnabled` | `PYRODASH_LOCAL_OFFLOAD_HINT_ENABLED` | `true` | 是否给**本地**请求补上「你可以输出 `<\|llm_offload\|>` 求助」的系统提示词。客户端已自带则自动跳过;该提示词**绝不会**进入给外部大模型的请求 |
| `PyroDash:LocalOffloadHint` | `PYRODASH_LOCAL_OFFLOAD_HINT` | 官方英文原文 | 自定义提示词文本(例如想换成中文) |
| `PyroDash:AdminToken` | `PYRODASH_ADMIN_TOKEN` | `null`(首次启动生成) | 管理接口令牌;**为空时管理接口全部拒绝** |
| `PyroDash:GatewayKeys` | `PYRODASH_GATEWAY_KEYS` | `null`(首次启动生成一枚) | 网关密钥数组(环境变量形式为逗号分隔);**为空时放行所有请求(仅限本机开发)** |
| `PyroDash:BalancePollSeconds` | `PYRODASH_BALANCE_POLL_SECONDS` | `300` | 余额轮询间隔,`0` 表示关闭 |
| `PyroDash:DefaultMaxTokens` | `PYRODASH_DEFAULT_MAX_TOKENS` | `4096` | 未指定时的输出上限 |
| `PyroDash:UpstreamTimeoutSeconds` | `PYRODASH_UPSTREAM_TIMEOUT_SECONDS` | `120` | 上游非流式请求超时 |
| `PyroDash:Media:MaxImagesPerRequest` | `PYRODASH_MAX_IMAGES` | `20` | 单请求图片张数上限 |
| `PyroDash:Media:MaxImageBytes` | `PYRODASH_MAX_IMAGE_BYTES` | `5242880` | 单张图片体积上限(字节) |
| `PyroDash:Media:AllowRemoteImageUrls` | `PYRODASH_ALLOW_REMOTE_IMAGE_URLS` | `false` | 是否允许远程图片 URL(默认拒绝,防 SSRF) |
| ——(不在文件里,只有环境变量) | `PYRODASH_DISABLE_TOKEN_PROVISIONING` | `false` | 逃生开关:置真时跳过首次启动的凭据生成,且不写任何文件 |
## 开发与验收
```bash
source scripts/dev-env.sh # .NET SDK 装在 ~/.dotnet,刻意不在 PATH
dotnet build -c Release # 必须 0 警告 0 错误
dotnet test -c Release # 必须全绿
./scripts/publish-aot.sh linux-x64 # 唯一发布入口(前端门禁 + 体积门禁 + 校验和)
./scripts/smoke-e2e.sh linux-x64 # 13 项端到端验收(假上游 + 真实 AOT 产物)
```
## 文档
- 方案原文转写:`docs/分享内容-转写.md`
- 设计总览与决策记录:`docs/superpowers/plans/2026-09-15-pyrodash-00-overview.md`
- 实施计划:`docs/superpowers/plans/2026-09-15-pyrodash-0{1,2,3}-*.md`