okengine 0.22.0 → 0.23.0

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 (262) hide show
  1. package/AGENTS.md +7 -0
  2. package/manifest.v1.schema.json +44 -0
  3. package/package.json +32 -19
  4. package/site/content/docs/ai/mcp.mdx +13 -9
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/agents.mdx +61 -13
  7. package/site/content/docs/elements/ai/decide.mdx +536 -0
  8. package/site/content/docs/elements/ai/events.mdx +373 -0
  9. package/site/content/docs/elements/ai/index.mdx +9 -3
  10. package/site/content/docs/elements/ai/meta.json +1 -1
  11. package/site/content/docs/elements/ai/prompts.mdx +4 -2
  12. package/site/content/docs/elements/gate/auth.mdx +4 -1
  13. package/site/content/docs/elements/store/index.mdx +1 -1
  14. package/site/content/docs/elements/store/kv.mdx +5 -5
  15. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  16. package/site/content/docs/recipes/index.mdx +6 -6
  17. package/site/content/docs/recipes/postgres.mdx +12 -8
  18. package/site/content/docs/recipes/redis.mdx +4 -4
  19. package/site/content/docs/reference/cli.mdx +2 -1
  20. package/site/content/docs/reference/configuration.mdx +9 -7
  21. package/site/content/docs/reference/fx.mdx +7 -6
  22. package/src/auth/api-key-sql.test.ts +44 -0
  23. package/src/auth/api-key-sql.ts +1 -0
  24. package/src/auth/api-keys.ts +30 -12
  25. package/src/auth/config.ts +6 -2
  26. package/src/auth/gate-auth.test.ts +20 -0
  27. package/src/cli/agents-md.test.ts +25 -0
  28. package/src/cli/ask-vault-gaps.test.ts +38 -1
  29. package/src/cli/ask-vault-gaps.ts +35 -2
  30. package/src/cli/decide.ts +174 -0
  31. package/src/cli/decision-lock-watch.test.ts +45 -0
  32. package/src/cli/decision-lock-watch.ts +51 -0
  33. package/src/cli/dev-app-runner.ts +34 -8
  34. package/src/cli/dev-auth-secret.test.ts +49 -0
  35. package/src/cli/dev-auth-secret.ts +49 -0
  36. package/src/cli/dev-hot.test.ts +41 -0
  37. package/src/cli/dev-hot.ts +48 -0
  38. package/src/cli/dev.test.ts +1 -1
  39. package/src/cli/dev.ts +24 -6
  40. package/src/cli/eval.ts +142 -1
  41. package/src/cli/index.ts +5 -0
  42. package/src/cli/load-config.images.test.ts +2 -2
  43. package/src/cli/load-config.ts +1 -1
  44. package/src/cli/mcp-from-console.ts +48 -0
  45. package/src/cli/registry.ts +35 -1
  46. package/src/client/agent.ts +307 -0
  47. package/src/client/transport.test.ts +20 -0
  48. package/src/client/transport.ts +2 -1
  49. package/src/client/types.ts +1 -1
  50. package/src/client-react/index.ts +7 -1
  51. package/src/client-react/use-agent-run.test.ts +271 -0
  52. package/src/client-react/use-agent-run.ts +193 -0
  53. package/src/compiler/ai-approval.test.ts +36 -0
  54. package/src/compiler/ai-repair.test.ts +22 -0
  55. package/src/compiler/decisions.extract.test.ts +164 -0
  56. package/src/compiler/effects-infer.ts +27 -1
  57. package/src/compiler/extract.ts +268 -12
  58. package/src/compiler/response.ts +35 -2
  59. package/src/config/index.ts +21 -3
  60. package/src/console/server/ai-runs-flows.ts +269 -0
  61. package/src/console/server/ai-runs.test.ts +323 -0
  62. package/src/console/server/ai-runs.ts +562 -0
  63. package/src/console/server/ai.ts +12 -1
  64. package/src/console/server/app.ts +10 -1
  65. package/src/console/server/decisions-flows.ts +157 -0
  66. package/src/console/server/decisions.test.ts +66 -0
  67. package/src/console/server/decisions.ts +229 -0
  68. package/src/console/server/flows.ts +50 -2
  69. package/src/console/server/invoke-user-flow.ts +9 -0
  70. package/src/console/server/runs-ingest.ts +16 -0
  71. package/src/console/server/serve.ts +5 -0
  72. package/src/console/server/state.ts +25 -1
  73. package/src/console/server/store-stats.test.ts +47 -0
  74. package/src/console/server/store-stats.ts +92 -18
  75. package/src/console/ui-next/dist/assets/access-page-DDKkhT9v.js +4 -0
  76. package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-62Xts8Xi.js} +1 -1
  77. package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-DyeoKHKA.js} +1 -1
  78. package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DvfpLXsU.js} +1 -1
  79. package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-BY03SeCg.js} +1 -1
  80. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  81. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  82. package/src/console/ui-next/dist/assets/decisions-page-CgkKlA56.js +1 -0
  83. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  84. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  85. package/src/console/ui-next/dist/assets/duration-tone-Y6HLjyJ5.js +9 -0
  86. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  87. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  88. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  89. package/src/console/ui-next/dist/assets/flows-page-DLPfNy-_.js +1 -0
  90. package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-D7nNzpgB.js} +1 -1
  91. package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-DdL19zzh.js} +1 -1
  92. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  93. package/src/console/ui-next/dist/assets/index-C0lc_s9d.js +58 -0
  94. package/src/console/ui-next/dist/assets/observability-page-CRlcyLCC.js +8 -0
  95. package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bb3FWUu9.js} +7 -7
  96. package/src/console/ui-next/dist/assets/request-meta-DYBMWhX8.js +1 -0
  97. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  98. package/src/console/ui-next/dist/assets/store-page-CcE-SXC8.js +41 -0
  99. package/src/console/ui-next/dist/assets/trace-detail-sheet-CKMSEQ4s.js +2 -0
  100. package/src/console/ui-next/dist/assets/tree-expand-toggle-QOfk0M0H.js +55 -0
  101. package/src/console/ui-next/dist/assets/units-page-DKC1CDDd.js +1 -0
  102. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  103. package/src/console/ui-next/dist/assets/vault-page-B54v4Yit.js +2 -0
  104. package/src/console/ui-next/dist/index.html +4 -3
  105. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  106. package/src/console/ui-next/src/client.ts +224 -1
  107. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  108. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  109. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  110. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  111. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  112. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  113. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  114. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  115. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  116. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  117. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  118. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  119. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  120. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  121. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  122. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  123. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  124. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  125. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  126. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  127. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  128. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  129. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  130. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  131. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  132. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  133. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  134. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  135. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  136. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  137. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
  138. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  139. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  140. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  141. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  142. package/src/console/ui-next/src/router.tsx +17 -0
  143. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  144. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  145. package/src/console/ui-next/vite.config.ts +9 -0
  146. package/src/docker/recipes/dragonfly.ts +3 -2
  147. package/src/docker/recipes/redis.ts +1 -1
  148. package/src/drivers/ai-anthropic.ts +212 -21
  149. package/src/drivers/ai-mock.ts +34 -1
  150. package/src/drivers/ai-openai-compatible.ts +67 -26
  151. package/src/drivers/ai-preconnect.ts +18 -0
  152. package/src/drivers/ai-stream.test.ts +62 -0
  153. package/src/drivers/ai-types.ts +20 -0
  154. package/src/drivers/journal-postgres.ts +289 -2
  155. package/src/drivers/memory-ddl.test.ts +18 -0
  156. package/src/drivers/memory.ts +21 -1
  157. package/src/drivers/pg-rls.test.ts +31 -0
  158. package/src/drivers/pg-rls.ts +15 -0
  159. package/src/drivers/postgres.test.ts +9 -0
  160. package/src/drivers/postgres.ts +49 -11
  161. package/src/elements/ai/agent-event-slot.ts +27 -0
  162. package/src/elements/ai/approval-http.ts +207 -0
  163. package/src/elements/ai/approval.test.ts +741 -0
  164. package/src/elements/ai/approval.ts +228 -0
  165. package/src/elements/ai/decisions/bind.ts +163 -0
  166. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  167. package/src/elements/ai/decisions/certificate.ts +377 -0
  168. package/src/elements/ai/decisions/certify.ts +314 -0
  169. package/src/elements/ai/decisions/decide.test.ts +953 -0
  170. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  171. package/src/elements/ai/decisions/export.test.ts +120 -0
  172. package/src/elements/ai/decisions/export.ts +146 -0
  173. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  174. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  175. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  176. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  177. package/src/elements/ai/decisions/http.test.ts +154 -0
  178. package/src/elements/ai/decisions/http.ts +240 -0
  179. package/src/elements/ai/decisions/labels.test.ts +282 -0
  180. package/src/elements/ai/decisions/labels.ts +257 -0
  181. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  182. package/src/elements/ai/decisions/openrouter.ts +36 -0
  183. package/src/elements/ai/decisions/provider.ts +102 -0
  184. package/src/elements/ai/decisions/typesafe.ts +36 -0
  185. package/src/elements/ai/declare.ts +252 -1
  186. package/src/elements/ai/events.test.ts +379 -0
  187. package/src/elements/ai/events.ts +138 -0
  188. package/src/elements/ai/run-events.test.ts +461 -0
  189. package/src/elements/ai/run-events.ts +522 -0
  190. package/src/elements/ai/runtime.ts +1186 -105
  191. package/src/elements/ai/stream-turn.ts +214 -0
  192. package/src/elements/ai/stream.live.test.ts +76 -0
  193. package/src/elements/ai/subagent.test.ts +136 -0
  194. package/src/elements/ai.test.ts +497 -3
  195. package/src/elements/ai.ts +16 -1
  196. package/src/elements/clock/durable.ts +27 -1
  197. package/src/elements/store/sql-session.test.ts +51 -0
  198. package/src/elements/store/sql-session.ts +111 -1
  199. package/src/i18n/catalogs/ar.ts +5 -0
  200. package/src/i18n/catalogs/en.ts +5 -0
  201. package/src/index.ts +2 -0
  202. package/src/kernel/agent-event-store.ts +448 -0
  203. package/src/kernel/app.ts +103 -1
  204. package/src/kernel/boot-bind/ai.ts +3 -0
  205. package/src/kernel/boot-bind/store.test.ts +26 -0
  206. package/src/kernel/boot-bind/store.ts +82 -6
  207. package/src/kernel/boot.ts +15 -4
  208. package/src/kernel/builtin-errors.ts +2 -0
  209. package/src/kernel/capability.ts +3 -0
  210. package/src/kernel/decision-label-store.ts +441 -0
  211. package/src/kernel/effects.test.ts +2 -1
  212. package/src/kernel/effects.ts +13 -1
  213. package/src/kernel/element-registries.ts +3 -0
  214. package/src/kernel/errors-text.ts +4 -0
  215. package/src/kernel/errors.ts +2 -0
  216. package/src/kernel/flow.test.ts +2 -3
  217. package/src/kernel/fx-decide.ts +761 -0
  218. package/src/kernel/fx.test.ts +12 -5
  219. package/src/kernel/fx.ts +312 -29
  220. package/src/kernel/http-frame.test.ts +94 -0
  221. package/src/kernel/http-frame.ts +203 -0
  222. package/src/kernel/journal.ts +104 -0
  223. package/src/kernel/json-result.ts +10 -0
  224. package/src/kernel/pipeline.test.ts +45 -0
  225. package/src/kernel/sse-id.ts +32 -0
  226. package/src/manifest/diff.ts +1 -0
  227. package/src/manifest/types.ts +29 -2
  228. package/src/mcp/ai-tools.test.ts +202 -0
  229. package/src/mcp/authorization.ts +66 -0
  230. package/src/mcp/session.ts +3 -0
  231. package/src/mcp/tools.ts +168 -0
  232. package/src/release/http-graph.test.ts +2 -0
  233. package/src/release/http-graph.ts +5 -0
  234. package/src/release/measure.ts +39 -14
  235. package/src/runs/collect.ts +4 -1
  236. package/src/runs/parquet.test.ts +27 -1
  237. package/src/runs/parquet.ts +19 -0
  238. package/src/runs/types.ts +31 -0
  239. package/src/runtime/json-code-block.test.ts +106 -47
  240. package/src/runtime/json-code-block.ts +1209 -46
  241. package/src/term.test.ts +13 -0
  242. package/src/term.ts +81 -49
  243. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  244. package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
  245. package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
  246. package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
  247. package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
  248. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
  249. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
  250. package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
  251. package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
  252. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  253. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
  254. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
  255. package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
  256. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
  257. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
  258. package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
  259. package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
  260. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
  261. package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
  262. package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
