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");
+ });
+});