@plumbus/core 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ai/__tests__/ai-service.test.js +704 -1
  3. package/dist/ai/__tests__/ai-service.test.js.map +1 -1
  4. package/dist/ai/__tests__/chunking.test.js +34 -0
  5. package/dist/ai/__tests__/chunking.test.js.map +1 -1
  6. package/dist/ai/__tests__/model-pricing.test.js +45 -1
  7. package/dist/ai/__tests__/model-pricing.test.js.map +1 -1
  8. package/dist/ai/__tests__/provider.test.d.ts.map +1 -1
  9. package/dist/ai/__tests__/provider.test.js +730 -1
  10. package/dist/ai/__tests__/provider.test.js.map +1 -1
  11. package/dist/ai/__tests__/validation.test.js +200 -1
  12. package/dist/ai/__tests__/validation.test.js.map +1 -1
  13. package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts +2 -0
  14. package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts.map +1 -0
  15. package/dist/ai/__tests__/zod-to-provider-schema.test.js +60 -0
  16. package/dist/ai/__tests__/zod-to-provider-schema.test.js.map +1 -0
  17. package/dist/ai/ai-service.d.ts +19 -2
  18. package/dist/ai/ai-service.d.ts.map +1 -1
  19. package/dist/ai/ai-service.js +544 -110
  20. package/dist/ai/ai-service.js.map +1 -1
  21. package/dist/ai/cost-tracker.d.ts +26 -1
  22. package/dist/ai/cost-tracker.d.ts.map +1 -1
  23. package/dist/ai/cost-tracker.js +1 -0
  24. package/dist/ai/cost-tracker.js.map +1 -1
  25. package/dist/ai/index.d.ts +6 -4
  26. package/dist/ai/index.d.ts.map +1 -1
  27. package/dist/ai/index.js +7 -3
  28. package/dist/ai/index.js.map +1 -1
  29. package/dist/ai/model-pricing.d.ts +24 -1
  30. package/dist/ai/model-pricing.d.ts.map +1 -1
  31. package/dist/ai/model-pricing.js +83 -56
  32. package/dist/ai/model-pricing.js.map +1 -1
  33. package/dist/ai/provider.d.ts +110 -1
  34. package/dist/ai/provider.d.ts.map +1 -1
  35. package/dist/ai/provider.js +358 -32
  36. package/dist/ai/provider.js.map +1 -1
  37. package/dist/ai/rag/chunking.js +53 -3
  38. package/dist/ai/rag/chunking.js.map +1 -1
  39. package/dist/ai/refusal.d.ts +28 -0
  40. package/dist/ai/refusal.d.ts.map +1 -0
  41. package/dist/ai/refusal.js +36 -0
  42. package/dist/ai/refusal.js.map +1 -0
  43. package/dist/ai/validation.d.ts +40 -8
  44. package/dist/ai/validation.d.ts.map +1 -1
  45. package/dist/ai/validation.js +97 -6
  46. package/dist/ai/validation.js.map +1 -1
  47. package/dist/ai/zod-to-provider-schema.d.ts +15 -0
  48. package/dist/ai/zod-to-provider-schema.d.ts.map +1 -0
  49. package/dist/ai/zod-to-provider-schema.js +200 -0
  50. package/dist/ai/zod-to-provider-schema.js.map +1 -0
  51. package/dist/cli/__tests__/doctor.test.js +104 -2
  52. package/dist/cli/__tests__/doctor.test.js.map +1 -1
  53. package/dist/cli/__tests__/init.test.js +127 -7
  54. package/dist/cli/__tests__/init.test.js.map +1 -1
  55. package/dist/cli/__tests__/migrate.test.js +159 -2
  56. package/dist/cli/__tests__/migrate.test.js.map +1 -1
  57. package/dist/cli/commands/__tests__/commands.test.js +5 -0
  58. package/dist/cli/commands/__tests__/commands.test.js.map +1 -1
  59. package/dist/cli/commands/dev.d.ts.map +1 -1
  60. package/dist/cli/commands/dev.js +21 -1
  61. package/dist/cli/commands/dev.js.map +1 -1
  62. package/dist/cli/commands/doctor.d.ts +2 -0
  63. package/dist/cli/commands/doctor.d.ts.map +1 -1
  64. package/dist/cli/commands/doctor.js +78 -0
  65. package/dist/cli/commands/doctor.js.map +1 -1
  66. package/dist/cli/commands/generate.d.ts.map +1 -1
  67. package/dist/cli/commands/generate.js +7 -2
  68. package/dist/cli/commands/generate.js.map +1 -1
  69. package/dist/cli/commands/init.d.ts +16 -2
  70. package/dist/cli/commands/init.d.ts.map +1 -1
  71. package/dist/cli/commands/init.js +226 -25
  72. package/dist/cli/commands/init.js.map +1 -1
  73. package/dist/cli/commands/migrate.d.ts.map +1 -1
  74. package/dist/cli/commands/migrate.js +258 -8
  75. package/dist/cli/commands/migrate.js.map +1 -1
  76. package/dist/cli/commands/start.d.ts.map +1 -1
  77. package/dist/cli/commands/start.js +21 -1
  78. package/dist/cli/commands/start.js.map +1 -1
  79. package/dist/data/__tests__/drift-inspector.test.d.ts +2 -0
  80. package/dist/data/__tests__/drift-inspector.test.d.ts.map +1 -0
  81. package/dist/data/__tests__/drift-inspector.test.js +170 -0
  82. package/dist/data/__tests__/drift-inspector.test.js.map +1 -0
  83. package/dist/data/__tests__/framework-migration-rewriter.test.d.ts +2 -0
  84. package/dist/data/__tests__/framework-migration-rewriter.test.d.ts.map +1 -0
  85. package/dist/data/__tests__/framework-migration-rewriter.test.js +329 -0
  86. package/dist/data/__tests__/framework-migration-rewriter.test.js.map +1 -0
  87. package/dist/data/__tests__/migration.test.js +93 -2
  88. package/dist/data/__tests__/migration.test.js.map +1 -1
  89. package/dist/data/__tests__/repository.test.js +126 -0
  90. package/dist/data/__tests__/repository.test.js.map +1 -1
  91. package/dist/data/drift-inspector.d.ts +41 -0
  92. package/dist/data/drift-inspector.d.ts.map +1 -0
  93. package/dist/data/drift-inspector.js +197 -0
  94. package/dist/data/drift-inspector.js.map +1 -0
  95. package/dist/data/framework-migration-rewriter.d.ts +61 -0
  96. package/dist/data/framework-migration-rewriter.d.ts.map +1 -0
  97. package/dist/data/framework-migration-rewriter.js +357 -0
  98. package/dist/data/framework-migration-rewriter.js.map +1 -0
  99. package/dist/data/index.d.ts +2 -1
  100. package/dist/data/index.d.ts.map +1 -1
  101. package/dist/data/index.js +2 -1
  102. package/dist/data/index.js.map +1 -1
  103. package/dist/data/migration.d.ts +31 -1
  104. package/dist/data/migration.d.ts.map +1 -1
  105. package/dist/data/migration.js +113 -3
  106. package/dist/data/migration.js.map +1 -1
  107. package/dist/data/repository.d.ts.map +1 -1
  108. package/dist/data/repository.js +21 -0
  109. package/dist/data/repository.js.map +1 -1
  110. package/dist/define/__tests__/definePrompt.test.js +37 -0
  111. package/dist/define/__tests__/definePrompt.test.js.map +1 -1
  112. package/dist/define/definePrompt.d.ts +13 -0
  113. package/dist/define/definePrompt.d.ts.map +1 -1
  114. package/dist/define/definePrompt.js +3 -0
  115. package/dist/define/definePrompt.js.map +1 -1
  116. package/dist/errors/http.d.ts.map +1 -1
  117. package/dist/errors/http.js +2 -0
  118. package/dist/errors/http.js.map +1 -1
  119. package/dist/errors/index.d.ts +12 -0
  120. package/dist/errors/index.d.ts.map +1 -1
  121. package/dist/errors/index.js +21 -0
  122. package/dist/errors/index.js.map +1 -1
  123. package/dist/events/__tests__/event-emitter.test.js +72 -0
  124. package/dist/events/__tests__/event-emitter.test.js.map +1 -1
  125. package/dist/events/emitter.d.ts.map +1 -1
  126. package/dist/events/emitter.js +51 -0
  127. package/dist/events/emitter.js.map +1 -1
  128. package/dist/execution/__tests__/context-factory.test.js +1 -0
  129. package/dist/execution/__tests__/context-factory.test.js.map +1 -1
  130. package/dist/execution/context-factory.d.ts.map +1 -1
  131. package/dist/execution/context-factory.js +2 -0
  132. package/dist/execution/context-factory.js.map +1 -1
  133. package/dist/flows/__tests__/flow-engine.test.js +451 -4
  134. package/dist/flows/__tests__/flow-engine.test.js.map +1 -1
  135. package/dist/flows/__tests__/flow-service.test.js +8 -0
  136. package/dist/flows/__tests__/flow-service.test.js.map +1 -1
  137. package/dist/flows/__tests__/step-executor.test.js +8 -2
  138. package/dist/flows/__tests__/step-executor.test.js.map +1 -1
  139. package/dist/flows/engine.d.ts +50 -1
  140. package/dist/flows/engine.d.ts.map +1 -1
  141. package/dist/flows/engine.js +427 -23
  142. package/dist/flows/engine.js.map +1 -1
  143. package/dist/flows/flow-service.d.ts.map +1 -1
  144. package/dist/flows/flow-service.js +4 -0
  145. package/dist/flows/flow-service.js.map +1 -1
  146. package/dist/flows/index.d.ts +1 -1
  147. package/dist/flows/index.d.ts.map +1 -1
  148. package/dist/flows/index.js +1 -1
  149. package/dist/flows/index.js.map +1 -1
  150. package/dist/flows/schema.d.ts +34 -0
  151. package/dist/flows/schema.d.ts.map +1 -1
  152. package/dist/flows/schema.js +3 -0
  153. package/dist/flows/schema.js.map +1 -1
  154. package/dist/index.d.ts +4 -4
  155. package/dist/index.d.ts.map +1 -1
  156. package/dist/index.js +3 -3
  157. package/dist/index.js.map +1 -1
  158. package/dist/server/bootstrap.d.ts +14 -0
  159. package/dist/server/bootstrap.d.ts.map +1 -1
  160. package/dist/server/bootstrap.js +23 -4
  161. package/dist/server/bootstrap.js.map +1 -1
  162. package/dist/testing/context.d.ts.map +1 -1
  163. package/dist/testing/context.js +37 -1
  164. package/dist/testing/context.js.map +1 -1
  165. package/dist/types/context.d.ts +110 -0
  166. package/dist/types/context.d.ts.map +1 -1
  167. package/dist/types/enums.d.ts +2 -0
  168. package/dist/types/enums.d.ts.map +1 -1
  169. package/dist/types/enums.js +2 -0
  170. package/dist/types/enums.js.map +1 -1
  171. package/dist/types/prompt.d.ts +64 -0
  172. package/dist/types/prompt.d.ts.map +1 -1
  173. package/dist/worker/__tests__/bootstrap.test.js +36 -2
  174. package/dist/worker/__tests__/bootstrap.test.js.map +1 -1
  175. package/dist/worker/bootstrap.d.ts +7 -0
  176. package/dist/worker/bootstrap.d.ts.map +1 -1
  177. package/dist/worker/bootstrap.js +153 -12
  178. package/dist/worker/bootstrap.js.map +1 -1
  179. package/instructions/framework.md +7 -0
  180. package/instructions/guardrails.md +39 -0
  181. package/instructions/patterns.md +10 -0
  182. package/package.json +3 -2