@@ -11,8 +11,11 @@ import type {
11
11
  AiDriver,
12
12
  AiModelClient,
13
13
  AiOpenOptions,
14
+ AiStreamChunk,
15
+ AiToolCall,
14
16
  } from "./ai-types.ts";
15
17
  import { hostFromUrl } from "./external.ts";
18
+ import { preconnectFetch } from "./ai-preconnect.ts";
16
19
 
17
20
  const DEFAULT_BASE = "https://api.anthropic.com";
18
21
  const ANTHROPIC_VERSION = "2023-06-01";
@@ -30,10 +33,7 @@ export async function openAnthropic(options: AiOpenOptions = {}): Promise<AiMode
30
33
  const model = options.model ?? "claude-sonnet-4-20250514";
31
34
  const baseUrl = (options.baseUrl ?? DEFAULT_BASE).replace(/\/$/, "");
32
35
  const fetchFn = options.fetch ?? globalThis.fetch;
33
- const preconnect = (fetchFn as { preconnect?: (href: string) => void }).preconnect;
34
- if (typeof preconnect === "function") {
35
- preconnect(baseUrl);
36
- }
36
+ preconnectFetch(fetchFn, baseUrl);
37
37
  const host = hostFromUrl(baseUrl);
38
38
  const external = host
39
39
  ? {
@@ -48,21 +48,10 @@ export async function openAnthropic(options: AiOpenOptions = {}): Promise<AiMode
48
48
  model,
49
49
  async complete(opts: AiCompleteOptions): Promise<AiCompleteResult> {
50
50
  const resolvedModel = opts.model ?? model;
51
- const { system, messages } = splitSystem(opts.messages);
52
51
  const res = await fetchFn(`${baseUrl}/v1/messages`, {
53
52
  method: "POST",
54
- headers: {
55
- "content-type": "application/json",
56
- "x-api-key": apiKey,
57
- "anthropic-version": ANTHROPIC_VERSION,
58
- },
59
- body: JSON.stringify({
60
- model: resolvedModel,
61
- max_tokens: opts.maxTokens ?? 1024,
62
- ...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
63
- ...(system !== undefined ? { system } : {}),
64
- messages,
65
- }),
53
+ headers: anthropicHeaders(apiKey),
54
+ body: JSON.stringify(anthropicBody(resolvedModel, opts)),
66
55
  ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
67
56
  });
68
57
  const raw = (await res.json().catch(() => ({}))) as AnthropicMessagesResponse;
@@ -71,11 +60,13 @@ export async function openAnthropic(options: AiOpenOptions = {}): Promise<AiMode
71
60
  throw new Error(`anthropic: ${msg}`);
72
61
  }
73
62
  const text = textFromContent(raw.content);
63
+ const toolCalls = toolCallsFromContent(raw.content);
74
64
  return {
75
65
  text,
76
66
  raw,
77
67
  model: raw.model ?? resolvedModel,
78
68
  driverId: "anthropic",
69
+ ...(toolCalls !== undefined ? { toolCalls } : {}),
79
70
  usage: {
80
71
  inputTokens: raw.usage?.input_tokens,
81
72
  outputTokens: raw.usage?.output_tokens,
@@ -83,6 +74,21 @@ export async function openAnthropic(options: AiOpenOptions = {}): Promise<AiMode
83
74
  ...(external !== undefined ? { external } : {}),
84
75
  };
85
76
  },
77
+ async *stream(opts: AiCompleteOptions): AsyncIterable<AiStreamChunk> {
78
+ const resolvedModel = opts.model ?? model;
79
+ const res = await fetchFn(`${baseUrl}/v1/messages`, {
80
+ method: "POST",
81
+ headers: anthropicHeaders(apiKey),
82
+ body: JSON.stringify({ ...anthropicBody(resolvedModel, opts), stream: true }),
83
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
84
+ });
85
+ if (!res.ok) {
86
+ const raw = (await res.json().catch(() => ({}))) as AnthropicMessagesResponse;
87
+ const msg = raw.error?.message ?? `anthropic HTTP ${res.status}`;
88
+ throw new Error(`anthropic: ${msg}`);
89
+ }
90
+ yield* readAnthropicSse(res, opts.signal);
91
+ },
86
92
  };
