@plumbus/core 0.2.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/ai/__tests__/ai-service.test.js +704 -1
- package/dist/ai/__tests__/ai-service.test.js.map +1 -1
- package/dist/ai/__tests__/chunking.test.js +34 -0
- package/dist/ai/__tests__/chunking.test.js.map +1 -1
- package/dist/ai/__tests__/provider.test.d.ts.map +1 -1
- package/dist/ai/__tests__/provider.test.js +425 -0
- package/dist/ai/__tests__/provider.test.js.map +1 -1
- package/dist/ai/__tests__/validation.test.js +200 -1
- package/dist/ai/__tests__/validation.test.js.map +1 -1
- package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts +2 -0
- package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts.map +1 -0
- package/dist/ai/__tests__/zod-to-provider-schema.test.js +60 -0
- package/dist/ai/__tests__/zod-to-provider-schema.test.js.map +1 -0
- package/dist/ai/ai-service.d.ts +19 -2
- package/dist/ai/ai-service.d.ts.map +1 -1
- package/dist/ai/ai-service.js +544 -110
- package/dist/ai/ai-service.js.map +1 -1
- package/dist/ai/cost-tracker.d.ts +26 -1
- package/dist/ai/cost-tracker.d.ts.map +1 -1
- package/dist/ai/cost-tracker.js +1 -0
- package/dist/ai/cost-tracker.js.map +1 -1
- package/dist/ai/index.d.ts +5 -3
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +5 -1
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/provider.d.ts +45 -1
- package/dist/ai/provider.d.ts.map +1 -1
- package/dist/ai/provider.js +226 -32
- package/dist/ai/provider.js.map +1 -1
- package/dist/ai/rag/chunking.js +53 -3
- package/dist/ai/rag/chunking.js.map +1 -1
- package/dist/ai/refusal.d.ts +28 -0
- package/dist/ai/refusal.d.ts.map +1 -0
- package/dist/ai/refusal.js +36 -0
- package/dist/ai/refusal.js.map +1 -0
- package/dist/ai/validation.d.ts +40 -8
- package/dist/ai/validation.d.ts.map +1 -1
- package/dist/ai/validation.js +97 -6
- package/dist/ai/validation.js.map +1 -1
- package/dist/ai/zod-to-provider-schema.d.ts +15 -0
- package/dist/ai/zod-to-provider-schema.d.ts.map +1 -0
- package/dist/ai/zod-to-provider-schema.js +200 -0
- package/dist/ai/zod-to-provider-schema.js.map +1 -0
- package/dist/cli/__tests__/doctor.test.js +104 -2
- package/dist/cli/__tests__/doctor.test.js.map +1 -1
- package/dist/cli/__tests__/init.test.js +127 -7
- package/dist/cli/__tests__/init.test.js.map +1 -1
- package/dist/cli/__tests__/migrate.test.js +159 -2
- package/dist/cli/__tests__/migrate.test.js.map +1 -1
- package/dist/cli/commands/__tests__/commands.test.js +5 -0
- package/dist/cli/commands/__tests__/commands.test.js.map +1 -1
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +21 -1
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +78 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +7 -2
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/init.d.ts +16 -2
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +226 -25
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/migrate.d.ts.map +1 -1
- package/dist/cli/commands/migrate.js +258 -8
- package/dist/cli/commands/migrate.js.map +1 -1
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +21 -1
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/data/__tests__/drift-inspector.test.d.ts +2 -0
- package/dist/data/__tests__/drift-inspector.test.d.ts.map +1 -0
- package/dist/data/__tests__/drift-inspector.test.js +170 -0
- package/dist/data/__tests__/drift-inspector.test.js.map +1 -0
- package/dist/data/__tests__/framework-migration-rewriter.test.d.ts +2 -0
- package/dist/data/__tests__/framework-migration-rewriter.test.d.ts.map +1 -0
- package/dist/data/__tests__/framework-migration-rewriter.test.js +329 -0
- package/dist/data/__tests__/framework-migration-rewriter.test.js.map +1 -0
- package/dist/data/__tests__/migration.test.js +93 -2
- package/dist/data/__tests__/migration.test.js.map +1 -1
- package/dist/data/__tests__/repository.test.js +126 -0
- package/dist/data/__tests__/repository.test.js.map +1 -1
- package/dist/data/drift-inspector.d.ts +41 -0
- package/dist/data/drift-inspector.d.ts.map +1 -0
- package/dist/data/drift-inspector.js +197 -0
- package/dist/data/drift-inspector.js.map +1 -0
- package/dist/data/framework-migration-rewriter.d.ts +61 -0
- package/dist/data/framework-migration-rewriter.d.ts.map +1 -0
- package/dist/data/framework-migration-rewriter.js +357 -0
- package/dist/data/framework-migration-rewriter.js.map +1 -0
- package/dist/data/index.d.ts +2 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +2 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/migration.d.ts +31 -1
- package/dist/data/migration.d.ts.map +1 -1
- package/dist/data/migration.js +113 -3
- package/dist/data/migration.js.map +1 -1
- package/dist/data/repository.d.ts.map +1 -1
- package/dist/data/repository.js +21 -0
- package/dist/data/repository.js.map +1 -1
- package/dist/define/__tests__/definePrompt.test.js +37 -0
- package/dist/define/__tests__/definePrompt.test.js.map +1 -1
- package/dist/define/definePrompt.d.ts +13 -0
- package/dist/define/definePrompt.d.ts.map +1 -1
- package/dist/define/definePrompt.js +3 -0
- package/dist/define/definePrompt.js.map +1 -1
- package/dist/errors/http.d.ts.map +1 -1
- package/dist/errors/http.js +2 -0
- package/dist/errors/http.js.map +1 -1
- package/dist/errors/index.d.ts +12 -0
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +21 -0
- package/dist/errors/index.js.map +1 -1
- package/dist/events/__tests__/event-emitter.test.js +72 -0
- package/dist/events/__tests__/event-emitter.test.js.map +1 -1
- package/dist/events/emitter.d.ts.map +1 -1
- package/dist/events/emitter.js +51 -0
- package/dist/events/emitter.js.map +1 -1
- package/dist/execution/__tests__/context-factory.test.js +1 -0
- package/dist/execution/__tests__/context-factory.test.js.map +1 -1
- package/dist/execution/context-factory.d.ts.map +1 -1
- package/dist/execution/context-factory.js +2 -0
- package/dist/execution/context-factory.js.map +1 -1
- package/dist/flows/__tests__/flow-engine.test.js +451 -4
- package/dist/flows/__tests__/flow-engine.test.js.map +1 -1
- package/dist/flows/__tests__/flow-service.test.js +8 -0
- package/dist/flows/__tests__/flow-service.test.js.map +1 -1
- package/dist/flows/__tests__/step-executor.test.js +8 -2
- package/dist/flows/__tests__/step-executor.test.js.map +1 -1
- package/dist/flows/engine.d.ts +50 -1
- package/dist/flows/engine.d.ts.map +1 -1
- package/dist/flows/engine.js +427 -23
- package/dist/flows/engine.js.map +1 -1
- package/dist/flows/flow-service.d.ts.map +1 -1
- package/dist/flows/flow-service.js +4 -0
- package/dist/flows/flow-service.js.map +1 -1
- package/dist/flows/index.d.ts +1 -1
- package/dist/flows/index.d.ts.map +1 -1
- package/dist/flows/index.js +1 -1
- package/dist/flows/index.js.map +1 -1
- package/dist/flows/schema.d.ts +34 -0
- package/dist/flows/schema.d.ts.map +1 -1
- package/dist/flows/schema.js +3 -0
- package/dist/flows/schema.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/server/bootstrap.d.ts +14 -0
- package/dist/server/bootstrap.d.ts.map +1 -1
- package/dist/server/bootstrap.js +23 -4
- package/dist/server/bootstrap.js.map +1 -1
- package/dist/testing/context.d.ts.map +1 -1
- package/dist/testing/context.js +37 -1
- package/dist/testing/context.js.map +1 -1
- package/dist/types/context.d.ts +110 -0
- package/dist/types/context.d.ts.map +1 -1
- package/dist/types/enums.d.ts +2 -0
- package/dist/types/enums.d.ts.map +1 -1
- package/dist/types/enums.js +2 -0
- package/dist/types/enums.js.map +1 -1
- package/dist/types/prompt.d.ts +64 -0
- package/dist/types/prompt.d.ts.map +1 -1
- package/dist/worker/__tests__/bootstrap.test.js +36 -2
- package/dist/worker/__tests__/bootstrap.test.js.map +1 -1
- package/dist/worker/bootstrap.d.ts +7 -0
- package/dist/worker/bootstrap.d.ts.map +1 -1
- package/dist/worker/bootstrap.js +153 -12
- package/dist/worker/bootstrap.js.map +1 -1
- package/instructions/framework.md +7 -0
- package/instructions/guardrails.md +39 -0
- package/instructions/patterns.md +10 -0
- package/package.json +3 -2
package/dist/ai/ai-service.js
CHANGED
|
@@ -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
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
text =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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:
|
|
118
|
-
cost:
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
?
|
|
195
|
-
:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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:
|
|
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
|
-
|
|
269
|
-
yield { type: 'done', data: validated, ...doneBase };
|
|
538
|
+
validatedData = promptDef.output.parse(parsed);
|
|
270
539
|
}
|
|
271
|
-
catch {
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
model:
|
|
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:
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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:
|
|
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',
|