@plumbus/core 0.8.0-beta.0 → 0.8.0-beta.10

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 (275) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +8 -1
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/ai/ai/cost-tracker.d.ts +22 -2
  5. package/dist/ai/ai/model-pricing.d.ts +8 -2
  6. package/dist/ai/ai/openai-strict-schema.d.ts +84 -0
  7. package/dist/ai/ai/provider.d.ts +90 -6
  8. package/dist/ai/ai-service.d.ts +53 -1
  9. package/dist/ai/ai-service.d.ts.map +1 -1
  10. package/dist/ai/ai-service.js +550 -119
  11. package/dist/ai/ai-service.js.map +1 -1
  12. package/dist/ai/cost-tracker.d.ts +22 -2
  13. package/dist/ai/cost-tracker.d.ts.map +1 -1
  14. package/dist/ai/cost-tracker.js +59 -20
  15. package/dist/ai/cost-tracker.js.map +1 -1
  16. package/dist/ai/explainability.d.ts +2 -1
  17. package/dist/ai/explainability.d.ts.map +1 -1
  18. package/dist/ai/explainability.js +1 -0
  19. package/dist/ai/explainability.js.map +1 -1
  20. package/dist/ai/index.d.ts +2 -2
  21. package/dist/ai/index.d.ts.map +1 -1
  22. package/dist/ai/index.js +1 -1
  23. package/dist/ai/index.js.map +1 -1
  24. package/dist/ai/model-pricing.d.ts +8 -2
  25. package/dist/ai/model-pricing.d.ts.map +1 -1
  26. package/dist/ai/model-pricing.js +113 -15
  27. package/dist/ai/model-pricing.js.map +1 -1
  28. package/dist/ai/openai-strict-schema.d.ts +85 -0
  29. package/dist/ai/openai-strict-schema.d.ts.map +1 -0
  30. package/dist/ai/openai-strict-schema.js +730 -0
  31. package/dist/ai/openai-strict-schema.js.map +1 -0
  32. package/dist/ai/prompt-overrides.d.ts +19 -0
  33. package/dist/ai/prompt-overrides.d.ts.map +1 -0
  34. package/dist/ai/prompt-overrides.js +60 -0
  35. package/dist/ai/prompt-overrides.js.map +1 -0
  36. package/dist/ai/provider-failure.d.ts +9 -0
  37. package/dist/ai/provider-failure.d.ts.map +1 -0
  38. package/dist/ai/provider-failure.js +112 -0
  39. package/dist/ai/provider-failure.js.map +1 -0
  40. package/dist/ai/provider.d.ts +90 -6
  41. package/dist/ai/provider.d.ts.map +1 -1
  42. package/dist/ai/provider.js +546 -97
  43. package/dist/ai/provider.js.map +1 -1
  44. package/dist/ai/rag/pipeline.d.ts +21 -1
  45. package/dist/ai/rag/pipeline.d.ts.map +1 -1
  46. package/dist/ai/rag/pipeline.js +17 -1
  47. package/dist/ai/rag/pipeline.js.map +1 -1
  48. package/dist/ai/tool-loop.d.ts +8 -1
  49. package/dist/ai/tool-loop.d.ts.map +1 -1
  50. package/dist/ai/tool-loop.js +7 -0
  51. package/dist/ai/tool-loop.js.map +1 -1
  52. package/dist/ai/types/config.d.ts +13 -0
  53. package/dist/ai/types/fields.d.ts +6 -0
  54. package/dist/ai/types/prompt.d.ts +9 -0
  55. package/dist/ai/usage-validation.d.ts.map +1 -1
  56. package/dist/ai/usage-validation.js +6 -1
  57. package/dist/ai/usage-validation.js.map +1 -1
  58. package/dist/ai/validation.d.ts +7 -3
  59. package/dist/ai/validation.d.ts.map +1 -1
  60. package/dist/ai/validation.js +38 -25
  61. package/dist/ai/validation.js.map +1 -1
  62. package/dist/ai/zod-to-provider-schema.d.ts.map +1 -1
  63. package/dist/ai/zod-to-provider-schema.js +246 -16
  64. package/dist/ai/zod-to-provider-schema.js.map +1 -1
  65. package/dist/api/route-generator.d.ts +2 -12
  66. package/dist/api/route-generator.d.ts.map +1 -1
  67. package/dist/api/route-generator.js +33 -41
  68. package/dist/api/route-generator.js.map +1 -1
  69. package/dist/approvals/index.d.ts +1 -1
  70. package/dist/approvals/index.d.ts.map +1 -1
  71. package/dist/approvals/index.js.map +1 -1
  72. package/dist/approvals/memory-store.d.ts.map +1 -1
  73. package/dist/approvals/memory-store.js +22 -0
  74. package/dist/approvals/memory-store.js.map +1 -1
  75. package/dist/approvals/schema.d.ts +34 -0
  76. package/dist/approvals/schema.d.ts.map +1 -1
  77. package/dist/approvals/schema.js +2 -0
  78. package/dist/approvals/schema.js.map +1 -1
  79. package/dist/approvals/service.d.ts.map +1 -1
  80. package/dist/approvals/service.js +36 -0
  81. package/dist/approvals/service.js.map +1 -1
  82. package/dist/approvals/sql-store.d.ts.map +1 -1
  83. package/dist/approvals/sql-store.js +42 -1
  84. package/dist/approvals/sql-store.js.map +1 -1
  85. package/dist/approvals/types.d.ts +11 -0
  86. package/dist/approvals/types.d.ts.map +1 -1
  87. package/dist/approvals/types.js +1 -0
  88. package/dist/approvals/types.js.map +1 -1
  89. package/dist/audit/service.d.ts.map +1 -1
  90. package/dist/audit/service.js +22 -2
  91. package/dist/audit/service.js.map +1 -1
  92. package/dist/cli/cli.d.ts.map +1 -1
  93. package/dist/cli/cli.js +2 -1
  94. package/dist/cli/cli.js.map +1 -1
  95. package/dist/cli/commands/browser-extension.d.ts +16 -0
  96. package/dist/cli/commands/browser-extension.d.ts.map +1 -1
  97. package/dist/cli/commands/browser-extension.js +16 -5
  98. package/dist/cli/commands/browser-extension.js.map +1 -1
  99. package/dist/cli/commands/dev.d.ts.map +1 -1
  100. package/dist/cli/commands/dev.js +7 -1
  101. package/dist/cli/commands/dev.js.map +1 -1
  102. package/dist/cli/commands/e2e.d.ts +12 -0
  103. package/dist/cli/commands/e2e.d.ts.map +1 -1
  104. package/dist/cli/commands/e2e.js +11 -0
  105. package/dist/cli/commands/e2e.js.map +1 -1
  106. package/dist/cli/commands/generate-openapi-from-zod.d.ts.map +1 -1
  107. package/dist/cli/commands/generate-openapi-from-zod.js +9 -1
  108. package/dist/cli/commands/generate-openapi-from-zod.js.map +1 -1
  109. package/dist/cli/commands/index.d.ts +1 -0
  110. package/dist/cli/commands/index.d.ts.map +1 -1
  111. package/dist/cli/commands/index.js +1 -0
  112. package/dist/cli/commands/index.js.map +1 -1
  113. package/dist/cli/commands/init.d.ts +1 -1
  114. package/dist/cli/commands/init.d.ts.map +1 -1
  115. package/dist/cli/commands/init.js +63 -3
  116. package/dist/cli/commands/init.js.map +1 -1
  117. package/dist/cli/commands/migrate.d.ts.map +1 -1
  118. package/dist/cli/commands/migrate.js +8 -14
  119. package/dist/cli/commands/migrate.js.map +1 -1
  120. package/dist/cli/commands/payments.d.ts +56 -0
  121. package/dist/cli/commands/payments.d.ts.map +1 -0
  122. package/dist/cli/commands/payments.js +184 -0
  123. package/dist/cli/commands/payments.js.map +1 -0
  124. package/dist/cli/commands/run.d.ts.map +1 -1
  125. package/dist/cli/commands/run.js +2 -7
  126. package/dist/cli/commands/run.js.map +1 -1
  127. package/dist/cli/commands/seed.d.ts.map +1 -1
  128. package/dist/cli/commands/seed.js +2 -7
  129. package/dist/cli/commands/seed.js.map +1 -1
  130. package/dist/cli/commands/start.d.ts.map +1 -1
  131. package/dist/cli/commands/start.js +9 -1
  132. package/dist/cli/commands/start.js.map +1 -1
  133. package/dist/cli/commands/worker.js +1 -1
  134. package/dist/cli/commands/worker.js.map +1 -1
  135. package/dist/cli/discover.d.ts +2 -0
  136. package/dist/cli/discover.d.ts.map +1 -1
  137. package/dist/cli/discover.js +39 -5
  138. package/dist/cli/discover.js.map +1 -1
  139. package/dist/config/loader.d.ts +4 -1
  140. package/dist/config/loader.d.ts.map +1 -1
  141. package/dist/config/loader.js +112 -24
  142. package/dist/config/loader.js.map +1 -1
  143. package/dist/data/connection.d.ts +23 -0
  144. package/dist/data/connection.d.ts.map +1 -1
  145. package/dist/data/connection.js +23 -6
  146. package/dist/data/connection.js.map +1 -1
  147. package/dist/data/schema-generator.d.ts.map +1 -1
  148. package/dist/data/schema-generator.js +2 -2
  149. package/dist/data/schema-generator.js.map +1 -1
  150. package/dist/define/definePrompt.d.ts.map +1 -1
  151. package/dist/define/definePrompt.js +6 -0
  152. package/dist/define/definePrompt.js.map +1 -1
  153. package/dist/durable/index.d.ts +2 -2
  154. package/dist/durable/index.d.ts.map +1 -1
  155. package/dist/durable/index.js +2 -2
  156. package/dist/durable/index.js.map +1 -1
  157. package/dist/durable/postgres-persist.d.ts +47 -2
  158. package/dist/durable/postgres-persist.d.ts.map +1 -1
  159. package/dist/durable/postgres-persist.js +152 -6
  160. package/dist/durable/postgres-persist.js.map +1 -1
  161. package/dist/durable/spine-claim.d.ts +22 -1
  162. package/dist/durable/spine-claim.d.ts.map +1 -1
  163. package/dist/durable/spine-claim.js +54 -3
  164. package/dist/durable/spine-claim.js.map +1 -1
  165. package/dist/errors/http.d.ts.map +1 -1
  166. package/dist/errors/http.js +9 -1
  167. package/dist/errors/http.js.map +1 -1
  168. package/dist/events/dispatcher.d.ts +20 -0
  169. package/dist/events/dispatcher.d.ts.map +1 -1
  170. package/dist/events/dispatcher.js +61 -26
  171. package/dist/events/dispatcher.js.map +1 -1
  172. package/dist/events/pipeline-audit.d.ts +14 -0
  173. package/dist/events/pipeline-audit.d.ts.map +1 -0
  174. package/dist/events/pipeline-audit.js +29 -0
  175. package/dist/events/pipeline-audit.js.map +1 -0
  176. package/dist/events/worker.d.ts +3 -0
  177. package/dist/events/worker.d.ts.map +1 -1
  178. package/dist/events/worker.js +14 -14
  179. package/dist/events/worker.js.map +1 -1
  180. package/dist/execution/capability-error-hook.d.ts +24 -0
  181. package/dist/execution/capability-error-hook.d.ts.map +1 -0
  182. package/dist/execution/capability-error-hook.js +17 -0
  183. package/dist/execution/capability-error-hook.js.map +1 -0
  184. package/dist/execution/capability-executor.d.ts +2 -2
  185. package/dist/execution/capability-executor.d.ts.map +1 -1
  186. package/dist/execution/capability-executor.js +45 -7
  187. package/dist/execution/capability-executor.js.map +1 -1
  188. package/dist/execution/context-factory.d.ts.map +1 -1
  189. package/dist/execution/context-factory.js +9 -1
  190. package/dist/execution/context-factory.js.map +1 -1
  191. package/dist/execution/deferred-flow-service.d.ts.map +1 -1
  192. package/dist/execution/deferred-flow-service.js +7 -0
  193. package/dist/execution/deferred-flow-service.js.map +1 -1
  194. package/dist/execution/index.d.ts +1 -0
  195. package/dist/execution/index.d.ts.map +1 -1
  196. package/dist/execution/index.js.map +1 -1
  197. package/dist/fields/index.d.ts +7 -0
  198. package/dist/fields/index.d.ts.map +1 -1
  199. package/dist/fields/index.js +10 -1
  200. package/dist/fields/index.js.map +1 -1
  201. package/dist/flows/dead-letter.d.ts +18 -1
  202. package/dist/flows/dead-letter.d.ts.map +1 -1
  203. package/dist/flows/dead-letter.js +17 -1
  204. package/dist/flows/dead-letter.js.map +1 -1
  205. package/dist/flows/engine.d.ts +16 -1
  206. package/dist/flows/engine.d.ts.map +1 -1
  207. package/dist/flows/engine.js +218 -29
  208. package/dist/flows/engine.js.map +1 -1
  209. package/dist/flows/flow-service.d.ts.map +1 -1
  210. package/dist/flows/flow-service.js +3 -0
  211. package/dist/flows/flow-service.js.map +1 -1
  212. package/dist/flows/step-executor.d.ts +8 -0
  213. package/dist/flows/step-executor.d.ts.map +1 -1
  214. package/dist/flows/step-executor.js.map +1 -1
  215. package/dist/index.d.ts +4 -4
  216. package/dist/index.d.ts.map +1 -1
  217. package/dist/index.js +1 -1
  218. package/dist/index.js.map +1 -1
  219. package/dist/runtime/bootstrap.d.ts +18 -1
  220. package/dist/runtime/bootstrap.d.ts.map +1 -1
  221. package/dist/runtime/bootstrap.js +36 -5
  222. package/dist/runtime/bootstrap.js.map +1 -1
  223. package/dist/runtime/load-extensions.d.ts +2 -1
  224. package/dist/runtime/load-extensions.d.ts.map +1 -1
  225. package/dist/runtime/load-extensions.js +49 -1
  226. package/dist/runtime/load-extensions.js.map +1 -1
  227. package/dist/runtime/register-consumers.d.ts +3 -0
  228. package/dist/runtime/register-consumers.d.ts.map +1 -1
  229. package/dist/runtime/register-consumers.js +27 -6
  230. package/dist/runtime/register-consumers.js.map +1 -1
  231. package/dist/runtime/start-worker-pool.d.ts.map +1 -1
  232. package/dist/runtime/start-worker-pool.js +36 -15
  233. package/dist/runtime/start-worker-pool.js.map +1 -1
  234. package/dist/server/bootstrap.d.ts +42 -16
  235. package/dist/server/bootstrap.d.ts.map +1 -1
  236. package/dist/server/bootstrap.js +91 -21
  237. package/dist/server/bootstrap.js.map +1 -1
  238. package/dist/testing/context.d.ts +2 -0
  239. package/dist/testing/context.d.ts.map +1 -1
  240. package/dist/testing/context.js +8 -3
  241. package/dist/testing/context.js.map +1 -1
  242. package/dist/testing/field-validation.d.ts.map +1 -1
  243. package/dist/testing/field-validation.js +3 -0
  244. package/dist/testing/field-validation.js.map +1 -1
  245. package/dist/types/config.d.ts +13 -0
  246. package/dist/types/config.d.ts.map +1 -1
  247. package/dist/types/context.d.ts +70 -4
  248. package/dist/types/context.d.ts.map +1 -1
  249. package/dist/types/fields.d.ts +6 -0
  250. package/dist/types/fields.d.ts.map +1 -1
  251. package/dist/types/index.d.ts +1 -1
  252. package/dist/types/index.d.ts.map +1 -1
  253. package/dist/types/index.js.map +1 -1
  254. package/dist/types/prompt.d.ts +9 -0
  255. package/dist/types/prompt.d.ts.map +1 -1
  256. package/dist/worker/bootstrap.d.ts +19 -0
  257. package/dist/worker/bootstrap.d.ts.map +1 -1
  258. package/dist/worker/bootstrap.js +156 -84
  259. package/dist/worker/bootstrap.js.map +1 -1
  260. package/instructions/README.md +4 -1
  261. package/instructions/ai-classification.md +132 -0
  262. package/instructions/ai.md +94 -15
  263. package/instructions/cli.md +19 -1
  264. package/instructions/entities.md +3 -1
  265. package/instructions/flows.md +6 -0
  266. package/instructions/framework.md +13 -3
  267. package/instructions/peer-dependencies.md +2 -1
  268. package/instructions/prompts.md +2 -1
  269. package/migrations/durable-tenant/0002_approval_cancellation.sql +3 -0
  270. package/migrations/durable-tenant/meta/_journal.json +7 -0
  271. package/package.json +5 -4
  272. package/dist/ai/__typecheck-fixtures__/legacy-cost-api.d.ts +0 -5
  273. package/dist/ai/__typecheck-fixtures__/legacy-cost-api.d.ts.map +0 -1
  274. package/dist/ai/__typecheck-fixtures__/legacy-cost-api.js +0 -15
  275. package/dist/ai/__typecheck-fixtures__/legacy-cost-api.js.map +0 -1
