@gajae-code/ai 0.1.1

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.
Files changed (349) hide show
  1. package/CHANGELOG.md +2644 -0
  2. package/README.md +1181 -0
  3. package/dist/types/api-registry.d.ts +30 -0
  4. package/dist/types/auth-broker/client.d.ts +66 -0
  5. package/dist/types/auth-broker/index.d.ts +5 -0
  6. package/dist/types/auth-broker/refresher.d.ts +25 -0
  7. package/dist/types/auth-broker/remote-store.d.ts +96 -0
  8. package/dist/types/auth-broker/server.d.ts +32 -0
  9. package/dist/types/auth-broker/types.d.ts +105 -0
  10. package/dist/types/auth-broker/wire-schemas.d.ts +412 -0
  11. package/dist/types/auth-gateway/http.d.ts +39 -0
  12. package/dist/types/auth-gateway/index.d.ts +3 -0
  13. package/dist/types/auth-gateway/server.d.ts +17 -0
  14. package/dist/types/auth-gateway/types.d.ts +115 -0
  15. package/dist/types/auth-storage.d.ts +641 -0
  16. package/dist/types/cli.d.ts +2 -0
  17. package/dist/types/index.d.ts +49 -0
  18. package/dist/types/model-cache.d.ts +17 -0
  19. package/dist/types/model-manager.d.ts +62 -0
  20. package/dist/types/model-thinking.d.ts +71 -0
  21. package/dist/types/models.d.ts +12 -0
  22. package/dist/types/provider-details.d.ts +24 -0
  23. package/dist/types/provider-models/bundled-references.d.ts +4 -0
  24. package/dist/types/provider-models/descriptors.d.ts +48 -0
  25. package/dist/types/provider-models/google.d.ts +20 -0
  26. package/dist/types/provider-models/index.d.ts +5 -0
  27. package/dist/types/provider-models/ollama.d.ts +7 -0
  28. package/dist/types/provider-models/openai-compat.d.ts +237 -0
  29. package/dist/types/provider-models/special.d.ts +16 -0
  30. package/dist/types/providers/amazon-bedrock.d.ts +36 -0
  31. package/dist/types/providers/anthropic-messages-server-schema.d.ts +450 -0
  32. package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
  33. package/dist/types/providers/anthropic.d.ts +188 -0
  34. package/dist/types/providers/aws-credentials.d.ts +43 -0
  35. package/dist/types/providers/aws-eventstream.d.ts +38 -0
  36. package/dist/types/providers/aws-sigv4.d.ts +55 -0
  37. package/dist/types/providers/azure-openai-responses.d.ts +15 -0
  38. package/dist/types/providers/cursor/gen/agent_pb.d.ts +13022 -0
  39. package/dist/types/providers/cursor.d.ts +42 -0
  40. package/dist/types/providers/error-message.d.ts +27 -0
  41. package/dist/types/providers/github-copilot-headers.d.ts +40 -0
  42. package/dist/types/providers/gitlab-duo.d.ts +27 -0
  43. package/dist/types/providers/google-auth.d.ts +24 -0
  44. package/dist/types/providers/google-gemini-cli.d.ts +72 -0
  45. package/dist/types/providers/google-gemini-headers.d.ts +18 -0
  46. package/dist/types/providers/google-shared.d.ts +163 -0
  47. package/dist/types/providers/google-types.d.ts +138 -0
  48. package/dist/types/providers/google-vertex.d.ts +7 -0
  49. package/dist/types/providers/google.d.ts +4 -0
  50. package/dist/types/providers/grammar.d.ts +1 -0
  51. package/dist/types/providers/kimi.d.ts +27 -0
  52. package/dist/types/providers/mock.d.ts +175 -0
  53. package/dist/types/providers/ollama.d.ts +6 -0
  54. package/dist/types/providers/openai-anthropic-shim.d.ts +31 -0
  55. package/dist/types/providers/openai-chat-server-schema.d.ts +814 -0
  56. package/dist/types/providers/openai-chat-server.d.ts +16 -0
  57. package/dist/types/providers/openai-codex/constants.d.ts +26 -0
  58. package/dist/types/providers/openai-codex/request-transformer.d.ts +49 -0
  59. package/dist/types/providers/openai-codex/response-handler.d.ts +17 -0
  60. package/dist/types/providers/openai-codex-responses.d.ts +67 -0
  61. package/dist/types/providers/openai-completions-compat.d.ts +25 -0
  62. package/dist/types/providers/openai-completions.d.ts +33 -0
  63. package/dist/types/providers/openai-responses-server-schema.d.ts +392 -0
  64. package/dist/types/providers/openai-responses-server.d.ts +17 -0
  65. package/dist/types/providers/openai-responses-shared.d.ts +89 -0
  66. package/dist/types/providers/openai-responses.d.ts +32 -0
  67. package/dist/types/providers/pi-native-client.d.ts +13 -0
  68. package/dist/types/providers/pi-native-server.d.ts +68 -0
  69. package/dist/types/providers/register-builtins.d.ts +31 -0
  70. package/dist/types/providers/synthetic.d.ts +26 -0
  71. package/dist/types/providers/transform-messages.d.ts +12 -0
  72. package/dist/types/providers/vision-guard.d.ts +8 -0
  73. package/dist/types/rate-limit-utils.d.ts +19 -0
  74. package/dist/types/stream.d.ts +24 -0
  75. package/dist/types/types.d.ts +746 -0
  76. package/dist/types/usage/claude.d.ts +3 -0
  77. package/dist/types/usage/gemini.d.ts +2 -0
  78. package/dist/types/usage/github-copilot.d.ts +7 -0
  79. package/dist/types/usage/google-antigravity.d.ts +2 -0
  80. package/dist/types/usage/kimi.d.ts +2 -0
  81. package/dist/types/usage/minimax-code.d.ts +2 -0
  82. package/dist/types/usage/openai-codex.d.ts +3 -0
  83. package/dist/types/usage/shared.d.ts +1 -0
  84. package/dist/types/usage/zai.d.ts +2 -0
  85. package/dist/types/usage.d.ts +258 -0
  86. package/dist/types/utils/abort.d.ts +19 -0
  87. package/dist/types/utils/anthropic-auth.d.ts +31 -0
  88. package/dist/types/utils/discovery/antigravity.d.ts +61 -0
  89. package/dist/types/utils/discovery/codex.d.ts +38 -0
  90. package/dist/types/utils/discovery/cursor.d.ts +23 -0
  91. package/dist/types/utils/discovery/gemini.d.ts +25 -0
  92. package/dist/types/utils/discovery/index.d.ts +4 -0
  93. package/dist/types/utils/discovery/openai-compatible.d.ts +72 -0
  94. package/dist/types/utils/event-stream.d.ts +28 -0
  95. package/dist/types/utils/fireworks-model-id.d.ts +10 -0
  96. package/dist/types/utils/foundry.d.ts +1 -0
  97. package/dist/types/utils/h2-fetch.d.ts +22 -0
  98. package/dist/types/utils/http-inspector.d.ts +31 -0
  99. package/dist/types/utils/idle-iterator.d.ts +67 -0
  100. package/dist/types/utils/json-parse.d.ts +10 -0
  101. package/dist/types/utils/oauth/alibaba-coding-plan.d.ts +18 -0
  102. package/dist/types/utils/oauth/anthropic.d.ts +22 -0
  103. package/dist/types/utils/oauth/api-key-login.d.ts +35 -0
  104. package/dist/types/utils/oauth/api-key-validation.d.ts +27 -0
  105. package/dist/types/utils/oauth/callback-server.d.ts +57 -0
  106. package/dist/types/utils/oauth/cerebras.d.ts +1 -0
  107. package/dist/types/utils/oauth/cloudflare-ai-gateway.d.ts +18 -0
  108. package/dist/types/utils/oauth/cursor.d.ts +15 -0
  109. package/dist/types/utils/oauth/deepseek.d.ts +10 -0
  110. package/dist/types/utils/oauth/firepass.d.ts +1 -0
  111. package/dist/types/utils/oauth/fireworks.d.ts +1 -0
  112. package/dist/types/utils/oauth/github-copilot.d.ts +38 -0
  113. package/dist/types/utils/oauth/gitlab-duo.d.ts +3 -0
  114. package/dist/types/utils/oauth/google-antigravity.d.ts +11 -0
  115. package/dist/types/utils/oauth/google-gemini-cli.d.ts +10 -0
  116. package/dist/types/utils/oauth/google-oauth-shared.d.ts +28 -0
  117. package/dist/types/utils/oauth/huggingface.d.ts +19 -0
  118. package/dist/types/utils/oauth/index.d.ts +38 -0
  119. package/dist/types/utils/oauth/kagi.d.ts +17 -0
  120. package/dist/types/utils/oauth/kilo.d.ts +5 -0
  121. package/dist/types/utils/oauth/kimi.d.ts +21 -0
  122. package/dist/types/utils/oauth/litellm.d.ts +18 -0
  123. package/dist/types/utils/oauth/lm-studio.d.ts +17 -0
  124. package/dist/types/utils/oauth/minimax-code.d.ts +28 -0
  125. package/dist/types/utils/oauth/moonshot.d.ts +1 -0
  126. package/dist/types/utils/oauth/nanogpt.d.ts +1 -0
  127. package/dist/types/utils/oauth/nvidia.d.ts +18 -0
  128. package/dist/types/utils/oauth/ollama-cloud.d.ts +2 -0
  129. package/dist/types/utils/oauth/ollama.d.ts +18 -0
  130. package/dist/types/utils/oauth/openai-codex.d.ts +21 -0
  131. package/dist/types/utils/oauth/opencode.d.ts +18 -0
  132. package/dist/types/utils/oauth/parallel.d.ts +17 -0
  133. package/dist/types/utils/oauth/perplexity.d.ts +9 -0
  134. package/dist/types/utils/oauth/pkce.d.ts +8 -0
  135. package/dist/types/utils/oauth/qianfan.d.ts +17 -0
  136. package/dist/types/utils/oauth/qwen-portal.d.ts +19 -0
  137. package/dist/types/utils/oauth/synthetic.d.ts +1 -0
  138. package/dist/types/utils/oauth/tavily.d.ts +17 -0
  139. package/dist/types/utils/oauth/together.d.ts +1 -0
  140. package/dist/types/utils/oauth/types.d.ts +44 -0
  141. package/dist/types/utils/oauth/venice.d.ts +18 -0
  142. package/dist/types/utils/oauth/vercel-ai-gateway.d.ts +18 -0
  143. package/dist/types/utils/oauth/vllm.d.ts +16 -0
  144. package/dist/types/utils/oauth/xiaomi.d.ts +19 -0
  145. package/dist/types/utils/oauth/zai.d.ts +18 -0
  146. package/dist/types/utils/oauth/zenmux.d.ts +1 -0
  147. package/dist/types/utils/overflow.d.ts +54 -0
  148. package/dist/types/utils/parse-bind.d.ts +23 -0
  149. package/dist/types/utils/provider-response.d.ts +3 -0
  150. package/dist/types/utils/retry-after.d.ts +3 -0
  151. package/dist/types/utils/retry.d.ts +26 -0
  152. package/dist/types/utils/schema/adapt.d.ts +24 -0
  153. package/dist/types/utils/schema/compatibility.d.ts +30 -0
  154. package/dist/types/utils/schema/dereference.d.ts +11 -0
  155. package/dist/types/utils/schema/draft.d.ts +10 -0
  156. package/dist/types/utils/schema/equality.d.ts +4 -0
  157. package/dist/types/utils/schema/fields.d.ts +49 -0
  158. package/dist/types/utils/schema/index.d.ts +13 -0
  159. package/dist/types/utils/schema/json-schema-validator.d.ts +12 -0
  160. package/dist/types/utils/schema/meta-validator.d.ts +2 -0
  161. package/dist/types/utils/schema/normalize.d.ts +93 -0
  162. package/dist/types/utils/schema/spill.d.ts +8 -0
  163. package/dist/types/utils/schema/stamps.d.ts +25 -0
  164. package/dist/types/utils/schema/types.d.ts +4 -0
  165. package/dist/types/utils/schema/wire.d.ts +54 -0
  166. package/dist/types/utils/schema/zod-decontaminate.d.ts +31 -0
  167. package/dist/types/utils/sse-debug.d.ts +10 -0
  168. package/dist/types/utils/tool-call-healing.d.ts +71 -0
  169. package/dist/types/utils/tool-choice.d.ts +50 -0
  170. package/dist/types/utils/validation.d.ts +17 -0
  171. package/dist/types/utils.d.ts +28 -0
  172. package/package.json +146 -0
  173. package/src/api-registry.ts +96 -0
  174. package/src/auth-broker/client.ts +358 -0
  175. package/src/auth-broker/index.ts +5 -0
  176. package/src/auth-broker/refresher.ts +127 -0
  177. package/src/auth-broker/remote-store.ts +623 -0
  178. package/src/auth-broker/server.ts +644 -0
  179. package/src/auth-broker/types.ts +127 -0
  180. package/src/auth-broker/wire-schemas.ts +200 -0
  181. package/src/auth-gateway/http.ts +194 -0
  182. package/src/auth-gateway/index.ts +3 -0
  183. package/src/auth-gateway/server.ts +717 -0
  184. package/src/auth-gateway/types.ts +134 -0
  185. package/src/auth-storage.ts +4104 -0
  186. package/src/cli.ts +262 -0
  187. package/src/index.ts +54 -0
  188. package/src/model-cache.ts +129 -0
  189. package/src/model-manager.ts +450 -0
  190. package/src/model-thinking.ts +691 -0
  191. package/src/models.json +73853 -0
  192. package/src/models.json.d.ts +9 -0
  193. package/src/models.ts +56 -0
  194. package/src/prompts/turn-aborted-guidance.md +4 -0
  195. package/src/provider-details.ts +90 -0
  196. package/src/provider-models/bundled-references.ts +38 -0
  197. package/src/provider-models/descriptors.ts +308 -0
  198. package/src/provider-models/google.ts +91 -0
  199. package/src/provider-models/index.ts +5 -0
  200. package/src/provider-models/ollama.ts +153 -0
  201. package/src/provider-models/openai-compat.ts +2275 -0
  202. package/src/provider-models/special.ts +67 -0
  203. package/src/providers/amazon-bedrock.ts +849 -0
  204. package/src/providers/anthropic-messages-server-schema.ts +229 -0
  205. package/src/providers/anthropic-messages-server.ts +677 -0
  206. package/src/providers/anthropic.ts +2696 -0
  207. package/src/providers/aws-credentials.ts +501 -0
  208. package/src/providers/aws-eventstream.ts +185 -0
  209. package/src/providers/aws-sigv4.ts +218 -0
  210. package/src/providers/azure-openai-responses.ts +337 -0
  211. package/src/providers/cursor/gen/agent_pb.ts +15274 -0
  212. package/src/providers/cursor/proto/agent.proto +3526 -0
  213. package/src/providers/cursor/proto/buf.gen.yaml +6 -0
  214. package/src/providers/cursor/proto/buf.yaml +17 -0
  215. package/src/providers/cursor.ts +2561 -0
  216. package/src/providers/error-message.ts +21 -0
  217. package/src/providers/github-copilot-headers.ts +140 -0
  218. package/src/providers/gitlab-duo.ts +372 -0
  219. package/src/providers/google-auth.ts +252 -0
  220. package/src/providers/google-gemini-cli.ts +795 -0
  221. package/src/providers/google-gemini-headers.ts +41 -0
  222. package/src/providers/google-shared.ts +902 -0
  223. package/src/providers/google-types.ts +167 -0
  224. package/src/providers/google-vertex.ts +88 -0
  225. package/src/providers/google.ts +41 -0
  226. package/src/providers/grammar.ts +70 -0
  227. package/src/providers/kimi.ts +52 -0
  228. package/src/providers/mock.ts +500 -0
  229. package/src/providers/ollama.ts +544 -0
  230. package/src/providers/openai-anthropic-shim.ts +138 -0
  231. package/src/providers/openai-chat-server-schema.ts +243 -0
  232. package/src/providers/openai-chat-server.ts +628 -0
  233. package/src/providers/openai-codex/constants.ts +43 -0
  234. package/src/providers/openai-codex/request-transformer.ts +161 -0
  235. package/src/providers/openai-codex/response-handler.ts +81 -0
  236. package/src/providers/openai-codex-responses.ts +2598 -0
  237. package/src/providers/openai-completions-compat.ts +279 -0
  238. package/src/providers/openai-completions.ts +1853 -0
  239. package/src/providers/openai-responses-server-schema.ts +290 -0
  240. package/src/providers/openai-responses-server.ts +1183 -0
  241. package/src/providers/openai-responses-shared.ts +800 -0
  242. package/src/providers/openai-responses.ts +621 -0
  243. package/src/providers/pi-native-client.ts +228 -0
  244. package/src/providers/pi-native-server.ts +210 -0
  245. package/src/providers/register-builtins.ts +412 -0
  246. package/src/providers/synthetic.ts +50 -0
  247. package/src/providers/transform-messages.ts +309 -0
  248. package/src/providers/vision-guard.ts +31 -0
  249. package/src/rate-limit-utils.ts +84 -0
  250. package/src/stream.ts +895 -0
  251. package/src/types.ts +884 -0
  252. package/src/usage/claude.ts +431 -0
  253. package/src/usage/gemini.ts +250 -0
  254. package/src/usage/github-copilot.ts +421 -0
  255. package/src/usage/google-antigravity.ts +201 -0
  256. package/src/usage/kimi.ts +271 -0
  257. package/src/usage/minimax-code.ts +31 -0
  258. package/src/usage/openai-codex.ts +503 -0
  259. package/src/usage/shared.ts +10 -0
  260. package/src/usage/zai.ts +247 -0
  261. package/src/usage.ts +183 -0
  262. package/src/utils/abort.ts +51 -0
  263. package/src/utils/anthropic-auth.ts +87 -0
  264. package/src/utils/discovery/antigravity.ts +261 -0
  265. package/src/utils/discovery/codex.ts +371 -0
  266. package/src/utils/discovery/cursor.ts +306 -0
  267. package/src/utils/discovery/gemini.ts +248 -0
  268. package/src/utils/discovery/index.ts +4 -0
  269. package/src/utils/discovery/openai-compatible.ts +224 -0
  270. package/src/utils/event-stream.ts +142 -0
  271. package/src/utils/fireworks-model-id.ts +30 -0
  272. package/src/utils/foundry.ts +8 -0
  273. package/src/utils/h2-fetch.ts +60 -0
  274. package/src/utils/http-inspector.ts +176 -0
  275. package/src/utils/idle-iterator.ts +250 -0
  276. package/src/utils/json-parse.ts +148 -0
  277. package/src/utils/oauth/alibaba-coding-plan.ts +59 -0
  278. package/src/utils/oauth/anthropic.ts +200 -0
  279. package/src/utils/oauth/api-key-login.ts +87 -0
  280. package/src/utils/oauth/api-key-validation.ts +92 -0
  281. package/src/utils/oauth/callback-server.ts +276 -0
  282. package/src/utils/oauth/cerebras.ts +16 -0
  283. package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
  284. package/src/utils/oauth/cursor.ts +157 -0
  285. package/src/utils/oauth/deepseek.ts +53 -0
  286. package/src/utils/oauth/firepass.ts +24 -0
  287. package/src/utils/oauth/fireworks.ts +15 -0
  288. package/src/utils/oauth/github-copilot.ts +362 -0
  289. package/src/utils/oauth/gitlab-duo.ts +123 -0
  290. package/src/utils/oauth/google-antigravity.ts +200 -0
  291. package/src/utils/oauth/google-gemini-cli.ts +256 -0
  292. package/src/utils/oauth/google-oauth-shared.ts +110 -0
  293. package/src/utils/oauth/huggingface.ts +62 -0
  294. package/src/utils/oauth/index.ts +444 -0
  295. package/src/utils/oauth/kagi.ts +47 -0
  296. package/src/utils/oauth/kilo.ts +87 -0
  297. package/src/utils/oauth/kimi.ts +254 -0
  298. package/src/utils/oauth/litellm.ts +47 -0
  299. package/src/utils/oauth/lm-studio.ts +38 -0
  300. package/src/utils/oauth/minimax-code.ts +78 -0
  301. package/src/utils/oauth/moonshot.ts +16 -0
  302. package/src/utils/oauth/nanogpt.ts +15 -0
  303. package/src/utils/oauth/nvidia.ts +70 -0
  304. package/src/utils/oauth/oauth.html +199 -0
  305. package/src/utils/oauth/ollama-cloud.ts +28 -0
  306. package/src/utils/oauth/ollama.ts +47 -0
  307. package/src/utils/oauth/openai-codex.ts +299 -0
  308. package/src/utils/oauth/opencode.ts +49 -0
  309. package/src/utils/oauth/parallel.ts +46 -0
  310. package/src/utils/oauth/perplexity.ts +206 -0
  311. package/src/utils/oauth/pkce.ts +18 -0
  312. package/src/utils/oauth/qianfan.ts +58 -0
  313. package/src/utils/oauth/qwen-portal.ts +60 -0
  314. package/src/utils/oauth/synthetic.ts +16 -0
  315. package/src/utils/oauth/tavily.ts +46 -0
  316. package/src/utils/oauth/together.ts +16 -0
  317. package/src/utils/oauth/types.ts +94 -0
  318. package/src/utils/oauth/venice.ts +59 -0
  319. package/src/utils/oauth/vercel-ai-gateway.ts +47 -0
  320. package/src/utils/oauth/vllm.ts +40 -0
  321. package/src/utils/oauth/xiaomi.ts +137 -0
  322. package/src/utils/oauth/zai.ts +60 -0
  323. package/src/utils/oauth/zenmux.ts +15 -0
  324. package/src/utils/overflow.ts +137 -0
  325. package/src/utils/parse-bind.ts +54 -0
  326. package/src/utils/provider-response.ts +30 -0
  327. package/src/utils/retry-after.ts +110 -0
  328. package/src/utils/retry.ts +54 -0
  329. package/src/utils/schema/CONSTRAINTS.md +164 -0
  330. package/src/utils/schema/adapt.ts +36 -0
  331. package/src/utils/schema/compatibility.ts +435 -0
  332. package/src/utils/schema/dereference.ts +98 -0
  333. package/src/utils/schema/draft.ts +341 -0
  334. package/src/utils/schema/equality.ts +97 -0
  335. package/src/utils/schema/fields.ts +190 -0
  336. package/src/utils/schema/index.ts +13 -0
  337. package/src/utils/schema/json-schema-validator.ts +577 -0
  338. package/src/utils/schema/meta-validator.ts +167 -0
  339. package/src/utils/schema/normalize.ts +1588 -0
  340. package/src/utils/schema/spill.ts +43 -0
  341. package/src/utils/schema/stamps.ts +97 -0
  342. package/src/utils/schema/types.ts +11 -0
  343. package/src/utils/schema/wire.ts +213 -0
  344. package/src/utils/schema/zod-decontaminate.ts +331 -0
  345. package/src/utils/sse-debug.ts +289 -0
  346. package/src/utils/tool-call-healing.ts +271 -0
  347. package/src/utils/tool-choice.ts +99 -0
  348. package/src/utils/validation.ts +1019 -0
  349. package/src/utils.ts +166 -0
