@uncoil.dev/sdk 0.0.0-stage → 0.6.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/README.md +216 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +88 -0
- package/dist/doctor.d.ts +33 -0
- package/dist/doctor.js +156 -0
- package/dist/index.d.ts +247 -0
- package/dist/index.js +652 -0
- package/dist/integrations/auto.d.ts +16 -0
- package/dist/integrations/auto.js +54 -0
- package/dist/integrations/langgraph.d.ts +24 -0
- package/dist/integrations/langgraph.js +80 -0
- package/dist/integrations/observation.d.ts +31 -0
- package/dist/integrations/observation.js +108 -0
- package/dist/integrations/providers.d.ts +7 -0
- package/dist/integrations/providers.js +224 -0
- package/dist/integrations/tools.d.ts +10 -0
- package/dist/integrations/tools.js +48 -0
- package/dist/integrations/vercel-ai.d.ts +24 -0
- package/dist/integrations/vercel-ai.js +72 -0
- package/dist/login.d.ts +37 -0
- package/dist/login.js +214 -0
- package/package.json +52 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.maskKey = exports.formatReport = exports.runDoctor = exports.uninit = exports.init = exports.traceTools = exports.uncoilMiddleware = exports.UncoilLangGraphHandler = exports.wrapAnthropic = exports.wrapOpenAI = exports.decision = exports.toolCall = exports.llmCall = exports.Step = exports.UncoilClient = exports.MAX_PAYLOAD_CHARS = exports.UNCOIL_REGION_HOSTS = exports.UncoilError = void 0;
|
|
4
|
+
exports.currentRunId = currentRunId;
|
|
5
|
+
exports.currentTraceContext = currentTraceContext;
|
|
6
|
+
exports.integrationPayload = integrationPayload;
|
|
7
|
+
exports.currentSpanId = currentSpanId;
|
|
8
|
+
exports.buildRedactor = buildRedactor;
|
|
9
|
+
exports.sampleBucket = sampleBucket;
|
|
10
|
+
exports.span = span;
|
|
11
|
+
exports.observe = observe;
|
|
12
|
+
exports.traceAgent = traceAgent;
|
|
13
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
14
|
+
/** A problem the SDK ran into, with a plain-language fix in `hint`. */
|
|
15
|
+
class UncoilError extends Error {
|
|
16
|
+
constructor(code, message, hint, status) {
|
|
17
|
+
super(`${message} ${hint}`);
|
|
18
|
+
this.name = "UncoilError";
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.hint = hint;
|
|
21
|
+
this.status = status;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.UncoilError = UncoilError;
|
|
25
|
+
exports.UNCOIL_REGION_HOSTS = {
|
|
26
|
+
eu: "https://eu.cloud.uncoil.dev",
|
|
27
|
+
us: "https://us.cloud.uncoil.dev",
|
|
28
|
+
};
|
|
29
|
+
function parseRegion(value) {
|
|
30
|
+
const normalized = value?.trim().toLowerCase();
|
|
31
|
+
return normalized === "eu" || normalized === "us" ? normalized : undefined;
|
|
32
|
+
}
|
|
33
|
+
function apiKeyRegion(apiKey) {
|
|
34
|
+
if (/^(uncoil|tracify)_sk_live_eu_/.test(apiKey))
|
|
35
|
+
return "eu";
|
|
36
|
+
if (/^(uncoil|tracify)_sk_live_us_/.test(apiKey))
|
|
37
|
+
return "us";
|
|
38
|
+
if (/^(uncoil|tracify)_sk_live_/.test(apiKey))
|
|
39
|
+
return "eu";
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The current run and step for this async call chain. AsyncLocalStorage keeps concurrent runs apart, so steps
|
|
44
|
+
* from two agents running at once never land in each other's trace.
|
|
45
|
+
*/
|
|
46
|
+
const stepContext = new node_async_hooks_1.AsyncLocalStorage();
|
|
47
|
+
function currentRunId() {
|
|
48
|
+
return stepContext.getStore()?.runId || (process.env.UNCOIL_CURRENT_RUN_ID ?? process.env.TRACIFY_CURRENT_RUN_ID) || 'unknown';
|
|
49
|
+
}
|
|
50
|
+
function currentTraceContext() { return stepContext.getStore(); }
|
|
51
|
+
function integrationPayload(value) { return payload(value); }
|
|
52
|
+
function currentSpanId() {
|
|
53
|
+
return stepContext.getStore()?.spanId;
|
|
54
|
+
}
|
|
55
|
+
exports.MAX_PAYLOAD_CHARS = 10000;
|
|
56
|
+
const SECRET_PATTERNS = [
|
|
57
|
+
/\bbearer\s+[a-z0-9._~+/=-]{12,}/gi,
|
|
58
|
+
/\b(?:sk|pk|rk)[-_](?:live|test|proj|ant)?[-_]?[A-Za-z0-9_-]{16,}/g,
|
|
59
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
|
|
60
|
+
];
|
|
61
|
+
const SECRET_FIELD = /((?:api[_-]?key|password|secret|token)["']?\s*[:=]\s*["']?)[^\s"',}]{6,}/gi;
|
|
62
|
+
/** Serialise an input or output, remove obvious secrets and cap its size. */
|
|
63
|
+
function payload(value) {
|
|
64
|
+
if (value === undefined || value === null)
|
|
65
|
+
return '';
|
|
66
|
+
let text;
|
|
67
|
+
if (typeof value === 'string')
|
|
68
|
+
text = value;
|
|
69
|
+
else {
|
|
70
|
+
try {
|
|
71
|
+
text = JSON.stringify(value);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
text = String(value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const pattern of SECRET_PATTERNS)
|
|
78
|
+
text = text.replace(pattern, '[redacted]');
|
|
79
|
+
text = text.replace(SECRET_FIELD, '$1[redacted]');
|
|
80
|
+
return text.length > exports.MAX_PAYLOAD_CHARS ? `${text.slice(0, exports.MAX_PAYLOAD_CHARS)}… [truncated ${text.length - exports.MAX_PAYLOAD_CHARS} chars]` : text;
|
|
81
|
+
}
|
|
82
|
+
const PII_PATTERNS = [
|
|
83
|
+
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
|
|
84
|
+
/(?<![\w.])\+?\d{1,3}[\s.-]?\(?\d{2,4}\)?[\s.-]\d{3,4}[\s.-]\d{3,4}\b/g,
|
|
85
|
+
];
|
|
86
|
+
const CARD_NUMBER = /\b(?:\d[ -]?){13,19}\b/g;
|
|
87
|
+
/** Card-number check, so long ids and timestamps are left alone. */
|
|
88
|
+
function passesLuhn(digits) {
|
|
89
|
+
let sum = 0;
|
|
90
|
+
for (let i = 0; i < digits.length; i++) {
|
|
91
|
+
let d = Number(digits[digits.length - 1 - i]);
|
|
92
|
+
if (i % 2 === 1) {
|
|
93
|
+
d *= 2;
|
|
94
|
+
if (d > 9)
|
|
95
|
+
d -= 9;
|
|
96
|
+
}
|
|
97
|
+
sum += d;
|
|
98
|
+
}
|
|
99
|
+
return sum % 10 === 0;
|
|
100
|
+
}
|
|
101
|
+
/** Build the clean-up for `redact`. Emails, phone numbers and card numbers are masked unless `pii: false`. */
|
|
102
|
+
function buildRedactor(options) {
|
|
103
|
+
const replacement = options?.replacement ?? "[redacted]";
|
|
104
|
+
const patterns = [];
|
|
105
|
+
const maskPii = options?.pii !== false;
|
|
106
|
+
if (maskPii)
|
|
107
|
+
patterns.push(...PII_PATTERNS);
|
|
108
|
+
if (!options)
|
|
109
|
+
options = {};
|
|
110
|
+
for (const pattern of options.patterns ?? []) {
|
|
111
|
+
if (typeof pattern === "string") {
|
|
112
|
+
if (pattern)
|
|
113
|
+
patterns.push(new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"));
|
|
114
|
+
}
|
|
115
|
+
else
|
|
116
|
+
patterns.push(new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`));
|
|
117
|
+
}
|
|
118
|
+
if (!patterns.length && !maskPii && !options.fn)
|
|
119
|
+
return undefined;
|
|
120
|
+
return (text) => {
|
|
121
|
+
if (maskPii)
|
|
122
|
+
text = text.replace(CARD_NUMBER, (m) => { const d = m.replace(/\D/g, ""); return d.length >= 13 && passesLuhn(d) ? replacement : m; });
|
|
123
|
+
for (const pattern of patterns)
|
|
124
|
+
text = text.replace(pattern, replacement);
|
|
125
|
+
return options.fn ? options.fn(text) : text;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Stable number in [0, 1) for a run id, so every span of a run gets the same sampling decision. */
|
|
129
|
+
function sampleBucket(runId) {
|
|
130
|
+
let hash = 2166136261;
|
|
131
|
+
for (let i = 0; i < runId.length; i++) {
|
|
132
|
+
hash ^= runId.charCodeAt(i);
|
|
133
|
+
hash = Math.imul(hash, 16777619);
|
|
134
|
+
}
|
|
135
|
+
return (hash >>> 0) / 4294967296;
|
|
136
|
+
}
|
|
137
|
+
function createId() {
|
|
138
|
+
if (globalThis.crypto?.randomUUID) {
|
|
139
|
+
return globalThis.crypto.randomUUID();
|
|
140
|
+
}
|
|
141
|
+
return `span_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
142
|
+
}
|
|
143
|
+
function sleep(ms) {
|
|
144
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
145
|
+
}
|
|
146
|
+
function isRetryableError(error, retryableErrors) {
|
|
147
|
+
if (!(error instanceof Error))
|
|
148
|
+
return false;
|
|
149
|
+
if (error.name === "AbortError")
|
|
150
|
+
return false; // AbortError is never retryable
|
|
151
|
+
const msg = error.message.toLowerCase();
|
|
152
|
+
const name = error.name?.toLowerCase?.() ?? "";
|
|
153
|
+
const status = error.status ?? error.statusCode ?? 0;
|
|
154
|
+
for (const code of retryableErrors) {
|
|
155
|
+
switch (code) {
|
|
156
|
+
case "timeout":
|
|
157
|
+
if (msg.includes("timeout") || msg.includes("timed out") || name === "timeouterror")
|
|
158
|
+
return true;
|
|
159
|
+
break;
|
|
160
|
+
case "5xx":
|
|
161
|
+
if (status >= 500 && status < 600)
|
|
162
|
+
return true;
|
|
163
|
+
break;
|
|
164
|
+
case "429":
|
|
165
|
+
if (status === 429 || msg.includes("rate limit") || msg.includes("too many requests"))
|
|
166
|
+
return true;
|
|
167
|
+
break;
|
|
168
|
+
case "rate_limit":
|
|
169
|
+
if (status === 429 || msg.includes("rate limit") || msg.includes("throttl"))
|
|
170
|
+
return true;
|
|
171
|
+
break;
|
|
172
|
+
case "overloaded":
|
|
173
|
+
if (msg.includes("overloaded") || msg.includes("capacity") || msg.includes("busy"))
|
|
174
|
+
return true;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
function isLatencyAbort(error) {
|
|
181
|
+
return error instanceof Error && error.name === "AbortError";
|
|
182
|
+
}
|
|
183
|
+
class UncoilClient {
|
|
184
|
+
constructor(config = {}) {
|
|
185
|
+
this._lastFailOpen = false;
|
|
186
|
+
this.promptCache = new Map();
|
|
187
|
+
this.sampleRate = 1;
|
|
188
|
+
this.keepErrors = true;
|
|
189
|
+
this.warned = new Set();
|
|
190
|
+
this.apiKey = (config.apiKey || (process.env.UNCOIL_API_KEY ?? process.env.TRACIFY_API_KEY) || '').trim();
|
|
191
|
+
this.onError = config.onError;
|
|
192
|
+
if (config.sampleRate !== undefined && !(config.sampleRate >= 0 && config.sampleRate <= 1)) {
|
|
193
|
+
throw new UncoilError("bad_sample_rate", `sampleRate must be a number from 0 to 1, but got ${String(config.sampleRate)}.`, "Use 1 to record every run, 0.1 to record about one in ten.");
|
|
194
|
+
}
|
|
195
|
+
this.sampleRate = config.sampleRate ?? 1;
|
|
196
|
+
this.keepErrors = config.keepErrors ?? true;
|
|
197
|
+
this.redactor = buildRedactor(config.redact);
|
|
198
|
+
if (!this.apiKey) {
|
|
199
|
+
this.report(new UncoilError("missing_key", "No Uncoil API key was found, so nothing will be recorded.", "Pass { apiKey } to the client or set the UNCOIL_API_KEY environment variable. You can create a key on the API keys page of your dashboard."));
|
|
200
|
+
}
|
|
201
|
+
else if (!/^(uncoil|tracify)_sk_/.test(this.apiKey)) {
|
|
202
|
+
this.report(new UncoilError("odd_key", "That API key does not look like an Uncoil key (they start with uncoil_sk_).", "Copy the key again from the API keys page of your dashboard; make sure no other service's key was pasted by mistake."));
|
|
203
|
+
}
|
|
204
|
+
const configuredRegion = config.region ?? parseRegion((process.env.UNCOIL_REGION ?? process.env.TRACIFY_REGION)) ?? "eu";
|
|
205
|
+
this.host = (config.host || (process.env.UNCOIL_HOST ?? process.env.TRACIFY_HOST) || exports.UNCOIL_REGION_HOSTS[configuredRegion]).replace(/\/$/, '');
|
|
206
|
+
const selectedHostRegion = Object.entries(exports.UNCOIL_REGION_HOSTS).find(([, host]) => host === this.host)?.[0];
|
|
207
|
+
const keyRegion = apiKeyRegion(this.apiKey);
|
|
208
|
+
if (keyRegion && selectedHostRegion && keyRegion !== selectedHostRegion) {
|
|
209
|
+
throw new UncoilError("region_mismatch", `Uncoil API key region mismatch: this key belongs to ${keyRegion.toUpperCase()}, but the client is configured for ${selectedHostRegion.toUpperCase()}.`, `Set region: "${keyRegion}" (or UNCOIL_REGION=${keyRegion}), or create a key in the ${selectedHostRegion.toUpperCase()} region.`);
|
|
210
|
+
}
|
|
211
|
+
this.projectId = config.projectId;
|
|
212
|
+
this.release = config.release;
|
|
213
|
+
this.environment = config.environment;
|
|
214
|
+
this.deploymentId = config.deploymentId;
|
|
215
|
+
this.sessionId = config.sessionId;
|
|
216
|
+
this.endUserId = config.endUserId;
|
|
217
|
+
this.tags = config.tags;
|
|
218
|
+
try {
|
|
219
|
+
new URL(this.host);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
throw new UncoilError("bad_host", `The host "${this.host}" is not a valid web address.`, 'Use a full address such as "https://eu.cloud.uncoil.dev", or drop the host option and use region: "eu" or "us".');
|
|
223
|
+
}
|
|
224
|
+
this.ingestUrl = `${this.host}/api/ingest`;
|
|
225
|
+
this.checkCostUrl = `${this.host}/api/orchestration/check-cost`;
|
|
226
|
+
}
|
|
227
|
+
clean(value) {
|
|
228
|
+
const text = payload(value);
|
|
229
|
+
return this.redactor && text ? this.redactor(text) : text;
|
|
230
|
+
}
|
|
231
|
+
/** Tell the app about a problem once per kind: through `onError` if given, otherwise as a console warning. */
|
|
232
|
+
report(error) {
|
|
233
|
+
if (this.warned.has(error.code))
|
|
234
|
+
return;
|
|
235
|
+
this.warned.add(error.code);
|
|
236
|
+
if (this.onError) {
|
|
237
|
+
try {
|
|
238
|
+
this.onError(error);
|
|
239
|
+
}
|
|
240
|
+
catch { /* a faulty handler must never break the app */ }
|
|
241
|
+
}
|
|
242
|
+
else
|
|
243
|
+
console.warn(`Uncoil Warning: ${error.message}`);
|
|
244
|
+
}
|
|
245
|
+
/** True when this span should be dropped by sampling. */
|
|
246
|
+
sampledOut(runId, data) {
|
|
247
|
+
if (this.sampleRate >= 1)
|
|
248
|
+
return false;
|
|
249
|
+
if (this.keepErrors && (data.errorType || data.errorMessage))
|
|
250
|
+
return false;
|
|
251
|
+
return sampleBucket(runId) >= this.sampleRate;
|
|
252
|
+
}
|
|
253
|
+
async checkIngestResponse(response) {
|
|
254
|
+
if (response.ok)
|
|
255
|
+
return;
|
|
256
|
+
const status = response.status;
|
|
257
|
+
if (status === 401)
|
|
258
|
+
this.report(new UncoilError("bad_key", "Uncoil rejected the API key (401).", "The key is wrong, was revoked, or belongs to another region. Check UNCOIL_API_KEY and UNCOIL_REGION.", status));
|
|
259
|
+
else if (status === 403)
|
|
260
|
+
this.report(new UncoilError("forbidden", "This API key is not allowed to send data (403).", "Check that the key belongs to the project you expect and has not been disabled.", status));
|
|
261
|
+
else if (status === 429)
|
|
262
|
+
this.report(new UncoilError("rate_limited", "Uncoil is receiving too much data from this app (429).", "Lower the volume with sampleRate (for example 0.2), or upgrade your plan.", status));
|
|
263
|
+
else if (status >= 500)
|
|
264
|
+
this.report(new UncoilError("server_error", `Uncoil had a problem on its side (${status}).`, "Your app is not affected; data from this moment may be missing. Check the status page if it keeps happening.", status));
|
|
265
|
+
else
|
|
266
|
+
this.report(new UncoilError("rejected", `Uncoil did not accept a span (${status}).`, "Update the SDK to the latest version; if it still happens, contact support.", status));
|
|
267
|
+
}
|
|
268
|
+
async ingest(data) {
|
|
269
|
+
if (!this.apiKey)
|
|
270
|
+
return;
|
|
271
|
+
const context = stepContext.getStore();
|
|
272
|
+
const spanId = data.spanId || createId();
|
|
273
|
+
if (this.sampledOut(data.runId || currentRunId(), data))
|
|
274
|
+
return;
|
|
275
|
+
const parentSpanId = data.parentSpanId || (context?.spanId && context.spanId !== spanId ? context.spanId : undefined);
|
|
276
|
+
const span = {
|
|
277
|
+
...data,
|
|
278
|
+
spanId,
|
|
279
|
+
runId: data.runId || currentRunId(),
|
|
280
|
+
traceName: data.traceName ?? context?.traceName,
|
|
281
|
+
parentSpanId,
|
|
282
|
+
createdAt: data.createdAt || new Date().toISOString(),
|
|
283
|
+
metadata: data.metadata || {},
|
|
284
|
+
input: this.clean(data.input),
|
|
285
|
+
output: this.clean(data.output),
|
|
286
|
+
release: data.release ?? this.release,
|
|
287
|
+
environment: data.environment ?? this.environment,
|
|
288
|
+
deploymentId: data.metadata?.deploymentId ?? this.deploymentId,
|
|
289
|
+
sessionId: data.sessionId ?? this.sessionId,
|
|
290
|
+
endUserId: data.endUserId ?? this.endUserId,
|
|
291
|
+
tags: data.tags ?? this.tags,
|
|
292
|
+
};
|
|
293
|
+
try {
|
|
294
|
+
fetch(this.ingestUrl, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: {
|
|
297
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
298
|
+
'Content-Type': 'application/json',
|
|
299
|
+
},
|
|
300
|
+
body: JSON.stringify(span),
|
|
301
|
+
}).then((response) => this.checkIngestResponse(response)).catch((err) => this.report(new UncoilError("network", `Could not reach Uncoil (${err?.message ?? err}).`, "Check your internet connection and the host setting. Your app keeps running; these spans are dropped.")));
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Ignore errors in telemetry
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** Resolve a prompt version labeled for an environment without redeploying application code. */
|
|
308
|
+
async getPrompt(name, environment = "production", options = {}) {
|
|
309
|
+
if (!this.apiKey)
|
|
310
|
+
throw new Error("An Uncoil API key is required to resolve prompts");
|
|
311
|
+
const key = `${name}:${environment}`;
|
|
312
|
+
const cached = this.promptCache.get(key);
|
|
313
|
+
if (cached && cached.expiresAt > Date.now())
|
|
314
|
+
return cached.value;
|
|
315
|
+
try {
|
|
316
|
+
const response = await fetch(`${this.host}/api/prompts/${encodeURIComponent(name)}?environment=${encodeURIComponent(environment)}`, { headers: { Authorization: `Bearer ${this.apiKey}` } });
|
|
317
|
+
const data = await response.json().catch(() => ({}));
|
|
318
|
+
if (!response.ok)
|
|
319
|
+
throw new Error(data?.error || `Prompt resolution failed (${response.status})`);
|
|
320
|
+
const value = data;
|
|
321
|
+
this.promptCache.set(key, { value, expiresAt: Date.now() + (options.cacheTtlMs ?? 60000) });
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (cached)
|
|
326
|
+
return cached.value;
|
|
327
|
+
if (options.fallback)
|
|
328
|
+
return options.fallback;
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** Attach end-user feedback to a trace or span. The request is fire-and-forget like span ingestion. */
|
|
333
|
+
feedback(traceId, value, options = {}) {
|
|
334
|
+
if (!this.apiKey || !(options.projectId || this.projectId))
|
|
335
|
+
return;
|
|
336
|
+
fetch(`${this.host}/api/feedback`, { method: 'POST', headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: options.projectId || this.projectId, traceId, spanId: options.spanId, kind: options.kind || (typeof value === 'number' ? 'star' : typeof value === 'boolean' ? 'thumb' : 'text'), value, reason: options.reason, comment: options.comment, endUserId: options.endUserId, dedupeKey: options.dedupeKey }) }).catch((error) => console.warn('Uncoil Warning: Failed to record feedback:', error));
|
|
337
|
+
}
|
|
338
|
+
/** Attach a typed score to a trace or span through the feedback API. */
|
|
339
|
+
score(traceId, name, value, options = {}) {
|
|
340
|
+
if (!this.apiKey || !(options.projectId || this.projectId))
|
|
341
|
+
return;
|
|
342
|
+
fetch(`${this.host}/api/feedback`, { method: 'POST', headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: options.projectId || this.projectId, traceId, spanId: options.spanId, kind: 'text', name, value, dataType: options.dataType, comment: options.comment }) }).catch((error) => console.warn('Uncoil Warning: Failed to record score:', error));
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Server-side cost check. Returns the server's decision on whether
|
|
346
|
+
* the next call would exceed the ceiling.
|
|
347
|
+
*/
|
|
348
|
+
async checkCost(runId, incrementUsd) {
|
|
349
|
+
try {
|
|
350
|
+
const res = await fetch(this.checkCostUrl, {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: {
|
|
353
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
354
|
+
'Content-Type': 'application/json',
|
|
355
|
+
},
|
|
356
|
+
body: JSON.stringify({ runId, incrementUsd }),
|
|
357
|
+
});
|
|
358
|
+
if (!res.ok) {
|
|
359
|
+
this._lastFailOpen = true;
|
|
360
|
+
return { allowed: true, reason: "check_failed" };
|
|
361
|
+
}
|
|
362
|
+
this._lastFailOpen = false;
|
|
363
|
+
return await res.json();
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
this._lastFailOpen = true;
|
|
367
|
+
return { allowed: true, reason: "network_error" };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async orchestrate(options) {
|
|
371
|
+
const { policy, call, model, input, calculateCost, runId: explicitRunId } = options;
|
|
372
|
+
const runId = explicitRunId || stepContext.getStore()?.runId || (process.env.UNCOIL_CURRENT_RUN_ID ?? process.env.TRACIFY_CURRENT_RUN_ID) || createId();
|
|
373
|
+
const allModels = [model, ...policy.fallbackChain.filter((m) => m !== model)];
|
|
374
|
+
let lastError = null;
|
|
375
|
+
for (let modelIndex = 0; modelIndex < allModels.length; modelIndex++) {
|
|
376
|
+
const currentModel = allModels[modelIndex];
|
|
377
|
+
const isFallback = modelIndex > 0;
|
|
378
|
+
const fallbackReason = isFallback
|
|
379
|
+
? lastError
|
|
380
|
+
? isLatencyAbort(lastError) ? "latency_budget" : "provider_error"
|
|
381
|
+
: "cost_ceiling"
|
|
382
|
+
: undefined;
|
|
383
|
+
const maxAttempts = isFallback ? 1 : (policy.retryPolicy.maxAttempts + 1);
|
|
384
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
385
|
+
const spanId = createId();
|
|
386
|
+
const startTime = Date.now();
|
|
387
|
+
// Server-side cost ceiling enforcement (shared across all instances)
|
|
388
|
+
if (policy.enforcementMode === "enforce") {
|
|
389
|
+
const estimatedCost = calculateCost ? 0.01 : 0; // Estimate before call; actual cost reported after
|
|
390
|
+
if (policy.maxCostPerRun !== undefined) {
|
|
391
|
+
const check = await this.checkCost(runId, estimatedCost);
|
|
392
|
+
if (!check.allowed && check.reason === "maxCostPerRun") {
|
|
393
|
+
this.ingest({
|
|
394
|
+
spanId,
|
|
395
|
+
runId,
|
|
396
|
+
spanType: "llm_call",
|
|
397
|
+
input,
|
|
398
|
+
output: JSON.stringify({ blocked: true, reason: "cost_ceiling" }),
|
|
399
|
+
modelId: currentModel,
|
|
400
|
+
costUsd: 0,
|
|
401
|
+
latencyMs: 0,
|
|
402
|
+
metadata: {
|
|
403
|
+
orchestrationBlocked: true,
|
|
404
|
+
orchestrationReason: "cost_ceiling",
|
|
405
|
+
orchestrationAttempt: attempt,
|
|
406
|
+
orchestrationModel: currentModel,
|
|
407
|
+
orchestrationOriginalModel: model,
|
|
408
|
+
orchestrationServerCost: check.currentCost,
|
|
409
|
+
orchestrationCeiling: check.ceiling,
|
|
410
|
+
orchestrationFailOpen: this._lastFailOpen,
|
|
411
|
+
orchestrationPolicy: JSON.stringify(policy),
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
if (modelIndex < allModels.length - 1) {
|
|
415
|
+
lastError = new Error(`Cost ceiling reached: $${check.currentCost?.toFixed(4)} >= $${check.ceiling}`);
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
throw new Error(`Cost ceiling reached: $${check.currentCost?.toFixed(4)} >= $${check.ceiling}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (policy.maxCostPerDay !== undefined) {
|
|
422
|
+
const check = await this.checkCost(runId, estimatedCost);
|
|
423
|
+
if (!check.allowed && check.reason === "maxCostPerDay") {
|
|
424
|
+
this.ingest({
|
|
425
|
+
spanId,
|
|
426
|
+
runId,
|
|
427
|
+
spanType: "llm_call",
|
|
428
|
+
input,
|
|
429
|
+
output: JSON.stringify({ blocked: true, reason: "daily_cost_ceiling" }),
|
|
430
|
+
modelId: currentModel,
|
|
431
|
+
costUsd: 0,
|
|
432
|
+
latencyMs: 0,
|
|
433
|
+
metadata: {
|
|
434
|
+
orchestrationBlocked: true,
|
|
435
|
+
orchestrationReason: "daily_cost_ceiling",
|
|
436
|
+
orchestrationAttempt: attempt,
|
|
437
|
+
orchestrationModel: currentModel,
|
|
438
|
+
orchestrationOriginalModel: model,
|
|
439
|
+
orchestrationServerCost: check.currentCost,
|
|
440
|
+
orchestrationCeiling: check.ceiling,
|
|
441
|
+
orchestrationFailOpen: this._lastFailOpen,
|
|
442
|
+
orchestrationPolicy: JSON.stringify(policy),
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
if (modelIndex < allModels.length - 1) {
|
|
446
|
+
lastError = new Error(`Daily cost ceiling reached: $${check.currentCost?.toFixed(4)} >= $${check.ceiling}`);
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
throw new Error(`Daily cost ceiling reached: $${check.currentCost?.toFixed(4)} >= $${check.ceiling}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// Latency budget enforcement via AbortController
|
|
454
|
+
const controller = new AbortController();
|
|
455
|
+
let latencyTimer;
|
|
456
|
+
if (policy.latencyBudgetMs !== undefined && policy.enforcementMode === "enforce") {
|
|
457
|
+
latencyTimer = setTimeout(() => controller.abort(), policy.latencyBudgetMs);
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
const result = await call(currentModel, controller.signal);
|
|
461
|
+
const latencyMs = Date.now() - startTime;
|
|
462
|
+
const costUsd = calculateCost ? calculateCost(result, currentModel) : 0;
|
|
463
|
+
if (latencyTimer)
|
|
464
|
+
clearTimeout(latencyTimer);
|
|
465
|
+
// Report actual cost to server
|
|
466
|
+
if (policy.enforcementMode === "enforce" && costUsd > 0) {
|
|
467
|
+
await this.checkCost(runId, costUsd);
|
|
468
|
+
}
|
|
469
|
+
this.ingest({
|
|
470
|
+
spanId,
|
|
471
|
+
runId,
|
|
472
|
+
spanType: "llm_call",
|
|
473
|
+
input,
|
|
474
|
+
output: result,
|
|
475
|
+
modelId: currentModel,
|
|
476
|
+
costUsd,
|
|
477
|
+
latencyMs,
|
|
478
|
+
metadata: {
|
|
479
|
+
orchestrationAttempt: attempt,
|
|
480
|
+
orchestrationModel: currentModel,
|
|
481
|
+
orchestrationOriginalModel: model,
|
|
482
|
+
orchestrationIsFallback: isFallback,
|
|
483
|
+
orchestrationFallbackReason: fallbackReason ?? null,
|
|
484
|
+
orchestrationFinal: true,
|
|
485
|
+
orchestrationFailOpen: this._lastFailOpen,
|
|
486
|
+
orchestrationPolicy: JSON.stringify(policy),
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
const latencyMs = Date.now() - startTime;
|
|
493
|
+
if (latencyTimer)
|
|
494
|
+
clearTimeout(latencyTimer);
|
|
495
|
+
lastError = error;
|
|
496
|
+
const retryable = isRetryableError(error, policy.retryPolicy.retryableErrors);
|
|
497
|
+
const isLastAttempt = attempt === maxAttempts;
|
|
498
|
+
const wasLatencyAbort = isLatencyAbort(error);
|
|
499
|
+
this.ingest({
|
|
500
|
+
spanId,
|
|
501
|
+
runId,
|
|
502
|
+
spanType: retryable && !isLastAttempt ? "llm_call" : "error",
|
|
503
|
+
input,
|
|
504
|
+
output: error instanceof Error ? error.message : String(error),
|
|
505
|
+
modelId: currentModel,
|
|
506
|
+
costUsd: 0,
|
|
507
|
+
latencyMs,
|
|
508
|
+
metadata: {
|
|
509
|
+
orchestrationAttempt: attempt,
|
|
510
|
+
orchestrationModel: currentModel,
|
|
511
|
+
orchestrationOriginalModel: model,
|
|
512
|
+
orchestrationIsFallback: isFallback,
|
|
513
|
+
orchestrationFallbackReason: fallbackReason ?? null,
|
|
514
|
+
orchestrationError: true,
|
|
515
|
+
orchestrationLatencyAbort: wasLatencyAbort,
|
|
516
|
+
orchestrationRetryable: retryable,
|
|
517
|
+
orchestrationWillRetry: retryable && !isLastAttempt,
|
|
518
|
+
orchestrationFailOpen: this._lastFailOpen,
|
|
519
|
+
orchestrationPolicy: JSON.stringify(policy),
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
if (retryable && !isLastAttempt) {
|
|
523
|
+
const delay = policy.retryPolicy.backoffMs * Math.pow(policy.retryPolicy.backoffMultiplier, attempt - 1);
|
|
524
|
+
await sleep(delay);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
throw lastError instanceof Error ? lastError : new Error("All fallback models exhausted");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
exports.UncoilClient = UncoilClient;
|
|
535
|
+
/** A step in progress. Set its output and anything it measured before it ends. */
|
|
536
|
+
class Step {
|
|
537
|
+
constructor(name, type, input, metadata) {
|
|
538
|
+
this.name = name;
|
|
539
|
+
this.type = type;
|
|
540
|
+
this.input = input;
|
|
541
|
+
this.id = createId();
|
|
542
|
+
this.fields = {};
|
|
543
|
+
this.metadata = { ...(metadata ?? {}) };
|
|
544
|
+
}
|
|
545
|
+
setOutput(output) {
|
|
546
|
+
this.output = output;
|
|
547
|
+
return this;
|
|
548
|
+
}
|
|
549
|
+
/** Record what the step measured; anything not listed goes into metadata. */
|
|
550
|
+
set({ model, inputTokens, outputTokens, costUsd, ...metadata }) {
|
|
551
|
+
if (model !== undefined)
|
|
552
|
+
this.fields.modelId = model;
|
|
553
|
+
if (inputTokens !== undefined)
|
|
554
|
+
this.fields.inputTokens = inputTokens;
|
|
555
|
+
if (outputTokens !== undefined)
|
|
556
|
+
this.fields.outputTokens = outputTokens;
|
|
557
|
+
if (costUsd !== undefined)
|
|
558
|
+
this.fields.costUsd = costUsd;
|
|
559
|
+
Object.assign(this.metadata, metadata);
|
|
560
|
+
return this;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
exports.Step = Step;
|
|
564
|
+
let defaultClientInstance;
|
|
565
|
+
function defaultClient() {
|
|
566
|
+
return (defaultClientInstance ?? (defaultClientInstance = new UncoilClient()));
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Record one step of the agent. Steps started inside `fn` become its children, so the trace shows which step
|
|
570
|
+
* called which. The step ends when `fn` settles; its return value is the output unless one was set.
|
|
571
|
+
*
|
|
572
|
+
* const docs = await span("search-docs", (step) => search(query), { type: "tool", input: query });
|
|
573
|
+
*/
|
|
574
|
+
async function span(name, fn, options = {}) {
|
|
575
|
+
const step = new Step(name, options.type ?? 'span', options.input, options.metadata);
|
|
576
|
+
const parent = stepContext.getStore();
|
|
577
|
+
const inheritedRun = options.runId || parent?.runId || currentRunId();
|
|
578
|
+
const runId = inheritedRun === "unknown" ? createId() : inheritedRun;
|
|
579
|
+
const started = Date.now();
|
|
580
|
+
const finish = (error) => {
|
|
581
|
+
const failed = error !== undefined;
|
|
582
|
+
(options.client ?? parent?.client ?? defaultClient()).ingest({
|
|
583
|
+
spanId: step.id,
|
|
584
|
+
runId,
|
|
585
|
+
parentSpanId: options.root ? undefined : parent?.spanId,
|
|
586
|
+
spanType: options.root ? (failed ? 'error' : 'run_end') : step.type,
|
|
587
|
+
toolName: step.name,
|
|
588
|
+
input: step.input,
|
|
589
|
+
output: failed ? (error instanceof Error ? error.message : String(error)) : step.output,
|
|
590
|
+
latencyMs: Date.now() - started,
|
|
591
|
+
costUsd: 0,
|
|
592
|
+
createdAt: new Date(started).toISOString(),
|
|
593
|
+
metadata: options.root ? { ...step.metadata, stepType: step.type } : step.metadata,
|
|
594
|
+
...step.fields,
|
|
595
|
+
...(failed ? {
|
|
596
|
+
errorType: error instanceof Error ? error.name : 'Error',
|
|
597
|
+
errorMessage: payload(error instanceof Error ? error.message : String(error)),
|
|
598
|
+
} : {}),
|
|
599
|
+
});
|
|
600
|
+
};
|
|
601
|
+
return stepContext.run({ runId, spanId: step.id, traceName: options.root ? name : parent?.traceName, client: options.client ?? parent?.client }, async () => {
|
|
602
|
+
try {
|
|
603
|
+
const result = await fn(step);
|
|
604
|
+
if (step.output === undefined)
|
|
605
|
+
step.setOutput(result);
|
|
606
|
+
finish();
|
|
607
|
+
return result;
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
finish(error ?? new Error('Unknown error'));
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
/** Wrap a function so each call is recorded as a step, with its arguments and result. */
|
|
616
|
+
function observe(fn, options = {}) {
|
|
617
|
+
const name = options.name || fn.name || 'step';
|
|
618
|
+
return (...args) => span(name, () => fn(...args), { ...options, input: options.captureInput === false ? undefined : args });
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Wrap an agent's entry point. Each call is one trace: a new run id, and a root step that every step recorded
|
|
622
|
+
* inside it hangs from. The root is sent as run_end (or error) when the call settles.
|
|
623
|
+
*/
|
|
624
|
+
function traceAgent(func, config = {}) {
|
|
625
|
+
const client = new UncoilClient(config);
|
|
626
|
+
const name = config.name || func.name || 'agent';
|
|
627
|
+
return ((...args) => span(name, () => func(...args), { type: 'agent', client, runId: createId(), root: true }));
|
|
628
|
+
}
|
|
629
|
+
/** Record a finished model call. It is attached to the step it was made in. */
|
|
630
|
+
const llmCall = (data) => defaultClient().ingest({ ...data, spanType: 'llm_call' });
|
|
631
|
+
exports.llmCall = llmCall;
|
|
632
|
+
/** Record a finished tool call. It is attached to the step it was made in. */
|
|
633
|
+
const toolCall = (data) => defaultClient().ingest({ ...data, spanType: 'tool_call' });
|
|
634
|
+
exports.toolCall = toolCall;
|
|
635
|
+
const decision = (data) => defaultClient().ingest({ ...data, spanType: 'decision' });
|
|
636
|
+
exports.decision = decision;
|
|
637
|
+
var providers_1 = require("./integrations/providers");
|
|
638
|
+
Object.defineProperty(exports, "wrapOpenAI", { enumerable: true, get: function () { return providers_1.wrapOpenAI; } });
|
|
639
|
+
Object.defineProperty(exports, "wrapAnthropic", { enumerable: true, get: function () { return providers_1.wrapAnthropic; } });
|
|
640
|
+
var langgraph_1 = require("./integrations/langgraph");
|
|
641
|
+
Object.defineProperty(exports, "UncoilLangGraphHandler", { enumerable: true, get: function () { return langgraph_1.UncoilLangGraphHandler; } });
|
|
642
|
+
var vercel_ai_1 = require("./integrations/vercel-ai");
|
|
643
|
+
Object.defineProperty(exports, "uncoilMiddleware", { enumerable: true, get: function () { return vercel_ai_1.uncoilMiddleware; } });
|
|
644
|
+
var tools_1 = require("./integrations/tools");
|
|
645
|
+
Object.defineProperty(exports, "traceTools", { enumerable: true, get: function () { return tools_1.traceTools; } });
|
|
646
|
+
var auto_1 = require("./integrations/auto");
|
|
647
|
+
Object.defineProperty(exports, "init", { enumerable: true, get: function () { return auto_1.init; } });
|
|
648
|
+
Object.defineProperty(exports, "uninit", { enumerable: true, get: function () { return auto_1.uninit; } });
|
|
649
|
+
var doctor_1 = require("./doctor");
|
|
650
|
+
Object.defineProperty(exports, "runDoctor", { enumerable: true, get: function () { return doctor_1.runDoctor; } });
|
|
651
|
+
Object.defineProperty(exports, "formatReport", { enumerable: true, get: function () { return doctor_1.formatReport; } });
|
|
652
|
+
Object.defineProperty(exports, "maskKey", { enumerable: true, get: function () { return doctor_1.maskKey; } });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { IntegrationOptions } from "./observation";
|
|
2
|
+
/**
|
|
3
|
+
* One-line setup: call `init()` once at startup and every OpenAI and Anthropic call your app makes is traced.
|
|
4
|
+
* It patches the installed `openai` and `@anthropic-ai/sdk` packages (skipping any that are missing), so no
|
|
5
|
+
* client has to be wrapped by hand. Both the CommonJS and the ES-module build are patched; the ES-module one
|
|
6
|
+
* lands a moment later, so `await init().ready` if your very first call happens at once.
|
|
7
|
+
* Call the returned function to switch it off again.
|
|
8
|
+
*
|
|
9
|
+
* For LangChain / LangGraph add `new UncoilLangGraphHandler()` to `callbacks`; for the Vercel AI SDK use
|
|
10
|
+
* `uncoilMiddleware()`. Use these OR `init()` for the same model calls, not both, or they are recorded twice.
|
|
11
|
+
*/
|
|
12
|
+
export declare function init(options?: IntegrationOptions): (() => void) & {
|
|
13
|
+
ready: Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
/** Switch off everything `init()` switched on. */
|
|
16
|
+
export declare function uninit(): void;
|