87
93
  }
88
94
 
@@ -92,9 +98,17 @@ export const anthropicAiDriver: AiDriver = {
92
98
  open: openAnthropic,
93
99
  };
94
100
 
101
+ interface AnthropicBlock {
102
+ readonly type?: string;
103
+ readonly text?: string;
104
+ readonly id?: string;
105
+ readonly name?: string;
106
+ readonly input?: unknown;
107
+ }
108
+
95
109
  interface AnthropicMessagesResponse {
96
110
  readonly model?: string;
97
- readonly content?: readonly { readonly type?: string; readonly text?: string }[];
111
+ readonly content?: readonly AnthropicBlock[];
98
112
  readonly usage?: {
99
113
  readonly input_tokens?: number;
100
114
  readonly output_tokens?: number;
@@ -102,19 +116,70 @@ interface AnthropicMessagesResponse {
102
116
  readonly error?: { readonly message?: string };
103
117
  }
104
118
 
119
+ function anthropicHeaders(apiKey: string): Record<string, string> {
120
+ return {
121
+ "content-type": "application/json",
122
+ "x-api-key": apiKey,
123
+ "anthropic-version": ANTHROPIC_VERSION,
124
+ };
125
+ }
126
+
127
+ function anthropicBody(model: string, opts: AiCompleteOptions): Record<string, unknown> {
128
+ const { system, messages } = splitSystem(opts.messages);
129
+ return {
130
+ model,
131
+ max_tokens: opts.maxTokens ?? 1024,
132
+ ...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
133
+ ...(system !== undefined ? { system } : {}),
134
+ messages,
135
+ ...(opts.tools !== undefined && opts.tools.length > 0
136
+ ? {
137
+ tools: opts.tools.map((tool) => ({
138
+ name: tool.name,
139
+ ...(tool.description !== undefined ? { description: tool.description } : {}),
140
+ input_schema: tool.parameters ?? { type: "object", properties: {} },
141
+ })),
142
+ }
143
+ : {}),
144
+ };
145
+ }
146
+
105
147
  function splitSystem(messages: AiCompleteOptions["messages"]): {
106
148
  system?: string;
107
- messages: Array<{ role: "user" | "assistant"; content: string }>;
149
+ messages: Array<{ role: "user" | "assistant"; content: unknown }>;
108
150
  } {
109
151
  const systemParts: string[] = [];
110
- const out: Array<{ role: "user" | "assistant"; content: string }> = [];
152
+ const out: Array<{ role: "user" | "assistant"; content: unknown }> = [];
111
153
  for (const m of messages) {
112
154
  if (m.role === "system") {
113
155
  systemParts.push(m.content);
114
156
  continue;
115
157
  }
116
158
  if (m.role === "tool") {
117
- out.push({ role: "user", content: m.content });
159
+ out.push({
160
+ role: "user",
161
+ content: [
162
+ {
163
+ type: "tool_result",
164
+ tool_use_id: m.toolCallId ?? "",
165
+ content: m.content,
166
+ },
167
+ ],
168
+ });
169
+ continue;
170
+ }
171
+ if (m.role === "assistant" && m.toolCalls && m.toolCalls.length > 0) {
172
+ const blocks: unknown[] = [];
173
+ if (m.content) blocks.push({ type: "text", text: m.content });
174
+ for (const call of m.toolCalls) {
175
+ blocks.push({
176
+ type: "tool_use",
177
+ id: call.id,
178
+ name: call.name,
179
+ input: call.arguments ?? {},
180
+ });
181
+ }
182
+ out.push({ role: "assistant", content: blocks });
118
183
  continue;
119
184
  }
120
185
  out.push({ role: m.role, content: m.content });
@@ -132,3 +197,129 @@ function textFromContent(content: AnthropicMessagesResponse["content"]): string
132
197
  .map((b) => b.text!)
133
198
  .join("");
134
199
  }
200
+
201
+ function toolCallsFromContent(
202
+ content: AnthropicMessagesResponse["content"],
203
+ ): readonly AiToolCall[] | undefined {
204
+ const calls = (content ?? []).filter((block) => block.type === "tool_use" && block.name);
205
+ if (calls.length === 0) return undefined;
206
+ return calls.map((block, index) => ({
207
+ id: block.id ?? `toolu_${index}`,
208
+ name: block.name ?? "",
209
+ arguments: block.input ?? {},
210
+ }));
211
+ }
212
+
213
+ /**
214
+ * Parse Anthropic Messages SSE into text and tool-call chunks.
215
+ *
216
+ * @param res - Streaming response
217
+ * @param signal - Optional abort
218
+ */
219
+ async function* readAnthropicSse(
220
+ res: Response,
221
+ signal?: AbortSignal,
222
+ ): AsyncGenerator<AiStreamChunk> {
223
+ const stream = (res as Response & { textStream?: () => AsyncIterable<string> }).textStream;
224
+ if (typeof stream !== "function") {
225
+ throw new Error("anthropic: Response.textStream is required (Bun >= 1.4.2)");
226
+ }
227
+ let buffer = "";
228
+ let inputTokens: number | undefined;
229
+ let outputTokens: number | undefined;
230
+ let stopped = false;
231
+ for await (const piece of stream.call(res)) {
232
+ if (signal?.aborted) {
233
+ const err = new Error("This operation was aborted");
234
+ err.name = "AbortError";
235
+ throw err;
236
+ }
237
+ buffer += piece;
238
+ const lines = buffer.split("\n");
239
+ buffer = lines.pop() ?? "";
240
+ for (const line of lines) {
241
+ const trimmed = line.trim();
242
+ if (!trimmed.startsWith("data:")) continue;
243
+ const data = trimmed.slice(5).trim();
244
+ if (!data || data === "[DONE]") continue;
245
+ let event: {
246
+ type?: string;
247
+ index?: number;
248
+ content_block?: AnthropicBlock;
249
+ delta?: {
250
+ type?: string;
251
+ text?: string;
252
+ partial_json?: string;
253
+ stop_reason?: string;
254
+ };
255
+ message?: { usage?: { input_tokens?: number; output_tokens?: number } };
256
+ usage?: { output_tokens?: number; input_tokens?: number };
257
+ };
258
+ try {
259
+ event = JSON.parse(data) as typeof event;
260
+ } catch {
261
+ continue;
262
+ }
263
+ if (event.type === "error") {
264
+ const message =
265
+ typeof (event as { error?: { message?: string } }).error?.message === "string"
266
+ ? (event as { error?: { message?: string } }).error?.message
267
+ : "anthropic stream error";
268
+ throw new Error(`anthropic: ${message}`);
269
+ }
270
+ if (event.type === "message_stop") {
271
+ stopped = true;
272
+ continue;
273
+ }
274
+ if (event.type === "message_start") {
275
+ inputTokens = event.message?.usage?.input_tokens ?? inputTokens;
276
+ continue;
277
+ }
278
+ if (event.type === "content_block_start" && event.content_block?.type === "tool_use") {
279
+ yield {
280
+ text: "",
281
+ toolCall: {
282
+ index: event.index ?? 0,
283
+ ...(event.content_block.id !== undefined ? { id: event.content_block.id } : {}),
284
+ ...(event.content_block.name !== undefined ? { name: event.content_block.name } : {}),
285
+ argumentsDelta: "",
286
+ },
287
+ };
288
+ continue;
289
+ }
290
+ if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
291
+ if (event.delta.text) yield { text: event.delta.text };
292
+ continue;
293
+ }
294
+ if (event.type === "content_block_delta" && event.delta?.type === "input_json_delta") {
295
+ yield {
296
+ text: "",
297
+ toolCall: {
298
+ index: event.index ?? 0,
299
+ ...(event.delta.partial_json !== undefined
300
+ ? { argumentsDelta: event.delta.partial_json }
301
+ : {}),
302
+ },
303
+ };
304
+ continue;
305
+ }
306
+ if (event.type === "message_delta") {
307
+ outputTokens = event.usage?.output_tokens ?? outputTokens;
308
+ inputTokens = event.usage?.input_tokens ?? inputTokens;
309
+ }
310
+ }
311
+ }
312
+ if (!stopped) throw new Error("anthropic: stream ended without message_stop");
313
+ yield {
314
+ text: "",
315
+ done: true,
316
+ ...(inputTokens !== undefined || outputTokens !== undefined
317
+ ? {
318
+ usage: {
319
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
320
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
321
+ },
322
+ }
323
+ : {}),
324
+ };
325
+ }
@@ -97,6 +97,39 @@ export const mockAiDriver: AiDriver = {
97
97
  break;
98
98
  }
99
99
  }
100
+ if (
101
+ payload &&
102
+ typeof payload === "object" &&
103
+ payload !== null &&
104
+ "__toolCalls" in payload
105
+ ) {
106
+ const script = payload as { __toolCalls: readonly AiToolCall[]; text?: string };
107
+ const text = script.text ?? "";
108
+ const size = Math.max(1, Math.ceil(text.length / 2) || 1);
109
+ for (let i = 0; i < text.length; i += size) {
110
+ yield { text: text.slice(i, i + size) };
111
+ }
112
+ for (const [index, tc] of script.__toolCalls.entries()) {
113
+ const args =
114
+ typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments ?? {});
115
+ yield {
116
+ text: "",
117
+ toolCall: { index, id: tc.id, name: tc.name, argumentsDelta: "" },
118
+ };
119
+ const mid = Math.max(1, Math.ceil(args.length / 2));
120
+ yield { text: "", toolCall: { index, argumentsDelta: args.slice(0, mid) } };
121
+ if (mid < args.length) {
122
+ yield { text: "", toolCall: { index, argumentsDelta: args.slice(mid) } };
123
+ }
124
+ }
125
+ yield {
126
+ text: "",
127
+ done: true,
128
+ raw: payload,
129
+ usage: { inputTokens: last.length, outputTokens: text.length, cost: 0 },
130
+ };
131
+ return;
132
+ }
100
133
  const text = typeof payload === "string" ? payload : JSON.stringify(payload);
