Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ CLERK_JWT_ISSUER_DOMAIN=https://your-app.clerk.accounts.dev
# Generate at https://openrouter.ai/settings/keys
OPENROUTER_API_KEY=sk-or-...

# OpenRouter model slugs for each AI task.
# Defaults (used when no user preference is saved):
# SCHEMA_INFERENCE_MODEL: anthropic/claude-sonnet-4.6 (powerful for schema inference)
# POPULATE_ORCHESTRATOR_MODEL: qwen/qwen3.7-max (cost-effective orchestrator)
# INVESTIGATE_SUBAGENT_MODEL: qwen/qwen3.7-max (cost-effective subagent)
# Find model IDs at https://openrouter.ai/models — any OpenRouter model slug is valid.
SCHEMA_INFERENCE_MODEL=anthropic/claude-sonnet-4.6
POPULATE_ORCHESTRATOR_MODEL=qwen/qwen3.7-max
INVESTIGATE_SUBAGENT_MODEL=qwen/qwen3.7-max

# TinyFish — used by the backend's populate agent for web search and fetch.
# TinyFish (required) — web search + dataset population.
# Generate at https://agent.tinyfish.ai/api-keys
TINYFISH_API_KEY=
Expand Down
174 changes: 174 additions & 0 deletions backend/src/config/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Backend configuration for AI models.
*
* Defines the typed interfaces and constants for OpenRouter model management.
*/

import { api, internal, convex } from "../convex.js";
import { env } from "../env.js";

export interface OpenRouterModel {
modelName: string;
canonicalSlug: string;
contextLength: number;
completionCost: number;
promptCost: number;
}

/**
* 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).
*/
export const DEFAULT_MODEL_IDS = {
SCHEMA_INFERENCE: env.SCHEMA_INFERENCE_MODEL,
POPULATE_ORCHESTRATOR: env.POPULATE_ORCHESTRATOR_MODEL,
INVESTIGATE_SUBAGENT: env.INVESTIGATE_SUBAGENT_MODEL,
} as const;

/**
* Model roles for the settings UI.
*/
export const MODEL_ROLES = [
{ key: "schemaInference", label: "Schema Inference" },
{ key: "populateOrchestrator", label: "Populate Orchestrator" },
{ key: "investigateSubagent", label: "Investigate Subagent" },
] as const;

/**
* Models explicitly excluded from the list.
* These are models that we exclude from the OpenRouter fetch results
* based on known incompatibilities or undesirability for our use case.
*/
export const EXCLUDED_MODEL_SLUGS: string[] = [];

/**
* Fetch all cached models from Convex.
* If the cache is empty, fetches from OpenRouter, stores in Convex, and returns.
*/
export async function getCachedModels(): Promise<OpenRouterModel[]> {
const models = await convex.query(api.openRouterModels.list, {});
const cached = models as unknown as OpenRouterModel[];
if (cached.length > 0) return cached;

const fetched = await fetchModelsFromOpenRouter();
await upsertModelBatch(fetched);
return fetched;
}

/**
* Validate that a model slug exists in the cached model list.
* Throws with a clear message if the slug is not found.
* Should be called before using any model from user config.
*/
export async function validateModelSlug(
slug: string,
role: "schemaInference" | "populateOrchestrator" | "investigateSubagent"
): Promise<void> {
const models = await getCachedModels();
const found = models.some((m) => m.canonicalSlug === slug);
if (!found) {
throw new Error(
`Invalid model slug "${slug}" for ${role}. ` +
`Available models: ${models.map((m) => m.canonicalSlug).join(", ") || "none (run /openrouter/refresh first)"}`
);
}
}

/**
* Upsert a batch of models to Convex.
* Called after successfully fetching from OpenRouter API.
*/
export async function upsertModelBatch(models: OpenRouterModel[]): Promise<void> {
await convex.mutation(internal.openRouterModels.upsertBatch, { models });
}

/**
* Upsert the model configuration for a specific user in Convex.
* Only fields that are explicitly provided (not undefined) are updated.
* Unset fields retain their existing values.
*/
export async function upsertModelConfig(
userId: string,
config: {
schemaInference?: string;
populateOrchestrator?: string;
investigateSubagent?: string;
}
): Promise<void> {
await convex.mutation(internal.modelConfig.upsertInternal, {
userId,
schemaInference: config.schemaInference ?? undefined,
populateOrchestrator: config.populateOrchestrator ?? undefined,
investigateSubagent: config.investigateSubagent ?? undefined,
});
}

/**
* Fetch the model configuration for a specific user from Convex.
* If the user has no saved config, returns the system defaults from env.
* Callers always get a complete config — never null.
*/
export async function getModelConfig(
userId: string
): Promise<{
schemaInference: string;
populateOrchestrator: string;
investigateSubagent: string;
}> {
const config = await convex.query(internal.modelConfig.getInternal, { userId });
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,
};
}

/**
* Fetch models from OpenRouter REST API and return parsed models ready
* for Convex storage.
*/
export async function fetchModelsFromOpenRouter(): Promise<OpenRouterModel[]> {
const apiKey = env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set");
}

// Only text-based models that support tools
const response = await fetch(
"https://openrouter.ai/api/v1/models?output_modalities=text&supported_parameters=tools",
{
headers: {
Authorization: `Bearer ${apiKey}`,
},
}
);

if (!response.ok) {
throw new Error(`OpenRouter API failed: ${response.status} ${response.statusText}`);
}

const json = (await response.json()) as {
data: Array<{
id: string;
name: string;
context_length: number;
pricing?: { completion?: string; prompt?: string };
}>;
};

// Filter excluded and map to OpenRouterModel
// Prices from OpenRouter are per-token; multiply by 1M for per-million
const models = json.data
.filter((m) => !EXCLUDED_MODEL_SLUGS.includes(m.id))
.map((model) => ({
modelName: model.name,
canonicalSlug: model.id,
contextLength: model.context_length ?? 0,
promptCost: parseFloat(model.pricing?.prompt ?? "0") * 1_000_000,
completionCost: parseFloat(model.pricing?.completion ?? "0") * 1_000_000,
}));

return models;
}
9 changes: 9 additions & 0 deletions backend/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export const env = {

OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,

// Default models — used when a user has not saved a preference.
// Each must be a valid OpenRouter model slug.
SCHEMA_INFERENCE_MODEL:
process.env.SCHEMA_INFERENCE_MODEL ?? "anthropic/claude-sonnet-4.6",
POPULATE_ORCHESTRATOR_MODEL:
process.env.POPULATE_ORCHESTRATOR_MODEL ?? "qwen/qwen3.7-max",
INVESTIGATE_SUBAGENT_MODEL:
process.env.INVESTIGATE_SUBAGENT_MODEL ?? "qwen/qwen3.7-max",

// Resend (transactional email). Optional — when RESEND_API_KEY is unset
// the email module no-ops with a log line, so local dev works without
// a Resend account. EMAIL_FROM must be a domain that's verified in the
Expand Down
Loading