light-infer-chat commited on
Commit
faefb1f
·
1 Parent(s): 114194d

feat: did optimization and cleanup

Browse files
app/api/deps.py CHANGED
@@ -1,6 +1,8 @@
1
  from __future__ import annotations
2
 
3
- from fastapi import Depends
 
 
4
 
5
  from app.core.security import require_api_key
6
  from app.services.auth_service import AuthService
@@ -54,3 +56,16 @@ def get_vector_store_service() -> VectorStoreService:
54
 
55
  def require_auth(token: str = Depends(require_api_key)) -> str:
56
  return token
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ from typing import Any, Dict, Optional, Tuple
4
+
5
+ from fastapi import Depends, Request
6
 
7
  from app.core.security import require_api_key
8
  from app.services.auth_service import AuthService
 
56
 
57
  def require_auth(token: str = Depends(require_api_key)) -> str:
58
  return token
59
+
60
+
61
+ def get_redis_scripts(request: Request) -> Tuple[Optional[Any], Dict[str, str]]:
62
+ """Extract Redis client and Lua scripts from app state.
63
+
64
+ Consolidates the repeated pattern:
65
+ redis = getattr(request.app.state, "redis", None)
66
+ scripts = getattr(request.app.state, "scripts", None)
67
+ """
68
+ return (
69
+ getattr(request.app.state, "redis", None),
70
+ getattr(request.app.state, "scripts", {}),
71
+ )
app/api/v1/chat.py CHANGED
@@ -8,7 +8,7 @@ from typing import Any, AsyncGenerator, Dict, List, Optional
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 _stream_chat_completion, chat_completion
13
  from app.utils.schema_utils import validate_response_format
14
 