@@ -3,8 +3,10 @@
3
3
  // Integrates: provider adapter, prompt registry, validation, cost tracking, security, RAG, explainability
4
4
  import { z } from 'zod';
5
5
  import { calculateModelCost } from './model-pricing.js';
6
+ import { AIIncompleteOutputError, AIRefusalError } from './refusal.js';
6
7
  import { checkPromptSecurity } from './security.js';
7
- import { generateWithValidation } from './validation.js';
8
+ import { AIValidationError, generateWithValidation, } from './validation.js';
9
+ import { zodToProviderJsonSchema } from './zod-to-provider-schema.js';
8
10
  /** Convenience: create config from a single provider (backward compat) */
9
11
  export function singleProviderConfig(provider, rest) {
10
12
  return {
@@ -14,7 +16,8 @@ export function singleProviderConfig(provider, rest) {
14
16
  };
15
17
  }
16
18
  export function createAIService(config) {
17
- const { providers, defaultProvider, promptRegistry, costTracker, ragPipeline, explainability, security, } = config;
19
+ const { providers, defaultProvider, promptRegistry, costTracker, ragPipeline, explainability, security, onAICostRecorded, } = config;
20
+ const responseSchemaCache = new WeakMap();
18
21
  function resolveProvider(providerName) {
19
22
  const name = providerName ?? defaultProvider;
20
23
  const adapter = providers[name];
@@ -24,6 +27,96 @@ export function createAIService(config) {
24
27
  }
25
28
  return adapter;
26
29
  }
30
+ function getSingleTextFieldName(schema) {
31
+ const shape = schema instanceof z.ZodObject ? schema.shape : undefined;
32
+ if (!shape)
33
+ return undefined;
34
+ const keys = Object.keys(shape);
35
+ const firstKey = keys[0];
36
+ if (keys.length === 1 && firstKey && shape[firstKey] instanceof z.ZodString) {
37
+ return firstKey;
38
+ }
39
+ return undefined;
40
+ }
41
+ function getStrictResponseSchema(promptName, promptDef) {
42
+ if (!promptDef)
43
+ return undefined;
44
+ if (!config.enableStrictStructuredOutputs) {
45
+ if (promptDef.requireStrictStructuredOutputs) {
46
+ throw new Error(`Prompt "${promptName}" requires provider-side structured outputs, but enableStrictStructuredOutputs is disabled`);
47
+ }
48
+ return undefined;
49
+ }
50
+ if (promptDef.disableStrictStructuredOutputs) {
51
+ if (promptDef.requireStrictStructuredOutputs) {
52
+ throw new Error(`Prompt "${promptName}" cannot require strict structured outputs while disableStrictStructuredOutputs is set`);
53
+ }
54
+ return undefined;
55
+ }
56
+ if (getSingleTextFieldName(promptDef.output)) {
57
+ if (promptDef.requireStrictStructuredOutputs) {
58
+ throw new Error(`Prompt "${promptName}" requires provider-side structured outputs, but single-string output schemas use text mode`);
59
+ }
60
+ return undefined;
61
+ }
62
+ const cached = responseSchemaCache.get(promptDef.output);
63
+ if (cached)
64
+ return cached;
65
+ const converted = zodToProviderJsonSchema(promptDef.output, { promptName });
66
+ responseSchemaCache.set(promptDef.output, converted.schema);
67
+ return converted.schema;
68
+ }
69
+ function getTypedAIErrorStatus(err) {
70
+ if (err instanceof AIRefusalError)
71
+ return 'refused';
72
+ if (err instanceof AIIncompleteOutputError)
73
+ return 'incomplete';
74
+ return 'failed';
75
+ }
76
+ function getTypedAIErrorUsage(err) {
77
+ if (err instanceof AIRefusalError || err instanceof AIIncompleteOutputError) {
78
+ return err.usage;
79
+ }
80
+ return undefined;
81
+ }
82
+ /**
83
+ * Centralized cost recorder: updates the in-memory tracker and fires the
84
+ * optional `onAICostRecorded` hook. Hook errors are swallowed so a
85
+ * misbehaving hook can never break the AI call. The `status` default and
86
+ * id/timestamp generation mirror what `createCostTracker` does internally
87
+ * so the hook gets a fully-formed `AICostRecord`.
88
+ */
89
+ async function recordCost(entry, costContext) {
90
+ if (costTracker) {
91
+ costTracker.record(entry);
92
+ }
93
+ if (onAICostRecorded) {
94
+ const finalEntry = {
95
+ ...entry,
96
+ status: entry.status ?? 'success',
97
+ id: crypto.randomUUID(),
98
+ timestamp: new Date(),
99
+ };
100
+ try {
101
+ await onAICostRecorded(finalEntry, costContext);
102
+ }
103
+ catch (hookErr) {
104
+ const msg = hookErr instanceof Error ? hookErr.message : String(hookErr);
105
+ console.error(`[plumbus:ai] onAICostRecorded hook threw: ${msg}`);
106
+ }
107
+ }
108
+ }
109
+ function sumUsage(a, b) {
110
+ const left = a ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
111
+ const right = b ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
112
+ return {
113
+ inputTokens: left.inputTokens + right.inputTokens,
114
+ outputTokens: left.outputTokens + right.outputTokens,
115
+ totalTokens: left.totalTokens + right.totalTokens,
116
+ cachedInputTokens: (left.cachedInputTokens ?? 0) + (right.cachedInputTokens ?? 0),
117
+ cacheWriteTokens: (left.cacheWriteTokens ?? 0) + (right.cacheWriteTokens ?? 0),
118
+ };
119
+ }
27
120
  function checkBudget(estimatedTokens) {
28
121
  if (!costTracker)
29
122
  return;
@@ -38,32 +131,64 @@ export function createAIService(config) {
38
131
  function buildPromptText(promptName, input) {
39
132
  if (!promptRegistry) {
40
133
  // No registry — treat promptName as raw prompt text
41
- return { text: promptName };
134
+ return { text: promptName, substitutedKeys: new Set() };
42
135
  }
43
136
  const def = promptRegistry.get(promptName);
44
137
  // Validate input against prompt input schema
45
138
  def.input.parse(input);
46
- // Resolve template: replace {{key}} with input values
47
- let text = promptName;
48
- if (def.description) {
49
- text = def.description;
50
- }
51
- // Simple template resolution: replace {{key}} placeholders
52
- for (const [key, value] of Object.entries(input)) {
53
- text = text.replaceAll(`{{${key}}}`, String(value));
54
- }
139
+ // Simple template resolution: replace top-level {{key}} placeholders.
140
+ const substitutedKeys = new Set();
141
+ const renderTemplate = (template) => {
142
+ let text = template;
143
+ for (const [key, value] of Object.entries(input)) {
144
+ const placeholder = `{{${key}}}`;
145
+ if (text.includes(placeholder)) {
146
+ substitutedKeys.add(key);
147
+ text = text.replaceAll(placeholder, String(value));
148
+ }
149
+ }
150
+ return text;
151
+ };
152
+ const system = def.system ? renderTemplate(def.system) : undefined;
153
+ const text = renderTemplate(def.description ?? promptName);
55
154
  // Resolution chain: config/env override → prompt-level → defaults
56
155
  // Look up override by prompt name (dots replaced with underscores for env var matching)
57
156
  const overrideKey = promptName.toLowerCase().replaceAll('.', '_');
58
157
  const override = config.promptOverrides?.[overrideKey] ?? config.promptOverrides?.[promptName];
59
158
  return {
159
+ system,
60
160
  text,
61
161
  model: override?.model ?? def.model?.name ?? config.defaultModel,
62
162
  provider: override?.provider ?? def.model?.provider,
63
163
  temperature: override?.temperature ?? def.model?.temperature,
64
164
  maxTokens: override?.maxTokens ?? def.model?.maxTokens,
165
+ appendUnsubstitutedInput: def.appendUnsubstitutedInput,
166
+ substitutedKeys,
65
167
  };
66
168
  }
169
+ /**
170
+ * Builds the final prompt string the provider sees, given a rendered
171
+ * `promptInfo` (with placeholders already substituted) and the original
172
+ * input object. Any keys that were inlined as `{{key}}` in the description
173
+ * are stripped from the appended `Input: ...` JSON blob to prevent the
174
+ * same value from being sent twice in one HTTP body.
175
+ */
176
+ function buildBasePrompt(promptInfo, inputForAI) {
177
+ if (promptInfo.appendUnsubstitutedInput === false) {
178
+ return promptInfo.text;
179
+ }
180
+ const substituted = promptInfo.substitutedKeys ?? new Set();
181
+ const remaining = {};
182
+ for (const [key, value] of Object.entries(inputForAI)) {
183
+ if (!substituted.has(key)) {
184
+ remaining[key] = value;
185
+ }
186
+ }
187
+ if (Object.keys(remaining).length === 0) {
188
+ return promptInfo.text;
189
+ }
190
+ return `${promptInfo.text}\n\nInput: ${JSON.stringify(remaining)}`;
191
+ }
67
192
  // ── Shared generate implementation (returns data + usage) ──
68
193
  async function _generateCore(params) {
69
194
  const start = performance.now();
@@ -76,51 +201,100 @@ export function createAIService(config) {
76
201
  const hasPromptDef = promptRegistry?.has(params.prompt);
77
202
  const promptInfo = hasPromptDef
78
203
  ? buildPromptText(params.prompt, inputForAI)
79
- : { text: params.prompt };
204
+ : { text: params.prompt, substitutedKeys: new Set() };
80
205
  // Resolve provider: prompt-level > default
81
206
  const activeProvider = resolveProvider('provider' in promptInfo ? promptInfo.provider : undefined);
82
207
  // Check if we have a schema to validate against
83
208
  const promptDef = hasPromptDef ? promptRegistry?.get(params.prompt) : undefined;
209
+ const singleTextField = promptDef ? getSingleTextFieldName(promptDef.output) : undefined;
210
+ const responseSchema = getStrictResponseSchema(params.prompt, promptDef);
211
+ const basePrompt = buildBasePrompt(promptInfo, inputForAI);
212
+ const useMultiTurn = Boolean(params.messages && params.messages.length > 0);
213
+ const mergedSystem = useMultiTurn
214
+ ? [promptInfo.system, basePrompt].filter((s) => s && String(s).trim().length > 0).join('\n\n')
215
+ : promptInfo.system;
84
216
  const request = {
85
- prompt: Object.keys(inputForAI).length > 0
86
- ? `${promptInfo.text}\n\nInput: ${JSON.stringify(inputForAI)}`
87
- : promptInfo.text,
217
+ system: mergedSystem || undefined,
218
+ prompt: useMultiTurn ? '' : basePrompt,
219
+ messages: params.messages,
88
220
  model: promptInfo.model ?? config.defaultModel,
89
221
  temperature: promptInfo.temperature,
90
222
  maxTokens: promptInfo.maxTokens,
223
+ responseFormat: promptDef && !singleTextField ? 'json' : undefined,
224
+ responseSchema,
225
+ structuredOutputTransport: promptDef?.structuredOutputTransport,
226
+ signal: params.signal,
227
+ seed: params.seed,
91
228
  };
229
+ const resolvedModel = promptInfo.model ?? config.defaultModel ?? activeProvider.name;
92
230
  let result;
93
231
  let totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
94
232
  let validationAttempts = 1;
95
233
  const validationPassed = true;
96
- if (promptDef) {
97
- // Use validated generation
98
- const validated = await generateWithValidation(activeProvider, request, promptDef.output, config.validation);
99
- result = validated.data;
100
- totalUsage = validated.usage;
101
- validationAttempts = validated.attempts;
102
- }
103
- else {
104
- // Raw generation
105
- const response = await activeProvider.complete(request);
106
- result = response.content;
107
- totalUsage = response.usage;
234
+ try {
235
+ if (promptDef) {
236
+ // Use validated generation
237
+ const validated = await generateWithValidation(activeProvider, request, promptDef.output, {
238
+ ...config.validation,
239
+ ...params.validation,
240
+ model: resolvedModel,
241
+ provider: activeProvider.name,
242
+ });
243
+ result = validated.data;
244
+ totalUsage = validated.usage;
245
+ validationAttempts = validated.attempts;
246
+ }
247
+ else {
248
+ // Raw generation
249
+ const response = await activeProvider.complete(request);
250
+ result = response.content;
251
+ totalUsage = response.usage;
252
+ }
108
253
  }
109
- const latencyMs = performance.now() - start;
110
- // Track cost
111
- if (costTracker) {
112
- costTracker.record({
113
- model: promptInfo.model ?? config.defaultModel ?? activeProvider.name,
254
+ catch (err) {
255
+ // Failure-path cost recording: capture whatever usage we know about
256
+ // (from AIValidationError or zero for opaque fetch errors) so the
257
+ // sunk provider-side spend is still visible to budgets and ledgers.
258
+ const latencyMs = performance.now() - start;
259
+ const failureUsage = err instanceof AIValidationError
260
+ ? err.usage
261
+ : (getTypedAIErrorUsage(err) ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
262
+ const errorMessage = err instanceof Error ? err.message : String(err);
263
+ const failureCost = failureUsage.totalTokens > 0
264
+ ? calculateModelCost(failureUsage.inputTokens, failureUsage.outputTokens, resolvedModel, {
265
+ cachedInputTokens: failureUsage.cachedInputTokens,
266
+ cacheWriteTokens: failureUsage.cacheWriteTokens,
267
+ })
268
+ : null;
269
+ await recordCost({
270
+ model: resolvedModel,
114
271
  provider: activeProvider.name,
115
272
  promptName: hasPromptDef ? params.prompt : undefined,
116
273
  operation: 'generate',
117
- usage: totalUsage,
118
- cost: null,
274
+ usage: failureUsage,
275
+ cost: failureCost,
119
276
  latencyMs,
120
277
  tenantId: config.budget?.tenantId,
121
278
  actor: config.budget?.actor,
122
- });
279
+ status: getTypedAIErrorStatus(err),
280
+ errorMessage,
281
+ }, params.costContext);
282
+ throw err;
123
283
  }
284
+ const latencyMs = performance.now() - start;
285
+ // Track cost (success path)
286
+ await recordCost({
287
+ model: resolvedModel,
288
+ provider: activeProvider.name,
289
+ promptName: hasPromptDef ? params.prompt : undefined,
290
+ operation: 'generate',
291
+ usage: totalUsage,
292
+ cost: null,
293
+ latencyMs,
294
+ tenantId: config.budget?.tenantId,
295
+ actor: config.budget?.actor,
296
+ status: 'success',
297
+ }, params.costContext);
124
298
  // Explainability
125
299
  if (explainability) {
126
300
  explainability.record({
@@ -141,7 +315,6 @@ export function createAIService(config) {
141
315
  latencyMs,
142
316
  });
143
317
  }
144
- const resolvedModel = promptInfo.model ?? config.defaultModel ?? activeProvider.name;
145
318
  const cost = calculateModelCost(totalUsage.inputTokens, totalUsage.outputTokens, resolvedModel, {
146
319
  cachedInputTokens: totalUsage.cachedInputTokens,
147
320
  cacheWriteTokens: totalUsage.cacheWriteTokens,
@@ -172,109 +345,310 @@ export function createAIService(config) {
172
345
  const hasPromptDef = promptRegistry?.has(params.prompt);
173
346
  const promptInfo = hasPromptDef
174
347
  ? buildPromptText(params.prompt, inputForAI)
175
- : { text: params.prompt };
348
+ : { text: params.prompt, substitutedKeys: new Set() };
176
349
  // Resolve provider
177
350
  const activeProvider = resolveProvider('provider' in promptInfo ? promptInfo.provider : undefined);
178
351
  // Detect if the output schema is a simple single-string-field object
179
352
  // (e.g. z.object({ question: z.string() })). If so, stream plain text
180
353
  // so the user sees readable words instead of JSON tokens.
181
354
  const promptDef = hasPromptDef ? promptRegistry?.get(params.prompt) : undefined;
182
- let singleTextField;
183
- if (promptDef) {
184
- const shape = promptDef.output instanceof z.ZodObject ? promptDef.output.shape : undefined;
185
- if (shape) {
186
- const keys = Object.keys(shape);
187
- const firstKey = keys[0];
188
- if (keys.length === 1 && firstKey && shape[firstKey] instanceof z.ZodString) {
189
- singleTextField = firstKey;
190
- }
191
- }
192
- }
193
- const basePrompt = Object.keys(inputForAI).length > 0
194
- ? `${promptInfo.text}\n\nInput: ${JSON.stringify(inputForAI)}`
195
- : promptInfo.text;
196
- const request = {
197
- prompt: singleTextField
198
- ? `${basePrompt}\n\nRespond with ONLY the plain text content. Do NOT wrap your response in JSON or any other format.`
199
- : !basePrompt.toLowerCase().includes('json')
355
+ const singleTextField = promptDef ? getSingleTextFieldName(promptDef.output) : undefined;
356
+ const responseSchema = getStrictResponseSchema(params.prompt, promptDef);
357
+ const basePrompt = buildBasePrompt(promptInfo, inputForAI);
358
+ // The text-mode brevity hint is the right default for Q&A-style prompts
359
+ // (it stops the model from accidentally wrapping a one-line answer in
360
+ // JSON), but some models read "ONLY the plain text content" as a
361
+ // brevity instruction and collapse long-form outputs into bullet
362
+ // points. Prompts can opt out via `disableTextModeBrevityHint: true`
363
+ // when they own their own length contract in the prompt body.
364
+ const skipTextBrevityHint = promptDef?.disableTextModeBrevityHint === true;
365
+ const useMultiTurn = Boolean(params.messages && params.messages.length > 0);
366
+ const promptForProvider = useMultiTurn
367
+ ? ''
368
+ : singleTextField
369
+ ? skipTextBrevityHint
370
+ ? basePrompt
371
+ : `${basePrompt}\n\nRespond with ONLY the plain text content. Do NOT wrap your response in JSON or any other format.`
372
+ : !responseSchema && !basePrompt.toLowerCase().includes('json')
200
373
  ? `${basePrompt}\n\nRespond with a valid JSON object.`
201
- : basePrompt,
374
+ : basePrompt;
375
+ const mergedSystem = useMultiTurn
376
+ ? [promptInfo.system, basePrompt]
377
+ .filter((s) => s && String(s).trim().length > 0)
378
+ .join('\n\n')
379
+ : promptInfo.system;
380
+ const request = {
381
+ system: mergedSystem || undefined,
382
+ prompt: promptForProvider,
383
+ // Native multi-turn: when caller supplies a messages array, providers
384
+ // use it verbatim and ignore `prompt`. Otherwise legacy single-user
385
+ // mode applies.
386
+ messages: params.messages,
202
387
  model: promptInfo.model ?? config.defaultModel,
203
388
  temperature: promptInfo.temperature,
204
389
  maxTokens: promptInfo.maxTokens,
205
390
  responseFormat: singleTextField ? 'text' : 'json',
391
+ responseSchema,
392
+ structuredOutputTransport: promptDef?.structuredOutputTransport,
393
+ signal: params.signal,
394
+ seed: params.seed,
206
395
  };
396
+ const resolvedModel = promptInfo.model ?? config.defaultModel ?? activeProvider.name;
207
397
  // Stream from provider, collecting full text and usage
208
398
  let fullText = '';
209
399
  let streamUsage;
210
- for await (const event of activeProvider.stream(request)) {
211
- if (event.type === 'content_delta' && event.delta) {
212
- fullText += event.delta;
213
- yield { type: 'delta', text: event.delta };
214
- }
215
- else if (event.type === 'usage' && event.usage) {
216
- streamUsage = {
217
- inputTokens: event.usage.inputTokens,
218
- outputTokens: event.usage.outputTokens,
219
- totalTokens: event.usage.totalTokens,
220
- cachedInputTokens: event.usage.cachedInputTokens,
221
- cacheWriteTokens: event.usage.cacheWriteTokens,
222
- };
223
- }
224
- else if (event.type === 'done') {
225
- // Provider-level done (finish_reason) — no action needed
226
- }
227
- else if (event.type === 'error') {
228
- yield { type: 'error', error: event.error };
229
- return;
400
+ let providerFinishReason;
401
+ try {
402
+ for await (const event of activeProvider.stream(request)) {
403
+ if (event.type === 'content_delta' && event.delta) {
404
+ fullText += event.delta;
405
+ yield { type: 'delta', text: event.delta };
406
+ }
407
+ else if (event.type === 'usage' && event.usage) {
408
+ streamUsage = {
409
+ inputTokens: event.usage.inputTokens,
410
+ outputTokens: event.usage.outputTokens,
411
+ totalTokens: event.usage.totalTokens,
412
+ cachedInputTokens: event.usage.cachedInputTokens,
413
+ cacheWriteTokens: event.usage.cacheWriteTokens,
414
+ };
415
+ }
416
+ else if (event.type === 'done') {
417
+ // Capture the provider's finish_reason so downstream callers can
418
+ // distinguish natural completion ('stop') from truncation
419
+ // ('length') — essential when diagnosing short-output compression
420
+ // vs a hit max_tokens ceiling.
421
+ if (event.finishReason) {
422
+ providerFinishReason = event.finishReason;
423
+ }
424
+ }
425
+ else if (event.type === 'error') {
426
+ // Provider-reported mid-stream error — record whatever usage we
427
+ // have so the sunk spend is visible, then yield error to caller.
428
+ const errCost = streamUsage
429
+ ? calculateModelCost(streamUsage.inputTokens, streamUsage.outputTokens, resolvedModel, {
430
+ cachedInputTokens: streamUsage.cachedInputTokens,
431
+ cacheWriteTokens: streamUsage.cacheWriteTokens,
432
+ })
433
+ : null;
434
+ await recordCost({
435
+ model: resolvedModel,
436
+ provider: activeProvider.name,
437
+ promptName: hasPromptDef ? params.prompt : undefined,
438
+ operation: 'generate',
439
+ usage: streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
440
+ cost: errCost,
441
+ latencyMs: 0,
442
+ tenantId: config.budget?.tenantId,
443
+ actor: config.budget?.actor,
444
+ status: 'failed',
445
+ errorMessage: event.error,
446
+ }, params.costContext);
447
+ yield { type: 'error', error: event.error };
448
+ return;
449
+ }
230
450
  }
231
451
  }
452
+ catch (err) {
453
+ // Transport-level failure mid-stream (fetch error, abort, etc.).
454
+ const errUsage = getTypedAIErrorUsage(err) ??
455
+ streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
456
+ const errCost = errUsage.totalTokens > 0
457
+ ? calculateModelCost(errUsage.inputTokens, errUsage.outputTokens, resolvedModel, {
458
+ cachedInputTokens: errUsage.cachedInputTokens,
459
+ cacheWriteTokens: errUsage.cacheWriteTokens,
460
+ })
461
+ : null;
462
+ await recordCost({
463
+ model: resolvedModel,
464
+ provider: activeProvider.name,
465
+ promptName: hasPromptDef ? params.prompt : undefined,
466
+ operation: 'generate',
467
+ usage: errUsage,
468
+ cost: errCost,
469
+ latencyMs: 0,
470
+ tenantId: config.budget?.tenantId,
471
+ actor: config.budget?.actor,
472
+ status: getTypedAIErrorStatus(err),
473
+ errorMessage: err instanceof Error ? err.message : String(err),
474
+ }, params.costContext);
475
+ throw err;
476
+ }
232
477
  // Compute cost and model info for the done event
233
- const resolvedModel = promptInfo.model ?? config.defaultModel ?? activeProvider.name;
234
478
  const streamCost = streamUsage
235
479
  ? calculateModelCost(streamUsage.inputTokens, streamUsage.outputTokens, resolvedModel, {
236
480
  cachedInputTokens: streamUsage.cachedInputTokens,
237
481
  cacheWriteTokens: streamUsage.cacheWriteTokens,
238
482
  })
239
483
  : 0;
240
- // Track cost (same as _generateCore)
241
- if (costTracker && streamUsage) {
242
- costTracker.record({
484
+ const doneBase = {
485
+ usage: streamUsage,
486
+ model: resolvedModel,
487
+ provider: activeProvider.name,
488
+ cost: streamCost,
489
+ finishReason: providerFinishReason,
490
+ };
491
+ if (request.responseSchema &&
492
+ (providerFinishReason === 'length' || providerFinishReason === 'max_tokens')) {
493
+ const failureUsage = streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
494
+ await recordCost({
243
495
  model: resolvedModel,
244
496
  provider: activeProvider.name,
245
497
  promptName: hasPromptDef ? params.prompt : undefined,
246
498
  operation: 'generate',
247
- usage: streamUsage,
499
+ usage: failureUsage,
248
500
  cost: null,
249
501
  latencyMs: 0,
250
502
  tenantId: config.budget?.tenantId,
251
503
  actor: config.budget?.actor,
504
+ status: 'incomplete',
505
+ errorMessage: `stream stopped before completing structured output: ${providerFinishReason}`,
506
+ }, params.costContext);
507
+ throw new AIIncompleteOutputError({
508
+ provider: activeProvider.name,
509
+ model: resolvedModel,
510
+ partialText: fullText,
511
+ usage: failureUsage,
512
+ finishReason: providerFinishReason,
252
513
  });
253
514
  }
254
- const doneBase = {
255
- usage: streamUsage,
256
- model: resolvedModel,
257
- provider: activeProvider.name,
258
- cost: streamCost,
259
- };
260
515
  // Build the final validated result
261
516
  if (singleTextField) {
262
517
  // Plain text mode — wrap the accumulated text in the schema field
518
+ await recordCost({
519
+ model: resolvedModel,
520
+ provider: activeProvider.name,
521
+ promptName: hasPromptDef ? params.prompt : undefined,
522
+ operation: 'generate',
523
+ usage: streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
524
+ cost: null,
525
+ latencyMs: 0,
526
+ tenantId: config.budget?.tenantId,
527
+ actor: config.budget?.actor,
528
+ status: 'success',
529
+ }, params.costContext);
263
530
  yield { type: 'done', data: { [singleTextField]: fullText.trim() }, ...doneBase };
264
531
  }
265
532
  else if (promptDef) {
533
+ let validatedData;
534
+ let validationFellBackToNonStreaming = false;
535
+ let fallbackUsage;
266
536
  try {
267
537
  const parsed = JSON.parse(fullText);
268
- const validated = promptDef.output.parse(parsed);
269
- yield { type: 'done', data: validated, ...doneBase };
538
+ validatedData = promptDef.output.parse(parsed);
270
539
  }
271
- catch {
272
- // Validation failed — fall back to non-streaming retry with JSON mode
273
- const validated = await generateWithValidation(activeProvider, { ...request, responseFormat: 'json' }, promptDef.output, config.validation);
274
- yield { type: 'done', data: validated.data, ...doneBase };
540
+ catch (parseErr) {
541
+ // Streamed output failed local validation. The default behaviour
542
+ // is to fall back to a non-streaming JSON-mode retry — but that
543
+ // silently doubles the input-token cost of large prompts. Prompts
544
+ // that opt out via `skipStreamValidationFallback` get a hard
545
+ // failure instead, so callers can implement their own recovery.
546
+ if (promptDef.skipStreamValidationFallback) {
547
+ const errMsg = parseErr instanceof Error ? parseErr.message : String(parseErr);
548
+ console.error(`[stream:${params.prompt}] validation-fallback SKIPPED by prompt config — stream produced invalid JSON (${errMsg}); throwing. streamedChars=${fullText.length} finishReason=${providerFinishReason ?? 'unknown'}`);
549
+ const failureUsage = streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
550
+ const failureCost = failureUsage.totalTokens > 0
551
+ ? calculateModelCost(failureUsage.inputTokens, failureUsage.outputTokens, resolvedModel, {
552
+ cachedInputTokens: failureUsage.cachedInputTokens,
553
+ cacheWriteTokens: failureUsage.cacheWriteTokens,
554
+ })
555
+ : null;
556
+ await recordCost({
557
+ model: resolvedModel,
558
+ provider: activeProvider.name,
559
+ promptName: hasPromptDef ? params.prompt : undefined,
560
+ operation: 'generate',
561
+ usage: failureUsage,
562
+ cost: failureCost,
563
+ latencyMs: 0,
564
+ tenantId: config.budget?.tenantId,
565
+ actor: config.budget?.actor,
566
+ status: 'failed',
567
+ errorMessage: `stream-validation failed and fallback disabled: ${errMsg}`,
568
+ }, params.costContext);
569
+ throw new Error(`${params.prompt}: streaming JSON validation failed and fallback is disabled — ${errMsg}`);
570
+ }
571
+ // Default path: fall back to a non-streaming JSON-mode retry with
572
+ // full validation+feedback. Hidden cost doubler — log loudly and
573
+ // tag the cost-ledger row so billing can surface how often it
574
+ // fires.
575
+ validationFellBackToNonStreaming = true;
576
+ console.warn(`[stream:${params.prompt}] validation-fallback fired — stream produced invalid JSON (${parseErr instanceof Error ? parseErr.message : String(parseErr)}); replaying non-streaming. streamedChars=${fullText.length} finishReason=${providerFinishReason ?? 'unknown'}`);
577
+ try {
578
+ const validated = await generateWithValidation(activeProvider, { ...request, responseFormat: 'json' }, promptDef.output, { ...config.validation, model: resolvedModel, provider: activeProvider.name });
579
+ validatedData = validated.data;
580
+ fallbackUsage = validated.usage;
581
+ }
582
+ catch (fallbackErr) {
583
+ // Bill the caller for the combined streamUsage + whatever the
584
+ // fallback provider ate before giving up (AIValidationError
585
+ // carries the accumulated retry usage since A1).
586
+ const fallbackErrorUsage = fallbackErr instanceof AIValidationError
587
+ ? fallbackErr.usage
588
+ : (getTypedAIErrorUsage(fallbackErr) ?? {
589
+ inputTokens: 0,
590
+ outputTokens: 0,
591
+ totalTokens: 0,
592
+ });
593
+ const combinedUsage = sumUsage(streamUsage, fallbackErrorUsage);
594
+ const combinedCost = combinedUsage.totalTokens > 0
595
+ ? calculateModelCost(combinedUsage.inputTokens, combinedUsage.outputTokens, resolvedModel, {
596
+ cachedInputTokens: combinedUsage.cachedInputTokens,
597
+ cacheWriteTokens: combinedUsage.cacheWriteTokens,
598
+ })
599
+ : null;
600
+ await recordCost({
601
+ model: resolvedModel,
602
+ provider: activeProvider.name,
603
+ promptName: hasPromptDef ? params.prompt : undefined,
604
+ operation: 'generate',
605
+ usage: combinedUsage,
606
+ cost: combinedCost,
607
+ latencyMs: 0,
608
+ tenantId: config.budget?.tenantId,
609
+ actor: config.budget?.actor,
610
+ status: getTypedAIErrorStatus(fallbackErr),
611
+ errorMessage: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
612
+ }, params.costContext);
613
+ throw fallbackErr;
614
+ }
275
615
  }
616
+ const successUsage = validationFellBackToNonStreaming
617
+ ? sumUsage(streamUsage, fallbackUsage)
618
+ : (streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
619
+ await recordCost({
620
+ model: resolvedModel,
621
+ provider: activeProvider.name,
622
+ promptName: hasPromptDef ? params.prompt : undefined,
623
+ operation: 'generate',
624
+ fallbackUsed: validationFellBackToNonStreaming || undefined,
625
+ usage: successUsage,
626
+ cost: null,
627
+ latencyMs: 0,
628
+ tenantId: config.budget?.tenantId,
629
+ actor: config.budget?.actor,
630
+ status: 'success',
631
+ }, params.costContext);
632
+ yield {
633
+ type: 'done',
634
+ data: validatedData,
635
+ ...doneBase,
636
+ validationFallbackFired: validationFellBackToNonStreaming,
637
+ };
276
638
  }
277
639
  else {
640
+ await recordCost({
641
+ model: resolvedModel,
642
+ provider: activeProvider.name,
643
+ promptName: hasPromptDef ? params.prompt : undefined,
644
+ operation: 'generate',
645
+ usage: streamUsage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
646
+ cost: null,
647
+ latencyMs: 0,
648
+ tenantId: config.budget?.tenantId,
649
+ actor: config.budget?.actor,
650
+ status: 'success',
651
+ }, params.costContext);
278
652
  yield { type: 'done', data: { content: fullText }, ...doneBase };
279
653
  }
280
654
  },
@@ -283,27 +657,57 @@ export function createAIService(config) {
283
657
  checkBudget();
284
658
  // extract uses default provider (no prompt-level routing)
285
659
  const activeProvider = resolveProvider();
660
+ const resolvedModel = config.defaultModel ?? activeProvider.name;
286
661
  const systemPrompt = 'Extract structured data from the following text. Return valid JSON matching the required schema.';
287
662
  const request = {
288
663
  system: systemPrompt,
289
664
  prompt: params.text,
290
665
  model: config.defaultModel,
291
666
  responseFormat: 'json',
667
+ responseSchema: config.enableStrictStructuredOutputs
668
+ ? zodToProviderJsonSchema(params.schema, { promptName: 'extract' }).schema
669
+ : undefined,
670
+ signal: params.signal,
292
671
  };
293
- const validated = await generateWithValidation(activeProvider, request, params.schema, config.validation);
294
- const latencyMs = performance.now() - start;
295
- if (costTracker) {
296
- costTracker.record({
297
- model: config.defaultModel ?? activeProvider.name,
672
+ let validated;
673
+ try {
674
+ validated = (await generateWithValidation(activeProvider, request, params.schema, {
675
+ ...config.validation,
676
+ model: resolvedModel,
677
+ provider: activeProvider.name,
678
+ }));
679
+ }
680
+ catch (err) {
681
+ const latencyMs = performance.now() - start;
682
+ const failureUsage = err instanceof AIValidationError
683
+ ? err.usage
684
+ : (getTypedAIErrorUsage(err) ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
685
+ await recordCost({
686
+ model: resolvedModel,
298
687
  provider: activeProvider.name,
299
688
  operation: 'extract',
300
- usage: validated.usage,
689
+ usage: failureUsage,
301
690
  cost: null,
302
691
  latencyMs,
303
692
  tenantId: config.budget?.tenantId,
304
693
  actor: config.budget?.actor,
305
- });
694
+ status: getTypedAIErrorStatus(err),
695
+ errorMessage: err instanceof Error ? err.message : String(err),
696
+ }, params.costContext);
697
+ throw err;
306
698
  }
699
+ const latencyMs = performance.now() - start;
700
+ await recordCost({
701
+ model: resolvedModel,
702
+ provider: activeProvider.name,
703
+ operation: 'extract',
704
+ usage: validated.usage,
705
+ cost: null,
706
+ latencyMs,
707
+ tenantId: config.budget?.tenantId,
708
+ actor: config.budget?.actor,
709
+ status: 'success',
710
+ }, params.costContext);
307
711
  if (explainability) {
308
712
  explainability.record({
309
713
  operation: 'extract',
@@ -325,30 +729,60 @@ export function createAIService(config) {
325
729
  checkBudget();
326
730
  // classify uses default provider (no prompt-level routing)
327
731
  const activeProvider = resolveProvider();
732
+ const resolvedModel = config.defaultModel ?? activeProvider.name;
328
733
  const systemPrompt = 'Classify the following text into one or more of the provided labels. Return a JSON array of matching label strings.';
329
734
  const request = {
330
735
  system: systemPrompt,
331
736
  prompt: `Labels: ${JSON.stringify(params.labels)}\n\nText: ${params.text}`,
332
737
  model: config.defaultModel,
333
738
  responseFormat: 'json',
739
+ responseSchema: config.enableStrictStructuredOutputs
740
+ ? zodToProviderJsonSchema(z.array(z.string()), { promptName: 'classify' }).schema
741
+ : undefined,
742
+ signal: params.signal,
334
743
  };
335
744
  const schema = z.array(z.string());
336
- const validated = await generateWithValidation(activeProvider, request, schema, config.validation);
337
- const latencyMs = performance.now() - start;
338
- // Filter to only valid labels
339
- const result = validated.data.filter((l) => params.labels.includes(l));
340
- if (costTracker) {
341
- costTracker.record({
342
- model: config.defaultModel ?? activeProvider.name,
745
+ let validated;
746
+ try {
747
+ validated = await generateWithValidation(activeProvider, request, schema, {
748
+ ...config.validation,
749
+ model: resolvedModel,
750
+ provider: activeProvider.name,
751
+ });
752
+ }
753
+ catch (err) {
754
+ const latencyMs = performance.now() - start;
755
+ const failureUsage = err instanceof AIValidationError
756
+ ? err.usage
757
+ : (getTypedAIErrorUsage(err) ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
758
+ await recordCost({
759
+ model: resolvedModel,
343
760
  provider: activeProvider.name,
344
761
  operation: 'classify',
345
- usage: validated.usage,
762
+ usage: failureUsage,
346
763
  cost: null,
347
764
  latencyMs,
348
765
  tenantId: config.budget?.tenantId,
349
766
  actor: config.budget?.actor,
350
- });
767
+ status: getTypedAIErrorStatus(err),
768
+ errorMessage: err instanceof Error ? err.message : String(err),
769
+ }, params.costContext);
770
+ throw err;
351
771
  }
772
+ const latencyMs = performance.now() - start;
773
+ // Filter to only valid labels
774
+ const result = validated.data.filter((l) => params.labels.includes(l));
775
+ await recordCost({
776
+ model: resolvedModel,
777
+ provider: activeProvider.name,
778
+ operation: 'classify',
779
+ usage: validated.usage,
780
+ cost: null,
781
+ latencyMs,
782
+ tenantId: config.budget?.tenantId,
783
+ actor: config.budget?.actor,
784
+ status: 'success',
785
+ }, params.costContext);
352
786
  if (explainability) {
353
787
  explainability.record({
354
788
  operation: 'classify',