From c31ef225c36b6c9f0fe2b7b422f361c2b0446486 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 10:17:42 +0000 Subject: [PATCH] AI: optional per-project provider config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a project point /v1/ai/chat at its own provider/model/key/gateway, falling back to the instance-wide AI_PROVIDER default when unset — so one server can serve tenants with different AI backends. - GET/PUT/DELETE /v1/ai/config (key-gated, per-project). PUT takes a flat { provider, model?, maxTokens?, apiKey?, baseUrl?, headers?, region? } and stores it as a full AiConfig; the existing getProvider(ai) is already parameterized, so the chat route just resolves the effective config and passes it through. Secrets are stored server-side and never read back (GET returns a redacted view with hasApiKey/hasHeaders). - The effective config (project override, else config.ai) is resolved by the chat route and /v1/stats (so stats reflects each project's provider), cached briefly to keep chat requests off the DB. New project_ai table on both backends. - SDK: client.ai.getConfig/setConfig/clearConfig; discovery advertises the endpoint. Verification: 71/71 SQLite + 53/53 Postgres 16 — override + redaction + validation + reset, and cross-tenant isolation (project A's openai override doesn't change project C's effective provider). Docs updated (README, /docs). --- README.md | 14 +++++ public/docs.html | 1 + public/sdk.d.ts | 24 ++++++++ public/sdk.js | 5 ++ src/db/postgres.ts | 5 ++ src/db/sqlite.ts | 8 +++ src/modules/ai/config-store.ts | 104 +++++++++++++++++++++++++++++++++ src/modules/ai/routes.ts | 44 ++++++++++++-- src/modules/meta/routes.ts | 9 ++- src/server.ts | 2 + src/types.d.ts | 3 + test/api.test.ts | 68 +++++++++++++++++++++ 12 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 src/modules/ai/config-store.ts 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/configPer-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`, {