PolyLLM — repository guide
PolyLLM (www.polyllm.io) is a BYOK multi-provider AI workspace: OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, Kimi,
OpenRouter and Cerebras behind one normalized adapter layer (createOpenAICompatAdapter for the OpenAI-shaped ones), real streaming, capability-driven configuration, Arena comparisons, usage/cost tracking.
The original product brief lives in docs/SPEC-original.md; the UI conventions in docs/UI-BRIEF.md.
Non-negotiables
- Research before provider changes. Read
docs/provider-research/<provider>.md, re-fetch the official docs, probe the real API (pnpm providers:matrix), then updatesrc/lib/ai/providers/<provider>/catalog*.ts. Never assume model ids, parameters, event names or limits from memory. BumpLast documentation audit. - Provider quirks stay inside
src/lib/ai/providers/*. UI and services only seePolyModel,UnifiedChatRequest,UnifiedStreamEvent,PolyProviderError. - Capabilities drive the UI. Never branch on a model id in components; use
model.capabilities/model.parameters. Never send a parameter the model does not accept (filterSettings()+ adapter translation). - Keys. User keys are encrypted (
src/lib/crypto/keys.ts) and decrypted only ingetDecryptedKey()right before a request. Never log, return or serialize them;PublicConnectionexposeskeyHintonly. Owner keys in.envare for the registry sync, admin diagnostics and tests — never for user requests. - No fake integrations. A capability is ✅ only after a real request passed (
docs/provider-test-matrix.md). - Secrets only in
.env(gitignored) and the mld manifest on M1M32;.env.exampledocuments the variables.
1.0 architecture notes (2026-09-11)
- Read
docs/UPGRADE-PLAN.md(shared primitives, ownership map) anddocs/upgrade-notes/*.md(per-area APIs, contracts) before touching chat, models, Arena, projects/library/prompts, usage/providers/endpoints, marketing or search/share. - Mobile first. Phones use
BottomSheet/ResponsiveDialog/ActionSheet(components/ui/sheet.tsx),useLongPress,.snap-rowcarousels,.taphit areas,.h-app(100dvh − keyboard) — neverwindow.prompt/confirm, never100vh. - Schema additions go in
src/db/schema-workspace.ts(or a newschema-*.tsre-exported fromsrc/db/index.ts);pnpm db:generate --name x && pnpm db:migrate(db:pushneeds a TTY). Never edit shipped migrations. - Cross-area contracts: prompt insert event
polyllm:insert-prompt(components/prompts/insert.ts), pending attachments (components/library/use-in-chat.ts), palette eventspolyllm:open-attach/polyllm:switch-model, share viaopenShareSheet(), exports viaexportConversation(). Custom endpoints resolve inprepareTurn/runArenaModelthroughresolveCustomEndpoint()(lib/endpoints/service.ts); their models are keyedcustom/<endpointId>:<modelId>. - QA: one dev server only (
.nextconflicts);node qa/responsive-qa.mjs+node qa/flows.mjsbefore deploying. - Brand assets are generated: edit
public/icon.svg/public/brand/*.svg, thenscripts/brand-assets.sh.
Commands
pnpm dev · pnpm typecheck · pnpm lint · pnpm test (vitest) · pnpm test:e2e (Playwright; dev server with
EMAIL_DRY_RUN=1 EMAIL_DRY_RUN_PRINT=1) · pnpm db:generate / pnpm db:migrate · pnpm models:sync [provider] ·
pnpm providers:matrix [provider].
Deploy
mld from the laptop (see docs/deployment-cluster.md): mld stage . polyllm && mld deploy polyllm --node M3U96a.
Manifest with secrets: M1M32:~/dispatch/apps/polyllm.json (local gitignored copy deploy/polyllm.mld.json).
Gotchas
- React Compiler lint: no
Date.now()/impure calls in render, no setState synchronously in effects without an explicit eslint-disable, memoize derived lists. import "server-only"modules are used by CLI scripts throughscripts/hooks.mjs(tsx--import) and by vitest via an alias.- Anthropic: adaptive thinking may legitimately produce zero thinking tokens on easy prompts; Haiku 4.5 needs
budget_tokens. - Gemini: free-tier keys have tiny per-model daily quotas (429
limit: 20) and some models are paid-tier only (429limit: 0); thought signatures must be replayed on function-call parts. - xAI: bad keys return HTTP 400 (
Incorrect API key),frequency/presence_penaltyalways 400, web search only via/v1/responses. - OpenAI: Responses API has no
seed/stop; sampling params only whenreasoning.effort === "none"on reasoning models. - Mistral: strict 422 on unknown keys (
random_seed,max_tokens, nodeveloperrole); reasoning is binaryreasoning_effortnone|high and streams asthinkingcontent-array chunks;magistral-*are aliases. - DeepSeek: thinking ON by default (
thinking:{type}+reasoning_effortlow|high|max); onlyjson_object;max_tokenscaps reasoning + answer; images silently dropped except on the vision model. - Kimi: temperature must be 1 / top_p 0.95 (400 otherwise);
/v1/models/{id}gives capabilities;reasoning_contentreplayed; json_schema not enforced (json_object + prompt). - OpenRouter: unsupported params are silently dropped — gate on
supported_parameters;usage.costis exact;:batchids 404 on chat; finish chunk arrives twice. - Cerebras: unknown params → 400 (whitelist); reasoning via
reasoning_effortonly;tools+response_format→ 400 on gpt-oss; strict schema needsadditionalProperties:false.