diff --git a/README.md b/README.md index a5cb0740..e8f9b6a2 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The three are designed to work together: search surfaces your notes, the graph r **🔌 Provider & model choice** -- **Providers:** OpenAI (and any OpenAI-compatible endpoint), Anthropic, Ollama (local), OpenRouter, and OpenAI Codex sign-in. Multiple instances of a provider with distinct names/endpoints are supported. +- **Providers:** OpenAI (and any OpenAI-compatible endpoint), Anthropic, Ollama (local), OpenRouter, [OrcaRouter](https://www.orcarouter.ai), and OpenAI Codex sign-in. Multiple instances of a provider with distinct names/endpoints are supported. - **Local & private:** With Ollama, models run entirely on your machine and no data leaves it. - **Quickly switch models:** Change the active model per chat — e.g. one model for scientific writing, another for brainstorming. - **Fine-grained privacy controls:** Mark individual providers as *trusted* or *untrusted*. Set the vault to **private-by-default** (only notes you explicitly allow can reach a provider) or **public-by-default** (only notes you explicitly exclude are blocked). Untrusted providers — cloud APIs, by default — are blocked from reading, embedding, or being sent private notes, even if the agent tries to access them. diff --git a/src/agent/Agent.ts b/src/agent/Agent.ts index 7fa07b4a..4262da42 100644 --- a/src/agent/Agent.ts +++ b/src/agent/Agent.ts @@ -51,7 +51,7 @@ const MAX_TEXT_ATTACHMENT_CHARS = 120_000; const MAX_PDF_EXTRACT_CHARS = 180_000; /** Providers whose APIs accept native PDF file content blocks. */ -export const NATIVE_PDF_PROVIDERS = new Set(["anthropic", "openai", "openrouter"]); +export const NATIVE_PDF_PROVIDERS = new Set(["anthropic", "openai", "openrouter", "orcarouter"]); function truncateContent(content: string, maxChars: number): string { if (content.length <= maxChars) return content; diff --git a/src/agent/AgentManager.ts b/src/agent/AgentManager.ts index 7a9fc8fb..c7a59c97 100644 --- a/src/agent/AgentManager.ts +++ b/src/agent/AgentManager.ts @@ -16,6 +16,7 @@ import { extractCapabilities as extractOpenRouterCapabilities, fetchOpenRouterModels, } from "../providers/openrouterModels"; +import { extractOrcaRouterCapabilities, fetchOrcaRouterModels } from "../providers/orcarouterModels"; import type { VisibleNoteRef } from "../hooks/useVisibleNotes.svelte"; import type { SelectionRef } from "../hooks/useSelection.svelte"; import type { GraphNoteRef } from "../stores/chatStore.svelte"; @@ -248,6 +249,15 @@ async function resolveVisionSupport(providerId: string, modelId: string): Promis } } + // 2b. OrcaRouter: derive vision from architecture.input_modalities (cached) + if (providerId === "orcarouter") { + const models = await fetchOrcaRouterModels(); + if (models) { + const info = models.get(modelId); + if (info) return extractOrcaRouterCapabilities(info).supportsVision; + } + } + // 3. Fallback: models.dev for other providers (OpenAI, Anthropic, etc.) const mdInfo = await lookupModelInfo(providerId, modelId); if (mdInfo) return mdInfo.attachment ?? false; diff --git a/src/components/ui/logos/OrcaRouterLogo.svelte b/src/components/ui/logos/OrcaRouterLogo.svelte new file mode 100644 index 00000000..c171793d --- /dev/null +++ b/src/components/ui/logos/OrcaRouterLogo.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/src/hooks/useAvailableModels.svelte.ts b/src/hooks/useAvailableModels.svelte.ts index 5d2c6837..27b7ba01 100644 --- a/src/hooks/useAvailableModels.svelte.ts +++ b/src/hooks/useAvailableModels.svelte.ts @@ -4,6 +4,7 @@ import { hydrateChatModel, hydrateEmbeddingModel } from "../lib/modelMetadataNor import { fetchModelsDevData, type ModelsDevApiResponse } from "../providers/modelsDevApi"; import { getOllamaModelsCache, type OllamaModelInfo } from "../providers/ollamaModels"; import { fetchOpenRouterModels, type OpenRouterModelInfo } from "../providers/openrouterModels"; +import { fetchOrcaRouterModels, type OrcaRouterModelInfo } from "../providers/orcarouterModels"; import { getProviderDefinition, isEmbeddingProvider } from "../providers/index"; import type { EmbedModelConfig } from "../providers/index"; import type { ChatModel } from "../stores/chatStore.svelte"; @@ -64,6 +65,7 @@ export class AvailableModels { #plugin = getPlugin(); #modelsDevData = $state(null); #openRouterData = $state | null>(null); + #orcaRouterData = $state | null>(null); #metadataLoadStarted = false; constructor() { @@ -75,9 +77,14 @@ export class AvailableModels { return; } this.#metadataLoadStarted = true; - const [modelsDevData, openRouterData] = await Promise.all([fetchModelsDevData(), fetchOpenRouterModels()]); + const [modelsDevData, openRouterData, orcaRouterData] = await Promise.all([ + fetchModelsDevData(), + fetchOpenRouterModels(), + fetchOrcaRouterModels(), + ]); this.#modelsDevData = modelsDevData; this.#openRouterData = openRouterData; + this.#orcaRouterData = orcaRouterData; } #getOllamaData(): Map | null { @@ -188,6 +195,7 @@ export class AvailableModels { hydrateChatModel(model.provider, model.model, { modelsDevData: this.#modelsDevData, openRouterData: this.#openRouterData, + orcaRouterData: this.#orcaRouterData, ollamaData, temperature: model.modelConfig.temperature, }), @@ -208,6 +216,7 @@ export class AvailableModels { hydrateEmbeddingModel(model.provider, model.model, { modelsDevData: this.#modelsDevData, openRouterData: this.#openRouterData, + orcaRouterData: this.#orcaRouterData, ollamaData, similarityThresholdDefault: model.modelConfig.similarityThreshold, }), @@ -296,6 +305,10 @@ export class AvailableModels { return this.#openRouterData; } + get orcaRouterModels(): Map | null { + return this.#orcaRouterData; + } + getOllamaModelInfo(modelId: string): OllamaModelInfo | undefined { const ollamaData = this.#getOllamaData(); return ollamaData?.get(modelId); diff --git a/src/lib/modelMetadataNormalizer.ts b/src/lib/modelMetadataNormalizer.ts index b837d136..6f19624b 100644 --- a/src/lib/modelMetadataNormalizer.ts +++ b/src/lib/modelMetadataNormalizer.ts @@ -4,6 +4,7 @@ import { extractCapabilities as extractOpenRouterCapabilities, type OpenRouterModelInfo, } from "../providers/openrouterModels"; +import { extractOrcaRouterCapabilities, type OrcaRouterModelInfo } from "../providers/orcarouterModels"; import type { HydratedChatModelMetadata, HydratedEmbeddingModelMetadata } from "../types/modelMetadata"; const DEFAULT_CHAT_CONTEXT_WINDOW = 128000; @@ -12,6 +13,7 @@ const DEFAULT_SIMILARITY_THRESHOLD = 0.7; const DEFAULT_EMBED_MAX_INPUT_TOKENS_BY_PROVIDER: Record = { openai: 8191, openrouter: 8191, + orcarouter: 8191, ollama: 8191, anthropic: 8191, }; @@ -19,6 +21,7 @@ const DEFAULT_EMBED_MAX_INPUT_TOKENS_BY_PROVIDER: Record = { export interface ModelHydrationSourceData { modelsDevData?: ModelsDevApiResponse | null; openRouterData?: Map | null; + orcaRouterData?: Map | null; ollamaData?: Map | null; temperature?: number; similarityThresholdDefault?: number; @@ -83,19 +86,23 @@ function lookupMetadata( sourceData: ModelHydrationSourceData, ): { openRouter?: OpenRouterModelInfo; + orcaRouter?: OrcaRouterModelInfo; ollama?: OllamaModelInfo; modelsDev?: ReturnType; } { const openRouter = provider === "openrouter" && sourceData.openRouterData ? sourceData.openRouterData.get(variantKey) : undefined; + const orcaRouter = + provider === "orcarouter" && sourceData.orcaRouterData ? sourceData.orcaRouterData.get(variantKey) : undefined; + const ollama = provider === "ollama" && sourceData.ollamaData ? sourceData.ollamaData.get(variantKey) : undefined; const modelsDev = sourceData.modelsDevData ? lookupModelInfoSync(sourceData.modelsDevData, provider, variantKey) : null; - return { openRouter, ollama, modelsDev }; + return { openRouter, orcaRouter, ollama, modelsDev }; } export function hydrateChatModel( @@ -103,24 +110,32 @@ export function hydrateChatModel( variantKey: string, sourceData: ModelHydrationSourceData = {}, ): HydratedChatModelMetadata { - const { openRouter, ollama, modelsDev } = lookupMetadata(provider, variantKey, sourceData); + const { openRouter, orcaRouter, ollama, modelsDev } = lookupMetadata(provider, variantKey, sourceData); const paramSize = normalizeParamSize(ollama?.parameterSize); const quantization = ollama?.quantization; const displayName = buildDisplayName( provider, variantKey, - openRouter?.name || ollama?.name || modelsDev?.name, + openRouter?.name || orcaRouter?.name || ollama?.name || modelsDev?.name, paramSize, ); const contextWindow = - openRouter?.context_length || ollama?.contextLength || modelsDev?.limit?.context || DEFAULT_CHAT_CONTEXT_WINDOW; + openRouter?.context_length || + orcaRouter?.context_length || + ollama?.contextLength || + modelsDev?.limit?.context || + DEFAULT_CHAT_CONTEXT_WINDOW; - const inputUsdPer1M = toUsdPer1MFromPerToken(openRouter?.pricing?.prompt); - const outputUsdPer1M = toUsdPer1MFromPerToken(openRouter?.pricing?.completion); + const inputUsdPer1M = + toUsdPer1MFromPerToken(openRouter?.pricing?.prompt) ?? toUsdPer1MFromPerToken(orcaRouter?.pricing?.prompt); + const outputUsdPer1M = + toUsdPer1MFromPerToken(openRouter?.pricing?.completion) ?? + toUsdPer1MFromPerToken(orcaRouter?.pricing?.completion); const hasPricing = inputUsdPer1M !== undefined || outputUsdPer1M !== undefined; const openRouterCapabilities = openRouter ? extractOpenRouterCapabilities(openRouter) : undefined; + const orcaRouterCapabilities = orcaRouter ? extractOrcaRouterCapabilities(orcaRouter) : undefined; return { kind: "chat", @@ -133,12 +148,23 @@ export function hydrateChatModel( temperature: sourceData.temperature, capabilities: { toolCalls: - openRouterCapabilities?.supportsToolCalls ?? ollama?.supportsTools ?? modelsDev?.tool_call ?? undefined, + openRouterCapabilities?.supportsToolCalls ?? + orcaRouterCapabilities?.supportsToolCalls ?? + ollama?.supportsTools ?? + modelsDev?.tool_call ?? + undefined, vision: - openRouterCapabilities?.supportsVision ?? ollama?.supportsVision ?? modelsDev?.attachment ?? undefined, + openRouterCapabilities?.supportsVision ?? + orcaRouterCapabilities?.supportsVision ?? + ollama?.supportsVision ?? + modelsDev?.attachment ?? + undefined, reasoning: openRouterCapabilities?.supportsReasoning ?? modelsDev?.reasoning ?? undefined, structuredOutput: - openRouterCapabilities?.supportsStructuredOutput ?? modelsDev?.structured_output ?? undefined, + openRouterCapabilities?.supportsStructuredOutput ?? + orcaRouterCapabilities?.supportsStructuredOutput ?? + modelsDev?.structured_output ?? + undefined, }, pricing: hasPricing ? { inputUsdPer1M, outputUsdPer1M } : undefined, }; @@ -149,14 +175,14 @@ export function hydrateEmbeddingModel( variantKey: string, sourceData: ModelHydrationSourceData = {}, ): HydratedEmbeddingModelMetadata { - const { openRouter, ollama, modelsDev } = lookupMetadata(provider, variantKey, sourceData); + const { openRouter, orcaRouter, ollama, modelsDev } = lookupMetadata(provider, variantKey, sourceData); const paramSize = normalizeParamSize(ollama?.parameterSize); const quantization = ollama?.quantization; const displayName = buildDisplayName( provider, variantKey, - openRouter?.name || ollama?.name || modelsDev?.name, + openRouter?.name || orcaRouter?.name || ollama?.name || modelsDev?.name, paramSize, ); @@ -167,13 +193,15 @@ export function hydrateEmbeddingModel( const maxInputTokens = maxInputFromOpenRouter || + orcaRouter?.context_length || ollama?.contextLength || modelsDev?.limit?.input || modelsDev?.limit?.context || providerDefaultMaxInputTokens || DEFAULT_EMBEDDING_MAX_INPUT_TOKENS; - const inputUsdPer1M = toUsdPer1MFromPerToken(openRouter?.pricing?.prompt); + const inputUsdPer1M = + toUsdPer1MFromPerToken(openRouter?.pricing?.prompt) ?? toUsdPer1MFromPerToken(orcaRouter?.pricing?.prompt); return { kind: "embedding", diff --git a/src/lib/modelVendorClassification.ts b/src/lib/modelVendorClassification.ts index 437d025d..aec919a6 100644 --- a/src/lib/modelVendorClassification.ts +++ b/src/lib/modelVendorClassification.ts @@ -153,6 +153,10 @@ export function extractVendor( return model.model.split("/")[0]; } + if ((model.templateId === "orcarouter" || model.provider === "orcarouter") && model.model.includes("/")) { + return model.model.split("/")[0]; + } + if (model.templateId === "openai-codex" || model.provider === "openai") { return "openai"; } diff --git a/src/providers/index.ts b/src/providers/index.ts index 689bf154..bcbe4393 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -11,6 +11,7 @@ import { anthropicProvider } from "./anthropic"; import { ollamaProvider } from "./ollama"; import { openaiProvider } from "./openai"; import { openrouterProvider } from "./openrouter"; +import { orcarouterProvider } from "./orcarouter"; import { openAICodexProvider } from "./openai-codex"; import { createOmlxProvider } from "./omlx"; @@ -51,6 +52,11 @@ export const PROVIDER_TEMPLATES: readonly ProviderTemplateDefinition[] = [ displayName: "OpenRouter", description: "OpenRouter account and model catalog.", }, + { + id: "orcarouter", + displayName: "OrcaRouter", + description: "OrcaRouter gateway with 200+ frontier models.", + }, ] as const; export function getProviderTemplate(templateId: ProviderTemplateId): ProviderTemplateDefinition | undefined { @@ -97,6 +103,12 @@ function createTemplateDefinition( id: instanceId, displayName: meta.displayName, }; + case "orcarouter": + return { + ...orcarouterProvider, + id: instanceId, + displayName: meta.displayName, + }; default: return undefined; } @@ -167,3 +179,4 @@ export { anthropicProvider } from "./anthropic"; export { openaiProvider } from "./openai"; export { ollamaProvider } from "./ollama"; export { openrouterProvider } from "./openrouter"; +export { orcarouterProvider } from "./orcarouter"; diff --git a/src/providers/orcarouter.ts b/src/providers/orcarouter.ts new file mode 100644 index 00000000..7e739c7d --- /dev/null +++ b/src/providers/orcarouter.ts @@ -0,0 +1,331 @@ +/** + * OrcaRouter built-in provider definition + * + * [OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible gateway that + * routes to 200+ frontier models through a single endpoint + * (`https://api.orcarouter.ai/v1`). It also runs gateway-level, zero-trust + * security for AI agents on the same endpoint — screening every prompt/response + * and governing every tool call on a default-deny basis, with no application + * code changes. + * + * This provider supports: + * - Chat models (routed via `orcarouter/auto`, plus any vendor/model pair) + * - Embedding models (filtered by name heuristics) + * - Model discovery via the OrcaRouter API + * + * Authentication: apiKey (required) + */ + +import { ChatOpenAI } from "@langchain/openai"; +import { requestUrl } from "obsidian"; +import OrcaRouterLogo from "../components/ui/logos/OrcaRouterLogo.svelte"; +import type { + AuthObject, + AuthValidationResult, + EmbeddingProviderDefinition, + ChatModelConfig, +} from "../types/provider/index"; +import { + createBufferedTransportedChatOpenAI, + createTransportedChatOpenAI, + createTransportedOpenAIEmbeddings, +} from "./chatProviders"; +import { populateOrcaRouterCache, type OrcaRouterModelInfo } from "./orcarouterModels"; + +// ============================================================================= +// Constants +// ============================================================================= + +/** OrcaRouter API base URL */ +const ORCAROUTER_BASE_URL = "https://api.orcarouter.ai/v1"; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Safely reads response text, returning undefined on error. + */ +async function safeReadText(response: Response): Promise { + try { + return await response.text(); + } catch { + return undefined; + } +} + +// ============================================================================= +// API Response Types +// ============================================================================= + +interface OrcaRouterModelResponse { + data?: OrcaRouterModelInfo[]; +} + +// ============================================================================= +// Provider Definition +// ============================================================================= + +/** + * OrcaRouter built-in provider definition. + * + * Supports chat and embedding models. + */ +export const orcarouterProvider: EmbeddingProviderDefinition = { + // ========================================================================= + // Identity + // ========================================================================= + id: "orcarouter", + displayName: "OrcaRouter", + logo: OrcaRouterLogo, + + // ========================================================================= + // Setup Instructions + // ========================================================================= + setupInstructions: { + steps: ["Create an account at OrcaRouter", "Generate an API key from the Keys page", "Paste the API key below"], + link: { + url: "https://www.orcarouter.ai", + text: "OrcaRouter", + }, + }, + + // ========================================================================= + // Auth Configuration + // ========================================================================= + auth: { + apiKey: { + label: "API Key", + description: "Your OrcaRouter API key for authentication", + kind: "secret", + required: true, + placeholder: "sk-orca-...", + }, + headers: { + label: "Custom Headers", + description: "Additional headers as JSON (optional)", + kind: "textarea", + required: false, + placeholder: '{"X-Custom-Header": "value"}', + }, + }, + + // ========================================================================= + // Runtime Methods + // ========================================================================= + + createChatInstance: (auth: AuthObject, modelId: string, options?: Partial) => { + const config: Record = { + model: modelId, + apiKey: auth.apiKey, + configuration: { + baseURL: ORCAROUTER_BASE_URL, + }, + }; + + if (options?.temperature !== undefined) { + config.temperature = options.temperature; + } + + // Add custom headers if provided + if (auth.headers && Object.keys(auth.headers).length > 0) { + (config.configuration as Record).defaultHeaders = auth.headers; + } + + return createTransportedChatOpenAI("orcarouter", config as ConstructorParameters[0]); + }, + + createSubAgentChatInstance: (auth: AuthObject, modelId: string, options?: Partial) => { + const config: Record = { + model: modelId, + apiKey: auth.apiKey, + configuration: { baseURL: ORCAROUTER_BASE_URL }, + }; + if (options?.temperature !== undefined) { + config.temperature = options.temperature; + } + if (auth.headers && Object.keys(auth.headers).length > 0) { + (config.configuration as Record).defaultHeaders = auth.headers; + } + return createBufferedTransportedChatOpenAI("orcarouter", config as ConstructorParameters[0]); + }, + + createEmbeddingInstance: (auth: AuthObject, modelId: string) => { + const config: Record = { + model: modelId, + apiKey: auth.apiKey, + // OpenAI SDK v6 defaults to encoding_format:'base64' which many third-party + // providers don't support, causing malformed responses. Use 'float' explicitly. + encodingFormat: "float", + configuration: { + baseURL: ORCAROUTER_BASE_URL, + }, + }; + + // Add custom headers if provided + if (auth.headers && Object.keys(auth.headers).length > 0) { + (config.configuration as Record).defaultHeaders = auth.headers; + } + + return createTransportedOpenAIEmbeddings("orcarouter", config); + }, + + validateAuth: async (auth: AuthObject): Promise => { + if (!auth.apiKey?.trim()) { + return { valid: false, error: "API key is required" }; + } + + const headers: Record = { + Authorization: `Bearer ${auth.apiKey}`, + "Content-Type": "application/json", + }; + + // Add custom headers if provided + if (auth.headers) { + Object.assign(headers, auth.headers); + } + + try { + const response = await requestUrl({ + url: `${ORCAROUTER_BASE_URL}/models`, + method: "GET", + headers, + throw: false, + }); + + if (response.status >= 200 && response.status < 300) { + return { valid: true }; + } + + let errorMessage: string | undefined; + try { + const parsed = response.json as { error?: { message?: string }; message?: string }; + errorMessage = parsed?.error?.message ?? parsed?.message; + } catch { + // ignore parse errors + } + + if (response.status === 401 || response.status === 403) { + return { + valid: false, + error: errorMessage || `Authentication failed (${response.status})`, + }; + } + + return { + valid: false, + error: errorMessage || response.text || `Request failed with status ${response.status}`, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { valid: false, error: `Connection failed: ${message}` }; + } + }, + + discoverModels: async (auth: AuthObject): Promise => { + if (!auth.apiKey?.trim()) { + throw new Error("OrcaRouter model discovery requires an API key."); + } + + const headers: Record = { + Authorization: `Bearer ${auth.apiKey}`, + "Content-Type": "application/json", + }; + + // Add custom headers if provided + if (auth.headers) { + Object.assign(headers, auth.headers); + } + + const response = await globalThis.fetch(`${ORCAROUTER_BASE_URL}/models`, { + method: "GET", + headers, + }); + + if (!response.ok) { + const errorBody = await safeReadText(response); + throw new Error(`Model discovery failed: ${errorBody || response.statusText}`); + } + + const payload = (await response.json()) as OrcaRouterModelResponse; + const resources = Array.isArray(payload.data) ? payload.data : []; + + // Populate the metadata cache with full model info + populateOrcaRouterCache(resources); + + return resources.map((r) => r.id).filter((id): id is string => typeof id === "string" && id.trim() !== ""); + }, + + discoverEmbeddingModels: async (auth: AuthObject): Promise => { + if (!auth.apiKey?.trim()) { + throw new Error("OrcaRouter embedding model discovery requires an API key."); + } + + const headers: Record = { + Authorization: `Bearer ${auth.apiKey}`, + "Content-Type": "application/json", + }; + + // Add custom headers if provided + if (auth.headers) { + Object.assign(headers, auth.headers); + } + + try { + const response = await requestUrl({ + url: `${ORCAROUTER_BASE_URL}/models`, + method: "GET", + headers, + }); + + const payload = response.json as OrcaRouterModelResponse; + const resources = Array.isArray(payload.data) ? payload.data : []; + + // OrcaRouter exposes embedding-capable models with a supported_endpoint_types + // value of "embeddings". Filter on that first, falling back to name patterns. + const embeddingIds = resources + .filter((r) => { + const entry = r as { supported_endpoint_types?: string[] }; + return entry.supported_endpoint_types?.includes("embeddings"); + }) + .map((r) => r.id) + .filter((id): id is string => typeof id === "string" && id.trim() !== ""); + + if (embeddingIds.length > 0) { + return embeddingIds; + } + } catch { + // fall through to name-heuristic filtering + } + + // Fallback: filter all models by name patterns + const fallbackResponse = await globalThis.fetch(`${ORCAROUTER_BASE_URL}/models`, { + method: "GET", + headers, + }); + + if (!fallbackResponse.ok) { + const errorBody = await safeReadText(fallbackResponse); + throw new Error(`Embedding model discovery failed: ${errorBody || fallbackResponse.statusText}`); + } + + const payload = (await fallbackResponse.json()) as OrcaRouterModelResponse; + const resources = Array.isArray(payload.data) ? payload.data : []; + + return resources + .map((r) => r.id) + .filter((id): id is string => { + if (typeof id !== "string" || !id.trim()) return false; + const lower = id.toLowerCase(); + return ( + lower.includes("embed") || + lower.includes("bge-") || + lower.includes("voyage") || + lower.includes("e5-") || + lower.includes("gte-") || + lower.includes("nomic-") || + lower.includes("-embedding") + ); + }); + }, +}; diff --git a/src/providers/orcarouterModels.ts b/src/providers/orcarouterModels.ts new file mode 100644 index 00000000..222fc2f2 --- /dev/null +++ b/src/providers/orcarouterModels.ts @@ -0,0 +1,155 @@ +/** + * OrcaRouter Models API Integration + * + * Fetches and caches model metadata from OrcaRouter's API. + * Used for rich model information (context window, capabilities, endpoint types). + * + * @see https://api.orcarouter.ai/v1/models + */ + +const ORCAROUTER_MODELS_URL = "https://api.orcarouter.ai/v1/models"; +const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours + +/** + * Model info from the OrcaRouter models API. + */ +export interface OrcaRouterModelInfo { + /** Model ID (e.g., "orcarouter/auto", "anthropic/claude-opus-4-8") */ + id: string; + /** Display name */ + name?: string; + /** Model description */ + description?: string; + /** Context length in tokens */ + context_length?: number; + /** Max completion tokens */ + max_completion_tokens?: number; + /** Architecture details */ + architecture?: { + input_modalities?: string[]; + output_modalities?: string[] | null; + }; + /** Endpoint types the model supports (e.g. "openai", "openai-response", "anthropic", "gemini", "embeddings") */ + supported_endpoint_types?: string[]; + /** Pricing per token (string for precision) */ + pricing?: { + prompt?: string; + completion?: string; + }; +} + +/** + * OrcaRouter models API response + */ +interface OrcaRouterModelsResponse { + data: OrcaRouterModelInfo[]; +} + +/** + * Cached data structure + */ +interface CachedData { + models: Map; + timestamp: number; +} + +// Module-level cache +let cachedResponse: CachedData | null = null; + +/** + * Populates the cache with model data from an external source. + * Used by the OrcaRouter provider to avoid duplicate API calls. + */ +export function populateOrcaRouterCache(models: OrcaRouterModelInfo[]): void { + const modelMap = new Map(); + for (const model of models) { + if (model.id) { + modelMap.set(model.id, model); + } + } + cachedResponse = { + models: modelMap, + timestamp: Date.now(), + }; +} + +/** + * Checks if the cache is still valid. + */ +export function hasValidOrcaRouterCache(): boolean { + return cachedResponse !== null && Date.now() - cachedResponse.timestamp < CACHE_TTL_MS; +} + +/** + * Fetches and caches OrcaRouter models data. + * Uses the public endpoint (no auth required). + */ +export async function fetchOrcaRouterModels(): Promise | null> { + // Return cached data if still valid + if (cachedResponse && Date.now() - cachedResponse.timestamp < CACHE_TTL_MS) { + return cachedResponse.models; + } + + try { + const response = await globalThis.fetch(ORCAROUTER_MODELS_URL, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + console.warn(`Failed to fetch OrcaRouter models: ${response.status}`); + return cachedResponse?.models ?? null; + } + + const data = (await response.json()) as OrcaRouterModelsResponse; + + // Build map for fast lookups + const models = new Map(); + if (Array.isArray(data.data)) { + for (const model of data.data) { + if (model.id) { + models.set(model.id, model); + } + } + } + + cachedResponse = { + models, + timestamp: Date.now(), + }; + + return models; + } catch (error) { + console.warn("Failed to fetch OrcaRouter models:", error); + return cachedResponse?.models ?? null; + } +} + +/** + * Extracts capabilities from OrcaRouter model info. + */ +export function extractOrcaRouterCapabilities(info: OrcaRouterModelInfo): { + supportsToolCalls: boolean; + supportsVision: boolean; + supportsStructuredOutput: boolean; + supportsEmbedding: boolean; +} { + const endpoints = info.supported_endpoint_types ?? []; + const inputModalities = info.architecture?.input_modalities ?? []; + + return { + supportsToolCalls: endpoints.includes("openai") || endpoints.includes("openai-response"), + supportsVision: inputModalities.includes("image"), + supportsStructuredOutput: endpoints.includes("openai") || endpoints.includes("openai-response"), + supportsEmbedding: endpoints.includes("embeddings"), + }; +} + +/** + * Clears the cached data (useful for testing or forcing refresh) + */ +export function clearOrcaRouterCache(): void { + cachedResponse = null; +} diff --git a/src/types/provider/stored.ts b/src/types/provider/stored.ts index 486e367e..2aeb0e1b 100644 --- a/src/types/provider/stored.ts +++ b/src/types/provider/stored.ts @@ -14,7 +14,8 @@ export type ProviderTemplateId = | "anthropic" | "ollama" | "omlx" - | "openrouter"; + | "openrouter" + | "orcarouter"; /** * Persisted metadata for a configured provider instance. diff --git a/src/vectorstore/VectorStoreService.ts b/src/vectorstore/VectorStoreService.ts index a65e1a96..ba0f544a 100644 --- a/src/vectorstore/VectorStoreService.ts +++ b/src/vectorstore/VectorStoreService.ts @@ -14,6 +14,7 @@ import type SecondBrainPlugin from "../main"; import { fetchModelsDevData } from "../providers/modelsDevApi"; import { getOllamaModelsCache } from "../providers/ollamaModels"; import { fetchOpenRouterModels } from "../providers/openrouterModels"; +import { fetchOrcaRouterModels } from "../providers/orcarouterModels"; import { getRegistry } from "../providers/registry"; import { ensureProviderRegistered } from "../providers/registrySync"; import { getData } from "../stores/dataStore.svelte"; @@ -565,9 +566,10 @@ export class VectorStoreService { return inst.maxInputTokensCache.maxInputTokens; } - const [modelsDevData, openRouterData] = await Promise.all([ + const [modelsDevData, openRouterData, orcaRouterData] = await Promise.all([ fetchModelsDevData(), defaultModel.provider === "openrouter" ? fetchOpenRouterModels() : Promise.resolve(null), + defaultModel.provider === "orcarouter" ? fetchOrcaRouterModels() : Promise.resolve(null), ]); const ollamaData = @@ -582,6 +584,7 @@ export class VectorStoreService { const metadata = hydrateEmbeddingModel(defaultModel.provider, defaultModel.model, { modelsDevData, openRouterData, + orcaRouterData, ollamaData, }); diff --git a/src/vectorstore/batchSize.ts b/src/vectorstore/batchSize.ts index 85a5680c..d60fa0b9 100644 --- a/src/vectorstore/batchSize.ts +++ b/src/vectorstore/batchSize.ts @@ -5,6 +5,7 @@ export function getDefaultEmbeddingBatchSize(providerId: string): number { switch (providerId) { case "openai": case "openrouter": + case "orcarouter": return 100; case "omlx": case "ollama": diff --git a/src/views/provider-setup/ProviderSetup.svelte b/src/views/provider-setup/ProviderSetup.svelte index 7adbf544..70e4d6cd 100644 --- a/src/views/provider-setup/ProviderSetup.svelte +++ b/src/views/provider-setup/ProviderSetup.svelte @@ -13,6 +13,7 @@ import AnthropicLogo from "../../components/ui/logos/AnthropicLogo.svelte"; import OllamaLogo from "../../components/ui/logos/OllamaLogo.svelte"; import OpenAILogo from "../../components/ui/logos/OpenAILogo.svelte"; import OpenRouterLogo from "../../components/ui/logos/OpenRouterLogo.svelte"; +import OrcaRouterLogo from "../../components/ui/logos/OrcaRouterLogo.svelte"; import OmlxLogo from "../../components/ui/logos/OmlxLogo.svelte"; import { createAuthStateQuery, @@ -57,7 +58,15 @@ const isConfigured = $derived(data.isProviderConfigured(providerId)); // Picker display order: lead with the most-used providers so the grid scans fast. // Templates not listed fall to the end in their registry order. -const PICKER_ORDER: ProviderTemplateId[] = ["openai-compatible", "omlx", "openrouter", "ollama", "openai", "anthropic"]; +const PICKER_ORDER: ProviderTemplateId[] = [ + "openai-compatible", + "omlx", + "openrouter", + "orcarouter", + "ollama", + "openai", + "anthropic", +]; // oMLX is a macOS-native app (Apple Silicon), so its template is only offered on macOS. const providerTemplates = [...getAllProviderTemplates()] .filter((t) => t.id !== "omlx" || Platform.isMacOS) @@ -228,6 +237,7 @@ const TEMPLATE_LOGOS: Partial>> ollama: OllamaLogo, omlx: OmlxLogo, openrouter: OpenRouterLogo, + orcarouter: OrcaRouterLogo, }; function getTemplateLogo(id: ProviderTemplateId): Component { diff --git a/test/providers/orcarouter.test.ts b/test/providers/orcarouter.test.ts new file mode 100644 index 00000000..7e54d889 --- /dev/null +++ b/test/providers/orcarouter.test.ts @@ -0,0 +1,49 @@ +/** + * Tests for the OrcaRouter provider definition. + * + * Verifies the provider is registered as a template, exposes OpenAI-compatible + * chat/embedding instances pointed at the OrcaRouter API, and that its model + * discovery/embedding heuristics behave correctly. + */ +import { describe, expect, it } from "vitest"; +import { getAllProviderTemplates, getProviderDefinition, orcarouterProvider } from "../../src/providers/index.ts"; + +describe("OrcaRouter provider", () => { + it("registers as a provider template", () => { + const template = getAllProviderTemplates().find((t) => t.id === "orcarouter"); + expect(template).toBeDefined(); + expect(template?.displayName).toBe("OrcaRouter"); + }); + + it("returns the built-in provider definition", () => { + const meta = { orcarouter: { templateId: "orcarouter" as const, displayName: "OrcaRouter" } }; + const provider = getProviderDefinition("orcarouter", meta); + expect(provider).toBeDefined(); + expect(provider?.id).toBe("orcarouter"); + expect(provider?.displayName).toBe("OrcaRouter"); + }); + + it("exports the orcarouterProvider with required auth", () => { + expect(orcarouterProvider.id).toBe("orcarouter"); + expect(orcarouterProvider.auth.apiKey?.required).toBe(true); + expect(typeof orcarouterProvider.createChatInstance).toBe("function"); + expect(typeof orcarouterProvider.createEmbeddingInstance).toBe("function"); + expect(typeof orcarouterProvider.validateAuth).toBe("function"); + expect(typeof orcarouterProvider.discoverModels).toBe("function"); + }); + + it("validates a missing API key", async () => { + const result = await orcarouterProvider.validateAuth({}); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain("API key"); + } + }); + + it("creates an OpenAI-compatible chat instance", () => { + const model = orcarouterProvider.createChatInstance({ apiKey: "sk-orca-test" }, "orcarouter/auto"); + expect(model).toBeDefined(); + // ChatOpenAI-backed instance — should be a valid BaseChatModel. + expect(typeof model.invoke).toBe("function"); + }); +});