feat(models): a caller can ask the provider how big a prompt is - #53
Merged
Conversation
The model port had no way to learn a token count. Every consumer that needed one estimated it from characters, which is a property of the content rather than of the model — the same text can run 1.1 to 3.2 characters per token depending on what it is, so a bound computed that way is not an estimate with a wide error bar, it is a number with no relationship to the quantity it names. Anthropic's /v1/messages/count_tokens answers with the model's own tokenizer, is free of charge, is rate-limited independently of message creation, and does not participate in prompt caching, so a caller may count the exact payload it is about to send without perturbing anything. BaseAPIModel.acount_tokens returns None by default: a provider without a counting service must say so rather than be guessed for, and the caller needs to tell an absent count from a small one. The two Anthropic legs implement it on their async adapters, each through its OWN format_request_payload — the OAuth leg prepends a required system block and renames reserved tools, so a body assembled any other way would count a request nobody makes. Only the generation controls come off. Failure is None, never an exception: the caller is sizing something, not producing the turn's answer. A 404/405 says the route is absent and is remembered for the process; a credential-shaped refusal is deliberately not, because the OAuth token file has several writers and a refresh in flight looks exactly like a rejection for one request.
Measured against the live API: counting a message list whose last row is an
assistant reply ending in whitespace is refused —
invalid_request_error: messages: final assistant content cannot end with
trailing whitespace
The messages call almost never meets this rule, because something is appended
after the last assistant turn on every real request. A count meets it
constantly: counting a conversation means presenting its last row as final, a
settled conversation ends with an assistant reply by definition, and models end
replies with a newline. So the first caller of this endpoint would have found
its post-fold counts failing on exactly the conversations most likely to need
them, with nothing on the wire to say why.
The count payload now right-trims the final assistant message's last text block,
copying rather than mutating (those blocks can be the caller's own durable rows),
and drops the message when nothing is left of it. And a non-200 logs the
provider's own sentence rather than a bare status — this one answers in a line,
and a status alone cost a round trip to diagnose.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a provider-counted token capability to the model port:
BaseAPIModel.acount_tokens(messages, *, tools=None, system=None) -> int | None, defaulting toNoneand delegating to the async adapter when it implements the method.AsyncAnthropicAdapter.acount_tokensandAsyncAnthropicOAuthAdapter.acount_tokens, both againstPOST /v1/messages/count_tokens.Why
Nothing in the framework could learn a token count; the only counter present is a chars/4 heuristic (
marsys/utils/tokens.py). Characters per token is a property of the content, not the model — the same day's traffic on one conversation measured 1.14 to 3.16 chars/token — so any bound or residual computed by subtracting a character estimate from a provider-reported count is unrelated to the quantity it names. The endpoint answers with the model's own tokenizer, is free, is rate-limited independently of message creation, and does not participate in prompt caching, so a caller can size the exact payload it is about to send without perturbing the request it is sizing.Shape
Each Anthropic leg builds the counted body through its own
format_request_payload, then drops only the keys the count endpoint rejects (max_tokens,stream, sampling params,output_config). That is deliberate: the OAuth leg prepends a required Claude-Code system block and renames reserved tool names, so a body assembled any other way would count a request that is never sent. The OAuth leg also refreshes its token first, like its messages call, and asks forapplication/jsoninstead of the streamingaccept.The two legs are siblings with no shared request path, so the transport code is per-leg (aiohttp for the api-key leg, httpx for the OAuth leg, matching each one's messages call); the URL derivation, the payload stripping and the response interpretation are shared module-level helpers in
anthropic.py.Failure behaviour
None, never an exception — the caller is sizing something, not producing an answer, and a count that fails must cost the request nothing. A404/405means the route is absent on that endpoint and is remembered for the process;401/403and transport errors are not remembered, because the OAuth credentials file has several writers (sibling adapters, the Claude CLI) and a refresh in flight looks exactly like a rejection for one request — memoising that would disable counting for the lifetime of a long-running process.Tests
tests/models/test_count_tokens.py, no network: URL derivation (including the OAuth leg's preserved query string), the stripped/kept payload keys, both legs' counted body (the OAuth system prefix and tool renaming are asserted present),Noneon transport failure / non-JSON body / a 200 withoutinput_tokens, the 404-remembered vs 401-not-remembered split, per-endpoint scoping of the memo, and the model-port delegation including the "provider cannot count" default.packages/framework/tests/models: 282 passed, 39 skipped.What a live run added
Driving the endpoint for real, rather than only through fakes, turned up a rule that
binds counts constantly and the messages call almost never:
Counting a conversation means presenting its last row as final; a settled
conversation ends with an assistant reply; models end replies with a newline. So the
first caller of this endpoint would have found its counts failing on exactly the
conversations it most wanted to size, with a bare "HTTP 400" and nothing else. The
count payload now right-trims the final assistant message's last text block (copying
rather than mutating — those blocks can be the caller's own durable rows) and drops
the message when nothing is left of it. A non-200 also logs the provider's own
sentence now, because a status alone cost a round trip to diagnose.
Note on the base branch
Opened against
azure-openai-legrather thanmain, because that is the commit the consuming submodule pin currently points at and both branches touchmodels/models.pyand the two Anthropic adapters. Ifazure-openai-legmerges first, GitHub retargets this tomain.