101
134
  const size = Math.max(1, Math.ceil(text.length / 3));
102
135
  for (let i = 0; i < text.length; i += size) {
@@ -107,7 +140,7 @@ export const mockAiDriver: AiDriver = {
107
140
  }
108
141
  yield { text: text.slice(i, i + size) };
109
142
  }
110
- yield { text: "", done: true };
143
+ yield { text: "", done: true, raw: payload };
111
144
  },
112
145
  async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
113
146
  const inputs = Array.isArray(opts.input) ? opts.input : [opts.input];
@@ -20,6 +20,7 @@ import type {
20
20
  } from "./ai-types.ts";
21
21
  import type { DriverExternal } from "./external.ts";
22
22
  import { hostFromUrl } from "./external.ts";
23
+ import { preconnectFetch } from "./ai-preconnect.ts";
23
24
 
24
25
  /** Default OpenAI cloud base — apiKey is required for this origin. */
25
26
  export const OPENAI_COMPAT_DEFAULT_BASE = "https://api.openai.com/v1";
@@ -151,9 +152,23 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
151
152
  model: resolvedModel,
152
153
  messages: opts.messages.map(serializeMessage),
153
154
  stream: true,
155
+ stream_options: { include_usage: true },
154
156
  };
155
157
  if (opts.temperature !== undefined) body.temperature = opts.temperature;
