Skip to content

[Bug] Native c-shared plugins never receive usage.handle #5244

Description

@BigiKoCcc

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

  1. Build any c-shared plugin whose plugin.register response contains
    "capabilities": {"usage_plugin": true, ...}.
  2. Load it (plugins.enabled: true, plugins.dir, plugins.configs.<id>).
    The logs show plugin loaded and plugin registered as expected.
  3. Send a normal completion request that produces usage.
  4. 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:

  1. dynamicLibraryClient.Call returns context.Canceled before the plugin is
    ever entered.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions