diff --git a/README.md b/README.md
index a83d665..12e2657 100644
--- a/README.md
+++ b/README.md
@@ -315,6 +315,20 @@ curl -N -X POST localhost:3737/v1/ai/chat \
Optional body fields: `system`, `model` (defaults to a Claude model on
`anthropic`; required for `openai`/`bedrock`), `max_tokens` (capped at 64000).
+The provider defaults to the instance-wide `AI_PROVIDER`, but a **project can
+override it** — point `/v1/ai/chat` at its own provider/model/key/gateway:
+
+```bash
+curl -X PUT localhost:3737/v1/ai/config -H 'content-type: application/json' -d '{
+ "provider": "openai", "model": "gpt-4o",
+ "baseUrl": "http://localhost:4000", "apiKey": "sk-…", "headers": {"x-org": "acme"}
+}'
+curl localhost:3737/v1/ai/config # redacted (keys never returned); DELETE to reset
+```
+
+Keys are stored server-side and never read back (GET shows `hasApiKey`, not the
+key). Key-gated and per-project; absent an override, the instance default applies.
+
### Realtime — websocket pub/sub + presence
```js
diff --git a/public/docs.html b/public/docs.html
index 71de777..5ecccb8 100644
--- a/public/docs.html
+++ b/public/docs.html
@@ -238,6 +238,7 @@
REST API — AI
| POST/v1/ai/chat | { messages:[{role,content}], system?, model?, max_tokens?, stream? }. Non-stream → { text, model, stop_reason, usage }; stream:true → SSE (data: text frames, then event: done). |
+ | GET·PUT·DELETE/v1/ai/config | Per-project provider override { provider, model?, apiKey?, baseUrl?, headers?, region? } (key-gated). Falls back to the instance AI_PROVIDER. Secrets are never read back (GET shows hasApiKey). |
Examples
diff --git a/public/sdk.d.ts b/public/sdk.d.ts
index 05dcc26..f1a5133 100644
--- a/public/sdk.d.ts
+++ b/public/sdk.d.ts
@@ -203,6 +203,30 @@ declare namespace Zero {
onText: (chunk: string) => void,
opts?: Record,
): Promise<{ stop_reason: string | null; usage: Usage | null }>;
+ /** The project's AI override (redacted), or null if it uses the instance default. */
+ getConfig(): Promise;
+ /** Override the provider/model/key/gateway for this project. */
+ setConfig(config: {
+ provider: 'anthropic' | 'openai' | 'bedrock';
+ model?: string;
+ maxTokens?: number;
+ apiKey?: string;
+ baseUrl?: string;
+ headers?: Record;
+ region?: string;
+ }): Promise;
+ /** Remove the override (fall back to the instance default). */
+ clearConfig(): Promise<{ config: null }>;
+ }
+
+ interface AiConfigView {
+ provider: 'anthropic' | 'openai' | 'bedrock';
+ model: string | null;
+ maxTokens: number;
+ baseUrl: string | null;
+ region: string | null;
+ hasApiKey: boolean;
+ hasHeaders: boolean;
}
interface Room {
diff --git a/public/sdk.js b/public/sdk.js
index 1beebaf..4cd1745 100644
--- a/public/sdk.js
+++ b/public/sdk.js
@@ -247,6 +247,11 @@
chat: function (input, options) {
return request('POST', '/v1/ai/chat', Object.assign({ messages: toMessages(input) }, options || {}));
},
+ // Per-project provider override (redacted on read). config: { provider, model?,
+ // apiKey?, baseUrl?, headers?, region? }. Returns the redacted view, or null.
+ getConfig: function () { return request('GET', '/v1/ai/config').then(function (r) { return r.config; }); },
+ setConfig: function (config) { return request('PUT', '/v1/ai/config', config || {}).then(function (r) { return r.config; }); },
+ clearConfig: function () { return request('DELETE', '/v1/ai/config'); },
// Streams text chunks to onText; resolves with { stop_reason, usage }.
stream: async function (input, onText, options) {
var body = Object.assign({ messages: toMessages(input), stream: true }, options || {});
diff --git a/src/db/postgres.ts b/src/db/postgres.ts
index fa79531..26aed79 100644
--- a/src/db/postgres.ts
+++ b/src/db/postgres.ts
@@ -178,6 +178,11 @@ async function bootstrap(pool: pg.Pool): Promise {
PRIMARY KEY (project, collection)
);
+ CREATE TABLE IF NOT EXISTS project_ai (
+ project text NOT NULL PRIMARY KEY DEFAULT 'default',
+ config text NOT NULL
+ );
+
CREATE TABLE IF NOT EXISTS webhooks (
id text PRIMARY KEY,
project text NOT NULL DEFAULT 'default',
diff --git a/src/db/sqlite.ts b/src/db/sqlite.ts
index 22c413c..0b59c29 100644
--- a/src/db/sqlite.ts
+++ b/src/db/sqlite.ts
@@ -199,6 +199,14 @@ export function bootstrap(db: Database.Database): void {
);
`);
+ // --- optional per-project AI provider override (instance default otherwise) ---
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS project_ai (
+ project TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
+ config TEXT NOT NULL
+ );
+ `);
+
// --- outbound webhooks + their persisted, retried delivery queue ---
db.exec(`
CREATE TABLE IF NOT EXISTS webhooks (
diff --git a/src/modules/ai/config-store.ts b/src/modules/ai/config-store.ts
new file mode 100644
index 0000000..4a170f0
--- /dev/null
+++ b/src/modules/ai/config-store.ts
@@ -0,0 +1,104 @@
+import { config } from '../../config.js';
+import type { Db } from '../../db.js';
+import type { AiConfig } from './provider.js';
+
+/** A project's saved AI override, redacted for reads (no secrets returned). */
+export interface AiConfigView {
+ provider: AiConfig['provider'];
+ model: string | null;
+ maxTokens: number;
+ baseUrl: string | null;
+ region: string | null;
+ hasApiKey: boolean;
+ hasHeaders: boolean;
+}
+
+/** The flat shape callers PUT to configure a project's provider. */
+export interface AiConfigInput {
+ provider: AiConfig['provider'];
+ model?: string | null;
+ maxTokens?: number;
+ apiKey?: string | null;
+ baseUrl?: string | null;
+ headers?: Record | null;
+ region?: string | null;
+}
+
+const CACHE_TTL_MS = 2000;
+
+/** Build a full AiConfig (the shape getProvider wants) from flat input. */
+function build(input: AiConfigInput): AiConfig {
+ return {
+ provider: input.provider,
+ model: input.model ?? null,
+ maxTokens: input.maxTokens ?? config.ai.maxTokens,
+ anthropic: { apiKey: input.apiKey ?? null, baseUrl: input.baseUrl ?? null },
+ openai: {
+ baseUrl: input.baseUrl ?? null,
+ apiKey: input.apiKey ?? null,
+ headers: input.headers ? JSON.stringify(input.headers) : null,
+ },
+ bedrock: { region: input.region ?? null },
+ };
+}
+
+function redact(ai: AiConfig): AiConfigView {
+ return {
+ provider: ai.provider,
+ model: ai.model,
+ maxTokens: ai.maxTokens,
+ baseUrl: ai.anthropic.baseUrl ?? ai.openai.baseUrl ?? null,
+ region: ai.bedrock.region,
+ hasApiKey: !!(ai.anthropic.apiKey || ai.openai.apiKey),
+ hasHeaders: !!ai.openai.headers,
+ };
+}
+
+/**
+ * Optional per-project AI provider config. A project can point `/v1/ai/chat` at
+ * its own provider/model/key/gateway; absent that, the instance default
+ * (AI_PROVIDER + friends) is used. Stored server-side; secrets are never read
+ * back. The effective config is cached briefly so chat requests skip the DB.
+ */
+export class AiConfigStore {
+ private readonly cache = new Map();
+ constructor(private readonly db: Db) {}
+
+ /** Save (or replace) a project's AI override. */
+ async set(project: string, input: AiConfigInput): Promise {
+ const ai = build(input);
+ await this.db.run(
+ `INSERT INTO project_ai (project, config) VALUES (?, ?)
+ ON CONFLICT (project) DO UPDATE SET config = excluded.config`,
+ [project, JSON.stringify(ai)],
+ );
+ this.cache.set(project, { ai, at: Date.now() });
+ return redact(ai);
+ }
+
+ /** Remove a project's override (it falls back to the instance default). */
+ async clear(project: string): Promise {
+ await this.db.run(`DELETE FROM project_ai WHERE project = ?`, [project]);
+ this.cache.set(project, { ai: null, at: Date.now() });
+ }
+
+ /** The redacted view of a project's override, or null if it uses the default. */
+ async view(project: string): Promise {
+ const ai = await this.override(project);
+ return ai ? redact(ai) : null;
+ }
+
+ /** The effective AiConfig for a project: its override, else the instance default. */
+ async effective(project: string): Promise {
+ return (await this.override(project)) ?? config.ai;
+ }
+
+ private async override(project: string): Promise {
+ const cached = this.cache.get(project);
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.ai;
+ const row = await this.db.get<{ config: string }>(`SELECT config FROM project_ai WHERE project = ?`, [project]);
+ const ai = row ? (JSON.parse(row.config) as AiConfig) : null;
+ this.cache.set(project, { ai, at: Date.now() });
+ return ai;
+ }
+}
diff --git a/src/modules/ai/routes.ts b/src/modules/ai/routes.ts
index 2ca30a6..92a0045 100644
--- a/src/modules/ai/routes.ts
+++ b/src/modules/ai/routes.ts
@@ -1,10 +1,11 @@
import type { FastifyInstance } from 'fastify';
-import { config } from '../../config.js';
-import { AiError, getProvider, normalizeMessages } from './provider.js';
+import { AiError, getProvider, normalizeMessages, type ProviderId } from './provider.js';
// Hard ceiling on output tokens regardless of what a caller requests.
const MAX_OUTPUT_TOKENS = 64000;
+const PROVIDERS = new Set(['anthropic', 'openai', 'bedrock']);
+
interface ChatBody {
messages?: unknown;
system?: string;
@@ -36,12 +37,14 @@ export async function aiRoutes(app: FastifyInstance): Promise {
});
}
- const provider = getProvider();
+ // Use the project's AI override if it set one, else the instance default.
+ const ai = await app.aiConfig.effective(req.projectId);
+ const provider = getProvider(ai);
if (!provider.ready) {
return reply.code(503).send({ error: 'ai_not_configured', message: provider.unconfiguredMessage });
}
- const model = body.model ?? config.ai.model ?? provider.defaultModel;
+ const model = body.model ?? ai.model ?? provider.defaultModel;
if (!model) {
return reply.code(400).send({
error: 'invalid_request',
@@ -49,7 +52,7 @@ export async function aiRoutes(app: FastifyInstance): Promise {
});
}
- const maxTokens = Math.min(body.max_tokens ?? config.ai.maxTokens, MAX_OUTPUT_TOKENS);
+ const maxTokens = Math.min(body.max_tokens ?? ai.maxTokens, MAX_OUTPUT_TOKENS);
const chatReq = { messages, system: body.system, model, maxTokens };
if (body.stream) {
@@ -82,4 +85,35 @@ export async function aiRoutes(app: FastifyInstance): Promise {
return reply.code(status).send({ error: 'ai_error', message });
}
});
+
+ // Per-project AI provider override (key-gated; never anonymous). Falls back to
+ // the instance default (AI_PROVIDER) when unset. Secrets are never read back.
+ app.get('/v1/ai/config', async (req) => {
+ return { config: await app.aiConfig.view(req.projectId) };
+ });
+
+ app.put('/v1/ai/config', async (req, reply) => {
+ const body = (req.body ?? {}) as Record;
+ if (typeof body.provider !== 'string' || !PROVIDERS.has(body.provider as ProviderId)) {
+ return reply.code(400).send({ error: 'bad_request', message: 'provider must be anthropic | openai | bedrock.' });
+ }
+ if (body.headers !== undefined && body.headers !== null && typeof body.headers !== 'object') {
+ return reply.code(400).send({ error: 'bad_request', message: 'headers must be an object of string values.' });
+ }
+ const view = await app.aiConfig.set(req.projectId, {
+ provider: body.provider as ProviderId,
+ model: typeof body.model === 'string' ? body.model : undefined,
+ maxTokens: typeof body.maxTokens === 'number' ? body.maxTokens : undefined,
+ apiKey: typeof body.apiKey === 'string' ? body.apiKey : undefined,
+ baseUrl: typeof body.baseUrl === 'string' ? body.baseUrl : undefined,
+ headers: (body.headers as Record | undefined) ?? undefined,
+ region: typeof body.region === 'string' ? body.region : undefined,
+ });
+ return { config: view };
+ });
+
+ app.delete('/v1/ai/config', async (req) => {
+ await app.aiConfig.clear(req.projectId);
+ return { config: null };
+ });
}
diff --git a/src/modules/meta/routes.ts b/src/modules/meta/routes.ts
index 8c20a97..f3e08c3 100644
--- a/src/modules/meta/routes.ts
+++ b/src/modules/meta/routes.ts
@@ -30,7 +30,9 @@ export async function metaRoutes(app: FastifyInstance): Promise {
app.get('/v1/stats', async (req) => {
const prefix = `${req.projectId}:`;
const channels = app.realtime.channels().filter((c) => c.name.startsWith(prefix));
- const provider = getProvider();
+ // Reflect this project's effective AI config (its override, else the default).
+ const ai = await app.aiConfig.effective(req.projectId);
+ const provider = getProvider(ai);
return {
status: 'ok',
version: pkg.version,
@@ -47,7 +49,7 @@ export async function metaRoutes(app: FastifyInstance): Promise {
provider: provider.id,
// True when the selected provider is configured (no live network ping).
ready: provider.ready,
- model: config.ai.model ?? provider.defaultModel ?? null,
+ model: ai.model ?? provider.defaultModel ?? null,
},
};
});
@@ -89,8 +91,9 @@ export async function metaRoutes(app: FastifyInstance): Promise {
remove: 'DELETE /v1/files/:id',
},
ai: {
- provider: config.ai.provider, // anthropic | openai | bedrock (AI_PROVIDER)
+ provider: config.ai.provider, // instance default (AI_PROVIDER); per-project override via config
chat: 'POST /v1/ai/chat { messages, system?, model?, max_tokens?, stream? }',
+ config: 'GET/PUT/DELETE /v1/ai/config — per-project provider override { provider, model?, apiKey?, baseUrl?, headers?, region? } (key-gated)',
},
webhooks: {
manage: 'POST/GET /v1/webhooks · GET/PATCH/DELETE /v1/webhooks/:id · POST /v1/webhooks/:id/rotate-secret (key-gated)',
diff --git a/src/server.ts b/src/server.ts
index 76a406b..75f50cd 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -24,6 +24,7 @@ import { WebhookDispatcher } from './modules/webhooks/dispatcher.js';
import { webhookRoutes } from './modules/webhooks/routes.js';
import { AclStore } from './modules/acl/store.js';
import { SchemaStore, SchemaValidationError } from './modules/data/schema.js';
+import { AiConfigStore } from './modules/ai/config-store.js';
import { getBlobStore } from './modules/files/blob.js';
import { newId } from './lib/id.js';
@@ -132,6 +133,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise {
}
});
+test('ai: per-project provider config overrides the instance default (secrets redacted)', async () => {
+ assert.equal((await json(await fetch(`${base}/v1/ai/config`))).config, null); // default: no override
+ let stats = await json(await fetch(`${base}/v1/stats`));
+ assert.equal(stats.ai.provider, 'anthropic'); // instance default (AI_PROVIDER unset)
+
+ const put = await json(
+ await fetch(`${base}/v1/ai/config`, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ provider: 'openai',
+ model: 'gpt-4o',
+ baseUrl: 'http://localhost:4000',
+ apiKey: 'sk-secret',
+ headers: { 'x-org': 'acme' },
+ }),
+ }),
+ );
+ assert.equal(put.config.provider, 'openai');
+ assert.equal(put.config.model, 'gpt-4o');
+ assert.equal(put.config.baseUrl, 'http://localhost:4000');
+ assert.equal(put.config.hasApiKey, true);
+ assert.equal(put.config.hasHeaders, true);
+ assert.equal(put.config.apiKey, undefined); // the secret is never returned
+
+ stats = await json(await fetch(`${base}/v1/stats`));
+ assert.equal(stats.ai.provider, 'openai'); // effective provider reflects the override
+ assert.equal(stats.ai.model, 'gpt-4o');
+
+ const bad = await fetch(`${base}/v1/ai/config`, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ provider: 'nope' }),
+ });
+ assert.equal(bad.status, 400);
+
+ await fetch(`${base}/v1/ai/config`, { method: 'DELETE' }); // back to the instance default
+ assert.equal((await json(await fetch(`${base}/v1/ai/config`))).config, null);
+ stats = await json(await fetch(`${base}/v1/stats`));
+ assert.equal(stats.ai.provider, 'anthropic');
+});
+
+test('ai: per-project config is isolated between tenants', async () => {
+ const ADMIN = 'admin-secret';
+ const adminH = { Authorization: `Bearer ${ADMIN}`, 'content-type': 'application/json' };
+ const server = await buildServer({ logger: false, adminKey: ADMIN });
+ await server.listen({ host: '127.0.0.1', port: 0 });
+ const b = `http://127.0.0.1:${(server.server.address() as AddressInfo).port}`;
+ try {
+ const a = await (await fetch(`${b}/v1/admin/projects`, { method: 'POST', headers: adminH, body: JSON.stringify({ name: 'a' }) })).json();
+ const c = await (await fetch(`${b}/v1/admin/projects`, { method: 'POST', headers: adminH, body: JSON.stringify({ name: 'c' }) })).json();
+
+ // Project A overrides to openai; project C leaves the default.
+ await fetch(`${b}/v1/ai/config`, {
+ method: 'PUT',
+ headers: { Authorization: `Bearer ${a.key}`, 'content-type': 'application/json' },
+ body: JSON.stringify({ provider: 'openai', baseUrl: 'http://localhost:4000' }),
+ });
+
+ const statsA = await (await fetch(`${b}/v1/stats`, { headers: { Authorization: `Bearer ${a.key}` } })).json();
+ const statsC = await (await fetch(`${b}/v1/stats`, { headers: { Authorization: `Bearer ${c.key}` } })).json();
+ assert.equal(statsA.ai.provider, 'openai');
+ assert.equal(statsC.ai.provider, 'anthropic'); // unaffected by A's override
+ } finally {
+ await server.close();
+ }
+});
+
test('ai: returns 503 when no key is configured', async (t) => {
if (process.env.ANTHROPIC_API_KEY) return t.skip('ANTHROPIC_API_KEY is set');
const res = await fetch(`${base}/v1/ai/chat`, {