Skip to content

Commit cb50e77

Browse files
njbrakeclaude
andcommitted
feat: wrap /v1/messages/count_tokens (regenerate core + ergonomic method)
The gateway spec gained POST /v1/messages/count_tokens, which the endpoint-coverage drift gate flagged as unaccounted-for (CI red on main). This regenerates the client core to include the count_tokens operation (absorbs the open codegen PR) and adds a hand-written count_tokens() ergonomic method to the sync and async shells, mirroring message(). It returns a typed CountTokensResponse and omits max_tokens, since the endpoint only counts input tokens. The endpoint is listed under [covered] in sdk-endpoints.txt and covered by sync + async unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3125466 commit cb50e77

5 files changed

Lines changed: 75 additions & 0 deletions

File tree

sdk-endpoints.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
POST /v1/chat/completions
1717
POST /v1/responses
1818
POST /v1/messages
19+
POST /v1/messages/count_tokens
1920
POST /v1/embeddings
2021
POST /v1/moderations
2122
POST /v1/rerank

src/otari/async_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from otari._client.api.responses_api import ResponsesApi
4444
from otari._client.exceptions import ApiException
4545
from otari._client.models.chat_completion_request import ChatCompletionRequest
46+
from otari._client.models.count_tokens_request import CountTokensRequest
4647
from otari._client.models.create_batch_request import CreateBatchRequest
4748
from otari._client.models.embedding_request import EmbeddingRequest
4849
from otari._client.models.messages_request import MessagesRequest
@@ -57,6 +58,7 @@
5758

