@percepta/create 3.4.2 → 3.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percepta/create",
3
- "version": "3.4.2",
3
+ "version": "3.4.3",
4
4
  "description": "Scaffold a new Mosaic package",
5
5
  "keywords": [
6
6
  "cli",
@@ -158,7 +158,7 @@ logger.info({ safe: { requestId }, unsafe: { email } }, "User action completed")
158
158
  logger.error({ safe: { documentId } }, "Processing failed", error);
159
159
  ```
160
160
 
161
- The app's logger is initialized in `src/services/logger/AppLogger.ts` using `createLogFactory()` and `createTracerFactory()` from this package. It uses `AsyncLocalStorage` for automatic request context propagation.
161
+ The app's logger is initialized in `src/services/logger/AppLogger.ts` using `createLoggerRuntime()` from this package. It uses `AsyncLocalStorage` for automatic request context propagation.
162
162
 
163
163
  ### @percepta/utils — Shared Utilities
164
164
 
@@ -43,14 +43,14 @@ schemas validate `event.data`, so do not wrap payload schemas in another
43
43
 
44
44
  ### 1. Create a function collection
45
45
 
46
- Group related functions into a collection class that implements `InngestFunctionCollection`:
46
+ Group related functions into a collection class that implements `PerceptaInngestFunctionCollection`:
47
47
 
48
48
  ```typescript
49
+ import { type PerceptaInngestFunctionCollection } from "@percepta/inngest";
49
50
  import { type InngestFunction } from "inngest";
50
- import { type InngestFunctionCollection } from "../InngestFunctionCollection";
51
51
  import { InngestService } from "../InngestService";
52
52
 
53
- export class DocumentFunctions implements InngestFunctionCollection {
53
+ export class DocumentFunctions implements PerceptaInngestFunctionCollection {
54
54
  private inngestService = InngestService.create();
55
55
 
56
56
  public get functions(): InngestFunction.Like[] {
@@ -85,9 +85,9 @@ export class DocumentFunctions implements InngestFunctionCollection {
85
85
  Add your collection to the `functionCollections` array in the Inngest serve endpoint:
86
86
 
87
87
  ```typescript
88
- const functionCollections: InngestFunctionCollection[] = compact([
88
+ const functionCollections: PerceptaInngestFunctionCollection[] = [
89
89
  new DocumentFunctions(),
90
- ]);
90
+ ];
91
91
  ```
92
92
 
93
93
  ## Sending Events
@@ -21,14 +21,14 @@ Langfuse is an open-source LLM observability platform. It captures traces, spans
21
21
  The template uses Next.js's instrumentation hook (called on server startup) to bootstrap OTEL with both the environment collector and optional Langfuse:
22
22
 
23
23
  ```typescript
24
- import { LangfuseSpanProcessor } from "@langfuse/otel";
25
- import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
26
24
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
25
+ import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
26
+ import { createLangfuseSpanProcessor } from "@percepta/ai";
27
27
 
28
28
  const sdk = new NodeSDK({
29
29
  spanProcessors: [
30
30
  new tracing.BatchSpanProcessor(otlpTraceExporter),
31
- new LangfuseSpanProcessor({ baseUrl, publicKey, secretKey }),
31
+ createLangfuseSpanProcessor(env, logger),
32
32
  ],
33
33
  instrumentations: [getNodeAutoInstrumentations()],
34
34
  });
@@ -37,7 +37,7 @@ sdk.start();
37
37
 
38
38
  - `getNodeAutoInstrumentations()` automatically instruments HTTP calls, database queries, and other standard Node.js operations.
39
39
  - The OTLP span processor forwards general server traces to the environment collector.
40
- - `LangfuseSpanProcessor` forwards only AI SDK spans to Langfuse when all `LANGFUSE_*` variables are set. General HTTP, DB, and server spans stay in the OTEL/LGTM pipeline and are not sent to Langfuse by default.
40
+ - The shared Langfuse span processor forwards only AI SDK spans to Langfuse when all `LANGFUSE_*` variables are set. General HTTP, DB, and server spans stay in the OTEL/LGTM pipeline and are not sent to Langfuse by default.
41
41
 
42
42
  **You do not need to manually instrument LLM calls.** Use `LLMService` from `src/services/llm/LLMService.ts`; it calls the Vercel AI SDK with telemetry enabled and provider/model metadata attached.
43
43
 
@@ -30,7 +30,7 @@ const stream = LLMService.create().streamText({
30
30
 
31
31
  ## Providers
32
32
 
33
- `LlmProviderService` chooses a provider at call time:
33
+ The shared `@percepta/ai` provider helper chooses a provider at call time:
34
34
 
35
35
  1. `LLM_PROVIDER`, when explicitly set.
36
36
  2. Anthropic when `ANTHROPIC_API_KEY` is available.
@@ -33,8 +33,6 @@
33
33
  "test:watch": "vitest"
34
34
  },
35
35
  "dependencies": {
36
- "@ai-sdk/anthropic": "^2.0.23",
37
- "@ai-sdk/openai": "^2.0.23",
38
36
  "@aws-sdk/client-s3": "^3.888.0",
39
37
  "@aws-sdk/client-secrets-manager": "^3.914.0",
40
38
  "@aws-sdk/client-sts": "^3.913.0",
@@ -48,9 +46,6 @@
48
46
  "@grafana/faro-web-sdk": "^1.14.0",
49
47
  "@grafana/faro-web-tracing": "^1.14.0",
50
48
  "@hookform/resolvers": "^5.2.2",
51
- "@inngest/middleware-validation": "^0.0.5",
52
- "@langfuse/otel": "^4.0.0",
53
- "@langfuse/tracing": "^4.0.0",
54
49
  "@mantine/hooks": "^8.3.1",
55
50
  "@next/env": "^15.3.5",
56
51
  "@opentelemetry/api": "^1.9.0",
@@ -58,10 +53,12 @@
58
53
  "@opentelemetry/exporter-trace-otlp-proto": "^0.203.0",
59
54
  "@opentelemetry/sdk-node": "^0.203.0",
60
55
  "@__REPO_NAME__/auth": "workspace:*",
61
- "@percepta/access-control": "0.6.1",
56
+ "@percepta/access-control": "0.7.0",
57
+ "@percepta/ai": "0.1.0",
62
58
  "@percepta/design": "0.3.2",
63
- "@percepta/logger": "0.0.6",
64
- "@percepta/next-utils": "0.1.0",
59
+ "@percepta/inngest": "0.1.0",
60
+ "@percepta/logger": "0.1.0",
61
+ "@percepta/next-utils": "0.2.0",
65
62
  "@percepta/utils": "0.1.10",
66
63
  "@radix-ui/react-slot": "^1.2.3",
67
64
  "@tanstack/react-query": "^5.81.5",
@@ -69,7 +66,6 @@
69
66
  "@trpc/client": "^11.4.3",
70
67
  "@trpc/server": "^11.4.3",
71
68
  "@trpc/tanstack-react-query": "^11.4.3",
72
- "ai": "^5.0.24",
73
69
  "better-auth": "^1.6.4",
74
70
  "clsx": "^2.1.1",
75
71
  "date-fns": "^4.1.0",
@@ -80,15 +76,12 @@
80
76
  "import-in-the-middle": "^1.14.2",
81
77
  "inngest": "^3.44.3",
82
78
  "keyed-type-union": "^0.1.0",
83
- "langfuse": "^3.38.6",
84
79
  "lodash-es": "^4.17.21",
85
80
  "lucide-react": "^0.542.0",
86
81
  "mime-types": "^3.0.1",
87
82
  "next": "^15.3.5",
88
83
  "numeral": "^2.0.6",
89
84
  "pg": "^8.16.3",
90
- "pino": "^10.1.0",
91
- "pino-pretty": "^13.1.3",
92
85
  "pluralize": "^8.0.0",
93
86
  "react": "^19.0.0",
94
87
  "react-dom": "^19.0.0",
@@ -1,31 +1,21 @@
1
- import { serve } from "inngest/next";
2
- import { compact } from "lodash-es";
3
- import { type InngestFunctionCollection } from "../../../services/inngest/InngestFunctionCollection";
1
+ import {
2
+ createPerceptaInngestNextRouteHandlers,
3
+ type PerceptaInngestFunctionCollection,
4
+ } from "@percepta/inngest";
4
5
  import { InngestService } from "../../../services/inngest/InngestService";
5
6
 
6
7
  export const dynamic = "force-dynamic";
7
8
 
8
- const functionCollections: InngestFunctionCollection[] = compact([]);
9
+ const functionCollections: PerceptaInngestFunctionCollection[] = [];
9
10
 
10
11
  // InngestService.create() reads env vars and throws if INNGEST_BASE_URL is
11
12
  // unset. Defer initialization until request time so `next build` can compile
12
13
  // this route without those vars present.
13
- type Handlers = ReturnType<typeof serve>;
14
+ const inngestHandlers = createPerceptaInngestNextRouteHandlers({
15
+ getService: () => InngestService.create(),
16
+ getFunctions: () => functionCollections.flatMap(({ functions }) => functions),
17
+ });
14
18
 
15
- let cachedHandlers: Handlers | undefined;
16
-
17
- function getHandlers(): Handlers {
18
- if (cachedHandlers == null) {
19
- const inngestService = InngestService.create();
20
- cachedHandlers = serve({
21
- client: inngestService.client,
22
- functions: functionCollections.flatMap(({ functions }) => functions),
23
- signingKey: inngestService.signingKey,
24
- });
25
- }
26
- return cachedHandlers;
27
- }
28
-
29
- export const GET: Handlers["GET"] = (...args) => getHandlers().GET(...args);
30
- export const POST: Handlers["POST"] = (...args) => getHandlers().POST(...args);
31
- export const PUT: Handlers["PUT"] = (...args) => getHandlers().PUT(...args);
19
+ export const GET = inngestHandlers.GET;
20
+ export const POST = inngestHandlers.POST;
21
+ export const PUT = inngestHandlers.PUT;
@@ -1,52 +1,12 @@
1
- import { LangfuseSpanProcessor } from "@langfuse/otel";
2
1
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
3
2
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
4
3
  import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
4
+ import { createLangfuseSpanProcessor } from "@percepta/ai";
5
5
  import { compact } from "lodash-es";
6
6
  import { getEnvConfig } from "./config/getEnvConfig";
7
7
  import { getLogger } from "./services/logger/AppLogger";
8
8
 
9
9
  type SpanProcessor = tracing.SpanProcessor;
10
- type ReadableSpan = Parameters<SpanProcessor["onEnd"]>[0];
11
- type OnStartArgs = Parameters<SpanProcessor["onStart"]>;
12
-
13
- class FilteringSpanProcessor implements SpanProcessor {
14
- public constructor(
15
- private delegate: SpanProcessor,
16
- private shouldExport: (span: ReadableSpan) => boolean,
17
- ) {}
18
-
19
- public forceFlush(): Promise<void> {
20
- return this.delegate.forceFlush();
21
- }
22
-
23
- public onStart(...args: OnStartArgs): void {
24
- this.delegate.onStart(...args);
25
- }
26
-
27
- public onEnd(span: ReadableSpan): void {
28
- if (this.shouldExport(span)) {
29
- this.delegate.onEnd(span);
30
- }
31
- }
32
-
33
- public shutdown(): Promise<void> {
34
- return this.delegate.shutdown();
35
- }
36
- }
37
-
38
- function isAiSdkSpan(span: ReadableSpan): boolean {
39
- const operationId = span.attributes["ai.operationId"];
40
- if (typeof operationId === "string" && operationId.startsWith("ai.")) {
41
- return true;
42
- }
43
-
44
- if (span.name.startsWith("ai.")) {
45
- return true;
46
- }
47
-
48
- return Object.keys(span.attributes).some((key) => key.startsWith("gen_ai."));
49
- }
50
10
 
51
11
  function getOtlpTracesEndpoint(): string | undefined {
52
12
  const {
@@ -90,28 +50,7 @@ function getOtlpSpanProcessor(): tracing.BatchSpanProcessor | undefined {
90
50
  }
91
51
 
92
52
  function getLangfuseSpanProcessor(): SpanProcessor | undefined {
93
- const {
94
- LANGFUSE_BASE_URL: baseUrl,
95
- LANGFUSE_PUBLIC_KEY: publicKey,
96
- LANGFUSE_SECRET_KEY: secretKey,
97
- } = getEnvConfig();
98
- if (!baseUrl || !publicKey || !secretKey) {
99
- getLogger().debug(
100
- undefined,
101
- "Langfuse environment is incomplete. Skipping Langfuse OpenTelemetry.",
102
- );
103
- return undefined;
104
- }
105
-
106
- getLogger().debug(undefined, "Registering Langfuse OpenTelemetry.");
107
- return new FilteringSpanProcessor(
108
- new LangfuseSpanProcessor({
109
- baseUrl,
110
- publicKey,
111
- secretKey,
112
- }),
113
- isAiSdkSpan,
114
- );
53
+ return createLangfuseSpanProcessor(getEnvConfig(), getLogger());
115
54
  }
116
55
 
117
56
  const spanProcessors: tracing.SpanProcessor[] = compact([
@@ -1,8 +1,5 @@
1
- import type { PermissionClient, SubjectRef } from "@percepta/access-control";
2
- import {
3
- createRequireApplicationAccess,
4
- createRequirePermission,
5
- } from "@percepta/access-control/trpc";
1
+ import type { SubjectRef } from "@percepta/access-control";
2
+ import { createAppAccessControlMiddlewares } from "@percepta/access-control/trpc";
6
3
  import { TRPCError, initTRPC } from "@trpc/server";
7
4
  import superjson from "superjson";
8
5
  import { accessManifest } from "../access/access.manifest";
@@ -25,12 +22,6 @@ export interface ProtectedContext extends Context {
25
22
  session: BetterAuthSession;
26
23
  }
27
24
 
28
- const lazyPermissionClient = {
29
- can(check) {
30
- return getAccessControl().permissions.can(check);
31
- },
32
- } satisfies Pick<PermissionClient, "can">;
33
-
34
25
  function getCurrentSubject(ctx: Context): SubjectRef | null {
35
26
  const userId = ctx.session?.user.id;
36
27
  return userId == null ? null : toUserSubject(userId);
@@ -76,17 +67,14 @@ const requireAuthenticatedUser = procedure.use(
76
67
  },
77
68
  );
78
69
 
79
- const requireApplicationAccess =
80
- createRequireApplicationAccess<ProtectedContext>({
81
- accessControl: lazyPermissionClient,
70
+ const { requireApplicationAccess, requirePermission } =
71
+ createAppAccessControlMiddlewares<ProtectedContext>({
72
+ getAccessControl,
82
73
  getSubject: getCurrentSubject,
83
74
  manifest: accessManifest,
84
75
  });
85
76
 
86
- export const requirePermission = createRequirePermission<ProtectedContext>({
87
- accessControl: lazyPermissionClient,
88
- getSubject: getCurrentSubject,
89
- });
77
+ export { requirePermission };
90
78
 
91
79
  export const protectedProcedure = requireAuthenticatedUser;
92
80
 
@@ -1,63 +1,11 @@
1
- import { trace } from "@opentelemetry/api";
2
- import { TRPCError } from "@trpc/server";
1
+ import {
2
+ createAuthContextServiceFactory,
3
+ type AuthContextService as PerceptaAuthContextService,
4
+ } from "@percepta/next-utils";
3
5
  import type { BetterAuthSession } from "../lib/auth";
4
6
 
5
7
  export type AuthSession = BetterAuthSession;
6
8
 
7
- export class AuthContextService {
8
- private static SINGLETON: AuthContextService | undefined;
9
- public static create(): AuthContextService {
10
- if (AuthContextService.SINGLETON == null) {
11
- AuthContextService.SINGLETON = new AuthContextService();
12
- }
13
-
14
- return AuthContextService.SINGLETON;
15
- }
16
-
17
- private authAsyncLocalStorage = new AsyncLocalStorage<LocalStorage>();
18
-
19
- private constructor() {}
20
-
21
- public withSession<TReturn>(
22
- session: AuthSession,
23
- callback: () => TReturn,
24
- ): TReturn {
25
- // Set user email on the active OpenTelemetry span
26
- const activeSpan = trace.getActiveSpan();
27
- if (
28
- activeSpan != null &&
29
- session.user.email != null &&
30
- session.user.id != null
31
- ) {
32
- activeSpan.setAttribute("user.email", session.user.email);
33
- activeSpan.setAttribute("user.id", session.user.id);
34
- }
35
-
36
- return this.authAsyncLocalStorage.run<TReturn>({ session }, () =>
37
- callback(),
38
- );
39
- }
40
-
41
- public getSession(): AuthSession | undefined {
42
- const context = this.authAsyncLocalStorage.getStore();
43
- if (context == null) {
44
- return undefined;
45
- }
46
-
47
- const { session } = context;
48
- return session;
49
- }
50
-
51
- public getSessionOrThrow(): AuthSession {
52
- const session = this.getSession();
53
- if (session == null) {
54
- throw new TRPCError({ code: "UNAUTHORIZED" });
55
- }
56
-
57
- return session;
58
- }
59
- }
60
-
61
- interface LocalStorage {
62
- session: AuthSession;
63
- }
9
+ export const AuthContextService =
10
+ createAuthContextServiceFactory<AuthSession>();
11
+ export type AuthContextService = PerceptaAuthContextService<AuthSession>;
@@ -1,5 +1,9 @@
1
- import { validationMiddleware } from "@inngest/middleware-validation";
2
- import { EventSchemas, Inngest } from "inngest";
1
+ import {
2
+ createPerceptaInngestServiceFactory,
3
+ type PerceptaInngest,
4
+ type PerceptaInngestService,
5
+ } from "@percepta/inngest";
6
+ import { EventSchemas } from "inngest";
3
7
  import { getEnvConfig } from "../../config/getEnvConfig";
4
8
  import { AppEvents } from "./events/AppEvents";
5
9
 
@@ -8,64 +12,12 @@ export interface PollOpts {
8
12
  delayMs: number;
9
13
  }
10
14
 
11
- export interface AppInngest extends AppInngestInternal {
12
- // Externally, we should never call createFunction on the client interface.
13
- createFunction: never;
14
- }
15
- type AppInngestInternal = Inngest<{
16
- id: string;
17
- schemas: typeof EVENT_SCHEMAS;
18
- }>;
19
-
20
- export class InngestService {
21
- private static SINGLETON: InngestService | undefined;
22
- public static create(): InngestService {
23
- if (InngestService.SINGLETON == null) {
24
- InngestService.SINGLETON = new InngestService();
25
- }
26
- return InngestService.SINGLETON;
27
- }
28
-
29
- private _signingKey: string | undefined;
30
- private inngest: AppInngestInternal;
31
-
32
- private constructor() {
33
- const {
34
- INNGEST_BASE_URL: baseUrl,
35
- INNGEST_SIGNING_KEY: signingKey,
36
- INNGEST_EVENT_KEY: eventKey,
37
- } = getEnvConfig();
38
-
39
- if (baseUrl == null) {
40
- throw new Error("INNGEST_BASE_URL is not set.");
41
- }
42
-
43
- this._signingKey = signingKey;
44
- this.inngest = new Inngest({
45
- id: "__APP_NAME__",
46
- eventKey,
47
- baseUrl,
48
- schemas: EVENT_SCHEMAS,
49
- middleware: [validationMiddleware()],
50
- });
51
- }
52
-
53
- public get client(): AppInngest {
54
- return this.inngest as never;
55
- }
56
-
57
- public get signingKey(): string | undefined {
58
- return this._signingKey;
59
- }
60
-
61
- public createFunction: Inngest.CreateFunction<AppInngest> = (
62
- options,
63
- trigger,
64
- handler,
65
- ) => {
66
- // TODO: Add context plumbing here.
67
- return this.inngest.createFunction(options, trigger, handler) as never;
68
- };
69
- }
70
-
71
15
  const EVENT_SCHEMAS = new EventSchemas().fromSchema(AppEvents);
16
+
17
+ export type AppInngest = PerceptaInngest<typeof EVENT_SCHEMAS>;
18
+ export const InngestService = createPerceptaInngestServiceFactory({
19
+ appId: "__APP_NAME__",
20
+ getEnv: getEnvConfig,
21
+ schemas: EVENT_SCHEMAS,
22
+ });
23
+ export type InngestService = PerceptaInngestService<typeof EVENT_SCHEMAS>;
@@ -1,80 +1,12 @@
1
- import { Langfuse } from "langfuse";
1
+ import {
2
+ createPerceptaLangfuseServiceFactory,
3
+ type PerceptaLangfuseService,
4
+ } from "@percepta/ai";
2
5
  import { getEnvConfig } from "../../config/getEnvConfig";
3
6
  import { getLogger } from "../logger/AppLogger";
4
7
 
5
- export class LangfuseService {
6
- private static SINGLETON: LangfuseService | undefined;
7
-
8
- public static create(): LangfuseService {
9
- if (LangfuseService.SINGLETON == null) {
10
- LangfuseService.SINGLETON = new LangfuseService();
11
- }
12
- return LangfuseService.SINGLETON;
13
- }
14
-
15
- private readonly client: Langfuse | undefined;
16
-
17
- private constructor() {
18
- const config = getEnvConfig();
19
- const baseUrl = config.LANGFUSE_BASE_URL;
20
- const publicKey = config.LANGFUSE_PUBLIC_KEY;
21
- const secretKey = config.LANGFUSE_SECRET_KEY;
22
-
23
- if (baseUrl && publicKey && secretKey) {
24
- this.client = new Langfuse({
25
- baseUrl,
26
- publicKey,
27
- secretKey,
28
- });
29
- }
30
- }
31
-
32
- /**
33
- * Check if Langfuse is configured.
34
- */
35
- public isConfigured(): boolean {
36
- return this.client != null;
37
- }
38
-
39
- /**
40
- * Update a Langfuse trace with metadata.
41
- * Uses trace.update() which merges with existing metadata.
42
- *
43
- * @param traceId - The trace ID (same as documentPipelineRuns.id)
44
- * @param metadata - The metadata to save (must be JSON-serializable)
45
- */
46
- public async updateTraceMetadata<T extends object>(
47
- traceId: string,
48
- metadata: T,
49
- ): Promise<void> {
50
- if (!this.isConfigured()) {
51
- getLogger().warn(
52
- { safe: { traceId } },
53
- "[LangfuseService] Langfuse not configured, skipping trace update",
54
- );
55
- return;
56
- }
57
-
58
- // Capture client after isConfigured() guard for type narrowing
59
- const client = this.client!;
60
-
61
- try {
62
- // Attach to existing trace by ID and update metadata
63
- const trace = client.trace({ id: traceId });
64
- trace.update({ metadata });
65
-
66
- // Flush to ensure the update is sent
67
- await client.flushAsync();
68
-
69
- getLogger().info(
70
- { safe: { traceId } },
71
- "[LangfuseService] Updated trace with metadata",
72
- );
73
- } catch (error) {
74
- getLogger().error(
75
- { safe: { traceId }, unsafe: { error: String(error) } },
76
- "[LangfuseService] Failed to update trace metadata",
77
- );
78
- }
79
- }
80
- }
8
+ export const LangfuseService = createPerceptaLangfuseServiceFactory({
9
+ getConfig: getEnvConfig,
10
+ getLogger,
11
+ });
12
+ export type LangfuseService = PerceptaLangfuseService;
@@ -1,88 +1,10 @@
1
- import { generateText, streamText } from "ai";
2
- import { type LlmProviderName, LlmProviderService } from "./LlmProviderService";
3
-
4
- type GenerateTextOptions = Omit<Parameters<typeof generateText>[0], "model"> & {
5
- modelId?: string;
6
- provider?: LlmProviderName;
7
- telemetryFunctionId?: string;
8
- };
9
-
10
- type StreamTextOptions = Omit<Parameters<typeof streamText>[0], "model"> & {
11
- modelId?: string;
12
- provider?: LlmProviderName;
13
- telemetryFunctionId?: string;
14
- };
15
-
16
- export class LLMService {
17
- private static SINGLETON: LLMService | undefined;
18
-
19
- public static create(): LLMService {
20
- if (LLMService.SINGLETON == null) {
21
- LLMService.SINGLETON = new LLMService(LlmProviderService.create());
22
- }
23
-
24
- return LLMService.SINGLETON;
25
- }
26
-
27
- private constructor(private llmProviderService: LlmProviderService) {}
28
-
29
- public generateText(
30
- options: GenerateTextOptions,
31
- ): ReturnType<typeof generateText> {
32
- const { modelId, provider, telemetryFunctionId, ...generateOptions } =
33
- options;
34
- const selection = this.llmProviderService.getLanguageModel({
35
- modelId,
36
- provider,
37
- });
38
-
39
- const aiOptions = {
40
- ...generateOptions,
41
- model: selection.model,
42
- experimental_telemetry: {
43
- ...generateOptions.experimental_telemetry,
44
- isEnabled: generateOptions.experimental_telemetry?.isEnabled ?? true,
45
- functionId:
46
- telemetryFunctionId ??
47
- generateOptions.experimental_telemetry?.functionId ??
48
- "llm.generateText",
49
- metadata: {
50
- ...generateOptions.experimental_telemetry?.metadata,
51
- llmProvider: selection.provider,
52
- llmModel: selection.modelId,
53
- },
54
- },
55
- } as Parameters<typeof generateText>[0];
56
-
57
- return generateText(aiOptions);
58
- }
59
-
60
- public streamText(options: StreamTextOptions): ReturnType<typeof streamText> {
61
- const { modelId, provider, telemetryFunctionId, ...streamOptions } =
62
- options;
63
- const selection = this.llmProviderService.getLanguageModel({
64
- modelId,
65
- provider,
66
- });
67
-
68
- const aiOptions = {
69
- ...streamOptions,
70
- model: selection.model,
71
- experimental_telemetry: {
72
- ...streamOptions.experimental_telemetry,
73
- isEnabled: streamOptions.experimental_telemetry?.isEnabled ?? true,
74
- functionId:
75
- telemetryFunctionId ??
76
- streamOptions.experimental_telemetry?.functionId ??
77
- "llm.streamText",
78
- metadata: {
79
- ...streamOptions.experimental_telemetry?.metadata,
80
- llmProvider: selection.provider,
81
- llmModel: selection.modelId,
82
- },
83
- },
84
- } as Parameters<typeof streamText>[0];
85
-
86
- return streamText(aiOptions);
87
- }
88
- }
1
+ import {
2
+ createPerceptaLlmServiceFactory,
3
+ type PerceptaLlmService,
4
+ } from "@percepta/ai";
5
+ import { getEnvConfig } from "../../config/getEnvConfig";
6
+
7
+ export const LLMService = createPerceptaLlmServiceFactory({
8
+ getConfig: getEnvConfig,
9
+ });
10
+ export type LLMService = PerceptaLlmService;
@@ -1,11 +1,4 @@
1
- import {
2
- type MosaicLogger,
3
- createLogFactory,
4
- createTracerFactory,
5
- } from "@percepta/logger";
6
- import { assertNever } from "@percepta/utils";
7
- import pino, { type Logger } from "pino";
8
- import pretty from "pino-pretty";
1
+ import { createLoggerRuntime } from "@percepta/logger";
9
2
  import { getEnvConfig } from "../../config/getEnvConfig";
10
3
 
11
4
  /**
@@ -19,43 +12,5 @@ import { getEnvConfig } from "../../config/getEnvConfig";
19
12
  * All logs automatically include request context (reqId, method, path, host)
20
13
  * when called within a request scope established by withLogContext().
21
14
  */
22
- export const { getLogger, withLogContext } = createLogFactory(
23
- createBasePinoInstance(),
24
- () => new AsyncLocalStorage<MosaicLogger>(),
25
- );
26
-
27
- /**
28
- * Tracer for request context propagation.
29
- * Uses AsyncLocalStorage to pass trace IDs through async boundaries.
30
- */
31
- export const { getTracer, withTracer } = createTracerFactory(
32
- () => new AsyncLocalStorage(),
33
- );
34
-
35
- // TODO(@dzhao): Generalize this and move to createPinoInstance.ts.
36
- function createBasePinoInstance(): Logger {
37
- const { NODE_ENV: nodeEnv, LOG_LEVEL: level } = getEnvConfig();
38
-
39
- switch (nodeEnv) {
40
- case "production":
41
- return pino({
42
- level,
43
- formatters: {
44
- level: (label) => ({ level: label }),
45
- },
46
- });
47
- case "development":
48
- case "test":
49
- // Development: use pino-pretty synchronously (avoids worker thread issues with Next.js/Turbopack)
50
- return pino(
51
- { level },
52
- pretty({
53
- colorize: true,
54
- translateTime: "SYS:standard",
55
- ignore: "pid,hostname",
56
- }),
57
- );
58
- default:
59
- return assertNever(nodeEnv);
60
- }
61
- }
15
+ export const { getLogger, getTracer, withLogContext, withTracer } =
16
+ createLoggerRuntime({ env: getEnvConfig() });
@@ -1,62 +1,10 @@
1
+ import { syncPerceptaInngestApp } from "@percepta/inngest";
1
2
  import { getEnvConfig } from "../config/getEnvConfig";
2
3
  import { getLogger } from "../services/logger/AppLogger";
3
4
 
4
5
  export async function syncInngestApp(): Promise<void> {
5
- const {
6
- SKIP_INNGEST_SYNC: skipSync,
7
- INNGEST_BASE_URL: inngestServerUrl,
8
- APP_BASE_URL: appBaseUrl,
9
- INNGEST_APP_URL,
10
- } = getEnvConfig();
11
-
12
- // Cascade: explicit INNGEST_APP_URL > derived from APP_BASE_URL > localhost fallback
13
- const appUrl =
14
- INNGEST_APP_URL ??
15
- (appBaseUrl
16
- ? `${appBaseUrl}/api/inngest`
17
- : "http://localhost:3000/api/inngest");
18
-
19
- if (skipSync) {
20
- getLogger().info(undefined, "SKIP_INNGEST_SYNC=true, skipping auto-sync");
21
- return;
22
- }
23
-
24
- // INNGEST_BASE_URL is required
25
- if (!inngestServerUrl) {
26
- getLogger().error(
27
- undefined,
28
- "INNGEST_BASE_URL environment variable is not set, skipping sync",
29
- );
30
- return;
31
- }
32
-
33
- const gqlEndpoint = `${inngestServerUrl}/v0/gql`;
34
-
35
- getLogger().info(
36
- { safe: { gqlEndpoint, appUrl } },
37
- "Syncing with Inngest Server",
38
- );
39
-
40
- const payload = {
41
- operationName: "CreateApp",
42
- query:
43
- "mutation CreateApp($input: CreateAppInput!) {\n createApp(input: $input) {\n url\n }\n}\n",
44
- variables: {
45
- input: {
46
- url: appUrl,
47
- },
48
- },
49
- };
50
-
51
- const response = await fetch(gqlEndpoint, {
52
- method: "POST",
53
- headers: {
54
- "Content-Type": "application/json",
55
- },
56
- body: JSON.stringify(payload),
6
+ await syncPerceptaInngestApp({
7
+ getEnv: getEnvConfig,
8
+ logger: getLogger(),
57
9
  });
58
-
59
- if (response.ok) {
60
- getLogger().info(undefined, "Successfully synced with Inngest Server");
61
- }
62
10
  }
@@ -1,5 +0,0 @@
1
- import { type InngestFunction } from "inngest";
2
-
3
- export interface InngestFunctionCollection {
4
- functions: InngestFunction.Like[];
5
- }
@@ -1,85 +0,0 @@
1
- import { createAnthropic } from "@ai-sdk/anthropic";
2
- import { createOpenAI } from "@ai-sdk/openai";
3
- import { type LanguageModel } from "ai";
4
- import { getEnvConfig } from "../../config/getEnvConfig";
5
-
6
- export type LlmProviderName = "anthropic" | "openai";
7
-
8
- export interface LanguageModelSelection {
9
- model: LanguageModel;
10
- modelId: string;
11
- provider: LlmProviderName;
12
- }
13
-
14
- interface GetLanguageModelOptions {
15
- modelId?: string;
16
- provider?: LlmProviderName;
17
- }
18
-
19
- const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5-20250929";
20
- const DEFAULT_OPENAI_MODEL = "gpt-4.1";
21
-
22
- export class LlmProviderService {
23
- private static SINGLETON: LlmProviderService | undefined;
24
-
25
- public static create(): LlmProviderService {
26
- if (LlmProviderService.SINGLETON == null) {
27
- LlmProviderService.SINGLETON = new LlmProviderService();
28
- }
29
-
30
- return LlmProviderService.SINGLETON;
31
- }
32
-
33
- private constructor() {}
34
-
35
- public getLanguageModel(
36
- options: GetLanguageModelOptions = {},
37
- ): LanguageModelSelection {
38
- const config = getEnvConfig();
39
- const provider =
40
- options.provider ??
41
- config.LLM_PROVIDER ??
42
- (config.ANTHROPIC_API_KEY ? "anthropic" : undefined) ??
43
- (config.OPENAI_API_KEY ? "openai" : undefined);
44
-
45
- if (provider === "anthropic") {
46
- if (!config.ANTHROPIC_API_KEY) {
47
- throw new Error(
48
- "LLM_PROVIDER=anthropic but ANTHROPIC_API_KEY is not configured.",
49
- );
50
- }
51
-
52
- const modelId =
53
- options.modelId ?? config.LLM_MODEL ?? DEFAULT_ANTHROPIC_MODEL;
54
- return {
55
- provider,
56
- modelId,
57
- model: createAnthropic({
58
- apiKey: config.ANTHROPIC_API_KEY,
59
- }).languageModel(modelId),
60
- };
61
- }
62
-
63
- if (provider === "openai") {
64
- if (!config.OPENAI_API_KEY) {
65
- throw new Error(
66
- "LLM_PROVIDER=openai but OPENAI_API_KEY is not configured.",
67
- );
68
- }
69
-
70
- const modelId =
71
- options.modelId ?? config.LLM_MODEL ?? DEFAULT_OPENAI_MODEL;
72
- return {
73
- provider,
74
- modelId,
75
- model: createOpenAI({
76
- apiKey: config.OPENAI_API_KEY,
77
- }).languageModel(modelId),
78
- };
79
- }
80
-
81
- throw new Error(
82
- "No LLM provider is configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY locally, or deploy to an environment with a shared LLM variable group.",
83
- );
84
- }
85
- }