Skip to content
Merged
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions public/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ <h2 id="rest-ai">REST API — AI</h2>
<table>
<tbody>
<tr><td class="ep"><span class="m">POST</span>/v1/ai/chat</td><td><code>{ messages:[{role,content}], system?, model?, max_tokens?, stream? }</code>. Non-stream → <code>{ text, model, stop_reason, usage }</code>; <code>stream:true</code> → SSE (<code>data:</code> text frames, then <code>event: done</code>).</td></tr>
<tr><td class="ep"><span class="m">GET·PUT·DELETE</span>/v1/ai/config</td><td>Per-project provider override <code>{ provider, model?, apiKey?, baseUrl?, headers?, region? }</code> (key-gated). Falls back to the instance <code>AI_PROVIDER</code>. Secrets are never read back (GET shows <code>hasApiKey</code>).</td></tr>
</tbody>
</table>
<p class="muted small" style="margin:.6rem 0 .2rem">Examples</p>
Expand Down
24 changes: 24 additions & 0 deletions public/sdk.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,30 @@ declare namespace Zero {
onText: (chunk: string) => void,
opts?: Record<string, unknown>,
): Promise<{ stop_reason: string | null; usage: Usage | null }>;
/** The project's AI override (redacted), or null if it uses the instance default. */
getConfig(): Promise<AiConfigView | null>;
/** 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<string, string>;
region?: string;
}): Promise<AiConfigView>;
/** 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 {
Expand Down
5 changes: 5 additions & 0 deletions public/sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {});
Expand Down
5 changes: 5 additions & 0 deletions src/db/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ async function bootstrap(pool: pg.Pool): Promise<void> {
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',
Expand Down
8 changes: 8 additions & 0 deletions src/db/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
104 changes: 104 additions & 0 deletions src/modules/ai/config-store.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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<string, { ai: AiConfig | null; at: number }>();
constructor(private readonly db: Db) {}

/** Save (or replace) a project's AI override. */
async set(project: string, input: AiConfigInput): Promise<AiConfigView> {
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<void> {
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<AiConfigView | null> {
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<AiConfig> {
return (await this.override(project)) ?? config.ai;
}

private async override(project: string): Promise<AiConfig | null> {
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;
}
}
44 changes: 39 additions & 5 deletions src/modules/ai/routes.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderId>(['anthropic', 'openai', 'bedrock']);

interface ChatBody {
messages?: unknown;
system?: string;
Expand Down Expand Up @@ -36,20 +37,22 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
});
}

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',
message: `No model configured for provider "${provider.id}". Set AI_MODEL or pass "model".`,
});
}

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) {
Expand Down Expand Up @@ -82,4 +85,35 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
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<string, unknown>;
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<string, string> | 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 };
});
}
9 changes: 6 additions & 3 deletions src/modules/meta/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ export async function metaRoutes(app: FastifyInstance): Promise<void> {
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,
Expand All @@ -47,7 +49,7 @@ export async function metaRoutes(app: FastifyInstance): Promise<void> {
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,
},
};
});
Expand Down Expand Up @@ -89,8 +91,9 @@ export async function metaRoutes(app: FastifyInstance): Promise<void> {
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)',
Expand Down
2 changes: 2 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -132,6 +133,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.decorate('projects', new ProjectStore(app.db, app.blobs));
app.decorate('acl', new AclStore(app.db));
app.decorate('schemas', new SchemaStore(app.db));
app.decorate('aiConfig', new AiConfigStore(app.db));
app.decorate('webhooks', new WebhookStore(app.db, opts.webhookMaxAttempts ?? config.webhooks.maxAttempts));
// Background delivery worker for the persisted webhook queue; started below.
const webhookDispatcher = new WebhookDispatcher(app.webhooks, app.log, config.webhooks.timeoutMs);
Expand Down
3 changes: 3 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { RealtimeHub } from './modules/realtime/hub.js';
import type { ProjectStore } from './modules/projects/store.js';
import type { AclStore } from './modules/acl/store.js';
import type { SchemaStore } from './modules/data/schema.js';
import type { AiConfigStore } from './modules/ai/config-store.js';
import type { WebhookStore } from './modules/webhooks/store.js';
import type { WebhookDispatcher } from './modules/webhooks/dispatcher.js';
import type { BlobStore } from './modules/files/blob.js';
Expand All @@ -17,6 +18,8 @@ declare module 'fastify' {
acl: AclStore;
/** Optional per-collection JSON Schema validation. */
schemas: SchemaStore;
/** Optional per-project AI provider override. */
aiConfig: AiConfigStore;
/** Outbound webhook registration + persisted delivery queue. */
webhooks: WebhookStore;
/** Background webhook delivery worker. */
Expand Down
Loading
Loading