@hunterzhu/pulse-runtime 0.1.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/context/builder.d.ts +68 -0
- package/dist/context/builder.js +127 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/merger.d.ts +25 -0
- package/dist/context/merger.js +125 -0
- package/dist/core/actions.d.ts +1 -0
- package/dist/core/actions.js +1 -0
- package/dist/core/errors.d.ts +8 -0
- package/dist/core/errors.js +36 -0
- package/dist/core/events.d.ts +10 -0
- package/dist/core/events.js +24 -0
- package/dist/core/factory.d.ts +35 -0
- package/dist/core/factory.js +27 -0
- package/dist/core/inbox.d.ts +119 -0
- package/dist/core/inbox.js +217 -0
- package/dist/core/mutations.d.ts +80 -0
- package/dist/core/mutations.js +127 -0
- package/dist/core/records.d.ts +1 -0
- package/dist/core/records.js +1 -0
- package/dist/core/types.d.ts +615 -0
- package/dist/core/types.js +109 -0
- package/dist/dependencies/graph.d.ts +25 -0
- package/dist/dependencies/graph.js +92 -0
- package/dist/dependencies/index.d.ts +1 -0
- package/dist/dependencies/index.js +1 -0
- package/dist/dsl/context-proxy.d.ts +20 -0
- package/dist/dsl/context-proxy.js +64 -0
- package/dist/dsl/index.d.ts +4 -0
- package/dist/dsl/index.js +4 -0
- package/dist/dsl/program.d.ts +314 -0
- package/dist/dsl/program.js +756 -0
- package/dist/dsl/session.d.ts +45 -0
- package/dist/dsl/session.js +93 -0
- package/dist/dsl/templates-index.d.ts +1 -0
- package/dist/dsl/templates-index.js +1 -0
- package/dist/dsl/templates.d.ts +85 -0
- package/dist/dsl/templates.js +110 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/lifecycle/scopes.d.ts +38 -0
- package/dist/lifecycle/scopes.js +50 -0
- package/dist/lifecycle/watchdog.d.ts +16 -0
- package/dist/lifecycle/watchdog.js +66 -0
- package/dist/models/actions.d.ts +10 -0
- package/dist/models/actions.js +68 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/models/index.js +2 -0
- package/dist/models/router.d.ts +187 -0
- package/dist/models/router.js +353 -0
- package/dist/scheduler/clock.d.ts +45 -0
- package/dist/scheduler/clock.js +92 -0
- package/dist/scheduler/decision.d.ts +72 -0
- package/dist/scheduler/decision.js +63 -0
- package/dist/scheduler/index.d.ts +6 -0
- package/dist/scheduler/index.js +6 -0
- package/dist/scheduler/locks.d.ts +18 -0
- package/dist/scheduler/locks.js +106 -0
- package/dist/scheduler/ready-queue.d.ts +32 -0
- package/dist/scheduler/ready-queue.js +40 -0
- package/dist/scheduler/runtime.d.ts +486 -0
- package/dist/scheduler/runtime.js +3445 -0
- package/dist/scheduler/telemetry.d.ts +111 -0
- package/dist/scheduler/telemetry.js +177 -0
- package/dist/scheduler/worker.d.ts +158 -0
- package/dist/scheduler/worker.js +744 -0
- package/dist/storage/artifacts.d.ts +17 -0
- package/dist/storage/artifacts.js +90 -0
- package/dist/storage/findings.d.ts +12 -0
- package/dist/storage/findings.js +70 -0
- package/dist/storage/index.d.ts +8 -0
- package/dist/storage/index.js +8 -0
- package/dist/storage/memory.d.ts +11 -0
- package/dist/storage/memory.js +21 -0
- package/dist/storage/mutation-log.d.ts +41 -0
- package/dist/storage/mutation-log.js +140 -0
- package/dist/storage/outbox.d.ts +30 -0
- package/dist/storage/outbox.js +59 -0
- package/dist/storage/persistence.d.ts +183 -0
- package/dist/storage/persistence.js +999 -0
- package/dist/storage/policy.d.ts +80 -0
- package/dist/storage/policy.js +268 -0
- package/dist/storage/session.d.ts +140 -0
- package/dist/storage/session.js +447 -0
- package/dist/tools/registry.d.ts +125 -0
- package/dist/tools/registry.js +308 -0
- package/dist/transitions/index.d.ts +2 -0
- package/dist/transitions/index.js +1 -0
- package/dist/transitions/validate.d.ts +4 -0
- package/dist/transitions/validate.js +1118 -0
- package/package.json +21 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { JsonValue, LLMRequestProjection, PrivacyLabel, ProvenanceRef } from '../core/types.js';
|
|
2
|
+
export type ReasoningLevel = 'low' | 'medium' | 'high';
|
|
3
|
+
export interface ModelCapabilities {
|
|
4
|
+
toolCalling?: boolean;
|
|
5
|
+
structuredOutput?: boolean;
|
|
6
|
+
reasoning?: ReasoningLevel;
|
|
7
|
+
maxContextTokens: number;
|
|
8
|
+
maxOutputTokens?: number;
|
|
9
|
+
local?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface ModelRouteRequirements extends Partial<ModelCapabilities> {
|
|
12
|
+
contextSize?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface ModelUsage {
|
|
15
|
+
inputTokens?: number;
|
|
16
|
+
outputTokens?: number;
|
|
17
|
+
cachedInputTokens?: number;
|
|
18
|
+
uncachedInputTokens?: number;
|
|
19
|
+
latencyMs?: number;
|
|
20
|
+
cost?: {
|
|
21
|
+
amount: number;
|
|
22
|
+
currency: string;
|
|
23
|
+
source: 'reported' | 'estimated';
|
|
24
|
+
pricingVersion?: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface ModelAdapter {
|
|
28
|
+
executeAttempt(params: {
|
|
29
|
+
request: LLMRequestProjection;
|
|
30
|
+
signal: AbortSignal;
|
|
31
|
+
onObservation?: (chunk: string) => void;
|
|
32
|
+
model?: string;
|
|
33
|
+
outputSchema?: JsonValue;
|
|
34
|
+
maxOutputTokens?: number;
|
|
35
|
+
}): Promise<LLMResult>;
|
|
36
|
+
}
|
|
37
|
+
export interface ModelCandidate {
|
|
38
|
+
id: string;
|
|
39
|
+
providerId: string;
|
|
40
|
+
tasks: string[];
|
|
41
|
+
capabilities: ModelCapabilities;
|
|
42
|
+
priority: number;
|
|
43
|
+
adapter?: ModelAdapter;
|
|
44
|
+
}
|
|
45
|
+
export interface ModelRouteDiagnostic {
|
|
46
|
+
id: string;
|
|
47
|
+
providerId: string;
|
|
48
|
+
accepted: boolean;
|
|
49
|
+
reasons: string[];
|
|
50
|
+
}
|
|
51
|
+
export interface ModelRegistry {
|
|
52
|
+
register(candidate: ModelCandidate): void;
|
|
53
|
+
list(): ModelCandidate[];
|
|
54
|
+
}
|
|
55
|
+
export interface ModelRoute {
|
|
56
|
+
task: string;
|
|
57
|
+
candidates: string[];
|
|
58
|
+
}
|
|
59
|
+
export interface ModelHostPolicy {
|
|
60
|
+
allowCloud?: boolean;
|
|
61
|
+
}
|
|
62
|
+
export interface ModelRouteFeedback {
|
|
63
|
+
modelId: string;
|
|
64
|
+
providerId?: string;
|
|
65
|
+
outcome: 'succeeded' | 'failed' | 'refused' | 'schema_rejected';
|
|
66
|
+
quality?: number;
|
|
67
|
+
usage?: ModelUsage;
|
|
68
|
+
}
|
|
69
|
+
export interface AdaptiveRoutePolicy {
|
|
70
|
+
priorityWeight?: number;
|
|
71
|
+
qualityWeight?: number;
|
|
72
|
+
latencyWeight?: number;
|
|
73
|
+
costWeight?: number;
|
|
74
|
+
cacheWeight?: number;
|
|
75
|
+
explorationWeight?: number;
|
|
76
|
+
targetLatencyMs?: number;
|
|
77
|
+
targetCost?: number;
|
|
78
|
+
}
|
|
79
|
+
export interface ModelRouteMetrics {
|
|
80
|
+
attempts: number;
|
|
81
|
+
successes: number;
|
|
82
|
+
failures: number;
|
|
83
|
+
qualityTotal: number;
|
|
84
|
+
latencyTotalMs: number;
|
|
85
|
+
latencySamples: number;
|
|
86
|
+
costTotal: number;
|
|
87
|
+
costSamples: number;
|
|
88
|
+
cachedInputTokens: number;
|
|
89
|
+
inputTokens: number;
|
|
90
|
+
}
|
|
91
|
+
export interface AdaptiveRouteSnapshot {
|
|
92
|
+
schemaVersion: 1;
|
|
93
|
+
metrics: Array<[string, ModelRouteMetrics]>;
|
|
94
|
+
}
|
|
95
|
+
export declare class InMemoryModelRegistry implements ModelRegistry {
|
|
96
|
+
private readonly candidates;
|
|
97
|
+
register(candidate: ModelCandidate): void;
|
|
98
|
+
list(): ModelCandidate[];
|
|
99
|
+
}
|
|
100
|
+
/** Conservative admission estimate used before a provider attempt is started. */
|
|
101
|
+
export declare function estimateProjectionTokens(projection: LLMRequestProjection): number;
|
|
102
|
+
export declare class ModelRouter {
|
|
103
|
+
readonly registry: ModelRegistry;
|
|
104
|
+
private readonly routes;
|
|
105
|
+
readonly hostPolicy: Required<ModelHostPolicy>;
|
|
106
|
+
constructor(registry: ModelRegistry, hostPolicy?: ModelHostPolicy);
|
|
107
|
+
register(route: ModelRoute): void;
|
|
108
|
+
route(task: string, privacy: PrivacyLabel, requirements?: ModelRouteRequirements): ModelCandidate[];
|
|
109
|
+
routeProjection(task: string, projection: LLMRequestProjection, requirements?: ModelRouteRequirements): ModelCandidate[];
|
|
110
|
+
recordFeedback(_feedback: ModelRouteFeedback): void;
|
|
111
|
+
protected rankCandidates(candidates: ModelCandidate[], preferredOrder?: string[]): ModelCandidate[];
|
|
112
|
+
private candidates;
|
|
113
|
+
diagnostics(task: string, privacy: PrivacyLabel, requirements?: ModelRouteRequirements, estimatedTokens?: number): ModelRouteDiagnostic[];
|
|
114
|
+
}
|
|
115
|
+
/** Deterministic feedback-driven routing. It only affects future candidate ordering. */
|
|
116
|
+
export declare class AdaptiveModelRouter extends ModelRouter {
|
|
117
|
+
private readonly feedback;
|
|
118
|
+
private readonly policy;
|
|
119
|
+
constructor(registry: ModelRegistry, policy?: AdaptiveRoutePolicy, hostPolicy?: ModelHostPolicy);
|
|
120
|
+
static fromSnapshot(registry: ModelRegistry, snapshot: AdaptiveRouteSnapshot, policy?: AdaptiveRoutePolicy): AdaptiveModelRouter;
|
|
121
|
+
snapshot(): AdaptiveRouteSnapshot;
|
|
122
|
+
restore(snapshot: AdaptiveRouteSnapshot): void;
|
|
123
|
+
recordFeedback(feedback: ModelRouteFeedback): void;
|
|
124
|
+
metrics(): ReadonlyMap<string, ModelRouteMetrics>;
|
|
125
|
+
protected rankCandidates(candidates: ModelCandidate[], preferredOrder?: string[]): ModelCandidate[];
|
|
126
|
+
}
|
|
127
|
+
export interface LLMResult {
|
|
128
|
+
text: string;
|
|
129
|
+
structured?: unknown;
|
|
130
|
+
refusal?: string;
|
|
131
|
+
toolCalls: Array<{
|
|
132
|
+
toolCallId: string;
|
|
133
|
+
name: string;
|
|
134
|
+
input: unknown;
|
|
135
|
+
}>;
|
|
136
|
+
finishReason: 'stop' | 'tool_calls' | 'length' | 'error' | 'refusal';
|
|
137
|
+
usage?: ModelUsage;
|
|
138
|
+
privacy?: PrivacyLabel;
|
|
139
|
+
derivedFrom?: ProvenanceRef[];
|
|
140
|
+
}
|
|
141
|
+
/** Provider call ids are adapter-local; Runtime owns the stable ToolCall id. */
|
|
142
|
+
export declare function assignRuntimeToolCallIds(result: LLMResult, effectId: string): LLMResult;
|
|
143
|
+
export declare function validateJsonSchema(value: unknown, schema: unknown): boolean;
|
|
144
|
+
export declare function assertCloudAllowed(projection: LLMRequestProjection, candidate: ModelCandidate): void;
|
|
145
|
+
export interface ModelAttemptDescriptor {
|
|
146
|
+
effectId: string;
|
|
147
|
+
attemptId: string;
|
|
148
|
+
attemptNo: number;
|
|
149
|
+
candidate: ModelCandidate;
|
|
150
|
+
}
|
|
151
|
+
export interface ModelFallbackError {
|
|
152
|
+
retryable: boolean;
|
|
153
|
+
localClosed: boolean;
|
|
154
|
+
sideEffectState: 'none' | 'applied' | 'known' | 'unknown';
|
|
155
|
+
reconciled?: boolean;
|
|
156
|
+
cause: unknown;
|
|
157
|
+
}
|
|
158
|
+
export interface ModelFallbackResult {
|
|
159
|
+
result: LLMResult;
|
|
160
|
+
candidate: ModelCandidate;
|
|
161
|
+
attempts: ModelAttemptDescriptor[];
|
|
162
|
+
}
|
|
163
|
+
export declare class ModelFallbackController {
|
|
164
|
+
execute(effectId: string, candidates: ModelCandidate[], run: (attempt: ModelAttemptDescriptor) => Promise<LLMResult>, maxAttempts?: number): Promise<ModelFallbackResult>;
|
|
165
|
+
}
|
|
166
|
+
export declare function modelFallbackError(input: Omit<ModelFallbackError, 'cause'> & {
|
|
167
|
+
cause: unknown;
|
|
168
|
+
}): Error & {
|
|
169
|
+
modelFallback: ModelFallbackError;
|
|
170
|
+
};
|
|
171
|
+
export type OutputValidationLayer = 'adapter' | 'structured' | 'action';
|
|
172
|
+
export declare class OutputValidationError extends Error {
|
|
173
|
+
readonly layer: OutputValidationLayer;
|
|
174
|
+
readonly code: string;
|
|
175
|
+
constructor(layer: OutputValidationLayer, code: string, message: string);
|
|
176
|
+
}
|
|
177
|
+
export declare function validateAdapterResult(result: LLMResult): LLMResult;
|
|
178
|
+
export declare function validateStructuredOutput<T>(result: LLMResult, schema: {
|
|
179
|
+
safeParse(value: unknown): {
|
|
180
|
+
success: true;
|
|
181
|
+
data: T;
|
|
182
|
+
} | {
|
|
183
|
+
success: false;
|
|
184
|
+
error: unknown;
|
|
185
|
+
};
|
|
186
|
+
}): T;
|
|
187
|
+
export declare function validateActionToolCalls(result: LLMResult, allowedTools: ReadonlySet<string>): void;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
function isModelCandidate(value) {
|
|
2
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
3
|
+
return false;
|
|
4
|
+
const candidate = value;
|
|
5
|
+
const capabilities = candidate.capabilities;
|
|
6
|
+
if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities))
|
|
7
|
+
return false;
|
|
8
|
+
const modelCapabilities = capabilities;
|
|
9
|
+
return typeof candidate.id === 'string' && candidate.id.length > 0 && typeof candidate.providerId === 'string' && candidate.providerId.length > 0 && Array.isArray(candidate.tasks) && candidate.tasks.length > 0 && candidate.tasks.every((task) => typeof task === 'string' && task.length > 0) && typeof candidate.priority === 'number' && Number.isFinite(candidate.priority) && Number.isInteger(modelCapabilities.maxContextTokens) && modelCapabilities.maxContextTokens > 0 && (modelCapabilities.maxOutputTokens === undefined || (Number.isInteger(modelCapabilities.maxOutputTokens) && modelCapabilities.maxOutputTokens > 0)) && (modelCapabilities.local === undefined || typeof modelCapabilities.local === 'boolean') && (modelCapabilities.toolCalling === undefined || typeof modelCapabilities.toolCalling === 'boolean') && (modelCapabilities.structuredOutput === undefined || typeof modelCapabilities.structuredOutput === 'boolean') && (modelCapabilities.reasoning === undefined || ['low', 'medium', 'high'].includes(String(modelCapabilities.reasoning))) && (candidate.adapter === undefined || (typeof candidate.adapter === 'object' && candidate.adapter !== null && typeof candidate.adapter.executeAttempt === 'function'));
|
|
10
|
+
}
|
|
11
|
+
export class InMemoryModelRegistry {
|
|
12
|
+
candidates = [];
|
|
13
|
+
register(candidate) {
|
|
14
|
+
if (!isModelCandidate(candidate))
|
|
15
|
+
throw new Error(`INVALID_MODEL_CANDIDATE:${typeof candidate === 'object' && candidate !== null && 'id' in candidate ? String(candidate.id) : ''}`);
|
|
16
|
+
if (this.candidates.some((existing) => existing.id === candidate.id))
|
|
17
|
+
throw new Error(`DUPLICATE_MODEL_CANDIDATE:${candidate.id}`);
|
|
18
|
+
this.candidates.push(candidate);
|
|
19
|
+
}
|
|
20
|
+
list() { return [...this.candidates]; }
|
|
21
|
+
}
|
|
22
|
+
/** Conservative admission estimate used before a provider attempt is started. */
|
|
23
|
+
export function estimateProjectionTokens(projection) {
|
|
24
|
+
return Math.ceil(Buffer.byteLength(JSON.stringify(projection.blocks), 'utf8') / 4);
|
|
25
|
+
}
|
|
26
|
+
export class ModelRouter {
|
|
27
|
+
registry;
|
|
28
|
+
routes = new Map();
|
|
29
|
+
hostPolicy;
|
|
30
|
+
constructor(registry, hostPolicy = {}) {
|
|
31
|
+
this.registry = registry;
|
|
32
|
+
this.hostPolicy = { allowCloud: hostPolicy.allowCloud ?? true };
|
|
33
|
+
}
|
|
34
|
+
register(route) {
|
|
35
|
+
if (!route || typeof route.task !== 'string' || !route.task || !Array.isArray(route.candidates) || route.candidates.length === 0 || route.candidates.some((candidate) => typeof candidate !== 'string' || !candidate) || new Set(route.candidates).size !== route.candidates.length)
|
|
36
|
+
throw new Error('INVALID_MODEL_ROUTE');
|
|
37
|
+
this.routes.set(route.task, [...new Set(route.candidates)]);
|
|
38
|
+
}
|
|
39
|
+
route(task, privacy, requirements = {}) { return this.rankCandidates(this.candidates(task, privacy, requirements), this.routes.get(task)); }
|
|
40
|
+
routeProjection(task, projection, requirements = {}) {
|
|
41
|
+
const estimatedTokens = Math.max(estimateProjectionTokens(projection) + (typeof requirements.maxOutputTokens === 'number' ? requirements.maxOutputTokens : 0), typeof requirements.contextSize === 'number' ? requirements.contextSize : 0);
|
|
42
|
+
return this.rankCandidates(this.candidates(task, projection.privacy, requirements, estimatedTokens), this.routes.get(task));
|
|
43
|
+
}
|
|
44
|
+
recordFeedback(_feedback) { }
|
|
45
|
+
rankCandidates(candidates, preferredOrder) {
|
|
46
|
+
if (preferredOrder !== undefined) {
|
|
47
|
+
const order = new Map(preferredOrder.map((id, index) => [id, index]));
|
|
48
|
+
return candidates.sort((a, b) => (order.get(a.id) ?? Number.POSITIVE_INFINITY) - (order.get(b.id) ?? Number.POSITIVE_INFINITY) || b.priority - a.priority || a.id.localeCompare(b.id));
|
|
49
|
+
}
|
|
50
|
+
return candidates.sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id));
|
|
51
|
+
}
|
|
52
|
+
candidates(task, privacy, requirements, estimatedTokens) {
|
|
53
|
+
const candidates = this.registry.list();
|
|
54
|
+
const allowed = this.routes.get(task);
|
|
55
|
+
const diagnostics = this.diagnostics(task, privacy, requirements, estimatedTokens);
|
|
56
|
+
return diagnostics.filter((item) => item.accepted && (allowed === undefined || allowed.includes(item.id))).map((item) => candidates.find((candidate) => candidate.id === item.id)).filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
diagnostics(task, privacy, requirements = {}, estimatedTokens) {
|
|
59
|
+
const preferred = this.routes.get(task);
|
|
60
|
+
return this.registry.list().map((candidate) => {
|
|
61
|
+
const reasons = [];
|
|
62
|
+
if (preferred !== undefined && !preferred.includes(candidate.id))
|
|
63
|
+
reasons.push('TASK_ROUTE_EXCLUDED');
|
|
64
|
+
if (!candidate.tasks.includes(task))
|
|
65
|
+
reasons.push('TASK_NOT_SUPPORTED');
|
|
66
|
+
if (privacy === 'local_only' && candidate.capabilities.local !== true)
|
|
67
|
+
reasons.push('PRIVACY_CLOUD_BLOCKED');
|
|
68
|
+
else if (!this.hostPolicy.allowCloud && candidate.capabilities.local !== true)
|
|
69
|
+
reasons.push('HOST_CLOUD_BLOCKED');
|
|
70
|
+
for (const [key, value] of Object.entries(requirements)) {
|
|
71
|
+
if (key === 'maxOutputTokens' || key === 'contextSize')
|
|
72
|
+
continue;
|
|
73
|
+
if (key === 'reasoning') {
|
|
74
|
+
const levels = { low: 1, medium: 2, high: 3 };
|
|
75
|
+
const required = value;
|
|
76
|
+
if (candidate.capabilities.reasoning === undefined || levels[candidate.capabilities.reasoning] < levels[required])
|
|
77
|
+
reasons.push('CAPABILITY_MISSING:reasoning');
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (candidate.capabilities[key] !== value)
|
|
81
|
+
reasons.push(`CAPABILITY_MISSING:${key}`);
|
|
82
|
+
}
|
|
83
|
+
if (typeof requirements.maxOutputTokens === 'number' && (candidate.capabilities.maxOutputTokens === undefined || candidate.capabilities.maxOutputTokens < requirements.maxOutputTokens))
|
|
84
|
+
reasons.push('OUTPUT_BUDGET_TOO_SMALL');
|
|
85
|
+
if (typeof requirements.contextSize === 'number' && candidate.capabilities.maxContextTokens < requirements.contextSize)
|
|
86
|
+
reasons.push('CONTEXT_WINDOW_TOO_SMALL');
|
|
87
|
+
if (estimatedTokens !== undefined && candidate.capabilities.maxContextTokens < estimatedTokens)
|
|
88
|
+
reasons.push('CONTEXT_WINDOW_TOO_SMALL');
|
|
89
|
+
return { id: candidate.id, providerId: candidate.providerId, accepted: reasons.length === 0, reasons };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Deterministic feedback-driven routing. It only affects future candidate ordering. */
|
|
94
|
+
export class AdaptiveModelRouter extends ModelRouter {
|
|
95
|
+
feedback = new Map();
|
|
96
|
+
policy;
|
|
97
|
+
constructor(registry, policy = {}, hostPolicy = {}) {
|
|
98
|
+
super(registry, hostPolicy);
|
|
99
|
+
this.policy = {
|
|
100
|
+
priorityWeight: policy.priorityWeight ?? 1,
|
|
101
|
+
qualityWeight: policy.qualityWeight ?? 4,
|
|
102
|
+
latencyWeight: policy.latencyWeight ?? 1,
|
|
103
|
+
costWeight: policy.costWeight ?? 1,
|
|
104
|
+
cacheWeight: policy.cacheWeight ?? 0.5,
|
|
105
|
+
explorationWeight: policy.explorationWeight ?? 0.25,
|
|
106
|
+
targetLatencyMs: policy.targetLatencyMs ?? 1_000,
|
|
107
|
+
targetCost: policy.targetCost ?? 1,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
static fromSnapshot(registry, snapshot, policy = {}) {
|
|
111
|
+
const router = new AdaptiveModelRouter(registry, policy);
|
|
112
|
+
router.restore(snapshot);
|
|
113
|
+
return router;
|
|
114
|
+
}
|
|
115
|
+
snapshot() { return { schemaVersion: 1, metrics: [...this.feedback.entries()].map(([id, metrics]) => [id, { ...metrics }]) }; }
|
|
116
|
+
restore(snapshot) {
|
|
117
|
+
if (snapshot.schemaVersion !== 1 || !Array.isArray(snapshot.metrics))
|
|
118
|
+
throw new Error('INVALID_ADAPTIVE_ROUTE_SNAPSHOT');
|
|
119
|
+
const known = new Set(this.registry.list().map((candidate) => candidate.id));
|
|
120
|
+
this.feedback.clear();
|
|
121
|
+
for (const entry of snapshot.metrics) {
|
|
122
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || !known.has(entry[0]))
|
|
123
|
+
throw new Error('INVALID_ADAPTIVE_ROUTE_SNAPSHOT');
|
|
124
|
+
const metrics = entry[1];
|
|
125
|
+
if (!metrics || !Number.isInteger(metrics.attempts) || metrics.attempts < 0 || !Number.isInteger(metrics.successes) || metrics.successes < 0 || !Number.isInteger(metrics.failures) || metrics.failures < 0 || metrics.successes + metrics.failures > metrics.attempts || !Object.values(metrics).every((value) => typeof value === 'number' && Number.isFinite(value) && value >= 0))
|
|
126
|
+
throw new Error('INVALID_ADAPTIVE_ROUTE_SNAPSHOT');
|
|
127
|
+
this.feedback.set(entry[0], { ...metrics });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
recordFeedback(feedback) {
|
|
131
|
+
if (!this.registry.list().some((candidate) => candidate.id === feedback.modelId))
|
|
132
|
+
return;
|
|
133
|
+
const metrics = this.feedback.get(feedback.modelId) ?? { attempts: 0, successes: 0, failures: 0, qualityTotal: 0, latencyTotalMs: 0, latencySamples: 0, costTotal: 0, costSamples: 0, cachedInputTokens: 0, inputTokens: 0 };
|
|
134
|
+
metrics.attempts++;
|
|
135
|
+
if (feedback.outcome === 'succeeded')
|
|
136
|
+
metrics.successes++;
|
|
137
|
+
else
|
|
138
|
+
metrics.failures++;
|
|
139
|
+
const quality = feedback.quality ?? (feedback.outcome === 'succeeded' ? 1 : 0);
|
|
140
|
+
if (Number.isFinite(quality))
|
|
141
|
+
metrics.qualityTotal += Math.max(0, Math.min(1, quality));
|
|
142
|
+
const latency = feedback.usage?.latencyMs;
|
|
143
|
+
if (latency !== undefined && Number.isFinite(latency) && latency >= 0) {
|
|
144
|
+
metrics.latencyTotalMs += latency;
|
|
145
|
+
metrics.latencySamples++;
|
|
146
|
+
}
|
|
147
|
+
const cost = feedback.usage?.cost?.amount;
|
|
148
|
+
if (cost !== undefined && Number.isFinite(cost) && cost >= 0) {
|
|
149
|
+
metrics.costTotal += cost;
|
|
150
|
+
metrics.costSamples++;
|
|
151
|
+
}
|
|
152
|
+
const inputTokens = feedback.usage?.inputTokens;
|
|
153
|
+
const cachedInputTokens = feedback.usage?.cachedInputTokens;
|
|
154
|
+
if (inputTokens !== undefined && cachedInputTokens !== undefined && Number.isFinite(inputTokens) && Number.isFinite(cachedInputTokens) && inputTokens > 0 && cachedInputTokens >= 0) {
|
|
155
|
+
metrics.inputTokens += inputTokens;
|
|
156
|
+
metrics.cachedInputTokens += Math.min(inputTokens, cachedInputTokens);
|
|
157
|
+
}
|
|
158
|
+
this.feedback.set(feedback.modelId, metrics);
|
|
159
|
+
}
|
|
160
|
+
metrics() { return new Map([...this.feedback.entries()].map(([id, metrics]) => [id, { ...metrics }])); }
|
|
161
|
+
rankCandidates(candidates, preferredOrder) {
|
|
162
|
+
const score = (candidate) => {
|
|
163
|
+
const metrics = this.feedback.get(candidate.id);
|
|
164
|
+
const attempts = metrics?.attempts ?? 0;
|
|
165
|
+
const quality = attempts ? (metrics?.qualityTotal ?? 0) / attempts : 0.5;
|
|
166
|
+
const latency = metrics?.latencySamples ? 1 / (1 + (metrics.latencyTotalMs / metrics.latencySamples) / Math.max(1, this.policy.targetLatencyMs)) : 0.5;
|
|
167
|
+
const cost = metrics?.costSamples ? 1 / (1 + (metrics.costTotal / metrics.costSamples) / Math.max(Number.MIN_VALUE, this.policy.targetCost)) : 0.5;
|
|
168
|
+
const cache = metrics?.inputTokens ? Math.max(0, Math.min(1, (metrics.cachedInputTokens / metrics.inputTokens))) : 0;
|
|
169
|
+
const exploration = 1 / Math.sqrt(attempts + 1);
|
|
170
|
+
return this.policy.priorityWeight * candidate.priority + this.policy.qualityWeight * quality + this.policy.latencyWeight * latency + this.policy.costWeight * cost + this.policy.cacheWeight * cache + this.policy.explorationWeight * exploration;
|
|
171
|
+
};
|
|
172
|
+
if (preferredOrder !== undefined) {
|
|
173
|
+
const order = new Map(preferredOrder.map((id, index) => [id, index]));
|
|
174
|
+
return candidates.sort((a, b) => score(b) - score(a) || (order.get(a.id) ?? Number.POSITIVE_INFINITY) - (order.get(b.id) ?? Number.POSITIVE_INFINITY) || b.priority - a.priority || a.id.localeCompare(b.id));
|
|
175
|
+
}
|
|
176
|
+
return candidates.sort((a, b) => score(b) - score(a) || b.priority - a.priority || a.id.localeCompare(b.id));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Provider call ids are adapter-local; Runtime owns the stable ToolCall id. */
|
|
180
|
+
export function assignRuntimeToolCallIds(result, effectId) {
|
|
181
|
+
return { ...result, toolCalls: result.toolCalls.map((call, index) => ({ ...call, toolCallId: `${effectId}:tool:${index + 1}` })) };
|
|
182
|
+
}
|
|
183
|
+
export function validateJsonSchema(value, schema) {
|
|
184
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema))
|
|
185
|
+
return false;
|
|
186
|
+
const document = schema;
|
|
187
|
+
if (Array.isArray(document.anyOf))
|
|
188
|
+
return document.anyOf.some((candidate) => validateJsonSchema(value, candidate));
|
|
189
|
+
if (Array.isArray(document.oneOf))
|
|
190
|
+
return document.oneOf.filter((candidate) => validateJsonSchema(value, candidate)).length === 1;
|
|
191
|
+
if (Array.isArray(document.allOf) && document.allOf.some((candidate) => !validateJsonSchema(value, candidate)))
|
|
192
|
+
return false;
|
|
193
|
+
if (document.not !== undefined && validateJsonSchema(value, document.not))
|
|
194
|
+
return false;
|
|
195
|
+
if (document.const !== undefined && JSON.stringify(value) !== JSON.stringify(document.const))
|
|
196
|
+
return false;
|
|
197
|
+
if (Array.isArray(document.enum) && !document.enum.some((candidate) => JSON.stringify(value) === JSON.stringify(candidate)))
|
|
198
|
+
return false;
|
|
199
|
+
if (typeof document.type === 'string') {
|
|
200
|
+
const matches = document.type === 'null' ? value === null : document.type === 'boolean' ? typeof value === 'boolean' : document.type === 'number' ? typeof value === 'number' && Number.isFinite(value) : document.type === 'integer' ? typeof value === 'number' && Number.isInteger(value) : document.type === 'string' ? typeof value === 'string' : document.type === 'array' ? Array.isArray(value) : document.type === 'object' ? typeof value === 'object' && value !== null && !Array.isArray(value) : false;
|
|
201
|
+
if (!matches)
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
if (typeof value === 'string') {
|
|
205
|
+
if (typeof document.minLength === 'number' && value.length < document.minLength)
|
|
206
|
+
return false;
|
|
207
|
+
if (typeof document.maxLength === 'number' && value.length > document.maxLength)
|
|
208
|
+
return false;
|
|
209
|
+
if (typeof document.pattern === 'string') {
|
|
210
|
+
try {
|
|
211
|
+
if (!new RegExp(document.pattern).test(value))
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (Array.isArray(value)) {
|
|
220
|
+
if (typeof document.minItems === 'number' && value.length < document.minItems)
|
|
221
|
+
return false;
|
|
222
|
+
if (typeof document.maxItems === 'number' && value.length > document.maxItems)
|
|
223
|
+
return false;
|
|
224
|
+
if (document.uniqueItems === true && new Set(value.map((item) => JSON.stringify(item))).size !== value.length)
|
|
225
|
+
return false;
|
|
226
|
+
if (document.items !== undefined && value.some((item) => !validateJsonSchema(item, document.items)))
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
230
|
+
if (typeof document.minimum === 'number' && value < document.minimum)
|
|
231
|
+
return false;
|
|
232
|
+
if (typeof document.maximum === 'number' && value > document.maximum)
|
|
233
|
+
return false;
|
|
234
|
+
if (typeof document.exclusiveMinimum === 'number' && value <= document.exclusiveMinimum)
|
|
235
|
+
return false;
|
|
236
|
+
if (typeof document.exclusiveMaximum === 'number' && value >= document.exclusiveMaximum)
|
|
237
|
+
return false;
|
|
238
|
+
if (typeof document.multipleOf === 'number' && document.multipleOf > 0 && Math.abs(value / document.multipleOf - Math.round(value / document.multipleOf)) > Number.EPSILON)
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
242
|
+
const object = value;
|
|
243
|
+
if (Array.isArray(document.required) && document.required.some((key) => typeof key !== 'string' || !(key in object)))
|
|
244
|
+
return false;
|
|
245
|
+
if (document.properties && typeof document.properties === 'object' && !Array.isArray(document.properties)) {
|
|
246
|
+
for (const [key, childSchema] of Object.entries(document.properties))
|
|
247
|
+
if (key in object && !validateJsonSchema(object[key], childSchema))
|
|
248
|
+
return false;
|
|
249
|
+
if (document.additionalProperties === false && Object.keys(object).some((key) => !(key in document.properties)))
|
|
250
|
+
return false;
|
|
251
|
+
if (document.additionalProperties && typeof document.additionalProperties === 'object' && Object.keys(object).some((key) => !(key in document.properties) && !validateJsonSchema(object[key], document.additionalProperties)))
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
export function assertCloudAllowed(projection, candidate) {
|
|
258
|
+
if (projection.privacy === 'local_only' && candidate.capabilities.local !== true)
|
|
259
|
+
throw new Error('PRIVACY_CLOUD_BLOCKED');
|
|
260
|
+
}
|
|
261
|
+
export class ModelFallbackController {
|
|
262
|
+
async execute(effectId, candidates, run, maxAttempts = candidates.length) {
|
|
263
|
+
if (candidates.length === 0)
|
|
264
|
+
throw new Error('NO_MODEL_CANDIDATE');
|
|
265
|
+
const attempts = [];
|
|
266
|
+
let lastError;
|
|
267
|
+
for (const [index, candidate] of candidates.slice(0, Math.max(0, maxAttempts)).entries()) {
|
|
268
|
+
if (lastError && (!lastError.retryable || !lastError.localClosed || (lastError.sideEffectState === 'unknown' && !lastError.reconciled) || (lastError.sideEffectState === 'applied' && !lastError.reconciled)))
|
|
269
|
+
break;
|
|
270
|
+
const attempt = { effectId, attemptId: `${effectId}-attempt-${index + 1}`, attemptNo: index + 1, candidate };
|
|
271
|
+
attempts.push(attempt);
|
|
272
|
+
try {
|
|
273
|
+
return { result: await run(attempt), candidate, attempts };
|
|
274
|
+
}
|
|
275
|
+
catch (cause) {
|
|
276
|
+
lastError = toModelFallbackError(cause) ?? { retryable: false, localClosed: false, sideEffectState: 'none', cause };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
throw lastError?.cause ?? new Error('MODEL_FALLBACK_FAILED');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
export function modelFallbackError(input) {
|
|
283
|
+
const error = new Error('MODEL_ATTEMPT_FAILED');
|
|
284
|
+
error.modelFallback = input;
|
|
285
|
+
return error;
|
|
286
|
+
}
|
|
287
|
+
function toModelFallbackError(cause) {
|
|
288
|
+
if (typeof cause !== 'object' || cause === null)
|
|
289
|
+
return undefined;
|
|
290
|
+
const candidate = 'modelFallback' in cause ? cause.modelFallback : cause;
|
|
291
|
+
if (typeof candidate !== 'object' || candidate === null || !('retryable' in candidate) || !('localClosed' in candidate) || !('sideEffectState' in candidate) || !('cause' in candidate))
|
|
292
|
+
return undefined;
|
|
293
|
+
return candidate;
|
|
294
|
+
}
|
|
295
|
+
export class OutputValidationError extends Error {
|
|
296
|
+
layer;
|
|
297
|
+
code;
|
|
298
|
+
constructor(layer, code, message) {
|
|
299
|
+
super(`${code}: ${message}`);
|
|
300
|
+
this.layer = layer;
|
|
301
|
+
this.code = code;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function validNonNegativeMetric(value) { return typeof value === 'number' && Number.isFinite(value) && value >= 0; }
|
|
305
|
+
function validateUsage(usage) {
|
|
306
|
+
if (usage === undefined)
|
|
307
|
+
return;
|
|
308
|
+
if (!usage || typeof usage !== 'object' || Array.isArray(usage))
|
|
309
|
+
throw new OutputValidationError('adapter', 'INVALID_USAGE', 'Provider usage must be an object');
|
|
310
|
+
const value = usage;
|
|
311
|
+
for (const key of ['inputTokens', 'outputTokens', 'cachedInputTokens', 'uncachedInputTokens'])
|
|
312
|
+
if (value[key] !== undefined && (!Number.isInteger(value[key]) || !validNonNegativeMetric(value[key])))
|
|
313
|
+
throw new OutputValidationError('adapter', 'INVALID_USAGE', `Provider usage ${key} must be a non-negative integer`);
|
|
314
|
+
if (value.latencyMs !== undefined && !validNonNegativeMetric(value.latencyMs))
|
|
315
|
+
throw new OutputValidationError('adapter', 'INVALID_USAGE', 'Provider usage latencyMs must be non-negative');
|
|
316
|
+
if (value.inputTokens !== undefined && value.cachedInputTokens !== undefined && value.cachedInputTokens > value.inputTokens)
|
|
317
|
+
throw new OutputValidationError('adapter', 'INVALID_USAGE', 'Cached input tokens cannot exceed input tokens');
|
|
318
|
+
if (value.inputTokens !== undefined && value.uncachedInputTokens !== undefined && value.uncachedInputTokens > value.inputTokens)
|
|
319
|
+
throw new OutputValidationError('adapter', 'INVALID_USAGE', 'Uncached input tokens cannot exceed input tokens');
|
|
320
|
+
if (value.cost !== undefined) {
|
|
321
|
+
if (!value.cost || typeof value.cost !== 'object' || Array.isArray(value.cost))
|
|
322
|
+
throw new OutputValidationError('adapter', 'INVALID_USAGE', 'Provider usage cost must be an object');
|
|
323
|
+
const cost = value.cost;
|
|
324
|
+
if (!validNonNegativeMetric(cost.amount) || typeof cost.currency !== 'string' || cost.currency.length === 0 || !['reported', 'estimated'].includes(String(cost.source)) || (cost.pricingVersion !== undefined && typeof cost.pricingVersion !== 'string'))
|
|
325
|
+
throw new OutputValidationError('adapter', 'INVALID_USAGE', 'Provider usage cost is malformed');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
export function validateAdapterResult(result) {
|
|
329
|
+
if (typeof result.text !== 'string' || !Array.isArray(result.toolCalls) || !['stop', 'tool_calls', 'length', 'error', 'refusal'].includes(result.finishReason))
|
|
330
|
+
throw new OutputValidationError('adapter', 'PROVIDER_RESPONSE_INVALID', 'Provider response is not a normalized LLMResult');
|
|
331
|
+
validateUsage(result.usage);
|
|
332
|
+
if (result.toolCalls.some((call) => typeof call.toolCallId !== 'string' || typeof call.name !== 'string' || call.name.length === 0))
|
|
333
|
+
throw new OutputValidationError('adapter', 'INVALID_TOOL_CALL', 'Normalized tool call is missing a stable id or name');
|
|
334
|
+
if (result.finishReason === 'tool_calls' && result.toolCalls.length === 0)
|
|
335
|
+
throw new OutputValidationError('adapter', 'INVALID_TOOL_CALL_FINISH_REASON', 'tool_calls finish reason requires at least one tool call');
|
|
336
|
+
if (result.finishReason !== 'tool_calls' && result.toolCalls.length > 0)
|
|
337
|
+
throw new OutputValidationError('adapter', 'UNEXPECTED_TOOL_CALL', 'A non-tool finish reason cannot contain tool calls');
|
|
338
|
+
if (result.finishReason === 'refusal' && (!result.refusal || result.refusal.length === 0))
|
|
339
|
+
throw new OutputValidationError('adapter', 'INVALID_REFUSAL', 'A refusal finish reason requires a refusal message');
|
|
340
|
+
if (result.finishReason !== 'refusal' && result.refusal !== undefined)
|
|
341
|
+
throw new OutputValidationError('adapter', 'UNEXPECTED_REFUSAL', 'A non-refusal result cannot contain a refusal message');
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
export function validateStructuredOutput(result, schema) {
|
|
345
|
+
const parsed = schema.safeParse(result.structured ?? result.text);
|
|
346
|
+
if (!parsed.success)
|
|
347
|
+
throw new OutputValidationError('structured', 'STRUCTURED_OUTPUT_REJECTED', 'Structured output did not match the declared schema');
|
|
348
|
+
return parsed.data;
|
|
349
|
+
}
|
|
350
|
+
export function validateActionToolCalls(result, allowedTools) {
|
|
351
|
+
if (result.toolCalls.some((call) => !allowedTools.has(call.name)))
|
|
352
|
+
throw new OutputValidationError('action', 'ACTION_TOOL_NOT_ALLOWED', 'Model requested a tool that is not present in the current tool set');
|
|
353
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface TimerEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
at: number;
|
|
4
|
+
callback: () => void;
|
|
5
|
+
cancelled: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare class TimerWheel {
|
|
8
|
+
private readonly entries;
|
|
9
|
+
private readonly selected;
|
|
10
|
+
private seq;
|
|
11
|
+
schedule(at: number, callback: () => void): string;
|
|
12
|
+
cancel(id: string): void;
|
|
13
|
+
due(now: number, limit?: number): TimerEntry[];
|
|
14
|
+
nextAt(): number | undefined;
|
|
15
|
+
get size(): number;
|
|
16
|
+
}
|
|
17
|
+
export interface RuntimeClock {
|
|
18
|
+
readonly timers: TimerWheel;
|
|
19
|
+
now(): number;
|
|
20
|
+
set(now: number): void;
|
|
21
|
+
advance(ms: number): void;
|
|
22
|
+
schedule(delayMs: number, callback: () => void): string;
|
|
23
|
+
waitUntil?(at: number): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export declare class VirtualClock implements RuntimeClock {
|
|
26
|
+
readonly timers: TimerWheel;
|
|
27
|
+
private current;
|
|
28
|
+
now(): number;
|
|
29
|
+
set(now: number): void;
|
|
30
|
+
advance(ms: number): void;
|
|
31
|
+
schedule(delayMs: number, callback: () => void): string;
|
|
32
|
+
}
|
|
33
|
+
/** Monotonic host clock for production runtimes; timers never fast-forward. */
|
|
34
|
+
export declare class MonotonicClock implements RuntimeClock {
|
|
35
|
+
readonly timers: TimerWheel;
|
|
36
|
+
private readonly startedAt;
|
|
37
|
+
private readonly epoch;
|
|
38
|
+
private current;
|
|
39
|
+
constructor(startAt?: number);
|
|
40
|
+
now(): number;
|
|
41
|
+
set(now: number): void;
|
|
42
|
+
advance(ms: number): void;
|
|
43
|
+
schedule(delayMs: number, callback: () => void): string;
|
|
44
|
+
waitUntil(at: number): Promise<void>;
|
|
45
|
+
}
|