From b67139c3fe4a16fbfea501da669c00a3e2813714 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 9 Jun 2026 13:50:26 -0700 Subject: [PATCH 01/17] 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 02/17] 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 From ef9de138ec9769936794b92e9697090018694a86 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Thu, 11 Jun 2026 14:43:18 -0700 Subject: [PATCH 03/17] testing row extraction --- backend/package-lock.json | 13 + backend/package.json | 1 + backend/src/mastra/tools/investigate-tool.ts | 35 ++ .../src/row-extractors/try-row-extractor.ts | 582 ++++++++++++++++++ 4 files changed, 631 insertions(+) create mode 100644 backend/src/row-extractors/try-row-extractor.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index e231b48..8239c4e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "dotenv": "^16.4.0", "fastify": "^5.0.0", "fastify-plugin": "^5.1.0", + "playwright-core": "^1.60.0", "posthog-node": "^5.35.1", "resend": "^6.12.3", "zod": "^4.4.3" @@ -7623,6 +7624,18 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postal-mime": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", diff --git a/backend/package.json b/backend/package.json index 11df78b..0e161cb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "dotenv": "^16.4.0", "fastify": "^5.0.0", "fastify-plugin": "^5.1.0", + "playwright-core": "^1.60.0", "posthog-node": "^5.35.1", "resend": "^6.12.3", "zod": "^4.4.3" diff --git a/backend/src/mastra/tools/investigate-tool.ts b/backend/src/mastra/tools/investigate-tool.ts index 0139aa4..65fc7e2 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 { tryRowExtractor } from "../../row-extractors/try-row-extractor.js"; const investigateInputSchema = z.object({ entity_hint: z @@ -100,6 +101,40 @@ export function buildSubagentTool( } if (metrics) metrics.investigateCalls++; + + const extractorResult = await tryRowExtractor({ + datasetId: authorizedDatasetId, + columns, + primaryKeys: primary_keys, + urls, + context, + }); + if (extractorResult.status === "inserted") { + if (metrics) metrics.rowsInserted++; + console.log( + `[run_subagent] row extractor inserted entity="${entity_hint}" reason="${extractorResult.reason}"`, + ); + return { + inserted: true, + reason: extractorResult.reason, + row_summary: extractorResult.rowSummary, + clues: undefined, + }; + } + if (/duplicate/i.test(extractorResult.reason)) { + return { + inserted: false, + reason: extractorResult.reason, + row_summary: undefined, + clues: undefined, + }; + } + if (extractorResult.status === "failed") { + console.warn( + `[run_subagent] row extractor failed entity="${entity_hint}" reason="${extractorResult.reason}"`, + ); + } + console.log( `[run_subagent] spawning subagent user=${authContext.authorizedUserId} run=${authContext.workflowRunId} dataset=${authorizedDatasetId} entity="${entity_hint}" pk=${JSON.stringify(primary_keys)}`, ); diff --git a/backend/src/row-extractors/try-row-extractor.ts b/backend/src/row-extractors/try-row-extractor.ts new file mode 100644 index 0000000..8f61112 --- /dev/null +++ b/backend/src/row-extractors/try-row-extractor.ts @@ -0,0 +1,582 @@ +import { chromium, type Browser, type Page } from "playwright-core"; + +import { getSignal } from "../abort-registry.js"; +import { convex, internal } from "../convex.js"; +import { FETCH_TIMEOUT_MS } from "../fetch-timeout.js"; +import { getTinyFishApiKey, tinyFishHeaders } from "../local-credentials.js"; +import type { PopulateColumn } from "../pipeline/populate.js"; + +type ExtractorStatus = "inserted" | "miss" | "failed"; + +export interface TryRowExtractorInput { + datasetId: string; + columns: PopulateColumn[]; + primaryKeys: Record; + urls?: string[]; + context?: string; +} + +export interface TryRowExtractorResult { + status: ExtractorStatus; + reason: string; + rowSummary?: string; + sources?: string[]; +} + +interface TinyFishBrowserSession { + session_id: string; + cdp_url: string; + base_url: string; +} + +interface GitHubRepoFacts { + owner: string; + repo: string; + fullName: string; + url: string; + description?: string; + stars?: number; + forks?: number; + watchers?: number; + issues?: number; + pullRequests?: number; + language?: string; + license?: string; + latestCommitAt?: string; + updatedAt?: string; + createdAt?: string; + homepage?: string; + archived?: boolean; +} + +interface RawGitHubRepoDomFacts { + description?: string; + stars?: string; + forks?: string; + watchers?: string; + issues?: string; + pullRequests?: string; + language?: string; + license?: string; + latestCommitAt?: string; + homepage?: string; + archived?: boolean; +} + +const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]); +const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]); +const BROWSER_TIMEOUT_MS = 45_000; +const CDP_CONNECT_TIMEOUT_MS = 45_000; +const BROWSER_ATTEMPTS = 2; + +export async function tryRowExtractor( + input: TryRowExtractorInput, +): Promise { + if (!ENABLED_VALUES.has((process.env.ROW_EXTRACTORS_ENABLED ?? "").toLowerCase())) { + return { status: "miss", reason: "row extractors are disabled" }; + } + + const url = firstCandidateUrl(input); + if (!url) return { status: "miss", reason: "no URL primary key or candidate URL" }; + + const repoRef = parseGitHubRepoUrl(url); + if (!repoRef) { + return { status: "miss", reason: `unsupported URL host: ${safeHost(url)}` }; + } + + try { + const facts = await extractGitHubRepoFacts(url, input.datasetId); + const row = buildGitHubRow(input.columns, input.primaryKeys, facts); + if (!row) { + return { + status: "miss", + reason: "GitHub extractor could not satisfy all requested columns", + }; + } + + await convex.mutation(internal.datasetRows.insert, { + datasetId: input.datasetId, + data: row, + sources: [facts.url], + rowSummary: facts.description + ? `${facts.fullName}: ${facts.description}` + : facts.fullName, + howFound: + "Opened the GitHub repository URL with TinyFish Browser and extracted repository facts from the rendered page.", + }); + + return { + status: "inserted", + reason: "Inserted by GitHub row extractor", + rowSummary: facts.description + ? `${facts.fullName}: ${facts.description}` + : facts.fullName, + sources: [facts.url], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/duplicate/i.test(msg)) { + return { + status: "miss", + reason: `${msg} Move on to the next entity.`, + }; + } + return { status: "failed", reason: msg }; + } +} + +function firstCandidateUrl(input: TryRowExtractorInput): string | undefined { + const fromPrimaryKey = Object.values(input.primaryKeys).find((value) => + isHttpUrl(value), + ); + if (fromPrimaryKey) return normalizeUrl(fromPrimaryKey); + + const fromUrls = input.urls?.find(isHttpUrl); + if (fromUrls) return normalizeUrl(fromUrls); + + const fromContext = input.context?.match(/https?:\/\/[^\s)>"']+/i)?.[0]; + return fromContext ? normalizeUrl(fromContext) : undefined; +} + +function normalizeUrl(value: string): string { + return value.trim().replace(/[.,;:]+$/, ""); +} + +function isHttpUrl(value: string | undefined): value is string { + if (!value) return false; + try { + const parsed = new URL(normalizeUrl(value)); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function safeHost(value: string): string { + try { + return new URL(value).host; + } catch { + return "invalid-url"; + } +} + +function parseGitHubRepoUrl(value: string): { owner: string; repo: string } | null { + try { + const url = new URL(value); + if (!GITHUB_HOSTS.has(url.hostname.toLowerCase())) return null; + const [owner, repo] = url.pathname + .split("/") + .filter(Boolean) + .map((part) => part.trim()); + if (!owner || !repo) return null; + if (["orgs", "topics", "marketplace", "features"].includes(owner)) return null; + return { owner, repo: repo.replace(/\.git$/i, "") }; + } catch { + return null; + } +} + +async function extractGitHubRepoFacts( + url: string, + datasetId: string, +): Promise { + const apiKey = await getTinyFishApiKey(); + if (!apiKey) throw new Error("TINYFISH_API_KEY is not configured"); + + let lastError: unknown; + for (let attempt = 1; attempt <= BROWSER_ATTEMPTS; attempt++) { + try { + return await extractGitHubRepoFactsOnce(apiKey, url, datasetId); + } catch (err) { + lastError = err; + if (getSignal(datasetId)?.aborted || attempt === BROWSER_ATTEMPTS) break; + const msg = err instanceof Error ? err.message : String(err); + console.warn( + `[row_extractor] GitHub browser attempt ${attempt} failed; retrying: ${msg}`, + ); + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +async function extractGitHubRepoFactsOnce( + apiKey: string, + url: string, + datasetId: string, +): Promise { + const session = await createTinyFishBrowserSession(apiKey, url, datasetId); + let browser: Browser | undefined; + try { + browser = await chromium.connectOverCDP(session.cdp_url, { + timeout: CDP_CONNECT_TIMEOUT_MS, + }); + const context = browser.contexts()[0] ?? (await browser.newContext()); + const page = context.pages()[0] ?? (await context.newPage()); + await page.goto(url, { + waitUntil: "domcontentloaded", + timeout: BROWSER_TIMEOUT_MS, + }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => { + // GitHub may keep long-lived requests open. DOMContentLoaded is enough. + }); + return await readGitHubRepoFacts(page); + } finally { + await browser?.close().catch(() => undefined); + } +} + +async function createTinyFishBrowserSession( + apiKey: string, + url: string, + datasetId: string, +): Promise { + const response = await withRunTimeoutSignal(datasetId, FETCH_TIMEOUT_MS, (signal) => + fetch("https://agent.tinyfish.ai/v1/browser", { + method: "POST", + headers: { + ...tinyFishHeaders(apiKey), + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ url }), + signal, + }), + ); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `TinyFish Browser returned HTTP ${response.status}: ${body.slice(0, 200)}`, + ); + } + + const data = (await response.json()) as Partial; + if (!data.session_id || !data.cdp_url || !data.base_url) { + throw new Error("TinyFish Browser response did not include CDP connection details"); + } + + return { + session_id: data.session_id, + cdp_url: data.cdp_url, + base_url: data.base_url, + }; +} + +async function withRunTimeoutSignal( + datasetId: string, + timeoutMs: number, + operation: (signal: AbortSignal) => Promise, +): Promise { + const runSignal = getSignal(datasetId); + if (runSignal?.aborted) throw new DOMException("Run was stopped", "AbortError"); + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new DOMException("Timed out", "TimeoutError")), + timeoutMs, + ); + const abortFromRun = () => + controller.abort(runSignal?.reason ?? new DOMException("Run was stopped", "AbortError")); + + runSignal?.addEventListener("abort", abortFromRun, { once: true }); + try { + return await operation(controller.signal); + } finally { + clearTimeout(timeout); + runSignal?.removeEventListener("abort", abortFromRun); + } +} + +async function readGitHubRepoFacts(page: Page): Promise { + const url = page.url(); + const repoRef = parseGitHubRepoUrl(url); + if (!repoRef) throw new Error(`Not a GitHub repository page: ${url}`); + + const facts = (await page.evaluate(` + (() => { + const text = (selector) => + document.querySelector(selector)?.textContent?.trim() || undefined; + const attr = (selector, name) => + document.querySelector(selector)?.getAttribute(name) || undefined; + const firstCandidateText = (selector, predicate) => + Array.from(document.querySelectorAll(selector)) + .map((el) => el.textContent?.trim()) + .filter(Boolean) + .find((value) => !predicate || predicate(value)); + const language = () => + text("[itemprop=\\"programmingLanguage\\"]") ?? + text("a[href*=\\"search?l=\\"] span.color-fg-default.text-bold") ?? + text("a[href*=\\"search?l=\\"] .text-bold"); + const license = () => + firstCandidateText( + "a[href*=\\"LICENSE\\"], a[href*=\\"license\\"], [data-testid*=\\"license\\"]", + (value) => /licensed|MIT|Apache|BSD|GPL|MPL|ISC/i.test(value), + ) ?? + firstCandidateText( + "a[href*=\\"LICENSE\\"], a[href*=\\"license\\"], [data-testid*=\\"license\\"]", + (value) => !/^(license|view license)$/i.test(value), + ) ?? + firstCandidateText( + "a[href*=\\"LICENSE\\"], a[href*=\\"license\\"], [data-testid*=\\"license\\"]", + ) ?? + text("svg.octicon-law + span"); + const bodyText = document.body?.innerText ?? ""; + + return { + description: + text("[data-pjax=\\"#repo-content-pjax-container\\"] [itemprop=\\"about\\"]") ?? + text("[itemprop=\\"about\\"]") ?? + attr("meta[name='description']", "content"), + stars: + text("#repo-stars-counter-star") ?? + text("a[href$='/stargazers'] strong") ?? + text("a[href$='/stargazers']"), + forks: + text("#repo-network-counter") ?? + text("a[href$='/forks'] strong") ?? + text("a[href$='/forks']"), + watchers: + text("a[href$='/watchers'] strong") ?? + text("a[href$='/watchers']"), + issues: + text("#issues-tab span.Counter") ?? + text("a[href$=\\"/issues\\"] span.Counter") ?? + text("a[data-tab-item=\\"i1issues-tab\\"] span.Counter"), + pullRequests: + text("#pull-requests-tab span.Counter") ?? + text("a[href$=\\"/pulls\\"] span.Counter") ?? + text("a[data-tab-item=\\"i2pull-requests-tab\\"] span.Counter"), + language: language(), + license: license(), + latestCommitAt: + attr("relative-time[datetime]", "datetime") ?? + attr("time-ago[datetime]", "datetime"), + homepage: attr("[itemprop='url']", "href"), + archived: /This repository has been archived/i.test(bodyText), + }; + })() + `)) as RawGitHubRepoDomFacts; + + const apiFacts = await fetchGitHubApiFacts(page, repoRef.owner, repoRef.repo).catch( + () => undefined, + ); + + return { + owner: repoRef.owner, + repo: repoRef.repo, + fullName: `${repoRef.owner}/${repoRef.repo}`, + url, + description: apiFacts?.description ?? cleanOptionalText(facts.description), + stars: apiFacts?.stars ?? parseCompactNumber(facts.stars), + forks: apiFacts?.forks ?? parseCompactNumber(facts.forks), + watchers: apiFacts?.watchers ?? parseCompactNumber(facts.watchers), + issues: parseCompactNumber(facts.issues) ?? apiFacts?.issues, + pullRequests: parseCompactNumber(facts.pullRequests) ?? apiFacts?.pullRequests, + language: apiFacts?.language ?? cleanOptionalText(facts.language), + license: apiFacts?.license ?? cleanOptionalText(facts.license), + latestCommitAt: apiFacts?.latestCommitAt ?? facts.latestCommitAt, + updatedAt: apiFacts?.updatedAt, + createdAt: apiFacts?.createdAt, + homepage: apiFacts?.homepage ?? cleanOptionalText(facts.homepage), + archived: apiFacts?.archived ?? facts.archived, + }; +} + +async function fetchGitHubApiFacts( + page: Page, + owner: string, + repo: string, +): Promise> { + const response = await page.request.get( + `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + { + headers: { + Accept: "application/vnd.github+json", + }, + timeout: FETCH_TIMEOUT_MS, + }, + ); + if (!response.ok()) { + throw new Error(`GitHub API returned HTTP ${response.status()}`); + } + + const data = (await response.json()) as { + description?: string | null; + stargazers_count?: number; + forks_count?: number; + watchers_count?: number; + open_issues_count?: number; + language?: string | null; + license?: { spdx_id?: string | null; name?: string | null } | null; + pushed_at?: string | null; + updated_at?: string | null; + created_at?: string | null; + homepage?: string | null; + archived?: boolean; + html_url?: string; + }; + + return { + url: data.html_url, + description: data.description ?? undefined, + stars: data.stargazers_count, + forks: data.forks_count, + watchers: data.watchers_count, + issues: data.open_issues_count, + language: data.language ?? undefined, + license: data.license?.spdx_id || data.license?.name || undefined, + latestCommitAt: data.pushed_at ?? undefined, + updatedAt: data.updated_at ?? undefined, + createdAt: data.created_at ?? undefined, + homepage: data.homepage || undefined, + archived: data.archived, + }; +} + +function buildGitHubRow( + columns: PopulateColumn[], + primaryKeys: Record, + facts: GitHubRepoFacts, +): Record | null { + const row: Record = {}; + + for (const column of columns) { + const pkValue = findPrimaryKeyValue(column.name, primaryKeys); + const rawValue = pkValue ?? valueForGitHubColumn(column.name, facts); + const value = coerceColumnValue(rawValue, column); + if (value === undefined) return null; + row[column.name] = value; + } + + return row; +} + +function findPrimaryKeyValue( + columnName: string, + primaryKeys: Record, +): string | undefined { + if (primaryKeys[columnName]) return primaryKeys[columnName]; + const normalizedColumn = normalizeFieldName(columnName); + const entry = Object.entries(primaryKeys).find( + ([key]) => normalizeFieldName(key) === normalizedColumn, + ); + return entry?.[1]; +} + +function valueForGitHubColumn( + columnName: string, + facts: GitHubRepoFacts, +): string | number | boolean | undefined { + const normalized = normalizeFieldName(columnName); + if (matches(normalized, ["repository_url", "repo_url", "github_url", "url", "link"])) { + return facts.url; + } + if (matches(normalized, ["repository_name", "repo_name"])) { + return facts.fullName; + } + if (matches(normalized, ["repository", "repo", "name"])) { + return facts.repo; + } + if (matches(normalized, ["full_name", "repository_full_name", "repo_full_name"])) { + return facts.fullName; + } + if (matches(normalized, ["owner", "organization", "org", "user"])) { + return facts.owner; + } + if (matches(normalized, ["description", "summary", "about"])) { + return facts.description; + } + if (matches(normalized, ["stars", "star_count", "stargazers", "stargazer_count"])) { + return facts.stars; + } + if (matches(normalized, ["forks", "fork_count"])) { + return facts.forks; + } + if (matches(normalized, ["watchers", "watcher_count"])) { + return facts.watchers; + } + if (matches(normalized, ["issues", "open_issues", "open_issue_count"])) { + return facts.issues; + } + if (matches(normalized, ["pull_requests", "open_pull_requests", "prs", "open_prs", "pr_count", "open_pr_count"])) { + return facts.pullRequests; + } + if (matches(normalized, ["language", "primary_language"])) { + return facts.language; + } + if (matches(normalized, ["license", "license_type", "license_spdx"])) { + return facts.license; + } + if (matches(normalized, ["latest_commit", "latest_commit_at", "last_commit", "pushed_at", "activity", "last_activity"])) { + return facts.latestCommitAt; + } + if (matches(normalized, ["updated", "updated_at", "last_updated"])) { + return facts.updatedAt; + } + if (matches(normalized, ["created", "created_at"])) { + return facts.createdAt; + } + if (matches(normalized, ["homepage", "website", "site"])) { + return facts.homepage; + } + if (matches(normalized, ["archived", "is_archived"])) { + return facts.archived; + } + return undefined; +} + +function coerceColumnValue( + value: string | number | boolean | undefined, + column: PopulateColumn, +): string | number | boolean | undefined { + if (value === undefined || value === "") return undefined; + switch (column.type) { + case "number": { + if (typeof value === "number") return Number.isFinite(value) ? value : undefined; + const parsed = Number(String(value).replace(/,/g, "")); + return Number.isFinite(parsed) ? parsed : undefined; + } + case "boolean": + if (typeof value === "boolean") return value; + if (/^(true|yes)$/i.test(String(value))) return true; + if (/^(false|no)$/i.test(String(value))) return false; + return undefined; + case "url": + return isHttpUrl(String(value)) ? normalizeUrl(String(value)) : undefined; + case "date": { + const date = new Date(String(value)); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); + } + case "text": + return String(value).trim(); + } +} + +function parseCompactNumber(value: string | undefined): number | undefined { + if (!value) return undefined; + const match = value.replace(/,/g, "").match(/([\d.]+)\s*([kmb])?/i); + if (!match) return undefined; + const base = Number(match[1]); + if (!Number.isFinite(base)) return undefined; + const suffix = match[2]?.toLowerCase(); + const multiplier = suffix === "k" ? 1_000 : suffix === "m" ? 1_000_000 : suffix === "b" ? 1_000_000_000 : 1; + return Math.round(base * multiplier); +} + +function cleanOptionalText(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +function normalizeFieldName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +function matches(value: string, candidates: string[]): boolean { + return candidates.includes(value); +} From 56f68abab26350abae45880e85ed744bb839c946 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Wed, 15 Jul 2026 11:45:52 -0700 Subject: [PATCH 04/17] Address PR review: hard-validate model slugs + auth-guard provider models Resolves the two review blockers on selecting a provider/model: 1. Hard validation on save. POST /settings/models now rejects a model slug the current provider doesn't offer (400) instead of warning and saving it, so incompatible selections fail at save time rather than during schema-inference/populate/update. Local/custom OpenAI-compatible providers (custom, ollama, lmstudio) whose catalogs can't be enumerated stay exempt. Validation fails closed (502 "try again") if the provider list can't be fetched. Logic centralized in findUnsupportedModelSlugs(). The frontend now surfaces the rejection inline in the model side sheet. 2. Auth guard on GET /llm-provider/models. The route exercised configured provider credentials and rate limits while being public; it now runs requireAuth first (passes through in local mode for setup, enforces a Clerk token in prod). getLlmProviderModels() threads the token from authenticated callers; the local-mode setup flow calls it without one. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/config/models.ts | 36 +++++++++++++++++++ backend/src/index.ts | 31 +++++++++++----- .../app/dashboard/settings/models/page.tsx | 22 +++++++++--- .../components/settings/ModelSideSheet.tsx | 7 ++++ frontend/lib/backend.ts | 10 +++++- 5 files changed, 91 insertions(+), 15 deletions(-) diff --git a/backend/src/config/models.ts b/backend/src/config/models.ts index 3da1baa..61675d3 100644 --- a/backend/src/config/models.ts +++ b/backend/src/config/models.ts @@ -12,6 +12,7 @@ import { defaultBaseUrlForLlmProvider, defaultModelForLlmProviderRole, modelsUrlForLlmProvider, + type LlmProviderType, type ModelRoleKey, } from "./llm.js"; @@ -416,6 +417,41 @@ export async function fetchModelsForCurrentLlmProvider(): Promise { + if (slugs.length === 0) return []; + + const config = await getLlmProviderConfig(); + if (config && providerAllowsAnyModelSlug(config.provider)) return []; + + const models = await fetchModelsForCurrentLlmProvider(); + const available = new Set(models.map((m) => m.canonicalSlug)); + return slugs.filter((slug) => !available.has(slug)); +} + /** * Validate that a model slug exists in the cached model list. * Throws with a clear message if the slug is not found. diff --git a/backend/src/index.ts b/backend/src/index.ts index beb6291..38e0abb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -839,6 +839,12 @@ fastify.get("/openrouter/models", async (req, reply) => { }); fastify.get("/llm-provider/models", async (req, reply) => { + // Fetching provider models exercises configured provider credentials and + // rate limits, so it must not be public. In local mode requireAuth passes + // through (single local user); in prod it enforces a valid Clerk token. + await requireAuth(req, reply); + if (reply.sent) return; + const { fetchModelsForCurrentLlmProvider } = await import("./config/models.js"); try { const models = await fetchModelsForCurrentLlmProvider(); @@ -864,7 +870,7 @@ await fastify.register(async (instance) => { }); instance.post("/settings/models", async (req, reply) => { - const { upsertModelConfig, fetchModelsForCurrentLlmProvider } = await import("./config/models.js"); + const { upsertModelConfig, findUnsupportedModelSlugs } = await import("./config/models.js"); const body = req.body as { schemaInference?: string | null; populateOrchestrator?: string | null; @@ -882,16 +888,23 @@ await fastify.register(async (instance) => { if (config.investigateSubagent) toValidate.push({ role: "investigateSubagent", slug: config.investigateSubagent }); if (toValidate.length > 0) { + let unsupported: string[]; try { - const models = await fetchModelsForCurrentLlmProvider(); - for (const { role, slug } of toValidate) { - const found = models.some((m) => m.canonicalSlug === slug); - if (!found) { - req.log.warn({ role, slug }, "Saving model slug that was not returned by the current LLM provider"); - } - } + unsupported = await findUnsupportedModelSlugs(toValidate.map((v) => v.slug)); } catch (err) { - req.log.error(err, "Failed to validate model slugs — allowing save"); + // Fail closed: if we can't confirm the slug against the provider, don't + // persist a selection that would only break at inference/populate time. + req.log.error(err, "Failed to verify model slugs against the current LLM provider"); + return reply.code(502).send({ + error: + "Couldn't verify the selected model against the provider. Please try again.", + }); + } + const uniqueUnsupported = [...new Set(unsupported)]; + if (uniqueUnsupported.length > 0) { + return reply.code(400).send({ + error: `Unsupported model${uniqueUnsupported.length > 1 ? "s" : ""}: ${uniqueUnsupported.join(", ")}. Choose a model offered by the current provider.`, + }); } } diff --git a/frontend/app/dashboard/settings/models/page.tsx b/frontend/app/dashboard/settings/models/page.tsx index f376c89..9417edc 100644 --- a/frontend/app/dashboard/settings/models/page.tsx +++ b/frontend/app/dashboard/settings/models/page.tsx @@ -42,6 +42,7 @@ export default function ModelSettingsPage() { ); const activeModelListCacheKeyRef = useRef(activeModelListCacheKey); const [isSavingModel, setIsSavingModel] = useState(false); + const [saveError, setSaveError] = useState(null); const [modelConfigReloadKey, setModelConfigReloadKey] = useState(0); const needsOpenRouterCache = !isLocalMode || llmProvider === "openrouter"; @@ -122,6 +123,7 @@ export default function ModelSettingsPage() { if (!nextModelId) return; setIsSavingModel(true); + setSaveError(null); try { const token = await getToken(); if (!token) throw new Error("Not authenticated"); @@ -130,19 +132,23 @@ export default function ModelSettingsPage() { prev ? { ...prev, [role.key]: nextModelId } : null ); setActiveSheet(null); - } catch { - // we will add toast later + } catch (err) { + setSaveError( + err instanceof Error ? err.message : "Failed to save model preference.", + ); } finally { setIsSavingModel(false); } } function openSideSheet(role: ModelRole) { + setSaveError(null); const cacheKey = activeModelListCacheKeyRef.current || activeModelListCacheKey; if (sheetModels.length === 0 || sheetModelsCacheKey !== cacheKey) { setSheetModels([]); setSheetModelsCacheKey(cacheKey); - getLlmProviderModels() + getToken() + .then((token) => getLlmProviderModels(token ?? undefined)) .then((models) => { if (activeModelListCacheKeyRef.current !== cacheKey) return; setSheetModels(models); @@ -224,7 +230,12 @@ export default function ModelSettingsPage() { {activeSheet && ( !isSavingModel && setActiveSheet(null)} + onClose={() => { + if (isSavingModel) return; + setSaveError(null); + setActiveSheet(null); + }} + error={saveError} title={`Select ${activeSheet.role.label} Model`} selectedModel={getSelectedModel(activeSheet.role)} models={sideSheetModels} @@ -240,7 +251,8 @@ export default function ModelSettingsPage() { if (!token) throw new Error("Not authenticated"); models = await refreshOpenRouterModels(token); } else { - models = await getLlmProviderModels(); + const token = await getToken(); + models = await getLlmProviderModels(token ?? undefined); } if (activeModelListCacheKeyRef.current !== cacheKey) return; setSheetModelsCacheKey(cacheKey); diff --git a/frontend/components/settings/ModelSideSheet.tsx b/frontend/components/settings/ModelSideSheet.tsx index e775d89..0074cbf 100644 --- a/frontend/components/settings/ModelSideSheet.tsx +++ b/frontend/components/settings/ModelSideSheet.tsx @@ -14,6 +14,7 @@ interface ModelSideSheetProps { onRefresh?: () => Promise; isRefreshing?: boolean; isSaving?: boolean; + error?: string | null; } function groupModelsByProvider(models: OpenRouterModel[]): Record { @@ -68,6 +69,7 @@ export function ModelSideSheet({ onRefresh, isRefreshing, isSaving, + error, }: ModelSideSheetProps) { const [search, setSearch] = useState(""); const [customSlug, setCustomSlug] = useState(selectedModel); @@ -187,6 +189,11 @@ export function ModelSideSheet({ Use + {error && ( +

+ {error} +

+ )}
diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index 6178dba..19d1a31 100644 --- a/frontend/lib/backend.ts +++ b/frontend/lib/backend.ts @@ -275,9 +275,17 @@ export async function getOpenRouterModels(): Promise { return data.models ?? []; } -export async function getLlmProviderModels(): Promise { +/** + * Fetch the current LLM provider's selectable model list. + * + * Requires auth outside local mode (the route exercises provider credentials + * and rate limits). Pass a Clerk JWT from the authenticated settings UI; the + * local-mode setup flow may call it without a token. + */ +export async function getLlmProviderModels(token?: string): Promise { const res = await fetch(`${BACKEND_URL}/llm-provider/models`, { method: "GET", + headers: token ? { Authorization: `Bearer ${token}` } : undefined, }); if (!res.ok) { From 3839441f68a2e9db8e51ed4f2ea1ceb4c8e17a42 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Wed, 15 Jul 2026 12:02:22 -0700 Subject: [PATCH 05/17] Fix model-save false-rejections found in self-review Two confirmed false-rejection regressions in the new hard-validation path: - qwen: fetchModelsForCurrentLlmProvider returns a static 8-entry stub, not a live catalog, so valid DashScope slugs (qwen-turbo, qwen-vl-max, future releases) were hard-rejected at save time despite working at runtime. qwen's catalog isn't enumerable, so it now joins custom/ollama/lmstudio in providerAllowsAnyModelSlug (validation skipped; slug accepted as-is). - openrouter: models are served from a persistent Convex cache with no TTL, so a cache predating a model's release would falsely reject a currently-offered slug. findUnsupportedModelSlugs now refreshes the cache once on a miss and re-checks before rejecting. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/config/models.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/backend/src/config/models.ts b/backend/src/config/models.ts index 61675d3..d3da6f6 100644 --- a/backend/src/config/models.ts +++ b/backend/src/config/models.ts @@ -418,13 +418,19 @@ export async function fetchModelsForCurrentLlmProvider(): Promise m.canonicalSlug)); - return slugs.filter((slug) => !available.has(slug)); + let missing = slugs.filter((slug) => !available.has(slug)); + + // OpenRouter is served from a persistent Convex cache with no TTL, so a + // stale cache that predates a model's release would falsely reject a slug the + // provider actually offers. Refresh once and re-check before rejecting. + if (missing.length > 0 && config?.provider === "openrouter") { + const fresh = await fetchModelsFromOpenRouter(); + await upsertModelBatch(fresh); + const refreshed = new Set(fresh.map((m) => m.canonicalSlug)); + missing = missing.filter((slug) => !refreshed.has(slug)); + } + + return missing; } /** From 241e401ada60a86de136ea45c90492f09738f1b6 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 21 Jul 2026 17:25:25 -0700 Subject: [PATCH 06/17] Modernize default model slugs + fix provider-selector reset Refresh every hardcoded default model to a current, verified provider API slug (researched against official docs, 2026-07-21): - OpenRouter env defaults: claude-sonnet-5 (schema/orchestrator), claude-haiku-4.5 (subagent) - OpenAI: gpt-5.6-terra / gpt-5.6-luna (Sol/Terra/Luna tiers; the old gpt-5.4-mini naming was wrong) - Anthropic: claude-sonnet-5 / claude-haiku-4-5 - xAI: grok-4.5 - DeepSeek: deepseek-v4-pro / deepseek-v4-flash (deepseek-chat is being deprecated 2026-07-24) - Qwen: qwen3-max / qwen3.5-flash; refreshed static picker list - Mistral: mistral-medium-latest / mistral-small-latest - Groq: gpt-oss-120b / gpt-oss-20b - Together / DeepInfra / Fireworks / HF: GLM 5.2 and DeepSeek V4 slugs - Gemini kept at gemini-3.5-flash (3.6 Flash unverified on official docs) Also fixes the provider modal snapping back to OpenRouter on open: the Experimental Providers toggle now initializes from the current selection, and removes a stray double blank line in the model settings page. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/config/llm.ts | 62 +++++++-------- backend/src/config/models.ts | 76 +++++-------------- backend/src/env.ts | 6 +- .../app/dashboard/settings/models/page.tsx | 2 - .../components/settings/llm-providers.tsx | 30 ++++---- frontend/lib/backend.ts | 4 +- 6 files changed, 72 insertions(+), 108 deletions(-) diff --git a/backend/src/config/llm.ts b/backend/src/config/llm.ts index cb603dc..bf2386e 100644 --- a/backend/src/config/llm.ts +++ b/backend/src/config/llm.ts @@ -87,14 +87,14 @@ export const LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE: Record< investigateSubagent: env.INVESTIGATE_SUBAGENT_MODEL, }, openai: { - schemaInference: "gpt-5.4-mini", - populateOrchestrator: "gpt-5.4-mini", - investigateSubagent: "gpt-5.4-mini", + schemaInference: "gpt-5.6-terra", + populateOrchestrator: "gpt-5.6-terra", + investigateSubagent: "gpt-5.6-luna", }, anthropic: { - schemaInference: "claude-sonnet-4-6", - populateOrchestrator: "claude-haiku-4-5-20251001", - investigateSubagent: "claude-haiku-4-5-20251001", + schemaInference: "claude-sonnet-5", + populateOrchestrator: "claude-sonnet-5", + investigateSubagent: "claude-haiku-4-5", }, google: { schemaInference: "gemini-3.5-flash", @@ -102,49 +102,49 @@ export const LLM_PROVIDER_DEFAULT_MODELS_BY_ROLE: Record< investigateSubagent: "gemini-3.5-flash", }, xai: { - schemaInference: "grok-4.3", - populateOrchestrator: "grok-4.3", - investigateSubagent: "grok-4.3", + schemaInference: "grok-4.5", + populateOrchestrator: "grok-4.5", + investigateSubagent: "grok-4.5", }, deepseek: { - schemaInference: "deepseek-chat", - populateOrchestrator: "deepseek-chat", - investigateSubagent: "deepseek-chat", + schemaInference: "deepseek-v4-pro", + populateOrchestrator: "deepseek-v4-pro", + investigateSubagent: "deepseek-v4-flash", }, qwen: { - schemaInference: "qwen-plus", - populateOrchestrator: "qwen-plus", - investigateSubagent: "qwen-plus", + schemaInference: "qwen3-max", + populateOrchestrator: "qwen3-max", + investigateSubagent: "qwen3.5-flash", }, mistral: { - schemaInference: "mistral-large-latest", - populateOrchestrator: "mistral-large-latest", - investigateSubagent: "mistral-large-latest", + schemaInference: "mistral-medium-latest", + populateOrchestrator: "mistral-medium-latest", + investigateSubagent: "mistral-small-latest", }, groq: { schemaInference: "openai/gpt-oss-120b", populateOrchestrator: "openai/gpt-oss-120b", - investigateSubagent: "openai/gpt-oss-120b", + investigateSubagent: "openai/gpt-oss-20b", }, togetherai: { - schemaInference: "Qwen/Qwen3.5-397B-A17B", - populateOrchestrator: "Qwen/Qwen3.5-397B-A17B", - investigateSubagent: "Qwen/Qwen3.5-397B-A17B", + schemaInference: "zai-org/GLM-5.2", + populateOrchestrator: "zai-org/GLM-5.2", + investigateSubagent: "openai/gpt-oss-120b", }, 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", + schemaInference: "deepseek-ai/DeepSeek-V4-Pro", + populateOrchestrator: "deepseek-ai/DeepSeek-V4-Pro", + investigateSubagent: "deepseek-ai/DeepSeek-V4-Flash", }, fireworks: { - schemaInference: "accounts/fireworks/models/kimi-k2p5", - populateOrchestrator: "accounts/fireworks/models/kimi-k2p5", - investigateSubagent: "accounts/fireworks/models/kimi-k2p5", + schemaInference: "accounts/fireworks/models/glm-5p2", + populateOrchestrator: "accounts/fireworks/models/glm-5p2", + investigateSubagent: "accounts/fireworks/models/glm-5p2", }, huggingface: { - schemaInference: "deepseek-ai/DeepSeek-V3-0324", - populateOrchestrator: "deepseek-ai/DeepSeek-V3-0324", - investigateSubagent: "deepseek-ai/DeepSeek-V3-0324", + schemaInference: "zai-org/GLM-5.2", + populateOrchestrator: "zai-org/GLM-5.2", + investigateSubagent: "Qwen/Qwen3.5-9B", }, ollama: { schemaInference: "", diff --git a/backend/src/config/models.ts b/backend/src/config/models.ts index d3da6f6..fac8607 100644 --- a/backend/src/config/models.ts +++ b/backend/src/config/models.ts @@ -85,64 +85,26 @@ const TEXT_MODEL_EXCLUDE_PATTERNS = [ "whisper", ]; +// Static picker list for Qwen (DashScope has no reliable public models +// endpoint). The newer qwen3.5-* slugs are served in the International / +// Chinese-mainland deployments; the US (Virginia) region currently only +// exposes qwen-plus and qwen-flash, so both are kept in the list. 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, - }, -]; + "qwen3-max", + "qwen3.5-plus", + "qwen3.5-flash", + "qwen-max", + "qwen-plus", + "qwen-flash", + "qwen-turbo", + "qwen-long", +].map((slug) => ({ + modelName: slug, + canonicalSlug: slug, + contextLength: 0, + completionCost: 0, + promptCost: 0, +})); function isOpenAITextModelId(id: string): boolean { const lower = id.toLowerCase(); diff --git a/backend/src/env.ts b/backend/src/env.ts index c02511b..2b99709 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -48,11 +48,11 @@ export const env = { // 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", + process.env.SCHEMA_INFERENCE_MODEL ?? "anthropic/claude-sonnet-5", POPULATE_ORCHESTRATOR_MODEL: - process.env.POPULATE_ORCHESTRATOR_MODEL ?? "qwen/qwen3.7-max", + process.env.POPULATE_ORCHESTRATOR_MODEL ?? "anthropic/claude-sonnet-5", INVESTIGATE_SUBAGENT_MODEL: - process.env.INVESTIGATE_SUBAGENT_MODEL ?? "qwen/qwen3.7-max", + process.env.INVESTIGATE_SUBAGENT_MODEL ?? "anthropic/claude-haiku-4.5", // Resend (transactional email). Optional — when RESEND_API_KEY is unset // the email module no-ops with a log line, so local dev works without diff --git a/frontend/app/dashboard/settings/models/page.tsx b/frontend/app/dashboard/settings/models/page.tsx index 9417edc..51d824e 100644 --- a/frontend/app/dashboard/settings/models/page.tsx +++ b/frontend/app/dashboard/settings/models/page.tsx @@ -267,8 +267,6 @@ export default function ModelSettingsPage() { isSaving={isSavingModel} /> )} - - ); } diff --git a/frontend/components/settings/llm-providers.tsx b/frontend/components/settings/llm-providers.tsx index 3271f30..8fc7167 100644 --- a/frontend/components/settings/llm-providers.tsx +++ b/frontend/components/settings/llm-providers.tsx @@ -58,7 +58,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Direct API", capability: "Hosted", authLabel: "API key", - defaultModel: "gpt-5.4-mini", + defaultModel: "gpt-5.6-terra", apiKeyPlaceholder: "sk-...", helperHref: "https://platform.openai.com/api-keys", iconSrc: "/logos/providers/openai-icon.svg", @@ -73,7 +73,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Direct API", capability: "Hosted", authLabel: "API key", - defaultModel: "claude-sonnet-4-6", + defaultModel: "claude-sonnet-5", apiKeyPlaceholder: "sk-ant-...", helperHref: "https://console.anthropic.com/settings/keys", iconSrc: "/logos/providers/anthropic-icon.svg", @@ -102,7 +102,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Direct API", capability: "Hosted", authLabel: "API key", - defaultModel: "grok-4.3", + defaultModel: "grok-4.5", apiKeyPlaceholder: "xai-...", helperHref: "https://console.x.ai/", iconSrc: "/logos/providers/xai.svg", @@ -116,7 +116,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Direct API", capability: "Hosted", authLabel: "API key", - defaultModel: "deepseek-chat", + defaultModel: "deepseek-v4-pro", apiKeyPlaceholder: "sk-...", helperHref: "https://platform.deepseek.com/api_keys", iconSrc: "/logos/providers/deepseek.svg", @@ -130,7 +130,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Direct API", capability: "Hosted", authLabel: "API key", - defaultModel: "qwen-plus", + defaultModel: "qwen3-max", apiKeyPlaceholder: "sk-...", helperHref: "https://modelstudio.console.alibabacloud.com/", iconSrc: "/logos/providers/qwen.svg", @@ -144,7 +144,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Direct API", capability: "Hosted", authLabel: "API key", - defaultModel: "mistral-large-latest", + defaultModel: "mistral-medium-latest", apiKeyPlaceholder: "sk-...", helperHref: "https://console.mistral.ai/api-keys/", iconSrc: "/logos/providers/mistral-ai.svg", @@ -172,7 +172,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Direct API", capability: "Hosted", authLabel: "API key", - defaultModel: "Qwen/Qwen3.5-397B-A17B", + defaultModel: "zai-org/GLM-5.2", apiKeyPlaceholder: "tok_...", helperHref: "https://api.together.ai/settings/api-keys", iconSrc: "/logos/providers/together-ai.svg", @@ -186,7 +186,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Direct API", capability: "Hosted", authLabel: "API key", - defaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo", + defaultModel: "deepseek-ai/DeepSeek-V4-Pro", apiKeyPlaceholder: "sk-...", helperHref: "https://deepinfra.com/dash/api_keys", iconSrc: "/logos/providers/deepinfra.svg", @@ -200,7 +200,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Direct API", capability: "Hosted", authLabel: "API key", - defaultModel: "accounts/fireworks/models/kimi-k2p5", + defaultModel: "accounts/fireworks/models/glm-5p2", apiKeyPlaceholder: "fw_...", helperHref: "https://fireworks.ai/account/api-keys", iconSrc: "/logos/providers/fireworks-ai.svg", @@ -214,7 +214,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Direct API", capability: "Hosted", authLabel: "API key", - defaultModel: "deepseek-ai/DeepSeek-V3-0324", + defaultModel: "zai-org/GLM-5.2", apiKeyPlaceholder: "hf_...", helperHref: "https://huggingface.co/settings/tokens", iconSrc: "/logos/providers/huggingface.svg", @@ -228,7 +228,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Model router", capability: "Multi-provider", authLabel: "API key or OAuth", - defaultModel: "anthropic/claude-sonnet-4.6", + defaultModel: "anthropic/claude-sonnet-5", apiKeyPlaceholder: "sk-or-...", helperHref: "https://openrouter.ai/settings/keys", iconSrc: "/logos/providers/openrouter.svg", @@ -456,8 +456,12 @@ export function LlmProviderSelector({ value: LlmProviderOptionValue; onChange: (provider: LlmProviderOptionValue) => void; }) { - const [showExperimentalProviders, setShowExperimentalProviders] = - useState(false); + const [showExperimentalProviders, setShowExperimentalProviders] = useState( + // Start expanded when the current selection is an experimental provider, so + // a user who already configured e.g. Anthropic doesn't get snapped back to + // OpenRouter by the reset effect below the moment the modal opens. + () => isExperimentalProvider(value), + ); const orderedOptions = LLM_PROVIDER_GROUPS.flatMap((group) => LLM_PROVIDER_OPTIONS.filter((option) => group.categories.includes(option.category), diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index 19d1a31..0573db8 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 provider model id (e.g. "openai/gpt-5.4-mini" or "gpt-5.4-mini") + * User's saved model preferences — stores the provider model id (e.g. "openai/gpt-oss-120b" or "gpt-5.6-terra") * for each agent role. Null means no preference saved — backend will use the env default. */ export interface SavedModelConfig { @@ -165,7 +165,7 @@ export async function saveOpenRouterApiKey( return saveLlmProviderConfig({ provider: "openrouter", apiKey, - defaultModel: "anthropic/claude-sonnet-4.6", + defaultModel: "anthropic/claude-sonnet-5", }); } From d1ae1ef495e2a9a9a6c414d99c3fb47f906df82e Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 21 Jul 2026 17:25:30 -0700 Subject: [PATCH 07/17] Address provider configuration review findings --- README.md | 18 +-- backend/CLAUDE.md | 2 +- backend/src/config/llm.ts | 26 ++-- backend/src/config/models.ts | 4 +- backend/src/env.ts | 13 ++ backend/src/index.ts | 126 ++++++++++-------- backend/src/local-credentials.ts | 26 ++-- backend/src/mastra/tools/dataset-tools.ts | 40 ++++-- backend/src/pipeline/schema-inference.ts | 2 +- frontend/app/setup/page.tsx | 14 +- .../settings/LocalCredentialsPanel.tsx | 10 +- .../components/settings/llm-providers.tsx | 25 ++-- frontend/convex/localCredentials.ts | 72 ++++------ frontend/convex/modelConfig.ts | 45 ++----- frontend/convex/schema.ts | 75 +++-------- frontend/lib/backend.ts | 25 +--- frontend/lib/llm-provider-types.ts | 28 ++++ frontend/lib/openrouter-oauth.ts | 15 ++- 18 files changed, 283 insertions(+), 283 deletions(-) create mode 100644 frontend/lib/llm-provider-types.ts diff --git a/README.md b/README.md index 4b6a7e9..4a35599 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) | -| **LLM provider** | Schema inference + agents | OpenRouter, OpenAI, Anthropic, or custom OpenAI-compatible | +| **LLM provider** | Schema inference + agents | OpenRouter, direct providers, Ollama, LM Studio, or another OpenAI-compatible endpoint | Local API keys are stored in your OS keychain. @@ -214,9 +214,9 @@ 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 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. +TinyFish credentials plus an LLM provider. Choose OpenRouter, a direct hosted +provider, Ollama, LM Studio, or another OpenAI-compatible endpoint. BigSet saves +local keys to your OS keychain for this workspace. ### Step 3: Connect TinyFish and an LLM provider @@ -224,11 +224,13 @@ 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. Choose an LLM provider: OpenRouter, OpenAI, Anthropic, or custom OpenAI-compatible -3. Paste the provider key and model name into BigSet's setup screen +2. Choose an LLM provider. Hosted providers use an API key; Ollama and LM Studio + need no key by default. +3. Save the provider, then choose models for each role in the model picker when + the provider does not supply defaults. > **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 + LLM provider accounts directly. ### Step 4 (optional): Load curated datasets @@ -310,7 +312,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) + local LLM provider (OpenRouter, OpenAI, Anthropic, or custom OpenAI-compatible) | +| AI orchestration | [Mastra](https://mastra.ai) workflows + [Vercel AI SDK](https://sdk.vercel.ai) + a configured hosted, local, or OpenAI-compatible LLM provider | | 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 741b444..be34e6e 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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` — production default LLM provider key; local mode can use OpenRouter, OpenAI, Anthropic, or custom OpenAI-compatible via setup UI +- `OPENROUTER_API_KEY` — production default LLM provider key; local mode can use providers including OpenRouter, direct hosted APIs, Ollama, LM Studio, and custom OpenAI-compatible endpoints via the 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/src/config/llm.ts b/backend/src/config/llm.ts index bf2386e..3926e2f 100644 --- a/backend/src/config/llm.ts +++ b/backend/src/config/llm.ts @@ -208,52 +208,52 @@ export function defaultBaseUrlForLlmProvider( provider: LlmProviderType, ): string | undefined { if (provider === "openrouter") { - return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"; + return env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"; } if (provider === "google") { return ( - process.env.GOOGLE_GENERATIVE_AI_BASE_URL || + 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"; + return env.XAI_BASE_URL || "https://api.x.ai/v1"; } if (provider === "deepseek") { - return process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com"; + return env.DEEPSEEK_BASE_URL || "https://api.deepseek.com"; } if (provider === "qwen") { return ( - process.env.QWEN_BASE_URL || + 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"; + return env.MISTRAL_BASE_URL || "https://api.mistral.ai/v1"; } if (provider === "groq") { - return process.env.GROQ_BASE_URL || "https://api.groq.com/openai/v1"; + return env.GROQ_BASE_URL || "https://api.groq.com/openai/v1"; } if (provider === "togetherai") { - return process.env.TOGETHER_BASE_URL || "https://api.together.xyz/v1"; + return env.TOGETHER_BASE_URL || "https://api.together.xyz/v1"; } if (provider === "deepinfra") { - return process.env.DEEPINFRA_BASE_URL || "https://api.deepinfra.com/v1"; + return env.DEEPINFRA_BASE_URL || "https://api.deepinfra.com/v1"; } if (provider === "fireworks") { return ( - process.env.FIREWORKS_BASE_URL || + env.FIREWORKS_BASE_URL || "https://api.fireworks.ai/inference/v1" ); } if (provider === "huggingface") { - return process.env.HUGGINGFACE_BASE_URL || "https://router.huggingface.co/v1"; + return env.HUGGINGFACE_BASE_URL || "https://router.huggingface.co/v1"; } if (provider === "ollama") { - return process.env.OLLAMA_BASE_URL || "http://localhost:11434/v1"; + return env.OLLAMA_BASE_URL || "http://localhost:11434/v1"; } if (provider === "lmstudio") { - return process.env.LM_STUDIO_BASE_URL || "http://localhost:1234/v1"; + return env.LM_STUDIO_BASE_URL || "http://localhost:1234/v1"; } return undefined; } diff --git a/backend/src/config/models.ts b/backend/src/config/models.ts index fac8607..c5f8b04 100644 --- a/backend/src/config/models.ts +++ b/backend/src/config/models.ts @@ -528,7 +528,9 @@ export async function getModelConfig( export async function fetchModelsFromOpenRouter(): Promise { const apiKey = await requireOpenRouterApiKey(); - const baseUrl = (process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1").replace(/\/+$/, ""); + const baseUrl = ( + env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1" + ).replace(/\/+$/, ""); const url = new URL(`${baseUrl}/models`); url.searchParams.set("output_modalities", "text"); url.searchParams.set("supported_parameters", "tools"); diff --git a/backend/src/env.ts b/backend/src/env.ts index 2b99709..005f051 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -39,6 +39,19 @@ export const env = { process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, + OPENROUTER_BASE_URL: process.env.OPENROUTER_BASE_URL, + GOOGLE_GENERATIVE_AI_BASE_URL: process.env.GOOGLE_GENERATIVE_AI_BASE_URL, + XAI_BASE_URL: process.env.XAI_BASE_URL, + DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL, + QWEN_BASE_URL: process.env.QWEN_BASE_URL, + MISTRAL_BASE_URL: process.env.MISTRAL_BASE_URL, + GROQ_BASE_URL: process.env.GROQ_BASE_URL, + TOGETHER_BASE_URL: process.env.TOGETHER_BASE_URL, + DEEPINFRA_BASE_URL: process.env.DEEPINFRA_BASE_URL, + FIREWORKS_BASE_URL: process.env.FIREWORKS_BASE_URL, + HUGGINGFACE_BASE_URL: process.env.HUGGINGFACE_BASE_URL, + OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL, + LM_STUDIO_BASE_URL: process.env.LM_STUDIO_BASE_URL, BIGSET_LOCAL_WORKSPACE_ID: required("BIGSET_LOCAL_WORKSPACE_ID"), LOCAL_KEYCHAIN_URL: process.env.LOCAL_KEYCHAIN_URL, LOCAL_KEYCHAIN_TOKEN: process.env.LOCAL_KEYCHAIN_TOKEN, diff --git a/backend/src/index.ts b/backend/src/index.ts index ba329b0..5bf1bfd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -760,52 +760,61 @@ 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" }); - } +fastify.post( + "/local-setup/llm-provider", + { preHandler: requireAuth }, + 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" }); - } + 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 isKeylessProvider = - provider === "custom" || provider === "ollama" || provider === "lmstudio"; - const isNewKeylessProvider = - isKeylessProvider && (provider !== "custom" || !!body.baseUrl?.trim()); - - if (!apiKey && !isNewKeylessProvider) { - 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` }); + try { + const apiKey = body.apiKey?.trim() ?? ""; + const isKeylessProvider = + provider === "custom" || + provider === "ollama" || + provider === "lmstudio"; + const isNewKeylessProvider = + isKeylessProvider && (provider !== "custom" || !!body.baseUrl?.trim()); + + if (!apiKey && !isNewKeylessProvider) { + 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(); } - await setActiveLocalLlmProvider(provider); + + 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 }); } - - 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) => { @@ -879,23 +888,24 @@ fastify.get("/openrouter/models", async (req, reply) => { } }); -fastify.get("/llm-provider/models", async (req, reply) => { - // Fetching provider models exercises configured provider credentials and - // rate limits, so it must not be public. In local mode requireAuth passes - // through (single local user); in prod it enforces a valid Clerk token. - await requireAuth(req, reply); - if (reply.sent) return; - - 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 }); - } -}); +fastify.get( + "/llm-provider/models", + { preHandler: requireAuth }, + 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 }); + } + }, +); // ──────────────────────────────────────────────────────────────────────── // Local trusted CLI routes diff --git a/backend/src/local-credentials.ts b/backend/src/local-credentials.ts index 16456ec..0b36c52 100644 --- a/backend/src/local-credentials.ts +++ b/backend/src/local-credentials.ts @@ -99,6 +99,7 @@ async function localCredential(service: LocalCredentialService): Promise<{ : undefined; const rowBaseUrl = typeof rowData?.llmBaseUrl === "string" ? rowData.llmBaseUrl : undefined; + const keychain = await getKeychainCredential(service); if ( rowProvider && service === rowProvider && @@ -106,10 +107,11 @@ async function localCredential(service: LocalCredentialService): Promise<{ rowBaseUrl ) { return { - apiKey: "", + apiKey: keychain?.apiKey ?? "", connectionMethod: rowData?.connectionMethod ?? "api_key", verifiedAt: rowData?.verifiedAt ?? null, - keychainAccount: rowData?.keychainAccount ?? "", + keychainAccount: + keychain?.keychainAccount ?? rowData?.keychainAccount ?? "", llmProvider: rowProvider, llmBaseUrl: rowBaseUrl, llmDefaultModel: @@ -119,7 +121,6 @@ async function localCredential(service: LocalCredentialService): Promise<{ }; } - const keychain = await getKeychainCredential(service); if (!keychain?.apiKey) { return null; } @@ -241,7 +242,7 @@ export async function getLlmProviderConfig(): Promise { provider: "openrouter", apiKey, - baseUrl: process.env.OPENROUTER_BASE_URL, + baseUrl: env.OPENROUTER_BASE_URL, defaultModel: env.SCHEMA_INFERENCE_MODEL, }, "env", @@ -362,10 +363,10 @@ export async function getLocalSetupStatus(): Promise { verifiedAt: null, }; - const providerStatuses = {} as Record; - for (const provider of LLM_PROVIDER_TYPES) { - const credential = await localCredentialForLlmProvider(provider); - providerStatuses[provider] = credential + const providerStatusEntries = await Promise.all( + LLM_PROVIDER_TYPES.map(async (provider) => { + const credential = await localCredentialForLlmProvider(provider); + const status: ServiceSetupStatus = credential ? { configured: true, source: "local", @@ -388,7 +389,12 @@ export async function getLocalSetupStatus(): Promise { baseUrl: defaultBaseUrlForLlmProvider(provider), defaultModel: defaultModelForLlmProvider(provider), }; - } + return [provider, status] as const; + }), + ); + const providerStatuses = Object.fromEntries( + providerStatusEntries, + ) as Record; const llmProvider = await activeLlmProviderForStatus(); const llm = providerStatuses[llmProvider]; @@ -495,7 +501,7 @@ export async function verifyTinyFishApiKey(apiKey: string): Promise { export async function verifyOpenRouterApiKey(apiKey: string): Promise { const baseUrl = ( - process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1" + env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1" ).replace(/\/+$/, ""); await withFetchTimeout( diff --git a/backend/src/mastra/tools/dataset-tools.ts b/backend/src/mastra/tools/dataset-tools.ts index e0109a6..62cacac 100644 --- a/backend/src/mastra/tools/dataset-tools.ts +++ b/backend/src/mastra/tools/dataset-tools.ts @@ -65,20 +65,30 @@ const rowDataCellSchema = z.object({ 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 normalizeDataKey(column: string): string { + return column.trim().replace(/^["`]+|["`]+$/g, "").trim(); } -function cleanDataKeys(data: Record): Record { - const cleaned: Record = {}; - for (const [key, value] of Object.entries(data)) { - cleaned[key.replace(/^["`]+|["`]+$/g, "")] = value; +function rowDataCellsToRecord( + data: RowDataCell[], +): + | { success: true; data: Record } + | { success: false; error: string } { + const row: Record = {}; + for (const cell of data) { + const column = normalizeDataKey(cell.column); + if (!column) { + return { success: false, error: "Column names cannot be empty." }; + } + if (Object.hasOwn(row, column)) { + return { + success: false, + error: `Duplicate column "${column}". Provide each column only once.`, + }; + } + row[column] = cell.value; } - return cleaned; + return { success: true, data: row }; } /** @@ -169,7 +179,9 @@ export function buildPopulateTools( 'data is required and must include at least one entry like { "column": "column_name", "value": "cell value" }.', }; - const cleanedData = cleanDataKeys(rowDataCellsToRecord(data)); + const normalizedData = rowDataCellsToRecord(data); + if (!normalizedData.success) return normalizedData; + const cleanedData = normalizedData.data; console.log( `[insert_row] ${logCtx} cols=${Object.keys(cleanedData).length} sources=${sources?.length ?? 0}`, ); @@ -308,7 +320,9 @@ export function buildPopulateTools( error: "data is required. Pass the full updated row data entries.", }; - const cleanedData = cleanDataKeys(rowDataCellsToRecord(data)); + const normalizedData = rowDataCellsToRecord(data); + if (!normalizedData.success) return normalizedData; + const cleanedData = normalizedData.data; console.log( `[update_row] ${logCtx} row=${rowId} cols=${Object.keys(cleanedData).length}`, ); diff --git a/backend/src/pipeline/schema-inference.ts b/backend/src/pipeline/schema-inference.ts index 8a72abe..806cc5c 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 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. +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 unique (unless this is guaranteed). Otherwise use things like URLs or IDs that are guaranteed to be 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/frontend/app/setup/page.tsx b/frontend/app/setup/page.tsx index 928d861..fe23ba4 100644 --- a/frontend/app/setup/page.tsx +++ b/frontend/app/setup/page.tsx @@ -366,6 +366,10 @@ export default function SetupPage() { status={status} onClose={() => setModal(null)} onSaved={(next) => { + setModelConfig(null); + setModelOptions([]); + setModelOptionsCacheKey(null); + setModelError(null); setStatus(next); setModal(null); }} @@ -781,8 +785,16 @@ function initialBaseUrl( provider: LlmProviderOptionValue, ) { const option = llmProviderOption(provider); + const activeStatus = status?.services.llm; + const activeSelection = + activeStatus?.provider === "custom" + ? (localLlmPresetForBaseUrl(activeStatus.baseUrl)?.value ?? "custom") + : activeStatus?.provider; + const providerStatus = + status?.services.llmProviders?.[option.provider] ?? + (activeSelection === provider ? activeStatus : undefined); const savedBaseUrl = displayBaseUrl( - status?.services.llmProviders?.[option.provider]?.baseUrl, + providerStatus?.baseUrl, ); if (option.defaultBaseUrl) return savedBaseUrl || option.defaultBaseUrl; if (option.provider === "custom") { diff --git a/frontend/components/settings/LocalCredentialsPanel.tsx b/frontend/components/settings/LocalCredentialsPanel.tsx index 772f3e8..21c1925 100644 --- a/frontend/components/settings/LocalCredentialsPanel.tsx +++ b/frontend/components/settings/LocalCredentialsPanel.tsx @@ -598,8 +598,16 @@ function initialBaseUrl( provider: LlmProviderOptionValue, ) { const option = llmProviderOption(provider); + const activeStatus = status?.services.llm; + const activeSelection = + activeStatus?.provider === "custom" + ? (localLlmPresetForBaseUrl(activeStatus.baseUrl)?.value ?? "custom") + : activeStatus?.provider; + const providerStatus = + status?.services.llmProviders?.[option.provider] ?? + (activeSelection === provider ? activeStatus : undefined); const savedBaseUrl = displayBaseUrl( - status?.services.llmProviders?.[option.provider]?.baseUrl, + providerStatus?.baseUrl, ); if (option.defaultBaseUrl) return savedBaseUrl || option.defaultBaseUrl; if (option.provider === "custom") { diff --git a/frontend/components/settings/llm-providers.tsx b/frontend/components/settings/llm-providers.tsx index 8fc7167..1aafd67 100644 --- a/frontend/components/settings/llm-providers.tsx +++ b/frontend/components/settings/llm-providers.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { CircleHelp, Plug, TriangleAlert } from "lucide-react"; import type { LlmProviderType, ServiceSetupStatus } from "@/lib/backend"; +import { OPENROUTER_DEFAULT_MODEL } from "@/lib/llm-provider-types"; type LlmProviderCategory = "direct" | "router" | "local" | "custom"; export type LlmProviderOptionValue = LlmProviderType; @@ -228,7 +229,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ shortLabel: "Model router", capability: "Multi-provider", authLabel: "API key or OAuth", - defaultModel: "anthropic/claude-sonnet-5", + defaultModel: OPENROUTER_DEFAULT_MODEL, apiKeyPlaceholder: "sk-or-...", helperHref: "https://openrouter.ai/settings/keys", iconSrc: "/logos/providers/openrouter.svg", @@ -456,27 +457,19 @@ export function LlmProviderSelector({ value: LlmProviderOptionValue; onChange: (provider: LlmProviderOptionValue) => void; }) { - const [showExperimentalProviders, setShowExperimentalProviders] = useState( - // Start expanded when the current selection is an experimental provider, so - // a user who already configured e.g. Anthropic doesn't get snapped back to - // OpenRouter by the reset effect below the moment the modal opens. - () => isExperimentalProvider(value), - ); + const [showExperimentalProviders, setShowExperimentalProviders] = + useState(() => isExperimentalProvider(value)); + const experimentalProvidersVisible = + showExperimentalProviders || isExperimentalProvider(value); const orderedOptions = LLM_PROVIDER_GROUPS.flatMap((group) => LLM_PROVIDER_OPTIONS.filter((option) => group.categories.includes(option.category), ), ).filter( (option) => - showExperimentalProviders || !isExperimentalProvider(option.value), + experimentalProvidersVisible || !isExperimentalProvider(option.value), ); - useEffect(() => { - if (!showExperimentalProviders && isExperimentalProvider(value)) { - onChange("openrouter"); - } - }, [onChange, showExperimentalProviders, value]); - function handleExperimentalChange(checked: boolean) { setShowExperimentalProviders(checked); if (!checked && isExperimentalProvider(value)) { @@ -490,7 +483,7 @@ export function LlmProviderSelector({
diff --git a/frontend/app/setup/page.tsx b/frontend/app/setup/page.tsx index fe23ba4..1cdbbe6 100644 --- a/frontend/app/setup/page.tsx +++ b/frontend/app/setup/page.tsx @@ -17,6 +17,7 @@ import { saveModelConfig, saveTinyFishApiKey, type EffectiveModelConfig, + type EffectiveModelRole, type LlmProviderType, type LocalSetupStatus, type OpenRouterModel, @@ -52,11 +53,15 @@ function modelListCacheKey(status: LocalSetupStatus | null): string { ].join("|"); } +function emptyModelRole(): EffectiveModelRole { + return { model: "", reasoning: "medium", reasoningOverridden: false }; +} + function emptyModelConfig(): EffectiveModelConfig { return { - schemaInference: "", - populateOrchestrator: "", - investigateSubagent: "", + schemaInference: emptyModelRole(), + populateOrchestrator: emptyModelRole(), + investigateSubagent: emptyModelRole(), }; } @@ -108,8 +113,8 @@ export default function SetupPage() { try { const token = await getToken(); if (!token) throw new Error("Not authenticated"); - const config = await getModelConfig(token); - if (active) setModelConfig(config); + const settings = await getModelConfig(token); + if (active) setModelConfig(settings.config); } catch (err) { if (!active) return; setModelConfig(emptyModelConfig()); @@ -166,7 +171,7 @@ export default function SetupPage() { function modelForRole(role: ModelRole): string { const key = role.key as keyof EffectiveModelConfig; - return modelConfig?.[key] ?? ""; + return modelConfig?.[key]?.model ?? ""; } function openModelSheet(role: ModelRole) { diff --git a/frontend/components/settings/ReasoningSlider.tsx b/frontend/components/settings/ReasoningSlider.tsx new file mode 100644 index 0000000..34f7a3e --- /dev/null +++ b/frontend/components/settings/ReasoningSlider.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useId } from "react"; +import { + REASONING_LEVELS, + REASONING_LEVEL_LABELS, + type ReasoningLevel, +} from "@/lib/backend"; + +interface ReasoningSliderProps { + value: ReasoningLevel; + /** False when the level is the provider/role default rather than a choice. */ + overridden: boolean; + /** Called with a level to pin it, or null to return the role to auto. */ + onChange: (level: ReasoningLevel | null) => void; + disabled?: boolean; + /** Shown in place of the control when the provider has no reasoning knob. */ + unsupportedReason?: string; +} + +/** + * Discrete slider over the canonical reasoning scale. + * + * "Auto" is deliberately not a stop on the track — it is a mode. The thumb + * always sits on the level that will actually be used, so an auto role still + * shows where it landed; moving the thumb pins that choice, and "Reset to auto" + * hands the role back to the provider/role default. + */ +export function ReasoningSlider({ + value, + overridden, + onChange, + disabled = false, + unsupportedReason, +}: ReasoningSliderProps) { + const id = useId(); + const index = Math.max(0, REASONING_LEVELS.indexOf(value)); + const max = REASONING_LEVELS.length - 1; + const progress = max === 0 ? 0 : (index / max) * 100; + + if (unsupportedReason) { + return ( +

+ {unsupportedReason} +

+ ); + } + + return ( +
+
+ +
+ + {REASONING_LEVEL_LABELS[value]} + + {overridden ? ( + + ) : ( + + Auto + + )} +
+
+ + + onChange(REASONING_LEVELS[Number(event.target.value)]) + } + style={{ + width: "100%", + height: "4px", + borderRadius: "999px", + appearance: "none", + WebkitAppearance: "none", + accentColor: "var(--accent)", + cursor: disabled ? "not-allowed" : "pointer", + background: `linear-gradient(to right, var(--accent) ${progress}%, var(--border) ${progress}%)`, + }} + /> + +
+ {REASONING_LEVELS.map((level) => ( + + {REASONING_LEVEL_LABELS[level]} + + ))} +
+
+ ); +} diff --git a/frontend/convex/modelConfig.ts b/frontend/convex/modelConfig.ts index 85406a8..3e64bbd 100644 --- a/frontend/convex/modelConfig.ts +++ b/frontend/convex/modelConfig.ts @@ -62,6 +62,10 @@ export const upsert = mutation({ schemaInference: v.optional(v.string()), populateOrchestrator: v.optional(v.string()), investigateSubagent: v.optional(v.string()), + // null clears the override (back to auto); undefined leaves it untouched. + schemaInferenceReasoning: v.optional(v.union(v.string(), v.null())), + populateOrchestratorReasoning: v.optional(v.union(v.string(), v.null())), + investigateSubagentReasoning: v.optional(v.union(v.string(), v.null())), }, handler: async (ctx, args) => { const identity = await getIdentity(ctx); @@ -75,10 +79,20 @@ export const upsert = mutation({ schemaInference?: string; populateOrchestrator?: string; investigateSubagent?: string; + schemaInferenceReasoning?: string | undefined; + populateOrchestratorReasoning?: string | undefined; + investigateSubagentReasoning?: string | undefined; } = { 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; + // `null` is a request to clear: patching the field to undefined removes it. + if (args.schemaInferenceReasoning !== undefined) + patch.schemaInferenceReasoning = args.schemaInferenceReasoning ?? undefined; + if (args.populateOrchestratorReasoning !== undefined) + patch.populateOrchestratorReasoning = args.populateOrchestratorReasoning ?? undefined; + if (args.investigateSubagentReasoning !== undefined) + patch.investigateSubagentReasoning = args.investigateSubagentReasoning ?? undefined; if (existing) { await ctx.db.patch(existing._id, patch); @@ -114,6 +128,10 @@ export const upsertInternal = internalMutation({ schemaInference: v.optional(v.string()), populateOrchestrator: v.optional(v.string()), investigateSubagent: v.optional(v.string()), + // null clears the override (back to auto); undefined leaves it untouched. + schemaInferenceReasoning: v.optional(v.union(v.string(), v.null())), + populateOrchestratorReasoning: v.optional(v.union(v.string(), v.null())), + investigateSubagentReasoning: v.optional(v.union(v.string(), v.null())), }, handler: async (ctx, args) => { const provider = args.provider ?? "openrouter"; @@ -124,10 +142,20 @@ export const upsertInternal = internalMutation({ schemaInference?: string; populateOrchestrator?: string; investigateSubagent?: string; + schemaInferenceReasoning?: string | undefined; + populateOrchestratorReasoning?: string | undefined; + investigateSubagentReasoning?: string | undefined; } = { 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; + // `null` is a request to clear: patching the field to undefined removes it. + if (args.schemaInferenceReasoning !== undefined) + patch.schemaInferenceReasoning = args.schemaInferenceReasoning ?? undefined; + if (args.populateOrchestratorReasoning !== undefined) + patch.populateOrchestratorReasoning = args.populateOrchestratorReasoning ?? undefined; + if (args.investigateSubagentReasoning !== undefined) + patch.investigateSubagentReasoning = args.investigateSubagentReasoning ?? undefined; if (existing) { await ctx.db.patch(existing._id, patch); diff --git a/frontend/convex/schema.ts b/frontend/convex/schema.ts index f2a05d5..1b5a47d 100644 --- a/frontend/convex/schema.ts +++ b/frontend/convex/schema.ts @@ -151,6 +151,13 @@ export default defineSchema({ schemaInference: v.optional(v.string()), populateOrchestrator: v.optional(v.string()), investigateSubagent: v.optional(v.string()), + // Reasoning-effort override per role, on the canonical scale defined in + // the backend (none | low | medium | high | max). Absent means "auto": + // the provider/role default is resolved at request time, so switching a + // role to a weaker model raises its reasoning without user action. + schemaInferenceReasoning: v.optional(v.string()), + populateOrchestratorReasoning: v.optional(v.string()), + investigateSubagentReasoning: v.optional(v.string()), }) .index("by_user", ["userId"]) .index("by_user_provider", ["userId", "provider"]), diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index 22b7592..b33b733 100644 --- a/frontend/lib/backend.ts +++ b/frontend/lib/backend.ts @@ -40,14 +40,38 @@ export interface WorkflowResult { } /** - * The effective model config — always complete, never null. - * schemaInference / populateOrchestrator / investigateSubagent are always strings - * (user preference or system default from env). + * Canonical reasoning scale, mirroring the backend. Each provider's own ladder + * differs (xAI has two rungs, Anthropic five, Qwen a token budget), so the UI + * and stored config speak this scale and the backend projects it per provider. + * Ordered weakest to strongest — the settings slider relies on that order. + */ +export const REASONING_LEVELS = ["none", "low", "medium", "high", "max"] as const; +export type ReasoningLevel = (typeof REASONING_LEVELS)[number]; + +export const REASONING_LEVEL_LABELS: Record = { + none: "None", + low: "Low", + medium: "Medium", + high: "High", + max: "Max", +}; + +/** One role's resolved model and the reasoning level it will run at. */ +export interface EffectiveModelRole { + model: string; + reasoning: ReasoningLevel; + /** False means the level came from the provider/role default, not the user. */ + reasoningOverridden: boolean; +} + +/** + * The effective model config — always complete, never null. Every role resolves + * to a concrete model and reasoning level (user preference, or system default). */ export interface EffectiveModelConfig { - schemaInference: string; - populateOrchestrator: string; - investigateSubagent: string; + schemaInference: EffectiveModelRole; + populateOrchestrator: EffectiveModelRole; + investigateSubagent: EffectiveModelRole; } /** @@ -58,6 +82,10 @@ export interface SavedModelConfig { schemaInference: string | null; populateOrchestrator: string | null; investigateSubagent: string | null; + /** An explicit level pins it; `null` returns the role to auto. */ + schemaInferenceReasoning: ReasoningLevel | null; + populateOrchestratorReasoning: ReasoningLevel | null; + investigateSubagentReasoning: ReasoningLevel | null; } export interface OpenRouterModel { @@ -187,7 +215,13 @@ export async function exchangeOpenRouterOAuth( * @param token - Clerk JWT obtained via getToken() * Throws if the request fails (network error, 401, 500). */ -export async function getModelConfig(token: string): Promise { +export interface ModelSettings { + config: EffectiveModelConfig; + /** False when the active provider exposes no reasoning control. */ + reasoningSupported: boolean; +} + +export async function getModelConfig(token: string): Promise { const res = await fetch(`${BACKEND_URL}/settings/models`, { method: "GET", headers: { @@ -203,7 +237,10 @@ export async function getModelConfig(token: string): Promise Date: Tue, 4 Aug 2026 14:44:27 -0700 Subject: [PATCH 12/17] Add reasoning effort to setup wizard Same per-role slider as dashboard settings, alongside each model picker during first-run setup, so the choice is visible where models are first chosen rather than only after onboarding. Also fixes a latent bug from the previous commit: setup's model save still wrote a bare slug into the role, which is now an object of {model, reasoning, reasoningOverridden}. The computed-key spread meant TypeScript widened the write instead of rejecting it, so this only would have surfaced at runtime as a role losing its reasoning level the moment its model changed. Selecting a model now re-reads the config in setup too, so a role on auto picks up the reasoning default for the model just chosen instead of keeping the previous model's level. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/app/setup/page.tsx | 116 +++++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 21 deletions(-) diff --git a/frontend/app/setup/page.tsx b/frontend/app/setup/page.tsx index 1cdbbe6..399e770 100644 --- a/frontend/app/setup/page.tsx +++ b/frontend/app/setup/page.tsx @@ -18,6 +18,7 @@ import { saveTinyFishApiKey, type EffectiveModelConfig, type EffectiveModelRole, + type ReasoningLevel, type LlmProviderType, type LocalSetupStatus, type OpenRouterModel, @@ -40,6 +41,7 @@ import { import { LocalUtilityMenu } from "@/components/LocalUtilityMenu"; import { ModelSideSheet } from "@/components/settings/ModelSideSheet"; import { MODEL_ROLES, type ModelRole } from "@/components/settings/types"; +import { ReasoningSlider } from "@/components/settings/ReasoningSlider"; import { useAppAuth } from "@/lib/app-auth"; function modelListCacheKey(status: LocalSetupStatus | null): string { @@ -83,6 +85,9 @@ export default function SetupPage() { >(null); const [refreshingModels, setRefreshingModels] = useState(false); const [savingModel, setSavingModel] = useState(false); + const [reasoningSupported, setReasoningSupported] = useState(true); + const [savingReasoningRole, setSavingReasoningRole] = useState(null); + const [modelConfigReloadKey, setModelConfigReloadKey] = useState(0); const activeModelListCacheKeyRef = useRef(""); useEffect(() => { @@ -114,7 +119,9 @@ export default function SetupPage() { const token = await getToken(); if (!token) throw new Error("Not authenticated"); const settings = await getModelConfig(token); - if (active) setModelConfig(settings.config); + if (!active) return; + setModelConfig(settings.config); + setReasoningSupported(settings.reasoningSupported); } catch (err) { if (!active) return; setModelConfig(emptyModelConfig()); @@ -133,6 +140,7 @@ export default function SetupPage() { }; }, [ getToken, + modelConfigReloadKey, status?.services.llm.baseUrl, status?.services.llm.configured, status?.services.llm.provider, @@ -193,9 +201,12 @@ export default function SetupPage() { setModelConfig((prev) => ({ ...emptyModelConfig(), ...prev, - [key]: nextModelId, + [key]: { ...(prev?.[key] ?? emptyModelRole()), model: nextModelId }, })); setActiveModelRole(null); + // A different model can change the auto-resolved reasoning level, so let + // the server recompute rather than leaving the previous model's level. + setModelConfigReloadKey((current) => current + 1); } catch (err) { setModelError( err instanceof Error ? err.message : "Failed to save model", @@ -205,6 +216,47 @@ export default function SetupPage() { } } + /** + * Persist a reasoning level for one role. `null` clears the override so the + * role returns to the provider/role default — sent explicitly, since an + * omitted field means "leave unchanged". + */ + async function saveReasoningForRole( + role: ModelRole, + level: ReasoningLevel | null, + ) { + const key = role.key as keyof EffectiveModelConfig; + const previous = modelConfig; + setSavingReasoningRole(role.key); + setModelError(null); + setModelConfig((prev) => + prev + ? { + ...prev, + [key]: { + ...prev[key], + ...(level ? { reasoning: level } : {}), + reasoningOverridden: level !== null, + }, + } + : prev, + ); + try { + const token = await getToken(); + if (!token) throw new Error("Not authenticated"); + await saveModelConfig({ [`${key}Reasoning`]: level }, token); + // Clearing needs the server's recomputed default, which we can't derive. + if (level === null) setModelConfigReloadKey((current) => current + 1); + } catch (err) { + setModelConfig(previous); + setModelError( + err instanceof Error ? err.message : "Failed to save reasoning effort", + ); + } finally { + setSavingReasoningRole(null); + } + } + const complete = status?.complete ?? false; const modelSelectionRequired = !!status?.services.llm.configured && !status.services.llm.defaultModel; @@ -311,28 +363,50 @@ export default function SetupPage() {
{MODEL_ROLES.map((role) => { const selectedModel = modelForRole(role); + const roleConfig = + modelConfig?.[role.key as keyof EffectiveModelConfig] ?? null; return ( - + + {roleConfig && ( +
+ + void saveReasoningForRole(role, level) + } + /> +
+ )} +
); })}
From 14d8b69800c786de54e20b9e4a686a22ef33e047 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Thu, 6 Aug 2026 11:33:46 -0700 Subject: [PATCH 13/17] Remove playwright row-extractor spike from main The "testing row extraction" commit (ef9de13) landed a spike on main: a 582-line GitHub-specific extractor plus a playwright-core dependency. This broke `node scripts/build-release.mjs`. The release bundler runs esbuild with `packages: "bundle"`, which tried to inline playwright-core and failed three ways: it requires chromium-bidi (not an installed dependency) and bundles its own chokidar, which pulls in the native fsevents.node binary that esbuild has no loader for. Remove the extractor, its call site in investigate-tool, and the playwright-core dependency. The extractor also branched on GitHub specifically, so it was not a general solution worth keeping. Co-Authored-By: Claude Opus 5 (1M context) --- backend/package-lock.json | 28 +- backend/package.json | 1 - backend/src/mastra/tools/investigate-tool.ts | 36 -- .../src/row-extractors/try-row-extractor.ts | 582 ------------------ 4 files changed, 1 insertion(+), 646 deletions(-) delete mode 100644 backend/src/row-extractors/try-row-extractor.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 8936ff5..436430c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -32,7 +32,6 @@ "dotenv": "^16.4.0", "fastify": "^5.0.0", "fastify-plugin": "^5.1.0", - "playwright-core": "^1.60.0", "posthog-node": "^5.35.1", "resend": "^6.12.3", "zod": "^4.4.3" @@ -437,7 +436,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1533,7 +1531,6 @@ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.14.1" }, @@ -1661,7 +1658,6 @@ "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.36.0.tgz", "integrity": "sha512-BEhDZPQeDcJ6jQRHtpfFLuoRiWAuv9dTCIjeWbXokzwDamI3D9jkyNzpBFJwFwy2S/a4jBTu4+d61nOaP7knTQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@a2a-js/sdk": "~0.3.13", "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.25", @@ -2917,8 +2913,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@tanstack/query-core": { "version": "5.100.11", @@ -3073,7 +3068,6 @@ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.185.tgz", "integrity": "sha512-oGsqscREaTlo75KHZLtwZxRyI+ZBwHV2wRX9B8smHjgOs13WwoCvUyr5aPUWpIBRz406wmIKy1RzoUEq0/WKJw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@ai-sdk/gateway": "3.0.116", "@ai-sdk/provider": "3.0.10", @@ -3363,7 +3357,6 @@ "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", "dev": true, "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -3579,7 +3572,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -4470,7 +4462,6 @@ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4645,7 +4636,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5303,7 +5293,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -7320,18 +7309,6 @@ "pathe": "^2.0.1" } }, - "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/postal-mime": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", @@ -7794,7 +7771,6 @@ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -8753,7 +8729,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9240,7 +9215,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/backend/package.json b/backend/package.json index 7ab2626..4fac14e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,7 +34,6 @@ "dotenv": "^16.4.0", "fastify": "^5.0.0", "fastify-plugin": "^5.1.0", - "playwright-core": "^1.60.0", "posthog-node": "^5.35.1", "resend": "^6.12.3", "zod": "^4.4.3" diff --git a/backend/src/mastra/tools/investigate-tool.ts b/backend/src/mastra/tools/investigate-tool.ts index f4fbbfa..038471d 100644 --- a/backend/src/mastra/tools/investigate-tool.ts +++ b/backend/src/mastra/tools/investigate-tool.ts @@ -7,7 +7,6 @@ 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"; -import { tryRowExtractor } from "../../row-extractors/try-row-extractor.js"; const keyValueSchema = z.object({ column: z.string().min(1), @@ -106,41 +105,6 @@ export function buildSubagentTool( if (metrics) metrics.investigateCalls++; - const extractorResult = await tryRowExtractor({ - datasetId: authorizedDatasetId, - columns, - primaryKeys: Object.fromEntries( - primary_keys.map(({ column, value }) => [column, value]), - ), - urls, - context, - }); - if (extractorResult.status === "inserted") { - if (metrics) metrics.rowsInserted++; - console.log( - `[run_subagent] row extractor inserted entity="${entity_hint}" reason="${extractorResult.reason}"`, - ); - return { - inserted: true, - reason: extractorResult.reason, - row_summary: extractorResult.rowSummary, - clues: undefined, - }; - } - if (/duplicate/i.test(extractorResult.reason)) { - return { - inserted: false, - reason: extractorResult.reason, - row_summary: undefined, - clues: undefined, - }; - } - if (extractorResult.status === "failed") { - console.warn( - `[run_subagent] row extractor failed entity="${entity_hint}" reason="${extractorResult.reason}"`, - ); - } - console.log( `[run_subagent] spawning subagent user=${authContext.authorizedUserId} run=${authContext.workflowRunId} dataset=${authorizedDatasetId} entity="${entity_hint}" pk=${JSON.stringify(primary_keys)}`, ); diff --git a/backend/src/row-extractors/try-row-extractor.ts b/backend/src/row-extractors/try-row-extractor.ts deleted file mode 100644 index 8f61112..0000000 --- a/backend/src/row-extractors/try-row-extractor.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { chromium, type Browser, type Page } from "playwright-core"; - -import { getSignal } from "../abort-registry.js"; -import { convex, internal } from "../convex.js"; -import { FETCH_TIMEOUT_MS } from "../fetch-timeout.js"; -import { getTinyFishApiKey, tinyFishHeaders } from "../local-credentials.js"; -import type { PopulateColumn } from "../pipeline/populate.js"; - -type ExtractorStatus = "inserted" | "miss" | "failed"; - -export interface TryRowExtractorInput { - datasetId: string; - columns: PopulateColumn[]; - primaryKeys: Record; - urls?: string[]; - context?: string; -} - -export interface TryRowExtractorResult { - status: ExtractorStatus; - reason: string; - rowSummary?: string; - sources?: string[]; -} - -interface TinyFishBrowserSession { - session_id: string; - cdp_url: string; - base_url: string; -} - -interface GitHubRepoFacts { - owner: string; - repo: string; - fullName: string; - url: string; - description?: string; - stars?: number; - forks?: number; - watchers?: number; - issues?: number; - pullRequests?: number; - language?: string; - license?: string; - latestCommitAt?: string; - updatedAt?: string; - createdAt?: string; - homepage?: string; - archived?: boolean; -} - -interface RawGitHubRepoDomFacts { - description?: string; - stars?: string; - forks?: string; - watchers?: string; - issues?: string; - pullRequests?: string; - language?: string; - license?: string; - latestCommitAt?: string; - homepage?: string; - archived?: boolean; -} - -const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]); -const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]); -const BROWSER_TIMEOUT_MS = 45_000; -const CDP_CONNECT_TIMEOUT_MS = 45_000; -const BROWSER_ATTEMPTS = 2; - -export async function tryRowExtractor( - input: TryRowExtractorInput, -): Promise { - if (!ENABLED_VALUES.has((process.env.ROW_EXTRACTORS_ENABLED ?? "").toLowerCase())) { - return { status: "miss", reason: "row extractors are disabled" }; - } - - const url = firstCandidateUrl(input); - if (!url) return { status: "miss", reason: "no URL primary key or candidate URL" }; - - const repoRef = parseGitHubRepoUrl(url); - if (!repoRef) { - return { status: "miss", reason: `unsupported URL host: ${safeHost(url)}` }; - } - - try { - const facts = await extractGitHubRepoFacts(url, input.datasetId); - const row = buildGitHubRow(input.columns, input.primaryKeys, facts); - if (!row) { - return { - status: "miss", - reason: "GitHub extractor could not satisfy all requested columns", - }; - } - - await convex.mutation(internal.datasetRows.insert, { - datasetId: input.datasetId, - data: row, - sources: [facts.url], - rowSummary: facts.description - ? `${facts.fullName}: ${facts.description}` - : facts.fullName, - howFound: - "Opened the GitHub repository URL with TinyFish Browser and extracted repository facts from the rendered page.", - }); - - return { - status: "inserted", - reason: "Inserted by GitHub row extractor", - rowSummary: facts.description - ? `${facts.fullName}: ${facts.description}` - : facts.fullName, - sources: [facts.url], - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (/duplicate/i.test(msg)) { - return { - status: "miss", - reason: `${msg} Move on to the next entity.`, - }; - } - return { status: "failed", reason: msg }; - } -} - -function firstCandidateUrl(input: TryRowExtractorInput): string | undefined { - const fromPrimaryKey = Object.values(input.primaryKeys).find((value) => - isHttpUrl(value), - ); - if (fromPrimaryKey) return normalizeUrl(fromPrimaryKey); - - const fromUrls = input.urls?.find(isHttpUrl); - if (fromUrls) return normalizeUrl(fromUrls); - - const fromContext = input.context?.match(/https?:\/\/[^\s)>"']+/i)?.[0]; - return fromContext ? normalizeUrl(fromContext) : undefined; -} - -function normalizeUrl(value: string): string { - return value.trim().replace(/[.,;:]+$/, ""); -} - -function isHttpUrl(value: string | undefined): value is string { - if (!value) return false; - try { - const parsed = new URL(normalizeUrl(value)); - return parsed.protocol === "http:" || parsed.protocol === "https:"; - } catch { - return false; - } -} - -function safeHost(value: string): string { - try { - return new URL(value).host; - } catch { - return "invalid-url"; - } -} - -function parseGitHubRepoUrl(value: string): { owner: string; repo: string } | null { - try { - const url = new URL(value); - if (!GITHUB_HOSTS.has(url.hostname.toLowerCase())) return null; - const [owner, repo] = url.pathname - .split("/") - .filter(Boolean) - .map((part) => part.trim()); - if (!owner || !repo) return null; - if (["orgs", "topics", "marketplace", "features"].includes(owner)) return null; - return { owner, repo: repo.replace(/\.git$/i, "") }; - } catch { - return null; - } -} - -async function extractGitHubRepoFacts( - url: string, - datasetId: string, -): Promise { - const apiKey = await getTinyFishApiKey(); - if (!apiKey) throw new Error("TINYFISH_API_KEY is not configured"); - - let lastError: unknown; - for (let attempt = 1; attempt <= BROWSER_ATTEMPTS; attempt++) { - try { - return await extractGitHubRepoFactsOnce(apiKey, url, datasetId); - } catch (err) { - lastError = err; - if (getSignal(datasetId)?.aborted || attempt === BROWSER_ATTEMPTS) break; - const msg = err instanceof Error ? err.message : String(err); - console.warn( - `[row_extractor] GitHub browser attempt ${attempt} failed; retrying: ${msg}`, - ); - } - } - - throw lastError instanceof Error ? lastError : new Error(String(lastError)); -} - -async function extractGitHubRepoFactsOnce( - apiKey: string, - url: string, - datasetId: string, -): Promise { - const session = await createTinyFishBrowserSession(apiKey, url, datasetId); - let browser: Browser | undefined; - try { - browser = await chromium.connectOverCDP(session.cdp_url, { - timeout: CDP_CONNECT_TIMEOUT_MS, - }); - const context = browser.contexts()[0] ?? (await browser.newContext()); - const page = context.pages()[0] ?? (await context.newPage()); - await page.goto(url, { - waitUntil: "domcontentloaded", - timeout: BROWSER_TIMEOUT_MS, - }); - await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => { - // GitHub may keep long-lived requests open. DOMContentLoaded is enough. - }); - return await readGitHubRepoFacts(page); - } finally { - await browser?.close().catch(() => undefined); - } -} - -async function createTinyFishBrowserSession( - apiKey: string, - url: string, - datasetId: string, -): Promise { - const response = await withRunTimeoutSignal(datasetId, FETCH_TIMEOUT_MS, (signal) => - fetch("https://agent.tinyfish.ai/v1/browser", { - method: "POST", - headers: { - ...tinyFishHeaders(apiKey), - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ url }), - signal, - }), - ); - - if (!response.ok) { - const body = await response.text().catch(() => ""); - throw new Error( - `TinyFish Browser returned HTTP ${response.status}: ${body.slice(0, 200)}`, - ); - } - - const data = (await response.json()) as Partial; - if (!data.session_id || !data.cdp_url || !data.base_url) { - throw new Error("TinyFish Browser response did not include CDP connection details"); - } - - return { - session_id: data.session_id, - cdp_url: data.cdp_url, - base_url: data.base_url, - }; -} - -async function withRunTimeoutSignal( - datasetId: string, - timeoutMs: number, - operation: (signal: AbortSignal) => Promise, -): Promise { - const runSignal = getSignal(datasetId); - if (runSignal?.aborted) throw new DOMException("Run was stopped", "AbortError"); - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(new DOMException("Timed out", "TimeoutError")), - timeoutMs, - ); - const abortFromRun = () => - controller.abort(runSignal?.reason ?? new DOMException("Run was stopped", "AbortError")); - - runSignal?.addEventListener("abort", abortFromRun, { once: true }); - try { - return await operation(controller.signal); - } finally { - clearTimeout(timeout); - runSignal?.removeEventListener("abort", abortFromRun); - } -} - -async function readGitHubRepoFacts(page: Page): Promise { - const url = page.url(); - const repoRef = parseGitHubRepoUrl(url); - if (!repoRef) throw new Error(`Not a GitHub repository page: ${url}`); - - const facts = (await page.evaluate(` - (() => { - const text = (selector) => - document.querySelector(selector)?.textContent?.trim() || undefined; - const attr = (selector, name) => - document.querySelector(selector)?.getAttribute(name) || undefined; - const firstCandidateText = (selector, predicate) => - Array.from(document.querySelectorAll(selector)) - .map((el) => el.textContent?.trim()) - .filter(Boolean) - .find((value) => !predicate || predicate(value)); - const language = () => - text("[itemprop=\\"programmingLanguage\\"]") ?? - text("a[href*=\\"search?l=\\"] span.color-fg-default.text-bold") ?? - text("a[href*=\\"search?l=\\"] .text-bold"); - const license = () => - firstCandidateText( - "a[href*=\\"LICENSE\\"], a[href*=\\"license\\"], [data-testid*=\\"license\\"]", - (value) => /licensed|MIT|Apache|BSD|GPL|MPL|ISC/i.test(value), - ) ?? - firstCandidateText( - "a[href*=\\"LICENSE\\"], a[href*=\\"license\\"], [data-testid*=\\"license\\"]", - (value) => !/^(license|view license)$/i.test(value), - ) ?? - firstCandidateText( - "a[href*=\\"LICENSE\\"], a[href*=\\"license\\"], [data-testid*=\\"license\\"]", - ) ?? - text("svg.octicon-law + span"); - const bodyText = document.body?.innerText ?? ""; - - return { - description: - text("[data-pjax=\\"#repo-content-pjax-container\\"] [itemprop=\\"about\\"]") ?? - text("[itemprop=\\"about\\"]") ?? - attr("meta[name='description']", "content"), - stars: - text("#repo-stars-counter-star") ?? - text("a[href$='/stargazers'] strong") ?? - text("a[href$='/stargazers']"), - forks: - text("#repo-network-counter") ?? - text("a[href$='/forks'] strong") ?? - text("a[href$='/forks']"), - watchers: - text("a[href$='/watchers'] strong") ?? - text("a[href$='/watchers']"), - issues: - text("#issues-tab span.Counter") ?? - text("a[href$=\\"/issues\\"] span.Counter") ?? - text("a[data-tab-item=\\"i1issues-tab\\"] span.Counter"), - pullRequests: - text("#pull-requests-tab span.Counter") ?? - text("a[href$=\\"/pulls\\"] span.Counter") ?? - text("a[data-tab-item=\\"i2pull-requests-tab\\"] span.Counter"), - language: language(), - license: license(), - latestCommitAt: - attr("relative-time[datetime]", "datetime") ?? - attr("time-ago[datetime]", "datetime"), - homepage: attr("[itemprop='url']", "href"), - archived: /This repository has been archived/i.test(bodyText), - }; - })() - `)) as RawGitHubRepoDomFacts; - - const apiFacts = await fetchGitHubApiFacts(page, repoRef.owner, repoRef.repo).catch( - () => undefined, - ); - - return { - owner: repoRef.owner, - repo: repoRef.repo, - fullName: `${repoRef.owner}/${repoRef.repo}`, - url, - description: apiFacts?.description ?? cleanOptionalText(facts.description), - stars: apiFacts?.stars ?? parseCompactNumber(facts.stars), - forks: apiFacts?.forks ?? parseCompactNumber(facts.forks), - watchers: apiFacts?.watchers ?? parseCompactNumber(facts.watchers), - issues: parseCompactNumber(facts.issues) ?? apiFacts?.issues, - pullRequests: parseCompactNumber(facts.pullRequests) ?? apiFacts?.pullRequests, - language: apiFacts?.language ?? cleanOptionalText(facts.language), - license: apiFacts?.license ?? cleanOptionalText(facts.license), - latestCommitAt: apiFacts?.latestCommitAt ?? facts.latestCommitAt, - updatedAt: apiFacts?.updatedAt, - createdAt: apiFacts?.createdAt, - homepage: apiFacts?.homepage ?? cleanOptionalText(facts.homepage), - archived: apiFacts?.archived ?? facts.archived, - }; -} - -async function fetchGitHubApiFacts( - page: Page, - owner: string, - repo: string, -): Promise> { - const response = await page.request.get( - `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, - { - headers: { - Accept: "application/vnd.github+json", - }, - timeout: FETCH_TIMEOUT_MS, - }, - ); - if (!response.ok()) { - throw new Error(`GitHub API returned HTTP ${response.status()}`); - } - - const data = (await response.json()) as { - description?: string | null; - stargazers_count?: number; - forks_count?: number; - watchers_count?: number; - open_issues_count?: number; - language?: string | null; - license?: { spdx_id?: string | null; name?: string | null } | null; - pushed_at?: string | null; - updated_at?: string | null; - created_at?: string | null; - homepage?: string | null; - archived?: boolean; - html_url?: string; - }; - - return { - url: data.html_url, - description: data.description ?? undefined, - stars: data.stargazers_count, - forks: data.forks_count, - watchers: data.watchers_count, - issues: data.open_issues_count, - language: data.language ?? undefined, - license: data.license?.spdx_id || data.license?.name || undefined, - latestCommitAt: data.pushed_at ?? undefined, - updatedAt: data.updated_at ?? undefined, - createdAt: data.created_at ?? undefined, - homepage: data.homepage || undefined, - archived: data.archived, - }; -} - -function buildGitHubRow( - columns: PopulateColumn[], - primaryKeys: Record, - facts: GitHubRepoFacts, -): Record | null { - const row: Record = {}; - - for (const column of columns) { - const pkValue = findPrimaryKeyValue(column.name, primaryKeys); - const rawValue = pkValue ?? valueForGitHubColumn(column.name, facts); - const value = coerceColumnValue(rawValue, column); - if (value === undefined) return null; - row[column.name] = value; - } - - return row; -} - -function findPrimaryKeyValue( - columnName: string, - primaryKeys: Record, -): string | undefined { - if (primaryKeys[columnName]) return primaryKeys[columnName]; - const normalizedColumn = normalizeFieldName(columnName); - const entry = Object.entries(primaryKeys).find( - ([key]) => normalizeFieldName(key) === normalizedColumn, - ); - return entry?.[1]; -} - -function valueForGitHubColumn( - columnName: string, - facts: GitHubRepoFacts, -): string | number | boolean | undefined { - const normalized = normalizeFieldName(columnName); - if (matches(normalized, ["repository_url", "repo_url", "github_url", "url", "link"])) { - return facts.url; - } - if (matches(normalized, ["repository_name", "repo_name"])) { - return facts.fullName; - } - if (matches(normalized, ["repository", "repo", "name"])) { - return facts.repo; - } - if (matches(normalized, ["full_name", "repository_full_name", "repo_full_name"])) { - return facts.fullName; - } - if (matches(normalized, ["owner", "organization", "org", "user"])) { - return facts.owner; - } - if (matches(normalized, ["description", "summary", "about"])) { - return facts.description; - } - if (matches(normalized, ["stars", "star_count", "stargazers", "stargazer_count"])) { - return facts.stars; - } - if (matches(normalized, ["forks", "fork_count"])) { - return facts.forks; - } - if (matches(normalized, ["watchers", "watcher_count"])) { - return facts.watchers; - } - if (matches(normalized, ["issues", "open_issues", "open_issue_count"])) { - return facts.issues; - } - if (matches(normalized, ["pull_requests", "open_pull_requests", "prs", "open_prs", "pr_count", "open_pr_count"])) { - return facts.pullRequests; - } - if (matches(normalized, ["language", "primary_language"])) { - return facts.language; - } - if (matches(normalized, ["license", "license_type", "license_spdx"])) { - return facts.license; - } - if (matches(normalized, ["latest_commit", "latest_commit_at", "last_commit", "pushed_at", "activity", "last_activity"])) { - return facts.latestCommitAt; - } - if (matches(normalized, ["updated", "updated_at", "last_updated"])) { - return facts.updatedAt; - } - if (matches(normalized, ["created", "created_at"])) { - return facts.createdAt; - } - if (matches(normalized, ["homepage", "website", "site"])) { - return facts.homepage; - } - if (matches(normalized, ["archived", "is_archived"])) { - return facts.archived; - } - return undefined; -} - -function coerceColumnValue( - value: string | number | boolean | undefined, - column: PopulateColumn, -): string | number | boolean | undefined { - if (value === undefined || value === "") return undefined; - switch (column.type) { - case "number": { - if (typeof value === "number") return Number.isFinite(value) ? value : undefined; - const parsed = Number(String(value).replace(/,/g, "")); - return Number.isFinite(parsed) ? parsed : undefined; - } - case "boolean": - if (typeof value === "boolean") return value; - if (/^(true|yes)$/i.test(String(value))) return true; - if (/^(false|no)$/i.test(String(value))) return false; - return undefined; - case "url": - return isHttpUrl(String(value)) ? normalizeUrl(String(value)) : undefined; - case "date": { - const date = new Date(String(value)); - return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); - } - case "text": - return String(value).trim(); - } -} - -function parseCompactNumber(value: string | undefined): number | undefined { - if (!value) return undefined; - const match = value.replace(/,/g, "").match(/([\d.]+)\s*([kmb])?/i); - if (!match) return undefined; - const base = Number(match[1]); - if (!Number.isFinite(base)) return undefined; - const suffix = match[2]?.toLowerCase(); - const multiplier = suffix === "k" ? 1_000 : suffix === "m" ? 1_000_000 : suffix === "b" ? 1_000_000_000 : 1; - return Math.round(base * multiplier); -} - -function cleanOptionalText(value: string | undefined | null): string | undefined { - const trimmed = value?.trim(); - return trimmed || undefined; -} - -function normalizeFieldName(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); -} - -function matches(value: string, candidates: string[]): boolean { - return candidates.includes(value); -} From f9923526f9c5f2d8eaebfe28b4cbc220f82c65c2 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Thu, 6 Aug 2026 12:25:02 -0700 Subject: [PATCH 14/17] Update dependencies to clear security advisories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: 19 advisories -> 0. - npm audit fix cleared the OpenTelemetry chain, brace-expansion, js-yaml, dompurify, protobufjs, ws, convex and posthog-js. - Bump the exact next pin 16.2.6 -> 16.3.0 (minor), which clears the next, postcss and sharp advisories. - Replace the abandoned xlsx@0.18.5 with @e965/xlsx@0.20.3, a maintained SheetJS republish. SheetJS moved distribution off npm, so the xlsx package there is frozen and permanently unpatched. Our usage is write-only (aoa_to_sheet/writeFile), so neither the prototype pollution nor the ReDoS advisory was reachable, but this keeps the audit clean. Backend: 16 advisories -> 6, all 6 highs resolved. - npm audit fix cleared find-my-way, ip-address, js-yaml, shell-quote, brace-expansion and fast-uri. - Bump the hono override 4.12.25 -> 4.12.34 for the CORS ReDoS advisory. The 6 remaining backend advisories (2 low, 4 moderate) are all unreachable in shipped code and have no non-regressive fix: - The 4 moderate are the mastra -> @mastra/deployer -> @hono/node-ws -> @hono/node-server chain. npm's only offered fix is a semver-major downgrade of mastra 1.23 -> 1.3.19. The esbuild metafile for the release bundle contains zero hono, @modelcontextprotocol/sdk and @mastra/deployer inputs, so this is dev-only (Mastra Studio), and the advisory is Windows serve-static path traversal. - @ai-sdk/provider-utils is pinned exactly at 3.0.30 by @mastra/core's multi-version shim; the 3.x line ends at 3.0.31, still within the advisory range, so no fixed version exists. - @mastra/core appears to be a prerelease-range false positive: the flagged range ends at 0.24.10-alpha.0 but 1.57.0 is installed. Both clear once Mastra updates its own dependencies. Verified: tsc --noEmit clean, eslint 0 errors, and node scripts/build-release.mjs produces a 36.2 MB artifact. Note: frontend/bun.lock is not regenerated here — it needs a bun install to match the updated package.json. Co-Authored-By: Claude Opus 5 (1M context) --- backend/package-lock.json | 2179 +++++++++++++++++++------------------ backend/package.json | 2 +- frontend/lib/export.ts | 9 +- frontend/package.json | 6 +- 4 files changed, 1127 insertions(+), 1069 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 436430c..4e10333 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -44,9 +44,9 @@ } }, "node_modules/@a2a-js/sdk": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.13.tgz", - "integrity": "sha512-BZr0f9JVNQs3GKOM9xINWCh6OKIJWZFPyqqVqTym5mxO2Eemc6I/0zL7zWnljHzGdaf5aZQyQN5xa6PSH62q+A==", + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.14.tgz", + "integrity": "sha512-F6Ew1AtPzCLhTn8h9yiqTe7DiDf6XVrSnq9V1YqSl9eWqPm6anMveTiKdCSb/76cW0YiJc24rNaUrVezFFHbqQ==", "license": "Apache-2.0", "dependencies": { "uuid": "^11.1.0" @@ -299,9 +299,9 @@ }, "node_modules/@ai-sdk/provider-utils-v5": { "name": "@ai-sdk/provider-utils", - "version": "3.0.25", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.25.tgz", - "integrity": "sha512-CvsRu+32Y8a167s+lrIBtsybvgTHp8j9y+6BeTvLeoW3Q+okw/b4CnNUFOLIXsRaKHQKAH+IHNJPYWywfpw0LA==", + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.30.tgz", + "integrity": "sha512-NCJ9JKow5ENAgEZxzvEvF20thwDiH+hutvzmrUDbloRX0azpJHNst8+7pZIVryYhLM9wgpT5/ShTSjPTFhkxEQ==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.3", @@ -329,12 +329,12 @@ }, "node_modules/@ai-sdk/provider-utils-v6": { "name": "@ai-sdk/provider-utils", - "version": "4.0.27", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", - "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.40.tgz", + "integrity": "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, @@ -345,6 +345,49 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/provider-utils-v6/node_modules/@ai-sdk/provider": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.14.tgz", + "integrity": "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils-v7": { + "name": "@ai-sdk/provider-utils", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.13.tgz", + "integrity": "sha512-fScDJMDnTbx32kLDQqp0MvPjvwkgiwvlBxlmIg7XW5PbS91LG6JjH3PQG+34oMFglqfpQA355e24OdGj5PPoDw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.4", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils-v7/node_modules/@ai-sdk/provider": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.4.tgz", + "integrity": "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@ai-sdk/provider-v5": { "name": "@ai-sdk/provider", "version": "2.0.3", @@ -360,9 +403,9 @@ }, "node_modules/@ai-sdk/provider-v6": { "name": "@ai-sdk/provider", - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", - "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.14.tgz", + "integrity": "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -371,6 +414,19 @@ "node": ">=18" } }, + "node_modules/@ai-sdk/provider-v7": { + "name": "@ai-sdk/provider", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.4.tgz", + "integrity": "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@ai-sdk/togetherai": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/@ai-sdk/togetherai/-/togetherai-2.0.53.tgz", @@ -420,6 +476,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/compat-data": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", @@ -461,6 +527,85 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -472,14 +617,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -488,19 +633,69 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz", + "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", @@ -539,59 +734,49 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", - "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-8.0.1.tgz", + "integrity": "sha512-++t3ZktzlLmASAxIlxeXQK9Z2YwUafYGYcvGBFevqOqt16HozVHStUoQvWD09fzAZOb/uJGpUTBuGK41AJAuOA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.29.0", - "semver": "^6.3.1" + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-member-expression-to-functions": "^8.0.0", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/helper-replace-supers": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/traverse": "^8.0.0", + "semver": "^7.7.3" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "@babel/core": "^8.0.0" } }, "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-8.0.0.tgz", + "integrity": "sha512-xkXrMbtk87Gk7+oKBVmBc6EORg/Qwx++AHESldmHkpvG8wgccdhJJFwrzqlF382Fk8wfXhJHWE/g/43QvEGNPQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-module-imports": { @@ -608,6 +793,85 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-transforms": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", @@ -626,79 +890,161 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "node_modules/@babel/helper-module-transforms/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-module-transforms/node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "node_modules/@babel/helper-module-transforms/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-8.0.0.tgz", + "integrity": "sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-8.0.1.tgz", + "integrity": "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-8.0.1.tgz", + "integrity": "sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-member-expression-to-functions": "^8.0.0", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/traverse": "^8.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^8.0.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-8.0.0.tgz", + "integrity": "sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-validator-option": { @@ -725,109 +1071,168 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { + "node_modules/@babel/helpers/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" }, "bin": { "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.0.0" + "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "node_modules/@babel/plugin-syntax-typescript": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-8.0.3.tgz", + "integrity": "sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" } }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-8.0.1.tgz", + "integrity": "sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-module-transforms": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" } }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/helper-module-imports": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-8.0.0.tgz", + "integrity": "sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/helper-module-transforms": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-8.0.1.tgz", + "integrity": "sha512-UgAhl1kqiW5ciE0yCXqqvnb4H2n3IELJ7lIIQRezwDPilPEZX5i+Rvbja9MFTkwUn2biEiSMeV31aUzR4Lwakw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.0", + "@babel/traverse": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-8.0.1.tgz", + "integrity": "sha512-0Svqp3413Eg0GElldykF/T7SNsxQO5YVGD70fZyAdZTnX8WRgcopmbiU7GTa5xY5ZnJcEpNbfns8/GjX+/1yeA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-create-class-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/plugin-syntax-typescript": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" } }, "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-8.0.1.tgz", + "integrity": "sha512-qrPhQIN1NLrPmzgazF9XKQqXrOcp/WJly+K+6ReFonn24FZqRJO7clxOJo6Ni75L+2vAqI3cHVU2OJLBxoPp5A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-option": "^8.0.0", + "@babel/plugin-transform-modules-commonjs": "^8.0.1", + "@babel/plugin-transform-typescript": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-typescript/node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/template": { @@ -845,43 +1250,147 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse": { + "node_modules/@babel/template/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@clack/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz", - "integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", "dev": true, "license": "MIT", "dependencies": { @@ -893,13 +1402,13 @@ } }, "node_modules/@clack/prompts": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz", - "integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", "dev": true, "license": "MIT", "dependencies": { - "@clack/core": "1.3.1", + "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" @@ -1527,9 +2036,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1555,28 +2064,10 @@ "hono": "^4.6.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@isaacs/ttlcache": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz", - "integrity": "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.5.tgz", + "integrity": "sha512-VwGZqqjAWPICTmxUZnbpEfO60LhPWzquik+bmyXGY7pYRn6diEvCI5i6Ca+J6o2y4vS73HrpuMTo2dOvUevH8w==", "license": "BlueOak-1.0.0", "engines": { "node": ">=12" @@ -1654,41 +2145,41 @@ } }, "node_modules/@mastra/core": { - "version": "1.36.0", - "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.36.0.tgz", - "integrity": "sha512-BEhDZPQeDcJ6jQRHtpfFLuoRiWAuv9dTCIjeWbXokzwDamI3D9jkyNzpBFJwFwy2S/a4jBTu4+d61nOaP7knTQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.57.0.tgz", + "integrity": "sha512-2ud56Ow5wwyAFegxXkkOHcQfCG0W9Sz1ex2qVf3y/704zwwYZl/RBZXZV/7277RIxWFk8Bnw8pmGtGQ4tZVWEg==", "license": "Apache-2.0", "dependencies": { - "@a2a-js/sdk": "~0.3.13", - "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.25", - "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.27", + "@a2a-js/sdk": "~0.3.14", + "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.30", + "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.40", + "@ai-sdk/provider-utils-v7": "npm:@ai-sdk/provider-utils@5.0.13", "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.3", - "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.10", - "@ai-sdk/ui-utils-v5": "npm:@ai-sdk/ui-utils@1.2.11", - "@isaacs/ttlcache": "^2.1.4", + "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.14", + "@ai-sdk/provider-v7": "npm:@ai-sdk/provider@4.0.4", + "@isaacs/ttlcache": "^2.1.5", "@lukeed/uuid": "^2.0.1", - "@mastra/schema-compat": "1.2.10", + "@mastra/schema-compat": "1.3.5", "@modelcontextprotocol/sdk": "^1.29.0", "@sindresorhus/slugify": "^2.2.1", "@standard-schema/spec": "^1.1.0", - "ajv": "^8.18.0", - "chat": "^4.29.0", + "ajv": "^8.20.0", + "chat": "^4.34.0", "croner": "^10.0.1", "dotenv": "^17.3.1", "execa": "^9.6.1", - "fastq": "^1.19.1", + "fastq": "^1.20.1", "gray-matter": "^4.0.3", - "hono": "^4.12.8", - "hono-openapi": "^1.3.0", "ignore": "^7.0.5", + "jpeg-js": "^0.4.4", "json-schema": "^0.4.0", "lru-cache": "^11.2.7", "p-map": "^7.0.4", "p-retry": "^7.1.1", "picomatch": "^4.0.3", - "posthog-node": "^5.30.6", + "posthog-node": "^5.37.0", "tokenx": "^1.3.0", - "ws": "^8.20.0", + "ws": "^8.21.0", "xxhash-wasm": "^1.1.0" }, "engines": { @@ -1698,95 +2189,6 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/@mastra/core/node_modules/@ai-sdk/provider": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", - "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", - "license": "Apache-2.0", - "dependencies": { - "json-schema": "^0.4.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@mastra/core/node_modules/@ai-sdk/ui-utils-v5": { - "name": "@ai-sdk/ui-utils", - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", - "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.23.8" - } - }, - "node_modules/@mastra/core/node_modules/@ai-sdk/ui-utils-v5/node_modules/@ai-sdk/provider-utils": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", - "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "1.1.3", - "nanoid": "^3.3.8", - "secure-json-parse": "^2.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.23.8" - } - }, - "node_modules/@mastra/core/node_modules/@standard-community/standard-openapi": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@standard-community/standard-openapi/-/standard-openapi-0.2.9.tgz", - "integrity": "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg==", - "license": "MIT", - "peer": true, - "peerDependencies": { - "@standard-community/standard-json": "^0.3.5", - "@standard-schema/spec": "^1.0.0", - "arktype": "^2.1.20", - "effect": "^3.17.14", - "openapi-types": "^12.1.3", - "sury": "^10.0.0", - "typebox": "^1.0.0", - "valibot": "^1.1.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-openapi": "^4" - }, - "peerDependenciesMeta": { - "arktype": { - "optional": true - }, - "effect": { - "optional": true - }, - "sury": { - "optional": true - }, - "typebox": { - "optional": true - }, - "valibot": { - "optional": true - }, - "zod": { - "optional": true - }, - "zod-openapi": { - "optional": true - } - } - }, "node_modules/@mastra/core/node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -1799,46 +2201,18 @@ "url": "https://dotenvx.com" } }, - "node_modules/@mastra/core/node_modules/hono-openapi": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/hono-openapi/-/hono-openapi-1.3.0.tgz", - "integrity": "sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig==", - "license": "MIT", - "peerDependencies": { - "@hono/standard-validator": "^0.2.0", - "@standard-community/standard-json": "^0.3.5", - "@standard-community/standard-openapi": "^0.2.9", - "@types/json-schema": "^7.0.15", - "hono": "^4.8.3", - "openapi-types": "^12.1.3" - }, - "peerDependenciesMeta": { - "@hono/standard-validator": { - "optional": true - }, - "hono": { - "optional": true - } - } - }, - "node_modules/@mastra/core/node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", - "license": "BSD-3-Clause" - }, "node_modules/@mastra/deployer": { - "version": "1.36.0", - "resolved": "https://registry.npmjs.org/@mastra/deployer/-/deployer-1.36.0.tgz", - "integrity": "sha512-lZbfghkQwfx2MpcE3NfNTrZcS3NaLucJ5jAROHkh05MYxw0ofqiyuvJoTemtkz6Rsp5qqkrpwBAzbpJX8FeNaQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@mastra/deployer/-/deployer-1.57.0.tgz", + "integrity": "sha512-slFPv72TQhDH5eJgh+BslSOs4ViHC+31leMtfxpFPnRV9/ZTWL3gs7XWJfI5DQ0LqIGRNiICA42fyq34eHlX2A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@babel/core": "^7.29.0", - "@babel/preset-typescript": "^7.28.5", - "@babel/traverse": "^7.29.0", + "@babel/core": "^8.0.1", + "@babel/preset-typescript": "^8.0.1", + "@babel/traverse": "^8.0.4", "@hono/node-ws": "^1.3.0", - "@mastra/server": "1.36.0", + "@mastra/server": "1.57.0", "@optimize-lodash/rollup-plugin": "^5.1.0", "@rollup/plugin-alias": "6.0.0", "@rollup/plugin-commonjs": "29.0.2", @@ -1847,33 +2221,32 @@ "@rollup/plugin-node-resolve": "16.0.3", "@rollup/plugin-virtual": "3.0.2", "@sindresorhus/slugify": "^2.2.1", - "@types/babel__traverse": "^7.28.0", "empathic": "^2.0.0", - "esbuild": "^0.27.4", + "esbuild": "^0.28.0", "find-workspaces": "^0.3.1", - "fs-extra": "^11.3.4", + "fs-extra": "^11.3.5", + "gray-matter": "^4.0.3", "hono": "^4.12.8", "local-pkg": "^1.1.2", "resolve.exports": "^2.0.3", - "rollup": "^4.59.0", + "rollup": "^4.61.1", "rollup-plugin-esbuild": "^6.2.1", "strip-json-comments": "^5.0.3", - "tinyglobby": "^0.2.16", + "tinyglobby": "^0.2.17", "typescript-paths": "^1.5.2", - "ws": "^8.20.0" + "ws": "^8.21.0" }, "engines": { "node": ">=22.13.0" }, "peerDependencies": { - "@mastra/core": ">=1.34.0-0 <2.0.0-0", - "zod": "^3.25.0 || ^4.0.0" + "@mastra/core": ">=1.50.0-0 <2.0.0-0" } }, "node_modules/@mastra/loggers": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@mastra/loggers/-/loggers-1.1.1.tgz", - "integrity": "sha512-zszCHjYlnADYeFLaOIvQH/c86Wdn+tX0WTboc6K6NvPXa5dIq9TI4qzdy5IdhQn1auSzrgbCYRKdJnZKKoJSpw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mastra/loggers/-/loggers-1.2.0.tgz", + "integrity": "sha512-1RJO8XsMgVsTC+NviJ0jGMK01Y5zCqSzFNnOH9D1swOUfX8DMviyAJzxJUmkWbhSrDxmeo/KKmFzRK8zzk4XYA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1888,9 +2261,9 @@ } }, "node_modules/@mastra/schema-compat": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@mastra/schema-compat/-/schema-compat-1.2.10.tgz", - "integrity": "sha512-8Fg8PeO7GsRPOrEZAzc5udZgsF9ZDxih5JSoxjgnR79d0ImjKffhcoysPW6wIYXPEZ5i6/QDNR7rCazZZSD5Tg==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@mastra/schema-compat/-/schema-compat-1.3.5.tgz", + "integrity": "sha512-9CdBZZ2Fb8q6Ms4r6hs0msH8MTmVn99Zrc+i8BnNNuqKlIrywIoH3BghmPr5VsvkBUx7u/GjdYAsJ6i9nPcZZA==", "license": "Apache-2.0", "dependencies": { "json-schema-to-zod": "^2.7.0", @@ -1906,9 +2279,9 @@ } }, "node_modules/@mastra/server": { - "version": "1.36.0", - "resolved": "https://registry.npmjs.org/@mastra/server/-/server-1.36.0.tgz", - "integrity": "sha512-3YJf007Mf0EQ0kgtZOOD+cqGqDC2YAuEeWmprPe4CgOul2AVOFc58UJu8tVYNi0cnUZuU2Pp1PufCLkitPJ0Wg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@mastra/server/-/server-1.57.0.tgz", + "integrity": "sha512-+n25jqZClDrX5QXSZ8dWrCV0KzRDJhY0Rd6500N9O+XhxJ7aTZzoqyzegukW3NaNZfhsB6RuPmMYN7rbzXZQFg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1918,17 +2291,17 @@ "node": ">=22.13.0" }, "peerDependencies": { - "@mastra/core": ">=1.34.0-0 <2.0.0-0", + "@mastra/core": ">=1.50.0-0 <2.0.0-0", "zod": "^3.25.0 || ^4.0.0" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -2181,6 +2554,26 @@ "node": ">= 10" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2278,30 +2671,19 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@posthog/core": { - "version": "1.29.9", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.29.9.tgz", - "integrity": "sha512-DjvuIyBZ2Z/gBhtZlITlM2D8PlnMsHSQ1D78dbUYoVsgGguvanpJTobZObjLlFkybyvfZFYkpoJkFNI/2Pw4IQ==", + "version": "1.46.9", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.46.9.tgz", + "integrity": "sha512-EXO6y5ih+jBkTCpUCuYgQmajuDZuvy6vMvflkub6pLQyi0GPlCPWVSvZZkOeQw9e2MxoD5GteeGCt9R8+UJ/yQ==", "license": "MIT", "dependencies": { - "@posthog/types": "1.376.0" + "@posthog/types": "^1.402.2" } }, "node_modules/@posthog/types": { - "version": "1.376.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.376.0.tgz", - "integrity": "sha512-gbFfxCuZDs/D4QZMwdE+smD1jsuqgGpS6yKGHZZ19foxMy8RYHsU1E47iG1b88n/uN02fAabLibVwuxLtq8juw==", + "version": "1.402.2", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.402.2.tgz", + "integrity": "sha512-ZZTiS4dLwF4/D0YTzS3gGSLFnhuNOY5yu4d9VGS9trGe5GW6FjIXo20p18K5BPW2RZSYSld8sdnSAY3AUXleUQ==", "license": "MIT" }, "node_modules/@rollup/plugin-alias": { @@ -2436,9 +2818,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2459,9 +2841,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -2473,9 +2855,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -2487,9 +2869,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -2501,9 +2883,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -2515,9 +2897,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -2529,9 +2911,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -2543,13 +2925,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2557,13 +2942,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2571,13 +2959,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2585,13 +2976,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2599,13 +2993,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2613,13 +3010,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2627,13 +3027,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2641,13 +3044,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2655,13 +3061,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2669,13 +3078,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2683,13 +3095,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2697,13 +3112,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2711,13 +3129,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2725,9 +3146,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -2739,9 +3160,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -2753,9 +3174,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -2767,9 +3188,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -2781,9 +3202,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -2795,9 +3216,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -2863,52 +3284,6 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, - "node_modules/@standard-community/standard-json": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@standard-community/standard-json/-/standard-json-0.3.5.tgz", - "integrity": "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==", - "license": "MIT", - "peer": true, - "peerDependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/json-schema": "^7.0.15", - "@valibot/to-json-schema": "^1.3.0", - "arktype": "^2.1.20", - "effect": "^3.16.8", - "quansync": "^0.2.11", - "sury": "^10.0.0", - "typebox": "^1.0.17", - "valibot": "^1.1.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.24.5" - }, - "peerDependenciesMeta": { - "@valibot/to-json-schema": { - "optional": true - }, - "arktype": { - "optional": true - }, - "effect": { - "optional": true - }, - "sury": { - "optional": true - }, - "typebox": { - "optional": true - }, - "valibot": { - "optional": true - }, - "zod": { - "optional": true - }, - "zod-to-json-schema": { - "optional": true - } - } - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2925,16 +3300,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2951,12 +3316,12 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT", - "peer": true + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/mdast": { "version": "4.0.4", @@ -3006,9 +3371,9 @@ } }, "node_modules/@workflow/serde": { - "version": "4.1.0-beta.2", - "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0-beta.2.tgz", - "integrity": "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", "license": "Apache-2.0" }, "node_modules/@zeit/schemas": { @@ -3051,9 +3416,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -3217,54 +3582,24 @@ "license": "MIT" }, "node_modules/archiver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", - "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", "dev": true, "license": "MIT", "dependencies": { - "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", - "readable-stream": "^4.0.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^3.0.0", - "zip-stream": "^6.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", - "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^10.0.0", - "graceful-fs": "^4.2.0", - "is-stream": "^2.0.1", + "is-stream": "^4.0.0", "lazystream": "^1.0.0", - "lodash": "^4.17.15", "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, "node_modules/arg": { @@ -3352,9 +3687,9 @@ "license": "MIT" }, "node_modules/bare-events": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", - "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -3367,9 +3702,9 @@ } }, "node_modules/bare-fs": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", - "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.0.tgz", + "integrity": "sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3380,7 +3715,7 @@ "fast-fifo": "^1.3.2" }, "engines": { - "bare": ">=1.16.0" + "bare": ">=1.28.0" }, "peerDependencies": { "bare-buffer": "*" @@ -3391,33 +3726,21 @@ } } }, - "node_modules/bare-os": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", - "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } + "license": "Apache-2.0" }, "node_modules/bare-stream": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", - "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", "dev": true, "license": "Apache-2.0", "dependencies": { + "b4a": "^1.8.1", "streamx": "^2.25.0", "teex": "^1.0.1" }, @@ -3439,9 +3762,9 @@ } }, "node_modules/bare-url": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", - "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.7.tgz", + "integrity": "sha512-o8CRCiJtib+ycO3mE4A5UChtGX4dDP2XxsWVu9P+Zc3H8tcmKwNVEDoDTXmwN+uuMhfKeT7/i7Y26xS8W7ohoA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3470,9 +3793,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3483,21 +3806,34 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -3530,13 +3866,26 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -3553,9 +3902,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -3573,10 +3922,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -3673,9 +4022,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001807", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz", + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==", "dev": true, "funding": [ { @@ -3776,9 +4125,9 @@ } }, "node_modules/chat": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/chat/-/chat-4.29.0.tgz", - "integrity": "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw==", + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/chat/-/chat-4.36.0.tgz", + "integrity": "sha512-3A5HjnjilStMazAgY2PESloyT8g+GsoEf5uvqzPtvpGJrNh9oGWQ7IRZzw4+pFFTbUnCEtsclEAc2KMloRRZRQ==", "license": "MIT", "dependencies": { "@workflow/serde": "4.1.0-beta.2", @@ -3793,18 +4142,28 @@ "node": ">=20" }, "peerDependencies": { - "ai": "^6.0.182", + "ai": "^6.0.182 || ^7.0.0", + "workflow": "^5.0.0-beta.35", "zod": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { "ai": { "optional": true }, + "workflow": { + "optional": true + }, "zod": { "optional": true } } }, + "node_modules/chat/node_modules/@workflow/serde": { + "version": "4.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0-beta.2.tgz", + "integrity": "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==", + "license": "Apache-2.0" + }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -3971,33 +4330,20 @@ "license": "MIT" }, "node_modules/compress-commons": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", - "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", "dev": true, "license": "MIT", "dependencies": { "crc-32": "^1.2.0", - "crc32-stream": "^6.0.0", - "is-stream": "^2.0.1", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/compress-commons/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, "node_modules/compressible": { @@ -4200,9 +4546,9 @@ } }, "node_modules/crc32-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", - "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", "dev": true, "license": "MIT", "dependencies": { @@ -4210,7 +4556,7 @@ "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 14" + "node": ">=18" } }, "node_modules/croner": { @@ -4377,9 +4723,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.379", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz", - "integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==", + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", "dev": true, "license": "ISC" }, @@ -4702,9 +5048,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", "dev": true, "license": "MIT" }, @@ -4727,9 +5073,9 @@ } }, "node_modules/fast-copy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", - "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.4.tgz", + "integrity": "sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==", "dev": true, "license": "MIT" }, @@ -4833,9 +5179,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -4984,9 +5330,9 @@ } }, "node_modules/find-my-way": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", - "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5009,23 +5355,6 @@ "yaml": "^2.3.4" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5045,9 +5374,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", "dependencies": { @@ -5160,9 +5489,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==", "dev": true, "license": "MIT", "dependencies": { @@ -5172,28 +5501,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -5289,9 +5596,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.27", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", - "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -5386,9 +5693,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -5597,22 +5904,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -5632,6 +5923,12 @@ "node": ">=10" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/js-cookie": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", @@ -5646,9 +5943,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -5842,15 +6139,15 @@ "license": "MIT" }, "node_modules/local-pkg": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.0.tgz", - "integrity": "sha512-U16tFsiwNEac4GuqQ/SmG3ayjPIT1YKmiFeH4x9NaHTZwYbSqmEhf9POmzJu6NdUDDVjaE7n1WQQLjymYYFx+Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", "dev": true, "license": "MIT", "dependencies": { - "mlly": "^1.8.2", - "pkg-types": "^2.3.1", - "quansync": "^1.0.0" + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" }, "engines": { "node": ">=14" @@ -5878,30 +6175,6 @@ "pathe": "^2.0.3" } }, - "node_modules/local-pkg/node_modules/quansync": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", - "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -5942,34 +6215,34 @@ } }, "node_modules/mastra": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/mastra/-/mastra-1.10.0.tgz", - "integrity": "sha512-AJNN8mmPvGBnJR9RKWca9me4Ny6DV40YCt+SRLEtHq6zC69rfLs9RPrU0GNK2lN8EYKm1EOEO44cUK5SvQXlEQ==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/mastra/-/mastra-1.23.0.tgz", + "integrity": "sha512-GBR76V3DIEWfZ5YcqWwfdrCsGrhABxUk8LHA2EZmgBLU3rBo8G5/d/gSLKcXkqGnoWI3eY7WVh7079PO32J/Ug==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "@clack/prompts": "^1.1.0", + "@babel/parser": "^8.0.4", + "@babel/types": "^8.0.4", + "@clack/prompts": "^1.7.0", "@expo/devcert": "^1.2.1", - "@mastra/deployer": "^1.36.0", - "@mastra/loggers": "^1.1.1", - "archiver": "^7.0.1", + "@mastra/deployer": "^1.57.0", + "@mastra/loggers": "^1.2.0", + "archiver": "^8.0.0", "commander": "^14.0.3", "dotenv": "^17.3.1", "execa": "^9.6.1", - "fs-extra": "^11.3.4", + "fs-extra": "^11.3.5", "get-port": "^7.1.0", "local-pkg": "^1.1.2", "openapi-fetch": "^0.17.0", "picocolors": "^1.1.1", - "posthog-node": "^5.30.6", + "posthog-node": "^5.37.0", "semver": "^7.7.4", "serve": "^14.2.6", "serve-handler": "^6.1.7", "shell-quote": "^1.8.3", "strip-json-comments": "^5.0.3", - "tinyglobby": "^0.2.16", + "tinyglobby": "^0.2.17", "yocto-spinner": "^1.1.0" }, "bin": { @@ -5979,8 +6252,7 @@ "node": ">=22.13.0" }, "peerDependencies": { - "@mastra/core": ">=1.34.0-0 <2.0.0-0", - "zod": "^3.25.0 || ^4.0.0" + "@mastra/core": ">=1.50.0-0 <2.0.0-0" } }, "node_modules/mastra/node_modules/dotenv": { @@ -6858,16 +7130,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6883,16 +7155,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -6912,24 +7174,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -6940,9 +7184,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "dev": true, "license": "MIT", "engines": { @@ -7008,6 +7252,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -7074,13 +7332,6 @@ "openapi-typescript-helpers": "^0.1.0" } }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT", - "peer": true - }, "node_modules/openapi-typescript-helpers": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", @@ -7115,13 +7366,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -7166,30 +7410,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -7316,12 +7536,12 @@ "license": "MIT-0" }, "node_modules/posthog-node": { - "version": "5.35.1", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.35.1.tgz", - "integrity": "sha512-F9S3pEIYfGEVjLYIFHKaqfTIhn5IpS02Dkp7C/f1rqr4Z67Iqbt4jbKO8raWsT0veEI3rUp+DKuXLW1hN07FQA==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.48.1.tgz", + "integrity": "sha512-BxLX2SqGEQhPqCPTalpyo0RRv1NMbf7UaN7q9d/ED77ksD6XOmE7ko2vIKO8F0zPL1NtKxIi+DYXap9lvR0RaA==", "license": "MIT", "dependencies": { - "@posthog/core": "1.29.9" + "@posthog/core": "^1.46.9" }, "engines": { "node": "^20.20.0 || >=22.22.0" @@ -7450,6 +7670,7 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, "funding": [ { "type": "individual", @@ -7460,8 +7681,7 @@ "url": "https://github.com/sponsors/sxzz" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -7558,26 +7778,19 @@ } }, "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" + "minimatch": "^10.2.2" }, "engines": { - "node": ">=10" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" } }, "node_modules/real-require": { @@ -7766,13 +7979,13 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -7782,31 +7995,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -7830,13 +8044,6 @@ "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" } }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8045,9 +8252,9 @@ } }, "node_modules/serve-handler/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -8192,9 +8399,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { @@ -8345,9 +8552,9 @@ "license": "MIT" }, "node_modules/streamx": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", - "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", "dev": true, "license": "MIT", "dependencies": { @@ -8384,52 +8591,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -8446,30 +8607,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", @@ -8591,9 +8728,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -9031,86 +9168,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -9196,18 +9253,18 @@ } }, "node_modules/zip-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", - "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", "dev": true, "license": "MIT", "dependencies": { - "archiver-utils": "^5.0.0", - "compress-commons": "^6.0.2", + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 14" + "node": ">=18" } }, "node_modules/zod": { @@ -9220,9 +9277,9 @@ } }, "node_modules/zod-from-json-schema": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/zod-from-json-schema/-/zod-from-json-schema-0.5.2.tgz", - "integrity": "sha512-/dNaicfdhJTOuUd4RImbLUE2g5yrSzzDjI/S6C2vO2ecAGZzn9UcRVgtyLSnENSmAOBRiSpUdzDS6fDWX3Z35g==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/zod-from-json-schema/-/zod-from-json-schema-0.5.6.tgz", + "integrity": "sha512-U33AJ7ZWS6y9XNSzMWcdy8hRAvZmWhTtpYJu0SXPT5AArbc9nq2ur7Magzmn5RF9KBV4b3FP0nCpmqqlfXlR9w==", "license": "MIT", "dependencies": { "zod": "^4.0.17" diff --git a/backend/package.json b/backend/package.json index 4fac14e..57987f2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -46,7 +46,7 @@ }, "overrides": { "ws": "^8.21.0", - "hono": "^4.12.25", + "hono": "^4.12.34", "js-cookie": "^3.0.7", "@babel/core": "^7.29.6", "esbuild": "^0.28.1" diff --git a/frontend/lib/export.ts b/frontend/lib/export.ts index 2f07f75..8f60847 100644 --- a/frontend/lib/export.ts +++ b/frontend/lib/export.ts @@ -2,9 +2,10 @@ * Client-side exporters for dataset views. * * CSV: hand-rolled, no dependencies. Tiny payload, fast. - * XLSX: dynamically imports `xlsx` (SheetJS) on demand so the ~700KB - * library doesn't enter the main bundle. Users who never click - * "Export XLSX" never download it. + * XLSX: dynamically imports `@e965/xlsx` (a maintained SheetJS republish — + * the `xlsx` package on npm is frozen at an abandoned 0.18.5) on demand + * so the ~700KB library doesn't enter the main bundle. Users who never + * click "Export XLSX" never download it. */ export interface ExportColumn { @@ -89,7 +90,7 @@ export async function downloadXLSX( rows: ExportRow[], ): Promise { // Dynamic import — keeps the ~700KB xlsx library out of the main bundle. - const XLSX = await import("xlsx"); + const XLSX = await import("@e965/xlsx"); // sheet_aoa expects a 2-D array. First row is headers. const aoa: unknown[][] = [ diff --git a/frontend/package.json b/frontend/package.json index 60e8d47..4776418 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,15 +10,15 @@ }, "dependencies": { "@clerk/nextjs": "^7.3.7", + "@e965/xlsx": "^0.20.3", "@tanstack/react-table": "^8.21.3", "convex": "^1.39.1", "lucide-react": "^1.17.0", - "next": "16.2.6", + "next": "16.3.0", "posthog-js": "^1.374.2", "react": "19.2.4", "react-dom": "19.2.4", - "react-window": "^1.8.11", - "xlsx": "^0.18.5" + "react-window": "^1.8.11" }, "devDependencies": { "@tailwindcss/postcss": "^4", From 5b0f5891837f333fd32f2107472a5d8371c2c24d Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Thu, 6 Aug 2026 13:33:49 -0700 Subject: [PATCH 15/17] Ship frontend/lib in the release package and verify it The release artifact built cleanly but was unusable: `convex deploy` failed on the user's machine with Could not resolve "../lib/llm-provider-types.js" convex/schema.ts, convex/modelConfig.ts and convex/localCredentials.ts all import shared types from the sibling frontend/lib directory, but copyConvexRuntime() only copied frontend/convex and package.json, so that file never shipped. Broken since d1ae1ef, which introduced the first convex -> lib import. This class of bug is invisible to the build: `convex deploy` only runs at install time, so a dangling import in the packaged tree surfaces on the user's machine rather than in CI. Copying lib/ fixes this instance; verifyConvexPackage() prevents the next one by resolving every relative import in the packaged Convex tree and failing the build if any dangle. Verified by removing the lib/ copy and confirming the build fails with all three unresolved imports (exit 1), then, with the fix in place, running the real installer command against a throwaway local backend: node node_modules/convex/bin/main.js deploy --url ... --admin-key ... => Deployed Convex functions --- scripts/build-release.mjs | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/scripts/build-release.mjs b/scripts/build-release.mjs index d2e2332..23fbe53 100644 --- a/scripts/build-release.mjs +++ b/scripts/build-release.mjs @@ -4,6 +4,7 @@ import { existsSync } from "node:fs"; import { cp, mkdir, + readdir, rm, stat, writeFile, @@ -222,6 +223,12 @@ async function copyConvexRuntime() { await cp(join(frontendDir, "convex"), join(packageRoot, "frontend", "convex"), { recursive: true, }); + // Convex modules import shared types from the sibling lib/ directory (e.g. + // schema.ts -> ../lib/llm-provider-types.js). Without it, `convex deploy` + // fails to resolve at install time even though the build itself succeeds. + await cp(join(frontendDir, "lib"), join(packageRoot, "frontend", "lib"), { + recursive: true, + }); for (const packageName of convexRuntimePackages) { const source = join(frontendDir, "node_modules", packageName); @@ -232,6 +239,42 @@ async function copyConvexRuntime() { } } +// The release build can succeed while the packaged Convex tree is unusable: +// `convex deploy` only runs at install time, so an import reaching a file we +// never copied surfaces on the user's machine, not here. Resolve every +// relative import in the packaged tree up front and fail the build instead. +async function verifyConvexPackage() { + const requireFromBackend = createRequire(join(backendDir, "package.json")); + const esbuild = requireFromBackend("esbuild"); + const convexDir = join(packageRoot, "frontend", "convex"); + + const entryPoints = (await readdir(convexDir, { recursive: true })) + .filter((name) => name.endsWith(".ts") || name.endsWith(".js")) + .map((name) => join(convexDir, name)); + + try { + await esbuild.build({ + entryPoints, + bundle: true, + write: false, + platform: "node", + format: "esm", + logLevel: "silent", + // Bare specifiers resolve from node_modules at runtime; we only care + // that relative imports point at files that actually shipped. + packages: "external", + outdir: join(workDir, "convex-verify"), + }); + } catch (err) { + const details = (err.errors ?? []) + .map((e) => ` ${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`) + .join("\n"); + throw new Error( + `Packaged Convex sources have unresolved imports, so \`convex deploy\` would fail on the user's machine:\n${details}`, + ); + } +} + function releaseFrontendAppDir() { const nested = join(packageRoot, "frontend", "frontend"); if (existsSync(join(nested, "server.js"))) return nested; @@ -277,6 +320,8 @@ async function main() { { recursive: true }, ); await copyConvexRuntime(); + console.log("Verifying packaged Convex sources..."); + await verifyConvexPackage(); await writeStartScript(); await writeReadme(); From debcd9f5c413bf8f1fcada224feb236aef659edc Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Thu, 6 Aug 2026 14:06:30 -0700 Subject: [PATCH 16/17] Fix Fireworks AI API key link helperHref pointed at fireworks.ai/account/api-keys, which is not the key management page. The actual one is app.fireworks.ai/settings/users/api-keys. The setup wizard reads the same entry via llmProviderOption(), so this corrects the link in both the settings page and setup flow. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/components/settings/llm-providers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/settings/llm-providers.tsx b/frontend/components/settings/llm-providers.tsx index 289e25a..3a34610 100644 --- a/frontend/components/settings/llm-providers.tsx +++ b/frontend/components/settings/llm-providers.tsx @@ -203,7 +203,7 @@ export const LLM_PROVIDER_OPTIONS: LlmProviderOption[] = [ authLabel: "API key", defaultModel: "accounts/fireworks/models/deepseek-v4-flash", apiKeyPlaceholder: "fw_...", - helperHref: "https://fireworks.ai/account/api-keys", + helperHref: "https://app.fireworks.ai/settings/users/api-keys", iconSrc: "/logos/providers/fireworks-ai.svg", }, { From 06f0cac6e83d68523c0a5b6b8df64a9c0ba0d533 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Thu, 6 Aug 2026 14:28:36 -0700 Subject: [PATCH 17/17] Replace the reasoning slider with a segmented control The range input set appearance:none but never styled ::-webkit-slider-thumb, so WebKit rendered no thumb at all -- there was nothing to drag. Inline styles cannot express pseudo-elements, so the component as written could never have styled one. The 4px track meant the entire hit target was a 4px strip, which is why clicks so often landed on nothing. Reasoning effort is five discrete named steps, so it is a segmented control, not a slider: five real buttons with full-height hit targets, ordered None -> Max so the scale still reads as a scale. Arrow keys and Home/End work via radiogroup/radio semantics with a roving tabindex. Also: - "Auto" was wrong. reasoningOverridden:false means the level came from the provider/role default, not from anything adaptive. It now reads "Default" / "Reset to default". - A pinned level renders filled and an inherited one renders highlighted next to the Default pill, so which level runs stays separable from who chose it. - SettingsTile had no hover state and no pointer cursor, so the model row gave no sign it was clickable. Each role is now a bordered card with the model row on top and reasoning below a divider, so the model reads as the primary control rather than a heading for the slider. - Converted from inline styles to Tailwind, matching the other settings components. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/dashboard/settings/models/page.tsx | 42 +++-- frontend/app/setup/page.tsx | 4 +- .../components/settings/ReasoningControl.tsx | 137 ++++++++++++++++ .../components/settings/ReasoningSlider.tsx | 147 ------------------ frontend/components/settings/SettingsTile.tsx | 32 ++-- 5 files changed, 175 insertions(+), 187 deletions(-) create mode 100644 frontend/components/settings/ReasoningControl.tsx delete mode 100644 frontend/components/settings/ReasoningSlider.tsx diff --git a/frontend/app/dashboard/settings/models/page.tsx b/frontend/app/dashboard/settings/models/page.tsx index 7cc1ad6..3296a09 100644 --- a/frontend/app/dashboard/settings/models/page.tsx +++ b/frontend/app/dashboard/settings/models/page.tsx @@ -10,7 +10,7 @@ import { SettingsTile } from "@/components/settings/SettingsTile"; import { LocalCredentialsPanel } from "@/components/settings/LocalCredentialsPanel"; import { ModelSideSheet } from "@/components/settings/ModelSideSheet"; import { MODEL_ROLES, type ModelRole } from "@/components/settings/types"; -import { ReasoningSlider } from "@/components/settings/ReasoningSlider"; +import { ReasoningControl } from "@/components/settings/ReasoningControl"; import { SkeletonList } from "@/components/settings/Skeleton"; import { useAppAuth } from "@/lib/app-auth"; import { isLocalMode } from "@/lib/app-mode"; @@ -139,7 +139,7 @@ export default function ModelSettingsPage() { const previous = effectiveConfig; setSavingReasoningRole(role.key); setSaveError(null); - // Optimistic: the slider should track the drag, not the round-trip. + // Optimistic: the control should track the click, not the round-trip. setEffectiveConfig((prev) => prev ? { @@ -179,8 +179,8 @@ export default function ModelSettingsPage() { if (!token) throw new Error("Not authenticated"); await saveModelConfig({ [role.key]: nextModelId }, token); setActiveSheet(null); - // A new model can change the auto-resolved reasoning level, so re-read - // rather than patching the slug in place. + // A new model can change the default reasoning level, so re-read rather + // than patching the slug in place. setModelConfigReloadKey((key) => key + 1); } catch (err) { setSaveError( @@ -260,14 +260,17 @@ export default function ModelSettingsPage() { } /> -
+
{isLoading ? ( ) : ( MODEL_ROLES.map((role) => { const roleConfig = getRoleConfig(role); return ( -
+
openSideSheet(role)} /> {roleConfig && ( - void saveReasoningForRole(role, level)} - /> + <> +
+
+ void saveReasoningForRole(role, level)} + /> +
+ )}
); diff --git a/frontend/app/setup/page.tsx b/frontend/app/setup/page.tsx index 399e770..dbfdedf 100644 --- a/frontend/app/setup/page.tsx +++ b/frontend/app/setup/page.tsx @@ -41,7 +41,7 @@ import { import { LocalUtilityMenu } from "@/components/LocalUtilityMenu"; import { ModelSideSheet } from "@/components/settings/ModelSideSheet"; import { MODEL_ROLES, type ModelRole } from "@/components/settings/types"; -import { ReasoningSlider } from "@/components/settings/ReasoningSlider"; +import { ReasoningControl } from "@/components/settings/ReasoningControl"; import { useAppAuth } from "@/lib/app-auth"; function modelListCacheKey(status: LocalSetupStatus | null): string { @@ -389,7 +389,7 @@ export default function SetupPage() { {roleConfig && (
- void; + disabled?: boolean; + /** Shown in place of the control when the provider has no reasoning knob. */ + unsupportedReason?: string; +} + +/** + * Segmented control over the canonical reasoning scale. + * + * This is a fixed set of five named steps, so every step is a real button with + * a full-height hit target rather than a thumb on a track — there is nothing to + * drag and nothing to miss. Levels read low-to-high left-to-right, so the scale + * still reads as a scale. + * + * The selected segment is filled when the level is pinned and outlined when it + * is inherited, so "which level runs" and "who chose it" stay separable at a + * glance: an inherited role still shows where it landed. + */ +export function ReasoningControl({ + value, + overridden, + onChange, + disabled = false, + unsupportedReason, +}: ReasoningControlProps) { + const labelId = useId(); + const groupRef = useRef(null); + const selectedIndex = Math.max(0, REASONING_LEVELS.indexOf(value)); + + if (unsupportedReason) { + return ( +

{unsupportedReason}

+ ); + } + + // Arrow keys move between steps and commit, matching native radio-group + // behaviour. Roving tabindex keeps the whole control a single tab stop. + function handleKeyDown(event: React.KeyboardEvent) { + const deltas: Record = { + ArrowLeft: -1, + ArrowUp: -1, + ArrowRight: 1, + ArrowDown: 1, + }; + let next: number | null = null; + + if (event.key in deltas) { + next = selectedIndex + deltas[event.key]; + } else if (event.key === "Home") { + next = 0; + } else if (event.key === "End") { + next = REASONING_LEVELS.length - 1; + } + if (next === null) return; + + event.preventDefault(); + const clamped = Math.min(Math.max(next, 0), REASONING_LEVELS.length - 1); + if (clamped === selectedIndex) return; + onChange(REASONING_LEVELS[clamped]); + const buttons = groupRef.current?.querySelectorAll("[role=radio]"); + buttons?.[clamped]?.focus(); + } + + return ( +
+
+ + Reasoning effort + + {overridden ? ( + + ) : ( + + Default + + )} +
+ +
+ {REASONING_LEVELS.map((level, index) => { + const isSelected = index === selectedIndex; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/components/settings/ReasoningSlider.tsx b/frontend/components/settings/ReasoningSlider.tsx deleted file mode 100644 index 34f7a3e..0000000 --- a/frontend/components/settings/ReasoningSlider.tsx +++ /dev/null @@ -1,147 +0,0 @@ -"use client"; - -import { useId } from "react"; -import { - REASONING_LEVELS, - REASONING_LEVEL_LABELS, - type ReasoningLevel, -} from "@/lib/backend"; - -interface ReasoningSliderProps { - value: ReasoningLevel; - /** False when the level is the provider/role default rather than a choice. */ - overridden: boolean; - /** Called with a level to pin it, or null to return the role to auto. */ - onChange: (level: ReasoningLevel | null) => void; - disabled?: boolean; - /** Shown in place of the control when the provider has no reasoning knob. */ - unsupportedReason?: string; -} - -/** - * Discrete slider over the canonical reasoning scale. - * - * "Auto" is deliberately not a stop on the track — it is a mode. The thumb - * always sits on the level that will actually be used, so an auto role still - * shows where it landed; moving the thumb pins that choice, and "Reset to auto" - * hands the role back to the provider/role default. - */ -export function ReasoningSlider({ - value, - overridden, - onChange, - disabled = false, - unsupportedReason, -}: ReasoningSliderProps) { - const id = useId(); - const index = Math.max(0, REASONING_LEVELS.indexOf(value)); - const max = REASONING_LEVELS.length - 1; - const progress = max === 0 ? 0 : (index / max) * 100; - - if (unsupportedReason) { - return ( -

- {unsupportedReason} -

- ); - } - - return ( -
-
- -
- - {REASONING_LEVEL_LABELS[value]} - - {overridden ? ( - - ) : ( - - Auto - - )} -
-
- - - onChange(REASONING_LEVELS[Number(event.target.value)]) - } - style={{ - width: "100%", - height: "4px", - borderRadius: "999px", - appearance: "none", - WebkitAppearance: "none", - accentColor: "var(--accent)", - cursor: disabled ? "not-allowed" : "pointer", - background: `linear-gradient(to right, var(--accent) ${progress}%, var(--border) ${progress}%)`, - }} - /> - -
- {REASONING_LEVELS.map((level) => ( - - {REASONING_LEVEL_LABELS[level]} - - ))} -
-
- ); -} diff --git a/frontend/components/settings/SettingsTile.tsx b/frontend/components/settings/SettingsTile.tsx index 51b16c9..9c5020b 100644 --- a/frontend/components/settings/SettingsTile.tsx +++ b/frontend/components/settings/SettingsTile.tsx @@ -23,45 +23,35 @@ export function SettingsTile({ ); -} \ No newline at end of file +}