Upgrade CPA to v8.0.20 and retire host patches

This commit is contained in:
2026-10-08 18:17:49 +08:00 Unverified
parent 31fe556b32
commit 214e45c6b9
21 changed files with 183 additions and 1144 deletions
+5 -5
View File
@@ -14,7 +14,7 @@
## 上游依赖与参考仓库边界
`.externals/CLIProxyAPI/` 是锁定官方基线的 Git submodule,用于宿主构建、契约核对和集成测试。宿主修复仍以根仓库 `patch/` 中的补丁维护,不直接提交到官方 submodule。
`.externals/CLIProxyAPI/` 是锁定官方基线的 Git submodule,用于宿主构建、契约核对和集成测试。Use clean upstream source without local patches or derived commits.
`.externals/cpa-plugin-key-billing/`、`.externals/cpa-usage-keeper/` 不是本项目的实际开发仓库,只用于测试、契约核对和实现对比。
@@ -22,7 +22,7 @@
- 不为这两个参考插件仓库修复问题,不修改或提交其中的代码,也不处理其 Git 状态。
- 上述限制不影响运行时测试:可直接加载已经构建好的参考插件;测试 CPA 插件组合时,按需启用 `cpa-key-billing` 作为对照插件,但不得因此进入其仓库做源码或 Git 操作。
- 只有在 billing 的兼容性验证、测试或实现判断确有必要时,才按最小范围只读查看相关文件。
- CPA submodule 只允许按 `patch/README.md` 应用、验证或重新生成本项目维护的宿主补丁;除此以外的 CPA 修改,以及两个参考插件的修改、构建、提交或维护,必须由用户明确提出。
- Keep the CPA submodule clean and pinned to an official upstream commit. Adapt billing to upstream contracts; do not patch CPA. The reference plugins remain read-only unless explicitly requested.
- 项目的实现、提交和工作区清洁度只以 billing 根仓库为准。
## DO
@@ -40,8 +40,8 @@
- 本地 CPA 在 WSL 中运行,项目运行目录统一使用 `cpa-plugin/.runtime`;Windows 客户端经 `127.0.0.1` 访问 WSL 时,localhost 转发层可能不会立即向 CPA 传递客户端断开,取消链路测试优先在 WSL 内直连执行。
- 从 PowerShell 跨 WSL 或 SSH 执行包含 Bash 变量、命令替换或多层引号的逻辑时,优先在 `.runtime/tmp/` 创建临时脚本后传入目标环境执行,不拼接复杂的单行 shell;临时脚本不提交。
- CLIProxyAPI 宿主补丁保存在 `patch/`,当前基线为 `main`(`v8.0.4` 之后)/ `a270e7b9`,以 `patch/series` 为唯一顺序清单,使用 `scripts/host-patches.sh` 检查、应用和反向还原;原 `usage-context` 修复已由上游吸收。升级 CPA 后按 `docs/operations.md` 核对补丁和已知上游测试例外。
- 构建本项目 patched CPA 时必须按上游 release workflow 使用 `-ldflags` 注入 `main.Version`、`main.Commit` 和 `main.BuildDate`。`Version` 固定使用“当前上游最新 tag + `-dev`”格式(例如 `v8.0.4-dev`),用于明确区分官方 Release 与本项目补丁构建;不得发布只显示 `dev`、`none` 或 `unknown` 的产物。上传前先在本地执行二进制并核对完整版本输出。
- CLIProxyAPI baseline: v8.0.20 / 0f96f568. No host patch series. Follow docs/operations.md for upstream upgrades and validation.
- Build unmodified CPA with upstream release ldflags for main.Version, main.Commit and main.BuildDate. Local source builds retain the tag + -dev version convention; verify the complete version line before release.
- CPA Linux 可执行文件解压后约 60 MiB,`gzip -9` 后约 20 MiB。上传远端时使用 gzip/tar.gz 或 `scp -C`,不要直接传输未压缩二进制;部署后的运行文件仍需解压为原始可执行文件。
- 实际服务器为 `root@akko.pchuan.top`,CPA 根目录为 `/root/cpa`。只维护本项目的 `billing` 和已要求启用的 `keeper`,不启用或部署其他参考插件。
- 远端替换 CPA 或 billing 前,先停止 `/root/cpa/cpa.sh` 管理的进程,并把旧二进制、插件和 `data/cpa-ext.db*` 备份到 `/root/cpa/backups/<timestamp>/`;新版本启动失败时恢复旧二进制和插件。
@@ -55,4 +55,4 @@ The current release target is `root@xx.pchuan.top:/root/cpa`, Docker Compose ser
## Service tier compatibility
Use `patch/series` as the canonical patch order. Each patch includes its regression tests. Codex service-tier translation follows upstream v8.0.4: priority and ultrafast are supported, fast is normalized to priority, and other tiers are removed. Do not restore the retired service-tier passthrough patch. Billing pricing rules are unchanged: compact usage uses configured model rates; recorded explicit tiers other than auto and default retain the existing Fast pricing behavior.
Use upstream service-tier translation. Billing pricing remains unchanged. Native ABI 1 / RPC schema 6; usage IDs are executor identities, not lifecycle identities.
+4 -4
View File
@@ -66,10 +66,10 @@ CLIProxyAPI 负责 HTTP 接入、协议转换、上游凭证和实际请求执
## 当前兼容目标
- CLIProxyAPI baseline: `main` after `v8.0.4` / `a270e7b9e57aaecd8f82555f44c2108518ad2330`, pinned by `.externals/CLIProxyAPI`; apply the three host patches in `patch/series` order.
- CLIProxyAPI baseline: `v8.0.20` / `0f96f568e4dbf6f84ad7399a74b78344c5eac7e6`, pinned by `.externals/CLIProxyAPI`; use the clean upstream checkout without local host patches.
- 插件版本:`0.1.0`
- Native ABI:`1`
- RPC schema:最高 `7`,注册时按宿主版本向下协商;schema v7 提供 Usage 请求身份,schema v4 保留上游 WebSocket 响应观察契约
- RPC schema:最高 `6`,注册时按宿主版本向下协商;use the upstream contract without a local schema extension
- 插件 ID / 动态库文件名:`billing`
- 已声明能力:`frontend_auth_provider`(独占)、`scheduler`、`request_interceptor`、`request_lifecycle_plugin`、`usage_plugin`、`management_api`
@@ -153,7 +153,7 @@ CGO_ENABLED=1 go test ./...
请求明细由服务端分页,每页 100 条,默认按请求时间和稳定 ID 倒序展示。管理接口支持时间范围、用户 Key、模型、结果、上游 Auth ID、端点和完整 Request ID 筛选;连续翻页使用与筛选条件绑定的游标,数字页码跳转使用 SQLite 索引定位。用户页的今日汇总、各用户用量和近 7 日 Token 由数据库独立聚合,不受当前明细页影响。
升级已有数据库时,插件会从原始 Usage 和请求终态事实自动建立轻量查询投影。事实表、计费账目和历史统计保持不变;没有 Usage 的取消或失败请求仍然可见。patched CPA 使用 RPC schema v7 向 billing 传递 Usage Request ID 和 Trace ID,可以按 Request ID 精确关联请求终态。旧宿主或旧 schema 没有请求身份时,只有模型和时间足够接近且匹配关系唯一才关联;存在并发歧义时保留为独立记录。
升级已有数据库时,插件会从原始 Usage 和请求终态事实自动建立轻量查询投影。事实表、计费账目和历史统计保持不变;没有 Usage 的取消或失败请求仍然可见。Upstream Usage Request IDs identify executor observations and are retained as Execution IDs for deduplication, including additional-model usage. They are not treated as lifecycle Request IDs. Request details use conservative user/model/time association; ambiguous concurrent observations remain separate. Historical facts and charges are unchanged.
## 工程布局
@@ -161,4 +161,4 @@ CGO_ENABLED=1 go test ./...
- `internal/plugin`:RPC dispatcher、契约 DTO、原子配置与 Usage 接入。
- `internal/modelcatalog`:models.dev 下载、规范化、搜索和最后可用缓存。
- `scripts`:环境检查与可复现构建。
- `.externals/CLIProxyAPI`:锁定官方基线的宿主 submodule,用于构建、契约核对和集成测试;本项目的宿主改动由 `patch/` 维护。
- `.externals/CLIProxyAPI`:锁定官方基线的宿主 submodule,用于构建、契约核对和集成测试;Use unmodified upstream source; no host patch series is maintained.
+1 -1
View File
@@ -6,7 +6,7 @@
git submodule update --init --recursive
```
需要构建本项目使用的 CPA 时,再按 [`../patch/README.md`](../patch/README.md) 使用 `bash scripts/host-patches.sh apply` 按 `patch/series` 应用三项宿主补丁。submodule 本身始终锁定官方基线,补丁来源以根仓库 `patch/` 为准。
Build CPA directly from the clean, pinned upstream submodule. Do not apply local host patches. Billing targets upstream Native ABI 1 / RPC schema 6.
升级 submodule、构建宿主、本地替换、验收和远端发布使用 [`operations.md`](operations.md)。该文档同时记录已确认的上游测试失败及免复测条件。
+30 -28
View File
@@ -4,11 +4,11 @@
| 项目 | 当前值 |
|---|---|
| CLIProxyAPI Tag | `main` after `v8.0.4` |
| submodule 提交 | `a270e7b9e57aaecd8f82555f44c2108518ad2330` |
| patched 构建版本 | `v8.0.4-dev` |
| CLIProxyAPI Tag | `v8.0.20` |
| submodule 提交 | `0f96f568e4dbf6f84ad7399a74b78344c5eac7e6` |
| Local upstream build version | `v8.0.20-dev` |
| Native ABI | `1` |
| RPC schema | `7` |
| RPC schema | `6` |
| Go | `1.26.x` |
| 本地运行目录 | `.runtime` |
| 本地管理页面 | `http://127.0.0.1:8317/management.html` |
@@ -37,26 +37,15 @@ CLIProxyAPI `v7.2.145` / `d9cea890` 在 Debian WSL 中运行 `go test ./... -cou
## submodule 升级
当前 submodule 工作区包含 `patch/series` 中的三项补丁。先确认工作区准确对应补丁,再反向还原;`status --porcelain` 必须为空后才更新上游:
Use an unmodified upstream checkout. Preserve local changes before switching revisions; never discard unrelated work.
```bash
bash scripts/host-patches.sh verify
bash scripts/host-patches.sh reverse
test -z "$(git -C .externals/CLIProxyAPI status --porcelain)"
git -C .externals/CLIProxyAPI fetch --tags --prune origin
git -C .externals/CLIProxyAPI switch --detach a270e7b9e57aaecd8f82555f44c2108518ad2330
git -C .externals/CLIProxyAPI switch --detach 0f96f568e4dbf6f84ad7399a74b78344c5eac7e6
```
逐项核对新上游是否已经吸收原需求;更新冲突补丁及其回归测试后再应用完整系列:
```bash
bash scripts/host-patches.sh check
bash scripts/host-patches.sh apply
bash scripts/host-patches.sh verify
git -C .externals/CLIProxyAPI diff --check
```
应用后 submodule 显示 `dirty` 是预期结果。根仓库提交新的 gitlink 指针和必要的 `patch/` 更新,不在 submodule 中创建派生提交。
Commit the official gitlink revision in billing. Adapt billing wire contracts and tests to upstream instead of maintaining a patch series.
## 测试顺序
@@ -83,9 +72,9 @@ go test ./... -count=1
go test -race ./... -count=1
```
补丁正确性的最低标准如下:
Upstream compatibility validation requires:
- `patch/series` 中全部补丁按顺序应用成功,`git diff --check` 通过;
- CPA source is clean and pinned to the reviewed official commit;
- `internal/pluginhost`、`sdk/api/handlers`、`sdk/cliproxy/usage` race 测试通过;
- billing 全量测试和全量 race 测试通过;
- CPA 全量测试除已记录的纯上游失败外通过;
@@ -97,7 +86,7 @@ CPA 版本必须使用当前最新 Tag 加 `-dev`,提交取 submodule HEAD:
```bash
cd .externals/CLIProxyAPI
version=v8.0.4-dev
version=v8.0.20-dev
commit="$(git rev-parse --short=8 HEAD)"
build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
CGO_ENABLED=1 go build -buildvcs=false \
@@ -141,7 +130,7 @@ bash scripts/run-test-host.sh
3. 日志版本与构建参数一致,且没有插件错误或 panic;
4. 无下游 Key 的请求返回 401;
5. 有效 billing Key 的 `/v1/responses` 请求返回 200;
6. 请求只新增一条明细,包含唯一 Request ID 和 Token;
6. Serial requests associate lifecycle and usage when unambiguous; ambiguous concurrent observations may remain separate. Verify raw usage count and billing ledger independently;
7. 额度只扣减一次,`active_requests` 回到 0。
本地 CPA 通过 WSL localhost 转发供 Windows 使用。取消链路测试需要在 WSL 内直连 `127.0.0.1:8317`,避免 Windows localhost 转发延迟传递断开。
@@ -157,8 +146,8 @@ bash scripts/run-test-host.sh
脚本默认执行以下操作:
- fetch `origin/main` 并要求 submodule HEAD 与其一致;
- 通过 `scripts/host-patches.sh verify` 要求 submodule 内容与 HEAD 加 `patch/series` 完全一致(包含补丁新增文件);
- 运行补丁相关三个包的 race 测试;
- Require a clean upstream submodule, including no unexpected untracked files;
- Run the three integration-related upstream packages with the race detector;
- 使用最新 Tag 加 `-dev` 和当前提交、构建时间编译核心;
- 生成 gzip 最高压缩级别的核心包和 SHA-256;
- 上传后在远端重新校验压缩包、版本和核心哈希;
@@ -172,7 +161,7 @@ bash scripts/run-test-host.sh
./scripts/push.ps1 -WhatIf
```
复用已有核心时使用 `-SkipBuild -BinaryPath <path>`;跳过补丁相关 race 测试使用 `-SkipTests`;部署锁定但不是当前 `origin/main` 的提交时显式使用 `-SkipUpstreamCheck`。请求排空默认等待 120 秒,可通过 `-DrainTimeoutSeconds` 和 `-DrainPollSeconds` 调整。
复用已有核心时使用 `-SkipBuild -BinaryPath <path>`;跳过上游相关 race 测试使用 `-SkipTests`;部署锁定但不是当前 `origin/main` 的提交时显式使用 `-SkipUpstreamCheck`。请求排空默认等待 120 秒,可通过 `-DrainTimeoutSeconds` 和 `-DrainPollSeconds` 调整。
远端只部署本项目的 billing 和已要求启用的 keeper。发布步骤如下:
@@ -219,13 +208,15 @@ bash scripts/run-test-host.sh
The active deployment target for this release is `root@xx.pchuan.top:/root/cpa`, using Docker Compose service `cpa`. The older `akko.pchuan.top` instructions above describe a different deployment and must not be used for this target.
The container mounts `bin/` read-only at `/app/bin` and `data/` at `/app/data`. Replace `bin/cpa` and `bin/billing.so` together for the schema 7 upgrade. Drain active requests, stop the Compose service, and back up both binaries and `data/` (excluding logs) before replacement. Preserve the existing Compose configuration, credentials, Xray binary, and runtime image. Verify artifact hashes, health, plugin registration, and embedded UI assets after restart; restore the backup if startup fails.
The container mounts `bin/` read-only at `/app/bin` and `data/` at `/app/data`. Replace `bin/cpa` and `bin/billing.so` together for the upstream-only / schema 6 migration. Drain active requests, stop the Compose service, and back up both binaries and `data/` (excluding logs) before replacement. Preserve the existing Compose configuration, credentials, Xray binary, and runtime image. Verify artifact hashes, health, plugin registration, and embedded UI assets after restart; restore the backup if startup fails.
Local validation passed with `v7.2.156-dev / d1a024e9`: CPA full tests, plugin full tests and race tests, and host plugin/handler/usage race tests. An isolated local DeepSeek Responses request verified authentication, one usage record, one charge, released concurrency, and compiled UI resources. The history/notes patch remains experimental; real upstream history/notes behavior has not been verified.
## Service tier compatibility
Use `patch/series` as the canonical patch order. Each patch includes its regression tests. Codex service-tier translation follows upstream v7.3.9: priority and ultrafast are supported, fast is normalized to priority, and other tiers are removed. Do not restore the retired service-tier passthrough patch. Billing pricing rules are unchanged: compact usage uses configured model rates; recorded explicit tiers other than auto and default retain the existing Fast pricing behavior.
Use upstream service-tier behavior without host patches. Billing pricing rules remain unchanged.
> Historical entries below describe retired patch-based builds, not the current workflow.
## 2026-09-15 本地升级记录
@@ -245,7 +236,7 @@ Scope: update the official source baseline and review/rebase the maintained host
- Baseline: `v7.3.9` / `61fdfc341b96178a8dcb53f2efc46cbc341d267c`; `origin/main` matched this tag at fetch time.
- Cleanliness: the original worktree exactly matched the old patch series. Reverse restored a clean v7.3.4 tree before fetch. The initial five-patch v7.3.9 series passed reverse/reapply/verify; it was subsequently reversed to a clean tree before retiring the service-tier patch.
- Four patches remain necessary and are active in `patch/series`. After review, the user chose upstream service-tier behavior; the passthrough patch and its custom translator tests were removed. Details: `../patch/README.md`.
- Four patches remain necessary and are active in `patch/series`. After review, the user chose upstream service-tier behavior; the passthrough patch and its custom translator tests were removed. Details remain available in Git history.
- Go toolchain: `go1.26.6`, Linux/amd64 under WSL, CGO enabled.
- CPA focused race tests passed: `internal/pluginhost`, `sdk/api/handlers`, `sdk/cliproxy/usage`, `sdk/pluginabi`, `internal/api`, `internal/registry`, `internal/client/codex/models`, and `internal/translator/codex/openai/responses`.
- Before retiring the service-tier patch, the CPA full suite passed with `go test ./... -count=1 -timeout=120s`, with no skipped tests. The first full run stalled in `TestAntigravityAsyncProbe_NormalRequestsDoNotDiscardProbe` awaiting `probeStarted`; its test process was interrupted with SIGQUIT after about 221 seconds to capture stacks. The package passed independently and the complete retry passed. This is a recorded intermittent test stall, not a proven patch defect or a permanent test exclusion.
@@ -314,3 +305,14 @@ Validation used Go 1.26.6, Linux/amd64 under WSL, with CGO enabled:
- Verified archive and installed artifact hashes, startup version, healthy container with restart count 0, billing registration and enabled state, unchanged plugin registrations, DNS resolution, and preserved image, mounts, environment, Compose files, and Xray binary/configuration.
- The existing management API-key list was preserved. The public /v1/models endpoint returned HTTP 200 using its configured key. No remote model inference was performed.
- At the user's request, removed the v8.0.3 and v8.0.4 deployment backups, the prior management-model-key configuration backup, and both deployment staging directories after all checks passed. Running binaries and data were preserved. The deployment log is retained locally at `.runtime/tmp/v804-deploy/deploy.log`.
## 2026-10-08 upstream-only migration
- Pinned clean upstream v8.0.20 / 0f96f568. Removed all host patches and the patch-management script.
- Billing registers RPC schema 6. Upstream usage IDs are preserved as executor identities; lifecycle association remains conservative and never changes historical facts.
- Additional-model usage from one execution remains independently billable; identical replayed events are deduplicated.
- Build, packaging and release scripts require clean upstream source. No deployment is part of this migration.
- Validation: complete upstream CPA tests passed; upstream pluginhost, API handlers and usage race tests passed. Billing complete tests and race tests passed, including schema negotiation, native identity association/replay, and execution-index migration across database reopen.
- Both Linux artifacts built with Go 1.26.6. CPA reports v8.0.20-dev / 0f96f568. Plugin packaging passed and records ABI 1 / RPC schema 6 without patch suffixes.
- Isolated local mock acceptance passed for Chat Completions and Responses, both streaming and non-streaming, including six concurrent mixed requests: 10 successes, 10 raw usage facts, 120 Tokens, 10 charges and zero active admissions. A disconnected streaming request also released its admission. The details page showed 18 rows before cancellation; lifecycle/usage observations can remain separate without the identity patch. No real-model acceptance or remote deployment was performed.
- Previous local CPA worktree changes were preserved in its Git stash before switching to the clean upstream commit.
+3 -5
View File
@@ -178,9 +178,10 @@ func (a *App) handleUsage(raw []byte) ([]byte, error) {
return nil, fmt.Errorf("用量数据库尚未初始化")
}
// CPA wire 类型只存在于适配层,采集模块接收与协议无关的观察值。
compact := a.isCompactRequest(record.RequestID)
// Upstream usage IDs identify executor observations, not handler lifecycles.
// Leave lifecycle association to the repository's conservative projection.
observed := collection.Record{
RequestID: record.RequestID,
ExecutionID: record.RequestID,
TraceID: record.TraceID,
RequestedAt: record.RequestedAt,
APIKey: record.APIKey,
@@ -205,9 +206,6 @@ func (a *App) handleUsage(raw []byte) ([]byte, error) {
TTFT: record.TTFT,
Latency: record.Latency,
}
if compact {
observed.Endpoint = compactEndpoint
}
if key, keyErr := a.resolveUsageManagedKey(a.store, record.APIKey); keyErr == nil {
observed.ManagedKeyID = key.ID
observed.KeyAlias = key.Name
+3 -3
View File
@@ -110,7 +110,7 @@ func TestUsageRecordWireFieldsMatchTargetContract(t *testing.T) {
}
}
func TestUsageRecordIdentityReconcilesLifecycleExactly(t *testing.T) {
func TestUpstreamUsageIdentityReconcilesLifecycleConservatively(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)
@@ -126,7 +126,7 @@ func TestUsageRecordIdentityReconcilesLifecycleExactly(t *testing.T) {
t.Fatal(err)
}
usageRaw, _ := json.Marshal(UsageRecord{
RequestID: "request-identity", TraceID: "trace-identity", Provider: "openai", Model: "deepseek-v4-flash",
RequestID: "upstream-execution", TraceID: "trace-identity", Provider: "openai", Model: "deepseek-v4-flash",
RequestedAt: startedAt.Add(5 * time.Millisecond), Detail: UsageDetail{InputTokens: 10, OutputTokens: 2, TotalTokens: 12},
})
if _, err := app.HandleMethod(MethodUsageHandle, usageRaw); err != nil {
@@ -137,7 +137,7 @@ func TestUsageRecordIdentityReconcilesLifecycleExactly(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(records) != 1 || records[0].RequestID != "request-identity" || records[0].TraceID != "trace-identity" || records[0].TotalTokens != 12 {
if len(records) != 1 || records[0].RequestID != "request-identity" || records[0].TraceID != "trace-identity" || records[0].TotalTokens != 12 || records[0].ExecutionID != "upstream-execution" {
t.Fatalf("records = %+v", records)
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
const (
ABIVersion uint32 = 1
SchemaVersion uint32 = 7
SchemaVersion uint32 = 6
PluginName = "billing"
Version = "0.1.0"
)
+67
View File
@@ -0,0 +1,67 @@
package plugin
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
)
func TestUpstreamUsageAssociationAndReplay(t *testing.T) {
for _, completionFirst := range []bool{false, true} {
t.Run(map[bool]string{false: "usage-first", true: "completion-first"}[completionFirst], func(t *testing.T) {
app := NewApp()
raw, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, 7, testConfig(t, "enabled: true\ncodex_only: false\n")))
if err != nil {
t.Fatal(err)
}
defer app.Shutdown()
var envelope Envelope
var registered Registration
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(envelope.Result, &registered); err != nil || registered.SchemaVersion != 6 {
t.Fatalf("registration=%+v, err=%v", registered, err)
}
started := time.Now().UTC()
completion := RequestCompletion{RequestID: "lifecycle-id", TraceID: "trace-id", Model: "upstream-model", StartedAt: started, CompletedAt: started.Add(time.Second), Outcome: RequestCompletionSucceeded, StatusCode: http.StatusOK}
usage := UsageRecord{RequestID: "executor-id", TraceID: "trace-id", Provider: "openai", Model: "upstream-model", RequestedAt: started.Add(time.Millisecond), Detail: UsageDetail{InputTokens: 10, TotalTokens: 10}}
send := func(method string, value any) {
t.Helper()
payload, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if _, err := app.HandleMethod(method, payload); err != nil {
t.Fatal(err)
}
}
if completionFirst {
send(MethodRequestComplete, completion)
}
send(MethodUsageHandle, usage)
if !completionFirst {
send(MethodRequestComplete, completion)
}
send(MethodUsageHandle, usage)
records, err := app.store.ListRecent(context.Background(), 10)
if err != nil || len(records) != 1 || records[0].RequestID != "lifecycle-id" || records[0].ExecutionID != "executor-id" || records[0].TotalTokens != 10 {
t.Fatalf("records=%+v, err=%v", records, err)
}
// Upstream can publish an additional model with the same executor UUID.
usage.Model = "image-model"
send(MethodUsageHandle, usage)
send(MethodUsageHandle, usage)
records, err = app.store.ListRecent(context.Background(), 10)
var tokens int64
for _, record := range records {
tokens += record.TotalTokens
}
if err != nil || tokens != 20 {
t.Fatalf("additional-model usage lost or replayed: records=%+v, err=%v", records, err)
}
})
}
}
+10 -3
View File
@@ -61,7 +61,7 @@ CREATE TABLE IF NOT EXISTS usage_records (
stream INTEGER
);
CREATE INDEX IF NOT EXISTS idx_usage_records_requested_at ON usage_records(requested_at DESC, id DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_records_execution_id ON usage_records(execution_id) WHERE execution_id <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_records_execution_model ON usage_records(execution_id, model) WHERE execution_id <> '';
CREATE INDEX IF NOT EXISTS idx_usage_records_model ON usage_records(model COLLATE NOCASE) WHERE model <> '';
CREATE INDEX IF NOT EXISTS idx_usage_records_missing_cost_model
ON usage_records(model COLLATE NOCASE, id) WHERE cost_micros IS NULL;
@@ -247,7 +247,7 @@ ON business_events(occurred_at DESC, id DESC);
`
const (
// Older CLIProxyAPI schemas have no RequestID in UsageRecord. Their usage and
// Upstream CLIProxyAPI usage IDs differ from lifecycle IDs. Their usage and
// terminal callbacks nevertheless carry request-start timestamps from the
// same host execution, normally only a few milliseconds apart. This window
// is used only for the read projection and never changes persisted billing facts.
@@ -283,6 +283,12 @@ func OpenSQLiteUsage(databasePath string) (*SQLiteUsageRepository, error) {
_ = db.Close()
return nil, fmt.Errorf("初始化 SQLite schema: %w", err)
}
// A native upstream execution may publish usage for multiple models.
// The composite index above replaces the historical execution-only index.
if _, err := db.Exec(`DROP INDEX IF EXISTS idx_usage_records_execution_id`); err != nil {
_ = db.Close()
return nil, fmt.Errorf("迁移上游执行用量索引: %w", err)
}
for _, migration := range []string{
`ALTER TABLE usage_records ADD COLUMN managed_key_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE request_records ADD COLUMN managed_key_id TEXT NOT NULL DEFAULT ''`,
@@ -461,7 +467,8 @@ INSERT OR IGNORE INTO usage_records (
func usageBillingEventKey(record collection.Record) string {
if executionID := strings.TrimSpace(record.ExecutionID); executionID != "" {
return "execution:" + record.ManagedKeyID + ":" + executionID
// One upstream execution can also publish usage for an additional model.
return "execution:" + record.ManagedKeyID + ":" + executionID + ":" + record.Model
}
hash := sha256.New()
_, _ = fmt.Fprintf(hash, "%s\x00%s\x00%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%d\x00%d\x00%d\x00%d\x00%d\x00%d\x00%t",
@@ -0,0 +1,50 @@
package repository
import (
"context"
"path/filepath"
"testing"
"time"
"billing/internal/collection"
)
func TestUpstreamExecutionIndexMigrationPreservesHistoryAndSideModel(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "usage.db")
store, err := OpenSQLiteUsage(path)
if err != nil {
t.Fatal(err)
}
old := collection.Record{ExecutionID: "upstream-id", Model: "main-model", RequestedAt: time.Now().UTC(), InputTokens: 10, TotalTokens: 10}
if err := store.Insert(ctx, old); err != nil {
t.Fatal(err)
}
// Simulate the previous database's execution-only uniqueness constraint.
if _, err := store.db.Exec(`DROP INDEX idx_usage_records_execution_model; CREATE UNIQUE INDEX idx_usage_records_execution_id ON usage_records(execution_id) WHERE execution_id <> ''`); err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
for range 2 {
store, err = OpenSQLiteUsage(path)
if err != nil {
t.Fatal(err)
}
additional := old
additional.Model = "image-model"
for _, record := range []collection.Record{old, additional, additional} {
if err := store.Insert(ctx, record); err != nil {
t.Fatal(err)
}
}
var count, tokens int
if err := store.db.QueryRow(`SELECT COUNT(*), SUM(total_tokens) FROM usage_records`).Scan(&count, &tokens); err != nil || count != 2 || tokens != 20 {
t.Fatalf("count=%d tokens=%d err=%v", count, tokens, err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
}
}
-189
View File
@@ -1,189 +0,0 @@
# CLIProxyAPI 宿主补丁
The current patches target CLIProxyAPI `main` after `v8.0.4`. `patch/series` is the canonical order, and each patch includes its regression tests.
1. [`cli-proxy-api-usage-identity.patch`](cli-proxy-api-usage-identity.patch)
2. [`cli-proxy-api-request-lifecycle-cancel.patch`](cli-proxy-api-request-lifecycle-cancel.patch)
3. [`cli-proxy-api-codex-context-capability.patch`](cli-proxy-api-codex-context-capability.patch): force Astra experimental context support at catalog load, including remote refreshes and explicit upstream false values.
适用基线:
- CLIProxyAPI: `main` after `v8.0.4`
- Commit: `a270e7b9e57aaecd8f82555f44c2108518ad2330`
- Native ABI:`1`
- 上游 RPC schema 为 `6`;第一项补丁把 schema 提升到 `7`,避免与上游新增的 schema 5、6 冲突。billing 需重新构建。
升级 CLIProxyAPI 后不要直接假设补丁仍然需要或仍能应用。应先在新版本复测,再根据新版本的宿主契约重新核对。
## 1. 已取消上下文导致动态插件漏记
使用 `/v1/responses` 且请求体为 `"stream": false` 时,上游请求能够成功,CLIProxyAPI 内置用量统计也会增加,但动态用量插件可能完全收不到 `usage.handle`:
- billing 没有新增用量事实;
- 用户余额没有扣减;
- 内置统计和动态插件统计不一致。
CLIProxyAPI 会异步分发最终用量记录,但分发任务沿用了原始 HTTP 请求上下文。非流式响应很快结束后,该上下文会被取消;动态插件加载器在调用 C ABI 前检查 `ctx.Done()`,于是跳过插件调用。内置插件不经过动态 C ABI 检查,所以仍能收到记录。
该修复已由上游 `b7f6c15f` 在 `v7.2.143` 吸收。上游现在会在动态用量适配器入口使用 `context.WithoutCancel`:
- 保留上下文值;
- 不再继承已经结束的 HTTP 请求的取消信号和截止时间;
- 每个最终用量仍只调用一次插件,不增加逐块回调;
- `nil` 上下文退回 `context.Background()`。
## 2. Usage 缺少请求身份导致并发拆行
CLIProxyAPI 的请求生命周期会生成唯一 Request ID,并通过 `request.complete` 发送给插件,但 schema v3 的 `usage.handle` 没有携带该身份。billing 只能按用户、模型和时间窗口关联两类事实。
串行请求通常可以唯一匹配;同用户、同模型高并发时会出现多个等价候选。billing 为避免错配,保守地保留一条 request-only 和一条 usage-only,因此请求明细数量可能翻倍,但底层用量和扣费并未重复。
第一项补丁引入 RPC schema v7:
- 请求生命周期创建后,把 Request ID 和父 Trace ID 写入实际执行上下文;
- `usage.handle` 增加可选的 `RequestID` 与 `TraceID`;
- 只有协商到 schema v7 的插件收到新增字段;
- schema v1–v6 插件保持原有载荷,能够继续加载,其中 schema v4 继续表示上游 WebSocket observer 契约;
- billing v5 按 Request ID 精确关联,旧宿主和历史数据仍走保守兼容逻辑。
补丁不虚构 Execution ID。一次生命周期内可能存在多个上游尝试时,只有宿主能够提供真实的逐尝试身份;当前修复只传递已经存在且语义明确的生命周期 Request ID。
## 3. SSE 启动阶段取消可能缺少请求终态
billing 在请求拦截阶段创建并发占用,依赖 CPA 后续发送唯一的 `request.complete` 释放占用。原实现只在同步执行启动完成后,由流消费协程观察执行上下文的取消并发送终态。
如果执行上下文恰好在上游连接、鉴权选择或首段流初始化期间取消,流消费协程可能尚未建立。此时 `request.complete` 必须等待同步启动调用返回;若自定义执行器或异常上游没有及时响应取消,就会表现为并发槽位长期占用。内置 Codex 执行器通常会随 context 立即返回,所以这是生命周期层的防御性加固,不是内置执行器的稳定必现缺陷。
第二项补丁在请求准入拦截器返回后、执行器启动前注册上下文取消回调:
- 等待准入完成,防止终态先释放、准入后占用造成并发槽位泄漏;
- 使用准入后的元数据快照,避免与后续鉴权和执行阶段的修改竞争;
- 不依赖执行器或流转发协程是否已经启动;
- 取消后立即发送 `outcome=canceled`、`status_code=0` 的终态;
- 正常完成时主动撤销取消回调;
- 与原有完成路径共用 `sync.Once`,两条路径竞态时仍只发送一个 `request.complete`;
- 不增加轮询、定时器或逐流块处理。
该补丁只能处理已经到达 CPA 执行上下文的取消信号。Windows 客户端通过 WSL localhost 转发访问 WSL 内 CPA 时,客户端断开可能不会立即传递到后端连接;这种情况下 CPA 看不到取消,任何生命周期回调都无法提前触发。生产 Linux 直连和 WSL 内直连不经过该转发层。
## 应用
首次克隆主仓库后,先初始化锁定官方基线的 CPA submodule:
```bash
git submodule update --init --recursive
```
然后从主仓库根目录执行:
```bash
bash scripts/host-patches.sh check
bash scripts/host-patches.sh apply
bash scripts/host-patches.sh verify
export GOTOOLCHAIN=go1.26.6
cd .externals/CLIProxyAPI
gofmt -w internal/pluginhost sdk/api/handlers sdk/cliproxy/usage sdk/pluginabi sdk/pluginapi
go test -race ./internal/pluginhost ./sdk/api/handlers ./sdk/cliproxy/usage -count=1
version=v8.0.4-dev
commit=a270e7b9
build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
CGO_ENABLED=1 go build -buildvcs=false \
-ldflags="-s -w -X main.Version=$version -X main.Commit=$commit -X main.BuildDate=$build_date" \
-o test-output ./cmd/server
help_output="$(./test-output -h 2>&1)"
first_line="${help_output%%$'\n'*}"
test "$first_line" = "CLIProxyAPI Version: $version, Commit: $commit, BuiltAt: $build_date"
rm test-output
```
应用补丁后 submodule 显示工作区修改是预期状态;主仓库只提交 submodule 的官方基线指针和 `patch/`,不提交官方仓库中的派生提交。
billing 必须使用 RPC schema v7 重新构建;旧 billing 动态库会向下协商到旧 schema,无法获得 Usage 请求身份。
## 验证标准
运行时至少交叉核对:
1. 分别发送一次非流式和一次 SSE 请求;
2. 两次请求均只产生一条明细和一次扣费;
3. 并发发送相同用户、相同模型的混合请求;
4. N 个 HTTP 200 最终对应 N 条明细、N 个唯一 Request ID、N 条底层 Usage 和 N 次扣费;
5. CLIProxyAPI 内置队列、billing 和独立 keeper 的增量一致;
6. 等待异步任务稳定后没有迟到重复;
7. 请求正常结束后并发占用回到零;
8. 在上游启动阶段和已开始传输后分别强制中断 SSE,两次都只产生一个 canceled 终态且并发占用回到零;
9. 日志中没有插件错误或 panic。
## Current validation (main after v8.0.4)
The three patches apply unchanged to a270e7b9. Actual reversal, clean-tree verification, reapplication, and worktree verification passed. Full CPA/billing tests, targeted CPA race tests, billing race tests, both Linux builds, and local mock acceptance passed. UUIDv7 Trace IDs remain intact through billing. See the 2026-09-29 entry in `../docs/operations.md`. No deployment was performed.
## Historical validation (v8.0.3)
The active series contains three patches. History/notes support and its dedicated review documents were removed at the user's request. Application, reversal to a clean upstream tree, reapplication, and worktree verification passed. Complete CPA and billing tests, targeted CPA race tests, billing race tests, both Linux builds, and isolated local mock acceptance passed. See the 2026-09-28 entry in `../docs/operations.md` for evidence and limits. No deployment was performed.
## Historical validation (v7.3.16)
Patch-series verification, focused context/identity tests, and the Linux CPA build passed. See the 2026-09-24 entry in `../docs/operations.md`. Astra now stays enabled across catalog refreshes. No full suite or deployment.
## Historical validation (v7.3.15)
See the 2026-09-23 entry in `../docs/operations.md` for tests, builds, and known upstream failures. Usage identity preserves the upstream response metadata added after v7.3.9. The context-capability patch no longer inserts a duplicate field into the embedded catalog: upstream's explicit false value remains false. Missing fields in older catalogs are still supplemented, and canonical aliases retain explicit true or false values through compact serialization.
## Historical validation (v7.3.9)
当前保留四项补丁,service tier 使用上游实现。升级阶段五项补丁的全量检查和移除 service-tier 后的定向验证分别记录在 `../docs/operations.md`。未执行真实模型验收或部署。
## 历史导入验证(v7.3.4)
`v7.3.4` 的五项补丁已通过应用、还原检查和相关包 race 测试,CPA 与 billing 编译成功。billing 全量及 race 测试通过;CPA 全量测试排除三个已在纯上游复现的失败后通过,详见 `../docs/operations.md`。本轮本地运行验收按用户要求跳过,history/notes 仍未完成真实上游验证。下节为历史记录。
## 已验证结果
原三个补丁与 billing schema v4 使用真实 DeepSeek 上游完成过本地验证;`v7.2.145` 的两项补丁与 schema v5 已通过静态测试、全量测试和构建,真实模型链路仍需在本地 CPA 替换后重新验收:
- `cpads` 有 Key 成功、无 Key 返回 401;
- 非流式和 SSE 各一次:2 条明细、2 次扣费,等待 4 秒无重复;
- 12 个混合并发请求:12 次 HTTP 200、12 条合并明细、12 个唯一 Request ID、12 次扣费;
- 流式与非流式各 6 条,终态、状态码、Token 和 Trace ID 完整;
- keeper 同步增加 12 条,等待 5 秒没有迟到重复;
- WSL 内直连分别在 50ms(首字节前)和 1s(流传输中)强制断开:两次均只有一条 canceled 明细,并发占用立即回到零;
- 取消补丁额外覆盖执行器启动阻塞且未响应 context 的单元场景;
- 请求正常结束后 `active_requests=0`,空闲 5 秒没有持续 CPU 占用;
- billing 全部测试、billing race 测试及 CLIProxyAPI 相关包 race 测试通过;
- schema v3 对照插件仍能正常注册,新增字段不会发送给旧 schema。
升级到 CLIProxyAPI `v7.2.139` / `0a14eb70` 后,三项补丁可从干净基线按顺序重放,补丁涉及的三个包及其 race 测试通过。`usage-identity` 已适配上游插件 executor 的独立 `execCtx`,保留嵌套执行防重复统计逻辑。宿主全仓测试中的 3 个 Claude 指纹失败和 2 个 `reviewedInPlaceByteWrites` 清单过期失败均已在未应用补丁的纯上游 `0a14eb70` 上原样复现,属于上游例外,详见 [`../docs/operations.md`](../docs/operations.md)。
## 性能影响
两项补丁都不增加队列、重试或流式分块回调。每个最终用量和请求终态仍最多调用插件一次;新增工作是在 schema v7 JSON 中增加两个短字符串,以及为活跃请求注册一个随正常完成立即撤销的标准库取消回调。
## 回滚
源码必须按应用顺序的反方向回滚:
```bash
bash scripts/host-patches.sh reverse
```
部署回滚应恢复升级前保存的 CLIProxyAPI 和 billing 动态库后重启宿主。数据库不需要回滚;schema v7 只让新记录获得精确身份,不改变历史账目。
## Service tier compatibility
Use `patch/series` as the canonical order; regression tests live in their corresponding patches. Codex service-tier translation follows upstream v8.0.3: priority and ultrafast are supported, fast is normalized to priority, and other tiers are removed. The passthrough patch is retired. Billing pricing rules are unchanged: compact usage uses configured model rates; recorded explicit tiers other than `auto` and `default` retain the existing Fast pricing behavior.
`check` checks application and reversal against HEAD in a temporary index. `verify` checks that the complete worktree matches HEAD plus the series, including new patch files and unrelated tracked or non-ignored untracked changes. Neither changes the real index or worktree. Ignored build artifacts are excluded. `apply` and `reverse` refuse unexpected changes in patch-owned paths.
## v7.3.9 patch review (2026-09-20)
- Official baseline: `v7.3.9` / `61fdfc341b96178a8dcb53f2efc46cbc341d267c`, matching `origin/main` at fetch time.
- Before fetch, `verify` proved the old worktree exactly matched all five patches. Reversing the series restored a clean v7.3.4 tree; no unrelated edits were discarded.
- `usage-identity`: still required. Upstream RPC schema remains 6 and plugin UsageRecord still lacks RequestID/TraceID. Keep local schema 7 and older-schema compatibility.
- `request-lifecycle-cancel`: still required. Upstream lifecycle files have not absorbed the admission-ordered cancellation callback. Keep exactly-once completion and the post-admission metadata snapshot.
- `codex-history-notes`: still required for the experimental routes. Existing authenticated routing metadata, completion-only observation, and size limits are preserved. No protocol or multi-account correctness claim is made by this rebase.
- `codex-context-capability`: still required. Upstream catalog loading does not supplement the missing gpt-6-astra flag. Preserve explicit upstream values and canonical alias inheritance.
- `codex-service-tier`: retired by user decision. Upstream ultrafast support satisfies the required behavior. Restore the upstream translator and tests, including fast-to-priority normalization and filtering other tiers. The patch file and series entry are removed; billing pricing rules are unchanged.
- Experimental `codex.response-steering` remains upstream-default false. This upgrade does not enable or certify that path for billing.
- Validation results and remaining limits: see `../docs/operations.md`.
@@ -1,138 +0,0 @@
diff --git a/internal/client/codex/models/experimental_context_test.go b/internal/client/codex/models/experimental_context_test.go
new file mode 100644
index 00000000..e41a1ec1
--- /dev/null
+++ b/internal/client/codex/models/experimental_context_test.go
@@ -0,0 +1,37 @@
+package models
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+)
+
+func TestCodexExperimentalContextFollowsCanonicalAlias(t *testing.T) {
+ r := registry.GetGlobalRegistry()
+ const clientID = "experimental-context-alias-test"
+ r.RegisterClient(clientID, "codex", []*registry.ModelInfo{
+ {ID: "context-test-alias", MetadataModelID: "gpt-6-astra"},
+ {ID: "context-test-unrelated"},
+ })
+ t.Cleanup(func() { r.UnregisterClient(clientID) })
+ response := BuildResponseForClient([]map[string]any{
+ {"id": "gpt-6-astra"}, {"id": "context-test-alias"}, {"id": "context-test-unrelated"},
+ }, r.GetModelProviders, false, "0.155.0")
+ encoded, err := MarshalCompact(response)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var decoded codexClientModelsPayload
+ if err := json.Unmarshal(encoded, &decoded); err != nil || len(decoded.Models) != 3 {
+ t.Fatalf("serialized catalog = %s, err = %v", encoded, err)
+ }
+ for _, entry := range decoded.Models {
+ slug := stringModelValue(entry, "slug")
+ want := slug != "context-test-unrelated"
+ got, present := entry["supports_experimental_context"].(bool)
+ if got != want || (slug != "context-test-unrelated" && !present) {
+ t.Fatalf("%s experimental context = %v, want %v", slug, got, want)
+ }
+ }
+}
diff --git a/internal/registry/codex_client_models.go b/internal/registry/codex_client_models.go
index 370abf29..1fae27be 100644
--- a/internal/registry/codex_client_models.go
+++ b/internal/registry/codex_client_models.go
@@ -10,6 +10,8 @@ import (
"sync"
log "github.com/sirupsen/logrus"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
)
//go:embed models/codex_client_models.json
@@ -59,6 +61,17 @@ func loadCodexClientModelsFromBytes(data []byte, source string) (bool, error) {
return false, fmt.Errorf("%s: %w", source, err)
}
+ // This patched host supports Astra experimental context, including after remote refresh.
+ for i, model := range gjson.GetBytes(data, "models").Array() {
+ if model.Get("slug").String() == "gpt-6-astra" {
+ updated, err := sjson.SetBytes(data, fmt.Sprintf("models.%d.supports_experimental_context", i), true)
+ if err != nil {
+ return false, fmt.Errorf("%s: supplement experimental context capability: %w", source, err)
+ }
+ data = updated
+ }
+ }
+
cloned := append([]byte(nil), data...)
codexClientCatalogStore.mu.Lock()
defer codexClientCatalogStore.mu.Unlock()
diff --git a/internal/registry/codex_experimental_context_test.go b/internal/registry/codex_experimental_context_test.go
new file mode 100644
index 00000000..c8d5e3ef
--- /dev/null
+++ b/internal/registry/codex_experimental_context_test.go
@@ -0,0 +1,58 @@
+package registry
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestExperimentalContextCatalogRefresh(t *testing.T) {
+ original, _ := GetCodexClientModelsSnapshot()
+ previousURLs := codexClientModelsURLs
+ t.Cleanup(func() {
+ codexClientModelsURLs = previousURLs
+ if _, err := loadCodexClientModelsFromBytes(original, "test cleanup"); err != nil {
+ t.Fatal(err)
+ }
+ })
+ for _, tc := range []struct {
+ name string
+ value any
+ want bool
+ }{
+ {"missing", nil, true},
+ {"explicit false", false, true},
+ {"explicit true", true, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ model := testCodexClientModel("gpt-6-astra", 2)
+ if tc.value != nil {
+ model["supports_experimental_context"] = tc.value
+ }
+ catalog := testCodexClientCatalog(t, testCodexClientModel("gpt-5.5", 1), model)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write(catalog)
+ }))
+ defer server.Close()
+ codexClientModelsURLs = []string{server.URL}
+ tryRefreshCodexClientModels(context.Background(), "test experimental refresh")
+ data, revision := GetCodexClientModelsSnapshot()
+ var payload codexClientModelsPayload
+ if err := json.Unmarshal(data, &payload); err != nil {
+ t.Fatal(err)
+ }
+ if got := payload.Models[1]["supports_experimental_context"]; got != tc.want {
+ t.Fatalf("experimental context = %v, want %v", got, tc.want)
+ }
+ if _, exists := payload.Models[0]["supports_experimental_context"]; exists {
+ t.Fatal("unrelated model gained experimental context")
+ }
+ tryRefreshCodexClientModels(context.Background(), "test repeated refresh")
+ if GetCodexClientModelsRevision() != revision {
+ t.Fatal("unchanged catalog advanced revision")
+ }
+ })
+ }
+}
@@ -1,341 +0,0 @@
diff --git a/sdk/api/handlers/handlers_execution.go b/sdk/api/handlers/handlers_execution.go
index 92b2f060..07b68cd0 100644
--- a/sdk/api/handlers/handlers_execution.go
+++ b/sdk/api/handlers/handlers_execution.go
@@ -91,6 +91,7 @@ func (h *BaseAPIHandler) executeWithAuthManagerFormats(ctx context.Context, entr
ctx = enrichContextWithSessionHierarchy(ctx, opts.Headers, req.Payload, opts.Metadata)
var interceptErr *interfaces.ErrorMessage
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(ctx, entryProtocol, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
+ lifecycle.watchCancellation(ctx)
if interceptErr != nil {
lifecycle.completeError(ctx, interceptErr)
return nil, nil, interceptErr
@@ -162,6 +163,7 @@ func (h *BaseAPIHandler) executeCountWithAuthManager(ctx context.Context, handle
ctx = enrichContextWithSessionHierarchy(ctx, opts.Headers, req.Payload, opts.Metadata)
var interceptErr *interfaces.ErrorMessage
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(ctx, handlerType, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
+ lifecycle.watchCancellation(ctx)
if interceptErr != nil {
lifecycle.completeError(ctx, interceptErr)
return nil, nil, interceptErr
@@ -198,6 +200,7 @@ func (h *BaseAPIHandler) executeWithPluginExecutor(ctx context.Context, entryPro
execCtx = lifecycle.executionContext()
var interceptErr *interfaces.ErrorMessage
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(execCtx, entryProtocol, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
+ lifecycle.watchCancellation(execCtx)
if interceptErr != nil {
lifecycle.completeError(execCtx, interceptErr)
return nil, nil, interceptErr
@@ -248,6 +251,7 @@ func (h *BaseAPIHandler) countWithPluginExecutor(ctx context.Context, handlerTyp
ctx = lifecycle.executionContext()
var interceptErr *interfaces.ErrorMessage
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(ctx, handlerType, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
+ lifecycle.watchCancellation(ctx)
if interceptErr != nil {
lifecycle.completeError(ctx, interceptErr)
return nil, nil, interceptErr
diff --git a/sdk/api/handlers/handlers_interceptors.go b/sdk/api/handlers/handlers_interceptors.go
index 3e2c73a1..3efe72a2 100644
--- a/sdk/api/handlers/handlers_interceptors.go
+++ b/sdk/api/handlers/handlers_interceptors.go
@@ -1,7 +1,9 @@
package handlers
import (
+ stdcontext "context"
"encoding/json"
+ "maps"
"net/http"
"sync"
"time"
@@ -109,6 +111,9 @@ func webSocketResponseObserversEnabled(host PluginInterceptorHost) bool {
type requestLifecycleTracker struct {
once sync.Once
+ cancelMu sync.Mutex
+ cancelStop func() bool
+ completed bool
ctx context.Context
host PluginInterceptorHost
skipPluginID string
@@ -119,7 +124,7 @@ func (h *BaseAPIHandler) newRequestLifecycleTracker(ctx context.Context, sourceF
requestID := uuid.NewString()
traceID := logging.GetRequestID(ctx)
ctx = coreusage.WithRequestIdentity(ctx, requestID, traceID)
- return &requestLifecycleTracker{
+ tracker := &requestLifecycleTracker{
ctx: ctx,
host: h.interceptorHost(),
skipPluginID: skipPluginID,
@@ -134,6 +139,42 @@ func (h *BaseAPIHandler) newRequestLifecycleTracker(ctx context.Context, sourceF
Metadata: metadata,
},
}
+ return tracker
+}
+
+func (t *requestLifecycleTracker) watchCancellation(ctx context.Context) {
+ if t == nil || ctx == nil || ctx.Done() == nil {
+ return
+ }
+ // Register only after admission returns. Later auth/executor stages can mutate
+ // their metadata, so cancellation publishes an independent snapshot.
+ completion := t.completion
+ completion.Metadata = maps.Clone(completion.Metadata)
+ stop := stdcontext.AfterFunc(ctx, func() {
+ t.completeRecord(completion, pluginapi.RequestCompletionCanceled, 0, ctx.Err())
+ })
+ t.cancelMu.Lock()
+ if t.completed {
+ t.cancelMu.Unlock()
+ stop()
+ return
+ }
+ t.cancelStop = stop
+ t.cancelMu.Unlock()
+}
+
+func (t *requestLifecycleTracker) stopCancellationWatch() {
+ if t == nil {
+ return
+ }
+ t.cancelMu.Lock()
+ t.completed = true
+ stop := t.cancelStop
+ t.cancelStop = nil
+ t.cancelMu.Unlock()
+ if stop != nil {
+ stop()
+ }
}
func (t *requestLifecycleTracker) executionContext() context.Context {
@@ -154,8 +195,12 @@ func (t *requestLifecycleTracker) complete(outcome pluginapi.RequestCompletionOu
if t == nil {
return
}
+ t.completeRecord(t.completion, outcome, statusCode, err)
+}
+
+func (t *requestLifecycleTracker) completeRecord(completion pluginapi.RequestCompletion, outcome pluginapi.RequestCompletionOutcome, statusCode int, err error) {
t.once.Do(func() {
- completion := t.completion
+ t.stopCancellationWatch()
completion.Outcome = outcome
completion.StatusCode = statusCode
completion.CompletedAt = time.Now()
diff --git a/sdk/api/handlers/handlers_interceptors_test.go b/sdk/api/handlers/handlers_interceptors_test.go
index 22f4d2f2..3f596e62 100644
--- a/sdk/api/handlers/handlers_interceptors_test.go
+++ b/sdk/api/handlers/handlers_interceptors_test.go
@@ -542,6 +542,78 @@ func TestHandlerLifecycleCompletesCanceledStream(t *testing.T) {
}
}
+func TestHandlerLifecycleCompletesCanceledStreamWhileExecutorIsStarting(t *testing.T) {
+ model := "handler-interceptor-lifecycle-canceled-before-stream-start"
+ executorStarted := make(chan struct{})
+ releaseExecutor := make(chan struct{})
+ executor := &interceptorCaptureExecutor{
+ stream: func(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ close(executorStarted)
+ <-releaseExecutor
+ chunks := make(chan coreexecutor.StreamChunk)
+ close(chunks)
+ return &coreexecutor.StreamResult{Chunks: chunks}, nil
+ },
+ }
+ handler := newInterceptorHandler(t, model, executor, &sdkconfig.SDKConfig{})
+ completions := make(chan pluginapi.RequestCompletion, 2)
+ handler.SetPluginHost(&handlerInterceptorTestHost{
+ completeRequest: func(_ context.Context, completion pluginapi.RequestCompletion) {
+ completions <- completion
+ },
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ type streamResult struct {
+ data <-chan []byte
+ errs <-chan *interfaces.ErrorMessage
+ }
+ result := make(chan streamResult, 1)
+ go func() {
+ data, _, errs := handler.ExecuteStreamWithAuthManager(ctx, "openai", model, []byte(`{"model":"`+model+`","stream":true}`), "")
+ result <- streamResult{data: data, errs: errs}
+ }()
+
+ select {
+ case <-executorStarted:
+ case <-time.After(time.Second):
+ close(releaseExecutor)
+ t.Fatal("executor did not start")
+ }
+ cancel()
+
+ select {
+ case completion := <-completions:
+ if completion.Outcome != pluginapi.RequestCompletionCanceled || completion.StatusCode != 0 || completion.Error == "" {
+ close(releaseExecutor)
+ t.Fatalf("completion = %#v", completion)
+ }
+ case <-time.After(time.Second):
+ close(releaseExecutor)
+ t.Fatal("missing cancellation completion while executor startup was blocked")
+ }
+
+ close(releaseExecutor)
+ channels := <-result
+ for channels.data != nil || channels.errs != nil {
+ select {
+ case _, ok := <-channels.data:
+ if !ok {
+ channels.data = nil
+ }
+ case _, ok := <-channels.errs:
+ if !ok {
+ channels.errs = nil
+ }
+ }
+ }
+ select {
+ case duplicate := <-completions:
+ t.Fatalf("duplicate stream completion = %#v", duplicate)
+ default:
+ }
+}
+
func TestHandlerRequestInterceptorRewritesExecutorRequest(t *testing.T) {
model := "handler-interceptor-request-model"
executor := &interceptorCaptureExecutor{}
diff --git a/sdk/api/handlers/handlers_lifecycle_admission_test.go b/sdk/api/handlers/handlers_lifecycle_admission_test.go
new file mode 100644
index 00000000..ab7abc36
--- /dev/null
+++ b/sdk/api/handlers/handlers_lifecycle_admission_test.go
@@ -0,0 +1,106 @@
+package handlers
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+)
+
+// Cancellation must not complete a request while an admission callback can still
+// create a concurrency reservation for it.
+func TestHandlerCancellationWaitsForAdmission(t *testing.T) {
+ model := "handler-cancel-during-admission"
+ handler := newInterceptorHandler(t, model, &interceptorCaptureExecutor{}, &sdkconfig.SDKConfig{})
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ var releaseOnce sync.Once
+ unblock := func() { releaseOnce.Do(func() { close(release) }) }
+ defer unblock()
+ completed := make(chan pluginapi.RequestCompletion, 2)
+ handler.SetPluginHost(&handlerInterceptorTestHost{
+ interceptRequestBeforeAuth: func(context.Context, pluginapi.RequestInterceptRequest) pluginapi.RequestInterceptResponse {
+ close(entered)
+ <-release
+ return pluginapi.RequestInterceptResponse{}
+ },
+ completeRequest: func(_ context.Context, completion pluginapi.RequestCompletion) { completed <- completion },
+ })
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ returned := make(chan struct{})
+ go func() {
+ defer close(returned)
+ data, _, errs := handler.ExecuteStreamWithAuthManager(ctx, "openai", model, []byte(`{"model":"`+model+`","stream":true}`), "")
+ for data != nil || errs != nil {
+ select {
+ case _, ok := <-data:
+ if !ok {
+ data = nil
+ }
+ case _, ok := <-errs:
+ if !ok {
+ errs = nil
+ }
+ }
+ }
+ }()
+ select {
+ case <-entered:
+ case <-time.After(time.Second):
+ t.Fatal("admission did not start")
+ }
+ cancel()
+ // A bounded observation window checks asynchronous callback ordering, not TTL.
+ select {
+ case completion := <-completed:
+ t.Fatalf("completion arrived before admission returned: %+v", completion)
+ case <-time.After(100 * time.Millisecond):
+ }
+ unblock()
+ select {
+ case completion := <-completed:
+ if completion.Outcome != pluginapi.RequestCompletionCanceled || completion.StatusCode != 0 {
+ t.Fatalf("completion = %+v", completion)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("missing cancellation completion after admission")
+ }
+ select {
+ case <-returned:
+ case <-time.After(time.Second):
+ t.Fatal("handler did not return")
+ }
+ select {
+ case completion := <-completed:
+ t.Fatalf("duplicate completion: %+v", completion)
+ default:
+ }
+}
+
+func TestLifecycleCancellationPreservesAdmissionMetadata(t *testing.T) {
+ handler := newInterceptorHandler(t, "cancel-metadata", &interceptorCaptureExecutor{}, &sdkconfig.SDKConfig{})
+ completed := make(chan pluginapi.RequestCompletion, 1)
+ handler.SetPluginHost(&handlerInterceptorTestHost{
+ completeRequest: func(_ context.Context, completion pluginapi.RequestCompletion) { completed <- completion },
+ })
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ metadata := map[string]any{"caller_scope": "admitted-user"}
+ tracker := handler.newRequestLifecycleTracker(ctx, "openai", "model", "model", true, metadata, "")
+ tracker.watchCancellation(ctx)
+ // Auth selection may continue to update its own metadata after admission.
+ metadata["caller_scope"] = "later-value"
+ cancel()
+ select {
+ case completion := <-completed:
+ if completion.Metadata["caller_scope"] != "admitted-user" {
+ t.Fatalf("cancellation metadata = %+v", completion.Metadata)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("missing cancellation completion")
+ }
+}
diff --git a/sdk/api/handlers/handlers_stream.go b/sdk/api/handlers/handlers_stream.go
index d865fa4d..0428c1e5 100644
--- a/sdk/api/handlers/handlers_stream.go
+++ b/sdk/api/handlers/handlers_stream.go
@@ -47,6 +47,7 @@ func (h *BaseAPIHandler) streamWithPluginExecutor(ctx context.Context, entryProt
execCtx = lifecycle.executionContext()
var interceptErr *interfaces.ErrorMessage
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(execCtx, entryProtocol, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
+ lifecycle.watchCancellation(execCtx)
if interceptErr != nil {
lifecycle.completeError(execCtx, interceptErr)
errChan := make(chan *interfaces.ErrorMessage, 1)
@@ -353,6 +354,7 @@ func (h *BaseAPIHandler) executeStreamWithAuthManagerFormats(ctx context.Context
ctx = enrichContextWithSessionHierarchy(ctx, opts.Headers, req.Payload, opts.Metadata)
var interceptErr *interfaces.ErrorMessage
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(ctx, entryProtocol, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
+ lifecycle.watchCancellation(ctx)
if interceptErr != nil {
lifecycle.completeError(ctx, interceptErr)
errChan := make(chan *interfaces.ErrorMessage, 1)
-325
View File
@@ -1,325 +0,0 @@
diff --git a/internal/pluginhost/adapters_test.go b/internal/pluginhost/adapters_test.go
index 629c6966..9d4bfa86 100644
--- a/internal/pluginhost/adapters_test.go
+++ b/internal/pluginhost/adapters_test.go
@@ -2315,6 +2315,41 @@ func TestUsageAdapterPanicFusesPlugin(t *testing.T) {
}
}
+func TestUsageAdapterRequestIdentitySchemaCompatibility(t *testing.T) {
+ for schema := uint32(1); schema <= pluginabi.SchemaVersionUsageIdentity; schema++ {
+ t.Run(fmt.Sprintf("schema_%d", schema), func(t *testing.T) {
+ var got pluginapi.UsageRecord
+ plugin := usagePluginFunc(func(_ context.Context, record pluginapi.UsageRecord) { got = record })
+ host := newHostWithRecords(capabilityRecord{
+ id: "usage-identity",
+ plugin: pluginapi.Plugin{SchemaVersion: schema, Capabilities: pluginapi.Capabilities{UsagePlugin: plugin}},
+ })
+ adapter := &usageAdapter{host: host, pluginID: "usage-identity", schemaVersion: schema}
+ ctx := coreusage.WithRequestIdentity(context.Background(), "request-1", "trace-1")
+ adapter.HandleUsage(ctx, coreusage.Record{
+ RequestID: "attempt-1", TraceID: "upstream-trace",
+ Provider: "provider", BaseURL: "https://upstream.example/v1", AuthID: "auth-1",
+ SessionID: "session-1", ParentSessionID: "parent-1",
+ Model: "requested-model", ResponseModel: "actual-model",
+ ServiceTier: "priority", ResponseServiceTier: "default", Stream: true,
+ })
+ wantRequestID, wantTraceID := "attempt-1", "upstream-trace"
+ if schema >= pluginabi.SchemaVersionUsageIdentity {
+ wantRequestID, wantTraceID = "request-1", "trace-1"
+ }
+ if got.RequestID != wantRequestID || got.TraceID != wantTraceID {
+ t.Fatalf("usage identity = %q, %q; want %q, %q", got.RequestID, got.TraceID, wantRequestID, wantTraceID)
+ }
+ if got.Provider != "provider" || got.BaseURL != "https://upstream.example/v1" || got.AuthID != "auth-1" || got.SessionID != "session-1" || got.ParentSessionID != "parent-1" {
+ t.Fatalf("upstream usage metadata lost: %+v", got)
+ }
+ if got.Model != "requested-model" || got.ResponseModel != "actual-model" || got.ServiceTier != "priority" || got.ResponseServiceTier != "default" || !got.Stream {
+ t.Fatalf("upstream response metadata lost: %+v", got)
+ }
+ })
+ }
+}
+
func TestUsageAdapterNormalizesOmittedGenerateToTrue(t *testing.T) {
var gotGenerate bool
plugin := usagePluginFunc(func(ctx context.Context, record pluginapi.UsageRecord) {
diff --git a/internal/pluginhost/adapters_usage_translation.go b/internal/pluginhost/adapters_usage_translation.go
index 5458302d..42c76099 100644
--- a/internal/pluginhost/adapters_usage_translation.go
+++ b/internal/pluginhost/adapters_usage_translation.go
@@ -11,6 +11,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
@@ -27,9 +28,10 @@ func (h *Host) RegisterUsagePlugins() {
continue
}
coreusage.RegisterNamedPlugin("plugin:"+record.id, &usageAdapter{
- host: h,
- pluginID: record.id,
- plugin: plugin,
+ host: h,
+ pluginID: record.id,
+ plugin: plugin,
+ schemaVersion: record.plugin.SchemaVersion,
})
}
}
@@ -115,9 +117,10 @@ func (h *Host) isPluginFused(id string) bool {
}
type usageAdapter struct {
- host *Host
- pluginID string
- plugin pluginapi.UsagePlugin
+ host *Host
+ pluginID string
+ plugin pluginapi.UsagePlugin
+ schemaVersion uint32
}
type thinkingAdapter struct {
@@ -167,6 +170,12 @@ func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record)
if traceID == "" {
traceID = strings.TrimSpace(logging.GetRequestID(ctx))
}
+ // Billing schema 7 associates usage with the handler lifecycle, not an upstream attempt.
+ if a.schemaVersion >= pluginabi.SchemaVersionUsageIdentity {
+ if lifecycleID, parentID := coreusage.RequestIdentityFromContext(ctx); lifecycleID != "" {
+ requestID, traceID = lifecycleID, parentID
+ }
+ }
plugin.HandleUsage(ctx, pluginapi.UsageRecord{
RequestID: requestID,
TraceID: traceID,
diff --git a/sdk/api/handlers/handlers_execution.go b/sdk/api/handlers/handlers_execution.go
index 225cbe65..92b2f060 100644
--- a/sdk/api/handlers/handlers_execution.go
+++ b/sdk/api/handlers/handlers_execution.go
@@ -74,6 +74,7 @@ func (h *BaseAPIHandler) executeWithAuthManagerFormats(ctx context.Context, entr
}
afterAuthCapture := &requestAfterAuthCapture{}
lifecycle := h.newRequestLifecycleTracker(ctx, entryProtocol, normalizedModel, originalRequestedModel, false, reqMeta, execOptions.SkipInterceptorPluginID)
+ ctx = lifecycle.executionContext()
opts := coreexecutor.Options{
Stream: false,
Alt: alt,
@@ -145,6 +146,7 @@ func (h *BaseAPIHandler) executeCountWithAuthManager(ctx context.Context, handle
}
afterAuthCapture := &requestAfterAuthCapture{}
lifecycle := h.newRequestLifecycleTracker(ctx, handlerType, normalizedModel, originalRequestedModel, false, reqMeta, execOptions.SkipInterceptorPluginID)
+ ctx = lifecycle.executionContext()
opts := coreexecutor.Options{
Stream: false,
Alt: alt,
@@ -193,6 +195,7 @@ func (h *BaseAPIHandler) executeWithPluginExecutor(ctx context.Context, entryPro
execCtx, nestedTracker := withNestedExecutionTracker(coreusage.WithStream(ctx, false))
req, opts := h.pluginExecutorRequest(execCtx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, false, execOptions)
lifecycle := h.newRequestLifecycleTracker(execCtx, entryProtocol, modelName, originalRequestedModel, false, opts.Metadata, execOptions.SkipInterceptorPluginID)
+ execCtx = lifecycle.executionContext()
var interceptErr *interfaces.ErrorMessage
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(execCtx, entryProtocol, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
if interceptErr != nil {
@@ -242,6 +245,7 @@ func (h *BaseAPIHandler) countWithPluginExecutor(ctx context.Context, handlerTyp
}
req, opts := h.pluginExecutorRequest(ctx, handlerType, handlerType, modelName, originalRequestedModel, rawJSON, alt, false, execOptions)
lifecycle := h.newRequestLifecycleTracker(ctx, handlerType, modelName, originalRequestedModel, false, opts.Metadata, execOptions.SkipInterceptorPluginID)
+ ctx = lifecycle.executionContext()
var interceptErr *interfaces.ErrorMessage
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(ctx, handlerType, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
if interceptErr != nil {
diff --git a/sdk/api/handlers/handlers_interceptors.go b/sdk/api/handlers/handlers_interceptors.go
index ea8adb41..3e2c73a1 100644
--- a/sdk/api/handlers/handlers_interceptors.go
+++ b/sdk/api/handlers/handlers_interceptors.go
@@ -11,6 +11,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/internal/logging"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
"golang.org/x/net/context"
)
@@ -117,6 +118,7 @@ type requestLifecycleTracker struct {
func (h *BaseAPIHandler) newRequestLifecycleTracker(ctx context.Context, sourceFormat, model, requestedModel string, stream bool, metadata map[string]any, skipPluginID string) *requestLifecycleTracker {
requestID := uuid.NewString()
traceID := logging.GetRequestID(ctx)
+ ctx = coreusage.WithRequestIdentity(ctx, requestID, traceID)
return &requestLifecycleTracker{
ctx: ctx,
host: h.interceptorHost(),
@@ -134,6 +136,13 @@ func (h *BaseAPIHandler) newRequestLifecycleTracker(ctx context.Context, sourceF
}
}
+func (t *requestLifecycleTracker) executionContext() context.Context {
+ if t == nil || t.ctx == nil {
+ return context.Background()
+ }
+ return t.ctx
+}
+
func (t *requestLifecycleTracker) requestID() string {
if t == nil {
return ""
diff --git a/sdk/api/handlers/handlers_interceptors_test.go b/sdk/api/handlers/handlers_interceptors_test.go
index 2eea0236..22f4d2f2 100644
--- a/sdk/api/handlers/handlers_interceptors_test.go
+++ b/sdk/api/handlers/handlers_interceptors_test.go
@@ -18,6 +18,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
)
@@ -256,6 +257,11 @@ func TestRequestLifecycleTrackerUsesUniqueExecutionIDs(t *testing.T) {
if first.completion.TraceID != "trace-1" || second.completion.TraceID != "trace-1" {
t.Fatalf("trace IDs = %q and %q", first.completion.TraceID, second.completion.TraceID)
}
+ firstRequestID, firstTraceID := coreusage.RequestIdentityFromContext(first.executionContext())
+ secondRequestID, secondTraceID := coreusage.RequestIdentityFromContext(second.executionContext())
+ if firstRequestID != first.requestID() || secondRequestID != second.requestID() || firstTraceID != "trace-1" || secondTraceID != "trace-1" {
+ t.Fatalf("usage identities = (%q, %q) and (%q, %q)", firstRequestID, firstTraceID, secondRequestID, secondTraceID)
+ }
}
func TestHandlerRequestInterceptorTerminatesBeforeAuth(t *testing.T) {
diff --git a/sdk/api/handlers/handlers_stream.go b/sdk/api/handlers/handlers_stream.go
index 935b00c8..d865fa4d 100644
--- a/sdk/api/handlers/handlers_stream.go
+++ b/sdk/api/handlers/handlers_stream.go
@@ -44,6 +44,7 @@ func (h *BaseAPIHandler) streamWithPluginExecutor(ctx context.Context, entryProt
execCtx, nestedTracker := withNestedExecutionTracker(coreusage.WithStream(ctx, true))
req, opts := h.pluginExecutorRequest(execCtx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, true, execOptions)
lifecycle := h.newRequestLifecycleTracker(execCtx, entryProtocol, modelName, originalRequestedModel, true, opts.Metadata, execOptions.SkipInterceptorPluginID)
+ execCtx = lifecycle.executionContext()
var interceptErr *interfaces.ErrorMessage
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(execCtx, entryProtocol, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
if interceptErr != nil {
@@ -335,6 +336,7 @@ func (h *BaseAPIHandler) executeStreamWithAuthManagerFormats(ctx context.Context
}
afterAuthCapture := &requestAfterAuthCapture{}
lifecycle := h.newRequestLifecycleTracker(ctx, entryProtocol, normalizedModel, originalRequestedModel, true, reqMeta, execOptions.SkipInterceptorPluginID)
+ ctx = lifecycle.executionContext()
opts := coreexecutor.Options{
Stream: true,
Alt: alt,
diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go
index 859dda38..4160a017 100644
--- a/sdk/cliproxy/usage/manager.go
+++ b/sdk/cliproxy/usage/manager.go
@@ -134,6 +134,35 @@ func TraceIDFromContext(ctx context.Context) string {
return ""
}
+type requestIdentityContextKey struct{}
+
+type requestIdentity struct {
+ requestID string
+ traceID string
+}
+
+// WithRequestIdentity stores the model execution and parent request IDs for usage sinks.
+func WithRequestIdentity(ctx context.Context, requestID, traceID string) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ requestID = strings.TrimSpace(requestID)
+ traceID = strings.TrimSpace(traceID)
+ if requestID == "" && traceID == "" {
+ return ctx
+ }
+ return context.WithValue(ctx, requestIdentityContextKey{}, requestIdentity{requestID: requestID, traceID: traceID})
+}
+
+// RequestIdentityFromContext returns the model execution and parent request IDs stored in ctx.
+func RequestIdentityFromContext(ctx context.Context) (requestID, traceID string) {
+ if ctx == nil {
+ return "", ""
+ }
+ identity, _ := ctx.Value(requestIdentityContextKey{}).(requestIdentity)
+ return strings.TrimSpace(identity.requestID), strings.TrimSpace(identity.traceID)
+}
+
// WithRequestedModelAlias stores the client-requested model name for usage sinks.
func WithRequestedModelAlias(ctx context.Context, alias string) context.Context {
if ctx == nil {
diff --git a/sdk/cliproxy/usage/manager_test.go b/sdk/cliproxy/usage/manager_test.go
index dfcc16d8..65e3dfe3 100644
--- a/sdk/cliproxy/usage/manager_test.go
+++ b/sdk/cliproxy/usage/manager_test.go
@@ -71,6 +71,21 @@ func TestGenerateFromContextHonorsExplicitFalse(t *testing.T) {
}
}
+func TestRequestIdentityContextRoundTrip(t *testing.T) {
+ ctx := WithRequestIdentity(context.Background(), " request-1 ", " trace-1 ")
+ requestID, traceID := RequestIdentityFromContext(ctx)
+ if requestID != "request-1" || traceID != "trace-1" {
+ t.Fatalf("request identity = %q, %q", requestID, traceID)
+ }
+}
+
+func TestRequestIdentityContextDefaultsEmpty(t *testing.T) {
+ requestID, traceID := RequestIdentityFromContext(nil)
+ if requestID != "" || traceID != "" {
+ t.Fatalf("empty request identity = %q, %q", requestID, traceID)
+ }
+}
+
func TestRecordOmittedGenerateIsEnabled(t *testing.T) {
// Existing callers construct Record without setting Generate.
// Omission must remain distinguishable from explicit false and default to true.
diff --git a/sdk/pluginabi/types.go b/sdk/pluginabi/types.go
index 31800ccc..c2ab971f 100644
--- a/sdk/pluginabi/types.go
+++ b/sdk/pluginabi/types.go
@@ -17,7 +17,8 @@ const (
// Version 6 preserves raw JSON bodies for plugin management responses.
// Plugins that still require HTML entity escaping on JSON response strings
// should keep schema_version < 6.
- SchemaVersion uint32 = 6
+ // Version 7 adds downstream usage request identity.
+ SchemaVersion uint32 = 7
// SchemaVersionStreamChunkOmitRequestBody is the first schema version that omits
// request bodies on payload stream-chunk interceptor calls.
SchemaVersionStreamChunkOmitRequestBody uint32 = 3
@@ -30,6 +31,9 @@ const (
// SchemaVersionRawManagementResponse is the first schema version where plugin
// management JSON responses are preserved without HTML-escaping strings.
SchemaVersionRawManagementResponse uint32 = 6
+ // SchemaVersionUsageIdentity is the first schema version that attaches
+ // request lifecycle identity to usage records.
+ SchemaVersionUsageIdentity uint32 = 7
)
const (
diff --git a/sdk/pluginabi/types_test.go b/sdk/pluginabi/types_test.go
index 80700f99..8bc98779 100644
--- a/sdk/pluginabi/types_test.go
+++ b/sdk/pluginabi/types_test.go
@@ -28,12 +28,15 @@ func TestEnvelopeRoundTrip(t *testing.T) {
}
func TestMethodNamesAreStable(t *testing.T) {
- if SchemaVersion != 6 {
- t.Fatalf("SchemaVersion = %d, want 6", SchemaVersion)
+ if SchemaVersion != 7 {
+ t.Fatalf("SchemaVersion = %d, want 7", SchemaVersion)
}
if SchemaVersionWebSocketResponseObserver != 4 {
t.Fatalf("SchemaVersionWebSocketResponseObserver = %d, want 4", SchemaVersionWebSocketResponseObserver)
}
+ if SchemaVersionUsageIdentity != 7 {
+ t.Fatalf("SchemaVersionUsageIdentity = %d, want 7", SchemaVersionUsageIdentity)
+ }
if SchemaVersionStreamChunkOmitRequestBody != 3 {
t.Fatalf("SchemaVersionStreamChunkOmitRequestBody = %d, want 3", SchemaVersionStreamChunkOmitRequestBody)
}
-5
View File
@@ -1,5 +0,0 @@
# CLIProxyAPI main after v8.0.4 / a270e7b9e57aaecd8f82555f44c2108518ad2330
# Each patch includes its regression tests. Apply in order; reverse in reverse order.
cli-proxy-api-usage-identity.patch
cli-proxy-api-request-lifecycle-cancel.patch
cli-proxy-api-codex-context-capability.patch
+1 -1
View File
@@ -13,5 +13,5 @@ function Resolve-Tool([string]$Name) {
$go = Resolve-Tool 'go'
Write-Host "Go: $(& $go version)"
Write-Host 'CLIProxyAPI target: ABI 1 / RPC schema 7'
Write-Host 'CLIProxyAPI target: ABI 1 / RPC schema 6'
Write-Warning '本项目默认在 WSL/Linux 构建;请优先运行 wsl bash ./scripts/build.sh。'
+1 -1
View File
@@ -13,4 +13,4 @@ command -v gcc >/dev/null 2>&1 || {
echo "Go bootstrap: $(go version)"
echo "Go effective: $(go env GOVERSION)"
echo "GCC: $(gcc --version | head -n 1)"
echo 'CLIProxyAPI target: ABI 1 / RPC schema 7'
echo 'CLIProxyAPI target: ABI 1 / RPC schema 6'
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
plugin_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
action="${1:-check}"
source_dir="${2:-${plugin_root}/.externals/CLIProxyAPI}"
case "${action}" in
check|verify|apply|reverse) ;;
*) printf 'Usage: %s [check|verify|apply|reverse] [CPA source directory]\n' "$0" >&2; exit 2 ;;
esac
source_dir="$(cd -- "${source_dir}" && pwd)"
patches=()
while IFS= read -r entry || [[ -n "${entry}" ]]; do
entry="${entry%$'\r'}"
[[ -z "${entry}" || "${entry}" == \#* ]] && continue
[[ "${entry}" != */* && -f "${plugin_root}/patch/${entry}" ]] || {
printf 'Invalid patch entry: %s\n' "${entry}" >&2; exit 1;
}
patches+=("${plugin_root}/patch/${entry}")
done < "${plugin_root}/patch/series"
(( ${#patches[@]} > 0 )) || { printf 'Patch series is empty.\n' >&2; exit 1; }
# Build and reverse the complete series in a disposable index. The working tree
# and its real index are not involved in checking whether the series applies.
scratch="$(mktemp -d)"
trap 'rm -f -- "${scratch}/index" "${scratch}/index.lock"; rmdir -- "${scratch}"' EXIT
index_git() { GIT_INDEX_FILE="${scratch}/index" git -C "${source_dir}" "$@"; }
base_tree="$(git -C "${source_dir}" rev-parse 'HEAD^{tree}')"
index_git read-tree HEAD
for patch_file in "${patches[@]}"; do
index_git apply --cached "${patch_file}"
done
patched_tree="$(index_git write-tree)"
for ((i=${#patches[@]}-1; i>=0; i--)); do
index_git apply --cached --reverse "${patches[i]}"
done
[[ "$(index_git write-tree)" == "${base_tree}" ]] || {
printf 'Reversing the series did not restore the baseline.\n' >&2; exit 1;
}
if [[ "${action}" == check ]]; then
printf 'Patch series applies and reverses cleanly on %s.\n' "$(git -C "${source_dir}" rev-parse --short=8 HEAD)"
exit 0
fi
# Release verification includes tracked changes and untracked additions. Use only
# a disposable index; never stage generated patch files in the real index.
if [[ "${action}" == verify ]]; then
index_git read-tree "${patched_tree}"
index_git add -A -- .
current_tree="$(index_git write-tree)"
if [[ "${current_tree}" != "${patched_tree}" ]]; then
printf 'CPA worktree differs from patch/series:\n' >&2
index_git diff --cached --stat "${patched_tree}" >&2
exit 1
fi
printf 'CPA worktree matches patch/series.\n'
exit 0
fi
# Compare only paths owned by the series. Refuse to overwrite changes in those
# paths, while leaving unrelated work and the real Git index untouched.
mapfile -d '' -t paths < <(git -C "${source_dir}" diff-tree -r --no-commit-id --name-only -z "${base_tree}" "${patched_tree}")
present_paths=()
for path in "${paths[@]}"; do
if [[ -e "${source_dir}/${path}" ]] || index_git ls-files --error-unmatch -- "${path}" >/dev/null 2>&1; then
present_paths+=("${path}")
fi
done
if (( ${#present_paths[@]} > 0 )); then index_git add -A -- "${present_paths[@]}"; fi
current_tree="$(index_git write-tree)"
if [[ "${action}" == apply ]]; then
[[ "${current_tree}" != "${patched_tree}" ]] || { printf 'Patch series is already applied.\n'; exit 0; }
[[ "${current_tree}" == "${base_tree}" ]] || { printf 'Patched paths contain unexpected changes; refusing to overwrite them.\n' >&2; exit 1; }
for patch_file in "${patches[@]}"; do git -C "${source_dir}" apply "${patch_file}"; done
else
[[ "${current_tree}" != "${base_tree}" ]] || { printf 'Patch series is already reversed.\n'; exit 0; }
[[ "${current_tree}" == "${patched_tree}" ]] || { printf 'Patched paths contain unexpected changes; refusing to overwrite them.\n' >&2; exit 1; }
for ((i=${#patches[@]}-1; i>=0; i--)); do git -C "${source_dir}" apply --reverse "${patches[i]}"; done
fi
printf 'Patch series %s completed.\n' "${action}"
+3 -11
View File
@@ -6,16 +6,8 @@ version="${1:-0.1.0}"
target="linux_amd64"
name="billing_${version}_${target}"
artifact="$root/bin/billing.so"
patch_suffix=""
while IFS= read -r entry || [[ -n "$entry" ]]; do
entry="${entry%$'\r'}"
[[ -z "$entry" || "$entry" == \#* ]] && continue
[[ "$entry" == cli-proxy-api-*.patch && "$entry" != */* && -f "$root/patch/$entry" ]] || { echo "Invalid patch entry: $entry" >&2; exit 1; }
label="${entry#cli-proxy-api-}"
patch_suffix+="+${label%.patch}"
done < "$root/patch/series"
[[ -n "$patch_suffix" ]] || { echo "Patch series is empty." >&2; exit 1; }
cpa_revision="$(git -C "$root/.externals/CLIProxyAPI" rev-parse HEAD)"
[[ -z "$(git -C "$root/.externals/CLIProxyAPI" status --porcelain)" ]] || { echo "CPA source must be a clean upstream checkout." >&2; exit 1; }
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid version: $version" >&2
@@ -39,8 +31,8 @@ printf '%s\n' \
"version=$version" \
"target=linux/amd64" \
"native_abi=1" \
"rpc_schema=7" \
"cliproxyapi_revision=${cpa_revision}${patch_suffix}" \
"rpc_schema=6" \
"cliproxyapi_revision=${cpa_revision}" \
>"$package_dir/VERSION.txt"
archive="$root/dist/$name.tar.gz"
+3 -1
View File
@@ -121,7 +121,9 @@ try {
Invoke-NativeCommand $git @('-C', $hostRoot, 'diff', '--check')
$wslRepoRoot = Get-NativeOutput $wsl @('-d', $WslDistribution, '--', 'wslpath', '-a', ($repoRoot -replace '\\', '/'))
Invoke-NativeCommand $wsl @('-d', $WslDistribution, '--', 'bash', "$wslRepoRoot/scripts/host-patches.sh", 'verify')
if (-not [string]::IsNullOrWhiteSpace((Get-NativeOutput $git @('-C', $hostRoot, 'status', '--porcelain')))) {
throw 'CPA source must be a clean upstream checkout.'
}
$archiveName = "cli-proxy-api-$version-$commit-linux-amd64.tar.gz"
$archivePath = Join-Path $deployRoot $archiveName
$checksumPath = "$archivePath.sha256"