@oxygen-agent/cli 1.354.0 → 1.377.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/command-manifest.js +12 -1
- package/dist/help.js +6 -5
- package/dist/index.js +692 -160
- package/node_modules/@oxygen/shared/dist/billing.d.ts +34 -10
- package/node_modules/@oxygen/shared/dist/billing.js +86 -11
- package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +12 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.js +36 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +212 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/index.js +9 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +77 -0
- package/node_modules/@oxygen/shared/dist/langfuse.js +231 -0
- package/node_modules/@oxygen/shared/dist/linkedin-mentions.d.ts +37 -0
- package/node_modules/@oxygen/shared/dist/linkedin-mentions.js +157 -0
- package/node_modules/@oxygen/shared/dist/log.js +15 -2
- package/node_modules/@oxygen/shared/dist/tags.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/tags.js +5 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-agents.d.ts +8 -7
- package/node_modules/@oxygen/shared/dist/workspace-agents.js +34 -7
- package/package.json +4 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical hosted-AI model registry — the single source of truth for which
|
|
3
|
+
* OXYGEN-hosted model backs each reasoning tier of each hosted-AI use case, its
|
|
4
|
+
* human-readable name, its fallback chain, the OpenRouter reasoning effort to
|
|
5
|
+
* request, and the conservative pre-call price estimates used ONLY to size
|
|
6
|
+
* credit reservations (billing always settles on the provider's actual
|
|
7
|
+
* `usage.cost`, never on the numbers here).
|
|
8
|
+
*
|
|
9
|
+
* Two use cases share the registry today:
|
|
10
|
+
* - `ai_column` — managed AI table columns. Its default model ids are
|
|
11
|
+
* re-sourced verbatim by `@oxygen/integrations` (`ai-column-runner.ts`
|
|
12
|
+
* `DEFAULT_REASONING_MODELS`) and `@oxygen/tenant-db` (`ai-models.ts`
|
|
13
|
+
* `DEFAULT_MANAGED_REASONING_MODELS`). Those modules keep owning the
|
|
14
|
+
* `OXYGEN_OPENROUTER_MODEL_*` env-override resolution; this registry only
|
|
15
|
+
* supplies the defaults, so AI-column behavior is byte-for-byte unchanged.
|
|
16
|
+
* - `copilot` — the hosted copilot. Its model + fallback chain accept Doppler
|
|
17
|
+
* overrides (`OXYGEN_COPILOT_MODEL_*` / `OXYGEN_COPILOT_FALLBACK_*`) applied
|
|
18
|
+
* here by `resolveHostedAiModel`.
|
|
19
|
+
*
|
|
20
|
+
* Kept as a leaf module (mirrors ./pricing-sheet.ts): no imports, so any package
|
|
21
|
+
* can re-source the catalog without dragging in the shared barrel.
|
|
22
|
+
*/
|
|
23
|
+
export const HOSTED_AI_MODEL_REGISTRY = {
|
|
24
|
+
ai_column: {
|
|
25
|
+
low: {
|
|
26
|
+
model: "deepseek/deepseek-v4-flash",
|
|
27
|
+
displayName: "DeepSeek V4 Flash",
|
|
28
|
+
fallbackModels: [],
|
|
29
|
+
estPromptUsdPerM: 0.09,
|
|
30
|
+
estCompletionUsdPerM: 0.18,
|
|
31
|
+
maxOutputTokens: 4096,
|
|
32
|
+
},
|
|
33
|
+
medium: {
|
|
34
|
+
model: "deepseek/deepseek-v4-pro",
|
|
35
|
+
displayName: "DeepSeek V4 Pro",
|
|
36
|
+
fallbackModels: [],
|
|
37
|
+
estPromptUsdPerM: 0.435,
|
|
38
|
+
estCompletionUsdPerM: 0.87,
|
|
39
|
+
maxOutputTokens: 4096,
|
|
40
|
+
},
|
|
41
|
+
high: {
|
|
42
|
+
model: "moonshotai/kimi-k2.6",
|
|
43
|
+
displayName: "Kimi K2.6",
|
|
44
|
+
fallbackModels: [],
|
|
45
|
+
estPromptUsdPerM: 0.66,
|
|
46
|
+
estCompletionUsdPerM: 3.41,
|
|
47
|
+
maxOutputTokens: 4096,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
copilot: {
|
|
51
|
+
low: {
|
|
52
|
+
model: "deepseek/deepseek-v4-flash",
|
|
53
|
+
displayName: "DeepSeek V4 Flash",
|
|
54
|
+
fallbackModels: [],
|
|
55
|
+
reasoningEffort: "medium",
|
|
56
|
+
estPromptUsdPerM: 0.09,
|
|
57
|
+
estCompletionUsdPerM: 0.18,
|
|
58
|
+
maxOutputTokens: 8192,
|
|
59
|
+
},
|
|
60
|
+
medium: {
|
|
61
|
+
model: "deepseek/deepseek-v4-pro",
|
|
62
|
+
displayName: "DeepSeek V4 Pro",
|
|
63
|
+
fallbackModels: [],
|
|
64
|
+
reasoningEffort: "high",
|
|
65
|
+
estPromptUsdPerM: 0.435,
|
|
66
|
+
estCompletionUsdPerM: 0.87,
|
|
67
|
+
maxOutputTokens: 8192,
|
|
68
|
+
},
|
|
69
|
+
high: {
|
|
70
|
+
model: "moonshotai/kimi-k3",
|
|
71
|
+
displayName: "Kimi K3",
|
|
72
|
+
fallbackModels: ["moonshotai/kimi-k2.6"],
|
|
73
|
+
// No reasoningEffort: Kimi K3 is max-only; the request builder omits the
|
|
74
|
+
// reasoning param for this tier.
|
|
75
|
+
estPromptUsdPerM: 3,
|
|
76
|
+
estCompletionUsdPerM: 15,
|
|
77
|
+
maxOutputTokens: 8192,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
agent: {
|
|
81
|
+
low: {
|
|
82
|
+
model: "deepseek/deepseek-v4-flash",
|
|
83
|
+
displayName: "DeepSeek V4 Flash",
|
|
84
|
+
fallbackModels: [],
|
|
85
|
+
reasoningEffort: "medium",
|
|
86
|
+
estPromptUsdPerM: 0.09,
|
|
87
|
+
estCompletionUsdPerM: 0.18,
|
|
88
|
+
maxOutputTokens: 8192,
|
|
89
|
+
},
|
|
90
|
+
medium: {
|
|
91
|
+
model: "deepseek/deepseek-v4-pro",
|
|
92
|
+
displayName: "DeepSeek V4 Pro",
|
|
93
|
+
fallbackModels: ["deepseek/deepseek-v4-flash"],
|
|
94
|
+
reasoningEffort: "high",
|
|
95
|
+
estPromptUsdPerM: 0.435,
|
|
96
|
+
estCompletionUsdPerM: 0.87,
|
|
97
|
+
maxOutputTokens: 8192,
|
|
98
|
+
},
|
|
99
|
+
high: {
|
|
100
|
+
model: "moonshotai/kimi-k2.6",
|
|
101
|
+
displayName: "Kimi K2.6",
|
|
102
|
+
fallbackModels: ["deepseek/deepseek-v4-pro"],
|
|
103
|
+
estPromptUsdPerM: 0.66,
|
|
104
|
+
estCompletionUsdPerM: 3.41,
|
|
105
|
+
maxOutputTokens: 8192,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
/** The reasoning tier the hosted copilot runs at by default. */
|
|
110
|
+
export const COPILOT_DEFAULT_LEVEL = "high";
|
|
111
|
+
export const AGENT_DEFAULT_LEVEL = "medium";
|
|
112
|
+
const LEVEL_ENV_SUFFIX = {
|
|
113
|
+
low: "LOW",
|
|
114
|
+
medium: "MEDIUM",
|
|
115
|
+
high: "HIGH",
|
|
116
|
+
};
|
|
117
|
+
function readTrimmedEnv(env, key) {
|
|
118
|
+
const raw = env[key];
|
|
119
|
+
if (typeof raw !== "string")
|
|
120
|
+
return undefined;
|
|
121
|
+
const trimmed = raw.trim();
|
|
122
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
123
|
+
}
|
|
124
|
+
function parseFallbackCsv(raw) {
|
|
125
|
+
return raw
|
|
126
|
+
.split(",")
|
|
127
|
+
.map((entry) => entry.trim())
|
|
128
|
+
.filter((entry) => entry.length > 0);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the model spec for a hosted-AI use case + reasoning level.
|
|
132
|
+
*
|
|
133
|
+
* For `copilot`, Doppler overrides are applied here:
|
|
134
|
+
* - `OXYGEN_COPILOT_MODEL_{LOW,MEDIUM,HIGH}` replaces `.model` (and, since no
|
|
135
|
+
* separate label is supplied, `.displayName` falls back to the model id).
|
|
136
|
+
* - `OXYGEN_COPILOT_FALLBACK_{LOW,MEDIUM,HIGH}` replaces `.fallbackModels`
|
|
137
|
+
* from a comma-separated list.
|
|
138
|
+
*
|
|
139
|
+
* For `ai_column`, NO env handling is applied here: `@oxygen/integrations`
|
|
140
|
+
* `ai-column-runner.ts` (`resolveModel`/`resolveAiColumnTierModels`) and
|
|
141
|
+
* `@oxygen/tenant-db` `ai-models.ts` (`resolveManagedReasoningModels`) remain
|
|
142
|
+
* the sole owners of `OXYGEN_OPENROUTER_MODEL_*` resolution, so the AI-column
|
|
143
|
+
* path is untouched.
|
|
144
|
+
*/
|
|
145
|
+
/**
|
|
146
|
+
* Look a model id up across the priced registry (same-use-case tiers first).
|
|
147
|
+
* A session-level model override must land on a spec that carries ITS OWN
|
|
148
|
+
* pricing/limits — splicing an arbitrary id onto another tier's spec sizes the
|
|
149
|
+
* credit reservation for the wrong model and breaks the spend ceiling.
|
|
150
|
+
*/
|
|
151
|
+
export function findHostedAiModelSpec(model, preferredUseCase) {
|
|
152
|
+
const useCases = Object.keys(HOSTED_AI_MODEL_REGISTRY);
|
|
153
|
+
const ordered = preferredUseCase
|
|
154
|
+
? [preferredUseCase, ...useCases.filter((u) => u !== preferredUseCase)]
|
|
155
|
+
: useCases;
|
|
156
|
+
for (const useCase of ordered) {
|
|
157
|
+
for (const spec of Object.values(HOSTED_AI_MODEL_REGISTRY[useCase])) {
|
|
158
|
+
if (spec.model === model)
|
|
159
|
+
return spec;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
export function resolveHostedAiModel(input) {
|
|
165
|
+
const base = HOSTED_AI_MODEL_REGISTRY[input.useCase][input.level];
|
|
166
|
+
if (input.useCase === "ai_column")
|
|
167
|
+
return base;
|
|
168
|
+
const env = input.env ?? process.env;
|
|
169
|
+
const suffix = LEVEL_ENV_SUFFIX[input.level];
|
|
170
|
+
const envPrefix = input.useCase === "agent" ? "OXYGEN_AGENT" : "OXYGEN_COPILOT";
|
|
171
|
+
const modelOverride = readTrimmedEnv(env, `${envPrefix}_MODEL_${suffix}`);
|
|
172
|
+
const rawFallback = readTrimmedEnv(env, `${envPrefix}_FALLBACK_${suffix}`);
|
|
173
|
+
const fallbackOverride = rawFallback === undefined ? undefined : parseFallbackCsv(rawFallback);
|
|
174
|
+
if (modelOverride === undefined && fallbackOverride === undefined)
|
|
175
|
+
return base;
|
|
176
|
+
// Spread preserves the absence of `reasoningEffort` on max-only tiers.
|
|
177
|
+
const resolved = { ...base };
|
|
178
|
+
if (modelOverride !== undefined) {
|
|
179
|
+
resolved.model = modelOverride;
|
|
180
|
+
resolved.displayName = modelOverride;
|
|
181
|
+
}
|
|
182
|
+
if (fallbackOverride !== undefined) {
|
|
183
|
+
resolved.fallbackModels = fallbackOverride;
|
|
184
|
+
}
|
|
185
|
+
return resolved;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Convenience map of each reasoning tier's default `.model` id for a use case.
|
|
189
|
+
* `@oxygen/integrations` and `@oxygen/tenant-db` re-source their AI-column
|
|
190
|
+
* default catalogs from `hostedAiDefaultModels("ai_column")` so the model ids
|
|
191
|
+
* live in exactly one place. Returns a fresh object each call.
|
|
192
|
+
*/
|
|
193
|
+
export function hostedAiDefaultModels(useCase) {
|
|
194
|
+
const specs = HOSTED_AI_MODEL_REGISTRY[useCase];
|
|
195
|
+
return {
|
|
196
|
+
low: specs.low.model,
|
|
197
|
+
medium: specs.medium.model,
|
|
198
|
+
high: specs.high.model,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Whether the hosted copilot is enabled for this environment. FAIL CLOSED: only
|
|
203
|
+
* the exact strings "1" or "true" (case-insensitive, trimmed) enable it;
|
|
204
|
+
* anything else — unset, "", "0", "false" — leaves it off.
|
|
205
|
+
*/
|
|
206
|
+
export function isCopilotEnabled(env = process.env) {
|
|
207
|
+
const raw = env.OXYGEN_COPILOT_ENABLED;
|
|
208
|
+
if (typeof raw !== "string")
|
|
209
|
+
return false;
|
|
210
|
+
const normalized = raw.trim().toLowerCase();
|
|
211
|
+
return normalized === "1" || normalized === "true";
|
|
212
|
+
}
|
|
@@ -8,16 +8,20 @@ export * from "./cli-envelope.js";
|
|
|
8
8
|
export * from "./cli-login-code.js";
|
|
9
9
|
export * from "./cli-result.js";
|
|
10
10
|
export * from "./column-types.js";
|
|
11
|
+
export * from "./copilot-journeys.js";
|
|
11
12
|
export * from "./credit-guidance.js";
|
|
12
13
|
export * from "./directory.js";
|
|
13
14
|
export * from "./email-tracking-token.js";
|
|
14
15
|
export * from "./email-unsubscribe-token.js";
|
|
15
16
|
export * from "./error-redaction.js";
|
|
17
|
+
export * from "./hosted-ai.js";
|
|
16
18
|
export * from "./identifiers.js";
|
|
17
19
|
export * from "./knowledge-constants.js";
|
|
18
20
|
export * from "./knowledge-links.js";
|
|
19
21
|
export * from "./knowledge-markdown.js";
|
|
20
22
|
export * from "./knowledge-seed-content.js";
|
|
23
|
+
export * from "./langfuse.js";
|
|
24
|
+
export * from "./linkedin-mentions.js";
|
|
21
25
|
export * from "./linkedin-post-url.js";
|
|
22
26
|
export * from "./linkedin-url.js";
|
|
23
27
|
export * from "./linkedin-sequences.js";
|
|
@@ -27,6 +31,7 @@ export * from "./sequence-template.js";
|
|
|
27
31
|
export * from "./sequences.js";
|
|
28
32
|
export * from "./suppression-entries.js";
|
|
29
33
|
export * from "./log.js";
|
|
34
|
+
export { sanitizeLogFields } from "./redaction.js";
|
|
30
35
|
export * from "./provider-request-outcomes.js";
|
|
31
36
|
export * from "./schedule-label.js";
|
|
32
37
|
export * from "./signup-lead-deliveries.js";
|
|
@@ -8,16 +8,20 @@ export * from "./cli-envelope.js";
|
|
|
8
8
|
export * from "./cli-login-code.js";
|
|
9
9
|
export * from "./cli-result.js";
|
|
10
10
|
export * from "./column-types.js";
|
|
11
|
+
export * from "./copilot-journeys.js";
|
|
11
12
|
export * from "./credit-guidance.js";
|
|
12
13
|
export * from "./directory.js";
|
|
13
14
|
export * from "./email-tracking-token.js";
|
|
14
15
|
export * from "./email-unsubscribe-token.js";
|
|
15
16
|
export * from "./error-redaction.js";
|
|
17
|
+
export * from "./hosted-ai.js";
|
|
16
18
|
export * from "./identifiers.js";
|
|
17
19
|
export * from "./knowledge-constants.js";
|
|
18
20
|
export * from "./knowledge-links.js";
|
|
19
21
|
export * from "./knowledge-markdown.js";
|
|
20
22
|
export * from "./knowledge-seed-content.js";
|
|
23
|
+
export * from "./langfuse.js";
|
|
24
|
+
export * from "./linkedin-mentions.js";
|
|
21
25
|
export * from "./linkedin-post-url.js";
|
|
22
26
|
export * from "./linkedin-url.js";
|
|
23
27
|
export * from "./linkedin-sequences.js";
|
|
@@ -27,6 +31,11 @@ export * from "./sequence-template.js";
|
|
|
27
31
|
export * from "./sequences.js";
|
|
28
32
|
export * from "./suppression-entries.js";
|
|
29
33
|
export * from "./log.js";
|
|
34
|
+
// Narrow, deliberate export (ADR 0014): lets telemetry emitters regression-test
|
|
35
|
+
// their field names against the REAL log sanitizer — the unanchored
|
|
36
|
+
// SECRET_KEY_PATTERN redacts any name containing "token", which mocked loggers
|
|
37
|
+
// cannot catch. Not a license to pre-sanitize outside log().
|
|
38
|
+
export { sanitizeLogFields } from "./redaction.js";
|
|
30
39
|
export * from "./provider-request-outcomes.js";
|
|
31
40
|
export * from "./schedule-label.js";
|
|
32
41
|
export * from "./signup-lead-deliveries.js";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Langfuse } from "langfuse";
|
|
2
|
+
type EnvMap = Record<string, string | undefined>;
|
|
3
|
+
export type LlmObservationLevel = "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
|
|
4
|
+
export type LlmTraceBody = {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
sessionId?: string | null;
|
|
8
|
+
userId?: string | null;
|
|
9
|
+
input?: unknown;
|
|
10
|
+
output?: unknown;
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
12
|
+
tags?: string[];
|
|
13
|
+
};
|
|
14
|
+
export type LlmSpanBody = {
|
|
15
|
+
id: string;
|
|
16
|
+
traceId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
input?: unknown;
|
|
19
|
+
output?: unknown;
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
21
|
+
startTime?: Date;
|
|
22
|
+
endTime?: Date;
|
|
23
|
+
level?: LlmObservationLevel;
|
|
24
|
+
statusMessage?: string | null;
|
|
25
|
+
};
|
|
26
|
+
export type LlmGenerationBody = LlmSpanBody & {
|
|
27
|
+
model?: string | null;
|
|
28
|
+
completionStartTime?: Date | null;
|
|
29
|
+
usageDetails?: Record<string, number>;
|
|
30
|
+
costDetails?: Record<string, number>;
|
|
31
|
+
};
|
|
32
|
+
export type LlmEventBody = {
|
|
33
|
+
id: string;
|
|
34
|
+
traceId: string;
|
|
35
|
+
name: string;
|
|
36
|
+
input?: unknown;
|
|
37
|
+
metadata?: Record<string, unknown>;
|
|
38
|
+
startTime?: Date;
|
|
39
|
+
};
|
|
40
|
+
export type LlmTracingClient = {
|
|
41
|
+
trace(body: LlmTraceBody): void;
|
|
42
|
+
span(body: LlmSpanBody): void;
|
|
43
|
+
generation(body: LlmGenerationBody): void;
|
|
44
|
+
event(body: LlmEventBody): void;
|
|
45
|
+
/** Never rejects; bounded at ~5s. */
|
|
46
|
+
flush(): Promise<void>;
|
|
47
|
+
/** Flush + stop background timers. Never rejects; bounded at ~5s. */
|
|
48
|
+
shutdown(): Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
|
|
52
|
+
* "1"/"true" (trimmed, case-insensitive) AND both Langfuse keys are present.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isLlmTracingEnabled(env?: EnvMap): boolean;
|
|
55
|
+
export declare function resolveLlmTracingEnvironment(env?: EnvMap): string;
|
|
56
|
+
type LangfuseLike = Pick<Langfuse, "trace" | "span" | "generation" | "event" | "flushAsync" | "shutdownAsync"> & {
|
|
57
|
+
on?: (event: string, listener: (...args: unknown[]) => void) => void;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Construct a fail-open Langfuse client, or `null` when tracing is disabled or
|
|
61
|
+
* misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
|
|
62
|
+
* this direct factory exists for tests (inject `langfuseImpl`).
|
|
63
|
+
*/
|
|
64
|
+
export declare function createLlmTracingClient(env?: EnvMap, options?: {
|
|
65
|
+
langfuseImpl?: LangfuseLike;
|
|
66
|
+
}): LlmTracingClient | null;
|
|
67
|
+
export declare function getLlmTracingClient(env?: EnvMap): LlmTracingClient | null;
|
|
68
|
+
/** Flush the singleton if it exists. Never rejects. Hang off request/cycle ends. */
|
|
69
|
+
export declare function flushLlmTracing(env?: EnvMap): Promise<void>;
|
|
70
|
+
type FlushScheduler = (task: () => Promise<void>) => void;
|
|
71
|
+
export declare function setLlmTracingFlushScheduler(scheduler: FlushScheduler | null): void;
|
|
72
|
+
/** Request-safe flush trigger: post-response on web, fire-and-forget elsewhere. */
|
|
73
|
+
export declare function scheduleLlmTracingFlush(env?: EnvMap): void;
|
|
74
|
+
/** Shutdown the singleton (worker exit). Never rejects. */
|
|
75
|
+
export declare function shutdownLlmTracing(env?: EnvMap): Promise<void>;
|
|
76
|
+
export declare function __resetLlmTracingForTests(): void;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// LLM-observability transport (ADR 0014): Langfuse is the ONE sanctioned store
|
|
2
|
+
// for full prompt/completion/tool-IO payloads. Axiom stays metadata-only (log.ts
|
|
3
|
+
// redaction drops prompt/input/output-named fields BY DESIGN — that boundary is
|
|
4
|
+
// unchanged), and PostHog stays sanitized product analytics. This module
|
|
5
|
+
// deliberately rides the official `langfuse` SDK's own batched ingestion instead
|
|
6
|
+
// of the OTel pipeline so prompt-bearing telemetry can never reach the Axiom
|
|
7
|
+
// OTLP exporters by construction.
|
|
8
|
+
//
|
|
9
|
+
// Fail-open doctrine: tracing must never fail or stall product work. Every
|
|
10
|
+
// method swallows internally (worst case: one throttled metadata-only warn via
|
|
11
|
+
// log()), `flush()`/`shutdown()` never reject and are time-bounded, and a
|
|
12
|
+
// missing flag/key yields `null` (callers no-op). Deterministic observation ids
|
|
13
|
+
// are the caller's job — Langfuse upserts by id, which is what makes worker
|
|
14
|
+
// lease-reclaim replays converge instead of duplicating.
|
|
15
|
+
import { Langfuse } from "langfuse";
|
|
16
|
+
import { log } from "./log.js";
|
|
17
|
+
const FLUSH_TIMEOUT_MS = 5_000;
|
|
18
|
+
const WARN_THROTTLE_MS = 30_000;
|
|
19
|
+
// Defensive per-field bound, well under Langfuse's ~1 MB event cap. Copilot
|
|
20
|
+
// transcripts max out around 150 KB; anything larger is truncated with an
|
|
21
|
+
// explicit marker rather than risking a rejected ingestion batch.
|
|
22
|
+
const MAX_JSON_FIELD_CHARS = 400_000;
|
|
23
|
+
/**
|
|
24
|
+
* FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
|
|
25
|
+
* "1"/"true" (trimmed, case-insensitive) AND both Langfuse keys are present.
|
|
26
|
+
*/
|
|
27
|
+
export function isLlmTracingEnabled(env = process.env) {
|
|
28
|
+
const raw = env.OXYGEN_LLM_TRACING_ENABLED;
|
|
29
|
+
if (typeof raw !== "string")
|
|
30
|
+
return false;
|
|
31
|
+
const normalized = raw.trim().toLowerCase();
|
|
32
|
+
if (normalized !== "1" && normalized !== "true")
|
|
33
|
+
return false;
|
|
34
|
+
return ((env.LANGFUSE_PUBLIC_KEY?.trim() ?? "") !== "" &&
|
|
35
|
+
(env.LANGFUSE_SECRET_KEY?.trim() ?? "") !== "");
|
|
36
|
+
}
|
|
37
|
+
// The Langfuse "environment" dimension separates dev/prod traces inside one
|
|
38
|
+
// project. Explicit LANGFUSE_TRACING_ENVIRONMENT (Doppler, per config) wins.
|
|
39
|
+
// Defaults per runtime: web has VERCEL_ENV; the Fly worker has NO VERCEL_ENV
|
|
40
|
+
// (and NODE_ENV=production on BOTH worker apps), so mirror the worker telemetry
|
|
41
|
+
// convention (apps/worker/src/telemetry.ts): the Fly-injected app name is the
|
|
42
|
+
// ground truth for prod vs dev — the "-dev" suffix marks the dev app; a
|
|
43
|
+
// cross-env Doppler drift cannot fake it (the OXY-1183 lesson) — then
|
|
44
|
+
// FLY_ENVIRONMENT as the softer signal. Everything else → development.
|
|
45
|
+
export function resolveLlmTracingEnvironment(env = process.env) {
|
|
46
|
+
const explicit = env.LANGFUSE_TRACING_ENVIRONMENT?.trim().toLowerCase();
|
|
47
|
+
if (explicit)
|
|
48
|
+
return explicit.replace(/[^a-z0-9_-]/g, "-");
|
|
49
|
+
if (env.VERCEL_ENV)
|
|
50
|
+
return env.VERCEL_ENV === "production" ? "production" : "development";
|
|
51
|
+
const flyApp = env.FLY_APP_NAME ?? env.FLY_APP;
|
|
52
|
+
if (flyApp)
|
|
53
|
+
return flyApp.includes("-dev") ? "development" : "production";
|
|
54
|
+
const flyEnv = env.FLY_ENVIRONMENT?.trim().toLowerCase();
|
|
55
|
+
if (flyEnv)
|
|
56
|
+
return flyEnv === "production" ? "production" : "development";
|
|
57
|
+
return "development";
|
|
58
|
+
}
|
|
59
|
+
// Bound one JSON-bearing field. Over the cap → an explicit truncation marker
|
|
60
|
+
// (never a silently clipped payload that parses as complete).
|
|
61
|
+
function boundJsonField(value) {
|
|
62
|
+
if (value === undefined || value === null)
|
|
63
|
+
return value;
|
|
64
|
+
let serialized;
|
|
65
|
+
try {
|
|
66
|
+
serialized = JSON.stringify(value) ?? "";
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return { truncated: true, reason: "unserializable" };
|
|
70
|
+
}
|
|
71
|
+
if (serialized.length <= MAX_JSON_FIELD_CHARS)
|
|
72
|
+
return value;
|
|
73
|
+
return {
|
|
74
|
+
truncated: true,
|
|
75
|
+
chars: serialized.length,
|
|
76
|
+
preview: serialized.slice(0, MAX_JSON_FIELD_CHARS),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function compact(body) {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const [key, value] of Object.entries(body)) {
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
continue;
|
|
84
|
+
out[key] = key === "input" || key === "output" ? boundJsonField(value) : value;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
function boundedNever(rejectable, warn) {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const timer = setTimeout(resolve, FLUSH_TIMEOUT_MS);
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
rejectable
|
|
93
|
+
.catch((error) => warn(error))
|
|
94
|
+
.finally(() => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
resolve();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Construct a fail-open Langfuse client, or `null` when tracing is disabled or
|
|
102
|
+
* misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
|
|
103
|
+
* this direct factory exists for tests (inject `langfuseImpl`).
|
|
104
|
+
*/
|
|
105
|
+
export function createLlmTracingClient(env = process.env, options) {
|
|
106
|
+
if (!options?.langfuseImpl && !isLlmTracingEnabled(env))
|
|
107
|
+
return null;
|
|
108
|
+
let lastWarnAtMs = 0;
|
|
109
|
+
const warn = (error, context) => {
|
|
110
|
+
const nowMs = Date.now();
|
|
111
|
+
if (nowMs - lastWarnAtMs < WARN_THROTTLE_MS)
|
|
112
|
+
return;
|
|
113
|
+
lastWarnAtMs = nowMs;
|
|
114
|
+
log("warn", "llm_tracing.ingest_failed", {
|
|
115
|
+
provider: "langfuse",
|
|
116
|
+
error_message: error instanceof Error ? error.message : String(error),
|
|
117
|
+
...context,
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
let sdk;
|
|
121
|
+
try {
|
|
122
|
+
sdk =
|
|
123
|
+
options?.langfuseImpl ??
|
|
124
|
+
new Langfuse({
|
|
125
|
+
publicKey: env.LANGFUSE_PUBLIC_KEY,
|
|
126
|
+
secretKey: env.LANGFUSE_SECRET_KEY,
|
|
127
|
+
...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
|
|
128
|
+
environment: resolveLlmTracingEnvironment(env),
|
|
129
|
+
sdkIntegration: "oxygen",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
warn(error, { stage: "construct" });
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
// The SDK surfaces async ingest failures on its emitter; unheard, they are
|
|
137
|
+
// unhandled rejections. Route them into the throttled warn.
|
|
138
|
+
try {
|
|
139
|
+
sdk.on?.("error", (error) => warn(error, { stage: "ingest" }));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// an emitter-less test double is fine
|
|
143
|
+
}
|
|
144
|
+
const guarded = (fn, stage) => {
|
|
145
|
+
try {
|
|
146
|
+
fn();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
warn(error, { stage });
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
trace: (body) => guarded(() => void sdk.trace(compact(body)), "trace"),
|
|
154
|
+
span: (body) => guarded(() => void sdk.span(compact(body)), "span"),
|
|
155
|
+
generation: (body) => guarded(() => void sdk.generation(compact(body)), "generation"),
|
|
156
|
+
event: (body) => guarded(() => void sdk.event(compact(body)), "event"),
|
|
157
|
+
flush: () => boundedNever(sdk.flushAsync(), (error) => warn(error, { stage: "flush" })),
|
|
158
|
+
shutdown: () => boundedNever(sdk.shutdownAsync(), (error) => warn(error, { stage: "shutdown" })),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// --- Process-wide singleton (both runtimes construct at most one client) ------
|
|
162
|
+
let cached = null;
|
|
163
|
+
function cacheKey(env) {
|
|
164
|
+
return [
|
|
165
|
+
env.OXYGEN_LLM_TRACING_ENABLED ?? "",
|
|
166
|
+
env.LANGFUSE_PUBLIC_KEY ?? "",
|
|
167
|
+
env.LANGFUSE_SECRET_KEY ?? "",
|
|
168
|
+
env.LANGFUSE_BASE_URL ?? "",
|
|
169
|
+
resolveLlmTracingEnvironment(env),
|
|
170
|
+
].join("|");
|
|
171
|
+
}
|
|
172
|
+
export function getLlmTracingClient(env = process.env) {
|
|
173
|
+
const key = cacheKey(env);
|
|
174
|
+
if (cached && cached.key === key)
|
|
175
|
+
return cached.client;
|
|
176
|
+
cached = { key, client: createLlmTracingClient(env) };
|
|
177
|
+
return cached.client;
|
|
178
|
+
}
|
|
179
|
+
/** Flush the singleton if it exists. Never rejects. Hang off request/cycle ends. */
|
|
180
|
+
export function flushLlmTracing(env = process.env) {
|
|
181
|
+
if (!isLlmTracingEnabled(env))
|
|
182
|
+
return Promise.resolve();
|
|
183
|
+
const client = getLlmTracingClient(env);
|
|
184
|
+
return client ? client.flush() : Promise.resolve();
|
|
185
|
+
}
|
|
186
|
+
// Serverless flush scheduling (the axiom-log-shipper pattern): the web runtime
|
|
187
|
+
// registers next/server's after() here at instrumentation time; emission sites
|
|
188
|
+
// then call scheduleLlmTracingFlush() and the flush runs post-response instead
|
|
189
|
+
// of adding latency inside the request. Off-web (worker, tests) there is no
|
|
190
|
+
// scheduler and the flush degrades to fire-and-forget — the worker's cycle-end
|
|
191
|
+
// awaited flush + SDK interval flush are the durability guarantee there.
|
|
192
|
+
//
|
|
193
|
+
// The registration lives on globalThis, not in a module-local: Next.js gives
|
|
194
|
+
// instrumentation.ts and each route handler their own copy of this module, so a
|
|
195
|
+
// module-local set at instrumentation time is invisible to the routes that emit
|
|
196
|
+
// traces — their flushes silently degraded to fire-and-forget, which a Vercel
|
|
197
|
+
// post-response freeze can kill. Same slot pattern as setLogSink and the
|
|
198
|
+
// LinkedIn/WhatsApp quota enforcers. (The `cached` client stays module-local on
|
|
199
|
+
// purpose: each bundle flushing its own client is correct — the run closure
|
|
200
|
+
// below flushes the CALLING bundle's client through the shared scheduler.)
|
|
201
|
+
const FLUSH_SCHEDULER_SLOT = Symbol.for("oxygen.llm_tracing.flush_scheduler");
|
|
202
|
+
export function setLlmTracingFlushScheduler(scheduler) {
|
|
203
|
+
globalThis[FLUSH_SCHEDULER_SLOT] = scheduler;
|
|
204
|
+
}
|
|
205
|
+
/** Request-safe flush trigger: post-response on web, fire-and-forget elsewhere. */
|
|
206
|
+
export function scheduleLlmTracingFlush(env = process.env) {
|
|
207
|
+
if (!isLlmTracingEnabled(env))
|
|
208
|
+
return;
|
|
209
|
+
const run = () => flushLlmTracing(env);
|
|
210
|
+
const flushScheduler = globalThis[FLUSH_SCHEDULER_SLOT] ?? null;
|
|
211
|
+
if (flushScheduler) {
|
|
212
|
+
try {
|
|
213
|
+
flushScheduler(run);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Outside a request scope after() throws — degrade to fire-and-forget.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
void run();
|
|
221
|
+
}
|
|
222
|
+
/** Shutdown the singleton (worker exit). Never rejects. */
|
|
223
|
+
export function shutdownLlmTracing(env = process.env) {
|
|
224
|
+
const client = cached?.client;
|
|
225
|
+
cached = null;
|
|
226
|
+
return client ? client.shutdown() : Promise.resolve();
|
|
227
|
+
}
|
|
228
|
+
export function __resetLlmTracingForTests() {
|
|
229
|
+
cached = null;
|
|
230
|
+
globalThis[FLUSH_SCHEDULER_SLOT] = null;
|
|
231
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile LinkedIn post text + stored mention state into Unipile's post
|
|
3
|
+
* contract: the outgoing text carries `{{i}}` placeholders and `mentions[i]`
|
|
4
|
+
* is `{ name, profile_id }`. Anything else (raw `@handle` text next to a
|
|
5
|
+
* mentions array, Oxygen mention-preview snapshots, unresolved handles) is
|
|
6
|
+
* NOT a tagged mention on LinkedIn — the provider either posts it literally
|
|
7
|
+
* or, worse, name-matches entries into the wrong span of text.
|
|
8
|
+
*
|
|
9
|
+
* Accepted inputs per entry (both arrays are optional):
|
|
10
|
+
* - Unipile-shaped: `{ name, profile_id }` (already the wire contract).
|
|
11
|
+
* - Oxygen preview-shaped (publishing mentions resolve output): uses
|
|
12
|
+
* `displayName`/`providerId` (or snake_case), and is only eligible when
|
|
13
|
+
* `status === "resolved"` and `semantics` is `"mention"` (links and plain
|
|
14
|
+
* references stay as typed).
|
|
15
|
+
*
|
|
16
|
+
* Placement: an entry becomes a tagged mention only when its span can be
|
|
17
|
+
* anchored in the text — an existing `{{i}}` placeholder, the entry's `raw`
|
|
18
|
+
* occurrence, or `@name`/`@identifier`. Entries that cannot be anchored are
|
|
19
|
+
* dropped from the outgoing array so the provider can never guess a span.
|
|
20
|
+
*/
|
|
21
|
+
export type UnipileLinkedInMention = {
|
|
22
|
+
name: string;
|
|
23
|
+
profile_id: string;
|
|
24
|
+
};
|
|
25
|
+
export type CompiledLinkedInMentions = {
|
|
26
|
+
text: string;
|
|
27
|
+
mentions: UnipileLinkedInMention[];
|
|
28
|
+
dropped: Array<{
|
|
29
|
+
name: string | null;
|
|
30
|
+
reason: "unresolved" | "not_in_text" | "invalid";
|
|
31
|
+
}>;
|
|
32
|
+
};
|
|
33
|
+
export declare function compileLinkedInMentions(input: {
|
|
34
|
+
text: string;
|
|
35
|
+
mentions?: unknown;
|
|
36
|
+
mentionPreviews?: unknown;
|
|
37
|
+
}): CompiledLinkedInMentions;
|