diff --git a/README.md b/README.md index 0964c5f93..ede3b9b03 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Your whole design system lives as data. Change one token and every page that use - **Templates** handle the shared chrome. One layout for the whole site, separate layouts per post type, and a real 404 page you design yourself. Content flows into an outlet, so a header and footer get written once and wrap everything. - **Loops** repeat a layout over a collection: your posts, your pages, your media, or anything a plugin exposes as a source. Give a loop a couple of variants and it alternates between them as it goes. Good for post lists, product grids, galleries. - **Forms that belong to your CMS.** Build a form out of semantic fields and the submissions land in your own data tables. Instatic can read the fields you placed and create the matching table for you. No third-party form service, no embed, no monthly fee for a contact form. -- **An AI agent that actually edits the page.** Describe what you want and it builds it on the canvas as real, editable nodes, not a screenshot or a wall of code. It writes semantic HTML for structure and CSS for style, through the same import pipeline you use when you paste markup. A 35-tool Site scope builds pages; a 15-tool Content scope edits entries. Bring your own model: Claude, OpenAI, OpenRouter, or local Ollama. Your key, your model, your bill. +- **An AI agent that actually edits the page.** Describe what you want and it builds it on the canvas as real, editable nodes, not a screenshot or a wall of code. It writes semantic HTML for structure and CSS for style, through the same import pipeline you use when you paste markup. A 35-tool Site scope builds pages; a 15-tool Content scope edits entries. Bring your own model: Claude, OpenAI, OpenRouter, Requesty, or local Ollama. Your key, your model, your bill. - **Imports that hold up.** Paste raw HTML and get editable nodes. Or drop a whole static site — HTML, CSS, images, fonts — and Super Import turns it into pages, style rules, design tokens, and media. Every conflict is shown to you before anything is written, and the entire import is a single undo. ### 🗂 Manage diff --git a/docs/features/agent.md b/docs/features/agent.md index 9be3a8fef..caa8f84ac 100644 --- a/docs/features/agent.md +++ b/docs/features/agent.md @@ -6,7 +6,7 @@ In the Site editor, the agent reads the current page snapshot, plans a sequence In the Content workspace, the agent works against content collections and entries. It reads collection schemas and document state server-side, then mutates the live content editor through a browser bridge so the open draft, Tiptap body editor, and sidebar selection stay authoritative. -The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive any supported model (Anthropic Claude, OpenAI, OpenRouter, Ollama, or any OpenAI-compatible endpoint). Every driver talks directly to its provider's REST API over HTTP/SSE — no provider SDKs. All drivers share one multi-turn tool loop (`drivers/http/toolLoop.ts`); each supplies only a small `ProviderAdapter` of pure mapping functions. The plain `@anthropic-ai/sdk` (and any provider SDK) is banned repo-wide. Gated by `ai-driver-isolation.test.ts`. +The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive any supported model (Anthropic Claude, OpenAI, OpenRouter, Requesty, Ollama, or any OpenAI-compatible endpoint). Every driver talks directly to its provider's REST API over HTTP/SSE — no provider SDKs. All drivers share one multi-turn tool loop (`drivers/http/toolLoop.ts`); each supplies only a small `ProviderAdapter` of pure mapping functions. The plain `@anthropic-ai/sdk` (and any provider SDK) is banned repo-wide. Gated by `ai-driver-isolation.test.ts`. --- @@ -17,7 +17,7 @@ The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive a - **Site scope: 35 tools total.** 6 server-side catalog read tools (resolved server-side from the posted snapshot / DB) + 29 browser-bridged tools. - **Content scope: 15 tools total.** 7 server-side content/catalog/media/user read tools + 8 browser-bridged document mutation/navigation tools. - **Two-endpoint bridge.** `POST /admin/api/ai/chat/:scope` opens an NDJSON stream. When the model calls a browser-bridged tool, the server emits `toolRequest`; the browser executor reads or mutates the live workspace and POSTs the `AiToolOutput` result to `POST /admin/api/ai/tool-result`. -- **Provider-agnostic.** The runtime selects a driver (Anthropic, OpenAI, OpenRouter, Ollama, Custom Provider) from the conversation's configured credential. +- **Provider-agnostic.** The runtime selects a driver (Anthropic, OpenAI, OpenRouter, Requesty, Ollama, Custom Provider) from the conversation's configured credential. - **Site tool input schemas are a single source of truth** in `@core/ai` (`src/core/ai/toolSchemas.ts`). The server registry and browser executor import from that shared leaf. Most tools reuse the exact same schema object; `site_apply_css` deliberately advertises a flat provider object because Anthropic rejects root-level schema composition, then the executor validates the payload against the leaf's exact operation union. Gated by `ai-tool-input-object.test.ts`, `ai-tool-schema-ssot.test.ts`, and `ai-tools-typebox-only.test.ts`. - **Capabilities.** `ai.chat` required to stream; `ai.tools.write` required for write tools. Gated by `ai-handlers-capability-gated.test.ts`. @@ -71,6 +71,7 @@ server/ai/ │ ├── anthropic.ts — Anthropic driver: direct POST /v1/messages (no SDK) │ ├── openai.ts — OpenAI driver: direct POST /v1/responses (no SDK) │ ├── openrouter.ts — OpenRouter driver: direct POST /v1/responses (shared Responses path; live /models; native cost) +│ ├── requesty.ts — Requesty driver: direct POST /v1/responses (shared Responses path; live /models) │ ├── ollama.ts — Ollama driver: POST /v1/chat/completions via shared chatCompletions adapter; live /api/tags catalogue │ └── openaiCompatible.ts — Custom Provider driver: any /v1/chat/completions endpoint; live GET /v1/models catalogue └── runtime/ @@ -186,6 +187,7 @@ Each entry in **Settings → AI → Providers** stores one credential. The provi | `anthropic` | Anthropic (Claude) | `apiKey` | API key (`sk-ant-…`) | — | Static `claude-*` catalogue enriched with OpenRouter prices + context windows | | `openai` | OpenAI | `apiKey` | API key (`sk-…`) | — | Static `gpt-*` / `o*` catalogue enriched with OpenRouter prices + context windows | | `openrouter` | OpenRouter | `apiKey` | API key (`sk-or-…`) | — | Live `GET /api/v1/models` (cross-provider; native cost reporting) | +| `requesty` | Requesty | `apiKey` | API key | — | Live `GET /v1/models` (cross-provider, OpenAI-compatible router); priced from the shared catalogue by normalised model id | | `ollama` | Ollama (local) | `baseUrl` | Base URL (e.g. `http://localhost:11434`) | API key (bearer, for proxied deployments) | Live `GET {baseUrl}/api/tags`, with `POST {baseUrl}/api/show` capability lookup per model; static fallback list when unreachable | | `openai-compatible` | Custom Provider | `baseUrl` | Base URL — any host serving the OpenAI `/v1/chat/completions` wire protocol | API key (bearer; cloud services need one, local servers often don't) | Live `GET {baseUrl}/v1/models` (standard OpenAI list shape); model `id` used as label | diff --git a/server/ai/drivers/index.ts b/server/ai/drivers/index.ts index 01472cef7..0b55a6d90 100644 --- a/server/ai/drivers/index.ts +++ b/server/ai/drivers/index.ts @@ -12,6 +12,7 @@ import { anthropicDriver } from './anthropic' import { openaiDriver } from './openai' import { ollamaDriver } from './ollama' import { openrouterDriver } from './openrouter' +import { requestyDriver } from './requesty' import { openaiCompatibleDriver } from './openaiCompatible' const DRIVERS: Record = { @@ -19,6 +20,7 @@ const DRIVERS: Record = { openai: openaiDriver, ollama: ollamaDriver, openrouter: openrouterDriver, + requesty: requestyDriver, 'openai-compatible': openaiCompatibleDriver, } diff --git a/server/ai/drivers/requesty.test.ts b/server/ai/drivers/requesty.test.ts new file mode 100644 index 000000000..5a7fd6d21 --- /dev/null +++ b/server/ai/drivers/requesty.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, afterEach } from 'bun:test' +import { requestyDriver } from './requesty' +import type { AiResolvedCredential } from './types' + +const realFetch = globalThis.fetch +afterEach(() => { globalThis.fetch = realFetch }) + +function creds(apiKey: string | null): AiResolvedCredential { + return { id: 'c1', providerId: 'requesty', authMode: 'apiKey', apiKey, baseUrl: null } +} + +describe('requesty driver', () => { + it('reports apiKey as its only auth mode', () => { + expect(requestyDriver.supportedAuthModes).toEqual(['apiKey']) + }) + + it('capabilities default to tool-calling + streaming', () => { + expect(requestyDriver.capabilities('anything')).toMatchObject({ toolCalling: true, streaming: true }) + }) + + it('listModels hits the Requesty /v1/models catalogue with the bearer', async () => { + let seenUrl = '' + let seenAuth: string | null = null + globalThis.fetch = (async (url: string, init?: RequestInit) => { + seenUrl = String(url) + const headers = new Headers(init?.headers) + seenAuth = headers.get('authorization') + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + }) as unknown as typeof fetch + + await requestyDriver.listModels(creds('rqsty-sk-test')) + expect(seenUrl).toBe('https://router.requesty.ai/v1/models') + expect(seenAuth).toBe('Bearer rqsty-sk-test') + }) + + it('listModels maps Requesty capability booleans, prices, and context window', async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'openai/gpt-4o-mini', + api: 'chat', + description: 'GPT-4o mini', + context_window: 128000, + supports_tool_calling: true, + supports_vision: true, + supports_caching: true, + // per-token USD floats — the picker shows per-million. + input_price: 1.5e-7, + output_price: 6e-7, + }, + ], + }), + { status: 200 }, + )) as unknown as typeof fetch + + const models = await requestyDriver.listModels(creds('rqsty-sk-test')) + expect(models).toHaveLength(1) + expect(models[0]).toMatchObject({ + id: 'openai/gpt-4o-mini', + label: 'GPT-4o mini', + capabilities: { + toolCalling: true, + visionInput: true, + promptCache: true, + streaming: true, + }, + // 1.5e-7 * 1e6 = 0.15 ; 6e-7 * 1e6 = 0.6 + pricing: { inputPerMTok: 0.15, outputPerMTok: 0.6 }, + contextWindow: 128000, + }) + }) + + it('listModels drops non-chat catalogue entries (e.g. embeddings)', async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + data: [ + { id: 'openai/gpt-4o-mini', api: 'chat' }, + { id: 'openai/text-embedding-3-small', api: 'embedding' }, + ], + }), + { status: 200 }, + )) as unknown as typeof fetch + + const models = await requestyDriver.listModels(creds('rqsty-sk-test')) + expect(models.map((m) => m.id)).toEqual(['openai/gpt-4o-mini']) + }) + + it('listModels defaults tool-calling on when the catalogue omits the flag', async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: [{ id: 'some/model' }] }), { status: 200 })) as unknown as typeof fetch + + const models = await requestyDriver.listModels(creds('rqsty-sk-test')) + expect(models[0]).toMatchObject({ + id: 'some/model', + label: 'some/model', + capabilities: { toolCalling: true, visionInput: false, promptCache: false, streaming: true }, + }) + // No price/context published → those fields are omitted. + expect(models[0]).not.toHaveProperty('pricing') + expect(models[0]).not.toHaveProperty('contextWindow') + }) + + it('listModels throws on a non-OK catalogue response', async () => { + globalThis.fetch = (async () => new Response('nope', { status: 500, statusText: 'Server Error' })) as unknown as typeof fetch + await expect(requestyDriver.listModels(creds('rqsty-sk-test'))).rejects.toThrow(/models request failed/) + }) + + it('stream yields a clean error when no API key is present', async () => { + const events = [] + for await (const ev of requestyDriver.stream({ + credentials: creds(null), + modelId: 'openai/gpt-4o-mini', + systemPrompt: [], + messages: [], + tools: [], + } as never)) { + events.push(ev) + } + expect(events).toEqual([ + { type: 'error', message: expect.stringContaining('Requesty requires an API key') }, + ]) + }) +}) diff --git a/server/ai/drivers/requesty.ts b/server/ai/drivers/requesty.ts new file mode 100644 index 000000000..afeb1fa66 --- /dev/null +++ b/server/ai/drivers/requesty.ts @@ -0,0 +1,194 @@ +/** + * Requesty driver — direct HTTP against the Responses API. + * + * Talks to `POST https://router.requesty.ai/v1/responses` with no SDK. Requesty + * is an OpenAI-compatible LLM router that exposes the OpenAI **Responses** wire + * protocol, so — exactly like the OpenRouter driver — it shares the entire + * mapping + SSE translation with the OpenAI driver via `responses-shared.ts`; + * this file owns only the Requesty-specific transport and one extra: + * + * - the live `/v1/models` catalogue fetch (`listModels`), TypeBox-validated + * at the boundary, so the picker reflects Requesty's 500+ models. + * + * Unlike OpenRouter, Requesty does not echo a per-call `usage.cost`, so a + * Requesty turn is priced by the shared cost path (`server/ai/pricing`) from + * the live catalogue like Anthropic/OpenAI, keyed by the normalised model id. + * + * The `/v1/models` object shape differs from OpenRouter's: capabilities are + * flat booleans (`supports_tool_calling`, `supports_vision`), the context + * window is `context_window` (not `context_length`), and prices are per-token + * floats (`input_price` / `output_price`) rather than nested decimal strings — + * so the parser maps Requesty's real fields rather than copying OpenRouter's. + * + * Tools are sent with their canonical TypeBox `inputSchema` as the JSON Schema + * `parameters` directly — no Zod bridge. + */ + +import { Type, parseValue } from '@core/utils/typeboxHelpers' +import type { + AiAuthMode, + AiProviderId, + AiStreamEvent, +} from '../runtime/types' +import type { + AiProvider, + AiProviderCapabilities, + AiProviderModel, + AiResolvedCredential, + AiStreamRequest, +} from './types' +import { runToolLoop } from './http/toolLoop' +import { createResponsesAdapter } from './responses-shared' + +const SUPPORTED_AUTH_MODES: AiAuthMode[] = ['apiKey'] + +const REQUESTY_BASE_URL = 'https://router.requesty.ai/v1' +const REQUESTY_ENDPOINT = `${REQUESTY_BASE_URL}/responses` + +// Capabilities are per-model and only knowable after `listModels()` has hit +// the catalog. The sync `capabilities()` accessor returns a permissive default +// (most Requesty models tool-call); the picker uses the richer per-model +// flags from `listModels()` when present. +const DEFAULT_CAPABILITIES: AiProviderCapabilities = { + toolCalling: true, + visionInput: false, + toolResultImages: false, + promptCache: false, + streaming: true, +} + +const requestyAdapter = createResponsesAdapter({ + label: 'Requesty', + endpoint: REQUESTY_ENDPOINT, + buildHeaders(req) { + return { + Authorization: `Bearer ${req.credentials.apiKey!}`, + 'content-type': 'application/json', + } + }, +}) + +export const requestyDriver: AiProvider = { + id: 'requesty' as AiProviderId, + label: 'Requesty', + supportedAuthModes: SUPPORTED_AUTH_MODES, + + capabilities(_modelId: string) { + return DEFAULT_CAPABILITIES + }, + + async resolveCapabilities(creds: AiResolvedCredential, modelId: string, signal: AbortSignal) { + const models = await fetchRequestyModels(creds, signal) + return models.find((model) => model.id === modelId)?.capabilities + ?? DEFAULT_CAPABILITIES + }, + + async listModels(creds: AiResolvedCredential, signal?: AbortSignal) { + return fetchRequestyModels(creds, signal) + }, + + async *stream(req: AiStreamRequest): AsyncIterable { + if (req.credentials.authMode !== 'apiKey' || !req.credentials.apiKey) { + // Defensive: a non-apiKey credential reaching the driver implies a + // mismatched DB row or a bypassed UI. Fail cleanly instead of POSTing + // and getting a generic 401. + yield { + type: 'error', + message: + 'Requesty requires an API key. Add an API-key credential in /admin/ai/providers and pick it for the site default.', + } + return + } + yield* runToolLoop(requestyAdapter, req) + }, +} + +// --------------------------------------------------------------------------- +// Live model catalogue +// --------------------------------------------------------------------------- + +// Requesty's `/v1/models` describes each model with flat capability booleans +// and per-token float prices — a different shape from OpenRouter's. Fields we +// don't consume are ignored (`additionalProperties: true`) so the parser stays +// tolerant of catalogue growth. +const RequestyModelSchema = Type.Object( + { + id: Type.String(), + // `chat` models are the completion-capable ones; embeddings and other + // endpoints also appear in the list and are filtered out below. + api: Type.Optional(Type.String()), + description: Type.Optional(Type.String()), + context_window: Type.Optional(Type.Number()), + supports_tool_calling: Type.Optional(Type.Boolean()), + supports_vision: Type.Optional(Type.Boolean()), + supports_caching: Type.Optional(Type.Boolean()), + // USD per single token (e.g. 1.5e-7); the picker shows per-million. + input_price: Type.Optional(Type.Number()), + output_price: Type.Optional(Type.Number()), + }, + { additionalProperties: true }, +) + +const RequestyModelsResponseSchema = Type.Object( + { data: Type.Array(RequestyModelSchema) }, + { additionalProperties: true }, +) + +/** Requesty quotes prices per token as a number; the picker shows + * per-million-token. Returns null for an absent/non-finite value. */ +function perMTok(value: number | undefined): number | null { + if (value === undefined || !Number.isFinite(value)) return null + return value * 1_000_000 +} + +async function fetchRequestyModels( + creds: AiResolvedCredential, + signal?: AbortSignal, +): Promise { + const headers: Record = {} + // The catalogue endpoint is public, but sending the bearer lets per-key + // availability (e.g. BYOK-only models) reflect in the list. + if (creds.apiKey) headers.Authorization = `Bearer ${creds.apiKey}` + + const res = await fetch(`${REQUESTY_BASE_URL}/models`, { headers, signal }) + if (!res.ok) { + throw new Error(`[ai/requesty] models request failed: ${res.status} ${res.statusText}`) + } + + // Validate the external API body at the boundary (no `as` cast). + const parsed = parseValue(RequestyModelsResponseSchema, await res.json()) + + // Keep the chat-completion models; drop embeddings and other endpoints. + // A missing `api` field is treated as chat rather than hidden. + const chatModels = parsed.data.filter( + (model) => model.api === undefined || model.api === 'chat', + ) + + return chatModels.map((model): AiProviderModel => { + // Requesty publishes prices + context windows inline, so the picker is + // enriched straight from this fetch (Anthropic/OpenAI are enriched by the + // models handler from the shared catalogue instead — their APIs omit both). + const inputPerMTok = perMTok(model.input_price) + const outputPerMTok = perMTok(model.output_price) + return { + id: model.id, + label: model.description ?? model.id, + capabilities: { + // When the catalogue declares a flag, honour it; when it omits one, + // assume tool-calling (the common case for Requesty chat models) + // rather than hiding the model from a tool-using scope. + toolCalling: model.supports_tool_calling ?? true, + visionInput: model.supports_vision ?? false, + toolResultImages: false, + promptCache: model.supports_caching ?? false, + streaming: true, + }, + ...(inputPerMTok !== null && outputPerMTok !== null + ? { pricing: { inputPerMTok, outputPerMTok } } + : {}), + ...(model.context_window && Number.isFinite(model.context_window) + ? { contextWindow: model.context_window } + : {}), + } + }) +} diff --git a/server/ai/handlers/credentials.ts b/server/ai/handlers/credentials.ts index be264ca6d..bd69dc920 100644 --- a/server/ai/handlers/credentials.ts +++ b/server/ai/handlers/credentials.ts @@ -37,6 +37,7 @@ const ProviderId = Type.Union([ Type.Literal('openai'), Type.Literal('ollama'), Type.Literal('openrouter'), + Type.Literal('requesty'), Type.Literal('openai-compatible'), ]) diff --git a/server/ai/handlers/models.ts b/server/ai/handlers/models.ts index 12374db7a..3541990c3 100644 --- a/server/ai/handlers/models.ts +++ b/server/ai/handlers/models.ts @@ -20,7 +20,7 @@ import { getModelCatalogue, pricingKey } from '../pricing' import type { AiProviderModel } from '../drivers/types' import type { AiProviderId } from '../runtime/types' -const VALID_PROVIDERS: AiProviderId[] = ['anthropic', 'openai', 'ollama', 'openrouter', 'openai-compatible'] +const VALID_PROVIDERS: AiProviderId[] = ['anthropic', 'openai', 'ollama', 'openrouter', 'requesty', 'openai-compatible'] export function tryHandleAiModels( req: Request, diff --git a/server/ai/runtime/types.ts b/server/ai/runtime/types.ts index b75e9b4b7..439ef866d 100644 --- a/server/ai/runtime/types.ts +++ b/server/ai/runtime/types.ts @@ -24,7 +24,7 @@ export type { AiContentBlock, AiToolImage, AiToolOutput } from '@core/ai' // Provider identity + auth modes // --------------------------------------------------------------------------- -export type AiProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'openai-compatible' +export type AiProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'requesty' | 'openai-compatible' /** * Credential auth modes. * diff --git a/src/admin/ai/api.ts b/src/admin/ai/api.ts index f567fc7c5..09621fb90 100644 --- a/src/admin/ai/api.ts +++ b/src/admin/ai/api.ts @@ -34,6 +34,7 @@ const ProviderId = Type.Union([ Type.Literal('openai'), Type.Literal('ollama'), Type.Literal('openrouter'), + Type.Literal('requesty'), Type.Literal('openai-compatible'), ]) @@ -178,13 +179,13 @@ export async function listCredentials(signal?: AbortSignal): Promise { const key = `${providerId}\0${credentialId ?? ''}` diff --git a/src/admin/pages/ai/tabs/ProvidersTab.tsx b/src/admin/pages/ai/tabs/ProvidersTab.tsx index 0e379cf0e..177f70668 100644 --- a/src/admin/pages/ai/tabs/ProvidersTab.tsx +++ b/src/admin/pages/ai/tabs/ProvidersTab.tsx @@ -27,7 +27,7 @@ import { ApiError } from '@core/http' import styles from '../AiPage.module.css' import { getErrorMessage } from '@core/utils/errorMessage' -type ProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'openai-compatible' +type ProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'requesty' | 'openai-compatible' type AuthMode = 'apiKey' | 'baseUrl' // Each provider has exactly one credential shape; the UI derives it instead @@ -36,6 +36,7 @@ const PROVIDERS: Array<{ id: ProviderId; label: string; authMode: AuthMode }> = { id: 'anthropic', label: 'Anthropic (Claude)', authMode: 'apiKey' }, { id: 'openai', label: 'OpenAI', authMode: 'apiKey' }, { id: 'openrouter', label: 'OpenRouter', authMode: 'apiKey' }, + { id: 'requesty', label: 'Requesty', authMode: 'apiKey' }, { id: 'ollama', label: 'Ollama (local)', authMode: 'baseUrl' }, { id: 'openai-compatible', label: 'Custom Provider', authMode: 'baseUrl' }, ] @@ -49,6 +50,7 @@ const PROVIDER_LABEL: Record = { anthropic: 'Anthropic', openai: 'OpenAI', openrouter: 'OpenRouter', + requesty: 'Requesty', ollama: 'Ollama', 'openai-compatible': 'Custom Provider', } @@ -57,6 +59,7 @@ const PROVIDER_LABEL: Record = { const API_KEY_PLACEHOLDER: Partial> = { anthropic: 'sk-ant-...', openrouter: 'sk-or-...', + requesty: 'sk-... or rqsty-...', 'openai-compatible': 'sk-... (optional)', }