From a224efd9e3e2a39dac54542f4d92fcf3b14a4631 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 16:02:36 -0600 Subject: [PATCH] refactor(admin): support keyless provider seeding and clarify API_KEY usage Update provider seeding logic and documentation to allow creation of admin/shared providers without requiring an API key. Adjust environment variable handling so that a blank or missing API_KEY is valid when API_BASE is set, enabling support for upstream TTS providers that do not require authentication. Remove defaulting to 'none' for API keys in API routes and ensure headers are omitted when no key is present. Add and update tests to verify correct handling of keyless providers. BREAKING CHANGE: Providers can now be seeded without an API key; API_KEY is no longer required if API_BASE is set. Existing deployments relying on a non-empty API_KEY for seeding should review their environment configuration. --- .env.example | 4 +- docs-site/docs/configure/admin-panel.md | 6 +- .../configure/tts-provider-guides/other.md | 2 +- docs-site/docs/deploy/local-development.md | 6 +- docs-site/docs/docker-quick-start.md | 8 +-- .../docs/reference/environment-variables.md | 5 +- src/app/api/audiobook/chapter/route.ts | 2 +- src/app/api/tts/segments/ensure/route.ts | 2 +- src/app/api/tts/voices/route.ts | 2 +- src/lib/server/admin/seed.ts | 19 +++--- src/lib/server/tts/generate.ts | 1 + src/lib/server/tts/voice-resolution.ts | 2 +- .../admin-providers-validation.vitest.spec.ts | 19 ++++++ tests/unit/runtime-seed-json.vitest.spec.ts | 58 +++++++++++++++++++ 14 files changed, 104 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index 02c3e57..173fc65 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ # remove them once the seed has run. Auth must be configured for this seed path. # See Settings → Admin → Shared providers. API_BASE=http://localhost:8880/v1 -API_KEY=api_key_optional +API_KEY= # Auth configuration (required in v4+) BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) @@ -88,4 +88,4 @@ S3_BUCKET= # (Optional) v4 JSON seed for first-boot runtime config + shared providers. # If both are set, RUNTIME_SEED_JSON_PATH is used. # RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json -# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]} +# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","defaultModel":"kokoro","enabled":true}]} diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 181071a..82a70c4 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -50,10 +50,10 @@ Whether users can supply their own personal built-in provider keys is controlled ### Auto-seeded "default-openai" -On first boot, if `admin_providers` is empty and the legacy `API_KEY` env var is set, OpenReader creates a single shared provider with: +On first boot, if `admin_providers` is empty and `API_BASE` or `API_KEY` is set, OpenReader creates a single shared provider with: - slug `default-openai`, displayName `Default (from env)`, providerType `custom-openai` -- baseUrl from `API_BASE`, apiKey from `API_KEY` (encrypted) +- baseUrl from `API_BASE`, apiKey from `API_KEY` when provided (blank keys are supported) - defaultModel set to `kokoro` (you can edit it in Admin → Shared providers) After this seed runs, the legacy `API_KEY` / `API_BASE` env vars are no longer read by the TTS routes — the DB row is authoritative. You can rename, edit, disable, or delete this row like any other from the admin UI, and remove the env vars from your `.env` when convenient. @@ -124,7 +124,7 @@ In v4, runtime site features are managed by admin settings and optional JSON see 1. Deploy this version with your existing env values in place. 2. Boot the app once. Open Settings → Admin and verify: - Seeded settings appear as **from seed** (if you supplied a runtime JSON seed). - - A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set). + - A `default-openai` row exists in **Shared providers** (if you had `API_BASE` or `API_KEY` set). 3. Remove any bootstrap env vars you no longer need from `.env`. 4. Redeploy. Behavior is unchanged — the DB is now the source of truth. diff --git a/docs-site/docs/configure/tts-provider-guides/other.md b/docs-site/docs/configure/tts-provider-guides/other.md index bcb3a0a..4e3aafc 100644 --- a/docs-site/docs/configure/tts-provider-guides/other.md +++ b/docs-site/docs/configure/tts-provider-guides/other.md @@ -26,7 +26,7 @@ Known compatible implementations: [Kokoro-FastAPI](./kokoro-fastapi), [KittenTTS ```env API_BASE=http://your-tts-server/v1 -API_KEY=optional-key-if-required +# API_KEY=optional-key-if-required ``` **Or in-app via Settings → TTS Provider:** diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 7454b7b..3f5f0b1 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -249,7 +249,6 @@ Use one of these `.env` mode templates: ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= # Optional when you need multiple local origins: @@ -260,10 +259,9 @@ AUTH_SECRET= ```env -# API_BASE / API_KEY are seeded into the admin "default-openai" shared provider +# API_BASE and optional API_KEY are seeded into the admin "default-openai" shared provider # on first boot, then no longer read. Manage them in Settings → Admin afterwards. API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= # Comma-separated emails to auto-promote to admin on signin. @@ -275,7 +273,6 @@ ADMIN_EMAILS=you@example.com ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none USE_EMBEDDED_WEED_MINI=false BASE_URL=http://localhost:3003 AUTH_SECRET= @@ -293,7 +290,6 @@ S3_SECRET_ACCESS_KEY=your-secret-key ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= COMPUTE_WORKER_URL=http://localhost:8081 diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index 06cf6fa..e9e1cc0 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -42,7 +42,6 @@ docker run --name openreader \ -p 8333:8333 \ -v openreader_docstore:/app/docstore \ -e API_BASE=http://host.docker.internal:8880/v1 \ - -e API_KEY=none \ -e BASE_URL=http://localhost:3003 \ -e AUTH_SECRET=$(openssl rand -hex 32) \ -e ADMIN_EMAILS=you@example.com \ @@ -54,7 +53,7 @@ What this command enables: - `-p 3003:3003`: exposes the OpenReader web app/API. - `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download. - `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state. -- `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel). +- `-e API_BASE=...` / optional `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest when provided). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel). - `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup. - `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings. @@ -70,7 +69,6 @@ docker run --name openreader \ -p 8333:8333 \ -v openreader_docstore:/app/docstore \ -e API_BASE=http://host.docker.internal:8880/v1 \ - -e API_KEY=none \ -e BASE_URL=http://:3003 \ -e AUTH_SECRET=$(openssl rand -hex 32) \ -e AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 \ @@ -88,7 +86,7 @@ What this command enables: - `AUTH_TRUSTED_ORIGINS` allows localhost loopback origins in addition to your primary LAN origin. - `USE_ANONYMOUS_AUTH_SESSIONS=true` allows guest sessions while auth is enabled. - `API_BASE` seeds the default TTS endpoint into the admin-managed `default-openai` shared provider on first boot. Edit it from **Settings → Admin → Shared providers** after that. -- `API_KEY` seeds the default provider's key (encrypted at rest). After first boot, manage keys from the admin panel or set per-user keys if `restrictUserApiKeys=false`. Always needed for seeding the default provider. +- `API_KEY` optionally seeds the default provider's key (encrypted at rest). Omit it for an upstream that does not require authentication. - `ADMIN_EMAILS=...` (optional) auto-promotes the listed email(s) to admin so they can manage shared providers and site feature flags from the UI. - `openreader_docstore` volume keeps data persistent across restarts. @@ -112,7 +110,7 @@ What this command enables: - Fast startup with only the required auth env vars. - No persistent volume (`/app/docstore` stays container-local), so data is ephemeral unless you add a mount. - The app still requires `BASE_URL` + `AUTH_SECRET` in v4+, so include them even in minimal mode. -- No TTS provider preset by default. Configure `API_BASE`/`API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel. +- No TTS provider preset by default. Configure `API_BASE` and, when required, `API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel. diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index d80bd8e..71fc981 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -85,7 +85,7 @@ App server log level. Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider. - Example: `http://host.docker.internal:8880/v1` -- Read only for provider bootstrap when shared providers are empty and `API_KEY` is set. +- Read only for provider bootstrap when shared providers are empty. Setting `API_BASE` is sufficient; `API_KEY` may be blank. - After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**. ### API_KEY @@ -446,7 +446,6 @@ Example: "displayName": "Default (seeded)", "providerType": "custom-openai", "baseUrl": "http://localhost:8880/v1", - "apiKey": "api_key_optional", "defaultModel": "kokoro", "enabled": true } @@ -457,7 +456,7 @@ Example: Provider fallback behavior: - If the JSON seed includes `providers` (including an empty array), `API_BASE` / `API_KEY` fallback is skipped. -- If the JSON seed does not include a `providers` key, the legacy `API_BASE` / `API_KEY` bootstrap fallback can still create `default-openai` when provider rows are empty. +- If the JSON seed does not include a `providers` key, the legacy `API_BASE` / `API_KEY` bootstrap fallback can still create `default-openai` when provider rows are empty. `API_BASE` alone is sufficient for an upstream that does not require authentication. Precedence summary: diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index e63d045..7aa881a 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -501,7 +501,7 @@ export async function POST(request: NextRequest) { normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 }, }); } - const openApiKey = credResolved.apiKey || 'none'; + const openApiKey = credResolved.apiKey; const openApiBaseUrl = credResolved.baseUrl; const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider; const model = resolveTtsModelForProvider({ diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index f089231..4dfb6d9 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -644,7 +644,7 @@ export async function POST(request: NextRequest) { instructions: effectiveSettings.ttsInstructions, language: effectiveSettings.language, provider: requestCreds.provider, - apiKey: requestCreds.apiKey || 'none', + apiKey: requestCreds.apiKey, baseUrl: requestCreds.baseUrl, testNamespace: scope.testNamespace, }, request.signal, { diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 0e1b5f5..1c31b39 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -62,7 +62,7 @@ export async function GET(req: NextRequest) { const voices = await resolveVoices({ provider: resolved.provider, model: requestedModel, - apiKey: resolved.apiKey || 'none', + apiKey: resolved.apiKey, baseUrl: resolved.baseUrl, }); return NextResponse.json({ voices }); diff --git a/src/lib/server/admin/seed.ts b/src/lib/server/admin/seed.ts index c06cc48..0409299 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -32,7 +32,7 @@ type SeedProviderInput = { slug: string; displayName: string; providerType: TtsProviderId; - apiKey: string; + apiKey?: string; baseUrl?: string | null; defaultModel?: string | null; defaultInstructions?: string | null; @@ -187,8 +187,8 @@ function validateSeedProviderEntry(value: unknown, index: number): SeedProviderI if (typeof rec.providerType !== 'string') { throw new Error(`Seed JSON providers[${index}].providerType must be a string`); } - if (typeof rec.apiKey !== 'string' || !rec.apiKey.trim()) { - throw new Error(`Seed JSON providers[${index}].apiKey must be a non-empty string`); + if (rec.apiKey !== undefined && typeof rec.apiKey !== 'string') { + throw new Error(`Seed JSON providers[${index}].apiKey must be a string when provided`); } if (rec.baseUrl !== undefined && rec.baseUrl !== null && typeof rec.baseUrl !== 'string') { @@ -208,7 +208,7 @@ function validateSeedProviderEntry(value: unknown, index: number): SeedProviderI slug: validateSlug(rec.slug), displayName: rec.displayName.trim(), providerType: validateProviderType(rec.providerType), - apiKey: rec.apiKey.trim(), + ...(rec.apiKey !== undefined ? { apiKey: rec.apiKey.trim() } : {}), ...(rec.baseUrl !== undefined ? { baseUrl: normalizeOptionalString(rec.baseUrl) } : {}), ...(rec.defaultModel !== undefined ? { defaultModel: normalizeOptionalString(rec.defaultModel) } : {}), ...(rec.defaultInstructions !== undefined ? { defaultInstructions: normalizeOptionalString(rec.defaultInstructions) } : {}), @@ -246,7 +246,8 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi if (existing.length > 0) continue; try { - const encrypted = encryptSecret(provider.apiKey); + const apiKey = provider.apiKey ?? ''; + const encrypted = encryptSecret(apiKey); await db.insert(adminProviders).values({ id: randomUUID(), slug: provider.slug, @@ -255,7 +256,7 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi baseUrl: provider.baseUrl ?? null, apiKeyCiphertext: encrypted.ciphertext, apiKeyIv: encrypted.iv, - apiKeyLast4: apiKeyLast4(provider.apiKey), + apiKeyLast4: apiKeyLast4(apiKey), defaultModel: provider.defaultModel ?? null, defaultInstructions: provider.defaultInstructions ?? null, enabled: provider.enabled === false ? 0 : 1, @@ -278,8 +279,9 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi } async function seedDefaultAdminProviderFromEnvFallback(): Promise { - const apiKey = process.env.API_KEY; - if (!apiKey || !apiKey.trim()) return; + const apiKey = process.env.API_KEY?.trim() ?? ''; + const baseUrl = process.env.API_BASE?.trim() || null; + if (!apiKey && !baseUrl) return; let existing: Array; try { @@ -295,7 +297,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise { } if (existing.length > 0) return; - const baseUrl = process.env.API_BASE?.trim() || null; const now = Date.now(); let enc: ReturnType; try { diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index fdeb583..bbbbd48 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -641,6 +641,7 @@ async function runProviderRequest( const openai = new OpenAI({ apiKey: request.apiKey, baseURL: request.baseUrl, + defaultHeaders: request.apiKey ? undefined : { Authorization: null }, maxRetries: 0, timeout: upstreamSettings.ttsUpstreamTimeoutMs, }); diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts index 1fd9420..4a517c4 100644 --- a/src/lib/server/tts/voice-resolution.ts +++ b/src/lib/server/tts/voice-resolution.ts @@ -377,7 +377,7 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise const response = await fetch(`${normalizedBaseUrl}/audio/voices`, { signal: controller.signal, headers: { - Authorization: `Bearer ${apiKey}`, + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), 'Content-Type': 'application/json', }, }); diff --git a/tests/unit/admin-providers-validation.vitest.spec.ts b/tests/unit/admin-providers-validation.vitest.spec.ts index e977cc7..6511e0d 100644 --- a/tests/unit/admin-providers-validation.vitest.spec.ts +++ b/tests/unit/admin-providers-validation.vitest.spec.ts @@ -5,6 +5,8 @@ import { adminProviders } from '../../src/db/schema'; import { AdminProviderError, + createAdminProvider, + decryptedKeyFor, listAdminProviders, toMasked, validateProviderType, @@ -53,6 +55,23 @@ describe('admin provider validation', () => { expect(toMasked(record).apiKeyMask).toBe('••••abcd'); }); + test('creates providers without an API key', async () => { + const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; + const created = await createAdminProvider({ + slug: `keyless-${suffix}`, + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }); + + try { + expect(toMasked(created).apiKeyMask).toBe('(not set)'); + await expect(decryptedKeyFor(created)).resolves.toBe(''); + } finally { + await db.delete(adminProviders).where(inArray(adminProviders.id, [created.id])); + } + }); + test('lists providers in deterministic updated/created/slug order', async () => { const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; const inserted = [ diff --git a/tests/unit/runtime-seed-json.vitest.spec.ts b/tests/unit/runtime-seed-json.vitest.spec.ts index e62cefc..fd710bf 100644 --- a/tests/unit/runtime-seed-json.vitest.spec.ts +++ b/tests/unit/runtime-seed-json.vitest.spec.ts @@ -160,6 +160,26 @@ describe('runtime seed JSON parsing', () => { expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true); expect(__seedInternals.shouldUseEnvProviderFallback(true)).toBe(false); }); + + test('accepts seeded providers without an API key', () => { + const parsed = __seedInternals.parseRuntimeSeedDocument(JSON.stringify({ + version: 1, + providers: [{ + slug: 'keyless-provider', + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }], + })); + + expect(parsed.seed.providers?.[0]).toMatchObject({ + slug: 'keyless-provider', + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }); + expect(parsed.seed.providers?.[0]).not.toHaveProperty('apiKey'); + }); }); describe('runtime config JSON seeding', () => { @@ -380,6 +400,44 @@ describe('provider seeding and fallback precedence', () => { } }); + test('falls back to API_BASE with a blank API_KEY', async () => { + const providerSnapshot = await snapshotProvidersBySlug(testSlugs); + const runtimeSeed = JSON.stringify({ version: 1, runtimeConfig: { enableUserSignups: true } }); + + try { + await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs)); + const blockedByOtherRows = await hasNonTestProviderRows(testSlugs); + if (blockedByOtherRows) return; + + await withEnv({ + RUNTIME_SEED_JSON: runtimeSeed, + RUNTIME_SEED_JSON_PATH: undefined, + API_BASE: 'http://localhost:8880/v1', + API_KEY: '', + AUTH_SECRET: 'seed-test-auth-secret-keyless', + }, async () => { + await __seedInternals.runSeed(); + }); + + const rows = await db + .select({ + slug: adminProviders.slug, + baseUrl: adminProviders.baseUrl, + apiKeyLast4: adminProviders.apiKeyLast4, + }) + .from(adminProviders); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + slug: 'default-openai', + baseUrl: 'http://localhost:8880/v1', + apiKeyLast4: '', + }); + } finally { + await restoreProvidersBySlug(testSlugs, providerSnapshot); + } + }); + test('does not use API_BASE/API_KEY fallback when providers key is present but empty', async () => { const providerSnapshot = await snapshotProvidersBySlug(testSlugs); const runtimeSeed = JSON.stringify({ version: 1, providers: [] });