@frankieai/server 0.1.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.
- package/dist/chunk-2YYKRPT7.js +1390 -0
- package/dist/chunk-2YYKRPT7.js.map +1 -0
- package/dist/chunk-ACXNDAOM.js +436 -0
- package/dist/chunk-ACXNDAOM.js.map +1 -0
- package/dist/chunk-ZY4JZVCI.js +148 -0
- package/dist/chunk-ZY4JZVCI.js.map +1 -0
- package/dist/control-server.js +33 -0
- package/dist/control-server.js.map +1 -0
- package/dist/index.d.ts +591 -0
- package/dist/index.js +87 -0
- package/dist/index.js.map +1 -0
- package/dist/server.js +48 -0
- package/dist/server.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/control/app.ts"],"sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport { Hono } from \"hono\";\nimport type { Context } from \"hono\";\nimport { parseFrankieConfig } from \"@frankieai/core\";\nimport { safeEqual } from \"../auth.js\";\nimport { toPublicTenant, type ControlStore, type TenantRecord } from \"./store.js\";\n\nexport interface ControlOptions {\n store: ControlStore;\n /** Bearer token that gates the admin API. A stand-in for real user auth. */\n adminToken?: string;\n logger?: (event: Record<string, unknown>) => void;\n}\n\n/**\n * The control plane: create a tenant, hand back its keys, and manage how its\n * product connects — bring an MCP, point at an OpenAPI spec, or stay\n * knowledge-only. The dashboard and the setup CLI are thin clients over this.\n *\n * Admin auth here is a single shared token — deliberately the simplest thing\n * that is not \"nothing\", and the seam where real per-user accounts go.\n */\nexport function createControlApp(options: ControlOptions): Hono {\n const { store } = options;\n const adminToken = options.adminToken ?? process.env[\"FRANKIE_ADMIN_TOKEN\"];\n const log = options.logger ?? ((event) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...event })));\n\n const app = new Hono();\n\n app.use(\"/admin/*\", async (c, next) => {\n if (!adminToken) {\n return c.json({ error: \"Control plane is locked: set FRANKIE_ADMIN_TOKEN.\" }, 503);\n }\n const bearer = c.req.header(\"authorization\")?.replace(/^Bearer\\s+/i, \"\") ?? \"\";\n if (!safeEqual(bearer, adminToken)) {\n return c.json({ error: \"Unauthorized.\" }, 401);\n }\n await next();\n return;\n });\n\n /* ------------------------------ create ------------------------------ */\n\n /** The dashboard identifies the acting user with this header (e.g. \"github:42\"). */\n const ownerOf = (c: Context): string | undefined => c.req.header(\"x-frankie-owner\") || undefined;\n\n app.post(\"/admin/tenants\", async (c) => {\n const body = (await readJson<CreateTenantBody>(c)) ?? {};\n if (!body.name?.trim()) return c.json({ error: \"`name` is required.\" }, 400);\n const ownerId = ownerOf(c);\n\n // Ids are globally unique across owners, so two customers can both have an\n // \"Acme\" without colliding: take the clean slug, or suffix it if taken.\n let id = slug(body.name);\n if (await store.get(id)) id = `${id}-${randomBytes(3).toString(\"hex\")}`;\n\n const publishableKey = `pk_live_${randomBytes(12).toString(\"hex\")}`;\n const jwtSecret = randomBytes(36).toString(\"base64\");\n const sessionSecret = randomBytes(36).toString(\"base64\");\n\n let config;\n try {\n config = parseFrankieConfig({\n tenantId: id,\n branding: { name: body.name },\n agent: {\n ...(body.goal ? { systemPrompt: body.goal } : {}),\n ...(body.greeting ? { greeting: body.greeting } : {}),\n },\n auth: {\n publishableKey,\n allowedOrigins: body.allowedOrigins ?? [],\n ...(body.allowAnonymous ? { allowAnonymous: true } : {}),\n },\n });\n } catch (error) {\n return c.json({ error: `Invalid tenant: ${(error as Error).message}` }, 400);\n }\n\n const now = new Date().toISOString();\n const record: TenantRecord = {\n id,\n ...(ownerId ? { ownerId } : {}),\n config,\n jwtSecret,\n sessionSecret,\n createdAt: now,\n updatedAt: now,\n };\n await store.put(record);\n log({ level: \"info\", event: \"tenant_created\", tenant: id, ...(ownerId ? { owner: ownerId } : {}) });\n\n // The JWT secret is shown exactly once: the customer's backend signs user\n // tokens with it, and Frankie never needs to reveal it again.\n return c.json(\n {\n tenant: toPublicTenant(record),\n secrets: { publishableKey, jwtSecret },\n next: \"Set FRANKIE_JWT_SECRET to the jwtSecret on your backend, then run `frankie init`.\",\n },\n 201,\n );\n });\n\n /* ------------------------------- read ------------------------------- */\n\n // A tenant not owned by the caller is reported as absent, never as forbidden,\n // so one owner can't probe another's tenant ids.\n const own = async (c: Context): Promise<TenantRecord | null> => {\n const id = c.req.param(\"id\");\n const record = id ? await store.get(id) : null;\n if (!record) return null;\n const owner = ownerOf(c);\n if (owner && record.ownerId && record.ownerId !== owner) return null;\n return record;\n };\n\n app.get(\"/admin/tenants\", async (c) => {\n return c.json({ tenants: (await store.list(ownerOf(c))).map(toPublicTenant) });\n });\n\n app.get(\"/admin/tenants/:id\", async (c) => {\n const record = await own(c);\n if (!record) return c.json({ error: \"No such tenant.\" }, 404);\n return c.json({ tenant: toPublicTenant(record) });\n });\n\n /* ---------------------------- connection ---------------------------- */\n\n /** Set how the tenant's product connects: MCP servers, an OpenAPI source, or neither. */\n app.put(\"/admin/tenants/:id/connection\", async (c) => {\n const record = await own(c);\n if (!record) return c.json({ error: \"No such tenant.\" }, 404);\n\n const body = (await readJson<ConnectionBody>(c)) ?? {};\n let config;\n try {\n config = parseFrankieConfig({\n ...record.config,\n mcpServers: body.mcpServers ?? [],\n apis: body.apis ?? [],\n });\n } catch (error) {\n return c.json({ error: `Invalid connection: ${(error as Error).message}` }, 400);\n }\n\n const updated: TenantRecord = { ...record, config, updatedAt: new Date().toISOString() };\n await store.put(updated);\n log({ level: \"info\", event: \"tenant_connection_updated\", tenant: record.id });\n return c.json({ tenant: toPublicTenant(updated) });\n });\n\n /** Set the sites allowed to open a chat for this tenant. */\n app.put(\"/admin/tenants/:id/origins\", async (c) => {\n const record = await own(c);\n if (!record) return c.json({ error: \"No such tenant.\" }, 404);\n\n const body = (await readJson<{ allowedOrigins?: string[] }>(c)) ?? {};\n let config;\n try {\n config = parseFrankieConfig({\n ...record.config,\n auth: { ...record.config.auth, allowedOrigins: body.allowedOrigins ?? [] },\n });\n } catch (error) {\n return c.json({ error: `Invalid origins: ${(error as Error).message}` }, 400);\n }\n\n const updated: TenantRecord = { ...record, config, updatedAt: new Date().toISOString() };\n await store.put(updated);\n return c.json({ tenant: toPublicTenant(updated) });\n });\n\n // Surface the real cause of an unhandled failure (usually a database\n // connection error). The admin API is token-gated, so returning the message\n // is safe and makes the dashboard show what actually went wrong.\n app.onError((error, c) => {\n const message = error instanceof Error ? error.message : \"Internal error.\";\n log({ level: \"error\", event: \"control_error\", message });\n return c.json({ error: `Control plane error: ${message}` }, 500);\n });\n\n return app;\n}\n\n/* -------------------------------- types --------------------------------- */\n\ninterface CreateTenantBody {\n name?: string;\n allowedOrigins?: string[];\n goal?: string;\n greeting?: string;\n /** Let visitors chat without a host-signed token — for public onboarding pages. */\n allowAnonymous?: boolean;\n}\n\ninterface ConnectionBody {\n mcpServers?: unknown[];\n apis?: unknown[];\n}\n\n/* -------------------------------- helpers -------------------------------- */\n\nasync function readJson<T>(c: Context): Promise<T | null> {\n try {\n return (await c.req.json()) as T;\n } catch {\n return null;\n }\n}\n\nexport function slug(value: string): string {\n return (\n value\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, 40) || \"tenant\"\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
createControlApp
|
|
4
|
+
} from "./chunk-ZY4JZVCI.js";
|
|
5
|
+
import {
|
|
6
|
+
resolveTenantStore
|
|
7
|
+
} from "./chunk-ACXNDAOM.js";
|
|
8
|
+
|
|
9
|
+
// src/control-server.ts
|
|
10
|
+
import { serve } from "@hono/node-server";
|
|
11
|
+
async function main() {
|
|
12
|
+
const port = Number(process.env["PORT"] ?? process.env["CONTROL_PORT"] ?? 8081);
|
|
13
|
+
const host = process.env["HOST"] ?? "0.0.0.0";
|
|
14
|
+
if (!process.env["FRANKIE_ADMIN_TOKEN"]) {
|
|
15
|
+
console.error("[frankie] Set FRANKIE_ADMIN_TOKEN to run the control plane.");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const { store, describe } = await resolveTenantStore();
|
|
19
|
+
const app = createControlApp({ store });
|
|
20
|
+
serve({ fetch: app.fetch, port, hostname: host }, () => {
|
|
21
|
+
console.log(
|
|
22
|
+
JSON.stringify({
|
|
23
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
24
|
+
level: "info",
|
|
25
|
+
event: "control_listening",
|
|
26
|
+
store: describe,
|
|
27
|
+
url: `http://${host}:${port}`
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
void main();
|
|
33
|
+
//# sourceMappingURL=control-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/control-server.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * The control plane, as its own process: the admin API the dashboard and the\n * setup CLI talk to. It writes tenants into FRANKIE_TENANTS_DIR — the same\n * directory the data-plane server reads — so what you create here is served\n * there with no further wiring.\n *\n * FRANKIE_ADMIN_TOKEN=… FRANKIE_TENANTS_DIR=./.frankie/tenants \\\n * pnpm --filter @frankieai/server start:control\n */\nimport { serve } from \"@hono/node-server\";\nimport { createControlApp } from \"./control/app.js\";\nimport { resolveTenantStore } from \"./control/resolve-store.js\";\n\nasync function main(): Promise<void> {\n const port = Number(process.env[\"PORT\"] ?? process.env[\"CONTROL_PORT\"] ?? 8081);\n const host = process.env[\"HOST\"] ?? \"0.0.0.0\";\n\n if (!process.env[\"FRANKIE_ADMIN_TOKEN\"]) {\n console.error(\"[frankie] Set FRANKIE_ADMIN_TOKEN to run the control plane.\");\n process.exit(1);\n }\n\n const { store, describe } = await resolveTenantStore();\n const app = createControlApp({ store });\n\n serve({ fetch: app.fetch, port, hostname: host }, () => {\n console.log(\n JSON.stringify({\n ts: new Date().toISOString(),\n level: \"info\",\n event: \"control_listening\",\n store: describe,\n url: `http://${host}:${port}`,\n }),\n );\n });\n}\n\nvoid main();\n"],"mappings":";;;;;;;;;AAUA,SAAS,aAAa;AAItB,eAAe,OAAsB;AACnC,QAAM,OAAO,OAAO,QAAQ,IAAI,MAAM,KAAK,QAAQ,IAAI,cAAc,KAAK,IAAI;AAC9E,QAAM,OAAO,QAAQ,IAAI,MAAM,KAAK;AAEpC,MAAI,CAAC,QAAQ,IAAI,qBAAqB,GAAG;AACvC,YAAQ,MAAM,6DAA6D;AAC3E,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,QAAM,EAAE,OAAO,SAAS,IAAI,MAAM,mBAAmB;AACrD,QAAM,MAAM,iBAAiB,EAAE,MAAM,CAAC;AAEtC,QAAM,EAAE,OAAO,IAAI,OAAO,MAAM,UAAU,KAAK,GAAG,MAAM;AACtD,YAAQ;AAAA,MACN,KAAK,UAAU;AAAA,QACb,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,QAC3B,OAAO;AAAA,QACP,OAAO;AAAA,QACP,OAAO;AAAA,QACP,KAAK,UAAU,IAAI,IAAI,IAAI;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK;","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { FrankieConfig, AuthConfig, FrankieServerEvent, McpServerConfig, ToolPolicy, ApiSourceConfig } from '@frankieai/core';
|
|
3
|
+
import { AgentMessage, AgentTool } from '@earendil-works/pi-agent-core';
|
|
4
|
+
import { JWTPayload } from 'jose';
|
|
5
|
+
import { Models, Model, Api } from '@earendil-works/pi-ai';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Secrets never live in the config file — the file names the env var that
|
|
9
|
+
* holds each one. `ResolvedConfig` is the config plus the resolved values.
|
|
10
|
+
*/
|
|
11
|
+
interface ResolvedConfig {
|
|
12
|
+
config: FrankieConfig;
|
|
13
|
+
secrets: {
|
|
14
|
+
/** HS256 secret the host signs end-user tokens with. Absent when using JWKS. */
|
|
15
|
+
jwtSecret?: string;
|
|
16
|
+
/** Signs Frankie's own session tokens. */
|
|
17
|
+
sessionSecret: string;
|
|
18
|
+
/** Key for the model provider named in `model.provider`. */
|
|
19
|
+
providerApiKey: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
declare class ConfigError extends Error {
|
|
23
|
+
}
|
|
24
|
+
declare function loadConfigFromFile(path?: string): Promise<FrankieConfig>;
|
|
25
|
+
/**
|
|
26
|
+
* A handful of fields have to move with the deployment rather than the config
|
|
27
|
+
* file — the model and the origin allowlist are what an operator most often
|
|
28
|
+
* wants to flip without a redeploy.
|
|
29
|
+
*/
|
|
30
|
+
declare function applyEnvOverrides(raw: Record<string, unknown>, env?: NodeJS.ProcessEnv): Record<string, unknown>;
|
|
31
|
+
declare function resolveSecrets(config: FrankieConfig, env?: NodeJS.ProcessEnv): ResolvedConfig;
|
|
32
|
+
declare function loadResolvedConfig(path?: string): Promise<ResolvedConfig>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sliding-window counter. In-memory by default; swap in Redis for multi-instance
|
|
36
|
+
* deployments by implementing `RateLimiter`.
|
|
37
|
+
*/
|
|
38
|
+
interface RateLimiter {
|
|
39
|
+
/** Returns false when the caller is over budget. */
|
|
40
|
+
take(key: string, limit: number, windowSeconds: number): Promise<boolean>;
|
|
41
|
+
}
|
|
42
|
+
declare class MemoryRateLimiter implements RateLimiter {
|
|
43
|
+
private readonly hits;
|
|
44
|
+
private lastSweep;
|
|
45
|
+
take(key: string, limit: number, windowSeconds: number): Promise<boolean>;
|
|
46
|
+
/** Drop empty buckets occasionally so a long-lived process does not grow. */
|
|
47
|
+
private sweep;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface VerifiedUser {
|
|
51
|
+
id: string;
|
|
52
|
+
displayName?: string;
|
|
53
|
+
email?: string;
|
|
54
|
+
anonymous: boolean;
|
|
55
|
+
/** Every claim from the host's token, for MCP forwarding and prompt context. */
|
|
56
|
+
claims: JWTPayload;
|
|
57
|
+
}
|
|
58
|
+
declare class AuthError extends Error {
|
|
59
|
+
readonly code: "unauthorized" | "forbidden_origin";
|
|
60
|
+
constructor(message: string, code?: "unauthorized" | "forbidden_origin");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Verifies the JWT the customer's backend mints for a signed-in end user, and
|
|
64
|
+
* issues/verifies Frankie's own short-lived session tokens.
|
|
65
|
+
*/
|
|
66
|
+
declare class TokenService {
|
|
67
|
+
private readonly auth;
|
|
68
|
+
private readonly sessionKey;
|
|
69
|
+
private readonly userKey?;
|
|
70
|
+
private jwks?;
|
|
71
|
+
constructor(auth: AuthConfig, secrets: {
|
|
72
|
+
sessionSecret: string;
|
|
73
|
+
jwtSecret?: string;
|
|
74
|
+
});
|
|
75
|
+
/** Verify a host-signed user token. Throws `AuthError` on any failure. */
|
|
76
|
+
verifyUserToken(token: string): Promise<VerifiedUser>;
|
|
77
|
+
/** Mint the bearer token the widget uses for the rest of the conversation. */
|
|
78
|
+
issueSessionToken(input: {
|
|
79
|
+
sessionId: string;
|
|
80
|
+
user: VerifiedUser;
|
|
81
|
+
tenantId: string;
|
|
82
|
+
}): Promise<{
|
|
83
|
+
token: string;
|
|
84
|
+
expiresAt: Date;
|
|
85
|
+
}>;
|
|
86
|
+
verifySessionToken(token: string): Promise<{
|
|
87
|
+
sessionId: string;
|
|
88
|
+
userId: string;
|
|
89
|
+
tenantId: string;
|
|
90
|
+
}>;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Exact-origin allowlist. Wildcards are deliberately unsupported: the widget
|
|
94
|
+
* runs on a page the customer controls, so the list is short and known.
|
|
95
|
+
* An empty list means "same-origin only", which is what a server mounted
|
|
96
|
+
* inside the host app wants.
|
|
97
|
+
*/
|
|
98
|
+
declare function isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean;
|
|
99
|
+
/** Constant-time string comparison for the publishable key check. */
|
|
100
|
+
declare function safeEqual(a: string, b: string): boolean;
|
|
101
|
+
/** Stable pseudonymous id for an anonymous visitor. */
|
|
102
|
+
declare function anonymousUser(seed: string): VerifiedUser;
|
|
103
|
+
|
|
104
|
+
interface ChatSession {
|
|
105
|
+
id: string;
|
|
106
|
+
tenantId: string;
|
|
107
|
+
user: VerifiedUser;
|
|
108
|
+
/**
|
|
109
|
+
* The raw host-signed token, kept only so it can be forwarded to MCP servers
|
|
110
|
+
* the tenant has explicitly opted into. Never written to disk or logs.
|
|
111
|
+
*/
|
|
112
|
+
userToken?: string;
|
|
113
|
+
/** Page context the host passed at session creation. */
|
|
114
|
+
context: Record<string, string | number | boolean | null>;
|
|
115
|
+
/**
|
|
116
|
+
* The conversation transcript. Frankie owns it outright — the agent loop is
|
|
117
|
+
* stateless between turns and is handed this array each time — which is what
|
|
118
|
+
* makes a session portable to a shared store, and exportable.
|
|
119
|
+
*/
|
|
120
|
+
messages: AgentMessage[];
|
|
121
|
+
createdAt: number;
|
|
122
|
+
lastActiveAt: number;
|
|
123
|
+
messageCount: number;
|
|
124
|
+
costUsd: number;
|
|
125
|
+
/** Set while a turn is in flight, so a second concurrent turn is rejected. */
|
|
126
|
+
busy: boolean;
|
|
127
|
+
}
|
|
128
|
+
interface SessionStore {
|
|
129
|
+
create(session: ChatSession): Promise<void>;
|
|
130
|
+
get(id: string): Promise<ChatSession | undefined>;
|
|
131
|
+
update(id: string, patch: Partial<ChatSession>): Promise<void>;
|
|
132
|
+
delete(id: string): Promise<void>;
|
|
133
|
+
}
|
|
134
|
+
declare class MemorySessionStore implements SessionStore {
|
|
135
|
+
private readonly ttlMs;
|
|
136
|
+
private readonly sessions;
|
|
137
|
+
constructor(ttlMs?: number);
|
|
138
|
+
create(session: ChatSession): Promise<void>;
|
|
139
|
+
get(id: string): Promise<ChatSession | undefined>;
|
|
140
|
+
update(id: string, patch: Partial<ChatSession>): Promise<void>;
|
|
141
|
+
delete(id: string): Promise<void>;
|
|
142
|
+
private evictExpired;
|
|
143
|
+
}
|
|
144
|
+
declare function newSessionId(): string;
|
|
145
|
+
|
|
146
|
+
declare class ModelResolutionError extends Error {
|
|
147
|
+
}
|
|
148
|
+
interface ResolvedModel {
|
|
149
|
+
models: Models;
|
|
150
|
+
model: Model<Api>;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Resolve the tenant's configured model against the provider catalogue.
|
|
154
|
+
*
|
|
155
|
+
* The catalogue already knows which wire API each model speaks — Kimi K2.6 and
|
|
156
|
+
* K2.7 on Fireworks answer the Anthropic Messages API, K3 the OpenAI
|
|
157
|
+
* completions API — so nothing here has to care, and swapping providers stays a
|
|
158
|
+
* config change.
|
|
159
|
+
*/
|
|
160
|
+
declare function resolveModel(config: FrankieConfig): ResolvedModel;
|
|
161
|
+
/** Narrow a resolved model to a specific wire API, for callers that need it. */
|
|
162
|
+
declare function modelHasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi>;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Everything a request handler needs to serve one tenant, built once and
|
|
166
|
+
* cached. The single-tenant server and the multi-tenant SaaS both run the same
|
|
167
|
+
* handlers; the only difference is where the `TenantRuntime` comes from.
|
|
168
|
+
*/
|
|
169
|
+
interface TenantRuntime {
|
|
170
|
+
config: FrankieConfig;
|
|
171
|
+
secrets: ResolvedConfig["secrets"];
|
|
172
|
+
tokens: TokenService;
|
|
173
|
+
models: ResolvedModel["models"];
|
|
174
|
+
model: ResolvedModel["model"];
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Where per-tenant config comes from. One process serves many tenants by
|
|
178
|
+
* looking each one up by its publishable key at request time.
|
|
179
|
+
*
|
|
180
|
+
* The in-memory and single-tenant stores below are enough to run and test the
|
|
181
|
+
* data plane. A hosted deployment implements this against its own control
|
|
182
|
+
* plane — a database row per tenant, secrets from a vault — and nothing in the
|
|
183
|
+
* request path changes.
|
|
184
|
+
*/
|
|
185
|
+
interface TenantStore {
|
|
186
|
+
/** The resolved config for a publishable key, or null if no tenant owns it. */
|
|
187
|
+
byPublishableKey(key: string): Promise<ResolvedConfig | null>;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Turns a `TenantStore` into ready-to-serve `TenantRuntime`s, caching each
|
|
191
|
+
* tenant's `TokenService` and resolved model so the model catalogue is walked
|
|
192
|
+
* once per tenant rather than once per request.
|
|
193
|
+
*/
|
|
194
|
+
declare class TenantRegistry {
|
|
195
|
+
private readonly store;
|
|
196
|
+
private readonly cache;
|
|
197
|
+
constructor(store: TenantStore);
|
|
198
|
+
/**
|
|
199
|
+
* A registry over exactly one tenant. Builds the runtime eagerly so an
|
|
200
|
+
* invalid provider, model or secret fails at startup — the same contract the
|
|
201
|
+
* single-tenant server has always had — rather than on the first message.
|
|
202
|
+
*/
|
|
203
|
+
static single(resolved: ResolvedConfig): TenantRegistry;
|
|
204
|
+
private build;
|
|
205
|
+
/**
|
|
206
|
+
* Resolve a publishable key to a tenant runtime, or null if the key is
|
|
207
|
+
* unknown. Building can throw (an invalid model in a tenant's config); the
|
|
208
|
+
* caller turns that into a 502 for that tenant alone, never a crash for the
|
|
209
|
+
* whole fleet.
|
|
210
|
+
*/
|
|
211
|
+
resolve(publishableKey: string): Promise<TenantRuntime | null>;
|
|
212
|
+
/** Drop a cached runtime so the next request rebuilds it from the store. */
|
|
213
|
+
evict(publishableKey: string): void;
|
|
214
|
+
}
|
|
215
|
+
/** A store over a single resolved config — the self-hosted, one-tenant case. */
|
|
216
|
+
declare class SingleTenantStore implements TenantStore {
|
|
217
|
+
private readonly resolved;
|
|
218
|
+
constructor(resolved: ResolvedConfig);
|
|
219
|
+
byPublishableKey(key: string): Promise<ResolvedConfig | null>;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* An in-memory store indexed by publishable key. Good for tests and small
|
|
223
|
+
* fixed fleets; a real SaaS swaps this for its database without touching the
|
|
224
|
+
* request path.
|
|
225
|
+
*/
|
|
226
|
+
declare class MemoryTenantStore implements TenantStore {
|
|
227
|
+
private readonly byKey;
|
|
228
|
+
constructor(tenants?: ReadonlyArray<ResolvedConfig>);
|
|
229
|
+
/** Register (or replace) a tenant. Its publishable key is its lookup key. */
|
|
230
|
+
add(resolved: ResolvedConfig): void;
|
|
231
|
+
byPublishableKey(key: string): Promise<ResolvedConfig | null>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
interface CreateAppOptions {
|
|
235
|
+
/** A single tenant, resolved at startup — the self-hosted case. */
|
|
236
|
+
resolved?: ResolvedConfig;
|
|
237
|
+
/** Many tenants, each resolved by publishable key at request time — the SaaS case. */
|
|
238
|
+
tenants?: TenantStore | TenantRegistry;
|
|
239
|
+
sessions?: SessionStore;
|
|
240
|
+
rateLimiter?: RateLimiter;
|
|
241
|
+
logger?: (event: Record<string, unknown>) => void;
|
|
242
|
+
}
|
|
243
|
+
type AppEnv = {
|
|
244
|
+
Variables: {
|
|
245
|
+
tenant: TenantRuntime;
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
declare function createFrankieApp(options: CreateAppOptions): Hono<AppEnv>;
|
|
249
|
+
|
|
250
|
+
interface McpHostOptions {
|
|
251
|
+
tenants: TenantStore | TenantRegistry;
|
|
252
|
+
logger?: (event: Record<string, unknown>) => void;
|
|
253
|
+
/** Injected in tests so generated API tools hit a stub rather than the network. */
|
|
254
|
+
fetchImpl?: typeof fetch;
|
|
255
|
+
}
|
|
256
|
+
declare function createMcpHostApp(options: McpHostOptions): Hono;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* What the control plane stores per tenant. The model provider key is *not*
|
|
260
|
+
* here — in the hosted shape that key is Frankie's own, one shared key read
|
|
261
|
+
* from the environment at resolve time, so a tenant record never carries it.
|
|
262
|
+
* The per-tenant secrets it does hold (the JWT verification secret the
|
|
263
|
+
* customer's backend signs with, and the session-token secret) live here for a
|
|
264
|
+
* self-hosted start; a hosted deployment swaps this file store for a vault.
|
|
265
|
+
*/
|
|
266
|
+
interface TenantRecord {
|
|
267
|
+
id: string;
|
|
268
|
+
/** Who owns this tenant — the dashboard user's stable id (e.g. "github:42"). */
|
|
269
|
+
ownerId?: string;
|
|
270
|
+
config: FrankieConfig;
|
|
271
|
+
jwtSecret?: string;
|
|
272
|
+
sessionSecret: string;
|
|
273
|
+
createdAt: string;
|
|
274
|
+
updatedAt: string;
|
|
275
|
+
}
|
|
276
|
+
/** A tenant record with its secrets stripped — safe to return from the API. */
|
|
277
|
+
interface PublicTenant {
|
|
278
|
+
id: string;
|
|
279
|
+
ownerId?: string;
|
|
280
|
+
name: string;
|
|
281
|
+
publishableKey: string;
|
|
282
|
+
allowedOrigins: string[];
|
|
283
|
+
allowAnonymous: boolean;
|
|
284
|
+
connection: "knowledge" | "mcp" | "openapi";
|
|
285
|
+
mcpServers: string[];
|
|
286
|
+
apis: string[];
|
|
287
|
+
createdAt: string;
|
|
288
|
+
updatedAt: string;
|
|
289
|
+
}
|
|
290
|
+
declare function toPublicTenant(record: TenantRecord): PublicTenant;
|
|
291
|
+
/**
|
|
292
|
+
* A `TenantStore` the control plane can also write to and list. The data plane
|
|
293
|
+
* only needs `byPublishableKey`; the control plane needs the rest. Both the
|
|
294
|
+
* file and Postgres stores implement this, so the admin API is written once
|
|
295
|
+
* against the interface.
|
|
296
|
+
*/
|
|
297
|
+
interface ControlStore extends TenantStore {
|
|
298
|
+
list(ownerId?: string): Promise<TenantRecord[]>;
|
|
299
|
+
get(id: string): Promise<TenantRecord | null>;
|
|
300
|
+
put(record: TenantRecord): Promise<void>;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The control plane's persistence, and at the same time a `TenantStore` the
|
|
304
|
+
* data plane can serve from — so what the dashboard writes, the agent reads.
|
|
305
|
+
*
|
|
306
|
+
* `MemoryTenantStore` is fine for tests; this one survives a restart by writing
|
|
307
|
+
* one JSON file per tenant under a directory. The provider key is injected from
|
|
308
|
+
* the environment at read time, keeping it out of the tenant files entirely.
|
|
309
|
+
*/
|
|
310
|
+
declare class FileTenantStore implements ControlStore {
|
|
311
|
+
private readonly dir;
|
|
312
|
+
private readonly providerKeyEnv;
|
|
313
|
+
private readonly env;
|
|
314
|
+
private index;
|
|
315
|
+
private byId;
|
|
316
|
+
private loaded;
|
|
317
|
+
constructor(options: {
|
|
318
|
+
dir: string;
|
|
319
|
+
providerKeyEnv?: string;
|
|
320
|
+
env?: NodeJS.ProcessEnv;
|
|
321
|
+
});
|
|
322
|
+
private load;
|
|
323
|
+
private remember;
|
|
324
|
+
byPublishableKey(key: string): Promise<ResolvedConfig | null>;
|
|
325
|
+
/** All tenants, or — when an owner is given — only that owner's. */
|
|
326
|
+
list(ownerId?: string): Promise<TenantRecord[]>;
|
|
327
|
+
get(id: string): Promise<TenantRecord | null>;
|
|
328
|
+
put(record: TenantRecord): Promise<void>;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The minimal surface of a Postgres client (node-postgres `Pool` or a test
|
|
333
|
+
* double). Keeping the store to this interface means it never imports `pg`
|
|
334
|
+
* directly, so it runs against an in-memory Postgres in tests and a real one in
|
|
335
|
+
* production without changing a line.
|
|
336
|
+
*/
|
|
337
|
+
interface Queryable {
|
|
338
|
+
query(text: string, params?: unknown[]): Promise<{
|
|
339
|
+
rows: Array<Record<string, unknown>>;
|
|
340
|
+
}>;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* A `TenantStore` backed by Postgres — the production shape. The web app, the
|
|
344
|
+
* control plane and the data plane run as separate services (Vercel + Cloud
|
|
345
|
+
* Run) that share no filesystem, so tenants live in a database they all reach.
|
|
346
|
+
*
|
|
347
|
+
* One table, the full record as JSON with the two lookup keys pulled out as
|
|
348
|
+
* columns. The model provider key is still injected from the environment at
|
|
349
|
+
* read time and never written to the row.
|
|
350
|
+
*/
|
|
351
|
+
declare class PostgresTenantStore implements ControlStore {
|
|
352
|
+
private readonly db;
|
|
353
|
+
private readonly providerKeyEnv;
|
|
354
|
+
private readonly env;
|
|
355
|
+
private ready?;
|
|
356
|
+
constructor(options: {
|
|
357
|
+
db: Queryable;
|
|
358
|
+
providerKeyEnv?: string;
|
|
359
|
+
env?: NodeJS.ProcessEnv;
|
|
360
|
+
});
|
|
361
|
+
/**
|
|
362
|
+
* Create the table on first use, once. Lazy so the server can start listening
|
|
363
|
+
* before the database is reachable — a failed connection then surfaces on a
|
|
364
|
+
* request (logged, a 502) instead of crashing boot on Cloud Run.
|
|
365
|
+
*/
|
|
366
|
+
private ensureReady;
|
|
367
|
+
/** Create the table if it isn't there yet. Safe to call repeatedly. */
|
|
368
|
+
init(): Promise<void>;
|
|
369
|
+
private parse;
|
|
370
|
+
byPublishableKey(key: string): Promise<ResolvedConfig | null>;
|
|
371
|
+
list(ownerId?: string): Promise<TenantRecord[]>;
|
|
372
|
+
get(id: string): Promise<TenantRecord | null>;
|
|
373
|
+
put(record: TenantRecord): Promise<void>;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
interface ResolvedStore {
|
|
377
|
+
store: ControlStore;
|
|
378
|
+
describe: string;
|
|
379
|
+
}
|
|
380
|
+
interface ResolvedStores {
|
|
381
|
+
tenants: ControlStore;
|
|
382
|
+
sessions: SessionStore;
|
|
383
|
+
describe: string;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Both stores the data plane needs, sharing one Postgres pool when
|
|
387
|
+
* DATABASE_URL is set (so tenant config and chat transcripts live in the same
|
|
388
|
+
* database), or a file tenant store + in-memory sessions otherwise.
|
|
389
|
+
*/
|
|
390
|
+
declare function resolveStores(env?: NodeJS.ProcessEnv): Promise<ResolvedStores>;
|
|
391
|
+
/**
|
|
392
|
+
* Pick the tenant store from the environment, so the same binary runs in every
|
|
393
|
+
* shape:
|
|
394
|
+
*
|
|
395
|
+
* - DATABASE_URL set → Postgres, the production shape. The web app (Vercel),
|
|
396
|
+
* the control plane and the data plane (Cloud Run) share no filesystem, so
|
|
397
|
+
* tenants live in a database all three reach.
|
|
398
|
+
* - otherwise → a file under FRANKIE_TENANTS_DIR, for a single host or local
|
|
399
|
+
* development, where one persistent disk is shared.
|
|
400
|
+
*/
|
|
401
|
+
declare function resolveTenantStore(env?: NodeJS.ProcessEnv): Promise<ResolvedStore>;
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A `SessionStore` backed by Postgres, so a user's chat history survives a
|
|
405
|
+
* restart and is shared across instances — the hosted default. The whole
|
|
406
|
+
* `ChatSession` (transcript included) is stored as one JSON row keyed by id.
|
|
407
|
+
*
|
|
408
|
+
* A customer who would rather keep transcripts in their own store implements
|
|
409
|
+
* `SessionStore` against it and passes it to `createFrankieApp` — this class is
|
|
410
|
+
* just the batteries-included option.
|
|
411
|
+
*/
|
|
412
|
+
declare class PostgresSessionStore implements SessionStore {
|
|
413
|
+
private readonly db;
|
|
414
|
+
private readonly ttlMs;
|
|
415
|
+
private ready?;
|
|
416
|
+
constructor(options: {
|
|
417
|
+
db: Queryable;
|
|
418
|
+
ttlMs?: number;
|
|
419
|
+
});
|
|
420
|
+
/** Create the table on first use, once — lazy, so boot never blocks on the DB. */
|
|
421
|
+
private ensureReady;
|
|
422
|
+
init(): Promise<void>;
|
|
423
|
+
create(session: ChatSession): Promise<void>;
|
|
424
|
+
get(id: string): Promise<ChatSession | undefined>;
|
|
425
|
+
update(id: string, patch: Partial<ChatSession>): Promise<void>;
|
|
426
|
+
delete(id: string): Promise<void>;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
interface ControlOptions {
|
|
430
|
+
store: ControlStore;
|
|
431
|
+
/** Bearer token that gates the admin API. A stand-in for real user auth. */
|
|
432
|
+
adminToken?: string;
|
|
433
|
+
logger?: (event: Record<string, unknown>) => void;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* The control plane: create a tenant, hand back its keys, and manage how its
|
|
437
|
+
* product connects — bring an MCP, point at an OpenAPI spec, or stay
|
|
438
|
+
* knowledge-only. The dashboard and the setup CLI are thin clients over this.
|
|
439
|
+
*
|
|
440
|
+
* Admin auth here is a single shared token — deliberately the simplest thing
|
|
441
|
+
* that is not "nothing", and the seam where real per-user accounts go.
|
|
442
|
+
*/
|
|
443
|
+
declare function createControlApp(options: ControlOptions): Hono;
|
|
444
|
+
|
|
445
|
+
interface RunTurnParams {
|
|
446
|
+
config: FrankieConfig;
|
|
447
|
+
session: ChatSession;
|
|
448
|
+
message: string;
|
|
449
|
+
/** Provider API key, resolved from the env var the config names. */
|
|
450
|
+
apiKey: string;
|
|
451
|
+
/** Model and provider registry, resolved once at startup. */
|
|
452
|
+
model: Model<Api>;
|
|
453
|
+
models: Models;
|
|
454
|
+
signal: AbortSignal;
|
|
455
|
+
/** Called with the full transcript once the turn settles, to persist it. */
|
|
456
|
+
onTranscript?: (messages: AgentMessage[]) => void;
|
|
457
|
+
onWarning?: (message: string, detail: Record<string, unknown>) => void;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Run one user turn and stream it back as widget-facing events.
|
|
461
|
+
*
|
|
462
|
+
* The agent loop runs in this process: there is no subprocess and no
|
|
463
|
+
* translation shim, so the only thing between the browser and the provider is
|
|
464
|
+
* the mapping below.
|
|
465
|
+
*/
|
|
466
|
+
declare function runTurn(params: RunTurnParams): AsyncGenerator<FrankieServerEvent>;
|
|
467
|
+
|
|
468
|
+
/** Tools are namespaced so one tenant's policy can name a server or a tool. */
|
|
469
|
+
declare const MCP_TOOL_PREFIX = "mcp__";
|
|
470
|
+
interface McpConnection {
|
|
471
|
+
tools: AgentTool[];
|
|
472
|
+
/** Servers that could not be reached, by name. */
|
|
473
|
+
failed: Array<{
|
|
474
|
+
name: string;
|
|
475
|
+
error: string;
|
|
476
|
+
}>;
|
|
477
|
+
close(): Promise<void>;
|
|
478
|
+
}
|
|
479
|
+
interface ConnectMcpOptions {
|
|
480
|
+
servers: readonly McpServerConfig[];
|
|
481
|
+
session: ChatSession;
|
|
482
|
+
policy: ToolPolicy;
|
|
483
|
+
connectTimeoutMs?: number;
|
|
484
|
+
callTimeoutMs?: number;
|
|
485
|
+
onWarning?: (message: string, detail: Record<string, unknown>) => void;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Connect to the tenant's MCP servers and expose their tools to the agent.
|
|
489
|
+
*
|
|
490
|
+
* Connections are per turn rather than pooled. That costs one handshake per
|
|
491
|
+
* message, and buys three things worth more for an onboarding agent: a server
|
|
492
|
+
* restarting mid-conversation heals on the next turn, tool lists stay current,
|
|
493
|
+
* and no connection outlives the request that authorised it.
|
|
494
|
+
*/
|
|
495
|
+
declare function connectMcpTools(options: ConnectMcpOptions): Promise<McpConnection>;
|
|
496
|
+
|
|
497
|
+
/** Generated API tools share the namespace shape of MCP tools. */
|
|
498
|
+
declare const API_TOOL_PREFIX = "api__";
|
|
499
|
+
declare class OpenApiError extends Error {
|
|
500
|
+
}
|
|
501
|
+
interface BuildApiToolsOptions {
|
|
502
|
+
sources: readonly ApiSourceConfig[];
|
|
503
|
+
session: ChatSession;
|
|
504
|
+
policy: ToolPolicy;
|
|
505
|
+
fetchImpl?: typeof fetch;
|
|
506
|
+
onWarning?: (message: string, detail: Record<string, unknown>) => void;
|
|
507
|
+
}
|
|
508
|
+
interface ApiToolSet {
|
|
509
|
+
tools: AgentTool[];
|
|
510
|
+
failed: Array<{
|
|
511
|
+
name: string;
|
|
512
|
+
error: string;
|
|
513
|
+
}>;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Turn a tenant's OpenAPI document into agent tools.
|
|
517
|
+
*
|
|
518
|
+
* This is the third onboarding path: a customer who has no MCP server and does
|
|
519
|
+
* not want to write one points Frankie at their spec instead. The tools it
|
|
520
|
+
* produces are indistinguishable to the agent from MCP ones, and go through the
|
|
521
|
+
* same deny-by-default policy and the same identity forwarding.
|
|
522
|
+
*/
|
|
523
|
+
declare function buildApiTools(options: BuildApiToolsOptions): Promise<ApiToolSet>;
|
|
524
|
+
interface OpenApiDoc {
|
|
525
|
+
servers?: Array<{
|
|
526
|
+
url?: string;
|
|
527
|
+
}>;
|
|
528
|
+
paths?: Record<string, unknown>;
|
|
529
|
+
components?: Record<string, unknown>;
|
|
530
|
+
}
|
|
531
|
+
declare function loadSpec(spec: string, fetchImpl?: typeof fetch): Promise<OpenApiDoc>;
|
|
532
|
+
/**
|
|
533
|
+
* `/projects/*` matches one path segment; `/projects/**` matches any depth and
|
|
534
|
+
* the base path itself, since a tenant writing that plainly means "this part of
|
|
535
|
+
* the API" rather than "everything under it but not it".
|
|
536
|
+
*/
|
|
537
|
+
declare function matchPath(glob: string, path: string): boolean;
|
|
538
|
+
|
|
539
|
+
interface CollectToolsOptions {
|
|
540
|
+
config: FrankieConfig;
|
|
541
|
+
session: ChatSession;
|
|
542
|
+
fetchImpl?: typeof fetch;
|
|
543
|
+
onWarning?: (message: string, detail: Record<string, unknown>) => void;
|
|
544
|
+
}
|
|
545
|
+
interface CollectedTools {
|
|
546
|
+
tools: AgentTool[];
|
|
547
|
+
/** Sources that could not be reached or parsed, by name. */
|
|
548
|
+
failed: Array<{
|
|
549
|
+
name: string;
|
|
550
|
+
error: string;
|
|
551
|
+
}>;
|
|
552
|
+
close(): Promise<void>;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Gather every tool the agent may use this turn, from both sources.
|
|
556
|
+
*
|
|
557
|
+
* A tenant can bring an MCP server, point at an OpenAPI document, or do both —
|
|
558
|
+
* the agent cannot tell the difference, and neither can the tool policy, which
|
|
559
|
+
* sees `mcp__<server>__<tool>` and `api__<name>__<operation>` in the same
|
|
560
|
+
* namespace.
|
|
561
|
+
*
|
|
562
|
+
* A source that fails is logged and skipped rather than failing the turn: one
|
|
563
|
+
* unreachable backend should not take the assistant down with it.
|
|
564
|
+
*/
|
|
565
|
+
declare function collectTools(options: CollectToolsOptions): Promise<CollectedTools>;
|
|
566
|
+
|
|
567
|
+
/** The forwarding fields MCP servers and API sources have in common. */
|
|
568
|
+
interface ForwardingConfig {
|
|
569
|
+
headers: Record<string, string>;
|
|
570
|
+
forwardUserTokenAs?: string | undefined;
|
|
571
|
+
forwardUserClaims: readonly string[];
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Headers sent to a tenant's own backend, whether that is an MCP server or an
|
|
575
|
+
* HTTP API.
|
|
576
|
+
*
|
|
577
|
+
* `forwardUserTokenAs` is how the tenant's tools act *as* the signed-in user:
|
|
578
|
+
* their API authorises the call exactly as it would any other request from that
|
|
579
|
+
* person. It is opt-in per source precisely because it hands that source the
|
|
580
|
+
* user's identity token.
|
|
581
|
+
*/
|
|
582
|
+
declare function buildForwardedHeaders(source: ForwardingConfig, session: ChatSession): Record<string, string>;
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The runtime's own operating instructions, followed by whatever the tenant
|
|
586
|
+
* configured. Host-supplied page context is fenced off as data: it comes from
|
|
587
|
+
* a web page and must never be able to redirect the agent.
|
|
588
|
+
*/
|
|
589
|
+
declare function buildSystemPrompt(config: FrankieConfig, session: ChatSession): string;
|
|
590
|
+
|
|
591
|
+
export { API_TOOL_PREFIX, type ApiToolSet, AuthError, type BuildApiToolsOptions, type ChatSession, type CollectToolsOptions, type CollectedTools, ConfigError, type ConnectMcpOptions, type ControlOptions, type ControlStore, type CreateAppOptions, FileTenantStore, type ForwardingConfig, MCP_TOOL_PREFIX, type McpConnection, type McpHostOptions, MemoryRateLimiter, MemorySessionStore, MemoryTenantStore, ModelResolutionError, OpenApiError, PostgresSessionStore, PostgresTenantStore, type PublicTenant, type Queryable, type RateLimiter, type ResolvedConfig, type ResolvedModel, type ResolvedStore, type ResolvedStores, type RunTurnParams, type SessionStore, SingleTenantStore, type TenantRecord, TenantRegistry, type TenantRuntime, type TenantStore, TokenService, type VerifiedUser, anonymousUser, applyEnvOverrides, buildApiTools, buildForwardedHeaders, buildSystemPrompt, collectTools, connectMcpTools, createControlApp, createFrankieApp, createMcpHostApp, isOriginAllowed, loadConfigFromFile, loadResolvedConfig, loadSpec, matchPath, modelHasApi, newSessionId, resolveModel, resolveSecrets, resolveStores, resolveTenantStore, runTurn, safeEqual, toPublicTenant };
|