Skip to content

feat(token_count): add Bedrock InvokeModel header-based extraction#240

Open
mkoushni wants to merge 1 commit into
praxis-proxy:mainfrom
mkoushni:feat/210-response-based-token-counting-from-json
Open

feat(token_count): add Bedrock InvokeModel header-based extraction#240
mkoushni wants to merge 1 commit into
praxis-proxy:mainfrom
mkoushni:feat/210-response-based-token-counting-from-json

Conversation

@mkoushni

@mkoushni mkoushni commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the provider: bedrock_invoke_model extraction path required by proposal #210 for the token_count filter.

Context

While this PR was in flight, #262 merged an equivalent token_count filter to main (filters/src/token_count/) covering the 5 body-based providers (OpenAI, Anthropic, Google, Bedrock Converse, Azure). This PR is now rebased on top of that merged filter and extends it with the one requirement from proposal #210 that #262 didn't cover: Bedrock InvokeModel, which returns token counts as HTTP response headers rather than in the response body.

Note: Bedrock InvokeModel is the only provider that does not return token counts in the response body — counts are delivered as HTTP response headers instead, making its extraction path fundamentally different from all other providers.

What changed

File Change
filters/src/token_count/mod.rs Added ProviderKind config enum (openai, anthropic, google, bedrock, bedrock_invoke_model, azure) and the header-only extraction path for bedrock_invoke_model
filters/src/token_count/tests.rs 7 new unit tests covering the header extraction path
tests/integration/tests/suite/examples/token_count.rs New end-to-end integration test chaining token_count + token_usage_headers to observe the extracted counts as response headers
docs/filters/token_count.md Regenerated via cargo xtask generate-filter-docs
examples/configs/token-usage-headers.yaml Fixed a backwards filter-ordering note (see below)

How it works

ProviderKind is distinct from the shared TokenUsageProvider (used for JSON/SSE body parsing) because Bedrock InvokeModel has no body format to parse:

enum ProviderKind {
    OpenAi,             // -> TokenUsageProvider::OpenAi
    Anthropic,          // -> TokenUsageProvider::Anthropic
    Google,             // -> TokenUsageProvider::Google
    Bedrock,            // -> TokenUsageProvider::Bedrock
    BedrockInvokeModel, // -> None (header-only path)
    Azure,              // -> TokenUsageProvider::Azure
}
  • response_body_access returns BodyAccess::None for bedrock_invoke_model — no body is ever buffered for this provider.
  • on_response short-circuits for bedrock_invoke_model: reads x-amzn-bedrock-input-token-count / x-amzn-bedrock-output-token-count directly from the upstream response headers and calls set_token_usage immediately, skipping content-type detection entirely.
  • on_response_body converts ProviderKind to Option<TokenUsageProvider> via to_library_provider(); it's a no-op when None (i.e. for bedrock_invoke_model, whose body is never delivered anyway).
  • All other providers are unchanged from the merged add token_count filter for streaming and non streaming reponses #262 implementation (incremental SSE scanning via BodyMode::Stream, no full-body buffering).

Fixed: incorrect filter-ordering guidance

While writing the integration test, I found that examples/configs/token-usage-headers.yaml's note on filter ordering was backwards. Response hooks in the Praxis pipeline run in reverse declared order (confirmed against filter/src/pipeline/http.rs in praxis core: self.filters.iter().enumerate().rev()), so a token-counting filter must be declared after token_usage_headers, not before, for on_response to populate filter_metadata before token_usage_headers reads it. Fixed the comment accordingly.

Configuration

filter_chains:
  - name: main
    filters:
      - filter: router
        routes:
          - path_prefix: "/"
            cluster: backend
      - filter: token_usage_headers
      - filter: token_count
        provider: bedrock_invoke_model   # openai | anthropic | google | bedrock | bedrock_invoke_model | azure
      - filter: load_balancer
        clusters:
          - name: backend
            endpoints:
              - "127.0.0.1:3000"

Testing

  • Unit tests — 7 new tests: header extraction success, missing headers (no-op), partial headers (no-op), confirms the header path never sets the content-type mode metadata, response_body_access is None for bedrock_invoke_model / ReadOnly for all other providers, on_response_body no-op for bedrock_invoke_model, and bedrock_invoke_model accepted by from_config.
  • Integration testtoken_count_bedrock_invoke_model_extracts_header_counts starts a real proxy chaining token_count (bedrock_invoke_model) with token_usage_headers, has the backend return the Bedrock headers, and asserts the proxy response carries praxis-token-input: 25, praxis-token-output: 50, praxis-token-total: 75.
  • Full workspace test suite (cargo test --workspace) passes: 2464 tests, 0 failed.
  • cargo clippy --workspace --all-targets -- -D warnings: clean.
  • cargo +nightly fmt --all --check: clean.
  • cargo xtask lint-separators / cargo xtask lint-filter-docs: clean.

Out of scope

  • Request-side prompt token counting
  • Persistent storage of token usage

