Skip to content

Commit ecaece4

Browse files
authored
Merge pull request coleam00#852 from coleam00/feature/openrouter-embeddings-support
Add OpenRouter Embeddings Support
2 parents 9bb1683 + b4b534b commit ecaece4

11 files changed

Lines changed: 637 additions & 37 deletions

File tree

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ PROD=false
124124
# Run the credentials_setup.sql file in your Supabase SQL editor to set up the credentials table.
125125
# Then use the Settings page in the web UI to manage:
126126
# - OPENAI_API_KEY (encrypted)
127-
# - MODEL_CHOICE
127+
# - OPENROUTER_API_KEY (encrypted, format: sk-or-v1-..., get from https://openrouter.ai/keys)
128+
# - MODEL_CHOICE
128129
# - TRANSPORT settings
129130
# - RAG strategy flags (USE_CONTEXTUAL_EMBEDDINGS, USE_HYBRID_SEARCH, etc.)
130131
# - Crawler settings:

archon-ui-main/src/components/settings/RAGSettings.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import OllamaModelSelectionModal from './OllamaModelSelectionModal';
1515
type ProviderKey = 'openai' | 'google' | 'ollama' | 'anthropic' | 'grok' | 'openrouter';
1616

1717
// Providers that support embedding models
18-
const EMBEDDING_CAPABLE_PROVIDERS: ProviderKey[] = ['openai', 'google', 'ollama'];
18+
const EMBEDDING_CAPABLE_PROVIDERS: ProviderKey[] = ['openai', 'google', 'openrouter', 'ollama'];
1919

