@percepta/create 3.1.2 → 3.1.4
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 +3 -4
- package/dist/{chunk-CG7IJSB4.js → chunk-CO3YWUD6.js} +2 -2
- package/dist/{chunk-WMJT7CB5.js → chunk-V5EJIUBJ.js} +5 -2
- package/dist/index.js +21 -53
- package/dist/{init-XDWSYHYK.js → init-EQZ2TCSJ.js} +2 -2
- package/dist/{status-BTHGN6QH.js → status-QW5TQDYY.js} +1 -1
- package/dist/{sync-3Q27L7XZ.js → sync-RLBZDOFB.js} +1 -1
- package/dist/{upstream-C5KFAHVR.js → upstream-TQFVPMEG.js} +1 -1
- package/package.json +1 -1
- package/templates/monorepo/.dockerignore +18 -0
- package/templates/webapp/.github/workflows/__APP_NAME__-ryvn-release.yaml +6 -2
- package/templates/webapp/.github/workflows/__APP_NAME__-terraform-ryvn-release.yaml +98 -0
- package/templates/webapp/AGENTS.md +18 -6
- package/templates/webapp/Dockerfile +16 -7
- package/templates/webapp/README.md +65 -3
- package/templates/webapp/agent-skills/database.md +5 -1
- package/templates/webapp/agent-skills/deploy.md +49 -64
- package/templates/webapp/agent-skills/inngest.md +17 -12
- package/templates/webapp/agent-skills/langfuse.md +15 -14
- package/templates/webapp/agent-skills/llm.md +59 -0
- package/templates/webapp/agent-skills/oneshot.md +15 -2
- package/templates/webapp/agent-skills/ryvn.md +1 -1
- package/templates/webapp/deploy/README.md +34 -33
- package/templates/webapp/deploy/ryvn/__APP_NAME__-terraform.service.yaml +10 -0
- package/templates/webapp/deploy/ryvn/__APP_NAME__.service.yaml +2 -2
- package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__-terraform.env.percepta-test.serviceinstallation.yaml +11 -0
- package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__.env.percepta-test.serviceinstallation.yaml +45 -9
- package/templates/webapp/env.example.template +20 -2
- package/templates/webapp/eslint.config.mjs +6 -0
- package/templates/webapp/next.config.ts +9 -0
- package/templates/webapp/package.json.template +8 -4
- package/templates/webapp/scripts/deploy-percepta-test.ts +1112 -0
- package/templates/webapp/scripts/generate-migrations.ts +28 -0
- package/templates/webapp/scripts/migrate.ts +3 -0
- package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +5 -3
- package/templates/webapp/scripts/with-local-env.ts +75 -0
- package/templates/webapp/src/config/getEnvConfig.ts +14 -0
- package/templates/webapp/src/drizzle/__tests__/migrationSql.test.ts +24 -0
- package/templates/webapp/src/drizzle/migrationSql.ts +8 -0
- package/templates/webapp/src/instrumentation.ts +102 -10
- package/templates/webapp/src/services/inngest/AppWorkflowService.ts +19 -0
- package/templates/webapp/src/services/inngest/__tests__/AppWorkflowService.test.ts +19 -0
- package/templates/webapp/src/services/inngest/events/AppEvents.ts +7 -13
- package/templates/webapp/src/services/inngest/events/payloads/ExampleEventPayload.ts +1 -3
- package/templates/webapp/src/services/llm/LLMService.ts +88 -0
- package/templates/webapp/src/services/llm/LlmProviderService.ts +85 -0
- package/templates/webapp/terraform/schema/main.tf +4 -0
- package/templates/webapp/terraform/schema/outputs.tf +9 -0
- package/templates/webapp/terraform/schema/variables.tf +19 -0
- package/templates/webapp/terraform/schema/versions.tf +38 -0
- package/templates/webapp/.github/workflows/__APP_NAME__-terraform.yml +0 -28
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { normalizeSchemaRelativeReferences } from "../src/drizzle/migrationSql";
|
|
7
|
+
|
|
8
|
+
const migrationsDir = path.resolve("src", "drizzle", "migrations");
|
|
9
|
+
|
|
10
|
+
function main(): void {
|
|
11
|
+
execFileSync("drizzle-kit", ["generate"], {
|
|
12
|
+
cwd: process.cwd(),
|
|
13
|
+
stdio: "inherit",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
for (const fileName of readdirSync(migrationsDir)) {
|
|
17
|
+
if (!fileName.endsWith(".sql")) continue;
|
|
18
|
+
|
|
19
|
+
const filePath = path.join(migrationsDir, fileName);
|
|
20
|
+
const original = readFileSync(filePath, "utf8");
|
|
21
|
+
const normalized = normalizeSchemaRelativeReferences(original);
|
|
22
|
+
if (normalized !== original) {
|
|
23
|
+
writeFileSync(filePath, normalized);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
main();
|
|
@@ -6,10 +6,13 @@ async function main(): Promise<void> {
|
|
|
6
6
|
loadEnvConfig(process.cwd());
|
|
7
7
|
|
|
8
8
|
// Dynamically load because we need to load the environment variables before importing modules that depend on them.
|
|
9
|
+
const { getEnvConfig } = await import("../src/config/getEnvConfig");
|
|
9
10
|
const { client } = await import("../src/drizzle/db");
|
|
11
|
+
const { DATABASE_SCHEMA: databaseSchema } = getEnvConfig();
|
|
10
12
|
|
|
11
13
|
await migrate(drizzle(client), {
|
|
12
14
|
migrationsFolder: "./src/drizzle/migrations",
|
|
15
|
+
...(databaseSchema ? { migrationsSchema: databaseSchema } : {}),
|
|
13
16
|
});
|
|
14
17
|
|
|
15
18
|
await client.end();
|
|
@@ -32,7 +32,9 @@ function parseArgs(argv: string[]): Options {
|
|
|
32
32
|
|
|
33
33
|
for (let index = 0; index < argv.length; index++) {
|
|
34
34
|
const arg = argv[index];
|
|
35
|
-
if (arg === "--
|
|
35
|
+
if (arg === "--") {
|
|
36
|
+
continue;
|
|
37
|
+
} else if (arg === "--yes" || arg === "-y") {
|
|
36
38
|
options.yes = true;
|
|
37
39
|
} else if (arg === "--no-clone") {
|
|
38
40
|
options.noClone = true;
|
|
@@ -357,7 +359,7 @@ function getPrBody(phase: DeployPhase): string {
|
|
|
357
359
|
"1. Let GitOps import the service.",
|
|
358
360
|
"2. Approve/apply the percepta-internal Terraform task if one is created.",
|
|
359
361
|
"3. Push the app to main or run the release workflow so Ryvn has a first release.",
|
|
360
|
-
`4. Open the installation PR with pnpm deploy:percepta-test -- --phase installation --yes.`,
|
|
362
|
+
`4. Open the installation PR with pnpm deploy:percepta-test:pr -- --phase installation --yes.`,
|
|
361
363
|
].join("\n");
|
|
362
364
|
}
|
|
363
365
|
|
|
@@ -383,7 +385,7 @@ function printNextSteps(phase: DeployPhase): void {
|
|
|
383
385
|
);
|
|
384
386
|
console.log("4. Push the app to main or run the release workflow.");
|
|
385
387
|
console.log(
|
|
386
|
-
"5. Run: pnpm deploy:percepta-test -- --phase installation --yes",
|
|
388
|
+
"5. Run: pnpm deploy:percepta-test:pr -- --phase installation --yes",
|
|
387
389
|
);
|
|
388
390
|
return;
|
|
389
391
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
type EnvMap = Record<string, string>;
|
|
9
|
+
|
|
10
|
+
const LOCAL_ENV_PATH = path.join(
|
|
11
|
+
homedir(),
|
|
12
|
+
".config",
|
|
13
|
+
"percepta",
|
|
14
|
+
"create.env",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
function parseEnvFile(filePath: string): EnvMap {
|
|
18
|
+
if (!existsSync(filePath)) return {};
|
|
19
|
+
|
|
20
|
+
const env: EnvMap = {};
|
|
21
|
+
const content = readFileSync(filePath, "utf8");
|
|
22
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
23
|
+
const line = rawLine.trim();
|
|
24
|
+
if (!line || line.startsWith("#")) continue;
|
|
25
|
+
|
|
26
|
+
const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
|
|
27
|
+
const separatorIndex = normalized.indexOf("=");
|
|
28
|
+
if (separatorIndex === -1) continue;
|
|
29
|
+
|
|
30
|
+
const key = normalized.slice(0, separatorIndex).trim();
|
|
31
|
+
const rawValue = normalized.slice(separatorIndex + 1).trim();
|
|
32
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
33
|
+
|
|
34
|
+
env[key] = unquote(rawValue);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return env;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function unquote(value: string): string {
|
|
41
|
+
if (
|
|
42
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
43
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
44
|
+
) {
|
|
45
|
+
return value.slice(1, -1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [command, ...args] = process.argv.slice(2);
|
|
52
|
+
if (!command) {
|
|
53
|
+
throw new Error("Usage: tsx scripts/with-local-env.ts <command> [...args]");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const child = spawn(command, args, {
|
|
57
|
+
env: {
|
|
58
|
+
...parseEnvFile(LOCAL_ENV_PATH),
|
|
59
|
+
...process.env,
|
|
60
|
+
},
|
|
61
|
+
stdio: "inherit",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
child.on("error", (error) => {
|
|
65
|
+
throw error;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
child.on("exit", (code, signal) => {
|
|
69
|
+
if (signal) {
|
|
70
|
+
process.kill(process.pid, signal);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
process.exit(code ?? 1);
|
|
75
|
+
});
|
|
@@ -44,12 +44,26 @@ export const { getEnvConfig, schema: ENV_CONFIG_SCHEMA } = createEnvConfig(
|
|
|
44
44
|
LANGFUSE_PUBLIC_KEY: z.string().optional(),
|
|
45
45
|
LANGFUSE_SECRET_KEY: z.string().optional(),
|
|
46
46
|
|
|
47
|
+
// OpenTelemetry:
|
|
48
|
+
OTEL_SERVICE_NAME: z.string().optional(),
|
|
49
|
+
OTEL_RESOURCE_ATTRIBUTES: z.string().optional(),
|
|
50
|
+
OTEL_TRACES_EXPORTER: z.string().optional(),
|
|
51
|
+
OTEL_METRICS_EXPORTER: z.string().optional(),
|
|
52
|
+
OTEL_EXPORTER_OTLP_PROTOCOL: z.string().optional(),
|
|
53
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
|
|
54
|
+
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: z.string().optional(),
|
|
55
|
+
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: z.string().optional(),
|
|
56
|
+
OTEL_METRIC_EXPORT_INTERVAL: z.string().optional(),
|
|
57
|
+
|
|
47
58
|
// Security:
|
|
48
59
|
ENCRYPTION_SECRET_KEY: z.string().optional(),
|
|
49
60
|
TRIGGER_ENDPOINT_API_KEY: z.string().optional(),
|
|
50
61
|
|
|
51
62
|
// LLM/AI providers:
|
|
63
|
+
ANTHROPIC_API_KEY: z.string().optional(),
|
|
52
64
|
OPENAI_API_KEY: z.string().optional(),
|
|
65
|
+
LLM_PROVIDER: z.enum(["anthropic", "openai"]).optional(),
|
|
66
|
+
LLM_MODEL: z.string().optional(),
|
|
53
67
|
|
|
54
68
|
// Readonly database (scripts):
|
|
55
69
|
READONLY_SECRET_NAME: z.string().optional(),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeSchemaRelativeReferences } from "../migrationSql";
|
|
3
|
+
|
|
4
|
+
describe("normalizeSchemaRelativeReferences", () => {
|
|
5
|
+
it("keeps generated foreign keys schema-relative for DATABASE_SCHEMA deploys", () => {
|
|
6
|
+
expect(
|
|
7
|
+
normalizeSchemaRelativeReferences(
|
|
8
|
+
'ALTER TABLE "child" ADD CONSTRAINT "child_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."parent"("id") ON DELETE cascade;',
|
|
9
|
+
),
|
|
10
|
+
).toBe(
|
|
11
|
+
'ALTER TABLE "child" ADD CONSTRAINT "child_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "parent"("id") ON DELETE cascade;',
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("leaves already schema-relative references unchanged", () => {
|
|
16
|
+
expect(
|
|
17
|
+
normalizeSchemaRelativeReferences(
|
|
18
|
+
'ALTER TABLE "child" ADD CONSTRAINT "child_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "parent"("id");',
|
|
19
|
+
),
|
|
20
|
+
).toBe(
|
|
21
|
+
'ALTER TABLE "child" ADD CONSTRAINT "child_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "parent"("id");',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const PUBLIC_SCHEMA_REFERENCE_PATTERN = /REFERENCES\s+"public"\."([^"]+)"/g;
|
|
2
|
+
|
|
3
|
+
export function normalizeSchemaRelativeReferences(sql: string): string {
|
|
4
|
+
return sql.replace(
|
|
5
|
+
PUBLIC_SCHEMA_REFERENCE_PATTERN,
|
|
6
|
+
(_match, tableName: string) => `REFERENCES "${tableName}"`,
|
|
7
|
+
);
|
|
8
|
+
}
|
|
@@ -1,34 +1,126 @@
|
|
|
1
1
|
import { LangfuseSpanProcessor } from "@langfuse/otel";
|
|
2
2
|
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
|
3
|
-
import {
|
|
3
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
|
4
|
+
import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
|
|
4
5
|
import { compact } from "lodash-es";
|
|
5
6
|
import { getEnvConfig } from "./config/getEnvConfig";
|
|
6
7
|
import { getLogger } from "./services/logger/AppLogger";
|
|
7
8
|
|
|
8
|
-
|
|
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
|
+
|
|
51
|
+
function getOtlpTracesEndpoint(): string | undefined {
|
|
52
|
+
const {
|
|
53
|
+
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: tracesEndpoint,
|
|
54
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: baseEndpoint,
|
|
55
|
+
} = getEnvConfig();
|
|
56
|
+
|
|
57
|
+
if (tracesEndpoint) return tracesEndpoint;
|
|
58
|
+
|
|
59
|
+
if (!baseEndpoint) return undefined;
|
|
60
|
+
|
|
61
|
+
return `${baseEndpoint.replace(/\/$/, "")}/v1/traces`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getOtlpSpanProcessor(): tracing.BatchSpanProcessor | undefined {
|
|
65
|
+
const { OTEL_TRACES_EXPORTER: tracesExporter } = getEnvConfig();
|
|
66
|
+
if (tracesExporter === "none") {
|
|
67
|
+
getLogger().debug(
|
|
68
|
+
undefined,
|
|
69
|
+
"OTEL_TRACES_EXPORTER=none. Skipping OTLP trace export.",
|
|
70
|
+
);
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tracesEndpoint = getOtlpTracesEndpoint();
|
|
75
|
+
if (!tracesEndpoint) {
|
|
76
|
+
getLogger().debug(
|
|
77
|
+
undefined,
|
|
78
|
+
"No OTLP trace endpoint found. Skipping OTLP trace export.",
|
|
79
|
+
);
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getLogger().debug(
|
|
84
|
+
{ safe: { tracesEndpoint } },
|
|
85
|
+
"Registering OTLP trace exporter.",
|
|
86
|
+
);
|
|
87
|
+
return new tracing.BatchSpanProcessor(
|
|
88
|
+
new OTLPTraceExporter({ url: tracesEndpoint }),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getLangfuseSpanProcessor(): SpanProcessor | undefined {
|
|
9
93
|
const {
|
|
10
94
|
LANGFUSE_BASE_URL: baseUrl,
|
|
11
95
|
LANGFUSE_PUBLIC_KEY: publicKey,
|
|
12
96
|
LANGFUSE_SECRET_KEY: secretKey,
|
|
13
97
|
} = getEnvConfig();
|
|
14
|
-
if (baseUrl
|
|
98
|
+
if (!baseUrl || !publicKey || !secretKey) {
|
|
15
99
|
getLogger().debug(
|
|
16
100
|
undefined,
|
|
17
|
-
"
|
|
101
|
+
"Langfuse environment is incomplete. Skipping Langfuse OpenTelemetry.",
|
|
18
102
|
);
|
|
19
103
|
return undefined;
|
|
20
104
|
}
|
|
21
105
|
|
|
22
106
|
getLogger().debug(undefined, "Registering Langfuse OpenTelemetry.");
|
|
23
|
-
return new
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
107
|
+
return new FilteringSpanProcessor(
|
|
108
|
+
new LangfuseSpanProcessor({
|
|
109
|
+
baseUrl,
|
|
110
|
+
publicKey,
|
|
111
|
+
secretKey,
|
|
112
|
+
}),
|
|
113
|
+
isAiSdkSpan,
|
|
114
|
+
);
|
|
28
115
|
}
|
|
29
116
|
|
|
117
|
+
const spanProcessors: tracing.SpanProcessor[] = compact([
|
|
118
|
+
getOtlpSpanProcessor(),
|
|
119
|
+
getLangfuseSpanProcessor(),
|
|
120
|
+
]);
|
|
121
|
+
|
|
30
122
|
const sdk = new NodeSDK({
|
|
31
|
-
spanProcessors
|
|
123
|
+
spanProcessors,
|
|
32
124
|
instrumentations: [getNodeAutoInstrumentations()],
|
|
33
125
|
});
|
|
34
126
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type AppInngest, InngestService } from "./InngestService";
|
|
2
|
+
import { type ExampleEventPayload } from "./events/payloads/ExampleEventPayload";
|
|
3
|
+
|
|
4
|
+
type AppWorkflowClient = Pick<AppInngest, "send">;
|
|
5
|
+
|
|
6
|
+
export class AppWorkflowService {
|
|
7
|
+
public static create(): AppWorkflowService {
|
|
8
|
+
return new AppWorkflowService(InngestService.create().client);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public constructor(private inngestClient: AppWorkflowClient) {}
|
|
12
|
+
|
|
13
|
+
public async sendExampleEvent(payload: ExampleEventPayload): Promise<void> {
|
|
14
|
+
await this.inngestClient.send({
|
|
15
|
+
name: "app/example.event",
|
|
16
|
+
data: payload,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AppWorkflowService } from "../AppWorkflowService";
|
|
3
|
+
import { AppEvents } from "../events/AppEvents";
|
|
4
|
+
|
|
5
|
+
describe("AppWorkflowService", () => {
|
|
6
|
+
it("sends event data in the shape validated by AppEvents", async () => {
|
|
7
|
+
const payload = { exampleId: "example-1" };
|
|
8
|
+
const send = vi.fn().mockResolvedValue(undefined);
|
|
9
|
+
const service = new AppWorkflowService({ send } as never);
|
|
10
|
+
|
|
11
|
+
await service.sendExampleEvent(payload);
|
|
12
|
+
|
|
13
|
+
expect(AppEvents["app/example.event"].parse(payload)).toEqual(payload);
|
|
14
|
+
expect(send).toHaveBeenCalledWith({
|
|
15
|
+
name: "app/example.event",
|
|
16
|
+
data: payload,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import z from "zod";
|
|
2
1
|
import { ExampleEventPayload } from "./payloads/ExampleEventPayload";
|
|
3
2
|
|
|
4
3
|
/**
|
|
@@ -6,29 +5,24 @@ import { ExampleEventPayload } from "./payloads/ExampleEventPayload";
|
|
|
6
5
|
*
|
|
7
6
|
* Each event should have:
|
|
8
7
|
* - A unique name (conventionally "app/event.name")
|
|
9
|
-
* - A Zod schema for the
|
|
8
|
+
* - A Zod schema for event.data. Do not wrap the payload in another
|
|
9
|
+
* `{ data: ... }` object here.
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```ts
|
|
13
13
|
* export const AppEvents = {
|
|
14
14
|
* "app/user.created": z.object({
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* email: z.string(),
|
|
18
|
-
* }),
|
|
15
|
+
* userId: z.string(),
|
|
16
|
+
* email: z.string(),
|
|
19
17
|
* }),
|
|
20
18
|
* "app/order.completed": z.object({
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* total: z.number(),
|
|
24
|
-
* }),
|
|
19
|
+
* orderId: z.string(),
|
|
20
|
+
* total: z.number(),
|
|
25
21
|
* }),
|
|
26
22
|
* };
|
|
27
23
|
* ```
|
|
28
24
|
*/
|
|
29
25
|
export const AppEvents = {
|
|
30
26
|
// Example event - replace with your actual events
|
|
31
|
-
"app/example.event":
|
|
32
|
-
data: ExampleEventPayload.SCHEMA,
|
|
33
|
-
}),
|
|
27
|
+
"app/example.event": ExampleEventPayload.SCHEMA,
|
|
34
28
|
};
|
|
@@ -7,8 +7,6 @@ import z from "zod";
|
|
|
7
7
|
export type ExampleEventPayload = z.infer<typeof ExampleEventPayload.SCHEMA>;
|
|
8
8
|
export namespace ExampleEventPayload {
|
|
9
9
|
export const SCHEMA = z.object({
|
|
10
|
-
|
|
11
|
-
// exampleId: z.string(),
|
|
12
|
-
// data: z.record(z.unknown()),
|
|
10
|
+
exampleId: z.string(),
|
|
13
11
|
});
|
|
14
12
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
variable "aws_region" {
|
|
2
|
+
description = "AWS region containing the shared Percepta internal database secret."
|
|
3
|
+
type = string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
variable "database_secret_name" {
|
|
7
|
+
description = "AWS Secrets Manager secret name containing shared Postgres credentials."
|
|
8
|
+
type = string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
variable "database_name" {
|
|
12
|
+
description = "Database where the demo app schema should be created."
|
|
13
|
+
type = string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
variable "schema_name" {
|
|
17
|
+
description = "Postgres schema name for this demo app."
|
|
18
|
+
type = string
|
|
19
|
+
}
|