Native (c-shared) plugins never receive usage.handle
Affected: CLIProxyAPI v7.2.141 (commit dc3c3b1), Linux amd64
Impact: any native plugin declaring usage_plugin silently receives no usage
records at all. Billing/quota plugins therefore see zero spend forever.
Fix: one line, and it matches what CPA already does for request.complete.
Summary
Usage records are dispatched asynchronously, but the dispatch carries the
HTTP request's context. By the time the usage worker runs, the response has
been sent and that context is cancelled. The native-plugin transport refuses a
cancelled context, and the caller discards the error, so the record is dropped
without a single log line.
In-process Go usage plugins (such as the built-in redisqueue) are unaffected,
because they are called directly rather than through the context-checking RPC.
That is why the problem is invisible in normal operation.
Reproduction
- Build any c-shared plugin whose
plugin.register response contains
"capabilities": {"usage_plugin": true, ...}.
- Load it (
plugins.enabled: true, plugins.dir, plugins.configs.<id>).
The logs show plugin loaded and plugin registered as expected.
- Send a normal completion request that produces usage.
- Observe:
GET /v0/management/usage-queue contains the record, so CPA itself
accounted for it — but the plugin's usage.handle is never invoked.
Counting RPC methods inside the plugin's dispatcher over three requests:
frontend_auth.authenticate 3
frontend_auth.identifier 11
management.handle 1
management.register 5
plugin.reconfigure 5
plugin.register 1
request.complete 3
request.intercept_after 3
request.intercept_before 3
usage.handle 0 <-- never called
Every other hook fires. Only usage does not.
Root cause
usage.Manager.Publish enqueues the record together with the caller's context
and returns; a worker goroutine dispatches later:
// sdk/cliproxy/usage/manager.go
func (m *Manager) Publish(ctx context.Context, record Record) {
...
m.queue = append(m.queue, queueItem{ctx: ctx, record: record})
...
}
The caller is the executor's usage reporter, whose ctx is the gin request
context. Once the response is written, gin cancels it.
The adapter then hands that cancelled context to the plugin:
// internal/pluginhost/adapters_usage_translation.go
func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record) {
...
plugin.HandleUsage(ctx, pluginapi.UsageRecord{...})
}
For native plugins that lands in:
// internal/pluginhost/rpc_client.go:539
func (a *rpcPluginAdapter) HandleUsage(ctx context.Context, record pluginapi.UsageRecord) {
_, _ = callPlugin[rpcEmptyResponse](ctx, a.client, pluginabi.MethodUsageHandle, record)
}
and finally:
// internal/pluginhost/loader_unix.go:169
func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) {
...
if ctx != nil {
select {
case <-ctx.Done():
return nil, ctx.Err() // request is abandoned here
default:
}
}
Two things combine to make this silent:
dynamicLibraryClient.Call returns context.Canceled before the plugin is
ever entered.
rpcPluginAdapter.HandleUsage discards both return values (_, _ =), so
nothing is logged and nothing is retried.
Adding a temporary log line to the adapter confirms it directly:
DIAG usageAdapter.HandleUsage: dispatching to <plugin-id> (ctx.Err=context canceled)
Suggested fix
Detach the context at the adapter, exactly as CompleteRequestExcept already
does for the request-lifecycle hook:
// internal/pluginhost/adapters_usage_translation.go
func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record) {
if a == nil {
return
}
plugin := a.host.currentUsagePlugin(a.pluginID)
if plugin == nil {
return
}
if ctx == nil {
ctx = context.Background()
} else {
ctx = context.WithoutCancel(ctx)
}
...
}
For comparison, internal/pluginhost/adapters_interceptors.go already contains:
if ctx == nil {
ctx = context.Background()
} else {
ctx = context.WithoutCancel(ctx)
}
so this looks like one call path that was missed rather than a deliberate
difference.
Verification
With only that change applied and CPA rebuilt from the same commit, the plugin
receives the record and accounts for it correctly on the first try:
plugin received: APIKey="cpa-quota-control:key:1" Provider="openai-compatible-..."
Model="gpt-5.4" in=1000000 out=100000 -> settled 4000000000 nUSD
and a budget of $10 with a $4.00-per-request workload now behaves as intended:
request 2: HTTP 200 spent $8.00 remaining $2.00
request 3: HTTP 200 spent $12.00 remaining $0.00
request 4: HTTP 429 spent $12.00 remaining $0.00 (refused)
Secondary suggestion
Independent of the fix above, rpcPluginAdapter.HandleUsage swallowing the
error made this take far longer to find than it should have. Even a
log.Debugf on failure would have surfaced it immediately:
if _, err := callPlugin[rpcEmptyResponse](ctx, a.client, pluginabi.MethodUsageHandle, record); err != nil {
log.Debugf("pluginhost: usage.handle to %s failed: %v", a.id, err)
}
Environment
- CLIProxyAPI v7.2.141, commit
dc3c3b1, official eceasy/cli-proxy-api:latest
- Linux amd64, Docker
- Plugin: Go
c-shared, schema_version 3, capabilities include
usage_plugin, request_interceptor, frontend_auth_provider,
management_api
- Reproduced with an
openai-compatibility provider pointing at a local stub
upstream, so no real provider credentials are involved.
Native (c-shared) plugins never receive
usage.handleAffected: CLIProxyAPI v7.2.141 (commit
dc3c3b1), Linux amd64Impact: any native plugin declaring
usage_pluginsilently receives no usagerecords at all. Billing/quota plugins therefore see zero spend forever.
Fix: one line, and it matches what CPA already does for
request.complete.Summary
Usage records are dispatched asynchronously, but the dispatch carries the
HTTP request's context. By the time the usage worker runs, the response has
been sent and that context is cancelled. The native-plugin transport refuses a
cancelled context, and the caller discards the error, so the record is dropped
without a single log line.
In-process Go usage plugins (such as the built-in
redisqueue) are unaffected,because they are called directly rather than through the context-checking RPC.
That is why the problem is invisible in normal operation.
Reproduction
plugin.registerresponse contains"capabilities": {"usage_plugin": true, ...}.plugins.enabled: true,plugins.dir,plugins.configs.<id>).The logs show
plugin loadedandplugin registeredas expected.GET /v0/management/usage-queuecontains the record, so CPA itselfaccounted for it — but the plugin's
usage.handleis never invoked.Counting RPC methods inside the plugin's dispatcher over three requests:
Every other hook fires. Only usage does not.
Root cause
usage.Manager.Publishenqueues the record together with the caller's contextand returns; a worker goroutine dispatches later:
The caller is the executor's usage reporter, whose
ctxis the gin requestcontext. Once the response is written, gin cancels it.
The adapter then hands that cancelled context to the plugin:
For native plugins that lands in:
and finally:
Two things combine to make this silent:
dynamicLibraryClient.Callreturnscontext.Canceledbefore the plugin isever entered.
rpcPluginAdapter.HandleUsagediscards both return values (_, _ =), sonothing is logged and nothing is retried.
Adding a temporary log line to the adapter confirms it directly:
Suggested fix
Detach the context at the adapter, exactly as
CompleteRequestExceptalreadydoes for the request-lifecycle hook:
For comparison,
internal/pluginhost/adapters_interceptors.goalready contains:so this looks like one call path that was missed rather than a deliberate
difference.
Verification
With only that change applied and CPA rebuilt from the same commit, the plugin
receives the record and accounts for it correctly on the first try:
and a budget of $10 with a $4.00-per-request workload now behaves as intended:
Secondary suggestion
Independent of the fix above,
rpcPluginAdapter.HandleUsageswallowing theerror made this take far longer to find than it should have. Even a
log.Debugfon failure would have surfaced it immediately:Environment
dc3c3b1, officialeceasy/cli-proxy-api:latestc-shared, schema_version 3, capabilities includeusage_plugin,request_interceptor,frontend_auth_provider,management_apiopenai-compatibilityprovider pointing at a local stubupstream, so no real provider credentials are involved.