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 used server-side only (never sent to the browser). |
||||
OPENAI_API_KEY= |
OPENAI_API_KEY= |
||||
|
|
||||
# Secret used to sign short-lived browser session tokens for the realtime WS handshake. |
# 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'))" |
# Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
||||
REALTIME_TOKEN_SECRET= |
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'; |
import type { SessionDefaults } from './session.js'; |
||||
|
|
||||
/** |
/** |
||||
* Server-owned session policy shared by the dev-mode Vite plugin and the |
* Merges a project's persona (`$lib/project/persona.ts`) with env-driven |
||||
* production custom server, so both environments start sessions identically. |
* overrides (`$lib/server/core/config.ts`'s `SessionRuntimeConfig`) into the |
||||
* A real client project will likely replace this with its own |
* session policy shared by the dev-mode Vite plugin and the production |
||||
* persona/instructions, possibly per-route rather than a single constant. |
* custom server, so both environments start sessions identically. |
||||
* |
* |
||||
* Deliberately missing `toolRegistry`: this file is core, and core never |
* This file is core and never imports project code directly (see |
||||
* imports project code (see docs/TOOLS.md) — the composition roots |
* docs/TOOLS.md) — `persona` is taken as a parameter, and the composition |
||||
* (server.js, vite.config.ts) merge this with the project's tool registry. |
* roots (server.js, vite.config.ts) are what supply it alongside the |
||||
|
* project's tool registry. |
||||
*/ |
*/ |
||||
export const DEFAULT_SESSION_DEFAULTS: Omit<SessionDefaults, 'toolRegistry'> = { |
export function resolveSessionDefaults( |
||||
model: 'gpt-realtime', |
persona: SessionPersona, |
||||
instructions: 'You are a helpful voice assistant for a webavatar demo. Keep responses concise.' |
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 { json } from '@sveltejs/kit'; |
||||
|
import { loadConfig } from '$lib/server/core/config.js'; |
||||
import { mintSessionToken } from '$lib/server/core/tokens.js'; |
import { mintSessionToken } from '$lib/server/core/tokens.js'; |
||||
import type { RequestHandler } from './$types'; |
import type { RequestHandler } from './$types'; |
||||
|
|
||||
export const POST: RequestHandler = () => { |
export const POST: RequestHandler = () => { |
||||
const { token, expiresAt } = mintSessionToken(); |
const { token, expiresAt } = mintSessionToken(loadConfig().session.tokenTtlMs); |
||||
return json({ token, expiresAt }); |
return json({ token, expiresAt }); |
||||
}; |
}; |
||||
|
|||||
Loading…
Reference in new issue