light-infer-chat commited on
Commit
2ebf97a
·
1 Parent(s): f02b0c0
app/api/server.py CHANGED
@@ -10,6 +10,8 @@ from fastapi.middleware.gzip import GZipMiddleware
10
  from app.config import get_settings
11
  from app.core.database import pool_manager
12
  from app.core.logger import get_logger
 
 
13
  from app.services.embeddings_service import EmbeddingService
14
  from app.api.v1.router import api_v1_router
15
 
@@ -43,9 +45,19 @@ async def lifespan(app: FastAPI):
43
  # await loop.run_in_executor(None, _embedding_service.load_vision_model) # DISABLED (OOM mitigation)
44
  _logger.info("Embedding service initialized with dims: %s", _embedding_service.loaded_dimensions)
45
 
 
 
 
 
 
 
 
 
 
46
  asyncio.create_task(_self_ping())
47
  yield
48
- _logger.info("Shutting down database connection pools...")
 
49
  await pool_manager.close_all()
50
 
51
 
 
10
  from app.config import get_settings
11
  from app.core.database import pool_manager
12
  from app.core.logger import get_logger
13
+ from app.core.redis_client import create_redis_client, close_redis
14
+ from app.core.scripts import load_scripts
15
  from app.services.embeddings_service import EmbeddingService
16
  from app.api.v1.router import api_v1_router
17
 
 
45
  # await loop.run_in_executor(None, _embedding_service.load_vision_model) # DISABLED (OOM mitigation)
46
  _logger.info("Embedding service initialized with dims: %s", _embedding_service.loaded_dimensions)
47
 
48
+ redis = create_redis_client(_settings.redis_url) if _settings.redis_url else None
49
+ scripts = await load_scripts(redis) if redis else {}
50
+ app.state.redis = redis
51
+ app.state.scripts = scripts
52
+ if redis:
53
+ _logger.info("Redis and Lua scripts initialized")
54
+ else:
55
+ _logger.warning("Redis not configured, running in degraded mode")
56
+
57
  asyncio.create_task(_self_ping())
58
  yield
59
+ _logger.info("Shutting down...")
60
+ await close_redis(redis)
61
  await pool_manager.close_all()
62
 
63
 
app/api/v1/chat.py CHANGED
@@ -1,13 +1,14 @@
1
  from __future__ import annotations
2
 
3
- from typing import Any, Dict
4
 
5
- from fastapi import APIRouter, Depends, HTTPException
6
 
7
  from app.api.deps import require_auth
8
  from app.services.chat_service import MODEL_MAP, chat_completion
9
 
10
  VALID_MODELS = list(MODEL_MAP.keys())
 
11
 
12
  router = APIRouter()
13
 
@@ -15,20 +16,47 @@ router = APIRouter()
15
  @router.post("/chat/completions")
16
  async def create_chat_completion(
17
  body: Dict[str, Any],
 
18
  token: str = Depends(require_auth),
19
  ) -> Dict[str, Any]:
20
- messages = body.get("messages")
21
  if not messages or not isinstance(messages, list):
