diff --git a/.env.example b/.env.example index 40676b7..81b5602 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,19 @@ -OPENROUTER_API_KEY=your-key-here +# Model provider API keys — set at least one. All providers are equal; +# keys can also be added later in Admin → Model Providers. +OPENROUTER_API_KEY= +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GOOGLE_API_KEY= +DEEPSEEK_API_KEY= +GROQ_API_KEY= +CEREBRAS_API_KEY= +MISTRAL_API_KEY= +XAI_API_KEY= +ZAI_API_KEY= +FIREWORKS_API_KEY= +MINIMAX_API_KEY= +HUGGINGFACE_API_KEY= + BRAVE_SEARCH_API_KEY=your-key-here # 32+ char secrets. Generate each with: openssl rand -hex 32 CREDENTIALS_KEY= diff --git a/README.md b/README.md index 934d490..49b6bb8 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Self-hosted. Multi-user. Runs unattended. git clone https://github.com/0-AI-UG/zero-agent.git cd zero-agent && bun install -cp .env.example .env # add OPENROUTER_API_KEY, generate JWT_SECRET + CREDENTIALS_KEY +cp .env.example .env # add a model provider API key, generate JWT_SECRET + CREDENTIALS_KEY bun run dev ``` @@ -105,12 +105,12 @@ The same image backs `docker-compose.yml`, so `docker compose pull` fetches it i | Variable | Description | |---|---| -| `OPENROUTER_API_KEY` | Required. [OpenRouter](https://openrouter.ai) key. | +| `OPENROUTER_API_KEY` · `ANTHROPIC_API_KEY` · `OPENAI_API_KEY` · … | At least one model provider key is required; all providers are configured the same way (see `.env.example` for the full list). | | `JWT_SECRET` · `CREDENTIALS_KEY` | Required. ≥32 chars each (`openssl rand -hex 32`). | | `BRAVE_SEARCH_API_KEY` | Optional. Web search. | | `APP_URL` · `RP_ID` · `CORS_ORIGIN` · `TRUST_PROXY` | Set in production. | -Models, image providers, and per-user limits are configured at runtime via the admin panel. +Models, provider keys, capability routing (embeddings / image generation / captioning), and per-user limits are configured at runtime via the admin panel. ## Tech Stack diff --git a/bun.lock b/bun.lock index 72ecaca..af878d0 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,10 @@ "name": "rednote-leads-agent", "dependencies": { "@0-ai/s3lite": "^0.6.0", + "@ai-sdk/anthropic": "^3", + "@ai-sdk/google": "^3", + "@ai-sdk/openai": "^3", + "@ai-sdk/openai-compatible": "^2", "@anthropic-ai/sandbox-runtime": "^0.0.50", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", @@ -26,7 +30,6 @@ "@hono/node-server": "^1.19.12", "@mozilla/readability": "^0.6.0", "@openrouter/ai-sdk-provider": "^2.8.0", - "@openrouter/sdk": "^0.12.3", "@simplewebauthn/server": "^13.3.0", "@types/ws": "^8.18.1", "ai": "^6.0.164", @@ -169,11 +172,19 @@ "packages": { "@0-ai/s3lite": ["@0-ai/s3lite@0.6.0", "", {}, "sha512-QUlEbFxyx7P9N0zoJeJmNsRYFZZ5MWrolPH4K8hxz+FCdh+wTu4txBQUXTuXfwE5dkzcoZ7Wydbvh1chNv2RPA=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.92", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dFrf4xhx2yM686KHFm76Nn7nBekjkjiw1btqOyR26/kXz58QMguhNsjyvMqPktD8AW/wwTAq0fDRVyDvF2gH1w=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.101", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kGhqxpM2tZaDVfu3Z8mpB7jDsp0LZW2vtFHCBxZDd6KI1YCIVxn6+QkjzG/Pjnzkdgf0OY1wEGs6tALih7SSXg=="], - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@ai-sdk/google": ["@ai-sdk/google@3.0.88", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CN3PHCz5pa2sBowwZG4sNqE+7YfHWZT6+5KU12YMWuBssZ03s143Jr2jThkN5Fgemy8Kyg+ub2XbpHGhtIZ2yQ=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@3.0.80", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-u3EfYbBG4YS/U2eOGH0yv8lPRwDj25X3sTluUKMYEwOLTZzWYv0IPtrpO7tPEra0QU4oq5Gpg49/FGFSrzE4vA=="], + + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="], "@anthropic-ai/sandbox-runtime": ["@anthropic-ai/sandbox-runtime@0.0.50", "", { "dependencies": { "@pondwader/socks5-server": "^1.0.10", "commander": "^12.1.0", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, "bin": { "srt": "dist/cli.js" } }, "sha512-2j0K7jfk72YM+JitJ+O/ml1U+XOlSduR3Vet8YI49ot9ah6TWHyDzFLVBAEk5NCWMdNPULPHok/H6/L9meoxmg=="], @@ -577,8 +588,6 @@ "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-oDDW/0KMqz4suHVloB9sNv0YyKLGNYf1FTevXH6adDkid5dsmbbcYuiEsbIhpZSZtHa6o5AVjK1jEAfePOLxww=="], - "@openrouter/sdk": ["@openrouter/sdk@0.12.3", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-qpzxyNS3ikz7BQ4vDvSlheJ33oM/34l103M1+k1E9yjOmXK5abz5LHCLNhOEmbLNg4MvZG9rg7TPLsGgREe0Ow=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], @@ -1173,7 +1182,7 @@ "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], @@ -1993,6 +2002,10 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "@anthropic-ai/sandbox-runtime/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@aws-sdk/core/@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="], @@ -2143,6 +2156,10 @@ "@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.6", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw=="], + "ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -2217,6 +2234,8 @@ "zero-agent-web/lucide-react": ["lucide-react@0.576.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-koNxU14BXrxUfZQ9cUaP0ES1uyPZKYDjk31FQZB6dQ/x+tXk979sVAn9ppZ/pVeJJyOxVM8j1E+8QEuSc02Vug=="], + "@ai-sdk/gateway/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "@radix-ui/react-accordion/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -2279,6 +2298,8 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "ai/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], diff --git a/package.json b/package.json index 71ce3d4..0119d26 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "trustedDependencies": ["sharp", "bcrypt", "better-sqlite3", "esbuild"], "dependencies": { "@0-ai/s3lite": "^0.6.0", + "@ai-sdk/anthropic": "^3", + "@ai-sdk/google": "^3", + "@ai-sdk/openai": "^3", + "@ai-sdk/openai-compatible": "^2", "@anthropic-ai/sandbox-runtime": "^0.0.50", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", @@ -30,14 +34,13 @@ "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.2", - "@hono/node-server": "^1.19.12", "@earendil-works/pi-agent-core": "^0.78.0", "@earendil-works/pi-ai": "^0.78.0", "@earendil-works/pi-coding-agent": "^0.78.0", "@earendil-works/pi-tui": "^0.78.0", + "@hono/node-server": "^1.19.12", "@mozilla/readability": "^0.6.0", "@openrouter/ai-sdk-provider": "^2.8.0", - "@openrouter/sdk": "^0.12.3", "@simplewebauthn/server": "^13.3.0", "@types/ws": "^8.18.1", "ai": "^6.0.164", diff --git a/server/cli-handlers/embed.ts b/server/cli-handlers/embed.ts index 414fc66..e6718a6 100644 --- a/server/cli-handlers/embed.ts +++ b/server/cli-handlers/embed.ts @@ -4,8 +4,7 @@ * embeddings without needing direct API key access. */ import type { z } from "zod"; -import { embed } from "@/lib/openrouter/embed.ts"; -import { getEmbeddingModelId } from "@/lib/providers/index.ts"; +import { embed } from "@/lib/inference/embed.ts"; import { isEmbeddingConfigured } from "@/lib/search/vectors.ts"; import type { CliContext } from "./context.ts"; import { ok, fail } from "./response.ts"; @@ -19,7 +18,7 @@ export async function handleEmbed( return fail("not_configured", "Embedding model is not configured", 503); } - const embeddings = await embed(input.texts, { model: getEmbeddingModelId() }); + const embeddings = await embed(input.texts); return ok({ embeddings, diff --git a/server/cli-handlers/image.ts b/server/cli-handlers/image.ts index e4d95f1..47a2340 100644 --- a/server/cli-handlers/image.ts +++ b/server/cli-handlers/image.ts @@ -1,11 +1,11 @@ /** - * Image generation handler — wraps generateImageViaOpenRouter, writes the + * Image generation handler — wraps generateImageViaProvider, writes the * bytes into the project directory so the agent can read them back, and * inserts a `files` row. The inotify watcher converges FTS / vectors * after the file lands. */ import type { z } from "zod"; -import { generateImageViaOpenRouter } from "@/lib/media/image.ts"; +import { generateImageViaProvider } from "@/lib/media/image.ts"; import { insertFile } from "@/db/queries/files.ts"; import { createFolder as createFolderRecord, getFolderByPath } from "@/db/queries/folders.ts"; import { writeProjectFile, workspacePathFor } from "@/lib/projects/fs-ops.ts"; @@ -31,7 +31,7 @@ export async function handleImageGenerate( ctx: CliContext, input: z.infer, ): Promise { - const image = await generateImageViaOpenRouter(input.prompt); + const image = await generateImageViaProvider(input.prompt); const timestamp = Date.now(); const rawPath = input.path ?? `images/${timestamp}.png`; const filePath = sanitizePath(rawPath); diff --git a/server/cli-handlers/llm.ts b/server/cli-handlers/llm.ts index be98649..72f46d1 100644 --- a/server/cli-handlers/llm.ts +++ b/server/cli-handlers/llm.ts @@ -4,8 +4,9 @@ * without needing direct API key access. */ import type { z } from "zod"; -import { generateText } from "@/lib/openrouter/text.ts"; +import { generateText } from "@/lib/inference/text.ts"; import { getScriptsModelId } from "@/lib/providers/index.ts"; +import { resolveModelForPi } from "@/lib/pi/model.ts"; import { insertUsageLog } from "@/db/queries/usage-logs.ts"; import type { CliContext } from "./context.ts"; import { ok } from "./response.ts"; @@ -16,10 +17,12 @@ export async function handleLlmGenerate( input: z.infer, ): Promise { const model = getScriptsModelId(ctx.projectId); + const resolved = resolveModelForPi(model); const result = await generateText({ - model, - messages: input.prompt, + provider: resolved.provider, + model: resolved.modelId, + prompt: input.prompt, system: input.system, maxOutputTokens: input.maxTokens ?? 4096, }); diff --git a/server/db/queries/models.ts b/server/db/queries/models.ts index 3c68f95..aae2a8c 100644 --- a/server/db/queries/models.ts +++ b/server/db/queries/models.ts @@ -49,7 +49,7 @@ export interface ModelInput { enabled?: boolean; sortOrder?: number; thinkingLevel?: ThinkingLevel | null; - piProvider?: string; + piProvider: string; piModelId?: string | null; } @@ -64,7 +64,7 @@ export function insertModel(data: ModelInput): ModelRow { data.enabled !== false ? 1 : 0, data.sortOrder ?? 0, data.thinkingLevel ?? null, - data.piProvider ?? "openrouter", + data.piProvider, data.piModelId ?? null, ); return getById.get(data.id) as ModelRow; diff --git a/server/index.ts b/server/index.ts index 68c02c3..53fdad8 100644 --- a/server/index.ts +++ b/server/index.ts @@ -167,6 +167,7 @@ import { handleAcceptUserInvitation, } from "@/routes/user-invitations.ts"; import { handleSetupStatus, handleSetupComplete } from "@/routes/setup.ts"; +import { handleListProviders } from "@/routes/providers.ts"; import { handleGetSettings, handleUpdateSettings, handleListImageModels } from "@/routes/settings.ts"; import { handleEmailFeatureStatus, handleEmailFeatureToggle } from "@/routes/admin-email.ts"; import { @@ -428,6 +429,9 @@ app.post("/api/pending-responses/:id/respond", h(handleRespondPendingResponse)); app.get("/api/setup/status", h(handleSetupStatus)); app.post("/api/setup/complete", h(handleSetupComplete)); +// Model providers (no auth required — static, no secrets) +app.get("/api/providers", h(handleListProviders)); + // Admin app.get("/api/admin/users", h(handleListUsers)); app.post("/api/admin/users", h(handleCreateUser)); diff --git a/server/lib/ai/provider.ts b/server/lib/ai/provider.ts deleted file mode 100644 index 497cdff..0000000 --- a/server/lib/ai/provider.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; -import type { OpenRouterProvider } from "@openrouter/ai-sdk-provider"; -import { getSetting } from "@/lib/settings.ts"; -import { log } from "@/lib/utils/logger.ts"; - -const providerLog = log.child({ module: "ai-provider" }); - -let _cachedKey: string | null = null; -let _cachedProvider: OpenRouterProvider | null = null; - -export function getProvider(): OpenRouterProvider { - const key = getSetting("OPENROUTER_API_KEY") ?? process.env.OPENROUTER_API_KEY ?? ""; - if (_cachedProvider && key === _cachedKey) return _cachedProvider; - _cachedKey = key; - _cachedProvider = createOpenRouter({ apiKey: key, compatibility: "strict" }); - providerLog.info("ai-sdk openrouter provider (re)created", { hasKey: !!key }); - return _cachedProvider; -} - -export function getLanguageModel(modelId: string) { - return getProvider().chat(modelId); -} - -export function getEmbeddingModel(modelId: string) { - return getProvider().textEmbeddingModel(modelId); -} - diff --git a/server/lib/chat-providers/email/router.ts b/server/lib/chat-providers/email/router.ts index 02f5df3..3da84d0 100644 --- a/server/lib/chat-providers/email/router.ts +++ b/server/lib/chat-providers/email/router.ts @@ -20,7 +20,7 @@ import { generateId, db } from "@/db/index.ts"; import type { ChatRow, ProjectRow } from "@/db/types.ts"; import { runTurn } from "@/lib/pi/run-turn.ts"; import { resolveModelForPi } from "@/lib/pi/model.ts"; -import { getActiveProvider } from "@/lib/providers/index.ts"; +import { getDefaultChatModelId } from "@/lib/providers/index.ts"; import { beginChatStream, endChatStream, publishPiEvent } from "@/lib/http/ws.ts"; import { events as eventBus } from "@/lib/tasks/events.ts"; @@ -229,7 +229,7 @@ async function runEmailAgentTurn( const isFirstTurn = !existsSync(join(sessionsDirFor(project.id), `${chatId}.jsonl`)); const priorOutbound = isFirstTurn ? outboundForChat(project.id, chatId) : []; const userText = composeUserMessage(parsed, attachments, priorOutbound, inboundEmailId); - const chatModelId = getActiveProvider().getDefaultChatModelId(); + const chatModelId = getDefaultChatModelId(); const resolved = resolveModelForPi(chatModelId); beginChatStream(chatId, ""); diff --git a/server/lib/chat-providers/telegram/provider.ts b/server/lib/chat-providers/telegram/provider.ts index 0076ea2..2249fc9 100644 --- a/server/lib/chat-providers/telegram/provider.ts +++ b/server/lib/chat-providers/telegram/provider.ts @@ -13,7 +13,7 @@ * replies can resolve the right pending response. */ import sharp from "sharp"; -import { generateText } from "@/lib/openrouter/text.ts"; +import { generateText } from "@/lib/inference/text.ts"; import { runTurn } from "@/lib/pi/run-turn.ts"; import { resolveModelForPi } from "@/lib/pi/model.ts"; @@ -30,7 +30,7 @@ import { type TelegramMessage, type TelegramCallbackQuery, } from "@/lib/telegram-global/telegram.ts"; -import { getActiveProvider, getVisionModelId } from "@/lib/providers/index.ts"; +import { getCapabilityRoute, getDefaultChatModelId } from "@/lib/providers/index.ts"; import { getBotToken, getBotInfoSync, @@ -413,10 +413,10 @@ async function runAgentTurn( } } - // Telegram has no UI to pick a model, so resolve the active provider's - // default once and pass it explicitly to the agent - that way the image + // Telegram has no UI to pick a model, so resolve the default chat model + // once and pass it explicitly to the agent - that way the image // capability check below and the actual run share one source of truth. - const chatModelId = getActiveProvider().getDefaultChatModelId(); + const chatModelId = getDefaultChatModelId(); const resolved = resolveModelForPi(chatModelId); const modelSupportsImages = resolved.supportsImages; @@ -432,13 +432,12 @@ async function runAgentTurn( tgLog.info("forwarding telegram image natively", { chatModelId }); } else { try { - const visionModel = getVisionModelId(); - const dataUrl = `data:${imageData.mediaType};base64,${imageData.base64}`; + const vision = getCapabilityRoute("vision"); const { text: caption } = await generateText({ - model: visionModel, - messages: - "Describe this image in detail. Include all visible text, layout, colors, and key elements.\n\n" + - dataUrl, + provider: vision.provider.id, + model: vision.modelId, + prompt: "Describe this image in detail. Include all visible text, layout, colors, and key elements.", + images: [{ data: imageData.base64, mediaType: imageData.mediaType }], }); imageCaption = caption; tgLog.info("captioned telegram image", { chatModelId, captionLength: caption.length }); diff --git a/server/lib/inference/embed.ts b/server/lib/inference/embed.ts new file mode 100644 index 0000000..8df2ccc --- /dev/null +++ b/server/lib/inference/embed.ts @@ -0,0 +1,24 @@ +/** + * Embeddings via the configured embedding route + * (`EMBEDDING_PROVIDER` / `EMBEDDING_MODEL` settings). + */ +import { embedMany } from "ai"; +import { getCapabilityRoute } from "@/lib/providers/index.ts"; + +export async function embed(texts: string[]): Promise { + if (texts.length === 0) return []; + + const { provider, modelId } = getCapabilityRoute("embedding"); + if (!provider.embeddingModel) { + throw new Error(`${provider.displayName} does not support embeddings`); + } + + const providerOptions = provider.embeddingProviderOptions?.(modelId); + const { embeddings } = await embedMany({ + model: provider.embeddingModel(modelId), + values: texts, + ...(providerOptions ? { providerOptions: providerOptions as any } : {}), + }); + + return embeddings; +} diff --git a/server/lib/inference/image.ts b/server/lib/inference/image.ts new file mode 100644 index 0000000..301f0f7 --- /dev/null +++ b/server/lib/inference/image.ts @@ -0,0 +1,28 @@ +/** + * Image generation via the configured image route + * (`IMAGE_PROVIDER` / `IMAGE_MODEL` settings). + */ +import { getCapabilityRoute } from "@/lib/providers/index.ts"; +import type { GeneratedImage } from "@/lib/providers/types.ts"; + +export interface GenerateImageOptions { + prompt: string; + model?: string; + aspectRatio?: string; + imageSize?: string; +} + +export type { GeneratedImage }; + +export async function generateImage(options: GenerateImageOptions): Promise { + const { provider, modelId } = getCapabilityRoute("image"); + if (!provider.generateImage) { + throw new Error(`${provider.displayName} does not support image generation`); + } + return provider.generateImage({ + prompt: options.prompt, + model: options.model ?? modelId, + aspectRatio: options.aspectRatio, + imageSize: options.imageSize, + }); +} diff --git a/server/lib/inference/text.ts b/server/lib/inference/text.ts new file mode 100644 index 0000000..d5e3539 --- /dev/null +++ b/server/lib/inference/text.ts @@ -0,0 +1,76 @@ +/** + * String-in / string-out text generation dispatched through the provider + * registry. Pi owns conversation-shaped agent turns; this covers server-side + * helpers (image captioning, the llm CLI handler) that want one quick call + * against an explicit (provider, model) pair. + */ +import { generateText as aiGenerateText } from "ai"; +import { getProviderOrThrow } from "@/lib/providers/index.ts"; + +export interface GenerateTextUsage { + inputTokens?: number; + outputTokens?: number; + reasoningTokens?: number; + cachedInputTokens?: number; + totalTokens?: number; +} + +export interface GenerateTextArgs { + /** Provider id from the registry (openrouter, anthropic, …). */ + provider: string; + model: string; + prompt: string; + system?: string; + maxOutputTokens?: number; + temperature?: number; + /** Base64-encoded images for multimodal prompts (vision captioning). */ + images?: Array<{ data: string; mediaType: string }>; +} + +export interface GenerateTextResult { + text: string; + usage?: GenerateTextUsage; +} + +export async function generateText(args: GenerateTextArgs): Promise { + const provider = getProviderOrThrow(args.provider); + const model = provider.languageModel(args.model); + const common = { + ...(args.system !== undefined ? { system: args.system } : {}), + ...(args.maxOutputTokens !== undefined ? { maxOutputTokens: args.maxOutputTokens } : {}), + ...(args.temperature !== undefined ? { temperature: args.temperature } : {}), + }; + + const result = args.images?.length + ? await aiGenerateText({ + model, + messages: [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: args.prompt }, + ...args.images.map((img) => ({ + type: "image" as const, + image: img.data, + mediaType: img.mediaType, + })), + ], + }, + ], + ...common, + }) + : await aiGenerateText({ model, prompt: args.prompt, ...common }); + + return { + text: result.text, + usage: result.usage + ? { + inputTokens: result.usage.inputTokens, + outputTokens: result.usage.outputTokens, + reasoningTokens: (result.usage as any).reasoningTokens, + cachedInputTokens: (result.usage as any).cachedInputTokens, + totalTokens: result.usage.totalTokens, + } + : undefined, + }; +} diff --git a/server/lib/media/image.ts b/server/lib/media/image.ts index 747b0e5..4656d57 100644 --- a/server/lib/media/image.ts +++ b/server/lib/media/image.ts @@ -1,10 +1,9 @@ -import { generateImage } from "@/lib/openrouter/image.ts"; -import { getImageModelId } from "@/lib/providers/index.ts"; +import { generateImage } from "@/lib/inference/image.ts"; import { log } from "@/lib/utils/logger.ts"; const imgLog = log.child({ module: "image" }); -export async function generateImageViaOpenRouter( +export async function generateImageViaProvider( prompt: string, ): Promise<{ data: Uint8Array; mediaType: string }> { imgLog.info("generating image", { prompt: prompt.slice(0, 200) }); @@ -12,7 +11,6 @@ export async function generateImageViaOpenRouter( try { const result = await generateImage({ - model: getImageModelId(), prompt, aspectRatio: "9:16", }); diff --git a/server/lib/openrouter/client.ts b/server/lib/openrouter/client.ts deleted file mode 100644 index a764aa3..0000000 --- a/server/lib/openrouter/client.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Singleton `OpenRouter` SDK client. - * - * Reads the API key from the settings store (matches current behavior in - * `server/lib/providers/openrouter.ts`). Re-creates the client when the key - * changes so settings-page updates take effect without a restart. - * - * Phase 1 builds the higher-level helpers (streamAgentTurn, generateText, - * embed, generateImage) on top of this client. - */ - -import { OpenRouter } from "@openrouter/sdk"; -import { getSetting } from "@/lib/settings.ts"; -import { log } from "@/lib/utils/logger.ts"; - -const orLog = log.child({ module: "openrouter-client" }); - -let _cachedKey: string | null = null; -let _cachedClient: OpenRouter | null = null; - -export function getOpenRouterClient(): OpenRouter { - const key = getSetting("OPENROUTER_API_KEY") ?? process.env.OPENROUTER_API_KEY ?? ""; - if (_cachedClient && key === _cachedKey) return _cachedClient; - _cachedKey = key; - _cachedClient = new OpenRouter({ - apiKey: key, - }); - orLog.info("openrouter client (re)created", { hasKey: !!key }); - return _cachedClient; -} diff --git a/server/lib/openrouter/embed.ts b/server/lib/openrouter/embed.ts deleted file mode 100644 index 51107aa..0000000 --- a/server/lib/openrouter/embed.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { embedMany } from "ai"; -import { getEmbeddingModel } from "@/lib/ai/provider.ts"; - -export interface EmbedArgs { - model: string; -} - -export async function embed(texts: string[], { model }: EmbedArgs): Promise { - if (texts.length === 0) return []; - - const { embeddings } = await embedMany({ - model: getEmbeddingModel(model), - values: texts, - }); - - return embeddings; -} diff --git a/server/lib/openrouter/image.ts b/server/lib/openrouter/image.ts deleted file mode 100644 index 2e97db9..0000000 --- a/server/lib/openrouter/image.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * OpenRouter image generation — calls /chat/completions directly with the - * right `modalities` for the chosen model. We bypass the AI SDK provider - * because it hardcodes `modalities: ["image", "text"]`, which breaks for - * image-only models (FLUX, Recraft, Seedream, Riverflow). - * - * Reference: https://openrouter.ai/docs/guides/overview/multimodal/image-generation - */ -import { getSetting } from "@/lib/settings.ts"; -import imageModels from "@/lib/media/image_models.json" with { type: "json" }; - -export interface GenerateImageArgs { - prompt: string; - model: string; - aspectRatio?: string; - imageSize?: string; -} - -export interface GeneratedImage { - data: Uint8Array; - mediaType: string; -} - -interface ImageModelEntry { - id: string; - outputModalities: string[]; -} - -const MODELS = imageModels as ImageModelEntry[]; - -function modalitiesFor(modelId: string): string[] { - const entry = MODELS.find((m) => m.id === modelId); - // Unknown / custom: assume image-only — safer fallback since image-only is - // the more restrictive endpoint and dual-output models also accept it. - return entry?.outputModalities ?? ["image"]; -} - -function decodeDataUrl(dataUrl: string): { data: Uint8Array; mediaType: string } { - const match = /^data:([^;,]+)(?:;base64)?,(.*)$/.exec(dataUrl); - if (!match) throw new Error("OpenRouter returned a non-data-URL image"); - const mediaType = match[1] ?? "image/png"; - const isBase64 = dataUrl.includes(";base64,"); - const payload = match[2] ?? ""; - const data = isBase64 - ? Uint8Array.from(Buffer.from(payload, "base64")) - : new TextEncoder().encode(decodeURIComponent(payload)); - return { data, mediaType }; -} - -export async function generateImage(args: GenerateImageArgs): Promise { - const { prompt, model, aspectRatio, imageSize } = args; - const apiKey = getSetting("OPENROUTER_API_KEY") ?? process.env.OPENROUTER_API_KEY ?? ""; - if (!apiKey) throw new Error("OPENROUTER_API_KEY is not set"); - - const imageConfig: Record = {}; - if (aspectRatio) imageConfig.aspect_ratio = aspectRatio; - if (imageSize) imageConfig.image_size = imageSize; - - const body = { - model, - messages: [{ role: "user", content: prompt }], - modalities: modalitiesFor(model), - ...(Object.keys(imageConfig).length > 0 && { image_config: imageConfig }), - }; - - const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`OpenRouter image gen failed (${res.status}): ${text}`); - } - - const json = (await res.json()) as { - choices?: Array<{ - message?: { - images?: Array<{ image_url?: { url?: string } }>; - }; - }>; - }; - - const url = json.choices?.[0]?.message?.images?.[0]?.image_url?.url; - if (!url) throw new Error("OpenRouter response contained no image"); - return decodeDataUrl(url); -} diff --git a/server/lib/openrouter/text.ts b/server/lib/openrouter/text.ts deleted file mode 100644 index 43b296c..0000000 --- a/server/lib/openrouter/text.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Thin string-in / string-out wrapper around the AI SDK `generateText` for - * server-side helpers (image captioning, llm CLI handler) that just want - * a quick model call. Pi owns conversation-shaped calls now; this only - * supports a flat string prompt. - */ -import { generateText as aiGenerateText } from "ai"; -import { getLanguageModel } from "@/lib/ai/provider.ts"; - -export interface GenerateTextUsage { - inputTokens?: number; - outputTokens?: number; - reasoningTokens?: number; - cachedInputTokens?: number; - totalTokens?: number; -} - -export interface GenerateTextArgs { - model: string; - messages: string; - system?: string; - maxOutputTokens?: number; - temperature?: number; - providerOptions?: Record; -} - -export interface GenerateTextResult { - text: string; - usage?: GenerateTextUsage; -} - -export async function generateText(args: GenerateTextArgs): Promise { - const { model, messages, system, maxOutputTokens, temperature, providerOptions } = args; - - const result = await aiGenerateText({ - model: getLanguageModel(model), - prompt: messages, - ...(system !== undefined ? { system } : {}), - ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), - ...(temperature !== undefined ? { temperature } : {}), - ...(providerOptions ? { providerOptions: providerOptions as any } : {}), - }); - return { - text: result.text, - usage: result.usage - ? { - inputTokens: result.usage.inputTokens, - outputTokens: result.usage.outputTokens, - reasoningTokens: (result.usage as any).reasoningTokens, - cachedInputTokens: (result.usage as any).cachedInputTokens, - totalTokens: result.usage.totalTokens, - } - : undefined, - }; -} diff --git a/server/lib/pi/model.ts b/server/lib/pi/model.ts index 9281525..f2f9c66 100644 --- a/server/lib/pi/model.ts +++ b/server/lib/pi/model.ts @@ -8,8 +8,12 @@ import { ModelRegistry, } from "@earendil-works/pi-coding-agent"; import { getSetting } from "@/lib/settings.ts"; -import { getActiveProvider, resolveChatModelId } from "@/lib/providers/index.ts"; -import { getModelById } from "@/db/queries/models.ts"; +import { + getDefaultChatModelId, + getProvider, + listProviders, +} from "@/lib/providers/index.ts"; +import { getDefaultModel, getModelById } from "@/db/queries/models.ts"; import type { ThinkingLevel } from "@/db/types.ts"; export interface ResolvedPiModel { @@ -24,11 +28,11 @@ export interface ResolvedPiModel { } export function resolveModelForPi(modelId?: string): ResolvedPiModel { - const canonicalId = modelId - ? resolveChatModelId(modelId) - : getActiveProvider().getDefaultChatModelId(); + const canonicalId = modelId ?? getDefaultChatModelId(); const row = getModelById(canonicalId); - const provider = row?.pi_provider ?? "openrouter"; + // Ids missing from the models table are raw aggregator slugs; route them + // like the default model does, with openrouter as the last-ditch guess. + const provider = row?.pi_provider ?? getDefaultModel()?.pi_provider ?? "openrouter"; const upstreamId = row?.pi_model_id ?? canonicalId; return { modelId: upstreamId, @@ -38,27 +42,19 @@ export function resolveModelForPi(modelId?: string): ResolvedPiModel { }; } -// Settings keys for Pi's built-in providers. AuthStorage natively falls -// back to env vars by these same names, so we only need to seed values -// that come from the settings store (admin-configured at runtime). -const BUILTIN_PROVIDER_KEYS: Record = { - openrouter: "OPENROUTER_API_KEY", - deepseek: "DEEPSEEK_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - openai: "OPENAI_API_KEY", - google: "GOOGLE_API_KEY", - groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - mistral: "MISTRAL_API_KEY", - xai: "XAI_API_KEY", - zai: "ZAI_API_KEY", - fireworks: "FIREWORKS_API_KEY", - minimax: "MINIMAX_API_KEY", - huggingface: "HUGGINGFACE_API_KEY", -}; - +/** + * Settings keys for Pi's built-in providers, sourced from the provider + * registry — Pi and the registry share one provider id space. AuthStorage + * natively falls back to env vars by these same names, so we only need to + * seed values that come from the settings store (admin-configured at runtime). + */ export function listBuiltinProviderKeys(): Array<{ provider: string; envVar: string }> { - return Object.entries(BUILTIN_PROVIDER_KEYS).map(([provider, envVar]) => ({ provider, envVar })); + return listProviders().map((p) => ({ provider: p.id, envVar: p.apiKeySettingKey })); +} + +/** Settings/env key holding the API key for a provider, if known. */ +export function envVarForProvider(provider: string): string | undefined { + return getProvider(provider)?.apiKeySettingKey; } /** @@ -72,7 +68,7 @@ export function bootstrapAuthAndRegistry(): { } { const authStorage = AuthStorage.inMemory(); - for (const [provider, envVar] of Object.entries(BUILTIN_PROVIDER_KEYS)) { + for (const { provider, envVar } of listBuiltinProviderKeys()) { const v = getSetting(envVar); if (v) authStorage.setRuntimeApiKey(provider, v); } diff --git a/server/lib/providers/anthropic.ts b/server/lib/providers/anthropic.ts new file mode 100644 index 0000000..eeedc8f --- /dev/null +++ b/server/lib/providers/anthropic.ts @@ -0,0 +1,29 @@ +/** + * Anthropic provider — chat and vision. Anthropic ships no embedding or + * image-generation API, so those capabilities stay off. + */ +import { createAnthropic } from "@ai-sdk/anthropic"; +import type { Capability, InferenceProvider } from "@/lib/providers/types.ts"; +import { cachedClient } from "@/lib/providers/util.ts"; + +const client = cachedClient("ANTHROPIC_API_KEY", (apiKey) => createAnthropic({ apiKey })); + +const DEFAULTS: Partial> = { + chat: "claude-opus-4-8", + vision: "claude-opus-4-8", +}; + +export const anthropicProvider: InferenceProvider = { + id: "anthropic", + displayName: "Anthropic", + capabilities: { chat: true, embedding: false, image: false, vision: true }, + apiKeySettingKey: "ANTHROPIC_API_KEY", + + defaultModel(capability) { + return DEFAULTS[capability]; + }, + + languageModel(modelId) { + return client().languageModel(modelId); + }, +}; diff --git a/server/lib/providers/google.ts b/server/lib/providers/google.ts new file mode 100644 index 0000000..6204109 --- /dev/null +++ b/server/lib/providers/google.ts @@ -0,0 +1,70 @@ +/** + * Google provider — chat/vision/embeddings via the AI SDK. Image generation + * runs through the Gemini image models, which return images as files on a + * language-model response rather than through a dedicated image endpoint. + */ +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { generateText as aiGenerateText } from "ai"; +import type { + Capability, + GeneratedImage, + GenerateImageArgs, + InferenceProvider, +} from "@/lib/providers/types.ts"; +import { cachedClient } from "@/lib/providers/util.ts"; + +const client = cachedClient("GOOGLE_API_KEY", (apiKey) => + createGoogleGenerativeAI({ apiKey }), +); + +async function generateImage(args: GenerateImageArgs): Promise { + const result = await aiGenerateText({ + model: client().languageModel(args.model), + prompt: args.prompt, + providerOptions: { + google: { + responseModalities: ["TEXT", "IMAGE"], + ...(args.aspectRatio + ? { imageConfig: { aspectRatio: args.aspectRatio } } + : {}), + }, + }, + }); + const image = result.files.find((f) => f.mediaType.startsWith("image/")); + if (!image) throw new Error("Google response contained no image"); + return { data: image.uint8Array, mediaType: image.mediaType }; +} + +const DEFAULTS: Partial> = { + // Auto-tracking alias — resolves to the current Gemini Flash generation. + chat: "gemini-flash-latest", + vision: "gemini-flash-latest", + embedding: "gemini-embedding-001", + image: "gemini-3.1-flash-image", +}; + +export const googleProvider: InferenceProvider = { + id: "google", + displayName: "Google", + capabilities: { chat: true, embedding: true, image: true, vision: true }, + apiKeySettingKey: "GOOGLE_API_KEY", + + defaultModel(capability) { + return DEFAULTS[capability]; + }, + + languageModel(modelId) { + return client().languageModel(modelId); + }, + + embeddingModel(modelId) { + return client().textEmbeddingModel(modelId); + }, + + embeddingProviderOptions() { + // Pin to the fixed vector-index dimension (Gemini embeddings default to 3072). + return { google: { outputDimensionality: 1536 } }; + }, + + generateImage, +}; diff --git a/server/lib/providers/index.ts b/server/lib/providers/index.ts index ad08f71..5f6c5b4 100644 --- a/server/lib/providers/index.ts +++ b/server/lib/providers/index.ts @@ -1,133 +1,155 @@ import { getSetting } from "@/lib/settings.ts"; import { log } from "@/lib/utils/logger.ts"; -import { getDefaultModel } from "@/db/queries/models.ts"; +import { getDefaultModel, getEnabledModels } from "@/db/queries/models.ts"; import { getProjectById } from "@/db/queries/projects.ts"; -import type { - InferenceProvider, - OpenRouterRouting, -} from "@/lib/providers/types.ts"; +import type { Capability, InferenceProvider } from "@/lib/providers/types.ts"; import { openrouterProvider } from "@/lib/providers/openrouter.ts"; +import { anthropicProvider } from "@/lib/providers/anthropic.ts"; +import { openaiProvider } from "@/lib/providers/openai.ts"; +import { googleProvider } from "@/lib/providers/google.ts"; +import { compatibleProviders } from "@/lib/providers/openai-compatible.ts"; const provLog = log.child({ module: "providers" }); // ── Registry ── - -const PROVIDERS: Record = { - [openrouterProvider.id]: openrouterProvider, -}; - -export function registerProvider(provider: InferenceProvider): void { - PROVIDERS[provider.id] = provider; -} +// Every supported provider registers here on equal footing; ids share Pi's +// provider id space, so `pi_provider` on model rows resolves against this +// same registry. List order only matters as the auto-pick order when no +// explicit *_PROVIDER setting exists — openrouter stays first so pre-existing +// deployments (which only had an OpenRouter key) keep their behavior. +const ALL_PROVIDERS: InferenceProvider[] = [ + openrouterProvider, + anthropicProvider, + openaiProvider, + googleProvider, + ...compatibleProviders, +]; + +const PROVIDERS: Record = Object.fromEntries( + ALL_PROVIDERS.map((p) => [p.id, p]), +); export function getProvider(id: string): InferenceProvider | undefined { return PROVIDERS[id]; } +export function getProviderOrThrow(id: string): InferenceProvider { + const provider = PROVIDERS[id]; + if (!provider) throw new Error(`Unknown inference provider: ${id}`); + return provider; +} + export function listProviders(): InferenceProvider[] { - return Object.values(PROVIDERS); + return ALL_PROVIDERS; } -// ── Active provider (global setting) ── +export function isProviderConfigured(provider: InferenceProvider): boolean { + return !!getSetting(provider.apiKeySettingKey); +} -const DEFAULT_PROVIDER_ID = "openrouter"; -const FALLBACK_PROVIDER_ID = "openrouter"; +// ── Per-capability routing (embedding / image / vision) ── +// Chat is not routed here: agent turns and aux text generation resolve +// (provider, model) per model row via `resolveModelForPi`. -export function getActiveProvider(): InferenceProvider { - const id = getSetting("INFERENCE_PROVIDER") ?? DEFAULT_PROVIDER_ID; - return PROVIDERS[id] ?? PROVIDERS[DEFAULT_PROVIDER_ID]!; -} +export type AuxCapability = Exclude; -/** - * Resolve the provider for a specific model id. Today we only register - * openrouter, so this collapses to the active provider — the per-model - * `inference_provider` column was dropped in the Pi cutover. - */ -export function getProviderForModel(_modelId: string): InferenceProvider { - return getActiveProvider(); -} +const ROUTE_SETTINGS: Record = { + embedding: { provider: "EMBEDDING_PROVIDER", model: "EMBEDDING_MODEL" }, + image: { provider: "IMAGE_PROVIDER", model: "IMAGE_MODEL" }, + vision: { provider: "VISION_PROVIDER", model: "VISION_MODEL" }, +}; -// ── Capability fallback ── - -const warnedFallbacks = new Set(); -function warnFallbackOnce(capability: string, activeId: string) { - const key = `${capability}:${activeId}`; - if (warnedFallbacks.has(key)) return; - warnedFallbacks.add(key); - provLog.warn("provider missing capability, falling back", { - capability, - activeProvider: activeId, - fallback: FALLBACK_PROVIDER_ID, - }); +export interface CapabilityRoute { + provider: InferenceProvider; + modelId: string; } -function withCapability(capability: K): InferenceProvider { - const active = getActiveProvider(); - if (active.capabilities[capability]) return active; - warnFallbackOnce(capability, active.id); - return PROVIDERS[FALLBACK_PROVIDER_ID]!; +const warnedRoutes = new Set(); +function warnRouteOnce(key: string, message: string, ctx: Record) { + if (warnedRoutes.has(key)) return; + warnedRoutes.add(key); + provLog.warn(message, ctx); } -// ── ID resolver surface (replaces the old AI-SDK-typed re-exports) ── - -export function getChatModelId(): string { - return getActiveProvider().getChatModelId(); +/** + * Provider serving a capability: the `*_PROVIDER` setting when valid, + * otherwise the first capable provider with an API key configured, + * otherwise the first capable provider. + */ +export function resolveCapabilityProvider(capability: AuxCapability): InferenceProvider | undefined { + const settingKey = ROUTE_SETTINGS[capability].provider; + const requested = getSetting(settingKey); + if (requested) { + const provider = PROVIDERS[requested]; + if (provider?.capabilities[capability]) return provider; + warnRouteOnce(`${capability}:${requested}`, "configured provider cannot serve capability; auto-picking", { + capability, + setting: settingKey, + requested, + }); + } + const capable = ALL_PROVIDERS.filter((p) => p.capabilities[capability]); + return capable.find(isProviderConfigured) ?? capable[0]; } -export function resolveChatModelId(modelId: string): string { - return getProviderForModel(modelId).getChatModelId(modelId); +export function getCapabilityRoute(capability: AuxCapability): CapabilityRoute { + const provider = resolveCapabilityProvider(capability); + if (!provider) throw new Error(`No provider supports ${capability}`); + const modelId = getSetting(ROUTE_SETTINGS[capability].model) ?? provider.defaultModel(capability); + if (!modelId) { + throw new Error( + `No ${capability} model configured for ${provider.displayName} — set ${ROUTE_SETTINGS[capability].model}`, + ); + } + return { provider, modelId }; } -export function getImageModelId(modelId?: string): string { - return withCapability("image").getImageModelId(modelId); +/** True when the provider serving `capability` has an API key configured. */ +export function isCapabilityConfigured(capability: AuxCapability): boolean { + const provider = resolveCapabilityProvider(capability); + return !!provider && isProviderConfigured(provider); } -export function getVisionModelId(modelId?: string): string { - return withCapability("vision").getVisionModelId(modelId); -} +// ── Chat model resolution (models table) ── -export function getEmbeddingModelId(modelId?: string): string { - return withCapability("embedding").getEmbeddingModelId(modelId); +/** + * Default chat model id: admin-marked default in the `models` table → first + * enabled model → a configured provider's built-in default. + */ +export function getDefaultChatModelId(): string { + const dbDefault = getDefaultModel(); + if (dbDefault) return dbDefault.id; + const firstEnabled = getEnabledModels()[0]; + if (firstEnabled) return firstEnabled.id; + const withDefault = ALL_PROVIDERS.filter((p) => p.defaultModel("chat")); + const provider = withDefault.find(isProviderConfigured) ?? withDefault[0]; + if (provider) return provider.defaultModel("chat")!; + throw new Error("No chat model configured"); } /** * Model used for `zero llm generate` — container scripts calling out via * the SDK/CLI proxy. Resolved in order: project `scripts_model` column → - * admin-marked default in the `models` table → active provider's default. + * default chat model. */ export function getScriptsModelId(projectId?: string): string { if (projectId) { const project = getProjectById(projectId); if (project?.scripts_model) return project.scripts_model; } - const dbDefault = getDefaultModel(); - if (dbDefault) return dbDefault.id; - return getActiveProvider().getDefaultChatModelId(); + return getDefaultChatModelId(); } /** * Model used by scheduled tasks (cron, event, script triggers, "run now"). - * Resolved in order: project `tasks_model` column → admin-marked default - * in the `models` table → active provider's default. + * Resolved in order: project `tasks_model` column → default chat model. */ export function getTasksModelId(projectId?: string): string { if (projectId) { const project = getProjectById(projectId); if (project?.tasks_model) return project.tasks_model; } - const dbDefault = getDefaultModel(); - if (dbDefault) return dbDefault.id; - return getActiveProvider().getDefaultChatModelId(); -} - -/** - * Per-model OpenRouter routing config (`{ order, allow_fallbacks }`) parsed - * from the model row's `provider_config` column. Callers merge this into - * `callModel({ provider: routing })`. - */ -export function getRoutingForModel(modelId: string): OpenRouterRouting | undefined { - const provider = getProviderForModel(modelId); - return provider.getRoutingForModel?.(modelId); + return getDefaultChatModelId(); } -export type { InferenceProvider, OpenRouterRouting }; +export type { Capability, InferenceProvider }; diff --git a/server/lib/providers/openai-compatible.ts b/server/lib/providers/openai-compatible.ts new file mode 100644 index 0000000..dd31069 --- /dev/null +++ b/server/lib/providers/openai-compatible.ts @@ -0,0 +1,90 @@ +/** + * OpenAI-compatible providers — vendors whose APIs speak the OpenAI chat + * protocol. They serve chat and vision (model permitting); embeddings and + * image generation stay off until a vendor-specific implementation exists. + */ +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import type { InferenceProvider } from "@/lib/providers/types.ts"; +import { cachedClient } from "@/lib/providers/util.ts"; + +interface CompatibleConfig { + id: string; + displayName: string; + apiKeySettingKey: string; + baseURL: string; +} + +function createCompatibleProvider(cfg: CompatibleConfig): InferenceProvider { + const client = cachedClient(cfg.apiKeySettingKey, (apiKey) => + createOpenAICompatible({ name: cfg.id, baseURL: cfg.baseURL, apiKey }), + ); + return { + id: cfg.id, + displayName: cfg.displayName, + capabilities: { chat: true, embedding: false, image: false, vision: true }, + apiKeySettingKey: cfg.apiKeySettingKey, + defaultModel() { + return undefined; + }, + languageModel(modelId) { + return client().chatModel(modelId); + }, + }; +} + +export const compatibleProviders: InferenceProvider[] = [ + { + id: "deepseek", + displayName: "DeepSeek", + apiKeySettingKey: "DEEPSEEK_API_KEY", + baseURL: "https://api.deepseek.com/v1", + }, + { + id: "groq", + displayName: "Groq", + apiKeySettingKey: "GROQ_API_KEY", + baseURL: "https://api.groq.com/openai/v1", + }, + { + id: "cerebras", + displayName: "Cerebras", + apiKeySettingKey: "CEREBRAS_API_KEY", + baseURL: "https://api.cerebras.ai/v1", + }, + { + id: "mistral", + displayName: "Mistral", + apiKeySettingKey: "MISTRAL_API_KEY", + baseURL: "https://api.mistral.ai/v1", + }, + { + id: "xai", + displayName: "xAI", + apiKeySettingKey: "XAI_API_KEY", + baseURL: "https://api.x.ai/v1", + }, + { + id: "zai", + displayName: "Z.AI", + apiKeySettingKey: "ZAI_API_KEY", + baseURL: "https://api.z.ai/api/paas/v4", + }, + { + id: "fireworks", + displayName: "Fireworks", + apiKeySettingKey: "FIREWORKS_API_KEY", + baseURL: "https://api.fireworks.ai/inference/v1", + }, + { + id: "minimax", + displayName: "MiniMax", + apiKeySettingKey: "MINIMAX_API_KEY", + baseURL: "https://api.minimax.io/v1", + }, + { + id: "huggingface", + displayName: "Hugging Face", + apiKeySettingKey: "HUGGINGFACE_API_KEY", + baseURL: "https://router.huggingface.co/v1", + }, +].map(createCompatibleProvider); diff --git a/server/lib/providers/openai.ts b/server/lib/providers/openai.ts new file mode 100644 index 0000000..7f3469f --- /dev/null +++ b/server/lib/providers/openai.ts @@ -0,0 +1,65 @@ +/** + * OpenAI provider — chat/vision/embeddings via the AI SDK, image generation + * via `generateImage` with the native image models. + */ +import { createOpenAI } from "@ai-sdk/openai"; +import { generateImage as aiGenerateImage } from "ai"; +import type { + Capability, + GeneratedImage, + GenerateImageArgs, + InferenceProvider, +} from "@/lib/providers/types.ts"; +import { cachedClient } from "@/lib/providers/util.ts"; + +const client = cachedClient("OPENAI_API_KEY", (apiKey) => createOpenAI({ apiKey })); + +// gpt-image models take fixed sizes, not free-form aspect ratios — map the +// common ratios onto the closest supported size. +const SIZE_FOR_RATIO: Record = { + "1:1": "1024x1024", + "9:16": "1024x1536", + "2:3": "1024x1536", + "16:9": "1536x1024", + "3:2": "1536x1024", +}; + +async function generateImage(args: GenerateImageArgs): Promise { + const size = + (args.imageSize as `${number}x${number}` | undefined) ?? + (args.aspectRatio ? SIZE_FOR_RATIO[args.aspectRatio] : undefined); + const { image } = await aiGenerateImage({ + model: client().imageModel(args.model), + prompt: args.prompt, + ...(size ? { size } : {}), + }); + return { data: image.uint8Array, mediaType: image.mediaType }; +} + +const DEFAULTS: Partial> = { + chat: "gpt-5.5", + vision: "gpt-5.5", + embedding: "text-embedding-3-small", + image: "gpt-image-2", +}; + +export const openaiProvider: InferenceProvider = { + id: "openai", + displayName: "OpenAI", + capabilities: { chat: true, embedding: true, image: true, vision: true }, + apiKeySettingKey: "OPENAI_API_KEY", + + defaultModel(capability) { + return DEFAULTS[capability]; + }, + + languageModel(modelId) { + return client().chat(modelId); + }, + + embeddingModel(modelId) { + return client().textEmbeddingModel(modelId); + }, + + generateImage, +}; diff --git a/server/lib/providers/openrouter.ts b/server/lib/providers/openrouter.ts index 85fc822..b8d512d 100644 --- a/server/lib/providers/openrouter.ts +++ b/server/lib/providers/openrouter.ts @@ -1,50 +1,121 @@ /** - * OpenRouter provider. Resolves model IDs only. + * OpenRouter provider — chat/vision/embeddings via the AI SDK provider, + * image generation via a direct /chat/completions call because the AI SDK + * provider hardcodes `modalities: ["image", "text"]`, which breaks for + * image-only models (FLUX, Recraft, Seedream, Riverflow). * - * Per-model routing (`provider_config`) was dropped in the Pi cutover — - * Pi handles provider fallback through its own settings now. + * Reference: https://openrouter.ai/docs/guides/overview/multimodal/image-generation */ - +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { getSetting } from "@/lib/settings.ts"; +import imageModels from "@/lib/media/image_models.json" with { type: "json" }; import type { + Capability, + GeneratedImage, + GenerateImageArgs, InferenceProvider, - OpenRouterRouting, } from "@/lib/providers/types.ts"; +import { cachedClient } from "@/lib/providers/util.ts"; + +const client = cachedClient("OPENROUTER_API_KEY", (apiKey) => + createOpenRouter({ apiKey, compatibility: "strict" }), +); -function getDefaultModelId(): string { - return getSetting("OPENROUTER_MODEL") ?? "~moonshotai/kimi-latest"; +interface ImageModelEntry { + id: string; + outputModalities: string[]; } -export const openrouterProvider: InferenceProvider = { - id: "openrouter", - displayName: "OpenRouter", - capabilities: { chat: true, image: true, vision: true, embedding: true }, +const MODELS = imageModels as ImageModelEntry[]; - getDefaultChatModelId() { - return getDefaultModelId(); - }, +function modalitiesFor(modelId: string): string[] { + const entry = MODELS.find((m) => m.id === modelId); + // Unknown / custom: assume image-only — safer fallback since image-only is + // the more restrictive endpoint and dual-output models also accept it. + return entry?.outputModalities ?? ["image"]; +} - getChatModelId(modelId?: string) { - return modelId ?? getDefaultModelId(); - }, +function decodeDataUrl(dataUrl: string): { data: Uint8Array; mediaType: string } { + const match = /^data:([^;,]+)(?:;base64)?,(.*)$/.exec(dataUrl); + if (!match) throw new Error("OpenRouter returned a non-data-URL image"); + const mediaType = match[1] ?? "image/png"; + const isBase64 = dataUrl.includes(";base64,"); + const payload = match[2] ?? ""; + const data = isBase64 + ? Uint8Array.from(Buffer.from(payload, "base64")) + : new TextEncoder().encode(decodeURIComponent(payload)); + return { data, mediaType }; +} - getImageModelId(modelId?: string) { - return modelId ?? getSetting("IMAGE_MODEL") ?? "google/gemini-2.5-flash-image"; - }, +async function generateImage(args: GenerateImageArgs): Promise { + const { prompt, model, aspectRatio, imageSize } = args; + const apiKey = getSetting("OPENROUTER_API_KEY") ?? ""; + if (!apiKey) throw new Error("OpenRouter API key is not set"); - getVisionModelId(modelId?: string) { - return modelId ?? process.env.VISION_MODEL ?? "qwen/qwen3.6-flash"; - }, + const imageConfig: Record = {}; + if (aspectRatio) imageConfig.aspect_ratio = aspectRatio; + if (imageSize) imageConfig.image_size = imageSize; + + const body = { + model, + messages: [{ role: "user", content: prompt }], + modalities: modalitiesFor(model), + ...(Object.keys(imageConfig).length > 0 && { image_config: imageConfig }), + }; - getEmbeddingModelId(modelId?: string) { - return modelId ?? "openai/text-embedding-3-small"; + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`OpenRouter image gen failed (${res.status}): ${text}`); + } + + const json = (await res.json()) as { + choices?: Array<{ + message?: { + images?: Array<{ image_url?: { url?: string } }>; + }; + }>; + }; + + const url = json.choices?.[0]?.message?.images?.[0]?.image_url?.url; + if (!url) throw new Error("OpenRouter response contained no image"); + return decodeDataUrl(url); +} + +const DEFAULTS: Partial> = { + embedding: "openai/text-embedding-3-small", + image: "google/gemini-3.1-flash-image", + vision: "qwen/qwen3.6-flash", +}; + +export const openrouterProvider: InferenceProvider = { + id: "openrouter", + displayName: "OpenRouter", + capabilities: { chat: true, embedding: true, image: true, vision: true }, + apiKeySettingKey: "OPENROUTER_API_KEY", + + defaultModel(capability) { + if (capability === "chat") { + return getSetting("OPENROUTER_MODEL") ?? "~moonshotai/kimi-latest"; + } + return DEFAULTS[capability]; }, - parseConfig(_raw: string | null) { - return undefined; + languageModel(modelId) { + return client().chat(modelId); }, - getRoutingForModel(_modelId: string): OpenRouterRouting | undefined { - return undefined; + embeddingModel(modelId) { + return client().textEmbeddingModel(modelId); }, + + generateImage, }; diff --git a/server/lib/providers/types.ts b/server/lib/providers/types.ts index 2e3b017..d1497ef 100644 --- a/server/lib/providers/types.ts +++ b/server/lib/providers/types.ts @@ -1,50 +1,54 @@ /** - * Provider interface. Post-AI-SDK: providers are now model-ID resolvers + - * config parsers. Callers pass the returned string into one of the helpers - * under `server/lib/openrouter/` (`generateText`, `embed`, `generateImage`) - * or into the agent loop — the SDK client is shared. + * Capability-aware inference provider interface. Every provider is a real + * implementation: it exposes AI SDK models for text/embedding work and an + * image-generation entry point where the vendor offers one. All providers + * register on equal footing in `index.ts` — which provider serves a given + * capability is a settings concern (`EMBEDDING_PROVIDER`, `IMAGE_PROVIDER`, + * `VISION_PROVIDER`), not a hierarchy. Chat routes per model row via + * `pi_provider`. */ +import type { EmbeddingModel, LanguageModel } from "ai"; -export interface ProviderCapabilities { - chat: boolean; - image: boolean; - vision: boolean; - embedding: boolean; +export type Capability = "chat" | "embedding" | "image" | "vision"; + +export type ProviderCapabilities = Record; + +export interface GenerateImageArgs { + prompt: string; + model: string; + aspectRatio?: string; + imageSize?: string; } -export interface OpenRouterRouting { - order: string[]; - allow_fallbacks?: boolean; +export interface GeneratedImage { + data: Uint8Array; + mediaType: string; } export interface InferenceProvider { - /** Stable identifier - must match the `inference_provider` column on model rows. */ + /** Stable identifier — shares Pi's provider id space (`pi_provider` column). */ id: string; displayName: string; capabilities: ProviderCapabilities; - /** Default chat model id used when callers don't specify one. */ - getDefaultChatModelId(): string; + /** Settings/env key holding this provider's API key. */ + apiKeySettingKey: string; - /** - * Resolve the model id for each category. Pass `undefined` to get the - * provider's default for that category. - */ - getChatModelId(modelId?: string): string; - getImageModelId(modelId?: string): string; - getVisionModelId(modelId?: string): string; - getEmbeddingModelId(modelId?: string): string; + /** Default model id for a capability, if the provider ships one. */ + defaultModel(capability: Capability): string | undefined; - /** - * Parse the provider-specific `provider_config` JSON blob from a model row. - * Each provider knows its own shape. - */ - parseConfig(raw: string | null): unknown; + /** AI SDK language model (chat + vision). */ + languageModel(modelId: string): LanguageModel; + + /** AI SDK embedding model — present iff `capabilities.embedding`. */ + embeddingModel?(modelId: string): EmbeddingModel; /** - * Optional: per-model routing/passthrough config. For OpenRouter this is the - * `{ order, allow_fallbacks }` object lifted from the `provider_config` - * column; callers merge it into `callModel` as `{ provider: routing }`. + * Provider options merged into `embedMany` calls, e.g. to pin the output + * dimensionality to the fixed vector-index dimension. */ - getRoutingForModel?(modelId: string): OpenRouterRouting | undefined; + embeddingProviderOptions?(modelId: string): Record | undefined; + + /** Image generation — present iff `capabilities.image`. */ + generateImage?(args: GenerateImageArgs): Promise; } diff --git a/server/lib/providers/util.ts b/server/lib/providers/util.ts new file mode 100644 index 0000000..31b6310 --- /dev/null +++ b/server/lib/providers/util.ts @@ -0,0 +1,17 @@ +import { getSetting } from "@/lib/settings.ts"; + +/** + * Cache an SDK client instance, recreating it when the API key setting + * changes so settings-page updates take effect without a restart. + */ +export function cachedClient(settingKey: string, create: (apiKey: string) => T): () => T { + let cachedKey: string | null = null; + let cached: T | null = null; + return () => { + const key = getSetting(settingKey) ?? ""; + if (cached !== null && key === cachedKey) return cached; + cachedKey = key; + cached = create(key); + return cached; + }; +} diff --git a/server/lib/search/reindex.ts b/server/lib/search/reindex.ts index 298aff5..9f12898 100644 --- a/server/lib/search/reindex.ts +++ b/server/lib/search/reindex.ts @@ -1,6 +1,5 @@ import { deleteProjectIndex, ensureIndex, putProjectVectors, isEmbeddingConfigured, chunkText, textToSparseVector } from "@/lib/search/vectors.ts"; -import { embed } from "@/lib/openrouter/embed.ts"; -import { getEmbeddingModelId } from "@/lib/providers/index.ts"; +import { embed } from "@/lib/inference/embed.ts"; import { log } from "@/lib/utils/logger.ts"; import { db } from "@/db/index.ts"; import { readProjectFile } from "@/lib/projects/fs-ops.ts"; @@ -70,7 +69,7 @@ const OVERALL_TIMEOUT_MS = 300_000; // 5 minutes const FILE_CONCURRENCY = 3; async function embedValues(values: string[]): Promise { - return embed(values, { model: getEmbeddingModelId() }); + return embed(values); } function storeVectors(projectId: string, vectors: VectorEntry[], indexReset: { done: boolean }): void { @@ -148,7 +147,7 @@ export async function reindexProject( onProgress?: (progress: ReindexProgress) => void, ): Promise<{ files: number }> { if (!isEmbeddingConfigured()) { - throw new Error("Embedding not configured - set OPENROUTER_API_KEY first"); + throw new Error("Embedding not configured - add an API key for the embedding provider first"); } if (isReindexRunning(projectId)) { diff --git a/server/lib/search/vectors.ts b/server/lib/search/vectors.ts index 19ea273..2e1025b 100644 --- a/server/lib/search/vectors.ts +++ b/server/lib/search/vectors.ts @@ -1,9 +1,8 @@ import { unlinkSync } from "node:fs"; import { VectorClient } from "@0-ai/s3lite/vectors"; import type { QueryResult, SparseVector } from "@0-ai/s3lite/vectors"; -import { embed } from "@/lib/openrouter/embed.ts"; -import { getEmbeddingModelId } from "@/lib/providers/index.ts"; -import { getSetting } from "@/lib/settings.ts"; +import { embed } from "@/lib/inference/embed.ts"; +import { isCapabilityConfigured } from "@/lib/providers/index.ts"; import { db } from "@/db/index.ts"; import { log } from "@/lib/utils/logger.ts"; const vecLog = log.child({ module: "vectors" }); @@ -96,14 +95,11 @@ export function closeVectorClient(): void { } export function isEmbeddingConfigured(): boolean { - // Embeddings are served by the active inference provider. We treat the - // OpenRouter API key as the canonical signal for now since it is the only - // provider that ships embedding support; future providers can extend this. - return !!getSetting("OPENROUTER_API_KEY"); + return isCapabilityConfigured("embedding"); } async function embedValues(values: string[]): Promise { - return embed(values, { model: getEmbeddingModelId() }); + return embed(values); } export async function embedValue(value: string): Promise { diff --git a/server/routes/models.ts b/server/routes/models.ts index 4f5c7ee..d8107fb 100644 --- a/server/routes/models.ts +++ b/server/routes/models.ts @@ -12,14 +12,18 @@ import { import type { ModelRow } from "@/db/types.ts"; import { getModel } from "@earendil-works/pi-ai"; -function lookupContextWindow(provider: string, id: string): number | undefined { - // The DB `provider` column groups models by maker (anthropic, moonshotai, …) - // but inference is routed through OpenRouter, so model ids are openrouter-format. - // Try openrouter first, then fall back to the maker provider. - for (const key of ["openrouter", provider]) { +function lookupContextWindow(m: ModelRow): number | undefined { + // Try the model's actual route first, then the aggregator id format, then + // the maker tag (the DB `provider` column groups models by maker). + const candidates: Array<[string, string]> = [ + [m.pi_provider, m.pi_model_id ?? m.id], + ["openrouter", m.id], + [m.provider, m.id], + ]; + for (const [key, id] of candidates) { try { - const m = getModel(key as never, id as never) as { contextWindow?: number } | undefined; - if (m?.contextWindow) return m.contextWindow; + const model = getModel(key as never, id as never) as { contextWindow?: number } | undefined; + if (model?.contextWindow) return model.contextWindow; } catch { // unknown (provider, id) combination — try next } @@ -39,7 +43,7 @@ function formatModel(m: ModelRow) { thinkingLevel: m.thinking_level, piProvider: m.pi_provider, piModelId: m.pi_model_id, - contextWindow: lookupContextWindow(m.provider, m.id), + contextWindow: lookupContextWindow(m), }; } @@ -79,8 +83,8 @@ export async function handleCreateModel(request: Request): Promise { await requireAdmin(request); const body: any = await request.json(); - if (!body.id || !body.name || !body.provider) { - return Response.json({ error: "id, name, and provider are required" }, { status: 400, headers: corsHeaders }); + if (!body.id || !body.name || !body.provider || !body.piProvider) { + return Response.json({ error: "id, name, provider, and piProvider are required" }, { status: 400, headers: corsHeaders }); } const data: ModelInput = { diff --git a/server/routes/providers.ts b/server/routes/providers.ts new file mode 100644 index 0000000..d9d91c0 --- /dev/null +++ b/server/routes/providers.ts @@ -0,0 +1,23 @@ +import { corsHeaders } from "@/lib/http/cors.ts"; +import { listProviders } from "@/lib/providers/index.ts"; + +/** + * Static list of supported model providers (id, display name, settings key, + * capabilities). No auth: it contains no secrets and the pre-auth setup + * wizard needs it to render its provider picker. + */ +export async function handleListProviders(_request: Request): Promise { + const providers = listProviders().map((p) => ({ + id: p.id, + displayName: p.displayName, + envVar: p.apiKeySettingKey, + capabilities: p.capabilities, + defaults: { + chat: p.defaultModel("chat"), + embedding: p.defaultModel("embedding"), + image: p.defaultModel("image"), + vision: p.defaultModel("vision"), + }, + })); + return Response.json({ providers }, { headers: corsHeaders }); +} diff --git a/server/routes/setup.ts b/server/routes/setup.ts index 727be31..1052f8a 100644 --- a/server/routes/setup.ts +++ b/server/routes/setup.ts @@ -8,6 +8,7 @@ import { generateCsrfToken, } from "@/lib/http/cookies.ts"; import { setSetting } from "@/lib/settings.ts"; +import { getProvider } from "@/lib/providers/index.ts"; import { handleError } from "@/routes/utils.ts"; import { usernameSchema, passwordSchema } from "@/lib/auth/validation.ts"; import { log } from "@/lib/utils/logger.ts"; @@ -33,11 +34,18 @@ export async function handleSetupStatus(_request: Request): Promise { export async function handleSetupComplete(request: Request): Promise { try { const body = await request.json() as Record; - const { openrouterApiKey, openrouterModel, braveSearchApiKey } = body; + const { provider, providerApiKey, braveSearchApiKey } = body; - if (!openrouterApiKey) { + if (!provider || !providerApiKey) { return Response.json( - { error: "OpenRouter API key is required" }, + { error: "A model provider and its API key are required" }, + { status: 400, headers: corsHeaders }, + ); + } + const providerDef = getProvider(provider); + if (!providerDef) { + return Response.json( + { error: `Unknown provider: ${provider}` }, { status: 400, headers: corsHeaders }, ); } @@ -70,8 +78,7 @@ export async function handleSetupComplete(request: Request): Promise { throw e; } - setSetting("OPENROUTER_API_KEY", openrouterApiKey); - if (openrouterModel) setSetting("OPENROUTER_MODEL", openrouterModel); + setSetting(providerDef.apiKeySettingKey, providerApiKey); if (braveSearchApiKey) setSetting("BRAVE_SEARCH_API_KEY", braveSearchApiKey); if (!IS_PROD) { diff --git a/web/src/api/providers.ts b/web/src/api/providers.ts new file mode 100644 index 0000000..8d795f2 --- /dev/null +++ b/web/src/api/providers.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; + +export type ProviderCapability = "chat" | "embedding" | "image" | "vision"; + +export interface ModelProvider { + id: string; + displayName: string; + envVar: string; + capabilities: Record; + defaults: Partial>; +} + +// Plain fetch (no auth headers): the endpoint is public because the pre-auth +// setup wizard renders the same provider list. +export function useModelProviders() { + return useQuery({ + queryKey: ["providers"], + queryFn: async () => { + const res = await fetch("/api/providers"); + if (!res.ok) throw new Error("Failed to load providers"); + const body = (await res.json()) as { providers: ModelProvider[] }; + return body.providers; + }, + staleTime: 5 * 60_000, + }); +} diff --git a/web/src/api/setup.ts b/web/src/api/setup.ts index 5013430..16e077a 100644 --- a/web/src/api/setup.ts +++ b/web/src/api/setup.ts @@ -12,8 +12,8 @@ export async function getSetupStatus(): Promise { export interface SetupCompleteRequest { username: string; password: string; - openrouterApiKey: string; - openrouterModel?: string; + provider: string; + providerApiKey: string; braveSearchApiKey?: string; } diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index c3fe709..0ecc442 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -92,7 +92,6 @@ import { CommandInput, CommandItem, CommandList, - CommandSeparator, } from "@/components/ui/command"; import { useAdminModels, @@ -100,6 +99,7 @@ import { useUpdateModel, useDeleteModel, } from "@/api/models"; +import { useModelProviders, type ProviderCapability } from "@/api/providers"; import { useUsageSummary, useUsageByModel, @@ -245,7 +245,8 @@ export function AdminPage() { -
+
+
@@ -330,41 +331,130 @@ function ApiKeyField({ ); } -const IMAGE_MODEL_DEFAULT = "google/gemini-2.5-flash-image"; +const AUTO_PROVIDER = "__auto__"; + +const CAPABILITY_ROUTES: Array<{ + capability: ProviderCapability; + title: string; + description: string; + providerKey: string; + modelKey: string; +}> = [ + { + capability: "embedding", + title: "Embeddings", + description: "Powers search indexing and the embed tool. Models must produce 1536-dimension vectors.", + providerKey: "EMBEDDING_PROVIDER", + modelKey: "EMBEDDING_MODEL", + }, + { + capability: "image", + title: "Image generation", + description: "Used by the image generate tool.", + providerKey: "IMAGE_PROVIDER", + modelKey: "IMAGE_MODEL", + }, + { + capability: "vision", + title: "Image captioning", + description: "Describes incoming photos when the chat model has no vision support.", + providerKey: "VISION_PROVIDER", + modelKey: "VISION_MODEL", + }, +]; -function ImageModelPicker() { - const { data: settings } = useAdminSettings(); +/** Curated OpenRouter image-model browser (from image_models.json). */ +function ImageModelBrowse({ onSelect }: { onSelect: (id: string) => void }) { const { data: options } = useImageModels(); - const updateSettings = useUpdateSettings(); const [open, setOpen] = useState(false); - const [customOpen, setCustomOpen] = useState(false); - const [customValue, setCustomValue] = useState(""); - const active = settings?.IMAGE_MODEL ?? IMAGE_MODEL_DEFAULT; - const activeEntry = options?.find((m) => m.id === active); - const isCustom = !!options && !options.find((m) => m.id === active); - - // Group models by provider (slug before /) + // Group models by maker (slug before /) const grouped = useMemo(() => { const map = new Map(); for (const m of options ?? []) { - const provider = m.id.split("/")[0] ?? "other"; - const list = map.get(provider) ?? []; + const maker = m.id.split("/")[0] ?? "other"; + const list = map.get(maker) ?? []; list.push(m); - map.set(provider, list); + map.set(maker, list); } return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)); }, [options]); - function save(id: string) { + return ( + + + + + + + + + No model found. + {grouped.map(([maker, items]) => ( + + {items.map((m) => ( + { onSelect(m.id); setOpen(false); }} + className="flex-col items-start gap-0" + > +
+ {m.name} + {m.outputModalities.length > 1 && ( + + +text + + )} +
+ {m.id} +
+ ))} +
+ ))} +
+
+
+
+ ); +} + +function CapabilityRouteCard({ + capability, + title, + description, + providerKey, + modelKey, +}: { + capability: ProviderCapability; + title: string; + description: string; + providerKey: string; + modelKey: string; +}) { + const { data: settings } = useAdminSettings(); + const { data: providers } = useModelProviders(); + const updateSettings = useUpdateSettings(); + const [providerDraft, setProviderDraft] = useState(null); + const [modelDraft, setModelDraft] = useState(null); + + const capable = (providers ?? []).filter((p) => p.capabilities[capability]); + const provider = providerDraft ?? settings?.[providerKey] ?? AUTO_PROVIDER; + const model = modelDraft ?? settings?.[modelKey] ?? ""; + const selected = capable.find((p) => p.id === provider); + const dirty = providerDraft !== null || modelDraft !== null; + + function save() { updateSettings.mutate( - { IMAGE_MODEL: id }, + { + [providerKey]: provider === AUTO_PROVIDER ? "" : provider, + [modelKey]: model.trim(), + }, { onSuccess: () => { - toast.success("Image model updated"); - setOpen(false); - setCustomOpen(false); - setCustomValue(""); + setProviderDraft(null); + setModelDraft(null); + toast.success(`${title} route updated`); }, onError: (err) => toast.error(err.message), }, @@ -372,107 +462,58 @@ function ImageModelPicker() { } return ( -
-
-
-

- {activeEntry?.name ?? active} -

- {isCustom && ( - Custom - )} -
-

{active}

-

- Used by the image generate tool. - {activeEntry?.description ? ` ${activeEntry.description}` : ""} +

+
+

{title}

+

{description}

+
+
+ + setModelDraft(e.target.value)} + placeholder={selected?.defaults[capability] ?? "model id (blank = provider default)"} + className="h-8 flex-1 text-xs font-mono" + /> + {capability === "image" && (provider === "openrouter" || provider === AUTO_PROVIDER) && ( + setModelDraft(id)} /> + )} + +
+
+ ); +} + +function CapabilityRoutingSection() { + return ( +
+
+

Capability routing

+

+ Which provider serves each built-in capability. Auto picks the first + configured provider that supports it.

- - - - - - - - - No model found. - {grouped.map(([provider, items]) => ( - - {items.map((m) => ( - save(m.id)} - className="flex-col items-start gap-0" - > -
- {m.name} - {m.outputModalities.length > 1 && ( - - +text - - )} - {m.id === active && } -
- {m.id} -
- ))} -
- ))} - - - { - setCustomValue(isCustom ? active : ""); - setCustomOpen(true); - setOpen(false); - }} - className="text-xs" - > - - Use custom model ID… - - -
-
-
-
- - - - - Custom image model - - Enter any OpenRouter model slug. Image-only models work via{" "} - modalities: ["image"]; we auto-select the right modalities. - - -
{ - e.preventDefault(); - if (customValue.trim()) save(customValue.trim()); - }} - className="space-y-3" - > - setCustomValue(e.target.value)} - className="font-mono text-xs" - autoFocus - /> - - - -
-
-
+ {CAPABILITY_ROUTES.map((route) => ( + + ))}
); } @@ -487,12 +528,6 @@ function InstanceSettingsSection() {

Instance Settings

- = [ - { provider: "anthropic", envVar: "ANTHROPIC_API_KEY" }, - { provider: "openai", envVar: "OPENAI_API_KEY" }, - { provider: "deepseek", envVar: "DEEPSEEK_API_KEY" }, - { provider: "google", envVar: "GOOGLE_API_KEY" }, - { provider: "groq", envVar: "GROQ_API_KEY" }, - { provider: "cerebras", envVar: "CEREBRAS_API_KEY" }, - { provider: "mistral", envVar: "MISTRAL_API_KEY" }, - { provider: "xai", envVar: "XAI_API_KEY" }, - { provider: "zai", envVar: "ZAI_API_KEY" }, - { provider: "fireworks", envVar: "FIREWORKS_API_KEY" }, - { provider: "minimax", envVar: "MINIMAX_API_KEY" }, - { provider: "huggingface", envVar: "HUGGINGFACE_API_KEY" }, -]; - -function DirectRouteKeysCard() { +function ProviderKeysSection() { const { data: settings } = useAdminSettings(); + const { data: providers } = useModelProviders(); return ( -
- - Direct-route provider keys - -
- {DIRECT_ROUTE_PROVIDERS.map((b) => ( +
+
+ +

Model Providers

+
+
+ {(providers ?? []).map((p) => ( ))}
-
+
); } @@ -629,18 +651,13 @@ type AdminModel = ModelConfig & { enabled: boolean; sortOrder: number }; /** Inline chip showing where a model's chat turns actually route. */ function RouteChip({ piProvider, piModelId }: { piProvider?: string; piModelId?: string | null }) { - const route = piProvider ?? "openrouter"; - const isDirect = route !== "openrouter"; + if (!piProvider) return null; return ( - {isDirect ? "→ " : ""}{route}{isDirect && piModelId ? `:${piModelId}` : ""} + {piProvider}{piModelId ? `:${piModelId}` : ""} ); } @@ -671,8 +688,6 @@ function ModelManagementSection() { - -
{isLoading ? (
@@ -791,7 +806,7 @@ function ModelManagementSection() { )}
- + {editModel && ( void; providers: string[] }) { const createModel = useCreateModel(); + const { data: modelProviders } = useModelProviders(); const [id, setId] = useState(""); const [name, setName] = useState(""); const [provider, setProvider] = useState(providers[0] ?? ""); + const [piProvider, setPiProvider] = useState(""); + const [piModelId, setPiModelId] = useState(""); const [multimodal, setMultimodal] = useState(false); - const canSubmit = id.trim() && name.trim() && provider.trim(); + const routeOptions = (modelProviders ?? []).filter((p) => p.capabilities.chat); + const canSubmit = id.trim() && name.trim() && provider.trim() && piProvider.trim(); function reset() { setId(""); setName(""); setProvider(providers[0] ?? ""); + setPiProvider(""); setPiModelId(""); setMultimodal(false); } @@ -825,6 +845,8 @@ function AddModelDialog({ open, onOpenChange, providers }: { open: boolean; onOp id: id.trim(), name: name.trim(), provider: provider.trim(), + piProvider: piProvider.trim(), + piModelId: piModelId.trim() === "" ? null : piModelId.trim(), multimodal, }, { @@ -845,7 +867,7 @@ function AddModelDialog({ open, onOpenChange, providers }: { open: boolean; onOp Add model - Add a new model available through OpenRouter. + Register a model and choose which provider it routes through.
{ e.preventDefault(); if (canSubmit) handleCreate(); }} className="space-y-3">
@@ -858,10 +880,10 @@ function AddModelDialog({ open, onOpenChange, providers }: { open: boolean; onOp setName(e.target.value)} className="h-8 text-xs" />
- +
+
+

Route through

+
+ + setPiModelId(e.target.value)} + placeholder="upstream model id (optional)" + className="h-8 text-xs font-mono" + /> +
+

Multimodal (Vision)

@@ -905,24 +948,13 @@ function EditModelDialog({ model, providers, open, onOpenChange }: { model: Admi const [thinkingLevel, setThinkingLevel] = useState( model.thinkingLevel ?? THINKING_DEFAULT, ); - const [piProvider, setPiProvider] = useState(model.piProvider ?? "openrouter"); + const [piProvider, setPiProvider] = useState(model.piProvider ?? ""); const [piModelId, setPiModelId] = useState(model.piModelId ?? ""); + const { data: modelProviders } = useModelProviders(); - const piOptions = [ - { value: "openrouter", label: "openrouter" }, - { value: "anthropic", label: "anthropic" }, - { value: "openai", label: "openai" }, - { value: "deepseek", label: "deepseek" }, - { value: "google", label: "google" }, - { value: "groq", label: "groq" }, - { value: "cerebras", label: "cerebras" }, - { value: "mistral", label: "mistral" }, - { value: "xai", label: "xai" }, - { value: "zai", label: "zai" }, - { value: "fireworks", label: "fireworks" }, - { value: "minimax", label: "minimax" }, - { value: "huggingface", label: "huggingface" }, - ]; + const piOptions = (modelProviders ?? []) + .filter((p) => p.capabilities.chat) + .map((p) => ({ value: p.id, label: p.displayName })); // Keep existing rows editable even if their pi_provider isn't in the list. if (piProvider && !piOptions.some((o) => o.value === piProvider)) { piOptions.unshift({ value: piProvider, label: piProvider }); @@ -936,7 +968,7 @@ function EditModelDialog({ model, providers, open, onOpenChange }: { model: Admi provider: provider.trim(), multimodal, thinkingLevel: thinkingLevel === THINKING_DEFAULT ? null : thinkingLevel, - piProvider: piProvider.trim() || "openrouter", + piProvider: piProvider.trim(), piModelId: piModelId.trim() === "" ? null : piModelId.trim(), }, { @@ -1013,14 +1045,14 @@ function EditModelDialog({ model, providers, open, onOpenChange }: { model: Admi setPiModelId(e.target.value)} - placeholder={piProvider === "openrouter" ? model.id : "upstream model id"} + placeholder="upstream model id (blank = model id)" className="h-8 text-xs font-mono" />
- diff --git a/web/src/pages/HelpPage.tsx b/web/src/pages/HelpPage.tsx index 3473a11..e606ed5 100644 --- a/web/src/pages/HelpPage.tsx +++ b/web/src/pages/HelpPage.tsx @@ -147,7 +147,7 @@ const CLAUDE_CODE_PROMPT = `Clone the zero-agent repo from https://github.com/0- 1. git clone https://github.com/0-AI-UG/zero-agent.git && cd zero-agent 2. Run bun install to install all dependencies (this is a monorepo with api/ and web/ workspaces) -3. Copy .env.example to .env - the only required key is OPENROUTER_API_KEY from https://openrouter.ai +3. Copy .env.example to .env - add an API key for at least one model provider (e.g. OPENROUTER_API_KEY or ANTHROPIC_API_KEY) 4. Start the dev server with bun run dev (this runs the API on :3001 and the web app on :3000 concurrently with HMR) 5. Open http://localhost:3000/setup in the browser to create an admin account and configure the LLM diff --git a/web/src/pages/SetupPage.tsx b/web/src/pages/SetupPage.tsx index 9956f03..8991cb9 100644 --- a/web/src/pages/SetupPage.tsx +++ b/web/src/pages/SetupPage.tsx @@ -8,6 +8,14 @@ import { startRegistration } from "@simplewebauthn/browser"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useModelProviders } from "@/api/providers"; import { Card, CardContent, @@ -37,8 +45,9 @@ export function SetupPage() { const [confirmPassword, setConfirmPassword] = useState(""); // Step 2 - const [openrouterApiKey, setOpenrouterApiKey] = useState(""); - const [openrouterModel, setOpenrouterModel] = useState(""); + const { data: providers } = useModelProviders(); + const [provider, setProvider] = useState(""); + const [providerApiKey, setProviderApiKey] = useState(""); const [braveSearchApiKey, setBraveSearchApiKey] = useState(""); // Step 3 (passkey) @@ -76,8 +85,8 @@ export function SetupPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - if (!openrouterApiKey) { - setError("OpenRouter API key is required."); + if (!provider || !providerApiKey) { + setError("Pick a model provider and enter its API key."); return; } setLoading(true); @@ -85,8 +94,8 @@ export function SetupPage() { const result = await completeSetup({ username, password, - openrouterApiKey, - openrouterModel: openrouterModel || undefined, + provider, + providerApiKey, braveSearchApiKey: braveSearchApiKey || undefined, }); if ("token" in result && !("requires2FASetup" in result)) { @@ -203,24 +212,29 @@ export function SetupPage() { {error &&
{error}
}
- + + +

+ You can add keys for more providers later in Admin settings. +

+
+
+ setOpenrouterApiKey(e.target.value)} + value={providerApiKey} + onChange={(e) => setProviderApiKey(e.target.value)} required - placeholder="sk-or-..." - /> -
-
- - setOpenrouterModel(e.target.value)} - placeholder="anthropic/claude-opus-4-7" />