Code-API commited on
Commit
10ca996
·
1 Parent(s): 62aa98f
Files changed (2) hide show
  1. app/api/v1/chat.py +54 -3
  2. app/services/chat_service.py +334 -1
app/api/v1/chat.py CHANGED
@@ -1,13 +1,15 @@
1
  from __future__ import annotations
2
 
 
3
  import logging
4
  import time
5
- from typing import Any, Dict, List, Optional
6
 
7
  from fastapi import APIRouter, Depends, HTTPException, Request
 
8
 
9
  from app.api.deps import require_auth
10
- from app.services.chat_service import chat_completion
11
 
12
  logger = logging.getLogger(__name__)
13
 
@@ -17,12 +19,50 @@ VALID_PROVIDERS = ["openprovider", "meganova", "aionlabs"]
17
  router = APIRouter()
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  @router.post("/chat/completions")
21
  async def create_chat_completion(
22
  body: Dict[str, Any],
23
  request: Request,
24
  token: str = Depends(require_auth),
25
- ) -> Dict[str, Any]:
26
  start = time.monotonic()
27
  req_id = hex(int(time.time() * 1_000_000))[-8:]
28
 
@@ -78,6 +118,17 @@ async def create_chat_completion(
78
  redis = getattr(request.app.state, "redis", None)
79
  scripts = getattr(request.app.state, "scripts", None)
80
 
 
 
 
 
 
 
 
 
 
 
 
81
  try:
82
  result = await chat_completion(
83
  messages=messages,
 
1
  from __future__ import annotations
2
 
3
+ import json
4
  import logging
5
  import time
6
+ from typing import Any, AsyncGenerator, Dict, List, Optional
7
 
8
  from fastapi import APIRouter, Depends, HTTPException, Request
9
+ from fastapi.responses import StreamingResponse
10
 
11
  from app.api.deps import require_auth
12
+ from app.services.chat_service import chat_completion, _stream_chat_completion
13
 
14
  logger = logging.getLogger(__name__)
15
 
 
19
  router = APIRouter()
20
 
21
 
22
+ async def _stream_events(
23
+ body: Dict[str, Any],
24
+ request: Request,
25
+ ) -> AsyncGenerator[str, None]:
26
+ messages = body["messages"]
27
+ model = body.get("model", "agentdeck-1.0")
28
+ provider = body.get("provider")
29
+ max_tokens = body.get("max_tokens", 1024)
30
+ temperature = body.get("temperature", 0.7)
31
+ top_p = body.get("top_p", 0.9)
32
+ response_format = body.get("response_format")
33
+ redis = getattr(request.app.state, "redis", None)
34
+ scripts = getattr(request.app.state, "scripts", None)
35
+
36
+ try:
37
+ async for chunk in _stream_chat_completion(
38
+ messages=messages,
39
+ model=model,
40
+ provider=provider,
41
+ response_format=response_format,
42
+ max_tokens=max_tokens,
43
+ temperature=temperature,
44
+ top_p=top_p,
45
+ redis=redis,
46
+ scripts=scripts,
47
+ ):
48
+ yield f"data: {json.dumps(chunk)}\n\n"
49
+ yield "data: [DONE]\n\n"
50
+ except RuntimeError as e:
51
+ error_detail = str(e)
52
+ if "All AI providers exhausted" in error_detail:
53
+ error_chunk = {"error": {"message": "All API keys are currently locked. Retry after a few minutes.", "code": 503}}
54
+ else:
55
+ error_chunk = {"error": {"message": error_detail, "code": 502}}
56
+ yield f"data: {json.dumps(error_chunk)}\n\n"
57
+ yield "data: [DONE]\n\n"
58
+
59
+
60
  @router.post("/chat/completions")
61
  async def create_chat_completion(
62
  body: Dict[str, Any],
63
  request: Request,
64
  token: str = Depends(require_auth),
65
+ ):
66
  start = time.monotonic()
67
  req_id = hex(int(time.time() * 1_000_000))[-8:]
68
 
 
118
  redis = getattr(request.app.state, "redis", None)
119
  scripts = getattr(request.app.state, "scripts", None)
120
 
121
+ if stream:
122
+ return StreamingResponse(
123
+ _stream_events(body, request),
124
+ media_type="text/event-stream",
125
+ headers={
126
+ "Cache-Control": "no-cache",
127
+ "Connection": "keep-alive",
128
+ "X-Accel-Buffering": "no",
129
+ },
130
+ )
131
+
132
  try:
133
  result = await chat_completion(
134
  messages=messages,
app/services/chat_service.py CHANGED
@@ -2,7 +2,7 @@ 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
@@ -602,3 +602,336 @@ async def call_aion_labs_no_redis(
602
  continue
603
 
604
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import json
4
  import logging
5
+ from typing import Any, AsyncGenerator, Dict, List, Optional
6
 
7
  import aiohttp
8
  from redis.asyncio import Redis
 
602
  continue
603
 
604
  return None
605
+
606
+
607
+ async def _sse_chunks(resp: aiohttp.ClientResponse) -> AsyncGenerator[Dict[str, Any], None]:
608
+ buffer = b""
609
+ async for raw in resp.content.iter_any():
610
+ buffer += raw
611
+ while b"\n" in buffer:
612
+ line, buffer = buffer.split(b"\n", 1)
613
+ line_str = line.decode("utf-8").strip()
614
+ if line_str.startswith("data: "):
615
+ payload = line_str[6:]
616
+ if payload.strip() == "[DONE]":
617
+ return
618
+ try:
619
+ yield json.loads(payload)
620
+ except json.JSONDecodeError:
621
+ continue
622
+
623
+
624
+ async def _stream_meganova(
625
+ redis: Redis,
626
+ scripts: Dict[str, str],
627
+ messages: List[Dict[str, str]],
628
+ response_format: Optional[Dict[str, str]],
629
+ max_tokens: int = DEFAULT_MAX_TOKENS,
630
+ temperature: float = DEFAULT_TEMPERATURE,
631
+ top_p: float = DEFAULT_TOP_P,
632
+ target_model: Optional[str] = None,
633
+ ) -> AsyncGenerator[Dict[str, Any], None]:
634
+ if target_model and target_model not in MODELS:
635
+ return
636
+
637
+ total_unique_slots = len(MODELS) * len(KEY_IDS)
638
+ tried: set = set()
639
+ hard_cap = total_unique_slots * 2 + 2
640
+ loops = 0
641
+ max_tries = len(KEY_IDS) if target_model else total_unique_slots
642
+
643
+ while len(tried) < max_tries and loops < hard_cap:
644
+ loops += 1
645
+ slot = (
646
+ await _acquire_slot_for_model(redis, target_model)
647
+ if target_model
648
+ else await _acquire_slot(redis, scripts)
649
+ )
650
+ if not slot:
651
+ return
652
+
653
+ combo_key = f"{slot['keyId']}:{slot['modelIndex']}"
654
+ if combo_key in tried:
655
+ continue
656
+ tried.add(combo_key)
657
+
658
+ prepared = prepare_messages(messages, response_format)
659
+ api_key = KEY_MAP.get(slot["keyId"])
660
+ if not api_key:
661
+ continue
662
+
663
+ payload = {
664
+ "messages": prepared,
665
+ "model": slot["model"],
666
+ "max_tokens": max_tokens,
667
+ "temperature": temperature,
668
+ "top_p": top_p,
669
+ "stream": True,
670
+ }
671
+
672
+ try:
673
+ timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
674
+ async with aiohttp.ClientSession(timeout=timeout) as session:
675
+ async with session.post(
676
+ f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
677
+ json=payload,
678
+ headers={
679
+ "Authorization": f"Bearer {api_key}",
680
+ "Content-Type": "application/json",
681
+ },
682
+ ) as resp:
683
+ if resp.status >= 400:
684
+ logger.warning(
685
+ "MegaNova stream HTTP %d for keyId=%s model=%s",
686
+ resp.status, slot["keyId"], slot["model"],
687
+ )
688
+ await _mark_failure(redis, scripts, slot["keyId"], slot["modelIndex"])
689
+ continue
690
+ async for chunk in _sse_chunks(resp):
691
+ yield chunk
692
+ return
693
+ except Exception as exc:
694
+ logger.warning("MegaNova stream failed: %s", exc)
695
+ continue
696
+
697
+
698
+ async def _stream_meganova_no_redis(
699
+ messages: List[Dict[str, str]],
700
+ response_format: Optional[Dict[str, str]],
701
+ max_tokens: int = DEFAULT_MAX_TOKENS,
702
+ temperature: float = DEFAULT_TEMPERATURE,
703
+ top_p: float = DEFAULT_TOP_P,
704
+ target_model: Optional[str] = None,
705
+ ) -> AsyncGenerator[Dict[str, Any], None]:
706
+ api_key = _get_meganova_key()
707
+ if not api_key:
708
+ logger.info("No MegaNova keys configured, skipping stream")
709
+ return
710
+
711
+ prepared = prepare_messages(messages, response_format)
712
+ payload = {
713
+ "model": target_model or MODELS[1],
714
+ "messages": prepared,
715
+ "max_tokens": max_tokens,
716
+ "temperature": temperature,
717
+ "top_p": top_p,
718
+ "stream": True,
719
+ }
720
+
721
+ try:
722
+ timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
723
+ async with aiohttp.ClientSession(timeout=timeout) as session:
724
+ async with session.post(
725
+ f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
726
+ json=payload,
727
+ headers={
728
+ "Authorization": f"Bearer {api_key}",
729
+ "Content-Type": "application/json",
730
+ },
731
+ ) as resp:
732
+ if resp.status != 200:
733
+ logger.warning("MegaNova stream HTTP %d", resp.status)
734
+ return
735
+ async for chunk in _sse_chunks(resp):
736
+ yield chunk
737
+ except Exception as exc:
738
+ logger.warning("MegaNova stream failed: %s", exc)
739
+
740
+
741
+ async def _stream_aion_labs(
742
+ redis: Redis,
743
+ messages: List[Dict[str, str]],
744
+ response_format: Optional[Dict[str, str]],
745
+ model: str = AION_LABS_DEFAULT_MODEL,
746
+ max_tokens: int = DEFAULT_MAX_TOKENS,
747
+ temperature: float = DEFAULT_TEMPERATURE,
748
+ top_p: float = DEFAULT_TOP_P,
749
+ ) -> AsyncGenerator[Dict[str, Any], None]:
750
+ keys_json = await redis.get(f"{AION_PREFIX}:keys")
751
+ if not keys_json:
752
+ logger.info("[aion] No keys in Redis, skipping stream")
753
+ return
754
+ keys: List[str] = json.loads(keys_json)
755
+ if not keys:
756
+ return
757
+
758
+ total_tries = min(len(keys), 3)
759
+ for attempt in range(total_tries):
760
+ key = await _get_next_aion_key(redis)
761
+ if not key:
762
+ return
763
+
764
+ prepared = prepare_messages(messages, response_format)
765
+ payload = {
766
+ "model": model,
767
+ "messages": prepared,
768
+ "max_tokens": max_tokens,
769
+ "temperature": temperature,
770
+ "top_p": top_p,
771
+ "stream": True,
772
+ }
773
+
774
+ try:
775
+ timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
776
+ async with aiohttp.ClientSession(timeout=timeout) as session:
777
+ async with session.post(
778
+ f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
779
+ json=payload,
780
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
781
+ ) as resp:
782
+ if resp.status != 200:
783
+ logger.warning("[aion] Stream attempt %s HTTP %s", attempt + 1, resp.status)
784
+ continue
785
+ async for chunk in _sse_chunks(resp):
786
+ yield chunk
787
+ return
788
+ except Exception as exc:
789
+ logger.warning("[aion] Stream attempt %s failed: %s", attempt + 1, exc)
790
+ continue
791
+
792
+
793
+ async def _stream_aion_labs_no_redis(
794
+ messages: List[Dict[str, str]],
795
+ response_format: Optional[Dict[str, str]],
796
+ model: str = AION_LABS_DEFAULT_MODEL,
797
+ max_tokens: int = DEFAULT_MAX_TOKENS,
798
+ temperature: float = DEFAULT_TEMPERATURE,
799
+ top_p: float = DEFAULT_TOP_P,
800
+ ) -> AsyncGenerator[Dict[str, Any], None]:
801
+ keys_str = _settings.aion_lab_keys
802
+ if not keys_str:
803
+ logger.info("No AION keys configured, skipping stream")
804
+ return
805
+ keys = [k.strip() for k in keys_str.split(",") if k.strip()]
806
+ if not keys:
807
+ return
808
+
809
+ for attempt, key in enumerate(keys[:3]):
810
+ prepared = prepare_messages(messages, response_format)
811
+ payload = {
812
+ "model": model,
813
+ "messages": prepared,
814
+ "max_tokens": max_tokens,
815
+ "temperature": temperature,
816
+ "top_p": top_p,
817
+ "stream": True,
818
+ }
819
+
820
+ try:
821
+ timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
822
+ async with aiohttp.ClientSession(timeout=timeout) as session:
823
+ async with session.post(
824
+ f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
825
+ json=payload,
826
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
827
+ ) as resp:
828
+ if resp.status != 200:
829
+ continue
830
+ async for chunk in _sse_chunks(resp):
831
+ yield chunk
832
+ return
833
+ except Exception:
834
+ continue
835
+
836
+
837
+ async def _stream_openrouter_mimika(
838
+ messages: List[Dict[str, str]],
839
+ response_format: Optional[Dict[str, str]],
840
+ max_tokens: int = DEFAULT_MAX_TOKENS,
841
+ temperature: float = DEFAULT_TEMPERATURE,
842
+ top_p: float = DEFAULT_TOP_P,
843
+ ) -> AsyncGenerator[Dict[str, Any], None]:
844
+ api_key = _settings.openrouter_mimika_api_key
845
+ if not api_key:
846
+ logger.info("OPENROUTER_MIMIKA_API_KEY not set, skipping stream")
847
+ return
848
+
849
+ prepared = prepare_messages(messages, response_format)
850
+ payload = {
851
+ "model": OPENROUTER_MIMIKA_MODEL,
852
+ "messages": prepared,
853
+ "max_tokens": max_tokens,
854
+ "temperature": temperature,
855
+ "top_p": top_p,
856
+ "stream": True,
857
+ }
858
+
859
+ try:
860
+ timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
861
+ async with aiohttp.ClientSession(timeout=timeout) as session:
862
+ async with session.post(
863
+ f"{OPENROUTER_MIMIKA_BASE_URL}{OPENROUTER_MIMIKA_CHAT_PATH}",
864
+ json=payload,
865
+ headers={
866
+ "Authorization": f"Bearer {api_key}",
867
+ "Content-Type": "application/json",
868
+ },
869
+ ) as resp:
870
+ if resp.status != 200:
871
+ logger.warning("OpenRouter Mimika stream HTTP %d", resp.status)
872
+ return
873
+ async for chunk in _sse_chunks(resp):
874
+ yield chunk
875
+ except Exception as exc:
876
+ logger.warning("OpenRouter Mimika stream failed: %s", exc)
877
+
878
+
879
+ async def _stream_chat_completion(
880
+ messages: List[Dict[str, str]],
881
+ model: str = "agentdeck-1.0",
882
+ response_format: Optional[Dict[str, str]] = None,
883
+ max_tokens: int = DEFAULT_MAX_TOKENS,
884
+ temperature: float = DEFAULT_TEMPERATURE,
885
+ top_p: float = DEFAULT_TOP_P,
886
+ provider: Optional[str] = None,
887
+ redis: Optional[Redis] = None,
888
+ scripts: Optional[Dict[str, str]] = None,
889
+ ) -> AsyncGenerator[Dict[str, Any], None]:
890
+ messages = inject_system_identity(messages, model)
891
+
892
+ if not provider:
893
+ provider = "meganova"
894
+
895
+ target = model if model in MODELS else None
896
+
897
+ if provider == "aionlabs":
898
+ if redis and scripts:
899
+ gen = _stream_aion_labs(redis, messages, response_format, model or AION_LABS_DEFAULT_MODEL, max_tokens, temperature, top_p)
900
+ else:
901
+ gen = _stream_aion_labs_no_redis(messages, response_format, model or AION_LABS_DEFAULT_MODEL, max_tokens, temperature, top_p)
902
+ async for chunk in gen:
903
+ yield chunk
904
+ return
905
+
906
+ if provider == "openprovider":
907
+ async for chunk in _stream_openrouter_mimika(messages, response_format, max_tokens, temperature, top_p):
908
+ yield chunk
909
+ return
910
+
911
+ for _ in range(3):
912
+ if provider == "meganova":
913
+ if redis and scripts:
914
+ gen = _stream_meganova(redis, scripts, messages, response_format, max_tokens, temperature, top_p, target)
915
+ else:
916
+ gen = _stream_meganova_no_redis(messages, response_format, max_tokens, temperature, top_p, target)
917
+ elif provider == "aionlabs":
918
+ if redis and scripts:
919
+ gen = _stream_aion_labs(redis, messages, response_format, model or AION_LABS_DEFAULT_MODEL, max_tokens, temperature, top_p)
920
+ else:
921
+ gen = _stream_aion_labs_no_redis(messages, response_format, model or AION_LABS_DEFAULT_MODEL, max_tokens, temperature, top_p)
922
+ else:
923
+ gen = _stream_openrouter_mimika(messages, response_format, max_tokens, temperature, top_p)
924
+
925
+ yielded = False
926
+ async for chunk in gen:
927
+ yielded = True
928
+ yield chunk
929
+ if yielded:
930
+ return
931
+
932
+ if provider == "meganova":
933
+ provider = "aionlabs"
934
+ elif provider == "aionlabs":
935
+ provider = "openprovider"
936
+ else:
937
+ break