Files
NexQuant/rdagent/oai/backend/litellm.py
T
you-n-g 09be71d586 feat: parallel loop running based on asyncio (#932)
* refactor: split workflow into pkg, add WorkflowTracker & wait_retry

* feat: add async LoopBase with parallel workers and step semaphores

* fix: replace pickle with dill and run blocking tasks via joblib wrapper

* feat: add log format settings, dynamic parallelism & pickle-based snapshot

* fix: default step semaphore to 1 and avoid subprocess when single worker

* merge bowen's changes

* merge tim's changes

* refactor: extract component task mapping, add conditional logger setup

* lint

* refactor: add type hints and safer remain_time metric logging in workflow

* lint

* fix: allow BadRequestError to be pickled via custom copyreg reducer

* fix: stop loop when LoopTerminationError is raised in LoopBase

* lint

* refactor: make log tag context-local using ContextVar for thread safety

* feat: add subproc_step flag and helper to decide subprocess execution

* fix: use ./cache path and normalize relative volume bind paths

* fix: reset loop_idx to 0 on loop restart/resume to ensure correct flow

* fix: avoid chmod on cache and input dirs in Env timeout wrapper

* fix: skip chmod on 'cache' and 'input' dirs using find -prune

* fix: restrict chmod to immediate mount dirs excluding cache/input

* fix: chmod cache and input dirs alongside their contents after entry run

* fix: guard chmod with directory checks for cache and input

* fix: prefix mount_path in chmod command for cache/input dirs

* fix: drop quotes from find exclude patterns to ensure chmod executes

* fix: skip chmod on cache/input directories to avoid warning spam

* feat: support string volume mappings and poll subprocess stdout/stderr

* support remove symbolic link

* test: use dynamic home path and code volume in LocalEnv local_simple

* fix: skip trace and progress update when loop step is withdrawn

* refactor: add clean_workspace util and non-destructive workspace backup

* fix: preserve symlinks when backing up workspace with copytree

* fix: prevent AttributeError when _pbar not yet initialized in LoopBase

* perf: replace shutil.copytree with rsync for faster workspace backup

* fix: cast log directory Path to str in tar command of data science loop

* fix: use portable 'cp -r -P' instead of rsync for workspace backup

* fix: add retry and logging to workspace backup for robustness

* refactor: extract backup_folder helper and reuse in DataScienceRDLoop

* fix: propagate backup errors & default _pbar getattr to avoid error

* fix the division by zero bug

* refactor: execute RD loops via asyncio.run and add necessary imports

* lint

* lint

* lint

---------

Co-authored-by: Xu <v-xuminrui@microsoft.com>
2025-06-12 11:44:14 +08:00

176 lines
6.5 KiB
Python

import copyreg
from typing import Any, Literal, cast
import numpy as np
from litellm import (
BadRequestError,
completion,
completion_cost,
embedding,
supports_response_schema,
token_counter,
)
from rdagent.log import LogColors
from rdagent.log import rdagent_logger as logger
from rdagent.oai.backend.base import APIBackend
from rdagent.oai.llm_conf import LLMSettings
# NOTE: Patching! Otherwise, the exception will call the constructor and with following error:
# `BadRequestError.__init__() missing 2 required positional arguments: 'model' and 'llm_provider'`
def _reduce_no_init(exc: Exception) -> tuple:
cls = exc.__class__
return (cls.__new__, (cls,), exc.__dict__)
# suppose you want to apply this to MyError
copyreg.pickle(BadRequestError, _reduce_no_init)
class LiteLLMSettings(LLMSettings):
class Config:
env_prefix = "LITELLM_"
"""Use `LITELLM_` as prefix for environment variables"""
# Placeholder for LiteLLM specific settings, so far it's empty
LITELLM_SETTINGS = LiteLLMSettings()
logger.info(f"{LITELLM_SETTINGS}")
ACC_COST = 0.0
class LiteLLMAPIBackend(APIBackend):
"""LiteLLM implementation of APIBackend interface"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
def _calculate_token_from_messages(self, messages: list[dict[str, Any]]) -> int:
"""
Calculate the token count from messages
"""
num_tokens = token_counter(
model=LITELLM_SETTINGS.chat_model,
messages=messages,
)
logger.info(f"{LogColors.CYAN}Token count: {LogColors.END} {num_tokens}", tag="debug_litellm_token")
return num_tokens
def _create_embedding_inner_function(
self, input_content_list: list[str], *args: Any, **kwargs: Any
) -> list[list[float]]: # noqa: ARG002
"""
Call the embedding function
"""
model_name = LITELLM_SETTINGS.embedding_model
logger.info(f"{LogColors.GREEN}Using emb model{LogColors.END} {model_name}", tag="debug_litellm_emb")
logger.info(f"Creating embedding for: {input_content_list}", tag="debug_litellm_emb")
response = embedding(
model=model_name,
input=input_content_list,
*args,
**kwargs,
)
response_list = [data["embedding"] for data in response.data]
return response_list
def _create_chat_completion_inner_function( # type: ignore[no-untyped-def] # noqa: C901, PLR0912, PLR0915
self,
messages: list[dict[str, Any]],
json_mode: bool = False,
*args,
**kwargs,
) -> tuple[str, str | None]:
"""
Call the chat completion function
"""
if json_mode and supports_response_schema(model=LITELLM_SETTINGS.chat_model):
kwargs["response_format"] = {"type": "json_object"}
logger.info(self._build_log_messages(messages), tag="llm_messages")
# Call LiteLLM completion
model = LITELLM_SETTINGS.chat_model
temperature = LITELLM_SETTINGS.chat_temperature
max_tokens = LITELLM_SETTINGS.chat_max_tokens
reasoning_effort = LITELLM_SETTINGS.reasoning_effort
if LITELLM_SETTINGS.chat_model_map:
for t, mc in LITELLM_SETTINGS.chat_model_map.items():
if t in logger._tag:
model = mc["model"]
if "temperature" in mc:
temperature = float(mc["temperature"])
if "max_tokens" in mc:
max_tokens = int(mc["max_tokens"])
if "reasoning_effort" in mc:
if mc["reasoning_effort"] in ["low", "medium", "high"]:
reasoning_effort = cast(Literal["low", "medium", "high"], mc["reasoning_effort"])
else:
reasoning_effort = None
break
response = completion(
model=model,
messages=messages,
stream=LITELLM_SETTINGS.chat_stream,
temperature=temperature,
max_tokens=max_tokens,
reasoning_effort=reasoning_effort,
max_retries=0,
**kwargs,
)
logger.info(f"{LogColors.GREEN}Using chat model{LogColors.END} {model}", tag="llm_messages")
if LITELLM_SETTINGS.chat_stream:
logger.info(f"{LogColors.BLUE}assistant:{LogColors.END}", tag="llm_messages")
content = ""
finish_reason = None
for message in response:
if message["choices"][0]["finish_reason"]:
finish_reason = message["choices"][0]["finish_reason"]
if "content" in message["choices"][0]["delta"]:
chunk = (
message["choices"][0]["delta"]["content"] or ""
) # when finish_reason is "stop", content is None
content += chunk
logger.info(LogColors.CYAN + chunk + LogColors.END, raw=True, tag="llm_messages")
logger.info("\n", raw=True, tag="llm_messages")
else:
content = str(response.choices[0].message.content)
finish_reason = response.choices[0].finish_reason
finish_reason_str = (
f"({LogColors.RED}Finish reason: {finish_reason}{LogColors.END})"
if finish_reason and finish_reason != "stop"
else ""
)
logger.info(f"{LogColors.BLUE}assistant:{LogColors.END} {finish_reason_str}\n{content}", tag="llm_messages")
global ACC_COST
try:
cost = completion_cost(model=model, messages=messages, completion=content)
except Exception as e:
logger.warning(f"Cost calculation failed for model {model}: {e}. Skip cost statistics.")
cost = np.nan
else:
ACC_COST += cost
logger.info(
f"Current Cost: ${float(cost):.10f}; Accumulated Cost: ${float(ACC_COST):.10f}; {finish_reason=}",
)
prompt_tokens = token_counter(model=model, messages=messages)
completion_tokens = token_counter(model=model, text=content)
logger.log_object(
{
"model": model,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"cost": cost,
"accumulated_cost": ACC_COST,
},
tag="token_cost",
)
return content, finish_reason