@mkoushni mkoushni requested review from a team and franciscojavierarceo July 2, 2026 09:57
@mkoushni mkoushni requested a review from shaneutt as a code owner July 2, 2026 09:57

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

token_count filter review

The filter design and test coverage are solid, but there are two critical issues that prevent the code from compiling and running.

Severity Count Summary
Critical 2 Wrong import path (won't compile); filter not registered (won't load at runtime)
Large 2 Integration tests use wrong registry; missing doc comment on TokenCountConfig
Medium 2 Unused import in integration tests; example config in wrong README table section

Non-inline findings

[Large] All integration tests in token_counting.rs call start_proxy(&config) which uses FilterRegistry::with_builtins(). The token_count filter is not a builtin, so every test will fail at pipeline construction with "unknown filter type: 'token_count'". Tests must use start_proxy_with_registry with a registry that includes token_count, similar to how other AI filter integration tests work.

Comment thread filters/src/inference/token_count.rs Outdated
Comment thread filters/src/inference/token_count.rs Outdated
Comment thread tests/integration/tests/suite/examples/token_counting.rs Outdated
Comment thread tests/integration/tests/suite/examples/token_counting.rs Outdated
Comment thread examples/README.md Outdated
Comment thread filters/src/inference/token_count.rs Outdated
@mkoushni mkoushni force-pushed the feat/210-response-based-token-counting-from-json branch from 309f0c8 to 4d7b50e Compare July 6, 2026 14:25
@praxis-bot-app

praxis-bot-app Bot commented Jul 6, 2026

Copy link
Copy Markdown

PR too large: 808 lines added (limit: 750, excludes Cargo files, tests, docs, examples, and benchmarks). Please split into smaller PRs. Add skip/pr-conventions label to override.

@mkoushni mkoushni force-pushed the feat/210-response-based-token-counting-from-json branch 8 times, most recently from 0f3e064 to 4c95a0a Compare July 6, 2026 15:28
@shaneutt shaneutt added this to the v0.1.0 milestone Jul 8, 2026
@shaneutt shaneutt moved this from Next to Review in AI Gateway - Model Serving Jul 8, 2026
@mkoushni mkoushni force-pushed the feat/210-response-based-token-counting-from-json branch from 4c95a0a to 3efc75b Compare July 9, 2026 08:44
@praxis-bot-app

praxis-bot-app Bot commented Jul 9, 2026

Copy link
Copy Markdown

Missing Signed-off-by: fb44e8c. All commits require sign-off (via git commit --signoff).

@mkoushni mkoushni force-pushed the feat/210-response-based-token-counting-from-json branch 2 times, most recently from 3f0c5c6 to acee3ef Compare July 9, 2026 15:13
@praxis-bot-app

praxis-bot-app Bot commented Jul 9, 2026

Copy link
Copy Markdown

Unsigned commits: acee3ef. Please sign your commits.

…raxis-proxy#210)

PR praxis-proxy#262 merged an equivalent token_count filter to main while this
branch was in flight. Rebase onto it and extend it to close the one
gap against the praxis-proxy#210 proposal: Bedrock InvokeModel returns token
counts as response headers (x-amzn-bedrock-input/output-token-count)
rather than in the body, so it needs a distinct extraction path.

Introduces a local ProviderKind config enum (OpenAi, Anthropic,
Google, Bedrock, BedrockInvokeModel, Azure) that maps to the shared
TokenUsageProvider for body-based providers and short-circuits to
header-only extraction for BedrockInvokeModel, with BodyAccess::None
so no body is ever buffered for that provider.

Also fixes a backwards ordering note in the token-usage-headers
example: response hooks run in reverse declared order, so a
token-counting filter must be declared after token_usage_headers,
not before.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni mkoushni force-pushed the feat/210-response-based-token-counting-from-json branch from acee3ef to a107239 Compare July 9, 2026 15:16
@mkoushni mkoushni changed the title feat(inference): add token_count filter for response-based token coun… feat(token_count): add Bedrock InvokeModel header-based extraction Jul 9, 2026

@aslakknutsen aslakknutsen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests cover absent and partial headers but not non-numeric values (e.g. x-amzn-bedrock-input-token-count: abc). Behavior is likely correct (parse failure -> no-op) but untested; low risk given symmetric parse logic.

/// other providers, detects the content-type to select the body
/// extraction strategy used by `on_response_body`.
async fn on_response(&self, ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
if self.provider == ProviderKind::BedrockInvokeModel {

@aslakknutsen aslakknutsen Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We call extract_bedrock_headers unconditionally before any status check on the response. Every one else is guarded by the is_success. Probably not the end of the world, but the API is relyable with token headers on non 200 responses ?

assert!(matches!(action, FilterAction::Continue));
assert!(ctx.get_metadata("token.input").is_none());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on_response_skips_non_success_status covers OpenAi but there is no analogue for BedrockInvokeModel with error status plus valid token headers. A regression test would lock the intended parity with body providers and guard the bug in bug-bedrock-no-success-check.

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

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

4 participants