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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/features/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand All @@ -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`.

Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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 |

Expand Down
2 changes: 2 additions & 0 deletions server/ai/drivers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ 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<AiProviderId, AiProvider> = {
anthropic: anthropicDriver,
openai: openaiDriver,
ollama: ollamaDriver,
openrouter: openrouterDriver,
requesty: requestyDriver,
'openai-compatible': openaiCompatibleDriver,
}

Expand Down
126 changes: 126 additions & 0 deletions server/ai/drivers/requesty.test.ts
Original file line number Diff line number Diff line change
@@ -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') },
])
})
})
194 changes: 194 additions & 0 deletions server/ai/drivers/requesty.ts
Original file line number Diff line number Diff line change
@@ -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<AiStreamEvent> {
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<AiProviderModel[]> {
const headers: Record<string, string> = {}
// 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 }
: {}),
}
})
}
Loading