feat: 发布 billing 0.1.0

This commit is contained in:
chuan
2026-08-15 22:31:12 +08:00
parent d2a0bebe16
commit 6775b64f1a
41 changed files with 1742 additions and 159 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
# cpa-ext
# billing
当前项目的本质是对 `CLIProxyAPI` 的核心扩展,功能边界会非常收敛
@@ -19,9 +19,9 @@
- 默认不检查这些目录的 Git 状态、提交历史、分支、远端或工作区改动。
- 不为这些仓库修复问题,不修改或提交其中的代码,也不处理其 Git 状态。
- 上述限制不影响运行时测试:可直接加载已经构建好的参考插件;测试 CPA 插件组合时,按需启用 `cpa-key-billing` 作为对照插件,但不得因此进入其仓库做源码或 Git 操作。
- 只有在 cpa-ext 的兼容性验证、测试或实现判断确有必要时,才按最小范围只读查看相关文件。
- 只有在 billing 的兼容性验证、测试或实现判断确有必要时,才按最小范围只读查看相关文件。
- 需要修改、构建、提交或维护任一参考仓库时,必须由用户明确提出。
- 项目的实现、提交和工作区清洁度只以 cpa-ext 根仓库为准。
- 项目的实现、提交和工作区清洁度只以 billing 根仓库为准。
## DO
+42 -30
View File
@@ -1,6 +1,6 @@
# cpa-ext
# billing
`cpa-ext` 是 CLIProxyAPI 的收敛式核心扩展,提供持久化用量与价格展示、下游 Key 管理、模型准入和上游凭证定向路由。一个 Key 代表一个调用用户;管理员可以创建、禁用或永久归档 Key,并按 Key 查看统计。
`billing` 是 CLIProxyAPI 的收敛式计费插件,提供持久化用量与价格展示、下游 Key 管理、模型准入和上游凭证定向路由。一个 Key 代表一个调用用户;管理员可以创建、禁用或永久归档 Key,并按 Key 查看统计。
每个 Key 同时拥有独立的美元额度账户。管理员可以分配本周期额度、设置日/周/月自动重置和并发上限,并查看不可变的扣费、额度调整与重置账目。额度采用请求结束后结算的软限制:余额为正时放行,实际 Usage 到达后扣费,最后一个或多个在途请求可能产生负余额,之后的新请求返回 429。
@@ -11,39 +11,39 @@ sequenceDiagram
autonumber
participant Client as 用户 / Codex
participant CPA as CLIProxyAPI
participant Ext as cpa-ext
participant Billing as billing
participant DB as SQLite
participant Upstream as 实际模型服务
Client->>CPA: 携带下游 Key 发起模型请求
CPA->>Ext: frontend_auth.authenticate
Ext->>DB: 查询 Key 与状态
DB-->>Ext: 用户访问配置
CPA->>Billing: frontend_auth.authenticate
Billing->>DB: 查询 Key 与状态
DB-->>Billing: 用户访问配置
alt Key 无效或已停用
Ext-->>CPA: 认证失败
Billing-->>CPA: 认证失败
CPA-->>Client: 401 Unauthorized
else 认证成功
Ext-->>CPA: 返回用户身份(稳定 Key ID)
CPA->>Ext: request.intercept_before
Ext->>DB: 检查模型权限、余额与并发并占用名额
Billing-->>CPA: 返回用户身份(稳定 Key ID)
CPA->>Billing: request.intercept_before
Billing->>DB: 检查模型权限、余额与并发并占用名额
alt 模型、额度或并发不允许
Ext-->>CPA: 终止请求(403 / 429 / 503
Billing-->>CPA: 终止请求(403 / 429 / 503
CPA-->>Client: 返回结构化错误
else 请求准入
Ext-->>CPA: 允许继续
CPA->>Ext: scheduler.pick(可用上游候选)
Billing-->>CPA: 允许继续
CPA->>Billing: scheduler.pick(可用上游候选)
alt 自动路由
Ext-->>CPA: 委托 CPA 内置调度
Billing-->>CPA: 委托 CPA 内置调度
else 严格路由
Ext->>DB: 读取该用户绑定的上游账号
Ext-->>CPA: 指定唯一 Auth ID
Billing->>DB: 读取该用户绑定的上游账号
Billing-->>CPA: 指定唯一 Auth ID
end
CPA->>Ext: request.intercept_after
Ext-->>CPA: 复核价格配置与实际上游
CPA->>Billing: request.intercept_after
Billing-->>CPA: 复核价格配置与实际上游
alt 价格缺失或严格路由失效
CPA-->>Client: 503 Service Unavailable
@@ -52,24 +52,25 @@ sequenceDiagram
Upstream-->>CPA: 模型响应 / 流式输出
CPA-->>Client: 返回模型结果
CPA->>Ext: request.complete(请求终态)
Ext->>DB: 释放并发名额,保存成功、失败或取消结果
CPA->>Ext: usage.handle(如有最终 Token 用量)
Ext->>Ext: 按模型价格计算成本
Ext->>DB: 保存用量、扣减余额并写入账目
CPA->>Billing: request.complete(请求终态)
Billing->>DB: 释放并发名额,保存成功、失败或取消结果
CPA->>Billing: usage.handle(如有最终 Token 用量)
Billing->>Billing: 按模型价格计算成本
Billing->>DB: 保存用量、扣减余额并写入账目
end
end
end
```
CLIProxyAPI 负责 HTTP 接入、协议转换、上游凭证和实际请求执行;`cpa-ext` 通过插件回调参与请求决策与记录,不直接连接模型服务。`request.complete``usage.handle` 是独立事实回调,实际到达顺序不作为数据正确性的前提。
CLIProxyAPI 负责 HTTP 接入、协议转换、上游凭证和实际请求执行;`billing` 通过插件回调参与请求决策与记录,不直接连接模型服务。`request.complete``usage.handle` 是独立事实回调,实际到达顺序不作为数据正确性的前提。
## 当前兼容目标
- CLIProxyAPI 源码:`.externals/CLIProxyAPI/`,检查时 revision 为 `f43aad7637ad813745bf7d341acb5663617570c5`
- 插件版本:`0.1.0`
- Native ABI`1`
- RPC schema:最高 `3`,注册时按宿主版本向下协商
- 插件 ID / 动态库文件名:`cpa-ext`
- 插件 ID / 动态库文件名:`billing`
- 已声明能力:`frontend_auth_provider`(独占)、`scheduler``request_interceptor``request_lifecycle_plugin``usage_plugin``management_api`
契约来源以本仓库内 `.externals/CLIProxyAPI/sdk/pluginabi/types.go``.externals/CLIProxyAPI/sdk/pluginapi/types.go``.externals/CLIProxyAPI/internal/pluginhost/rpc_schema.go` 为准。
@@ -91,7 +92,15 @@ sudo apt-get install -y build-essential
./scripts/build.ps1
```
产物为 `bin/cpa-ext.so`,适用于运行在 WSL/Linux 的 CLIProxyAPI。SQLite 驱动依赖 CGO,普通开发测试也应在已经安装 GCC 的 WSL/Linux 环境执行
生成 Linux/amd64 发布包和 SHA-256 校验文件
```bash
bash ./scripts/package.sh 0.1.0
```
产物位于 `dist/billing_0.1.0_linux_amd64.tar.gz`,只包含动态库、版本元数据、README 和示例配置。
产物为 `bin/billing.so`,适用于运行在 WSL/Linux 的 CLIProxyAPI。SQLite 驱动依赖 CGO,普通开发测试也应在已经安装 GCC 的 WSL/Linux 环境执行:
```bash
CGO_ENABLED=1 go test ./...
@@ -99,9 +108,9 @@ CGO_ENABLED=1 go test ./...
## 加载到 CLIProxyAPI
1.`bin/cpa-ext.so` 放进 Linux CLIProxyAPI 配置的插件目录(默认 `plugins/`)。
1.`bin/billing.so` 放进 Linux CLIProxyAPI 配置的插件目录(默认 `plugins/`)。
2. 合并 `config.example.yaml` 中的 `plugins` 配置。
3. 重启宿主,确认日志中成功加载插件 `cpa-ext`
3. 重启宿主,确认日志中成功加载插件 `billing`
4. 首次启动会创建名称为 `default`、值为 `000000` 的下游 Key,保持现有 Codex 配置可用。
5. 发起一个 Codex 请求,确认管理页面出现该 Key 的用量和实际上游 Auth ID。
@@ -122,7 +131,9 @@ CGO_ENABLED=1 go test ./...
现有和新建 Key 的初始额度都是 `$0`,默认不自动重置、并发上限为 4。管理员分配额度后才能发起模型请求。金额以微美元整数保存;修改额度不会清空本周期已用金额,手动或自动重置不会结转旧余额。允许模型没有价格配置时,请求会在触达上游前以 503 拒绝。
`config_yaml` 中包含宿主补充的 `enabled``priority`;插件会解析 `codex_only`、数据库路径和首次导入 Key。完整下游 Key 只通过受 CPA Management Key 保护的 Key 管理接口返回,不写入普通日志或错误消息;上游 Token、Cookie 和原始凭证不会由插件读取或保存
价格页面可以手动从 `models.dev/catalog.json` 更新供应商级参考目录,并把任意参考模型的价格导入任意本地模型。例如本地的 `deepseek-v4-flash` 可以显式采用 `OpenAI / gpt-5.6-sol` 价格。目录和本地有效价格彼此独立;刷新只展示变化,管理员确认后才更新已关联价格,人工保存则解除目录关联。全零目录项不作为免费价格导入,下载失败继续保留上次缓存且不影响请求链路
`config_yaml` 中包含宿主补充的 `enabled``priority`;插件会解析 `codex_only`、数据库路径、`models_dev_url`、目录缓存路径和首次导入 Key。目录缓存默认与 SQLite 数据库放在同一目录。完整下游 Key 只通过受 CPA Management Key 保护的 Key 管理接口返回,不写入普通日志或错误消息;上游 Token、Cookie 和原始凭证不会由插件读取或保存。
## 请求明细与汇总
@@ -132,7 +143,8 @@ CGO_ENABLED=1 go test ./...
## 工程布局
- `cmd/cpa-ext`:仅负责 C ABI、请求字节复制和 C 内存释放。
- `cmd/billing`:仅负责 C ABI、请求字节复制和 C 内存释放。
- `internal/plugin`RPC dispatcher、契约 DTO、原子配置与 Usage 接入。
- `internal/modelcatalog`models.dev 下载、规范化、搜索和最后可用缓存。
- `scripts`:环境检查与可复现构建。
- `.externals/CLIProxyAPI`:上游契约参考,不属于插件实现。
+1
View File
@@ -5,3 +5,4 @@
- [x] 测试模式与真实数据隔离的紧凑管理台
- [x] 美元额度、周期重置、并发限制与不可变账目
- [x] 请求明细的服务端分页与筛选
- [x] models.dev 供应商参考价目录、显式导入与确认更新
+2 -2
View File
@@ -1,6 +1,6 @@
//go:build cshared
// Command cpa-ext is the thin C ABI entry point for CLIProxyAPI.
// Command billing is the thin C ABI entry point for CLIProxyAPI.
package main
/*
@@ -63,7 +63,7 @@ import (
"net/http"
"unsafe"
"cpa-ext/internal/plugin"
"billing/internal/plugin"
)
var app = plugin.NewApp()
+4 -2
View File
@@ -2,10 +2,12 @@ plugins:
enabled: true
dir: plugins
configs:
cpa-ext:
billing:
enabled: true
priority: 100
codex_only: true
database_path: data/cpa-ext.db
database_path: data/billing.db
models_dev_url: https://models.dev/catalog.json
models_dev_cache_path: data/models-dev-catalog.json
bootstrap_name: default
bootstrap_key: "000000"
+17 -6
View File
@@ -2,7 +2,7 @@
## 模块定位
管理台是 cpa-ext 各业务模块的统一操作和查看入口。它不单独保存业务事实,而是通过 CLIProxyAPI 受保护的 Management API 读取或修改 SQLite 中的真实配置与记录。
管理台是 billing 各业务模块的统一操作和查看入口。它不单独保存业务事实,而是通过 CLIProxyAPI 受保护的 Management API 读取或修改 SQLite 中的真实配置与记录。
当前管理台采用单页、紧凑布局,直接作为插件资源嵌入,不依赖 CDN、外部前端框架或独立构建服务。
@@ -12,7 +12,7 @@
| --- | --- |
| 用户 Key | 管理用户、访问策略、路由、额度和并发,查看用户汇总与账目 |
| 请求明细 | 分页查看请求结果、上游、Token、性能和成本,执行组合筛选 |
| 价格配置 | 维护模型基础价格、长上下文价格和 Fast 倍率 |
| 价格配置 | 维护本地价格,并搜索、导入和确认更新 models.dev 参考价格 |
页面顶部统一提供实时/测试模式切换和 CPA 管理密钥输入。
@@ -70,9 +70,15 @@ Key 列表展示:
- 设置长上下文输入门槛及“大于/大于等于”比较方式;
- 可选启用 Fast 价格并设置倍率;
- 保存或删除模型价格。
- 手动更新 models.dev 供应商价格目录;
- 按模型或供应商搜索参考价格,并导入到当前本地模型;
- 查看价格是手动配置还是关联到具体 models.dev 供应商与模型;
- 目录价格变化时先查看输入/输出差异,再确认是否更新本地价格。
价格单位统一显示为 `$ / 1M Token`。保存前由管理接口完成严格字段校验,页面不会自行推断缺失价格。
点击普通“保存价格”始终把该模型切换为手动配置。目录更新或下载失败不会改变本地价格,也不会影响用户请求。
## 实时模式
实时模式连接当前 CLIProxyAPI 实例:
@@ -89,8 +95,9 @@ Key 列表展示:
测试模式用于管理员熟悉页面和验证交互,与真实数据完全隔离:
- 不需要管理密钥;
- 不访问任何真实 cpa-ext 管理接口;
- 不访问任何真实 billing 管理接口;
- 使用浏览器本地的模拟 Key、上游、请求、价格和账目;
- 使用浏览器本地的参考价格样例测试搜索和导入,不访问 models.dev
- 创建、编辑、归档、重置和删除只修改本地模拟状态;
- 提供放大的请求记录量,便于测试分页、筛选和表格布局;
- 可以一键恢复初始测试数据。
@@ -102,7 +109,7 @@ Key 列表展示:
管理接口统一挂载在:
```text
/v0/management/plugins/cpa-ext
/v0/management/plugins/billing
```
| 路径 | 方法 | 用途 |
@@ -116,11 +123,15 @@ Key 列表展示:
| `/usage` | GET | 查询分页请求明细 |
| `/usage-summary` | GET | 查询今日、用户和每日汇总 |
| `/prices` | GET、PUT、DELETE | 查询、保存和删除模型价格 |
| `/prices/import` | POST | 将指定 models.dev 参考价格导入本地模型 |
| `/price-catalog` | GET | 查看目录状态并搜索供应商级参考价格 |
| `/price-catalog/refresh` | POST | 下载新目录并返回已关联价格差异,不修改本地价格 |
| `/price-catalog/apply` | POST | 按目录 revision 确认应用选中的价格变化 |
管理台 HTML 由以下插件资源提供:
```text
/v0/resource/plugins/cpa-ext/ui
/v0/resource/plugins/billing/ui
```
管理接口返回结构化 JSON 和明确的 HTTP 状态码;未知路径返回 `not_found`,数据库不可用、请求字段错误等情况返回对应错误码和消息。
@@ -140,7 +151,7 @@ Key 列表展示:
本模块负责:
- 提供统一管理页面;
- 调用并呈现 cpa-ext Management API
- 调用并呈现 billing Management API
- 管理实时和测试数据模式;
- 提供紧凑、可筛选、可分页的操作界面。
+21
View File
@@ -16,6 +16,7 @@
| 手动重置 | 管理员可立即开始新额度周期 |
| 并发限制 | 每个 Key 独立限制同时执行的请求数 |
| 价格规则 | 支持基础价格、长上下文价格和 Fast 倍率 |
| 参考价格 | 搜索并显式导入 models.dev 供应商级价格,刷新前展示差异 |
| 计费账目 | 永久记录扣费、额度调整和周期重置 |
| 请求拦截 | 余额、并发或价格不满足时在上游调用前拒绝 |
@@ -118,6 +119,25 @@
系统先选择基础或长上下文档位,再应用 Fast 倍率,最后对整次请求执行一次四舍五入。
### models.dev 参考目录
`models.dev` 只提供候选参考价格,不直接参与在线计费。插件仅在管理员点击“更新目录”时下载 `catalog.json`,把能够由当前计费模型准确表达的供应商级 Token 价格规范化后保存到本地缓存。用户请求、认证、额度检查和最终扣费都不会访问外网。
管理员可以把任意目录价格导入任意本地模型,本地模型名与参考模型名不必一致。导入后,本地价格保存其 `models.dev` 供应商、模型、目录 revision 和获取时间;实际计费仍读取 SQLite 中已经确认的本地价格。
目录映射规则:
- 输入和输出价格必须存在;
- 缓存价格缺失时使用输入价格,避免把缓存错误计为免费;
- 只支持一个可完整表示的长上下文档位;
- 独立音频或推理价格无法由当前四类 Token 表达时不提供导入;
- 输入、输出和缓存全部为零的条目不自动认定为免费;
- Fast 倍率是本地业务规则,导入和刷新不会改变它。
刷新目录会先返回所有已关联价格的变化和已下架来源。页面只有在管理员确认后才更新发生变化的本地价格;取消确认不会改动账单。人工保存某个模型价格会把来源切换为“手动配置”,以后的目录刷新不再跟随它。
下载或解析失败时继续使用最后一次成功缓存,本地有效价格保持不变。目录下载有超时和体积限制,解析、写缓存和发布新索引成功后才替换旧版本。
## 价格缺失
允许访问的模型必须存在价格配置。系统在 CPA 已经选择上游后、真正访问模型服务前再次检查实际模型价格。
@@ -154,6 +174,7 @@
本模块负责:
- 模型价格和确定性成本计算;
- 参考价格目录缓存、显式导入和确认更新;
- 用户额度、余额和额度周期;
- 并发请求准入;
- 请求结束后的实际扣费;
+1 -1
View File
@@ -1,4 +1,4 @@
module cpa-ext
module billing
go 1.24
+528
View File
@@ -0,0 +1,528 @@
// Package modelcatalog downloads and indexes models.dev reference prices.
// The catalog is deliberately separate from the effective local price table:
// callers must explicitly import a row before it can affect billing.
package modelcatalog
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"billing/internal/pricing"
)
const (
DefaultSourceURL = "https://models.dev/catalog.json"
maxDownloadBytes = 32 << 20
requestTimeout = 30 * time.Second
)
// Info describes the currently cached, normalized catalog snapshot.
type Info struct {
SourceURL string `json:"source_url"`
FetchedAt time.Time `json:"fetched_at"`
Revision string `json:"revision"`
Models int `json:"models"`
}
// Entry is one provider-specific reference price that can be represented by
// billing's deterministic token price model.
type Entry struct {
ID string `json:"id"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
Model string `json:"model"`
ModelName string `json:"model_name"`
Base pricing.Rates `json:"base"`
LongContext *pricing.LongContext `json:"long_context,omitempty"`
}
// Policy creates a local price policy from the reference row. Fast pricing is
// local business policy and is therefore supplied by the caller, not models.dev.
func (e Entry) Policy(localModel string, fastEnabled bool, multiplier pricing.Ratio) pricing.Policy {
longContext := cloneLongContext(e.LongContext)
return pricing.Policy{
Model: strings.TrimSpace(localModel), Base: e.Base, LongContext: longContext,
FastPricingEnabled: fastEnabled, FastMultiplier: multiplier,
}
}
type cacheDocument struct {
Info Info `json:"info"`
Entries []Entry `json:"entries"`
}
type index struct {
info Info
entries []Entry
byID map[string]Entry
}
// Manager owns one immutable in-memory catalog index and a compact cache file.
// Refresh downloads and parses outside the read lock, so catalog searches and
// CPA request handling never wait on the network.
type Manager struct {
mu sync.RWMutex
refreshMu sync.Mutex
cachePath string
sourceURL string
client *http.Client
loaded *index
lastError string
}
func NewManager(cachePath, sourceURL string) *Manager {
sourceURL = strings.TrimSpace(sourceURL)
if sourceURL == "" {
sourceURL = DefaultSourceURL
}
transport := &http.Transport{Proxy: http.ProxyFromEnvironment, DisableKeepAlives: true}
manager := &Manager{
cachePath: strings.TrimSpace(cachePath), sourceURL: sourceURL,
client: &http.Client{Timeout: requestTimeout, Transport: transport},
}
if err := manager.loadCache(); err != nil && !errors.Is(err, os.ErrNotExist) {
manager.lastError = err.Error()
}
return manager
}
// Status reports cache availability without attempting network access.
func (m *Manager) Status() (Info, string, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.loaded == nil {
return Info{SourceURL: m.sourceURL}, m.lastError, false
}
return m.loaded.info, m.lastError, true
}
// Refresh downloads and atomically publishes a new last-known-good snapshot.
// A failed refresh leaves both the in-memory index and cache file untouched.
func (m *Manager) Refresh(ctx context.Context) (Info, error) {
m.refreshMu.Lock()
defer m.refreshMu.Unlock()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.sourceURL, nil)
if err != nil {
return Info{}, m.rememberError(fmt.Errorf("创建 models.dev 请求: %w", err))
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "billing/models.dev")
response, err := m.client.Do(request)
if err != nil {
return Info{}, m.rememberError(fmt.Errorf("下载 models.dev 价格目录: %w", err))
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return Info{}, m.rememberError(fmt.Errorf("下载 models.dev 价格目录: HTTP %s", response.Status))
}
if response.ContentLength > maxDownloadBytes {
return Info{}, m.rememberError(fmt.Errorf("models.dev 价格目录超过 %d MiB 限制", maxDownloadBytes>>20))
}
raw, err := io.ReadAll(io.LimitReader(response.Body, maxDownloadBytes+1))
if err != nil {
return Info{}, m.rememberError(fmt.Errorf("读取 models.dev 价格目录: %w", err))
}
if len(raw) > maxDownloadBytes {
return Info{}, m.rememberError(fmt.Errorf("models.dev 价格目录超过 %d MiB 限制", maxDownloadBytes>>20))
}
loaded, err := parseSource(raw, m.sourceURL, time.Now().UTC())
if err != nil {
return Info{}, m.rememberError(fmt.Errorf("解析 models.dev 价格目录: %w", err))
}
if err := m.writeCache(loaded); err != nil {
return Info{}, m.rememberError(err)
}
m.mu.Lock()
m.loaded = loaded
m.lastError = ""
m.mu.Unlock()
return loaded.info, nil
}
func (m *Manager) rememberError(err error) error {
m.mu.Lock()
m.lastError = err.Error()
m.mu.Unlock()
return err
}
// Search ranks exact model/ID matches before prefixes and substrings.
func (m *Manager) Search(query string, limit int) ([]Entry, Info, bool) {
query = strings.ToLower(strings.TrimSpace(query))
if limit <= 0 || limit > 50 {
limit = 20
}
m.mu.RLock()
defer m.mu.RUnlock()
if m.loaded == nil {
return nil, Info{SourceURL: m.sourceURL}, false
}
if query == "" {
return []Entry{}, m.loaded.info, true
}
groups := [4][]Entry{}
for _, entry := range m.loaded.entries {
fields := []string{strings.ToLower(entry.ID), strings.ToLower(entry.Model), strings.ToLower(entry.ModelName), strings.ToLower(entry.Provider), strings.ToLower(entry.ProviderName)}
group := -1
switch {
case fields[0] == query:
group = 0
case fields[1] == query || fields[2] == query || fields[3] == query || fields[4] == query:
group = 1
default:
for _, field := range fields {
if strings.HasPrefix(field, query) {
group = 2
break
}
if group < 0 && strings.Contains(field, query) {
group = 3
}
}
}
if group >= 0 {
groups[group] = append(groups[group], cloneEntry(entry))
}
}
result := make([]Entry, 0, limit)
for _, group := range groups {
for _, entry := range group {
result = append(result, entry)
if len(result) == limit {
return result, m.loaded.info, true
}
}
}
return result, m.loaded.info, true
}
func (m *Manager) Lookup(id string) (Entry, Info, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.loaded == nil {
return Entry{}, Info{SourceURL: m.sourceURL}, false
}
entry, ok := m.loaded.byID[strings.ToLower(strings.TrimSpace(id))]
return cloneEntry(entry), m.loaded.info, ok
}
func (m *Manager) loadCache() error {
if m.cachePath == "" {
return os.ErrNotExist
}
raw, err := os.ReadFile(m.cachePath)
if err != nil {
return err
}
var document cacheDocument
if err := json.Unmarshal(raw, &document); err != nil {
return fmt.Errorf("读取 models.dev 本地缓存: %w", err)
}
if strings.TrimSpace(document.Info.SourceURL) != m.sourceURL {
return fmt.Errorf("读取 models.dev 本地缓存: 缓存来源与当前 models_dev_url 不一致")
}
loaded, err := indexDocument(document)
if err != nil {
return fmt.Errorf("读取 models.dev 本地缓存: %w", err)
}
m.loaded = loaded
return nil
}
func (m *Manager) writeCache(loaded *index) error {
if m.cachePath == "" {
return nil
}
directory := filepath.Dir(m.cachePath)
if err := os.MkdirAll(directory, 0o755); err != nil {
return fmt.Errorf("创建 models.dev 缓存目录: %w", err)
}
raw, err := json.Marshal(cacheDocument{Info: loaded.info, Entries: loaded.entries})
if err != nil {
return fmt.Errorf("编码 models.dev 本地缓存: %w", err)
}
temporary, err := os.CreateTemp(directory, ".billing-models-dev-*.tmp")
if err != nil {
return fmt.Errorf("创建 models.dev 临时缓存: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if chmodErr := temporary.Chmod(0o600); chmodErr != nil {
_ = temporary.Close()
return fmt.Errorf("设置 models.dev 本地缓存权限: %w", chmodErr)
}
_, err = temporary.Write(raw)
if err == nil {
err = temporary.Sync()
}
closeErr := temporary.Close()
if err != nil {
return fmt.Errorf("写入 models.dev 本地缓存: %w", err)
}
if closeErr != nil {
return fmt.Errorf("关闭 models.dev 本地缓存: %w", closeErr)
}
if err := os.Rename(temporaryPath, m.cachePath); err != nil {
return fmt.Errorf("替换 models.dev 本地缓存: %w", err)
}
return nil
}
type sourceCatalog struct {
Providers map[string]sourceProvider `json:"providers"`
}
type sourceProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Models map[string]json.RawMessage `json:"models"`
}
type sourceModel struct {
ID string `json:"id"`
Name string `json:"name"`
Cost *sourceCost `json:"cost"`
}
type sourceCost struct {
Input *json.Number `json:"input"`
Output *json.Number `json:"output"`
Reasoning *json.Number `json:"reasoning"`
CacheRead *json.Number `json:"cache_read"`
CacheWrite *json.Number `json:"cache_write"`
InputAudio *json.Number `json:"input_audio"`
OutputAudio *json.Number `json:"output_audio"`
Tiers []sourceCostTier `json:"tiers"`
}
type sourceCostTier struct {
Input *json.Number `json:"input"`
Output *json.Number `json:"output"`
Reasoning *json.Number `json:"reasoning"`
CacheRead *json.Number `json:"cache_read"`
CacheWrite *json.Number `json:"cache_write"`
InputAudio *json.Number `json:"input_audio"`
OutputAudio *json.Number `json:"output_audio"`
Tier struct {
Type string `json:"type"`
Size int64 `json:"size"`
} `json:"tier"`
}
func parseSource(raw []byte, sourceURL string, fetchedAt time.Time) (*index, error) {
var source sourceCatalog
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(&source); err != nil {
return nil, err
}
entryByID := make(map[string]Entry)
ambiguousIDs := make(map[string]struct{})
for providerKey, provider := range source.Providers {
providerID := normalizedID(provider.ID, providerKey)
if providerID == "" {
continue
}
providerName := strings.TrimSpace(provider.Name)
if providerName == "" {
providerName = providerID
}
for modelKey, rawModel := range provider.Models {
var model sourceModel
modelDecoder := json.NewDecoder(bytes.NewReader(rawModel))
modelDecoder.UseNumber()
if modelDecoder.Decode(&model) != nil || model.Cost == nil {
continue
}
modelID := strings.TrimSpace(model.ID)
if modelID == "" {
modelID = strings.TrimSpace(modelKey)
}
entry, ok := entryFromCost(providerID, providerName, modelID, model.Name, model.Cost)
if ok {
if _, ambiguous := ambiguousIDs[entry.ID]; ambiguous {
continue
}
if existing, duplicate := entryByID[entry.ID]; duplicate {
if sameEntryPrice(existing, entry) {
continue
}
delete(entryByID, entry.ID)
ambiguousIDs[entry.ID] = struct{}{}
continue
}
entryByID[entry.ID] = entry
}
}
}
entries := make([]Entry, 0, len(entryByID))
for _, entry := range entryByID {
entries = append(entries, entry)
}
if len(entries) == 0 {
return nil, errors.New("目录中没有可安全表示的非零 Token 价格")
}
sort.Slice(entries, func(i, j int) bool { return entries[i].ID < entries[j].ID })
digest := sha256.Sum256(raw)
document := cacheDocument{
Info: Info{SourceURL: sourceURL, FetchedAt: fetchedAt, Revision: hex.EncodeToString(digest[:]), Models: len(entries)},
Entries: entries,
}
return indexDocument(document)
}
func indexDocument(document cacheDocument) (*index, error) {
if strings.TrimSpace(document.Info.Revision) == "" || document.Info.FetchedAt.IsZero() || len(document.Entries) == 0 {
return nil, errors.New("缓存元数据不完整")
}
loaded := &index{info: document.Info, entries: make([]Entry, 0, len(document.Entries)), byID: make(map[string]Entry, len(document.Entries))}
for _, entry := range document.Entries {
entry.ID = strings.ToLower(strings.TrimSpace(entry.ID))
if entry.ID == "" {
return nil, errors.New("缓存包含空目录 ID")
}
if _, duplicate := loaded.byID[entry.ID]; duplicate {
return nil, fmt.Errorf("缓存包含重复目录 ID %q", entry.ID)
}
policy := entry.Policy("catalog-validation", false, pricing.Ratio{Numerator: 1, Denominator: 1})
if err := policy.Validate(); err != nil {
return nil, fmt.Errorf("缓存价格 %q 无效: %w", entry.ID, err)
}
entry = cloneEntry(entry)
loaded.entries = append(loaded.entries, entry)
loaded.byID[entry.ID] = entry
}
loaded.info.Models = len(loaded.entries)
return loaded, nil
}
func entryFromCost(providerID, providerName, modelID, modelName string, cost *sourceCost) (Entry, bool) {
if strings.TrimSpace(modelID) == "" || cost.Input == nil || cost.Output == nil {
return Entry{}, false
}
input, okInput := numberMicros(cost.Input)
output, okOutput := numberMicros(cost.Output)
if !okInput || !okOutput || !optionalMatches(cost.Reasoning, output) || !optionalMatches(cost.InputAudio, input) || !optionalMatches(cost.OutputAudio, output) {
return Entry{}, false
}
cacheRead, ok := optionalMicros(cost.CacheRead, input)
if !ok {
return Entry{}, false
}
cacheWrite, ok := optionalMicros(cost.CacheWrite, input)
if !ok || (input == 0 && output == 0 && cacheRead == 0 && cacheWrite == 0) {
return Entry{}, false
}
entry := Entry{
ID: strings.ToLower(providerID + "/" + strings.TrimSpace(modelID)), Provider: providerID,
ProviderName: providerName, Model: strings.TrimSpace(modelID), ModelName: strings.TrimSpace(modelName),
Base: pricing.Rates{InputMicrosPer1M: input, CacheReadMicrosPer1M: cacheRead, CacheWriteMicrosPer1M: cacheWrite, OutputMicrosPer1M: output},
}
if entry.ModelName == "" {
entry.ModelName = entry.Model
}
if len(cost.Tiers) > 1 {
return Entry{}, false
}
if len(cost.Tiers) == 1 {
tier := cost.Tiers[0]
if tier.Tier.Type != "context" || tier.Tier.Size <= 0 || tier.Input == nil || tier.Output == nil {
return Entry{}, false
}
tierInput, okInput := numberMicros(tier.Input)
tierOutput, okOutput := numberMicros(tier.Output)
if !okInput || !okOutput || !optionalMatches(tier.Reasoning, tierOutput) || !optionalMatches(tier.InputAudio, tierInput) || !optionalMatches(tier.OutputAudio, tierOutput) {
return Entry{}, false
}
tierCacheRead, okRead := optionalMicros(tier.CacheRead, tierInput)
tierCacheWrite, okWrite := optionalMicros(tier.CacheWrite, tierInput)
if !okRead || !okWrite {
return Entry{}, false
}
entry.LongContext = &pricing.LongContext{
ThresholdInputTokens: tier.Tier.Size, Comparison: "gt",
Rates: pricing.Rates{InputMicrosPer1M: tierInput, CacheReadMicrosPer1M: tierCacheRead, CacheWriteMicrosPer1M: tierCacheWrite, OutputMicrosPer1M: tierOutput},
}
}
return entry, true
}
func normalizedID(id, fallback string) string {
id = strings.ToLower(strings.TrimSpace(id))
if id == "" {
id = strings.ToLower(strings.TrimSpace(fallback))
}
return id
}
func sameEntryPrice(left, right Entry) bool {
if left.Base != right.Base {
return false
}
if left.LongContext == nil || right.LongContext == nil {
return left.LongContext == nil && right.LongContext == nil
}
return *left.LongContext == *right.LongContext
}
func optionalMicros(number *json.Number, fallback int64) (int64, bool) {
if number == nil {
return fallback, true
}
return numberMicros(number)
}
func optionalMatches(number *json.Number, expected int64) bool {
if number == nil {
return true
}
value, ok := numberMicros(number)
return ok && value == expected
}
func numberMicros(number *json.Number) (int64, bool) {
if number == nil {
return 0, false
}
rational, ok := new(big.Rat).SetString(number.String())
if !ok || rational.Sign() < 0 {
return 0, false
}
rational.Mul(rational, big.NewRat(1_000_000, 1))
numerator := rational.Num()
denominator := rational.Denom()
quotient, remainder := new(big.Int), new(big.Int)
quotient.QuoRem(numerator, denominator, remainder)
if new(big.Int).Lsh(remainder, 1).Cmp(denominator) >= 0 {
quotient.Add(quotient, big.NewInt(1))
}
return quotient.Int64(), quotient.IsInt64()
}
func cloneLongContext(value *pricing.LongContext) *pricing.LongContext {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func cloneEntry(value Entry) Entry {
value.LongContext = cloneLongContext(value.LongContext)
return value
}
+140
View File
@@ -0,0 +1,140 @@
package modelcatalog
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
const testCatalog = `{
"providers": {
"openai": {"id":"openai","name":"OpenAI","models":{
"gpt-5.6-sol":{"id":"gpt-5.6-sol","name":"GPT-5.6 Sol","cost":{"input":5,"output":30,"cache_read":0.5,"cache_write":6.25,"tiers":[{"input":10,"output":45,"cache_read":1,"cache_write":12.5,"tier":{"type":"context","size":272000}}]}},
"gpt-sol-alias":{"id":"gpt-5.6-sol","name":"GPT Sol Alias","cost":{"input":5,"output":30,"cache_read":0.5,"cache_write":6.25,"tiers":[{"input":10,"output":45,"cache_read":1,"cache_write":12.5,"tier":{"type":"context","size":272000}}]}},
"free-placeholder":{"id":"free-placeholder","cost":{"input":0,"output":0}},
"audio-special":{"id":"audio-special","cost":{"input":1,"output":2,"input_audio":3}}
}},
"google": {"id":"google","name":"Google","models":{
"gemini-flash":{"id":"gemini-flash","name":"Gemini Flash","cost":{"input":0.1,"output":0.4}}
}}
}
}`
func TestParseCatalogKeepsRepresentableNonZeroProviderPrices(t *testing.T) {
loaded, err := parseSource([]byte(testCatalog), DefaultSourceURL, time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if loaded.info.Models != 2 {
t.Fatalf("models = %d, want 2", loaded.info.Models)
}
entry, ok := loaded.byID["openai/gpt-5.6-sol"]
if !ok || entry.Base.InputMicrosPer1M != 5_000_000 || entry.Base.CacheReadMicrosPer1M != 500_000 || entry.Base.CacheWriteMicrosPer1M != 6_250_000 || entry.Base.OutputMicrosPer1M != 30_000_000 {
t.Fatalf("openai entry = %+v", entry)
}
if entry.LongContext == nil || entry.LongContext.ThresholdInputTokens != 272_000 || entry.LongContext.Rates.OutputMicrosPer1M != 45_000_000 {
t.Fatalf("long context = %+v", entry.LongContext)
}
gemini := loaded.byID["google/gemini-flash"]
if gemini.Base.CacheReadMicrosPer1M != gemini.Base.InputMicrosPer1M || gemini.Base.CacheWriteMicrosPer1M != gemini.Base.InputMicrosPer1M {
t.Fatalf("missing cache prices did not fall back to input: %+v", gemini.Base)
}
if _, exists := loaded.byID["openai/free-placeholder"]; exists {
t.Fatal("zero/zero placeholder was treated as a usable free price")
}
if _, exists := loaded.byID["openai/audio-special"]; exists {
t.Fatal("unrepresentable audio price was imported")
}
}
func TestParseCatalogDropsOnlyConflictingDuplicateID(t *testing.T) {
raw := []byte(`{"providers":{"provider":{"id":"provider","models":{"alias-a":{"id":"same","cost":{"input":1,"output":2}},"alias-b":{"id":"same","cost":{"input":3,"output":4}},"safe":{"id":"safe","cost":{"input":5,"output":6}}}}}}`)
loaded, err := parseSource(raw, DefaultSourceURL, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if loaded.info.Models != 1 {
t.Fatalf("models = %d, want only safe row", loaded.info.Models)
}
if _, exists := loaded.byID["provider/same"]; exists {
t.Fatal("conflicting duplicate ID was retained")
}
if _, exists := loaded.byID["provider/safe"]; !exists {
t.Fatal("unrelated safe row was dropped")
}
}
func TestManagerRefreshSearchCacheAndLastKnownGood(t *testing.T) {
body := testCatalog
status := http.StatusOK
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(status)
_, _ = response.Write([]byte(body))
}))
defer server.Close()
cachePath := filepath.Join(t.TempDir(), "models-dev.json")
manager := NewManager(cachePath, server.URL)
if _, _, loaded := manager.Status(); loaded {
t.Fatal("new manager unexpectedly had a catalog")
}
first, err := manager.Refresh(context.Background())
if err != nil || first.Models != 2 || first.Revision == "" {
t.Fatalf("refresh info=%+v err=%v", first, err)
}
results, info, loaded := manager.Search("gpt-5.6", 20)
if !loaded || info.Revision != first.Revision || len(results) != 1 || results[0].ID != "openai/gpt-5.6-sol" {
t.Fatalf("search results=%+v info=%+v loaded=%v", results, info, loaded)
}
results, _, _ = manager.Search("openai/gpt-5.6-sol", 20)
if len(results) == 0 || results[0].ID != "openai/gpt-5.6-sol" {
t.Fatalf("exact catalog ID was not ranked first: %+v", results)
}
reopened := NewManager(cachePath, server.URL)
results, cached, loaded := reopened.Search("OpenAI", 20)
if !loaded || cached.Revision != first.Revision || len(results) != 1 {
t.Fatalf("cached search results=%+v info=%+v loaded=%v", results, cached, loaded)
}
wrongSource := NewManager(cachePath, "https://unreachable.invalid/catalog.json")
if _, lastError, loaded := wrongSource.Status(); loaded || !strings.Contains(lastError, "来源") {
t.Fatalf("wrong-source cache loaded=%v error=%q", loaded, lastError)
}
status = http.StatusBadGateway
if _, err := manager.Refresh(context.Background()); err == nil || !strings.Contains(err.Error(), "502") {
t.Fatalf("failed refresh error = %v", err)
}
entry, afterFailure, ok := manager.Lookup("openai/gpt-5.6-sol")
if !ok || entry.Model != "gpt-5.6-sol" || afterFailure.Revision != first.Revision {
t.Fatalf("last-known-good entry=%+v info=%+v ok=%v", entry, afterFailure, ok)
}
body = strings.ReplaceAll(testCatalog, `"input":5`, `"input":6`)
status = http.StatusOK
second, err := manager.Refresh(context.Background())
if err != nil || second.Revision == first.Revision {
t.Fatalf("second refresh info=%+v err=%v", second, err)
}
updated, _, _ := manager.Lookup("openai/gpt-5.6-sol")
if updated.Base.InputMicrosPer1M != 6_000_000 {
t.Fatalf("updated input = %d", updated.Base.InputMicrosPer1M)
}
}
func TestManagerRejectsOversizedCatalogWithoutReadingPastLimit(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Content-Length", fmt.Sprint(maxDownloadBytes+1))
_, _ = response.Write([]byte("x"))
}))
defer server.Close()
manager := NewManager(filepath.Join(t.TempDir(), "catalog.json"), server.URL)
if _, err := manager.Refresh(context.Background()); err == nil || !strings.Contains(err.Error(), "超过") {
t.Fatalf("oversized refresh error = %v", err)
}
}
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"strings"
"time"
managedaccess "cpa-ext/internal/access"
"cpa-ext/internal/pricing"
"cpa-ext/internal/repository"
managedaccess "billing/internal/access"
"billing/internal/pricing"
"billing/internal/repository"
)
const (
+30 -1
View File
@@ -8,7 +8,7 @@ import (
"testing"
"time"
managedaccess "cpa-ext/internal/access"
managedaccess "billing/internal/access"
)
func TestManagedKeyAuthenticationAndLifecycle(t *testing.T) {
@@ -273,3 +273,32 @@ func TestBillingAdmissionConcurrencyAndUsageSettlement(t *testing.T) {
t.Fatalf("settled state=%+v err=%v", state, err)
}
}
func TestUsageSettlementResolvesCurrentCPAKeyPrincipal(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
t.Fatal(err)
}
price := []byte(`{"model":"deepseek-v4-flash","base":{"input_per_1m":"1","cache_read_per_1m":"1","cache_write_per_1m":"1","output_per_1m":"2"},"fast_pricing_enabled":false,"fast_multiplier":"2.5"}`)
if response := managementCallBody(t, app, http.MethodPut, managementBase+routePrices, price); response.StatusCode != http.StatusOK {
t.Fatalf("put price=%d %s", response.StatusCode, response.Body)
}
if _, err := app.store.UpdateBilling(context.Background(), "key_default", managedaccess.BillingSettings{QuotaMicros: 1_000_000, ResetPeriod: managedaccess.ResetNone, MaxConcurrency: 4}, time.Now()); err != nil {
t.Fatal(err)
}
for _, reference := range []string{"key_default", managedaccess.CallerScope("key_default"), "000000"} {
key, err := app.resolveUsageManagedKey(app.store, reference)
if err != nil || key.ID != "key_default" {
t.Fatalf("resolve usage reference %q: key=%+v err=%v", reference, key, err)
}
}
usage := UsageRecord{APIKey: "key_default", Model: "deepseek-v4-flash", RequestedAt: time.Now(), Detail: UsageDetail{InputTokens: 10_000, OutputTokens: 10_000, TotalTokens: 20_000}}
raw, _ := json.Marshal(usage)
if _, err := app.HandleMethod(MethodUsageHandle, raw); err != nil {
t.Fatal(err)
}
state, err := app.store.BillingState(context.Background(), "key_default", time.Now())
if err != nil || state.SpentMicros != 30_000 || state.BalanceMicros != 970_000 {
t.Fatalf("principal-settled state=%+v err=%v", state, err)
}
}
+27 -5
View File
@@ -10,9 +10,11 @@ import (
"sync/atomic"
"time"
"cpa-ext/internal/collection"
"cpa-ext/internal/pricing"
"cpa-ext/internal/repository"
managedaccess "billing/internal/access"
"billing/internal/collection"
"billing/internal/modelcatalog"
"billing/internal/pricing"
"billing/internal/repository"
)
type App struct {
@@ -22,6 +24,7 @@ type App struct {
usage *collection.Service
store *repository.SQLiteUsageRepository
prices map[string]pricing.Policy
catalog *modelcatalog.Manager
host HostCaller
closed bool
seen atomic.Uint64
@@ -104,6 +107,7 @@ func (a *App) configure(raw []byte) ([]byte, error) {
for _, policy := range policies {
nextPrices[normalizeModelName(policy.Model)] = policy
}
nextCatalog := modelcatalog.NewManager(cfg.CatalogPath, cfg.ModelsDevURL)
a.mu.RLock()
releaseStaleAdmissions := a.usage == nil || a.config.DatabasePath != cfg.DatabasePath
a.mu.RUnlock()
@@ -125,6 +129,7 @@ func (a *App) configure(raw []byte) ([]byte, error) {
a.usage = nextUsage
a.store = usageRepository
a.prices = nextPrices
a.catalog = nextCatalog
if previousUsage != nil {
_ = previousUsage.Close()
}
@@ -137,7 +142,7 @@ func registration(schemaVersion uint32) Registration {
Metadata: Metadata{
Name: PluginName,
Version: Version,
Author: "cpa-ext",
Author: "billing",
GitHubRepository: "https://git.pchuan.top/agent/cpa-plugin",
ConfigFields: []ConfigField{
{Name: "enabled", Type: "boolean", Description: "启用 CPA 扩展。"},
@@ -145,6 +150,8 @@ func registration(schemaVersion uint32) Registration {
{Name: "database_path", Type: "string", Description: "SQLite 数据库文件路径。"},
{Name: "bootstrap_name", Type: "string", Description: "首次启动时现有 Key 的名称。"},
{Name: "bootstrap_key", Type: "string", Description: "首次启动时导入的现有下游 Key。"},
{Name: "models_dev_url", Type: "string", Description: "models.dev 合并价格目录地址。"},
{Name: "models_dev_cache_path", Type: "string", Description: "规范化价格目录缓存文件;默认与数据库同目录。"},
},
},
Capabilities: Capabilities{
@@ -191,7 +198,7 @@ func (a *App) handleUsage(raw []byte) ([]byte, error) {
TTFT: record.TTFT,
Latency: record.Latency,
}
if key, keyErr := a.store.ManagedKeyByCredential(context.Background(), record.APIKey); keyErr == nil {
if key, keyErr := a.resolveUsageManagedKey(a.store, record.APIKey); keyErr == nil {
observed.ManagedKeyID = key.ID
observed.KeyAlias = key.Name
}
@@ -218,6 +225,20 @@ func (a *App) handleUsage(raw []byte) ([]byte, error) {
return OKEnvelope(struct{}{})
}
func (a *App) resolveUsageManagedKey(store *repository.SQLiteUsageRepository, reference string) (managedaccess.ManagedKey, error) {
if keyID, ok := a.scopes.Load(strings.TrimSpace(reference)); ok {
if id, valid := keyID.(string); valid {
if key, err := store.ManagedKeyByID(context.Background(), id); err == nil {
return key, nil
}
}
}
if key, err := store.ManagedKeyByReference(context.Background(), reference); err == nil {
return key, nil
}
return store.ManagedKeyByCredential(context.Background(), reference)
}
func (a *App) handleRequestComplete(raw []byte) ([]byte, error) {
var completion RequestCompletion
if err := json.Unmarshal(raw, &completion); err != nil {
@@ -285,5 +306,6 @@ func (a *App) Shutdown() {
_ = a.usage.Close()
a.usage = nil
a.store = nil
a.catalog = nil
}
}
+16 -2
View File
@@ -10,8 +10,8 @@ import (
"testing"
"time"
managedaccess "cpa-ext/internal/access"
"cpa-ext/internal/repository"
managedaccess "billing/internal/access"
"billing/internal/repository"
)
func lifecycleRequest(t *testing.T, schema uint32, config string) []byte {
@@ -71,6 +71,20 @@ func TestConfigRejectsShortBootstrapKey(t *testing.T) {
}
}
func TestConfigDerivesCatalogCacheBesideDatabaseAndValidatesURL(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "nested", "usage.db")
cfg, err := decodeConfig([]byte(fmt.Sprintf("database_path: %q\n", databasePath)))
if err != nil {
t.Fatal(err)
}
if cfg.CatalogPath != filepath.Join(filepath.Dir(databasePath), "models-dev-catalog.json") {
t.Fatalf("catalog path = %q", cfg.CatalogPath)
}
if _, err := decodeConfig([]byte("models_dev_url: file:///tmp/catalog.json\n")); err == nil {
t.Fatal("non-HTTP models_dev_url unexpectedly accepted")
}
}
func TestUsageRecordWireFieldsMatchTargetContract(t *testing.T) {
raw, err := json.Marshal(UsageRecord{})
if err != nil {
+299
View File
@@ -0,0 +1,299 @@
package plugin
import (
"context"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"billing/internal/modelcatalog"
"billing/internal/pricing"
"billing/internal/repository"
)
type catalogEntryDTO struct {
ID string `json:"id"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
Model string `json:"model"`
ModelName string `json:"model_name"`
Base priceRatesDTO `json:"base"`
LongContext *longContextDTO `json:"long_context,omitempty"`
}
type catalogChangeDTO struct {
Model string `json:"model"`
CatalogID string `json:"catalog_id"`
Status string `json:"status"`
Before priceDTO `json:"before"`
After *priceDTO `json:"after,omitempty"`
FetchedAt *time.Time `json:"fetched_at,omitempty"`
}
type catalogImportRequest struct {
Model string `json:"model"`
CatalogID string `json:"catalog_id"`
}
type catalogApplyRequest struct {
Revision string `json:"revision"`
Models []string `json:"models"`
}
func (a *App) searchPriceCatalog(query url.Values) ManagementResponse {
a.mu.RLock()
catalog := a.catalog
a.mu.RUnlock()
if catalog == nil {
return catalogManagementError(http.StatusServiceUnavailable, "catalog_unavailable", "价格目录尚未初始化")
}
limit := 20
if raw := strings.TrimSpace(query.Get("limit")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 50 {
return catalogManagementError(http.StatusBadRequest, "invalid_limit", "limit 必须是 1-50 的整数")
}
limit = parsed
}
entries, info, loaded := catalog.Search(query.Get("q"), limit)
_, lastError, _ := catalog.Status()
results := make([]catalogEntryDTO, 0, len(entries))
for _, entry := range entries {
results = append(results, catalogEntryToDTO(entry))
}
return jsonManagementResponse(http.StatusOK, map[string]any{
"loaded": loaded, "catalog": info, "models": results, "last_error": lastError,
})
}
func (a *App) refreshPriceCatalog() ManagementResponse {
a.mu.RLock()
catalog := a.catalog
a.mu.RUnlock()
if catalog == nil {
return catalogManagementError(http.StatusServiceUnavailable, "catalog_unavailable", "价格目录尚未初始化")
}
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
defer cancel()
info, err := catalog.Refresh(ctx)
if err != nil {
return catalogManagementError(http.StatusBadGateway, "catalog_download_failed", err.Error())
}
a.mu.RLock()
defer a.mu.RUnlock()
if a.catalog != catalog || a.store == nil {
return catalogManagementError(http.StatusServiceUnavailable, "catalog_reconfigured", "价格目录配置已经变化,请重新刷新")
}
changes, err := previewCatalogChanges(context.Background(), a.store, catalog)
if err != nil {
return catalogManagementError(http.StatusInternalServerError, "database_error", err.Error())
}
changed, missing := catalogChangeCounts(changes)
return jsonManagementResponse(http.StatusOK, map[string]any{
"catalog": info, "changes": changes, "changed": changed, "missing": missing,
})
}
func (a *App) importCatalogPrice(body []byte) ManagementResponse {
var request catalogImportRequest
if err := decodeJSONBody(body, &request); err != nil {
return catalogManagementError(http.StatusBadRequest, "invalid_catalog_import", err.Error())
}
request.Model = strings.TrimSpace(request.Model)
request.CatalogID = strings.TrimSpace(request.CatalogID)
if request.Model == "" || request.CatalogID == "" {
return catalogManagementError(http.StatusBadRequest, "invalid_catalog_import", "model 和 catalog_id 不能为空")
}
a.priceMu.Lock()
defer a.priceMu.Unlock()
a.mu.RLock()
catalog := a.catalog
current, currentExists := a.prices[normalizeModelName(request.Model)]
a.mu.RUnlock()
if catalog == nil {
return catalogManagementError(http.StatusServiceUnavailable, "catalog_unavailable", "价格目录尚未初始化")
}
entry, info, found := catalog.Lookup(request.CatalogID)
if !found {
return catalogManagementError(http.StatusNotFound, "catalog_price_not_found", "参考价格不存在或当前目录尚未下载")
}
fastEnabled := false
multiplier := pricing.Ratio{Numerator: 5, Denominator: 2}
if currentExists {
fastEnabled = current.FastPricingEnabled
multiplier = current.FastMultiplier
}
policy := entry.Policy(request.Model, fastEnabled, multiplier)
if err := policy.Validate(); err != nil {
return catalogManagementError(http.StatusBadRequest, "invalid_catalog_price", err.Error())
}
record := repository.PriceRecord{
Policy: policy,
Source: repository.PriceSource{Kind: repository.PriceSourceModelsDev, CatalogID: entry.ID, Revision: info.Revision, FetchedAt: info.FetchedAt},
}
return a.persistPriceRecordLocked(record, catalog)
}
func (a *App) applyCatalogChanges(body []byte) ManagementResponse {
var request catalogApplyRequest
if err := decodeJSONBody(body, &request); err != nil {
return catalogManagementError(http.StatusBadRequest, "invalid_catalog_apply", err.Error())
}
request.Revision = strings.TrimSpace(request.Revision)
requested := make(map[string]struct{}, len(request.Models))
for _, model := range request.Models {
model = normalizeModelName(model)
if model != "" {
requested[model] = struct{}{}
}
}
if request.Revision == "" || len(requested) == 0 {
return catalogManagementError(http.StatusBadRequest, "invalid_catalog_apply", "revision 和 models 不能为空")
}
a.priceMu.Lock()
defer a.priceMu.Unlock()
a.mu.Lock()
catalog, store := a.catalog, a.store
if catalog == nil || store == nil {
a.mu.Unlock()
return catalogManagementError(http.StatusServiceUnavailable, "catalog_unavailable", "价格目录或数据库尚未初始化")
}
info, _, loaded := catalog.Status()
if !loaded || info.Revision != request.Revision {
a.mu.Unlock()
return catalogManagementError(http.StatusConflict, "catalog_changed", "价格目录已经变化,请重新查看差异")
}
records, err := store.ListPriceRecords(context.Background())
if err != nil {
a.mu.Unlock()
return catalogManagementError(http.StatusInternalServerError, "database_error", err.Error())
}
updates := make([]repository.PriceRecord, 0, len(requested))
seen := make(map[string]struct{}, len(requested))
for _, record := range records {
normalized := normalizeModelName(record.Policy.Model)
if _, wanted := requested[normalized]; !wanted {
continue
}
if record.Source.Kind != repository.PriceSourceModelsDev {
a.mu.Unlock()
return catalogManagementError(http.StatusConflict, "price_source_changed", record.Policy.Model+" 已经改为手动价格")
}
entry, _, found := catalog.Lookup(record.Source.CatalogID)
if !found {
a.mu.Unlock()
return catalogManagementError(http.StatusConflict, "catalog_price_missing", record.Policy.Model+" 的参考价格已不存在")
}
policy := entry.Policy(record.Policy.Model, record.Policy.FastPricingEnabled, record.Policy.FastMultiplier)
updates = append(updates, repository.PriceRecord{
Policy: policy,
Source: repository.PriceSource{Kind: repository.PriceSourceModelsDev, CatalogID: entry.ID, Revision: info.Revision, FetchedAt: info.FetchedAt},
})
seen[normalized] = struct{}{}
}
if len(seen) != len(requested) {
a.mu.Unlock()
return catalogManagementError(http.StatusConflict, "price_source_changed", "部分待更新价格已经被删除或改变")
}
if err := store.UpsertPriceRecords(context.Background(), updates); err != nil {
a.mu.Unlock()
return catalogManagementError(http.StatusInternalServerError, "database_error", err.Error())
}
for _, update := range updates {
a.prices[normalizeModelName(update.Policy.Model)] = update.Policy
}
a.mu.Unlock()
for _, update := range updates {
if _, err := store.BackfillMissingCosts(context.Background(), update.Policy); err != nil {
a.mu.RLock()
reconfigured := a.store != store
a.mu.RUnlock()
if reconfigured {
return catalogManagementError(http.StatusServiceUnavailable, "database_reconfigured", "价格已经更新,重新配置中止了历史补算")
}
return catalogManagementError(http.StatusInternalServerError, "database_error", err.Error())
}
}
models := make([]string, 0, len(updates))
for _, update := range updates {
models = append(models, update.Policy.Model)
}
sort.Strings(models)
return jsonManagementResponse(http.StatusOK, map[string]any{"updated": len(updates), "models": models, "catalog": info})
}
func previewCatalogChanges(ctx context.Context, store *repository.SQLiteUsageRepository, catalog *modelcatalog.Manager) ([]catalogChangeDTO, error) {
records, err := store.ListPriceRecords(ctx)
if err != nil {
return nil, err
}
changes := make([]catalogChangeDTO, 0)
for _, record := range records {
if record.Source.Kind != repository.PriceSourceModelsDev {
continue
}
entry, info, found := catalog.Lookup(record.Source.CatalogID)
before := priceRecordToDTO(record)
if !found {
changes = append(changes, catalogChangeDTO{Model: record.Policy.Model, CatalogID: record.Source.CatalogID, Status: "missing", Before: before})
continue
}
policy := entry.Policy(record.Policy.Model, record.Policy.FastPricingEnabled, record.Policy.FastMultiplier)
if sameCatalogPolicy(record.Policy, policy) {
continue
}
afterRecord := repository.PriceRecord{
Policy: policy,
Source: repository.PriceSource{Kind: repository.PriceSourceModelsDev, CatalogID: entry.ID, Revision: info.Revision, FetchedAt: info.FetchedAt},
}
after := priceRecordToDTO(afterRecord)
fetchedAt := info.FetchedAt
changes = append(changes, catalogChangeDTO{Model: record.Policy.Model, CatalogID: entry.ID, Status: "changed", Before: before, After: &after, FetchedAt: &fetchedAt})
}
sort.Slice(changes, func(i, j int) bool { return changes[i].Model < changes[j].Model })
return changes, nil
}
func catalogChangeCounts(changes []catalogChangeDTO) (changed, missing int) {
for _, change := range changes {
if change.Status == "changed" {
changed++
} else if change.Status == "missing" {
missing++
}
}
return changed, missing
}
func sameCatalogPolicy(left, right pricing.Policy) bool {
if left.Model != right.Model || left.Base != right.Base || left.FastPricingEnabled != right.FastPricingEnabled || left.FastMultiplier != right.FastMultiplier {
return false
}
if left.LongContext == nil || right.LongContext == nil {
return left.LongContext == nil && right.LongContext == nil
}
return *left.LongContext == *right.LongContext
}
func catalogEntryToDTO(entry modelcatalog.Entry) catalogEntryDTO {
dto := catalogEntryDTO{
ID: entry.ID, Provider: entry.Provider, ProviderName: entry.ProviderName,
Model: entry.Model, ModelName: entry.ModelName, Base: ratesToDTO(entry.Base),
}
if entry.LongContext != nil {
dto.LongContext = &longContextDTO{
ThresholdInputTokens: entry.LongContext.ThresholdInputTokens,
Comparison: entry.LongContext.Comparison, priceRatesDTO: ratesToDTO(entry.LongContext.Rates),
}
}
return dto
}
func catalogManagementError(status int, code, message string) ManagementResponse {
return jsonManagementResponse(status, map[string]any{"error": map[string]string{"code": code, "message": message}})
}
+16 -1
View File
@@ -2,8 +2,12 @@ package plugin
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"billing/internal/modelcatalog"
"gopkg.in/yaml.v3"
)
@@ -13,12 +17,15 @@ type Config struct {
DatabasePath string `yaml:"database_path"`
BootstrapName string `yaml:"bootstrap_name"`
BootstrapKey string `yaml:"bootstrap_key"`
ModelsDevURL string `yaml:"models_dev_url"`
CatalogPath string `yaml:"models_dev_cache_path"`
}
func defaultConfig() Config {
return Config{
Enabled: true, CodexOnly: true, DatabasePath: "data/cpa-ext.db",
Enabled: true, CodexOnly: true, DatabasePath: "data/billing.db",
BootstrapName: "default", BootstrapKey: "000000",
ModelsDevURL: modelcatalog.DefaultSourceURL,
}
}
@@ -39,6 +46,14 @@ func decodeConfig(raw []byte) (Config, error) {
if !validManagedSecret(cfg.BootstrapKey) {
return Config{}, fmt.Errorf("bootstrap_key 必须为 6-256 个不含空白或控制字符的字符")
}
catalogURL, err := url.Parse(strings.TrimSpace(cfg.ModelsDevURL))
if err != nil || catalogURL.Host == "" || (catalogURL.Scheme != "http" && catalogURL.Scheme != "https") {
return Config{}, fmt.Errorf("models_dev_url 必须是有效的 HTTP(S) URL")
}
cfg.ModelsDevURL = catalogURL.String()
if strings.TrimSpace(cfg.CatalogPath) == "" && cfg.DatabasePath != ":memory:" {
cfg.CatalogPath = filepath.Join(filepath.Dir(cfg.DatabasePath), "models-dev-catalog.json")
}
return cfg, nil
}
+3 -3
View File
@@ -13,9 +13,9 @@ import (
"time"
"unicode"
managedaccess "cpa-ext/internal/access"
"cpa-ext/internal/collection"
"cpa-ext/internal/repository"
managedaccess "billing/internal/access"
"billing/internal/collection"
"billing/internal/repository"
)
type createManagedKeyRequest struct {
+34 -14
View File
@@ -10,23 +10,27 @@ import (
"strings"
"time"
"cpa-ext/internal/collection"
"cpa-ext/internal/web"
"billing/internal/collection"
"billing/internal/web"
)
const (
managementBase = "/v0/management/plugins/" + PluginName
resourceBase = "/v0/resource/plugins/" + PluginName
routeUsage = "/usage"
routeUsageSummary = "/usage-summary"
routePrices = "/prices"
routeKeys = "/keys"
routeKeyStats = "/key-stats"
routeUpstreams = "/upstreams"
routeModels = "/model-suggestions"
routeBillingReset = "/billing-reset"
routeBillingLedger = "/billing-ledger"
resourceUI = "/ui"
managementBase = "/v0/management/plugins/" + PluginName
resourceBase = "/v0/resource/plugins/" + PluginName
routeUsage = "/usage"
routeUsageSummary = "/usage-summary"
routePrices = "/prices"
routePriceImport = "/prices/import"
routeCatalog = "/price-catalog"
routeCatalogRefresh = "/price-catalog/refresh"
routeCatalogApply = "/price-catalog/apply"
routeKeys = "/keys"
routeKeyStats = "/key-stats"
routeUpstreams = "/upstreams"
routeModels = "/model-suggestions"
routeBillingReset = "/billing-reset"
routeBillingLedger = "/billing-ledger"
resourceUI = "/ui"
)
func managementRegistration() ManagementRegistrationResponse {
@@ -37,6 +41,10 @@ func managementRegistration() ManagementRegistrationResponse {
{Method: http.MethodGet, Path: managementBase + routePrices, Description: "查看模型价格。"},
{Method: http.MethodPut, Path: managementBase + routePrices, Description: "保存模型价格。"},
{Method: http.MethodDelete, Path: managementBase + routePrices, Description: "删除模型价格。"},
{Method: http.MethodPost, Path: managementBase + routePriceImport, Description: "从 models.dev 导入参考价格。"},
{Method: http.MethodGet, Path: managementBase + routeCatalog, Description: "搜索 models.dev 参考价格。"},
{Method: http.MethodPost, Path: managementBase + routeCatalogRefresh, Description: "刷新 models.dev 价格目录并预览变化。"},
{Method: http.MethodPost, Path: managementBase + routeCatalogApply, Description: "确认应用参考价格变化。"},
{Method: http.MethodGet, Path: managementBase + routeKeys, Description: "查看下游 Key。"},
{Method: http.MethodPost, Path: managementBase + routeKeys, Description: "创建下游 Key。"},
{Method: http.MethodPatch, Path: managementBase + routeKeys, Description: "更新下游 Key。"},
@@ -85,6 +93,18 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
return OKEnvelope(a.deletePrice(req.Body))
}
}
if req.Method == http.MethodPost && path == managementBase+routePriceImport {
return OKEnvelope(a.importCatalogPrice(req.Body))
}
if req.Method == http.MethodGet && path == managementBase+routeCatalog {
return OKEnvelope(a.searchPriceCatalog(req.Query))
}
if req.Method == http.MethodPost && path == managementBase+routeCatalogRefresh {
return OKEnvelope(a.refreshPriceCatalog())
}
if req.Method == http.MethodPost && path == managementBase+routeCatalogApply {
return OKEnvelope(a.applyCatalogChanges(req.Body))
}
if path == managementBase+routeKeys {
switch req.Method {
case http.MethodGet:
+88 -2
View File
@@ -4,7 +4,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
@@ -52,7 +54,7 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
if err := json.Unmarshal(envelope.Result, &registration); err != nil {
t.Fatal(err)
}
if len(registration.Routes) != 14 || registration.Routes[0].Path != managementBase+routeUsage || registration.Routes[1].Path != managementBase+routeUsageSummary || registration.Routes[2].Path != managementBase+routePrices {
if len(registration.Routes) != 18 || registration.Routes[0].Path != managementBase+routeUsage || registration.Routes[1].Path != managementBase+routeUsageSummary || registration.Routes[2].Path != managementBase+routePrices {
t.Fatalf("unexpected management routes: %+v", registration.Routes)
}
if len(registration.Resources) != 1 || registration.Resources[0].Path != resourceBase+resourceUI {
@@ -60,6 +62,90 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
}
}
func TestModelsDevCatalogRequiresExplicitImportAndConfirmedRefresh(t *testing.T) {
catalogBody := `{"providers":{"openai":{"id":"openai","name":"OpenAI","models":{"gpt-5.6-sol":{"id":"gpt-5.6-sol","name":"GPT-5.6 Sol","cost":{"input":5,"output":30,"cache_read":0.5,"cache_write":6.25}}}},"free":{"id":"free","name":"Free","models":{"placeholder":{"id":"placeholder","cost":{"input":0,"output":0}}}}}}`
serverStatus := http.StatusOK
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(serverStatus)
_, _ = response.Write([]byte(catalogBody))
}))
defer server.Close()
directory := t.TempDir()
config := fmt.Sprintf("database_path: %q\nmodels_dev_url: %q\nmodels_dev_cache_path: %q\nenabled: true\ncodex_only: false\n",
filepath.Join(directory, "usage.db"), server.URL, filepath.Join(directory, "catalog.json"))
app := NewApp()
defer app.Shutdown()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, config)); err != nil {
t.Fatal(err)
}
status := managementCallRequest(t, app, ManagementRequest{Method: http.MethodGet, Path: managementBase + routeCatalog, Query: url.Values{"q": {"gpt"}}})
if status.StatusCode != http.StatusOK || !strings.Contains(string(status.Body), `"loaded":false`) {
t.Fatalf("catalog status=%d body=%s", status.StatusCode, status.Body)
}
refresh := managementCall(t, app, http.MethodPost, managementBase+routeCatalogRefresh)
if refresh.StatusCode != http.StatusOK || !strings.Contains(string(refresh.Body), `"changed":0`) {
t.Fatalf("initial refresh status=%d body=%s", refresh.StatusCode, refresh.Body)
}
search := managementCallRequest(t, app, ManagementRequest{Method: http.MethodGet, Path: managementBase + routeCatalog, Query: url.Values{"q": {"gpt-5.6"}}})
if search.StatusCode != http.StatusOK || !strings.Contains(string(search.Body), `"id":"openai/gpt-5.6-sol"`) || strings.Contains(string(search.Body), "placeholder") {
t.Fatalf("catalog search status=%d body=%s", search.StatusCode, search.Body)
}
imported := managementCallBody(t, app, http.MethodPost, managementBase+routePriceImport, []byte(`{"model":"deepseek-v4-flash","catalog_id":"openai/gpt-5.6-sol"}`))
if imported.StatusCode != http.StatusOK || !strings.Contains(string(imported.Body), `"input_per_1m":"5"`) || !strings.Contains(string(imported.Body), `"kind":"models.dev"`) {
t.Fatalf("catalog import status=%d body=%s", imported.StatusCode, imported.Body)
}
catalogBody = strings.Replace(catalogBody, `"input":5`, `"input":6`, 1)
preview := managementCall(t, app, http.MethodPost, managementBase+routeCatalogRefresh)
if preview.StatusCode != http.StatusOK || !strings.Contains(string(preview.Body), `"changed":1`) || !strings.Contains(string(preview.Body), `"input_per_1m":"6"`) {
t.Fatalf("refresh preview status=%d body=%s", preview.StatusCode, preview.Body)
}
pricesBefore := managementCall(t, app, http.MethodGet, managementBase+routePrices)
if !strings.Contains(string(pricesBefore.Body), `"input_per_1m":"5"`) {
t.Fatalf("preview silently changed local price: %s", pricesBefore.Body)
}
var previewPayload struct {
Catalog struct {
Revision string `json:"revision"`
} `json:"catalog"`
}
if err := json.Unmarshal(preview.Body, &previewPayload); err != nil {
t.Fatal(err)
}
applyBody, _ := json.Marshal(map[string]any{"revision": previewPayload.Catalog.Revision, "models": []string{"deepseek-v4-flash"}})
applied := managementCallBody(t, app, http.MethodPost, managementBase+routeCatalogApply, applyBody)
if applied.StatusCode != http.StatusOK || !strings.Contains(string(applied.Body), `"updated":1`) {
t.Fatalf("catalog apply status=%d body=%s", applied.StatusCode, applied.Body)
}
pricesAfter := managementCall(t, app, http.MethodGet, managementBase+routePrices)
if !strings.Contains(string(pricesAfter.Body), `"input_per_1m":"6"`) {
t.Fatalf("confirmed price was not applied: %s", pricesAfter.Body)
}
manual := []byte(`{"model":"deepseek-v4-flash","base":{"input_per_1m":"7","cache_read_per_1m":"0.5","cache_write_per_1m":"6.25","output_per_1m":"30"},"fast_pricing_enabled":false,"fast_multiplier":"2.5"}`)
if response := managementCallBody(t, app, http.MethodPut, managementBase+routePrices, manual); response.StatusCode != http.StatusOK || !strings.Contains(string(response.Body), `"kind":"manual"`) {
t.Fatalf("manual save status=%d body=%s", response.StatusCode, response.Body)
}
catalogBody = strings.Replace(catalogBody, `"input":6`, `"input":8`, 1)
manualPreview := managementCall(t, app, http.MethodPost, managementBase+routeCatalogRefresh)
if manualPreview.StatusCode != http.StatusOK || !strings.Contains(string(manualPreview.Body), `"changed":0`) {
t.Fatalf("manual price followed catalog: status=%d body=%s", manualPreview.StatusCode, manualPreview.Body)
}
serverStatus = http.StatusBadGateway
failed := managementCall(t, app, http.MethodPost, managementBase+routeCatalogRefresh)
if failed.StatusCode != http.StatusBadGateway {
t.Fatalf("failed refresh status=%d body=%s", failed.StatusCode, failed.Body)
}
lastGood := managementCallRequest(t, app, ManagementRequest{Method: http.MethodGet, Path: managementBase + routeCatalog, Query: url.Values{"q": {"gpt"}}})
if lastGood.StatusCode != http.StatusOK || !strings.Contains(string(lastGood.Body), `"loaded":true`) || !strings.Contains(string(lastGood.Body), `"input_per_1m":"8"`) {
t.Fatalf("last-known-good catalog unavailable: status=%d body=%s", lastGood.StatusCode, lastGood.Body)
}
}
func TestUsageManagementSupportsServerPaginationFiltersAndSummary(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
@@ -467,7 +553,7 @@ func TestUsageResourceServesTablePage(t *testing.T) {
if strings.Index(page, `id="page-buttons"`) > strings.Index(page, `id="headers"`) {
t.Fatal("UI pagination controls are not above the usage table")
}
for _, feature := range []string{`class="surface price-panel price-layout"`, `id="new-price"`, "基础价格 · $ / 1M Token", "长上下文价格", "Fast 价格", "删除 ${model} 的真实价格配置"} {
for _, feature := range []string{`class="surface price-panel price-layout"`, `id="new-price"`, "基础价格 · $ / 1M Token", "长上下文价格", "Fast 价格", "删除 ${model} 的真实价格配置", `id="catalog-query"`, `id="refresh-catalog"`, "PRICE_IMPORT_API", "发现 ${changed.length} 个已关联价格发生变化", "测试目录 · 不访问 models.dev"} {
if !strings.Contains(page, feature) {
t.Fatalf("UI does not contain redesigned pricing feature %q", feature)
}
+56 -10
View File
@@ -8,8 +8,11 @@ import (
"net/http"
"strconv"
"strings"
"time"
"cpa-ext/internal/pricing"
"billing/internal/modelcatalog"
"billing/internal/pricing"
"billing/internal/repository"
)
type priceRatesDTO struct {
@@ -31,6 +34,16 @@ type priceDTO struct {
LongContext *longContextDTO `json:"long_context,omitempty"`
FastPricingEnabled bool `json:"fast_pricing_enabled"`
FastMultiplier string `json:"fast_multiplier"`
Source priceSourceDTO `json:"source"`
}
type priceSourceDTO struct {
Kind string `json:"kind"`
CatalogID string `json:"catalog_id,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Revision string `json:"revision,omitempty"`
FetchedAt time.Time `json:"fetched_at,omitzero"`
}
type priceDeleteRequest struct {
@@ -43,13 +56,13 @@ func (a *App) listPrices() ManagementResponse {
if a.store == nil {
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_unavailable", "message": "价格数据库尚未初始化"}})
}
policies, err := a.store.ListPrices(context.Background())
records, err := a.store.ListPriceRecords(context.Background())
if err != nil {
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
prices := make([]priceDTO, 0, len(policies))
for _, policy := range policies {
prices = append(prices, policyToDTO(policy))
prices := make([]priceDTO, 0, len(records))
for _, record := range records {
prices = append(prices, priceRecordToDTO(record))
}
return jsonManagementResponse(http.StatusOK, map[string]any{"prices": prices})
}
@@ -63,19 +76,35 @@ func (a *App) putPrice(body []byte) ManagementResponse {
if err != nil {
return jsonManagementResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "invalid_price", "message": err.Error()}})
}
return a.persistPriceRecord(repository.PriceRecord{Policy: policy, Source: repository.PriceSource{Kind: repository.PriceSourceManual}})
}
func (a *App) persistPriceRecord(record repository.PriceRecord) ManagementResponse {
return a.persistPriceRecordFromCatalog(record, nil)
}
func (a *App) persistPriceRecordFromCatalog(record repository.PriceRecord, expectedCatalog *modelcatalog.Manager) ManagementResponse {
a.priceMu.Lock()
defer a.priceMu.Unlock()
return a.persistPriceRecordLocked(record, expectedCatalog)
}
func (a *App) persistPriceRecordLocked(record repository.PriceRecord, expectedCatalog *modelcatalog.Manager) ManagementResponse {
a.mu.Lock()
store := a.store
if store == nil {
a.mu.Unlock()
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_unavailable", "message": "价格数据库尚未初始化"}})
}
if err := store.UpsertPrice(context.Background(), policy); err != nil {
if expectedCatalog != nil && a.catalog != expectedCatalog {
a.mu.Unlock()
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "catalog_reconfigured", "message": "价格目录配置已经变化,请重新搜索"}})
}
if err := store.UpsertPriceRecord(context.Background(), record); err != nil {
a.mu.Unlock()
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
a.prices[normalizeModelName(policy.Model)] = policy
a.prices[normalizeModelName(record.Policy.Model)] = record.Policy
a.mu.Unlock()
a.mu.RLock()
currentStore := a.store == store
@@ -83,7 +112,7 @@ func (a *App) putPrice(body []byte) ManagementResponse {
if !currentStore {
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_reconfigured", "message": "价格已经保存,请在重新配置后重试历史补算"}})
}
if _, err := store.BackfillMissingCosts(context.Background(), policy); err != nil {
if _, err := store.BackfillMissingCosts(context.Background(), record.Policy); err != nil {
a.mu.RLock()
reconfigured := a.store != store
a.mu.RUnlock()
@@ -92,7 +121,7 @@ func (a *App) putPrice(body []byte) ManagementResponse {
}
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
return jsonManagementResponse(http.StatusOK, policyToDTO(policy))
return jsonManagementResponse(http.StatusOK, priceRecordToDTO(record))
}
func (a *App) deletePrice(body []byte) ManagementResponse {
@@ -162,13 +191,30 @@ func dtoRates(dto priceRatesDTO) (pricing.Rates, error) {
}
func policyToDTO(policy pricing.Policy) priceDTO {
dto := priceDTO{Model: policy.Model, Base: ratesToDTO(policy.Base), FastPricingEnabled: policy.FastPricingEnabled, FastMultiplier: formatRatio(policy.FastMultiplier)}
dto := priceDTO{Model: policy.Model, Base: ratesToDTO(policy.Base), FastPricingEnabled: policy.FastPricingEnabled, FastMultiplier: formatRatio(policy.FastMultiplier), Source: priceSourceDTO{Kind: repository.PriceSourceManual}}
if policy.LongContext != nil {
dto.LongContext = &longContextDTO{ThresholdInputTokens: policy.LongContext.ThresholdInputTokens, Comparison: policy.LongContext.Comparison, priceRatesDTO: ratesToDTO(policy.LongContext.Rates)}
}
return dto
}
func priceRecordToDTO(record repository.PriceRecord) priceDTO {
dto := policyToDTO(record.Policy)
source := record.Source
if source.Kind == "" {
source.Kind = repository.PriceSourceManual
}
dto.Source = priceSourceDTO{Kind: source.Kind, CatalogID: source.CatalogID, Revision: source.Revision, FetchedAt: source.FetchedAt}
if source.CatalogID != "" {
parts := strings.SplitN(source.CatalogID, "/", 2)
dto.Source.Provider = parts[0]
if len(parts) == 2 {
dto.Source.Model = parts[1]
}
}
return dto
}
func ratesToDTO(rates pricing.Rates) priceRatesDTO {
return priceRatesDTO{InputPer1M: formatMicros(rates.InputMicrosPer1M), CacheReadPer1M: formatMicros(rates.CacheReadMicrosPer1M), CacheWritePer1M: formatMicros(rates.CacheWriteMicrosPer1M), OutputPer1M: formatMicros(rates.OutputMicrosPer1M)}
}
+2 -2
View File
@@ -10,8 +10,8 @@ import (
const (
ABIVersion uint32 = 1
SchemaVersion uint32 = 3
PluginName = "cpa-ext"
Version = "0.1.0-dev"
PluginName = "billing"
Version = "0.1.0"
)
const (
+1 -1
View File
@@ -3,7 +3,7 @@ package pricing_test
import (
"testing"
"cpa-ext/internal/pricing"
"billing/internal/pricing"
)
func testPolicy() pricing.Policy {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings"
"time"
managedaccess "cpa-ext/internal/access"
managedaccess "billing/internal/access"
)
var ErrManagedKeyNotFound = errors.New("managed key not found")
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"testing"
"time"
managedaccess "cpa-ext/internal/access"
"cpa-ext/internal/collection"
"cpa-ext/internal/repository"
managedaccess "billing/internal/access"
"billing/internal/collection"
"billing/internal/repository"
)
func TestManagedKeysBootstrapLifecycleAndHistoricalUsage(t *testing.T) {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strings"
"time"
managedaccess "cpa-ext/internal/access"
managedaccess "billing/internal/access"
)
var (
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"testing"
"time"
managedaccess "cpa-ext/internal/access"
"cpa-ext/internal/collection"
managedaccess "billing/internal/access"
"billing/internal/collection"
)
func TestBillingQuotaConcurrencySettlementAndReset(t *testing.T) {
@@ -7,8 +7,8 @@ import (
"testing"
"time"
"cpa-ext/internal/collection"
"cpa-ext/internal/pricing"
"billing/internal/collection"
"billing/internal/pricing"
)
func TestSQLiteSeparatesReaderPoolFromWriter(t *testing.T) {
+135 -16
View File
@@ -2,69 +2,163 @@ package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"cpa-ext/internal/pricing"
"billing/internal/pricing"
)
const (
PriceSourceManual = "manual"
PriceSourceModelsDev = "models.dev"
)
// PriceSource records where the current effective local price was imported
// from. It is bookkeeping only; billing always reads the embedded Policy.
type PriceSource struct {
Kind string
CatalogID string
Revision string
FetchedAt time.Time
}
type PriceRecord struct {
Policy pricing.Policy
Source PriceSource
}
// ListPrices returns every configured exact-model policy.
func (r *SQLiteUsageRepository) ListPrices(ctx context.Context) ([]pricing.Policy, error) {
records, err := r.ListPriceRecords(ctx)
if err != nil {
return nil, err
}
policies := make([]pricing.Policy, 0, len(records))
for _, record := range records {
policies = append(policies, record.Policy)
}
return policies, nil
}
// ListPriceRecords returns effective prices together with their optional
// models.dev import link.
func (r *SQLiteUsageRepository) ListPriceRecords(ctx context.Context) ([]PriceRecord, error) {
rows, err := r.readDB.QueryContext(ctx, `
SELECT model, input_rate_micros, cache_read_rate_micros, cache_write_rate_micros, output_rate_micros,
long_context_enabled, long_context_threshold, long_context_comparison,
long_input_rate_micros, long_cache_read_rate_micros, long_cache_write_rate_micros, long_output_rate_micros,
fast_pricing_enabled, fast_multiplier_numerator, fast_multiplier_denominator
fast_pricing_enabled, fast_multiplier_numerator, fast_multiplier_denominator,
source_kind, source_catalog_id, source_revision, source_fetched_at
FROM model_prices ORDER BY model`)
if err != nil {
return nil, fmt.Errorf("查询模型价格: %w", err)
}
defer rows.Close()
var policies []pricing.Policy
var records []PriceRecord
for rows.Next() {
var policy pricing.Policy
var record PriceRecord
var longEnabled bool
var long pricing.LongContext
var fetchedAt string
if err := rows.Scan(
&policy.Model,
&policy.Base.InputMicrosPer1M, &policy.Base.CacheReadMicrosPer1M,
&policy.Base.CacheWriteMicrosPer1M, &policy.Base.OutputMicrosPer1M,
&record.Policy.Model,
&record.Policy.Base.InputMicrosPer1M, &record.Policy.Base.CacheReadMicrosPer1M,
&record.Policy.Base.CacheWriteMicrosPer1M, &record.Policy.Base.OutputMicrosPer1M,
&longEnabled, &long.ThresholdInputTokens, &long.Comparison,
&long.Rates.InputMicrosPer1M, &long.Rates.CacheReadMicrosPer1M,
&long.Rates.CacheWriteMicrosPer1M, &long.Rates.OutputMicrosPer1M,
&policy.FastPricingEnabled, &policy.FastMultiplier.Numerator, &policy.FastMultiplier.Denominator,
&record.Policy.FastPricingEnabled, &record.Policy.FastMultiplier.Numerator, &record.Policy.FastMultiplier.Denominator,
&record.Source.Kind, &record.Source.CatalogID, &record.Source.Revision, &fetchedAt,
); err != nil {
return nil, fmt.Errorf("读取模型价格: %w", err)
}
if longEnabled {
policy.LongContext = &long
record.Policy.LongContext = &long
}
policies = append(policies, policy)
if strings.TrimSpace(record.Source.Kind) == "" {
record.Source.Kind = PriceSourceManual
}
if fetchedAt != "" {
parsed, parseErr := time.Parse(time.RFC3339Nano, fetchedAt)
if parseErr != nil {
return nil, fmt.Errorf("解析模型价格来源时间: %w", parseErr)
}
record.Source.FetchedAt = parsed
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历模型价格: %w", err)
}
return policies, nil
return records, nil
}
// UpsertPrice atomically replaces one model policy.
func (r *SQLiteUsageRepository) UpsertPrice(ctx context.Context, policy pricing.Policy) error {
if err := policy.Validate(); err != nil {
return err
return r.UpsertPriceRecord(ctx, PriceRecord{Policy: policy, Source: PriceSource{Kind: PriceSourceManual}})
}
// UpsertPriceRecord atomically replaces one effective price and its source.
func (r *SQLiteUsageRepository) UpsertPriceRecord(ctx context.Context, record PriceRecord) error {
return r.UpsertPriceRecords(ctx, []PriceRecord{record})
}
// UpsertPriceRecords updates a confirmed catalog diff in one transaction.
func (r *SQLiteUsageRepository) UpsertPriceRecords(ctx context.Context, records []PriceRecord) error {
if len(records) == 0 {
return nil
}
for index := range records {
if strings.TrimSpace(records[index].Source.Kind) == "" {
records[index].Source.Kind = PriceSourceManual
}
if err := validatePriceRecord(records[index]); err != nil {
return err
}
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("开始保存模型价格: %w", err)
}
for _, record := range records {
if err := upsertPriceRecord(ctx, tx, record); err != nil {
_ = tx.Rollback()
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交模型价格: %w", err)
}
return nil
}
type priceExecer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func upsertPriceRecord(ctx context.Context, executor priceExecer, record PriceRecord) error {
policy := record.Policy
longEnabled := policy.LongContext != nil
long := pricing.LongContext{Comparison: "gt"}
if policy.LongContext != nil {
long = *policy.LongContext
}
_, err := r.db.ExecContext(ctx, `
fetchedAt := ""
if !record.Source.FetchedAt.IsZero() {
fetchedAt = record.Source.FetchedAt.UTC().Format(time.RFC3339Nano)
}
_, err := executor.ExecContext(ctx, `
INSERT INTO model_prices (
model, input_rate_micros, cache_read_rate_micros, cache_write_rate_micros, output_rate_micros,
long_context_enabled, long_context_threshold, long_context_comparison,
long_input_rate_micros, long_cache_read_rate_micros, long_cache_write_rate_micros, long_output_rate_micros,
fast_pricing_enabled, fast_multiplier_numerator, fast_multiplier_denominator, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
fast_pricing_enabled, fast_multiplier_numerator, fast_multiplier_denominator,
source_kind, source_catalog_id, source_revision, source_fetched_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(model) DO UPDATE SET
input_rate_micros=excluded.input_rate_micros,
cache_read_rate_micros=excluded.cache_read_rate_micros,
@@ -80,6 +174,10 @@ ON CONFLICT(model) DO UPDATE SET
fast_pricing_enabled=excluded.fast_pricing_enabled,
fast_multiplier_numerator=excluded.fast_multiplier_numerator,
fast_multiplier_denominator=excluded.fast_multiplier_denominator,
source_kind=excluded.source_kind,
source_catalog_id=excluded.source_catalog_id,
source_revision=excluded.source_revision,
source_fetched_at=excluded.source_fetched_at,
updated_at=excluded.updated_at`,
policy.Model,
policy.Base.InputMicrosPer1M, policy.Base.CacheReadMicrosPer1M,
@@ -88,6 +186,7 @@ ON CONFLICT(model) DO UPDATE SET
long.Rates.InputMicrosPer1M, long.Rates.CacheReadMicrosPer1M,
long.Rates.CacheWriteMicrosPer1M, long.Rates.OutputMicrosPer1M,
policy.FastPricingEnabled, policy.FastMultiplier.Numerator, policy.FastMultiplier.Denominator,
record.Source.Kind, record.Source.CatalogID, record.Source.Revision, fetchedAt,
time.Now().UTC().Format(time.RFC3339Nano),
)
if err != nil {
@@ -96,6 +195,26 @@ ON CONFLICT(model) DO UPDATE SET
return nil
}
func validatePriceRecord(record PriceRecord) error {
if err := record.Policy.Validate(); err != nil {
return err
}
record.Source.Kind = strings.TrimSpace(record.Source.Kind)
switch record.Source.Kind {
case "", PriceSourceManual:
if record.Source.CatalogID != "" || record.Source.Revision != "" || !record.Source.FetchedAt.IsZero() {
return errors.New("手动价格不能包含目录来源")
}
case PriceSourceModelsDev:
if strings.TrimSpace(record.Source.CatalogID) == "" || strings.TrimSpace(record.Source.Revision) == "" || record.Source.FetchedAt.IsZero() {
return errors.New("models.dev 价格来源不完整")
}
default:
return fmt.Errorf("不支持的价格来源 %q", record.Source.Kind)
}
return nil
}
const costBackfillBatchSize = 1000
// BackfillMissingCosts 只补算尚未定价的历史记录,已经保存的账单金额不会随价格修改而变化。
+37 -3
View File
@@ -6,9 +6,9 @@ import (
"testing"
"time"
"cpa-ext/internal/collection"
"cpa-ext/internal/pricing"
"cpa-ext/internal/repository"
"billing/internal/collection"
"billing/internal/pricing"
"billing/internal/repository"
)
func TestSQLitePricingRoundTripAndDelete(t *testing.T) {
@@ -44,6 +44,40 @@ func TestSQLitePricingRoundTripAndDelete(t *testing.T) {
}
}
func TestSQLitePricingPersistsCatalogSourceAndManualSaveClearsIt(t *testing.T) {
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
fetchedAt := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
policy := pricing.Policy{
Model: "deepseek-v4-flash", Base: pricing.Rates{InputMicrosPer1M: 5_000_000, CacheReadMicrosPer1M: 500_000, CacheWriteMicrosPer1M: 6_250_000, OutputMicrosPer1M: 30_000_000},
FastMultiplier: pricing.Ratio{Numerator: 5, Denominator: 2},
}
if err := store.UpsertPriceRecord(context.Background(), repository.PriceRecord{
Policy: policy,
Source: repository.PriceSource{Kind: repository.PriceSourceModelsDev, CatalogID: "openai/gpt-5.6-sol", Revision: "revision-one", FetchedAt: fetchedAt},
}); err != nil {
t.Fatal(err)
}
records, err := store.ListPriceRecords(context.Background())
if err != nil || len(records) != 1 {
t.Fatalf("records=%+v err=%v", records, err)
}
if records[0].Source.Kind != repository.PriceSourceModelsDev || records[0].Source.CatalogID != "openai/gpt-5.6-sol" || !records[0].Source.FetchedAt.Equal(fetchedAt) {
t.Fatalf("catalog source = %+v", records[0].Source)
}
policy.Base.InputMicrosPer1M = 7_000_000
if err := store.UpsertPrice(context.Background(), policy); err != nil {
t.Fatal(err)
}
records, err = store.ListPriceRecords(context.Background())
if err != nil || records[0].Source.Kind != repository.PriceSourceManual || records[0].Source.CatalogID != "" || records[0].Policy.Base.InputMicrosPer1M != 7_000_000 {
t.Fatalf("manual record=%+v err=%v", records, err)
}
}
func TestBackfillMissingCostsDoesNotRewriteExistingCost(t *testing.T) {
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
if err != nil {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strings"
"time"
"cpa-ext/internal/collection"
"billing/internal/collection"
)
const requestDetailProjectionMigration = "request-detail-index-v1"
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"strings"
"time"
"cpa-ext/internal/collection"
"billing/internal/collection"
)
type usageCursor struct {
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"cpa-ext/internal/collection"
"billing/internal/collection"
)
func TestRequestDetailProjectionBackfillsExistingFacts(t *testing.T) {
@@ -8,7 +8,7 @@ import (
"testing"
"time"
"cpa-ext/internal/collection"
"billing/internal/collection"
)
func TestSQLiteUsageQueryAtMillionRows(t *testing.T) {
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"testing"
"time"
"cpa-ext/internal/collection"
"cpa-ext/internal/repository"
"billing/internal/collection"
"billing/internal/repository"
)
func TestSQLiteUsageQueryPaginatesAndFilters(t *testing.T) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"time"
"cpa-ext/internal/collection"
"billing/internal/collection"
)
const usageDashboardUsersSQL = `
+12 -3
View File
@@ -1,4 +1,4 @@
// Package repository 实现 cpa-ext 的本地持久化。
// Package repository 实现 billing 的本地持久化。
package repository
import (
@@ -14,7 +14,7 @@ import (
"strings"
"time"
"cpa-ext/internal/collection"
"billing/internal/collection"
_ "github.com/mattn/go-sqlite3"
)
@@ -113,6 +113,7 @@ ON request_detail_index(endpoint_kind, requested_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_request_detail_request
ON request_detail_index(request_id, requested_at DESC, id DESC);
-- Keep the legacy table name so existing databases retain migration history.
CREATE TABLE IF NOT EXISTS cpa_ext_migrations (
name TEXT PRIMARY KEY,
completed_at TEXT NOT NULL
@@ -168,6 +169,10 @@ CREATE TABLE IF NOT EXISTS model_prices (
fast_pricing_enabled INTEGER NOT NULL DEFAULT 0,
fast_multiplier_numerator INTEGER NOT NULL DEFAULT 5,
fast_multiplier_denominator INTEGER NOT NULL DEFAULT 2,
source_kind TEXT NOT NULL DEFAULT 'manual',
source_catalog_id TEXT NOT NULL DEFAULT '',
source_revision TEXT NOT NULL DEFAULT '',
source_fetched_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
@@ -268,6 +273,10 @@ func OpenSQLiteUsage(databasePath string) (*SQLiteUsageRepository, error) {
`ALTER TABLE usage_records ADD COLUMN auth_index TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE usage_records ADD COLUMN auth_type TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE usage_records ADD COLUMN billing_event_key TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE model_prices ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'manual'`,
`ALTER TABLE model_prices ADD COLUMN source_catalog_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE model_prices ADD COLUMN source_revision TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE model_prices ADD COLUMN source_fetched_at TEXT NOT NULL DEFAULT ''`,
} {
if _, err := db.Exec(migration); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
_ = db.Close()
@@ -326,7 +335,7 @@ func sqliteDSN(databasePath string) (string, error) {
return "", errors.New("database_path 不能为空")
}
if databasePath == ":memory:" {
return "file:cpa-ext-memory?mode=memory&cache=shared&_busy_timeout=5000&_foreign_keys=on", nil
return "file:billing-memory?mode=memory&cache=shared&_busy_timeout=5000&_foreign_keys=on", nil
}
if strings.HasPrefix(databasePath, "file:") {
separator := "?"
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"testing"
"time"
"cpa-ext/internal/collection"
"cpa-ext/internal/repository"
"billing/internal/collection"
"billing/internal/repository"
)
func TestSQLiteUsagePersistsAllRecordsAndLimitsQueries(t *testing.T) {
+163 -29
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CPA Ext 管理台</title>
<title>Billing 管理台</title>
<style>
:root {
color-scheme: dark;
@@ -23,8 +23,9 @@
body { margin: 0; min-width: 320px; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { min-height: 100vh; }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; height: 58px; padding: 0 22px; border-bottom: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg-primary) 92%, transparent); backdrop-filter: blur(16px); }
.app-shell { min-height: 100vh; padding-top: 18px; }
/* CPA Manager overlays its own controls on the iframe's top-right corner. */
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 58px; padding: 0 222px 0 22px; border-bottom: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg-primary) 92%, transparent); backdrop-filter: blur(16px); }
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.brand-mark { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid var(--border-primary); border-radius: 8px; background: var(--bg-tertiary); color: var(--text-primary); font-size: 11px; font-weight: 800; letter-spacing: .04em; }
.brand strong { font-size: 14px; letter-spacing: .02em; }
@@ -117,6 +118,15 @@
.price-grid.long-grid { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
.field, .price-grid label { display: grid; gap: 6px; color: var(--muted); font-size: 10px; }
.price-model-field { max-width: 480px; }
.catalog-toolbar { display: flex; align-items: center; gap: 7px; }
.catalog-toolbar input { flex: 1 1 260px; }
.catalog-summary { min-height: 17px; margin-bottom: 8px; color: var(--muted); font-size: 10px; }
.catalog-results { display: grid; gap: 6px; max-height: 250px; margin-top: 8px; overflow-y: auto; }
.catalog-result { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-primary); }
.catalog-result strong { display: block; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.catalog-result small { display: block; margin-top: 3px; color: var(--muted); font-size: 9px; }
.price-source { min-height: 18px; margin-top: 8px; color: var(--text-tertiary); font-size: 10px; }
.price-source.catalog-linked { color: var(--text-secondary); }
.fast-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(130px, 220px); align-items: end; gap: 16px; }
.fast-row .inline-check { align-self: center; color: var(--text); }
.price-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 3px; }
@@ -193,13 +203,13 @@
.usage-filter-actions { display: flex; align-items: end; justify-content: flex-end; gap: 7px; grid-column: span 2; }
@media (max-width: 1100px) { .usage-filters { grid-template-columns: repeat(3, minmax(140px, 1fr)); } }
@media (max-width: 900px) { .price-layout { grid-template-columns: 220px minmax(0, 1fr); } .price-grid, .price-grid.long-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } .summary-grid { grid-template-columns: repeat(3, minmax(130px, 1fr)); overflow-x: auto; } .chart-grid { grid-template-columns: 1fr; } }
@media (max-width: 680px) { .topbar { align-items: flex-start; height: auto; padding: 11px 12px; } .brand span, .auth-box label { display: none; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } .demo-banner { padding-right: 12px; padding-left: 12px; } .workspace-nav { padding-right: 12px; padding-left: 12px; } main { padding: 14px 12px 22px; } .section-heading { align-items: stretch; flex-direction: column; } .section-heading .tools { justify-content: flex-start; } .price-layout { grid-template-columns: 1fr; } .price-sidebar { border-right: 0; border-bottom: 1px solid var(--border-soft); } .price-grid, .price-grid.long-grid, .field-row, .fast-row { grid-template-columns: 1fr; } .drawer { width: 100vw; } .summary-grid { margin-right: -12px; padding-right: 12px; } .pagination { align-items: flex-start; flex-direction: column; } .usage-filters { grid-template-columns: 1fr 1fr; } .usage-filter.request-filter, .usage-filter-actions { grid-column: span 2; } }
@media (max-width: 680px) { .app-shell { padding-top: 14px; } .topbar { align-items: flex-start; min-height: 0; padding: 11px 12px; } .brand span, .auth-box label { display: none; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } .demo-banner { padding-right: 12px; padding-left: 12px; } .workspace-nav { padding-right: 12px; padding-left: 12px; } main { padding: 14px 12px 22px; } .section-heading { align-items: stretch; flex-direction: column; } .section-heading .tools { justify-content: flex-start; } .price-layout { grid-template-columns: 1fr; } .price-sidebar { border-right: 0; border-bottom: 1px solid var(--border-soft); } .price-grid, .price-grid.long-grid, .field-row, .fast-row { grid-template-columns: 1fr; } .drawer { width: 100vw; } .summary-grid { margin-right: -12px; padding-right: 12px; } .pagination { align-items: flex-start; flex-direction: column; } .usage-filters { grid-template-columns: 1fr 1fr; } .usage-filter.request-filter, .usage-filter-actions { grid-column: span 2; } }
</style>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand"><div class="brand-mark">CX</div><div><strong>CPA Ext</strong><span> · 管理控制台</span></div></div>
<div class="brand"><div class="brand-mark">B</div><div><strong>Billing</strong><span> · 管理控制台</span></div></div>
<div class="auth-box"><div class="mode-switch" aria-label="数据模式"><button class="mode-button active" type="button" data-mode="live">实时</button><button class="mode-button" type="button" data-mode="demo">测试</button></div><label for="key">管理密钥</label><input id="key" type="password" autocomplete="off" placeholder="输入管理密钥"></div>
</header>
<div id="demo-banner" class="demo-banner hidden"><span><strong>测试模式</strong> · 页面使用浏览器本地样例数据,任何创建、编辑、归档和价格操作都不会访问 CPA。</span><button id="reset-demo" type="button">重置测试数据</button></div>
@@ -245,7 +255,8 @@
<aside class="price-sidebar"><div class="price-sidebar-header"><h2>已配置模型</h2><button id="new-price" class="small" type="button">新增</button></div><div id="price-list" class="price-list"></div></aside>
<section class="price-editor">
<div class="price-editor-header"><h2>模型价格</h2></div>
<div class="price-section"><label class="field price-model-field">模型名称<input id="price-model" placeholder="gpt-5.6-sol"></label></div>
<div class="price-section"><label class="field price-model-field">模型名称<input id="price-model" placeholder="gpt-5.6-sol"></label><div id="price-source" class="price-source">手动配置</div></div>
<div class="price-section"><div class="price-section-header"><h3>models.dev 参考价格</h3><button id="refresh-catalog" class="small" type="button">更新目录</button></div><div id="catalog-summary" class="catalog-summary"></div><div class="catalog-toolbar"><input id="catalog-query" placeholder="搜索模型或供应商"><button id="search-catalog" class="small" type="button">搜索</button></div><div id="catalog-results" class="catalog-results"></div></div>
<div class="price-section"><div class="price-section-header"><h3>基础价格 · $ / 1M Token</h3></div><div class="price-grid">
<label>输入<input id="price-input" inputmode="decimal"></label><label>缓存读取<input id="price-cache-read" inputmode="decimal"></label><label>缓存写入<input id="price-cache-write" inputmode="decimal"></label><label>输出<input id="price-output" inputmode="decimal"></label>
</div></div>
@@ -288,23 +299,31 @@
<div id="editor-footer" class="drawer-footer"><button id="archive-key" class="danger hidden" type="button">永久归档</button><div><button id="cancel-editor" type="button">取消</button><button id="save-key" class="primary" type="button">保存</button></div></div>
</aside>
<script>
const API = "/v0/management/plugins/cpa-ext/usage";
const SUMMARY_API = "/v0/management/plugins/cpa-ext/usage-summary";
const PRICE_API = "/v0/management/plugins/cpa-ext/prices";
const KEYS_API = "/v0/management/plugins/cpa-ext/keys";
const KEY_STATS_API = "/v0/management/plugins/cpa-ext/key-stats";
const UPSTREAMS_API = "/v0/management/plugins/cpa-ext/upstreams";
const MODELS_API = "/v0/management/plugins/cpa-ext/model-suggestions";
const BILLING_RESET_API = "/v0/management/plugins/cpa-ext/billing-reset";
const BILLING_LEDGER_API = "/v0/management/plugins/cpa-ext/billing-ledger";
const MANAGEMENT_API = "/v0/management/plugins/billing";
const API = MANAGEMENT_API + "/usage";
const SUMMARY_API = MANAGEMENT_API + "/usage-summary";
const PRICE_API = MANAGEMENT_API + "/prices";
const PRICE_IMPORT_API = MANAGEMENT_API + "/prices/import";
const CATALOG_API = MANAGEMENT_API + "/price-catalog";
const CATALOG_REFRESH_API = MANAGEMENT_API + "/price-catalog/refresh";
const CATALOG_APPLY_API = MANAGEMENT_API + "/price-catalog/apply";
const KEYS_API = MANAGEMENT_API + "/keys";
const KEY_STATS_API = MANAGEMENT_API + "/key-stats";
const UPSTREAMS_API = MANAGEMENT_API + "/upstreams";
const MODELS_API = MANAGEMENT_API + "/model-suggestions";
const BILLING_RESET_API = MANAGEMENT_API + "/billing-reset";
const BILLING_LEDGER_API = MANAGEMENT_API + "/billing-ledger";
const keyInput = document.querySelector("#key");
const statusNode = document.querySelector("#status");
const rowsNode = document.querySelector("#rows");
const headersNode = document.querySelector("#headers");
const columnOptionsNode = document.querySelector("#column-options");
const columnStoreKey = "cpa-ext:usage-columns";
const columnStoreKey = "billing:usage-columns";
const priceStatusNode = document.querySelector("#price-status");
const priceListNode = document.querySelector("#price-list");
const priceSourceNode = document.querySelector("#price-source");
const catalogSummaryNode = document.querySelector("#catalog-summary");
const catalogResultsNode = document.querySelector("#catalog-results");
const keyRowsNode = document.querySelector("#key-rows");
const keyStatusNode = document.querySelector("#key-status");
const keyStatsNode = document.querySelector("#key-stats");
@@ -317,7 +336,7 @@
const statsRecentNode = document.querySelector("#stats-recent");
const pageButtonsNode = document.querySelector("#page-buttons");
const PAGE_SIZE = 100;
const DEMO_STORE_KEY = "cpa-ext:demo-state:v3";
const DEMO_STORE_KEY = "billing:demo-state:v4";
let currentRecords = [];
let currentPage = 1;
let currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" };
@@ -325,6 +344,7 @@
let lastSignature = "";
let loading = false;
let currentPrices = [];
let currentCatalog = null;
let managedKeys = [];
let upstreamAccounts = [];
let modelSuggestions = [];
@@ -337,10 +357,19 @@
model: "deepseek-v4-flash",
base: { input_per_1m: "2.5", cache_read_per_1m: "0.25", cache_write_per_1m: "3.125", output_per_1m: "15" },
long_context: { threshold_input_tokens: 272000, comparison: "gt", input_per_1m: "5", cache_read_per_1m: "0.5", cache_write_per_1m: "6.25", output_per_1m: "22.5" },
fast_pricing_enabled: true, fast_multiplier: "2.5"
fast_pricing_enabled: true, fast_multiplier: "2.5",
source: { kind: "manual" }
};
}
function demoCatalog() {
return [
{ id: "openai/gpt-5.6-sol", provider: "openai", provider_name: "OpenAI", model: "gpt-5.6-sol", model_name: "GPT-5.6 Sol", base: { input_per_1m: "5", cache_read_per_1m: "0.5", cache_write_per_1m: "6.25", output_per_1m: "30" }, long_context: { threshold_input_tokens: 272000, comparison: "gt", input_per_1m: "10", cache_read_per_1m: "1", cache_write_per_1m: "12.5", output_per_1m: "45" } },
{ id: "deepseek/deepseek-v4-flash", provider: "deepseek", provider_name: "DeepSeek", model: "deepseek-v4-flash", model_name: "DeepSeek V4 Flash", base: { input_per_1m: "0.14", cache_read_per_1m: "0.014", cache_write_per_1m: "0.14", output_per_1m: "0.28" } },
{ id: "google/gemini-3.7-flash", provider: "google", provider_name: "Google", model: "gemini-3.7-flash", model_name: "Gemini 3.7 Flash", base: { input_per_1m: "0.38", cache_read_per_1m: "0.038", cache_write_per_1m: "0.38", output_per_1m: "1.88" } }
];
}
function freshDemoState() {
const upstreams = ["A", "B", "C"].map((name, index) => ({
id: "demo-upstream-" + name.toLowerCase(), cpa_auth_id: "codex:demo:" + name.toLowerCase(),
@@ -973,7 +1002,7 @@
function setView(view) {
document.querySelectorAll(".nav-button").forEach(button => button.classList.toggle("active", button.dataset.view === view));
document.querySelectorAll(".view").forEach(panel => panel.classList.toggle("active", panel.id === "view-" + view));
try { sessionStorage.setItem("cpa-ext:active-view", view); } catch (_) {}
try { sessionStorage.setItem("billing:active-view", view); } catch (_) {}
if (view === "usage") { loadUsageFilterOptions(); load(true, 1); }
if (view === "pricing") loadPrices();
if (view === "keys") loadKeyManagement();
@@ -987,10 +1016,10 @@
keyInput.disabled = testMode;
keyInput.placeholder = testMode ? "测试模式无需密钥" : "输入管理密钥";
lastSignature = ""; currentPage = 1; currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" }; closeDrawer();
currentRecords = []; managedKeys = []; upstreamAccounts = []; currentPrices = [];
currentRecords = []; managedKeys = []; upstreamAccounts = []; currentPrices = []; currentCatalog = null;
usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
render([]); renderManagedKeys(); renderPrices();
try { sessionStorage.setItem("cpa-ext:data-mode", mode); } catch (_) {}
render([]); renderManagedKeys(); renderPrices(); renderCatalogResults([]); catalogSummaryNode.textContent = "";
try { sessionStorage.setItem("billing:data-mode", mode); } catch (_) {}
const active = document.querySelector(".nav-button.active")?.dataset.view || "keys";
if (active === "keys") loadKeyManagement();
if (active === "usage") { loadUsageFilterOptions(); load(true, 1); }
@@ -1003,9 +1032,20 @@
document.querySelector("#long-comparison").value = "gt";
document.querySelector("#fast-pricing-enabled").checked = false;
document.querySelector("#fast-multiplier").value = "2.5";
showPriceSource({ kind: "manual" });
setLongFields(false);
}
function priceSourceText(source) {
if (source?.kind === "models.dev") return `models.dev · ${source.provider || "-"} / ${source.model || source.catalog_id || "-"}`;
return "手动配置";
}
function showPriceSource(source) {
priceSourceNode.textContent = priceSourceText(source);
priceSourceNode.classList.toggle("catalog-linked", source?.kind === "models.dev");
}
function fillPriceForm(price) {
document.querySelector("#price-model").value = price.model;
document.querySelector("#price-input").value = price.base.input_per_1m;
@@ -1022,6 +1062,7 @@
document.querySelector("#long-output").value = long?.output_per_1m ?? "";
document.querySelector("#fast-pricing-enabled").checked = price.fast_pricing_enabled;
document.querySelector("#fast-multiplier").value = price.fast_multiplier || "2.5";
showPriceSource(price.source || { kind: "manual" });
setLongFields(Boolean(long));
}
@@ -1055,7 +1096,7 @@
model.className = "price-name";
model.textContent = price.model;
const detail = document.createElement("small");
detail.textContent = `输入 ${price.base.input_per_1m} · 输出 ${price.base.output_per_1m}${price.long_context ? " · 长上下文" : ""}${price.fast_pricing_enabled ? " · Fast ×" + price.fast_multiplier : ""}`;
detail.textContent = `输入 ${price.base.input_per_1m} · 输出 ${price.base.output_per_1m}${price.long_context ? " · 长上下文" : ""}${price.fast_pricing_enabled ? " · Fast ×" + price.fast_multiplier : ""} · ${priceSourceText(price.source)}`;
text.append(model, detail);
const actions = document.createElement("div"); actions.className = "key-actions";
const edit = document.createElement("button");
@@ -1069,7 +1110,7 @@
async function loadPrices() {
if (testMode) {
currentPrices = demoState.prices.map(price => structuredClone(price)); renderPrices();
priceStatusNode.textContent = `测试数据 · 已配置 ${currentPrices.length} 个模型`; return;
priceStatusNode.textContent = `测试数据 · 已配置 ${currentPrices.length} 个模型`; await loadCatalogStatus(); return;
}
if (!keyInput.value.trim()) return;
try {
@@ -1079,6 +1120,7 @@
currentPrices = payload.prices || [];
renderPrices();
priceStatusNode.textContent = currentPrices.length ? `已配置 ${currentPrices.length} 个模型` : "尚未配置模型价格";
await loadCatalogStatus();
} catch (error) { priceStatusNode.textContent = "读取价格失败: " + error.message; }
}
@@ -1087,17 +1129,106 @@
if (testMode) {
const payload = pricePayload(); const index = demoState.prices.findIndex(price => price.model === payload.model);
if (!payload.model) throw new Error("请输入模型");
payload.source = { kind: "manual" };
if (index >= 0) demoState.prices[index] = payload; else demoState.prices.push(payload);
saveDemoState(); priceStatusNode.textContent = "测试价格已保存"; await loadPrices(); return;
saveDemoState(); priceStatusNode.textContent = "测试价格已保存"; await loadPrices(); showPriceSource({ kind: "manual" }); return;
}
const response = await fetch(PRICE_API, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(pricePayload()) });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
priceStatusNode.textContent = "价格已保存";
await loadPrices();
showPriceSource({ kind: "manual" });
} catch (error) { priceStatusNode.textContent = "保存失败: " + error.message; }
}
function renderCatalogResults(models) {
if (!models.length) {
catalogResultsNode.replaceChildren();
return;
}
catalogResultsNode.replaceChildren(...models.map(entry => {
const row = document.createElement("div"); row.className = "catalog-result";
const text = document.createElement("div");
const title = document.createElement("strong"); title.textContent = `${entry.provider_name || entry.provider} · ${entry.model_name || entry.model}`;
const detail = document.createElement("small"); detail.textContent = `${entry.id} · 输入 ${entry.base.input_per_1m} / 输出 ${entry.base.output_per_1m}${entry.long_context ? " · 长上下文" : ""}`;
const use = document.createElement("button"); use.type = "button"; use.className = "small"; use.textContent = "使用"; use.addEventListener("click", () => importCatalogPrice(entry));
text.append(title, detail); row.append(text, use); return row;
}));
}
function catalogSummary(payload) {
currentCatalog = payload?.catalog || null;
if (testMode) return "测试目录 · 不访问 models.dev";
if (!payload?.loaded) return payload?.last_error ? `尚无可用目录 · ${payload.last_error}` : "尚未下载目录";
const updated = currentCatalog?.fetched_at ? new Date(currentCatalog.fetched_at).toLocaleString() : "-";
return `${currentCatalog?.models || 0} 条参考价格 · ${updated}`;
}
async function searchCatalog() {
const query = document.querySelector("#catalog-query").value.trim().toLowerCase();
try {
if (testMode) {
const models = query ? demoCatalog().filter(entry => [entry.id, entry.provider, entry.provider_name, entry.model, entry.model_name].some(value => value.toLowerCase().includes(query))) : [];
currentCatalog = { revision: "demo-v1", models: demoCatalog().length, fetched_at: new Date().toISOString() };
catalogSummaryNode.textContent = "测试目录 · 不访问 models.dev";
renderCatalogResults(models); return;
}
if (!keyInput.value.trim()) { catalogSummaryNode.textContent = "输入管理密钥后读取目录"; return; }
const payload = await managedFetch(CATALOG_API + "?q=" + encodeURIComponent(query));
catalogSummaryNode.textContent = catalogSummary(payload);
renderCatalogResults(payload.models || []);
} catch (error) { catalogSummaryNode.textContent = "读取参考价格失败:" + error.message; renderCatalogResults([]); }
}
async function loadCatalogStatus() {
await searchCatalog();
}
async function importCatalogPrice(entry) {
const localModel = document.querySelector("#price-model").value.trim() || entry.model;
if (!localModel) { priceStatusNode.textContent = "请先输入本地模型名称"; return; }
try {
let imported;
if (testMode) {
const existing = demoState.prices.find(price => price.model.toLowerCase() === localModel.toLowerCase());
imported = {
model: localModel, base: structuredClone(entry.base), long_context: entry.long_context ? structuredClone(entry.long_context) : undefined,
fast_pricing_enabled: existing?.fast_pricing_enabled || false, fast_multiplier: existing?.fast_multiplier || "2.5",
source: { kind: "models.dev", catalog_id: entry.id, provider: entry.provider, model: entry.model, revision: "demo-v1", fetched_at: new Date().toISOString() }
};
const index = demoState.prices.findIndex(price => price.model.toLowerCase() === localModel.toLowerCase());
if (index >= 0) demoState.prices[index] = imported; else demoState.prices.push(imported);
saveDemoState();
} else {
imported = await managedFetch(PRICE_IMPORT_API, { method: "POST", body: JSON.stringify({ model: localModel, catalog_id: entry.id }) });
}
await loadPrices(); fillPriceForm(imported); priceStatusNode.textContent = `${localModel} 已采用 ${entry.provider_name || entry.provider} / ${entry.model} 参考价`;
} catch (error) { priceStatusNode.textContent = "导入参考价格失败:" + error.message; }
}
async function refreshCatalog() {
try {
if (testMode) { catalogSummaryNode.textContent = "测试目录已刷新 · 未访问 models.dev"; return; }
if (!keyInput.value.trim()) throw new Error("请输入管理密钥");
catalogSummaryNode.textContent = "正在更新 models.dev 目录";
const preview = await managedFetch(CATALOG_REFRESH_API, { method: "POST" });
currentCatalog = preview.catalog;
const changed = (preview.changes || []).filter(change => change.status === "changed");
const missing = (preview.changes || []).filter(change => change.status === "missing");
if (!changed.length) {
catalogSummaryNode.textContent = `目录已更新 · 没有价格变化${missing.length ? ` · ${missing.length} 个来源已下架` : ""}`;
await searchCatalog(); return;
}
const lines = changed.slice(0, 8).map(change => `${change.model}: 输入 ${change.before.base.input_per_1m} → ${change.after.base.input_per_1m},输出 ${change.before.base.output_per_1m} → ${change.after.base.output_per_1m}`);
const confirmed = confirm(`发现 ${changed.length} 个已关联价格发生变化:\n\n${lines.join("\n")}${changed.length > lines.length ? "\n…" : ""}\n\n确认更新这些本地价格?`);
if (!confirmed) { catalogSummaryNode.textContent = `目录已更新 · ${changed.length} 个变化尚未应用`; return; }
const applied = await managedFetch(CATALOG_APPLY_API, { method: "POST", body: JSON.stringify({ revision: preview.catalog.revision, models: changed.map(change => change.model) }) });
catalogSummaryNode.textContent = `已确认更新 ${applied.updated} 个本地价格`;
await loadPrices();
} catch (error) { catalogSummaryNode.textContent = "更新目录失败:" + error.message; }
}
async function deletePrice(model) {
try {
if (testMode) {
@@ -1149,8 +1280,8 @@
}
}
keyInput.value = storedPanelKey() || sessionStorage.getItem("cpa-ext:management-key") || "";
keyInput.addEventListener("change", () => sessionStorage.setItem("cpa-ext:management-key", keyInput.value.trim()));
keyInput.value = storedPanelKey() || sessionStorage.getItem("billing:management-key") || "";
keyInput.addEventListener("change", () => sessionStorage.setItem("billing:management-key", keyInput.value.trim()));
document.querySelector("#refresh").addEventListener("click", () => load(true, currentPage));
document.querySelector("#apply-usage-filters").addEventListener("click", () => { currentPage = 1; load(true, 1); });
document.querySelector("#clear-usage-filters").addEventListener("click", () => { ["usage-from", "usage-to", "usage-model", "usage-request-id"].forEach(id => document.querySelector("#" + id).value = ""); ["usage-key", "usage-result", "usage-auth", "usage-endpoint"].forEach(id => document.querySelector("#" + id).value = ""); currentPage = 1; load(true, 1); });
@@ -1159,6 +1290,9 @@
document.querySelector("#save-price").addEventListener("click", savePrice);
document.querySelector("#clear-price").addEventListener("click", clearPriceForm);
document.querySelector("#new-price").addEventListener("click", clearPriceForm);
document.querySelector("#search-catalog").addEventListener("click", searchCatalog);
document.querySelector("#catalog-query").addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); searchCatalog(); } });
document.querySelector("#refresh-catalog").addEventListener("click", refreshCatalog);
document.querySelector("#create-key").addEventListener("click", () => openKeyEditor());
document.querySelector("#reload-keys").addEventListener("click", loadKeyManagement);
document.querySelector("#include-archived").addEventListener("change", loadKeyManagement);
@@ -1177,8 +1311,8 @@
document.addEventListener("keydown", event => { if (event.key === "Escape") closeDrawer(); });
document.addEventListener("visibilitychange", () => { if (!testMode && currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); });
renderColumnControls();
setMode(sessionStorage.getItem("cpa-ext:data-mode") || "live");
setView(sessionStorage.getItem("cpa-ext:active-view") || "keys");
setMode(sessionStorage.getItem("billing:data-mode") || "live");
setView(sessionStorage.getItem("billing:active-view") || "keys");
setInterval(() => { if (!testMode && currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); }, 3000);
</script>
</body>
+2 -2
View File
@@ -14,6 +14,6 @@ if [[ -n "$unformatted" ]]; then
fi
go list -mod=readonly ./... >/dev/null
CGO_ENABLED=1 go test ./...
CGO_ENABLED=1 go build -tags cshared -buildmode=c-shared -o bin/cpa-ext.so ./cmd/cpa-ext
CGO_ENABLED=1 go build -tags cshared -buildmode=c-shared -o bin/billing.so ./cmd/billing
echo "Built: $root/bin/cpa-ext.so"
echo "Built: $root/bin/billing.so"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:-0.1.0}"
target="linux_amd64"
name="billing_${version}_${target}"
artifact="$root/bin/billing.so"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid version: $version" >&2
exit 1
fi
if [[ ! -f "$artifact" ]]; then
echo "Missing $artifact; run scripts/build.sh first." >&2
exit 1
fi
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
package_dir="$stage/$name"
mkdir -p "$package_dir" "$root/dist"
install -m 0755 "$artifact" "$package_dir/billing.so"
install -m 0644 "$root/README.md" "$package_dir/README.md"
install -m 0644 "$root/config.example.yaml" "$package_dir/config.example.yaml"
printf '%s\n' \
"name=billing" \
"version=$version" \
"target=linux/amd64" \
"native_abi=1" \
"rpc_schema=3" \
"cliproxyapi_revision=f43aad7637ad813745bf7d341acb5663617570c5" \
>"$package_dir/VERSION.txt"
archive="$root/dist/$name.tar.gz"
tar -czf "$archive" -C "$stage" "$name"
sha256sum "$archive" >"$archive.sha256"
echo "Packaged: $archive"
echo "Checksum: $archive.sha256"