@plumbus/core 0.2.8 → 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 +482 -4
- package/dist/ai/__tests__/provider.test.js.map +1 -1
- package/dist/ai/__tests__/validation.test.js +238 -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 +565 -90
- 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 +59 -1
- package/dist/ai/provider.d.ts.map +1 -1
- package/dist/ai/provider.js +388 -90
- 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 +212 -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/api/__tests__/route-generator.test.js +3 -0
- package/dist/api/__tests__/route-generator.test.js.map +1 -1
- package/dist/api/route-generator.d.ts +4 -0
- package/dist/api/route-generator.d.ts.map +1 -1
- package/dist/api/route-generator.js +1 -0
- package/dist/api/route-generator.js.map +1 -1
- 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 +22 -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 +8 -3
- 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 +22 -1
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +3 -0
- package/dist/config/loader.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.d.ts +2 -0
- package/dist/data/__tests__/migration.test.d.ts.map +1 -0
- package/dist/data/__tests__/migration.test.js +143 -0
- package/dist/data/__tests__/migration.test.js.map +1 -0
- 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 +127 -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/__tests__/http.test.js +3 -0
- package/dist/errors/__tests__/http.test.js.map +1 -1
- package/dist/errors/http.d.ts.map +1 -1
- package/dist/errors/http.js +14 -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__/capability-executor.test.js +28 -0
- package/dist/execution/__tests__/capability-executor.test.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/capability-executor.d.ts.map +1 -1
- package/dist/execution/capability-executor.js +21 -0
- package/dist/execution/capability-executor.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 +535 -3
- 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 +14 -4
- package/dist/flows/__tests__/step-executor.test.js.map +1 -1
- package/dist/flows/engine.d.ts +53 -1
- package/dist/flows/engine.d.ts.map +1 -1
- package/dist/flows/engine.js +462 -25
- 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/flows/step-executor.d.ts +2 -0
- package/dist/flows/step-executor.d.ts.map +1 -1
- package/dist/flows/step-executor.js +6 -6
- package/dist/flows/step-executor.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/__tests__/bootstrap.test.js +37 -0
- package/dist/server/__tests__/bootstrap.test.js.map +1 -1
- package/dist/server/bootstrap.d.ts +19 -0
- package/dist/server/bootstrap.d.ts.map +1 -1
- package/dist/server/bootstrap.js +48 -4
- package/dist/server/bootstrap.js.map +1 -1
- package/dist/testing/__tests__/simulate-flow.test.js +24 -2
- package/dist/testing/__tests__/simulate-flow.test.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/testing/simulate-flow.d.ts.map +1 -1
- package/dist/testing/simulate-flow.js +25 -1
- package/dist/testing/simulate-flow.js.map +1 -1
- package/dist/types/context.d.ts +118 -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 +164 -14
- 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,69 +345,311 @@ 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
|
};
|
|
207
|
-
|
|
396
|
+
const resolvedModel = promptInfo.model ?? config.defaultModel ?? activeProvider.name;
|
|
397
|
+
// Stream from provider, collecting full text and usage
|
|
208
398
|
let fullText = '';
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
399
|
+
let streamUsage;
|
|
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
|
+
}
|
|
217
450
|
}
|
|
218
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
|
+
}
|
|
477
|
+
// Compute cost and model info for the done event
|
|
478
|
+
const streamCost = streamUsage
|
|
479
|
+
? calculateModelCost(streamUsage.inputTokens, streamUsage.outputTokens, resolvedModel, {
|
|
480
|
+
cachedInputTokens: streamUsage.cachedInputTokens,
|
|
481
|
+
cacheWriteTokens: streamUsage.cacheWriteTokens,
|
|
482
|
+
})
|
|
483
|
+
: 0;
|
|
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({
|
|
495
|
+
model: resolvedModel,
|
|
496
|
+
provider: activeProvider.name,
|
|
497
|
+
promptName: hasPromptDef ? params.prompt : undefined,
|
|
498
|
+
operation: 'generate',
|
|
499
|
+
usage: failureUsage,
|
|
500
|
+
cost: null,
|
|
501
|
+
latencyMs: 0,
|
|
502
|
+
tenantId: config.budget?.tenantId,
|
|
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,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
219
515
|
// Build the final validated result
|
|
220
516
|
if (singleTextField) {
|
|
221
517
|
// Plain text mode — wrap the accumulated text in the schema field
|
|
222
|
-
|
|
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);
|
|
530
|
+
yield { type: 'done', data: { [singleTextField]: fullText.trim() }, ...doneBase };
|
|
223
531
|
}
|
|
224
532
|
else if (promptDef) {
|
|
533
|
+
let validatedData;
|
|
534
|
+
let validationFellBackToNonStreaming = false;
|
|
535
|
+
let fallbackUsage;
|
|
225
536
|
try {
|
|
226
537
|
const parsed = JSON.parse(fullText);
|
|
227
|
-
|
|
228
|
-
yield { type: 'done', data: validated };
|
|
538
|
+
validatedData = promptDef.output.parse(parsed);
|
|
229
539
|
}
|
|
230
|
-
catch {
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
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
|
+
}
|
|
234
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
|
+
};
|
|
235
638
|
}
|
|
236
639
|
else {
|
|
237
|
-
|
|
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);
|
|
652
|
+
yield { type: 'done', data: { content: fullText }, ...doneBase };
|
|
238
653
|
}
|
|
239
654
|
},
|
|
240
655
|
async extract(params) {
|
|
@@ -242,27 +657,57 @@ export function createAIService(config) {
|
|
|
242
657
|
checkBudget();
|
|
243
658
|
// extract uses default provider (no prompt-level routing)
|
|
244
659
|
const activeProvider = resolveProvider();
|
|
660
|
+
const resolvedModel = config.defaultModel ?? activeProvider.name;
|
|
245
661
|
const systemPrompt = 'Extract structured data from the following text. Return valid JSON matching the required schema.';
|
|
246
662
|
const request = {
|
|
247
663
|
system: systemPrompt,
|
|
248
664
|
prompt: params.text,
|
|
249
665
|
model: config.defaultModel,
|
|
250
666
|
responseFormat: 'json',
|
|
667
|
+
responseSchema: config.enableStrictStructuredOutputs
|
|
668
|
+
? zodToProviderJsonSchema(params.schema, { promptName: 'extract' }).schema
|
|
669
|
+
: undefined,
|
|
670
|
+
signal: params.signal,
|
|
251
671
|
};
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
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,
|
|
257
687
|
provider: activeProvider.name,
|
|
258
688
|
operation: 'extract',
|
|
259
|
-
usage:
|
|
689
|
+
usage: failureUsage,
|
|
260
690
|
cost: null,
|
|
261
691
|
latencyMs,
|
|
262
692
|
tenantId: config.budget?.tenantId,
|
|
263
693
|
actor: config.budget?.actor,
|
|
264
|
-
|
|
694
|
+
status: getTypedAIErrorStatus(err),
|
|
695
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
696
|
+
}, params.costContext);
|
|
697
|
+
throw err;
|
|
265
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);
|
|
266
711
|
if (explainability) {
|
|
267
712
|
explainability.record({
|
|
268
713
|
operation: 'extract',
|
|
@@ -284,30 +729,60 @@ export function createAIService(config) {
|
|
|
284
729
|
checkBudget();
|
|
285
730
|
// classify uses default provider (no prompt-level routing)
|
|
286
731
|
const activeProvider = resolveProvider();
|
|
732
|
+
const resolvedModel = config.defaultModel ?? activeProvider.name;
|
|
287
733
|
const systemPrompt = 'Classify the following text into one or more of the provided labels. Return a JSON array of matching label strings.';
|
|
288
734
|
const request = {
|
|
289
735
|
system: systemPrompt,
|
|
290
736
|
prompt: `Labels: ${JSON.stringify(params.labels)}\n\nText: ${params.text}`,
|
|
291
737
|
model: config.defaultModel,
|
|
292
738
|
responseFormat: 'json',
|
|
739
|
+
responseSchema: config.enableStrictStructuredOutputs
|
|
740
|
+
? zodToProviderJsonSchema(z.array(z.string()), { promptName: 'classify' }).schema
|
|
741
|
+
: undefined,
|
|
742
|
+
signal: params.signal,
|
|
293
743
|
};
|
|
294
744
|
const schema = z.array(z.string());
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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,
|
|
302
760
|
provider: activeProvider.name,
|
|
303
761
|
operation: 'classify',
|
|
304
|
-
usage:
|
|
762
|
+
usage: failureUsage,
|
|
305
763
|
cost: null,
|
|
306
764
|
latencyMs,
|
|
307
765
|
tenantId: config.budget?.tenantId,
|
|
308
766
|
actor: config.budget?.actor,
|
|
309
|
-
|
|
767
|
+
status: getTypedAIErrorStatus(err),
|
|
768
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
769
|
+
}, params.costContext);
|
|
770
|
+
throw err;
|
|
310
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);
|
|
311
786
|
if (explainability) {
|
|
312
787
|
explainability.record({
|
|
313
788
|
operation: 'classify',
|