@@ -0,0 +1,1019 @@
1
+ /**
2
+ * Tool-call argument validation pipeline.
3
+ *
4
+ * Tools may declare their parameters as either Zod schemas (canonical) or
5
+ * plain JSON Schema (legacy / extensions). This module is the single
6
+ * entrypoint the agent calls before dispatching a tool — it:
7
+ *
8
+ * 1. Builds (or fetches from cache) a `ValidationContext` for the tool —
9
+ * the Zod schema if available plus the equivalent wire JSON Schema, or
10
+ * just the JSON Schema for non-Zod tools.
11
+ * 2. Normalizes LLM quirks (null / "null" → omit-or-default substitution)
12
+ * against the JSON Schema before validation.
13
+ * 3. Validates with the Zod or JSON-Schema validator.
14
+ * 4. On failure, walks the resulting issues and coerces JSON-stringified
15
+ * values (`"[1,2]"` → `[1,2]`), drops unrecognized keys, and retries up
16
+ * to `MAX_COERCION_PASSES` times.
17
+ * 5. Throws a formatted error if reconciliation fails; otherwise returns
18
+ * the parsed arguments with original unknown root fields preserved (so
19
+ * hallucinated top-level keys still surface to the caller).
20
+ *
21
+ * The goal is to be conservative: every coercion is a structural rewrite that
22
+ * keeps the schema in charge of acceptance — we never invent values, only
23
+ * massage shapes the LLM almost got right.
24
+ */
25
+ import { structuredCloneJSON } from "@gajae-code/utils";
26
+ import type { ZodType } from "zod/v4";
27
+ import type { $ZodIssue as ZodIssue } from "zod/v4/core";
28
+ import type { Tool, ToolCall } from "../types";
29
+ import { upgradeJsonSchemaTo202012 } from "./schema/draft";
30
+ import {
31
+ isJsonSchemaValueValid,
32
+ type JsonSchemaValidationIssue,
33
+ validateJsonSchemaValue,
34
+ } from "./schema/json-schema-validator";
35
+ import { isZodSchema, zodToWireSchema } from "./schema/wire";
36
+
37
+ // ============================================================================
38
+ // Type Coercion Utilities
39
+ // ============================================================================
40
+ //
41
+ // LLMs sometimes produce tool arguments where a value that should be a number,
42
+ // boolean, array, or object is instead passed as a JSON-encoded string. For
43
+ // example, an array parameter might arrive as `"[1, 2, 3]"` instead of `[1, 2, 3]`.
44
+ //
45
+ // Rather than rejecting these outright, we attempt automatic coercion:
46
+ // 1. Validate against the tool's schema (Zod, derived from TypeBox when the
47
+ // tool was authored with TypeBox).
48
+ // 2. For each type error where the actual value is a string, we check if
49
+ // parsing it as JSON yields a value matching the expected type.
50
+ // 3. If so, we replace the string with the parsed value and re-validate.
51
+ //
52
+ // This is intentionally conservative: we only parse strings that look like
53
+ // valid JSON literals (objects, arrays, booleans, null, numbers) and only
54
+ // accept the result if it matches the schema's expected type.
55
+ // ============================================================================
56
+
57
+ /** Regex matching valid JSON number literals (integers, decimals, scientific notation) */
58
+ const JSON_NUMBER_PATTERN = /^[+-]?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
59
+
60
+ /** Regex matching numeric strings (allows leading zeros) */
61
+ const NUMERIC_STRING_PATTERN = /^[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
62
+
63
+ /**
64
+ * Checks if a value matches any of the expected JSON Schema types.
65
+ * Used to verify that a parsed JSON value is actually what the schema wants.
66
+ */
67
+ function matchesExpectedType(value: unknown, expectedTypes: string[]): boolean {
68
+ return expectedTypes.some(type => {
69
+ switch (type) {
70
+ case "string":
71
+ return typeof value === "string";
72
+ case "number":
73
+ return typeof value === "number" && Number.isFinite(value);
74
+ case "integer":
75
+ return typeof value === "number" && Number.isInteger(value);
76
+ case "boolean":
77
+ return typeof value === "boolean";
78
+ case "null":
79
+ return value === null;
80
+ case "array":
81
+ return Array.isArray(value);
82
+ case "object":
83
+ return value !== null && typeof value === "object" && !Array.isArray(value);
84
+ default:
85
+ return false;
86
+ }
87
+ });
88
+ }
89
+
90
+ function tryParseNumberString(value: string, expectedTypes: string[]): { value: unknown; changed: boolean } {
91
+ if (!expectedTypes.includes("number") && !expectedTypes.includes("integer")) {
92
+ return { value, changed: false };
93
+ }
94
+
95
+ const trimmed = value.trim();
96
+ if (!trimmed || !NUMERIC_STRING_PATTERN.test(trimmed)) {
97
+ return { value, changed: false };
98
+ }
99
+
100
+ const parsed = Number(trimmed);
101
+ if (!Number.isFinite(parsed)) {
102
+ return { value, changed: false };
103
+ }
104
+
105
+ if (!matchesExpectedType(parsed, expectedTypes)) {
106
+ return { value, changed: false };
107
+ }
108
+
109
+ return { value: parsed, changed: true };
110
+ }
111
+
112
+ function tryParseLeadingJsonContainer(value: string): unknown | undefined {
113
+ const firstChar = value[0];
114
+ const closingChar = firstChar === "{" ? "}" : firstChar === "[" ? "]" : undefined;
115
+ if (!closingChar) return undefined;
116
+
117
+ let depth = 0;
118
+ let inString = false;
119
+ let escaped = false;
120
+
121
+ for (let index = 0; index < value.length; index += 1) {
122
+ const char = value[index];
123
+
124
+ if (inString) {
125
+ if (escaped) {
126
+ escaped = false;
127
+ continue;
128
+ }
129
+ if (char === "\\") {
130
+ escaped = true;
131
+ continue;
132
+ }
133
+ if (char === '"') inString = false;
134
+ continue;
135
+ }
136
+
137
+ if (char === '"') {
138
+ inString = true;
139
+ continue;
140
+ }
141
+
142
+ if (char === firstChar) {
143
+ depth += 1;
144
+ continue;
145
+ }
146
+
147
+ if (char !== closingChar) continue;
148
+ depth -= 1;
149
+ if (depth !== 0) continue;
150
+
151
+ const prefix = value.slice(0, index + 1);
152
+ try {
153
+ return JSON.parse(prefix) as unknown;
154
+ } catch {
155
+ // LLMs sometimes emit literal `\n` or `\t` between JSON tokens
156
+ // (e.g. `[{...}\n]`). Convert these to real whitespace and retry.
157
+ const cleaned = cleanLiteralEscapes(prefix);
158
+ if (cleaned !== prefix) {
159
+ try {
160
+ return JSON.parse(cleaned) as unknown;
161
+ } catch {}
162
+ }
163
+ // Try escaping raw control chars that appear inside string literals.
164
+ const escapedControls = escapeRawControlsInJsonStrings(prefix);
165
+ if (escapedControls !== prefix) {
166
+ try {
167
+ return JSON.parse(escapedControls) as unknown;
168
+ } catch {}
169
+ }
170
+ // Also try single-char healing on the extracted prefix.
171
+ return tryHealMalformedJson(prefix);
172
+ }
173
+ }
174
+
175
+ return undefined;
176
+ }
177
+
178
+ /**
179
+ * Replace literal `\n`, `\t`, `\r` sequences that appear OUTSIDE of JSON
180
+ * strings with actual whitespace. LLMs sometimes produce these when they
181
+ * confuse the tool-call encoding with the content encoding.
182
+ */
183
+ function cleanLiteralEscapes(value: string): string {
184
+ let result = "";
185
+ let inString = false;
186
+ let i = 0;
187
+ while (i < value.length) {
188
+ const ch = value[i];
189
+ if (inString) {
190
+ if (ch === "\\" && i + 1 < value.length) {
191
+ result += ch + value[i + 1];
192
+ i += 2;
193
+ continue;
194
+ }
195
+ if (ch === '"') inString = false;
196
+ result += ch;
197
+ i += 1;
198
+ continue;
199
+ }
200
+ if (ch === '"') {
201
+ inString = true;
202
+ result += ch;
203
+ i += 1;
204
+ continue;
205
+ }
206
+ // Outside a string: replace literal \n, \t, \r with whitespace
207
+ if (ch === "\\" && i + 1 < value.length) {
208
+ const next = value[i + 1];
209
+ if (next === "n" || next === "t" || next === "r") {
210
+ result += " ";
211
+ i += 2;
212
+ continue;
213
+ }
214
+ }
215
+ result += ch;
216
+ i += 1;
217
+ }
218
+ return result;
219
+ }
220
+
221
+ /**
222
+ * Escape raw control characters (0x00–0x1F) that appear *inside* JSON string
223
+ * literals. LLMs sometimes emit literal newlines/tabs/etc. inside string
224
+ * content instead of `\n` / `\t` escape sequences, which `JSON.parse` rejects
225
+ * even though the surrounding structure is valid.
226
+ *
227
+ * This function only rewrites characters while inside a string; structural
228
+ * whitespace outside of strings is preserved unchanged.
229
+ */
230
+ function escapeRawControlsInJsonStrings(value: string): string {
231
+ let result = "";
232
+ let inString = false;
233
+ let escaped = false;
234
+ let changed = false;
235
+ for (let i = 0; i < value.length; i += 1) {
236
+ const ch = value[i];
237
+ if (inString) {
238
+ if (escaped) {
239
+ result += ch;
240
+ escaped = false;
241
+ continue;
242
+ }
243
+ if (ch === "\\") {
244
+ result += ch;
245
+ escaped = true;
246
+ continue;
247
+ }
248
+ if (ch === '"') {
249
+ result += ch;
250
+ inString = false;
251
+ continue;
252
+ }
253
+ const code = ch.charCodeAt(0);
254
+ if (code < 0x20) {
255
+ changed = true;
256
+ switch (ch) {
257
+ case "\n":
258
+ result += "\\n";
259
+ break;
260
+ case "\r":
261
+ result += "\\r";
262
+ break;
263
+ case "\t":
264
+ result += "\\t";
265
+ break;
266
+ case "\b":
267
+ result += "\\b";
268
+ break;
269
+ case "\f":
270
+ result += "\\f";
271
+ break;
272
+ default:
273
+ result += `\\u${code.toString(16).padStart(4, "0")}`;
274
+ }
275
+ continue;
276
+ }
277
+ result += ch;
278
+ continue;
279
+ }
280
+ if (ch === '"') {
281
+ inString = true;
282
+ }
283
+ result += ch;
284
+ }
285
+ return changed ? result : value;
286
+ }
287
+
288
+ /** Maximum single-character edits to attempt when healing malformed JSON. */
289
+ const MAX_HEAL_DISTANCE = 3;
290
+ const BRACKET_CHARS = ["[", "]", "{", "}"] as const;
291
+
292
+ /**
293
+ * Attempts to heal near-valid JSON by applying single-character edits near the
294
+ * end of the string. LLMs (especially smaller ones) sometimes produce JSON with
295
+ * a single misplaced, extra, or wrong bracket at the end — e.g. `"}]"` becomes
296
+ * `"]}"` or gets an extra `}` appended. This function tries:
297
+ * 1. Removing a single character from the last few positions
298
+ * 2. Replacing a single character in the last few positions with each bracket type
299
+ *
300
+ * Returns the parsed value on success, undefined on failure.
301
+ */
302
+ function tryHealMalformedJson(value: string): unknown | undefined {
303
+ // Verify it actually fails to parse
304
+ try {
305
+ return JSON.parse(value) as unknown;
306
+ } catch {}
307
+
308
+ // Only attempt edits within the last few characters — the error is always
309
+ // a bracket issue at the tail for the class of LLM mistakes this targets.
310
+ const tailStart = Math.max(0, value.length - (MAX_HEAL_DISTANCE * 2 + 1));
311
+
312
+ // Strategy 1: remove a single character from the tail
313
+ for (let i = tailStart; i < value.length; i += 1) {
314
+ const candidate = value.slice(0, i) + value.slice(i + 1);
315
+ try {
316
+ return JSON.parse(candidate) as unknown;
317
+ } catch {}
318
+ }
319
+
320
+ // Strategy 2: replace a single character in the tail with each bracket type
321
+ for (let i = tailStart; i < value.length; i += 1) {
322
+ const original = value[i];
323
+ for (const replacement of BRACKET_CHARS) {
324
+ if (replacement === original) continue;
325
+ const candidate = value.slice(0, i) + replacement + value.slice(i + 1);
326
+ try {
327
+ return JSON.parse(candidate) as unknown;
328
+ } catch {}
329
+ }
330
+ }
331
+
332
+ return undefined;
333
+ }
334
+
335
+ /**
336
+ * Attempts to parse a string as JSON if it looks like a JSON literal and
337
+ * the parsed result matches one of the expected types.
338
+ *
339
+ * Only attempts parsing for strings that syntactically look like JSON:
340
+ * - Objects: `{...}`
341
+ * - Arrays: `[...]`
342
+ * - Literals: `true`, `false`, `null`, or numeric strings
343
+ *
344
+ * Returns `{ changed: true }` only if parsing succeeded AND the result
345
+ * matches an expected type. This prevents false positives like parsing
346
+ * the string `"123"` when the schema actually wants a string.
347
+ */
348
+ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value: unknown; changed: boolean } {
349
+ const trimmed = value.trim();
350
+ if (!trimmed) return { value, changed: false };
351
+
352
+ const numberCoercion = tryParseNumberString(trimmed, expectedTypes);
353
+ if (numberCoercion.changed) {
354
+ return numberCoercion;
355
+ }
356
+
357
+ // Quick syntactic checks to avoid unnecessary parse attempts
358
+ const looksJsonObject = trimmed.startsWith("{");
359
+ const looksJsonArray = trimmed.startsWith("[");
360
+ const looksJsonLiteral =
361
+ trimmed === "true" || trimmed === "false" || trimmed === "null" || JSON_NUMBER_PATTERN.test(trimmed);
362
+
363
+ if (!looksJsonObject && !looksJsonArray && !looksJsonLiteral) {
364
+ return { value, changed: false };
365
+ }
366
+
367
+ try {
368
+ const parsed = JSON.parse(trimmed) as unknown;
369
+ // If the string was "null", we parsed it to actual null.
370
+ // Accept this even if null isn't in expectedTypes — the LLM meant "no value".
371
+ // normalizeOptionalNullsForSchema will strip it from optional fields, and
372
+ // the validator will correctly error on required fields.
373
+ if (parsed === null && trimmed === "null") {
374
+ return { value: null, changed: true };
375
+ }
376
+ // For non-null values, only accept if the parsed type matches what the schema expects
377
+ if (matchesExpectedType(parsed, expectedTypes)) {
378
+ return { value: parsed, changed: true };
379
+ }
380
+ } catch {
381
+ if (looksJsonObject || looksJsonArray) {
382
+ // Try escaping raw control chars inside string literals (LLMs sometimes
383
+ // emit literal newlines/tabs inside string content rather than `\n`/`\t`).
384
+ const escapedControls = escapeRawControlsInJsonStrings(trimmed);
385
+ if (escapedControls !== trimmed) {
386
+ try {
387
+ const parsed = JSON.parse(escapedControls) as unknown;
388
+ if (matchesExpectedType(parsed, expectedTypes)) {
389
+ return { value: parsed, changed: true };
390
+ }
391
+ } catch {}
392
+ }
393
+ // Try extracting a valid JSON prefix (handles trailing junk after balanced container)
394
+ const leading = tryParseLeadingJsonContainer(trimmed);
395
+ if (leading !== undefined && matchesExpectedType(leading, expectedTypes)) {
396
+ return { value: leading, changed: true };
397
+ }
398
+ // Try healing single-character bracket errors near the end of the string
399
+ const healed = tryHealMalformedJson(trimmed);
400
+ if (healed !== undefined && matchesExpectedType(healed, expectedTypes)) {
401
+ return { value: healed, changed: true };
402
+ }
403
+ }
404
+ return { value, changed: false };
405
+ }
406
+
407
+ return { value, changed: false };
408
+ }
409
+
410
+ // ============================================================================
411
+ // JSON Pointer Utilities (RFC 6901)
412
+ // ============================================================================
413
+ //
414
+ // Internally we still address error locations using JSON Pointer syntax
415
+ // (e.g., `/foo/0/bar`). These utilities let coercion read and write values at
416
+ // those paths regardless of whether the original error came from Zod or
417
+ // from JSON-Schema-shaped normalization.
418
+ // ============================================================================
419
+
420
+ /** Encode a structured Zod issue path as a JSON Pointer. */
421
+ function pathToPointer(path: ReadonlyArray<PropertyKey>): string {
422
+ if (path.length === 0) return "";
423
+ return `/${path.map(seg => String(seg).replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`;
424
+ }
425
+
426
+ /**
427
+ * Decodes a JSON Pointer string into path segments.
428
+ * Handles RFC 6901 escape sequences: ~1 -> /, ~0 -> ~
429
+ */
430
+ function decodeJsonPointer(pointer: string): string[] {
431
+ if (!pointer) return [];
432
+ return pointer
433
+ .split("/")
434
+ .slice(1) // Remove leading empty segment from initial "/"
435
+ .map(segment => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
436
+ }
437
+
438
+ /**
439
+ * Retrieves a value from a nested object/array structure using a JSON Pointer.
440
+ * Returns undefined if the path doesn't exist or traversal fails.
441
+ */
442
+ function getValueAtPointer(root: unknown, pointer: string): unknown {
443
+ if (!pointer) return root;
444
+ const segments = decodeJsonPointer(pointer);
445
+ let current: unknown = root;
446
+
447
+ for (const segment of segments) {
448
+ if (current === null || current === undefined) return undefined;
449
+ if (Array.isArray(current)) {
450
+ const index = Number(segment);
451
+ if (!Number.isInteger(index)) return undefined;
452
+ current = current[index];
453
+ continue;
454
+ }
455
+ if (typeof current !== "object") return undefined;
456
+ current = (current as Record<string, unknown>)[segment];
457
+ }
458
+
459
+ return current;
460
+ }
461
+
462
+ /**
463
+ * Sets a value in a nested object/array structure using a JSON Pointer.
464
+ * Mutates the structure in-place. Returns the root (possibly unchanged if
465
+ * the path was invalid).
466
+ */
467
+ function setValueAtPointer(root: unknown, pointer: string, value: unknown): unknown {
468
+ if (!pointer) return value;
469
+ const segments = decodeJsonPointer(pointer);
470
+ let current: unknown = root;
471
+
472
+ // Navigate to the parent of the target location
473
+ for (let index = 0; index < segments.length - 1; index += 1) {
474
+ const segment = segments[index];
475
+ if (current === null || current === undefined) return root;
476
+ if (Array.isArray(current)) {
477
+ const arrayIndex = Number(segment);
478
+ if (!Number.isInteger(arrayIndex)) return root;
479
+ current = current[arrayIndex];
480
+ continue;
481
+ }
482
+ if (typeof current !== "object") return root;
483
+ current = (current as Record<string, unknown>)[segment];
484
+ }
485
+
486
+ // Set the value at the final segment
487
+ const lastSegment = segments[segments.length - 1];
488
+ if (Array.isArray(current)) {
489
+ const arrayIndex = Number(lastSegment);
490
+ if (!Number.isInteger(arrayIndex)) return root;
491
+ current[arrayIndex] = value;
492
+ return root;
493
+ }
494
+
495
+ if (typeof current !== "object" || current === null) return root;
496
+ (current as Record<string, unknown>)[lastSegment] = value;
497
+ return root;
498
+ }
499
+
500
+ /**
501
+ * Returns a new structure with the key at `pointer` removed. Only the
502
+ * containers along the path are shallow-cloned (`O(depth)` allocations);
503
+ * every sibling subtree is shared with the input. Returns the input
504
+ * reference unchanged when the pointer is empty, the path is invalid, or
505
+ * the final key is absent — so callers can detect a no-op via identity.
506
+ */
507
+ function deleteValueAtPointer(root: unknown, pointer: string): unknown {
508
+ if (!pointer) return root;
509
+ const segments = decodeJsonPointer(pointer);
510
+ if (segments.length === 0) return root;
511
+ return deleteAtSegment(root, segments, 0);
512
+ }
513
+
514
+ function deleteAtSegment(node: unknown, segments: string[], depth: number): unknown {
515
+ const segment = segments[depth];
516
+ const isLeaf = depth === segments.length - 1;
517
+
518
+ if (Array.isArray(node)) {
519
+ const index = Number(segment);
520
+ if (!Number.isInteger(index) || index < 0 || index >= node.length) return node;
521
+ if (isLeaf) {
522
+ const next = node.slice();
523
+ next.splice(index, 1);
524
+ return next;
525
+ }
526
+ const child = deleteAtSegment(node[index], segments, depth + 1);
527
+ if (child === node[index]) return node;
528
+ const next = node.slice();
529
+ next[index] = child;
530
+ return next;
531
+ }
532
+
533
+ if (typeof node !== "object" || node === null) return node;
534
+ const obj = node as Record<string, unknown>;
535
+ if (!Object.hasOwn(obj, segment)) return node;
536
+ if (isLeaf) {
537
+ const { [segment]: _omit, ...rest } = obj;
538
+ return rest;
539
+ }
540
+ const child = deleteAtSegment(obj[segment], segments, depth + 1);
541
+ if (child === obj[segment]) return node;
542
+ return { ...obj, [segment]: child };
543
+ }
544
+
545
+ // ============================================================================
546
+ // JSON-Schema-driven normalization passes (LLM quirks).
547
+ // ============================================================================
548
+
549
+ /**
550
+ * Test a JSON-Schema branch during nullable normalization. Kept deliberately
551
+ * small and synchronous so validation does not need to compile legacy schemas
552
+ * into another schema language.
553
+ */
554
+ function branchMatchesSchema(branch: unknown, value: unknown): boolean {
555
+ return isJsonSchemaValueValid(branch, value);
556
+ }
557
+
558
+ function normalizeOptionalNullsForSchema(
559
+ schema: unknown,
560
+ value: unknown,
561
+ isRoot = true,
562
+ ): { value: unknown; changed: boolean } {
563
+ if (value === null || value === undefined) return { value, changed: false };
564
+ if (schema === null || typeof schema !== "object") return { value, changed: false };
565
+
566
+ const schemaObject = schema as Record<string, unknown>;
567
+
568
+ const normalizeAnyOfLike = (keyword: "anyOf" | "oneOf"): { value: unknown; changed: boolean } => {
569
+ const branches = schemaObject[keyword];
570
+ if (!Array.isArray(branches)) return { value, changed: false };
571
+
572
+ let changedCandidate: { value: unknown; changed: true } | null = null;
573
+
574
+ for (const branch of branches) {
575
+ const normalized = normalizeOptionalNullsForSchema(branch, value, isRoot);
576
+ if (!normalized.changed) continue;
577
+
578
+ if (branchMatchesSchema(branch, normalized.value)) {
579
+ return normalized;
580
+ }
581
+
582
+ if (!changedCandidate) {
583
+ changedCandidate = { value: normalized.value, changed: true };
584
+ }
585
+ }
586
+
587
+ return changedCandidate ?? { value, changed: false };
588
+ };
589
+
590
+ const anyOfNormalization = normalizeAnyOfLike("anyOf");
591
+ if (anyOfNormalization.changed) return anyOfNormalization;
592
+
593
+ const oneOfNormalization = normalizeAnyOfLike("oneOf");
594
+ if (oneOfNormalization.changed) return oneOfNormalization;
595
+
596
+ if (Array.isArray(schemaObject.allOf)) {
597
+ let changed = false;
598
+ let nextValue: unknown = value;
599
+ for (const branch of schemaObject.allOf) {
600
+ const normalized = normalizeOptionalNullsForSchema(branch, nextValue, isRoot);
601
+ if (!normalized.changed) continue;
602
+ nextValue = normalized.value;
603
+ changed = true;
604
+ }
605
+ if (changed) return { value: nextValue, changed: true };
606
+ }
607
+
608
+ if (Array.isArray(value)) {
609
+ const itemSchema = schemaObject.items;
610
+ if (itemSchema === null || typeof itemSchema !== "object" || Array.isArray(itemSchema)) {
611
+ return { value, changed: false };
612
+ }
613
+
614
+ let changed = false;
615
+ let nextValue = value;
616
+ for (let i = 0; i < value.length; i += 1) {
617
+ const normalized = normalizeOptionalNullsForSchema(itemSchema, value[i], false);
618
+ if (!normalized.changed) continue;
619
+ if (!changed) {
620
+ nextValue = [...value];
621
+ changed = true;
622
+ }
623
+ nextValue[i] = normalized.value;
624
+ }
625
+ return { value: changed ? nextValue : value, changed };
626
+ }
627
+
628
+ // Coerce string → number/integer when the schema branch declares those types.
629
+ // This fixes anyOf:[{type:"number"},{type:"null"}] (i.e. Optional<number>) where
630
+ // the validator reports an "anyOf" error rather than a "type" error.
631
+ if ((schemaObject.type === "number" || schemaObject.type === "integer") && typeof value === "string") {
632
+ return tryParseNumberString(value, [schemaObject.type as string]);
633
+ }
634
+
635
+ if (schemaObject.type !== "object") return { value, changed: false };
636
+ if (typeof value !== "object" || value === null) return { value, changed: false };
637
+ if (Array.isArray(value)) return { value, changed: false };
638
+ if (schemaObject.properties === null || typeof schemaObject.properties !== "object") {
639
+ return { value, changed: false };
640
+ }
641
+
642
+ const properties = schemaObject.properties as Record<string, unknown>;
643
+ const required = new Set(Array.isArray(schemaObject.required) ? (schemaObject.required as string[]) : []);
644
+
645
+ let changed = false;
646
+ let nextValue = value as Record<string, unknown>;
647
+
648
+ for (const [key, propertySchema] of Object.entries(properties)) {
649
+ if (!(key in nextValue)) continue;
650
+ const currentValue = nextValue[key];
651
+ const isNullish = currentValue === null || currentValue === "null";
652
+
653
+ // Strip null and the string "null" from optional fields.
654
+ // The LLM sometimes outputs string "null" to mean "no value".
655
+ if (isNullish && !required.has(key)) {
656
+ if (!changed) {
657
+ nextValue = { ...nextValue };
658
+ changed = true;
659
+ }
660
+ delete nextValue[key];
661
+ continue;
662
+ }
663
+
664
+ // Substitute the schema-supplied default when a required field arrives
665
+ // as null/"null". LLMs commonly emit null for "I have nothing to say
666
+ // here"; if the schema documents a default, honor it instead of
667
+ // rejecting the whole call. The default is cloned so mutations on the
668
+ // validated value never bleed back into the schema.
669
+ if (isNullish && propertySchema && typeof propertySchema === "object") {
670
+ const propertyObject = propertySchema as Record<string, unknown>;
671
+ if ("default" in propertyObject) {
672
+ if (!changed) {
673
+ nextValue = { ...nextValue };
674
+ changed = true;
675
+ }
676
+ nextValue[key] = structuredCloneJSON(propertyObject.default);
677
+ continue;
678
+ }
679
+ }
680
+ const normalized = normalizeOptionalNullsForSchema(propertySchema, currentValue, false);
681
+ if (!normalized.changed) continue;
682
+
683
+ if (!changed) {
684
+ nextValue = { ...nextValue };
685
+ changed = true;
686
+ }
687
+ nextValue[key] = normalized.value;
688
+ }
689
+
690
+ // Strip unknown keys with null/"null" values when the schema forbids extras.
691
+ // LLMs sometimes hallucinate verbs alongside valid ones (e.g. `split: null`,
692
+ // `original: null`). Rejecting the entire tool call wastes a turn; treating
693
+ // these the same as null on known optional fields is a safer fallback. Keys
694
+ // with non-null unknown values are left intact so genuine schema mistakes
695
+ // still surface as validation errors.
696
+ //
697
+ // At the ROOT level we deliberately keep unknown null-valued keys intact:
698
+ // Zod-emitted wire schemas always set `additionalProperties: false`, but the
699
+ // post-validation `preserveUnknownRootFields` pass re-attaches root extras
700
+ // so callers can observe (and reject) hallucinated fields. Stripping here
701
+ // would erase the field before that snapshot, hiding the rejection signal.
702
+ if (!isRoot && schemaObject.additionalProperties === false) {
703
+ const knownKeys = new Set(Object.keys(properties));
704
+ for (const key of Object.keys(nextValue)) {
705
+ if (knownKeys.has(key)) continue;
706
+ const v = nextValue[key];
707
+ if (v !== null && v !== "null") continue;
708
+ if (!changed) {
709
+ nextValue = { ...nextValue };
710
+ changed = true;
711
+ }
712
+ delete nextValue[key];
713
+ }
714
+ }
715
+
716
+ return { value: changed ? nextValue : value, changed };
717
+ }
718
+
719
+ // ============================================================================
720
+ // Zod issue → coercion bridge
721
+ // ============================================================================
722
+
723
+ interface FlatIssue {
724
+ keyword: "type" | "unrecognized" | "other";
725
+ instancePath: string;
726
+ expectedTypes: string[];
727
+ }
728
+
729
+ /**
730
+ * Translate the Zod expected-type marker into the JSON-Schema type name our
731
+ * coercion helpers already understand.
732
+ */
733
+ function mapZodExpectedToJsonSchemaType(expected: unknown): string | null {
734
+ if (typeof expected !== "string") return null;
735
+ switch (expected) {
736
+ case "string":
737
+ case "number":
738
+ case "boolean":
739
+ case "array":
740
+ case "object":
741
+ case "null":
742
+ return expected;
743
+ case "record":
744
+ return "object";
745
+ case "int":
746
+ case "bigint":
747
+ return "integer";
748
+ case "nan":
749
+ return "number";
750
+ default:
751
+ return null;
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Flatten Zod issues into a list of (path, expected-types) records suitable
757
+ * for the coercion pass. Recurses through `invalid_union` so each inner
758
+ * candidate produces independent coercion attempts.
759
+ */
760
+ function flattenIssues(issues: ReadonlyArray<ZodIssue>): FlatIssue[] {
761
+ const out: FlatIssue[] = [];
762
+ const walk = (issue: ZodIssue, prefix: ReadonlyArray<PropertyKey>): void => {
763
+ const fullPath = prefix.length === 0 ? issue.path : [...prefix, ...issue.path];
764
+ if (issue.code === "invalid_type") {
765
+ const mapped = mapZodExpectedToJsonSchemaType((issue as { expected?: unknown }).expected);
766
+ if (mapped) {
767
+ out.push({ keyword: "type", instancePath: pathToPointer(fullPath), expectedTypes: [mapped] });
768
+ return;
769
+ }
770
+ }
771
+ if (issue.code === "unrecognized_keys") {
772
+ const keys = (issue as { keys?: ReadonlyArray<string> }).keys ?? [];
773
+ for (const key of keys) {
774
+ out.push({
775
+ keyword: "unrecognized",
776
+ instancePath: pathToPointer([...fullPath, key]),
777
+ expectedTypes: [],
778
+ });
779
+ }
780
+ return;
781
+ }
782
+ if (issue.code === "invalid_union") {
783
+ const inner = (issue as unknown as { errors?: ReadonlyArray<ReadonlyArray<ZodIssue>> }).errors;
784
+ if (inner) {
785
+ for (const branch of inner) {
786
+ for (const child of branch) {
787
+ walk(child, fullPath);
788
+ }
789
+ }
790
+ }
791
+ return;
792
+ }
793
+ out.push({ keyword: "other", instancePath: pathToPointer(fullPath), expectedTypes: [] });
794
+ };
795
+ for (const issue of issues) walk(issue, []);
796
+ return out;
797
+ }
798
+
799
+ /**
800
+ * Repair issues raised by the validator before we surface them to the caller.
801
+ *
802
+ * Two kinds of repair are applied:
803
+ * - **type**: when a value is a JSON-encoded string and the schema wants
804
+ * something else, parse it and substitute the parsed value.
805
+ * - **unrecognized**: when a strict object received an extra key (Zod's
806
+ * `unrecognized_keys` or JSON Schema's `additionalProperties: false`),
807
+ * drop that key so re-validation succeeds. This effectively coerces every
808
+ * object schema to loose semantics recursively without rebuilding the
809
+ * underlying Zod tree.
810
+ *
811
+ * The function is safe and conservative:
812
+ * - Only processes "type" and "unrecognized" issues
813
+ * - Only attempts JSON coercion on string values
814
+ * - Only accepts parsed results that match the expected type
815
+ * - Clones the args object before mutation (copy-on-write)
816
+ */
817
+ function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unknown; changed: boolean } {
818
+ if (issues.length === 0) return { value: args, changed: false };
819
+
820
+ let changed = false;
821
+ // Tracks whether `nextArgs` is a fully owned deep copy (safe to mutate
822
+ // leaves). The unrecognized-key path uses path-shallow immutable updates
823
+ // and does NOT require ownership, so we only pay for the deep clone when
824
+ // a type coercion actually needs to write into a leaf.
825
+ let owned = false;
826
+ let nextArgs: unknown = args;
827
+
828
+ for (const issue of issues) {
829
+ if (issue.keyword === "unrecognized") {
830
+ const previous = nextArgs;
831
+ nextArgs = deleteValueAtPointer(nextArgs, issue.instancePath);
832
+ if (nextArgs !== previous) changed = true;
833
+ continue;
834
+ }
835
+ if (issue.keyword !== "type") continue;
836
+ if (issue.expectedTypes.length === 0) continue;
837
+
838
+ const currentValue = getValueAtPointer(nextArgs, issue.instancePath);
839
+ if (typeof currentValue !== "string") continue;
840
+
841
+ const result = tryParseJsonForTypes(currentValue, issue.expectedTypes);
842
+ if (!result.changed) continue;
843
+
844
+ if (!owned) {
845
+ nextArgs = structuredCloneJSON(nextArgs);
846
+ owned = true;
847
+ changed = true;
848
+ }
849
+ nextArgs = setValueAtPointer(nextArgs, issue.instancePath, result.value);
850
+ }
851
+
852
+ return { value: changed ? nextArgs : args, changed };
853
+ }
854
+
855
+ // ============================================================================
856
+ // Public API
857
+ // ============================================================================
858
+
859
+ type ValidationContext =
860
+ | {
861
+ kind: "zod";
862
+ zod: ZodType;
863
+ json: Record<string, unknown>;
864
+ }
865
+ | {
866
+ kind: "json";
867
+ json: Record<string, unknown>;
868
+ };
869
+
870
+ /**
871
+ * Cache the validation context derived from a tool's parameters schema.
872
+ * Keyed by the parameters object identity, which is stable across tool
873
+ * registrations.
874
+ */
875
+ const kValidationContext = Symbol("ai.validationContext");
876
+ type ParamsWithValidationContext = object & { [kValidationContext]?: ValidationContext };
877
+ function getValidationContext(tool: Tool): ValidationContext {
878
+ const params = tool.parameters as ParamsWithValidationContext;
879
+ const existing = params[kValidationContext];
880
+ if (existing) return existing;
881
+ const ctx: ValidationContext = isZodSchema(params)
882
+ ? { kind: "zod", zod: params, json: zodToWireSchema(params) }
883
+ : { kind: "json", json: upgradeJsonSchemaTo202012(params) as Record<string, unknown> };
884
+ params[kValidationContext] = ctx;
885
+ return ctx;
886
+ }
887
+
888
+ type ContextValidationResult =
889
+ | { success: true; value: unknown }
890
+ | { success: false; flatIssues: FlatIssue[]; messages: string[] };
891
+
892
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
893
+ return typeof value === "object" && value !== null && !Array.isArray(value);
894
+ }
895
+
896
+ function preserveUnknownRootFields(input: unknown, parsed: unknown): unknown {
897
+ if (!isPlainRecord(input) || !isPlainRecord(parsed)) return parsed;
898
+ return { ...input, ...parsed };
899
+ }
900
+
901
+ function flattenJsonSchemaIssues(issues: ReadonlyArray<JsonSchemaValidationIssue>): FlatIssue[] {
902
+ return issues.map(issue => {
903
+ if (issue.keyword === "additionalProperties") {
904
+ return {
905
+ keyword: "unrecognized",
906
+ instancePath: pathToPointer(issue.path),
907
+ expectedTypes: [],
908
+ };
909
+ }
910
+ return {
911
+ keyword: issue.keyword === "type" ? "type" : "other",
912
+ instancePath: pathToPointer(issue.path),
913
+ expectedTypes: issue.expectedTypes ?? [],
914
+ };
915
+ });
916
+ }
917
+
918
+ function formatIssuePath(path: ReadonlyArray<PropertyKey>): string {
919
+ return path.length === 0 ? "root" : path.map(seg => String(seg)).join("/");
920
+ }
921
+
922
+ function validateContext(ctx: ValidationContext, value: unknown): ContextValidationResult {
923
+ if (ctx.kind === "zod") {
924
+ const result = ctx.zod.safeParse(value);
925
+ if (result.success) {
926
+ return { success: true, value: preserveUnknownRootFields(value, result.data) };
927
+ }
928
+ return {
929
+ success: false,
930
+ flatIssues: flattenIssues(result.error.issues),
931
+ messages: result.error.issues.map(issue => ` - ${formatIssuePath(issue.path)}: ${issue.message}`),
932
+ };
933
+ }
934
+
935
+ const result = validateJsonSchemaValue(ctx.json, value);
936
+ if (result.success) return { success: true, value };
937
+ return {
938
+ success: false,
939
+ flatIssues: flattenJsonSchemaIssues(result.issues),
940
+ messages: result.issues.map(issue => ` - ${formatIssuePath(issue.path)}: ${issue.message}`),
941
+ };
942
+ }
943
+
944
+ const MAX_COERCION_PASSES = 5;
945
+
946
+ /**
947
+ * Finds a tool by name and validates the tool call arguments against its schema.
948
+ * @param tools Array of tool definitions
949
+ * @param toolCall The tool call from the LLM
950
+ * @returns The validated arguments
951
+ * @throws Error if tool is not found or validation fails
952
+ */
953
+ export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["arguments"] {
954
+ const tool = tools.find(t => t.name === toolCall.name);
955
+ if (!tool) {
956
+ throw new Error(`Tool "${toolCall.name}" not found`);
957
+ }
958
+ return validateToolArguments(tool, toolCall);
959
+ }
960
+
961
+ /**
962
+ * Validates tool call arguments against the tool's schema (Zod or plain JSON
963
+ * Schema). Applies LLM-quirk coercions (numeric strings, JSON-string
964
+ * containers, null-for-optional, null-for-default) before declaring failure.
965
+ *
966
+ * @throws Error with a formatted message when validation cannot be reconciled.
967
+ */
968
+ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall["arguments"] {
969
+ const originalArgs = toolCall.arguments;
970
+ const ctx = getValidationContext(tool);
971
+ const { json } = ctx;
972
+
973
+ // Always normalize first — strip null and string "null" from optional
974
+ // fields and substitute defaults. Handles LLM outputting string "null"
975
+ // to mean "no value" even when validation would otherwise pass.
976
+ let normalizedArgs: unknown = originalArgs;
977
+ let changed = false;
978
+ const initialNormalization = normalizeOptionalNullsForSchema(json, normalizedArgs);
979
+ if (initialNormalization.changed) {
980
+ normalizedArgs = initialNormalization.value;
981
+ changed = true;
982
+ }
983
+
984
+ let result = validateContext(ctx, normalizedArgs);
985
+ if (result.success) return result.value as ToolCall["arguments"];
986
+
987
+ for (let pass = 0; pass < MAX_COERCION_PASSES; pass += 1) {
988
+ const coercion = coerceArgsFromIssues(normalizedArgs, result.flatIssues);
989
+ if (!coercion.changed) break;
990
+
991
+ normalizedArgs = coercion.value;
992
+ changed = true;
993
+
994
+ const nullNormalization = normalizeOptionalNullsForSchema(json, normalizedArgs);
995
+ if (nullNormalization.changed) {
996
+ normalizedArgs = nullNormalization.value;
997
+ }
998
+
999
+ result = validateContext(ctx, normalizedArgs);
1000
+ if (result.success) return result.value as ToolCall["arguments"];
1001
+ }
1002
+
1003
+ // Format validation errors nicely. The header phrase is asserted by
1004
+ // existing tests; the detailed body is informational.
1005
+ const errors = result.messages.join("\n") || "Unknown validation error";
1006
+
1007
+ const receivedArgs = changed
1008
+ ? {
1009
+ original: originalArgs,
1010
+ normalized: normalizedArgs,
1011
+ }
1012
+ : originalArgs;
1013
+
1014
+ const errorMessage = `Validation failed for tool "${
1015
+ toolCall.name
1016
+ }":\n${errors}\n\nReceived arguments:\n${JSON.stringify(receivedArgs, null, 2)}`;
1017
+
1018
+ throw new Error(errorMessage);
1019
+ }