Form Copilot — agent-driven editing in the builder
Status: M1 landed (Rust API + web client). M2 pending. Owner: a.kadem Date: 2026-06-15
Implementation status (M1 + M2 in progress)
M1 is built on the Rust API (apps/api-rust, axum + sqlx), not the legacy Elysia apps/api (dead in prod — Caddy proxies all /api/* to the Rust service on 127.0.0.1:3010). The prose below was drafted Elysia-first; the architecture is identical but the real files are Rust:
apps/api-rust/src/api/services/ai/deepseek.rs— direct DeepSeek provider (call_deepseek_chat, tools +tool_choice; reusesparse_openrouter_sse).…/services/ai/planner.rs—submitPlantool, planner prompt,plan_edits.…/services/ai/executor.rs— ref resolution (id/label/tempId), field-op dispatch onto the existingtools.rsexecutors, mutation events, andcopilot_structural_fingerprint(FNV-1a, byte-identical to the webcopilotStructuralFingerprint— pinned by a cross-language test).…/controllers/copilot.rs—POST /api/v1/forms/{form_id}/ai/copilot(SSE), registered in…/api/mod.rs.- Web client (
apps/web) is unchanged in shape: store replay/journal/undo,use-form-copilotSSE hook,CopilotPanel, FormBuilder wiring.
Deviations from the original plan, decided during the port:
- Gate returns 403 via the existing
require_ai_access(professional/enterprise), not 402 — consistent with every other AI route. The webtoGateErroralready treats 403/PREMIUM_REQUIREDas the upgrade gate. Buffered SSE, not chunkedRESOLVED — now true SSE streaming (see the M2 "Streaming" entry below). The handler returns an axumSsebody; events flush incrementally with keep-alive frames covering the LLM latency.ai_chatis still buffered; only/copilotwas converted.- No persistence in
/copilot— it computes + streams; the client replays onto the store and the normal Save persists. (User/assistant messages + token usage ARE recorded against the conversation, mirroringai_chat.) - Planner pinned to DeepSeek in M1 (
body.modelhonored only if it's adeepseek*id); OpenRouter routing for the picker is deferred. - Dev topology caveat:
apps/webVite proxies/apito:3000; the Rust API binds:3010. To exercise copilot locally, run the Rust binary on:3000(PORT) or repoint the Vite proxy to:3010.
M2 progress
- Increment 1 — dependency rules + whole-form generation (done):
addRule/updateRule/removeRuledispatch onto the existingtools.rsrule executors and emit a mutation carrying the full updateddependencyData; the web hook routes those toonDependencyChange(rules live outside the builder store) and snapshots dep data per turn for undo. Whole-form generation needs no new op — the planner emits an orderedaddFieldsequence. - Increment 2 — non-gated structural ops (done):
setLayout,setTheme,updateFormMeta.setLayoutwritesschema.layout(validated/normalized:single→single-page; unknown types rejected) and replays onto the builder store (checkpoint undo restores it).setTheme(validated against the 9shadcnThemeNames) andupdateFormMetaare pass-through ops — no schema change; the mutation carries theme/name/description and the web hook applies them to editor state (setPresetTheme, name/description), persisted on Save. The requestcontext.formMeta(name/description/ theme) feeds the planner a "Form settings" block; per-turn snapshots of theme/name/ description join dep data so an undo reverts all editor-owned state at once. - Streaming — true SSE (done):
/copilotnow returns an axumSsebody instead of a bufferedString. The hard constraint is the request DB scope:req_db_layerreleases the per-request connection (and clears RLS GUCs) the instant the handler returns — before the body streams — and theREQ_DBtask-local isn't visible in the body future. So the handler does ALLreq_conn()reads/auth up front (tier gate, form/conversation lookups, history, user-message persist) with?, so 4xx/5xx still surface as real HTTP status before any byte. It then captures an ownedPgPoolclone and the planner inputs, and returns the stream. Inside the stream the DeepSeekplan_editscall runs (keep-alive frames cover the latency),execute_planruns, the assistant message + token usage persist via the pool's ownbegin()+set_tenant(stream-safe;req_conn()is NOT), thenplan/mutation/error/doneevents flush. Two router changes were required:api::streaming_routes()is merged WITHOUT theCompressionLayer(it buffers the body, defeating per-event flush) andTimeoutLayer(defensive;Timeoutonly bounds head production, which is now fast, but exemption is correct for long-lived streams). Behavior change: a planner/provider error is now an in-banderrorevent (HTTP 200) instead of a 5xx, since the response is already committed — the client'sonErroralready handles it. Tests:copilot_stream_tests(event ordering + wire format viaaxum::body::to_bytes) andhttp::tests::streaming_route_flushes_incrementally_and_uncompressed(real TCP proof that frames flush incrementally and uncompressed through the layer split).async-streamadded to Cargo.toml for thestream!generator. - Increment 3 — gated ops + confirm gate (done): sensitive ops are not applied in the main stream. The executor (
execute_plan) holds them inresult.pending; the stream emits aconfirm_neededevent carrying an HMAC-signed token (confirm.rssign_confirm_token,BETTER_AUTH_SECRET, domain-separated, 10-min exp, binding op+form_id+conversation_id). The client shows an Approve/Reject card; Approve POSTs the token to a buffered JSON endpointPOST /forms/{id}/ai/copilot/confirm, which verifies the token (rejects tamper/expiry/cross-form replay) and applies the op.setAuthSettings(always gated): on confirm,apply_set_auth_settingsUPDATEsrespondent_auth_method/allow_anonymousand inserts aform_setting_auditrow in onepool.begin()+set_tenanttx — the only copilot op that persists at confirm time (so the audit captures it). Auth methods are constrained tonone/email_link/password(the builder UI coercesoauth). The client mirrors the applied value into editor state viaonAuthChange; it is an explicit committed change, so it is not part of the per-turn undo.replaceForm(gated only on a non-empty form):normalize_replacement_fieldsre-mints a fresh unique id for every field/child (robust to planner output), guarantees labels, fills per-type defaults under the planner's values, and validates the layout through the samenormalize_layoutguardsetLayoutuses. Compute-only — the client replayssetFields+setLayout, clears now-orphaned rules, and the user's Save persists. Because the destructive swap lands after the turn (no per-turn checkpoint), the client snapshots fields/layout/deps at confirm time and the approved card shows an in-app Undo. On an empty form it applies directly (no gate).- New
form_setting_audittable (migration0006, drizzle schema,rls.ts): RLS tenant-isolation policy, idempotent owner/grant blocks. Apply withbun run db:migrate. Tests:confirm_tests(token round-trip/tamper/cross-form, gated-op detection), executorreplace_form_*, controllergated_op_emits_confirm.
Summary
Move AI form editing out of the standalone chat page (/forms/$formId/chat) and into the form builder. A docked copilot panel sits beside the WYSIWYG canvas. A planner (DeepSeek deepseek-v4-pro, direct API) reads the user's request plus the current working schema and emits an ordered edit plan; a deterministic executor validates each operation and streams it to the canvas as a live mutation, replayed onto the same builder store manual editing uses. Nothing persists until Save; each user turn is one undo step.
Locked decisions
| Question | Decision |
|---|---|
| Surface | Docked copilot panel in the builder; deprecate the chat page |
| Apply model | Live-apply to the canvas store, per-turn undo (no accept/reject phase) |
| Agent architecture | Two-stage: LLM planner drafts an ordered plan → deterministic executor validates + live-applies each op |
| Planner backend | Direct DeepSeek API (DeepSeekClient) behind a provider interface; default deepseek-v4-pro |
| Other backends | Existing OpenRouter client kept as alternative planner backends, selectable via the model picker |
| Capabilities | Fields + structure, dependency rules, layout/theme/meta, auth settings (confirm-gated), whole-form generation |
| Concurrency | Last-write-wins on Save; no server lock |
| Free tier | Panel visible (discoverability), input disabled behind an upgrade CTA; server-enforced |
Goals
- Natural-language editing of the open form: "make email required", "add a shipping section with address fields", "show company only when role is business".
- Whole-form generation from an empty canvas.
- Agent edits are visually attributable (highlight what changed) and reversible (undo last turn).
- Reuse, not rebuild: tool executors, conversation persistence, SSE streaming, and the validator layer already exist behind
FEATURE_AI_CHAT.
Non-goals (v1)
- Cross-form or workspace-level operations.
- Autonomous runs without a user message per turn.
- Real-time multi-editor collaboration (see Concurrency).
- Mobile layout for the panel.
Background — what exists today
apps/api/src/services/ai/—form-agent.ts(single tool-loop, max 10 iterations),llm-client.ts(OpenRouter, multi-model),tools/(field, dependency, read-only schema tools),validators/,prompts/,conversations.ts.apps/api/src/controllers/ai.ts—/api/v1/forms/:formId/ai/*: models list, conversation CRUD, SSE chat stream,/apply(two-phase apply).- DB:
ai_conversations,ai_chat_messages. - Web:
pages/forms/chat.tsx(706 lines) — separate page, propose → accept → apply. This is what we replace. - Builder:
useFormBuilderStoreexposes the exact mutation surface the executor needs (addField,updateField,removeField,moveField,reorderFields,duplicateField,setLayout, …);BuilderCanvasrenders live store state with selection/highlight affordances.
The today→tomorrow shift: today one LLM loop mutates a server schema copy and the client applies a batch at the end. Tomorrow a planner LLM produces a plan, a deterministic executor applies it op-by-op server-side, and each op streams down as a mutation the client replays live. The LLM reasons; the executor is the only thing that touches schema, so validation and id-minting stay centralized and deterministic.
UX
┌──────────────────────────────────────────────────────────────┐
│ Form Builder [Save][Publish] │
├─────────┬──────────────────────────────┬─────────────────────┤
│ Palette │ LIVE PREVIEW CANVAS │ Copilot │
│ │ │ ┌─────────────────┐ │
│ [T][#] │ ┌────────────────────────┐ │ │ history │ │
│ [@][▼] │ │ First Name │ │ └─────────────────┘ │
│ │ │ Email ✨ added │ │ Plan ✓ · 5 edits │
│ │ └────────────────────────┘ │ ✓ add Email │
│ │ │ ⟳ require Phone… │
│ │ │ > add a phone field │
└─────────┴──────────────────────────────┴─────────────────────┘- Panel: third column in
FormBuilder, toggleable like Fields/Structure (localStorage-persisted). Holds conversation history (fromai_conversations), the current turn's transcript (planner summary, then a checklist of plan ops ticking off as each applies), the model picker (defaultdeepseek-v4-pro), input, and a stop button that aborts the SSE request. - Plan-then-stream: after planning, the panel shows "Plan ✓ · N edits" and the op list; each op then animates onto the canvas as its
mutationarrives (ring + "✨ added/updated/moved" chip fading ~3s). Last touched field becomes the selection. - Manual editing is primary; the agent is additive. The preview is the editable canvas — drag from the palette, reorder, resize, click-select, edit via the field dialog, all by hand. You never have to prompt for everything; do the obvious edits directly and ask the copilot for the structural/tedious ones. Both paths mutate the same store. (Optional enhancement: inline label rename on double-click so the field dialog is rarely needed — see Mixed-initiative section.)
- Soft lock: the only time manual editing is blocked is while a turn is actively streaming its plan, via one
agentBusystore flag (inputs still scroll). Stop or turn-end releases it. Between turns, editing is fully free. - Undo: an "Undo" chip per completed turn reverts
{fields, layout, dependencyData, presetTheme, meta}to the turn-start checkpoint. - Confirm gates (the only pauses in an otherwise live flow): when the executor reaches a
replaceFormop on a non-empty form, or anysetAuthSettingsop, it emitsconfirm_needed; the panel shows an inline one-click confirm before applying. - The editor header "AI Assistant" button opens the panel instead of navigating to
/chat.
Architecture
Builder (web) API
┌────────────────────┐ ┌────────────────────────────────────┐
│ useFormBuilderStore│ POST /ai/copilot │ copilot controller (Elysia, SSE) │
│ ▲ replay ops │ ─────────────────▶ │ │ PlannerInput: {message, schema,│
│ │ journal (u+a) │ │ │ dependencyData, uiContext, │
│ │ │ │ │ changeJournal, conversation} │
│ CopilotPanel │ │ ▼ │
│ │ SSE events │ plan / mutation / │ ┌─ PLANNER (LlmProvider) ────────┐ │
│ ◀──────────────── │ confirm / done │ │ DeepSeekClient (deepseek-v4-pro)│ │
│ text plan mutation│ │ │ → submitPlan(ops[]) │ │
│ confirm_needed │ POST .../confirm │ └────────────┬────────────────────┘ │
│ done(hash) │ ─────────────────▶ │ ▼ │
└────────────────────┘ │ ┌─ EXECUTOR (deterministic) ──────┐ │
│ │ resolve refs → validate → apply │ │
│ │ to in-memory schema; mint ids; │ │
│ │ emit mutation per op │ │
│ └─────────────────────────────────┘ │
└────────────────────────────────────┘Provider interface
interface LlmProvider {
name: string;
// Single-shot: given system + context + user message, return the plan via a
// forced `submitPlan` tool call (or JSON mode). No multi-tool loop here.
plan(input: PlannerInput): Promise<EditPlan>;
}
class DeepSeekClient implements LlmProvider // direct api.deepseek.com (OpenAI-compatible), DEEPSEEK_API_KEY
class OpenRouterClient implements LlmProvider // existing client, refactored to the interfaceProvider is resolved from the selected model id: deepseek-* → DeepSeekClient, anything else → OpenRouterClient. Default model deepseek-v4-pro (hardcoded constant; swap is a one-line change). The executor uses no LLM — the planner is the only model in the loop.
The plan
The planner returns an ordered op list. Refs are symbolic so the planner never needs to know minted ids:
type FieldRef = { id?: string; tempId?: string; label?: string }; // executor resolves
type PlanOp =
| { op: "addField"; tempId: string; type: WidgetType; parentRef?: FieldRef; index?: number; config: FieldConfig }
| { op: "updateField"; ref: FieldRef; patch: Partial<FieldConfig> }
| { op: "removeField"; ref: FieldRef }
| { op: "moveField"; ref: FieldRef; parentRef?: FieldRef; index?: number }
| { op: "duplicateField"; ref: FieldRef; tempId: string }
| { op: "addRule" | "updateRule" | "removeRule"; /* rule payload, refs symbolic */ }
| { op: "setLayout"; layout: FormLayout }
| { op: "setTheme"; theme: ShadcnThemeName }
| { op: "updateFormMeta"; name?: string; description?: string }
| { op: "setAuthSettings"; authMethod?: RespondentAuthMethod; allowAnonymous?: boolean } // confirm-gated
| { op: "replaceForm"; fields: BuilderField[]; layout?: FormLayout }; // confirm-gated if non-empty
interface EditPlan { summary: string; ops: PlanOp[] }Executor
For each op, in order:
- Resolve refs → concrete ids.
idis used directly;labelis looked up;tempIdis resolved via a temp→minted map populated asaddField/duplicateFieldcreate fields earlier in the same plan. Unresolvable ref → validation error. - Validate with the existing
validators/layer (field types, rule references, layout step↔field consistency, theme name, no cycles). - Confirm gate if op is
setAuthSettings, orreplaceFormon a non-empty form: emitconfirm_needed, wait (bounded) forPOST /ai/copilot/confirm. Decline → skip the op and continue. Accept → apply + write an audit-log entry (who/when/old→new). - Apply to the in-memory schema, minting ids server-side.
- Emit a
mutationevent with the canonical op + the minted ids + a one-line summary.
Repair loop (bounded): if any op fails validation, the executor sends the failures back to the planner once for a corrected submitPlan. If the re-plan still fails, emit error listing the bad ops and stop (no partial garbage; the turn's checkpoint is the clean rollback). Default: 1 repair attempt.
Mutation replay (client)
The client replays each mutation onto useFormBuilderStore. Because ids are minted server-side and carried in the event, both sides apply identical ops to identical starting state and stay in lockstep.
| op | store replay |
|---|---|
addField | addField(type, parentId, index, { id }) then updateField(id, config) — needs a new optional id param on the store action |
updateField | updateField(id, patch) |
removeField | removeField(id) |
moveField | moveField(id, parentId, index) |
duplicateField | replay as addField+updateField with the server-minted id (never the store's own duplicateField — it mints a different id) |
addRule/updateRule/removeRule | setDependencyData(next) via the editor's onDependencyChange |
setLayout | setLayout(layout) |
setTheme | editor callback → setPresetTheme |
updateFormMeta | editor callback → name/description |
setAuthSettings | editor callback → settings-panel state |
replaceForm | setFields(fields) + setLayout(layout), single undo step |
done carries SHA-256 of the server's final schema JSON. The client hashes its own fieldsToSchema(...); on mismatch (a replay bug, not an expected path) it hard-replaces store state from finalSchema included in the event, logs to PostHog, and re-renders — self-healing.
Stream protocol — AiStreamEvent additions
Existing: text, tool_call, tool_result, preview, error, done.
| { type: "plan"; data: { summary: string; ops: { op: string; summary: string }[] } }
| { type: "mutation"; data: { op: string; ids: Record<string,string>; args: …; summary: string } }
| { type: "confirm_needed"; data: { op: "replaceForm" | "setAuthSettings"; summary: string; token: string } }
| { type: "done"; data: { schemaSha256: string; finalSchema?: FormBuilderSchema; usage: {…} } }The old preview event and /apply endpoint stay during migration, then are deleted with the chat page.
New tools / ops vs. existing
Existing field, dependency, and read tool executors are reused (the executor maps plan ops onto them). New ops needing executor + validator work: setLayout, setTheme, updateFormMeta, setAuthSettings, replaceForm. The planner system prompt (prompts/) gains documentation + few-shot examples for layout/theme/meta/auth and whole-form generation, and is rewritten to output submitPlan rather than loop on tools.
Mixed-initiative editing & live agent context
The user and the agent edit the same form, the same way. Manual edits and agent ops both flow through useFormBuilderStore, so the user is never forced to prompt for everything — they hand-edit what's obvious and delegate the rest.
For this to work, the planner must on every turn see (a) the current state including manual edits made since the last turn, and (b) a diff history of what recently changed and who changed it. The planner input is therefore built fresh from the store at send-time — never from the DB or a cached snapshot:
interface PlannerInput {
message: string; // the user's instruction
schema: FormBuilderSchema; // fieldsToSchema(fields, layout) read NOW from the store
dependencyData: FormDependencyData | null;
uiContext: { // so the user can use deixis ("this field")
selectedFieldId: string | null;
lastTouchedFieldId: string | null; // last field the user OR agent edited
activeLayout: { kind: "single" | "wizard" | "tabs"; step?: string; tab?: string };
};
changeJournal: ChangeEntry[]; // recent diffs, user + agent (see below)
conversation: AiChatMessage[]; // prior turns for this form
}Change journal
Every mutation appends a structured diff to a journal slice in the store, regardless of source:
interface ChangeEntry {
id: string;
ts: number;
source: "user" | "agent"; // manual edit vs. an executor op
op: string; // addField | updateField | moveField | setLayout | …
targetId?: string;
summary: string; // "user added 'Phone' after 'Email'"
before?: unknown; after?: unknown; // compact, for update/move
}- Manual edits are journaled by instrumenting the store mutation actions (each
addField/updateField/… appends an entry taggedsource: "user"). - Agent edits are journaled by the replay layer as each
mutationevent lands, taggedsource: "agent". - Each turn sends the window since the last agent turn (plus a small tail, cap ~30 entries). This is what lets the user say "undo what you just did and instead…", "rename the field I just added", or "why did you move that?" — and stops the agent re-doing or clobbering work the user already did by hand.
The journal is ephemeral working context (in-memory, capped, not persisted); durable history is the conversation table plus the per-turn undo checkpoints below. The planner already carries the op vocabulary in its system prompt, so it has both the tools (what it can do) and the diff history (what just happened) in context.
Inline preview editing (optional enhancement, M3)
Beyond drag/resize/select, add lightweight in-place edits so the field dialog is rarely needed: double-click a label to rename, an inline "required" toggle on hover, inline option add/remove for choice fields. Each routes through the same store action (and thus the same journal), keeping manual and agent edits symmetric.
Undo
New useFormBuilderStore slice:
checkpoints: { turnId; fields; layout; dependencyData; presetTheme; meta }[] // cap ~20
journal: ChangeEntry[] // cap ~30, user + agent
beginAgentTurn(turnId) // push checkpoint, set agentBusy
endAgentTurn() // clear agentBusy
undoAgentTurn(turnId) // restore checkpoint, drop newer onesOne checkpoint per turn (not per op). The journal (above) is the op-granular companion the planner reads each turn; checkpoints are the coarse rollback. Together they seed a future general manual-undo stack.
Tier gating
- Free tier: the panel renders (discoverability) but the input is disabled with an inline upgrade CTA — free users never run a turn. Enforced server-side: the
/ai/copilotendpoint checks the team's tier and returns402for free teams; the client-side disabled input is UX only, not the gate. - Paid tiers: unmetered in v1 (the existing per-team rate-limit middleware is the abuse guard). No usage counter to build.
- Gate lives next to the existing tenant/permissions middleware; tier lookup uses the team subscription tier already on
teams.
Guardrails
- Caps: planner produces ≤ ~40 ops/turn; executor enforces the cap and a token budget; repair attempts capped at 1.
- Validators run on every op before apply (reused layer).
replaceFormpayloads additionally round-trip throughbuildJsonSchemaFromFormsemantics server-side. - Auth-settings safety:
setAuthSettingsis always confirm-gated and writes an audit-log entry. This is the one capability with a security-review obligation; flag it for/security-reviewbefore launch. - Permissions: endpoint requires editor access via the existing middleware chain (same as
/apply). - Feature flag: reuse
FEATURE_AI_CHAT(rename toFEATURE_FORM_COPILOTonly if we want to stage the surfaces independently). - Telemetry (PostHog) per turn: model, op counts, duration, tokens, undo rate, confirm accept/decline, hash-mismatch count, free-tier CTA impressions/clicks. Undo rate is the key quality signal.
- All code lives in
apps/api+apps/web— nopackages/rendererchanges — so the sibling-reposync:packagesoverwrite hazard does not apply.
Concurrency
Last-write-wins on Save, as with manual editing today. The agentBusy soft lock is per-tab only (stops a user fighting their own agent). Cross-editor clashes are resolved by whoever Saves last. No server lock table in v1.
Migration
- Build
CopilotPanel+/ai/copilotSSE endpoint (planner+executor) alongside the chat page, both behind the flag. - Editor "AI Assistant" opens the panel;
/forms/$formId/chatbecomes a redirect into the editor with the panel open. - After stabilization: delete
chat.tsx, thepreview//applyflow, and theAiProposedChangeplumbing.
Conversations persist in the existing tables, so chat-page history carries over.
Testing
- Parity golden tests (critical): for a corpus of plans, assert executor's final schema ≡ client-store final schema after replay (store run in vitest). Pins the replay mapping and id-minting.
- Planner contract tests with a mocked provider (scripted
submitPlanoutputs): ref resolution (id/label/tempId), repair loop, confirm pause/resume, mid-turn abort, hash mismatch recovery. DeepSeekClientintegration test (recorded/replayed): request shape, tool-call parsing, error/4xx handling (incl. an unknown-model 404 surfacing cleanly).- E2E happy path: "add a phone field after email" → canvas shows it; undo removes it.
- Auth-gate test:
setAuthSettingsop pauses, decline skips, accept applies + audits.
Milestones
- M1 — planner/executor wire-up: provider interface +
DeepSeekClient, plannersubmitPlan, deterministic executor over the existing field/dependency tools,/ai/copilotSSE (plan/mutation/done),CopilotPanel, replay layer + storeidparam,agentBusylock, per-turn undo, change journal (user+agent) + uiContext inPlannerInput, hash check, free-tier server gate. - M2 — full capability:
setLayout/setTheme/updateFormMeta/setAuthSettings/replaceFormops + validators, confirm gates + audit log, whole-form generation, prompt examples. - M3 — polish & migration: highlight/selection sync, stop button, conversation history UI, repair-loop UX, chat-page redirect then deletion, telemetry dashboards, security review of the auth capability.
Defaults chosen without a question (override any)
- Planner is single-shot per user turn with 1 repair attempt on validation failure (not a free-running multi-step agent).
- Executor uses no LLM (fully deterministic); the planner is the only model.
setAuthSettingsaudit-log entry uses a new lightweight audit record (or the existing audit mechanism if one is found during M2).- Agent writes
LanguageMaplabels only when the form is already multilingual; otherwise plain English strings. deepseek-v4-prowired as a constant default (could not verify against DeepSeek's catalog; if it 404s we adjust the id — surfaced cleanly by the client error path).
Open questions (remaining)
- Audit log: is there an existing audit table/mechanism to hang
setAuthSettingson, or do we add a minimalform_setting_auditrecord? - DeepSeek
deepseek-v4-pro— confirm it's the intended id once we can hit the live API; do we want a fallback model if it's unavailable in a given region? - Should the model picker expose DeepSeek-only, or DeepSeek default + the existing OpenRouter models as alternates for power users?
