Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,11 @@ Open http://localhost:5173 (the Vite dev UI), add your provider keys on the **Ke

For repeatable Docker/server installs, FreeLLMAPI can apply a JSON config on
every boot. Set `FREEAPI_CONFIG_PATH=/path/to/freellmapi.config.json` or put the
same JSON in `FREEAPI_CONFIG_JSON`. The config is idempotent: existing keys,
same JSON in `FREEAPI_CONFIG_JSON`. When `FREEAPI_CONFIG_PATH` is used, the
server watches its directory and reapplies valid atomic replacements without a
process restart; write a temporary file and rename it over the target. The
last valid configuration remains active if a replacement is invalid. The
config is idempotent: existing keys,
custom providers, model edits, fallback rows, and routing settings are updated
instead of duplicated.

Expand Down
16 changes: 16 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ docker compose up -d --build
| `FREEAPI_CONFIG_PATH` | No | None | JSON config file applied idempotently after migrations on every boot. |
| `FREEAPI_CONFIG_JSON` | No | None | Inline JSON config. Takes precedence over `FREEAPI_CONFIG_PATH`. |

### Live declarative configuration reload

When `FREEAPI_CONFIG_PATH` points to a JSON file, FreeLLMAPI watches the file's
directory and reapplies a valid atomic replacement without restarting the
process. Secret managers and deployment controllers should write a temporary
file and rename it over the configured path. The previous valid configuration
remains active when a replacement is invalid or temporarily unavailable.

```bash
FREEAPI_CONFIG_PATH=/run/secrets/freellmapi.config.json docker compose up -d
```

The watcher is intentionally provider-agnostic: Infisical, Vault, AWS Secrets
Manager, Kubernetes Secrets, or a local GitOps reconciler can materialize the
file. FreeLLMAPI does not contact or depend on another FreeLLMAPI instance.

The `freellmapi-data` volume stores SQLite data at `/app/server/data`. Keep the same volume and `ENCRYPTION_KEY` when upgrading, otherwise existing encrypted provider keys cannot be decrypted.

Example `freellmapi.config.json`:
Expand Down
59 changes: 59 additions & 0 deletions server/src/__tests__/services/declarative-config-reloader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createDeclarativeConfigReloader } from '../../services/declarative-config-reloader.js';

describe('declarative config reloader', () => {
const cleanups: Array<() => void> = [];

afterEach(() => {
while (cleanups.length > 0) cleanups.pop()?.();
vi.restoreAllMocks();
});

it('applies an atomically replaced config file without restarting the process', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'freeapi-config-'));
const configPath = path.join(dir, 'freellmapi.config.json');
fs.writeFileSync(configPath, JSON.stringify({ keys: [{ platform: 'groq', key: 'first' }] }));
const applied: unknown[] = [];
const reloader = createDeclarativeConfigReloader({
configPath,
debounceMs: 10,
apply: (value) => { applied.push(value); },
});
cleanups.push(reloader.stop);

await reloader.start();
expect(applied).toHaveLength(1);

const replacement = path.join(dir, 'replacement.json');
fs.writeFileSync(replacement, JSON.stringify({ keys: [{ platform: 'groq', key: 'second' }] }));
fs.renameSync(replacement, configPath);

await vi.waitFor(() => expect(applied).toHaveLength(2), { timeout: 500 });
expect(applied[1]).toEqual({ keys: [{ platform: 'groq', key: 'second' }] });
});

it('keeps the last good config when a replacement is invalid', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'freeapi-config-'));
const configPath = path.join(dir, 'freellmapi.config.json');
fs.writeFileSync(configPath, JSON.stringify({ keys: [{ platform: 'groq', key: 'first' }] }));
const applied: unknown[] = [];
const onError = vi.fn();
const reloader = createDeclarativeConfigReloader({
configPath,
debounceMs: 10,
apply: (value) => { applied.push(value); },
onError,
});
cleanups.push(reloader.stop);

await reloader.start();
fs.writeFileSync(configPath, '{ invalid json');