156
158
  if (opts.maxTokens !== undefined) body.max_tokens = opts.maxTokens;
159
+ if (opts.responseFormat !== undefined) {
160
+ body.response_format = wireOpenaiResponseFormat(opts.responseFormat);
161
+ }
162
+ if (opts.tools !== undefined && opts.tools.length > 0) {
163
+ body.tools = opts.tools.map((t) => ({
164
+ type: "function",
165
+ function: {
166
+ name: t.name,
167
+ ...(t.description !== undefined ? { description: t.description } : {}),
168
+ ...(t.parameters !== undefined ? { parameters: t.parameters } : {}),
169
+ },
170
+ }));
171
+ }
157
172
 
158
173
  const res = await fetchFn(`${baseUrl}/chat/completions`, {
159
174
  method: "POST",
@@ -166,7 +181,7 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
166
181
  const msg = raw.error?.message ?? `openai-compatible HTTP ${res.status}`;
167
182
  throwHttp(`openai-compatible: ${msg}`, res.status);
168
183
  }
169
- yield* readOpenaiSse(res, opts.signal);
184
+ yield* readOpenaiSse(res, opts.signal, externalBase);
170
185
  },
171
186
  async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
172
187
  const resolvedModel = opts.model ?? model;
@@ -290,8 +305,13 @@ function parseToolCalls(
290
305
  * @param res - Streaming response
291
306
  * @param signal - Optional abort
292
307
  */
293
- async function* readOpenaiSse(res: Response, signal?: AbortSignal): AsyncGenerator<AiStreamChunk> {
308
+ async function* readOpenaiSse(
309
+ res: Response,
310
+ signal?: AbortSignal,
311
+ external?: AiStreamChunk["external"],
312
+ ): AsyncGenerator<AiStreamChunk> {
294
313
  let buffer = "";
314
+ let closed = false;
295
315
  for await (const piece of responseTextStream(res)) {
296
316
  if (signal?.aborted) throw abortAsError(signal.reason);
297
317
  buffer += piece;
@@ -302,30 +322,64 @@ async function* readOpenaiSse(res: Response, signal?: AbortSignal): AsyncGenerat
302
322
  if (!trimmed.startsWith("data:")) continue;
303
323
  const data = trimmed.slice(5).trim();
304
324
  if (data === "[DONE]") {
305
- yield { text: "", done: true };
325
+ yield { text: "", done: true, ...(external !== undefined ? { external } : {}) };
306
326
  return;
307
327
  }
308
328
  try {
309
329
  const chunk = JSON.parse(data) as {
310
330
  choices?: readonly {
311
- delta?: { content?: string | null };
331
+ delta?: {
332
+ content?: string | null;
333
+ tool_calls?: readonly {
334
+ index?: number;
335
+ id?: string;
336
+ function?: { name?: string; arguments?: string };
337
+ }[];
338
+ };
312
339
  finish_reason?: string | null;
313
340
  }[];
341
+ usage?: { prompt_tokens?: number; completion_tokens?: number };
314
342
  };
315
- const delta = chunk.choices?.[0]?.delta?.content;
316
- if (typeof delta === "string" && delta.length > 0) {
317
- yield { text: delta };
343
+ if (chunk.choices?.[0]?.finish_reason) closed = true;
344
+ const delta = chunk.choices?.[0]?.delta;
345
+ const content = delta?.content;
346
+ if (typeof content === "string" && content.length > 0) {
347
+ yield { text: content };
348
+ }
349
+ for (const call of delta?.tool_calls ?? []) {
350
+ const args = call.function?.arguments;
351
+ yield {
352
+ text: "",
353
+ toolCall: {
354
+ index: call.index ?? 0,
355
+ ...(call.id !== undefined ? { id: call.id } : {}),
356
+ ...(call.function?.name !== undefined ? { name: call.function.name } : {}),
357
+ ...(typeof args === "string" ? { argumentsDelta: args } : {}),
358
+ },
359
+ };
318
360
  }
319
- if (chunk.choices?.[0]?.finish_reason) {
320
- yield { text: "", done: true };
321
- return;
361
+ if (chunk.usage) {
362
+ yield {
363
+ text: "",
364
+ usage: {
365
+ ...(chunk.usage.prompt_tokens !== undefined
366
+ ? { inputTokens: chunk.usage.prompt_tokens }
367
+ : {}),
368
+ ...(chunk.usage.completion_tokens !== undefined
369
+ ? { outputTokens: chunk.usage.completion_tokens }
370
+ : {}),
371
+ },
372
+ };
322
373
  }
323
- } catch {
324
- // ignore malformed SSE lines
374
+ } catch (err) {
375
+ if (err instanceof Error && err.message.startsWith("openai-compatible:")) throw err;
325
376
  }
326
377
  }
327
378
  }
328
- yield { text: "", done: true };
379
+ if (!closed) {
380
+ throw new Error("openai-compatible: stream ended without [DONE] or finish_reason");
381
+ }
382
+ yield { text: "", done: true, ...(external !== undefined ? { external } : {}) };
329
383
  }
330
384
 
331
385
  /**
@@ -341,19 +395,6 @@ function responseTextStream(res: Response): AsyncIterable<string> {
341
395
  return stream.call(res);
342
396
  }
343
397
 
344
- /**
345
- * Warm DNS+TCP+TLS for a cloud origin. No-op when `fetch` is a test stub.
346
- *
347
- * @param fetchFn - Fetch implementation
348
- * @param url - Provider base URL
349
- */
350
- function preconnectFetch(fetchFn: typeof fetch, url: string): void {
351
- const preconnect = (fetchFn as { preconnect?: (href: string) => void }).preconnect;
352
- if (typeof preconnect === "function") {
353
- preconnect(url);
354
- }
355
- }
356
-
357
398
  function abortAsError(reason?: unknown): Error {
358
399
  if (reason instanceof Error) return reason;
359
400
  const err = new Error(reason !== undefined ? String(reason) : "This operation was aborted");
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Best-effort `fetch.preconnect` for AI HTTP drivers.
3
+ *
4
+ * Bun throws `Invalid port` when the URL uses an implicit 80/443. A failed
5
+ * preconnect must not fail the request and must not log.
6
+ *
7
+ * @param fetchFn - Injected or global fetch
8
+ * @param url - Origin or base URL to warm
9
+ */
10
+ export function preconnectFetch(fetchFn: typeof fetch, url: string): void {
11
+ const preconnect = (fetchFn as { preconnect?: (href: string) => void }).preconnect;
12
+ if (typeof preconnect !== "function") return;
13
+ try {
14
+ preconnect(url);
15
+ } catch {
16
+ // Bun rejects default ports. The request still runs.
17
+ }
18
+ }
@@ -6,6 +6,7 @@ import { describe, expect, test } from "bun:test";
6
6
  import { withAbortSignal } from "../kernel/abort-scope.ts";
7
7
  import { createFx, createFxContext } from "../kernel/fx.ts";
8
8
  import { ai, createAiRuntime } from "../elements/ai.ts";
9
+ import { openAnthropic } from "./ai-anthropic.ts";
9
10
  import { openOpenaiCompatible } from "./ai-openai-compatible.ts";
10
11
  import { mockAiDriver } from "./ai-mock.ts";
11
12
 
@@ -44,6 +45,29 @@ describe("openai-compatible SSE stream", () => {
44
45
  if (chunk.text) parts.push(chunk.text);
45
46
  }
46
47
  expect(parts.join("")).toBe("Hello");
48
+ const bodies: unknown[] = [];
49
+ const formatFetch = Object.assign(
50
+ async (_input: string | URL | Request, init?: RequestInit) => {
51
+ bodies.push(JSON.parse(String(init?.body)));
52
+ return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
53
+ },
54
+ { preconnect: () => {} },
55
+ ) as typeof fetch;
56
+ const formatted = await openOpenaiCompatible({
57
+ apiKey: "sk-test",
58
+ baseUrl: "https://example.test/v1",
59
+ fetch: formatFetch,
60
+ });
61
+ for await (const _chunk of formatted.stream!({
62
+ messages: [{ role: "user", content: "hi" }],
63
+ responseFormat: {
64
+ type: "json_schema",
65
+ json_schema: { name: "out", schema: { type: "object" } },
66
+ },
67
+ })) {
68
+ // drain
69
+ }
70
+ expect(bodies[0]).toMatchObject({ response_format: { type: "json_object" } });
47
71
 
48
72
  const ac = new AbortController();
49
73
  const iter = client.stream!({
@@ -58,6 +82,44 @@ describe("openai-compatible SSE stream", () => {
58
82
  });
59
83
  });
60
84
 
85
+ describe("anthropic messages stream", () => {
86
+ test("text deltas, input_json_delta, and message_delta usage", async () => {
87
+ const sse = [
88
+ 'data: {"type":"message_start","message":{"usage":{"input_tokens":4,"output_tokens":1}}}',
89
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Ok"}}',
90
+ 'data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"orders.get","input":{}}}',
91
+ 'data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"id\\":"}}',
92
+ 'data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\\"14\\"}"}}',
93
+ 'data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":6}}',
94
+ 'data: {"type":"message_stop"}',
95
+ "",
96
+ ].join("\n");
97
+ const fetchFn = Object.assign(
98
+ async () =>
99
+ new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }),
100
+ { preconnect: () => {} },
101
+ ) as typeof fetch;
102
+ const client = await openAnthropic({
103
+ apiKey: "sk-test",
104
+ fetch: fetchFn,
105
+ });
106
+ const texts: string[] = [];
107
+ const args: string[] = [];
108
+ let usage: { inputTokens?: number; outputTokens?: number } | undefined;
109
+ for await (const chunk of client.stream!({
110
+ messages: [{ role: "user", content: "status" }],
111
+ tools: [{ name: "orders.get" }],
112
+ })) {
113
+ if (chunk.text) texts.push(chunk.text);
114
+ if (chunk.toolCall?.argumentsDelta) args.push(chunk.toolCall.argumentsDelta);
115
+ if (chunk.usage) usage = chunk.usage;
116
+ }
117
+ expect(texts.join("")).toBe("Ok");
118
+ expect(args.join("")).toBe('{"id":"14"}');
119
+ expect(usage).toEqual({ inputTokens: 4, outputTokens: 6 });
120
+ });
121
+ });
122
+
61
123
  describe("fx.stream uses ambient AbortSignal", () => {
62
124
  test("race abort cancels in-flight stream fetch", async () => {
63
125
  let sawAbort = false;
@@ -66,10 +66,30 @@ export interface AiCompleteResult {
66
66
  readonly external?: DriverExternal;
67
67
  }
68
68
 
69
+ /** One tool-call fragment inside a model stream. */
70
+ export interface AiStreamToolCallDelta {
71
+ /** Provider index for this call within the turn. */
72
+ readonly index: number;
73
+ readonly id?: string;
74
+ readonly name?: string;
75
+ /** Partial JSON arguments. Empty when the call is only announced. */
76
+ readonly argumentsDelta?: string;
77
+ }
78
+
69
79
  /** One streamed token / delta from a model. */
70
80
  export interface AiStreamChunk {
71
81
  readonly text: string;
72
82
  readonly done?: boolean;
83
+ readonly toolCall?: AiStreamToolCallDelta;
84
+ readonly usage?: {
85
+ readonly inputTokens?: number;
86
+ readonly outputTokens?: number;
87
+ readonly cost?: number;
88
+ };
89
+ /** Provider payload. Present on the terminal chunk when the driver has one. */
90
+ readonly raw?: unknown;
91
+ /** Egress identity, matching {@link AiCompleteResult.external}. */
92
+ readonly external?: DriverExternal;
73
93
  }
74
94
 
75
95
  /** Embedding request. */