22
- raise HTTPException(status_code=400, detail="messages is required and must be a list")
23
-
24
- model = body.get("model")
25
- if not model or not isinstance(model, str):
26
- raise HTTPException(status_code=400, detail="model is required")
27
- if model not in VALID_MODELS:
28
- raise HTTPException(
29
- status_code=400,
30
- detail=f"Invalid model '{model}'. Must be one of: {', '.join(VALID_MODELS)}",
31
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  max_tokens = body.get("max_tokens", 1024)
34
  temperature = body.get("temperature", 0.7)
@@ -36,15 +64,26 @@ async def create_chat_completion(
36
 
37
  response_format = body.get("response_format", None)
38
 
 
 
 
39
  try:
40
  result = await chat_completion(
41
  messages=messages,
42
  model=model,
 
43
  response_format=response_format,
44
  max_tokens=max_tokens,
45
  temperature=temperature,
46
  top_p=top_p,
 
 
47
  )
48
  return result
49
  except RuntimeError as e:
 
 
 
 
 
50
  raise HTTPException(status_code=502, detail=str(e))
 
1
  from __future__ import annotations
2
 
3
+ from typing import Any, Dict, List, Optional
4
 
5
+ from fastapi import APIRouter, Depends, HTTPException, Request
6
 
7
  from app.api.deps import require_auth
8
  from app.services.chat_service import MODEL_MAP, chat_completion
9
 
10
  VALID_MODELS = list(MODEL_MAP.keys())
11
+ VALID_PROVIDERS = ["openprovider", "meganova", "aionlabs"]
12
 
13
  router = APIRouter()
14
 
 
16
  @router.post("/chat/completions")
17
  async def create_chat_completion(
18
  body: Dict[str, Any],
19
+ request: Request,
20
  token: str = Depends(require_auth),
21
  ) -> Dict[str, Any]:
22
+ messages: Optional[List[Dict[str, str]]] = body.get("messages")
23
  if not messages or not isinstance(messages, list):
24
+ raise HTTPException(status_code=400, detail="messages is required and must be a non-empty array")
25
+
26
+ for msg in messages:
27
+ if not isinstance(msg, dict):
28
+ raise HTTPException(status_code=400, detail="Each message must be an object")
29
+ if not isinstance(msg.get("role"), str) or not isinstance(msg.get("content"), str):
30
+ raise HTTPException(status_code=400, detail="Each message must have string role and content fields")
31
+ if msg["role"] not in ("system", "user", "assistant"):
32
+ raise HTTPException(status_code=400, detail=f"Invalid role '{msg['role']}'. Must be system, user, or assistant")
33
+
34
+ model: Any = body.get("model")
35
+ if model is not None:
36
+ if not isinstance(model, str) or model not in VALID_MODELS:
37
+ raise HTTPException(
38
+ status_code=400,
39
+ detail=f"Invalid model '{model}'. Must be one of: {', '.join(VALID_MODELS)}",
40
+ )
41
+
42
+ provider: Any = body.get("provider")
43
+ if provider is not None:
44
+ if not isinstance(provider, str) or provider not in VALID_PROVIDERS:
45
+ raise HTTPException(
46
+ status_code=400,
47
+ detail=f"Invalid provider '{provider}'",
48
+ )
49
+
50
+ return_json: Any = body.get("return_json")
51
+ if return_json is not None and not isinstance(return_json, bool):
52
+ raise HTTPException(status_code=400, detail="return_json must be a boolean")
53
+
54
+ stream: Any = body.get("stream", False)
55
+ if not isinstance(stream, bool):
56
+ raise HTTPException(status_code=400, detail="stream must be a boolean")
57
+
58
+ if stream and return_json:
59
+ raise HTTPException(status_code=400, detail="stream and return_json cannot both be true")
60
 
61
  max_tokens = body.get("max_tokens", 1024)
62
  temperature = body.get("temperature", 0.7)
 
64
 
65
  response_format = body.get("response_format", None)
66
 
67
+ redis = getattr(request.app.state, "redis", None)
68
+ scripts = getattr(request.app.state, "scripts", None)
69
+
70
  try:
71
  result = await chat_completion(
72
  messages=messages,
73
  model=model,
74
+ provider=provider,
75
  response_format=response_format,
76
  max_tokens=max_tokens,
77
  temperature=temperature,
78
  top_p=top_p,
79
+ redis=redis,
80
+ scripts=scripts,
81
  )
82
  return result
83
  except RuntimeError as e:
84
+ if "All AI providers exhausted" in str(e) or "All API keys are locked" in str(e):
85
+ raise HTTPException(
86
+ status_code=503,
87
+ detail="All API keys are currently locked. Retry after a few minutes.",
88
+ )
89
  raise HTTPException(status_code=502, detail=str(e))
app/config.py CHANGED
@@ -56,13 +56,42 @@ class Settings(BaseSettings):
56
  openrouter_mimika_api_key: Optional[str] = None
57
  aion_lab_keys: str = ""
58
  ai_api_keys: str = ""
 
59
  request_timeout_ms: int = 60000
 
 
60
 
61
  @property
62
  def max_upload_mb(self) -> int:
63
  return self.max_upload_bytes // (1024 * 1024)
64
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  @lru_cache(maxsize=1)
67
  def get_settings() -> Settings:
68
  return Settings()
 
56
  openrouter_mimika_api_key: Optional[str] = None
57
  aion_lab_keys: str = ""
58
  ai_api_keys: str = ""
59
+ proxy_auth_key: str = "changeme"
60
  request_timeout_ms: int = 60000
61
+ key_lock_ttl: int = 600
62
+ rounds_per_model: int = 50
63
 
64
  @property
65
  def max_upload_mb(self) -> int:
66
  return self.max_upload_bytes // (1024 * 1024)
67
 
68
 
69
+ MODELS = [
70
+ "mistralai/Mistral-Small-3.2-24B-Instruct-2506",
71
+ "meganova-ai/manta-flash-1.0",
72
+ "meganova-ai/manta-mini-1.0",
73
+ "FallenMerick/MN-Violet-Lotus-12B",
74
+ "Sao10K/L3-70B-Euryale-v2.1",
75
+ "Sao10K/L3-8B-Stheno-v3.2",
76
+ ]
77
+
78
+ MEGANOVA_BASE_URL = "https://api.meganova.ai"
79
+ MEGANOVA_CHAT_PATH = "/v1/chat/completions"
80
+
81
+ OPENROUTER_MIMIKA_BASE_URL = "https://openprovider.mimika.in"
82
+ OPENROUTER_MIMIKA_CHAT_PATH = "/v1/chat/completions"
83
+ OPENROUTER_MIMIKA_MODEL = "openprovider/auto-free"
84
+
85
+ AION_LABS_BASE_URL = "https://api.aionlabs.ai"
86
+ AION_LABS_CHAT_PATH = "/v1/chat/completions"
87
+ AION_LABS_DEFAULT_MODEL = "aion-labs/aion-2.5"
88
+
89
+ DEFAULT_MAX_TOKENS = 1024
90
+ DEFAULT_TEMPERATURE = 0.7
91
+ DEFAULT_TOP_P = 0.9
92
+ DEFAULT_STREAM = False
93
+
94
+
95
  @lru_cache(maxsize=1)
96
  def get_settings() -> Settings:
97
  return Settings()
app/core/redis_client.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from redis.asyncio import Redis
7
+
8
+ from app.config import get_settings
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def create_redis_client(redis_url: str) -> Optional[Redis]:
14
+ if not redis_url:
15
+ logger.warning("REDIS_URL not configured, Redis features disabled")
16
+ return None
17
+ logger.info("Connecting to Redis at %s", redis_url.split("@")[-1] if "@" in redis_url else redis_url)
18
+ return Redis.from_url(
19
+ redis_url,
20
+ max_connections=10,
21
+ socket_connect_timeout=5,
22
+ socket_timeout=5,
23
+ retry_on_timeout=True,
24
+ decode_responses=True,
25
+ )
26
+
27
+
28
+ async def close_redis(redis: Optional[Redis]) -> None:
29
+ if redis:
30
+ await redis.aclose()
31
+ logger.info("Redis connection closed")
app/core/scripts.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Dict, Optional
6
+
7
+ from redis.asyncio import Redis
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ LUA_DIR = Path(__file__).resolve().parent.parent.parent / "lua"
12
+
13
+
14
+ def load_lua(filename: str) -> str:
15
+ path = LUA_DIR / filename
16
+ return path.read_text(encoding="utf-8")
17
+
18
+
19
+ async def load_scripts(redis: Optional[Redis]) -> Dict[str, str]:
20
+ if not redis:
21
+ logger.warning("Redis not available, Lua scripts not loaded")
22
+ return {}
23
+ acquire_slot_sha = await redis.script_load(load_lua("acquire_slot.lua"))
24
+ lock_key_sha = await redis.script_load(load_lua("lock_key.lua"))
25
+ logger.info("Lua scripts loaded")
26
+ return {
27
+ "acquire_slot_sha": acquire_slot_sha,
28
+ "lock_key_sha": lock_key_sha,
29
+ }
app/services/chat_service.py CHANGED
@@ -1,18 +1,34 @@
1
  from __future__ import annotations
2
 
3
- import asyncio
4
  import json
5
  import logging
6
- import re
7
- from typing import Any, Dict, List, Optional, Tuple
8
 
9
  import aiohttp
10
-
11
- from app.config import get_settings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  logger = logging.getLogger(__name__)
14
  _settings = get_settings()
15
 
 
 
16
  DEFAULT_JSON_PROMPT = "Return your response as a valid JSON object inside a JSON code block (```json)."
17
 
18
  MODEL_MAP: Dict[str, str] = {
@@ -21,64 +37,33 @@ MODEL_MAP: Dict[str, str] = {
21
  "agentdeck-0.1": "openprovider",
22
  }
23
 
 
 
24
 
25
- def build_default_system_prompt(model_name: str) -> str:
26
- return (
27
- f"Role: You are LLM model {model_name}. "
28
- f"You are built by AgentDeck. "
29
- f"You are a helpful, respectful, and honest assistant. "
30
- f"Always respond in a concise and accurate manner."
31
- )
32
-
33
- MEGANOVA_BASE_URL = "https://api.meganova.ai"
34
- MEGANOVA_CHAT_PATH = "/v1/chat/completions"
35
- MEGANOVA_MODEL = "meganova-ai/manta-flash-1.0"
36
-
37
- OPENROUTER_MIMIKA_BASE_URL = "https://openprovider.mimika.in"
38
- OPENROUTER_MIMIKA_CHAT_PATH = "/v1/chat/completions"
39
- OPENROUTER_MIMIKA_MODEL = "openprovider/auto-free"
40
 
41
- AION_LABS_BASE_URL = "https://api.aionlabs.ai"
42
- AION_LABS_CHAT_PATH = "/v1/chat/completions"
43
- AION_LABS_DEFAULT_MODEL = "aion-labs/aion-2.5"
 
 
 
 
 
 
 
44
 
45
- REDIS_PREFIX = "ai_lb:aion"
46
 
 
47
 
48
- def extract_json_blocks(text: str) -> List[Any]:
49
- blocks: List[Any] = []
50
 
51
- # try parsing the entire text as JSON first
52
- stripped = text.strip()
53
- try:
54
- blocks.append(json.loads(stripped))
55
- return blocks
56
- except json.JSONDecodeError:
57
- pass
58
-
59
- # fallback: extract from ```json code blocks
60
- pattern = r"```json\s*\n?(.*?)```"
61
- for match in re.findall(pattern, text, re.DOTALL):
62
- stripped = match.strip()
63
- if stripped:
64
- try:
65
- blocks.append(json.loads(stripped))
66
- except json.JSONDecodeError:
67
- logger.warning("Failed to parse JSON block: %s", stripped[:100])
68
-
69
- # fallback: try to find any top-level {...} or [...] in the text
70
- if not blocks:
71
- for delim in (("{", "}"), ("[", "]")):
72
- start = text.find(delim[0])
73
- end = text.rfind(delim[1])
74
- if start != -1 and end != -1 and end > start:
75
- candidate = text[start : end + 1]
76
- try:
77
- blocks.append(json.loads(candidate))
78
- except json.JSONDecodeError:
79
- pass
80
-
81
- return blocks
82
 
83
 
84
  def inject_system_identity(
@@ -110,45 +95,48 @@ def prepare_messages(
110
  has_system = messages and messages[0].get("role") == "system"
111
 
112
  if has_system:
113
- messages[0] = {
114
- "role": "system",
115
- "content": f"{messages[0]['content']}\n\n{DEFAULT_JSON_PROMPT}",
116
- }
 
 
 
117
 
118
- return messages
 
 
 
119
 
120
 
121
  def attach_json_content(
122
  response_data: Dict[str, Any],
123
  response_format: Optional[Dict[str, str]],
124
- ) -> Dict[str, Any]:
125
  if not response_format or response_format.get("type") != "json_object":
126
- return response_data
127
 
128
  try:
129
  choices = response_data.get("choices", [])
130
  if not choices:
131
- return response_data
132
  content = choices[0].get("message", {}).get("content", "")
133
  if content:
134
  parsed = extract_json_blocks(content)
135
  if parsed:
136
- choices[0]["message"]["content"] = json.dumps(
137
- parsed[0] if len(parsed) == 1 else parsed,
138
- ensure_ascii=False,
139
  )
140
  except Exception as exc:
141
- choices[0]["message"]["content"] = json.dumps({"error": str(exc)})
142
-
143
- return response_data
144
 
145
 
146
  async def call_openrouter_mimika(
147
  messages: List[Dict[str, str]],
148
  response_format: Optional[Dict[str, str]],
149
- max_tokens: int = 1024,
150
- temperature: float = 0.7,
151
- top_p: float = 0.9,
152
  ) -> Optional[Dict[str, Any]]:
153
  api_key = _settings.openrouter_mimika_api_key
154
  if not api_key:
@@ -181,7 +169,8 @@ async def call_openrouter_mimika(
181
  logger.warning("OpenRouter Mimika HTTP %d", resp.status)
182
  return None
183
  data = await resp.json()
184
- return attach_json_content(data, response_format)
 
185
  except Exception as exc:
186
  logger.warning("OpenRouter Mimika failed: %s", exc)
187
  return None
@@ -195,76 +184,354 @@ def _get_meganova_key() -> Optional[str]:
195
  return keys[0] if keys else None
196
 
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  async def call_meganova(
 
 
199
  messages: List[Dict[str, str]],
200
  response_format: Optional[Dict[str, str]],
201
- max_tokens: int = 1024,
202
- temperature: float = 0.7,
203
- top_p: float = 0.9,
 
204
  ) -> Optional[Dict[str, Any]]:
205
- api_key = _get_meganova_key()
206
- if not api_key:
207
- logger.info("No MegaNova keys configured (ai_api_keys), skipping")
208
  return None
209
 
210
- prepared = prepare_messages(messages, response_format)
211
- payload = {
212
- "model": MEGANOVA_MODEL,
213
- "messages": prepared,
214
- "max_tokens": max_tokens,
215
- "temperature": temperature,
216
- "top_p": top_p,
217
- "stream": False,
218
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
- logger.info("Calling MegaNova...")
221
- try:
222
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
223
- async with aiohttp.ClientSession(timeout=timeout) as session:
224
- async with session.post(
225
- f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
226
- json=payload,
227
- headers={
228
- "Authorization": f"Bearer {api_key}",
229
- "Content-Type": "application/json",
230
- },
231
- ) as resp:
232
- if resp.status != 200:
233
- logger.warning("MegaNova HTTP %d", resp.status)
234
- return None
235
- data = await resp.json()
236
- return attach_json_content(data, response_format)
237
- except Exception as exc:
238
- logger.warning("MegaNova failed: %s", exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  return None
240
 
 
 
241
 
242
- def get_next_aion_key() -> Tuple[Optional[str], int]:
243
- keys_str = _settings.aion_lab_keys
244
- if not keys_str:
245
- return None, 0
246
- keys = [k.strip() for k in keys_str.split(",") if k.strip()]
247
- if not keys:
248
- return None, 0
249
- return keys[0], len(keys)
250
 
251
 
252
  async def call_aion_labs(
 
253
  messages: List[Dict[str, str]],
254
  response_format: Optional[Dict[str, str]],
255
  model: str = AION_LABS_DEFAULT_MODEL,
256
- max_tokens: int = 1024,
257
- temperature: float = 0.7,
258
- top_p: float = 0.9,
259
  ) -> Optional[Dict[str, Any]]:
260
- key, _ = get_next_aion_key()
261
- if not key:
262
- logger.info("No AION keys configured, skipping")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  return None
264
 
265
  prepared = prepare_messages(messages, response_format)
266
  payload = {
267
- "model": model,
268
  "messages": prepared,
269
  "max_tokens": max_tokens,
270
  "temperature": temperature,
@@ -272,58 +539,74 @@ async def call_aion_labs(
272
  "stream": False,
273
  }
274
 
275
- logger.info("Calling AionLabs...")
276
  try:
277
  timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
278
  async with aiohttp.ClientSession(timeout=timeout) as session:
279
  async with session.post(
280
- f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
281
  json=payload,
282
  headers={
283
- "Authorization": f"Bearer {key}",
284
  "Content-Type": "application/json",
285
  },
286
  ) as resp:
287
  if resp.status != 200:
288
- logger.warning("AionLabs HTTP %d", resp.status)
289
  return None
290
  data = await resp.json()
291
- return attach_json_content(data, response_format)
 
292
  except Exception as exc:
293
- logger.warning("AionLabs failed: %s", exc)
294
  return None
295
 
296
 
297
- async def chat_completion(
298
  messages: List[Dict[str, str]],
299
- model: str = "agentdeck-1.0",
300
- response_format: Optional[Dict[str, str]] = None,
301
- max_tokens: int = 1024,
302
- temperature: float = 0.7,
303
- top_p: float = 0.9,
304
- ) -> Dict[str, Any]:
305
- messages = inject_system_identity(messages, model)
306
- provider = MODEL_MAP.get(model, "meganova")
307
- logger.info("Chat completion: model=%s provider=%s messages=%d", model, provider, len(messages))
308
-
309
- if provider == "openprovider":
310
- result = await call_openrouter_mimika(messages, response_format, max_tokens, temperature, top_p)
311
- if result:
312
- return result
313
- raise RuntimeError("OpenRouter Mimika request failed")
314
-
315
- if provider == "aionlabs":
316
- result = await call_aion_labs(messages, response_format, AION_LABS_DEFAULT_MODEL, max_tokens, temperature, top_p)
317
- if result:
318
- return result
319
- raise RuntimeError("AionLabs request failed")
320
-
321
- result = await call_meganova(messages, response_format, max_tokens, temperature, top_p)
322
- if result:
323
- return result
324
 
325
- result = await call_aion_labs(messages, response_format, AION_LABS_DEFAULT_MODEL, max_tokens, temperature, top_p)
326
- if result:
327
- return result
 
 
 
 
 
 
 
328
 
329
- raise RuntimeError("All AI providers exhausted")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
 
3
  import json
4
  import logging
5
+ from typing import Any, Dict, List, Optional
 
6
 
7
  import aiohttp
8
+ from redis.asyncio import Redis
9
+
10
+ from app.config import (
11
+ AION_LABS_BASE_URL,
12
+ AION_LABS_CHAT_PATH,
13
+ AION_LABS_DEFAULT_MODEL,
14
+ DEFAULT_MAX_TOKENS,
15
+ DEFAULT_TEMPERATURE,
16
+ DEFAULT_TOP_P,
17
+ MEGANOVA_BASE_URL,
18
+ MEGANOVA_CHAT_PATH,
19
+ MODELS,
20
+ OPENROUTER_MIMIKA_BASE_URL,
21
+ OPENROUTER_MIMIKA_CHAT_PATH,
22
+ OPENROUTER_MIMIKA_MODEL,
23
+ get_settings,
24
+ )
25
+ from app.utils.json_utils import extract_json_blocks
26
 
27
  logger = logging.getLogger(__name__)
28
  _settings = get_settings()
29
 
30
+ PREFIX = "ai_lb"
31
+ AION_PREFIX = "ai_lb:aion"
32
  DEFAULT_JSON_PROMPT = "Return your response as a valid JSON object inside a JSON code block (```json)."
33
 
34
  MODEL_MAP: Dict[str, str] = {
 
37
  "agentdeck-0.1": "openprovider",
38
  }
39
 
40
+ KEY_IDS: List[str] = []
41
+ KEY_MAP: Dict[str, str] = {}
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
+ def _refresh_keys() -> None:
45
+ global KEY_IDS, KEY_MAP
46
+ keys_str = _settings.ai_api_keys
47
+ if keys_str:
48
+ keys = [k.strip() for k in keys_str.split(",") if k.strip()]
49
+ KEY_IDS = [f"k{i}" for i in range(len(keys))]
50
+ KEY_MAP = {f"k{i}": key for i, key in enumerate(keys)}
51
+ else:
52
+ KEY_IDS = []
53
+ KEY_MAP = {}
54
 
 
55
 
56
+ _refresh_keys()
57
 
 
 
58
 
59
+ def build_default_system_prompt(model_name: Optional[str]) -> str:
60
+ name = model_name or "agentdeck-1.0"
61
+ return (
62
+ f"Role: You are LLM model {name}. "
63
+ f"You are built by AgentDeck. "
64
+ f"You are a helpful, respectful, and honest assistant. "
65
+ f"Always respond in a concise and accurate manner."
66
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
 
69
  def inject_system_identity(
 
95
  has_system = messages and messages[0].get("role") == "system"
96
 
97
  if has_system:
98
+ return [
99
+ {
100
+ "role": "system",
101
+ "content": f"{messages[0]['content']}\n\n{DEFAULT_JSON_PROMPT}",
102
+ },
103
+ *messages[1:],
104
+ ]
105
 
106
+ return [
107
+ {"role": "system", "content": DEFAULT_JSON_PROMPT},
108
+ *messages,
109
+ ]
110
 
111
 
112
  def attach_json_content(
113
  response_data: Dict[str, Any],
114
  response_format: Optional[Dict[str, str]],
115
+ ) -> None:
116
  if not response_format or response_format.get("type") != "json_object":
117
+ return
118
 
119
  try:
120
  choices = response_data.get("choices", [])
121
  if not choices:
122
+ return
123
  content = choices[0].get("message", {}).get("content", "")
124
  if content:
125
  parsed = extract_json_blocks(content)
126
  if parsed:
127
+ response_data["parsed"] = (
128
+ parsed[0] if len(parsed) == 1 else parsed
 
129
  )
130
  except Exception as exc:
131
+ response_data["parsed"] = {"error": str(exc)}
 
 
132
 
133
 
134
  async def call_openrouter_mimika(
135
  messages: List[Dict[str, str]],
136
  response_format: Optional[Dict[str, str]],
137
+ max_tokens: int = DEFAULT_MAX_TOKENS,
138
+ temperature: float = DEFAULT_TEMPERATURE,
139
+ top_p: float = DEFAULT_TOP_P,
140
  ) -> Optional[Dict[str, Any]]:
141
  api_key = _settings.openrouter_mimika_api_key
142
  if not api_key:
 
169
  logger.warning("OpenRouter Mimika HTTP %d", resp.status)
170
  return None
171
  data = await resp.json()
172
+ attach_json_content(data, response_format)
173
+ return data
174
  except Exception as exc:
175
  logger.warning("OpenRouter Mimika failed: %s", exc)
176
  return None
 
184
  return keys[0] if keys else None
185
 
186
 
187
+ async def _acquire_slot(
188
+ redis: Redis,
189
+ scripts: Dict[str, str],
190
+ ) -> Optional[Dict[str, Any]]:
191
+ _refresh_keys()
192
+ if not KEY_IDS:
193
+ return None
194
+
195
+ raw = await redis.evalsha(
196
+ scripts["acquire_slot_sha"],
197
+ 1,
198
+ PREFIX,
199
+ json.dumps(KEY_IDS),
200
+ json.dumps(MODELS),
201
+ str(_settings.rounds_per_model),
202
+ str(len(KEY_IDS)),
203
+ )
204
+
205
+ if not raw or raw[0] != "ok":
206
+ return None
207
+
208
+ return {
209
+ "keyId": raw[1],
210
+ "model": raw[2],
211
+ "modelIndex": int(raw[3]),
212
+ }
213
+
214
+
215
+ async def _acquire_slot_for_model(
216
+ redis: Redis,
217
+ target_model: str,
218
+ ) -> Optional[Dict[str, Any]]:
219
+ _refresh_keys()
220
+ if not KEY_IDS:
221
+ return None
222
+
223
+ target_idx = MODELS.index(target_model) if target_model in MODELS else -1
224
+ if target_idx == -1:
225
+ return None
226
+
227
+ for key_id in KEY_IDS:
228
+ lock_k = f"{PREFIX}:key:{key_id}:lock"
229
+ failed_k = f"{PREFIX}:key:{key_id}:m{target_idx}:failed"
230
+
231
+ locked = await redis.exists(lock_k)
232
+ if locked:
233
+ continue
234
+
235
+ failed = await redis.get(failed_k)
236
+ if failed == "1":
237
+ continue
238
+
239
+ rounds_k = f"{PREFIX}:key:{key_id}:m{target_idx}:rounds"
240
+ used = int(await redis.get(rounds_k) or "0")
241
+ if used < _settings.rounds_per_model:
242
+ await redis.incr(rounds_k)
243
+ return {
244
+ "keyId": key_id,
245
+ "model": target_model,
246
+ "modelIndex": target_idx,
247
+ }
248
+
249
+ return None
250
+
251
+
252
+ async def _mark_failure(
253
+ redis: Redis,
254
+ scripts: Dict[str, str],
255
+ key_id: str,
256
+ model_index: int,
257
+ ) -> str:
258
+ result = await redis.evalsha(
259
+ scripts["lock_key_sha"],
260
+ 1,
261
+ PREFIX,
262
+ key_id,
263
+ str(model_index),
264
+ str(len(MODELS)),
265
+ str(_settings.key_lock_ttl),
266
+ )
267
+ return result
268
+
269
+
270
  async def call_meganova(
271
+ redis: Redis,
272
+ scripts: Dict[str, str],
273
  messages: List[Dict[str, str]],
274
  response_format: Optional[Dict[str, str]],
275
+ max_tokens: int = DEFAULT_MAX_TOKENS,
276
+ temperature: float = DEFAULT_TEMPERATURE,
277
+ top_p: float = DEFAULT_TOP_P,
278
+ target_model: Optional[str] = None,
279
  ) -> Optional[Dict[str, Any]]:
280
+ if target_model and target_model not in MODELS:
 
 
281
  return None
282
 
283
+ total_unique_slots = len(MODELS) * len(KEY_IDS)
284
+ tried: set = set()
285
+ hard_cap = total_unique_slots * 2 + 2
286
+ loops = 0
287
+
288
+ max_tries = len(KEY_IDS) if target_model else total_unique_slots
289
+
290
+ while len(tried) < max_tries and loops < hard_cap:
291
+ loops += 1
292
+
293
+ slot = (
294
+ await _acquire_slot_for_model(redis, target_model)
295
+ if target_model
296
+ else await _acquire_slot(redis, scripts)
297
+ )
298
+
299
+ if not slot:
300
+ return None
301
+
302
+ combo_key = f"{slot['keyId']}:{slot['modelIndex']}"
303
+ if combo_key in tried:
304
+ continue
305
+ tried.add(combo_key)
306
+
307
+ prepared = prepare_messages(messages, response_format)
308
+ api_key = KEY_MAP.get(slot["keyId"])
309
+ if not api_key:
310
+ continue
311
+
312
+ payload = {
313
+ "messages": prepared,
314
+ "model": slot["model"],
315
+ "max_tokens": max_tokens,
316
+ "temperature": temperature,
317
+ "top_p": top_p,
318
+ "stream": False,
319
+ }
320
 
321
+ try:
322
+ timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
323
+ async with aiohttp.ClientSession(timeout=timeout) as session:
324
+ async with session.post(
325
+ f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
326
+ json=payload,
327
+ headers={
328
+ "Authorization": f"Bearer {api_key}",
329
+ "Content-Type": "application/json",
330
+ },
331
+ ) as resp:
332
+ if resp.status >= 400:
333
+ logger.warning(
334
+ "MegaNova HTTP %d for keyId=%s model=%s",
335
+ resp.status,
336
+ slot["keyId"],
337
+ slot["model"],
338
+ )
339
+ await _mark_failure(redis, scripts, slot["keyId"], slot["modelIndex"])
340
+ continue
341
+
342
+ data = await resp.json()
343
+ attach_json_content(data, response_format)
344
+ return data
345
+ except Exception as exc:
346
+ logger.warning("MegaNova request failed: %s", exc)
347
+ continue
348
+
349
+ return None
350
+
351
+
352
+ async def _get_next_aion_key(redis: Redis) -> Optional[str]:
353
+ keys_json = await redis.get(f"{AION_PREFIX}:keys")
354
+ if not keys_json:
355
+ return None
356
+ keys: List[str] = json.loads(keys_json)
357
+ if not keys:
358
  return None
359
 
360
+ ptr = await redis.incr(f"{AION_PREFIX}:rr_ptr")
361
+ idx = (ptr - 1) % len(keys)
362
 
363
+ await redis.incr(f"{AION_PREFIX}:key:{idx}:uses")
364
+ logger.info("[aion] Round-robin: ptr=%s, idx=%s, total=%s", ptr, idx, len(keys))
365
+ return keys[idx]
 
 
 
 
 
366
 
367
 
368
  async def call_aion_labs(
369
+ redis: Redis,
370
  messages: List[Dict[str, str]],
371
  response_format: Optional[Dict[str, str]],
372
  model: str = AION_LABS_DEFAULT_MODEL,
373
+ max_tokens: int = DEFAULT_MAX_TOKENS,
374
+ temperature: float = DEFAULT_TEMPERATURE,
375
+ top_p: float = DEFAULT_TOP_P,
376
  ) -> Optional[Dict[str, Any]]:
377
+ keys_json = await redis.get(f"{AION_PREFIX}:keys")
378
+ if not keys_json:
379
+ logger.info("[aion] No keys in Redis, skipping")
380
+ return None
381
+ keys: List[str] = json.loads(keys_json)
382
+ if not keys:
383
+ return None
384
+
385
+ total_tries = min(len(keys), 3)
386
+ for attempt in range(total_tries):
387
+ key = await _get_next_aion_key(redis)
388
+ if not key:
389
+ return None
390
+
391
+ prepared = prepare_messages(messages, response_format)
392
+ payload = {
393
+ "model": model,
394
+ "messages": prepared,
395
+ "max_tokens": max_tokens,
396
+ "temperature": temperature,
397
+ "top_p": top_p,
398
+ "stream": False,
399
+ }
400
+
401
+ logger.info("[aion] Attempt %s/%s", attempt + 1, total_tries)
402
+
403
+ try:
404
+ timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
405
+ async with aiohttp.ClientSession(timeout=timeout) as session:
406
+ async with session.post(
407
+ f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
408
+ json=payload,
409
+ headers={
410
+ "Authorization": f"Bearer {key}",
411
+ "Content-Type": "application/json",
412
+ },
413
+ ) as resp:
414
+ if resp.status != 200:
415
+ logger.warning("[aion] Attempt %s HTTP %s", attempt + 1, resp.status)
416
+ continue
417
+ data = await resp.json()
418
+ attach_json_content(data, response_format)
419
+ return data
420
+ except Exception as exc:
421
+ logger.warning("[aion] Attempt %s failed: %s", attempt + 1, exc)
422
+ continue
423
+
424
+ logger.info("[aion] All attempts exhausted")
425
+ return None
426
+
427
+
428
+ async def chat_completion(
429
+ messages: List[Dict[str, str]],
430
+ model: str = "agentdeck-1.0",
431
+ response_format: Optional[Dict[str, str]] = None,
432
+ max_tokens: int = DEFAULT_MAX_TOKENS,
433
+ temperature: float = DEFAULT_TEMPERATURE,
434
+ top_p: float = DEFAULT_TOP_P,
435
+ provider: Optional[str] = None,
436
+ redis: Optional[Redis] = None,
437
+ scripts: Optional[Dict[str, str]] = None,
438
+ ) -> Dict[str, Any]:
439
+ messages = inject_system_identity(messages, model)
440
+
441
+ if not provider:
442
+ provider = MODEL_MAP.get(model)
443
+
444
+ logger.info(
445
+ "Chat completion: model=%s provider=%s messages=%d",
446
+ model,
447
+ provider,
448
+ len(messages),
449
+ )
450
+
451
+ if provider == "openprovider":
452
+ result = await call_openrouter_mimika(
453
+ messages, response_format, max_tokens, temperature, top_p,
454
+ )
455
+ if result:
456
+ return result
457
+ raise RuntimeError("OpenRouter Mimika request failed")
458
+
459
+ if provider == "aionlabs":
460
+ if redis and scripts:
461
+ result = await call_aion_labs(
462
+ redis, messages, response_format, model or AION_LABS_DEFAULT_MODEL,
463
+ max_tokens, temperature, top_p,
464
+ )
465
+ if result:
466
+ return result
467
+ raise RuntimeError("AionLabs request failed")
468
+ # degraded fallback without Redis
469
+ result = await call_aion_labs_no_redis(
470
+ messages, response_format, model or AION_LABS_DEFAULT_MODEL,
471
+ max_tokens, temperature, top_p,
472
+ )
473
+ if result:
474
+ return result
475
+ raise RuntimeError("AionLabs request failed")
476
+
477
+ if provider != "meganova":
478
+ mimika_result = await call_openrouter_mimika(
479
+ messages, response_format, max_tokens, temperature, top_p,
480
+ )
481
+ if mimika_result is not None:
482
+ logger.info("OpenRouter Mimika handled request, skipping meganova")
483
+ return mimika_result
484
+
485
+ target = model if model in MODELS else None
486
+ if redis and scripts:
487
+ meganova_result = await call_meganova(
488
+ redis, scripts, messages, response_format,
489
+ max_tokens, temperature, top_p, target,
490
+ )
491
+ if meganova_result is not None:
492
+ return meganova_result
493
+ else:
494
+ meganova_result = await call_meganova_no_redis(
495
+ messages, response_format, max_tokens, temperature, top_p, target,
496
+ )
497
+ if meganova_result is not None:
498
+ return meganova_result
499
+
500
+ logger.info("MegaNova failed, falling back to AionLabs")
501
+ if redis and scripts:
502
+ aion_result = await call_aion_labs(
503
+ redis, messages, response_format, model or AION_LABS_DEFAULT_MODEL,
504
+ max_tokens, temperature, top_p,
505
+ )
506
+ if aion_result is not None:
507
+ return aion_result
508
+ else:
509
+ aion_result = await call_aion_labs_no_redis(
510
+ messages, response_format, model or AION_LABS_DEFAULT_MODEL,
511
+ max_tokens, temperature, top_p,
512
+ )
513
+ if aion_result is not None:
514
+ return aion_result
515
+
516
+ raise RuntimeError("All AI providers exhausted")
517
+
518
+
519
+ async def call_meganova_no_redis(
520
+ messages: List[Dict[str, str]],
521
+ response_format: Optional[Dict[str, str]],
522
+ max_tokens: int = DEFAULT_MAX_TOKENS,
523
+ temperature: float = DEFAULT_TEMPERATURE,
524
+ top_p: float = DEFAULT_TOP_P,
525
+ target_model: Optional[str] = None,
526
+ ) -> Optional[Dict[str, Any]]:
527
+ api_key = _get_meganova_key()
528
+ if not api_key:
529
+ logger.info("No MegaNova keys configured, skipping")
530
  return None
531
 
532
  prepared = prepare_messages(messages, response_format)
533
  payload = {
534
+ "model": target_model or MODELS[1],
535
  "messages": prepared,
536
  "max_tokens": max_tokens,
537
  "temperature": temperature,
 
539
  "stream": False,
540
  }
541
 
542
+ logger.info("Calling MegaNova (no Redis)...")
543
  try:
544
  timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
545
  async with aiohttp.ClientSession(timeout=timeout) as session:
546
  async with session.post(
547
+ f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
548
  json=payload,
549
  headers={
550
+ "Authorization": f"Bearer {api_key}",
551
  "Content-Type": "application/json",
552
  },
553
  ) as resp:
554
  if resp.status != 200:
555
+ logger.warning("MegaNova HTTP %d", resp.status)
556
  return None
557
  data = await resp.json()
558
+ attach_json_content(data, response_format)
559
+ return data
560
  except Exception as exc:
561
+ logger.warning("MegaNova failed: %s", exc)
562
  return None
563
 
564
 
565
+ async def call_aion_labs_no_redis(
566
  messages: List[Dict[str, str]],
567
+ response_format: Optional[Dict[str, str]],
568
+ model: str = AION_LABS_DEFAULT_MODEL,
569
+ max_tokens: int = DEFAULT_MAX_TOKENS,
570
+ temperature: float = DEFAULT_TEMPERATURE,
571
+ top_p: float = DEFAULT_TOP_P,
572
+ ) -> Optional[Dict[str, Any]]:
573
+ keys_str = _settings.aion_lab_keys
574
+ if not keys_str:
575
+ logger.info("No AION keys configured, skipping")
576
+ return None
577
+ keys = [k.strip() for k in keys_str.split(",") if k.strip()]
578
+ if not keys:
579
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
580
 
581
+ for attempt, key in enumerate(keys[:3]):
582
+ prepared = prepare_messages(messages, response_format)
583
+ payload = {
584
+ "model": model,
585
+ "messages": prepared,
586
+ "max_tokens": max_tokens,
587
+ "temperature": temperature,
588
+ "top_p": top_p,
589
+ "stream": False,
590
+ }
591
 
592
+ logger.info("[aion] (no redis) Attempt %s/%s", attempt + 1, min(len(keys), 3))
593
+ try:
594
+ timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
595
+ async with aiohttp.ClientSession(timeout=timeout) as session:
596
+ async with session.post(
597
+ f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
598
+ json=payload,
599
+ headers={
600
+ "Authorization": f"Bearer {key}",
601
+ "Content-Type": "application/json",
602
+ },
603
+ ) as resp:
604
+ if resp.status != 200:
605
+ continue
606
+ data = await resp.json()
607
+ attach_json_content(data, response_format)
608
+ return data
609
+ except Exception:
610
+ continue
611
+
612
+ return None
app/utils/__init__.py ADDED
File without changes
app/utils/json_utils.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from typing import Any, List
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def extract_json_blocks(text: str) -> List[Any]:
12
+ blocks: List[Any] = []
13
+
14
+ stripped = text.strip()
15
+ try:
16
+ blocks.append(json.loads(stripped))
17
+ return blocks
18
+ except json.JSONDecodeError:
19
+ pass
20
+
21
+ pattern = r"```json\s*\n?(.*?)```"
22
+ for match in re.findall(pattern, text, re.DOTALL):
23
+ stripped = match.strip()
24
+ if stripped:
25
+ try:
26
+ blocks.append(json.loads(stripped))
27
+ except json.JSONDecodeError:
28
+ logger.warning("Failed to parse JSON block: %s", stripped[:100])
29
+
30
+ if not blocks:
31
+ for delim in (("{", "}"), ("[", "]")):
32
+ start = text.find(delim[0])
33
+ end = text.rfind(delim[1])
34
+ if start != -1 and end != -1 and end > start:
35
+ candidate = text[start : end + 1]
36
+ try:
37
+ blocks.append(json.loads(candidate))
38
+ except json.JSONDecodeError:
39
+ pass
40
+
41
+ return blocks
lua/acquire_slot.lua ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --[[
2
+ acquire_slot.lua v4
3
+ ─────────────────────────────────────────────────────────────────────────────
4
+ Round-robin slot acquisition. Rounds are a rotation hint, never a lock cause.
5
+
6
+ Flow:
7
+ 1. Read rr_ptr — starting key index.
8
+ 2. Walk all keys. Skip failure-locked ones.
9
+ 3. For each unlocked key, walk models from its midx.
10
+ Skip any model flagged with m{idx}:failed (error-mark from lock_key.lua).
11
+ For non-failed models, find first where rounds < maxRounds.
12
+ 4. INCR rounds, advance rr_ptr, return slot.
13
+ 5. If a model is error-marked, rotate midx past it and clear the flag.
14
+ 6. If a model is naturally exhausted (rounds >= max),
15
+ rotate midx to next model and RESET that model's counter
16
+ so it can be reused — never lock for round exhaustion.
17
+ 7. Return "none" only when ALL keys have an active failure lock.
18
+
19
+ Keys:
20
+ {prefix}:rr_ptr
21
+ {prefix}:key:{id}:lock (set only by lock_key.lua on upstream error)
22
+ {prefix}:key:{id}:midx
23
+ {prefix}:key:{id}:m{idx}:rounds
24
+ {prefix}:key:{id}:m{idx}:failed
25
+ ]]
26
+
27
+ local prefix = KEYS[1]
28
+ local keyIds = cjson.decode(ARGV[1])
29
+ local models = cjson.decode(ARGV[2])
30
+ local maxRounds = tonumber(ARGV[3])
31
+ local numKeys = tonumber(ARGV[4])
32
+ local numModels = #models
33
+
34
+ local rrPtrK = prefix .. ":rr_ptr"
35
+ local startPtr = tonumber(redis.call("GET", rrPtrK) or "0") or 0
36
+
37
+ for attempt = 0, numKeys - 1 do
38
+ local keySlot = (startPtr + attempt) % numKeys
39
+ local keyId = keyIds[keySlot + 1]
40
+ local lockK = prefix .. ":key:" .. keyId .. ":lock"
41
+
42
+ if redis.call("EXISTS", lockK) == 0 then
43
+ local midxK = prefix .. ":key:" .. keyId .. ":midx"
44
+ local mIdx = tonumber(redis.call("GET", midxK) or "0") or 0
45
+
46
+ for mAttempt = 0, numModels - 1 do
47
+ local realMIdx = (mIdx + mAttempt) % numModels
48
+ local failedK = prefix .. ":key:" .. keyId .. ":m" .. realMIdx .. ":failed"
49
+
50
+ if redis.call("GET", failedK) == "1" then
51
+ local nextMIdx = (realMIdx + 1) % numModels
52
+ redis.call("SET", midxK, tostring(nextMIdx))
53
+ redis.call("DEL", failedK)
54
+ else
55
+ local roundsK = prefix .. ":key:" .. keyId .. ":m" .. realMIdx .. ":rounds"
56
+ local used = tonumber(redis.call("GET", roundsK) or "0") or 0
57
+
58
+ if used < maxRounds then
59
+ redis.call("INCR", roundsK)
60
+ redis.call("SET", rrPtrK, tostring((keySlot + 1) % numKeys))
61
+ redis.call("SET", midxK, tostring(realMIdx))
62
+ return { "ok", keyId, models[realMIdx + 1], tostring(realMIdx) }
63
+ else
64
+ local nextMIdx = (realMIdx + 1) % numModels
65
+ redis.call("SET", midxK, tostring(nextMIdx))
66
+ redis.call("SET", roundsK, "0")
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ return { "none" }
lua/lock_key.lua ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --[[
2
+ lock_key.lua v4
3
+ ─────────────────────────────────────────────────────────────────────────────
4
+ Called ONLY on upstream error (4xx / 5xx).
5
+ Flags the failed model and checks whether other models on the same key remain.
6
+
7
+ Rules:
8
+ - Set a per-model error flag (m{idx}:failed) so acquire_slot skips it.
9
+ - Advance midx to the next model.
10
+ - If at least one other model is NOT flagged failed → return "rotated"
11
+ (key is still usable; balancer will retry immediately).
12
+ - If ALL models on this key are flagged failed → lock the key for lockTtl.
13
+ The key unlocks automatically when the TTL expires.
14
+
15
+ KEYS[1] = prefix
16
+ ARGV[1] = keyId
17
+ ARGV[2] = failed model index (0-based)
18
+ ARGV[3] = total model count
19
+ ARGV[4] = lock TTL in seconds
20
+
21
+ Returns: "rotated" | "all_exhausted"
22
+ ]]
23
+
24
+ local prefix = KEYS[1]
25
+ local keyId = ARGV[1]
26
+ local failedMIdx = tonumber(ARGV[2])
27
+ local numModels = tonumber(ARGV[3])
28
+ local lockTtl = tonumber(ARGV[4])
29
+
30
+ local midxK = prefix .. ":key:" .. keyId .. ":midx"
31
+ local lockK = prefix .. ":key:" .. keyId .. ":lock"
32
+
33
+ local failedK = prefix .. ":key:" .. keyId .. ":m" .. failedMIdx .. ":failed"
34
+ redis.call("SET", failedK, "1")
35
+
36
+ local nextMIdx = (failedMIdx + 1) % numModels
37
+ redis.call("SET", midxK, tostring(nextMIdx))
38
+
39
+ for i = 0, numModels - 1 do
40
+ if i ~= failedMIdx then
41
+ local otherFailedK = prefix .. ":key:" .. keyId .. ":m" .. i .. ":failed"
42
+ if redis.call("GET", otherFailedK) ~= "1" then
43
+ return "rotated"
44
+ end
45
+ end
46
+ end
47
+
48
+ redis.call("SET", lockK, "1", "EX", lockTtl)
49
+ return "all_exhausted"