@@ -31,8 +31,7 @@ async def _stream_events(
31
  temperature = body.get("temperature", 0.7)
32
  top_p = body.get("top_p", 0.9)
33
  response_format = body.get("response_format")
34
- redis = getattr(request.app.state, "redis", None)
35
- scripts = getattr(request.app.state, "scripts", None)
36
 
37
  try:
38
  async for chunk in _stream_chat_completion(
@@ -119,8 +118,7 @@ async def create_chat_completion(
119
  except ValueError as e:
120
  raise HTTPException(status_code=400, detail=str(e))
121
 
122
- redis = getattr(request.app.state, "redis", None)
123
- scripts = getattr(request.app.state, "scripts", None)
124
 
125
  if stream:
126
  return StreamingResponse(
 
8
  from fastapi import APIRouter, Depends, HTTPException, Request
9
  from fastapi.responses import StreamingResponse
10
 
11
+ from app.api.deps import get_redis_scripts, require_auth
12
  from app.services.chat_service import _stream_chat_completion, chat_completion
13
  from app.utils.schema_utils import validate_response_format
14
 
 
31
  temperature = body.get("temperature", 0.7)
32
  top_p = body.get("top_p", 0.9)
33
  response_format = body.get("response_format")
34
+ redis, scripts = get_redis_scripts(request)
 
35
 
36
  try:
37
  async for chunk in _stream_chat_completion(
 
118
  except ValueError as e:
119
  raise HTTPException(status_code=400, detail=str(e))
120
 
121
+ redis, scripts = get_redis_scripts(request)
 
122
 
123
  if stream:
124
  return StreamingResponse(
app/api/v1/csv_analysis.py CHANGED
@@ -6,7 +6,7 @@ from typing import Annotated, Any, Dict, List, Optional
6
  from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
7
  from pydantic import BaseModel, ValidationError
8
 
9
- from app.api.deps import require_auth
10
  from app.config import get_settings
11
  from app.services.chat_service import chat_completion
12
  from app.services.csv_analysis_service import (
@@ -221,8 +221,7 @@ async def csv_chat(
221
  {"role": "user", "content": query},
222
  ]
223
 
224
- redis = getattr(request.app.state, "redis", None)
225
- scripts = getattr(request.app.state, "scripts", None)
226
 
227
  try:
228
  ai_response = await chat_completion(
 
6
  from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
7
  from pydantic import BaseModel, ValidationError
8
 
9
+ from app.api.deps import get_redis_scripts, require_auth
10
  from app.config import get_settings
11
  from app.services.chat_service import chat_completion
12
  from app.services.csv_analysis_service import (
 
221
  {"role": "user", "content": query},
222
  ]
223
 
224
+ redis, scripts = get_redis_scripts(request)
 
225
 
226
  try:
227
  ai_response = await chat_completion(
app/api/v1/json_extract.py CHANGED
@@ -1,7 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import time
4
- from typing import Any, Dict, List, Optional
5
 
6
  from fastapi import APIRouter, Depends, HTTPException
7
  from pydantic import BaseModel, Field
 
1
  from __future__ import annotations
2
 
3
  import time
4
+ from typing import Any, Optional
5
 
6
  from fastapi import APIRouter, Depends, HTTPException
7
  from pydantic import BaseModel, Field
app/api/v1/qr_decoder.py CHANGED
@@ -1,7 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import time
4
- from typing import Any, Dict, List, Optional
5
 
6
  from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
7
  from pydantic import BaseModel, Field
 
1
  from __future__ import annotations
2
 
3
  import time
4
+ from typing import List, Optional
5
 
6
  from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
7
  from pydantic import BaseModel, Field
app/services/chat_service.py CHANGED
@@ -22,7 +22,8 @@ from app.config import (
22
  OPENROUTER_MIMIKA_MODEL,
23
  get_settings,
24
  )
25
- from app.utils.json_utils import extract_json_blocks, extract_single_json
 
26
  from app.utils.schema_utils import generate_schema_prompt, validate_against_schema
27
 
28
  logger = logging.getLogger(__name__)
@@ -51,6 +52,48 @@ def _refresh_keys() -> None:
51
  _refresh_keys()
52
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  def build_default_system_prompt(model_name: Optional[str]) -> str:
55
  name = model_name or "agentdeck-1.0"
56
  return (
@@ -185,33 +228,22 @@ async def call_openrouter_mimika(
185
  return None
186
 
187
  prepared = prepare_messages(messages, response_format)
188
- payload = {
189
- "model": OPENROUTER_MIMIKA_MODEL,
190
- "messages": prepared,
191
- "max_tokens": max_tokens,
192
- "temperature": temperature,
193
- "top_p": top_p,
194
- "stream": False,
195
- }
196
-
197
  logger.info("Calling OpenRouter Mimika...")
198
  try:
199
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
200
- async with aiohttp.ClientSession(timeout=timeout) as session:
201
- async with session.post(
202
- f"{OPENROUTER_MIMIKA_BASE_URL}{OPENROUTER_MIMIKA_CHAT_PATH}",
203
- json=payload,
204
- headers={
205
- "Authorization": f"Bearer {api_key}",
206
- "Content-Type": "application/json",
207
- },
208
- ) as resp:
209
- if resp.status != 200:
210
- logger.warning("OpenRouter Mimika HTTP %d", resp.status)
211
- return None
212
- data = await resp.json()
213
- attach_json_content(data, response_format)
214
- return data
215
  except Exception as exc:
216
  logger.warning("OpenRouter Mimika failed: %s", exc)
217
  return None
@@ -360,7 +392,7 @@ async def call_meganova(
360
  }
361
 
362
  try:
363
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
364
  async with aiohttp.ClientSession(timeout=timeout) as session:
365
  async with session.post(
366
  f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
@@ -442,7 +474,7 @@ async def call_aion_labs(
442
  logger.info("[aion] Attempt %s/%s", attempt + 1, total_tries)
443
 
444
  try:
445
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
446
  async with aiohttp.ClientSession(timeout=timeout) as session:
447
  async with session.post(
448
  f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
@@ -569,33 +601,22 @@ async def call_meganova_no_redis(
569
  return None
570
 
571
  prepared = prepare_messages(messages, response_format)
572
- payload = {
573
- "model": target_model or MODELS[1],
574
- "messages": prepared,
575
- "max_tokens": max_tokens,
576
- "temperature": temperature,
577
- "top_p": top_p,
578
- "stream": False,
579
- }
580
-
581
  logger.info("Calling MegaNova (no Redis)...")
582
  try:
583
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
584
- async with aiohttp.ClientSession(timeout=timeout) as session:
585
- async with session.post(
586
- f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
587
- json=payload,
588
- headers={
589
- "Authorization": f"Bearer {api_key}",
590
- "Content-Type": "application/json",
591
- },
592
- ) as resp:
593
- if resp.status != 200:
594
- logger.warning("MegaNova HTTP %d", resp.status)
595
- return None
596
- data = await resp.json()
597
- attach_json_content(data, response_format)
598
- return data
599
  except Exception as exc:
600
  logger.warning("MegaNova failed: %s", exc)
601
  return None
@@ -619,32 +640,22 @@ async def call_aion_labs_no_redis(
619
 
620
  for attempt, key in enumerate(keys[:3]):
621
  prepared = prepare_messages(messages, response_format)
622
- payload = {
623
- "model": model,
624
- "messages": prepared,
625
- "max_tokens": max_tokens,
626
- "temperature": temperature,
627
- "top_p": top_p,
628
- "stream": False,
629
- }
630
-
631
  logger.info("[aion] (no redis) Attempt %s/%s", attempt + 1, min(len(keys), 3))
632
  try:
633
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
634
- async with aiohttp.ClientSession(timeout=timeout) as session:
635
- async with session.post(
636
- f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
637
- json=payload,
638
- headers={
639
- "Authorization": f"Bearer {key}",
640
- "Content-Type": "application/json",
641
- },
642
- ) as resp:
643
- if resp.status != 200:
644
- continue
645
- data = await resp.json()
646
- attach_json_content(data, response_format)
647
- return data
648
  except Exception:
649
  continue
650
 
@@ -717,7 +728,7 @@ async def _stream_meganova(
717
  }
718
 
719
  try:
720
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
721
  async with aiohttp.ClientSession(timeout=timeout) as session:
722
  async with session.post(
723
  f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
@@ -766,7 +777,7 @@ async def _stream_meganova_no_redis(
766
  }
767
 
768
  try:
769
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
770
  async with aiohttp.ClientSession(timeout=timeout) as session:
771
  async with session.post(
772
  f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
@@ -819,7 +830,7 @@ async def _stream_aion_labs(
819
  }
820
 
821
  try:
822
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
823
  async with aiohttp.ClientSession(timeout=timeout) as session:
824
  async with session.post(
825
  f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
@@ -865,7 +876,7 @@ async def _stream_aion_labs_no_redis(
865
  }
866
 
867
  try:
868
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
869
  async with aiohttp.ClientSession(timeout=timeout) as session:
870
  async with session.post(
871
  f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
@@ -904,7 +915,7 @@ async def _stream_openrouter_mimika(
904
  }
905
 
906
  try:
907
- timeout = aiohttp.ClientTimeout(total=_settings.request_timeout_ms / 1000)
908
  async with aiohttp.ClientSession(timeout=timeout) as session:
909
  async with session.post(
910
  f"{OPENROUTER_MIMIKA_BASE_URL}{OPENROUTER_MIMIKA_CHAT_PATH}",
 
22
  OPENROUTER_MIMIKA_MODEL,
23
  get_settings,
24
  )
25
+ from app.utils.http_utils import create_session_timeout
26
+ from app.utils.json_utils import extract_single_json
27
  from app.utils.schema_utils import generate_schema_prompt, validate_against_schema
28
 
29
  logger = logging.getLogger(__name__)
 
52
  _refresh_keys()
53
 
54
 
55
+ async def _call_llm_api(
56
+ base_url: str,
57
+ path: str,
58
+ api_key: str,
59
+ model: str,
60
+ prepared_messages: List[Dict[str, str]],
61
+ *,
62
+ max_tokens: int = DEFAULT_MAX_TOKENS,
63
+ temperature: float = DEFAULT_TEMPERATURE,
64
+ top_p: float = DEFAULT_TOP_P,
65
+ stream: bool = False,
66
+ ) -> Optional[Dict[str, Any]]:
67
+ """Common LLM API call pattern: build payload → session → POST → parse JSON.
68
+
69
+ Consolidates the duplicated HTTP call logic across call_openrouter_mimika,
70
+ call_meganova, call_aion_labs, and their _no_redis/stream variants.
71
+ """
72
+ payload: Dict[str, Any] = {
73
+ "model": model,
74
+ "messages": prepared_messages,
75
+ "max_tokens": max_tokens,
76
+ "temperature": temperature,
77
+ "top_p": top_p,
78
+ "stream": stream,
79
+ }
80
+
81
+ timeout = create_session_timeout()
82
+ async with aiohttp.ClientSession(timeout=timeout) as session:
83
+ async with session.post(
84
+ f"{base_url}{path}",
85
+ json=payload,
86
+ headers={
87
+ "Authorization": f"Bearer {api_key}",
88
+ "Content-Type": "application/json",
89
+ },
90
+ ) as resp:
91
+ if resp.status != 200:
92
+ logger.warning("LLM API HTTP %d from %s", resp.status, base_url)
93
+ return None
94
+ return await resp.json()
95
+
96
+
97
  def build_default_system_prompt(model_name: Optional[str]) -> str:
98
  name = model_name or "agentdeck-1.0"
99
  return (
 
228
  return None
229
 
230
  prepared = prepare_messages(messages, response_format)
 
 
 
 
 
 
 
 
 
231
  logger.info("Calling OpenRouter Mimika...")
232
  try:
233
+ data = await _call_llm_api(
234
+ OPENROUTER_MIMIKA_BASE_URL,
235
+ OPENROUTER_MIMIKA_CHAT_PATH,
236
+ api_key,
237
+ OPENROUTER_MIMIKA_MODEL,
238
+ prepared,
239
+ max_tokens=max_tokens,
240
+ temperature=temperature,
241
+ top_p=top_p,
242
+ )
243
+ if data is None:
244
+ return None
245
+ attach_json_content(data, response_format)
246
+ return data
 
 
247
  except Exception as exc:
248
  logger.warning("OpenRouter Mimika failed: %s", exc)
249
  return None
 
392
  }
393
 
394
  try:
395
+ timeout = create_session_timeout()
396
  async with aiohttp.ClientSession(timeout=timeout) as session:
397
  async with session.post(
398
  f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
 
474
  logger.info("[aion] Attempt %s/%s", attempt + 1, total_tries)
475
 
476
  try:
477
+ timeout = create_session_timeout()
478
  async with aiohttp.ClientSession(timeout=timeout) as session:
479
  async with session.post(
480
  f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
 
601
  return None
602
 
603
  prepared = prepare_messages(messages, response_format)
 
 
 
 
 
 
 
 
 
604
  logger.info("Calling MegaNova (no Redis)...")
605
  try:
606
+ data = await _call_llm_api(
607
+ MEGANOVA_BASE_URL,
608
+ MEGANOVA_CHAT_PATH,
609
+ api_key,
610
+ target_model or MODELS[1],
611
+ prepared,
612
+ max_tokens=max_tokens,
613
+ temperature=temperature,
614
+ top_p=top_p,
615
+ )
616
+ if data is None:
617
+ return None
618
+ attach_json_content(data, response_format)
619
+ return data
 
 
620
  except Exception as exc:
621
  logger.warning("MegaNova failed: %s", exc)
622
  return None
 
640
 
641
  for attempt, key in enumerate(keys[:3]):
642
  prepared = prepare_messages(messages, response_format)
 
 
 
 
 
 
 
 
 
643
  logger.info("[aion] (no redis) Attempt %s/%s", attempt + 1, min(len(keys), 3))
644
  try:
645
+ data = await _call_llm_api(
646
+ AION_LABS_BASE_URL,
647
+ AION_LABS_CHAT_PATH,
648
+ key,
649
+ model,
650
+ prepared,
651
+ max_tokens=max_tokens,
652
+ temperature=temperature,
653
+ top_p=top_p,
654
+ )
655
+ if data is None:
656
+ continue
657
+ attach_json_content(data, response_format)
658
+ return data
 
659
  except Exception:
660
  continue
661
 
 
728
  }
729
 
730
  try:
731
+ timeout = create_session_timeout()
732
  async with aiohttp.ClientSession(timeout=timeout) as session:
733
  async with session.post(
734
  f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
 
777
  }
778
 
779
  try:
780
+ timeout = create_session_timeout()
781
  async with aiohttp.ClientSession(timeout=timeout) as session:
782
  async with session.post(
783
  f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
 
830
  }
831
 
832
  try:
833
+ timeout = create_session_timeout()
834
  async with aiohttp.ClientSession(timeout=timeout) as session:
835
  async with session.post(
836
  f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
 
876
  }
877
 
878
  try:
879
+ timeout = create_session_timeout()
880
  async with aiohttp.ClientSession(timeout=timeout) as session:
881
  async with session.post(
882
  f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
 
915
  }
916
 
917
  try:
918
+ timeout = create_session_timeout()
919
  async with aiohttp.ClientSession(timeout=timeout) as session:
920
  async with session.post(
921
  f"{OPENROUTER_MIMIKA_BASE_URL}{OPENROUTER_MIMIKA_CHAT_PATH}",
app/services/code_executor_service.py CHANGED
@@ -2,17 +2,15 @@ from __future__ import annotations
2
 
3
  import asyncio
4
  import logging
5
- import os
6
  import re
7
  import shutil
8
- import signal
9
- import subprocess
10
  import tempfile
11
  import time
12
  from pathlib import Path
13
  from typing import Optional
14
 
15
  from app.config import get_settings
 
16
 
17
  _logger = logging.getLogger(__name__)
18
 
@@ -168,33 +166,7 @@ class CodeExecutorService:
168
  cmd: list[str],
169
  timeout: float,
170
  ) -> dict:
171
- proc = subprocess.Popen(
172
- cmd,
173
- stdin=subprocess.DEVNULL,
174
- stdout=subprocess.PIPE,
175
- stderr=subprocess.PIPE,
176
- )
177
- try:
178
- stdout_bytes, stderr_bytes = proc.communicate(timeout=timeout)
179
- timed_out = False
180
- except subprocess.TimeoutExpired:
181
- try:
182
- if os.name == "nt":
183
- proc.kill()
184
- else:
185
- os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
186
- except Exception:
187
- proc.kill()
188
- stdout_bytes, stderr_bytes = proc.communicate()
189
- timed_out = True
190
-
191
- max_out = self._max_output_bytes
192
- return {
193
- "stdout": (stdout_bytes.decode("utf-8", errors="replace")[:max_out] if stdout_bytes else ""),
194
- "stderr": (stderr_bytes.decode("utf-8", errors="replace")[:max_out] if stderr_bytes else ""),
195
- "exit_code": proc.returncode,
196
- "timed_out": timed_out,
197
- }
198
 
199
  async def check_runtimes(self) -> dict[str, str]:
200
  status = {}
 
2
 
3
  import asyncio
4
  import logging
 
5
  import re
6
  import shutil
 
 
7
  import tempfile
8
  import time
9
  from pathlib import Path
10
  from typing import Optional
11
 
12
  from app.config import get_settings
13
+ from app.utils.subprocess_utils import run_subprocess
14
 
15
  _logger = logging.getLogger(__name__)
16
 
 
166
  cmd: list[str],
167
  timeout: float,
168
  ) -> dict:
169
+ return run_subprocess(cmd, timeout, self._max_output_bytes)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
  async def check_runtimes(self) -> dict[str, str]:
172
  status = {}
app/services/csv_analysis_service.py CHANGED
@@ -3,10 +3,7 @@ from __future__ import annotations
3
  import asyncio
4
  import json
5
  import logging
6
- import os
7
  import shutil
8
- import signal
9
- import subprocess
10
  import sys
11
  import tempfile
12
  import time
@@ -14,17 +11,16 @@ from pathlib import Path
14
  from typing import Any, Dict, List, Optional, Tuple, Union
15
  from urllib.parse import unquote, urlparse
16
 
17
- import aiohttp
18
-
19
  from app.services.code_executor_service import CodeSanitizer
20
  from app.services.dataset_metadata_service import extract_metadata
 
 
21
 
22
  logger = logging.getLogger(__name__)
23
 
24
  _PYTHON = getattr(sys, "executable", None) or "python3"
25
  _MAX_OUTPUT_BYTES = 65536
26
  _MAX_CONCURRENT = 8
27
- _DOWNLOAD_CHUNK_SIZE = 256 * 1024
28
  _DOWNLOAD_TIMEOUT = 120
29
 
30
  _semaphore = asyncio.Semaphore(_MAX_CONCURRENT)
@@ -35,18 +31,8 @@ class CSVAnalysisError(Exception):
35
 
36
 
37
  async def _download_file(url: str) -> bytes:
38
- timeout = aiohttp.ClientTimeout(total=_DOWNLOAD_TIMEOUT)
39
- try:
40
- async with aiohttp.ClientSession(timeout=timeout) as session:
41
- async with session.get(url) as resp:
42
- if resp.status != 200:
43
- raise CSVAnalysisError(f"HTTP {resp.status} when fetching {url}")
44
- chunks: List[bytes] = []
45
- async for chunk in resp.content.iter_chunked(_DOWNLOAD_CHUNK_SIZE):
46
- chunks.append(chunk)
47
- return b"".join(chunks)
48
- except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
49
- raise CSVAnalysisError(f"Download failed for {url}: {exc}") from exc
50
 
51
 
52
  async def _resolve_source(source: Union[str, bytes]) -> Tuple[bytes, Optional[str]]:
@@ -62,32 +48,7 @@ async def _resolve_source(source: Union[str, bytes]) -> Tuple[bytes, Optional[st
62
 
63
 
64
  def _run_subprocess(cmd: List[str], timeout: float, max_output: int) -> Dict[str, Any]:
65
- proc = subprocess.Popen(
66
- cmd,
67
- stdin=subprocess.DEVNULL,
68
- stdout=subprocess.PIPE,
69
- stderr=subprocess.PIPE,
70
- )
71
- try:
72
- stdout_bytes, stderr_bytes = proc.communicate(timeout=timeout)
73
- timed_out = False
74
- except subprocess.TimeoutExpired:
75
- try:
76
- if os.name == "nt":
77
- proc.kill()
78
- else:
79
- os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
80
- except Exception:
81
- proc.kill()
82
- stdout_bytes, stderr_bytes = proc.communicate()
83
- timed_out = True
84
-
85
- return {
86
- "stdout": (stdout_bytes.decode("utf-8", errors="replace")[:max_output] if stdout_bytes else ""),
87
- "stderr": (stderr_bytes.decode("utf-8", errors="replace")[:max_output] if stderr_bytes else ""),
88
- "exit_code": proc.returncode,
89
- "timed_out": timed_out,
90
- }
91
 
92
 
93
  _CHAT_SCRIPT = """\
 
3
  import asyncio
4
  import json
5
  import logging
 
6
  import shutil
 
 
7
  import sys
8
  import tempfile
9
  import time
 
11
  from typing import Any, Dict, List, Optional, Tuple, Union
12
  from urllib.parse import unquote, urlparse
13
 
 
 
14
  from app.services.code_executor_service import CodeSanitizer
15
  from app.services.dataset_metadata_service import extract_metadata
16
+ from app.utils.http_utils import download_url
17
+ from app.utils.subprocess_utils import run_subprocess
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
  _PYTHON = getattr(sys, "executable", None) or "python3"
22
  _MAX_OUTPUT_BYTES = 65536
23
  _MAX_CONCURRENT = 8
 
24
  _DOWNLOAD_TIMEOUT = 120
25
 
26
  _semaphore = asyncio.Semaphore(_MAX_CONCURRENT)
 
31
 
32
 
33
  async def _download_file(url: str) -> bytes:
34
+ data, _ = await download_url(url, timeout_seconds=_DOWNLOAD_TIMEOUT)
35
+ return data
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  async def _resolve_source(source: Union[str, bytes]) -> Tuple[bytes, Optional[str]]:
 
48
 
49
 
50
  def _run_subprocess(cmd: List[str], timeout: float, max_output: int) -> Dict[str, Any]:
51
+ return run_subprocess(cmd, timeout, max_output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  _CHAT_SCRIPT = """\
app/utils/http_utils.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import List, Optional, Tuple
7
+ from urllib.parse import unquote, urlparse
8
+
9
+ import aiohttp
10
+
11
+ from app.config import get_settings
12
+
13
+ _logger = logging.getLogger(__name__)
14
+ _settings = get_settings()
15
+
16
+
17
+ def create_session_timeout(total_seconds: Optional[float] = None) -> aiohttp.ClientTimeout:
18
+ """Create an aiohttp timeout using the configured request timeout."""
19
+ if total_seconds is None:
20
+ total_seconds = _settings.request_timeout_ms / 1000
21
+ return aiohttp.ClientTimeout(total=total_seconds)
22
+
23
+
24
+ async def download_url(
25
+ url: str,
26
+ *,
27
+ timeout_seconds: float = 120,
28
+ max_size_bytes: Optional[int] = None,
29
+ chunk_size: int = 256 * 1024,
30
+ ) -> Tuple[bytes, Optional[str]]:
31
+ """Download a file from a URL with streaming and size guard.
32
+
33
+ Returns (data, filename) where filename may be None.
34
+ Consolidates _download_file from csv_analysis_service and
35
+ _download_from_url from dataset_metadata_service.
36
+ """
37
+ timeout = aiohttp.ClientTimeout(total=timeout_seconds)
38
+ try:
39
+ async with aiohttp.ClientSession(timeout=timeout) as session:
40
+ async with session.get(url) as resp:
41
+ if resp.status != 200:
42
+ raise RuntimeError(f"HTTP {resp.status} when fetching {url}")
43
+
44
+ if max_size_bytes and resp.content_length and resp.content_length > max_size_bytes:
45
+ raise RuntimeError(
46
+ f"Remote file advertises {resp.content_length} bytes, "
47
+ f"limit is {max_size_bytes}"
48
+ )
49
+
50
+ chunks: List[bytes] = []
51
+ total = 0
52
+ async for chunk in resp.content.iter_chunked(chunk_size):
53
+ total += len(chunk)
54
+ if max_size_bytes and total > max_size_bytes:
55
+ raise RuntimeError(f"Download exceeded {max_size_bytes} bytes")
56
+ chunks.append(chunk)
57
+
58
+ data = b"".join(chunks)
59
+ except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
60
+ raise RuntimeError(f"Download failed for {url}: {exc}") from exc
61
+
62
+ parsed = urlparse(url)
63
+ filename = unquote(Path(parsed.path).name) if parsed.path else None
64
+ return data, filename
app/utils/subprocess_utils.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ from typing import Dict, Any, List
8
+
9
+ _logger = logging.getLogger(__name__)
10
+
11
+
12
+ def run_subprocess(
13
+ cmd: List[str],
14
+ timeout: float,
15
+ max_output: int = 65536,
16
+ ) -> Dict[str, Any]:
17
+ """Run a subprocess with timeout handling and output capture.
18
+
19
+ Consolidates the duplicate subprocess logic from code_executor_service
20
+ and csv_analysis_service.
21
+ """
22
+ proc = subprocess.Popen(
23
+ cmd,
24
+ stdin=subprocess.DEVNULL,
25
+ stdout=subprocess.PIPE,
26
+ stderr=subprocess.PIPE,
27
+ )
28
+ try:
29
+ stdout_bytes, stderr_bytes = proc.communicate(timeout=timeout)
30
+ timed_out = False
31
+ except subprocess.TimeoutExpired:
32
+ try:
33
+ if os.name == "nt":
34
+ proc.kill()
35
+ else:
36
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
37
+ except Exception:
38
+ proc.kill()
39
+ stdout_bytes, stderr_bytes = proc.communicate()
40
+ timed_out = True
41
+
42
+ return {
43
+ "stdout": (stdout_bytes.decode("utf-8", errors="replace")[:max_output] if stdout_bytes else ""),
44
+ "stderr": (stderr_bytes.decode("utf-8", errors="replace")[:max_output] if stderr_bytes else ""),
45
+ "exit_code": proc.returncode,
46
+ "timed_out": timed_out,
47
+ }