diff --git a/.env.example b/.env.example index 47010b2..c7fb701 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,31 @@ -# Server -PORT=3000 -# Path to the sqlite database file -KLOE_DB=data/kloe.db +# Deployment config lives in kloe.json (see kloe.example.json + kloe.schema.json). +# Everything below is an ENV OVERRIDE of a kloe.json value — env wins over the +# file. Any "$NAME" interpolations in kloe.json (e.g. provider apiKeys) also read +# their values from here. -# Model catalog source (charmbracelet's catwalk). All optional: the live -# fetch falls back to the disk cache, then to vendor/catwalk.seed.json. +# Path to the config file itself (default ./kloe.json) +#KLOE_CONFIG=kloe.json + +# --- overrides of kloe.json fields --- +# server.port / server.dbPath +#PORT=3000 +#KLOE_DB=data/kloe.db + +# blobs.backend (fs|s3), blobs.path, blobs.s3.prefix +#KLOE_BLOB_BACKEND=fs +#KLOE_BLOBS=data/blobs +#KLOE_S3_PREFIX=blobs/ + +# S3 credentials (Bun's S3Client reads these; used when blobs.backend=s3 and +# they're not set explicitly in kloe.json). AWS_* are also accepted. +#S3_ACCESS_KEY_ID= +#S3_SECRET_ACCESS_KEY= +#S3_ENDPOINT= +#S3_BUCKET= +#S3_REGION= + +# Model catalog source (charmbracelet's catwalk) → catwalk.{url,cachePath,seedPath}. +# All optional: live fetch falls back to the disk cache, then vendor/catwalk.seed.json. #CATWALK_URL= #CATWALK_CACHE= #CATWALK_SEED= @@ -12,8 +33,7 @@ KLOE_DB=data/kloe.db # Delay between echo-model chunks (ms). Only affects the built-in mock. #ECHO_DELAY_MS=5 -# Providers are enabled in providers.json (see providers.example.json), -# where apiKey/apiEndpoint may reference env vars as "$NAME". Set the keys -# they name, e.g.: +# --- provider secrets referenced by kloe.json "$NAME" interpolations --- #ANTHROPIC_API_KEY= #OPENROUTER_API_KEY= +#HYPER_API_KEY= diff --git a/.gitignore b/.gitignore index 0fe6fb7..f0819f9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ data/ .env .env.local providers.json +kloe.json diff --git a/AGENTS.md b/AGENTS.md index 5afca0c..aa6733e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Event names (`src/events.ts`) follow AG-UI conventions (`message-start`, `text-d ## The model pipeline (three layers, keep them separate) 1. **Catalog** (`src/catalog.ts`): read-only metadata about what models exist, fetched live from catwalk with two fallbacks: disk cache (`.cache/catwalk.json`) then vendored seed (`vendor/catwalk.seed.json`). Raw payloads are snake_case and parsed into camelCase here; never let raw catwalk shapes leak past `Catalog.fromRaw`. -2. **Ops config** (`providers.json`, parsed in `src/providers.ts`): which providers this deployment enables, plus secrets and rate limits. An entry in this file means "enabled". `apiKey`/`apiEndpoint` may be `"$ENV_VAR"` interpolation strings. A provider listed here but missing from the catalog must carry `apiEndpoint`; if it also has no inline `models` list, models are **discovered live** from `{apiEndpoint}/models` at startup (`initInference` awaits `registry.discover()`), then enriched by the type's enricher in `src/discover.ts`. Crush convention: empty model list ⇒ discover, explicit list ⇒ skip (unless `discoverModels: true`). Explicit inline entries always win over discovered duplicates. `type` selects both the AI SDK adapter and the enricher (`"hyper"`, ...; default `"openai-compat"`). +2. **Ops config** (the `providers` array of `kloe.json`, loaded + validated in `src/settings.ts`, consumed by `src/providers.ts`): which providers this deployment enables, plus secrets and rate limits. An entry means "enabled". `apiKey`/`apiEndpoint` may be `"$ENV_VAR"` interpolation strings (resolved lazily via `resolveRef`); `apiKey` is optional (omit it for a keyless local provider — a *declared* key that resolves empty still errors). A provider missing from the catalog must carry `apiEndpoint`; if it also has no inline `models` list, models are **discovered live** from `{apiEndpoint}/models` at startup (`initInference` awaits `registry.discover()`), then enriched by the type's enricher in `src/discover.ts`. Crush convention: empty model list ⇒ discover, explicit list ⇒ skip (unless `discoverModels: true`). Explicit inline entries always win over discovered duplicates. `type` selects both the AI SDK adapter and the enricher (`"hyper"`, ...; default `"openai-compat"`). 3. **Curation** (`model_settings` table, `PATCH /api/models`): which models the chat UI shows. **Opt-in: a model with no row is hidden.** `/api/models` is the admin view (all models + curation state), `/api/models/chat` is the curated view. The built-in **`echo` model** (`createEchoModel` in `src/providers.ts`) is a deterministic streaming mock that bypasses all three layers. It exists so the whole pipeline runs with zero network access; tests and the smoke script rely on it. Refs are `provider/model`; `echo` is the one bare ref allowed. @@ -52,14 +52,15 @@ The built-in **`echo` model** (`createEchoModel` in `src/providers.ts`) is a det - `src/store.ts` — SQLite schema and all prepared statements. Column names are snake_case; TS interfaces camelCase; `rowToSetting`-style converters bridge them. Follow that pattern for new tables. - `src/inference.ts` — module-level registry (`initInference`/`getRegistry`/`setRegistry`) and `run()`, which wraps `streamText`. - `src/ratelimit.ts` — per-provider concurrency cap + min-interval shaping with 429-adaptive backoff. The semaphore deliberately hands permits from `release()` directly to a waiter without decrementing `active`; don't "simplify" it, the comment explains the race it prevents. +- `src/settings.ts` — the single validated deployment config (`kloe.json` + env + `$VAR` interpolation); `getConfig()` is the one loader every module reads. `src/blobs.ts` — content-addressed blob store (`BlobStore` interface, `FsBlobStore`/`S3BlobStore` backends, `createBlobStore()` picks by `config.blobs.backend`). - `src/catalog.ts`, `src/providers.ts`, `src/discover.ts`, `src/events.ts`, `src/sse.ts`, `src/config.ts`, `src/errors.ts`. Discovery is ported from crush's `internal/discover`: generic `{base}/models` listing + per-type enrichers that backfill metadata without ever overwriting operator-set fields, failing soft at every step. -- Tuning constants live in `src/config.ts`. `LEASE_GRACE_MS` (30s) must stay above `HEARTBEAT_INTERVAL_MS` (10s) or healthy runs get reaped between beats. +- **Internal tuning constants** live in `src/config.ts` (batch sizes, lease/heartbeat timings) — distinct from **deployment config** (`kloe.json` via `settings.ts`). `LEASE_GRACE_MS` (30s) must stay above `HEARTBEAT_INTERVAL_MS` (10s) or healthy runs get reaped between beats. ## Testing conventions - `bun:test` with real temp-dir SQLite (`mkdtempSync` + `new Store(path)`); close and `rmSync` in `afterAll`. Never point tests at `data/`. - Exercise HTTP by starting a real server on an ephemeral port: `Bun.serve({ port: 0, routes: apiRoutes({ store }) })`, then `fetch` against `server.url.origin`. `apiRoutes` carries no HTML routes, so tests never trigger frontend bundling. Stop servers and `rmSync` temp dirs in `afterAll`. -- The inference registry is module-global, so tests call `setRegistry(...)` in `beforeEach` (not just `beforeAll`) to survive interleaving with other test files' mutations. Build fixtures with `Catalog.fromRaw([...])` and `new ProviderRegistry(catalog, { config: { providers: [...] } })`; the `config` option bypasses reading `providers.json` from disk. +- The inference registry is module-global, so tests call `setRegistry(...)` in `beforeEach` (not just `beforeAll`) to survive interleaving with other test files' mutations. Build fixtures with `Catalog.fromRaw([...])` and `new ProviderRegistry(catalog, { config: { providers: [...] } })`; the registry reads no file itself — providers are always injected (production wires in `getConfig().providers` from `src/settings.ts`). - Fake generation by passing inline async generators to `actor.runText`, or use the `echo` model. Never hit real providers in tests. Network code (`loadCatalog`, discovery) takes an injectable `fetchImpl`; tests mock it with `okFetch`-style helpers rather than intercepting globals. - SSE assertions parse frames manually (`event:`/`id:`/`data:` blocks split on blank lines, skipping `:` comment keepalives). Copy the existing `readSse` helper rather than adding a dependency. - `JobDriver` (`src/drive.ts`) in a test plays the role of the drive loop end-to-end; for finer control, `store.claimExpiredExclusive` + `actor.runText` + `store.markDone` is the manual equivalent. @@ -67,7 +68,7 @@ The built-in **`echo` model** (`createEchoModel` in `src/providers.ts`) is a det ## Gotchas - `scripts/smoke.ts` sleeps (e.g. 1200ms for claim) assume the 1s drive-loop polling interval and the `ECHO_DELAY_MS` it sets; if you change either, re-check the cancel timing. -- `.env.example` documents only the real env surface: `PORT`, `KLOE_DB`, `CATWALK_URL`, `CATWALK_CACHE`, `CATWALK_SEED`, `ECHO_DELAY_MS`, plus whatever `$ENV_VAR` interpolations `providers.json` names. +- Deployment config is one validated file, `kloe.json` (schema in `src/settings.ts` → generated `kloe.schema.json` via `bun run schema`; example in `kloe.example.json`). `getConfig()` is the single loader: schema defaults < file < a documented env-override map (`applyEnvOverrides`). `.env.example` documents that env surface (`PORT`, `KLOE_DB`, `KLOE_BLOB_BACKEND`, `KLOE_BLOBS`, `KLOE_S3_PREFIX`, `S3_*`, `CATWALK_*`, `ECHO_DELAY_MS`) plus the `$ENV_VAR` interpolations `kloe.json` names. Don't add scattered `process.env` reads — thread new config through `settings.ts`. - `Store` creates the db's parent directory on construction, and treats `:memory:` specially. Keep both behaviors if you touch the constructor. - Unknown-model validation at the HTTP edge (422) exists so jobs never fail silently at claim time. Any new endpoint that takes a model ref starts with `requireKnownModel()` in `src/http.ts`. - Job params are parsed in exactly one place (`parseJobParams` in `src/store.ts`); a corrupt row is marked `failed` immediately rather than re-claimed forever. Don't re-parse `row.params` ad hoc. diff --git a/bun.lock b/bun.lock index fc00a32..6ac5649 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ }, "devDependencies": { "@types/bun": "^1.2.2", + "@valibot/to-json-schema": "^1.7.1", }, }, }, @@ -37,6 +38,8 @@ "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], + "@valibot/to-json-schema": ["@valibot/to-json-schema@1.7.1", "", { "peerDependencies": { "valibot": "^1.4.0" } }, "sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], diff --git a/kloe.example.json b/kloe.example.json new file mode 100644 index 0000000..50366fb --- /dev/null +++ b/kloe.example.json @@ -0,0 +1,42 @@ +{ + "$schema": "./kloe.schema.json", + "server": { + "port": 3000, + "dbPath": "data/kloe.db" + }, + "blobs": { + "backend": "fs", + "path": "data/blobs", + "maxBytes": 26214400, + "s3": { + "bucket": "kloe", + "endpoint": "http://localhost:9000", + "accessKeyId": "$S3_ACCESS_KEY_ID", + "secretAccessKey": "$S3_SECRET_ACCESS_KEY", + "prefix": "blobs/" + } + }, + "providers": [ + { + "id": "anthropic", + "apiKey": "$ANTHROPIC_API_KEY", + "maxConcurrency": 4 + }, + { + "id": "openrouter", + "apiKey": "$OPENROUTER_API_KEY", + "maxConcurrency": 8 + }, + { + "id": "hyper", + "apiKey": "$HYPER_API_KEY", + "apiEndpoint": "https://hyper.charm.land/v1", + "type": "hyper" + }, + { + "id": "ollama", + "apiEndpoint": "http://localhost:11434/v1", + "type": "openai-compat" + } + ] +} diff --git a/kloe.schema.json b/kloe.schema.json new file mode 100644 index 0000000..3f54c08 --- /dev/null +++ b/kloe.schema.json @@ -0,0 +1,176 @@ +{ + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "server": { + "type": "object", + "properties": { + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "default": 3000 + }, + "dbPath": { + "type": "string", + "default": "data/kloe.db" + } + }, + "required": [], + "default": { + "port": 3000, + "dbPath": "data/kloe.db" + } + }, + "blobs": { + "type": "object", + "properties": { + "backend": { + "enum": [ + "fs", + "s3" + ], + "type": "string", + "default": "fs" + }, + "path": { + "type": "string", + "default": "data/blobs" + }, + "maxBytes": { + "type": "integer", + "minimum": 1, + "default": 26214400 + }, + "s3": { + "type": "object", + "properties": { + "bucket": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "region": { + "type": "string" + }, + "accessKeyId": { + "type": "string" + }, + "secretAccessKey": { + "type": "string" + }, + "prefix": { + "type": "string", + "default": "blobs/" + }, + "virtualHostedStyle": { + "type": "boolean" + } + }, + "required": [], + "default": { + "prefix": "blobs/" + } + } + }, + "required": [], + "default": { + "backend": "fs", + "path": "data/blobs", + "maxBytes": 26214400, + "s3": { + "prefix": "blobs/" + } + } + }, + "catwalk": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "cachePath": { + "type": "string" + }, + "seedPath": { + "type": "string" + } + }, + "required": [], + "default": {} + }, + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "apiKey": { + "type": "string" + }, + "apiEndpoint": { + "type": "string" + }, + "type": { + "type": "string" + }, + "maxConcurrency": { + "type": "number" + }, + "minIntervalMs": { + "type": "number" + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "context_window": { + "type": "number" + }, + "default_max_tokens": { + "type": "number" + }, + "can_reason": { + "type": "boolean" + }, + "reasoning_levels": { + "type": "array", + "items": { + "type": "string" + } + }, + "supports_attachments": { + "type": "boolean" + } + }, + "required": [ + "id" + ] + } + }, + "discoverModels": { + "type": "boolean" + } + }, + "required": [ + "id" + ] + }, + "default": [] + } + }, + "required": [], + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/package.json b/package.json index 7915760..c669f43 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "bun --hot server.ts", "start": "bun server.ts", - "test": "bun test" + "test": "bun test", + "schema": "bun scripts/gen-schema.ts" }, "dependencies": { "@ai-sdk/anthropic": "^4.0.27", @@ -18,6 +19,7 @@ "valibot": "^1.4.2" }, "devDependencies": { - "@types/bun": "^1.2.2" + "@types/bun": "^1.2.2", + "@valibot/to-json-schema": "^1.7.1" } } diff --git a/providers.example.json b/providers.example.json deleted file mode 100644 index b991d53..0000000 --- a/providers.example.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "providers": [ - { - "id": "openrouter", - "apiKey": "$OPENROUTER_API_KEY", - "maxConcurrency": 8, - "minIntervalMs": 0 - }, - { - "id": "anthropic", - "apiKey": "$ANTHROPIC_API_KEY", - "maxConcurrency": 4, - "minIntervalMs": 0 - }, - { - "id": "hyper", - "apiKey": "$HYPER_API_KEY", - "apiEndpoint": "https://hyper.charm.land/v1", - "type": "hyper", - "maxConcurrency": 4, - "minIntervalMs": 0 - } - ] -} diff --git a/scripts/gen-schema.ts b/scripts/gen-schema.ts new file mode 100644 index 0000000..7719e84 --- /dev/null +++ b/scripts/gen-schema.ts @@ -0,0 +1,14 @@ +/** + * Generates kloe.schema.json from the valibot config schema — the single + * source of truth. Run `bun run schema` after changing src/settings.ts so the + * committed JSON Schema (editor autocomplete + validation for kloe.json) stays + * in sync. `errorMode: "ignore"` tolerates the function-valued section defaults, + * which don't serialize to JSON Schema but don't need to. + */ +import { toJsonSchema } from "@valibot/to-json-schema"; +import { ConfigSchema } from "../src/settings"; + +const schema = toJsonSchema(ConfigSchema, { errorMode: "ignore" }); +const out = new URL("../kloe.schema.json", import.meta.url); +await Bun.write(out, JSON.stringify(schema, null, 2) + "\n"); +console.log(`wrote ${out.pathname}`); diff --git a/server.ts b/server.ts index 1c99567..f45f047 100644 --- a/server.ts +++ b/server.ts @@ -5,6 +5,7 @@ import { Store } from "./src/store"; import { initInference } from "./src/inference"; import { apiRoutes, getActor, evictIdleActors } from "./src/http"; import { JobDriver } from "./src/drive"; +import { getConfig } from "./src/settings"; import { REAP_INTERVAL_MS } from "./src/config"; /** @@ -49,7 +50,7 @@ if (import.meta.main) { "/og-image.png": file("og-image.png"), }; - const port = Number(process.env.PORT ?? 3000); + const port = getConfig().server.port; // The SSE stream is intentionally long-lived and can sit idle between // generations. Bun's default idleTimeout is 10s — shorter than our 15s // keepalive — so an idle stream would be killed before the first keepalive diff --git a/src/blobs.ts b/src/blobs.ts index c6aaeac..db8e115 100644 --- a/src/blobs.ts +++ b/src/blobs.ts @@ -3,6 +3,7 @@ import { rename, unlink } from "node:fs/promises"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { S3Client } from "bun"; +import { getConfig, type Config } from "./settings"; /** * Content-addressed blob storage — the byte layer behind attachments and agent @@ -61,7 +62,7 @@ export class FsBlobStore implements BlobStore { private readonly root: string; private readonly tmpDir: string; - constructor(root: string = process.env.KLOE_BLOBS ?? "data/blobs") { + constructor(root: string = "data/blobs") { this.root = root; this.tmpDir = join(root, "tmp"); mkdirSync(this.tmpDir, { recursive: true }); @@ -152,7 +153,7 @@ export class S3BlobStore implements BlobStore { constructor(opts: S3BlobStoreOptions = {}) { const { client, prefix, ...creds } = opts; - this.prefix = prefix ?? process.env.KLOE_S3_PREFIX ?? "blobs/"; + this.prefix = prefix ?? "blobs/"; // Drop undefined so an unset option doesn't clobber Bun's env fallback. const clean = Object.fromEntries( Object.entries(creds).filter(([, v]) => v !== undefined), @@ -202,21 +203,19 @@ export class S3BlobStore implements BlobStore { } /** - * Builds the blob store from env: `KLOE_BLOB_BACKEND` selects `fs` (default) or - * `s3`. The `fs` backend reads `KLOE_BLOBS` for its root; the `s3` backend reads - * the standard `S3_*`/`AWS_*` credentials (plus `KLOE_S3_PREFIX`). This is the - * one place the deployment picks a backend — everything else takes a `BlobStore`. + * Builds the blob store from validated config (`config.blobs`): `backend` + * selects `fs` (root = `path`) or `s3` (creds + `prefix`, missing creds falling + * back to Bun's `S3_*`/`AWS_*` env). The backend value is schema-validated + * upstream, so an invalid one fails at config load, not here. This is the one + * place the deployment picks a backend — everything else takes a `BlobStore`. */ -export function createBlobStore(): BlobStore { - const backend = (process.env.KLOE_BLOB_BACKEND ?? "fs").toLowerCase(); - switch (backend) { +export function createBlobStore(blobs: Config["blobs"] = getConfig().blobs): BlobStore { + switch (blobs.backend) { case "fs": - return new FsBlobStore(); + return new FsBlobStore(blobs.path); case "s3": - return new S3BlobStore(); + return new S3BlobStore({ ...blobs.s3 }); default: - throw new Error( - `unknown KLOE_BLOB_BACKEND "${backend}" (expected "fs" or "s3")`, - ); + throw new Error(`unknown blob backend "${blobs.backend}"`); } } diff --git a/src/catalog.ts b/src/catalog.ts index 268c04a..63206f4 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -1,5 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +import { getConfig } from "./settings"; /** * The model/provider *catalog*: universal metadata (endpoints, context windows, @@ -91,8 +92,8 @@ function reqStr(value: unknown, field: string): string { /** * Parses one model entry from raw catwalk JSON (snake_case). Exported so - * `providers.json` can carry inline model lists for providers that aren't in - * the catalog. + * `kloe.json` providers can carry inline model lists for providers that aren't + * in the catalog. */ export function parseModel(m: RawModel): CatalogModel { const id = reqStr(m.id, "model.id"); @@ -202,9 +203,10 @@ function readJsonFile(path: string): unknown | undefined { * logged so a stale catalog is visible in the logs rather than silent. */ export async function loadCatalog(opts: LoadCatalogOptions = {}): Promise { - const url = opts.url ?? process.env.CATWALK_URL ?? DEFAULT_URL; - const cachePath = opts.cachePath ?? process.env.CATWALK_CACHE ?? DEFAULT_CACHE; - const seedPath = opts.seedPath ?? process.env.CATWALK_SEED ?? DEFAULT_SEED; + const cat = getConfig().catwalk; + const url = opts.url ?? cat.url ?? DEFAULT_URL; + const cachePath = opts.cachePath ?? cat.cachePath ?? DEFAULT_CACHE; + const seedPath = opts.seedPath ?? cat.seedPath ?? DEFAULT_SEED; const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const fetchImpl = opts.fetchImpl ?? fetch; diff --git a/src/config.ts b/src/config.ts index ff76922..39b940f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,8 +7,5 @@ export const LEASE_GRACE_MS = 30_000; export const REAP_INTERVAL_MS = 5_000; export const SUBSCRIBER_HEARTBEAT_MS = 15_000; export const MAX_SSE_FIELD_BYTES = 8 * 1024; -// Upload cap enforced at POST /api/blobs (policy, not the byte store's concern). -// Bounds disk/memory per upload; the S3 backend buffers up to this to hash. -export const MAX_BLOB_BYTES = 25 * 1024 * 1024; // Actors with no subscribers and no active run are evicted after this TTL. export const ACTOR_IDLE_TTL_MS = 5 * 60_000; diff --git a/src/inference.ts b/src/inference.ts index 1d56fc4..26fd689 100644 --- a/src/inference.ts +++ b/src/inference.ts @@ -2,6 +2,7 @@ import { streamText, type LanguageModel, type ModelMessage } from "ai"; import { ProviderRegistry } from "./providers"; import { RateLimiter } from "./ratelimit"; import { loadCatalog, type LoadCatalogOptions } from "./catalog"; +import { getConfig } from "./settings"; import type { RunStep } from "./actor"; import type { TokenUsage } from "./events"; @@ -50,12 +51,13 @@ export function getRegistry(): ProviderRegistry { * limiter state. Pass `force: true` to rebuild (e.g. to reload the catalog). */ export function initInference( - opts: { catalog?: LoadCatalogOptions; configPath?: string; force?: boolean } = {}, + opts: { catalog?: LoadCatalogOptions; force?: boolean } = {}, ): Promise { if (initPromise && !opts.force) return initPromise; initPromise = (async () => { const catalog = await loadCatalog(opts.catalog); - const r = new ProviderRegistry(catalog, { configPath: opts.configPath }); + // Providers come from the single validated config (kloe.json + env). + const r = new ProviderRegistry(catalog, { config: { providers: getConfig().providers } }); // Live model discovery for non-catalog providers (e.g. Hyper). Soft-fails: // the server boots with whatever models were declared inline. await r.discover(); diff --git a/src/providers.ts b/src/providers.ts index 80248e6..ed4cf82 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -2,21 +2,21 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createOpenAI } from "@ai-sdk/openai"; import { createAnthropic } from "@ai-sdk/anthropic"; import type { LanguageModel } from "ai"; -import { existsSync, readFileSync } from "node:fs"; import type { Catalog, CatalogModel, ProviderType } from "./catalog"; import { parseModel } from "./catalog"; import { discoverModels, enrichModels } from "./discover"; +import { resolveRef } from "./settings"; /** * Ops config for one *enabled* provider — the deployment-specific layer. An - * entry in providers.json means "this provider is turned on: here's its secret + * entry in kloe.json's `providers` means "this provider is turned on: here's its secret * and how hard to push it". Everything else (endpoint, model list, pricing, * capabilities) comes from the catalog, matched by `id`. */ export interface ProviderConfig { id: string; - /** API key: a "$ENV_VAR" interpolation or a literal. */ - apiKey: string; + /** API key: a "$ENV_VAR" interpolation or a literal. Absent for keyless providers. */ + apiKey?: string; /** Optional endpoint override (else the catalog's); "$ENV_VAR" or literal. */ apiEndpoint?: string; /** Max concurrent in-flight requests to this provider. */ @@ -28,7 +28,7 @@ export interface ProviderConfig { interface OpsFile { providers: Array<{ id: string; - apiKey: string; + apiKey?: string; apiEndpoint?: string; maxConcurrency?: number; minIntervalMs?: number; @@ -97,7 +97,7 @@ type ModelFactory = (modelId: string) => LanguageModel; /** Cached factory plus the resolved credentials it was built with, so a * rotated key/endpoint rebuilds instead of serving a stale one. */ interface CachedFactory { - apiKey: string; + apiKey: string | undefined; baseURL: string | undefined; factory: ModelFactory; } @@ -126,11 +126,13 @@ export class ProviderRegistry { constructor( catalog: Catalog, - opts: { configPath?: string; config?: OpsFile; fetchImpl?: typeof fetch } = {}, + opts: { config?: OpsFile; fetchImpl?: typeof fetch } = {}, ) { this.catalog = catalog; this.fetchImpl = opts.fetchImpl; - const ops = opts.config ?? this.loadFile(opts.configPath ?? "providers.json"); + // Providers come pre-loaded and validated from settings (kloe.json); the + // registry no longer reads any file itself. Absent config → echo only. + const ops = opts.config ?? { providers: [] }; for (const p of ops.providers) { if (p.id === "echo") continue; // built-in, not catalog-backed if (!catalog.getProvider(p.id)) { @@ -174,14 +176,14 @@ export class ProviderRegistry { for (const [id, inline] of this.inline) { if (!inline.needsDiscovery) continue; const config = this.configs.get(id)!; - const baseUrl = resolveEnv(config.apiEndpoint); + const baseUrl = resolveRef(config.apiEndpoint); if (!baseUrl) continue; jobs.push( (async () => { const cfg = { id, baseUrl, - apiKey: resolveEnv(config.apiKey), + apiKey: resolveRef(config.apiKey), fetchImpl: this.fetchImpl, timeoutMs: opts.timeoutMs, }; @@ -205,18 +207,6 @@ export class ProviderRegistry { await Promise.all(jobs); } - private loadFile(path: string): OpsFile { - if (!existsSync(path)) return { providers: [] }; - const raw = readFileSync(path, "utf8"); - try { - return JSON.parse(raw) as OpsFile; - } catch (err) { - throw new Error( - `failed to parse provider config "${path}": ${(err as Error).message}`, - ); - } - } - /** Ops config for an enabled provider (echo/unknown → undefined). */ getConfig(id: string): ProviderConfig | undefined { return this.configs.get(id); @@ -299,16 +289,20 @@ export class ProviderRegistry { private factoryFor(providerId: string, config: ProviderConfig): ModelFactory { const catProvider = this.catalog.getProvider(providerId); const inline = this.inline.get(providerId); - const apiKey = resolveEnv(config.apiKey); - if (!apiKey) { + // Keyless is allowed when NO key is declared: it resolves to undefined and + // the adapter omits the credential (local endpoints). But a key that WAS + // declared yet resolves empty is a misconfig (env var forgotten), not a + // keyless provider — flag it rather than silently sending no credential. + const apiKey = resolveRef(config.apiKey); + if (config.apiKey !== undefined && !apiKey) { throw new Error( - `provider "${providerId}" requires an API key (config: ${config.apiKey})`, + `provider "${providerId}" declares an API key that resolved empty (config: ${config.apiKey}); set its env var, or remove apiKey for a keyless provider`, ); } // Inline providers are required (at construction) to have an apiEndpoint, // so it always wins here via config.apiEndpoint. const baseURL = - resolveEnv(config.apiEndpoint) ?? resolveEnv(catProvider?.apiEndpoint); + resolveRef(config.apiEndpoint) ?? resolveRef(catProvider?.apiEndpoint); const cached = this.factories.get(providerId); if (cached && cached.apiKey === apiKey && cached.baseURL === baseURL) { @@ -322,27 +316,23 @@ export class ProviderRegistry { } } -/** Resolves a "$ENV_VAR" interpolation to its env value; passes literals through. */ -function resolveEnv(value: string | undefined): string | undefined { - if (!value) return undefined; - if (value.startsWith("$")) return process.env[value.slice(1)]; - return value; -} - /** Selects the AI SDK adapter for a provider based on its catalog `type`. */ function buildFactory( providerId: string, type: ProviderType, - apiKey: string, + apiKey: string | undefined, baseURL: string | undefined, ): ModelFactory { + // Omit the credential entirely when there isn't one, so keyless (local) + // endpoints work and keyed ones are unchanged. + const key = apiKey ? { apiKey } : {}; switch (type) { case "anthropic": { - const p = createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }); + const p = createAnthropic({ ...key, ...(baseURL ? { baseURL } : {}) }); return (modelId) => p(modelId); } case "openai": { - const p = createOpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); + const p = createOpenAI({ ...key, ...(baseURL ? { baseURL } : {}) }); return (modelId) => p(modelId); } // openai-compat, openrouter, and any other type fall back to the @@ -353,7 +343,7 @@ function buildFactory( `provider "${providerId}" (type "${type}") needs an endpoint; set apiEndpoint or ensure the catalog provides one`, ); } - const p = createOpenAICompatible({ name: providerId, baseURL, apiKey }); + const p = createOpenAICompatible({ name: providerId, baseURL, ...key }); return (modelId) => p(modelId); } } diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..0de862a --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,233 @@ +import * as v from "valibot"; +import { existsSync, readFileSync } from "node:fs"; + +/** + * The single source of truth for kloe's deployment config. One `kloe.json`, + * validated by one valibot schema, from which `kloe.schema.json` is generated + * (see scripts/gen-schema.ts) so the file gets editor autocomplete + validation. + * + * Three layers, lowest precedence first: + * 1. schema defaults (below) + * 2. `kloe.json` on disk (path via `KLOE_CONFIG`, default `./kloe.json`) + * 3. environment overrides (a small, documented map — `applyEnvOverrides`) + * + * String values may interpolate env vars — `$VAR`, `${VAR}`, `${VAR:-default}` + * — resolved at load time. This replaces the old unvalidated + * `JSON.parse(...) as OpsFile` cast and the scattered `process.env.*` reads; + * everything deployment-shaped now flows through `getConfig()`. + * + * Internal tuning constants (batch sizes, lease/heartbeat timings) stay in + * config.ts — they're implementation tuning, not deployment config. + */ + +// ---- schema ------------------------------------------------------------ + +/** A model declared inline for a provider the catwalk catalog doesn't know. */ +const ProviderModelSchema = v.object({ + id: v.string(), + name: v.optional(v.string()), + context_window: v.optional(v.number()), + default_max_tokens: v.optional(v.number()), + can_reason: v.optional(v.boolean()), + reasoning_levels: v.optional(v.array(v.string())), + supports_attachments: v.optional(v.boolean()), +}); + +/** + * One enabled provider (the ops layer). Shape matches what ProviderRegistry + * consumes; `maxConcurrency`/`minIntervalMs` are left undefined here so the + * registry applies its own DEFAULTS (no double-defaulting). + */ +const ProviderSchema = v.object({ + id: v.string(), + // Optional: keyless providers (local endpoints) need no credential. Providers + // that do need one fail at request time via the upstream's own auth error. + apiKey: v.optional(v.string()), + apiEndpoint: v.optional(v.string()), + type: v.optional(v.string()), + maxConcurrency: v.optional(v.number()), + minIntervalMs: v.optional(v.number()), + models: v.optional(v.array(ProviderModelSchema)), + discoverModels: v.optional(v.boolean()), +}); + +const S3Schema = v.object({ + bucket: v.optional(v.string()), + endpoint: v.optional(v.string()), + region: v.optional(v.string()), + accessKeyId: v.optional(v.string()), + secretAccessKey: v.optional(v.string()), + prefix: v.optional(v.string(), "blobs/"), + virtualHostedStyle: v.optional(v.boolean()), +}); + +const BlobsSchema = v.object({ + backend: v.optional(v.picklist(["fs", "s3"]), "fs"), + path: v.optional(v.string(), "data/blobs"), + maxBytes: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 25 * 1024 * 1024), + s3: section(S3Schema), +}); + +const CatwalkSchema = v.object({ + url: v.optional(v.string()), + cachePath: v.optional(v.string()), + seedPath: v.optional(v.string()), +}); + +const ServerSchema = v.object({ + port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(65535)), 3000), + dbPath: v.optional(v.string(), "data/kloe.db"), +}); + +export const ConfigSchema = v.object({ + $schema: v.optional(v.string()), + server: section(ServerSchema), + blobs: section(BlobsSchema), + catwalk: section(CatwalkSchema), + providers: v.optional(v.array(ProviderSchema), []), +}); +export type Config = v.InferOutput; + +/** A sub-object that defaults to its own filled defaults when the key is absent. */ +function section>(schema: TSchema) { + return v.optional(schema, () => v.parse(schema, {})); +} + +// ---- interpolation ----------------------------------------------------- + +/** + * Resolves a WHOLE-VALUE reference — `$VAR`, `${VAR}`, `${VAR:-default}` — to + * its env value, or undefined if unset with no default; passes literals + * through. Used for provider credentials, where "unset" must stay undefined + * (not empty string) so callers can skip a provider with no key. + */ +export function resolveRef( + value: string | undefined, + env: Record = process.env, +): string | undefined { + if (!value) return undefined; + const m = value.match(/^\$\{?(\w+)(?::-([^}]*))?\}?$/); + if (!m) return value; + const resolved = env[m[1]!]; + return resolved !== undefined && resolved !== "" ? resolved : m[2]; +} + +const EMBEDDED = /\$\{(\w+)(?::-([^}]*))?\}|\$(\w+)/g; + +/** + * Resolves EMBEDDED references anywhere in a string (`https://host/${TOKEN}`), + * substituting "" for an unset var with no default. Used for config file + * string values other than provider credentials. + */ +export function interpolate( + value: string, + env: Record = process.env, +): string { + return value.replace(EMBEDDED, (_m, braced, def, bare) => { + const resolved = env[(braced ?? bare) as string]; + return resolved !== undefined && resolved !== "" ? resolved : (def ?? ""); + }); +} + +/** Interpolates every string in a value, recursively. */ +function interpolateDeep(node: unknown, env: Record): unknown { + if (typeof node === "string") return interpolate(node, env); + if (Array.isArray(node)) return node.map((n) => interpolateDeep(n, env)); + if (node && typeof node === "object") { + return Object.fromEntries( + Object.entries(node).map(([k, val]) => [k, interpolateDeep(val, env)]), + ); + } + return node; +} + +// ---- loading ----------------------------------------------------------- + +type Env = Record; + +function readConfigFile(path: string): Record { + if (!existsSync(path)) return {}; + const raw = readFileSync(path, "utf8"); + try { + return JSON.parse(raw) as Record; + } catch (err) { + throw new Error(`config "${path}" is not valid JSON: ${(err as Error).message}`); + } +} + +/** Writes a value at a nested key path, creating intermediate objects. */ +function setPath(obj: Record, keys: string[], value: unknown): void { + let o = obj; + for (let i = 0; i < keys.length - 1; i++) { + o[keys[i]!] ??= {}; + o = o[keys[i]!]; + } + o[keys[keys.length - 1]!] = value; +} + +/** + * The documented env → config-path map. Env wins over the file (highest + * precedence). This is the ONLY place these vars are read, replacing the + * scattered `process.env.*` fallbacks across modules. + */ +function applyEnvOverrides(raw: Record, env: Env): Record { + const cfg = structuredClone(raw); + const put = (keys: string[], value: unknown) => setPath(cfg, keys, value); + if (env.PORT) put(["server", "port"], Number(env.PORT)); + if (env.KLOE_DB) put(["server", "dbPath"], env.KLOE_DB); + if (env.KLOE_BLOB_BACKEND) put(["blobs", "backend"], env.KLOE_BLOB_BACKEND); + if (env.KLOE_BLOBS) put(["blobs", "path"], env.KLOE_BLOBS); + if (env.KLOE_S3_PREFIX) put(["blobs", "s3", "prefix"], env.KLOE_S3_PREFIX); + if (env.CATWALK_URL) put(["catwalk", "url"], env.CATWALK_URL); + if (env.CATWALK_CACHE) put(["catwalk", "cachePath"], env.CATWALK_CACHE); + if (env.CATWALK_SEED) put(["catwalk", "seedPath"], env.CATWALK_SEED); + return cfg; +} + +export interface LoadOptions { + path?: string; + env?: Env; +} + +/** + * Loads, layers, interpolates, and validates the config. Pure over its inputs + * (path + env), so tests can drive it without touching the process env. The + * `providers` subtree is left un-interpolated on purpose: provider credentials + * are resolved lazily by the registry (via `resolveRef`), preserving its + * rotate-rebuild behavior and its direct-injection test path. + */ +export function loadConfig(opts: LoadOptions = {}): Config { + const env = opts.env ?? process.env; + const path = opts.path ?? env.KLOE_CONFIG ?? "kloe.json"; + const withEnv = applyEnvOverrides(readConfigFile(path), env); + + const { providers, ...rest } = withEnv; + const resolved = { ...(interpolateDeep(rest, env) as object), providers }; + + try { + return v.parse(ConfigSchema, resolved); + } catch (err) { + if (err instanceof v.ValiError) { + const details = err.issues + .map((i) => { + const p = v.getDotPath(i); + return p ? `${p}: ${i.message}` : i.message; + }) + .join("; "); + throw new Error(`invalid config "${path}": ${details}`); + } + throw err; + } +} + +let cached: Config | null = null; + +/** The process-wide config, loaded once from `kloe.json` + env on first use. */ +export function getConfig(): Config { + return (cached ??= loadConfig()); +} + +/** Overrides the cached config (tests); pass null to force a reload next call. */ +export function setConfig(config: Config | null): void { + cached = config; +} diff --git a/src/store.ts b/src/store.ts index 51fc935..11d5354 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,6 +1,7 @@ import { Database } from "bun:sqlite"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; +import { getConfig } from "./settings"; export interface JobRow { id: string; @@ -228,7 +229,7 @@ export class Store { private pendingQueueStmt: ReturnType; private hasPendingFlushStmt: ReturnType; - constructor(databasePath: string = process.env.KLOE_DB ?? "data/kloe.db") { + constructor(databasePath: string = getConfig().server.dbPath) { // Ensure the parent directory exists so a fresh checkout (where `data/` is // gitignored and absent) doesn't crash with SQLITE_CANTOPEN. Skipped for // in-memory databases, which have no filesystem path. diff --git a/tests/blobs.test.ts b/tests/blobs.test.ts index 7491dd3..beb314b 100644 --- a/tests/blobs.test.ts +++ b/tests/blobs.test.ts @@ -70,29 +70,22 @@ test("a malformed sha256 never touches the filesystem", async () => { await blobs.delete("../../etc/passwd"); // no throw, no escape }); -// ---- factory (backend selection) --------------------------------------- -test("createBlobStore defaults to the fs backend", () => { - const prev = process.env.KLOE_BLOB_BACKEND; - delete process.env.KLOE_BLOB_BACKEND; - try { - expect(createBlobStore()).toBeInstanceOf(FsBlobStore); - process.env.KLOE_BLOB_BACKEND = "fs"; - expect(createBlobStore()).toBeInstanceOf(FsBlobStore); - } finally { - if (prev === undefined) delete process.env.KLOE_BLOB_BACKEND; - else process.env.KLOE_BLOB_BACKEND = prev; - } +// ---- factory (backend selection from validated config) ----------------- +// The backend value is validated at config load (see settings.test.ts), so the +// factory just dispatches; it takes an explicit blobs-config so it's pure. +test("createBlobStore builds the fs backend from config", () => { + const store = createBlobStore({ backend: "fs", path: root, maxBytes: 1, s3: { prefix: "blobs/" } }); + expect(store).toBeInstanceOf(FsBlobStore); }); -test("createBlobStore rejects an unknown backend", () => { - const prev = process.env.KLOE_BLOB_BACKEND; - process.env.KLOE_BLOB_BACKEND = "gopher"; - try { - expect(() => createBlobStore()).toThrow(/unknown KLOE_BLOB_BACKEND/); - } finally { - if (prev === undefined) delete process.env.KLOE_BLOB_BACKEND; - else process.env.KLOE_BLOB_BACKEND = prev; - } +test("createBlobStore builds the s3 backend from config", () => { + const store = createBlobStore({ + backend: "s3", + path: "data/blobs", + maxBytes: 1, + s3: { bucket: "b", prefix: "blobs/" }, + }); + expect(store).toBeInstanceOf(S3BlobStore); }); // ---- S3 backend (opt-in: needs a real S3-compatible endpoint) ----------- diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index 82d40ce..73a6d21 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -125,7 +125,6 @@ test("initInference is idempotent — concurrent calls share one registry", asyn seedPath: tmpFile("seed.json", JSON.stringify(RAW)), fetchImpl: failFetch(), }, - configPath: "/nonexistent-providers.json", }; const [a, b] = await Promise.all([initInference(opts), initInference(opts)]); expect(a).toBe(b); diff --git a/tests/providers.test.ts b/tests/providers.test.ts index 3766a39..a408772 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -158,6 +158,17 @@ test("resolveModel requires the provider's API key env var", () => { expect(() => registry().resolveModel("acme/acme-1")).toThrow(/API key/); }); +test("a provider that declares NO apiKey resolves keyless", () => { + // No apiKey field at all → intentional keyless (local endpoint), not a + // misconfig; the adapter is built without a credential. + const keyless = inlineConfig(); + delete (keyless as { apiKey?: string }).apiKey; + const reg = new ProviderRegistry(fixtureCatalog(), { + config: { providers: [keyless] }, + }); + expect(reg.resolveModel("hyper/hyper-1")).toBeDefined(); +}); + test("resolveModel builds an adapter when the key is present", () => { process.env.ACME_KEY = "sk-test"; process.env.ANTH_KEY = "sk-anthropic"; diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..5788fd9 --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,85 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadConfig, resolveRef, interpolate } from "../src/settings"; + +/** Writes a kloe.json into a fresh tmp dir and returns its path. */ +function writeConfig(obj: unknown): string { + const dir = mkdtempSync(join(tmpdir(), "kloe-cfg-")); + const path = join(dir, "kloe.json"); + writeFileSync(path, JSON.stringify(obj)); + return path; +} + +// ---- defaults & precedence --------------------------------------------- +test("an absent file yields fully-defaulted config", () => { + const cfg = loadConfig({ path: join(tmpdir(), "does-not-exist.json"), env: {} }); + expect(cfg.server.port).toBe(3000); + expect(cfg.server.dbPath).toBe("data/kloe.db"); + expect(cfg.blobs.backend).toBe("fs"); + expect(cfg.blobs.path).toBe("data/blobs"); + expect(cfg.blobs.s3.prefix).toBe("blobs/"); // nested section default fills + expect(cfg.providers).toEqual([]); +}); + +test("file overrides defaults; env overrides the file", () => { + const path = writeConfig({ server: { port: 4000 }, blobs: { backend: "fs" } }); + const fileOnly = loadConfig({ path, env: {} }); + expect(fileOnly.server.port).toBe(4000); + + const withEnv = loadConfig({ path, env: { PORT: "5000", KLOE_BLOB_BACKEND: "s3" } }); + expect(withEnv.server.port).toBe(5000); // env wins over file + expect(withEnv.blobs.backend).toBe("s3"); +}); + +// ---- validation -------------------------------------------------------- +test("an invalid backend fails loudly at load", () => { + const path = writeConfig({ blobs: { backend: "gopher" } }); + expect(() => loadConfig({ path, env: {} })).toThrow(/invalid config/); +}); + +test("a non-numeric PORT env is rejected by the schema", () => { + expect(() => loadConfig({ path: "none.json", env: { PORT: "abc" } })).toThrow(/invalid config/); +}); + +test("malformed JSON reports which file", () => { + const dir = mkdtempSync(join(tmpdir(), "kloe-cfg-")); + const path = join(dir, "kloe.json"); + writeFileSync(path, "{ not json"); + try { + expect(() => loadConfig({ path, env: {} })).toThrow(/not valid JSON/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ---- interpolation ----------------------------------------------------- +test("config string values interpolate env vars", () => { + const path = writeConfig({ blobs: { s3: { endpoint: "https://${HOST}/s3" } } }); + const cfg = loadConfig({ path, env: { HOST: "minio.home" } }); + expect(cfg.blobs.s3.endpoint).toBe("https://minio.home/s3"); +}); + +test("provider credentials are left raw for the registry to resolve", () => { + const path = writeConfig({ providers: [{ id: "x", apiKey: "$SECRET" }] }); + const cfg = loadConfig({ path, env: { SECRET: "shh" } }); + // Not interpolated at load — the registry resolves lazily via resolveRef. + expect(cfg.providers[0]!.apiKey).toBe("$SECRET"); +}); + +test("resolveRef handles $VAR, ${VAR}, defaults, and literals", () => { + const env = { A: "one", EMPTY: "" }; + expect(resolveRef("$A", env)).toBe("one"); + expect(resolveRef("${A}", env)).toBe("one"); + expect(resolveRef("${MISSING:-fallback}", env)).toBe("fallback"); + expect(resolveRef("$MISSING", env)).toBeUndefined(); // unset, no default → undefined + expect(resolveRef("${EMPTY:-def}", env)).toBe("def"); // empty counts as unset + expect(resolveRef("literal", env)).toBe("literal"); +}); + +test("interpolate substitutes embedded refs, defaulting unset to empty", () => { + expect(interpolate("a/$B/c", { B: "x" })).toBe("a/x/c"); + expect(interpolate("a/${MISSING}/c", {})).toBe("a//c"); + expect(interpolate("a/${MISSING:-d}/c", {})).toBe("a/d/c"); +});