@oxygen-agent/cli 1.936.1 → 1.982.3
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 +1 -1
- package/dist/admin-primary-providers-render.js +9 -1
- package/dist/cli-values.d.ts +14 -0
- package/dist/cli-values.js +26 -0
- package/dist/command-manifest.js +30 -2
- package/dist/functions-commands.js +13 -5
- package/dist/help.js +2 -0
- package/dist/index.js +1509 -290
- package/dist/knowledge-repository-commands.d.ts +6 -0
- package/dist/knowledge-repository-commands.js +198 -0
- package/dist/skills.js +20 -0
- package/dist/ugc-commands.js +470 -15
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +11 -6
- package/node_modules/@oxygen/shared/dist/byok-connect.js +14 -6
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +152 -20
- package/node_modules/@oxygen/shared/dist/copilot-errors.js +3 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +19 -1
- package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +26 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.js +8 -41
- package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
- package/node_modules/@oxygen/shared/dist/inbox-avatar-url.d.ts +28 -0
- package/node_modules/@oxygen/shared/dist/inbox-avatar-url.js +57 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/index.js +10 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bases.d.ts +74 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bases.js +456 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +56 -48
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +50 -49
- package/node_modules/@oxygen/shared/dist/knowledge-repository.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/knowledge-repository.js +121 -0
- package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.d.ts +20 -0
- package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.js +155 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
- package/node_modules/@oxygen/shared/dist/langfuse.js +177 -130
- package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
- package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
- package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/mailbox-import.js +53 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.js +8 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
- package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +116 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +120 -0
- package/node_modules/@oxygen/shared/dist/recipes.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/recipes.js +23 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +126 -2
- package/node_modules/@oxygen/shared/dist/sequences.js +280 -4
- package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.js +24 -0
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +29 -1
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +8 -1
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +3 -1
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +15 -4
- package/node_modules/@oxygen/shared/package.json +15 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
- package/package.json +2 -1
|
@@ -21,11 +21,9 @@
|
|
|
21
21
|
// * Trace ids stay deterministic: sha256(seed) — same trace per turn/run, so
|
|
22
22
|
// lease-reclaim replays still converge onto ONE trace. See
|
|
23
23
|
// llmTraceIdForSeed.
|
|
24
|
-
// *
|
|
25
|
-
// ids
|
|
26
|
-
//
|
|
27
|
-
// longer upserts: a replayed slice appends duplicate observations to the
|
|
28
|
-
// same trace instead of overwriting them.
|
|
24
|
+
// * A private OTel id generator emits one real deterministic root per seed.
|
|
25
|
+
// Child ids identify physical observations; oxygen_observation_id preserves
|
|
26
|
+
// the caller's logical identity without conflating repeated executions.
|
|
29
27
|
// * v5 is observations-first: correlating attributes (userId, sessionId,
|
|
30
28
|
// tags) must ride EVERY observation, not just the root, or per-session cost
|
|
31
29
|
// rollups miss the cost-bearing generations. They are applied through
|
|
@@ -34,7 +32,9 @@
|
|
|
34
32
|
// * Trace-level input/output is deprecated in v5. Overall IO goes on the ROOT
|
|
35
33
|
// observation instead; setTraceIO()/setActiveTraceIO() are deliberately not
|
|
36
34
|
// used here.
|
|
37
|
-
import { createHash } from "node:crypto";
|
|
35
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
36
|
+
import { OXYGEN_VERSION } from "./version.js";
|
|
37
|
+
import { prepareLlmPayload } from "./llm-payload.js";
|
|
38
38
|
import { log } from "./log.js";
|
|
39
39
|
const FLUSH_TIMEOUT_MS = 5_000;
|
|
40
40
|
const WARN_THROTTLE_MS = 30_000;
|
|
@@ -45,9 +45,7 @@ const WARN_THROTTLE_MS = 30_000;
|
|
|
45
45
|
* meant every flush that ran long lost its spans instead of finishing late.
|
|
46
46
|
*/
|
|
47
47
|
const LANGFUSE_EXPORT_TIMEOUT_SECONDS = 10;
|
|
48
|
-
//
|
|
49
|
-
// transcripts max out around 150 KB; anything larger is truncated with an
|
|
50
|
-
// explicit marker rather than risking a rejected ingestion batch.
|
|
48
|
+
// Scores are API strings; unlike observation payloads they cannot use chunks.
|
|
51
49
|
const MAX_JSON_FIELD_CHARS = 400_000;
|
|
52
50
|
/**
|
|
53
51
|
* FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
|
|
@@ -100,42 +98,13 @@ export function resolveLlmTracingEnvironment(env = process.env) {
|
|
|
100
98
|
export function llmTraceIdForSeed(seed) {
|
|
101
99
|
return createHash("sha256").update(seed, "utf8").digest("hex").slice(0, 32);
|
|
102
100
|
}
|
|
103
|
-
/**
|
|
104
|
-
* Deterministic synthetic parent span id for one trace.
|
|
105
|
-
*
|
|
106
|
-
* v5 does not let a caller choose observation ids, and the worker emits a single
|
|
107
|
-
* run's observations from different processes and slices — so there is no real
|
|
108
|
-
* root span id to nest under. Every observation of a run instead attaches to
|
|
109
|
-
* this stable pseudo-parent, which reproduces EXACTLY the flat shape the v3
|
|
110
|
-
* adapter already produced (v3 passed only `traceId`, never
|
|
111
|
-
* `parentObservationId`, so observations were already siblings of the trace).
|
|
112
|
-
*/
|
|
101
|
+
/** The real root's stable W3C span id, also used across worker resumes. */
|
|
113
102
|
function rootSpanIdForSeed(seed) {
|
|
114
103
|
return createHash("sha256").update(`langfuse-root:${seed}`, "utf8").digest("hex").slice(0, 16);
|
|
115
104
|
}
|
|
116
|
-
// Bound one JSON-bearing field. Over the cap → an explicit truncation marker
|
|
117
|
-
// (never a silently clipped payload that parses as complete).
|
|
118
|
-
function boundJsonField(value) {
|
|
119
|
-
if (value === undefined || value === null)
|
|
120
|
-
return value;
|
|
121
|
-
let serialized;
|
|
122
|
-
try {
|
|
123
|
-
serialized = JSON.stringify(value) ?? "";
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
return { truncated: true, reason: "unserializable" };
|
|
127
|
-
}
|
|
128
|
-
if (serialized.length <= MAX_JSON_FIELD_CHARS)
|
|
129
|
-
return value;
|
|
130
|
-
return {
|
|
131
|
-
truncated: true,
|
|
132
|
-
chars: serialized.length,
|
|
133
|
-
preview: serialized.slice(0, MAX_JSON_FIELD_CHARS),
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
105
|
// A score's `comment` is free text (a thumbs-down reason is user-authored and
|
|
137
106
|
// unbounded) and the API types it as a STRING, so it cannot take
|
|
138
|
-
//
|
|
107
|
+
// a recoverable payload envelope. It gets the same
|
|
139
108
|
// MAX_JSON_FIELD_CHARS ceiling and the same "explicit marker, never a silent
|
|
140
109
|
// clip" rule, with the marker counted INSIDE the cap so the bound holds.
|
|
141
110
|
const COMMENT_TRUNCATION_MARKER = "…[truncated]";
|
|
@@ -149,13 +118,13 @@ function compact(body) {
|
|
|
149
118
|
for (const [key, value] of Object.entries(body)) {
|
|
150
119
|
if (value === undefined || value === null)
|
|
151
120
|
continue;
|
|
152
|
-
out[key] =
|
|
121
|
+
out[key] = value;
|
|
153
122
|
}
|
|
154
123
|
return out;
|
|
155
124
|
}
|
|
156
|
-
function boundedNever(rejectable, warn) {
|
|
125
|
+
function boundedNever(rejectable, warn, timeoutMs = FLUSH_TIMEOUT_MS) {
|
|
157
126
|
return new Promise((resolve) => {
|
|
158
|
-
const timer = setTimeout(resolve,
|
|
127
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
159
128
|
timer.unref?.();
|
|
160
129
|
rejectable
|
|
161
130
|
.catch((error) => warn(error))
|
|
@@ -165,52 +134,98 @@ function boundedNever(rejectable, warn) {
|
|
|
165
134
|
});
|
|
166
135
|
});
|
|
167
136
|
}
|
|
168
|
-
/**
|
|
169
|
-
* The real v4 emitter. Everything OTel is loaded LAZILY, on first emission, so
|
|
170
|
-
* a runtime with tracing disabled (the packed CLI, every test) never pays for
|
|
171
|
-
* the OTel tree — matching the "inert unless enabled" doctrine the flag already
|
|
172
|
-
* promises.
|
|
173
|
-
*/
|
|
137
|
+
/** Lazy, private, always-sampled OTel transport. Never register it globally. */
|
|
174
138
|
function createOtelEmitter(env, warn) {
|
|
175
139
|
let handle = null;
|
|
176
140
|
const init = () => {
|
|
177
141
|
handle ??= (async () => {
|
|
178
142
|
try {
|
|
179
|
-
const [{ LangfuseSpanProcessor }, { BasicTracerProvider }, tracing] = await Promise.all([
|
|
180
|
-
import("@langfuse/otel"),
|
|
181
|
-
import("@opentelemetry/
|
|
182
|
-
import("@langfuse/tracing"),
|
|
143
|
+
const [{ LangfuseSpanProcessor }, { BasicTracerProvider, AlwaysOnSampler }, tracing, otel, core] = await Promise.all([
|
|
144
|
+
import("@langfuse/otel"), import("@opentelemetry/sdk-trace-base"),
|
|
145
|
+
import("@langfuse/tracing"), import("@opentelemetry/api"), import("@langfuse/core"),
|
|
183
146
|
]);
|
|
184
147
|
const processor = new LangfuseSpanProcessor({
|
|
185
148
|
publicKey: env.LANGFUSE_PUBLIC_KEY,
|
|
186
149
|
secretKey: env.LANGFUSE_SECRET_KEY,
|
|
187
150
|
...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
|
|
188
151
|
environment: resolveLlmTracingEnvironment(env),
|
|
189
|
-
|
|
190
|
-
// which is exactly FLUSH_TIMEOUT_MS — zero headroom, so a batch that
|
|
191
|
-
// needed 6s was guaranteed to die on the transport and be dropped:
|
|
192
|
-
// 37 `llm_tracing.ingest_failed` warns over 30 days to 2026-09-09,
|
|
193
|
-
// every one stage='flush' / 'Request timed out', against 275,970
|
|
194
|
-
// traces Langfuse accepted in the same window. 10s gives the POST
|
|
195
|
-
// room without touching FLUSH_TIMEOUT_MS — boundedNever still
|
|
196
|
-
// releases the caller at 5s, so a slow Langfuse can never hold a
|
|
197
|
-
// worker tick.
|
|
152
|
+
release: env.VERCEL_GIT_COMMIT_SHA || env.OXYGEN_GIT_SHA || OXYGEN_VERSION,
|
|
198
153
|
timeout: LANGFUSE_EXPORT_TIMEOUT_SECONDS,
|
|
154
|
+
// SimpleSpanProcessor hits the exporter's concurrent-request limit
|
|
155
|
+
// on large chunk bursts. Batch them, starting promptly; after() drains
|
|
156
|
+
// the batch with a bound above the exporter's own deadline.
|
|
157
|
+
exportMode: "batched",
|
|
158
|
+
flushInterval: 1,
|
|
159
|
+
flushAt: 128,
|
|
199
160
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
161
|
+
let identity;
|
|
162
|
+
const provider = new BasicTracerProvider({
|
|
163
|
+
spanProcessors: [{
|
|
164
|
+
onStart: (span) => {
|
|
165
|
+
// Directly pass the private parent claim to the processor. A
|
|
166
|
+
// standalone process may have no global context manager at all;
|
|
167
|
+
// context.with()/propagateAttributes alone would silently lose
|
|
168
|
+
// the claim and user/session on every observation in that case.
|
|
169
|
+
const parentContext = identity && !identity.isRoot
|
|
170
|
+
? core.setLangfuseTraceIdInBaggage(otel.ROOT_CONTEXT, identity.traceId)
|
|
171
|
+
: otel.ROOT_CONTEXT;
|
|
172
|
+
processor.onStart(span, parentContext);
|
|
173
|
+
if (identity)
|
|
174
|
+
span.setAttributes(compact({
|
|
175
|
+
"user.id": identity.correlation.userId,
|
|
176
|
+
"session.id": identity.correlation.sessionId,
|
|
177
|
+
"langfuse.trace.name": identity.correlation.traceName,
|
|
178
|
+
"langfuse.trace.tags": identity.correlation.tags,
|
|
179
|
+
}));
|
|
180
|
+
},
|
|
181
|
+
onEnd: (span) => processor.onEnd(span),
|
|
182
|
+
forceFlush: () => processor.forceFlush(),
|
|
183
|
+
shutdown: () => processor.shutdown(),
|
|
184
|
+
}], sampler: new AlwaysOnSampler(),
|
|
185
|
+
idGenerator: {
|
|
186
|
+
generateTraceId: () => identity?.traceId ?? randomBytes(16).toString("hex"),
|
|
187
|
+
generateSpanId: () => identity?.spanId ?? randomBytes(8).toString("hex"),
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
// Only used to avoid repeatedly creating provisional roots in this
|
|
191
|
+
// process. A final root emission always updates the same physical id.
|
|
192
|
+
const roots = new Set();
|
|
193
|
+
const emit = (emission) => {
|
|
194
|
+
const traceId = llmTraceIdForSeed(emission.traceSeed);
|
|
195
|
+
const rootId = rootSpanIdForSeed(emission.traceSeed);
|
|
196
|
+
if (!emission.isRoot && !roots.has(traceId)) {
|
|
197
|
+
emit({
|
|
198
|
+
kind: "span", traceSeed: emission.traceSeed, isRoot: true,
|
|
199
|
+
observationId: rootId, name: emission.correlation.traceName ?? emission.name.split(".")[0] + ".run",
|
|
200
|
+
attributes: { version: OXYGEN_VERSION, metadata: { oxygen_trace_root: true, oxygen_trace_seed: emission.traceSeed, oxygen_capture_version: 2 } },
|
|
201
|
+
correlation: emission.correlation, startTime: emission.startTime, endTime: emission.startTime,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (emission.isRoot) {
|
|
205
|
+
roots.add(traceId);
|
|
206
|
+
if (roots.size > 4096)
|
|
207
|
+
roots.delete(roots.values().next().value);
|
|
208
|
+
}
|
|
209
|
+
// SDK configuration is module-local, whereas Next bundles and tests
|
|
210
|
+
// may construct more than one private client. Select THIS provider
|
|
211
|
+
// immediately before the synchronous creation; never use global OTel.
|
|
212
|
+
tracing.setLangfuseTracerProvider(provider);
|
|
213
|
+
identity = { traceId, spanId: emission.observationId ?? randomBytes(8).toString("hex"), isRoot: emission.isRoot === true, correlation: emission.correlation };
|
|
214
|
+
try {
|
|
215
|
+
otel.context.with(emission.isRoot ? otel.ROOT_CONTEXT : core.setLangfuseTraceIdInBaggage(otel.ROOT_CONTEXT, traceId), () => tracing.propagateAttributes(emission.correlation, () => {
|
|
216
|
+
const observation = tracing.startObservation(emission.name, emission.attributes, {
|
|
217
|
+
asType: emission.kind, startTime: emission.startTime,
|
|
218
|
+
...(emission.isRoot ? {} : { parentSpanContext: { traceId, spanId: emission.parentObservationId ?? rootId, traceFlags: 1 } }),
|
|
219
|
+
});
|
|
220
|
+
if (emission.kind !== "event")
|
|
221
|
+
observation.end(emission.endTime);
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
identity = undefined;
|
|
226
|
+
}
|
|
213
227
|
};
|
|
228
|
+
return { processor, emit };
|
|
214
229
|
}
|
|
215
230
|
catch (error) {
|
|
216
231
|
warn(error, { stage: "init" });
|
|
@@ -220,33 +235,7 @@ function createOtelEmitter(env, warn) {
|
|
|
220
235
|
return handle;
|
|
221
236
|
};
|
|
222
237
|
return {
|
|
223
|
-
emit: (emission) => {
|
|
224
|
-
void init()
|
|
225
|
-
.then((h) => {
|
|
226
|
-
if (!h)
|
|
227
|
-
return;
|
|
228
|
-
const traceId = llmTraceIdForSeed(emission.traceSeed);
|
|
229
|
-
// propagateAttributes is scope-based in v5: the observation must be
|
|
230
|
-
// created INSIDE the callback to inherit userId/sessionId/tags.
|
|
231
|
-
h.propagateAttributes(emission.correlation, () => {
|
|
232
|
-
// The kind is a union, so no single overload matches it. Every
|
|
233
|
-
// overload returns an observation extending the same base, and the
|
|
234
|
-
// only method used here is .end() — so resolving against the span
|
|
235
|
-
// overload is safe while the real kind is passed at runtime.
|
|
236
|
-
const observation = h.startObservation(emission.name, emission.attributes, {
|
|
237
|
-
asType: emission.kind,
|
|
238
|
-
startTime: emission.startTime,
|
|
239
|
-
parentSpanContext: {
|
|
240
|
-
traceId,
|
|
241
|
-
spanId: rootSpanIdForSeed(emission.traceSeed),
|
|
242
|
-
traceFlags: 1,
|
|
243
|
-
},
|
|
244
|
-
});
|
|
245
|
-
observation.end(emission.endTime);
|
|
246
|
-
});
|
|
247
|
-
})
|
|
248
|
-
.catch((error) => warn(error, { stage: emission.kind }));
|
|
249
|
-
},
|
|
238
|
+
emit: (emission) => { void init().then((h) => h?.emit(emission)).catch((error) => warn(error, { stage: emission.kind })); },
|
|
250
239
|
flush: () => init().then((h) => h?.processor.forceFlush() ?? Promise.resolve()),
|
|
251
240
|
shutdown: () => init().then((h) => h?.processor.shutdown() ?? Promise.resolve()),
|
|
252
241
|
};
|
|
@@ -330,7 +319,52 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
330
319
|
...context,
|
|
331
320
|
});
|
|
332
321
|
};
|
|
333
|
-
const
|
|
322
|
+
const transport = options?.emitterImpl ?? createOtelEmitter(env, warn);
|
|
323
|
+
const emitter = {
|
|
324
|
+
...transport,
|
|
325
|
+
emit: (emission) => {
|
|
326
|
+
const observationId = emission.observationId ?? randomBytes(8).toString("hex");
|
|
327
|
+
const metadata = { ...emission.attributes.metadata, oxygen_capture_version: 2 };
|
|
328
|
+
const attributes = { ...emission.attributes, version: OXYGEN_VERSION, metadata };
|
|
329
|
+
const chunkEmissions = [];
|
|
330
|
+
const capture = (value, field, key) => {
|
|
331
|
+
const prepared = prepareLlmPayload(value, key);
|
|
332
|
+
for (const chunk of prepared.chunks) {
|
|
333
|
+
chunkEmissions.push({
|
|
334
|
+
kind: "event", traceSeed: emission.traceSeed, name: "llm.payload_chunk", parentObservationId: observationId,
|
|
335
|
+
attributes: { version: OXYGEN_VERSION, input: chunk, metadata: {
|
|
336
|
+
oxygen_capture_version: 2, oxygen_payload_id: chunk.payload_id,
|
|
337
|
+
oxygen_payload_chunk_index: chunk.index, oxygen_payload_chunk_count: prepared.chunks.length,
|
|
338
|
+
oxygen_observation_id: `${metadata.oxygen_observation_id ?? observationId}:payload:${field}:${chunk.index}`,
|
|
339
|
+
} },
|
|
340
|
+
correlation: emission.correlation, startTime: emission.endTime ?? emission.startTime, endTime: emission.endTime ?? emission.startTime,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return prepared.value;
|
|
344
|
+
};
|
|
345
|
+
for (const key of ["input", "output", "modelParameters"]) {
|
|
346
|
+
if (key in attributes)
|
|
347
|
+
attributes[key] = capture(attributes[key], key);
|
|
348
|
+
}
|
|
349
|
+
if (typeof attributes.statusMessage === "string") {
|
|
350
|
+
const status = prepareLlmPayload(attributes.statusMessage);
|
|
351
|
+
if (typeof status.value === "string" && status.value.length <= 2000)
|
|
352
|
+
attributes.statusMessage = status.value;
|
|
353
|
+
else {
|
|
354
|
+
metadata.status_message = attributes.statusMessage;
|
|
355
|
+
attributes.statusMessage = "See metadata.status_message for full error detail";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const [key, value] of Object.entries(metadata))
|
|
359
|
+
metadata[key] = capture(value, `metadata.${key}`, key);
|
|
360
|
+
if (attributes.modelParameters && typeof attributes.modelParameters === "object") {
|
|
361
|
+
attributes.modelParameters = Object.fromEntries(Object.entries(attributes.modelParameters).map(([key, value]) => [key, typeof value === "number" || typeof value === "string" ? value : JSON.stringify(value)]));
|
|
362
|
+
}
|
|
363
|
+
transport.emit({ ...emission, observationId, attributes });
|
|
364
|
+
for (const chunk of chunkEmissions)
|
|
365
|
+
transport.emit(chunk);
|
|
366
|
+
},
|
|
367
|
+
};
|
|
334
368
|
const scorer = options?.scorerImpl ?? createApiScorer(env, warn);
|
|
335
369
|
const guarded = (fn, stage) => {
|
|
336
370
|
try {
|
|
@@ -360,19 +394,44 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
360
394
|
tags: input.tags,
|
|
361
395
|
});
|
|
362
396
|
};
|
|
397
|
+
const modelObservation = (body, kind) => guarded(() => {
|
|
398
|
+
emitter.emit({
|
|
399
|
+
kind,
|
|
400
|
+
traceSeed: body.traceId,
|
|
401
|
+
name: body.name,
|
|
402
|
+
attributes: compact({
|
|
403
|
+
input: body.input,
|
|
404
|
+
output: body.output,
|
|
405
|
+
level: body.level,
|
|
406
|
+
statusMessage: body.statusMessage,
|
|
407
|
+
model: body.model,
|
|
408
|
+
modelParameters: body.modelParameters,
|
|
409
|
+
completionStartTime: body.completionStartTime,
|
|
410
|
+
usageDetails: body.usageDetails,
|
|
411
|
+
costDetails: body.costDetails,
|
|
412
|
+
metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
|
|
413
|
+
}),
|
|
414
|
+
// The cost-bearing observation: v5 session cost only rolls up when
|
|
415
|
+
// the generation itself carries the session.
|
|
416
|
+
correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
|
|
417
|
+
startTime: body.startTime ?? new Date(),
|
|
418
|
+
...(body.endTime ? { endTime: body.endTime } : {}),
|
|
419
|
+
});
|
|
420
|
+
}, kind);
|
|
363
421
|
return {
|
|
364
422
|
trace: (body) => guarded(() => {
|
|
365
423
|
const startTime = body.startTime ?? new Date();
|
|
366
424
|
emitter.emit({
|
|
367
425
|
kind: "span",
|
|
368
426
|
traceSeed: body.id,
|
|
427
|
+
observationId: rootSpanIdForSeed(body.id), isRoot: true,
|
|
369
428
|
name: body.name,
|
|
370
429
|
// Overall trace IO lives on this ROOT observation — v5 deprecates
|
|
371
430
|
// trace-level input/output, so it is deliberately not set separately.
|
|
372
431
|
attributes: compact({
|
|
373
432
|
input: body.input,
|
|
374
433
|
output: body.output,
|
|
375
|
-
metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
|
|
434
|
+
metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id, oxygen_trace_root: true, oxygen_trace_seed: body.id },
|
|
376
435
|
}),
|
|
377
436
|
correlation: correlate({
|
|
378
437
|
traceName: body.name,
|
|
@@ -383,6 +442,15 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
383
442
|
startTime,
|
|
384
443
|
endTime: body.endTime ?? startTime,
|
|
385
444
|
});
|
|
445
|
+
if (body.metadata?.observation_scope === "execution_slice") {
|
|
446
|
+
const sliceStart = typeof body.metadata.slice_started_at === "string" ? new Date(body.metadata.slice_started_at) : startTime;
|
|
447
|
+
emitter.emit({
|
|
448
|
+
kind: "span", traceSeed: body.id, name: `${body.name}.slice`,
|
|
449
|
+
attributes: compact({ input: body.input, output: body.output, metadata: { ...body.metadata, oxygen_observation_id: `${body.id}:slice:${body.metadata.slice_id}` } }),
|
|
450
|
+
correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
|
|
451
|
+
startTime: Number.isFinite(sliceStart.getTime()) ? sliceStart : startTime, endTime: body.endTime ?? startTime,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
386
454
|
}, "trace"),
|
|
387
455
|
span: (body) => guarded(() => {
|
|
388
456
|
emitter.emit({
|
|
@@ -401,29 +469,8 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
401
469
|
...(body.endTime ? { endTime: body.endTime } : {}),
|
|
402
470
|
});
|
|
403
471
|
}, "span"),
|
|
404
|
-
generation: (body) =>
|
|
405
|
-
|
|
406
|
-
kind: "generation",
|
|
407
|
-
traceSeed: body.traceId,
|
|
408
|
-
name: body.name,
|
|
409
|
-
attributes: compact({
|
|
410
|
-
input: body.input,
|
|
411
|
-
output: body.output,
|
|
412
|
-
level: body.level,
|
|
413
|
-
statusMessage: body.statusMessage,
|
|
414
|
-
model: body.model,
|
|
415
|
-
completionStartTime: body.completionStartTime,
|
|
416
|
-
usageDetails: body.usageDetails,
|
|
417
|
-
costDetails: body.costDetails,
|
|
418
|
-
metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
|
|
419
|
-
}),
|
|
420
|
-
// The cost-bearing observation: v5 session cost only rolls up when
|
|
421
|
-
// the generation itself carries the session.
|
|
422
|
-
correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
|
|
423
|
-
startTime: body.startTime ?? new Date(),
|
|
424
|
-
...(body.endTime ? { endTime: body.endTime } : {}),
|
|
425
|
-
});
|
|
426
|
-
}, "generation"),
|
|
472
|
+
generation: (body) => modelObservation(body, "generation"),
|
|
473
|
+
embedding: (body) => modelObservation(body, "embedding"),
|
|
427
474
|
event: (body) => guarded(() => {
|
|
428
475
|
const startTime = body.startTime ?? new Date();
|
|
429
476
|
emitter.emit({
|
|
@@ -452,10 +499,10 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
452
499
|
}
|
|
453
500
|
},
|
|
454
501
|
// The "never rejects, bounded at ~5s" contract is the CLIENT's, so it is
|
|
455
|
-
// enforced here rather than inside one emitter — an emitter that throws
|
|
502
|
+
// enforced here (15s for serverless after()) rather than inside one emitter — an emitter that throws
|
|
456
503
|
// synchronously or rejects must still not escape into product code.
|
|
457
|
-
flush: () => boundedNever((async () => emitter.flush())(), (error) => warn(error, { stage: "flush" })),
|
|
458
|
-
shutdown: () => boundedNever((async () => emitter.shutdown())(), (error) => warn(error, { stage: "shutdown" })),
|
|
504
|
+
flush: () => boundedNever((async () => emitter.flush())(), (error) => warn(error, { stage: "flush" }), env.VERCEL ? 15_000 : FLUSH_TIMEOUT_MS),
|
|
505
|
+
shutdown: () => boundedNever((async () => emitter.shutdown())(), (error) => warn(error, { stage: "shutdown" }), env.VERCEL ? 15_000 : FLUSH_TIMEOUT_MS),
|
|
459
506
|
};
|
|
460
507
|
}
|
|
461
508
|
// --- Process-wide singleton (both runtimes construct at most one client) ------
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type LlmPayloadChunk = {
|
|
2
|
+
payload_id: string;
|
|
3
|
+
index: number;
|
|
4
|
+
data: string;
|
|
5
|
+
};
|
|
6
|
+
/** Only credentials are masked: prompts, tool arguments and token counts stay. */
|
|
7
|
+
export declare function prepareLlmPayload(value: unknown, fieldKey?: string): {
|
|
8
|
+
value: unknown;
|
|
9
|
+
chunks: LlmPayloadChunk[];
|
|
10
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { redactSecretsInString } from "./redaction.js";
|
|
3
|
+
// Bound individual OTLP fields in UTF-8 bytes. Large payloads remain fully
|
|
4
|
+
// recoverable in child events rather than losing the end of a model response.
|
|
5
|
+
const INLINE_BYTES = 128 * 1024;
|
|
6
|
+
const CHUNK_BYTES = 64 * 1024;
|
|
7
|
+
const SECRET_KEY = /^(?:authorization|proxy-authorization|cookie|set-cookie|password|passwd|secret|(?:x[-_]?)?api[-_]?key|x[-_]?auth[-_]?token|access[-_]?token|refresh[-_]?token|client[-_]?secret|private[-_]?key|connection[-_]?uri|database[-_]?url)$/i;
|
|
8
|
+
const SECRET_QUERY = /([?&](?:token|access_token|api_key|key|signature|x-amz-signature|x-goog-signature)=)[^&#\s"]+/gi;
|
|
9
|
+
/** Only credentials are masked: prompts, tool arguments and token counts stay. */
|
|
10
|
+
export function prepareLlmPayload(value, fieldKey = "") {
|
|
11
|
+
if (SECRET_KEY.test(fieldKey))
|
|
12
|
+
value = "[REDACTED]";
|
|
13
|
+
if (value === undefined || value === null)
|
|
14
|
+
return { value, chunks: [] };
|
|
15
|
+
let serialized;
|
|
16
|
+
try {
|
|
17
|
+
// Tool arguments and SSE frames are often JSON encoded inside a string.
|
|
18
|
+
// Inspect those nested documents too; redacting only outer object keys
|
|
19
|
+
// would leave an API key inside tool_calls[].function.arguments intact.
|
|
20
|
+
const serialize = (input, nestedDepth = 0) => JSON.stringify(input, (key, item) => {
|
|
21
|
+
if (SECRET_KEY.test(key))
|
|
22
|
+
return "[REDACTED]";
|
|
23
|
+
if (typeof item !== "string")
|
|
24
|
+
return item;
|
|
25
|
+
let text = redactSecretsInString(item).replace(SECRET_QUERY, "$1[REDACTED]");
|
|
26
|
+
if (/^\s*[\[{]/.test(text)) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(text);
|
|
29
|
+
if (nestedDepth >= 32)
|
|
30
|
+
return "[REDACTED_NESTED_JSON_DEPTH]";
|
|
31
|
+
text = serialize(parsed, nestedDepth + 1);
|
|
32
|
+
}
|
|
33
|
+
catch { /* ordinary prompt text, not a JSON document */ }
|
|
34
|
+
}
|
|
35
|
+
return text;
|
|
36
|
+
}) ?? "null";
|
|
37
|
+
serialized = serialize(value);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { value: { truncated: true, reason: "unserializable" }, chunks: [] };
|
|
41
|
+
}
|
|
42
|
+
const bytes = Buffer.from(serialized, "utf8");
|
|
43
|
+
if (bytes.length <= INLINE_BYTES)
|
|
44
|
+
return { value: JSON.parse(serialized), chunks: [] };
|
|
45
|
+
const id = createHash("sha256").update(bytes).digest("hex");
|
|
46
|
+
const chunks = [];
|
|
47
|
+
for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
|
|
48
|
+
chunks.push({ payload_id: id, index: chunks.length, data: bytes.subarray(offset, offset + CHUNK_BYTES).toString("base64") });
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
value: { oxygen_payload: { id, encoding: "base64-json-utf8", bytes: bytes.length, chunks: chunks.length, sha256: id } },
|
|
52
|
+
chunks,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Inclusive provider counts. Keep the original provider usage on the observation's metadata. */
|
|
2
|
+
export type LlmProviderUsage = {
|
|
3
|
+
promptTokens?: number | null | undefined;
|
|
4
|
+
completionTokens?: number | null | undefined;
|
|
5
|
+
reasoningTokens?: number | null | undefined;
|
|
6
|
+
cachedPromptTokens?: number | null | undefined;
|
|
7
|
+
cacheWriteTokens?: number | null | undefined;
|
|
8
|
+
totalTokens?: number | null | undefined;
|
|
9
|
+
};
|
|
10
|
+
/** Langfuse usage keys are mutually exclusive buckets, not inclusive breakdowns. */
|
|
11
|
+
export declare function normalizeLlmUsageDetails(usage: LlmProviderUsage | null | undefined): Record<string, number> | undefined;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Langfuse usage keys are mutually exclusive buckets, not inclusive breakdowns. */
|
|
2
|
+
export function normalizeLlmUsageDetails(usage) {
|
|
3
|
+
if (!usage)
|
|
4
|
+
return undefined;
|
|
5
|
+
const count = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined;
|
|
6
|
+
const input = count(usage.promptTokens);
|
|
7
|
+
const output = count(usage.completionTokens);
|
|
8
|
+
const details = {};
|
|
9
|
+
if (input !== undefined) {
|
|
10
|
+
const cached = Math.min(input, count(usage.cachedPromptTokens) ?? 0);
|
|
11
|
+
const created = Math.min(input - cached, count(usage.cacheWriteTokens) ?? 0);
|
|
12
|
+
details.input = input - cached - created;
|
|
13
|
+
if (cached > 0)
|
|
14
|
+
details.input_cached_tokens = cached;
|
|
15
|
+
if (created > 0)
|
|
16
|
+
details.input_cache_creation_tokens = created;
|
|
17
|
+
}
|
|
18
|
+
if (output !== undefined) {
|
|
19
|
+
// Some providers return a detail count larger than its inclusive parent.
|
|
20
|
+
// Preserve the parent total rather than inventing extra billed tokens.
|
|
21
|
+
const reasoning = Math.min(output, count(usage.reasoningTokens) ?? 0);
|
|
22
|
+
details.output = output - reasoning;
|
|
23
|
+
if (reasoning > 0)
|
|
24
|
+
details.output_reasoning_tokens = reasoning;
|
|
25
|
+
}
|
|
26
|
+
const total = input !== undefined && output !== undefined ? input + output : count(usage.totalTokens);
|
|
27
|
+
if (total !== undefined)
|
|
28
|
+
details.total = total;
|
|
29
|
+
return Object.keys(details).length > 0 ? details : undefined;
|
|
30
|
+
}
|
|
@@ -8,6 +8,16 @@ export type NormalizedMailboxImportRow = {
|
|
|
8
8
|
infrastructure_platform?: "google_workspace" | "microsoft_365" | "microsoft_azure";
|
|
9
9
|
tenant_id?: string;
|
|
10
10
|
app_password?: string;
|
|
11
|
+
/**
|
|
12
|
+
* WHO the inbox sends as (v1.954.0 — an inbox always belongs to a sender).
|
|
13
|
+
* Non-secret identity, so it rides the same whitelist as the address: a vendor
|
|
14
|
+
* export that already knows the person spares the customer a guessed profile
|
|
15
|
+
* named from the address local part.
|
|
16
|
+
*/
|
|
17
|
+
first_name?: string;
|
|
18
|
+
last_name?: string;
|
|
19
|
+
display_name?: string;
|
|
20
|
+
sender_profile_id?: string;
|
|
11
21
|
};
|
|
12
22
|
export type MailboxImportValidationSummary = {
|
|
13
23
|
valid: true;
|
|
@@ -49,6 +49,27 @@ const MAILBOX_TENANT_HEADERS = new Set([
|
|
|
49
49
|
"microsofttenantid",
|
|
50
50
|
"tenantid",
|
|
51
51
|
]);
|
|
52
|
+
const MAILBOX_FIRST_NAME_HEADERS = new Set([
|
|
53
|
+
"first",
|
|
54
|
+
"firstname",
|
|
55
|
+
"givenname",
|
|
56
|
+
]);
|
|
57
|
+
const MAILBOX_LAST_NAME_HEADERS = new Set([
|
|
58
|
+
"familyname",
|
|
59
|
+
"last",
|
|
60
|
+
"lastname",
|
|
61
|
+
"surname",
|
|
62
|
+
]);
|
|
63
|
+
const MAILBOX_DISPLAY_NAME_HEADERS = new Set([
|
|
64
|
+
"displayname",
|
|
65
|
+
"fromname",
|
|
66
|
+
"name",
|
|
67
|
+
"sendername",
|
|
68
|
+
]);
|
|
69
|
+
const MAILBOX_SENDER_PROFILE_HEADERS = new Set([
|
|
70
|
+
"senderprofile",
|
|
71
|
+
"senderprofileid",
|
|
72
|
+
]);
|
|
52
73
|
const MAILBOX_NON_TRANSFERABLE_SECRET_HEADERS = new Set([
|
|
53
74
|
"accesstoken",
|
|
54
75
|
"applicationsecret",
|
|
@@ -328,6 +349,17 @@ function normalizeMailboxExportRow(row, index, mode) {
|
|
|
328
349
|
? "microsoft_azure"
|
|
329
350
|
: null);
|
|
330
351
|
const infrastructurePlatform = normalizeMailboxInfrastructurePlatform(infrastructurePlatformRaw, provider, index);
|
|
352
|
+
// WHO the inbox sends as. Carried through because an inbox always belongs to a
|
|
353
|
+
// sender (v1.954.0): a file that already names the person is the difference
|
|
354
|
+
// between a real sender profile and one OXYGEN guesses from the address.
|
|
355
|
+
const firstName = readMailboxIdentityName(byHeader, MAILBOX_FIRST_NAME_HEADERS, index, "first_name");
|
|
356
|
+
const lastName = readMailboxIdentityName(byHeader, MAILBOX_LAST_NAME_HEADERS, index, "last_name");
|
|
357
|
+
const displayName = readMailboxIdentityName(byHeader, MAILBOX_DISPLAY_NAME_HEADERS, index, "display_name");
|
|
358
|
+
const senderProfileId = readUniqueMailboxExportString(byHeader, MAILBOX_SENDER_PROFILE_HEADERS, index, "sender_profile_id", (value) => value.trim().toLowerCase());
|
|
359
|
+
if (senderProfileId &&
|
|
360
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(senderProfileId)) {
|
|
361
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].sender_profile_id must be a sender profile id (UUID). List them with \`oxygen senders profiles list\`, or drop the column and let the row's first/last name name the sender.`, { exitCode: 1 });
|
|
362
|
+
}
|
|
331
363
|
return {
|
|
332
364
|
email_address: email.trim().toLowerCase(),
|
|
333
365
|
provider,
|
|
@@ -338,6 +370,10 @@ function normalizeMailboxExportRow(row, index, mode) {
|
|
|
338
370
|
? { infrastructure_platform: infrastructurePlatform }
|
|
339
371
|
: {}),
|
|
340
372
|
...(tenantId ? { tenant_id: tenantId.trim().toLowerCase() } : {}),
|
|
373
|
+
...(firstName ? { first_name: firstName } : {}),
|
|
374
|
+
...(lastName ? { last_name: lastName } : {}),
|
|
375
|
+
...(displayName ? { display_name: displayName } : {}),
|
|
376
|
+
...(senderProfileId ? { sender_profile_id: senderProfileId } : {}),
|
|
341
377
|
...(mode === "credential" &&
|
|
342
378
|
provider === "google" &&
|
|
343
379
|
distinctPasswords[0] !== undefined
|
|
@@ -345,6 +381,23 @@ function normalizeMailboxExportRow(row, index, mode) {
|
|
|
345
381
|
: {}),
|
|
346
382
|
};
|
|
347
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* One identity half off an export row. Header-bound (the From name rides an SMTP
|
|
386
|
+
* header), so a CR/LF is refused by field name rather than silently stripped — the
|
|
387
|
+
* caller is usually holding hundreds of rows and needs to know which one.
|
|
388
|
+
*/
|
|
389
|
+
function readMailboxIdentityName(byHeader, headers, index, field) {
|
|
390
|
+
const value = readUniqueMailboxExportString(byHeader, headers, index, field, (raw) => raw.trim());
|
|
391
|
+
if (!value)
|
|
392
|
+
return null;
|
|
393
|
+
if (value.length > 200) {
|
|
394
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].${field} must be at most 200 characters.`, { exitCode: 1 });
|
|
395
|
+
}
|
|
396
|
+
if (/[\r\n]/.test(value)) {
|
|
397
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].${field} must be a single line.`, { exitCode: 1 });
|
|
398
|
+
}
|
|
399
|
+
return value;
|
|
400
|
+
}
|
|
348
401
|
function normalizeMailboxExportHeader(value) {
|
|
349
402
|
return value
|
|
350
403
|
.replace(/^\uFEFF/, "")
|
|
@@ -78,6 +78,14 @@ export type PlanLimits = {
|
|
|
78
78
|
*/
|
|
79
79
|
export declare const TABLE_IMPORT_ROW_LIMIT: 3000000;
|
|
80
80
|
export { VERCEL_REQUEST_BODY_LIMIT_BYTES } from "./import-limits.js";
|
|
81
|
+
/**
|
|
82
|
+
* OXYGEN's own JSON-body ceiling for every `/api/cli/*` route (enforced by
|
|
83
|
+
* `assertCliJsonBodyWithinLimit` on the content-length header). Shared so the CLI
|
|
84
|
+
* pre-splits a row batch by measured bytes instead of learning the number from a
|
|
85
|
+
* 413. Must stay below VERCEL_REQUEST_BODY_LIMIT_BYTES, and must never be raised
|
|
86
|
+
* in the CLI alone — an older server would still 413 at the old number.
|
|
87
|
+
*/
|
|
88
|
+
export declare const MAX_CLI_JSON_BODY_BYTES = 2000000;
|
|
81
89
|
/**
|
|
82
90
|
* The per-rung limit matrix. `ai_live` deliberately equals `tool_live` at every
|
|
83
91
|
* rung — one "live actions" mental model; the per-call cost asymmetry between
|