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