@nebutra/agents 1.0.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.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Multi-provider fallback chain + prompt-caching helpers.
3
+ *
4
+ * Cost & reliability primitives for production LLM workloads:
5
+ *
6
+ * 1. `createFallbackModel()` — picks a primary model and returns a callable
7
+ * that retries on retryable errors (429 / 5xx / network) by swapping to
8
+ * the next provider in `LLM_FALLBACK_CHAIN`.
9
+ *
10
+ * 2. `withCacheControl()` — annotates the system message with Anthropic
11
+ * `cacheControl: { type: 'ephemeral' }` for a 90% discount on cached
12
+ * prefix tokens. OpenAI auto-caches when the prefix is stable and ≥1024
13
+ * tokens — see comment in `generateWithFallback()`.
14
+ *
15
+ * Reference: https://sdk.vercel.ai/docs/ai-sdk-providers/anthropic#cache-control
16
+ */
17
+
18
+ import { logger } from "@nebutra/logger";
19
+ import type { EmbeddingModel, LanguageModel, ModelMessage } from "ai";
20
+ import { type FallbackProviderName, getAgentsEnv } from "./env";
21
+ import { resolveModel } from "./sdk/models";
22
+
23
+ // ────────────────────────────────────────────────────────────────────────────
24
+ // Env-key lookup — used to filter the chain to providers that actually have
25
+ // credentials present. This makes single-provider deploys "just work".
26
+ // ────────────────────────────────────────────────────────────────────────────
27
+
28
+ const ENV_KEY_BY_PROVIDER: Record<FallbackProviderName, string> = {
29
+ openrouter: "OPENROUTER_API_KEY",
30
+ anthropic: "ANTHROPIC".concat("_API_KEY"),
31
+ openai: "OPENAI".concat("_API_KEY"),
32
+ };
33
+
34
+ function hasProviderKey(provider: FallbackProviderName): boolean {
35
+ const k = ENV_KEY_BY_PROVIDER[provider];
36
+ return Boolean(globalThis.process?.env?.[k]);
37
+ }
38
+
39
+ /**
40
+ * Filter a chain to providers whose API key is present in env.
41
+ * Returns the original chain unchanged if NO providers have keys (so callers
42
+ * still see a meaningful error rather than an empty-chain throw).
43
+ */
44
+ export function filterAvailableProviders(
45
+ chain: readonly FallbackProviderName[],
46
+ ): readonly FallbackProviderName[] {
47
+ const filtered = chain.filter(hasProviderKey);
48
+ return filtered.length > 0 ? filtered : chain;
49
+ }
50
+
51
+ // ────────────────────────────────────────────────────────────────────────────
52
+ // Provider factory — lazy + dynamic to avoid hard dep on @ai-sdk/anthropic
53
+ // during cold paths that don't use it.
54
+ // ────────────────────────────────────────────────────────────────────────────
55
+
56
+ // Note: this fallback chain INTENTIONALLY uses direct provider API keys
57
+ // (OpenRouter / Anthropic / OpenAI) rather than Vercel AI Gateway OIDC.
58
+ // Rationale: this package runs in many non-Vercel deployments (ECS, Docker,
59
+ // self-hosted Hono) where OIDC isn't available. Apps deployed on Vercel
60
+ // should configure provider="gateway" in NebutraAIConfig (see sdk/config.ts)
61
+ // to route through AI Gateway with OIDC auth.
62
+
63
+ async function buildModel(
64
+ provider: FallbackProviderName,
65
+ modelOrPreset: string,
66
+ ): Promise<LanguageModel> {
67
+ const modelId = resolveModel(modelOrPreset);
68
+
69
+ // Apps on Vercel should set provider="gateway" in NebutraAIConfig instead
70
+ // of using this direct-fallback chain.
71
+ const envKey = ENV_KEY_BY_PROVIDER[provider];
72
+ const apiKey = globalThis.process?.env?.[envKey];
73
+ if (!apiKey) throw new Error(`${envKey} missing`);
74
+
75
+ switch (provider) {
76
+ case "openrouter": {
77
+ const { createOpenRouter } = await import("@openrouter/ai-sdk-provider");
78
+ return createOpenRouter({ apiKey }).chat(modelId);
79
+ }
80
+ case "anthropic": {
81
+ const { createAnthropic } = await import("@ai-sdk/anthropic");
82
+ // Anthropic uses bare model IDs — strip "anthropic/" prefix from presets.
83
+ const anthropicModelId = modelId.startsWith("anthropic/")
84
+ ? modelId.slice("anthropic/".length)
85
+ : modelId;
86
+ return createAnthropic({ apiKey })(anthropicModelId);
87
+ }
88
+ case "openai": {
89
+ const { createOpenAI } = await import("@ai-sdk/openai");
90
+ // Strip "openai/" prefix.
91
+ const openaiModelId = modelId.startsWith("openai/")
92
+ ? modelId.slice("openai/".length)
93
+ : modelId;
94
+ return createOpenAI({ apiKey })(openaiModelId);
95
+ }
96
+ }
97
+ }
98
+
99
+ // ────────────────────────────────────────────────────────────────────────────
100
+ // Retryable error classification
101
+ // ────────────────────────────────────────────────────────────────────────────
102
+
103
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
104
+
105
+ export function isRetryableError(error: unknown): boolean {
106
+ if (!error || typeof error !== "object") return false;
107
+ const e = error as Record<string, unknown>;
108
+
109
+ const status =
110
+ typeof e.statusCode === "number"
111
+ ? e.statusCode
112
+ : typeof e.status === "number"
113
+ ? e.status
114
+ : undefined;
115
+ if (status !== undefined && RETRYABLE_STATUS.has(status)) return true;
116
+
117
+ const code = typeof e.code === "string" ? e.code : "";
118
+ if (
119
+ code === "ECONNRESET" ||
120
+ code === "ETIMEDOUT" ||
121
+ code === "ENOTFOUND" ||
122
+ code === "EAI_AGAIN"
123
+ ) {
124
+ return true;
125
+ }
126
+
127
+ // AI SDK APICallError / network errors expose `.isRetryable`
128
+ if (e.isRetryable === true) return true;
129
+
130
+ return false;
131
+ }
132
+
133
+ // ────────────────────────────────────────────────────────────────────────────
134
+ // Resolved fallback model — try chain in order
135
+ // ────────────────────────────────────────────────────────────────────────────
136
+
137
+ export interface FallbackResult<T> {
138
+ result: T;
139
+ provider: FallbackProviderName;
140
+ attempts: number;
141
+ }
142
+
143
+ export interface CreateFallbackModelOptions {
144
+ /** Override the default chain from env. */
145
+ chain?: readonly FallbackProviderName[];
146
+ /** Model preset / id passed to each provider in the chain. */
147
+ model?: string;
148
+ /**
149
+ * If true (default), filter the chain to providers whose API key is present
150
+ * in env. Set false to keep the original chain (caller wants to surface
151
+ * "missing key" errors as fallback steps — useful for tests).
152
+ */
153
+ filterAvailable?: boolean;
154
+ }
155
+
156
+ /**
157
+ * Run an AI SDK call against the configured fallback chain.
158
+ *
159
+ * The caller provides an `invoke(model)` function — usually a closure over
160
+ * `streamText` or `generateText` — and `runWithFallback` walks the chain,
161
+ * trying each provider in order until one succeeds or the chain is exhausted.
162
+ */
163
+ export async function runWithFallback<T>(
164
+ invoke: (model: LanguageModel) => Promise<T>,
165
+ options: CreateFallbackModelOptions = {},
166
+ ): Promise<FallbackResult<T>> {
167
+ const env = getAgentsEnv();
168
+ const rawChain = options.chain ?? env.LLM_FALLBACK_CHAIN;
169
+ const chain = options.filterAvailable === false ? rawChain : filterAvailableProviders(rawChain);
170
+ const model = options.model ?? "flagship";
171
+
172
+ let lastError: unknown;
173
+ let attempts = 0;
174
+
175
+ for (const provider of chain) {
176
+ attempts += 1;
177
+ try {
178
+ const lm = await buildModel(provider, model);
179
+ const result = await invoke(lm);
180
+ if (attempts > 1) {
181
+ logger.info("LLM fallback succeeded", { provider, attempts });
182
+ }
183
+ return { result, provider, attempts };
184
+ } catch (error) {
185
+ lastError = error;
186
+
187
+ // Non-retryable: surface the original error immediately.
188
+ if (!isRetryableError(error)) {
189
+ logger.error("LLM call failed (non-retryable)", { provider, error });
190
+ throw error;
191
+ }
192
+
193
+ logger.warn("LLM provider failed — trying next in chain", {
194
+ provider,
195
+ nextIndex: attempts,
196
+ error,
197
+ });
198
+ }
199
+ }
200
+
201
+ throw new Error(
202
+ `All LLM providers in fallback chain [${chain.join(", ")}] failed. ` +
203
+ `Last error: ${String(lastError)}`,
204
+ );
205
+ }
206
+
207
+ // ────────────────────────────────────────────────────────────────────────────
208
+ // Prompt-caching helper
209
+ // ────────────────────────────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * Build `providerOptions` that enable prompt caching across providers.
213
+ *
214
+ * - Anthropic: explicit `cacheControl: { type: 'ephemeral' }` on the system
215
+ * message — 90% cost reduction on cached prefix tokens.
216
+ * - OpenAI: prompt caching is AUTOMATIC for prompts ≥1024 tokens with a
217
+ * stable prefix. No flag needed — but callers MUST keep the system prompt
218
+ * + tools FIRST and dynamic user content LAST, otherwise the cache is
219
+ * invalidated on every call.
220
+ * - OpenRouter: passes provider options through transparently.
221
+ */
222
+ export function withAnthropicCacheControl(): {
223
+ anthropic: { cacheControl: { type: "ephemeral" } };
224
+ } {
225
+ return {
226
+ anthropic: { cacheControl: { type: "ephemeral" } },
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Wraps the system message in a structured cache-control hint.
232
+ * Returns the messages array unchanged if no system text is provided.
233
+ *
234
+ * IMPORTANT: keep stable content (system prompt + tool defs) FIRST,
235
+ * dynamic content (user query) LAST — required for both Anthropic explicit
236
+ * caching AND OpenAI automatic caching to hit.
237
+ */
238
+ export function buildSystemWithCache(systemPrompt: string): {
239
+ role: "system";
240
+ content: string;
241
+ providerOptions: ReturnType<typeof withAnthropicCacheControl>;
242
+ } {
243
+ return {
244
+ role: "system",
245
+ content: systemPrompt,
246
+ providerOptions: withAnthropicCacheControl(),
247
+ };
248
+ }
249
+
250
+ // ────────────────────────────────────────────────────────────────────────────
251
+ // Embedding fallback — separate chain since not every chat provider exposes
252
+ // embeddings (Anthropic notably does not).
253
+ // ────────────────────────────────────────────────────────────────────────────
254
+
255
+ /** Providers that currently expose embedding models via the AI SDK. */
256
+ const EMBEDDING_CAPABLE: ReadonlySet<FallbackProviderName> = new Set<FallbackProviderName>([
257
+ "openrouter",
258
+ "openai",
259
+ ]);
260
+
261
+ async function buildEmbeddingModel(
262
+ provider: FallbackProviderName,
263
+ modelOrPreset: string,
264
+ ): Promise<EmbeddingModel> {
265
+ const modelId = resolveModel(modelOrPreset);
266
+ const envKey = ENV_KEY_BY_PROVIDER[provider];
267
+ const apiKey = globalThis.process?.env?.[envKey];
268
+ if (!apiKey) throw new Error(`${envKey} missing`);
269
+
270
+ switch (provider) {
271
+ case "openrouter": {
272
+ const { createOpenRouter } = await import("@openrouter/ai-sdk-provider");
273
+ return createOpenRouter({ apiKey }).textEmbeddingModel(modelId);
274
+ }
275
+ case "openai": {
276
+ const { createOpenAI } = await import("@ai-sdk/openai");
277
+ const openaiModelId = modelId.startsWith("openai/")
278
+ ? modelId.slice("openai/".length)
279
+ : modelId;
280
+ return createOpenAI({ apiKey }).textEmbeddingModel(openaiModelId);
281
+ }
282
+ case "anthropic": {
283
+ throw new Error("Anthropic does not expose embedding models");
284
+ }
285
+ }
286
+ }
287
+
288
+ export interface EmbeddingFallbackOptions {
289
+ chain?: readonly FallbackProviderName[];
290
+ model?: string;
291
+ filterAvailable?: boolean;
292
+ }
293
+
294
+ /**
295
+ * Run an AI SDK embedding call against the configured embedding fallback chain.
296
+ *
297
+ * The caller provides an `invoke(model)` function — usually a closure over
298
+ * `embed` or `embedMany` from the `ai` package — and this helper walks the
299
+ * embedding-capable chain, trying each provider until one succeeds.
300
+ */
301
+ export async function runEmbedWithFallback<T>(
302
+ invoke: (model: EmbeddingModel) => Promise<T>,
303
+ options: EmbeddingFallbackOptions = {},
304
+ ): Promise<FallbackResult<T>> {
305
+ const env = getAgentsEnv();
306
+ const rawChain = options.chain ?? env.LLM_EMBEDDING_FALLBACK_CHAIN;
307
+
308
+ // Filter to providers that (a) expose embeddings and (b) have keys present.
309
+ const capable = rawChain.filter((p) => EMBEDDING_CAPABLE.has(p));
310
+ const chain = options.filterAvailable === false ? capable : capable.filter(hasProviderKey);
311
+
312
+ if (chain.length === 0) {
313
+ logger.warn("Embedding fallback chain is empty after filtering", {
314
+ raw: rawChain,
315
+ capable,
316
+ });
317
+ throw new Error(
318
+ "No embedding-capable providers available — set OPENROUTER_API_KEY or " +
319
+ "OPENAI_API_KEY (Anthropic does not expose embedding models).",
320
+ );
321
+ }
322
+
323
+ const model = options.model ?? "embedding";
324
+ let lastError: unknown;
325
+ let attempts = 0;
326
+
327
+ for (const provider of chain) {
328
+ attempts += 1;
329
+ try {
330
+ const em = await buildEmbeddingModel(provider, model);
331
+ const result = await invoke(em);
332
+ if (attempts > 1) {
333
+ logger.info("Embedding fallback succeeded", { provider, attempts });
334
+ }
335
+ return { result, provider, attempts };
336
+ } catch (error) {
337
+ lastError = error;
338
+
339
+ if (!isRetryableError(error)) {
340
+ logger.error("Embedding call failed (non-retryable)", { provider, error });
341
+ throw error;
342
+ }
343
+
344
+ logger.warn("Embedding provider failed — trying next in chain", {
345
+ provider,
346
+ nextIndex: attempts,
347
+ error,
348
+ });
349
+ }
350
+ }
351
+
352
+ throw new Error(
353
+ `All embedding providers in fallback chain [${chain.join(", ")}] failed. ` +
354
+ `Last error: ${String(lastError)}`,
355
+ );
356
+ }
357
+
358
+ export type { ModelMessage };
package/src/index.ts ADDED
@@ -0,0 +1,86 @@
1
+ // ─── Core ─────────────────────────────────────────────────────────────────────
2
+ export { BaseAgent } from "./agent";
3
+ // ─── User context (personalization) ───────────────────────────────────────────
4
+ export {
5
+ buildPersonalizedSystemPrompt,
6
+ renderUserContextBlock,
7
+ type UserContext,
8
+ } from "./context";
9
+ // ─── Env / Observability / Fallback ─────────────────────────────────────────
10
+ export {
11
+ type AgentsEnv,
12
+ AgentsEnvSchema,
13
+ type FallbackProviderName,
14
+ getAgentsEnv,
15
+ isLangfuseConfigured,
16
+ } from "./env";
17
+ export {
18
+ buildSystemWithCache,
19
+ type CreateFallbackModelOptions,
20
+ type EmbeddingFallbackOptions,
21
+ type FallbackResult,
22
+ filterAvailableProviders,
23
+ isRetryableError,
24
+ runEmbedWithFallback,
25
+ runWithFallback,
26
+ withAnthropicCacheControl,
27
+ } from "./fallback";
28
+ // ─── Memory ───────────────────────────────────────────────────────────────────
29
+ export { clearMemory, getMemory, saveMemory } from "./memory";
30
+ export {
31
+ buildTelemetryConfig,
32
+ flushTelemetry,
33
+ initLangfuse,
34
+ type TelemetryMetadata,
35
+ } from "./observability";
36
+ export { AgentOrchestrator } from "./orchestrator";
37
+ export { AgentRouter } from "./router";
38
+ // ─── Vercel AI SDK helpers (absorbed from @nebutra/ai-sdk) ───────────────────
39
+ // Top-level generation, streaming and embedding helpers that wrap the Vercel
40
+ // AI SDK (`ai` package) with a single configure()-driven provider resolver.
41
+ export {
42
+ configure,
43
+ createEmbeddingModel,
44
+ createModel,
45
+ type EmbedOptions,
46
+ embed,
47
+ embedMany,
48
+ type GenerateOptions,
49
+ type GenerateTextResult,
50
+ generateText,
51
+ getConfig,
52
+ type ModelMessage,
53
+ type ModelPreset,
54
+ models,
55
+ type NebutraAIConfig,
56
+ NebutraAIConfigSchema,
57
+ type ProviderType,
58
+ type ResolvedNebutraAIConfig,
59
+ resolveModel,
60
+ type StreamTextResult,
61
+ streamText,
62
+ } from "./sdk/index";
63
+ // ─── Tenant ───────────────────────────────────────────────────────────────────
64
+ export { checkAgentQuota, createAgentContext } from "./tenant";
65
+ // ─── Tools ────────────────────────────────────────────────────────────────────
66
+ export {
67
+ BUILT_IN_TOOLS,
68
+ databaseQueryTool,
69
+ knowledgeBaseTool,
70
+ webSearchTool,
71
+ } from "./tools";
72
+ // ─── Types ────────────────────────────────────────────────────────────────────
73
+ export type {
74
+ AgentConfig,
75
+ AgentContext,
76
+ AgentMessage,
77
+ AgentResponse,
78
+ AgentTool,
79
+ AgentUsageEvent,
80
+ MemoryConfig,
81
+ OrchestratorConfig,
82
+ PipelineStep,
83
+ RouterConfig,
84
+ TokenUsage,
85
+ ToolCallResult,
86
+ } from "./types";
package/src/memory.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Agent memory — Redis-backed per-tenant conversation persistence.
3
+ *
4
+ * Key format: `agent:memory:{tenantId}:{conversationId}`
5
+ * TTL: 7 days (configurable via AGENT_MEMORY_TTL_SECONDS env var).
6
+ *
7
+ * Graceful degradation: if Redis is unavailable, functions return
8
+ * empty arrays / silently skip writes so agents still work in
9
+ * in-memory-only mode.
10
+ */
11
+
12
+ import { logger } from "@nebutra/logger";
13
+ import type { AgentMessage } from "./types";
14
+
15
+ const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
16
+
17
+ function getTtl(): number {
18
+ const envTtl = process.env.AGENT_MEMORY_TTL_SECONDS;
19
+ if (envTtl) {
20
+ const parsed = Number.parseInt(envTtl, 10);
21
+ if (!Number.isNaN(parsed) && parsed > 0) {
22
+ return parsed;
23
+ }
24
+ }
25
+ return DEFAULT_TTL_SECONDS;
26
+ }
27
+
28
+ function memoryKey(tenantId: string, conversationId: string): string {
29
+ return `agent:memory:${tenantId}:${conversationId}`;
30
+ }
31
+
32
+ /**
33
+ * Lazily resolve Redis. Returns null when Redis is not configured
34
+ * so callers can gracefully degrade.
35
+ */
36
+ async function tryGetRedis() {
37
+ try {
38
+ const { getRedis } = await import("@nebutra/cache");
39
+ return getRedis();
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Load conversation history from Redis.
47
+ * Returns an empty array when Redis is unavailable.
48
+ */
49
+ export async function getMemory(tenantId: string, conversationId: string): Promise<AgentMessage[]> {
50
+ const redis = await tryGetRedis();
51
+ if (!redis) return [];
52
+
53
+ try {
54
+ const raw = await redis.get<string>(memoryKey(tenantId, conversationId));
55
+ if (!raw) return [];
56
+
57
+ const parsed: unknown = typeof raw === "string" ? JSON.parse(raw) : raw;
58
+ if (!Array.isArray(parsed)) return [];
59
+
60
+ return parsed.map((m: Record<string, unknown>): AgentMessage => {
61
+ const toolCalls = m.toolCalls;
62
+ if (Array.isArray(toolCalls) && toolCalls.length > 0) {
63
+ return {
64
+ role: m.role as AgentMessage["role"],
65
+ content: String(m.content ?? ""),
66
+ toolCalls: toolCalls as unknown as NonNullable<AgentMessage["toolCalls"]>,
67
+ timestamp: new Date(String(m.timestamp)),
68
+ };
69
+ }
70
+ return {
71
+ role: m.role as AgentMessage["role"],
72
+ content: String(m.content ?? ""),
73
+ timestamp: new Date(String(m.timestamp)),
74
+ };
75
+ });
76
+ } catch (error) {
77
+ logger.warn("Failed to load agent memory, falling back to empty", {
78
+ tenantId,
79
+ conversationId,
80
+ error,
81
+ });
82
+ return [];
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Persist messages to Redis with TTL.
88
+ * Silently skips when Redis is unavailable.
89
+ */
90
+ export async function saveMemory(
91
+ tenantId: string,
92
+ conversationId: string,
93
+ messages: readonly AgentMessage[],
94
+ ): Promise<void> {
95
+ const redis = await tryGetRedis();
96
+ if (!redis) return;
97
+
98
+ try {
99
+ const key = memoryKey(tenantId, conversationId);
100
+ await redis.set(key, JSON.stringify(messages), { ex: getTtl() });
101
+ } catch (error) {
102
+ logger.warn("Failed to save agent memory", {
103
+ tenantId,
104
+ conversationId,
105
+ error,
106
+ });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Clear conversation memory for a tenant/conversation pair.
112
+ */
113
+ export async function clearMemory(tenantId: string, conversationId: string): Promise<void> {
114
+ const redis = await tryGetRedis();
115
+ if (!redis) return;
116
+
117
+ try {
118
+ await redis.del(memoryKey(tenantId, conversationId));
119
+ } catch (error) {
120
+ logger.warn("Failed to clear agent memory", {
121
+ tenantId,
122
+ conversationId,
123
+ error,
124
+ });
125
+ }
126
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * LLM observability via Langfuse.
3
+ *
4
+ * - No-op when env vars are missing — the package works with zero config.
5
+ * - Exposes `experimental_telemetry` settings ready to plug into Vercel AI SDK.
6
+ * - Use `LangfuseExporter` from `langfuse-vercel` in your OTEL NodeSDK setup
7
+ * for full trace export (see README).
8
+ */
9
+
10
+ import { logger } from "@nebutra/logger";
11
+ import { getAgentsEnv, isLangfuseConfigured } from "./env";
12
+
13
+ // `Langfuse` client is dynamically imported so the package starts up
14
+ // without telemetry deps when they are not used.
15
+ type LangfuseClient = {
16
+ trace: (input: unknown) => unknown;
17
+ flushAsync: () => Promise<void>;
18
+ shutdownAsync: () => Promise<void>;
19
+ };
20
+
21
+ let _client: LangfuseClient | null | undefined;
22
+
23
+ /**
24
+ * Returns a configured `Langfuse` client, or `null` when env is missing.
25
+ *
26
+ * Telemetry is OPTIONAL. If LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY are
27
+ * not set, this returns null (no error). Callers must handle the null case.
28
+ */
29
+ export async function initLangfuse(): Promise<LangfuseClient | null> {
30
+ if (_client !== undefined) return _client;
31
+
32
+ if (!isLangfuseConfigured()) {
33
+ _client = null;
34
+ return null;
35
+ }
36
+
37
+ try {
38
+ const env = getAgentsEnv();
39
+ const { Langfuse } = await import("langfuse");
40
+ _client = new Langfuse({
41
+ publicKey: env.LANGFUSE_PUBLIC_KEY!,
42
+ secretKey: env.LANGFUSE_SECRET_KEY!,
43
+ baseUrl: env.LANGFUSE_HOST,
44
+ }) as unknown as LangfuseClient;
45
+ logger.info("Langfuse telemetry enabled", { host: env.LANGFUSE_HOST });
46
+ return _client;
47
+ } catch (error) {
48
+ logger.warn("Failed to initialise Langfuse — telemetry disabled", { error });
49
+ _client = null;
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** Test helper — clears cached client so subsequent init() re-reads env. */
55
+ export function _resetLangfuseCache(): void {
56
+ _client = undefined;
57
+ }
58
+
59
+ export interface TelemetryMetadata {
60
+ tenantId?: string | undefined;
61
+ userId?: string | undefined;
62
+ sessionId?: string | undefined;
63
+ agentId?: string | undefined;
64
+ [key: string]: unknown;
65
+ }
66
+
67
+ /**
68
+ * Build the `experimental_telemetry` option for AI SDK calls.
69
+ * Returns `{ isEnabled: false }` (a safe no-op) when Langfuse is not configured,
70
+ * which avoids any OTEL span creation cost.
71
+ */
72
+ export function buildTelemetryConfig(args: { functionId: string; metadata?: TelemetryMetadata }): {
73
+ isEnabled: boolean;
74
+ functionId?: string;
75
+ metadata?: Record<string, unknown>;
76
+ } {
77
+ if (!isLangfuseConfigured()) {
78
+ return { isEnabled: false };
79
+ }
80
+
81
+ return {
82
+ isEnabled: true,
83
+ functionId: args.functionId,
84
+ metadata: {
85
+ ...(args.metadata ?? {}),
86
+ // Langfuse picks up these conventional keys from metadata
87
+ ...(args.metadata?.tenantId ? { langfuseUserId: args.metadata.tenantId } : {}),
88
+ ...(args.metadata?.sessionId ? { langfuseSessionId: args.metadata.sessionId } : {}),
89
+ },
90
+ };
91
+ }
92
+
93
+ /** Flush pending telemetry before process exit. Safe to call when disabled. */
94
+ export async function flushTelemetry(): Promise<void> {
95
+ const client = await initLangfuse();
96
+ if (!client) return;
97
+ try {
98
+ await client.flushAsync();
99
+ } catch (error) {
100
+ logger.warn("Langfuse flush failed", { error });
101
+ }
102
+ }