2020
interface ProviderModels {
2121
chatModel: string;
@@ -42,7 +42,7 @@ const getDefaultModels = (provider: ProviderKey): ProviderModels => {
4242
anthropic: 'text-embedding-3-small', // Fallback to OpenAI
4343
google: 'text-embedding-004',
4444
grok: 'text-embedding-3-small', // Fallback to OpenAI
45-
openrouter: 'text-embedding-3-small',
45+
openrouter: 'openai/text-embedding-3-small', // MUST include provider prefix for OpenRouter
4646
ollama: 'nomic-embed-text'
4747
};
4848

@@ -1291,7 +1291,7 @@ const manualTestConnection = async (
12911291
Select {activeSelection === 'chat' ? 'Chat' : 'Embedding'} Provider
12921292
</label>
12931293
<div className={`grid gap-3 mb-4 ${
1294-
activeSelection === 'chat' ? 'grid-cols-6' : 'grid-cols-3'
1294+
activeSelection === 'chat' ? 'grid-cols-6' : 'grid-cols-4'
12951295
}`}>
12961296
{[
12971297
{ key: 'openai', name: 'OpenAI', logo: '/img/OpenAI.png', color: 'green' },
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/**
2+
* OpenRouter Service Client
3+
*
4+
* Provides frontend API client for OpenRouter model discovery.
5+
*/
6+
7+
import { getApiUrl } from "../config/api";
8+
9+
// Type definitions for OpenRouter API responses
10+
export interface OpenRouterEmbeddingModel {
11+
id: string;
12+
provider: string;
13+
name: string;
14+
dimensions: number;
15+
context_length: number;
16+
pricing_per_1m_tokens: number;
17+
supports_dimension_reduction: boolean;
18+
}
19+
20+
export interface OpenRouterModelListResponse {
21+
embedding_models: OpenRouterEmbeddingModel[];
22+
total_count: number;
23+
}
24+
25+
class OpenRouterService {
26+
private getBaseUrl = () => getApiUrl();
27+
private cacheKey = "openrouter_models_cache";
28+
private cacheTTL = 5 * 60 * 1000; // 5 minutes
29+
30+
private handleApiError(error: unknown, context: string): Error {
31+
const errorMessage = error instanceof Error ? error.message : String(error);
32+
33+
// Check for network errors
34+
if (
35+
errorMessage.toLowerCase().includes("network") ||
36+
errorMessage.includes("fetch") ||
37+
errorMessage.includes("Failed to fetch")
38+
) {
39+
return new Error(
40+
`Network error while ${context.toLowerCase()}: ${errorMessage}. ` +
41+
"Please check your connection.",
42+
);
43+
}
44+
45+
// Check for timeout errors
46+
if (errorMessage.includes("timeout") || errorMessage.includes("AbortError")) {
47+
return new Error(
48+
`Timeout error while ${context.toLowerCase()}: The server may be slow to respond.`,
49+
);
50+
}
51+
52+
// Return original error with context
53+
return new Error(`${context} failed: ${errorMessage}`);
54+
}
55+
56+
/**
57+
* Type guard to validate cache entry structure
58+
*/
59+
private isCacheEntry(
60+
value: unknown,
61+
): value is { data: OpenRouterModelListResponse; timestamp: number } {
62+
if (typeof value !== "object" || value === null) {
63+
return false;
64+
}
65+
66+
const obj = value as Record<string, unknown>;
67+
68+
// Validate timestamp is a number
69+
if (typeof obj.timestamp !== "number") {
70+
return false;
71+
}
72+
73+
// Validate data property exists and is an object
74+
if (typeof obj.data !== "object" || obj.data === null) {
75+
return false;
76+
}
77+
78+
const data = obj.data as Record<string, unknown>;
79+
80+
// Validate OpenRouterModelListResponse structure
81+
if (!Array.isArray(data.embedding_models)) {
82+
return false;
83+
}
84+
85+
if (typeof data.total_count !== "number") {
86+
return false;
87+
}
88+
89+
// Validate each model in the array has required fields
90+
for (const model of data.embedding_models) {
91+
if (typeof model !== "object" || model === null) {
92+
return false;
93+
}
94+
const m = model as Record<string, unknown>;
95+
if (
96+
typeof m.id !== "string" ||
97+
typeof m.provider !== "string" ||
98+
typeof m.name !== "string" ||
99+
typeof m.dimensions !== "number" ||
100+
typeof m.context_length !== "number" ||
101+
typeof m.pricing_per_1m_tokens !== "number" ||
102+
typeof m.supports_dimension_reduction !== "boolean"
103+
) {
104+
return false;
105+
}
106+
}
107+
108+
return true;
109+
}
110+
111+
/**
112+
* Get cached models if available and not expired
113+
*/
114+
private getCachedModels(): OpenRouterModelListResponse | null {
115+
try {
116+
const cached = sessionStorage.getItem(this.cacheKey);
117+
if (!cached) return null;
118+
119+
const parsed: unknown = JSON.parse(cached);
120+
121+
// Validate cache structure
122+
if (!this.isCacheEntry(parsed)) {
123+
// Cache is corrupted, remove it to avoid repeated failures
124+
sessionStorage.removeItem(this.cacheKey);
125+
return null;
126+
}
127+
128+
const now = Date.now();
129+
130+
// Check expiration
131+
if (now - parsed.timestamp > this.cacheTTL) {
132+
sessionStorage.removeItem(this.cacheKey);
133+
return null;
134+
}
135+
136+
return parsed.data;
137+
} catch {
138+
// JSON parsing failed or other error, clear cache
139+
sessionStorage.removeItem(this.cacheKey);
140+
return null;
141+
}
142+
}
143+
144+
/**
145+
* Cache models for the TTL duration
146+
*/
147+
private cacheModels(data: OpenRouterModelListResponse): void {
148+
try {
149+
const cacheData = {
150+
data,
151+
timestamp: Date.now(),
152+
};
153+
sessionStorage.setItem(this.cacheKey, JSON.stringify(cacheData));
154+
} catch {
155+
// Ignore cache errors
156+
}
157+
}
158+
159+
/**
160+
* Discover available OpenRouter embedding models
161+
*/
162+
async discoverModels(): Promise<OpenRouterModelListResponse> {
163+
try {
164+
// Check cache first
165+
const cached = this.getCachedModels();
166+
if (cached) {
167+
return cached;
168+
}
169+
170+
const response = await fetch(`${this.getBaseUrl()}/api/openrouter/models`, {
171+
method: "GET",
172+
headers: {
173+
"Content-Type": "application/json",
174+
},
175+
});
176+
177+
if (!response.ok) {
178+
const errorText = await response.text();
179+
throw new Error(`HTTP ${response.status}: ${errorText}`);
180+
}
181+
182+
const data = await response.json();
183+
184+
// Validate response structure
185+
if (!data.embedding_models || !Array.isArray(data.embedding_models)) {
186+
throw new Error("Invalid response structure: missing or invalid embedding_models array");
187+
}
188+
189+
if (typeof data.total_count !== "number" || data.total_count < 0) {
190+
throw new Error("Invalid response structure: total_count must be a non-negative number");
191+
}
192+
193+
if (data.total_count !== data.embedding_models.length) {
194+
throw new Error(
195+
`Response structure mismatch: total_count (${data.total_count}) does not match embedding_models length (${data.embedding_models.length})`,
196+
);
197+
}
198+
199+
// Validate at least one model has required fields
200+
if (data.embedding_models.length > 0) {
201+
const firstModel = data.embedding_models[0];
202+
if (
203+
!firstModel.id ||
204+
typeof firstModel.id !== "string" ||
205+
!firstModel.provider ||
206+
typeof firstModel.provider !== "string" ||
207+
typeof firstModel.dimensions !== "number" ||
208+
firstModel.dimensions <= 0
209+
) {
210+
throw new Error(
211+
"Invalid model structure: models must have id (string), provider (string), and positive dimensions",
212+
);
213+
}
214+
215+
// Validate provider name is from expected set
216+
const validProviders = ["openai", "google", "qwen", "mistralai"];
217+
if (!validProviders.includes(firstModel.provider)) {
218+
throw new Error(`Invalid provider name: ${firstModel.provider}`);
219+
}
220+
}
221+
222+
// Cache the successful response
223+
this.cacheModels(data);
224+
225+
return data;
226+
} catch (error) {
227+
throw this.handleApiError(error, "Model discovery");
228+
}
229+
}
230+
231+
/**
232+
* Clear the models cache
233+
*/
234+
clearCache(): void {
235+
try {
236+
sessionStorage.removeItem(this.cacheKey);
237+
} catch {
238+
// Ignore cache clearing errors
239+
}
240+
}
241+
}
242+
243+
// Export singleton instance
244+
export const openrouterService = new OpenRouterService();
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
OpenRouter API routes.
3+
4+
Endpoints for OpenRouter model discovery and configuration.
5+
"""
6+
7+
from fastapi import APIRouter
8+
9+
from ..services.openrouter_discovery_service import OpenRouterModelListResponse, openrouter_discovery_service
10+
11+
router = APIRouter(prefix="/api/openrouter", tags=["openrouter"])
12+
13+
14+
@router.get("/models", response_model=OpenRouterModelListResponse)
15+
async def get_openrouter_models() -> OpenRouterModelListResponse:
16+
"""
17+
Get available OpenRouter embedding models.
18+
19+
Returns a list of embedding models available through OpenRouter,
20+
including models from OpenAI, Google, Qwen, and Mistral providers.
21+
22+
Returns:
23+
OpenRouterModelListResponse: List of embedding models with metadata
24+
"""
25+
models = await openrouter_discovery_service.discover_embedding_models()
26+
27+
return OpenRouterModelListResponse(embedding_models=models, total_count=len(models))

python/src/server/config/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ def validate_openai_api_key(api_key: str) -> bool:
6666
return True
6767

6868

69+
def validate_openrouter_api_key(api_key: str) -> bool:
70+
"""Validate OpenRouter API key format."""
71+
if not api_key:
72+
raise ConfigurationError("OpenRouter API key cannot be empty")
73+
74+
if not api_key.startswith("sk-or-v1-"):
75+
raise ConfigurationError(
76+
"OpenRouter API key must start with 'sk-or-v1-'. " "Get your key at https://openrouter.ai/keys"
77+
)
78+
79+
return True
80+
81+
6982
def validate_supabase_key(supabase_key: str) -> tuple[bool, str]:
7083
"""Validate Supabase key type and return validation result.
7184

python/src/server/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from .api_routes.mcp_api import router as mcp_router
2727
from .api_routes.migration_api import router as migration_router
2828
from .api_routes.ollama_api import router as ollama_router
29+
from .api_routes.openrouter_api import router as openrouter_router
2930
from .api_routes.pages_api import router as pages_router
3031
from .api_routes.progress_api import router as progress_router
3132
from .api_routes.projects_api import router as projects_router
@@ -187,6 +188,7 @@ async def skip_health_check_logs(request, call_next):
187188
app.include_router(knowledge_router)
188189
app.include_router(pages_router)
189190
app.include_router(ollama_router)
191+
app.include_router(openrouter_router)
190192
app.include_router(projects_router)
191193
app.include_router(progress_router)
192194
app.include_router(agent_chat_router)

python/src/server/services/credential_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ async def get_active_provider(self, service_type: str = "llm") -> dict[str, Any]
443443
explicit_embedding_provider = rag_settings.get("EMBEDDING_PROVIDER")
444444

445445
# Validate that embedding provider actually supports embeddings
446-
embedding_capable_providers = {"openai", "google", "ollama"}
446+
embedding_capable_providers = {"openai", "google", "openrouter", "ollama"}
447447

448448
if (explicit_embedding_provider and
449449
explicit_embedding_provider != "" and

0 commit comments

Comments
 (0)