await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1), { timeout: 500 });
expect(applied).toHaveLength(1);
expect(reloader.status().lastError).toMatch(/JSON|Unexpected/);
});
});
21 changes: 19 additions & 2 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { startCatalogSync } from './services/catalog-sync.js';
import { installProcessSafetyNet } from './lib/process-safety-net.js';
import { NodeScheduler } from './lib/scheduler.js';
import { loadConfig } from './lib/config.js';
import { applyDeclarativeConfigFromEnv } from './services/declarative-config.js';
import { applyDeclarativeConfig, applyDeclarativeConfigFromEnv } from './services/declarative-config.js';
import { createDeclarativeConfigReloader } from './services/declarative-config-reloader.js';
import { restoreDbBackupIfNeeded, startDbBackupPump } from './lib/db-backup.js';
import { userCount } from './services/auth.js';
import { generateSetupCode } from './lib/setup-code.js';
Expand All @@ -31,7 +32,23 @@ async function main() {
await restoreDbBackupIfNeeded();
}
initDb(config.dbPath ?? undefined);
applyDeclarativeConfigFromEnv();
const declarativeConfigPath = process.env.FREEAPI_CONFIG_PATH?.trim();
if (declarativeConfigPath) {
const reloader = createDeclarativeConfigReloader({
configPath: declarativeConfigPath,
apply: (value, source) => {
const result = applyDeclarativeConfig(value, source);
console.log(
`[config] applied ${source}: ${result.keys} keys, ${result.customModels} custom models, ` +
`${result.models} model edits, ${result.fallback} fallback rows${result.routing ? ', routing' : ''}`,
);
},
onError: (error) => console.error(`[config] declarative reload failed: ${error.message}`),
});
await reloader.start();
} else {
applyDeclarativeConfigFromEnv();
}

// First-run hardening: when the dashboard is still unclaimed, mint a one-time
// setup code and log it. A loopback browser can finish setup without it; a
Expand Down
124 changes: 124 additions & 0 deletions server/src/services/declarative-config-reloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';

export interface DeclarativeConfigReloaderStatus {
source: string;
fingerprint: string | null;
lastAppliedAt: string | null;
lastError: string | null;
}

export interface DeclarativeConfigReloaderOptions {
configPath: string;
debounceMs?: number;
apply: (value: unknown, source: string) => void | Promise<void>;
onError?: (error: Error) => void;
}

export interface DeclarativeConfigReloader {
start(): Promise<boolean>;
stop(): void;
reload(): Promise<boolean>;
status(): DeclarativeConfigReloaderStatus;
}

function errorFromUnknown(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

/**
* Watches a declarative JSON config directory so secret/config managers can
* atomically replace the file without coupling themselves to this process.
* The last successfully applied snapshot remains active when a replacement is
* invalid or temporarily unavailable.
*/
export function createDeclarativeConfigReloader(
options: DeclarativeConfigReloaderOptions,
): DeclarativeConfigReloader {
const configPath = path.resolve(options.configPath);
const source = configPath;
const debounceMs = Math.max(0, options.debounceMs ?? 250);
let watcher: fs.FSWatcher | null = null;
let timer: NodeJS.Timeout | null = null;
let running = false;
let inFlight: Promise<boolean> | null = null;
let needsReload = false;
let state: DeclarativeConfigReloaderStatus = {
source,
fingerprint: null,
lastAppliedAt: null,
lastError: null,
};

const reportError = (error: unknown): void => {
const normalized = errorFromUnknown(error);
state = { ...state, lastError: normalized.message };
options.onError?.(normalized);
};

const reload = async (): Promise<boolean> => {
if (inFlight) {
needsReload = true;
return inFlight;
}
inFlight = (async () => {
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
const fingerprint = crypto.createHash('sha256').update(raw).digest('hex');
if (fingerprint === state.fingerprint) return false;
const value = JSON.parse(raw) as unknown;
await options.apply(value, source);
state = {
...state,
fingerprint,
lastAppliedAt: new Date().toISOString(),
lastError: null,
};
return true;
} catch (error) {
reportError(error);
return false;
} finally {
inFlight = null;
if (needsReload && running) {
needsReload = false;
scheduleReload();
}
}
})();
return inFlight;
};

const scheduleReload = (): void => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
void reload();
}, debounceMs);
};

return {
async start(): Promise<boolean> {
if (running) return false;
running = true;
watcher = fs.watch(path.dirname(configPath), (_event, filename) => {
if (!filename || filename.toString() === path.basename(configPath)) scheduleReload();
});
watcher.on('error', reportError);
return reload();
},
stop(): void {
running = false;
needsReload = false;
if (timer) clearTimeout(timer);
timer = null;
watcher?.close();
watcher = null;
},
reload,
status(): DeclarativeConfigReloaderStatus {
return { ...state };
},
};
}