Browse Source
feat(jina): add multi-key failover support for embedding, reranking and wiki sync
master
feat(jina): add multi-key failover support for embedding, reranking and wiki sync
master
6 changed files with 259 additions and 160 deletions
-
3config/production.env
-
36src/knowledge/embedding_factory.py
-
163src/knowledge/multi_jina_embedder.py
-
37src/knowledge/sync_wiki.py
-
21src/utils/jina_keys.py
-
153src/utils/reranker.py
@ -1,67 +1,55 @@ |
|||||
import yaml |
import yaml |
||||
import os |
import os |
||||
from typing import Optional |
from typing import Optional |
||||
from agno.knowledge.embedder.openai import OpenAIEmbedder |
|
||||
from agno.knowledge.embedder.jina import JinaEmbedder |
|
||||
from pathlib import Path |
from pathlib import Path |
||||
# If Agno supports generic OpenAI-like embedders, we use OpenAIEmbedder with base_url |
|
||||
|
from agno.knowledge.embedder.openai import OpenAIEmbedder |
||||
|
from src.knowledge.multi_jina_embedder import MultiKeyJinaEmbedder |
||||
|
from src.utils.jina_keys import get_jina_keys |
||||
|
|
||||
class EmbeddingFactory: |
class EmbeddingFactory: |
||||
def __init__(self): |
def __init__(self): |
||||
# Get the directory where this file (factory.py) is located |
|
||||
current_file_path = Path(__file__).resolve() |
current_file_path = Path(__file__).resolve() |
||||
|
|
||||
# Navigate up to the project root |
|
||||
# If structure is: /app/src/models/factory.py |
|
||||
# .parent = models, .parent = src, .parent = app (root) |
|
||||
project_root = current_file_path.parent.parent.parent |
project_root = current_file_path.parent.parent.parent |
||||
|
|
||||
# Construct the absolute path |
|
||||
config_path = project_root / 'config' / 'embeddings.yaml' |
config_path = project_root / 'config' / 'embeddings.yaml' |
||||
|
|
||||
print(f"Loading config from: {config_path}") # Debug log |
|
||||
with open(config_path) as f: |
|
||||
# Simple variable expansion for ${VAR} |
|
||||
|
print(f"Loading config from: {config_path}") |
||||
|
with open(config_path, "r", encoding="utf-8") as f: |
||||
content = f.read() |
content = f.read() |
||||
for key, val in os.environ.items(): |
for key, val in os.environ.items(): |
||||
content = content.replace(f"${{{key}}}", val) |
content = content.replace(f"${{{key}}}", val) |
||||
self.config = yaml.safe_load(content) |
self.config = yaml.safe_load(content) |
||||
|
|
||||
def get_embedder(self, model_name: Optional[str] = None): |
def get_embedder(self, model_name: Optional[str] = None): |
||||
# 1. Default Logic |
|
||||
if model_name is None: |
if model_name is None: |
||||
model_name = self.config['embeddings']['default'] |
model_name = self.config['embeddings']['default'] |
||||
|
|
||||
|
|
||||
|
|
||||
models_config = self.config['embeddings']['models'] |
models_config = self.config['embeddings']['models'] |
||||
if model_name not in models_config: |
if model_name not in models_config: |
||||
raise ValueError(f"Embedding model '{model_name}' not found in config.") |
raise ValueError(f"Embedding model '{model_name}' not found in config.") |
||||
|
|
||||
config = models_config[model_name] |
config = models_config[model_name] |
||||
provider = config['provider'] |
provider = config['provider'] |
||||
# # 2. Provider Logic |
|
||||
api_key_env = config.get('api_key') |
api_key_env = config.get('api_key') |
||||
if api_key_env and api_key_env.startswith("${"): |
|
||||
|
if api_key_env and str(api_key_env).startswith("${"): |
||||
api_key = os.getenv(api_key_env[2:-1]) |
api_key = os.getenv(api_key_env[2:-1]) |
||||
else: |
else: |
||||
api_key = api_key_env |
api_key = api_key_env |
||||
|
|
||||
# CASE B: OpenAI (Official) |
|
||||
if provider == "openai": |
if provider == "openai": |
||||
return OpenAIEmbedder( |
return OpenAIEmbedder( |
||||
id=config['id'], |
id=config['id'], |
||||
dimensions=config['dimensions'], |
dimensions=config['dimensions'], |
||||
api_key=api_key |
api_key=api_key |
||||
) |
) |
||||
|
|
||||
# CASE C: OpenAI Compatible (Jina API, etc.) |
|
||||
elif provider == "jinaai": |
elif provider == "jinaai": |
||||
return JinaEmbedder( |
|
||||
|
all_keys = get_jina_keys() |
||||
|
if api_key and api_key not in all_keys: |
||||
|
all_keys.insert(0, api_key) |
||||
|
return MultiKeyJinaEmbedder( |
||||
id=config['id'], |
id=config['id'], |
||||
dimensions=config['dimensions'], |
dimensions=config['dimensions'], |
||||
api_key=api_key |
|
||||
|
api_keys=all_keys, |
||||
|
api_key=all_keys[0] if all_keys else None |
||||
) |
) |
||||
|
|
||||
print(f"Unknown provider type: {provider}") |
|
||||
raise ValueError(f"Unknown provider type: {provider}") |
raise ValueError(f"Unknown provider type: {provider}") |
||||
@ -0,0 +1,163 @@ |
|||||
|
import os |
||||
|
import requests |
||||
|
import aiohttp |
||||
|
import logging |
||||
|
from dataclasses import dataclass, field |
||||
|
from typing import List, Optional, Dict, Any, Tuple |
||||
|
from agno.knowledge.embedder.jina import JinaEmbedder |
||||
|
from src.utils.jina_keys import get_jina_keys |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
@dataclass |
||||
|
class MultiKeyJinaEmbedder(JinaEmbedder): |
||||
|
""" |
||||
|
JinaEmbedder subclass that supports multiple API keys with automatic failover/rotation. |
||||
|
If a key hits balance limit (402), forbidden (403), unauthorized (401), or rate limit (429), |
||||
|
it automatically switches to the next available key and retries. |
||||
|
""" |
||||
|
api_keys: List[str] = field(default_factory=get_jina_keys) |
||||
|
_current_key_idx: int = field(default=0, init=False, repr=False) |
||||
|
|
||||
|
def __post_init__(self): |
||||
|
if not self.api_keys: |
||||
|
self.api_keys = get_jina_keys() |
||||
|
if self.api_key and self.api_key not in self.api_keys: |
||||
|
self.api_keys.insert(0, self.api_key) |
||||
|
if self.api_keys: |
||||
|
self.api_key = self.api_keys[0] |
||||
|
|
||||
|
def _get_active_key(self) -> str: |
||||
|
if not self.api_keys: |
||||
|
if self.api_key: |
||||
|
return self.api_key |
||||
|
raise ValueError("No Jina API keys provided in JINA_API_KEYS or JINA_API_KEY") |
||||
|
return self.api_keys[self._current_key_idx % len(self.api_keys)] |
||||
|
|
||||
|
def _rotate_key(self) -> str: |
||||
|
if len(self.api_keys) > 1: |
||||
|
prev = self._get_active_key() |
||||
|
self._current_key_idx = (self._current_key_idx + 1) % len(self.api_keys) |
||||
|
new_key = self._get_active_key() |
||||
|
self.api_key = new_key |
||||
|
logger.warning(f"🔄 Rotating Jina API Key from {prev[:12]}... to {new_key[:12]}...") |
||||
|
return new_key |
||||
|
return self._get_active_key() |
||||
|
|
||||
|
def _get_headers_for_key(self, key: str) -> Dict[str, str]: |
||||
|
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {key}"} |
||||
|
if self.headers: |
||||
|
headers.update(self.headers) |
||||
|
return headers |
||||
|
|
||||
|
def _response(self, text: str) -> Dict[str, Any]: |
||||
|
data = { |
||||
|
"model": self.id, |
||||
|
"late_chunking": self.late_chunking, |
||||
|
"dimensions": self.dimensions, |
||||
|
"embedding_type": self.embedding_type, |
||||
|
"input": [text], |
||||
|
} |
||||
|
if self.user is not None: |
||||
|
data["user"] = self.user |
||||
|
if self.request_params: |
||||
|
data.update(self.request_params) |
||||
|
|
||||
|
keys_to_try = max(len(self.api_keys), 1) |
||||
|
last_exception = None |
||||
|
|
||||
|
for _ in range(keys_to_try): |
||||
|
key = self._get_active_key() |
||||
|
try: |
||||
|
headers = self._get_headers_for_key(key) |
||||
|
response = requests.post(self.base_url, headers=headers, json=data, timeout=self.timeout or 30.0) |
||||
|
if response.status_code in (401, 402, 403, 429): |
||||
|
logger.warning(f"⚠️ Jina key {key[:12]}... failed with HTTP {response.status_code}: {response.text[:120]}") |
||||
|
self._rotate_key() |
||||
|
continue |
||||
|
response.raise_for_status() |
||||
|
return response.json() |
||||
|
except Exception as e: |
||||
|
last_exception = e |
||||
|
logger.warning(f"⚠️ Exception with Jina key {key[:12]}...: {e}. Trying next key...") |
||||
|
self._rotate_key() |
||||
|
|
||||
|
if last_exception: |
||||
|
raise last_exception |
||||
|
raise RuntimeError("All Jina API keys failed") |
||||
|
|
||||
|
async def _async_response(self, text: str) -> Dict[str, Any]: |
||||
|
data = { |
||||
|
"model": self.id, |
||||
|
"late_chunking": self.late_chunking, |
||||
|
"dimensions": self.dimensions, |
||||
|
"embedding_type": self.embedding_type, |
||||
|
"input": [text], |
||||
|
} |
||||
|
if self.user is not None: |
||||
|
data["user"] = self.user |
||||
|
if self.request_params: |
||||
|
data.update(self.request_params) |
||||
|
|
||||
|
timeout = aiohttp.ClientTimeout(total=self.timeout or 30.0) |
||||
|
keys_to_try = max(len(self.api_keys), 1) |
||||
|
last_exception = None |
||||
|
|
||||
|
for _ in range(keys_to_try): |
||||
|
key = self._get_active_key() |
||||
|
try: |
||||
|
headers = self._get_headers_for_key(key) |
||||
|
async with aiohttp.ClientSession(timeout=timeout) as session: |
||||
|
async with session.post(self.base_url, headers=headers, json=data) as response: |
||||
|
if response.status in (401, 402, 403, 429): |
||||
|
logger.warning(f"⚠️ Async Jina key {key[:12]}... failed with HTTP {response.status}") |
||||
|
self._rotate_key() |
||||
|
continue |
||||
|
response.raise_for_status() |
||||
|
return await response.json() |
||||
|
except Exception as e: |
||||
|
last_exception = e |
||||
|
logger.warning(f"⚠️ Async Jina exception with key {key[:12]}...: {e}. Trying next key...") |
||||
|
self._rotate_key() |
||||
|
|
||||
|
if last_exception: |
||||
|
raise last_exception |
||||
|
raise RuntimeError("All Jina API keys failed in async request") |
||||
|
|
||||
|
async def _async_batch_response(self, texts: List[str]) -> Dict[str, Any]: |
||||
|
data = { |
||||
|
"model": self.id, |
||||
|
"late_chunking": self.late_chunking, |
||||
|
"dimensions": self.dimensions, |
||||
|
"embedding_type": self.embedding_type, |
||||
|
"input": texts, |
||||
|
} |
||||
|
if self.user is not None: |
||||
|
data["user"] = self.user |
||||
|
if self.request_params: |
||||
|
data.update(self.request_params) |
||||
|
|
||||
|
timeout = aiohttp.ClientTimeout(total=self.timeout or 60.0) |
||||
|
keys_to_try = max(len(self.api_keys), 1) |
||||
|
last_exception = None |
||||
|
|
||||
|
for _ in range(keys_to_try): |
||||
|
key = self._get_active_key() |
||||
|
try: |
||||
|
headers = self._get_headers_for_key(key) |
||||
|
async with aiohttp.ClientSession(timeout=timeout) as session: |
||||
|
async with session.post(self.base_url, headers=headers, json=data) as response: |
||||
|
if response.status in (401, 402, 403, 429): |
||||
|
logger.warning(f"⚠️ Async batch Jina key {key[:12]}... failed with HTTP {response.status}") |
||||
|
self._rotate_key() |
||||
|
continue |
||||
|
response.raise_for_status() |
||||
|
return await response.json() |
||||
|
except Exception as e: |
||||
|
last_exception = e |
||||
|
logger.warning(f"⚠️ Async batch Jina exception with key {key[:12]}...: {e}. Trying next key...") |
||||
|
self._rotate_key() |
||||
|
|
||||
|
if last_exception: |
||||
|
raise last_exception |
||||
|
raise RuntimeError("All Jina API keys failed in async batch request") |
||||
@ -0,0 +1,21 @@ |
|||||
|
import os |
||||
|
from typing import List |
||||
|
|
||||
|
def get_jina_keys() -> List[str]: |
||||
|
""" |
||||
|
Returns a list of Jina API keys from environment variables. |
||||
|
Supports both JINA_API_KEYS (comma-separated) and JINA_API_KEY. |
||||
|
""" |
||||
|
keys = [] |
||||
|
keys_env = os.getenv("JINA_API_KEYS", "") |
||||
|
if keys_env: |
||||
|
for k in keys_env.split(","): |
||||
|
cleaned = k.strip() |
||||
|
if cleaned and cleaned not in keys: |
||||
|
keys.append(cleaned) |
||||
|
|
||||
|
single_key = os.getenv("JINA_API_KEY", "").strip() |
||||
|
if single_key and single_key not in keys: |
||||
|
keys.append(single_key) |
||||
|
|
||||
|
return keys |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue