fix: 修复 CPA 用量与请求终态关联
This commit is contained in:
@@ -66,10 +66,10 @@ CLIProxyAPI 负责 HTTP 接入、协议转换、上游凭证和实际请求执
|
||||
|
||||
## 当前兼容目标
|
||||
|
||||
- CLIProxyAPI 源码:`.externals/CLIProxyAPI/`,检查时 revision 为 `f43aad7637ad813745bf7d341acb5663617570c5`
|
||||
- CLIProxyAPI 基线:`v7.2.132` / `78f0c4079e3e6273d65d03b5549cffc898703264`,需依次应用 `patch/` 中两项宿主补丁
|
||||
- 插件版本:`0.1.0`
|
||||
- Native ABI:`1`
|
||||
- RPC schema:最高 `3`,注册时按宿主版本向下协商
|
||||
- RPC schema:最高 `4`,注册时按宿主版本向下协商;schema v4 提供 Usage 请求身份
|
||||
- 插件 ID / 动态库文件名:`billing`
|
||||
- 已声明能力:`frontend_auth_provider`(独占)、`scheduler`、`request_interceptor`、`request_lifecycle_plugin`、`usage_plugin`、`management_api`
|
||||
|
||||
|
||||
@@ -26,20 +26,21 @@ CLIProxyAPI 会通过两个独立回调提供请求信息:
|
||||
| 事实 | 来源 | 主要内容 |
|
||||
| --- | --- | --- |
|
||||
| 请求终态 | `request.complete` | Request ID、开始与结束时间、成功/失败/拒绝/取消、状态码和错误 |
|
||||
| 最终用量 | `usage.handle` | 实际上游、模型、Token、延迟和 Usage 结果 |
|
||||
| 最终用量 | `usage.handle` | Request ID、Trace ID、实际上游、模型、Token、延迟和 Usage 结果 |
|
||||
|
||||
这两个回调可能乱序、重复或只到达其中一个,因此数据库分别保存原始事实,再建立请求明细查询投影。管理台看到的一行是查询结果,不会为了合并展示而修改原始事实或计费账目。
|
||||
|
||||
## 请求关联与独立用量
|
||||
|
||||
- Request ID 用于关联一次下游请求的生命周期。
|
||||
- 当前目标 CPA 的 Usage 契约不提供 Request ID 或 Execution ID;Request ID 和 Trace ID 来自独立的请求终态。
|
||||
- RPC schema v4 的 Usage 契约提供 Request ID 和 Trace ID,用于与请求终态精确关联。
|
||||
- 旧 schema 或历史 Usage 没有请求身份时,仍使用保守的唯一时间窗口匹配作为兼容路径。
|
||||
- 每条可区分的 Usage 事实分别保存,不会为了得到一条整齐记录而把多个用量相加。
|
||||
- 当前契约无法保证把每次上游重试稳定标记为某个 Execution ID,因此管理台不声明这种保证。
|
||||
- 没有 Usage 的拒绝、取消或失败请求仍然显示请求终态。
|
||||
- 只有 Usage、暂时没有终态的记录也可以单独显示,终态到达后投影会自动更新。
|
||||
|
||||
系统仅在模型和请求时间足够接近且匹配关系唯一时,将 Usage 与终态合并;存在并发歧义时宁可保留为两条,也不会错误关联到其他用户的请求。
|
||||
schema v4 记录按 Request ID 精确归并,不受同模型并发影响。只有旧 schema 或历史孤立记录才使用模型和请求时间匹配;存在并发歧义时宁可保留为两条,也不会错误关联到其他用户的请求。
|
||||
|
||||
## 请求结果
|
||||
|
||||
|
||||
@@ -178,6 +178,8 @@ func (a *App) handleUsage(raw []byte) ([]byte, error) {
|
||||
}
|
||||
// CPA wire 类型只存在于适配层,采集模块接收与协议无关的观察值。
|
||||
observed := collection.Record{
|
||||
RequestID: record.RequestID,
|
||||
TraceID: record.TraceID,
|
||||
RequestedAt: record.RequestedAt,
|
||||
APIKey: record.APIKey,
|
||||
AuthID: record.AuthID,
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestUsageRecordWireFieldsMatchTargetContract(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"Provider", "ExecutorType", "Model", "Alias", "APIKey", "AuthID", "AuthIndex", "AuthType",
|
||||
"RequestID", "TraceID", "Provider", "ExecutorType", "Model", "Alias", "APIKey", "AuthID", "AuthIndex", "AuthType",
|
||||
"Source", "ReasoningEffort", "ServiceTier", "Generate", "RequestedAt", "Latency", "TTFT",
|
||||
"Failed", "Failure", "Detail", "ResponseHeaders",
|
||||
}
|
||||
@@ -109,6 +109,38 @@ func TestUsageRecordWireFieldsMatchTargetContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageRecordIdentityReconcilesLifecycleExactly(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)
|
||||
}
|
||||
defer app.Shutdown()
|
||||
|
||||
startedAt := time.Now().UTC()
|
||||
completionRaw, _ := json.Marshal(RequestCompletion{
|
||||
RequestID: "request-identity", TraceID: "trace-identity", Model: "deepseek-v4-flash",
|
||||
StartedAt: startedAt, CompletedAt: startedAt.Add(time.Second), Outcome: RequestCompletionSucceeded,
|
||||
})
|
||||
if _, err := app.HandleMethod(MethodRequestComplete, completionRaw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usageRaw, _ := json.Marshal(UsageRecord{
|
||||
RequestID: "request-identity", 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
records, err := app.store.ListRecent(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 1 || records[0].RequestID != "request-identity" || records[0].TraceID != "trace-identity" || records[0].TotalTokens != 12 {
|
||||
t.Fatalf("records = %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureIsAtomicAndFiltersUsage(t *testing.T) {
|
||||
app := NewApp()
|
||||
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: true\n"))); err != nil {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
const (
|
||||
ABIVersion uint32 = 1
|
||||
SchemaVersion uint32 = 3
|
||||
SchemaVersion uint32 = 4
|
||||
PluginName = "billing"
|
||||
Version = "0.1.0"
|
||||
)
|
||||
@@ -226,6 +226,8 @@ type ManagementResponse struct {
|
||||
// UsageRecord mirrors CLIProxyAPI sdk/pluginapi. Keep it in sync with the
|
||||
// target host because exported field names are part of the JSON wire contract.
|
||||
type UsageRecord struct {
|
||||
RequestID string
|
||||
TraceID string
|
||||
Provider string
|
||||
ExecutorType string
|
||||
Model string
|
||||
|
||||
@@ -166,7 +166,7 @@ WHERE u.id=?`, usageID)
|
||||
}
|
||||
|
||||
func syncRequestProjection(ctx context.Context, tx *sql.Tx, requestID string) (bool, error) {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE request_detail_index
|
||||
SET lifecycle_request_id=?, request_id=?,
|
||||
managed_key_id=COALESCE(NULLIF((SELECT managed_key_id FROM usage_records WHERE id=request_detail_index.usage_id), ''),
|
||||
@@ -180,8 +180,15 @@ WHERE usage_id IN (SELECT id FROM usage_records WHERE request_id=?)`, requestID,
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("关联请求终态索引: %w", err)
|
||||
}
|
||||
linked, _ := result.RowsAffected()
|
||||
if linked > 0 {
|
||||
var linked bool
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM request_detail_index
|
||||
WHERE usage_id IN (SELECT id FROM usage_records WHERE request_id=?)
|
||||
)`, requestID).Scan(&linked); err != nil {
|
||||
return false, fmt.Errorf("确认请求终态索引关联: %w", err)
|
||||
}
|
||||
if linked {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE request_detail_index SET endpoint_kind=`+endpointKindSQL(`COALESCE(NULLIF((SELECT endpoint FROM usage_records WHERE id=request_detail_index.usage_id), ''), (SELECT endpoint FROM request_records WHERE request_id=?), '')`)+` WHERE usage_id IN (SELECT id FROM usage_records WHERE request_id=?)`, requestID, requestID, requestID, requestID, requestID); err != nil {
|
||||
return false, fmt.Errorf("更新请求端点索引: %w", err)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"billing/internal/collection"
|
||||
@@ -231,10 +232,10 @@ ON billing_ledger(managed_key_id, id DESC);
|
||||
`
|
||||
|
||||
const (
|
||||
// CLIProxyAPI UsageRecord currently has no RequestID. The 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.
|
||||
// Older CLIProxyAPI schemas have no RequestID in UsageRecord. 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.
|
||||
orphanLifecycleMatchWindow = 250 * time.Millisecond
|
||||
// Near-equal candidates are deliberately left separate instead of risking a
|
||||
// cross-request association under concurrent same-model traffic.
|
||||
@@ -243,8 +244,9 @@ const (
|
||||
|
||||
// SQLiteUsageRepository 使用单写连接保存事实,并用独立连接池承载只读查询。
|
||||
type SQLiteUsageRepository struct {
|
||||
db *sql.DB
|
||||
readDB *sql.DB
|
||||
db *sql.DB
|
||||
readDB *sql.DB
|
||||
projectionMu sync.Mutex
|
||||
}
|
||||
|
||||
func OpenSQLiteUsage(databasePath string) (*SQLiteUsageRepository, error) {
|
||||
@@ -356,6 +358,8 @@ func sqliteDSN(databasePath string) (string, error) {
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) Insert(ctx context.Context, record collection.Record) error {
|
||||
r.projectionMu.Lock()
|
||||
defer r.projectionMu.Unlock()
|
||||
eventKey := usageBillingEventKey(record)
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -468,6 +472,8 @@ UPDATE billing_cycles SET spent_micros=spent_micros+? WHERE managed_key_id=? AND
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) UpsertRequest(ctx context.Context, record collection.RequestRecord) error {
|
||||
r.projectionMu.Lock()
|
||||
defer r.projectionMu.Unlock()
|
||||
requestID := strings.TrimSpace(record.RequestID)
|
||||
if requestID == "" {
|
||||
return errors.New("request_id 不能为空")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -162,6 +163,126 @@ func TestSQLiteUsageMergesOrphanUsageWithLifecycleProjection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteUsageMergesExactRequestIdentityInEitherOrder(t *testing.T) {
|
||||
for _, order := range []string{"usage-first", "lifecycle-first"} {
|
||||
t.Run(order, func(t *testing.T) {
|
||||
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
startedAt := time.Date(2026, 8, 15, 18, 31, 43, 160_000_000, time.UTC)
|
||||
usage := collection.Record{
|
||||
ManagedKeyID: "key-exact", RequestID: "request-exact", TraceID: "trace-exact",
|
||||
RequestedAt: startedAt.Add(10 * time.Millisecond), APIKey: "exact-000000",
|
||||
Model: "deepseek-v4-flash", ExecutorType: "CodexExecutor", RequestType: "SSE",
|
||||
InputTokens: 90, OutputTokens: 15, TotalTokens: 105,
|
||||
}
|
||||
lifecycle := collection.RequestRecord{
|
||||
ManagedKeyID: "key-exact", RequestID: "request-exact", TraceID: "trace-exact",
|
||||
RequestedAt: startedAt, CompletedAt: startedAt.Add(time.Second),
|
||||
Model: "deepseek-v4-flash", SourceFormat: "openai-response", Stream: true,
|
||||
Outcome: "succeeded", StatusCode: 200, Endpoint: "/v1/responses",
|
||||
}
|
||||
|
||||
insertUsage := func() {
|
||||
if err := store.Insert(context.Background(), usage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
insertLifecycle := func() {
|
||||
if err := store.UpsertRequest(context.Background(), lifecycle); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if order == "usage-first" {
|
||||
insertUsage()
|
||||
insertLifecycle()
|
||||
} else {
|
||||
insertLifecycle()
|
||||
insertUsage()
|
||||
}
|
||||
|
||||
records, err := store.ListRecent(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %d, want one exact request: %+v", len(records), records)
|
||||
}
|
||||
got := records[0]
|
||||
if got.RequestID != "request-exact" || got.TraceID != "trace-exact" || got.Outcome != "succeeded" ||
|
||||
got.StatusCode != 200 || got.TotalTokens != 105 {
|
||||
t.Fatalf("merged exact record = %+v", got)
|
||||
}
|
||||
page, err := store.QueryUsage(context.Background(), collection.UsageQuery{RequestID: "request-exact", PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Records) != 1 || page.Records[0].TotalTokens != 105 {
|
||||
t.Fatalf("projected exact records = %+v", page)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteUsageMergesExactRequestIdentityConcurrently(t *testing.T) {
|
||||
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
const requestCount = 64
|
||||
startedAt := time.Date(2026, 8, 15, 18, 40, 0, 0, time.UTC)
|
||||
start := make(chan struct{})
|
||||
errors := make(chan error, requestCount*2)
|
||||
var workers sync.WaitGroup
|
||||
for index := 0; index < requestCount; index++ {
|
||||
requestID := fmt.Sprintf("request-exact-%03d", index)
|
||||
workers.Add(2)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
<-start
|
||||
errors <- store.Insert(context.Background(), collection.Record{
|
||||
ManagedKeyID: "key-exact", RequestID: requestID, TraceID: "trace-exact",
|
||||
RequestedAt: startedAt, Model: "deepseek-v4-flash", TotalTokens: 105,
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
<-start
|
||||
errors <- store.UpsertRequest(context.Background(), collection.RequestRecord{
|
||||
ManagedKeyID: "key-exact", RequestID: requestID, TraceID: "trace-exact",
|
||||
RequestedAt: startedAt, CompletedAt: startedAt.Add(time.Second),
|
||||
Model: "deepseek-v4-flash", Stream: true, Outcome: "succeeded", StatusCode: 200,
|
||||
})
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
workers.Wait()
|
||||
close(errors)
|
||||
for err := range errors {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := store.QueryUsage(context.Background(), collection.UsageQuery{KeyID: "key-exact", PageSize: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != requestCount || len(page.Records) != requestCount {
|
||||
t.Fatalf("projected records = %d/%d, want %d: %+v", len(page.Records), page.Total, requestCount, page.Records)
|
||||
}
|
||||
for _, record := range page.Records {
|
||||
if record.RequestID == "" || record.TotalTokens != 105 || record.Outcome != "succeeded" || record.StatusCode != 200 {
|
||||
t.Fatalf("incomplete concurrent merge = %+v", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteUsageMergesResolvedModelWithManagedKeyAlias(t *testing.T) {
|
||||
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
|
||||
if err != nil {
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# CLIProxyAPI 宿主补丁
|
||||
|
||||
这里保存 billing 在 CLIProxyAPI `v7.2.132` 上需要的三项宿主修复。三项补丁都不改变计费规则或数据库结构,必须按下列顺序应用:
|
||||
|
||||
1. [`cli-proxy-api-usage-context.patch`](cli-proxy-api-usage-context.patch)
|
||||
2. [`cli-proxy-api-usage-identity.patch`](cli-proxy-api-usage-identity.patch)
|
||||
3. [`cli-proxy-api-request-lifecycle-cancel.patch`](cli-proxy-api-request-lifecycle-cancel.patch)
|
||||
|
||||
适用基线:
|
||||
|
||||
- CLIProxyAPI:`v7.2.132`
|
||||
- 提交:`78f0c4079e3e6273d65d03b5549cffc898703264`
|
||||
- Native ABI:`1`
|
||||
- 第二项补丁把 RPC schema 从 `3` 提升到 `4`
|
||||
|
||||
升级 CLIProxyAPI 后不要直接假设补丁仍然需要或仍能应用。应先在新版本复测,再根据新版本的宿主契约重新核对。
|
||||
|
||||
## 1. 已取消上下文导致动态插件漏记
|
||||
|
||||
使用 `/v1/responses` 且请求体为 `"stream": false` 时,上游请求能够成功,CLIProxyAPI 内置用量统计也会增加,但动态用量插件可能完全收不到 `usage.handle`:
|
||||
|
||||
- billing 没有新增用量事实;
|
||||
- 用户余额没有扣减;
|
||||
- 内置统计和动态插件统计不一致。
|
||||
|
||||
CLIProxyAPI 会异步分发最终用量记录,但分发任务沿用了原始 HTTP 请求上下文。非流式响应很快结束后,该上下文会被取消;动态插件加载器在调用 C ABI 前检查 `ctx.Done()`,于是跳过插件调用。内置插件不经过动态 C ABI 检查,所以仍能收到记录。
|
||||
|
||||
第一项补丁在动态用量适配器入口使用 `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 v4:
|
||||
|
||||
- 请求生命周期创建后,把 Request ID 和父 Trace ID 写入实际执行上下文;
|
||||
- `usage.handle` 增加可选的 `RequestID` 与 `TraceID`;
|
||||
- 只有协商到 schema v4 的插件收到新增字段;
|
||||
- schema v1–v3 插件保持原有载荷,能够继续加载;
|
||||
- billing v4 按 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 内直连不经过该转发层。
|
||||
|
||||
## 应用
|
||||
|
||||
在干净的 CLIProxyAPI 工作区执行:
|
||||
|
||||
```bash
|
||||
git checkout 78f0c4079e3e6273d65d03b5549cffc898703264
|
||||
git apply --check /path/to/billing/patch/cli-proxy-api-usage-context.patch
|
||||
git apply /path/to/billing/patch/cli-proxy-api-usage-context.patch
|
||||
git apply --check /path/to/billing/patch/cli-proxy-api-usage-identity.patch
|
||||
git apply /path/to/billing/patch/cli-proxy-api-usage-identity.patch
|
||||
git apply --check /path/to/billing/patch/cli-proxy-api-request-lifecycle-cancel.patch
|
||||
git apply /path/to/billing/patch/cli-proxy-api-request-lifecycle-cancel.patch
|
||||
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
|
||||
CGO_ENABLED=1 go build -o test-output ./cmd/server
|
||||
rm test-output
|
||||
```
|
||||
|
||||
billing 必须使用 RPC schema v4 重新构建;旧 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。
|
||||
|
||||
## 已验证结果
|
||||
|
||||
三个补丁与 billing schema v4 使用真实 DeepSeek 上游完成了本地验证:
|
||||
|
||||
- `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 明细,并发占用立即回到零;
|
||||
- 仅前两项补丁与三项补丁在内置 Codex 执行器上的直连取消 A/B 都能正常终止;第三项补丁额外覆盖执行器启动阻塞且未响应 context 的单元场景;
|
||||
- 请求正常结束后 `active_requests=0`,空闲 5 秒没有持续 CPU 占用;
|
||||
- billing 全部测试、billing race 测试及 CLIProxyAPI 相关包 race 测试通过;
|
||||
- schema v3 对照插件仍能正常注册,新增字段不会发送给旧 schema。
|
||||
|
||||
CLIProxyAPI 全仓 `go test ./...` 在该基线自身仍有 5 个无关失败:3 个 Claude OS 指纹用例在 WSL 中得到 `MacOS` 而期望 `Linux`,以及 2 个 `reviewedInPlaceByteWrites` 清单陈旧项。补丁涉及的包及其 race 测试均通过。
|
||||
|
||||
## 性能影响
|
||||
|
||||
三项修复都不增加队列、重试或流式分块回调。每个最终用量和请求终态仍最多调用插件一次;新增工作是创建轻量上下文包装、在 schema v4 JSON 中增加两个短字符串,以及为活跃请求注册一个随正常完成立即撤销的标准库取消回调。并发验证中没有观察到持续 CPU 或内存增长。
|
||||
|
||||
## 回滚
|
||||
|
||||
源码必须按应用顺序的反方向回滚:
|
||||
|
||||
```bash
|
||||
git apply -R /path/to/billing/patch/cli-proxy-api-request-lifecycle-cancel.patch
|
||||
git apply -R /path/to/billing/patch/cli-proxy-api-usage-identity.patch
|
||||
git apply -R /path/to/billing/patch/cli-proxy-api-usage-context.patch
|
||||
```
|
||||
|
||||
部署回滚应恢复升级前保存的 CLIProxyAPI 和 billing 动态库后重启宿主。数据库不需要回滚;schema v4 只让新记录获得精确身份,不改变历史账目。
|
||||
@@ -0,0 +1,160 @@
|
||||
diff --git a/sdk/api/handlers/handlers_interceptors.go b/sdk/api/handlers/handlers_interceptors.go
|
||||
--- a/sdk/api/handlers/handlers_interceptors.go
|
||||
+++ b/sdk/api/handlers/handlers_interceptors.go
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
+ stdcontext "context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -66,6 +67,9 @@ type requestLifecycleSkipHost interface {
|
||||
|
||||
type requestLifecycleTracker struct {
|
||||
once sync.Once
|
||||
+ cancelMu sync.Mutex
|
||||
+ cancelStop func() bool
|
||||
+ completed bool
|
||||
ctx context.Context
|
||||
host PluginInterceptorHost
|
||||
skipPluginID string
|
||||
@@ -76,7 +80,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,
|
||||
@@ -91,6 +95,39 @@ func (h *BaseAPIHandler) newRequestLifecycleTracker(ctx context.Context, sourceF
|
||||
Metadata: metadata,
|
||||
},
|
||||
}
|
||||
+ tracker.watchCancellation(ctx)
|
||||
+ return tracker
|
||||
+}
|
||||
+
|
||||
+func (t *requestLifecycleTracker) watchCancellation(ctx context.Context) {
|
||||
+ if t == nil || ctx == nil || ctx.Done() == nil {
|
||||
+ return
|
||||
+ }
|
||||
+ stop := stdcontext.AfterFunc(ctx, func() {
|
||||
+ t.complete(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 {
|
||||
@@ -112,6 +149,7 @@ func (t *requestLifecycleTracker) complete(outcome pluginapi.RequestCompletionOu
|
||||
return
|
||||
}
|
||||
t.once.Do(func() {
|
||||
+ t.stopCancellationWatch()
|
||||
completion := t.completion
|
||||
completion.Outcome = outcome
|
||||
completion.StatusCode = statusCode
|
||||
diff --git a/sdk/api/handlers/handlers_interceptors_test.go b/sdk/api/handlers/handlers_interceptors_test.go
|
||||
--- a/sdk/api/handlers/handlers_interceptors_test.go
|
||||
+++ b/sdk/api/handlers/handlers_interceptors_test.go
|
||||
@@ -516,6 +516,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{}
|
||||
@@ -0,0 +1,60 @@
|
||||
diff --git a/internal/pluginhost/adapters_test.go b/internal/pluginhost/adapters_test.go
|
||||
index de62918e..302b1f38 100644
|
||||
--- a/internal/pluginhost/adapters_test.go
|
||||
+++ b/internal/pluginhost/adapters_test.go
|
||||
@@ -2234,6 +2234,39 @@ func TestUsageAdapterPanicFusesPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
+func TestUsageAdapterDetachesCanceledRequestContext(t *testing.T) {
|
||||
+ type contextKey struct{}
|
||||
+ const contextValue = "usage-context-value"
|
||||
+
|
||||
+ called := 0
|
||||
+ plugin := usagePluginFunc(func(ctx context.Context, record pluginapi.UsageRecord) {
|
||||
+ called++
|
||||
+ if errContext := ctx.Err(); errContext != nil {
|
||||
+ t.Fatalf("usage context error = %v, want nil", errContext)
|
||||
+ }
|
||||
+ if value := ctx.Value(contextKey{}); value != contextValue {
|
||||
+ t.Fatalf("usage context value = %v, want %q", value, contextValue)
|
||||
+ }
|
||||
+ })
|
||||
+ host := newHostWithRecords(capabilityRecord{
|
||||
+ id: "usage-canceled-context",
|
||||
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
|
||||
+ UsagePlugin: plugin,
|
||||
+ }},
|
||||
+ })
|
||||
+ adapter := &usageAdapter{
|
||||
+ host: host,
|
||||
+ pluginID: "usage-canceled-context",
|
||||
+ }
|
||||
+
|
||||
+ ctx, cancel := context.WithCancel(context.WithValue(context.Background(), contextKey{}, contextValue))
|
||||
+ cancel()
|
||||
+ adapter.HandleUsage(ctx, coreusage.Record{Provider: "plugin-provider"})
|
||||
+ if called != 1 {
|
||||
+ t.Fatalf("usage plugin calls = %d, want 1", called)
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
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 2201eb6c..93a4c8a5 100644
|
||||
--- a/internal/pluginhost/adapters_usage_translation.go
|
||||
+++ b/internal/pluginhost/adapters_usage_translation.go
|
||||
@@ -132,6 +132,11 @@ func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record)
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
+ if ctx == nil {
|
||||
+ ctx = context.Background()
|
||||
+ } else {
|
||||
+ ctx = context.WithoutCancel(ctx)
|
||||
+ }
|
||||
plugin := a.host.currentUsagePlugin(a.pluginID)
|
||||
if plugin == nil {
|
||||
return
|
||||
@@ -0,0 +1,348 @@
|
||||
diff --git a/internal/pluginhost/adapters_test.go b/internal/pluginhost/adapters_test.go
|
||||
index 302b1f38..610a3753 100644
|
||||
--- a/internal/pluginhost/adapters_test.go
|
||||
+++ b/internal/pluginhost/adapters_test.go
|
||||
@@ -2266,6 +2266,54 @@ func TestUsageAdapterDetachesCanceledRequestContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
+func TestUsageAdapterIncludesRequestIdentityForSchemaFour(t *testing.T) {
|
||||
+ var got pluginapi.UsageRecord
|
||||
+ plugin := usagePluginFunc(func(ctx context.Context, record pluginapi.UsageRecord) {
|
||||
+ got = record
|
||||
+ })
|
||||
+ host := newHostWithRecords(capabilityRecord{
|
||||
+ id: "usage-identity",
|
||||
+ plugin: pluginapi.Plugin{SchemaVersion: pluginabi.SchemaVersionUsageIdentity, Capabilities: pluginapi.Capabilities{
|
||||
+ UsagePlugin: plugin,
|
||||
+ }},
|
||||
+ })
|
||||
+ adapter := &usageAdapter{
|
||||
+ host: host,
|
||||
+ pluginID: "usage-identity",
|
||||
+ schemaVersion: pluginabi.SchemaVersionUsageIdentity,
|
||||
+ }
|
||||
+
|
||||
+ ctx := coreusage.WithRequestIdentity(context.Background(), "request-1", "trace-1")
|
||||
+ adapter.HandleUsage(ctx, coreusage.Record{Provider: "provider"})
|
||||
+ if got.RequestID != "request-1" || got.TraceID != "trace-1" {
|
||||
+ t.Fatalf("usage identity = %q, %q", got.RequestID, got.TraceID)
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+func TestUsageAdapterOmitsRequestIdentityForLegacySchema(t *testing.T) {
|
||||
+ var got pluginapi.UsageRecord
|
||||
+ plugin := usagePluginFunc(func(ctx context.Context, record pluginapi.UsageRecord) {
|
||||
+ got = record
|
||||
+ })
|
||||
+ host := newHostWithRecords(capabilityRecord{
|
||||
+ id: "usage-identity-legacy",
|
||||
+ plugin: pluginapi.Plugin{SchemaVersion: pluginabi.SchemaVersionUsageIdentity - 1, Capabilities: pluginapi.Capabilities{
|
||||
+ UsagePlugin: plugin,
|
||||
+ }},
|
||||
+ })
|
||||
+ adapter := &usageAdapter{
|
||||
+ host: host,
|
||||
+ pluginID: "usage-identity-legacy",
|
||||
+ schemaVersion: pluginabi.SchemaVersionUsageIdentity - 1,
|
||||
+ }
|
||||
+
|
||||
+ ctx := coreusage.WithRequestIdentity(context.Background(), "request-1", "trace-1")
|
||||
+ adapter.HandleUsage(ctx, coreusage.Record{Provider: "provider"})
|
||||
+ if got.RequestID != "" || got.TraceID != "" {
|
||||
+ t.Fatalf("legacy usage identity = %q, %q", got.RequestID, got.TraceID)
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
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 93a4c8a5..ad046780 100644
|
||||
--- a/internal/pluginhost/adapters_usage_translation.go
|
||||
+++ b/internal/pluginhost/adapters_usage_translation.go
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
|
||||
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -26,9 +27,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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -114,9 +116,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 {
|
||||
@@ -146,7 +149,13 @@ func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record)
|
||||
a.host.fusePlugin(a.pluginID, "UsagePlugin.HandleUsage", recovered)
|
||||
}
|
||||
}()
|
||||
+ requestID, traceID := "", ""
|
||||
+ if a.schemaVersion >= pluginabi.SchemaVersionUsageIdentity {
|
||||
+ requestID, traceID = coreusage.RequestIdentityFromContext(ctx)
|
||||
+ }
|
||||
plugin.HandleUsage(ctx, pluginapi.UsageRecord{
|
||||
+ RequestID: requestID,
|
||||
+ TraceID: traceID,
|
||||
Provider: record.Provider,
|
||||
ExecutorType: record.ExecutorType,
|
||||
Model: record.Model,
|
||||
diff --git a/sdk/api/handlers/handlers_execution.go b/sdk/api/handlers/handlers_execution.go
|
||||
index 994bd33a..3cc7743e 100644
|
||||
--- a/sdk/api/handlers/handlers_execution.go
|
||||
+++ b/sdk/api/handlers/handlers_execution.go
|
||||
@@ -71,6 +71,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,
|
||||
@@ -136,6 +137,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,
|
||||
@@ -177,6 +179,7 @@ func (h *BaseAPIHandler) executeWithPluginExecutor(ctx context.Context, entryPro
|
||||
}
|
||||
req, opts := h.pluginExecutorRequest(ctx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, false, execOptions)
|
||||
lifecycle := h.newRequestLifecycleTracker(ctx, entryProtocol, modelName, originalRequestedModel, false, opts.Metadata, execOptions.SkipInterceptorPluginID)
|
||||
+ ctx = lifecycle.executionContext()
|
||||
var interceptErr *interfaces.ErrorMessage
|
||||
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(ctx, entryProtocol, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
|
||||
if interceptErr != nil {
|
||||
@@ -211,6 +214,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 a8b35604..d27198c9 100644
|
||||
--- a/sdk/api/handlers/handlers_interceptors.go
|
||||
+++ b/sdk/api/handlers/handlers_interceptors.go
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
||||
+ coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
@@ -74,6 +75,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(),
|
||||
@@ -91,6 +93,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 0b328f3e..1ad85527 100644
|
||||
--- a/sdk/api/handlers/handlers_interceptors_test.go
|
||||
+++ b/sdk/api/handlers/handlers_interceptors_test.go
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
||||
+ coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
@@ -230,6 +231,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 9cceec54..dcdc08df 100644
|
||||
--- a/sdk/api/handlers/handlers_stream.go
|
||||
+++ b/sdk/api/handlers/handlers_stream.go
|
||||
@@ -41,6 +41,7 @@ func (h *BaseAPIHandler) streamWithPluginExecutor(ctx context.Context, entryProt
|
||||
}
|
||||
req, opts := h.pluginExecutorRequest(ctx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, true, execOptions)
|
||||
lifecycle := h.newRequestLifecycleTracker(ctx, entryProtocol, modelName, originalRequestedModel, true, opts.Metadata, execOptions.SkipInterceptorPluginID)
|
||||
+ ctx = lifecycle.executionContext()
|
||||
var interceptErr *interfaces.ErrorMessage
|
||||
req, opts, interceptErr = h.applyRequestInterceptorsBeforeAuth(ctx, entryProtocol, originalRequestedModel, lifecycle.requestID(), req, opts, execOptions.SkipInterceptorPluginID)
|
||||
if interceptErr != nil {
|
||||
@@ -305,6 +306,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 ca36dc55..13506fbb 100644
|
||||
--- a/sdk/cliproxy/usage/manager.go
|
||||
+++ b/sdk/cliproxy/usage/manager.go
|
||||
@@ -78,6 +78,34 @@ type requestedModelAliasContextKey struct{}
|
||||
type reasoningEffortContextKey struct{}
|
||||
type serviceTierContextKey struct{}
|
||||
type generateContextKey struct{}
|
||||
+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 {
|
||||
diff --git a/sdk/cliproxy/usage/manager_test.go b/sdk/cliproxy/usage/manager_test.go
|
||||
index 6f7b1fbb..fc2b3120 100644
|
||||
--- a/sdk/cliproxy/usage/manager_test.go
|
||||
+++ b/sdk/cliproxy/usage/manager_test.go
|
||||
@@ -36,6 +36,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 97c41a13..a8a3e470 100644
|
||||
--- a/sdk/pluginabi/types.go
|
||||
+++ b/sdk/pluginabi/types.go
|
||||
@@ -10,10 +10,13 @@ const (
|
||||
// Version 3 omits OriginalRequest/RequestBody on payload stream chunks
|
||||
// (ChunkIndex >= 0); those fields remain on StreamChunkHeaderInitIndex only.
|
||||
// Plugins that still need per-chunk request bodies should keep schema_version < 3.
|
||||
- SchemaVersion uint32 = 3
|
||||
+ SchemaVersion uint32 = 4
|
||||
// SchemaVersionStreamChunkOmitRequestBody is the first schema version that omits
|
||||
// request bodies on payload stream-chunk interceptor calls.
|
||||
SchemaVersionStreamChunkOmitRequestBody uint32 = 3
|
||||
+ // SchemaVersionUsageIdentity is the first schema version that attaches
|
||||
+ // request lifecycle identity to usage records.
|
||||
+ SchemaVersionUsageIdentity uint32 = 4
|
||||
)
|
||||
|
||||
const (
|
||||
diff --git a/sdk/pluginabi/types_test.go b/sdk/pluginabi/types_test.go
|
||||
index 8fa63542..86253848 100644
|
||||
--- a/sdk/pluginabi/types_test.go
|
||||
+++ b/sdk/pluginabi/types_test.go
|
||||
@@ -27,12 +27,15 @@ func TestEnvelopeRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMethodNamesAreStable(t *testing.T) {
|
||||
- if SchemaVersion != 3 {
|
||||
- t.Fatalf("SchemaVersion = %d, want 3", SchemaVersion)
|
||||
+ if SchemaVersion != 4 {
|
||||
+ t.Fatalf("SchemaVersion = %d, want 4", SchemaVersion)
|
||||
}
|
||||
if SchemaVersionStreamChunkOmitRequestBody != 3 {
|
||||
t.Fatalf("SchemaVersionStreamChunkOmitRequestBody = %d, want 3", SchemaVersionStreamChunkOmitRequestBody)
|
||||
}
|
||||
+ if SchemaVersionUsageIdentity != 4 {
|
||||
+ t.Fatalf("SchemaVersionUsageIdentity = %d, want 4", SchemaVersionUsageIdentity)
|
||||
+ }
|
||||
if MethodPluginRegister != "plugin.register" {
|
||||
t.Fatalf("MethodPluginRegister = %q", MethodPluginRegister)
|
||||
}
|
||||
diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go
|
||||
index 6add5d69..894e3b77 100644
|
||||
--- a/sdk/pluginapi/types.go
|
||||
+++ b/sdk/pluginapi/types.go
|
||||
@@ -1316,6 +1316,10 @@ type ManagementResponse struct {
|
||||
|
||||
// UsageRecord describes request usage and billing metadata.
|
||||
type UsageRecord struct {
|
||||
+ // RequestID identifies the request lifecycle associated with this usage record.
|
||||
+ RequestID string
|
||||
+ // TraceID identifies the parent inbound HTTP request when available.
|
||||
+ TraceID string
|
||||
// Provider identifies the upstream provider.
|
||||
Provider string
|
||||
// ExecutorType identifies the executor implementation.
|
||||
@@ -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 3'
|
||||
Write-Host 'CLIProxyAPI target: ABI 1 / RPC schema 4'
|
||||
Write-Warning '本项目默认在 WSL/Linux 构建;请优先运行 wsl bash ./scripts/build.sh。'
|
||||
|
||||
@@ -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 3'
|
||||
echo 'CLIProxyAPI target: ABI 1 / RPC schema 4'
|
||||
|
||||
+2
-2
@@ -29,8 +29,8 @@ printf '%s\n' \
|
||||
"version=$version" \
|
||||
"target=linux/amd64" \
|
||||
"native_abi=1" \
|
||||
"rpc_schema=3" \
|
||||
"cliproxyapi_revision=f43aad7637ad813745bf7d341acb5663617570c5" \
|
||||
"rpc_schema=4" \
|
||||
"cliproxyapi_revision=78f0c4079e3e6273d65d03b5549cffc898703264+usage-context+usage-identity+request-lifecycle-cancel" \
|
||||
>"$package_dir/VERSION.txt"
|
||||
|
||||
archive="$root/dist/$name.tar.gz"
|
||||
|
||||
@@ -16,6 +16,10 @@ if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
kill -0 "$pid" 2>/dev/null || break
|
||||
sleep 0.25
|
||||
done
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "CPA test host did not stop within 5 seconds (PID $pid)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
echo 'CPA test host stopped.'
|
||||
|
||||
Reference in New Issue
Block a user