From b67139c3fe4a16fbfea501da669c00a3e2813714 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 9 Jun 2026 13:50:26 -0700 Subject: [PATCH 1/2] add provider choice (or concepts of it at least) --- CLAUDE.md | 10 +- README.md | 23 +- backend/CLAUDE.md | 6 +- backend/package-lock.json | 52 +++ backend/package.json | 4 + backend/src/config/llm.ts | 313 ++++++++++++++++++ backend/src/config/models.ts | 186 ++++++++++- backend/src/env.ts | 3 +- backend/src/index.ts | 92 ++++- backend/src/local-credential-types.ts | 8 +- backend/src/local-credentials.ts | 308 +++++++++++++++-- backend/src/mastra/agents/investigate.ts | 10 +- backend/src/mastra/agents/populate.ts | 12 +- backend/src/mastra/agents/refresh.ts | 10 +- backend/src/mastra/tools/investigate-tool.ts | 5 +- backend/src/mastra/workflows/populate.ts | 16 +- backend/src/mastra/workflows/update.ts | 6 +- backend/src/pipeline/schema-inference.ts | 14 +- frontend/Dockerfile.dev | 5 +- .../app/dashboard/settings/models/page.tsx | 135 ++++++-- frontend/app/setup/page.tsx | 142 ++++---- .../settings/LocalCredentialsPanel.tsx | 158 +++++---- .../components/settings/ModelSideSheet.tsx | 72 +++- .../components/settings/llm-providers.tsx | 156 +++++++++ frontend/convex/localCredentials.ts | 39 ++- frontend/convex/modelConfig.ts | 122 ++++--- frontend/convex/schema.ts | 35 +- frontend/lib/backend.ts | 50 ++- frontend/public/logos/providers/anthropic.svg | 1 + frontend/public/logos/providers/openai.svg | 10 + .../logos/providers/openrouter-wordmark.svg | 10 + .../public/logos/providers/openrouter.svg | 6 + makefiles/Makefile | 6 +- 33 files changed, 1684 insertions(+), 341 deletions(-) create mode 100644 backend/src/config/llm.ts create mode 100644 frontend/components/settings/llm-providers.tsx create mode 100644 frontend/public/logos/providers/anthropic.svg create mode 100644 frontend/public/logos/providers/openai.svg create mode 100644 frontend/public/logos/providers/openrouter-wordmark.svg create mode 100644 frontend/public/logos/providers/openrouter.svg diff --git a/CLAUDE.md b/CLAUDE.md index b9b15bd..43bcbd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,9 @@ Frontend on :3500, backend on :3501, Mastra Studio on :4111, Convex dashboard on ## Setup -1. Copy `.env.example` to `.env` and fill in your keys: +1. Local mode creates `.env` automatically and collects TinyFish + LLM provider credentials in the setup UI. Production still uses env keys: - `TINYFISH_API_KEY` — from https://agent.tinyfish.ai/api-keys?utm_source=github&utm_medium=organic&utm_campaign=bigset-developer-2026q2 - - `OPENROUTER_API_KEY` — from https://openrouter.ai/settings/keys + - `OPENROUTER_API_KEY` — production default LLM provider key - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` — from Clerk API Keys - `CLERK_SECRET_KEY` — from Clerk API Keys - `CLERK_JWT_ISSUER_DOMAIN` — your Frontend API URL (e.g. `https://your-app.clerk.accounts.dev`) @@ -26,9 +26,9 @@ Frontend uses Convex React hooks (`useQuery`, `useMutation`) with `ConvexProvide Backend is Fastify + Mastra. Fastify serves the HTTP API (Clerk JWT auth on protected routes via `backend/src/clerk-auth.ts`). Mastra (`backend/src/mastra/`) is the workflow orchestration layer — it wraps pipelines into inspectable workflows with a Studio UI. Both run as separate Docker services sharing the same source code. -The schema inference pipeline: frontend calls `POST /infer-schema` → Fastify verifies the Clerk JWT → calls `inferSchema()` in `backend/src/pipeline/schema-inference.ts` → Claude Sonnet 4.6 via OpenRouter → returns a Zod-validated `DatasetSchema` → frontend maps it to editable columns in the wizard. +The schema inference pipeline: frontend calls `POST /infer-schema` → Fastify verifies the Clerk JWT → calls `inferSchema()` in `backend/src/pipeline/schema-inference.ts` → selected LLM provider/model → returns a Zod-validated `DatasetSchema` → frontend maps it to editable columns in the wizard. -The populate pipeline: frontend calls `POST /populate` with `{ datasetId, datasetName, description, columns }` → Fastify verifies the Clerk JWT → triggers `populateWorkflow` which: (1) clears existing rows, (2) builds a prompt from the schema, (3) runs the populate agent (Claude Sonnet 4.6) which searches the web via TinyFish APIs, then inserts rows into Convex one by one. Rows appear in realtime on the frontend via Convex reactive queries. +The populate pipeline: frontend calls `POST /populate` with `{ datasetId, datasetName, description, columns }` → Fastify verifies the Clerk JWT → triggers `populateWorkflow` which: (1) clears existing rows, (2) builds a prompt from the schema, (3) runs the populate agent using the selected LLM provider/model. The agent searches the web via TinyFish APIs, then inserts rows into Convex one by one. Rows appear in realtime on the frontend via Convex reactive queries. Convex functions use `ctx.auth.getUserIdentity()` to get the authenticated user. The `ownerId` field on datasets stores `identity.subject` (Clerk user ID). Do not pass `ownerId` from the client. @@ -36,7 +36,7 @@ Convex functions use `ctx.auth.getUserIdentity()` to get the authenticated user. Root `.env` is the only local env file. Docker Compose, package scripts, and Convex CLI helper targets all read it. Key variables: - `TINYFISH_API_KEY` — used by the populate agent for web search and fetch (get one at https://agent.tinyfish.ai/api-keys?utm_source=github&utm_medium=organic&utm_campaign=bigset-developer-2026q2) -- `OPENROUTER_API_KEY` — used by backend and Mastra for AI model calls +- `OPENROUTER_API_KEY` — production default LLM provider key; local mode can use OpenRouter, OpenAI, Anthropic, or custom OpenAI-compatible via setup UI - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY` — shared by frontend and backend - `CONVEX_SELF_HOSTED_ADMIN_KEY` — used by backend for system-level Convex writes diff --git a/README.md b/README.md index 7995e51..365a579 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ On first launch, BigSet sends you to setup. You'll connect two services: | Service | What it's for | Get your key | |---------|--------------|-------------| | **TinyFish** | Web search + page fetching | [tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys?utm_source=github&utm_medium=organic&utm_campaign=bigset-developer-2026q2) | -| **OpenRouter** | LLM calls (schema inference + agents) | [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) | +| **LLM provider** | Schema inference + agents | OpenRouter, OpenAI, Anthropic, or custom OpenAI-compatible | Local API keys are stored in your OS keychain. @@ -150,23 +150,22 @@ Once everything is ready, you'll see: | **Mastra Studio** (workflow inspector) | [localhost:4111](http://localhost:4111) | Open [localhost:3500](http://localhost:3500). The setup screen will ask for -TinyFish and OpenRouter credentials and save them to your OS keychain for this -workspace. +TinyFish credentials plus an LLM provider (OpenRouter, OpenAI, Anthropic, or a +custom OpenAI-compatible endpoint) and save local keys to your OS keychain for +this workspace. -### Step 3: Connect TinyFish and OpenRouter +### Step 3: Connect TinyFish and an LLM provider -TinyFish powers web search and page fetching. OpenRouter routes LLM calls to -the models BigSet uses for schema inference and agents. +TinyFish powers web search and page fetching. Your LLM provider powers schema +inference and dataset-building agents. 1. Create a TinyFish key at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys?utm_source=github&utm_medium=organic&utm_campaign=bigset-developer-2026q2) -2. Create an OpenRouter key at [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) -3. Paste both into BigSet's setup screen - -OpenRouter is pay-as-you-go; $5-10 is plenty to start. +2. Choose an LLM provider: OpenRouter, OpenAI, Anthropic, or custom OpenAI-compatible +3. Paste the provider key and model name into BigSet's setup screen > **Note:** root `.env` is the only local env file. If you edit Convex functions in `frontend/convex/`, run `make convex-push` to deploy the changes. -> **Free tier:** cloud signed-in accounts get **2,500 row operations per calendar month** (resets on the 1st, UTC). Local mode bypasses the cloud quota and uses your TinyFish/OpenRouter accounts directly. +> **Free tier:** cloud signed-in accounts get **2,500 row operations per calendar month** (resets on the 1st, UTC). Local mode bypasses the cloud quota and uses your TinyFish + LLM provider accounts directly. ### Step 4 (optional): Load curated datasets @@ -247,7 +246,7 @@ If you want a completely fresh start: `make clean` then `make dev`. | Auth | Local auth (dev); [Clerk](https://clerk.com) (cloud) | | Database | [Convex](https://convex.dev) (self-hosted) | | Data Collection | [TinyFish](https://www.tinyfish.ai?utm_source=github&utm_medium=organic&utm_campaign=bigset-developer-2026q2) APIs (Search, Fetch, Browser) | -| AI orchestration | [Mastra](https://mastra.ai) workflows + [Vercel AI SDK](https://sdk.vercel.ai) + [OpenRouter](https://openrouter.ai) → Claude Sonnet (schema inference + populate agent) | +| AI orchestration | [Mastra](https://mastra.ai) workflows + [Vercel AI SDK](https://sdk.vercel.ai) + local LLM provider (OpenRouter, OpenAI, Anthropic, or custom OpenAI-compatible) | | Table view | [TanStack Table](https://tanstack.com/table) + [react-window](https://github.com/bvaughn/react-window) virtualization | | Exports | CSV (built-in) + XLSX ([SheetJS](https://sheetjs.com), dynamic-imported) | | Analytics | [PostHog](https://posthog.com) — events, session replay, error tracking (optional) | diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 53218a0..741b444 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -15,7 +15,7 @@ To add a new protected route, register it inside the scoped plugin in `src/index ## Schema Inference Pipeline -`src/pipeline/schema-inference.ts` — takes a natural language prompt and returns a structured `DatasetSchema` (Zod-validated, defined in `src/pipeline/types.ts`). Uses Claude Sonnet 4.6 via OpenRouter (`@openrouter/ai-sdk-provider` + Vercel AI SDK). Auto-retries once on validation failure by feeding the error back to the model. +`src/pipeline/schema-inference.ts` — takes a natural language prompt and returns a structured `DatasetSchema` (Zod-validated, defined in `src/pipeline/types.ts`). Uses the configured LLM provider/model via `src/config/llm.ts` + Vercel AI SDK. Auto-retries once on validation failure by feeding the error back to the model. The pipeline is a pure function (`inferSchema(prompt) → DatasetSchema`). It is called by both Fastify (for the HTTP API) and Mastra (for workflow orchestration). @@ -26,7 +26,7 @@ The pipeline is a pure function (`inferSchema(prompt) → DatasetSchema`). It is - `src/mastra/index.ts` — registers workflows with the `Mastra` instance (the populate agent is built per-run, not registered as a singleton) - `src/mastra/workflows/infer-schema.ts` — `inferSchemaWorkflow`, a single-step workflow wrapping `inferSchema()` - `src/mastra/workflows/populate.ts` — `populateWorkflow`, 3-step workflow: clear rows → build prompt → run populate agent -- `src/mastra/agents/populate.ts` — `buildPopulateAgent(authorizedDatasetId, authContext, columns)`, builds the orchestrator agent (Claude Sonnet 4.6) with 3 tools: `search_web`, `fetch_page`, `investigate_row`. No write access — all inserts go through investigate subagents. +- `src/mastra/agents/populate.ts` — `buildPopulateAgent(authorizedDatasetId, authContext, columns)`, builds the orchestrator agent with the configured LLM provider/model and 3 tools: `search_web`, `fetch_page`, `investigate_row`. No write access — all inserts go through investigate subagents. - `src/mastra/agents/investigate.ts` — `buildInvestigateAgent(authorizedDatasetId, authContext, columns)`, builds a per-entity subagent with `insert_row`, `list_rows`, `search_web`, `fetch_page`. Researches one entity, inserts at most one row, returns structured feedback (`INSERTED/SUMMARY/CLUES/REASON`). - `src/mastra/tools/investigate-tool.ts` — `buildInvestigateTool(authorizedDatasetId, authContext, columns)` creates the `investigate_row` tool. The orchestrator calls it to hand off a lead; it spawns a fresh investigate agent, runs it (maxSteps: 25), parses the structured output, and returns it to the orchestrator. Errors are caught and returned as structured failures so the orchestrator can self-correct. - `src/mastra/tools/dataset-tools.ts` — `buildPopulateTools(authorizedDatasetId, authContext)` factory returning 5 Convex-backed tools: `insert_row`, `list_rows`, `get_row`, `update_row`, `delete_row`. The dataset id is captured by closure so the LLM cannot redirect writes to other datasets; `authContext` (Clerk userId + workflow run id) is captured for caller-attribution in security logs and the `CAPABILITY_VIOLATION` PostHog event. See the security note at the top of the file. @@ -48,7 +48,7 @@ Required env vars (see `.env.example`): - `CONVEX_URL` — Convex instance URL - `CONVEX_SELF_HOSTED_ADMIN_KEY` — for system-level Convex writes (internal mutations) - `CLERK_SECRET_KEY`, `CLERK_PUBLISHABLE_KEY` — for JWT verification -- `OPENROUTER_API_KEY` — for AI model calls +- `OPENROUTER_API_KEY` — production default LLM provider key; local mode can use OpenRouter, OpenAI, Anthropic, or custom OpenAI-compatible via setup UI - `TINYFISH_API_KEY` — for web search and fetch (populate agent). Get one at https://agent.tinyfish.ai/api-keys?utm_source=github&utm_medium=organic&utm_campaign=bigset-developer-2026q2 In Docker, these are interpolated from the root `.env` file via `docker-compose.dev.yml`. diff --git a/backend/package-lock.json b/backend/package-lock.json index e231b48..c9a41ed 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,10 @@ "name": "bigset-backend", "version": "0.1.0", "dependencies": { + "@ai-sdk/anthropic": "^3.0.81", + "@ai-sdk/openai": "^3.0.68", + "@ai-sdk/openai-compatible": "^2.0.48", + "@ai-sdk/provider": "^3.0.10", "@clerk/backend": "^3.4.11", "@fastify/cors": "^11.0.0", "@mastra/core": "^1.36.0", @@ -57,6 +61,22 @@ } } }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.81", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.81.tgz", + "integrity": "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/gateway": { "version": "3.0.116", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.116.tgz", @@ -74,6 +94,38 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.68", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.68.tgz", + "integrity": "sha512-FCs/DPr4M95UyZ/ABHJmTmCEYRCka/4J0Bna0nsd78QCdGIS0X/zhn+fVzB7mZJo7464uOWYUjROx9PGNGOb0w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.48.tgz", + "integrity": "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/provider": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", diff --git a/backend/package.json b/backend/package.json index 11df78b..1bd11aa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,10 @@ "mastra:dev": "node ../scripts/with-root-env.mjs mastra dev" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.81", + "@ai-sdk/openai": "^3.0.68", + "@ai-sdk/openai-compatible": "^2.0.48", + "@ai-sdk/provider": "^3.0.10", "@clerk/backend": "^3.4.11", "@fastify/cors": "^11.0.0", "@mastra/core": "^1.36.0", diff --git a/backend/src/config/llm.ts b/backend/src/config/llm.ts new file mode 100644 index 0000000..0c50523 --- /dev/null +++ b/backend/src/config/llm.ts @@ -0,0 +1,313 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createOpenAI } from "@ai-sdk/openai"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; + +import { env } from "../env.js"; +import { FETCH_TIMEOUT_MS } from "../fetch-timeout.js"; + +export const LLM_PROVIDER_TYPES = [ + "openrouter", + "openai", + "anthropic", + "custom", +] as const; + +export type LlmProviderType = (typeof LLM_PROVIDER_TYPES)[number]; + +export type ModelRoleKey = + | "schemaInference" + | "populateOrchestrator" + | "investigateSubagent"; + +export interface LlmProviderConfig { + provider: LlmProviderType; + apiKey: string; + defaultModel: string; + baseUrl?: string; + source: "local" | "env"; +} + +export interface LlmProviderInput { + provider: LlmProviderType; + apiKey: string; + defaultModel?: string; + baseUrl?: string; +} + +export const LLM_PROVIDER_LABELS: Record = { + openrouter: "OpenRouter", + openai: "OpenAI", + anthropic: "Anthropic", + custom: "Custom OpenAI-compatible", +}; + +export const LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE: Record< + LlmProviderType, + Record +> = { + openrouter: { + schemaInference: env.SCHEMA_INFERENCE_MODEL, + populateOrchestrator: env.POPULATE_ORCHESTRATOR_MODEL, + investigateSubagent: env.INVESTIGATE_SUBAGENT_MODEL, + }, + openai: { + schemaInference: "gpt-5.4-mini", + populateOrchestrator: "gpt-5.4-mini", + investigateSubagent: "gpt-5.4-mini", + }, + anthropic: { + schemaInference: "claude-sonnet-4-6", + populateOrchestrator: "claude-haiku-4-5-20251001", + investigateSubagent: "claude-haiku-4-5-20251001", + }, + custom: { + schemaInference: "", + populateOrchestrator: "", + investigateSubagent: "", + }, +}; + +export const LLM_PROVIDER_DEFAULT_MODELS: Record = { + openrouter: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.openrouter.schemaInference, + openai: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.openai.schemaInference, + anthropic: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.anthropic.schemaInference, + custom: "", +}; + +export function isLlmProviderType(value: unknown): value is LlmProviderType { + return ( + typeof value === "string" && + (LLM_PROVIDER_TYPES as readonly string[]).includes(value) + ); +} + +export function llmProviderLabel(provider: LlmProviderType): string { + return LLM_PROVIDER_LABELS[provider]; +} + +export function defaultModelForLlmProvider(provider: LlmProviderType): string { + return LLM_PROVIDER_DEFAULT_MODELS[provider]; +} + +export function defaultModelForLlmProviderRole( + provider: LlmProviderType, + role: ModelRoleKey, +): string { + return LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE[provider][role]; +} + +export function defaultBaseUrlForLlmProvider( + provider: LlmProviderType, +): string | undefined { + if (provider === "openrouter") { + return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"; + } + return undefined; +} + +function isLoopbackHost(hostname: string): boolean { + return ["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"].includes( + hostname, + ); +} + +function normalizeLocalLoopbackForBackend(parsed: URL): void { + if (env.IS_LOCAL_MODE && isLoopbackHost(parsed.hostname)) { + // In local dev the backend runs inside Docker. From the container, + // localhost points at the container, not the host machine where LM Studio + // and other local OpenAI-compatible servers usually listen. + parsed.hostname = "host.docker.internal"; + } +} + +export function normalizeBaseUrl(baseUrl?: string): string | undefined { + const trimmed = baseUrl?.trim(); + if (!trimmed) return undefined; + const parsed = new URL(trimmed); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("Base URL must start with http:// or https://"); + } + normalizeLocalLoopbackForBackend(parsed); + return parsed.toString().replace(/\/+$/, ""); +} + +export function normalizeCustomBaseUrl(baseUrl?: string): string | undefined { + const normalized = normalizeBaseUrl(baseUrl); + if (!normalized) return undefined; + + const parsed = new URL(normalized); + if (parsed.pathname === "" || parsed.pathname === "/") { + parsed.pathname = "/v1"; + } + return parsed.toString().replace(/\/+$/, ""); +} + +export function normalizeLlmProviderInput( + input: LlmProviderInput, + source: "local" | "env", +): LlmProviderConfig { + const provider = input.provider; + const apiKey = input.apiKey.trim(); + if (!apiKey && provider !== "custom") { + throw new Error(`${llmProviderLabel(provider)} API key is required`); + } + + const baseUrl = + provider === "custom" + ? normalizeCustomBaseUrl(input.baseUrl) + : normalizeBaseUrl(input.baseUrl) ?? defaultBaseUrlForLlmProvider(provider); + + if (provider === "custom" && !baseUrl) { + throw new Error("Custom providers require a base URL"); + } + + const defaultModel = + input.defaultModel?.trim() || defaultModelForLlmProvider(provider); + + return { + provider, + apiKey, + defaultModel, + baseUrl, + source, + }; +} + +export function createLanguageModel( + config: LlmProviderConfig, + modelId?: string, +): LanguageModelV3 { + const resolvedModelId = (modelId?.trim() || config.defaultModel).trim(); + if (!resolvedModelId) { + throw new Error("Model name is required"); + } + + switch (config.provider) { + case "openrouter": { + const provider = createOpenRouter({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "openai": { + const provider = createOpenAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "anthropic": { + const provider = createAnthropic({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "custom": { + if (!config.baseUrl) { + throw new Error("Custom providers require a base URL"); + } + const provider = createOpenAICompatible({ + name: "custom", + apiKey: config.apiKey || undefined, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + } +} + +function providerVerificationRequest(config: LlmProviderConfig): { + url: string; + headers: Record; +} { + switch (config.provider) { + case "openrouter": { + const baseUrl = (config.baseUrl || "https://openrouter.ai/api/v1").replace( + /\/+$/, + "", + ); + return { + url: `${baseUrl}/key`, + headers: { Authorization: `Bearer ${config.apiKey}` }, + }; + } + case "openai": { + const baseUrl = (config.baseUrl || "https://api.openai.com/v1").replace( + /\/+$/, + "", + ); + return { + url: `${baseUrl}/models`, + headers: { Authorization: `Bearer ${config.apiKey}` }, + }; + } + case "anthropic": { + const baseUrl = (config.baseUrl || "https://api.anthropic.com/v1").replace( + /\/+$/, + "", + ); + return { + url: `${baseUrl}/models?limit=1`, + headers: { + "x-api-key": config.apiKey, + "anthropic-version": "2023-06-01", + }, + }; + } + case "custom": { + if (!config.baseUrl) { + throw new Error("Custom providers require a base URL"); + } + const baseUrl = config.baseUrl.replace(/\/+$/, ""); + return { + url: `${baseUrl}/models`, + headers: config.apiKey + ? { Authorization: `Bearer ${config.apiKey}` } + : {}, + }; + } + } +} + +export async function verifyLlmProviderConfig( + config: LlmProviderConfig, +): Promise { + const { url, headers } = providerVerificationRequest(config); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { + headers, + signal: controller.signal, + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new Error(`${llmProviderLabel(config.provider)} rejected that API key.`); + } + throw new Error( + `${llmProviderLabel(config.provider)} verification failed with HTTP ${response.status}.`, + ); + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error( + `${llmProviderLabel(config.provider)} verification timed out after ${FETCH_TIMEOUT_MS / 1000} seconds.`, + ); + } + if (err instanceof Error && err.message === "fetch failed") { + const displayUrl = url.replace("host.docker.internal", "localhost"); + throw new Error( + `${llmProviderLabel(config.provider)} verification failed: could not reach ${displayUrl}. If this is LM Studio, start the local server and use http://localhost:1234 or http://localhost:1234/v1.`, + ); + } + throw err; + } finally { + clearTimeout(timeout); + } +} diff --git a/backend/src/config/models.ts b/backend/src/config/models.ts index 1d28cbf..0b65699 100644 --- a/backend/src/config/models.ts +++ b/backend/src/config/models.ts @@ -1,12 +1,14 @@ /** * Backend configuration for AI models. * - * Defines the typed interfaces and constants for OpenRouter model management. + * Defines the typed interfaces and constants for model management. */ import { api, internal, convex } from "../convex.js"; import { env } from "../env.js"; -import { requireOpenRouterApiKey } from "../local-credentials.js"; +import { getLlmProviderConfig, requireOpenRouterApiKey } from "../local-credentials.js"; +import { FETCH_TIMEOUT_MS } from "../fetch-timeout.js"; +import { defaultModelForLlmProviderRole, type ModelRoleKey } from "./llm.js"; export interface OpenRouterModel { modelName: string; @@ -17,10 +19,10 @@ export interface OpenRouterModel { } /** - * Default model slugs for each agent role. - * Read from environment variables so operators can change defaults - * without touching code. Falls back to typed literals when env vars - * are unset (useful for local dev without a .env file). + * Default model identifiers for each agent role. + * Read from environment variables so operators can change production defaults + * without touching code. Local mode falls back to the selected LLM provider's + * default model first. */ export const DEFAULT_MODEL_IDS = { SCHEMA_INFERENCE: env.SCHEMA_INFERENCE_MODEL, @@ -28,6 +30,87 @@ export const DEFAULT_MODEL_IDS = { INVESTIGATE_SUBAGENT: env.INVESTIGATE_SUBAGENT_MODEL, } as const; +const OPENAI_MODEL_EXCLUDE_PATTERNS = [ + "audio", + "babbage", + "dall-e", + "davinci", + "embedding", + "image", + "instruct", + "moderation", + "realtime", + "sora", + "transcribe", + "tts", + "whisper", +]; + +function isOpenAITextModelId(id: string): boolean { + const lower = id.toLowerCase(); + if (OPENAI_MODEL_EXCLUDE_PATTERNS.some((pattern) => lower.includes(pattern))) { + return false; + } + return ( + lower.startsWith("gpt-") || + lower.startsWith("o1") || + lower.startsWith("o3") || + lower.startsWith("o4") || + lower.startsWith("chatgpt-") + ); +} + +function sortModels(models: OpenRouterModel[]): OpenRouterModel[] { + return models.sort((a, b) => a.modelName.localeCompare(b.modelName)); +} + +function isModelCompatibleWithProvider( + modelId: string | undefined, + provider: Awaited>, +): modelId is string { + if (!modelId) return false; + if (!provider) return true; + switch (provider.provider) { + case "openrouter": + return modelId.includes("/"); + case "openai": + return isOpenAITextModelId(modelId) && !modelId.includes("/"); + case "anthropic": + return modelId.startsWith("claude-") && !modelId.includes("/"); + case "custom": + return true; + } +} + +function modelForProvider( + savedModel: string | undefined, + role: ModelRoleKey, + envDefault: string, + provider: Awaited>, +): string { + if (isModelCompatibleWithProvider(savedModel, provider)) return savedModel; + if (provider?.provider) return defaultModelForLlmProviderRole(provider.provider, role); + return envDefault; +} + +async function fetchJsonWithTimeout( + url: string, + headers: Record, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { headers, signal: controller.signal }); + if (!response.ok) { + throw new Error(`Model list request failed with HTTP ${response.status}.`); + } + return (await response.json()) as T; + } finally { + clearTimeout(timeout); + } +} + /** * Model roles for the settings UI. */ @@ -58,6 +141,66 @@ export async function getCachedModels(): Promise { return fetched; } +export async function fetchModelsForCurrentLlmProvider(): Promise { + const config = await getLlmProviderConfig(); + if (!config) { + throw new Error("LLM provider is not configured."); + } + + if (config.provider === "openrouter") { + return await getCachedModels(); + } + + if (config.provider === "anthropic") { + const baseUrl = (config.baseUrl || "https://api.anthropic.com/v1").replace(/\/+$/, ""); + const json = await fetchJsonWithTimeout<{ + data?: Array<{ + id: string; + display_name?: string; + max_input_tokens?: number; + }>; + }>(`${baseUrl}/models?limit=100`, { + "x-api-key": config.apiKey, + "anthropic-version": "2023-06-01", + }); + + return sortModels( + (json.data ?? []).map((model) => ({ + modelName: model.display_name ?? model.id, + canonicalSlug: model.id, + contextLength: model.max_input_tokens ?? 0, + completionCost: 0, + promptCost: 0, + })), + ); + } + + const baseUrl = (config.baseUrl || "https://api.openai.com/v1").replace(/\/+$/, ""); + const headers: Record = + config.provider === "custom" && !config.apiKey + ? {} + : { Authorization: `Bearer ${config.apiKey}` }; + const json = await fetchJsonWithTimeout<{ + data?: Array<{ id: string }>; + }>(`${baseUrl}/models`, headers); + + const models = (json.data ?? []) + .filter((model) => + config.provider === "openai" + ? isOpenAITextModelId(model.id) + : true, + ) + .map((model) => ({ + modelName: model.id, + canonicalSlug: model.id, + contextLength: 0, + completionCost: 0, + promptCost: 0, + })); + + return sortModels(models); +} + /** * Validate that a model slug exists in the cached model list. * Throws with a clear message if the slug is not found. @@ -98,8 +241,10 @@ export async function upsertModelConfig( investigateSubagent?: string; } ): Promise { + const llmConfig = await getLlmProviderConfig(); await convex.mutation(internal.modelConfig.upsertInternal, { userId, + provider: llmConfig?.provider ?? "openrouter", schemaInference: config.schemaInference ?? undefined, populateOrchestrator: config.populateOrchestrator ?? undefined, investigateSubagent: config.investigateSubagent ?? undefined, @@ -108,7 +253,7 @@ export async function upsertModelConfig( /** * Fetch the model configuration for a specific user from Convex. - * If the user has no saved config, returns the system defaults from env. + * If the user has no saved config, returns the selected provider default or env defaults. * Callers always get a complete config — never null. */ export async function getModelConfig( @@ -118,11 +263,30 @@ export async function getModelConfig( populateOrchestrator: string; investigateSubagent: string; }> { - const config = await convex.query(internal.modelConfig.getInternal, { userId }); + const llmConfig = await getLlmProviderConfig(); + const config = await convex.query(internal.modelConfig.getInternal, { + userId, + provider: llmConfig?.provider ?? "openrouter", + }); return { - schemaInference: config?.schemaInference ?? DEFAULT_MODEL_IDS.SCHEMA_INFERENCE, - populateOrchestrator: config?.populateOrchestrator ?? DEFAULT_MODEL_IDS.POPULATE_ORCHESTRATOR, - investigateSubagent: config?.investigateSubagent ?? DEFAULT_MODEL_IDS.INVESTIGATE_SUBAGENT, + schemaInference: modelForProvider( + config?.schemaInference, + "schemaInference", + DEFAULT_MODEL_IDS.SCHEMA_INFERENCE, + llmConfig, + ), + populateOrchestrator: modelForProvider( + config?.populateOrchestrator, + "populateOrchestrator", + DEFAULT_MODEL_IDS.POPULATE_ORCHESTRATOR, + llmConfig, + ), + investigateSubagent: modelForProvider( + config?.investigateSubagent, + "investigateSubagent", + DEFAULT_MODEL_IDS.INVESTIGATE_SUBAGENT, + llmConfig, + ), }; } diff --git a/backend/src/env.ts b/backend/src/env.ts index 97c410f..a43fa0b 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -45,7 +45,8 @@ export const env = { LOCAL_KEYCHAIN_TIMEOUT_MS: numberFromEnv("LOCAL_KEYCHAIN_TIMEOUT_MS", 5_000), // Default models — used when a user has not saved a preference. - // Each must be a valid OpenRouter model slug. + // In production these are still interpreted as OpenRouter model slugs; in + // local mode the selected LLM provider's default model is used first. SCHEMA_INFERENCE_MODEL: process.env.SCHEMA_INFERENCE_MODEL ?? "anthropic/claude-sonnet-4.6", POPULATE_ORCHESTRATOR_MODEL: diff --git a/backend/src/index.ts b/backend/src/index.ts index cb57cd1..834a702 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,9 +20,17 @@ import { getLocalSetupStatus, requireLocalSetupComplete, saveLocalCredential, + saveLocalLlmProviderConfig, + setActiveLocalLlmProvider, verifyOpenRouterApiKey, verifyTinyFishApiKey, } from "./local-credentials.js"; +import { + isLlmProviderType, + normalizeLlmProviderInput, + verifyLlmProviderConfig, + type LlmProviderInput, +} from "./config/llm.js"; /** Domain part of an email, for analytics (we never log full addresses). */ function emailDomain(email: string): string { @@ -159,7 +167,7 @@ async function ensureLocalSetupReady(reply: FastifyReply): Promise { return true; } catch { await reply.code(428).send({ - error: "Local setup is incomplete. Connect TinyFish and OpenRouter first.", + error: "Local setup is incomplete. Connect TinyFish and an LLM provider first.", }); return false; } @@ -711,6 +719,51 @@ fastify.post("/local-setup/tinyfish", async (req, reply) => { } }); +fastify.post("/local-setup/llm-provider", async (req, reply) => { + if (!env.IS_LOCAL_MODE) { + return reply.code(404).send({ error: "Not found" }); + } + + const body = (req.body ?? {}) as Partial; + const provider = body.provider; + if (!isLlmProviderType(provider)) { + return reply.code(400).send({ error: "Choose a supported LLM provider" }); + } + + try { + const apiKey = body.apiKey?.trim() ?? ""; + const isNewCustomWithoutKey = provider === "custom" && !!body.baseUrl?.trim(); + + if (!apiKey && !isNewCustomWithoutKey) { + const status = await getLocalSetupStatus(); + const savedProvider = status.services.llmProviders?.[provider]; + if (!savedProvider?.configured) { + return reply.code(400).send({ error: `${savedProvider?.providerLabel ?? provider} API key is required` }); + } + await setActiveLocalLlmProvider(provider); + return await getLocalSetupStatus(); + } + + const config = normalizeLlmProviderInput( + { + provider, + apiKey, + baseUrl: body.baseUrl, + defaultModel: body.defaultModel, + }, + "local", + ); + await verifyLlmProviderConfig(config); + await saveLocalLlmProviderConfig(config, "api_key"); + return await getLocalSetupStatus(); + } catch (err) { + const message = err instanceof Error ? err.message : "LLM provider verification failed"; + req.log.warn({ err }, "LLM provider local setup verification failed"); + return reply.code(400).send({ error: message }); + } +}); + +// Backward-compatible endpoint for older setup UI builds. fastify.post("/local-setup/openrouter-key", async (req, reply) => { if (!env.IS_LOCAL_MODE) { return reply.code(404).send({ error: "Not found" }); @@ -782,6 +835,18 @@ fastify.get("/openrouter/models", async (req, reply) => { } }); +fastify.get("/llm-provider/models", async (req, reply) => { + const { fetchModelsForCurrentLlmProvider } = await import("./config/models.js"); + try { + const models = await fetchModelsForCurrentLlmProvider(); + return { models }; + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load models"; + req.log.error(err, "Failed to load current LLM provider models"); + return reply.code(500).send({ error: message }); + } +}); + // ──────────────────────────────────────────────────────────────────────── // Protected routes — gated by Clerk JWT verification // ──────────────────────────────────────────────────────────────────────── @@ -796,27 +861,30 @@ await fastify.register(async (instance) => { }); instance.post("/settings/models", async (req, reply) => { - const { upsertModelConfig, validateModelSlug, getCachedModels } = await import("./config/models.js"); + const { upsertModelConfig, fetchModelsForCurrentLlmProvider } = await import("./config/models.js"); const body = req.body as { schemaInference?: string | null; populateOrchestrator?: string | null; investigateSubagent?: string | null; }; + const config = { + schemaInference: typeof body.schemaInference === "string" ? body.schemaInference.trim() || undefined : undefined, + populateOrchestrator: typeof body.populateOrchestrator === "string" ? body.populateOrchestrator.trim() || undefined : undefined, + investigateSubagent: typeof body.investigateSubagent === "string" ? body.investigateSubagent.trim() || undefined : undefined, + }; const toValidate: Array<{ role: "schemaInference" | "populateOrchestrator" | "investigateSubagent"; slug: string }> = []; - if (body.schemaInference) toValidate.push({ role: "schemaInference", slug: body.schemaInference }); - if (body.populateOrchestrator) toValidate.push({ role: "populateOrchestrator", slug: body.populateOrchestrator }); - if (body.investigateSubagent) toValidate.push({ role: "investigateSubagent", slug: body.investigateSubagent }); + if (config.schemaInference) toValidate.push({ role: "schemaInference", slug: config.schemaInference }); + if (config.populateOrchestrator) toValidate.push({ role: "populateOrchestrator", slug: config.populateOrchestrator }); + if (config.investigateSubagent) toValidate.push({ role: "investigateSubagent", slug: config.investigateSubagent }); if (toValidate.length > 0) { try { - const models = await getCachedModels(); + const models = await fetchModelsForCurrentLlmProvider(); for (const { role, slug } of toValidate) { const found = models.some((m) => m.canonicalSlug === slug); if (!found) { - return reply.code(400).send({ - error: `Invalid model slug "${slug}" for ${role}. Refresh the model list first or choose a different model.`, - }); + req.log.warn({ role, slug }, "Saving model slug that was not returned by the current LLM provider"); } } } catch (err) { @@ -826,9 +894,9 @@ await fastify.register(async (instance) => { try { await upsertModelConfig(req.auth!.userId, { - schemaInference: body.schemaInference ?? undefined, - populateOrchestrator: body.populateOrchestrator ?? undefined, - investigateSubagent: body.investigateSubagent ?? undefined, + schemaInference: config.schemaInference, + populateOrchestrator: config.populateOrchestrator, + investigateSubagent: config.investigateSubagent, }); return { success: true }; } catch (err) { diff --git a/backend/src/local-credential-types.ts b/backend/src/local-credential-types.ts index bcb4235..9d5fc95 100644 --- a/backend/src/local-credential-types.ts +++ b/backend/src/local-credential-types.ts @@ -1,4 +1,10 @@ -export const LOCAL_CREDENTIAL_SERVICES = ["tinyfish", "openrouter"] as const; +export const LOCAL_CREDENTIAL_SERVICES = [ + "tinyfish", + "openrouter", + "openai", + "anthropic", + "custom", +] as const; export type LocalCredentialService = (typeof LOCAL_CREDENTIAL_SERVICES)[number]; export type ConnectionMethod = "api_key" | "oauth"; diff --git a/backend/src/local-credentials.ts b/backend/src/local-credentials.ts index a6a5b4b..5b7727d 100644 --- a/backend/src/local-credentials.ts +++ b/backend/src/local-credentials.ts @@ -5,6 +5,16 @@ import { getKeychainCredential, setKeychainCredential, } from "./local-keychain-client.js"; +import { + defaultBaseUrlForLlmProvider, + defaultModelForLlmProvider, + isLlmProviderType, + llmProviderLabel, + normalizeLlmProviderInput, + type LlmProviderConfig, + type LlmProviderInput, + type LlmProviderType, +} from "./config/llm.js"; import type { ConnectionMethod, LocalCredentialService, @@ -17,13 +27,23 @@ export interface ServiceSetupStatus { source: "local" | "env" | null; connectionMethod: ConnectionMethod | null; verifiedAt: number | null; + provider?: LlmProviderType; + providerLabel?: string; + baseUrl?: string; + defaultModel?: string; } export interface LocalSetupStatus { mode: "local" | "production"; required: boolean; complete: boolean; - services: Record; + services: { + tinyfish: ServiceSetupStatus; + llm: ServiceSetupStatus; + llmProviders?: Record; + /** Deprecated compatibility alias for older UI code. */ + openrouter?: ServiceSetupStatus; + }; } function isPlaceholder(value: string, service: LocalCredentialService): boolean { @@ -35,30 +55,84 @@ function isPlaceholder(value: string, service: LocalCredentialService): boolean function envCredential(service: LocalCredentialService): string | undefined { const value = - service === "tinyfish" ? process.env.TINYFISH_API_KEY : env.OPENROUTER_API_KEY; + service === "tinyfish" + ? process.env.TINYFISH_API_KEY + : service === "openrouter" + ? env.OPENROUTER_API_KEY + : undefined; if (!value || isPlaceholder(value, service)) return undefined; return value; } +function llmProviderService(provider: LlmProviderType): LocalCredentialService { + return provider; +} + async function localCredential(service: LocalCredentialService): Promise<{ apiKey: string; connectionMethod: ConnectionMethod; verifiedAt: number | null; keychainAccount: string; + llmProvider?: LlmProviderType; + llmBaseUrl?: string; + llmDefaultModel?: string; } | null> { if (!env.IS_LOCAL_MODE) return null; - const keychain = await getKeychainCredential(service); - if (!keychain?.apiKey) return null; const row = await convex.query(internal.localCredentials.getInternal, { service, }); + const rowData = row as + | { + keychainAccount?: string; + connectionMethod?: ConnectionMethod; + verifiedAt?: number; + llmProvider?: unknown; + llmBaseUrl?: unknown; + llmDefaultModel?: unknown; + } + | null; + + const keychain = await getKeychainCredential(service); + if (!keychain?.apiKey) { + // LM Studio and many local OpenAI-compatible servers do not require an + // API key. A custom-provider row with a base URL is therefore a valid + // local credential even when there is no keychain secret. + if ( + service === "custom" && + rowData?.llmProvider === "custom" && + typeof rowData.llmBaseUrl === "string" + ) { + return { + apiKey: "", + connectionMethod: rowData.connectionMethod ?? "api_key", + verifiedAt: rowData.verifiedAt ?? null, + keychainAccount: rowData.keychainAccount ?? "", + llmProvider: "custom", + llmBaseUrl: rowData.llmBaseUrl, + llmDefaultModel: + typeof rowData.llmDefaultModel === "string" + ? rowData.llmDefaultModel + : undefined, + }; + } + return null; + } return { apiKey: keychain.apiKey, - connectionMethod: row?.connectionMethod ?? "api_key", - verifiedAt: row?.verifiedAt ?? null, + connectionMethod: rowData?.connectionMethod ?? "api_key", + verifiedAt: rowData?.verifiedAt ?? null, keychainAccount: keychain.keychainAccount, + llmProvider: isLlmProviderType(rowData?.llmProvider) + ? rowData.llmProvider + : undefined, + llmBaseUrl: + typeof rowData?.llmBaseUrl === "string" ? rowData.llmBaseUrl : undefined, + llmDefaultModel: + typeof rowData?.llmDefaultModel === "string" + ? rowData.llmDefaultModel + : undefined, }; } @@ -72,6 +146,57 @@ async function localCredentialForStatus( } } +async function activeLlmProviderForStatus(): Promise { + if (!env.IS_LOCAL_MODE) return "openrouter"; + + try { + const active = await convex.query(internal.localCredentials.getInternal, { + service: "llm", + }); + const activeProvider = (active as { llmProvider?: unknown } | null) + ?.llmProvider; + if (isLlmProviderType(activeProvider)) return activeProvider; + } catch { + // Convex functions may be one push behind during local development. Fall + // back instead of making every backend status/settings route return 500. + } + + const legacy = await localCredentialForStatus("openrouter"); + if (legacy?.llmProvider) return legacy.llmProvider; + return "openrouter"; +} + +async function localCredentialForLlmProvider( + provider: LlmProviderType, +): Promise>> { + const direct = await localCredentialForStatus(llmProviderService(provider)); + if (direct && (!direct.llmProvider || direct.llmProvider === provider)) { + return direct; + } + + if (provider !== "openrouter") { + const legacy = await localCredentialForStatus("openrouter"); + if (legacy?.llmProvider === provider) return legacy; + } + + return null; +} + +export async function setActiveLocalLlmProvider( + provider: LlmProviderType, +): Promise { + if (!env.IS_LOCAL_MODE) { + throw new Error("Local credential storage is disabled when PROD=1."); + } + + await convex.mutation(internal.localCredentials.upsertInternal, { + service: "llm", + connectionMethod: "api_key", + verifiedAt: Date.now(), + llmProvider: provider, + }); +} + export async function resolveCredential( service: LocalCredentialService, ): Promise<{ apiKey: string; source: "local" | "env" } | null> { @@ -86,14 +211,55 @@ export async function resolveCredential( return null; } +export async function getLlmProviderConfig(): Promise { + if (env.IS_LOCAL_MODE) { + const provider = await activeLlmProviderForStatus(); + const local = await localCredentialForLlmProvider(provider); + if (!local) return null; + + return normalizeLlmProviderInput( + { + provider, + apiKey: local.apiKey, + baseUrl: + local.llmBaseUrl ?? defaultBaseUrlForLlmProvider(provider), + defaultModel: + local.llmDefaultModel || defaultModelForLlmProvider(provider), + }, + "local", + ); + } + + const apiKey = envCredential("openrouter"); + if (!apiKey) return null; + return normalizeLlmProviderInput( + { + provider: "openrouter", + apiKey, + baseUrl: process.env.OPENROUTER_BASE_URL, + defaultModel: env.SCHEMA_INFERENCE_MODEL, + }, + "env", + ); +} + +export async function requireLlmProviderConfig(): Promise { + const config = await getLlmProviderConfig(); + if (!config) { + throw new Error("LLM provider is not configured. Complete local setup first."); + } + return config; +} + export async function getOpenRouterApiKey(): Promise { - return (await resolveCredential("openrouter"))?.apiKey; + const config = await getLlmProviderConfig(); + return config?.provider === "openrouter" ? config.apiKey : undefined; } export async function requireOpenRouterApiKey(): Promise { const apiKey = await getOpenRouterApiKey(); if (!apiKey) { - throw new Error("OpenRouter is not configured. Complete local setup first."); + throw new Error("OpenRouter is not configured as the current LLM provider."); } return apiKey; } @@ -140,7 +306,24 @@ export async function requireLocalSetupComplete(): Promise { export async function getLocalSetupStatus(): Promise { if (!env.IS_LOCAL_MODE) { const tinyfish = envCredential("tinyfish"); - const openrouter = envCredential("openrouter"); + const llmConfig = await getLlmProviderConfig(); + const llm: ServiceSetupStatus = llmConfig + ? { + configured: true, + source: llmConfig.source, + connectionMethod: "api_key", + verifiedAt: null, + provider: llmConfig.provider, + providerLabel: llmProviderLabel(llmConfig.provider), + baseUrl: llmConfig.baseUrl, + defaultModel: llmConfig.defaultModel, + } + : { + configured: false, + source: null, + connectionMethod: null, + verifiedAt: null, + }; return { mode: "production", required: false, @@ -152,18 +335,13 @@ export async function getLocalSetupStatus(): Promise { connectionMethod: tinyfish ? "api_key" : null, verifiedAt: null, }, - openrouter: { - configured: !!openrouter, - source: openrouter ? "env" : null, - connectionMethod: openrouter ? "api_key" : null, - verifiedAt: null, - }, + llm, + openrouter: llm, }, }; } const tinyfishLocal = await localCredentialForStatus("tinyfish"); - const openrouterLocal = await localCredentialForStatus("openrouter"); const tinyfish: ServiceSetupStatus = tinyfishLocal ? { @@ -179,25 +357,52 @@ export async function getLocalSetupStatus(): Promise { verifiedAt: null, }; - const openrouter: ServiceSetupStatus = openrouterLocal - ? { - configured: true, - source: "local", - connectionMethod: openrouterLocal.connectionMethod, - verifiedAt: openrouterLocal.verifiedAt, - } - : { - configured: false, - source: null, - connectionMethod: null, - verifiedAt: null, - }; + const providerStatuses = {} as Record; + for (const provider of [ + "openrouter", + "openai", + "anthropic", + "custom", + ] as const) { + const credential = await localCredentialForLlmProvider(provider); + providerStatuses[provider] = credential + ? { + configured: true, + source: "local", + connectionMethod: credential.connectionMethod, + verifiedAt: credential.verifiedAt, + provider, + providerLabel: llmProviderLabel(provider), + baseUrl: + credential.llmBaseUrl ?? defaultBaseUrlForLlmProvider(provider), + defaultModel: + credential.llmDefaultModel || defaultModelForLlmProvider(provider), + } + : { + configured: false, + source: null, + connectionMethod: null, + verifiedAt: null, + provider, + providerLabel: llmProviderLabel(provider), + baseUrl: defaultBaseUrlForLlmProvider(provider), + defaultModel: defaultModelForLlmProvider(provider), + }; + } + + const llmProvider = await activeLlmProviderForStatus(); + const llm = providerStatuses[llmProvider]; return { mode: "local", required: true, - complete: tinyfish.configured && openrouter.configured, - services: { tinyfish, openrouter }, + complete: tinyfish.configured && llm.configured, + services: { + tinyfish, + llm, + llmProviders: providerStatuses, + openrouter: providerStatuses.openrouter, + }, }; } @@ -215,7 +420,48 @@ export async function saveLocalCredential( keychainAccount, connectionMethod, verifiedAt: Date.now(), + ...(service === "openrouter" + ? { + llmProvider: "openrouter" as const, + llmBaseUrl: defaultBaseUrlForLlmProvider("openrouter"), + llmDefaultModel: defaultModelForLlmProvider("openrouter"), + } + : {}), + }); + + if (service === "openrouter") { + await setActiveLocalLlmProvider("openrouter"); + } +} + +export async function saveLocalLlmProviderConfig( + input: LlmProviderInput, + connectionMethod: ConnectionMethod = "api_key", +): Promise { + if (!env.IS_LOCAL_MODE) { + throw new Error("Local credential storage is disabled when PROD=1."); + } + + const config = normalizeLlmProviderInput(input, "local"); + const keychainAccount = config.apiKey + ? ( + await setKeychainCredential( + llmProviderService(config.provider), + config.apiKey, + ) + ).keychainAccount + : undefined; + await convex.mutation(internal.localCredentials.upsertInternal, { + service: llmProviderService(config.provider), + ...(keychainAccount ? { keychainAccount } : {}), + connectionMethod, + verifiedAt: Date.now(), + llmProvider: config.provider, + llmBaseUrl: config.baseUrl, + llmDefaultModel: config.defaultModel, }); + await setActiveLocalLlmProvider(config.provider); + return config; } export async function clearLegacyPlaintextLocalCredentials(): Promise { diff --git a/backend/src/mastra/agents/investigate.ts b/backend/src/mastra/agents/investigate.ts index 63a7b8c..c930f5e 100644 --- a/backend/src/mastra/agents/investigate.ts +++ b/backend/src/mastra/agents/investigate.ts @@ -1,5 +1,5 @@ import { Agent } from "@mastra/core/agent"; -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { createLanguageModel, type LlmProviderConfig } from "../../config/llm.js"; import { buildPopulateTools } from "../tools/dataset-tools.js"; import { searchWebTool, fetchPageTool } from "../tools/web-tools.js"; import type { AuthContext } from "../workflows/populate.js"; @@ -55,13 +55,9 @@ export function buildInvestigateAgent( authorizedDatasetId: string, authContext: AuthContext, columns: PopulateColumn[], - openRouterApiKey: string, + llmConfig: LlmProviderConfig, ): Agent { const modelSlug = authContext.modelConfig!.investigateSubagent; - const openrouter = createOpenRouter({ - apiKey: openRouterApiKey, - baseURL: process.env.OPENROUTER_BASE_URL, - }); const { insert_row } = buildPopulateTools( authorizedDatasetId, @@ -71,7 +67,7 @@ export function buildInvestigateAgent( id: "investigate-agent", name: "Dataset Investigate Agent", instructions: buildInvestigateInstructions(columns), - model: openrouter(modelSlug), + model: createLanguageModel(llmConfig, modelSlug), tools: { insert_row, diff --git a/backend/src/mastra/agents/populate.ts b/backend/src/mastra/agents/populate.ts index eb9b26f..f5d9869 100644 --- a/backend/src/mastra/agents/populate.ts +++ b/backend/src/mastra/agents/populate.ts @@ -1,5 +1,5 @@ import { Agent } from "@mastra/core/agent"; -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { createLanguageModel, type LlmProviderConfig } from "../../config/llm.js"; import { buildSubagentTool } from "../tools/investigate-tool.js"; import { searchWebTool, fetchPageTool } from "../tools/web-tools.js"; import type { AuthContext } from "../workflows/populate.js"; @@ -40,21 +40,17 @@ export function buildPopulateAgent( authorizedDatasetId: string, authContext: AuthContext, columns: PopulateColumn[], - openRouterApiKey: string, + llmConfig: LlmProviderConfig, maxRowCount: number, metrics?: RunMetrics, ): Agent { const modelSlug = authContext.modelConfig!.populateOrchestrator; - const openrouter = createOpenRouter({ - apiKey: openRouterApiKey, - baseURL: process.env.OPENROUTER_BASE_URL, - }); return new Agent({ id: "populate-agent", name: "Dataset Populate Orchestrator", instructions: buildInstructions(maxRowCount), - model: openrouter(modelSlug), + model: createLanguageModel(llmConfig, modelSlug), tools: { search_web: searchWebTool, fetch_page: fetchPageTool, @@ -62,7 +58,7 @@ export function buildPopulateAgent( authorizedDatasetId, authContext, columns, - openRouterApiKey, + llmConfig, maxRowCount, metrics, ), diff --git a/backend/src/mastra/agents/refresh.ts b/backend/src/mastra/agents/refresh.ts index 144065a..6593f96 100644 --- a/backend/src/mastra/agents/refresh.ts +++ b/backend/src/mastra/agents/refresh.ts @@ -1,5 +1,5 @@ import { Agent } from "@mastra/core/agent"; -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { createLanguageModel, type LlmProviderConfig } from "../../config/llm.js"; import { buildPopulateTools } from "../tools/dataset-tools.js"; import { searchWebTool, fetchPageTool } from "../tools/web-tools.js"; import type { AuthContext } from "../workflows/populate.js"; @@ -51,13 +51,9 @@ export function buildRefreshAgent( authorizedDatasetId: string, authContext: AuthContext, columns: PopulateColumn[], - openRouterApiKey: string, + llmConfig: LlmProviderConfig, ): Agent { const modelSlug = authContext.modelConfig!.investigateSubagent; - const openrouter = createOpenRouter({ - apiKey: openRouterApiKey, - baseURL: process.env.OPENROUTER_BASE_URL, - }); const { update_row } = buildPopulateTools( authorizedDatasetId, authContext, @@ -66,7 +62,7 @@ export function buildRefreshAgent( id: "refresh-agent", name: "Dataset Refresh Agent", instructions: buildRefreshInstructions(columns), - model: openrouter(modelSlug), + model: createLanguageModel(llmConfig, modelSlug), tools: { update_row, search_web: searchWebTool, diff --git a/backend/src/mastra/tools/investigate-tool.ts b/backend/src/mastra/tools/investigate-tool.ts index 0139aa4..1b23764 100644 --- a/backend/src/mastra/tools/investigate-tool.ts +++ b/backend/src/mastra/tools/investigate-tool.ts @@ -6,6 +6,7 @@ import type { AuthContext } from "../workflows/populate.js"; import type { PopulateColumn } from "../../pipeline/populate.js"; import type { RunMetrics } from "../run-metrics.js"; import { getSignal } from "../../abort-registry.js"; +import type { LlmProviderConfig } from "../../config/llm.js"; const investigateInputSchema = z.object({ entity_hint: z @@ -75,7 +76,7 @@ export function buildSubagentTool( authorizedDatasetId: string, authContext: AuthContext, columns: PopulateColumn[], - openRouterApiKey: string, + llmConfig: LlmProviderConfig, maxRowCount: number, metrics?: RunMetrics, ) { @@ -108,7 +109,7 @@ export function buildSubagentTool( authorizedDatasetId, authContext, columns, - openRouterApiKey, + llmConfig, ); const pkBlock = Object.entries(primary_keys) diff --git a/backend/src/mastra/workflows/populate.ts b/backend/src/mastra/workflows/populate.ts index e07ed8e..3673047 100644 --- a/backend/src/mastra/workflows/populate.ts +++ b/backend/src/mastra/workflows/populate.ts @@ -1,11 +1,11 @@ import { createStep, createWorkflow } from "@mastra/core/workflows"; import { z } from "zod"; import { generateText } from "ai"; -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { datasetContextSchema, populateColumnSchema } from "../../pipeline/populate.js"; import { convex, internal } from "../../convex.js"; import { DEFAULT_MODEL_IDS } from "../../config/models.js"; -import { requireOpenRouterApiKey } from "../../local-credentials.js"; +import { createLanguageModel } from "../../config/llm.js"; +import { requireLlmProviderConfig } from "../../local-credentials.js"; import { buildPopulateAgent } from "../agents/populate.js"; import { RunMetrics } from "../run-metrics.js"; import { saveRunMetrics } from "../save-run-metrics.js"; @@ -109,15 +109,11 @@ Respond with EXACTLY one word: scraper or search`; let classification: "scraper" | "search" = "search"; try { - const apiKey = await requireOpenRouterApiKey(); - const openrouter = createOpenRouter({ - apiKey, - baseURL: process.env.OPENROUTER_BASE_URL, - }); + const llmConfig = await requireLlmProviderConfig(); const modelSlug = - inputData.authContext?.modelConfig?.schemaInference ?? DEFAULT_MODEL_IDS.SCHEMA_INFERENCE; + inputData.authContext?.modelConfig?.schemaInference ?? llmConfig.defaultModel ?? DEFAULT_MODEL_IDS.SCHEMA_INFERENCE; const result = await generateText({ - model: openrouter(modelSlug), + model: createLanguageModel(llmConfig, modelSlug), prompt: classificationPrompt, maxOutputTokens: 10, abortSignal: getSignal(inputData.datasetId), @@ -251,7 +247,7 @@ const agentStep = createStep({ inputData.authorizedDatasetId, inputData.authContext, inputData.columns, - await requireOpenRouterApiKey(), + await requireLlmProviderConfig(), inputData.maxRowCount, metrics, ); diff --git a/backend/src/mastra/workflows/update.ts b/backend/src/mastra/workflows/update.ts index 28aadee..4bb8ada 100644 --- a/backend/src/mastra/workflows/update.ts +++ b/backend/src/mastra/workflows/update.ts @@ -4,7 +4,7 @@ import { datasetContextSchema, populateColumnSchema } from "../../pipeline/popul import { convex, internal } from "../../convex.js"; import { buildRefreshAgent } from "../agents/refresh.js"; import { authContextSchema } from "./populate.js"; -import { requireOpenRouterApiKey } from "../../local-credentials.js"; +import { requireLlmProviderConfig } from "../../local-credentials.js"; import { RunMetrics } from "../run-metrics.js"; import { saveRunMetrics } from "../save-run-metrics.js"; import { getSignal } from "../../abort-registry.js"; @@ -100,7 +100,7 @@ const refreshRowsStep = createStep({ const metrics = new RunMetrics(); const startedAt = Date.now(); - const openRouterApiKey = await requireOpenRouterApiKey(); + const llmConfig = await requireLlmProviderConfig(); const pkColumns = columns.filter((c) => c.isPrimaryKey); @@ -110,7 +110,7 @@ const refreshRowsStep = createStep({ datasetId, authContext, columns, - openRouterApiKey, + llmConfig, ); const pkBlock = diff --git a/backend/src/pipeline/schema-inference.ts b/backend/src/pipeline/schema-inference.ts index 467a393..d1ab510 100644 --- a/backend/src/pipeline/schema-inference.ts +++ b/backend/src/pipeline/schema-inference.ts @@ -1,8 +1,8 @@ import { generateText, Output, NoObjectGeneratedError } from "ai"; -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { DEFAULT_MODEL_IDS } from "../config/models.js"; -import { requireOpenRouterApiKey } from "../local-credentials.js"; +import { createLanguageModel } from "../config/llm.js"; +import { requireLlmProviderConfig } from "../local-credentials.js"; import { datasetSchemaSchema, type DatasetSchema } from "./types.js"; const SYSTEM_PROMPT = `You are a data engineering assistant that converts natural-language prompts into structured dataset schemas. Given a user prompt describing a dataset they want to build, you produce a precise schema definition. @@ -28,13 +28,9 @@ Rules: - When a column is a scalar numeric rating (e.g. average score like 4.3/5 for restaurants, cafes, hotels, products, apps): name it generically (e.g. "rating" not "yelp_rating") and write a retrieval_hint explaining that review sites (Yelp, TripAdvisor, Google Maps) block direct page fetches, so the agent must extract ratings from **search result snippets**. The hint should say: "Search for \\" rating reviews\\" and include location terms only when location is part of the entity identity. Look for ratings in snippets from TripAdvisor (\\"rated X.X of 5\\"), Yelp search listings (\\"X.X (N reviews)\\"), or aggregator sites (Birdeye, joe.coffee, giftly, Uber Eats, menufyy). Do NOT try to fetch yelp.com or tripadvisor.com directly — they block automated access. Accept ratings from any reputable source." If including a rating column, also add a "rating_source" text column so the agent records where the rating came from. Do not rename review-count or review-text fields to "rating" — keep those as distinct columns (e.g. "review_count") when the user explicitly asks for them.`; async function getModel(modelSlug?: string) { - const apiKey = await requireOpenRouterApiKey(); - const openrouter = createOpenRouter({ - apiKey, - baseURL: process.env.OPENROUTER_BASE_URL, - }); - const resolvedSlug = modelSlug ?? DEFAULT_MODEL_IDS.SCHEMA_INFERENCE; - return openrouter(resolvedSlug); + const config = await requireLlmProviderConfig(); + const resolvedSlug = modelSlug ?? config.defaultModel ?? DEFAULT_MODEL_IDS.SCHEMA_INFERENCE; + return createLanguageModel(config, resolvedSlug); } export async function inferSchema(prompt: string, modelSlug?: string): Promise { diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index daa1db8..3a07d32 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -10,4 +10,7 @@ RUN bun install COPY --chown=node:node . . -CMD ["bun", "dev", "--hostname", "0.0.0.0", "--port", "3500"] +# Run Next directly instead of through Bun's script runner, and force webpack +# for local Docker dev. Next 16 defaults to Turbopack, which can get OOM-killed +# in constrained Docker Desktop setups during the first page compile. +CMD ["node", "../scripts/with-root-env.mjs", "./node_modules/.bin/next", "dev", "--webpack", "--hostname", "0.0.0.0", "--port", "3500"] diff --git a/frontend/app/dashboard/settings/models/page.tsx b/frontend/app/dashboard/settings/models/page.tsx index 6a3acb4..f376c89 100644 --- a/frontend/app/dashboard/settings/models/page.tsx +++ b/frontend/app/dashboard/settings/models/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useQuery } from "convex/react"; import { api } from "@/convex/_generated/api"; -import { getModelConfig, saveModelConfig, getOpenRouterModels, refreshOpenRouterModels, type EffectiveModelConfig, type OpenRouterModel } from "@/lib/backend"; +import { getLocalSetupStatus, getLlmProviderModels, getModelConfig, saveModelConfig, refreshOpenRouterModels, type EffectiveModelConfig, type LlmProviderType, type LocalSetupStatus, type OpenRouterModel } from "@/lib/backend"; import { SettingsPageLayout } from "@/components/settings/SettingsPageLayout"; import { SettingsHeader } from "@/components/settings/SettingsHeader"; import { SettingsTile } from "@/components/settings/SettingsTile"; @@ -12,6 +12,17 @@ import { ModelSideSheet } from "@/components/settings/ModelSideSheet"; import { MODEL_ROLES, type ModelRole } from "@/components/settings/types"; import { SkeletonList } from "@/components/settings/Skeleton"; import { useAppAuth } from "@/lib/app-auth"; +import { isLocalMode } from "@/lib/app-mode"; + +function modelListCacheKey(status: LocalSetupStatus): string { + const llm = status.services.llm; + return [ + llm.provider ?? "openrouter", + llm.baseUrl ?? "", + llm.defaultModel ?? "", + llm.verifiedAt ?? "", + ].join("|"); +} export default function ModelSettingsPage() { const { getToken } = useAppAuth(); @@ -21,21 +32,70 @@ export default function ModelSettingsPage() { const [isLoadingConfig, setIsLoadingConfig] = useState(true); const [refreshing, setRefreshing] = useState(false); const [sheetModels, setSheetModels] = useState([]); + const [sheetModelsCacheKey, setSheetModelsCacheKey] = useState(null); const [activeSheet, setActiveSheet] = useState<{ role: ModelRole } | null>(null); + const [llmProvider, setLlmProvider] = useState( + isLocalMode ? null : "openrouter", + ); + const [activeModelListCacheKey, setActiveModelListCacheKey] = useState( + isLocalMode ? "" : "openrouter|||", + ); + const activeModelListCacheKeyRef = useRef(activeModelListCacheKey); const [isSavingModel, setIsSavingModel] = useState(false); + const [modelConfigReloadKey, setModelConfigReloadKey] = useState(0); + + const needsOpenRouterCache = !isLocalMode || llmProvider === "openrouter"; + const isLoading = + (needsOpenRouterCache && convexModels === undefined) || + isLoadingConfig || + (isLocalMode && llmProvider === null); - const isLoading = convexModels === undefined || isLoadingConfig; + const syncLlmProvider = useCallback((status: LocalSetupStatus) => { + const nextCacheKey = modelListCacheKey(status); + activeModelListCacheKeyRef.current = nextCacheKey; + setLlmProvider(status.services.llm.provider ?? "openrouter"); + setActiveModelListCacheKey(nextCacheKey); + }, []); + + const handleLocalCredentialsStatus = useCallback( + (status: LocalSetupStatus) => { + syncLlmProvider(status); + setSheetModels([]); + setSheetModelsCacheKey(null); + setModelConfigReloadKey((key) => key + 1); + }, + [syncLlmProvider], + ); useEffect(() => { + let active = true; + getToken() .then((token) => { if (!token) throw new Error("Not authenticated"); return getModelConfig(token); }) - .then((config) => setEffectiveConfig(config)) - .catch(() => setEffectiveConfig(null)) - .finally(() => setIsLoadingConfig(false)); - }, [getToken]); + .then((config) => { + if (active) setEffectiveConfig(config); + }) + .catch(() => { + if (active) setEffectiveConfig(null); + }) + .finally(() => { + if (active) setIsLoadingConfig(false); + }); + + return () => { + active = false; + }; + }, [getToken, modelConfigReloadKey]); + + useEffect(() => { + if (!isLocalMode) return; + getLocalSetupStatus() + .then(syncLlmProvider) + .catch(() => setLlmProvider("openrouter")); + }, [syncLlmProvider]); const models: OpenRouterModel[] = convexModels ? convexModels.map((m) => ({ @@ -46,19 +106,28 @@ export default function ModelSettingsPage() { promptCost: m.promptCost, })) : []; + const sideSheetModels = + sheetModels.length > 0 + ? sheetModels + : llmProvider === "openrouter" + ? models + : []; function getSelectedModel(role: ModelRole): string { return effectiveConfig?.[role.key as keyof typeof effectiveConfig] ?? ""; } - async function handleModelSelect(role: ModelRole, model: OpenRouterModel) { + async function saveModelForRole(role: ModelRole, modelId: string) { + const nextModelId = modelId.trim(); + if (!nextModelId) return; + setIsSavingModel(true); try { const token = await getToken(); if (!token) throw new Error("Not authenticated"); - await saveModelConfig({ [role.key]: model.canonicalSlug }, token); + await saveModelConfig({ [role.key]: nextModelId }, token); setEffectiveConfig((prev: EffectiveModelConfig | null) => - prev ? { ...prev, [role.key]: model.canonicalSlug } : null + prev ? { ...prev, [role.key]: nextModelId } : null ); setActiveSheet(null); } catch { @@ -69,9 +138,16 @@ export default function ModelSettingsPage() { } function openSideSheet(role: ModelRole) { - if (sheetModels.length === 0) { - getOpenRouterModels() - .then((models) => setSheetModels(models)) + const cacheKey = activeModelListCacheKeyRef.current || activeModelListCacheKey; + if (sheetModels.length === 0 || sheetModelsCacheKey !== cacheKey) { + setSheetModels([]); + setSheetModelsCacheKey(cacheKey); + getLlmProviderModels() + .then((models) => { + if (activeModelListCacheKeyRef.current !== cacheKey) return; + setSheetModels(models); + setSheetModelsCacheKey(cacheKey); + }) .catch(() => { // we will add toast later }); @@ -117,11 +193,15 @@ export default function ModelSettingsPage() { return (
- +
@@ -147,18 +227,23 @@ export default function ModelSettingsPage() { onClose={() => !isSavingModel && setActiveSheet(null)} title={`Select ${activeSheet.role.label} Model`} selectedModel={getSelectedModel(activeSheet.role)} - models={sheetModels.length > 0 ? sheetModels : models} - onSelect={(slug) => { - const sourceModels = sheetModels.length > 0 ? sheetModels : models; - const model = sourceModels.find((m) => m.canonicalSlug === slug); - if (model) handleModelSelect(activeSheet.role, model); - }} + models={sideSheetModels} + onSelect={(slug) => saveModelForRole(activeSheet.role, slug)} onRefresh={async () => { setRefreshing(true); try { - const token = await getToken(); - if (!token) throw new Error("Not authenticated"); - const models = await refreshOpenRouterModels(token); + const provider = llmProvider ?? "openrouter"; + const cacheKey = activeModelListCacheKeyRef.current || activeModelListCacheKey; + let models: OpenRouterModel[]; + if (provider === "openrouter") { + const token = await getToken(); + if (!token) throw new Error("Not authenticated"); + models = await refreshOpenRouterModels(token); + } else { + models = await getLlmProviderModels(); + } + if (activeModelListCacheKeyRef.current !== cacheKey) return; + setSheetModelsCacheKey(cacheKey); setSheetModels(models); } catch { // we will add toast later @@ -170,6 +255,8 @@ export default function ModelSettingsPage() { isSaving={isSavingModel} /> )} + + ); } diff --git a/frontend/app/setup/page.tsx b/frontend/app/setup/page.tsx index 9e6bd71..2814ad3 100644 --- a/frontend/app/setup/page.tsx +++ b/frontend/app/setup/page.tsx @@ -11,18 +11,24 @@ import { } from "lucide-react"; import { getLocalSetupStatus, - saveOpenRouterApiKey, + saveLlmProviderConfig, saveTinyFishApiKey, + type LlmProviderType, type LocalSetupStatus, type ServiceSetupStatus, } from "@/lib/backend"; import { isLocalMode } from "@/lib/app-mode"; +import { + LlmProviderBrand, + LlmProviderSelector, + llmProviderOption, +} from "@/components/settings/llm-providers"; export default function SetupPage() { const router = useRouter(); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); - const [modal, setModal] = useState<"tinyfish" | "openrouter" | null>(null); + const [modal, setModal] = useState<"tinyfish" | "llm" | null>(null); useEffect(() => { if (!isLocalMode) { @@ -59,8 +65,8 @@ export default function SetupPage() { Connect your services

- Add TinyFish and OpenRouter access to start building live - datasets. + Add TinyFish and your preferred LLM provider to start building + live datasets.

@@ -92,18 +98,18 @@ export default function SetupPage() { /> } - description="BigSet uses OpenRouter's API to power BigSet with AI model access." - status={status?.services.openrouter} + brand={} + description="BigSet uses your LLM provider for schema generation and dataset-building agents." + status={status?.services.llm} primaryLabel={ - status?.services.openrouter.configured - ? "Update key" - : "Add API key" + status?.services.llm.configured + ? "Update provider" + : "Choose provider" } - onPrimary={() => setModal("openrouter")} - helperHref="https://openrouter.ai/settings/keys" - helperLabel="Need an OpenRouter key?" - helperDescription="Open the OpenRouter keys page" + onPrimary={() => setModal("llm")} + helperHref="https://platform.openai.com/api-keys" + helperLabel="Bring your own model" + helperDescription="OpenAI, Anthropic, OpenRouter, or custom" />
@@ -166,10 +172,11 @@ function ServiceCard({ const connected = status?.configured ?? false; const detail = useMemo(() => { if (!connected) return "Not connected"; + if (status?.providerLabel) return status.providerLabel; if (status?.connectionMethod === "oauth") return "Connected through OAuth"; if (status?.source === "env") return "Connected through .env"; return "Connected through API key"; - }, [connected, status?.connectionMethod, status?.source]); + }, [connected, status?.connectionMethod, status?.providerLabel, status?.source]); return (
@@ -224,56 +231,48 @@ function ServiceCard({ ); } -function OpenRouterBrand() { - return ( -
- - OpenRouter -
- ); -} - function ApiKeyModal({ service, onClose, onSaved, }: { - service: "tinyfish" | "openrouter"; + service: "tinyfish" | "llm"; onClose: () => void; onSaved: (status: LocalSetupStatus) => void; }) { const [apiKey, setApiKey] = useState(""); + const [provider, setProvider] = useState("openrouter"); + const [baseUrl, setBaseUrl] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const isTinyFish = service === "tinyfish"; + const providerCopy = llmProviderOption(provider); + + function handleProviderChange(next: LlmProviderType) { + setProvider(next); + setBaseUrl(""); + } async function handleSubmit() { - if (!apiKey.trim() || saving) return; + if (saving) return; + if (isTinyFish && !apiKey.trim()) return; + if (!isTinyFish && provider !== "custom" && !apiKey.trim()) return; + if (!isTinyFish && provider === "custom" && !baseUrl.trim()) { + setError("Custom providers require a base URL"); + return; + } + setSaving(true); setError(null); try { const next = isTinyFish ? await saveTinyFishApiKey(apiKey.trim()) - : await saveOpenRouterApiKey(apiKey.trim()); + : await saveLlmProviderConfig({ + provider, + apiKey: apiKey.trim(), + defaultModel: llmProviderOption(provider).defaultModel, + baseUrl: provider === "custom" ? baseUrl.trim() : undefined, + }); onSaved(next); } catch (err) { setError(err instanceof Error ? err.message : "Verification failed"); @@ -282,6 +281,18 @@ function ApiKeyModal({ } } + const helperHref = isTinyFish + ? "https://agent.tinyfish.ai/api-keys?utm_source=github&utm_medium=organic&utm_campaign=bigset-developer-2026q2" + : providerCopy.helperHref; + const helperLabel = !isTinyFish && provider === "custom" ? "Provider docs" : "Get a key"; + const canSubmit = + !saving && + (isTinyFish + ? !!apiKey.trim() + : provider === "custom" + ? !!baseUrl.trim() + : !!apiKey.trim()); + return (
@@ -114,10 +127,10 @@ export function LocalCredentialsPanel() { onApiKey={() => setModal("tinyfish")} /> setModal("openrouter")} + onApiKey={() => setModal("llm")} /> )} @@ -128,6 +141,7 @@ export function LocalCredentialsPanel() { onClose={() => setModal(null)} onSaved={(next) => { setStatus(next); + onStatusChange?.(next); setModal(null); }} /> @@ -156,7 +170,7 @@ function CredentialCard({
- +

{detail}

@@ -174,7 +188,7 @@ function CredentialCard({ className="inline-flex w-fit items-center gap-2 rounded-lg border border-accent bg-accent px-4 py-2.5 text-sm font-semibold text-accent-text transition-opacity hover:opacity-90" > - {connected ? "Update key" : "Add API key"} + {service === "llm" ? (connected ? "Update provider" : "Choose provider") : connected ? "Update key" : "Add API key"} @@ -208,35 +228,7 @@ function ServiceBrand({ service }: { service: ServiceName }) { ); } - return ; -} - -function OpenRouterBrand() { - return ( -
- - OpenRouter -
- ); + return ; } function StatusLabel({ @@ -272,10 +264,11 @@ function useCredentialDetail( return useMemo(() => { if (loading) return "Checking connection..."; if (!status?.configured) return "Not connected"; + if (status.providerLabel) return status.providerLabel; if (status.connectionMethod === "oauth") return "Connected through OAuth"; if (status.source === "env") return "Connected through .env"; return "Connected through API key"; - }, [loading, status?.configured, status?.connectionMethod, status?.source]); + }, [loading, status?.configured, status?.connectionMethod, status?.providerLabel, status?.source]); } function ApiKeyModal({ @@ -288,19 +281,39 @@ function ApiKeyModal({ onSaved: (status: LocalSetupStatus) => void; }) { const [apiKey, setApiKey] = useState(""); + const [provider, setProvider] = useState("openrouter"); + const [baseUrl, setBaseUrl] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const copy = SERVICE_COPY[service]; const isTinyFish = service === "tinyfish"; + const providerCopy = llmProviderOption(provider); + + function handleProviderChange(next: LlmProviderType) { + setProvider(next); + setBaseUrl(""); + } async function handleSubmit() { - if (!apiKey.trim() || saving) return; + if (saving) return; + if (isTinyFish && !apiKey.trim()) return; + if (!isTinyFish && provider !== "custom" && !apiKey.trim()) return; + if (!isTinyFish && provider === "custom" && !baseUrl.trim()) { + setError("Custom providers require a base URL"); + return; + } + setSaving(true); setError(null); try { const next = isTinyFish ? await saveTinyFishApiKey(apiKey.trim()) - : await saveOpenRouterApiKey(apiKey.trim()); + : await saveLlmProviderConfig({ + provider, + apiKey: apiKey.trim(), + defaultModel: llmProviderOption(provider).defaultModel, + baseUrl: provider === "custom" ? baseUrl.trim() : undefined, + }); onSaved(next); } catch (err) { setError(err instanceof Error ? err.message : "Verification failed"); @@ -309,6 +322,16 @@ function ApiKeyModal({ } } + const helperHref = isTinyFish ? copy.helperHref : providerCopy.helperHref; + const helperLabel = !isTinyFish && provider === "custom" ? "Provider docs" : "Get a key"; + const canSubmit = + !saving && + (isTinyFish + ? !!apiKey.trim() + : provider === "custom" + ? !!baseUrl.trim() + : !!apiKey.trim()); + return (
+
@@ -158,7 +195,6 @@ export function ModelSideSheet({ ) : providers.length === 0 ? (

No models found

-

Try a different search term

) : (
@@ -218,19 +254,21 @@ export function ModelSideSheet({

- {model.contextLength >= 1000 - ? `${(model.contextLength / 1000).toLocaleString()}K` - : model.contextLength.toLocaleString()} + {model.contextLength > 0 + ? model.contextLength >= 1000 + ? `${(model.contextLength / 1000).toLocaleString()}K` + : model.contextLength.toLocaleString() + : "—"}

- ${model.promptCost.toFixed(2)}/1M + {model.promptCost > 0 ? `$${model.promptCost.toFixed(2)}/1M` : "—"}

- ${model.completionCost.toFixed(2)}/1M + {model.completionCost > 0 ? `$${model.completionCost.toFixed(2)}/1M` : "—"}

diff --git a/frontend/components/settings/llm-providers.tsx b/frontend/components/settings/llm-providers.tsx new file mode 100644 index 0000000..64e87b6 --- /dev/null +++ b/frontend/components/settings/llm-providers.tsx @@ -0,0 +1,156 @@ +"use client"; + +import type { LlmProviderType } from "@/lib/backend"; + +export type LlmProviderOption = { + value: LlmProviderType; + label: string; + description: string; + defaultModel: string; + apiKeyPlaceholder: string; + helperHref: string; + iconSrc?: string; + wordmarkSrc?: string; +}; + +export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ + { + value: "openrouter", + label: "OpenRouter", + description: "Use OpenRouter model slugs.", + defaultModel: "anthropic/claude-sonnet-4.6", + apiKeyPlaceholder: "sk-or-...", + helperHref: "https://openrouter.ai/settings/keys", + iconSrc: "/logos/providers/openrouter.svg", + wordmarkSrc: "/logos/providers/openrouter-wordmark.svg", + }, + { + value: "openai", + label: "OpenAI", + description: "Use an OpenAI API key directly.", + defaultModel: "gpt-5.4-mini", + apiKeyPlaceholder: "sk-...", + helperHref: "https://platform.openai.com/api-keys", + iconSrc: "/logos/providers/openai.svg", + wordmarkSrc: "/logos/providers/openai.svg", + }, + { + value: "anthropic", + label: "Anthropic", + description: "Use a Claude API key directly.", + defaultModel: "claude-sonnet-4-6", + apiKeyPlaceholder: "sk-ant-...", + helperHref: "https://console.anthropic.com/settings/keys", + iconSrc: "/logos/providers/anthropic.svg", + wordmarkSrc: "/logos/providers/anthropic.svg", + }, + { + value: "custom", + label: "Custom", + description: "Use LM Studio or any OpenAI-compatible base URL.", + defaultModel: "", + apiKeyPlaceholder: "Optional — leave blank for LM Studio", + helperHref: "https://lmstudio.ai/docs/app/api/endpoints/openai", + }, +]; + +export function llmProviderOption(value: LlmProviderType) { + return ( + LLM_PROVIDER_OPTIONS.find((option) => option.value === value) ?? + LLM_PROVIDER_OPTIONS[0] + ); +} + +export function LlmProviderLogo({ + provider, + variant = "wordmark", + className = "", +}: { + provider: LlmProviderType; + variant?: "icon" | "wordmark"; + className?: string; +}) { + const option = llmProviderOption(provider); + + if (provider === "custom") { + return ( + + Custom + + ); + } + + const src = variant === "icon" ? option.iconSrc : option.wordmarkSrc; + + return ( + {option.label} + ); +} + +export function LlmProviderBrand({ provider }: { provider?: LlmProviderType }) { + if (provider) { + return ( +
+ +
+ ); + } + + return ( +
+ + AI + + LLM Provider +
+ ); +} + +export function LlmProviderSelector({ + value, + onChange, +}: { + value: LlmProviderType; + onChange: (provider: LlmProviderType) => void; +}) { + return ( +
+ {LLM_PROVIDER_OPTIONS.map((option) => { + const selected = option.value === value; + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/convex/localCredentials.ts b/frontend/convex/localCredentials.ts index d8e8c97..eeafd3f 100644 --- a/frontend/convex/localCredentials.ts +++ b/frontend/convex/localCredentials.ts @@ -3,7 +3,11 @@ import { v } from "convex/values"; const serviceValidator = v.union( v.literal("tinyfish"), + v.literal("llm"), v.literal("openrouter"), + v.literal("openai"), + v.literal("anthropic"), + v.literal("custom"), ); const connectionMethodValidator = v.union( @@ -11,6 +15,13 @@ const connectionMethodValidator = v.union( v.literal("oauth"), ); +const llmProviderValidator = v.union( + v.literal("openrouter"), + v.literal("openai"), + v.literal("anthropic"), + v.literal("custom"), +); + export const getInternal = internalQuery({ args: { service: serviceValidator }, handler: async (ctx, args) => { @@ -24,9 +35,12 @@ export const getInternal = internalQuery({ export const upsertInternal = internalMutation({ args: { service: serviceValidator, - keychainAccount: v.string(), + keychainAccount: v.optional(v.string()), connectionMethod: connectionMethodValidator, verifiedAt: v.number(), + llmProvider: v.optional(llmProviderValidator), + llmBaseUrl: v.optional(v.string()), + llmDefaultModel: v.optional(v.string()), }, handler: async (ctx, args) => { const existing = await ctx.db @@ -35,20 +49,39 @@ export const upsertInternal = internalMutation({ .unique(); const update = { - keychainAccount: args.keychainAccount, + ...(args.keychainAccount !== undefined + ? { keychainAccount: args.keychainAccount } + : {}), connectionMethod: args.connectionMethod, verifiedAt: args.verifiedAt, updatedAt: Date.now(), }; + const llmPatch = args.llmProvider !== undefined + ? { + llmProvider: args.llmProvider, + // Explicit undefined clears stale custom-provider values when the + // user switches back to OpenAI/Anthropic/OpenRouter. + llmBaseUrl: args.llmBaseUrl, + llmDefaultModel: args.llmDefaultModel, + } + : {}; + const llmInsert = args.llmProvider !== undefined + ? { + llmProvider: args.llmProvider, + ...(args.llmBaseUrl !== undefined ? { llmBaseUrl: args.llmBaseUrl } : {}), + ...(args.llmDefaultModel !== undefined ? { llmDefaultModel: args.llmDefaultModel } : {}), + } + : {}; if (existing) { - await ctx.db.patch(existing._id, { ...update, apiKey: undefined }); + await ctx.db.patch(existing._id, { ...update, ...llmPatch, apiKey: undefined }); return existing._id; } return await ctx.db.insert("localCredentials", { service: args.service, ...update, + ...llmInsert, }); }, }); diff --git a/frontend/convex/modelConfig.ts b/frontend/convex/modelConfig.ts index e10fd3d..dc47991 100644 --- a/frontend/convex/modelConfig.ts +++ b/frontend/convex/modelConfig.ts @@ -1,32 +1,65 @@ import { query, mutation, internalQuery, internalMutation } from "./_generated/server.js"; +import type { MutationCtx, QueryCtx } from "./_generated/server.js"; import { v } from "convex/values"; import { getIdentity } from "./lib/authz.js"; +type LlmProvider = "openrouter" | "openai" | "anthropic" | "custom"; + +const providerValidator = v.union( + v.literal("openrouter"), + v.literal("openai"), + v.literal("anthropic"), + v.literal("custom"), +); + +async function findProviderConfig( + ctx: QueryCtx | MutationCtx, + userId: string, + provider: LlmProvider, +) { + const providerRow = await ctx.db + .query("modelConfig") + .withIndex("by_user_provider", (q) => + q.eq("userId", userId).eq("provider", provider), + ) + .first(); + + if (providerRow) return providerRow; + + if (provider === "openrouter") { + return await ctx.db + .query("modelConfig") + .withIndex("by_user", (q) => q.eq("userId", userId)) + .filter((q) => q.eq(q.field("provider"), undefined)) + .first(); + } + + return null; +} + export const get = query({ - args: {}, - handler: async (ctx) => { + args: { provider: v.optional(providerValidator) }, + handler: async (ctx, args) => { const identity = await getIdentity(ctx); if (!identity) return null; - const existing = await ctx.db - .query("modelConfig") - .withIndex("by_user", (q) => q.eq("userId", identity.subject)) - .first(); - return existing ?? null; + return await findProviderConfig( + ctx, + identity.subject, + args.provider ?? "openrouter", + ); }, }); /** - * Upsert one or more model preferences for the authenticated user. + * Upsert one or more model preferences for the authenticated user and provider. * * Only fields that are explicitly provided (not undefined) are updated. * Unset fields retain their existing database values. - * - * Example: sending only { schemaInference: "x" } will update schemaInference - * while leaving populateOrchestrator and investigateSubagent untouched. */ export const upsert = mutation({ args: { + provider: v.optional(providerValidator), schemaInference: v.optional(v.string()), populateOrchestrator: v.optional(v.string()), investigateSubagent: v.optional(v.string()), @@ -35,67 +68,64 @@ export const upsert = mutation({ const identity = await getIdentity(ctx); if (!identity) throw new Error("Not authenticated"); - const existing = await ctx.db - .query("modelConfig") - .withIndex("by_user", (q) => q.eq("userId", identity.subject)) - .first(); + const provider = args.provider ?? "openrouter"; + const existing = await findProviderConfig(ctx, identity.subject, provider); + + const patch: { + provider?: LlmProvider; + schemaInference?: string; + populateOrchestrator?: string; + investigateSubagent?: string; + } = { provider }; + if (args.schemaInference !== undefined) patch.schemaInference = args.schemaInference; + if (args.populateOrchestrator !== undefined) patch.populateOrchestrator = args.populateOrchestrator; + if (args.investigateSubagent !== undefined) patch.investigateSubagent = args.investigateSubagent; if (existing) { - // Partial update — only touch fields that were explicitly provided. - // Omitting a field preserves its current database value. - const patch: Record = {}; - if (args.schemaInference !== undefined) patch.schemaInference = args.schemaInference; - if (args.populateOrchestrator !== undefined) patch.populateOrchestrator = args.populateOrchestrator; - if (args.investigateSubagent !== undefined) patch.investigateSubagent = args.investigateSubagent; await ctx.db.patch(existing._id, patch); } else { - // First-time save — build insert object from provided fields only. - // userId is always required and comes from the authenticated identity. - const insert: { - userId: string; - schemaInference?: string; - populateOrchestrator?: string; - investigateSubagent?: string; - } = { userId: identity.subject }; - if (args.schemaInference !== undefined) insert.schemaInference = args.schemaInference; - if (args.populateOrchestrator !== undefined) insert.populateOrchestrator = args.populateOrchestrator; - if (args.investigateSubagent !== undefined) insert.investigateSubagent = args.investigateSubagent; - await ctx.db.insert("modelConfig", insert); + await ctx.db.insert("modelConfig", { + userId: identity.subject, + ...patch, + }); } }, }); export const getInternal = internalQuery({ - args: { userId: v.string() }, + args: { userId: v.string(), provider: v.optional(providerValidator) }, handler: async (ctx, args) => { - const existing = await ctx.db - .query("modelConfig") - .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .first(); - return existing ?? null; + return await findProviderConfig( + ctx, + args.userId, + args.provider ?? "openrouter", + ); }, }); /** - * Upsert model preferences for a specific user (internal, backend-only). + * Upsert model preferences for a specific user/provider (internal, backend-only). * * Only fields that are explicitly provided (not undefined) are updated. - * Unset fields are omitted from the insert, leaving the database unchanged. */ export const upsertInternal = internalMutation({ args: { userId: v.string(), + provider: v.optional(providerValidator), schemaInference: v.optional(v.string()), populateOrchestrator: v.optional(v.string()), investigateSubagent: v.optional(v.string()), }, handler: async (ctx, args) => { - const existing = await ctx.db - .query("modelConfig") - .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .first(); + const provider = args.provider ?? "openrouter"; + const existing = await findProviderConfig(ctx, args.userId, provider); - const patch: Record = {}; + const patch: { + provider: LlmProvider; + schemaInference?: string; + populateOrchestrator?: string; + investigateSubagent?: string; + } = { provider }; if (args.schemaInference !== undefined) patch.schemaInference = args.schemaInference; if (args.populateOrchestrator !== undefined) patch.populateOrchestrator = args.populateOrchestrator; if (args.investigateSubagent !== undefined) patch.investigateSubagent = args.investigateSubagent; diff --git a/frontend/convex/schema.ts b/frontend/convex/schema.ts index 83ea007..458dcc5 100644 --- a/frontend/convex/schema.ts +++ b/frontend/convex/schema.ts @@ -135,17 +135,48 @@ export default defineSchema({ modelConfig: defineTable({ userId: v.string(), + provider: v.optional( + v.union( + v.literal("openrouter"), + v.literal("openai"), + v.literal("anthropic"), + v.literal("custom") + ) + ), schemaInference: v.optional(v.string()), populateOrchestrator: v.optional(v.string()), investigateSubagent: v.optional(v.string()), - }).index("by_user", ["userId"]), + }) + .index("by_user", ["userId"]) + .index("by_user_provider", ["userId", "provider"]), localCredentials: defineTable({ - service: v.union(v.literal("tinyfish"), v.literal("openrouter")), + service: v.union( + v.literal("tinyfish"), + v.literal("llm"), + v.literal("openrouter"), + v.literal("openai"), + v.literal("anthropic"), + v.literal("custom") + ), keychainAccount: v.optional(v.string()), connectionMethod: v.union(v.literal("api_key"), v.literal("oauth")), verifiedAt: v.number(), updatedAt: v.number(), + // For service:"llm" this stores the active local LLM provider. + // Provider-specific rows store each provider's keychain account and + // optional custom base URL so users can switch providers without + // re-entering keys. + llmProvider: v.optional( + v.union( + v.literal("openrouter"), + v.literal("openai"), + v.literal("anthropic"), + v.literal("custom") + ) + ), + llmBaseUrl: v.optional(v.string()), + llmDefaultModel: v.optional(v.string()), // Legacy only: accepted so the migration can deploy, then cleared by the // backend startup purge. New code never writes this field. apiKey: v.optional(v.string()), diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index f5ea3e5..09f7951 100644 --- a/frontend/lib/backend.ts +++ b/frontend/lib/backend.ts @@ -46,7 +46,7 @@ export interface EffectiveModelConfig { } /** - * User's saved model preferences — stores the canonical slug (e.g. "anthropic/claude-sonnet-4.6") + * User's saved model preferences — stores the provider model id (e.g. "openai/gpt-5.4-mini" or "gpt-5.4-mini") * for each agent role. Null means no preference saved — backend will use the env default. */ export interface SavedModelConfig { @@ -63,11 +63,17 @@ export interface OpenRouterModel { promptCost: number; } +export type LlmProviderType = "openrouter" | "openai" | "anthropic" | "custom"; + export interface ServiceSetupStatus { configured: boolean; source: "local" | "env" | null; connectionMethod: "api_key" | "oauth" | null; verifiedAt: number | null; + provider?: LlmProviderType; + providerLabel?: string; + baseUrl?: string; + defaultModel?: string; } export interface LocalSetupStatus { @@ -76,7 +82,9 @@ export interface LocalSetupStatus { complete: boolean; services: { tinyfish: ServiceSetupStatus; - openrouter: ServiceSetupStatus; + llm: ServiceSetupStatus; + llmProviders?: Record; + openrouter?: ServiceSetupStatus; }; } @@ -116,13 +124,16 @@ export async function saveTinyFishApiKey( return res.json(); } -export async function saveOpenRouterApiKey( - apiKey: string, -): Promise { - const res = await fetch(`${BACKEND_URL}/local-setup/openrouter-key`, { +export async function saveLlmProviderConfig(config: { + provider: LlmProviderType; + apiKey?: string; + defaultModel: string; + baseUrl?: string; +}): Promise { + const res = await fetch(`${BACKEND_URL}/local-setup/llm-provider`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ apiKey }), + body: JSON.stringify(config), }); if (!res.ok) { @@ -132,6 +143,16 @@ export async function saveOpenRouterApiKey( return res.json(); } +export async function saveOpenRouterApiKey( + apiKey: string, +): Promise { + return saveLlmProviderConfig({ + provider: "openrouter", + apiKey, + defaultModel: "openai/gpt-5.4-mini", + }); +} + export async function exchangeOpenRouterOAuth( code: string, codeVerifier: string, @@ -238,6 +259,21 @@ export async function getOpenRouterModels(): Promise { return data.models ?? []; } +export async function getLlmProviderModels(): Promise { + const res = await fetch(`${BACKEND_URL}/llm-provider/models`, { + method: "GET", + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + const message = body?.error || `Backend error (${res.status})`; + throw new Error(message); + } + + const data = await res.json(); + return data.models ?? []; +} + /** * Refresh the OpenRouter model cache by fetching the latest list from the * OpenRouter API and storing it in Convex. diff --git a/frontend/public/logos/providers/anthropic.svg b/frontend/public/logos/providers/anthropic.svg new file mode 100644 index 0000000..cbcc39e --- /dev/null +++ b/frontend/public/logos/providers/anthropic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/logos/providers/openai.svg b/frontend/public/logos/providers/openai.svg new file mode 100644 index 0000000..367828b --- /dev/null +++ b/frontend/public/logos/providers/openai.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/logos/providers/openrouter-wordmark.svg b/frontend/public/logos/providers/openrouter-wordmark.svg new file mode 100644 index 0000000..f4bfcbb --- /dev/null +++ b/frontend/public/logos/providers/openrouter-wordmark.svg @@ -0,0 +1,10 @@ + + OpenRouter + + + + + + + OpenRouter + diff --git a/frontend/public/logos/providers/openrouter.svg b/frontend/public/logos/providers/openrouter.svg new file mode 100644 index 0000000..d71ed64 --- /dev/null +++ b/frontend/public/logos/providers/openrouter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/makefiles/Makefile b/makefiles/Makefile index cd7ef5e..2eccc1a 100644 --- a/makefiles/Makefile +++ b/makefiles/Makefile @@ -62,7 +62,7 @@ validate-dev-env: fi @prod="$$(grep '^PROD=' .env | cut -d= -f2-)"; \ if [[ "$$prod" != "1" ]]; then \ - echo "Local mode: Clerk, TinyFish, and OpenRouter env validation skipped."; \ + echo "Local mode: Clerk, TinyFish, and LLM provider env validation skipped."; \ fi @prod="$$(grep '^PROD=' .env | cut -d= -f2-)"; \ if [[ "$$prod" != "1" ]]; then exit 0; fi; \ @@ -198,7 +198,9 @@ convex-env: fi; \ if [[ "$$prod" != "1" ]]; then \ $(CONVEX_CLI_RUN) -e CONVEX_SELF_HOSTED_ADMIN_KEY="$$admin_key" frontend sh -lc \ - 'npx convex env set BIGSET_LOCAL_MODE 1 --url $(CONVEX_CLI_URL) --admin-key "$$CONVEX_SELF_HOSTED_ADMIN_KEY"'; \ + 'npx convex env set BIGSET_LOCAL_MODE 1 --url $(CONVEX_CLI_URL) --admin-key "$$CONVEX_SELF_HOSTED_ADMIN_KEY"' || exit 1; \ + $(CONVEX_CLI_RUN) -e CONVEX_SELF_HOSTED_ADMIN_KEY="$$admin_key" frontend sh -lc \ + 'npx convex env set CLERK_JWT_ISSUER_DOMAIN "https://bigset.local.invalid" --url $(CONVEX_CLI_URL) --admin-key "$$CONVEX_SELF_HOSTED_ADMIN_KEY"' || exit 1; \ exit 0; \ fi; \ issuer="$$(grep '^CLERK_JWT_ISSUER_DOMAIN=' .env | cut -d= -f2-)"; \ From 8cf6beaafd9b7392b07ff5389d8d0901091bf640 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 9 Jun 2026 15:57:22 -0700 Subject: [PATCH 2/2] tested a little bit and it should work i think? idk --- backend/package-lock.json | 176 ++++++ backend/package.json | 10 + backend/prompts/schema-inference.txt | 2 +- backend/src/config/llm.ts | 404 +++++++++++- backend/src/config/models.ts | 257 +++++++- backend/src/index.ts | 7 +- backend/src/local-credential-types.ts | 12 + backend/src/local-credentials.ts | 54 +- backend/src/mastra/agents/investigate.ts | 5 +- backend/src/mastra/agents/refresh.ts | 7 +- backend/src/mastra/tools/dataset-tools.ts | 43 +- backend/src/mastra/tools/investigate-tool.ts | 17 +- backend/src/mastra/workflows/populate.ts | 3 +- backend/src/pipeline/schema-inference.ts | 2 +- backend/src/pipeline/types.ts | 6 +- frontend/app/setup/page.tsx | 592 +++++++++++++++--- .../settings/LocalCredentialsPanel.tsx | 387 +++++++++--- .../components/settings/llm-providers.tsx | 560 +++++++++++++++-- frontend/convex/localCredentials.ts | 24 + frontend/convex/modelConfig.ts | 30 +- frontend/convex/schema.ts | 36 ++ frontend/lib/backend.ts | 24 +- frontend/lib/openrouter-oauth.ts | 90 +++ .../public/logos/providers/anthropic-icon.svg | 1 + frontend/public/logos/providers/deepinfra.svg | 29 + frontend/public/logos/providers/deepseek.svg | 1 + .../public/logos/providers/fireworks-ai.svg | 1 + frontend/public/logos/providers/google-g.svg | 1 + frontend/public/logos/providers/groq.svg | 1 + .../public/logos/providers/huggingface.svg | 1 + frontend/public/logos/providers/lmstudio.svg | 1 + .../public/logos/providers/mistral-ai.svg | 1 + frontend/public/logos/providers/ollama.svg | 1 + .../public/logos/providers/openai-icon.svg | 1 + frontend/public/logos/providers/qwen.svg | 1 + .../public/logos/providers/together-ai.svg | 1 + frontend/public/logos/providers/xai.svg | 1 + 37 files changed, 2419 insertions(+), 371 deletions(-) create mode 100644 frontend/public/logos/providers/anthropic-icon.svg create mode 100644 frontend/public/logos/providers/deepinfra.svg create mode 100644 frontend/public/logos/providers/deepseek.svg create mode 100644 frontend/public/logos/providers/fireworks-ai.svg create mode 100644 frontend/public/logos/providers/google-g.svg create mode 100644 frontend/public/logos/providers/groq.svg create mode 100644 frontend/public/logos/providers/huggingface.svg create mode 100644 frontend/public/logos/providers/lmstudio.svg create mode 100644 frontend/public/logos/providers/mistral-ai.svg create mode 100644 frontend/public/logos/providers/ollama.svg create mode 100644 frontend/public/logos/providers/openai-icon.svg create mode 100644 frontend/public/logos/providers/qwen.svg create mode 100644 frontend/public/logos/providers/together-ai.svg create mode 100644 frontend/public/logos/providers/xai.svg diff --git a/backend/package-lock.json b/backend/package-lock.json index c9a41ed..7ceb1f9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,10 +8,20 @@ "name": "bigset-backend", "version": "0.1.0", "dependencies": { + "@ai-sdk/alibaba": "^1.0.26", "@ai-sdk/anthropic": "^3.0.81", + "@ai-sdk/deepinfra": "^2.0.52", + "@ai-sdk/deepseek": "^2.0.35", + "@ai-sdk/fireworks": "^2.0.53", + "@ai-sdk/google": "^3.0.80", + "@ai-sdk/groq": "^3.0.39", + "@ai-sdk/huggingface": "^1.0.50", + "@ai-sdk/mistral": "^3.0.37", "@ai-sdk/openai": "^3.0.68", "@ai-sdk/openai-compatible": "^2.0.48", "@ai-sdk/provider": "^3.0.10", + "@ai-sdk/togetherai": "^2.0.53", + "@ai-sdk/xai": "^3.0.93", "@clerk/backend": "^3.4.11", "@fastify/cors": "^11.0.0", "@mastra/core": "^1.36.0", @@ -61,6 +71,23 @@ } } }, + "node_modules/@ai-sdk/alibaba": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@ai-sdk/alibaba/-/alibaba-1.0.26.tgz", + "integrity": "sha512-8j4QPWKDTraUlXh3+6AotaLA4CewOdLMBCtk8SATWpjwSSCfrUS8ExGEuwNSXx1eLIDiGVjwIb+hbtZL1e6fLw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.48", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/anthropic": { "version": "3.0.81", "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.81.tgz", @@ -77,6 +104,56 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/deepinfra": { + "version": "2.0.52", + "resolved": "https://registry.npmjs.org/@ai-sdk/deepinfra/-/deepinfra-2.0.52.tgz", + "integrity": "sha512-/S4WchqBHlZr0wZmpWfDafM6omNz5i1tIo7WeQ6Hd5y/Jz1le1uWW0AEb1J0ehw+uYMEHc/bLA9RMBf1zehIOg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.48", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/deepseek": { + "version": "2.0.35", + "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-2.0.35.tgz", + "integrity": "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/fireworks": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/@ai-sdk/fireworks/-/fireworks-2.0.53.tgz", + "integrity": "sha512-HjeiGsdxSzrCkOf2l2V+K+opzlqxBtduBq6BCiohAdgQk2KdZmI/67SMkBM6Kdze/BjUXiZlv0d7zNICPhxVDA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.48", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/gateway": { "version": "3.0.116", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.116.tgz", @@ -94,6 +171,71 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/google": { + "version": "3.0.80", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.80.tgz", + "integrity": "sha512-5ORbm/yFUPO0MEvZsxBMN0cdKw2+lwU/wVn5KN3KF8Dmk1LughuDuUohMh/7iU/XFTiyB0OvmTW/tdV/J7O9zg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/groq": { + "version": "3.0.39", + "resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-3.0.39.tgz", + "integrity": "sha512-BZAr6DjCbzWQ0Qn1/TSsHo/bmCt4JaAMb4A7HCSUZBQCAcOjne/03D0sVjHnQhUC3TpwcmYiv7tHAviK7BluRw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/huggingface": { + "version": "1.0.50", + "resolved": "https://registry.npmjs.org/@ai-sdk/huggingface/-/huggingface-1.0.50.tgz", + "integrity": "sha512-2qn7UAP4q2YrQstKtyRSJro8ujicwgEgPChKMiTbTnAH7SDagI2TfciU+hC9adOP1dM9HDqFP+lVnl4+VBv6Aw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.48", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, + "node_modules/@ai-sdk/mistral": { + "version": "3.0.37", + "resolved": "https://registry.npmjs.org/@ai-sdk/mistral/-/mistral-3.0.37.tgz", + "integrity": "sha512-KkdaMjs4C2y+vrZWJE990E3ZxBFiOTHQ94ZlquuIttpphcqJTMxNoIpnKT/4UzMVWXL0BUEE2vs+1UEVXkN8Kg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/openai": { "version": "3.0.68", "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.68.tgz", @@ -229,6 +371,40 @@ "node": ">=18" } }, + "node_modules/@ai-sdk/togetherai": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/@ai-sdk/togetherai/-/togetherai-2.0.53.tgz", + "integrity": "sha512-M/qsqM1HMlFpWHxHTEorENCLFmBefwxhGTB+XVsjUh77gfyt1io8eg96c/CyWZTxCPa5GxLfl9mhPxp8wjWATg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.48", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/xai": { + "version": "3.0.93", + "resolved": "https://registry.npmjs.org/@ai-sdk/xai/-/xai-3.0.93.tgz", + "integrity": "sha512-HxazLIcSTgI0UQoq6ua0rcSR8+eXuNy0Qh4jkCY9EAWedYn6CQ0XD/j34U4/JYtO738xJdY+tE95okdqWqXkHA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.48", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", diff --git a/backend/package.json b/backend/package.json index 1bd11aa..9b1cb19 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,10 +10,20 @@ "mastra:dev": "node ../scripts/with-root-env.mjs mastra dev" }, "dependencies": { + "@ai-sdk/alibaba": "^1.0.26", "@ai-sdk/anthropic": "^3.0.81", + "@ai-sdk/deepinfra": "^2.0.52", + "@ai-sdk/deepseek": "^2.0.35", + "@ai-sdk/fireworks": "^2.0.53", + "@ai-sdk/google": "^3.0.80", + "@ai-sdk/groq": "^3.0.39", + "@ai-sdk/huggingface": "^1.0.50", + "@ai-sdk/mistral": "^3.0.37", "@ai-sdk/openai": "^3.0.68", "@ai-sdk/openai-compatible": "^2.0.48", "@ai-sdk/provider": "^3.0.10", + "@ai-sdk/togetherai": "^2.0.53", + "@ai-sdk/xai": "^3.0.93", "@clerk/backend": "^3.4.11", "@fastify/cors": "^11.0.0", "@mastra/core": "^1.36.0", diff --git a/backend/prompts/schema-inference.txt b/backend/prompts/schema-inference.txt index 9752429..8f5a720 100644 --- a/backend/prompts/schema-inference.txt +++ b/backend/prompts/schema-inference.txt @@ -3,7 +3,7 @@ You are a data engineering assistant that converts natural-language prompts into Your job is to: 1. Identify the universe of entities the user wants to collect. Each entity becomes one row in the dataset. -2. Pick a clear primary key — the column whose values uniquely identify each row. This is usually a name, ID, or canonical URL. Exactly one column must have `is_primary_key: true`, and its `name` must equal `primary_key`. The primary key column must have `nullable: false` and `is_enumerable: true`. +2. Pick a clear primary key — the column whose values uniquely identify each row. This is usually a name, ID, or canonical URL. Exactly one column must have `is_primary_key: true`, and `primary_key` must be a one-item array containing that column name. The primary key column must have `nullable: false` and `is_enumerable: true`. 3. Choose useful columns. Each column captures one fact about the entity. Use snake_case names. Mark `is_enumerable: true` only on columns whose values can be used to list all rows (typically just the primary key, and occasionally one or two others when a source page lists them alongside the primary key). 4. Set `retrieval_strategy`: - `search_fetch` — the data lives on a static page or sitemap that can be fetched as HTML. diff --git a/backend/src/config/llm.ts b/backend/src/config/llm.ts index 0c50523..cb603dc 100644 --- a/backend/src/config/llm.ts +++ b/backend/src/config/llm.ts @@ -1,7 +1,17 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"; +import { createAlibaba } from "@ai-sdk/alibaba"; import { createAnthropic } from "@ai-sdk/anthropic"; +import { createDeepInfra } from "@ai-sdk/deepinfra"; +import { createDeepSeek } from "@ai-sdk/deepseek"; +import { createFireworks } from "@ai-sdk/fireworks"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createGroq } from "@ai-sdk/groq"; +import { createHuggingFace } from "@ai-sdk/huggingface"; +import { createMistral } from "@ai-sdk/mistral"; import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { createTogetherAI } from "@ai-sdk/togetherai"; +import { createXai } from "@ai-sdk/xai"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { env } from "../env.js"; @@ -11,6 +21,18 @@ export const LLM_PROVIDER_TYPES = [ "openrouter", "openai", "anthropic", + "google", + "xai", + "deepseek", + "qwen", + "mistral", + "groq", + "togetherai", + "deepinfra", + "fireworks", + "huggingface", + "ollama", + "lmstudio", "custom", ] as const; @@ -40,6 +62,18 @@ export const LLM_PROVIDER_LABELS: Record = { openrouter: "OpenRouter", openai: "OpenAI", anthropic: "Anthropic", + google: "Google Gemini", + xai: "xAI", + deepseek: "DeepSeek", + qwen: "Qwen", + mistral: "Mistral AI", + groq: "Groq", + togetherai: "Together.ai", + deepinfra: "DeepInfra", + fireworks: "Fireworks AI", + huggingface: "Hugging Face", + ollama: "Ollama", + lmstudio: "LM Studio", custom: "Custom OpenAI-compatible", }; @@ -62,6 +96,66 @@ export const LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE: Record< populateOrchestrator: "claude-haiku-4-5-20251001", investigateSubagent: "claude-haiku-4-5-20251001", }, + google: { + schemaInference: "gemini-3.5-flash", + populateOrchestrator: "gemini-3.5-flash", + investigateSubagent: "gemini-3.5-flash", + }, + xai: { + schemaInference: "grok-4.3", + populateOrchestrator: "grok-4.3", + investigateSubagent: "grok-4.3", + }, + deepseek: { + schemaInference: "deepseek-chat", + populateOrchestrator: "deepseek-chat", + investigateSubagent: "deepseek-chat", + }, + qwen: { + schemaInference: "qwen-plus", + populateOrchestrator: "qwen-plus", + investigateSubagent: "qwen-plus", + }, + mistral: { + schemaInference: "mistral-large-latest", + populateOrchestrator: "mistral-large-latest", + investigateSubagent: "mistral-large-latest", + }, + groq: { + schemaInference: "openai/gpt-oss-120b", + populateOrchestrator: "openai/gpt-oss-120b", + investigateSubagent: "openai/gpt-oss-120b", + }, + togetherai: { + schemaInference: "Qwen/Qwen3.5-397B-A17B", + populateOrchestrator: "Qwen/Qwen3.5-397B-A17B", + investigateSubagent: "Qwen/Qwen3.5-397B-A17B", + }, + deepinfra: { + schemaInference: "meta-llama/Llama-3.3-70B-Instruct-Turbo", + populateOrchestrator: "meta-llama/Llama-3.3-70B-Instruct-Turbo", + investigateSubagent: "meta-llama/Llama-3.3-70B-Instruct-Turbo", + }, + fireworks: { + schemaInference: "accounts/fireworks/models/kimi-k2p5", + populateOrchestrator: "accounts/fireworks/models/kimi-k2p5", + investigateSubagent: "accounts/fireworks/models/kimi-k2p5", + }, + huggingface: { + schemaInference: "deepseek-ai/DeepSeek-V3-0324", + populateOrchestrator: "deepseek-ai/DeepSeek-V3-0324", + investigateSubagent: "deepseek-ai/DeepSeek-V3-0324", + }, + ollama: { + schemaInference: "", + populateOrchestrator: "", + investigateSubagent: "", + }, + lmstudio: { + schemaInference: "", + populateOrchestrator: "", + investigateSubagent: "", + }, custom: { schemaInference: "", populateOrchestrator: "", @@ -73,6 +167,18 @@ export const LLM_PROVIDER_DEFAULT_MODELS: Record = { openrouter: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.openrouter.schemaInference, openai: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.openai.schemaInference, anthropic: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.anthropic.schemaInference, + google: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.google.schemaInference, + xai: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.xai.schemaInference, + deepseek: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.deepseek.schemaInference, + qwen: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.qwen.schemaInference, + mistral: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.mistral.schemaInference, + groq: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.groq.schemaInference, + togetherai: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.togetherai.schemaInference, + deepinfra: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.deepinfra.schemaInference, + fireworks: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.fireworks.schemaInference, + huggingface: LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE.huggingface.schemaInference, + ollama: "", + lmstudio: "", custom: "", }; @@ -104,9 +210,62 @@ export function defaultBaseUrlForLlmProvider( if (provider === "openrouter") { return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"; } + if (provider === "google") { + return ( + process.env.GOOGLE_GENERATIVE_AI_BASE_URL || + "https://generativelanguage.googleapis.com/v1beta" + ); + } + if (provider === "xai") { + return process.env.XAI_BASE_URL || "https://api.x.ai/v1"; + } + if (provider === "deepseek") { + return process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com"; + } + if (provider === "qwen") { + return ( + process.env.QWEN_BASE_URL || + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + ); + } + if (provider === "mistral") { + return process.env.MISTRAL_BASE_URL || "https://api.mistral.ai/v1"; + } + if (provider === "groq") { + return process.env.GROQ_BASE_URL || "https://api.groq.com/openai/v1"; + } + if (provider === "togetherai") { + return process.env.TOGETHER_BASE_URL || "https://api.together.xyz/v1"; + } + if (provider === "deepinfra") { + return process.env.DEEPINFRA_BASE_URL || "https://api.deepinfra.com/v1"; + } + if (provider === "fireworks") { + return ( + process.env.FIREWORKS_BASE_URL || + "https://api.fireworks.ai/inference/v1" + ); + } + if (provider === "huggingface") { + return process.env.HUGGINGFACE_BASE_URL || "https://router.huggingface.co/v1"; + } + if (provider === "ollama") { + return process.env.OLLAMA_BASE_URL || "http://localhost:11434/v1"; + } + if (provider === "lmstudio") { + return process.env.LM_STUDIO_BASE_URL || "http://localhost:1234/v1"; + } return undefined; } +function isOpenAiCompatibleProvider(provider: LlmProviderType): boolean { + return provider === "custom" || provider === "ollama" || provider === "lmstudio"; +} + +function providerAllowsMissingApiKey(provider: LlmProviderType): boolean { + return isOpenAiCompatibleProvider(provider); +} + function isLoopbackHost(hostname: string): boolean { return ["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"].includes( hostname, @@ -150,17 +309,19 @@ export function normalizeLlmProviderInput( ): LlmProviderConfig { const provider = input.provider; const apiKey = input.apiKey.trim(); - if (!apiKey && provider !== "custom") { + if (!apiKey && !providerAllowsMissingApiKey(provider)) { throw new Error(`${llmProviderLabel(provider)} API key is required`); } const baseUrl = - provider === "custom" - ? normalizeCustomBaseUrl(input.baseUrl) + isOpenAiCompatibleProvider(provider) + ? normalizeCustomBaseUrl( + input.baseUrl ?? defaultBaseUrlForLlmProvider(provider), + ) : normalizeBaseUrl(input.baseUrl) ?? defaultBaseUrlForLlmProvider(provider); - if (provider === "custom" && !baseUrl) { - throw new Error("Custom providers require a base URL"); + if (isOpenAiCompatibleProvider(provider) && !baseUrl) { + throw new Error(`${llmProviderLabel(provider)} requires a base URL`); } const defaultModel = @@ -206,12 +367,84 @@ export function createLanguageModel( }); return provider(resolvedModelId); } + case "google": { + const provider = createGoogleGenerativeAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "xai": { + const provider = createXai({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "deepseek": { + const provider = createDeepSeek({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "qwen": { + const provider = createAlibaba({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "mistral": { + const provider = createMistral({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "groq": { + const provider = createGroq({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "togetherai": { + const provider = createTogetherAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "deepinfra": { + const provider = createDeepInfra({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "fireworks": { + const provider = createFireworks({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "huggingface": { + const provider = createHuggingFace({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(resolvedModelId); + } + case "ollama": + case "lmstudio": case "custom": { if (!config.baseUrl) { - throw new Error("Custom providers require a base URL"); + throw new Error(`${llmProviderLabel(config.provider)} requires a base URL`); } const provider = createOpenAICompatible({ - name: "custom", + name: config.provider, apiKey: config.apiKey || undefined, baseURL: config.baseUrl, }); @@ -220,55 +453,144 @@ export function createLanguageModel( } } -function providerVerificationRequest(config: LlmProviderConfig): { +export function modelsUrlForLlmProvider( + provider: LlmProviderType, + baseUrl?: string, +): string { + const resolvedBaseUrl = ( + baseUrl || + defaultBaseUrlForLlmProvider(provider) || + "https://api.openai.com/v1" + ).replace(/\/+$/, ""); + + if (provider === "deepinfra") { + return resolvedBaseUrl.endsWith("/openai") + ? `${resolvedBaseUrl}/models` + : `${resolvedBaseUrl}/openai/models`; + } + + return `${resolvedBaseUrl}/models`; +} + +type ProviderVerificationRequest = { url: string; headers: Record; -} { + method?: "GET" | "POST"; + body?: string; + fallbackStatuses?: number[]; +}; + +function openAiStyleModelsVerificationRequest( + config: LlmProviderConfig, +): ProviderVerificationRequest { + return { + url: modelsUrlForLlmProvider(config.provider, config.baseUrl), + headers: { Authorization: `Bearer ${config.apiKey}` }, + }; +} + +function qwenChatVerificationRequest( + config: LlmProviderConfig, +): ProviderVerificationRequest { + const baseUrl = ( + config.baseUrl || + defaultBaseUrlForLlmProvider("qwen") || + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + ).replace(/\/+$/, ""); + + return { + url: `${baseUrl}/chat/completions`, + method: "POST", + headers: { + Authorization: `Bearer ${config.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: config.defaultModel || defaultModelForLlmProvider("qwen"), + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + }), + }; +} + +function providerVerificationRequests( + config: LlmProviderConfig, +): ProviderVerificationRequest[] { switch (config.provider) { case "openrouter": { const baseUrl = (config.baseUrl || "https://openrouter.ai/api/v1").replace( /\/+$/, "", ); - return { + return [{ url: `${baseUrl}/key`, headers: { Authorization: `Bearer ${config.apiKey}` }, - }; + }]; } case "openai": { const baseUrl = (config.baseUrl || "https://api.openai.com/v1").replace( /\/+$/, "", ); - return { + return [{ url: `${baseUrl}/models`, headers: { Authorization: `Bearer ${config.apiKey}` }, - }; + }]; } case "anthropic": { const baseUrl = (config.baseUrl || "https://api.anthropic.com/v1").replace( /\/+$/, "", ); - return { + return [{ url: `${baseUrl}/models?limit=1`, headers: { "x-api-key": config.apiKey, "anthropic-version": "2023-06-01", }, - }; + }]; + } + case "google": { + const baseUrl = ( + config.baseUrl || "https://generativelanguage.googleapis.com/v1beta" + ).replace(/\/+$/, ""); + return [{ + url: `${baseUrl}/models`, + headers: { "x-goog-api-key": config.apiKey }, + }]; + } + case "qwen": { + return [ + { + ...openAiStyleModelsVerificationRequest(config), + fallbackStatuses: [404, 405], + }, + qwenChatVerificationRequest(config), + ]; + } + case "xai": + case "deepseek": + case "mistral": + case "groq": + case "togetherai": + case "deepinfra": + case "fireworks": + case "huggingface": { + return [openAiStyleModelsVerificationRequest(config)]; } + case "ollama": + case "lmstudio": case "custom": { if (!config.baseUrl) { - throw new Error("Custom providers require a base URL"); + throw new Error(`${llmProviderLabel(config.provider)} requires a base URL`); } const baseUrl = config.baseUrl.replace(/\/+$/, ""); - return { + return [{ url: `${baseUrl}/models`, headers: config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}, - }; + }]; } } } @@ -276,24 +598,41 @@ function providerVerificationRequest(config: LlmProviderConfig): { export async function verifyLlmProviderConfig( config: LlmProviderConfig, ): Promise { - const { url, headers } = providerVerificationRequest(config); + const requests = providerVerificationRequests(config); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + let lastResponseStatus: number | undefined; + let lastUrl: string | undefined; try { - const response = await fetch(url, { - headers, - signal: controller.signal, - }); + for (const request of requests) { + lastUrl = request.url; + const response = await fetch(request.url, { + method: request.method ?? "GET", + headers: request.headers, + body: request.body, + signal: controller.signal, + }); + + if (response.ok) return; - if (!response.ok) { + lastResponseStatus = response.status; + if (request.fallbackStatuses?.includes(response.status)) { + continue; + } if (response.status === 401 || response.status === 403) { - throw new Error(`${llmProviderLabel(config.provider)} rejected that API key.`); + throw new Error( + `${llmProviderLabel(config.provider)} rejected that API key.`, + ); } throw new Error( `${llmProviderLabel(config.provider)} verification failed with HTTP ${response.status}.`, ); } + + throw new Error( + `${llmProviderLabel(config.provider)} verification failed with HTTP ${lastResponseStatus ?? "unknown"}.`, + ); } catch (err) { if (err instanceof Error && err.name === "AbortError") { throw new Error( @@ -301,9 +640,20 @@ export async function verifyLlmProviderConfig( ); } if (err instanceof Error && err.message === "fetch failed") { - const displayUrl = url.replace("host.docker.internal", "localhost"); + const displayUrl = (lastUrl ?? requests[0]?.url ?? "").replace( + "host.docker.internal", + "localhost", + ); + const localHint = + config.provider === "ollama" + ? " Start Ollama and confirm the OpenAI-compatible endpoint is enabled." + : config.provider === "lmstudio" + ? " Start the LM Studio local server and confirm the port." + : config.provider === "custom" + ? " Check that the endpoint is running and reachable." + : ""; throw new Error( - `${llmProviderLabel(config.provider)} verification failed: could not reach ${displayUrl}. If this is LM Studio, start the local server and use http://localhost:1234 or http://localhost:1234/v1.`, + `${llmProviderLabel(config.provider)} verification failed: could not reach ${displayUrl}.${localHint}`, ); } throw err; diff --git a/backend/src/config/models.ts b/backend/src/config/models.ts index 0b65699..3da1baa 100644 --- a/backend/src/config/models.ts +++ b/backend/src/config/models.ts @@ -8,7 +8,12 @@ import { api, internal, convex } from "../convex.js"; import { env } from "../env.js"; import { getLlmProviderConfig, requireOpenRouterApiKey } from "../local-credentials.js"; import { FETCH_TIMEOUT_MS } from "../fetch-timeout.js"; -import { defaultModelForLlmProviderRole, type ModelRoleKey } from "./llm.js"; +import { + defaultBaseUrlForLlmProvider, + defaultModelForLlmProviderRole, + modelsUrlForLlmProvider, + type ModelRoleKey, +} from "./llm.js"; export interface OpenRouterModel { modelName: string; @@ -46,6 +51,98 @@ const OPENAI_MODEL_EXCLUDE_PATTERNS = [ "whisper", ]; +const GOOGLE_MODEL_EXCLUDE_PATTERNS = [ + "audio", + "embedding", + "imagen", + "image", + "live", + "lyria", + "nano-banana", + "robotics", + "tts", + "veo", +]; + +const TEXT_MODEL_EXCLUDE_PATTERNS = [ + "audio", + "babbage", + "dall-e", + "embedding", + "image", + "moderation", + "rerank", + "safeguard", + "sdxl", + "speech", + "stable-diffusion", + "transcribe", + "tts", + "video", + "voice", + "wan", + "whisper", +]; + +const QWEN_MODELS: OpenRouterModel[] = [ + { + modelName: "qwen-plus", + canonicalSlug: "qwen-plus", + contextLength: 0, + completionCost: 0, + promptCost: 0, + }, + { + modelName: "qwen3.5-plus", + canonicalSlug: "qwen3.5-plus", + contextLength: 0, + completionCost: 0, + promptCost: 0, + }, + { + modelName: "qwen3-max", + canonicalSlug: "qwen3-max", + contextLength: 0, + completionCost: 0, + promptCost: 0, + }, + { + modelName: "qwen-max", + canonicalSlug: "qwen-max", + contextLength: 0, + completionCost: 0, + promptCost: 0, + }, + { + modelName: "qwen-flash", + canonicalSlug: "qwen-flash", + contextLength: 0, + completionCost: 0, + promptCost: 0, + }, + { + modelName: "qwen3-235b-a22b-instruct-2507", + canonicalSlug: "qwen3-235b-a22b-instruct-2507", + contextLength: 0, + completionCost: 0, + promptCost: 0, + }, + { + modelName: "qwen3-235b-a22b-thinking-2507", + canonicalSlug: "qwen3-235b-a22b-thinking-2507", + contextLength: 0, + completionCost: 0, + promptCost: 0, + }, + { + modelName: "qwen3-coder-plus", + canonicalSlug: "qwen3-coder-plus", + contextLength: 0, + completionCost: 0, + promptCost: 0, + }, +]; + function isOpenAITextModelId(id: string): boolean { const lower = id.toLowerCase(); if (OPENAI_MODEL_EXCLUDE_PATTERNS.some((pattern) => lower.includes(pattern))) { @@ -60,28 +157,89 @@ function isOpenAITextModelId(id: string): boolean { ); } -function sortModels(models: OpenRouterModel[]): OpenRouterModel[] { - return models.sort((a, b) => a.modelName.localeCompare(b.modelName)); +function isGenericTextModelId(id: string): boolean { + const lower = id.toLowerCase(); + return !TEXT_MODEL_EXCLUDE_PATTERNS.some((pattern) => + lower.includes(pattern), + ); } -function isModelCompatibleWithProvider( - modelId: string | undefined, +function isGoogleTextModelId(id: string): boolean { + const lower = id.toLowerCase(); + if (GOOGLE_MODEL_EXCLUDE_PATTERNS.some((pattern) => lower.includes(pattern))) { + return false; + } + return ( + lower.startsWith("gemini-") || + lower.startsWith("gemma-") || + lower.startsWith("deep-research-") + ); +} + +function isMistralTextModelId(id: string): boolean { + const lower = id.toLowerCase(); + return ( + isGenericTextModelId(id) && + (lower.startsWith("mistral-") || + lower.startsWith("magistral-") || + lower.startsWith("ministral-") || + lower.startsWith("codestral-") || + lower.startsWith("devstral-") || + lower.startsWith("pixtral-")) + ); +} + +function isProviderTextModelId( + id: string, provider: Awaited>, -): modelId is string { - if (!modelId) return false; +): boolean { if (!provider) return true; switch (provider.provider) { case "openrouter": - return modelId.includes("/"); + return id.includes("/"); case "openai": - return isOpenAITextModelId(modelId) && !modelId.includes("/"); + return isOpenAITextModelId(id) && !id.includes("/"); case "anthropic": - return modelId.startsWith("claude-") && !modelId.includes("/"); + return id.startsWith("claude-") && !id.includes("/"); + case "google": + return isGoogleTextModelId(id) && !id.includes("/"); + case "xai": + return id.startsWith("grok-") && !id.includes("imagine"); + case "deepseek": + return id.startsWith("deepseek-"); + case "qwen": + return id.startsWith("qwen") || id.startsWith("qwq-"); + case "mistral": + return isMistralTextModelId(id); + case "groq": + case "togetherai": + case "deepinfra": + case "fireworks": + case "huggingface": + return isGenericTextModelId(id); + case "ollama": + case "lmstudio": case "custom": return true; } } +function googleModelIdFromName(name: string): string { + return name.replace(/^models\//, ""); +} + +function sortModels(models: OpenRouterModel[]): OpenRouterModel[] { + return models.sort((a, b) => a.modelName.localeCompare(b.modelName)); +} + +function isModelCompatibleWithProvider( + modelId: string | undefined, + provider: Awaited>, +): modelId is string { + if (!modelId) return false; + return isProviderTextModelId(modelId, provider); +} + function modelForProvider( savedModel: string | undefined, role: ModelRoleKey, @@ -175,25 +333,82 @@ export async function fetchModelsForCurrentLlmProvider(): Promise; + }>(`${baseUrl}/models`, { + "x-goog-api-key": config.apiKey, + }); + + return sortModels( + (json.models ?? []) + .map((model) => { + const modelId = model.baseModelId || googleModelIdFromName(model.name); + return { + model, + modelId, + actions: + model.supportedActions ?? model.supportedGenerationMethods ?? [], + }; + }) + .filter(({ modelId, actions }) => { + return ( + isGoogleTextModelId(modelId) && + (actions.length === 0 || actions.includes("generateContent")) + ); + }) + .map(({ model, modelId }) => ({ + modelName: model.displayName ?? modelId, + canonicalSlug: modelId, + contextLength: model.inputTokenLimit ?? 0, + completionCost: 0, + promptCost: 0, + })), + ); + } + + if (config.provider === "qwen") { + return sortModels([...QWEN_MODELS]); + } + + const baseUrl = ( + config.baseUrl || + defaultBaseUrlForLlmProvider(config.provider) || + "https://api.openai.com/v1" + ).replace(/\/+$/, ""); const headers: Record = - config.provider === "custom" && !config.apiKey + ["custom", "ollama", "lmstudio"].includes(config.provider) && !config.apiKey ? {} : { Authorization: `Bearer ${config.apiKey}` }; const json = await fetchJsonWithTimeout<{ - data?: Array<{ id: string }>; - }>(`${baseUrl}/models`, headers); + data?: Array<{ + id: string; + display_name?: string; + name?: string; + context_length?: number; + contextLength?: number; + }>; + }>(modelsUrlForLlmProvider(config.provider, baseUrl), headers); const models = (json.data ?? []) - .filter((model) => - config.provider === "openai" - ? isOpenAITextModelId(model.id) - : true, - ) + .filter((model) => isProviderTextModelId(model.id, config)) .map((model) => ({ - modelName: model.id, + modelName: model.display_name ?? model.name ?? model.id, canonicalSlug: model.id, - contextLength: 0, + contextLength: model.context_length ?? model.contextLength ?? 0, completionCost: 0, promptCost: 0, })); diff --git a/backend/src/index.ts b/backend/src/index.ts index 834a702..beb6291 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -732,9 +732,12 @@ fastify.post("/local-setup/llm-provider", async (req, reply) => { try { const apiKey = body.apiKey?.trim() ?? ""; - const isNewCustomWithoutKey = provider === "custom" && !!body.baseUrl?.trim(); + const isKeylessProvider = + provider === "custom" || provider === "ollama" || provider === "lmstudio"; + const isNewKeylessProvider = + isKeylessProvider && (provider !== "custom" || !!body.baseUrl?.trim()); - if (!apiKey && !isNewCustomWithoutKey) { + if (!apiKey && !isNewKeylessProvider) { const status = await getLocalSetupStatus(); const savedProvider = status.services.llmProviders?.[provider]; if (!savedProvider?.configured) { diff --git a/backend/src/local-credential-types.ts b/backend/src/local-credential-types.ts index 9d5fc95..f899fc1 100644 --- a/backend/src/local-credential-types.ts +++ b/backend/src/local-credential-types.ts @@ -3,6 +3,18 @@ export const LOCAL_CREDENTIAL_SERVICES = [ "openrouter", "openai", "anthropic", + "google", + "xai", + "deepseek", + "qwen", + "mistral", + "groq", + "togetherai", + "deepinfra", + "fireworks", + "huggingface", + "ollama", + "lmstudio", "custom", ] as const; diff --git a/backend/src/local-credentials.ts b/backend/src/local-credentials.ts index 5b7727d..16456ec 100644 --- a/backend/src/local-credentials.ts +++ b/backend/src/local-credentials.ts @@ -6,6 +6,7 @@ import { setKeychainCredential, } from "./local-keychain-client.js"; import { + LLM_PROVIDER_TYPES, defaultBaseUrlForLlmProvider, defaultModelForLlmProvider, isLlmProviderType, @@ -93,29 +94,33 @@ async function localCredential(service: LocalCredentialService): Promise<{ } | null; + const rowProvider = isLlmProviderType(rowData?.llmProvider) + ? rowData.llmProvider + : undefined; + const rowBaseUrl = + typeof rowData?.llmBaseUrl === "string" ? rowData.llmBaseUrl : undefined; + if ( + rowProvider && + service === rowProvider && + ["custom", "ollama", "lmstudio"].includes(rowProvider) && + rowBaseUrl + ) { + return { + apiKey: "", + connectionMethod: rowData?.connectionMethod ?? "api_key", + verifiedAt: rowData?.verifiedAt ?? null, + keychainAccount: rowData?.keychainAccount ?? "", + llmProvider: rowProvider, + llmBaseUrl: rowBaseUrl, + llmDefaultModel: + typeof rowData?.llmDefaultModel === "string" + ? rowData.llmDefaultModel + : undefined, + }; + } + const keychain = await getKeychainCredential(service); if (!keychain?.apiKey) { - // LM Studio and many local OpenAI-compatible servers do not require an - // API key. A custom-provider row with a base URL is therefore a valid - // local credential even when there is no keychain secret. - if ( - service === "custom" && - rowData?.llmProvider === "custom" && - typeof rowData.llmBaseUrl === "string" - ) { - return { - apiKey: "", - connectionMethod: rowData.connectionMethod ?? "api_key", - verifiedAt: rowData.verifiedAt ?? null, - keychainAccount: rowData.keychainAccount ?? "", - llmProvider: "custom", - llmBaseUrl: rowData.llmBaseUrl, - llmDefaultModel: - typeof rowData.llmDefaultModel === "string" - ? rowData.llmDefaultModel - : undefined, - }; - } return null; } @@ -358,12 +363,7 @@ export async function getLocalSetupStatus(): Promise { }; const providerStatuses = {} as Record; - for (const provider of [ - "openrouter", - "openai", - "anthropic", - "custom", - ] as const) { + for (const provider of LLM_PROVIDER_TYPES) { const credential = await localCredentialForLlmProvider(provider); providerStatuses[provider] = credential ? { diff --git a/backend/src/mastra/agents/investigate.ts b/backend/src/mastra/agents/investigate.ts index c930f5e..ac80b73 100644 --- a/backend/src/mastra/agents/investigate.ts +++ b/backend/src/mastra/agents/investigate.ts @@ -7,6 +7,9 @@ import type { PopulateColumn } from "../../pipeline/populate.js"; function buildInvestigateInstructions(columns: PopulateColumn[]): string { const columnNames = columns.map((c) => c.name); + const dataExample = columnNames + .map((n) => `{"column": "${n}", "value": "value"}`) + .join(", "); const columnsDesc = columns .map( (c) => @@ -29,7 +32,7 @@ RULES: TOOL CALL FORMAT — every tool call argument must be a JSON object wrapped in curly braces: search_web: {"query": "your search terms"} fetch_page: {"url": "https://example.com"} - insert_row: {"data": {${columnNames.map((n) => `"${n}": "value"`).join(", ")}}, "sources": ["https://url-you-fetched.com"], "row_summary": "one line about this entity", "how_found": "step by step guide on how to extract the data so an agent in the future can do it too"} + insert_row: {"data": [${dataExample}], "sources": ["https://url-you-fetched.com"], "row_summary": "one line about this entity", "how_found": "step by step guide on how to extract the data so an agent in the future can do it too"} WORKFLOW: 1. Fetch 1-2 of the provided URLs to get real data (if URLs were given). diff --git a/backend/src/mastra/agents/refresh.ts b/backend/src/mastra/agents/refresh.ts index 6593f96..bc38eb4 100644 --- a/backend/src/mastra/agents/refresh.ts +++ b/backend/src/mastra/agents/refresh.ts @@ -7,6 +7,9 @@ import type { PopulateColumn } from "../../pipeline/populate.js"; function buildRefreshInstructions(columns: PopulateColumn[]): string { const columnNames = columns.map((c) => c.name); + const dataExample = columnNames + .map((n) => `{"column": "${n}", "value": "value"}`) + .join(", "); const columnsDesc = columns .map( (c) => @@ -25,7 +28,7 @@ RULES: - If no "Previously found via" steps are provided, fall back to fetching the source URLs directly. - If a source returns a 404, timeout, or is blocked, note it and move to the next. - Compare the fetched data with the existing row data carefully. -- If data has MEANINGFULLY changed (not just formatting differences), call update_row with the FULL updated data object (all columns, not just changed ones), plus updated sources, row_summary, and how_found. +- If data has MEANINGFULLY changed (not just formatting differences), call update_row with the FULL updated row data (all columns, not just changed ones), plus updated sources, row_summary, and how_found. - If NO sources work (all 404/blocked), try ONE web search using the primary key values to find a current source. - If the data is unchanged, do NOT call update_row. Just report your findings. - Never fabricate values. If you can't verify a field, keep the existing value. @@ -33,7 +36,7 @@ RULES: TOOL CALL FORMAT — every tool call argument must be a JSON object wrapped in curly braces: fetch_page: {"url": "https://example.com"} search_web: {"query": "your search terms"} - update_row: {"rowId": "", "data": {${columnNames.map((n) => `"${n}": "value"`).join(", ")}}, "sources": ["https://..."], "row_summary": "one line about this entity", "how_found": "how you verified this data"} + update_row: {"rowId": "", "data": [${dataExample}], "sources": ["https://..."], "row_summary": "one line about this entity", "how_found": "how you verified this data"} WORKFLOW: 1. Fetch the provided source URLs (1-2 calls). diff --git a/backend/src/mastra/tools/dataset-tools.ts b/backend/src/mastra/tools/dataset-tools.ts index 1fc016e..e0109a6 100644 --- a/backend/src/mastra/tools/dataset-tools.ts +++ b/backend/src/mastra/tools/dataset-tools.ts @@ -58,6 +58,21 @@ const writeResultSchema = z.object({ const ROW_NOT_FOUND_MSG = "Row not found. It may have been deleted, or the id belongs to a different dataset. Use list_rows to see valid row ids."; +const rowDataCellSchema = z.object({ + column: z.string().min(1), + value: z.string(), +}); + +type RowDataCell = z.infer; + +function rowDataCellsToRecord(data: RowDataCell[]): Record { + const row: Record = {}; + for (const cell of data) { + row[cell.column] = cell.value; + } + return row; +} + function cleanDataKeys(data: Record): Record { const cleaned: Record = {}; for (const [key, value] of Object.entries(data)) { @@ -126,7 +141,12 @@ export function buildPopulateTools( description: "Insert a single row into the dataset you are populating. Call this each time you have a row ready — don't wait to batch them.", inputSchema: z.object({ - data: z.record(z.string(), z.any()), + data: z + .array(rowDataCellSchema) + .min(1) + .describe( + 'Row values as {"column": "column_name", "value": "cell value"} entries. Use an empty string for unknown values.', + ), sources: z .array(z.string()) .optional() @@ -142,14 +162,14 @@ export function buildPopulateTools( }), outputSchema: writeResultSchema, execute: async ({ data, sources, row_summary, how_found }) => { - if (!data || Object.keys(data).length === 0) + if (!data || data.length === 0) return { success: false, error: - 'data is required and must have at least one key. Pass an object like { "Column Name": value }.', + 'data is required and must include at least one entry like { "column": "column_name", "value": "cell value" }.', }; - const cleanedData = cleanDataKeys(data); + const cleanedData = cleanDataKeys(rowDataCellsToRecord(data)); console.log( `[insert_row] ${logCtx} cols=${Object.keys(cleanedData).length} sources=${sources?.length ?? 0}`, ); @@ -257,10 +277,15 @@ export function buildPopulateTools( const updateRowTool = createTool({ id: "update_row", description: - "Update an existing row by its ID. Pass the full updated data object. Changes are tracked in history.", + "Update an existing row by its ID. Pass the full updated row data. Changes are tracked in history.", inputSchema: z.object({ rowId: z.string(), - data: z.record(z.string(), z.any()), + data: z + .array(rowDataCellSchema) + .min(1) + .describe( + 'Full row values as {"column": "column_name", "value": "cell value"} entries. Use an empty string for unknown values.', + ), sources: z .array(z.string()) .optional() @@ -277,13 +302,13 @@ export function buildPopulateTools( outputSchema: writeResultSchema, execute: async ({ rowId, data, sources, row_summary, how_found }) => { if (!rowId) return { success: false, error: "rowId is required." }; - if (!data || Object.keys(data).length === 0) + if (!data || data.length === 0) return { success: false, - error: "data is required. Pass the full updated row data object.", + error: "data is required. Pass the full updated row data entries.", }; - const cleanedData = cleanDataKeys(data); + const cleanedData = cleanDataKeys(rowDataCellsToRecord(data)); console.log( `[update_row] ${logCtx} row=${rowId} cols=${Object.keys(cleanedData).length}`, ); diff --git a/backend/src/mastra/tools/investigate-tool.ts b/backend/src/mastra/tools/investigate-tool.ts index 1b23764..0202100 100644 --- a/backend/src/mastra/tools/investigate-tool.ts +++ b/backend/src/mastra/tools/investigate-tool.ts @@ -8,6 +8,11 @@ import type { RunMetrics } from "../run-metrics.js"; import { getSignal } from "../../abort-registry.js"; import type { LlmProviderConfig } from "../../config/llm.js"; +const keyValueSchema = z.object({ + column: z.string().min(1), + value: z.string().min(1), +}); + const investigateInputSchema = z.object({ entity_hint: z .string() @@ -15,12 +20,10 @@ const investigateInputSchema = z.object({ "What entity to look for, e.g. 'head of GTM at Appcharge' or 'Starbucks coffee products on Amazon'", ), primary_keys: z - .record(z.string(), z.string()) - .refine((v) => Object.keys(v).length > 0, { - message: "primary_keys must include at least one primary-key value", - }) + .array(keyValueSchema) + .min(1, "primary_keys must include at least one primary-key value") .describe( - "REQUIRED: the primary key column value(s) for this entity. e.g. {\"Company Name\": \"Stripe\"} or {\"First Name\": \"John\", \"Last Name\": \"Doe\"}. You MUST provide at least the primary key values you have found.", + 'REQUIRED: primary key values as {"column": "column_name", "value": "value"} entries. e.g. [{"column": "company_name", "value": "Stripe"}]. You MUST provide at least the primary key values you have found.', ), context: z .string() @@ -112,8 +115,8 @@ export function buildSubagentTool( llmConfig, ); - const pkBlock = Object.entries(primary_keys) - .map(([k, v]) => `- ${k}: ${v}`) + const pkBlock = primary_keys + .map(({ column, value }) => `- ${column}: ${value}`) .join("\n"); const urlsBlock = urls && urls.length > 0 diff --git a/backend/src/mastra/workflows/populate.ts b/backend/src/mastra/workflows/populate.ts index 3673047..bd1c97e 100644 --- a/backend/src/mastra/workflows/populate.ts +++ b/backend/src/mastra/workflows/populate.ts @@ -174,7 +174,7 @@ const buildPromptStep = createStep({ const pkNote = pkColumns.length > 0 - ? `\nPrimary key column(s): ${pkColumns.map((c) => `"${c.name}"`).join(", ")}. When calling run_subagent, you MUST pass these values in the primary_keys field. The subagent will research and fill in the remaining columns.` + ? `\nPrimary key column(s): ${pkColumns.map((c) => `"${c.name}"`).join(", ")}. When calling run_subagent, you MUST pass these values in the primary_keys field as an array of {"column": "column_name", "value": "value"} entries. The subagent will research and fill in the remaining columns.` : ""; let manifestNote = ""; @@ -202,6 +202,7 @@ ${columnsDesc}${pkNote}${manifestNote}${strategyNote} Search the web broadly to find real entities that fit this dataset topic. For each lead you find, call run_subagent with the primary key values and any context/URLs you have found. +Example primary_keys format: [{"column": "company_name", "value": "Stripe"}] If run_subagent returns ROW_LIMIT_REACHED, stop immediately and do not make any more tool calls. Stop the populate run as soon as the dataset reaches ${inputData.maxRowCount} rows.`; diff --git a/backend/src/pipeline/schema-inference.ts b/backend/src/pipeline/schema-inference.ts index d1ab510..8a72abe 100644 --- a/backend/src/pipeline/schema-inference.ts +++ b/backend/src/pipeline/schema-inference.ts @@ -10,7 +10,7 @@ const SYSTEM_PROMPT = `You are a data engineering assistant that converts natura Your job is to: 1. Identify the universe of entities the user wants to collect. Each entity becomes one row in the dataset. -2. Pick primary key column(s) — one or more columns whose combined values uniquely identify each row (no two legitimate rows should share the same values across all primary key columns in any case). Refrain from names unless necessary, as they may not always be unqiue (unless this is guarenteed). Otherwise use thigns like URLs or IDs that have a 100% guarentee of being unique. Set \`is_primary_key: true\` on each primary key column. Set \`primary_key\` to the column name if there is one, or an array of column names if there are multiple. Every primary key column must have \`nullable: false\` and \`is_enumerable: true\`. Prefer a single column when one naturally uniquely identifies each row. +2. Pick primary key column(s) — one or more columns whose combined values uniquely identify each row (no two legitimate rows should share the same values across all primary key columns in any case). Refrain from names unless necessary, as they may not always be unqiue (unless this is guarenteed). Otherwise use thigns like URLs or IDs that have a 100% guarentee of being unique. Set \`is_primary_key: true\` on each primary key column. Set \`primary_key\` to an array of primary key column names; use a one-item array for a single primary key. Every primary key column must have \`nullable: false\` and \`is_enumerable: true\`. Prefer a single column when one naturally uniquely identifies each row. 3. Choose useful columns. Each column captures one fact about the entity. Use snake_case names. Mark \`is_enumerable: true\` only on columns whose values can be used to list all rows (typically just the primary key, and occasionally one or two others when a source page lists them alongside the primary key). 4. Set \`retrieval_strategy\`: - \`search_fetch\` — the data lives on a static page or sitemap that can be fetched as HTML. diff --git a/backend/src/pipeline/types.ts b/backend/src/pipeline/types.ts index e0b95f9..ee9ed13 100644 --- a/backend/src/pipeline/types.ts +++ b/backend/src/pipeline/types.ts @@ -35,7 +35,7 @@ export const datasetSchemaSchema = z dataset_name: z.string().regex(snakeCase, "must be snake_case"), description: z.string().min(1), columns: z.array(columnDefinitionSchema).min(1), - primary_key: z.union([z.string(), z.array(z.string())]), + primary_key: z.array(z.string()).min(1), retrieval_strategy: retrievalStrategySchema, source_hint: z.string().min(1), }) @@ -61,9 +61,7 @@ export const datasetSchemaSchema = z } const pkNames = pkCols.map((c) => c.name); - const declaredPkRaw = Array.isArray(data.primary_key) - ? data.primary_key - : [data.primary_key]; + const declaredPkRaw = data.primary_key; const declaredPk = [...new Set(declaredPkRaw)]; if ( declaredPk.length !== declaredPkRaw.length || diff --git a/frontend/app/setup/page.tsx b/frontend/app/setup/page.tsx index 2814ad3..928d861 100644 --- a/frontend/app/setup/page.tsx +++ b/frontend/app/setup/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useRouter } from "next/navigation"; import { CheckCircle2, @@ -11,24 +11,74 @@ import { } from "lucide-react"; import { getLocalSetupStatus, + getLlmProviderModels, + getModelConfig, saveLlmProviderConfig, + saveModelConfig, saveTinyFishApiKey, + type EffectiveModelConfig, type LlmProviderType, type LocalSetupStatus, + type OpenRouterModel, type ServiceSetupStatus, } from "@/lib/backend"; import { isLocalMode } from "@/lib/app-mode"; import { LlmProviderBrand, LlmProviderSelector, + displayBaseUrl, + llmProviderLabelForStatus, llmProviderOption, + localLlmPresetForBaseUrl, + type LlmProviderOptionValue, } from "@/components/settings/llm-providers"; +import { + beginOpenRouterOAuth, + useCanUseOpenRouterOAuth, +} from "@/lib/openrouter-oauth"; +import { LocalUtilityMenu } from "@/components/LocalUtilityMenu"; +import { ModelSideSheet } from "@/components/settings/ModelSideSheet"; +import { MODEL_ROLES, type ModelRole } from "@/components/settings/types"; +import { useAppAuth } from "@/lib/app-auth"; + +function modelListCacheKey(status: LocalSetupStatus | null): string { + const llm = status?.services.llm; + if (!llm) return ""; + return [ + llm.provider ?? "openrouter", + llm.baseUrl ?? "", + llm.defaultModel ?? "", + llm.verifiedAt ?? "", + ].join("|"); +} + +function emptyModelConfig(): EffectiveModelConfig { + return { + schemaInference: "", + populateOrchestrator: "", + investigateSubagent: "", + }; +} export default function SetupPage() { const router = useRouter(); + const { getToken } = useAppAuth(); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [modal, setModal] = useState<"tinyfish" | "llm" | null>(null); + const [modelConfig, setModelConfig] = useState( + null, + ); + const [loadingModelConfig, setLoadingModelConfig] = useState(false); + const [modelError, setModelError] = useState(null); + const [activeModelRole, setActiveModelRole] = useState(null); + const [modelOptions, setModelOptions] = useState([]); + const [modelOptionsCacheKey, setModelOptionsCacheKey] = useState< + string | null + >(null); + const [refreshingModels, setRefreshingModels] = useState(false); + const [savingModel, setSavingModel] = useState(false); + const activeModelListCacheKeyRef = useRef(""); useEffect(() => { if (!isLocalMode) { @@ -41,7 +91,122 @@ export default function SetupPage() { .finally(() => setLoading(false)); }, [router]); + const activeModelListCacheKey = modelListCacheKey(status); + + useEffect(() => { + activeModelListCacheKeyRef.current = activeModelListCacheKey; + }, [activeModelListCacheKey]); + + useEffect(() => { + let active = true; + + if (!status?.services.llm.configured) return; + + async function loadModelConfig() { + setLoadingModelConfig(true); + setModelError(null); + try { + const token = await getToken(); + if (!token) throw new Error("Not authenticated"); + const config = await getModelConfig(token); + if (active) setModelConfig(config); + } catch (err) { + if (!active) return; + setModelConfig(emptyModelConfig()); + setModelError( + err instanceof Error ? err.message : "Failed to load model settings", + ); + } finally { + if (active) setLoadingModelConfig(false); + } + } + + void loadModelConfig(); + + return () => { + active = false; + }; + }, [ + getToken, + status?.services.llm.baseUrl, + status?.services.llm.configured, + status?.services.llm.provider, + status?.services.llm.verifiedAt, + ]); + + const loadProviderModels = useCallback( + async (force = false) => { + const cacheKey = activeModelListCacheKeyRef.current; + if (!force && modelOptionsCacheKey === cacheKey && modelOptions.length > 0) { + return; + } + + setRefreshingModels(true); + setModelError(null); + try { + const models = await getLlmProviderModels(); + if (activeModelListCacheKeyRef.current !== cacheKey) return; + setModelOptions(models); + setModelOptionsCacheKey(cacheKey); + } catch (err) { + if (activeModelListCacheKeyRef.current !== cacheKey) return; + setModelOptions([]); + setModelOptionsCacheKey(cacheKey); + setModelError( + err instanceof Error ? err.message : "Failed to load models", + ); + } finally { + if (activeModelListCacheKeyRef.current === cacheKey) { + setRefreshingModels(false); + } + } + }, + [modelOptions.length, modelOptionsCacheKey], + ); + + function modelForRole(role: ModelRole): string { + const key = role.key as keyof EffectiveModelConfig; + return modelConfig?.[key] ?? ""; + } + + function openModelSheet(role: ModelRole) { + setActiveModelRole(role); + void loadProviderModels(); + } + + async function saveModelForRole(role: ModelRole, modelId: string) { + const nextModelId = modelId.trim(); + if (!nextModelId) return; + + const key = role.key as keyof EffectiveModelConfig; + setSavingModel(true); + setModelError(null); + try { + const token = await getToken(); + if (!token) throw new Error("Not authenticated"); + await saveModelConfig({ [key]: nextModelId }, token); + setModelConfig((prev) => ({ + ...emptyModelConfig(), + ...prev, + [key]: nextModelId, + })); + setActiveModelRole(null); + } catch (err) { + setModelError( + err instanceof Error ? err.message : "Failed to save model", + ); + } finally { + setSavingModel(false); + } + } + const complete = status?.complete ?? false; + const modelSelectionRequired = + !!status?.services.llm.configured && !status.services.llm.defaultModel; + const modelsConfigured = + !modelSelectionRequired || + MODEL_ROLES.every((role) => modelForRole(role).trim().length > 0); + const canCompleteSetup = complete && modelsConfigured; if (loading) { return ( @@ -53,9 +218,12 @@ export default function SetupPage() { return (
-
- BigSet - BigSet +
+
+ BigSet + BigSet +
+
@@ -65,12 +233,11 @@ export default function SetupPage() { Connect your services

- Add TinyFish and your preferred LLM provider to start building - live datasets. + Add TinyFish and choose where BigSet should run model calls.

-
+
@@ -86,20 +253,28 @@ export default function SetupPage() { /> } - description="BigSet uses TinyFish's best-in-class search API to unlock real-time information." + description="Connect TinyFish for live search and source pages." status={status?.services.tinyfish} primaryLabel={ status?.services.tinyfish.configured ? "Update key" : "Add API key" } onPrimary={() => setModal("tinyfish")} helperHref="https://agent.tinyfish.ai/api-keys?utm_source=github&utm_medium=organic&utm_campaign=bigset-developer-2026q2" - helperLabel="Need a TinyFish key?" - helperDescription="Open the TinyFish API keys page" + helperLabel="Get your TinyFish API Key" /> } - description="BigSet uses your LLM provider for schema generation and dataset-building agents." + brand={ + + } + description="Choose the provider BigSet uses for schema generation and agents." status={status?.services.llm} primaryLabel={ status?.services.llm.configured @@ -107,21 +282,74 @@ export default function SetupPage() { : "Choose provider" } onPrimary={() => setModal("llm")} - helperHref="https://platform.openai.com/api-keys" - helperLabel="Bring your own model" - helperDescription="OpenAI, Anthropic, OpenRouter, or custom" />
+ {status?.services.llm.configured && ( +
+
+
+

+ Models +

+

+ {llmProviderLabelForStatus(status.services.llm) ?? + "Model provider"} +

+
+ {modelSelectionRequired && !modelsConfigured && ( + + Required + + )} +
+
+ {MODEL_ROLES.map((role) => { + const selectedModel = modelForRole(role); + return ( + + ); + })} +
+ {modelError && ( +
+ {modelError} +
+ )} +
+ )} +

- {complete + {complete && modelsConfigured ? "Everything is connected. You can start building datasets." + : complete && modelSelectionRequired + ? "Choose models to continue." : "Complete both connections to continue."}

); } @@ -165,21 +408,22 @@ function ServiceCard({ onPrimary: () => void; secondaryLabel?: string; onSecondary?: () => void; - helperHref: string; - helperLabel: string; - helperDescription: string; + helperHref?: string; + helperLabel?: string; + helperDescription?: string; }) { const connected = status?.configured ?? false; const detail = useMemo(() => { if (!connected) return "Not connected"; - if (status?.providerLabel) return status.providerLabel; + const llmLabel = llmProviderLabelForStatus(status); + if (llmLabel) return llmLabel; if (status?.connectionMethod === "oauth") return "Connected through OAuth"; if (status?.source === "env") return "Connected through .env"; return "Connected through API key"; - }, [connected, status?.connectionMethod, status?.providerLabel, status?.source]); + }, [connected, status]); return ( -
+
{brand}
@@ -197,7 +441,7 @@ function ServiceCard({ {description}

-
+
- - {helperLabel} {helperDescription} - - + {helperHref && helperLabel ? ( + + {helperLabel} + {helperDescription ? ` ${helperDescription}` : null} + + + ) : helperLabel ? ( +

+ {helperLabel}:{" "} + {helperDescription} +

+ ) : null}
); @@ -233,31 +485,54 @@ function ServiceCard({ function ApiKeyModal({ service, + status, onClose, onSaved, }: { service: "tinyfish" | "llm"; + status: LocalSetupStatus | null; onClose: () => void; onSaved: (status: LocalSetupStatus) => void; }) { + const initialProvider = initialLlmProviderSelection(status); const [apiKey, setApiKey] = useState(""); - const [provider, setProvider] = useState("openrouter"); - const [baseUrl, setBaseUrl] = useState(""); + const [provider, setProvider] = + useState(initialProvider); + const [baseUrl, setBaseUrl] = useState(() => + initialBaseUrl(status, initialProvider), + ); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const isTinyFish = service === "tinyfish"; const providerCopy = llmProviderOption(provider); + const providerStatuses = status?.services.llmProviders; + const resolvedProvider = providerCopy.provider; + const selectedProviderStatus = providerStatuses?.[resolvedProvider]; + const selectedProviderConfigured = + selectedProviderStatus?.configured ?? + (status?.services.llm.configured && + status.services.llm.provider === resolvedProvider) ?? + false; + const selectedRequiresBaseUrl = providerCopy.requiresBaseUrl ?? false; + const selectedRequiresApiKey = providerCopy.requiresApiKey ?? resolvedProvider !== "custom"; + const selectedUsesPresetBaseUrl = !!providerCopy.defaultBaseUrl; + const showOpenRouterOAuth = useCanUseOpenRouterOAuth(); + const isCustomEndpoint = provider === "custom"; - function handleProviderChange(next: LlmProviderType) { + function handleProviderChange(next: LlmProviderOptionValue) { setProvider(next); - setBaseUrl(""); + setBaseUrl(initialBaseUrl(status, next)); + setApiKey(""); + setError(null); } async function handleSubmit() { if (saving) return; if (isTinyFish && !apiKey.trim()) return; - if (!isTinyFish && provider !== "custom" && !apiKey.trim()) return; - if (!isTinyFish && provider === "custom" && !baseUrl.trim()) { + if (!isTinyFish && selectedRequiresApiKey && !apiKey.trim() && !selectedProviderConfigured) { + return; + } + if (!isTinyFish && selectedRequiresBaseUrl && !baseUrl.trim() && !selectedProviderConfigured) { setError("Custom providers require a base URL"); return; } @@ -268,10 +543,16 @@ function ApiKeyModal({ const next = isTinyFish ? await saveTinyFishApiKey(apiKey.trim()) : await saveLlmProviderConfig({ - provider, - apiKey: apiKey.trim(), - defaultModel: llmProviderOption(provider).defaultModel, - baseUrl: provider === "custom" ? baseUrl.trim() : undefined, + provider: resolvedProvider, + apiKey: + selectedRequiresApiKey || provider === "custom" + ? apiKey.trim() + : "", + defaultModel: providerCopy.defaultModel, + baseUrl: + selectedRequiresBaseUrl && baseUrl.trim() + ? baseUrl.trim() + : undefined, }); onSaved(next); } catch (err) { @@ -284,14 +565,33 @@ function ApiKeyModal({ const helperHref = isTinyFish ? "https://agent.tinyfish.ai/api-keys?utm_source=github&utm_medium=organic&utm_campaign=bigset-developer-2026q2" : providerCopy.helperHref; - const helperLabel = !isTinyFish && provider === "custom" ? "Provider docs" : "Get a key"; + const helperLabel = isTinyFish + ? "Get your TinyFish API Key" + : isCustomEndpoint + ? "OpenAI API docs" + : selectedRequiresBaseUrl + ? "Provider docs" + : "Get a key"; + const showApiKeyHelper = + !!helperHref && (isTinyFish || selectedRequiresApiKey); const canSubmit = !saving && (isTinyFish ? !!apiKey.trim() - : provider === "custom" - ? !!baseUrl.trim() - : !!apiKey.trim()); + : selectedRequiresBaseUrl + ? !!baseUrl.trim() || selectedProviderConfigured + : !selectedRequiresApiKey || !!apiKey.trim() || selectedProviderConfigured); + const usingSavedProvider = + !isTinyFish && + selectedProviderConfigured && + !apiKey.trim() && + (!selectedRequiresBaseUrl || + !baseUrl.trim() || + baseUrl.trim() === displayBaseUrl(selectedProviderStatus?.baseUrl)); + const modalTitle = isTinyFish ? "TinyFish API key" : "Model provider"; + const modalDescription = isTinyFish + ? "BigSet verifies the key and stores it in your OS keychain." + : "Select a provider. BigSet stores local credentials in your OS keychain."; return (
@@ -304,16 +604,14 @@ function ApiKeyModal({
-
+
-

- {isTinyFish ? "TinyFish API key" : "LLM provider"} -

-

- BigSet checks the provider endpoint and stores the key in your OS keychain. -

+

{modalTitle}

+

{modalDescription}

-
+
{!isTinyFish && (
- Provider - + + Provider + +
)} - {!isTinyFish && provider === "custom" && ( -
-
- - {helperLabel} - - - -
+
+
); } + +function initialLlmProviderSelection( + status: LocalSetupStatus | null, +): LlmProviderOptionValue { + if (status?.services.llm.configured && status.services.llm.provider) { + if (status.services.llm.provider === "custom") { + return ( + localLlmPresetForBaseUrl(status.services.llm.baseUrl)?.value ?? "custom" + ); + } + return status.services.llm.provider; + } + + const savedProvider = (Object.entries(status?.services.llmProviders ?? {}) as [ + LlmProviderType, + ServiceSetupStatus, + ][]).find(([, providerStatus]) => providerStatus.configured)?.[0]; + + if (savedProvider === "custom") { + return ( + localLlmPresetForBaseUrl(status?.services.llmProviders?.custom?.baseUrl) + ?.value ?? "custom" + ); + } + + return savedProvider ?? "openrouter"; +} + +function initialBaseUrl( + status: LocalSetupStatus | null, + provider: LlmProviderOptionValue, +) { + const option = llmProviderOption(provider); + const savedBaseUrl = displayBaseUrl( + status?.services.llmProviders?.[option.provider]?.baseUrl, + ); + if (option.defaultBaseUrl) return savedBaseUrl || option.defaultBaseUrl; + if (option.provider === "custom") { + return savedBaseUrl; + } + return ""; +} diff --git a/frontend/components/settings/LocalCredentialsPanel.tsx b/frontend/components/settings/LocalCredentialsPanel.tsx index cbd4132..772f3e8 100644 --- a/frontend/components/settings/LocalCredentialsPanel.tsx +++ b/frontend/components/settings/LocalCredentialsPanel.tsx @@ -20,46 +20,52 @@ import { isLocalMode } from "@/lib/app-mode"; import { LlmProviderBrand, LlmProviderSelector, + displayBaseUrl, + llmProviderLabelForStatus, llmProviderOption, + localLlmPresetForBaseUrl, + type LlmProviderOptionValue, } from "@/components/settings/llm-providers"; +import { + beginOpenRouterOAuth, + useCanUseOpenRouterOAuth, +} from "@/lib/openrouter-oauth"; type ServiceName = "tinyfish" | "llm"; -const SERVICE_COPY = { +type ServiceCopy = { + modalTitle: string; + description: string; + inputPlaceholder: string; + modalDescription: string; + helperHref?: string; + helperLabel: string; + helperDescription: string; +}; + +const SERVICE_COPY: Record = { tinyfish: { - modalTitle: "TinyFish API key", + modalTitle: "Connect TinyFish", description: - "BigSet uses TinyFish's best-in-class search API to unlock real-time information.", + "Connect TinyFish for live search and source pages.", inputPlaceholder: "tf_...", modalDescription: "BigSet verifies the key and stores it in your OS keychain.", helperHref: "https://agent.tinyfish.ai/api-keys?utm_source=github&utm_medium=organic&utm_campaign=bigset-developer-2026q2", - helperLabel: "Need a TinyFish key?", - helperDescription: "Open the TinyFish API keys page", + helperLabel: "Get your TinyFish API Key", + helperDescription: "", }, llm: { - modalTitle: "LLM provider", + modalTitle: "Model provider", description: - "BigSet uses your LLM provider for schema generation and dataset-building agents.", + "Choose the provider BigSet uses for schema generation and agents.", inputPlaceholder: "API key", modalDescription: - "BigSet checks the provider endpoint and stores the key in your OS keychain.", - helperHref: "https://platform.openai.com/api-keys", - helperLabel: "Bring your own model", - helperDescription: "OpenAI, Anthropic, OpenRouter, or custom", + "Select a provider. BigSet stores local credentials in your OS keychain.", + helperLabel: "", + helperDescription: "", }, -} satisfies Record< - ServiceName, - { - modalTitle: string; - description: string; - inputPlaceholder: string; - modalDescription: string; - helperHref: string; - helperLabel: string; - helperDescription: string; - } ->; +}; export function LocalCredentialsPanel({ onStatusChange, @@ -119,7 +125,7 @@ export function LocalCredentialsPanel({ {loadError}
) : ( -
+
setModal(null)} onSaved={(next) => { setStatus(next); @@ -164,13 +171,25 @@ function CredentialCard({ const copy = SERVICE_COPY[service]; const connected = status?.configured ?? false; const detail = useCredentialDetail(status, loading); + const primaryLabel = + service === "llm" + ? connected + ? "Update provider" + : "Choose provider" + : connected + ? "Update key" + : "Add API key"; return ( -
+
- +

{detail}

@@ -181,24 +200,34 @@ function CredentialCard({ {copy.description}

-
+
- - {copy.helperLabel} {copy.helperDescription} - - + {copy.helperHref && copy.helperLabel ? ( + + {copy.helperLabel} + {copy.helperDescription ? ` ${copy.helperDescription}` : null} + + + ) : copy.helperLabel ? ( +

+ + {copy.helperLabel}: + {" "} + {copy.helperDescription} +

+ ) : null}
); @@ -207,9 +236,11 @@ function CredentialCard({ function ServiceBrand({ service, provider, + baseUrl, }: { service: ServiceName; provider?: LlmProviderType; + baseUrl?: string; }) { if (service === "tinyfish") { return ( @@ -228,7 +259,7 @@ function ServiceBrand({ ); } - return ; + return ; } function StatusLabel({ @@ -264,41 +295,65 @@ function useCredentialDetail( return useMemo(() => { if (loading) return "Checking connection..."; if (!status?.configured) return "Not connected"; - if (status.providerLabel) return status.providerLabel; + const llmLabel = llmProviderLabelForStatus(status); + if (llmLabel) return llmLabel; if (status.connectionMethod === "oauth") return "Connected through OAuth"; if (status.source === "env") return "Connected through .env"; return "Connected through API key"; - }, [loading, status?.configured, status?.connectionMethod, status?.providerLabel, status?.source]); + }, [loading, status]); } function ApiKeyModal({ service, + status, onClose, onSaved, }: { service: ServiceName; + status: LocalSetupStatus | null; onClose: () => void; onSaved: (status: LocalSetupStatus) => void; }) { + const initialProvider = initialLlmProviderSelection(status); const [apiKey, setApiKey] = useState(""); - const [provider, setProvider] = useState("openrouter"); - const [baseUrl, setBaseUrl] = useState(""); + const [provider, setProvider] = + useState(initialProvider); + const [baseUrl, setBaseUrl] = useState(() => + initialBaseUrl(status, initialProvider), + ); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const copy = SERVICE_COPY[service]; const isTinyFish = service === "tinyfish"; const providerCopy = llmProviderOption(provider); - - function handleProviderChange(next: LlmProviderType) { + const providerStatuses = status?.services.llmProviders; + const resolvedProvider = providerCopy.provider; + const selectedProviderStatus = providerStatuses?.[resolvedProvider]; + const selectedProviderConfigured = + selectedProviderStatus?.configured ?? + (status?.services.llm.configured && + status.services.llm.provider === resolvedProvider) ?? + false; + const selectedRequiresBaseUrl = providerCopy.requiresBaseUrl ?? false; + const selectedRequiresApiKey = providerCopy.requiresApiKey ?? resolvedProvider !== "custom"; + const selectedUsesPresetBaseUrl = !!providerCopy.defaultBaseUrl; + const showOpenRouterOAuth = useCanUseOpenRouterOAuth(); + const isCustomEndpoint = provider === "custom"; + + function handleProviderChange(next: LlmProviderOptionValue) { setProvider(next); - setBaseUrl(""); + setBaseUrl(initialBaseUrl(status, next)); + setApiKey(""); + setError(null); } async function handleSubmit() { if (saving) return; if (isTinyFish && !apiKey.trim()) return; - if (!isTinyFish && provider !== "custom" && !apiKey.trim()) return; - if (!isTinyFish && provider === "custom" && !baseUrl.trim()) { + if (!isTinyFish && selectedRequiresApiKey && !apiKey.trim() && !selectedProviderConfigured) { + return; + } + if (!isTinyFish && selectedRequiresBaseUrl && !baseUrl.trim() && !selectedProviderConfigured) { setError("Custom providers require a base URL"); return; } @@ -309,10 +364,16 @@ function ApiKeyModal({ const next = isTinyFish ? await saveTinyFishApiKey(apiKey.trim()) : await saveLlmProviderConfig({ - provider, - apiKey: apiKey.trim(), - defaultModel: llmProviderOption(provider).defaultModel, - baseUrl: provider === "custom" ? baseUrl.trim() : undefined, + provider: resolvedProvider, + apiKey: + selectedRequiresApiKey || provider === "custom" + ? apiKey.trim() + : "", + defaultModel: providerCopy.defaultModel, + baseUrl: + selectedRequiresBaseUrl && baseUrl.trim() + ? baseUrl.trim() + : undefined, }); onSaved(next); } catch (err) { @@ -323,14 +384,29 @@ function ApiKeyModal({ } const helperHref = isTinyFish ? copy.helperHref : providerCopy.helperHref; - const helperLabel = !isTinyFish && provider === "custom" ? "Provider docs" : "Get a key"; + const helperLabel = isTinyFish + ? "Get your TinyFish API Key" + : isCustomEndpoint + ? "OpenAI API docs" + : selectedRequiresBaseUrl + ? "Provider docs" + : "Get a key"; + const showApiKeyHelper = + !!helperHref && (isTinyFish || selectedRequiresApiKey); const canSubmit = !saving && (isTinyFish ? !!apiKey.trim() - : provider === "custom" - ? !!baseUrl.trim() - : !!apiKey.trim()); + : selectedRequiresBaseUrl + ? !!baseUrl.trim() || selectedProviderConfigured + : !selectedRequiresApiKey || !!apiKey.trim() || selectedProviderConfigured); + const usingSavedProvider = + !isTinyFish && + selectedProviderConfigured && + !apiKey.trim() && + (!selectedRequiresBaseUrl || + !baseUrl.trim() || + baseUrl.trim() === displayBaseUrl(selectedProviderStatus?.baseUrl)); return (
@@ -343,12 +419,16 @@ function ApiKeyModal({
-
+
-

{copy.modalTitle}

-

{copy.modalDescription}

+

{copy.modalTitle}

+

+ {copy.modalDescription} +

-
+
{!isTinyFish && (
- Provider - + + Provider + +
)} - {!isTinyFish && provider === "custom" && ( -
-
- - {helperLabel} - - - -
+
+
); } + +function initialLlmProviderSelection( + status: LocalSetupStatus | null, +): LlmProviderOptionValue { + if (status?.services.llm.configured && status.services.llm.provider) { + if (status.services.llm.provider === "custom") { + return ( + localLlmPresetForBaseUrl(status.services.llm.baseUrl)?.value ?? "custom" + ); + } + return status.services.llm.provider; + } + + const savedProvider = (Object.entries(status?.services.llmProviders ?? {}) as [ + LlmProviderType, + ServiceSetupStatus, + ][]).find(([, providerStatus]) => providerStatus.configured)?.[0]; + + if (savedProvider === "custom") { + return ( + localLlmPresetForBaseUrl(status?.services.llmProviders?.custom?.baseUrl) + ?.value ?? "custom" + ); + } + + return savedProvider ?? "openrouter"; +} + +function initialBaseUrl( + status: LocalSetupStatus | null, + provider: LlmProviderOptionValue, +) { + const option = llmProviderOption(provider); + const savedBaseUrl = displayBaseUrl( + status?.services.llmProviders?.[option.provider]?.baseUrl, + ); + if (option.defaultBaseUrl) return savedBaseUrl || option.defaultBaseUrl; + if (option.provider === "custom") { + return savedBaseUrl; + } + return ""; +} + +function currentReturnPath() { + if (typeof window === "undefined") return "/setup"; + return `${window.location.pathname}${window.location.search}`; +} diff --git a/frontend/components/settings/llm-providers.tsx b/frontend/components/settings/llm-providers.tsx index 64e87b6..3271f30 100644 --- a/frontend/components/settings/llm-providers.tsx +++ b/frontend/components/settings/llm-providers.tsx @@ -1,111 +1,450 @@ "use client"; -import type { LlmProviderType } from "@/lib/backend"; +import { useEffect, useState } from "react"; +import { CircleHelp, Plug, TriangleAlert } from "lucide-react"; +import type { LlmProviderType, ServiceSetupStatus } from "@/lib/backend"; + +type LlmProviderCategory = "direct" | "router" | "local" | "custom"; +export type LlmProviderOptionValue = LlmProviderType; export type LlmProviderOption = { - value: LlmProviderType; + value: LlmProviderOptionValue; + provider: LlmProviderType; label: string; description: string; + category: LlmProviderCategory; + shortLabel: string; + capability: string; + authLabel: string; defaultModel: string; + defaultBaseUrl?: string; + requiresBaseUrl?: boolean; + requiresApiKey?: boolean; apiKeyPlaceholder: string; helperHref: string; iconSrc?: string; wordmarkSrc?: string; }; -export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ +export const LLM_PROVIDER_GROUPS: { + categories: LlmProviderCategory[]; + label: string; +}[] = [ { - value: "openrouter", - label: "OpenRouter", - description: "Use OpenRouter model slugs.", - defaultModel: "anthropic/claude-sonnet-4.6", - apiKeyPlaceholder: "sk-or-...", - helperHref: "https://openrouter.ai/settings/keys", - iconSrc: "/logos/providers/openrouter.svg", - wordmarkSrc: "/logos/providers/openrouter-wordmark.svg", + categories: ["router"], + label: "Router", + }, + { + categories: ["direct"], + label: "Direct", }, + { + categories: ["local"], + label: "Local", + }, + { + categories: ["custom"], + label: "Custom", + }, +]; + +export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ { value: "openai", + provider: "openai", label: "OpenAI", - description: "Use an OpenAI API key directly.", + description: "Use OpenAI models directly with your API key.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", defaultModel: "gpt-5.4-mini", apiKeyPlaceholder: "sk-...", helperHref: "https://platform.openai.com/api-keys", - iconSrc: "/logos/providers/openai.svg", + iconSrc: "/logos/providers/openai-icon.svg", wordmarkSrc: "/logos/providers/openai.svg", }, { value: "anthropic", + provider: "anthropic", label: "Anthropic", - description: "Use a Claude API key directly.", + description: "Use Claude models directly with your API key.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", defaultModel: "claude-sonnet-4-6", apiKeyPlaceholder: "sk-ant-...", helperHref: "https://console.anthropic.com/settings/keys", - iconSrc: "/logos/providers/anthropic.svg", + iconSrc: "/logos/providers/anthropic-icon.svg", wordmarkSrc: "/logos/providers/anthropic.svg", }, { - value: "custom", - label: "Custom", - description: "Use LM Studio or any OpenAI-compatible base URL.", + value: "google", + provider: "google", + label: "Google Gemini", + description: "Use Gemini models directly with your Google AI Studio API key.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", + defaultModel: "gemini-3.5-flash", + apiKeyPlaceholder: "AIza...", + helperHref: "https://aistudio.google.com/app/apikey", + iconSrc: "/logos/providers/google-g.svg", + }, + { + value: "xai", + provider: "xai", + label: "xAI", + description: "Use Grok models directly with your xAI API key.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", + defaultModel: "grok-4.3", + apiKeyPlaceholder: "xai-...", + helperHref: "https://console.x.ai/", + iconSrc: "/logos/providers/xai.svg", + }, + { + value: "deepseek", + provider: "deepseek", + label: "DeepSeek", + description: "Use DeepSeek chat and reasoning models directly.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", + defaultModel: "deepseek-chat", + apiKeyPlaceholder: "sk-...", + helperHref: "https://platform.deepseek.com/api_keys", + iconSrc: "/logos/providers/deepseek.svg", + }, + { + value: "qwen", + provider: "qwen", + label: "Qwen", + description: "Use Qwen models through Alibaba Cloud Model Studio.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", + defaultModel: "qwen-plus", + apiKeyPlaceholder: "sk-...", + helperHref: "https://modelstudio.console.alibabacloud.com/", + iconSrc: "/logos/providers/qwen.svg", + }, + { + value: "mistral", + provider: "mistral", + label: "Mistral AI", + description: "Use Mistral chat and reasoning models directly.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", + defaultModel: "mistral-large-latest", + apiKeyPlaceholder: "sk-...", + helperHref: "https://console.mistral.ai/api-keys/", + iconSrc: "/logos/providers/mistral-ai.svg", + }, + { + value: "groq", + provider: "groq", + label: "Groq", + description: "Use fast hosted open-weight models through GroqCloud.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", + defaultModel: "openai/gpt-oss-120b", + apiKeyPlaceholder: "gsk_...", + helperHref: "https://console.groq.com/keys", + iconSrc: "/logos/providers/groq.svg", + }, + { + value: "togetherai", + provider: "togetherai", + label: "Together.ai", + description: "Use Together.ai serverless open-source model hosting.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", + defaultModel: "Qwen/Qwen3.5-397B-A17B", + apiKeyPlaceholder: "tok_...", + helperHref: "https://api.together.ai/settings/api-keys", + iconSrc: "/logos/providers/together-ai.svg", + }, + { + value: "deepinfra", + provider: "deepinfra", + label: "DeepInfra", + description: "Use DeepInfra's hosted open-source model catalog.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", + defaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo", + apiKeyPlaceholder: "sk-...", + helperHref: "https://deepinfra.com/dash/api_keys", + iconSrc: "/logos/providers/deepinfra.svg", + }, + { + value: "fireworks", + provider: "fireworks", + label: "Fireworks AI", + description: "Use Fireworks-hosted open-weight and fine-tuned models.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", + defaultModel: "accounts/fireworks/models/kimi-k2p5", + apiKeyPlaceholder: "fw_...", + helperHref: "https://fireworks.ai/account/api-keys", + iconSrc: "/logos/providers/fireworks-ai.svg", + }, + { + value: "huggingface", + provider: "huggingface", + label: "Hugging Face", + description: "Use Hugging Face Inference Providers and routed models.", + category: "direct", + shortLabel: "Direct API", + capability: "Hosted", + authLabel: "API key", + defaultModel: "deepseek-ai/DeepSeek-V3-0324", + apiKeyPlaceholder: "hf_...", + helperHref: "https://huggingface.co/settings/tokens", + iconSrc: "/logos/providers/huggingface.svg", + }, + { + value: "openrouter", + provider: "openrouter", + label: "OpenRouter", + description: "Route across many hosted model families with one account.", + category: "router", + shortLabel: "Model router", + capability: "Multi-provider", + authLabel: "API key or OAuth", + defaultModel: "anthropic/claude-sonnet-4.6", + apiKeyPlaceholder: "sk-or-...", + helperHref: "https://openrouter.ai/settings/keys", + iconSrc: "/logos/providers/openrouter.svg", + wordmarkSrc: "/logos/providers/openrouter-wordmark.svg", + }, + { + value: "ollama", + provider: "ollama", + label: "Ollama", + description: "Use Ollama's local OpenAI-compatible endpoint.", + category: "local", + shortLabel: "Local", + capability: "Local", + authLabel: "No key", defaultModel: "", - apiKeyPlaceholder: "Optional — leave blank for LM Studio", + defaultBaseUrl: "http://localhost:11434/v1", + requiresBaseUrl: true, + requiresApiKey: false, + apiKeyPlaceholder: "No key required", + helperHref: "https://github.com/ollama/ollama/blob/main/docs/openai.md", + iconSrc: "/logos/providers/ollama.svg", + }, + { + value: "lmstudio", + provider: "lmstudio", + label: "LM Studio", + description: "Use LM Studio's local OpenAI-compatible server.", + category: "local", + shortLabel: "Local", + capability: "Local", + authLabel: "No key", + defaultModel: "", + defaultBaseUrl: "http://localhost:1234/v1", + requiresBaseUrl: true, + requiresApiKey: false, + apiKeyPlaceholder: "No key required", helperHref: "https://lmstudio.ai/docs/app/api/endpoints/openai", + iconSrc: "/logos/providers/lmstudio.svg", + }, + { + value: "custom", + provider: "custom", + label: "Custom endpoint", + description: "Use another OpenAI-compatible base URL.", + category: "custom", + shortLabel: "OpenAI-compatible", + capability: "Local or hosted", + authLabel: "Optional key", + defaultModel: "", + requiresBaseUrl: true, + requiresApiKey: false, + apiKeyPlaceholder: "Optional for local endpoints", + helperHref: "https://platform.openai.com/docs/api-reference", }, ]; -export function llmProviderOption(value: LlmProviderType) { +const EXPERIMENTAL_PROVIDER_VALUES = new Set([ + "openai", + "anthropic", + "google", + "xai", + "deepseek", + "qwen", + "mistral", + "groq", + "togetherai", + "deepinfra", + "fireworks", + "huggingface", + "ollama", + "lmstudio", + "custom", +]); + +const LOCAL_MODEL_PROVIDER_VALUES = new Set([ + "ollama", + "lmstudio", +]); + +function isExperimentalProvider(value: LlmProviderOptionValue) { + return EXPERIMENTAL_PROVIDER_VALUES.has(value); +} + +function isLocalModelProvider(value: LlmProviderOptionValue) { + return LOCAL_MODEL_PROVIDER_VALUES.has(value); +} + +export function llmProviderOption(value: LlmProviderOptionValue) { return ( LLM_PROVIDER_OPTIONS.find((option) => option.value === value) ?? + LLM_PROVIDER_OPTIONS.find((option) => option.value === "openrouter") ?? LLM_PROVIDER_OPTIONS[0] ); } +export function displayBaseUrl(baseUrl?: string) { + return baseUrl?.replace("host.docker.internal", "localhost") ?? ""; +} + +export function localLlmPresetForBaseUrl(baseUrl?: string) { + const displayUrl = displayBaseUrl(baseUrl); + if (!displayUrl) return undefined; + + try { + const parsed = new URL(displayUrl); + if (parsed.port === "11434") { + return llmProviderOption("ollama"); + } + if (parsed.port === "1234") { + return llmProviderOption("lmstudio"); + } + } catch { + if (displayUrl.includes(":11434")) return llmProviderOption("ollama"); + if (displayUrl.includes(":1234")) return llmProviderOption("lmstudio"); + } + + return undefined; +} + +export function llmProviderLabelForStatus(status?: ServiceSetupStatus) { + if (status?.provider === "custom") { + return localLlmPresetForBaseUrl(status.baseUrl)?.label ?? "Custom endpoint"; + } + if (status?.provider) return llmProviderOption(status.provider).label; + return status?.providerLabel; +} + export function LlmProviderLogo({ provider, variant = "wordmark", className = "", }: { - provider: LlmProviderType; + provider: LlmProviderOptionValue; variant?: "icon" | "wordmark"; className?: string; }) { const option = llmProviderOption(provider); + const src = option.iconSrc ?? option.wordmarkSrc; + + if (variant === "icon") { + if (!src) { + return ( + + ); + } - if (provider === "custom") { return ( - - Custom - + {option.label} ); } - const src = variant === "icon" ? option.iconSrc : option.wordmarkSrc; + if (!src) { + return ( + + + ); + } return ( - {option.label} + + + + {option.label} + + ); } -export function LlmProviderBrand({ provider }: { provider?: LlmProviderType }) { +export function LlmProviderBrand({ + provider, + baseUrl, +}: { + provider?: LlmProviderType; + baseUrl?: string; +}) { if (provider) { + const option = + provider === "custom" + ? localLlmPresetForBaseUrl(baseUrl) ?? llmProviderOption("custom") + : llmProviderOption(provider); + return (
- +
); } return (
- - AI - - LLM Provider + Model provider
); } @@ -114,43 +453,126 @@ export function LlmProviderSelector({ value, onChange, }: { - value: LlmProviderType; - onChange: (provider: LlmProviderType) => void; + value: LlmProviderOptionValue; + onChange: (provider: LlmProviderOptionValue) => void; }) { + const [showExperimentalProviders, setShowExperimentalProviders] = + useState(false); + const orderedOptions = LLM_PROVIDER_GROUPS.flatMap((group) => + LLM_PROVIDER_OPTIONS.filter((option) => + group.categories.includes(option.category), + ), + ).filter( + (option) => + showExperimentalProviders || !isExperimentalProvider(option.value), + ); + + useEffect(() => { + if (!showExperimentalProviders && isExperimentalProvider(value)) { + onChange("openrouter"); + } + }, [onChange, showExperimentalProviders, value]); + + function handleExperimentalChange(checked: boolean) { + setShowExperimentalProviders(checked); + if (!checked && isExperimentalProvider(value)) { + onChange("openrouter"); + } + } + return ( -
- {LLM_PROVIDER_OPTIONS.map((option) => { - const selected = option.value === value; - - return ( -
-

{option.description}

- - ); - })} +
+
); } diff --git a/frontend/convex/localCredentials.ts b/frontend/convex/localCredentials.ts index eeafd3f..cc9ac5c 100644 --- a/frontend/convex/localCredentials.ts +++ b/frontend/convex/localCredentials.ts @@ -7,6 +7,18 @@ const serviceValidator = v.union( v.literal("openrouter"), v.literal("openai"), v.literal("anthropic"), + v.literal("google"), + v.literal("xai"), + v.literal("deepseek"), + v.literal("qwen"), + v.literal("mistral"), + v.literal("groq"), + v.literal("togetherai"), + v.literal("deepinfra"), + v.literal("fireworks"), + v.literal("huggingface"), + v.literal("ollama"), + v.literal("lmstudio"), v.literal("custom"), ); @@ -19,6 +31,18 @@ const llmProviderValidator = v.union( v.literal("openrouter"), v.literal("openai"), v.literal("anthropic"), + v.literal("google"), + v.literal("xai"), + v.literal("deepseek"), + v.literal("qwen"), + v.literal("mistral"), + v.literal("groq"), + v.literal("togetherai"), + v.literal("deepinfra"), + v.literal("fireworks"), + v.literal("huggingface"), + v.literal("ollama"), + v.literal("lmstudio"), v.literal("custom"), ); diff --git a/frontend/convex/modelConfig.ts b/frontend/convex/modelConfig.ts index dc47991..5d7480d 100644 --- a/frontend/convex/modelConfig.ts +++ b/frontend/convex/modelConfig.ts @@ -3,12 +3,40 @@ import type { MutationCtx, QueryCtx } from "./_generated/server.js"; import { v } from "convex/values"; import { getIdentity } from "./lib/authz.js"; -type LlmProvider = "openrouter" | "openai" | "anthropic" | "custom"; +type LlmProvider = + | "openrouter" + | "openai" + | "anthropic" + | "google" + | "xai" + | "deepseek" + | "qwen" + | "mistral" + | "groq" + | "togetherai" + | "deepinfra" + | "fireworks" + | "huggingface" + | "ollama" + | "lmstudio" + | "custom"; const providerValidator = v.union( v.literal("openrouter"), v.literal("openai"), v.literal("anthropic"), + v.literal("google"), + v.literal("xai"), + v.literal("deepseek"), + v.literal("qwen"), + v.literal("mistral"), + v.literal("groq"), + v.literal("togetherai"), + v.literal("deepinfra"), + v.literal("fireworks"), + v.literal("huggingface"), + v.literal("ollama"), + v.literal("lmstudio"), v.literal("custom"), ); diff --git a/frontend/convex/schema.ts b/frontend/convex/schema.ts index 458dcc5..f002c21 100644 --- a/frontend/convex/schema.ts +++ b/frontend/convex/schema.ts @@ -140,6 +140,18 @@ export default defineSchema({ v.literal("openrouter"), v.literal("openai"), v.literal("anthropic"), + v.literal("google"), + v.literal("xai"), + v.literal("deepseek"), + v.literal("qwen"), + v.literal("mistral"), + v.literal("groq"), + v.literal("togetherai"), + v.literal("deepinfra"), + v.literal("fireworks"), + v.literal("huggingface"), + v.literal("ollama"), + v.literal("lmstudio"), v.literal("custom") ) ), @@ -157,6 +169,18 @@ export default defineSchema({ v.literal("openrouter"), v.literal("openai"), v.literal("anthropic"), + v.literal("google"), + v.literal("xai"), + v.literal("deepseek"), + v.literal("qwen"), + v.literal("mistral"), + v.literal("groq"), + v.literal("togetherai"), + v.literal("deepinfra"), + v.literal("fireworks"), + v.literal("huggingface"), + v.literal("ollama"), + v.literal("lmstudio"), v.literal("custom") ), keychainAccount: v.optional(v.string()), @@ -172,6 +196,18 @@ export default defineSchema({ v.literal("openrouter"), v.literal("openai"), v.literal("anthropic"), + v.literal("google"), + v.literal("xai"), + v.literal("deepseek"), + v.literal("qwen"), + v.literal("mistral"), + v.literal("groq"), + v.literal("togetherai"), + v.literal("deepinfra"), + v.literal("fireworks"), + v.literal("huggingface"), + v.literal("ollama"), + v.literal("lmstudio"), v.literal("custom") ) ), diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index 09f7951..6178dba 100644 --- a/frontend/lib/backend.ts +++ b/frontend/lib/backend.ts @@ -2,7 +2,7 @@ export interface InferredSchema { dataset_name: string; description: string; columns: InferredColumn[]; - primary_key: string; + primary_key: string[]; retrieval_strategy: "search_fetch" | "browser" | "hybrid"; source_hint: string; } @@ -63,7 +63,23 @@ export interface OpenRouterModel { promptCost: number; } -export type LlmProviderType = "openrouter" | "openai" | "anthropic" | "custom"; +export type LlmProviderType = + | "openrouter" + | "openai" + | "anthropic" + | "google" + | "xai" + | "deepseek" + | "qwen" + | "mistral" + | "groq" + | "togetherai" + | "deepinfra" + | "fireworks" + | "huggingface" + | "ollama" + | "lmstudio" + | "custom"; export interface ServiceSetupStatus { configured: boolean; @@ -149,7 +165,7 @@ export async function saveOpenRouterApiKey( return saveLlmProviderConfig({ provider: "openrouter", apiKey, - defaultModel: "openai/gpt-5.4-mini", + defaultModel: "anthropic/claude-sonnet-4.6", }); } @@ -208,7 +224,7 @@ export async function getModelConfig(token: string): Promise { + if (!/^\d{1,3}$/.test(part)) return Number.NaN; + const value = Number(part); + return value >= 0 && value <= 255 ? value : Number.NaN; + }); + if (octets.some(Number.isNaN)) return false; + + const [first, second] = octets; + if (first === 0 || first === 10 || first === 127 || first === 192) { + return true; + } + if (first === 100 && second >= 64 && second <= 127) return true; + if (first === 169 && second === 254) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + if (first === 198 && (second === 18 || second === 19)) return true; + + return false; +} + +function isLocalIpv6Hostname(hostname: string): boolean { + if (!hostname.includes(":")) return false; + if (hostname === "::1" || hostname === "0:0:0:0:0:0:0:1") return true; + + const firstSegment = Number.parseInt(hostname.split(":")[0] || "0", 16); + if (Number.isNaN(firstSegment)) return false; + + return (firstSegment & 0xfe00) === 0xfc00 || (firstSegment & 0xffc0) === 0xfe80; +} + +export function isLocalOpenRouterOAuthHostname(hostname: string): boolean { + const normalized = normalizedHostname(hostname); + if (!normalized) return true; + if (LOCAL_HOSTNAMES.has(normalized)) return true; + if (LOCAL_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) { + return true; + } + if (!normalized.includes(".") && !normalized.includes(":")) return true; + + return isLocalIpv4Hostname(normalized) || isLocalIpv6Hostname(normalized); +} + +export function canUseOpenRouterOAuth(): boolean { + if (typeof window === "undefined") return false; + return !isLocalOpenRouterOAuthHostname(window.location.hostname); +} + +function subscribeToOpenRouterOAuthAvailability() { + return () => {}; +} + +function unavailableOnServer() { + return false; +} + +export function useCanUseOpenRouterOAuth(): boolean { + return useSyncExternalStore( + subscribeToOpenRouterOAuthAvailability, + canUseOpenRouterOAuth, + unavailableOnServer, + ); +} + export async function beginOpenRouterOAuth(returnTo = "/setup") { + if (!canUseOpenRouterOAuth()) return; + const verifier = randomVerifier(); const challenge = base64Url(await sha256(verifier)); sessionStorage.setItem(OPENROUTER_VERIFIER_KEY, verifier); diff --git a/frontend/public/logos/providers/anthropic-icon.svg b/frontend/public/logos/providers/anthropic-icon.svg new file mode 100644 index 0000000..88dc745 --- /dev/null +++ b/frontend/public/logos/providers/anthropic-icon.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/frontend/public/logos/providers/deepinfra.svg b/frontend/public/logos/providers/deepinfra.svg new file mode 100644 index 0000000..925c139 --- /dev/null +++ b/frontend/public/logos/providers/deepinfra.svg @@ -0,0 +1,29 @@ + + DeepInfra + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/logos/providers/deepseek.svg b/frontend/public/logos/providers/deepseek.svg new file mode 100644 index 0000000..117e952 --- /dev/null +++ b/frontend/public/logos/providers/deepseek.svg @@ -0,0 +1 @@ +DeepSeek diff --git a/frontend/public/logos/providers/fireworks-ai.svg b/frontend/public/logos/providers/fireworks-ai.svg new file mode 100644 index 0000000..06da2e7 --- /dev/null +++ b/frontend/public/logos/providers/fireworks-ai.svg @@ -0,0 +1 @@ +Fireworks AI diff --git a/frontend/public/logos/providers/google-g.svg b/frontend/public/logos/providers/google-g.svg new file mode 100644 index 0000000..40c9063 --- /dev/null +++ b/frontend/public/logos/providers/google-g.svg @@ -0,0 +1 @@ +Google Gemini diff --git a/frontend/public/logos/providers/groq.svg b/frontend/public/logos/providers/groq.svg new file mode 100644 index 0000000..49a43fa --- /dev/null +++ b/frontend/public/logos/providers/groq.svg @@ -0,0 +1 @@ +Groq diff --git a/frontend/public/logos/providers/huggingface.svg b/frontend/public/logos/providers/huggingface.svg new file mode 100644 index 0000000..5992e36 --- /dev/null +++ b/frontend/public/logos/providers/huggingface.svg @@ -0,0 +1 @@ +Hugging Face diff --git a/frontend/public/logos/providers/lmstudio.svg b/frontend/public/logos/providers/lmstudio.svg new file mode 100644 index 0000000..a2a179f --- /dev/null +++ b/frontend/public/logos/providers/lmstudio.svg @@ -0,0 +1 @@ +LM Studio \ No newline at end of file diff --git a/frontend/public/logos/providers/mistral-ai.svg b/frontend/public/logos/providers/mistral-ai.svg new file mode 100644 index 0000000..b0af170 --- /dev/null +++ b/frontend/public/logos/providers/mistral-ai.svg @@ -0,0 +1 @@ +Mistral AI diff --git a/frontend/public/logos/providers/ollama.svg b/frontend/public/logos/providers/ollama.svg new file mode 100644 index 0000000..432f73e --- /dev/null +++ b/frontend/public/logos/providers/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/frontend/public/logos/providers/openai-icon.svg b/frontend/public/logos/providers/openai-icon.svg new file mode 100644 index 0000000..ebbdab0 --- /dev/null +++ b/frontend/public/logos/providers/openai-icon.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/frontend/public/logos/providers/qwen.svg b/frontend/public/logos/providers/qwen.svg new file mode 100644 index 0000000..fbf9232 --- /dev/null +++ b/frontend/public/logos/providers/qwen.svg @@ -0,0 +1 @@ +Qwen diff --git a/frontend/public/logos/providers/together-ai.svg b/frontend/public/logos/providers/together-ai.svg new file mode 100644 index 0000000..8d0f75b --- /dev/null +++ b/frontend/public/logos/providers/together-ai.svg @@ -0,0 +1 @@ +Together.ai diff --git a/frontend/public/logos/providers/xai.svg b/frontend/public/logos/providers/xai.svg new file mode 100644 index 0000000..d8e9f54 --- /dev/null +++ b/frontend/public/logos/providers/xai.svg @@ -0,0 +1 @@ +xAI