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.
This commit is contained in:
parent
23b9417567
commit
a224efd9e3
14 changed files with 104 additions and 32 deletions
|
|
@ -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}]}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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=<generate-with-openssl-rand-hex-32>
|
||||
# Optional when you need multiple local origins:
|
||||
|
|
@ -260,10 +259,9 @@ AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
|||
<TabItem value="auth-with-admin" label="Auth + Admin Panel">
|
||||
|
||||
```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=<generate-with-openssl-rand-hex-32>
|
||||
# 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=<generate-with-openssl-rand-hex-32>
|
||||
|
|
@ -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=<generate-with-openssl-rand-hex-32>
|
||||
COMPUTE_WORKER_URL=http://localhost:8081
|
||||
|
|
|
|||
|
|
@ -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://<YOUR_LAN_IP>: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.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<unknown>;
|
||||
try {
|
||||
|
|
@ -295,7 +297,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
|
|||
}
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const baseUrl = process.env.API_BASE?.trim() || null;
|
||||
const now = Date.now();
|
||||
let enc: ReturnType<typeof encryptSecret>;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
|
|
|
|||
Loading…
Reference in a new issue