@@ -2,16 +2,101 @@
2
2
  // Full ctx.ai implementation: generate, extract, classify, retrieve
3
3
  // Integrates: provider adapter, prompt registry, validation, cost tracking, security, RAG, explainability
4
4
  import { z } from 'zod';
5
+ import { createErrorService } from '../errors/index.js';
5
6
  import { AIBudgetExceededError, AISecurityBlockedError } from '../errors/data-errors.js';
7
+ import { PlumbusError } from '../errors/plumbus-error.js';
6
8
  import { fillPromptTemplate } from './fill-prompt-template.js';
7
9
  import { normalizeCost, validateTokenUsage } from './usage-validation.js';
8
10
  import { estimateModelCost as calculateModelCost, findModelRate } from './model-pricing.js';
11
+ import { restoreOmittedOptionalProperties } from './openai-strict-schema.js';
12
+ import { resolvePromptOverride } from './prompt-overrides.js';
9
13
  import { createPromptRegistry } from './prompt-registry.js';
10
- import { normalizeFinishReason, } from './provider.js';
14
+ import { isCostFreeProviderFailure } from './provider-failure.js';
15
+ import { assertRequestTimeout, normalizeFinishReason, } from './provider.js';
11
16
  import { AIIncompleteOutputError, AIRefusalError } from './refusal.js';
