toolgz 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -283,6 +283,19 @@ trip. A test asserts that file matches the code, so it cannot drift.
283
283
 
284
284
  ---
285
285
 
286
+ ### `signature`: the level-3 style with no lookups
287
+
288
+ Worth calling out because it trades differently from the others. `signature` puts the full
289
+ parameter list in the map, so the model never needs a `q()` lookup — **measured 0.0 lookups
290
+ on all four providers**, against 0.1–0.5 for the default.
291
+
292
+ That makes it the fastest and cheapest option on OpenAI (4.0s vs 5.6s; median cost $0.0106
293
+ vs $0.0129) at the price of a larger cached map. But it is **not** universally better: on
294
+ grok-4.5 it was slower (7.4s vs 4.6s), dearer, and produced the one malformed argument in
295
+ that arm. Reach for it if lookups are your bottleneck and you are not on xAI.
296
+
297
+ ---
298
+
286
299
  ## Optional: let the library pick the map style for your model
287
300
 
288
301
  Level 3 has several map styles. Which one is cheapest turns out to depend on the
@@ -828,6 +841,12 @@ Returns:
828
841
  | `resolve(name, args)` | `→ Resolution` | translate a model call back |
829
842
  | `codeFor(name)` | `→ string` | real name → level-3 code; throws below level 3 |
830
843
  | `stats` | `CompressStats` | `level`, `mapStyle`, `requestedMapStyle`, `fallbackReason`, `toolCount`, `wireToolCount`, `originalChars`, `compressedChars`, `savedPct` |
844
+
845
+ > **`savedPct` counts characters, not tokens — and it overstates.** It is derived from
846
+ > `countSchemaTokensApprox`, which is just `JSON.stringify(x).length`. On our real corpus
847
+ > it reports ~97.8% where `count_tokens` measures **95.6%**. Use it as a cheap sanity
848
+ > signal, never as a published figure; for anything you quote, measure with your
849
+ > provider's token counter.
831
850
  | `encodeCallForTest(name, args)` | `→ {name, args}` | build the raw call a model would emit; test aid |
832
851
 
833
852
  ### `recommendLevel(tools, namespaceOf?) → Recommendation`
@@ -864,6 +883,34 @@ Methodology and repo conventions: [../AGENTS.md](../AGENTS.md).
864
883
 
865
884
  ## Providers
866
885
 
886
+ ### Why `forGemini` returns one tool where the others return two
887
+
888
+ It isn't sending less. Gemini's API nests *all* function declarations inside a single
889
+ tool object — `[{ functionDeclarations: [...] }]` — where Anthropic and OpenAI take a
890
+ flat array of tools. At level 3 the two dispatcher tools (`t` and `q`) are both present;
891
+ they are just both inside that one wrapper. Count `tools[0].functionDeclarations.length`,
892
+ not `tools.length`.
893
+
894
+ ### `forGemini` repairs three schema forms Gemini rejects
895
+
896
+ Gemini rejects the **whole request** if any single declaration is invalid, so one
897
+ non-conforming tool anywhere in your catalogue breaks every call. Three forms occur in
898
+ real MCP servers, and the adapter repairs all three:
899
+
900
+ | Form | Found in the real corpus | Repair |
901
+ |---|---|---|
902
+ | array with no `items` | 7 of 149 tools | adds `items: {}` |
903
+ | `enum` on a non-string type | `{type:"number", enum:[1,2,3]}` | drops the `enum`, keeps the type |
904
+ | union type `["string","array"]` | 1 tool | takes the first type |
905
+
906
+ **The dropped `enum` is not a lost constraint.** `validateArgs` checks arguments against
907
+ your *original* schema before dispatch at every level, so an out-of-range value is still
908
+ caught — the check moves from provider-side to library-side. We deliberately do not coerce
909
+ a numeric enum into a string enum: Gemini would accept it, and the model would then send
910
+ `"1"` where your API wants `1`, turning a caught error into silent bad data.
911
+
912
+ All 149 tools in the committed corpus pass Gemini at levels 0–3.
913
+
867
914
  ```ts
868
915
  import { forAnthropic, forOpenAI, forOpenAIResponses, forGemini } from "toolgz";
869
916
  ```
@@ -908,7 +955,7 @@ and it does not get deleted.
908
955
  ## Development
909
956
 
910
957
  ```bash
911
- npm test # 252 tests, offline, no cost
958
+ npm test # 268 tests, offline, no cost
912
959
  npm run build # tsc → dist/ with .d.ts
913
960
 
914
961
  npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants # costs money
@@ -95,6 +95,50 @@ export function forGemini(c) {
95
95
  ? clean(v)
96
96
  : v;
97
97
  }
98
+ // Gemini rejects an array without `items`, and rejects the WHOLE request:
99
+ // 400 GenerateContentRequest.tools[0].function_declarations[4]
100
+ // .parameters.properties[shipments].items: missing field
101
+ //
102
+ // Real MCP servers ship these — 7 of the 149 tools in bench/fixtures have an
103
+ // untyped array parameter (analyze_consolidation.shipments,
104
+ // compute_route.intermediates, optimize_waypoints.waypoints among them). One such
105
+ // tool anywhere in the catalogue used to fail every Gemini call.
106
+ //
107
+ // `items: {}` is what Gemini accepts and is the only honest repair: the source
108
+ // schema does not say what the items are, so we do not invent a type. Guessing
109
+ // `{type:"string"}` would also be accepted and would make the model send strings
110
+ // where the real API may want objects — a silent wrong-data bug in place of a loud
111
+ // rejection. Verified empirically: omitted is rejected; `{}`, `{type:"string"}` and
112
+ // `{type:"object"}` are all accepted.
113
+ // JSON Schema permits a union type (`"type": ["string","array"]`, as
114
+ // send_email_with_attachments.cc uses). Gemini requires a single type string and
115
+ // rejects the whole request otherwise. Take the first — deterministic, and the
116
+ // prompt-cache prefix must be byte-stable — and drop `items` if the pick is not an
117
+ // array, where it would be meaningless.
118
+ //
119
+ // Narrowing is safe because the ORIGINAL schema still accepts the narrowed form, so
120
+ // a value the model sends against it remains valid; validateArgs checks against
121
+ // that original before dispatch.
122
+ if (Array.isArray(out.type)) {
123
+ out.type = out.type[0];
124
+ if (out.type !== "array")
125
+ delete out.items;
126
+ }
127
+ if (out.type === "array" && out.items === undefined)
128
+ out.items = {};
129
+ // Gemini accepts `enum` only on strings, and rejects the whole request otherwise:
130
+ // 400 Invalid value at tools[0].function_declarations[30].parameters.properties[2]
131
+ // Real MCP servers ship numeric enums — deep_research.depth is
132
+ // {type:"number", enum:[1,2,3]}. Verified: string+enum accepted, number+enum and
133
+ // integer+enum rejected, `oneOf` accepted (so it needs no repair).
134
+ //
135
+ // We drop the enum and keep the type. The constraint is not lost: validateArgs
136
+ // checks arguments against the ORIGINAL schema before dispatch at every level, so
137
+ // an out-of-range value is still caught — the check moves from provider-side to
138
+ // library-side. Coercing to a string enum instead would make the model send "1"
139
+ // where the API wants 1, which is a silent type error rather than a caught one.
140
+ if (out.enum && out.type && out.type !== "string")
141
+ delete out.enum;
98
142
  return out;
99
143
  };
100
144
  const functionDeclarations = c.tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolgz",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Compress LLM tool definitions to reclaim context window. Measured across Anthropic, OpenAI, Google and xAI.",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {