@pylonsync/functions 0.3.197 → 0.3.198

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": "@pylonsync/functions",
3
- "version": "0.3.197",
3
+ "version": "0.3.198",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/runtime.ts CHANGED
@@ -21,6 +21,10 @@ import type {
21
21
  EmailSender,
22
22
  Stream,
23
23
  Scheduler,
24
+ Llm,
25
+ LlmCompleteRequest,
26
+ LlmCompleteResponse,
27
+ Connections,
24
28
  QueryCtx,
25
29
  MutationCtx,
26
30
  ActionCtx,
@@ -529,12 +533,103 @@ function buildEmail(callId: string): EmailSender {
529
533
  };
530
534
  }
531
535
 
536
+ /**
537
+ * Build the LLM client that round-trips through the host runtime.
538
+ *
539
+ * Each call emits an `llm_complete` protocol message; the runtime
540
+ * forwards to the configured provider (PYLON_LLM_PROVIDER) and replies
541
+ * with the parsed response. The host enforces model-allowlist gating
542
+ * for non-admin callers — that's why the API key never leaves the
543
+ * server process.
544
+ *
545
+ * Errors carry an `err.code` so handlers can branch on `LLM_NOT_CONFIGURED`
546
+ * vs `PROVIDER_HTTP_429` vs `MODEL_NOT_ALLOWED` without parsing message
547
+ * strings.
548
+ */
549
+ function buildLlm(callId: string): Llm {
550
+ return {
551
+ async complete(request: LlmCompleteRequest): Promise<LlmCompleteResponse> {
552
+ return (await rpc(callId, {
553
+ type: "llm_complete",
554
+ request,
555
+ })) as LlmCompleteResponse;
556
+ },
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Build the connection registry that round-trips through the host.
562
+ * Each method emits a `{type:"connection", op:"..."}` message;
563
+ * the host's ConnectionManager runs the actual OAuth flow + DB
564
+ * read/write and returns the typed reply.
565
+ */
566
+ function buildConnections(callId: string): Connections {
567
+ return {
568
+ async authorizeUrl(name, opts) {
569
+ const data = (await rpc(callId, {
570
+ type: "connection",
571
+ op: "authorize_url",
572
+ payload: {
573
+ name,
574
+ post_redirect: opts?.postRedirect,
575
+ },
576
+ })) as { url: string };
577
+ return data;
578
+ },
579
+ async get(name) {
580
+ const data = (await rpc(callId, {
581
+ type: "connection",
582
+ op: "get",
583
+ payload: { name },
584
+ })) as { access_token: string; scope: string | null; expires_at: number | null };
585
+ return {
586
+ accessToken: data.access_token,
587
+ scope: data.scope,
588
+ expiresAt: data.expires_at,
589
+ };
590
+ },
591
+ async list() {
592
+ const data = (await rpc(callId, {
593
+ type: "connection",
594
+ op: "list",
595
+ payload: {},
596
+ })) as {
597
+ connections: Array<{
598
+ name: string;
599
+ provider: string;
600
+ scope: string | null;
601
+ expires_at: number | null;
602
+ updated_at: number;
603
+ }>;
604
+ };
605
+ return {
606
+ connections: data.connections.map((c) => ({
607
+ name: c.name,
608
+ provider: c.provider,
609
+ scope: c.scope,
610
+ expiresAt: c.expires_at,
611
+ updatedAt: c.updated_at,
612
+ })),
613
+ };
614
+ },
615
+ async disconnect(name) {
616
+ return (await rpc(callId, {
617
+ type: "connection",
618
+ op: "disconnect",
619
+ payload: { name },
620
+ })) as { disconnected: boolean };
621
+ },
622
+ };
623
+ }
624
+
532
625
  function buildActionCtx(
533
626
  callId: string,
534
627
  auth: AuthInfo,
535
628
  stream: Stream,
536
629
  scheduler: Scheduler,
537
630
  email: EmailSender,
631
+ llm: Llm,
632
+ connections: Connections,
538
633
  request?: unknown
539
634
  ): ActionCtx {
540
635
  // The host sends `request` as snake_case JSON (`raw_body`); normalize it
@@ -555,6 +650,8 @@ function buildActionCtx(
555
650
  stream,
556
651
  scheduler,
557
652
  email,
653
+ llm,
654
+ connections,
558
655
  env: process.env as Record<string, string>,
559
656
  async runQuery(fnName, args) {
560
657
  return rpc(callId, {
@@ -635,6 +732,8 @@ async function handleCall(msg: CallMessage): Promise<void> {
635
732
  const stream = buildStream(msg.call_id);
636
733
  const scheduler = buildScheduler(msg.call_id);
637
734
  const email = buildEmail(msg.call_id);
735
+ const llm = buildLlm(msg.call_id);
736
+ const connections = buildConnections(msg.call_id);
638
737
 
639
738
  // Normalize the Rust-side auth envelope (snake_case) to the camelCase
640
739
  // shape that AuthInfo documents. Handlers read `ctx.auth.userId`; the
@@ -680,6 +779,9 @@ async function handleCall(msg: CallMessage): Promise<void> {
680
779
  let ctx: QueryCtx | MutationCtx | ActionCtx;
681
780
  switch (def.type) {
682
781
  case "query":
782
+ // ctx.llm is intentionally absent on queries — reactive
783
+ // re-runs would re-bill the LLM call on every dep change.
784
+ // Move LLM calls into actions / mutations.
683
785
  ctx = { db: buildDbReader(msg.call_id), auth, env };
684
786
  break;
685
787
  case "mutation":
@@ -689,6 +791,8 @@ async function handleCall(msg: CallMessage): Promise<void> {
689
791
  stream,
690
792
  scheduler,
691
793
  env,
794
+ llm,
795
+ connections,
692
796
  error(code, message) {
693
797
  const err = new Error(message);
694
798
  (err as any).code = code;
@@ -697,17 +801,14 @@ async function handleCall(msg: CallMessage): Promise<void> {
697
801
  };
698
802
  break;
699
803
  case "action":
700
- // Pass `msg.request` so actions invoked via `defineRoute` HTTP
701
- // bindings can reach raw headers + body (for webhook signature
702
- // verification). Programmatic invocations (runAction, jobs) get
703
- // undefined here and `ctx.request` reads as undefined — the type
704
- // is optional on purpose.
705
804
  ctx = buildActionCtx(
706
805
  msg.call_id,
707
806
  auth,
708
807
  stream,
709
808
  scheduler,
710
809
  email,
810
+ llm,
811
+ connections,
711
812
  (msg as unknown as { request?: unknown }).request,
712
813
  );
713
814
  break;
package/src/types.ts CHANGED
@@ -305,11 +305,176 @@ export interface EmailSender {
305
305
  send(to: string, subject: string, body: string): Promise<void>;
306
306
  }
307
307
 
308
+ // ---------------------------------------------------------------------------
309
+ // LLM — provider-abstracted text/tool-use completion
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /**
313
+ * Server-side LLM client. Available on every ctx variant (query,
314
+ * mutation, action) because agent loops often run as queries —
315
+ * read tool args from the message, ship the response back.
316
+ *
317
+ * Provider is configured at the server boot (PYLON_LLM_PROVIDER +
318
+ * ANTHROPIC_API_KEY or OPENAI_API_KEY). The wire shape is Anthropic
319
+ * Messages — OpenAI calls translate at the transport boundary, so
320
+ * the same caller code works against either provider.
321
+ *
322
+ * The framework does NOT expose this surface to the browser; clients
323
+ * that need streaming should call POST /api/ai/stream directly.
324
+ * `ctx.llm.complete` is server-only on purpose — the API key never
325
+ * leaves the runtime process.
326
+ */
327
+ export interface Llm {
328
+ /**
329
+ * Send a completion request to the configured LLM provider. The
330
+ * shape is Anthropic Messages: a list of {role, content} pairs
331
+ * where content is either a string or a list of content blocks
332
+ * (text, tool_use, tool_result). Returns the full response once
333
+ * the model finishes generating.
334
+ *
335
+ * For agent tool-use loops, inspect `response.stopReason` — when
336
+ * it's `"tool_use"`, append the assistant's content (which
337
+ * includes the `tool_use` blocks) plus your `tool_result`
338
+ * follow-ups to the message list and call again. Loop until
339
+ * `stopReason === "end_turn"`.
340
+ *
341
+ * Errors are thrown as standard Error objects with an `err.code`
342
+ * property set to one of: `LLM_NOT_CONFIGURED`, `MODEL_NOT_ALLOWED`,
343
+ * `MODEL_OVERRIDE_FORBIDDEN`, `PROVIDER_HTTP_<code>`,
344
+ * `PROVIDER_UNREACHABLE`, `INVALID_REQUEST`.
345
+ */
346
+ complete(request: LlmCompleteRequest): Promise<LlmCompleteResponse>;
347
+ }
348
+
349
+ export interface LlmMessage {
350
+ role: "user" | "assistant" | "system" | "tool";
351
+ content: string | LlmContentBlock[];
352
+ }
353
+
354
+ export type LlmContentBlock =
355
+ | { type: "text"; text: string }
356
+ | {
357
+ type: "tool_use";
358
+ id: string;
359
+ name: string;
360
+ input: Record<string, unknown>;
361
+ }
362
+ | {
363
+ type: "tool_result";
364
+ tool_use_id: string;
365
+ content: string;
366
+ is_error?: boolean;
367
+ };
368
+
369
+ export interface LlmTool {
370
+ name: string;
371
+ description?: string;
372
+ /** JSON Schema object describing the tool's input shape. */
373
+ input_schema: Record<string, unknown>;
374
+ }
375
+
376
+ export interface LlmCompleteRequest {
377
+ /** Override the server's default model. Subject to
378
+ * PYLON_AI_MODELS_ALLOWED gating for non-admin callers. */
379
+ model?: string;
380
+ messages: LlmMessage[];
381
+ system?: string;
382
+ tools?: LlmTool[];
383
+ /** Defaults to 4096. */
384
+ max_tokens?: number;
385
+ temperature?: number;
386
+ }
387
+
388
+ export interface LlmCompleteResponse {
389
+ model: string;
390
+ content: LlmContentBlock[];
391
+ /** `end_turn` | `tool_use` | `max_tokens` | `stop_sequence` */
392
+ stop_reason: string;
393
+ usage: { input_tokens: number; output_tokens: number };
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Connections — per-user OAuth integrations
398
+ // ---------------------------------------------------------------------------
399
+
400
+ /**
401
+ * Server-side OAuth connection registry. Apps declare connections
402
+ * via `defineConnection({...})` in `app.ts`; this surface lets
403
+ * actions fetch fresh access tokens (auto-refresh) and start the
404
+ * OAuth dance.
405
+ *
406
+ * Available on mutation + action ctx only — connections perform
407
+ * external I/O (token refresh, DB writes) that doesn't belong
408
+ * inside a reactive query.
409
+ *
410
+ * All ops require an authenticated caller (`ctx.auth.userId !==
411
+ * null`). Public functions must `ctx.auth.elevate({ admin: true,
412
+ * reason: "..." })` before reaching `ctx.connections.*`.
413
+ */
414
+ export interface Connections {
415
+ /**
416
+ * Mint the URL the browser should navigate to so the user can
417
+ * link an external account. `name` matches a `defineConnection({...})`
418
+ * entry. `postRedirect` (optional) is where the browser lands
419
+ * after a successful callback — defaults to `/`.
420
+ *
421
+ * Throws `CONNECTIONS_NOT_CONFIGURED`, `CONNECTION_UNKNOWN`,
422
+ * `PROVIDER_NOT_CONFIGURED`, or `ENCRYPTION_REQUIRED` (refresh
423
+ * tokens are not allowed to land in plaintext).
424
+ */
425
+ authorizeUrl(
426
+ name: string,
427
+ opts?: { postRedirect?: string }
428
+ ): Promise<{ url: string }>;
429
+
430
+ /**
431
+ * Returns a fresh access token for `(ctx.auth.userId, name)`. If
432
+ * the stored token expires within 60s, the framework refreshes
433
+ * via the provider's refresh-token grant FIRST, persists the new
434
+ * token pair, then returns the new access token.
435
+ *
436
+ * Throws `CONNECTION_NOT_LINKED` when the user hasn't started
437
+ * the OAuth flow, `REFRESH_FAILED` when the provider rejects
438
+ * the refresh token (user must re-link).
439
+ */
440
+ get(name: string): Promise<{
441
+ accessToken: string;
442
+ scope: string | null;
443
+ expiresAt: number | null;
444
+ }>;
445
+
446
+ /** List the signed-in user's linked connections. Token values
447
+ * are NOT included — call `get(name)` for those. */
448
+ list(): Promise<{
449
+ connections: Array<{
450
+ name: string;
451
+ provider: string;
452
+ scope: string | null;
453
+ expiresAt: number | null;
454
+ updatedAt: number;
455
+ }>;
456
+ }>;
457
+
458
+ /** Remove the stored connection. Provider-side revocation is
459
+ * the caller's responsibility — most providers expose a separate
460
+ * `/revoke` endpoint that this surface intentionally doesn't
461
+ * call (revoke vs unlink semantics differ per provider). */
462
+ disconnect(name: string): Promise<{ disconnected: boolean }>;
463
+ }
464
+
308
465
  // ---------------------------------------------------------------------------
309
466
  // Context objects — what handlers receive
310
467
  // ---------------------------------------------------------------------------
311
468
 
312
- /** Context for query handlers (read-only). */
469
+ /** Context for query handlers (read-only).
470
+ *
471
+ * NOTE: `ctx.llm` is NOT exposed here. Queries are reactive: a
472
+ * subscribed query re-runs whenever its `ctx.db.*` reads change.
473
+ * Calling a stochastic, paid LLM from a query would (a) silently
474
+ * burn the framework's API key on every dep invalidation, and
475
+ * (b) violate the reactive purity contract (same inputs → same
476
+ * outputs). LLM calls belong in mutations (transactional) or
477
+ * actions (external I/O). */
313
478
  export interface QueryCtx<R extends AuthRequirement = "optional"> {
314
479
  db: DbReader;
315
480
  auth: AuthInfo<R>;
@@ -325,6 +490,10 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
325
490
  scheduler: Scheduler;
326
491
  /** Environment variables / secrets. */
327
492
  env: Record<string, string>;
493
+ /** Provider-abstracted LLM client. */
494
+ llm: Llm;
495
+ /** Per-user OAuth connection registry. */
496
+ connections: Connections;
328
497
  /** Create a typed error that triggers rollback. */
329
498
  error(code: string, message: string): Error;
330
499
  }
@@ -336,6 +505,10 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
336
505
  scheduler: Scheduler;
337
506
  /** Send transactional email via the runtime's configured provider. */
338
507
  email: EmailSender;
508
+ /** Provider-abstracted LLM client. */
509
+ llm: Llm;
510
+ /** Per-user OAuth connection registry. */
511
+ connections: Connections;
339
512
  /** Environment variables / secrets. */
340
513
  env: Record<string, string>;
341
514
  /** Run a registered query within its own read transaction. */