bcee009917
* Add Novita as an LLM provider Registers Novita (novita.ai) as an OpenAI-routed provider, following the same pattern as Requesty/Atlas Cloud/SiliconFlow: a base_url + API key env var entry in _OPENAI_ROUTED_PROVIDERS, a handful of model registry entries (DeepSeek/Qwen/GLM), onboarding wizard support (constants/steps/wizard/ helpers), a key validator using the auth-preflight sentinel pattern (Novita's /v1/models endpoint returns the public catalog even for an invalid key, so auth must be checked via a chat completion instead), and a host-to-provider mapping entry for error attribution. * Recommend Novita's current flagship models The models listed for Novita were older ids that no longer reflect what the platform leads with. Point the recommendations at the three current flagships instead, each verified against api.novita.ai: moonshotai/kimi-k3 1M context, native vision zai-org/glm-5.2 1M context, long-horizon agentic work deepseek/deepseek-v4-flash-0731 1M context, cheapest of the three Context windows, output limits, input modalities and pricing were taken from the live /openai/v1/models response rather than carried over. * Keep branch CI workflow files unchanged (no workflow OAuth scope) Co-authored-by: multica-agent <github@multica.ai> * ci: restore workflow files to match main --------- Co-authored-by: jax-novita <jax-novita@users.noreply.github.com> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Dinos Papakostas <dinospk1999@gmail.com> Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
643 lines
27 KiB
Python
643 lines
27 KiB
Python
"""LLM model configuration based on LangChain init_chat_model.
|
|
|
|
This module provides a unified interface for creating chat model instances
|
|
with support for multiple providers (Anthropic, OpenAI, Google GenAI, Atlas
|
|
Cloud, MiniMax (Anthropic-compatible), NVIDIA, SiliconFlow, OpenRouter, Requesty,
|
|
Novita, ZhipuAI, Volcengine, DashScope, DashScope-Code, DeepSeek, Ollama, and
|
|
custom OpenAI/Anthropic-compatible endpoints) and convenient short names for
|
|
common models.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import warnings
|
|
from functools import lru_cache
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from langchain.chat_models import init_chat_model
|
|
|
|
from ..config.settings import (
|
|
OPENROUTER_DEFAULT_APP_CATEGORIES,
|
|
OPENROUTER_DEFAULT_APP_TITLE,
|
|
OPENROUTER_DEFAULT_HTTP_REFERER,
|
|
)
|
|
from .context_window import apply_known_context_window
|
|
from .deepseek import EvoChatDeepSeek
|
|
from .patches import (
|
|
_is_ccproxy_codex,
|
|
_patch_anthropic_strip_foreign_reasoning,
|
|
_patch_anthropic_structured_output,
|
|
_patch_ccproxy_system_to_developer,
|
|
_patch_openai_compat_content,
|
|
_patch_openrouter_strip_responses_reasoning,
|
|
_patch_openrouter_structured_output,
|
|
)
|
|
from .registry import (
|
|
_ANTHROPIC_ROUTED_PROVIDERS,
|
|
_MODEL_ENTRIES,
|
|
_OPENAI_ROUTED_PROVIDERS,
|
|
_OPENROUTER_JSON_SCHEMA_STRUCTURED_OUTPUT_MODELS, # noqa: F401 — re-exported
|
|
_THINKING_CAPABLE_PROVIDERS,
|
|
DEFAULT_MODEL,
|
|
MODELS,
|
|
_is_mandatory_thinking_kimi,
|
|
get_model_info, # noqa: F401 — re-exported for existing import sites
|
|
get_models_for_provider, # noqa: F401 — re-exported for existing import sites
|
|
list_model_picker_entries, # noqa: F401 — re-exported for existing import sites
|
|
list_models, # noqa: F401 — re-exported for existing import sites
|
|
list_models_by_provider, # noqa: F401 — re-exported for existing import sites
|
|
)
|
|
|
|
# Minimum Codex CLI version advertised when no explicit override is set. Newer
|
|
# installed versions are advertised automatically.
|
|
_CODEX_CLIENT_VERSION_FALLBACK = "0.144.1"
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _installed_codex_client_version() -> str:
|
|
"""Return the installed Codex CLI version, or an empty string."""
|
|
try:
|
|
result = subprocess.run(
|
|
["codex", "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return ""
|
|
|
|
if result.returncode != 0:
|
|
return ""
|
|
match = re.search(r"\b(\d+\.\d+\.\d+)\b", result.stdout + result.stderr)
|
|
return match.group(1) if match else ""
|
|
|
|
|
|
def _resolve_codex_client_version() -> str:
|
|
"""Resolve an explicit override or the newer of installed and minimum versions."""
|
|
override = os.environ.get("EVOSCIENTIST_CODEX_CLIENT_VERSION", "").strip()
|
|
if override:
|
|
return override
|
|
|
|
installed = _installed_codex_client_version()
|
|
if installed and tuple(map(int, installed.split("."))) >= tuple(
|
|
map(int, _CODEX_CLIENT_VERSION_FALLBACK.split("."))
|
|
):
|
|
return installed
|
|
return _CODEX_CLIENT_VERSION_FALLBACK
|
|
|
|
|
|
def _resolve_reasoning_effort(default: str) -> str:
|
|
"""Return the configured reasoning effort or a provider-specific default."""
|
|
return os.environ.get("EVOSCIENTIST_REASONING_EFFORT", "").strip() or default
|
|
|
|
|
|
# Qwen 3.8 Max canonical levels and documented OpenAI alias mappings:
|
|
# https://docs.qwencloud.com/api-reference/chat/openai-chat#reasoning-effort
|
|
_DASHSCOPE_QWEN38_REASONING_EFFORTS = frozenset(
|
|
{"none", "minimal", "low", "medium", "high", "xhigh", "max"}
|
|
)
|
|
|
|
|
|
def _validate_dashscope_reasoning_effort(
|
|
provider: str,
|
|
model_id: str,
|
|
effort: str,
|
|
) -> None:
|
|
"""Reject reasoning levels unsupported by DashScope Qwen 3.8 Max."""
|
|
if effort not in _DASHSCOPE_QWEN38_REASONING_EFFORTS:
|
|
choices = ", ".join(sorted(_DASHSCOPE_QWEN38_REASONING_EFFORTS))
|
|
raise ValueError(
|
|
f"Unsupported EVOSCIENTIST_REASONING_EFFORT={effort!r} for "
|
|
f"{provider} model {model_id!r}. Supported values: {choices}."
|
|
)
|
|
|
|
|
|
def _apply_openai_compat_reasoning_config(
|
|
provider: str,
|
|
model_id: str,
|
|
kwargs: dict[str, Any],
|
|
) -> None:
|
|
"""Apply reasoning controls supported by OpenAI-compatible providers.
|
|
|
|
Routed providers deliberately skip the native-OpenAI branch in
|
|
:func:`_apply_auto_config`, because most compatible endpoints reject
|
|
OpenAI-only ``reasoning`` payloads. A small subset does support the
|
|
standard ``reasoning_effort`` field, though:
|
|
|
|
* DashScope Qwen 3.8 Max supports ``low`` / ``medium`` / ``xhigh`` and
|
|
maps the OpenAI aliases (including ``none``). Its server default is
|
|
extremely large, so use the standard ``medium`` level unless the user
|
|
selected another level.
|
|
* ``custom-openai`` is user-owned. Forward an *explicit* setting only;
|
|
with no setting, preserve compatibility with endpoints that reject the
|
|
field (including many non-reasoning OpenAI-compatible APIs).
|
|
|
|
Explicit caller kwargs always win.
|
|
"""
|
|
configured = os.environ.get("EVOSCIENTIST_REASONING_EFFORT", "").strip()
|
|
short_model_id = model_id.rsplit("/", 1)[-1]
|
|
|
|
if provider == "dashscope" and short_model_id.startswith("qwen3.8-max"):
|
|
if "reasoning_effort" not in kwargs:
|
|
effort = configured or "medium"
|
|
_validate_dashscope_reasoning_effort(provider, model_id, effort)
|
|
kwargs["reasoning_effort"] = effort
|
|
return
|
|
|
|
if provider == "custom-openai" and configured:
|
|
kwargs.setdefault("reasoning_effort", configured)
|
|
|
|
|
|
def _is_deepseek_endpoint(base_url: str | None) -> bool:
|
|
"""Return whether an OpenAI-compatible endpoint is DeepSeek's API."""
|
|
if not base_url:
|
|
return False
|
|
try:
|
|
return urlparse(base_url).hostname == "api.deepseek.com"
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
_TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"}
|
|
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
|
|
|
|
# OpenRouter app attribution (issue #339). Default values are the single source
|
|
# of truth in config/settings.py (imported above); langchain-openrouter maps
|
|
# app_url → HTTP-Referer, app_title → X-Title, app_categories →
|
|
# X-OpenRouter-Categories. OpenRouter honors at most this many categories per
|
|
# request (server-side limit) and silently ignores the rest, so the sent list is
|
|
# capped to this many below. https://openrouter.ai/docs/app-attribution
|
|
_OPENROUTER_MAX_CATEGORIES_PER_REQUEST = 2
|
|
|
|
|
|
def _env_flag_enabled(name: str) -> bool:
|
|
return os.environ.get(name, "").strip().lower() in _TRUTHY_ENV_VALUES
|
|
|
|
|
|
def _env_flag_disabled(name: str) -> bool:
|
|
value = os.environ.get(name)
|
|
return value is not None and value.strip().lower() in _FALSEY_ENV_VALUES
|
|
|
|
|
|
def _supports_openrouter_anthropic_prompt_cache(
|
|
provider: str | None, model_id: str
|
|
) -> bool:
|
|
"""Return whether EvoScientist should declare Claude caching for a router.
|
|
|
|
Both OpenRouter and Requesty are OpenAI-compatible routers that forward an
|
|
Anthropic-style ``cache_control`` declaration through to Claude models
|
|
addressed as ``anthropic/...``. Implicit caching is handled upstream for
|
|
most providers, but Claude prompt caching needs the explicit declaration.
|
|
"""
|
|
return provider in ("openrouter", "requesty") and model_id.startswith(
|
|
("anthropic/", "~anthropic/")
|
|
)
|
|
|
|
|
|
def _has_cache_control_override(kwargs: dict[str, Any]) -> bool:
|
|
"""Return whether the caller already supplied cache-control settings."""
|
|
if "cache_control" in kwargs:
|
|
return True
|
|
model_kwargs = kwargs.get("model_kwargs")
|
|
if model_kwargs is None:
|
|
return False
|
|
if not isinstance(model_kwargs, dict):
|
|
warnings.warn(
|
|
"OpenRouter Anthropic prompt caching was not applied because "
|
|
"`model_kwargs` is not a dict; pass cache_control explicitly or use "
|
|
"a dict-shaped model_kwargs.",
|
|
UserWarning,
|
|
stacklevel=3,
|
|
)
|
|
return True
|
|
return "cache_control" in model_kwargs
|
|
|
|
|
|
def _apply_openrouter_anthropic_prompt_cache(
|
|
provider: str | None,
|
|
model_id: str,
|
|
kwargs: dict[str, Any],
|
|
) -> None:
|
|
"""Declare router Claude prompt caching unless explicitly disabled.
|
|
|
|
OpenRouter and Requesty both handle implicit caching for most providers,
|
|
but Claude prompt caching needs an Anthropic-style cache-control
|
|
declaration. Each router honours its own opt-out env flag
|
|
(``EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE`` /
|
|
``EVOSCIENTIST_REQUESTY_ANTHROPIC_PROMPT_CACHE``).
|
|
"""
|
|
if provider is None:
|
|
return
|
|
disable_flag = {
|
|
"openrouter": "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE",
|
|
"requesty": "EVOSCIENTIST_REQUESTY_ANTHROPIC_PROMPT_CACHE",
|
|
}.get(provider)
|
|
if disable_flag is not None and _env_flag_disabled(disable_flag):
|
|
return
|
|
if not _supports_openrouter_anthropic_prompt_cache(provider, model_id):
|
|
return
|
|
if _has_cache_control_override(kwargs):
|
|
return
|
|
kwargs.setdefault("model_kwargs", {})["cache_control"] = {"type": "ephemeral"}
|
|
|
|
|
|
def _enable_openrouter_429_retry(chat_model: Any) -> None:
|
|
"""Add 429 to the OpenRouter SDK's retryable status codes (default ["5XX"]).
|
|
|
|
Upstream rate limits ("temporarily rate-limited upstream", whose
|
|
Retry-After the SDK backoff already honors) otherwise fail the run outright.
|
|
"""
|
|
sdk_config = getattr(getattr(chat_model, "client", None), "sdk_configuration", None)
|
|
retry_config: Any = getattr(sdk_config, "retry_config", None)
|
|
# Skip the UNSET sentinel (max_retries=0) and explicit caller overrides.
|
|
if not hasattr(retry_config, "status_codes_override"):
|
|
return
|
|
if retry_config.status_codes_override:
|
|
return
|
|
retry_config.status_codes_override = ["429", "5XX"]
|
|
|
|
|
|
def _apply_auto_config(
|
|
provider: str,
|
|
model_id: str,
|
|
is_third_party: bool,
|
|
kwargs: dict[str, Any],
|
|
original_provider: str | None = None,
|
|
) -> None:
|
|
"""Auto-enable provider-specific features (thinking, reasoning, etc.).
|
|
|
|
Mutates *kwargs* in place. Only sets keys that the caller hasn't already
|
|
provided, so explicit user settings are never overridden.
|
|
"""
|
|
# Anthropic: extended thinking
|
|
if provider == "anthropic" and "thinking" not in kwargs:
|
|
_supports_thinking = original_provider in _THINKING_CAPABLE_PROVIDERS
|
|
# Detect local proxy (e.g. ccproxy): thinking blocks in conversation
|
|
# history cause 422 errors because the proxy doesn't accept 'thinking'
|
|
# as a valid content block type on round-trip.
|
|
if not is_third_party:
|
|
base_url = os.environ.get("ANTHROPIC_BASE_URL", "")
|
|
_is_proxy = "127.0.0.1" in base_url or "localhost" in base_url
|
|
else:
|
|
_is_proxy = False
|
|
if _is_proxy or (is_third_party and not _supports_thinking):
|
|
# Mandatory-thinking Kimi models (K3 / Kimi For Coding) must declare
|
|
# thinking so with_structured_output avoids forced tool_choice (400).
|
|
# max_tokens must exceed budget_tokens (default resolves to 4096).
|
|
if is_third_party and _is_mandatory_thinking_kimi(model_id):
|
|
kwargs["thinking"] = {"type": "enabled", "budget_tokens": 10000}
|
|
kwargs.setdefault("max_tokens", 16000)
|
|
elif "fable" in model_id or model_id.endswith(
|
|
("opus-5", "sonnet-5", "4-6", "4-7", "4-8")
|
|
):
|
|
kwargs["thinking"] = {"type": "adaptive", "display": "summarized"}
|
|
kwargs.setdefault("effort", "max")
|
|
else:
|
|
kwargs["thinking"] = {"type": "enabled", "budget_tokens": 10000}
|
|
|
|
# OpenAI (native, not third-party routed): reasoning
|
|
if provider == "openai" and not is_third_party and "reasoning" not in kwargs:
|
|
_default_effort = (
|
|
"xhigh"
|
|
if (
|
|
"5.4" in model_id
|
|
or "5.5" in model_id
|
|
or "5.6" in model_id
|
|
or "codex" in model_id
|
|
)
|
|
else "high"
|
|
)
|
|
_eff = _resolve_reasoning_effort(_default_effort)
|
|
kwargs["reasoning"] = {"effort": _eff, "summary": "auto"}
|
|
|
|
# Google GenAI: surface thinking traces
|
|
if provider == "google-genai":
|
|
kwargs.setdefault("include_thoughts", True)
|
|
|
|
# Ollama: separate reasoning content from response for thinking models
|
|
if provider == "ollama" and "reasoning" not in kwargs:
|
|
kwargs["reasoning"] = True
|
|
|
|
|
|
def get_chat_model(
|
|
model: str | None = None,
|
|
provider: str | None = None,
|
|
**kwargs: Any,
|
|
) -> Any:
|
|
"""Get a chat model instance.
|
|
|
|
Args:
|
|
model: Model name (short name like 'claude-sonnet-4-6' or full ID
|
|
like 'claude-sonnet-4-6-20250929'). Defaults to DEFAULT_MODEL.
|
|
provider: Override the provider (e.g., 'anthropic', 'openai').
|
|
If not specified, inferred from model name or defaults to 'anthropic'.
|
|
**kwargs: Additional arguments passed to init_chat_model (e.g., temperature).
|
|
|
|
Returns:
|
|
A LangChain chat model instance.
|
|
|
|
Examples:
|
|
>>> model = get_chat_model() # Uses default (claude-sonnet-4-6)
|
|
>>> model = get_chat_model("claude-opus-4-8") # Use short name
|
|
>>> model = get_chat_model("gpt-4o") # OpenAI model
|
|
>>> model = get_chat_model("claude-3-opus-20240229", provider="anthropic") # Full ID
|
|
"""
|
|
model = model or DEFAULT_MODEL
|
|
|
|
# Look up short name in registry (provider-aware)
|
|
model_id = None
|
|
if provider:
|
|
# Try exact match with provider first
|
|
for name, mid, p in _MODEL_ENTRIES:
|
|
if name == model and p == provider:
|
|
model_id = mid
|
|
break
|
|
if model_id is None and model in MODELS:
|
|
model_id, default_provider = MODELS[model]
|
|
provider = provider or default_provider
|
|
|
|
if model_id is None:
|
|
# Assume it's a full model ID
|
|
model_id = model
|
|
# Try to infer provider from model ID prefix
|
|
if provider is None:
|
|
if model_id.startswith(("claude-", "anthropic")):
|
|
provider = "anthropic"
|
|
elif model_id.startswith(("gpt-", "o1", "davinci", "text-")):
|
|
provider = "openai"
|
|
elif model_id.startswith("gemini"):
|
|
provider = "google-genai"
|
|
elif model_id.startswith("ollama:"):
|
|
provider = "ollama"
|
|
model_id = model_id.removeprefix("ollama:")
|
|
else:
|
|
provider = "anthropic" # Default fallback
|
|
|
|
# Anthropic base_url override (e.g. ccproxy at localhost:8000/api/v1)
|
|
_is_third_party = (
|
|
provider in _OPENAI_ROUTED_PROVIDERS or provider in _ANTHROPIC_ROUTED_PROVIDERS
|
|
)
|
|
_is_openai_proxy = False
|
|
_original_provider: str | None = None
|
|
if provider == "anthropic":
|
|
base_url = os.environ.get("ANTHROPIC_BASE_URL", "")
|
|
if base_url:
|
|
kwargs["base_url"] = base_url
|
|
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
if api_key:
|
|
kwargs["api_key"] = api_key
|
|
|
|
# Native OpenAI base_url override (e.g. ccproxy Codex at localhost:8000/codex/v1)
|
|
elif provider == "openai":
|
|
base_url = os.environ.get("OPENAI_BASE_URL", "")
|
|
if base_url:
|
|
kwargs["base_url"] = base_url
|
|
_is_openai_proxy = _is_ccproxy_codex()
|
|
if _is_openai_proxy:
|
|
# Use Responses API for ccproxy: bypasses the format chain
|
|
# converter (Chat→Responses→Chat) which returns 502 on
|
|
# complex responses. System messages are converted to
|
|
# developer role by _patch_ccproxy_system_to_developer().
|
|
kwargs.setdefault("use_responses_api", True)
|
|
# Streaming must stay ON for Responses API: ccproxy's
|
|
# StreamingBufferService loses output when assembling
|
|
# non-streaming responses. (The old streaming=False was
|
|
# for Chat Completions tool_call duplication — not an issue
|
|
# with the Responses API SSE format.)
|
|
kwargs.pop("streaming", None) # remove if set elsewhere
|
|
# ccproxy forwards client headers upstream and only
|
|
# gap-fills its own, so the Codex backend sees this
|
|
# client's identity. Without Codex-CLI-shaped headers it
|
|
# rejects current models ("The '<model>' model requires
|
|
# a newer version of Codex").
|
|
_codex_ver = _resolve_codex_client_version()
|
|
_headers = kwargs.get("default_headers") or {}
|
|
kwargs["default_headers"] = _headers
|
|
_headers.setdefault("originator", "codex_cli_rs")
|
|
_headers.setdefault("version", _codex_ver)
|
|
_headers.setdefault(
|
|
"User-Agent",
|
|
f"codex_cli_rs/{_headers['version']} (EvoScientist)",
|
|
)
|
|
api_key = os.environ.get("OPENAI_API_KEY", "")
|
|
if api_key:
|
|
kwargs["api_key"] = api_key
|
|
|
|
elif provider == "deepseek":
|
|
api_key = os.environ.get("DEEPSEEK_API_KEY", "")
|
|
if api_key:
|
|
kwargs["api_key"] = api_key
|
|
|
|
# OpenAI-routed providers → route through OpenAI provider with base_url
|
|
elif provider in _OPENAI_ROUTED_PROVIDERS:
|
|
_original_provider = provider
|
|
base_url_default, api_key_env = _OPENAI_ROUTED_PROVIDERS[provider]
|
|
if provider == "custom-openai":
|
|
base_url = os.environ.get("CUSTOM_OPENAI_BASE_URL", "")
|
|
if not base_url:
|
|
raise ValueError(
|
|
"CUSTOM_OPENAI_BASE_URL environment variable is required when using "
|
|
"the 'custom-openai' provider. Please set it to your "
|
|
"OpenAI-compatible API endpoint URL (e.g. https://api.openai.com/v1)."
|
|
)
|
|
base_url = base_url.rstrip("/")
|
|
else:
|
|
base_url = base_url_default
|
|
if base_url:
|
|
kwargs["base_url"] = base_url
|
|
api_key = os.environ.get(api_key_env, "")
|
|
if api_key:
|
|
kwargs["api_key"] = api_key
|
|
_apply_openai_compat_reasoning_config(provider, model_id, kwargs)
|
|
# SiliconFlow: disable thinking — LangChain drops reasoning_content
|
|
# from history, causing error 20015 on multi-turn requests.
|
|
if provider == "siliconflow":
|
|
kwargs.setdefault("extra_body", {})["enable_thinking"] = False
|
|
# Moonshot: disable thinking for pre-K3 models to prevent LangChain from
|
|
# dropping reasoning_content, which causes multi-turn conversation errors
|
|
# (error 20015). Even native thinking models like kimi-k2-thinking operate
|
|
# in non-thinking mode. kimi-k3+ is exempt: always-thinking, and
|
|
# Moonshot's K3 guide forbids the K2.x `thinking` parameter for it.
|
|
if provider == "moonshot" and not model_id.startswith("kimi-k3"):
|
|
kwargs.setdefault("extra_body", {})["thinking"] = {"type": "disabled"}
|
|
provider = "openai"
|
|
|
|
# OpenRouter → native ChatOpenRouter via init_chat_model.
|
|
elif provider == "openrouter":
|
|
_is_third_party = True
|
|
api_key = os.environ.get("OPENROUTER_API_KEY", "")
|
|
if api_key:
|
|
kwargs["api_key"] = api_key
|
|
# Reasoning via `effort` + `summary: "auto"` so a readable reasoning
|
|
# summary is returned for display. OpenAI-Responses also emits encrypted
|
|
# reasoning items (`rs_*` id) that can't be replayed on multi-turn
|
|
# passback (OpenRouter's `/responses` beta is stateless, store=false —
|
|
# "Item with id 'rs_...' not found"); the patch strips them on passback,
|
|
# so enabling `summary` is safe. See langchain-ai/langchain#37777.
|
|
# Note: mandatory-reasoning endpoints (kimi-k3, grok-4.5, …) reject
|
|
# effort "none" with HTTP 400 — that error is surfaced to the user
|
|
# as-is; pick a real effort (low+) for those models.
|
|
effort = _resolve_reasoning_effort("high")
|
|
kwargs.setdefault("reasoning", {"effort": effort, "summary": "auto"})
|
|
# App attribution (issue #339): identify EvoScientist to OpenRouter so
|
|
# usage is credited to the project (app rankings, model app tabs,
|
|
# analytics) rather than langchain-openrouter's LangChain-branded
|
|
# defaults. setdefault so an explicit caller kwarg wins; values are
|
|
# configurable via EVOSCIENTIST_OPENROUTER_* env (fed from the config
|
|
# file by apply_config_to_env). Applied only here, so no other provider
|
|
# ever receives these kwargs.
|
|
kwargs.setdefault(
|
|
"app_url",
|
|
os.environ.get("EVOSCIENTIST_OPENROUTER_HTTP_REFERER", "").strip()
|
|
or OPENROUTER_DEFAULT_HTTP_REFERER,
|
|
)
|
|
kwargs.setdefault(
|
|
"app_title",
|
|
os.environ.get("EVOSCIENTIST_OPENROUTER_APP_TITLE", "").strip()
|
|
or OPENROUTER_DEFAULT_APP_TITLE,
|
|
)
|
|
# app_categories must be a list[str] (langchain-openrouter joins it into
|
|
# the X-OpenRouter-Categories header); split the comma-separated config
|
|
# value and drop blanks so a stray comma/space can't emit an empty one.
|
|
_app_categories_raw = (
|
|
os.environ.get("EVOSCIENTIST_OPENROUTER_APP_CATEGORIES", "").strip()
|
|
or OPENROUTER_DEFAULT_APP_CATEGORIES
|
|
)
|
|
_app_categories = [
|
|
c.strip() for c in _app_categories_raw.split(",") if c.strip()
|
|
]
|
|
# Cap to the per-request limit and warn, so a misconfigured extra is
|
|
# dropped predictably here (and surfaced to the user) rather than being
|
|
# silently truncated server-side.
|
|
_limit = _OPENROUTER_MAX_CATEGORIES_PER_REQUEST
|
|
if len(_app_categories) > _limit:
|
|
warnings.warn(
|
|
f"OpenRouter accepts at most {_limit} app categories per "
|
|
f"request, so only the first {_limit} are sent: "
|
|
f"{_app_categories[:_limit]}. Ignoring the rest: "
|
|
f"{_app_categories[_limit:]}. Set "
|
|
f"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES (or the "
|
|
f"openrouter_app_categories config) to at most {_limit} "
|
|
f"categories to silence this warning.",
|
|
UserWarning,
|
|
stacklevel=2,
|
|
)
|
|
_app_categories = _app_categories[:_limit]
|
|
if _app_categories:
|
|
kwargs.setdefault("app_categories", _app_categories)
|
|
_patch_openrouter_strip_responses_reasoning()
|
|
_patch_openrouter_structured_output()
|
|
|
|
# Anthropic-routed providers → route through Anthropic provider with base_url
|
|
elif provider in _ANTHROPIC_ROUTED_PROVIDERS:
|
|
_original_provider = provider
|
|
base_url_default, api_key_env = _ANTHROPIC_ROUTED_PROVIDERS[provider]
|
|
if provider == "custom-anthropic":
|
|
base_url = os.environ.get("CUSTOM_ANTHROPIC_BASE_URL", "")
|
|
if not base_url:
|
|
raise ValueError(
|
|
"CUSTOM_ANTHROPIC_BASE_URL environment variable is required when using "
|
|
"the 'custom-anthropic' provider. Please set it to your "
|
|
"Anthropic-compatible API endpoint URL (e.g. https://api.anthropic.com)."
|
|
)
|
|
base_url = base_url.rstrip("/")
|
|
elif provider == "minimax":
|
|
base_url = os.environ.get("MINIMAX_BASE_URL", base_url_default).rstrip("/")
|
|
else:
|
|
base_url = base_url_default
|
|
if base_url:
|
|
kwargs["base_url"] = base_url
|
|
api_key = os.environ.get(api_key_env, "")
|
|
if api_key:
|
|
kwargs["api_key"] = api_key
|
|
# Kimi Coding Plan requires claude-code User-Agent header
|
|
if provider == "kimi-coding":
|
|
kwargs.setdefault("default_headers", {})["User-Agent"] = "claude-code/0.1.0"
|
|
provider = "anthropic"
|
|
|
|
elif provider == "ollama":
|
|
base_url = os.environ.get("OLLAMA_BASE_URL", "")
|
|
if base_url:
|
|
kwargs["base_url"] = base_url
|
|
|
|
_apply_auto_config(provider, model_id, _is_third_party, kwargs, _original_provider)
|
|
# OpenAI-routed routers (e.g. Requesty) reassign ``provider`` to "openai"
|
|
# above, so use the original provider name to detect router-level caching.
|
|
_cache_provider = _original_provider or provider
|
|
_apply_openrouter_anthropic_prompt_cache(_cache_provider, model_id, kwargs)
|
|
|
|
_uses_native_deepseek = provider == "deepseek" or (
|
|
provider == "openai"
|
|
and _original_provider == "custom-openai"
|
|
and _is_deepseek_endpoint(kwargs.get("base_url"))
|
|
)
|
|
|
|
# User-level override for the OpenAI Responses API vs Chat Completions.
|
|
# When "false", force Chat Completions and drop reasoning (which triggers
|
|
# the Responses API path in langchain-openai). Only applies to OpenAI.
|
|
if _uses_native_deepseek:
|
|
if kwargs.get("use_responses_api") is True:
|
|
raise ValueError(
|
|
"DeepSeek does not support the OpenAI Responses API. "
|
|
"Remove use_responses_api=True."
|
|
)
|
|
kwargs.pop("use_responses_api", None)
|
|
elif provider == "openai":
|
|
_responses_api_setting = (
|
|
os.environ.get("EVOSCIENTIST_USE_RESPONSES_API", "").strip().lower()
|
|
)
|
|
if _responses_api_setting == "false":
|
|
kwargs["use_responses_api"] = False
|
|
kwargs.pop("reasoning", None)
|
|
elif _responses_api_setting == "true":
|
|
kwargs["use_responses_api"] = True
|
|
|
|
if _is_openai_proxy and kwargs.get("use_responses_api") is True:
|
|
reasoning = kwargs.setdefault("reasoning", {})
|
|
if isinstance(reasoning, dict):
|
|
reasoning = dict(reasoning)
|
|
reasoning.setdefault("context", "all_turns")
|
|
kwargs["reasoning"] = reasoning
|
|
|
|
if _uses_native_deepseek:
|
|
chat_model = EvoChatDeepSeek(model=model_id, **kwargs)
|
|
else:
|
|
chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs)
|
|
|
|
# Flatten list content to strings for strict OpenAI-compatible providers
|
|
# (SiliconFlow, OpenRouter, custom-openai, etc.) and
|
|
# native OpenAI through a proxy, to avoid "sequence expected string" errors.
|
|
# Moonshot and Kimi Coding support standard format, no patch needed.
|
|
# Mandatory-thinking Kimi models on Anthropic-routed endpoints are exempt:
|
|
# flatten drops thinking blocks, which Kimi requires on tool-call turns.
|
|
_no_patch_providers = {"moonshot", "kimi-coding"}
|
|
if (
|
|
(_is_third_party or _is_openai_proxy)
|
|
and _original_provider not in _no_patch_providers
|
|
and not _uses_native_deepseek
|
|
and not (provider == "anthropic" and _is_mandatory_thinking_kimi(model_id))
|
|
):
|
|
# Anthropic-routed providers accept media in tool results natively;
|
|
# only OpenAI-compatible providers need tool-media hoisting.
|
|
_hoist = _original_provider not in _ANTHROPIC_ROUTED_PROVIDERS
|
|
_patch_openai_compat_content(chat_model, hoist_tool_media=_hoist)
|
|
|
|
if _is_openai_proxy:
|
|
_patch_ccproxy_system_to_developer(chat_model)
|
|
|
|
if provider == "openrouter":
|
|
_enable_openrouter_429_retry(chat_model)
|
|
|
|
if provider == "anthropic":
|
|
_patch_anthropic_strip_foreign_reasoning()
|
|
_patch_anthropic_structured_output()
|
|
|
|
apply_known_context_window(chat_model)
|
|
|
|
return chat_model
|