Repository navigation
Conversation
Read tool input schemas through the AI SDK's asSchema() instead of walking zod 4 internals, so zod 3, zod 4, jsonSchema() and (v6+) Standard Schema tools all count. A schema count() cannot read synchronously counts 0 and is listed in tools.unresolved. Count the payload a provider sees for v7 file parts, reasoning, reasoning-file and every tool-result output shape, with one countMediaTokens function for all images and files. Widen the peer range to ai 5, 6 and 7 and develop against ai 7 with an explicit zod dev dependency. Change-Id: I735b100758607503ccda61b172690e8d3946460c Signed-off-by: Thomas Kosiewski <tk@coder.com>
scripts/test-sdk-matrix.sh installs one pairing, type-checks and tests the sdk, builds dist and loads the CJS and ESM builds. CI runs the cells the script lists. Change-Id: I689441a6f0a6fe1c93ab32e44a7abf0089177991 Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I2287f560194044fd968fbbe77277d04b38e6d7e8 Signed-off-by: Thomas Kosiewski <tk@coder.com>
…fault execution-denied text
Review findings:
- zod 4 .meta({ id }) and pydantic MCP schemas put nested objects behind a local $ref. Resolve #, #/definitions/X and #/$defs/X so those tools count as before.
- Provider tools carry an inputSchema the provider never receives. Count their name and description only.
- OpenAI and Anthropic send 'Tool call execution denied.' when there is no reason. Count it.
- A thenable whose then() throws is still unreadable synchronously.
- Drop two assertions that could not fail, and make the matrix script fail loudly when bun prints no test summary.
Change-Id: Idd3aa4dda1c425c87c8a0a44c58ee2319170d209
Signed-off-by: Thomas Kosiewski <tk@coder.com>
ThomasK33
marked this pull request as ready for review
November 3, 2026 11:41
Member
Author
|
@codex review |
…d $ref walks - Count a tool result as JSON.stringify(output), wrapper included, as main did. Payload-only counting under-counted every result by about 6 tokens. A content output still prices its images and files with countMediaTokens instead of serialising them. - Skip reasoning and reasoning-file parts again. Anthropic's count_tokens gives the same total with and without an earlier turn's thinking block. - Remember the count of each walked properties object, so definitions that share definitions are walked once instead of once per path (depth 22: 24.9 s to under 5 ms). - Resolve only JSON pointer $refs (# and #/...), not anchors like #A. Change-Id: I6b807d6398e6ce560ae42d4f3d678b9f667c91ac Signed-off-by: Thomas Kosiewski <tk@coder.com>
Member
Author
|
@codex review |
…ount $ref siblings - Count each definition once per tool, including definitions inside a cycle (the back edge counts 0 where the walk first reaches it). A clique of 10 mutually recursive definitions took 21.4 s and a depth-20 diamond with a back edge to the root took 7.9 s. Both now take about 1 ms. - A schema the AI SDK converts to nothing (arktype on ai v5) is listed in tools.unresolved with reason error instead of counting 0 silently. - Keywords next to a $ref (enum, description) win over the target's, so they count. - README: list what the walker does not follow. Change-Id: Ibafb90075be8586bb0c2e350ef4d484c8b89ce15 Signed-off-by: Thomas Kosiewski <tk@coder.com>
…vert each input schema once - Keywords next to a $ref whose value is undefined no longer hide the target's. - A sibling `properties` is added to the target's properties and wins on a clash, instead of replacing them. The merged object is cached per pair, so a recursive $ref with sibling properties still ends. - Remember the JSON Schema read from each tool input schema (WeakMap on the input schema), so repeated count() calls do not convert it again. Only complete reads are kept. Change-Id: I04a1dd03a78cf1a867459c70d923baff917af5f8 Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I9192869cec4c8b0b6b4446ec2297cd4a95be77e1 Signed-off-by: Thomas Kosiewski <tk@coder.com>
Main counts every file part as 100. The rewrite counted a file part with an image media type as 85, so { type: "file", data, mediaType: "image/png" } on openai/gpt-5 went from 110 to 95. Keep main's totals: file parts count 100, image parts count 85, until the image-counting change replaces countMediaTokens().
Change-Id: I505b3572eea34e9ac298df0743ce86bd54f5020d
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Member
Author
|
Review record for head
Generated with |
3 tasks
Change-Id: I5b04be9f2e1155e2a8eef9693380794f67bca904 Signed-off-by: Thomas Kosiewski <tk@coder.com>
This branch has not been deployed
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.
Why
This library has not been updated in a long while, and this PR brings its AI SDK support up to date.
ai-tokenizerstill declarespeerDependencies: { ai: ^5.0.0 }, butaiis at 7.0.162 (v6 shipped 2025-12-22, v7 2026-06-25). The goal is to support v5, v6 and v7. Todaycount()fromai-tokenizer/sdkreads tool schemas by walking zod 4 internals (_def), so it counts 0 schema tokens for zod 3,jsonSchema(), MCP and Standard Schema tools, and it ignores v7's new part shapes.What
src/sdk.tsreads each tool's input schema through the AI SDK's publicasSchema(), then walks the resulting JSON Schema (properties, descriptions, enums, nested objects, arrays of objects) with the sameModelTokensfields as before. zod 3, zod 4,jsonSchema()and (v6+) Standard Schema tools all count. The walker reads through.optional()/.nullable()/.default()wrappers (anyOfwithnull) and follows local JSON pointer$refs (#,#/definitions/X,#/$defs/X). It counts each definition once per tool (a definition inside a cycle is counted with its back edge cut), so the walk is bounded by the size of the schema. Keywords next to a$ref, such asenumanddescription, count with the target's (a sibling keyword that isundefinedis ignored, and siblingpropertiesare added to the target's and win on a clash). The JSON Schema read from each tool input schema is remembered (aWeakMapon the input schema), so counting the same tools again does not convert them again.image,file, v5media, v6image-*/file-*, v7 taggedfile), goes through one function,countMediaTokens(). The 85 (image) and 100 (file) constants are unchanged, and afilepart counts 100 whatever its media type, as on main. The later image-counting change replaces that one function. A v7filewith inline text data counts as text.JSON.stringify(output), wrapper included. Acontentoutput still prices its images and files withcountMediaTokens()instead of serialising their base64.reasoningandreasoning-fileparts add 0.count()stays synchronous and never throws because of a tool definition. A schema it cannot read synchronously (a Promise, a thunk that returns one, a Standard Schema vendor without JSON Schema conversion, a throwing v7 description function) counts as 0 and is listed in the newresult.tools.unresolved. I chose this overthrow(an estimator in a hot path must not fail on a tool the SDK accepts) and overcountAsync()(two ways to do one task). A schema the AI SDK converts to nothing (arktype on ai v5) is listed the same way.ai ^5.0.0 || ^6.0.0 || ^7.0.0. Dev dependencies areai ^7.0.162and an explicitzod ^4.6.7(zod was only a transitive peer before).scripts/test-sdk-matrix.shinstalls oneai+zodpairing, type-checks and tests the sdk files, buildsdist, and loads the CJS and ESM builds (scripts/smoke-sdk-dist.mjs). CI runs the 8 cells the script lists (ai 5/6/7 x zod 3.25.76/4, plus Node 20 for ai 5 and 6).sdk.tsnow importsasSchemafromaiat runtime, soai-tokenizer/sdkneedsaiinstalled (it needed only its types before).aistays an optional peer because the rootai-tokenizerentry does not use it. tsdown externalises it, so there is no config change.src/tokenizer.ts,src/models.json,tsdown.config.ts.Evidence
Gates on head
0c2d44a:bun run typecheckok,bun test730 pass / 1 skip / 0 fail (the skip is the Standard Schema test for the other AI SDK major),bun run build --dts=falseok,bun run build(dts) ok,bun run format --checkexit 0.Accuracy does not move from main on this repo's own workload. I replayed the message loop of
scripts/generate-accuracy.ts(same tool, same tool-call/tool-result messages, seeded randomness, sonnet-4.5 config) at 3 sizes with 5 seeds each, and took the truth from Anthropic's freecount_tokensendpoint on the request the AI SDK builds (@ai-sdk/anthropic, claude-sonnet-4-5).mainisorigin/mainandPRis this head:PR and main give the same total on all 15 conversations. Counting only the payload of a tool result, without its wrapper, would lower every result by 5 to 11 tokens (I measured that against
count_tokenson an earlier version of this PR), so tool results keep the wrapper.Reasoning. Anthropic's
count_tokensgives the same total with and without the signed thinking block of an earlier assistant turn: 194 with, 194 without. Main and this PR both give 145 for that conversation. A v7filepart with inline text data (data: { type: "text" }) is sent as a document: the API says 275, main gave 113 and this PR gives 272.This branch merges
origin/mainas of PR #10 (the ctok Claude encodings and codec dispatch), andsrc/sdk.tskeeps callingtokenizer.count()as main does.Matrix, one line per cell (
scripts/test-sdk-matrix.sh --all, run locally on Node 22.23.4, so the two Node 20 cells print the same lines as their Node 22 twins and are shown once. "tests" is sdk.test.ts + sdk.compat.test.ts.cjsandesmare the totals of one fixedcount()call through each build):CI runs the two Node 20 cells as well. I ran the same smoke by hand under Node 20.20.2 with ai 5, 6 and 7, and all three load the CJS build (
cjs=172 esm=172). I did not test older Node versions. v7 itself declaresengines.node >= 22.zod 4 totals are the same as before wherever no wrapper hides the enum, object or description. I ran the old walker (
git show 107814b:src/sdk.ts) and the new code over 41 zod 4 tool shapes with a syntheticModelTokenswhose fields all differ. 26 are identical (flat, described, enums, nested objects, arrays of objects/strings/enums,strict/passthrough,z.record/z.union/z.literal, unicode keys, a description on the outer.default()/.optional()/.nullable(), and schemas registered with.meta({ id }), which reach the walker as a$ref).src/sdk.compat.test.tspins 21 of them for both zod 3 and zod 4 and compares the.meta({ id })shapes with their inline form. The tool inscripts/generate-accuracy.tsgives identical tool totals old vs new for all 59 models with a claude or o200k encoding, and the 18expectedtotals insdk.test.tsare unchanged.15 shapes change on purpose. The old walker read the wrapper, not the inner type, so it dropped the description, enum or nested properties behind
.optional(),.nullable(),.default()or.readonly(). The provider sees them, so they count now. One more shape drops: TypeScript numeric enums lost their reverse-mapping labels..describe("x").optional().describe("x").nullable()z.enum([a,b]).optional()z.enum([a,b]).optional().describe("U")z.enum([a,b]).nullable()z.enum([a,b]).readonly()z.object({x}).optional()z.object({x}).nullable()z.object({x}).default({})z.array(z.object({x})).optional()z.array(z.object({x})).nullable()enum N {A, B}I checked these shapes against Anthropic's
count_tokens(claude-sonnet-4-5-20250929, two runs of 13 and 8 schemas, 1 tool each,description: "d". I took them before the$refand memo changes. I repeated the 41-shape old-vs-new run above on this head and it gives the same 26 identical and 15 changed shapes). Columns are the tool tokens by the API, the new prediction and the old prediction, with the error in brackets. Every required-only shape is identical old/new and sits +20 above the API: that is a constant of the current sonnet-4.5 config, not of the walker. Raw errors hide the old under-count behind that constant. Measured against it, the 13 wrapped shapes are within -22..+9 with the new walker and within -51..-2 with the old one:Shared and recursive definitions are bounded. Each definition is walked once per tool. These cases are tests in
src/sdk.compat.test.ts, and each asserts under 500 ms. Times on the previous head (without counting cycles once) and on this head:The last two timings come from the same schemas run through
count()directly (diamond depth 20 and clique of 10).Risks
ai-tokenizer/sdknow needsaiinstalled at runtime. The root entry is unaffected. A CJS app on a Node withoutrequire()of ESM that uses v7 cannotrequire("ai")v7 itself either.unresolvedentry: v5'sasSchema()only converts zod. v6 and v7 convert any vendor that exposes JSON Schema.anyOf/oneOfwith 2+ non-null variants),allOf,$refs that point outside the schema or use an anchor (#name), and$refalias chains longer than one hop. The last row of the API table shows the cost of a real union (-45)..meta({ id })object againstcount_tokens(claude-sonnet-4-5-20250929): used once, +3 (old and new); used twice, +113 (old and new); used four times, +333 (old and new). Counting each referenced definition once would fix that, and it would change totals for repeated.meta({ id })shapes from main. I kept the main totals.tool_resultblock overhead, whichModelTokenshas no field for. A later PR can calibrate a per-model field and drop the wrapper.CountResultMessageContentis unchanged andCountResultToolsgains the fieldunresolved. Afilepart still counts 100 whatever its media type ({ type: "file", data, mediaType: "image/png" }on openai/gpt-5 is 110 on main and 110 here), and animagepart counts 85. A v7filewith an image media type is therefore not counted as an image yet.z.date(),z.bigint(),z.set(),z.map()and similar, so the SDK cannot send such a tool either. The old walker counted their properties. Provider tools (type: "provider"or"provider-defined") count their name and description but not their schema, because the provider ignores it.{ context: {} }. A throwing function lands inunresolved.count()converts each tool input schema once per input schema object, then reads the remembered JSON Schema. With 20 zod 4 tools and the same tool objects on every call I measured 0.35 ms per call on main and 0.07 ms now (2000 calls, Bun 1.2.15). A call with tool objects built fresh each time converts them each time (0.67 ms for the same 20 tools in an earlier run).bun run format --checkrunsprettier --writeand rewritessrc/models.jsonlocally. I reverted it every time and did not commit it.Generated with
xum• Model:anthropic:claude-sonnet-5-5• Thinking:xhigh