Skip to content

fix(ai): bill and credit-check the session's user, not a re-decoded token - #1200

Open
data-bot-coasys wants to merge 1 commit into
devfrom
fix/1175-billing-session-user
Open

data-bot-coasys wants to merge 1 commit into
devfrom
fix/1175-billing-session-user

Conversation

@data-bot-coasys

Copy link
Copy Markdown
Contributor

Stacked on #1167 (fix/user-token-node-operations). Only the last commit is this PR.

What was wrong

At e9dfb4e (the head of #1167), every AI billing path decoded the caller's token a second time to find the user:

  • rust-executor/src/ai_service/mod.rs:1789 and :1811 — bill_prompt_if_authed / bill_embed_if_authed run after the model call and call user_email_from_token on the token they were handed.
  • rust-executor/src/api/ai_ws.rs:23 — check_compute_credits_ws takes the token and decodes it.
  • rust-executor/src/api/openai_compat/chat.rs:542 (the user_email helper used by chat, completions, the streaming path and native_tools.rs:57), embeddings.rs:40, audio.rs:99 and :125 — the /v1 handlers decode the token for their credit check.
  • rust-executor/src/api/auth.rs:23 — AuthContext::to_request_context decoded once more.

An expired token does not decode. So when a token expires during a long prompt, the hook finds no user and charges nobody. And on a WS connection whose token expired after it opened, the credit check finds no user and lets a user with zero credits through to the model.

What changed

The user is resolved once, when the request is authenticated, and everything downstream reads that:

  • api/auth.rs — AuthContext gets user_email, set in from_request_parts; to_request_context copies it instead of decoding again.
  • ai_service/mod.rs — prompt, prompt_messages, prompt_messages_stream, prompt_with_tools, embed and bill_and_forward_stream_result take user_email: Option<String> instead of auth_token. The two hooks take Option<&str> and no longer touch user_email_from_token. None still means "bill nobody", so the internal callers that passed None are unchanged.
  • api/ai_ws.rs — check_compute_credits_ws(ctx.user_email.as_deref()) at all four call sites; ai.prompt / ai.embed pass ctx.user_email.
  • api/openai_compat/{chat,native_tools,embeddings,audio}.rs — the credit checks and the AI service calls use auth.user_email; the user_email(&auth) helper is gone.
  • api/openai_compat/harness_bridge.rs + perspectives/interpretation/run.rs — the bridge carries the billed user. The WS harness path bills context.user_email when it was given a token; the auto-processor passes no token and still bills nobody.

Out of scope, left as is: the transcription stream (open_transcription_stream, transcribe_buffer, verify_stream_ownership) still takes the token, because it also uses it for stream ownership and the DID. Not in the issue's list.

Red before, green after

Both tests were written and run before the fix, on e9dfb4e + the tests only:

test api::ai_ws::credit_check_tests::a_session_whose_token_expired_is_still_credit_checked ... FAILED
test api::ai_ws::credit_check_tests::a_session_with_credits_passes_the_check ... ok
test api::openai_compat::tests::embeddings_credit_check_uses_the_session_user ... FAILED

---- api::ai_ws::credit_check_tests::a_session_whose_token_expired_is_still_credit_checked stdout ----
assertion `left == right` failed: got 500: Service not initialized
  left: 500
 right: 403

---- api::openai_compat::tests::embeddings_credit_check_uses_the_session_user stdout ----
assertion `left == right` failed: got 500 Internal Server Error: Service not initialized
  left: 500
 right: 429

500 "Service not initialized" is the model call: the zero-credit user got past the credit check. With the fix both return the credit error (403 on WS, 429 on /v1) before reaching the service.

The tests build a session the way the dispatcher does (user resolved, token since expired: test_utils::expired_user_token signs a JWT with the node's key and exp an hour in the past, and each test asserts the token no longer decodes). The control test in credit_check_tests shows the same session with credits passes the check.

The three existing streaming-billing tests in openai_compat/tests.rs now pass the user instead of minting a JWT; the JWT helper they used is removed.

How to verify

cargo test --release -p ad4m-executor --lib -- --test-threads=1 api::ai_ws api::openai_compat harness_bridge

141 tests pass locally. To see the red state: check out origin/fix/user-token-node-operations, cherry-pick only the test hunks of this commit (ai_ws.rs credit_check_tests, test_utils.rs, the embeddings_credit_check_uses_the_session_user test with the user_email field removed from its AuthContext literal), and run the same command.

Fixes #1175

🤖 Generated with Claude Code

…oken

The AI billing hooks (bill_prompt_if_authed, bill_embed_if_authed), the
WS credit check (check_compute_credits_ws) and the /v1 chat, embeddings
and audio handlers each decoded the caller's token again to find the
user. An expired token does not decode, so a user whose token expired
during a long prompt was charged nothing, and a user without credits
whose token had expired since the connection opened passed the credit
check and reached the model.

Every request already resolves its user once, when it is authenticated
(RequestContext::user_email for WS sessions). This commit gives the /v1
extractor the same field (AuthContext::user_email, resolved in
from_request_parts) and passes the user, not the token, into the AI
service's prompt/embed methods, the billing hooks, the credit checks and
the harness bridge. Internal callers that passed None still pass None.

Tests, red before this fix:
- api::ai_ws::credit_check_tests: a session with an expired token and no
  credits got 500 "Service not initialized" instead of 403.
- api::openai_compat::tests::embeddings_credit_check_uses_the_session_user:
  same, 500 instead of 429.

Fixes #1175

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 20 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 86546e8d-a6f4-4df5-8ad5-12778ab201bd

📥 Commits

Reviewing files that changed from the base of the PR and between d3a5596 and 69ba850.


📒 Files selected for processing (11)
  • rust-executor/src/ai_service/mod.rs
  • rust-executor/src/api/ai_ws.rs
  • rust-executor/src/api/auth.rs
  • rust-executor/src/api/openai_compat/audio.rs
  • rust-executor/src/api/openai_compat/chat.rs
  • rust-executor/src/api/openai_compat/embeddings.rs
  • rust-executor/src/api/openai_compat/harness_bridge.rs
  • rust-executor/src/api/openai_compat/native_tools.rs
  • rust-executor/src/api/openai_compat/tests.rs
  • rust-executor/src/perspectives/interpretation/run.rs
  • rust-executor/src/test_utils.rs


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI billing decodes the token again instead of using the session's user

1 participant