5859
from otari._client.models.chat_completion import ChatCompletion
5960
from otari._client.models.chat_completion_chunk import ChatCompletionChunk
61+
from otari._client.models.count_tokens_response import CountTokensResponse
6062
from otari._client.models.create_embedding_response import CreateEmbeddingResponse
6163
from otari._client.models.model_object import ModelObject
6264
from otari._client.models.moderation_response import ModerationResponse
@@ -231,6 +233,25 @@ async def message(
231233
request = build_request(MessagesRequest, body)
232234
return await self._call(lambda: self._messages.create_message_v1_messages_post(request))
233235

236+
async def count_tokens(
237+
self,
238+
*,
239+
model: str,
240+
messages: list[dict[str, Any]],
241+
**kwargs: Any,
242+
) -> CountTokensResponse:
243+
"""Count input tokens for an Anthropic-style message request.
244+
245+
Calls the gateway ``/v1/messages/count_tokens`` endpoint, which counts
246+
the tokens a ``/messages`` request would consume without generating a
247+
response. Returns a typed ``CountTokensResponse``.
248+
"""
249+
request = build_request(CountTokensRequest, {"model": model, "messages": messages, **kwargs})
250+
result = await self._call(
251+
lambda: self._messages.count_message_tokens_v1_messages_count_tokens_post(request)
252+
)
253+
return cast("CountTokensResponse", result)
254+
234255
# -- Embeddings ---------------------------------------------------------
235256

236257
async def embedding(

src/otari/client.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from otari._client.api.responses_api import ResponsesApi
4444
from otari._client.exceptions import ApiException
4545
from otari._client.models.chat_completion_request import ChatCompletionRequest
46+
from otari._client.models.count_tokens_request import CountTokensRequest
4647
from otari._client.models.create_batch_request import CreateBatchRequest
4748
from otari._client.models.embedding_request import EmbeddingRequest
4849
from otari._client.models.messages_request import MessagesRequest
@@ -57,6 +58,7 @@
5758

5859
from otari._client.models.chat_completion import ChatCompletion
5960
from otari._client.models.chat_completion_chunk import ChatCompletionChunk
61+
from otari._client.models.count_tokens_response import CountTokensResponse
6062
from otari._client.models.create_embedding_response import CreateEmbeddingResponse
6163
from otari._client.models.model_object import ModelObject
6264
from otari._client.models.moderation_response import ModerationResponse
@@ -254,6 +256,32 @@ def message(
254256
request = build_request(MessagesRequest, body)
255257
return self._call(lambda: self._messages.create_message_v1_messages_post(request))
256258

259+
def count_tokens(
260+
self,
261+
*,
262+
model: str,
263+
messages: list[dict[str, Any]],
264+
**kwargs: Any,
265+
) -> CountTokensResponse:
266+
"""Count input tokens for an Anthropic-style message request.
267+
268+
Calls the gateway ``/v1/messages/count_tokens`` endpoint, which counts
269+
the tokens a ``/messages`` request would consume without generating a
270+
response. Returns a typed
271+
:class:`~otari._client.models.count_tokens_response.CountTokensResponse`.
272+
273+
Args:
274+
model: Model identifier (e.g. ``"anthropic:claude-3-5-sonnet"``).
275+
messages: Anthropic-style message list.
276+
**kwargs: Additional count-tokens parameters (``system``, ``tools``,
277+
``tool_choice``, ``thinking``, ...).
278+
"""
279+
request = build_request(CountTokensRequest, {"model": model, "messages": messages, **kwargs})
280+
result = self._call(
281+
lambda: self._messages.count_message_tokens_v1_messages_count_tokens_post(request),
282+
)
283+
return cast("CountTokensResponse", result)
284+
257285
# -- Embeddings ---------------------------------------------------------
258286

259287
def embedding(

tests/unit/test_async_client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
)
2929
from tests.unit.test_client import (
3030
CHAT_RESPONSE,
31+
COUNT_TOKENS_RESPONSE,
3132
EMBEDDING_RESPONSE,
3233
MESSAGE_RESPONSE,
3334
MODELS_RESPONSE,
@@ -87,6 +88,15 @@ async def test_message_returns_typed(self, mock_rest: Any) -> None:
8788
assert result.id == "msg-1"
8889
assert mock.last.url.endswith("/v1/messages")
8990

91+
async def test_count_tokens_returns_typed(self, mock_rest: Any) -> None:
92+
mock = mock_rest(status=200, body=COUNT_TOKENS_RESPONSE)
93+
client = AsyncOtariClient(api_base="http://localhost:8000", api_key="vk")
94+
result = await client.count_tokens(
95+
model="anthropic:claude", messages=[{"role": "user", "content": "Hi"}]
96+
)
97+
assert result.input_tokens == 42
98+
assert mock.last.url.endswith("/v1/messages/count_tokens")
99+
90100
async def test_list_models_returns_typed(self, mock_rest: Any) -> None:
91101
mock_rest(status=200, body=MODELS_RESPONSE)
92102
client = AsyncOtariClient(api_base="http://localhost:8000", api_key="vk")

tests/unit/test_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
"usage": {"input_tokens": 1, "output_tokens": 1},
6565
}
6666

67+
COUNT_TOKENS_RESPONSE: dict[str, Any] = {"input_tokens": 42}
68+
6769
MODERATION_RESPONSE: dict[str, Any] = {
6870
"id": "modr-1",
6971
"model": "openai:omni-moderation-latest",
@@ -213,6 +215,19 @@ def test_returns_typed_message_response(self, mock_rest: Any) -> None:
213215
assert body["max_tokens"] == 64
214216
assert body["model"] == "anthropic:claude-3-5-sonnet"
215217

218+
def test_count_tokens_returns_typed_response(self, mock_rest: Any) -> None:
219+
mock = mock_rest(status=200, body=COUNT_TOKENS_RESPONSE)
220+
client = OtariClient(api_base="http://localhost:8000", api_key="vk")
221+
result = client.count_tokens(
222+
model="anthropic:claude-3-5-sonnet",
223+
messages=[{"role": "user", "content": "Hi"}],
224+
)
225+
assert result.input_tokens == 42
226+
assert mock.last.url.endswith("/v1/messages/count_tokens")
227+
body = mock.last.json_body
228+
assert body["model"] == "anthropic:claude-3-5-sonnet"
229+
assert "max_tokens" not in body
230+
216231

217232
class TestModeration:
218233
def test_returns_typed_moderation(self, mock_rest: Any) -> None:

0 commit comments

Comments
 (0)