Adds the openai-codex OAuth provider, login command, Settings metadata, supported default model, and focused adapter regression tests.
269 lines
9.9 KiB
Python
269 lines
9.9 KiB
Python
"""LLM factory and JSON extraction helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
try:
|
|
from dotenv import load_dotenv
|
|
except ImportError:
|
|
load_dotenv = None # type: ignore
|
|
|
|
try:
|
|
from langchain_openai import ChatOpenAI
|
|
except ImportError:
|
|
ChatOpenAI = None # type: ignore
|
|
|
|
|
|
if ChatOpenAI is not None:
|
|
class ChatOpenAIWithReasoning(ChatOpenAI): # type: ignore[misc,valid-type]
|
|
"""ChatOpenAI that preserves provider reasoning across invoke + stream.
|
|
|
|
langchain-openai 0.3.x drops non-standard fields in three paths:
|
|
* _convert_dict_to_message — invoke / ainvoke (inbound)
|
|
* _convert_delta_to_message_chunk — stream / astream (inbound)
|
|
* _convert_message_to_dict — request serialization (outbound)
|
|
Moonshot/DeepSeek emit `reasoning_content`; OpenRouter relays as
|
|
`reasoning`. Inbound paths normalize to additional_kwargs["reasoning_content"];
|
|
outbound path re-injects it so strict providers (kimi-k2.5) accept
|
|
multi-turn continuations.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _capture(src: Any, msg: Any) -> None:
|
|
if value := src.get("reasoning_content") or src.get("reasoning"):
|
|
msg.additional_kwargs["reasoning_content"] = value
|
|
|
|
def _create_chat_result(self, response, generation_info=None): # type: ignore[override]
|
|
result = super()._create_chat_result(response, generation_info)
|
|
raw = response if isinstance(response, dict) else response.model_dump()
|
|
for gen, choice in zip(result.generations, raw["choices"]):
|
|
self._capture(choice["message"], gen.message)
|
|
return result
|
|
|
|
def _convert_chunk_to_generation_chunk( # type: ignore[override]
|
|
self,
|
|
chunk: dict,
|
|
default_chunk_class: type,
|
|
base_generation_info: Optional[dict],
|
|
):
|
|
gen = super()._convert_chunk_to_generation_chunk(
|
|
chunk, default_chunk_class, base_generation_info
|
|
)
|
|
if gen is None:
|
|
return None
|
|
choices = chunk.get("choices") or chunk.get("chunk", {}).get("choices")
|
|
if choices:
|
|
self._capture(choices[0]["delta"], gen.message)
|
|
return gen
|
|
|
|
def _get_request_payload( # type: ignore[override]
|
|
self,
|
|
input_: Any,
|
|
*,
|
|
stop: Optional[list[str]] = None,
|
|
**kwargs: Any,
|
|
) -> dict:
|
|
"""Re-inject reasoning_content and normalize assistant content.
|
|
|
|
LangChain strips ``reasoning_content`` when serializing AIMessages
|
|
back to OpenAI wire format. Moonshot kimi-k2.5 also rejects
|
|
assistant turns where ``content`` is null or ``reasoning_content``
|
|
is absent, breaking ReAct continuations after a tool call (#39).
|
|
"""
|
|
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
|
messages = super()._convert_input(input_).to_messages()
|
|
for i, m in enumerate(payload["messages"]):
|
|
if m.get("role") != "assistant":
|
|
continue
|
|
if m.get("content") is None:
|
|
m["content"] = ""
|
|
m["reasoning_content"] = messages[i].additional_kwargs.get("reasoning_content", "")
|
|
return payload
|
|
else:
|
|
ChatOpenAIWithReasoning = None # type: ignore
|
|
|
|
AGENT_DIR = Path(__file__).resolve().parents[2]
|
|
|
|
# .env search order: ~/.vibe-trading/.env → agent/.env → $CWD/.env
|
|
_ENV_CANDIDATES = [
|
|
Path.home() / ".vibe-trading" / ".env",
|
|
AGENT_DIR / ".env",
|
|
Path.cwd() / ".env",
|
|
]
|
|
|
|
_dotenv_loaded: bool = False
|
|
|
|
|
|
def _load_env_file(path: Path) -> None:
|
|
"""Load a single .env file into os.environ (setdefault, no override)."""
|
|
if load_dotenv is not None:
|
|
load_dotenv(dotenv_path=path, override=False)
|
|
else:
|
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
if key:
|
|
os.environ.setdefault(key, value.strip().strip('"').strip("'"))
|
|
|
|
|
|
def _ensure_dotenv() -> None:
|
|
"""Load `.env` from the first found candidate path."""
|
|
global _dotenv_loaded
|
|
if _dotenv_loaded:
|
|
return
|
|
for candidate in _ENV_CANDIDATES:
|
|
if candidate.exists():
|
|
_load_env_file(candidate)
|
|
break
|
|
_dotenv_loaded = True
|
|
|
|
|
|
def _sync_provider_env() -> None:
|
|
"""Map provider-specific env vars to OPENAI_* for ChatOpenAI.
|
|
|
|
Each entry: provider_name -> (api_key_env, base_url_env).
|
|
All base URLs must be set explicitly in .env — no hardcoded defaults.
|
|
api_key_env=None means no key required (e.g. Ollama local).
|
|
"""
|
|
_ensure_dotenv()
|
|
provider = os.getenv("LANGCHAIN_PROVIDER", "openai").lower()
|
|
|
|
if provider in {"openai-codex", "openai_codex"}:
|
|
codex_url = os.getenv("OPENAI_CODEX_BASE_URL", "https://chatgpt.com/backend-api/codex/responses")
|
|
os.environ["OPENAI_API_BASE"] = codex_url
|
|
os.environ["OPENAI_BASE_URL"] = codex_url
|
|
os.environ.pop("OPENAI_API_KEY", None)
|
|
return
|
|
|
|
# (api_key_env, base_url_env)
|
|
_PROVIDER_MAP: dict[str, tuple[str | None, str]] = {
|
|
"openai": ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
|
|
"openrouter": ("OPENROUTER_API_KEY", "OPENROUTER_BASE_URL"),
|
|
"deepseek": ("DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"),
|
|
"gemini": ("GEMINI_API_KEY", "GEMINI_BASE_URL"),
|
|
"groq": ("GROQ_API_KEY", "GROQ_BASE_URL"),
|
|
"dashscope": ("DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL"),
|
|
"qwen": ("DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL"),
|
|
"zhipu": ("ZHIPU_API_KEY", "ZHIPU_BASE_URL"),
|
|
"moonshot": ("MOONSHOT_API_KEY", "MOONSHOT_BASE_URL"),
|
|
"minimax": ("MINIMAX_API_KEY", "MINIMAX_BASE_URL"),
|
|
"mimo": ("MIMO_API_KEY", "MIMO_BASE_URL"),
|
|
"zai": ("ZAI_API_KEY", "ZAI_BASE_URL"),
|
|
"ollama": (None, "OLLAMA_BASE_URL"),
|
|
}
|
|
|
|
spec = _PROVIDER_MAP.get(provider, _PROVIDER_MAP["openai"])
|
|
key_env, base_env = spec
|
|
|
|
# Resolve API key: provider-specific env → OPENAI_API_KEY fallback
|
|
if key_env is not None:
|
|
api_key = os.getenv(key_env, "") or os.getenv("OPENAI_API_KEY", "")
|
|
else:
|
|
api_key = os.getenv("OPENAI_API_KEY", "") or "ollama"
|
|
|
|
# Resolve base URL: provider-specific env → OPENAI_BASE_URL fallback
|
|
base_url = os.getenv(base_env, "") or os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "")
|
|
|
|
if api_key:
|
|
os.environ["OPENAI_API_KEY"] = api_key
|
|
if base_url:
|
|
os.environ["OPENAI_API_BASE"] = base_url
|
|
os.environ.setdefault("OPENAI_BASE_URL", base_url)
|
|
|
|
|
|
def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any:
|
|
"""Construct a ChatOpenAI instance.
|
|
|
|
Args:
|
|
model_name: Model name; defaults to LANGCHAIN_MODEL_NAME.
|
|
callbacks: Optional LangChain callbacks.
|
|
|
|
Returns:
|
|
ChatOpenAI instance.
|
|
|
|
Raises:
|
|
RuntimeError: If langchain-openai is missing or LANGCHAIN_MODEL_NAME is unset.
|
|
"""
|
|
_sync_provider_env()
|
|
name = model_name or os.getenv("LANGCHAIN_MODEL_NAME", "").strip()
|
|
if not name:
|
|
raise RuntimeError("LANGCHAIN_MODEL_NAME is not set")
|
|
temperature = float(os.getenv("LANGCHAIN_TEMPERATURE", "0.0"))
|
|
provider = os.getenv("LANGCHAIN_PROVIDER", "openai").lower()
|
|
if provider in {"openai-codex", "openai_codex"}:
|
|
from src.providers.openai_codex import OpenAICodexLLM
|
|
|
|
effort = os.getenv("LANGCHAIN_REASONING_EFFORT", "").strip().lower()
|
|
return OpenAICodexLLM(
|
|
model=name,
|
|
temperature=temperature,
|
|
timeout=int(os.getenv("TIMEOUT_SECONDS", "120")),
|
|
reasoning_effort=effort or None,
|
|
)
|
|
|
|
if ChatOpenAI is None:
|
|
raise RuntimeError("langchain-openai is not installed")
|
|
# MiniMax requires temperature in (0.0, 1.0] — clamp to 0.01 when the
|
|
# default 0.0 is used to avoid an API validation error.
|
|
if provider == "minimax" and temperature <= 0.0:
|
|
temperature = 0.01
|
|
# Optional reasoning activation for relays requiring opt-in (e.g. OpenRouter).
|
|
# Moonshot/DeepSeek official APIs emit reasoning by default and ignore this field.
|
|
effort = os.getenv("LANGCHAIN_REASONING_EFFORT", "").strip().lower()
|
|
return ChatOpenAIWithReasoning(
|
|
model=name,
|
|
temperature=temperature,
|
|
timeout=int(os.getenv("TIMEOUT_SECONDS", "120")),
|
|
max_retries=int(os.getenv("MAX_RETRIES", "2")),
|
|
callbacks=callbacks,
|
|
extra_body={"reasoning": {"effort": effort}} if effort else None,
|
|
)
|
|
|
|
|
|
def _extract_balanced_json(text: str) -> Optional[Dict[str, Any]]:
|
|
"""Extract the outermost JSON object from text using bracket balancing.
|
|
|
|
Args:
|
|
text: Text that may embed a JSON object.
|
|
|
|
Returns:
|
|
Parsed dict, or None on failure.
|
|
"""
|
|
start = -1
|
|
depth = 0
|
|
in_string = False
|
|
escape = False
|
|
|
|
for i, ch in enumerate(text):
|
|
if escape:
|
|
escape = False
|
|
continue
|
|
if ch == "\\" and in_string:
|
|
escape = True
|
|
continue
|
|
if ch == '"':
|
|
in_string = not in_string
|
|
continue
|
|
if in_string:
|
|
continue
|
|
if ch == "{":
|
|
if depth == 0:
|
|
start = i
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0 and start >= 0:
|
|
candidate = text[start : i + 1]
|
|
try:
|
|
return json.loads(candidate)
|
|
except json.JSONDecodeError:
|
|
start = -1
|
|
return None
|