12
17
  import { checkPromptSecurity } from './security.js';
13
18
  import { AIValidationError, generateWithValidation, } from './validation.js';
14
19
  import { zodToProviderJsonSchema } from './zod-to-provider-schema.js';
20
+ /**
21
+ * Output a request without `maxTokens` is assumed to have produced when its usage never came
22
+ * back. Matches the Anthropic adapter's default `max_tokens`.
23
+ */
24
+ const UNMETERED_OUTPUT_TOKENS = 4096;
25
+ function addUsage(left, right) {
26
+ // Reasoning tokens are part of outputTokens (and priced there); summed once either side has them.
27
+ const reasoning = left.reasoningTokens === undefined && right.reasoningTokens === undefined
28
+ ? undefined
29
+ : (left.reasoningTokens ?? 0) + (right.reasoningTokens ?? 0);
30
+ return {
31
+ inputTokens: left.inputTokens + right.inputTokens,
32
+ outputTokens: left.outputTokens + right.outputTokens,
33
+ totalTokens: left.totalTokens + right.totalTokens,
34
+ cachedInputTokens: (left.cachedInputTokens ?? 0) + (right.cachedInputTokens ?? 0),
35
+ cacheWriteTokens: (left.cacheWriteTokens ?? 0) + (right.cacheWriteTokens ?? 0),
36
+ ...(reasoning === undefined ? {} : { reasoningTokens: reasoning }),
37
+ };
38
+ }
39
+ /** Catalog price, or undefined for an unpriced model or usage the catalog rejects. */
40
+ function catalogCost(usage, model) {
41
+ try {
42
+ return calculateModelCost(usage.inputTokens, usage.outputTokens, model, {
43
+ cachedInputTokens: usage.cachedInputTokens,
44
+ cacheWriteTokens: usage.cacheWriteTokens,
45
+ });
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function meterAnsweredAttempt(spend, attempt, usage, providerCost) {
52
+ spend.answered = addUsage(spend.answered, usage);
53
+ const cost = providerCost ?? catalogCost(usage, attempt.model) ?? null;
54
+ spend.answeredCost =
55
+ spend.answeredCost == null || cost == null ? null : spend.answeredCost + cost;
56
+ }
57
+ function meterUnansweredAttempt(spend, attempt, partial = {}) {
58
+ spend.unmetered += 1;
59
+ if (partial.usage)
60
+ spend.unmeteredUsage = addUsage(spend.unmeteredUsage, partial.usage);
61
+ // A model with no price adds only what the provider had reported: the rest cannot be
62
+ // estimated. The attempt has already failed; refusing later calls on its account would be a
63
+ // lockout for the rest of the day.
64
+ const estimate = catalogCost({
65
+ inputTokens: attempt.inputTokens,
66
+ outputTokens: attempt.outputTokens,
67
+ totalTokens: attempt.inputTokens + attempt.outputTokens,
68
+ }, attempt.model) ?? 0;
69
+ // Usage reported mid-stream (Anthropic's opening input count) leaves out the output.
70
+ const reportedSoFar = partial.cost ?? (partial.usage ? catalogCost(partial.usage, attempt.model) : undefined) ?? 0;
71
+ spend.unmeteredEstimate += Math.max(estimate, reportedSoFar);
72
+ }
73
+ /**
74
+ * Meter an attempt that threw or ended in a provider `error` event. Free when nothing came back
75
+ * and the request never reached the provider or was turned away; the reported usage when the
76
+ * provider answered (refusal, truncation) or had finished; an estimate otherwise.
77
+ */
78
+ function meterFailedAttempt(spend, attempt, failure, dispatched, report) {
79
+ const errorUsage = getTypedAIErrorUsage(failure);
80
+ if (errorUsage && errorUsage.totalTokens > 0) {
81
+ meterAnsweredAttempt(spend, attempt, errorUsage, null);
82
+ return;
83
+ }
84
+ const reportedSpend = report.usage !== undefined || report.cost !== undefined;
85
+ if (!report.streamed && !reportedSpend && (!dispatched || isCostFreeProviderFailure(failure))) {
86
+ return;
87
+ }
88
+ if (report.finished && reportedSpend) {
89
+ meterAnsweredAttempt(spend, attempt, report.usage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, report.cost ?? null);
90
+ return;
91
+ }
92
+ meterUnansweredAttempt(spend, attempt, report);
93
+ }
94
+ function getTypedAIErrorUsage(err) {
95
+ if (err instanceof AIRefusalError || err instanceof AIIncompleteOutputError) {
96
+ return err.usage;
97
+ }
98
+ return undefined;
99
+ }
15
100
  /** Convenience: create config from a single provider (backward compat) */
16
101
  export function singleProviderConfig(provider, rest) {
17
102
  return {
@@ -26,7 +111,7 @@ function resolveReasoningOverride(reasoning, inherited) {
26
111
  return inherited;
27
112
  }
28
113
  export function createAIService(config) {
29
- const { providers, defaultProvider, promptRegistry: configuredPromptRegistry, prompts, costTracker, ragPipeline, explainability, security, onAICostRecorded, } = config;
114
+ const { providers, promptRegistry: configuredPromptRegistry, prompts, costTracker, ragPipeline, explainability, security, onAICostRecorded, } = config;
30
115
  const promptRegistry = prompts !== undefined
31
116
  ? (() => {
32
117
  const registry = configuredPromptRegistry ?? createPromptRegistry([]);
@@ -38,13 +123,108 @@ export function createAIService(config) {
38
123
  })()
39
124
  : configuredPromptRegistry;
40
125
  const responseSchemaCache = new WeakMap();
41
- function resolveProvider(providerName) {
42
- const name = providerName ?? defaultProvider;
126
+ // Keyed by the per-call wrapper `resolveProvider` returns, so each logical call has its own.
127
+ const providerCallSpend = new WeakMap();
128
+ const concurrencyState = config._providerConcurrencyState ?? {
129
+ active: new Map(),
130
+ };
131
+ // Dynamic-override wrappers retain the original config object; attach the runtime so every
132
+ // identity-bound service created from it competes for the same slots.
133
+ config._providerConcurrencyState = concurrencyState;
134
+ function callContext(provider, operation, costContext) {
135
+ return {
136
+ provider,
137
+ operation,
138
+ ...(config.budget?.tenantId ? { tenantId: config.budget.tenantId } : {}),
139
+ ...(config.budget?.actor ? { actor: config.budget.actor } : {}),
140
+ ...(config.budget?.correlationId ? { correlationId: config.budget.correlationId } : {}),
141
+ ...(costContext ? { costContext } : {}),
142
+ };
143
+ }
144
+ function acquireProviderSlot(context) {
145
+ const policy = config.providerConcurrency;
146
+ if (!policy)
147
+ return () => undefined;
148
+ if (!Number.isSafeInteger(policy.maxConcurrentCalls) || policy.maxConcurrentCalls < 1) {
149
+ throw new Error('AI provider concurrency maxConcurrentCalls must be a positive integer');
150
+ }
151
+ const scope = policy.resolveScope?.(context) ??
152
+ [
153
+ context.provider,
154
+ context.tenantId ?? 'platform',
155
+ context.costContext?.serviceArea ?? 'default',
156
+ ].join(':');
157
+ const active = concurrencyState.active.get(scope) ?? 0;
158
+ if (active >= policy.maxConcurrentCalls) {
159
+ throw new PlumbusError('conflict', 'AI provider concurrency is exhausted for this tenant and package', {
160
+ failureCode: 'ai-provider-concurrency-exhausted',
161
+ reason: 'ai-provider-concurrency-exhausted',
162
+ provider: context.provider,
163
+ limit: policy.maxConcurrentCalls,
164
+ retryAfterSeconds: 1,
165
+ });
166
+ }
167
+ concurrencyState.active.set(scope, active + 1);
168
+ let released = false;
169
+ return () => {
170
+ if (released)
171
+ return;
172
+ released = true;
173
+ const remaining = (concurrencyState.active.get(scope) ?? 1) - 1;
174
+ if (remaining <= 0)
175
+ concurrencyState.active.delete(scope);
176
+ else
177
+ concurrencyState.active.set(scope, remaining);
178
+ };
179
+ }
180
+ async function trustedProviderHeaders(context) {
181
+ const headers = (await config.resolveProviderHeaders?.(context)) ?? {};
182
+ const projected = {};
183
+ for (const [name, value] of Object.entries(headers)) {
184
+ const lower = name.toLowerCase();
185
+ if (['authorization', 'proxy-authorization', 'x-api-key', 'api-key', 'content-type'].includes(lower)) {
186
+ throw new Error(`AI provider propagation header "${name}" is reserved`);
187
+ }
188
+ projected[lower] = String(value);
189
+ }
190
+ return projected;
191
+ }
192
+ async function reportProviderSpan(context, startedAt, durationMs, status, traceparent, error) {
193
+ if (!config.onProviderSpan)
194
+ return;
195
+ try {
196
+ await config.onProviderSpan({
197
+ ...context,
198
+ startedAt: startedAt.toISOString(),
199
+ durationMs,
200
+ status,
201
+ ...(traceparent ? { traceparent } : {}),
202
+ ...(status === 'error'
203
+ ? { errorType: error instanceof Error ? error.name : 'UnknownError' }
204
+ : {}),
205
+ });
206
+ }
207
+ catch {
208
+ // Observability must never turn a provider answer into an application failure.
209
+ }
210
+ }
211
+ function resolveProvider(providerName, operation, costContext) {
212
+ // Read at call time: `resolveAiOverrides` may replace the default provider between calls.
213
+ const name = providerName ?? config.defaultProvider;
43
214
  const adapter = providers[name];
44
215
  if (!adapter) {
45
216
  const available = Object.keys(providers).join(', ');
46
217
  throw new Error(`AI provider "${name}" not configured. Available: ${available}`);
47
218
  }
219
+ const context = callContext(adapter.name, operation, costContext);
220
+ const providerLabel = adapter.name;
221
+ const spend = {
222
+ answered: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
223
+ answeredCost: 0,
224
+ unmetered: 0,
225
+ unmeteredUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
226
+ unmeteredEstimate: 0,
227
+ };
48
228
  // Every provider attempt, including validation retries, checks the budget.
49
229
  const checkRequest = (request) => {
50
230
  const input = JSON.stringify({
@@ -54,27 +234,113 @@ export function createAIService(config) {
54
234
  tools: request.tools,
55
235
  responseSchema: request.responseSchema,
56
236
  });
57
- checkBudget(Math.ceil(Buffer.byteLength(input, 'utf8') / 4) + (request.maxTokens ?? 0));
237
+ const inputTokens = Math.ceil(Buffer.byteLength(input, 'utf8') / 4);
238
+ // The same model the call's ledger rows carry, so unpriced spend is matched to it.
239
+ const model = request.model ?? providerLabel;
240
+ checkBudget(inputTokens + (request.maxTokens ?? 0), undefined, {
241
+ provider: providerLabel,
242
+ model,
243
+ });
244
+ const cap = request.maxTokens;
245
+ return {
246
+ model,
247
+ inputTokens,
248
+ outputTokens: cap !== undefined && Number.isSafeInteger(cap) && cap > 0 ? cap : UNMETERED_OUTPUT_TOKENS,
249
+ };
58
250
  };
59
- return {
251
+ const wrapper = {
60
252
  ...adapter,
61
253
  name: adapter.name,
62
254
  embed: (request) => adapter.embed(request),
63
255
  async complete(request) {
64
- checkRequest(request);
65
- const response = await adapter.complete(request);
66
- validateTokenUsage(response.usage);
67
- return { ...response, cost: normalizeCost(response.cost) ?? undefined };
256
+ const attempt = checkRequest(request);
257
+ const release = acquireProviderSlot(context);
258
+ const startedAt = new Date();
259
+ let traceparent;
260
+ // Refusals above (budget, concurrency) and header failures never reach the provider.
261
+ let dispatched = false;
262
+ try {
263
+ const transportHeaders = await trustedProviderHeaders(context);
264
+ traceparent = transportHeaders.traceparent;
265
+ // An already-fired abort makes the adapter's fetch reject without opening a connection.
266
+ dispatched = !request.signal?.aborted;
267
+ const response = await adapter.complete({ ...request, transportHeaders });
268
+ validateTokenUsage(response.usage);
269
+ meterAnsweredAttempt(spend, attempt, response.usage, normalizeCost(response.cost));
270
+ await reportProviderSpan(context, startedAt, Date.now() - startedAt.getTime(), 'ok', traceparent);
271
+ return { ...response, cost: normalizeCost(response.cost) ?? undefined };
272
+ }
273
+ catch (error) {
274
+ meterFailedAttempt(spend, attempt, error, dispatched, {
275
+ streamed: false,
276
+ finished: false,
277
+ });
278
+ await reportProviderSpan(context, startedAt, Date.now() - startedAt.getTime(), 'error', traceparent, error);
279
+ throw error;
280
+ }
281
+ finally {
282
+ release();
283
+ }
68
284
  },
69
285
  async *stream(request) {
70
- checkRequest(request);
71
- for await (const event of adapter.stream(request)) {
72
- if (event.usage)
73
- validateTokenUsage(event.usage);
74
- yield { ...event, cost: normalizeCost(event.cost) ?? undefined };
286
+ const attempt = checkRequest(request);
287
+ const release = acquireProviderSlot(context);
288
+ const startedAt = new Date();
289
+ let traceparent;
290
+ let dispatched = false;
291
+ let metered = false;
292
+ const reported = { streamed: false, finished: false };
293
+ try {
294
+ const transportHeaders = await trustedProviderHeaders(context);
295
+ traceparent = transportHeaders.traceparent;
296
+ dispatched = !request.signal?.aborted;
297
+ for await (const event of adapter.stream({ ...request, transportHeaders })) {
298
+ if (event.usage)
299
+ reported.usage = validateTokenUsage(event.usage);
300
+ const cost = normalizeCost(event.cost) ?? undefined;
301
+ if (cost !== undefined)
302
+ reported.cost = cost;
303
+ if (event.type === 'error') {
304
+ // Built-in adapters report a failed fetch (never connected, 4xx) as an opening
305
+ // error event carrying the caught error. Metered before the yield: the caller
306
+ // stops at a provider error event, so this generator never resumes past it.
307
+ if (!metered) {
308
+ metered = true;
309
+ meterFailedAttempt(spend, attempt, event.cause, dispatched, reported);
310
+ }
311
+ }
312
+ else {
313
+ reported.streamed = true;
314
+ if (event.type === 'done')
315
+ reported.finished = true;
316
+ }
317
+ yield { ...event, cost };
318
+ }
319
+ if (!metered) {
320
+ metered = true;
321
+ // A stream that ended normally without usage still cost something: estimate it.
322
+ if (reported.usage === undefined && reported.cost === undefined) {
323
+ meterUnansweredAttempt(spend, attempt);
324
+ }
325
+ else {
326
+ meterAnsweredAttempt(spend, attempt, reported.usage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, reported.cost ?? null);
327
+ }
328
+ }
329
+ await reportProviderSpan(context, startedAt, Date.now() - startedAt.getTime(), 'ok', traceparent);
330
+ }
331
+ catch (error) {
332
+ if (!metered)
333
+ meterFailedAttempt(spend, attempt, error, dispatched, reported);
334
+ await reportProviderSpan(context, startedAt, Date.now() - startedAt.getTime(), 'error', traceparent, error);
335
+ throw error;
336
+ }
337
+ finally {
338
+ release();
75
339
  }
76
340
  },
77
341
  };
342
+ providerCallSpend.set(wrapper, spend);
343
+ return wrapper;
78
344
  }
79
345
  function getSingleTextFieldName(schema) {
80
346
  const shape = schema instanceof z.ZodObject ? schema.shape : undefined;
@@ -122,12 +388,6 @@ export function createAIService(config) {
122
388
  return 'incomplete';
123
389
  return 'failed';
124
390
  }
125
- function getTypedAIErrorUsage(err) {
126
- if (err instanceof AIRefusalError || err instanceof AIIncompleteOutputError) {
127
- return err.usage;
128
- }
129
- return undefined;
130
- }
131
391
  /**
132
392
  * Centralized cost recorder: updates the in-memory tracker and fires the
133
393
  * optional `onAICostRecorded` hook. Hook errors are swallowed so a
@@ -135,32 +395,43 @@ export function createAIService(config) {
135
395
  * id/timestamp generation mirror what `createCostTracker` does internally
136
396
  * so the hook gets a fully-formed `AICostRecord`.
137
397
  */
138
- async function recordProviderCost(entry, costContext) {
398
+ async function recordProviderCost(entry, costContext, inferCatalogCost = true) {
399
+ const { estimatedCost: rawEstimate, ...rest } = entry;
400
+ const estimatedCost = normalizeCost(rawEstimate);
139
401
  entry = {
140
- ...entry,
402
+ ...rest,
141
403
  tenantId: config.budget ? config.budget.tenantId : entry.tenantId,
142
404
  actor: config.budget ? config.budget.actor : entry.actor,
143
405
  cost: (entry.cost == null ? undefined : normalizeCost(entry.cost)) ??
144
- (entry.cost == null && entry.usage.totalTokens > 0 && findModelRate(entry.model)
406
+ // An estimate means part of the call's spend is unknown; pricing only the answered
407
+ // usage would present the call as fully priced.
408
+ (inferCatalogCost &&
409
+ estimatedCost == null &&
410
+ entry.operation !== 'decide' &&
411
+ entry.cost == null &&
412
+ entry.usage.totalTokens > 0 &&
413
+ findModelRate(entry.model)
145
414
  ? calculateModelCost(entry.usage.inputTokens, entry.usage.outputTokens, entry.model, {
146
415
  cachedInputTokens: entry.usage.cachedInputTokens,
147
416
  cacheWriteTokens: entry.usage.cacheWriteTokens,
148
417
  })
149
418
  : null),
150
419
  };
151
- if (costTracker) {
152
- costTracker.record(entry);
153
- }
420
+ const cost = normalizeCost(entry.cost);
421
+ const finalEntry = {
422
+ ...entry,
423
+ usage: { ...entry.usage },
424
+ ...(entry.mediaUsage ? { mediaUsage: { ...entry.mediaUsage } } : {}),
425
+ cost,
426
+ ...(cost == null && estimatedCost != null ? { estimatedCost } : {}),
427
+ status: entry.status ?? 'success',
428
+ id: crypto.randomUUID(),
429
+ timestamp: new Date(),
430
+ };
431
+ costTracker?.record(finalEntry);
154
432
  if (onAICostRecorded) {
155
- const finalEntry = {
156
- ...entry,
157
- cost: normalizeCost(entry.cost),
158
- status: entry.status ?? 'success',
159
- id: crypto.randomUUID(),
160
- timestamp: new Date(),
161
- };
162
433
  try {
163
- await onAICostRecorded(finalEntry, costContext);
434
+ await onAICostRecorded(structuredClone(finalEntry), costContext);
164
435
  }
165
436
  catch (hookErr) {
166
437
  const msg = hookErr instanceof Error ? hookErr.message : String(hookErr);
@@ -169,23 +440,47 @@ export function createAIService(config) {
169
440
  }
170
441
  }
171
442
  function sumUsage(a, b) {
172
- const left = a ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
173
- const right = b ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
443
+ return addUsage(a ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, b ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
444
+ }
445
+ /**
446
+ * Usage and cost for the ledger row of a logical call that failed, from the attempts its
447
+ * resolved provider metered: everything the provider answered (including validation attempts
448
+ * a later transport error would otherwise drop) and, while any attempt's usage never came
449
+ * back, an `estimatedCost` in place of a cost so dollar budgets stay enforced. A call whose
450
+ * attempts never reached the provider, or were turned away, costs zero.
451
+ */
452
+ function failedCallCost(provider, usage, model) {
453
+ const spend = providerCallSpend.get(provider);
454
+ if (!spend) {
455
+ return { usage, cost: usage.totalTokens > 0 ? (catalogCost(usage, model) ?? null) : null };
456
+ }
457
+ // The provider's view covers every attempt, including usage a cut-off stream had reported;
458
+ // the call site's own figure only shows on the row if it is larger.
459
+ const metered = addUsage(spend.answered, spend.unmeteredUsage);
460
+ const rowUsage = metered.totalTokens >= usage.totalTokens ? metered : usage;
461
+ if (spend.unmetered === 0) {
462
+ return {
463
+ usage: rowUsage,
464
+ cost: rowUsage === metered ? spend.answeredCost : (catalogCost(usage, model) ?? null),
465
+ };
466
+ }
467
+ // Usage of the cut-off attempts is already inside their estimate; do not price it twice.
174
468
  return {
175
- inputTokens: left.inputTokens + right.inputTokens,
176
- outputTokens: left.outputTokens + right.outputTokens,
177
- totalTokens: left.totalTokens + right.totalTokens,
178
- cachedInputTokens: (left.cachedInputTokens ?? 0) + (right.cachedInputTokens ?? 0),
179
- cacheWriteTokens: (left.cacheWriteTokens ?? 0) + (right.cacheWriteTokens ?? 0),
469
+ usage: rowUsage,
470
+ cost: null,
471
+ ...(spend.answeredCost != null
472
+ ? { estimatedCost: spend.answeredCost + spend.unmeteredEstimate }
473
+ : {}),
180
474
  };
181
475
  }
182
- function checkBudget(estimatedTokens, estimatedCostUsd) {
476
+ function checkBudget(estimatedTokens, estimatedCostUsd, scope) {
183
477
  if (!costTracker)
184
478
  return;
185
479
  const result = costTracker.checkBudget({
186
480
  tenantId: config.budget?.tenantId,
187
481
  estimatedTokens,
188
482
  estimatedCostUsd,
483
+ ...scope,
189
484
  });
190
485
  if (!result.allowed) {
191
486
  throw new AIBudgetExceededError(result.reason ?? 'budget exceeded');
@@ -209,10 +504,18 @@ export function createAIService(config) {
209
504
  ]);
210
505
  const system = systemFilled?.text;
211
506
  const text = textFilled.text;
212
- // Resolution chain: config/env override → prompt-level → defaults
213
- // Look up override by prompt name (dots replaced with underscores for env var matching)
214
- const overrideKey = promptName.toLowerCase().replaceAll('.', '_');
215
- const override = config.promptOverrides?.[overrideKey] ?? config.promptOverrides?.[promptName];
507
+ // Field by field: resolveAiOverrides → PROMPT_{NAME}_* env → prompt definition → defaults
508
+ // (per-call values are applied over this by the caller). Each layer is looked up by the
509
+ // prompt's own name and by its env-style key (`report.summarize` → `report_summarize`), the
510
+ // name winning when both set a field.
511
+ const override = resolvePromptOverride(promptName, [
512
+ config.promptOverrides,
513
+ config._dynamicPromptOverrides,
514
+ ]);
515
+ // `reasoning` and its deprecated shorthand `reasoningEffort` are one setting: an override
516
+ // that sets either replaces the definition's pair (adapters prefer `reasoning` when both are
517
+ // sent, so mixing them would let the definition's `reasoning` beat the override).
518
+ const reasoningSource = override?.reasoning != null || override?.reasoningEffort != null ? override : def.model;
216
519
  return {
217
520
  system,
218
521
  text,
@@ -220,8 +523,9 @@ export function createAIService(config) {
220
523
  provider: override?.provider ?? def.model?.provider,
221
524
  temperature: override?.temperature ?? def.model?.temperature,
222
525
  maxTokens: override?.maxTokens ?? def.model?.maxTokens,
223
- reasoning: override?.reasoning ?? def.model?.reasoning,
224
- reasoningEffort: override?.reasoningEffort ?? def.model?.reasoningEffort,
526
+ reasoning: reasoningSource?.reasoning,
527
+ reasoningEffort: reasoningSource?.reasoningEffort,
528
+ timeoutMs: override?.timeoutMs ?? def.model?.timeoutMs,
225
529
  appendUnsubstitutedInput: def.appendUnsubstitutedInput,
226
530
  substitutedKeys,
227
531
  };
@@ -266,15 +570,17 @@ export function createAIService(config) {
266
570
  async function _generateCore(params) {
267
571
  const start = performance.now();
268
572
  const { inputForAI, securityResult } = applyPromptSecurity(params.input);
269
- // Budget pre-check
270
- checkBudget();
271
573
  // Build prompt
272
574
  const hasPromptDef = promptRegistry?.has(params.prompt);
273
575
  const promptInfo = hasPromptDef
274
576
  ? buildPromptText(params.prompt, inputForAI)
275
577
  : { text: params.prompt, substitutedKeys: new Set() };
276
578
  // Resolve provider: per-call > prompt-level > default
277
- const activeProvider = resolveProvider(params.provider ?? ('provider' in promptInfo ? promptInfo.provider : undefined));
579
+ const activeProvider = resolveProvider(params.provider ?? ('provider' in promptInfo ? promptInfo.provider : undefined), 'generate', params.costContext);
580
+ const resolvedModel = params.model ?? promptInfo.model ?? config.defaultModel ?? activeProvider.name;
581
+ // Budget pre-check, scoped to the model so unpriced spend elsewhere does not refuse it.
582
+ // A refusal here leaves no ledger row.
583
+ checkBudget(undefined, undefined, { provider: activeProvider.name, model: resolvedModel });
278
584
  // Check if we have a schema to validate against
279
585
  const promptDef = hasPromptDef ? promptRegistry?.get(params.prompt) : undefined;
280
586
  const singleTextField = promptDef ? getSingleTextFieldName(promptDef.output) : undefined;
@@ -286,7 +592,8 @@ export function createAIService(config) {
286
592
  : promptInfo.system;
287
593
  const toolsEnabled = params.tools !== undefined;
288
594
  const outputValidationNone = params.outputValidation === 'none';
289
- const skipStructuredOutput = toolsEnabled || outputValidationNone;
595
+ const validateToolAnswer = toolsEnabled && params.outputValidation === 'prompt' && Boolean(promptDef);
596
+ const skipStructuredOutput = (toolsEnabled && !validateToolAnswer) || outputValidationNone;
290
597
  const structuredResponseFormat = promptDef && !singleTextField ? 'json' : undefined;
291
598
  const resolvedReasoning = resolveReasoningOverride(params.reasoning, 'reasoning' in promptInfo ? promptInfo.reasoning : undefined);
292
599
  const resolvedLegacyReasoningEffort = params.reasoning === null || params.reasoningEffort === null
@@ -309,7 +616,14 @@ export function createAIService(config) {
309
616
  : promptDef?.structuredOutputTransport,
310
617
  signal: params.signal,
311
618
  seed: params.seed,
619
+ cache: params.cache ?? config.cache,
312
620
  };
621
+ // Per call → prompt override → prompt definition; unset keeps the adapter's requestTimeout.
622
+ const timeoutMs = params.timeoutMs ?? ('timeoutMs' in promptInfo ? promptInfo.timeoutMs : undefined);
623
+ if (timeoutMs !== undefined) {
624
+ assertRequestTimeout(timeoutMs);
625
+ request.timeout = timeoutMs;
626
+ }
313
627
  if (toolsEnabled) {
314
628
  request.tools = params.tools;
315
629
  if (params.toolChoice !== undefined)
@@ -317,7 +631,6 @@ export function createAIService(config) {
317
631
  if (params.toolExecution !== undefined)
318
632
  request.toolExecution = params.toolExecution;
319
633
  }
320
- const resolvedModel = params.model ?? promptInfo.model ?? config.defaultModel ?? activeProvider.name;
321
634
  let result;
322
635
  let totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
323
636
  let providerCost;
@@ -329,10 +642,9 @@ export function createAIService(config) {
329
642
  let toolFinishNormalized;
330
643
  try {
331
644
  if (toolsEnabled) {
332
- // Tool-enabled generation: forward tools verbatim, skip Zod output
333
- // validation entirely (honors outputValidation:'none'), and surface
334
- // the provider tool calls when finishReason normalizes to 'tool_calls'.
335
- // A non-tool final answer returns { content } as data.
645
+ // Default tool rounds remain raw. Explicit prompt validation constrains
646
+ // and validates final answers in this call; tool calls bypass the answer
647
+ // schema and never cause a validation retry or repeated tool action.
336
648
  const response = await activeProvider.complete(request);
337
649
  result = { content: response.content };
338
650
  totalUsage = response.usage;
@@ -340,6 +652,37 @@ export function createAIService(config) {
340
652
  toolCallsResult = response.toolCalls;
341
653
  toolProviderState = response.providerState;
342
654
  toolFinishNormalized = normalizeFinishReason(response.finishReason);
655
+ if (validateToolAnswer && promptDef && toolFinishNormalized !== 'tool_calls') {
656
+ try {
657
+ if (toolFinishNormalized !== 'stop') {
658
+ throw new AIIncompleteOutputError({
659
+ partialText: response.content,
660
+ finishReason: response.finishReason ?? 'other',
661
+ usage: response.usage,
662
+ model: resolvedModel,
663
+ provider: activeProvider.name,
664
+ });
665
+ }
666
+ const candidate = singleTextField
667
+ ? { [singleTextField]: response.content }
668
+ : JSON.parse(response.content);
669
+ result = promptDef.output.parse(request.responseSchema
670
+ ? restoreOmittedOptionalProperties(candidate, request.responseSchema)
671
+ : candidate);
672
+ }
673
+ catch (error) {
674
+ if (error instanceof AIIncompleteOutputError)
675
+ throw error;
676
+ throw new AIValidationError({
677
+ attempts: 1,
678
+ rawOutput: response.content,
679
+ lastError: error instanceof Error ? error : null,
680
+ usage: response.usage,
681
+ model: resolvedModel,
682
+ provider: activeProvider.name,
683
+ });
684
+ }
685
+ }
343
686
  }
344
687
  else if (outputValidationNone) {
345
688
  // Explicit no-validation path (no tools): return raw content as data.
@@ -377,26 +720,19 @@ export function createAIService(config) {
377
720
  }
378
721
  catch (err) {
379
722
  // Failure-path cost recording: capture whatever usage we know about
380
- // (from AIValidationError or zero for opaque fetch errors) so the
381
- // sunk provider-side spend is still visible to budgets and ledgers.
723
+ // (from AIValidationError, or what the provider metered per attempt)
724
+ // so the sunk provider-side spend is still visible to budgets and ledgers.
382
725
  const latencyMs = performance.now() - start;
383
726
  const failureUsage = err instanceof AIValidationError
384
727
  ? err.usage
385
728
  : (getTypedAIErrorUsage(err) ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
386
729
  const errorMessage = err instanceof Error ? err.message : String(err);
387
- const failureCost = failureUsage.totalTokens > 0
388
- ? calculateModelCost(failureUsage.inputTokens, failureUsage.outputTokens, resolvedModel, {
389
- cachedInputTokens: failureUsage.cachedInputTokens,
390
- cacheWriteTokens: failureUsage.cacheWriteTokens,
391
- })
392
- : null;
393
730
  await recordProviderCost({
394
731
  model: resolvedModel,
395
732
  provider: activeProvider.name,
396
733
  promptName: hasPromptDef ? params.prompt : undefined,
397
734
  operation: 'generate',
398
- usage: failureUsage,
399
- cost: failureCost,
735
+ ...failedCallCost(activeProvider, failureUsage, resolvedModel),
400
736
  latencyMs,
401
737
  tenantId: config.budget?.tenantId,
402
738
  actor: config.budget?.actor,
@@ -476,15 +812,65 @@ export function createAIService(config) {
476
812
  function generateWithUsage(config) {
477
813
  return _generateCore(config);
478
814
  }
815
+ async function executeDecision(params, operation, project = (result) => result.answers) {
816
+ const costContext = params.costContext ? { ...params.costContext } : undefined;
817
+ // Runtime loading keeps the shared package's core error/Zod imports out of initialization.
818
+ const packageName = '@plumbus/ai-decision';
819
+ const runtime = (await import(packageName));
820
+ let securedInput = {};
821
+ let warnings;
822
+ let decisionName;
823
+ const result = await runtime.runDecision(params, config.decisions, {
824
+ secure(input) {
825
+ const checked = applyPromptSecurity(input);
826
+ securedInput = checked.inputForAI;
827
+ warnings = checked.securityResult?.warnings.map((warning) => warning.message);
828
+ return securedInput;
829
+ },
830
+ checkBudget,
831
+ async record(record) {
832
+ decisionName = record.decisionName;
833
+ await recordProviderCost({
834
+ ...record,
835
+ operation,
836
+ tenantId: config.budget?.tenantId,
837
+ actor: config.budget?.actor,
838
+ }, costContext,
839
+ // Native decision prices must not fall back to the text-model catalog.
840
+ false);
841
+ },
842
+ });
843
+ explainability?.record({
844
+ operation,
845
+ decisionName,
846
+ provider: result.provider,
847
+ model: result.model,
848
+ input: securedInput,
849
+ output: project(result),
850
+ usage: result.usage,
851
+ securityWarnings: warnings,
852
+ latencyMs: result.latencyMs,
853
+ tenantId: config.budget?.tenantId,
854
+ actor: config.budget?.actor,
855
+ });
856
+ return result;
857
+ }
479
858
  return {
480
859
  withContext(identity) {
481
- return createAIService({ ...config, budget: { ...identity } });
860
+ return createAIService({
861
+ ...config,
862
+ budget: { ...identity },
863
+ _providerConcurrencyState: concurrencyState,
864
+ });
482
865
  },
483
- features: { perCallProviderModelReasoning: true },
866
+ features: { perCallProviderModelReasoning: true, typedDecisions: true },
484
867
  recordProviderCost,
485
868
  checkProviderCostBudget(config = {}) {
486
869
  checkBudget(config.estimatedTokens, config.estimatedCostUsd);
487
870
  },
871
+ decide(params) {
872
+ return executeDecision(params, 'decide');
873
+ },
488
874
  async generate(params) {
489
875
  const result = await _generateCore(params);
490
876
  if (result.finishReason === 'tool_calls') {
@@ -497,15 +883,16 @@ export function createAIService(config) {
497
883
  const streamStart = performance.now();
498
884
  const { inputForAI, securityResult } = applyPromptSecurity(params.input);
499
885
  const streamSecurityWarnings = securityResult?.warnings.map((w) => w.message);
500
- // Budget pre-check
501
- checkBudget();
502
886
  // Build prompt
503
887
  const hasPromptDef = promptRegistry?.has(params.prompt);
504
888
  const promptInfo = hasPromptDef
505
889
  ? buildPromptText(params.prompt, inputForAI)
506
890
  : { text: params.prompt, substitutedKeys: new Set() };
507
891
  // Resolve provider
508
- const activeProvider = resolveProvider(params.provider ?? ('provider' in promptInfo ? promptInfo.provider : undefined));
892
+ const activeProvider = resolveProvider(params.provider ?? ('provider' in promptInfo ? promptInfo.provider : undefined), 'stream', params.costContext);
893
+ const resolvedModel = params.model ?? promptInfo.model ?? config.defaultModel ?? activeProvider.name;
894
+ // Budget pre-check, scoped to the model; a refusal here leaves no ledger row.
895
+ checkBudget(undefined, undefined, { provider: activeProvider.name, model: resolvedModel });
509
896
  // Detect if the output schema is a simple single-string-field object
510
897
  // (e.g. z.object({ question: z.string() })). If so, stream plain text
511
898
  // so the user sees readable words instead of JSON tokens.
@@ -561,8 +948,15 @@ export function createAIService(config) {
561
948
  structuredOutputTransport: promptDef?.structuredOutputTransport,
562
949
  signal: params.signal,
563
950
  seed: params.seed,
951
+ cache: params.cache ?? config.cache,
564
952
  };
565
- const resolvedModel = params.model ?? promptInfo.model ?? config.defaultModel ?? activeProvider.name;
953
+ // Per call → prompt override → prompt definition; unset keeps the adapter's requestTimeout.
954
+ // The non-streaming validation fallback below reuses this request, timeout included.
955
+ const timeoutMs = params.timeoutMs ?? ('timeoutMs' in promptInfo ? promptInfo.timeoutMs : undefined);
956
+ if (timeoutMs !== undefined) {
957
+ assertRequestTimeout(timeoutMs);
958
+ request.timeout = timeoutMs;
959
+ }
566
960
  // Stream from provider, collecting full text and usage
567
961
  let fullText = '';
568
962
  let streamUsage;
@@ -581,6 +975,9 @@ export function createAIService(config) {
581
975
  totalTokens: event.usage.totalTokens,
582
976
  cachedInputTokens: event.usage.cachedInputTokens,
583
977
  cacheWriteTokens: event.usage.cacheWriteTokens,
978
+ ...(event.usage.reasoningTokens !== undefined
979
+ ? { reasoningTokens: event.usage.reasoningTokens }
980
+ : {}),
584
981
  };
585
982
  if (event.cost != null)
586
983
  streamProviderCost = event.cost;
@@ -602,25 +999,21 @@ export function createAIService(config) {
602
999
  totalTokens: event.usage.totalTokens,
603
1000
  cachedInputTokens: event.usage.cachedInputTokens,
604
1001
  cacheWriteTokens: event.usage.cacheWriteTokens,
1002
+ ...(event.usage.reasoningTokens !== undefined
1003
+ ? { reasoningTokens: event.usage.reasoningTokens }
1004
+ : {}),
605
1005
  };
606
1006
  }
607
1007
  }
608
1008
  else if (event.type === 'error') {
609
1009
  // Provider-reported mid-stream error — record whatever usage we
610
1010
  // have so the sunk spend is visible, then yield error to caller.
611
- const errCost = streamUsage
612
- ? calculateModelCost(streamUsage.inputTokens, streamUsage.outputTokens, resolvedModel, {
613
- cachedInputTokens: streamUsage.cachedInputTokens,
614
- cacheWriteTokens: streamUsage.cacheWriteTokens,
615
- })
616
- : null;
617
1011
  await recordProviderCost({
618
1012
  model: resolvedModel,
619
1013
  provider: activeProvider.name,
620
1014
  promptName: hasPromptDef ? params.prompt : undefined,
621
1015
  operation: 'generate',
622
- usage: streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
623
- cost: errCost,
1016
+ ...failedCallCost(activeProvider, streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, resolvedModel),
624
1017
  latencyMs: 0,
625
1018
  tenantId: config.budget?.tenantId,
626
1019
  actor: config.budget?.actor,
@@ -636,19 +1029,12 @@ export function createAIService(config) {
636
1029
  // Transport-level failure mid-stream (fetch error, abort, etc.).
637
1030
  const errUsage = getTypedAIErrorUsage(err) ??
638
1031
  streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
639
- const errCost = errUsage.totalTokens > 0
640
- ? calculateModelCost(errUsage.inputTokens, errUsage.outputTokens, resolvedModel, {
641
- cachedInputTokens: errUsage.cachedInputTokens,
642
- cacheWriteTokens: errUsage.cacheWriteTokens,
643
- })
644
- : null;
645
1032
  await recordProviderCost({
646
1033
  model: resolvedModel,
647
1034
  provider: activeProvider.name,
648
1035
  promptName: hasPromptDef ? params.prompt : undefined,
649
1036
  operation: 'generate',
650
- usage: errUsage,
651
- cost: errCost,
1037
+ ...failedCallCost(activeProvider, errUsage, resolvedModel),
652
1038
  latencyMs: 0,
653
1039
  tenantId: config.budget?.tenantId,
654
1040
  actor: config.budget?.actor,
@@ -745,7 +1131,11 @@ export function createAIService(config) {
745
1131
  let fallbackCost;
746
1132
  try {
747
1133
  const parsed = JSON.parse(fullText);
748
- validatedData = promptDef.output.parse(parsed);
1134
+ // Same null-to-absent step as non-streaming validation: OpenAI strict mode streams an
1135
+ // omitted optional property as null.
1136
+ validatedData = promptDef.output.parse(request.responseSchema
1137
+ ? restoreOmittedOptionalProperties(parsed, request.responseSchema)
1138
+ : parsed);
749
1139
  }
750
1140
  catch (parseErr) {
751
1141
  // Streamed output failed local validation. The default behaviour
@@ -757,19 +1147,12 @@ export function createAIService(config) {
757
1147
  const errMsg = parseErr instanceof Error ? parseErr.message : String(parseErr);
758
1148
  console.error(`[stream:${params.prompt}] validation-fallback SKIPPED by prompt config — stream produced invalid JSON (${errMsg}); throwing. streamedChars=${fullText.length} finishReason=${providerFinishReason ?? 'unknown'}`);
759
1149
  const failureUsage = streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
760
- const failureCost = failureUsage.totalTokens > 0
761
- ? calculateModelCost(failureUsage.inputTokens, failureUsage.outputTokens, resolvedModel, {
762
- cachedInputTokens: failureUsage.cachedInputTokens,
763
- cacheWriteTokens: failureUsage.cacheWriteTokens,
764
- })
765
- : null;
766
1150
  await recordProviderCost({
767
1151
  model: resolvedModel,
768
1152
  provider: activeProvider.name,
769
1153
  promptName: hasPromptDef ? params.prompt : undefined,
770
1154
  operation: 'generate',
771
- usage: failureUsage,
772
- cost: failureCost,
1155
+ ...failedCallCost(activeProvider, failureUsage, resolvedModel),
773
1156
  latencyMs: 0,
774
1157
  tenantId: config.budget?.tenantId,
775
1158
  actor: config.budget?.actor,
@@ -804,19 +1187,12 @@ export function createAIService(config) {
804
1187
  totalTokens: 0,
805
1188
  });
806
1189
  const combinedUsage = sumUsage(streamUsage, fallbackErrorUsage);
807
- const combinedCost = combinedUsage.totalTokens > 0
808
- ? calculateModelCost(combinedUsage.inputTokens, combinedUsage.outputTokens, resolvedModel, {
809
- cachedInputTokens: combinedUsage.cachedInputTokens,
810
- cacheWriteTokens: combinedUsage.cacheWriteTokens,
811
- })
812
- : null;
813
1190
  await recordProviderCost({
814
1191
  model: resolvedModel,
815
1192
  provider: activeProvider.name,
816
1193
  promptName: hasPromptDef ? params.prompt : undefined,
817
1194
  operation: 'generate',
818
- usage: combinedUsage,
819
- cost: combinedCost,
1195
+ ...failedCallCost(activeProvider, combinedUsage, resolvedModel),
820
1196
  latencyMs: 0,
821
1197
  tenantId: config.budget?.tenantId,
822
1198
  actor: config.budget?.actor,
@@ -877,10 +1253,10 @@ export function createAIService(config) {
877
1253
  const start = performance.now();
878
1254
  const { inputForAI, securityResult } = applyPromptSecurity({ text: params.text });
879
1255
  const textForAI = String(inputForAI.text);
880
- checkBudget();
881
1256
  // extract uses default provider (no prompt-level routing)
882
- const activeProvider = resolveProvider();
1257
+ const activeProvider = resolveProvider(undefined, 'extract', params.costContext);
883
1258
  const resolvedModel = config.defaultModel ?? activeProvider.name;
1259
+ checkBudget(undefined, undefined, { provider: activeProvider.name, model: resolvedModel });
884
1260
  const systemPrompt = 'Extract structured data from the following text. Return valid JSON matching the required schema.';
885
1261
  const request = {
886
1262
  system: systemPrompt,
@@ -892,6 +1268,11 @@ export function createAIService(config) {
892
1268
  : undefined,
893
1269
  signal: params.signal,
894
1270
  };
1271
+ // Unset keeps the adapter's requestTimeout; each validation retry gets its own.
1272
+ if (params.timeoutMs !== undefined) {
1273
+ assertRequestTimeout(params.timeoutMs);
1274
+ request.timeout = params.timeoutMs;
1275
+ }
895
1276
  let validated;
896
1277
  try {
897
1278
  validated = (await generateWithValidation(activeProvider, request, params.schema, {
@@ -909,8 +1290,7 @@ export function createAIService(config) {
909
1290
  model: resolvedModel,
910
1291
  provider: activeProvider.name,
911
1292
  operation: 'extract',
912
- usage: failureUsage,
913
- cost: null,
1293
+ ...failedCallCost(activeProvider, failureUsage, resolvedModel),
914
1294
  latencyMs,
915
1295
  tenantId: config.budget?.tenantId,
916
1296
  actor: config.budget?.actor,
@@ -951,24 +1331,72 @@ export function createAIService(config) {
951
1331
  return validated.data;
952
1332
  },
953
1333
  async classify(params) {
1334
+ const options = z
1335
+ .object({
1336
+ provider: z.string().trim().min(1).optional(),
1337
+ model: z.string().trim().min(1).optional(),
1338
+ threshold: z.number().finite().min(0).max(1).optional(),
1339
+ })
1340
+ .safeParse(params);
1341
+ if (!options.success)
1342
+ throw createErrorService().validation('Invalid classification options');
1343
+ const { provider, model, threshold } = options.data;
1344
+ if (provider && config.decisions && Object.hasOwn(config.decisions.providers, provider)) {
1345
+ if (Object.hasOwn(providers, provider))
1346
+ throw createErrorService().validation('Classification provider is registered in both registries; use distinct names');
1347
+ const input = z
1348
+ .object({ labels: z.array(z.string().min(1)).min(1).max(256), text: z.string() })
1349
+ .safeParse(params);
1350
+ if (!input.success)
1351
+ throw createErrorService().validation('Decision classification requires 1–256 nonempty labels and string text');
1352
+ const { labels, text } = input.data;
1353
+ const questions = Object.fromEntries(labels.map((label, index) => [
1354
+ `label_${index}`,
1355
+ {
1356
+ type: 'probability',
1357
+ instructions: { label, question: 'Does the label apply to the text?' },
1358
+ },
1359
+ ]));
1360
+ const select = (result) => labels.filter((_label, index) => (result.answers[`label_${index}`]?.probability ?? -1) >= (threshold ?? 0.5));
1361
+ const result = await executeDecision({
1362
+ state: { text },
1363
+ questions,
1364
+ provider,
1365
+ model,
1366
+ signal: params.signal,
1367
+ timeoutMs: params.timeoutMs,
1368
+ costContext: params.costContext,
1369
+ }, 'classify', select);
1370
+ return select(result);
1371
+ }
1372
+ if (provider && !Object.hasOwn(providers, provider))
1373
+ throw createErrorService().validation('Classification provider is not registered');
1374
+ if (threshold !== undefined)
1375
+ throw createErrorService().validation('Classification threshold requires a decision provider');
954
1376
  const start = performance.now();
955
1377
  const { inputForAI, securityResult } = applyPromptSecurity({ text: params.text });
956
1378
  const textForAI = String(inputForAI.text);
957
- checkBudget();
958
- // classify uses default provider (no prompt-level routing)
959
- const activeProvider = resolveProvider();
960
- const resolvedModel = config.defaultModel ?? activeProvider.name;
1379
+ // Text classification retains its existing path, with optional per-call routing.
1380
+ const activeProvider = resolveProvider(provider, 'classify', params.costContext);
1381
+ const requestedModel = model ?? config.defaultModel;
1382
+ const resolvedModel = requestedModel ?? activeProvider.name;
1383
+ checkBudget(undefined, undefined, { provider: activeProvider.name, model: resolvedModel });
961
1384
  const systemPrompt = 'Classify the following text into one or more of the provided labels. Return a JSON array of matching label strings.';
962
1385
  const request = {
963
1386
  system: systemPrompt,
964
1387
  prompt: `Labels: ${JSON.stringify(params.labels)}\n\nText: ${textForAI}`,
965
- model: config.defaultModel,
1388
+ model: requestedModel,
966
1389
  responseFormat: 'json',
967
1390
  responseSchema: config.enableStrictStructuredOutputs
968
1391
  ? zodToProviderJsonSchema(z.array(z.string()), { promptName: 'classify' }).schema
969
1392
  : undefined,
970
1393
  signal: params.signal,
971
1394
  };
1395
+ // Unset keeps the adapter's requestTimeout; each validation retry gets its own.
1396
+ if (params.timeoutMs !== undefined) {
1397
+ assertRequestTimeout(params.timeoutMs);
1398
+ request.timeout = params.timeoutMs;
1399
+ }
972
1400
  const schema = z.array(z.string());
973
1401
  let validated;
974
1402
  try {
@@ -987,8 +1415,7 @@ export function createAIService(config) {
987
1415
  model: resolvedModel,
988
1416
  provider: activeProvider.name,
989
1417
  operation: 'classify',
990
- usage: failureUsage,
991
- cost: null,
1418
+ ...failedCallCost(activeProvider, failureUsage, resolvedModel),
992
1419
  latencyMs,
993
1420
  tenantId: config.budget?.tenantId,
994
1421
  actor: config.budget?.actor,
@@ -1016,7 +1443,7 @@ export function createAIService(config) {
1016
1443
  if (explainability) {
1017
1444
  explainability.record({
1018
1445
  operation: 'classify',
1019
- model: config.defaultModel,
1446
+ model: requestedModel,
1020
1447
  provider: activeProvider.name,
1021
1448
  input: { labels: params.labels, ...inputForAI },
1022
1449
  securityWarnings: securityResult?.warnings.map((warning) => warning.message),
@@ -1036,6 +1463,8 @@ export function createAIService(config) {
1036
1463
  }
1037
1464
  const start = performance.now();
1038
1465
  checkBudget();
1466
+ if (params.timeoutMs !== undefined)
1467
+ assertRequestTimeout(params.timeoutMs);
1039
1468
  const results = await ragPipeline.retrieve({
1040
1469
  query: params.query,
1041
1470
  tenantId: config.budget?.tenantId,
@@ -1043,6 +1472,8 @@ export function createAIService(config) {
1043
1472
  filter: params.filter,
1044
1473
  limit: params.limit,
1045
1474
  minScore: params.minScore,
1475
+ ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
1476
+ ...(params.signal !== undefined ? { signal: params.signal } : {}),
1046
1477
  });
1047
1478
  const latencyMs = performance.now() - start;
1048
1479
  if (explainability) {