Browse Source
Centralizes env parsing/validation into a single loadConfig() (config.ts), replacing scattered process.env reads across server.js and dev-plugin.ts and making dev fail fast on a missing OPENAI_API_KEY the same way prod already did. Analytics gains a master on/off switch (a new NoopAnalyticsRecorder, selected when ANALYTICS_ENABLED=false) plus independent category toggles for messages/tool calls/events, so a deployment can control what gets logged without writing a custom recorder. Session persona (model/instructions) moves out of the core defaults.ts into project-owned persona.ts, mirroring the existing tools.ts split and fixing a prior inconsistency where a branched project was expected to hand-edit a core file. Client tool timeout, token TTL, model override, and default voice are now env-configurable instead of hardcoded constants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>main
19 changed files with 765 additions and 88 deletions
@ -1,6 +1,47 @@ |
|||
# --- Secrets (required) --- |
|||
|
|||
# OpenAI API key used server-side only (never sent to the browser). |
|||
OPENAI_API_KEY= |
|||
|
|||
# Secret used to sign short-lived browser session tokens for the realtime WS handshake. |
|||
# Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
|||
REALTIME_TOKEN_SECRET= |
|||
|
|||
# --- Server --- |
|||
|
|||
# HTTP port the production server (server.js) listens on. |
|||
# PORT=3000 |
|||
|
|||
# --- Analytics (see docs/ANALYTICS.md and docs/CONFIGURATION.md) --- |
|||
|
|||
# Master switch. Set to false to disable analytics recording entirely — no |
|||
# data/analytics.db file is created at all. |
|||
# ANALYTICS_ENABLED=true |
|||
|
|||
# Where the local SQLite analytics db lives. |
|||
# ANALYTICS_DB_PATH=./data/analytics.db |
|||
|
|||
# Per-category toggles, independent of ANALYTICS_ENABLED and each other. |
|||
# Set any of these to false to stop recording that category's data while |
|||
# still recording session start/end (model, voice, timing) — that volume |
|||
# signal is always recorded whenever analytics is enabled at all. |
|||
# ANALYTICS_LOG_MESSAGES=true # conversation text (both roles) |
|||
# ANALYTICS_LOG_TOOL_CALLS=true # tool call arguments/output |
|||
# ANALYTICS_LOG_EVENTS=true # errors and session.closed detail |
|||
|
|||
# --- Session --- |
|||
|
|||
# Overrides the model configured in src/lib/project/persona.ts. Leave unset |
|||
# to use that file's default. |
|||
# SESSION_MODEL= |
|||
|
|||
# Voice used when the browser's session.start doesn't specify one. Leave |
|||
# unset to let the provider pick its own default. |
|||
# SESSION_DEFAULT_VOICE= |
|||
|
|||
# How long a client-executed tool call may go unanswered before the session |
|||
# gives up on it and resolves it with an error. |
|||
# CLIENT_TOOL_TIMEOUT_MS=10000 |
|||
|
|||
# TTL for the short-lived token minted by POST /api/realtime/session. |
|||
# REALTIME_TOKEN_TTL_MS=60000 |
|||
|
|||
@ -0,0 +1,57 @@ |
|||
# Configuration |
|||
|
|||
Every env var this app reads is parsed and validated in one place: [`src/lib/server/core/config.ts`](../src/lib/server/core/config.ts)'s `loadConfig()`. It reads `process.env` directly (not `$env/static/private`) for the same reason `tokens.ts` and `dev-plugin.ts` already do — this module tree is compiled standalone (`tsc -p tsconfig.server.json`) for `server.js` to import outside Vite's module graph. See [`.env.example`](../.env.example) for the full list with inline comments. |
|||
|
|||
## `AppConfig` shape |
|||
|
|||
```ts |
|||
interface AppConfig { |
|||
port: number; |
|||
openaiApiKey: string; |
|||
analytics: AnalyticsConfig; // { enabled, dbPath, messages, toolCalls, events } |
|||
session: SessionRuntimeConfig; // { modelOverride?, defaultVoice?, clientToolTimeoutMs, tokenTtlMs } |
|||
} |
|||
``` |
|||
|
|||
`AnalyticsConfig` extends `AnalyticsCategoryConfig` (defined in `analytics.ts` itself, not here — it's the recorder's own config shape, the same way `SessionDefaults` lives in `session.ts`). |
|||
|
|||
## Env vars |
|||
|
|||
| Var | Default | Required | |
|||
| -------------------------- | ----------------------------------- | -------- | |
|||
| `OPENAI_API_KEY` | — | yes | |
|||
| `REALTIME_TOKEN_SECRET` | — | yes | |
|||
| `PORT` | `3000` | no | |
|||
| `ANALYTICS_ENABLED` | `true` | no | |
|||
| `ANALYTICS_DB_PATH` | `./data/analytics.db` | no | |
|||
| `ANALYTICS_LOG_MESSAGES` | `true` | no | |
|||
| `ANALYTICS_LOG_TOOL_CALLS` | `true` | no | |
|||
| `ANALYTICS_LOG_EVENTS` | `true` | no | |
|||
| `SESSION_MODEL` | `SESSION_PERSONA.model` (see below) | no | |
|||
| `SESSION_DEFAULT_VOICE` | unset — provider picks | no | |
|||
| `CLIENT_TOOL_TIMEOUT_MS` | `10000` | no | |
|||
| `REALTIME_TOKEN_TTL_MS` | `60000` | no | |
|||
|
|||
Booleans accept exactly `"true"`/`"false"`; anything else (if the var is set at all) is a validation error. Integers are validated for range where one applies (e.g. `PORT` must be `1–65535`). |
|||
|
|||
## Fail-fast, in both dev and prod |
|||
|
|||
`loadConfig()` collects every problem — missing required var, malformed boolean/integer, out-of-range integer — into a single thrown `Error` listing all of them, rather than failing on the first one found. It's called once, eagerly, at both composition roots (`server.js`'s top level; `dev-plugin.ts`'s `configureServer`, right after `.env` is loaded), and memoized after that (env vars don't change mid-process, and this gets called again from the session-token route to read `tokenTtlMs`). |
|||
|
|||
This means `npm run dev` now requires a fully-populated `.env` to start at all — previously, a missing `OPENAI_API_KEY` only produced a console warning in dev and the failure surfaced later, confusingly, inside a WS handshake attempt. Prod already threw eagerly; this just makes dev match it, consistent with this repo's existing "fail loud at boot" precedent (see `createToolRegistry`'s doc comment in `server/core/tools.ts`). |
|||
|
|||
`REALTIME_TOKEN_SECRET` is a deliberate partial exception: `loadConfig()` checks it's _present_ (so a missing secret is caught at the same startup point as everything else) but does not store its value on `AppConfig` — `tokens.ts` remains the sole reader of the actual secret, reading `process.env` directly and lazily, exactly as before. This is what keeps `tokens.spec.ts`'s existing pattern of mutating `process.env.REALTIME_TOKEN_SECRET` per test working unchanged. |
|||
|
|||
## The `vite.config.ts` / Vitest gotcha |
|||
|
|||
**Do not** move a throwing, env-validating call to `vite.config.ts`'s top level, and don't have it build an already-resolved session-defaults object either. Both Vitest projects in `vite.config.ts`'s `test.projects` use `extends: './vite.config.ts'`, so this file's top-level code — and any plugin `configureServer` hook — runs on every `npm run test:unit`, not just `npm run dev`. `dev-plugin.ts`'s `configureServer` already has to guard itself with `if (process.env.VITEST) return` for exactly this reason. |
|||
|
|||
Concretely: `vite.config.ts` passes only raw, env-independent pieces into the dev plugin — `realtimeDevWsPlugin({ persona: SESSION_PERSONA, toolRegistry })` — never a pre-resolved `defaults`/`config` object. The `loadConfig()` call, `resolveSessionDefaults(...)`, and the `NoopAnalyticsRecorder`/`SqliteAnalyticsRecorder` choice all happen _inside_ `configureServer`, below the `VITEST` guard. If a future change needs another env-driven value in dev, thread it through the same way — resolve it inside `configureServer`, not at `vite.config.ts` module scope — or `npm run test:unit` will start requiring a populated `.env` for no reason, and may pick up a developer's real local secrets into `process.env` during test runs. |
|||
|
|||
## Session persona vs. runtime overrides |
|||
|
|||
The actual persona — model choice and system instructions — is project-owned content in [`src/lib/project/persona.ts`](../src/lib/project/persona.ts), not part of this config system (see [`docs/TOOLS.md`](TOOLS.md) for why core/project content is split this way). `config.ts`'s `session.modelOverride` (from `SESSION_MODEL`) lets a deployment swap the model without touching that file; `instructions` deliberately has **no** env override — it's prose, and belongs in a commented TypeScript file, not a `.env` value. |
|||
|
|||
## What's not configurable (on purpose) |
|||
|
|||
`src/lib/server/core/providers/openai.ts`'s hardcoded upstream URL, its always-`['audio']` `output_modalities`, and its `tool_choice: 'auto'` are deliberately out of scope here — these are provider-tuning knobs nobody asked to change yet, and adding config surface for them now would be speculative. |
|||
@ -0,0 +1,22 @@ |
|||
/** |
|||
* Project-owned session persona — the model and system instructions a |
|||
* branched client project actually wants. Isomorphic (no server/client-only |
|||
* imports), mirroring `$lib/project/tools.ts`: core never imports this file |
|||
* directly (see docs/TOOLS.md) — `resolveSessionDefaults` in |
|||
* `$lib/server/core/defaults.ts` takes it as a parameter instead, and the |
|||
* composition roots (server.js, vite.config.ts) are what wire it in. |
|||
* |
|||
* A branched project edits this file freely. `model` can also be overridden |
|||
* per-deployment via the `SESSION_MODEL` env var (see |
|||
* $lib/server/core/config.ts); `instructions` deliberately has no env |
|||
* override — prose doesn't belong in a .env value, and this file is its home. |
|||
*/ |
|||
export interface SessionPersona { |
|||
model: string; |
|||
instructions: string; |
|||
} |
|||
|
|||
export const SESSION_PERSONA: SessionPersona = { |
|||
model: 'gpt-realtime', |
|||
instructions: 'You are a helpful voice assistant for a webavatar demo. Keep responses concise.' |
|||
}; |
|||
@ -0,0 +1,173 @@ |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
|||
|
|||
// Every env var loadConfig() reads — snapshotted/restored per test so this
|
|||
// file can't leak state into (or pick up state from) other spec files.
|
|||
const ENV_KEYS = [ |
|||
'OPENAI_API_KEY', |
|||
'REALTIME_TOKEN_SECRET', |
|||
'PORT', |
|||
'ANALYTICS_ENABLED', |
|||
'ANALYTICS_DB_PATH', |
|||
'ANALYTICS_LOG_MESSAGES', |
|||
'ANALYTICS_LOG_TOOL_CALLS', |
|||
'ANALYTICS_LOG_EVENTS', |
|||
'SESSION_MODEL', |
|||
'SESSION_DEFAULT_VOICE', |
|||
'CLIENT_TOOL_TIMEOUT_MS', |
|||
'REALTIME_TOKEN_TTL_MS' |
|||
] as const; |
|||
|
|||
const REQUIRED_ENV = { |
|||
OPENAI_API_KEY: 'test-key', |
|||
REALTIME_TOKEN_SECRET: 'test-secret' |
|||
}; |
|||
|
|||
let originalEnv: Partial<Record<(typeof ENV_KEYS)[number], string>>; |
|||
|
|||
beforeEach(() => { |
|||
originalEnv = {}; |
|||
for (const key of ENV_KEYS) { |
|||
originalEnv[key] = process.env[key]; |
|||
delete process.env[key]; |
|||
} |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
for (const key of ENV_KEYS) { |
|||
if (originalEnv[key] === undefined) delete process.env[key]; |
|||
else process.env[key] = originalEnv[key]; |
|||
} |
|||
}); |
|||
|
|||
/** |
|||
* loadConfig() memoizes its result at module scope, so every test needs a |
|||
* fresh module instance to see its own process.env setup rather than a |
|||
* previous test's cached result. |
|||
*/ |
|||
async function freshLoadConfig() { |
|||
vi.resetModules(); |
|||
const mod = await import('./config.js'); |
|||
return mod.loadConfig; |
|||
} |
|||
|
|||
describe('loadConfig', () => { |
|||
it('applies every default when only the required secrets are set', async () => { |
|||
Object.assign(process.env, REQUIRED_ENV); |
|||
const loadConfig = await freshLoadConfig(); |
|||
|
|||
expect(loadConfig()).toEqual({ |
|||
port: 3000, |
|||
openaiApiKey: 'test-key', |
|||
analytics: { |
|||
enabled: true, |
|||
dbPath: expect.stringContaining('analytics.db'), |
|||
messages: true, |
|||
toolCalls: true, |
|||
events: true |
|||
}, |
|||
session: { |
|||
modelOverride: undefined, |
|||
defaultVoice: undefined, |
|||
clientToolTimeoutMs: 10_000, |
|||
tokenTtlMs: 60_000 |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
it('does not expose REALTIME_TOKEN_SECRET anywhere on the returned config', async () => { |
|||
Object.assign(process.env, REQUIRED_ENV); |
|||
const loadConfig = await freshLoadConfig(); |
|||
|
|||
expect(JSON.stringify(loadConfig())).not.toContain('test-secret'); |
|||
}); |
|||
|
|||
it('reads every override when set', async () => { |
|||
Object.assign(process.env, REQUIRED_ENV, { |
|||
PORT: '8080', |
|||
ANALYTICS_ENABLED: 'false', |
|||
ANALYTICS_DB_PATH: '/tmp/custom.db', |
|||
ANALYTICS_LOG_MESSAGES: 'false', |
|||
ANALYTICS_LOG_TOOL_CALLS: 'false', |
|||
ANALYTICS_LOG_EVENTS: 'false', |
|||
SESSION_MODEL: 'custom-model', |
|||
SESSION_DEFAULT_VOICE: 'alloy', |
|||
CLIENT_TOOL_TIMEOUT_MS: '5000', |
|||
REALTIME_TOKEN_TTL_MS: '30000' |
|||
}); |
|||
const loadConfig = await freshLoadConfig(); |
|||
|
|||
expect(loadConfig()).toEqual({ |
|||
port: 8080, |
|||
openaiApiKey: 'test-key', |
|||
analytics: { |
|||
enabled: false, |
|||
dbPath: '/tmp/custom.db', |
|||
messages: false, |
|||
toolCalls: false, |
|||
events: false |
|||
}, |
|||
session: { |
|||
modelOverride: 'custom-model', |
|||
defaultVoice: 'alloy', |
|||
clientToolTimeoutMs: 5000, |
|||
tokenTtlMs: 30000 |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
it('memoizes: a later process.env change is not picked up by the same loaded module', async () => { |
|||
Object.assign(process.env, REQUIRED_ENV); |
|||
const loadConfig = await freshLoadConfig(); |
|||
|
|||
expect(loadConfig().port).toBe(3000); |
|||
process.env.PORT = '9999'; |
|||
expect(loadConfig().port).toBe(3000); |
|||
}); |
|||
|
|||
it('throws mentioning every missing required var when none are set', async () => { |
|||
const loadConfig = await freshLoadConfig(); |
|||
expect(() => loadConfig()).toThrow(/OPENAI_API_KEY/); |
|||
expect(() => loadConfig()).toThrow(/REALTIME_TOKEN_SECRET/); |
|||
}); |
|||
|
|||
it('throws a descriptive error for an invalid boolean value', async () => { |
|||
Object.assign(process.env, REQUIRED_ENV, { ANALYTICS_ENABLED: 'yes' }); |
|||
const loadConfig = await freshLoadConfig(); |
|||
expect(() => loadConfig()).toThrow(/ANALYTICS_ENABLED must be "true" or "false"/); |
|||
}); |
|||
|
|||
it('throws a descriptive error for a non-integer value', async () => { |
|||
Object.assign(process.env, REQUIRED_ENV, { PORT: 'not-a-number' }); |
|||
const loadConfig = await freshLoadConfig(); |
|||
expect(() => loadConfig()).toThrow(/PORT must be an integer/); |
|||
}); |
|||
|
|||
it('throws a descriptive error when an integer is out of range', async () => { |
|||
Object.assign(process.env, REQUIRED_ENV, { PORT: '99999' }); |
|||
const loadConfig = await freshLoadConfig(); |
|||
expect(() => loadConfig()).toThrow(/PORT must be <= 65535/); |
|||
}); |
|||
|
|||
it('aggregates multiple simultaneous problems into one thrown error', async () => { |
|||
Object.assign(process.env, { |
|||
// OPENAI_API_KEY and REALTIME_TOKEN_SECRET both left unset.
|
|||
PORT: 'not-a-number', |
|||
ANALYTICS_ENABLED: 'maybe' |
|||
}); |
|||
const loadConfig = await freshLoadConfig(); |
|||
|
|||
let thrown: unknown; |
|||
try { |
|||
loadConfig(); |
|||
} catch (err) { |
|||
thrown = err; |
|||
} |
|||
|
|||
expect(thrown).toBeInstanceOf(Error); |
|||
const message = (thrown as Error).message; |
|||
expect(message).toContain('OPENAI_API_KEY'); |
|||
expect(message).toContain('REALTIME_TOKEN_SECRET'); |
|||
expect(message).toContain('PORT'); |
|||
expect(message).toContain('ANALYTICS_ENABLED'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,128 @@ |
|||
import { join } from 'node:path'; |
|||
import type { AnalyticsCategoryConfig } from './analytics.js'; |
|||
|
|||
/** |
|||
* Central env-var parsing/validation for this app. Lives in `server/core` |
|||
* (not `core/`) because it reads `process.env` directly rather than |
|||
* SvelteKit's `$env/static/private` — same reasoning as `tokens.ts`: this |
|||
* module tree is compiled standalone (`tsc -p tsconfig.server.json`) for |
|||
* `server.js` to import, outside Vite's module graph. |
|||
*/ |
|||
|
|||
export interface AnalyticsConfig extends AnalyticsCategoryConfig { |
|||
enabled: boolean; |
|||
dbPath: string; |
|||
} |
|||
|
|||
export interface SessionRuntimeConfig { |
|||
/** Overrides `SessionPersona.model` (see $lib/project/persona.ts) when set. */ |
|||
modelOverride?: string; |
|||
defaultVoice?: string; |
|||
clientToolTimeoutMs: number; |
|||
tokenTtlMs: number; |
|||
} |
|||
|
|||
export interface AppConfig { |
|||
port: number; |
|||
openaiApiKey: string; |
|||
analytics: AnalyticsConfig; |
|||
session: SessionRuntimeConfig; |
|||
} |
|||
|
|||
let cached: AppConfig | null = null; |
|||
|
|||
/** |
|||
* Parses and validates every env var this app reads, once, into a single |
|||
* typed object — memoized, since env vars don't change mid-process and this |
|||
* is called from both composition roots (server.js, dev-plugin.ts) plus the |
|||
* session-token route. Throws one Error listing every problem found, not |
|||
* just the first, so a misconfigured deployment fails loudly and completely |
|||
* at startup rather than one env var at a time. |
|||
* |
|||
* `REALTIME_TOKEN_SECRET` is checked for presence here (so a missing secret |
|||
* fails at the same startup point as everything else) but its value is |
|||
* deliberately NOT stored on `AppConfig` — `tokens.ts` remains the sole |
|||
* reader of the actual secret, reading `process.env` directly and lazily. |
|||
* Routing it through this memoized singleton would break `tokens.spec.ts`'s |
|||
* pattern of mutating `process.env.REALTIME_TOKEN_SECRET` per test to |
|||
* exercise `mintSessionToken`/`verifySessionToken` against different |
|||
* secrets — don't "simplify" this away. |
|||
*/ |
|||
export function loadConfig(): AppConfig { |
|||
if (cached) return cached; |
|||
|
|||
const errors: string[] = []; |
|||
|
|||
const port = parseIntEnv('PORT', 3000, { min: 1, max: 65535 }, errors); |
|||
const openaiApiKey = requireEnv('OPENAI_API_KEY', errors); |
|||
requireEnv('REALTIME_TOKEN_SECRET', errors); // presence only — see doc comment above
|
|||
|
|||
const analytics: AnalyticsConfig = { |
|||
enabled: parseBoolEnv('ANALYTICS_ENABLED', true, errors), |
|||
dbPath: process.env.ANALYTICS_DB_PATH || join(process.cwd(), 'data', 'analytics.db'), |
|||
messages: parseBoolEnv('ANALYTICS_LOG_MESSAGES', true, errors), |
|||
toolCalls: parseBoolEnv('ANALYTICS_LOG_TOOL_CALLS', true, errors), |
|||
events: parseBoolEnv('ANALYTICS_LOG_EVENTS', true, errors) |
|||
}; |
|||
|
|||
const session: SessionRuntimeConfig = { |
|||
modelOverride: optionalEnv('SESSION_MODEL'), |
|||
defaultVoice: optionalEnv('SESSION_DEFAULT_VOICE'), |
|||
clientToolTimeoutMs: parseIntEnv('CLIENT_TOOL_TIMEOUT_MS', 10_000, { min: 1 }, errors), |
|||
tokenTtlMs: parseIntEnv('REALTIME_TOKEN_TTL_MS', 60_000, { min: 1 }, errors) |
|||
}; |
|||
|
|||
if (errors.length > 0) { |
|||
throw new Error(`Invalid configuration:\n${errors.map((e) => ` - ${e}`).join('\n')}`); |
|||
} |
|||
|
|||
cached = { port, openaiApiKey, analytics, session }; |
|||
return cached; |
|||
} |
|||
|
|||
function parseBoolEnv(name: string, def: boolean, errors: string[]): boolean { |
|||
const raw = process.env[name]; |
|||
if (raw === undefined || raw === '') return def; |
|||
if (raw === 'true') return true; |
|||
if (raw === 'false') return false; |
|||
errors.push(`${name} must be "true" or "false" if set (got "${raw}").`); |
|||
return def; |
|||
} |
|||
|
|||
function parseIntEnv( |
|||
name: string, |
|||
def: number, |
|||
opts: { min?: number; max?: number }, |
|||
errors: string[] |
|||
): number { |
|||
const raw = process.env[name]; |
|||
if (raw === undefined || raw === '') return def; |
|||
const n = Number(raw); |
|||
if (!Number.isInteger(n)) { |
|||
errors.push(`${name} must be an integer if set (got "${raw}").`); |
|||
return def; |
|||
} |
|||
if (opts.min !== undefined && n < opts.min) { |
|||
errors.push(`${name} must be >= ${opts.min} (got ${n}).`); |
|||
return def; |
|||
} |
|||
if (opts.max !== undefined && n > opts.max) { |
|||
errors.push(`${name} must be <= ${opts.max} (got ${n}).`); |
|||
return def; |
|||
} |
|||
return n; |
|||
} |
|||
|
|||
function requireEnv(name: string, errors: string[]): string { |
|||
const raw = process.env[name]; |
|||
if (!raw) { |
|||
errors.push(`${name} is not set. See .env.example.`); |
|||
return ''; |
|||
} |
|||
return raw; |
|||
} |
|||
|
|||
function optionalEnv(name: string): string | undefined { |
|||
const raw = process.env[name]; |
|||
return raw === undefined || raw === '' ? undefined : raw; |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
import { describe, expect, it } from 'vitest'; |
|||
import { resolveSessionDefaults } from './defaults.js'; |
|||
|
|||
const PERSONA = { model: 'persona-model', instructions: 'persona instructions' }; |
|||
|
|||
describe('resolveSessionDefaults', () => { |
|||
it('uses the persona model/instructions when no overrides are given', () => { |
|||
expect(resolveSessionDefaults(PERSONA, {})).toEqual({ |
|||
model: 'persona-model', |
|||
instructions: 'persona instructions', |
|||
defaultVoice: undefined |
|||
}); |
|||
}); |
|||
|
|||
it('prefers modelOverride over the persona model when set', () => { |
|||
expect(resolveSessionDefaults(PERSONA, { modelOverride: 'override-model' })).toEqual({ |
|||
model: 'override-model', |
|||
instructions: 'persona instructions', |
|||
defaultVoice: undefined |
|||
}); |
|||
}); |
|||
|
|||
it('passes defaultVoice through unchanged', () => { |
|||
expect(resolveSessionDefaults(PERSONA, { defaultVoice: 'coral' })).toEqual({ |
|||
model: 'persona-model', |
|||
instructions: 'persona instructions', |
|||
defaultVoice: 'coral' |
|||
}); |
|||
}); |
|||
}); |
|||
@ -1,16 +1,24 @@ |
|||
import type { SessionPersona } from '../../project/persona.js'; |
|||
import type { SessionDefaults } from './session.js'; |
|||
|
|||
/** |
|||
* Server-owned session policy shared by the dev-mode Vite plugin and the |
|||
* production custom server, so both environments start sessions identically. |
|||
* A real client project will likely replace this with its own |
|||
* persona/instructions, possibly per-route rather than a single constant. |
|||
* Merges a project's persona (`$lib/project/persona.ts`) with env-driven |
|||
* overrides (`$lib/server/core/config.ts`'s `SessionRuntimeConfig`) into the |
|||
* session policy shared by the dev-mode Vite plugin and the production |
|||
* custom server, so both environments start sessions identically. |
|||
* |
|||
* Deliberately missing `toolRegistry`: this file is core, and core never |
|||
* imports project code (see docs/TOOLS.md) — the composition roots |
|||
* (server.js, vite.config.ts) merge this with the project's tool registry. |
|||
* This file is core and never imports project code directly (see |
|||
* docs/TOOLS.md) — `persona` is taken as a parameter, and the composition |
|||
* roots (server.js, vite.config.ts) are what supply it alongside the |
|||
* project's tool registry. |
|||
*/ |
|||
export const DEFAULT_SESSION_DEFAULTS: Omit<SessionDefaults, 'toolRegistry'> = { |
|||
model: 'gpt-realtime', |
|||
instructions: 'You are a helpful voice assistant for a webavatar demo. Keep responses concise.' |
|||
export function resolveSessionDefaults( |
|||
persona: SessionPersona, |
|||
overrides: { modelOverride?: string; defaultVoice?: string } |
|||
): Omit<SessionDefaults, 'toolRegistry'> { |
|||
return { |
|||
model: overrides.modelOverride ?? persona.model, |
|||
instructions: persona.instructions, |
|||
defaultVoice: overrides.defaultVoice |
|||
}; |
|||
} |
|||
|
|||
@ -1,8 +1,9 @@ |
|||
import { json } from '@sveltejs/kit'; |
|||
import { loadConfig } from '$lib/server/core/config.js'; |
|||
import { mintSessionToken } from '$lib/server/core/tokens.js'; |
|||
import type { RequestHandler } from './$types'; |
|||
|
|||
export const POST: RequestHandler = () => { |
|||
const { token, expiresAt } = mintSessionToken(); |
|||
const { token, expiresAt } = mintSessionToken(loadConfig().session.tokenTtlMs); |
|||
return json({ token, expiresAt }); |
|||
}; |
|||
|
|||
Loading…
Reference in new issue