feat: add Chinese UI, Responses models, and mirrored Windows setup
Localize the web console and persist custom model and capability settings. Use OpenAI Responses exclusively and move runtime limits into environment configuration. Bootstrap uv and the virtual environment on Windows, support optional China mirrors, and package source distributions without environments, build output, or credentials. Update regression tests and repository quality checks for the new behavior.
This commit is contained in:
1 parent
371aa6df56
commit
2ddec53954
183 files changed
+3427
-11857
No files matched your search
@@ -1,57 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Google / Gemini specific knowledge shared by every call site.
|
||||
|
||||
Two concerns live here so they are defined exactly once:
|
||||
|
||||
* :mod:`artemis.llm.google.provider` - "is this endpoint Google?" and the
|
||||
Gemini model capability table (thinking levels, agentic video).
|
||||
* :mod:`artemis.llm.google.usage` - token-usage normalization across the
|
||||
LangChain, ``generate_content`` and Interactions API response shapes.
|
||||
|
||||
Both modules are dependency-free (no settings, no SDK client) so they can be
|
||||
imported from configuration code without creating import cycles.
|
||||
"""
|
||||
|
||||
from artemis.llm.google.provider import (
|
||||
VideoProcessing,
|
||||
gemini_version,
|
||||
is_agentic_video_auto_eligible,
|
||||
is_gemini_model,
|
||||
is_google_chat_model,
|
||||
is_google_family_provider,
|
||||
is_google_provider,
|
||||
resolve_video_processing,
|
||||
strip_provider_prefix,
|
||||
supports_agentic_video,
|
||||
supports_thinking_level,
|
||||
)
|
||||
from artemis.llm.google.usage import normalize_usage, usage_from_message
|
||||
|
||||
__all__ = [
|
||||
"VideoProcessing",
|
||||
"gemini_version",
|
||||
"is_agentic_video_auto_eligible",
|
||||
"is_gemini_model",
|
||||
"is_google_chat_model",
|
||||
"is_google_family_provider",
|
||||
"is_google_provider",
|
||||
"normalize_usage",
|
||||
"resolve_video_processing",
|
||||
"strip_provider_prefix",
|
||||
"supports_agentic_video",
|
||||
"supports_thinking_level",
|
||||
"usage_from_message",
|
||||
]
|
||||
@@ -1,185 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Google provider identity and the Gemini model capability table.
|
||||
|
||||
Provider identity has two tiers:
|
||||
|
||||
* :func:`is_google_provider` - the Gemini Developer API (``google`` /
|
||||
``gemini``). Native ``google-genai`` features (Files API, explicit caching,
|
||||
the Interactions API) are only reachable here.
|
||||
* :func:`is_google_family_provider` - additionally Vertex AI, which shares
|
||||
Google credentials but not the developer-API-only features.
|
||||
|
||||
Model IDs use ``gemini-<major>.<minor>-<tier>[<suffix>]``. Release suffixes
|
||||
(``preview``, ``latest``, ``exp``, and numeric tokens) retain base-family
|
||||
capabilities. Variants such as ``image`` and ``tts`` are excluded from agentic
|
||||
video support, but their version still determines thinking-level support.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
VideoProcessing = Literal["auto", "agentic", "static"]
|
||||
ResolvedVideoProcessing = Literal["agentic", "static"]
|
||||
|
||||
_GOOGLE_API_PROVIDERS = frozenset({"google", "gemini"})
|
||||
_GOOGLE_FAMILY_PROVIDERS = _GOOGLE_API_PROVIDERS | frozenset({"vertexai", "vertex"})
|
||||
|
||||
_GEMINI_MODEL_RE = re.compile(r"gemini-(\d+)\.(\d+)-(flash-lite|flash|pro)", re.IGNORECASE)
|
||||
_RELEASE_SUFFIX_TOKENS = frozenset({"preview", "latest", "exp"})
|
||||
|
||||
|
||||
class _GeminiModel(NamedTuple):
|
||||
version: tuple[int, int]
|
||||
tier: str
|
||||
#: False for variant ids (``-image``, ``-tts``, ...) that carry their own
|
||||
#: capabilities and must not inherit the family's answers.
|
||||
is_base: bool
|
||||
|
||||
|
||||
# Agentic video understanding (Interactions API, ``processing: "agentic"``)
|
||||
# launched on Gemini 3.6/3.7/3.8 Flash and 3.5 Flash-Lite. Later releases in
|
||||
# the same families are assumed to keep it.
|
||||
_AGENTIC_VIDEO_MIN_FLASH = (3, 6)
|
||||
_AGENTIC_VIDEO_MIN_FLASH_LITE = (3, 5)
|
||||
|
||||
|
||||
def _normalize_provider(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
value = getattr(value, "value", value)
|
||||
return str(value).lower().replace("_", "").replace("-", "").strip()
|
||||
|
||||
|
||||
def is_google_provider(value: Any) -> bool:
|
||||
"""True for the Gemini Developer API (``google`` / ``gemini``).
|
||||
|
||||
Accepts a provider name, a provider enum, or anything exposing ``.value``.
|
||||
"""
|
||||
return _normalize_provider(value) in _GOOGLE_API_PROVIDERS
|
||||
|
||||
|
||||
def is_google_family_provider(value: Any) -> bool:
|
||||
"""True for every provider that authenticates with Google credentials."""
|
||||
return _normalize_provider(value) in _GOOGLE_FAMILY_PROVIDERS
|
||||
|
||||
|
||||
def is_google_chat_model(model: Any) -> bool:
|
||||
"""True when a LangChain chat model is backed by Google (by class name).
|
||||
|
||||
Only used when no endpoint metadata is available; the class-name sniff is
|
||||
the historical fallback and is kept in one place here.
|
||||
"""
|
||||
cls = getattr(model, "__class__", None)
|
||||
name = cls.__name__ if cls else ""
|
||||
return "Google" in name or "Gemini" in name or "VertexAI" in name
|
||||
|
||||
|
||||
def strip_provider_prefix(model_name: Any) -> str:
|
||||
"""Drops a ``provider/`` prefix (``google/gemini-3.8-flash`` -> ``gemini-3.8-flash``)."""
|
||||
name = str(model_name or "")
|
||||
return name.rsplit("/", 1)[-1] if "/" in name else name
|
||||
|
||||
|
||||
def is_gemini_model(model_name: Any) -> bool:
|
||||
"""True when the model name refers to any Gemini model."""
|
||||
return "gemini" in strip_provider_prefix(model_name).lower()
|
||||
|
||||
|
||||
def _is_release_suffix(rest: str) -> bool:
|
||||
"""Whether what follows the tier is a release suffix (see the module docstring)."""
|
||||
if not rest:
|
||||
return True
|
||||
if not rest.startswith("-"):
|
||||
return False
|
||||
return all(token in _RELEASE_SUFFIX_TOKENS or token.isdigit() for token in rest[1:].split("-"))
|
||||
|
||||
|
||||
def _parse_gemini_model(model_name: Any) -> _GeminiModel | None:
|
||||
name = strip_provider_prefix(model_name).lower()
|
||||
match = _GEMINI_MODEL_RE.search(name)
|
||||
if not match:
|
||||
return None
|
||||
return _GeminiModel(
|
||||
version=(int(match.group(1)), int(match.group(2))),
|
||||
tier=match.group(3),
|
||||
is_base=_is_release_suffix(name[match.end() :]),
|
||||
)
|
||||
|
||||
|
||||
def gemini_version(model_name: Any) -> tuple[int, int] | None:
|
||||
"""``(major, minor)`` parsed from a ``gemini-X.Y-*`` name, else ``None``."""
|
||||
parsed = _parse_gemini_model(model_name)
|
||||
return parsed.version if parsed else None
|
||||
|
||||
|
||||
def _gemini_tier(model_name: Any) -> str | None:
|
||||
"""The tier of a *base* model id; ``None`` for variants and non-Gemini names."""
|
||||
parsed = _parse_gemini_model(model_name)
|
||||
return parsed.tier if parsed and parsed.is_base else None
|
||||
|
||||
|
||||
def supports_thinking_level(model_name: Any) -> bool:
|
||||
"""Whether the model accepts ``thinking_level`` (Gemini 3 and later).
|
||||
|
||||
Gemini 1.x/2.x only understand ``thinking_budget``. Names that do not
|
||||
carry a parsable version (e.g. the robotics ER models) are assumed to be
|
||||
current and therefore accept it.
|
||||
"""
|
||||
version = gemini_version(model_name)
|
||||
return version is None or version[0] >= 3
|
||||
|
||||
|
||||
def supports_agentic_video(model_name: Any) -> bool:
|
||||
"""Whether the Gemini API accepts ``processing: "agentic"`` for this model.
|
||||
|
||||
Only base Flash / Flash-Lite ids qualify; variants (``-image``, ``-tts``,
|
||||
``-live``, ...) do not inherit the family's support.
|
||||
"""
|
||||
parsed = _parse_gemini_model(model_name)
|
||||
if parsed is None or not parsed.is_base:
|
||||
return False
|
||||
if parsed.tier == "flash":
|
||||
return parsed.version >= _AGENTIC_VIDEO_MIN_FLASH
|
||||
if parsed.tier == "flash-lite":
|
||||
return parsed.version >= _AGENTIC_VIDEO_MIN_FLASH_LITE
|
||||
return False
|
||||
|
||||
|
||||
def is_agentic_video_auto_eligible(model_name: Any) -> bool:
|
||||
"""Models that get agentic video processing under ``processing: "auto"``.
|
||||
|
||||
Flash-Lite is deliberately excluded: it accepts the mode but mis-reads
|
||||
timestamps in practice, so it must be opted into explicitly.
|
||||
"""
|
||||
return supports_agentic_video(model_name) and _gemini_tier(model_name) == "flash"
|
||||
|
||||
|
||||
def resolve_video_processing(configured: Any, model_name: Any) -> ResolvedVideoProcessing:
|
||||
"""Turns the configured video-processing knob into a concrete mode.
|
||||
|
||||
``static`` is always honoured. ``agentic`` is honoured only for models
|
||||
that support it and silently degrades otherwise. ``auto`` (and any
|
||||
unrecognised value) enables agentic processing for the auto-eligible
|
||||
models and keeps static processing for everything else.
|
||||
"""
|
||||
mode = str(configured or "auto").strip().lower() if isinstance(configured, str) else "auto"
|
||||
if mode == "static":
|
||||
return "static"
|
||||
if mode == "agentic":
|
||||
return "agentic" if supports_agentic_video(model_name) else "static"
|
||||
return "agentic" if is_agentic_video_auto_eligible(model_name) else "static"
|
||||
+43
-323
@@ -12,12 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Dynamic Multi-Provider Model Router for ARTEMIS.
|
||||
|
||||
Provides role-based LLM/VLM dispatching, dynamic fallback chains,
|
||||
and unified provider configuration across Gemini, Vertex AI, OpenAI,
|
||||
Anthropic, OpenRouter, and local Ollama/vLLM endpoints.
|
||||
"""
|
||||
"""Single transport for all Artemis models: OpenAI Responses API."""
|
||||
|
||||
from enum import StrEnum
|
||||
import hashlib
|
||||
@@ -28,94 +23,34 @@ from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from artemis.config.settings import settings
|
||||
from artemis.llm.google.provider import supports_thinking_level
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ModelProvider(StrEnum):
|
||||
GOOGLE = "google"
|
||||
GEMINI = "google"
|
||||
VERTEX_AI = "vertexai"
|
||||
OPENAI = "openai"
|
||||
ANTHROPIC = "anthropic"
|
||||
OPENROUTER = "openrouter"
|
||||
XAI = "xai"
|
||||
OLLAMA = "ollama"
|
||||
VLLM = "vllm"
|
||||
CUSTOM = "custom"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, val: Any) -> "ModelProvider":
|
||||
"""Converts a string or enum into a canonical ModelProvider.
|
||||
|
||||
``None``/empty means "use the default" (Google); an unrecognized
|
||||
non-empty value raises instead of silently routing to Gemini with
|
||||
the wrong credentials.
|
||||
"""
|
||||
if isinstance(val, cls):
|
||||
return val
|
||||
if val is None:
|
||||
return cls.GOOGLE
|
||||
s = str(val).lower().replace("_", "").replace("-", "").strip()
|
||||
if not s:
|
||||
return cls.GOOGLE
|
||||
mapping = {
|
||||
"google": cls.GOOGLE,
|
||||
"gemini": cls.GOOGLE,
|
||||
"vertexai": cls.VERTEX_AI,
|
||||
"vertex": cls.VERTEX_AI,
|
||||
"openai": cls.OPENAI,
|
||||
"anthropic": cls.ANTHROPIC,
|
||||
"claude": cls.ANTHROPIC,
|
||||
"openrouter": cls.OPENROUTER,
|
||||
"xai": cls.XAI,
|
||||
"grok": cls.XAI,
|
||||
"ollama": cls.OLLAMA,
|
||||
"vllm": cls.VLLM,
|
||||
"custom": cls.CUSTOM,
|
||||
}
|
||||
provider = mapping.get(s)
|
||||
if provider is None:
|
||||
raise ValueError(
|
||||
f"Unknown LLM provider {val!r}. Valid providers: {sorted(set(mapping))}"
|
||||
)
|
||||
return provider
|
||||
if val is None or str(val).strip().lower() in ("", "openai"):
|
||||
return cls.OPENAI
|
||||
raise ValueError(f"Unsupported LLM provider {val!r}; only OpenAI Responses is supported")
|
||||
|
||||
|
||||
class ModelEndpoint(BaseModel):
|
||||
"""Configuration definition for an LLM/VLM model endpoint."""
|
||||
|
||||
provider: ModelProvider = Field(default=ModelProvider.GOOGLE, description="Model provider")
|
||||
model_name: str = Field(default="gemini-2.5-flash", description="Model name identifier")
|
||||
api_key: str | None = Field(default=None, description="API Key or secret")
|
||||
api_base: str | None = Field(default=None, description="Custom API endpoint base URL")
|
||||
temperature: float = Field(default=0.0, description="Sampling temperature")
|
||||
max_tokens: int | None = Field(default=None, description="Maximum completion tokens")
|
||||
timeout_seconds: float = Field(default=60.0, description="Request timeout in seconds")
|
||||
is_multimodal: bool = Field(
|
||||
default=True, description="Whether endpoint accepts image/video inputs"
|
||||
)
|
||||
reasoning_effort: str | None = Field(
|
||||
default=None, description="Reasoning effort: 'low', 'medium', 'high'"
|
||||
)
|
||||
thinking_budget: int | None = Field(default=None, description="Thinking budget token count")
|
||||
thinking_level: str | None = Field(
|
||||
default=None, description="Thinking level: 'minimal', 'low', 'medium', 'high'"
|
||||
)
|
||||
include_thoughts: bool | None = Field(
|
||||
default=None, description="Whether to include thought traces in response"
|
||||
)
|
||||
enable_grounding: bool = Field(
|
||||
default=False,
|
||||
description="Whether to enable Google Search grounding for Gemini endpoints",
|
||||
)
|
||||
provider: ModelProvider = ModelProvider.OPENAI
|
||||
model_name: str = Field(default_factory=lambda: settings.OPENAI_MODEL)
|
||||
api_key: str | None = Field(default=None, repr=False)
|
||||
api_base: str | None = None
|
||||
temperature: float | None = None
|
||||
max_tokens: int | None = Field(default=None, gt=0)
|
||||
timeout_seconds: float = Field(default_factory=lambda: settings.OPENAI_TIMEOUT_SECONDS, gt=0)
|
||||
max_retries: int = Field(default_factory=lambda: settings.OPENAI_MAX_RETRIES, ge=0)
|
||||
is_multimodal: bool = True
|
||||
reasoning_effort: str | None = None
|
||||
enable_grounding: bool = False
|
||||
|
||||
def cache_key(self) -> tuple:
|
||||
"""Key the client cache by endpoint settings, hashing the API key."""
|
||||
api_key_digest = (
|
||||
hashlib.sha256(self.api_key.encode("utf-8")).hexdigest()[:16] if self.api_key else None
|
||||
key = self.api_key or (
|
||||
settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else ""
|
||||
)
|
||||
return (
|
||||
self.provider.value,
|
||||
@@ -123,81 +58,15 @@ class ModelEndpoint(BaseModel):
|
||||
self.temperature,
|
||||
self.max_tokens,
|
||||
self.timeout_seconds,
|
||||
self.thinking_budget,
|
||||
self.thinking_level,
|
||||
self.include_thoughts,
|
||||
self.max_retries,
|
||||
self.reasoning_effort,
|
||||
self.enable_grounding,
|
||||
self.api_base,
|
||||
api_key_digest,
|
||||
self.api_base or settings.OPENAI_BASE_URL,
|
||||
hashlib.sha256(key.encode()).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def _patch_langchain_google_genai():
|
||||
"""Patches ChatGoogleGenerativeAI._process_tool_config to preserve and ensure include_server_side_tool_invocations."""
|
||||
try:
|
||||
from langchain_google_genai.chat_models import ChatGoogleGenerativeAI
|
||||
from google.genai.types import ToolConfig
|
||||
|
||||
if getattr(ChatGoogleGenerativeAI, "_is_artemis_patched", False):
|
||||
return
|
||||
|
||||
original_process = ChatGoogleGenerativeAI._process_tool_config
|
||||
|
||||
def patched_process_tool_config(self, tool_choice, tool_config, formatted_tools):
|
||||
config = original_process(self, tool_choice, tool_config, formatted_tools)
|
||||
|
||||
has_builtin = False
|
||||
has_functions = False
|
||||
if formatted_tools:
|
||||
for tool in formatted_tools:
|
||||
tool_dict = tool if isinstance(tool, dict) else getattr(tool, "__dict__", {})
|
||||
if any(
|
||||
tool_dict.get(k) is not None
|
||||
for k in (
|
||||
"google_search",
|
||||
"code_execution",
|
||||
"google_maps",
|
||||
"google_search_retrieval",
|
||||
)
|
||||
):
|
||||
has_builtin = True
|
||||
if tool_dict.get("function_declarations"):
|
||||
has_functions = True
|
||||
|
||||
if tool_config:
|
||||
normalized = (
|
||||
ToolConfig.model_validate(tool_config)
|
||||
if isinstance(tool_config, dict)
|
||||
else tool_config
|
||||
)
|
||||
if getattr(normalized, "include_server_side_tool_invocations", None) is not None:
|
||||
if config is None:
|
||||
config = ToolConfig(
|
||||
include_server_side_tool_invocations=normalized.include_server_side_tool_invocations
|
||||
)
|
||||
else:
|
||||
config.include_server_side_tool_invocations = (
|
||||
normalized.include_server_side_tool_invocations
|
||||
)
|
||||
|
||||
if has_builtin and has_functions:
|
||||
if config is None:
|
||||
config = ToolConfig(include_server_side_tool_invocations=True)
|
||||
else:
|
||||
config.include_server_side_tool_invocations = True
|
||||
|
||||
return config
|
||||
|
||||
ChatGoogleGenerativeAI._process_tool_config = patched_process_tool_config
|
||||
ChatGoogleGenerativeAI._is_artemis_patched = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not patch ChatGoogleGenerativeAI._process_tool_config: {e}")
|
||||
|
||||
|
||||
class ModelFactory:
|
||||
"""Unified factory for instantiating and caching LangChain chat models across providers."""
|
||||
|
||||
_cache: dict[tuple, BaseChatModel] = {}
|
||||
|
||||
@classmethod
|
||||
@@ -209,181 +78,32 @@ class ModelFactory:
|
||||
|
||||
@classmethod
|
||||
def create_model(cls, endpoint: ModelEndpoint) -> BaseChatModel:
|
||||
ModelProvider.from_string(endpoint.provider)
|
||||
if os.environ.get("ARTEMIS_FAKE_LLM") == "1":
|
||||
from artemis.llm.fake_model import FakeChatModel
|
||||
|
||||
delay = float(os.environ.get("ARTEMIS_FAKE_LLM_DELAY_S", "0") or 0)
|
||||
logger.warning(
|
||||
f"ARTEMIS_FAKE_LLM=1 — returning FakeChatModel (delay={delay}s) "
|
||||
f"instead of {endpoint.provider}/{endpoint.model_name}"
|
||||
)
|
||||
return FakeChatModel(delay_s=delay)
|
||||
|
||||
provider = ModelProvider.from_string(endpoint.provider)
|
||||
|
||||
if provider == ModelProvider.GOOGLE:
|
||||
_patch_langchain_google_genai()
|
||||
from langchain_google_genai import (
|
||||
ChatGoogleGenerativeAI,
|
||||
HarmBlockThreshold,
|
||||
HarmCategory,
|
||||
return FakeChatModel(
|
||||
delay_s=float(os.environ.get("ARTEMIS_FAKE_LLM_DELAY_S", "0") or 0)
|
||||
)
|
||||
|
||||
api_key = (
|
||||
endpoint.api_key
|
||||
or (settings.GOOGLE_API_KEY.get_secret_value() if settings.GOOGLE_API_KEY else None)
|
||||
or os.environ.get("GOOGLE_API_KEY")
|
||||
or os.environ.get("GEMINI_API_KEY")
|
||||
)
|
||||
# Gemini 1.x/2.x only understand thinking_budget.
|
||||
thinking_level = (
|
||||
endpoint.thinking_level if supports_thinking_level(endpoint.model_name) else None
|
||||
)
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": endpoint.model_name,
|
||||
"temperature": endpoint.temperature,
|
||||
"max_output_tokens": endpoint.max_tokens,
|
||||
"api_key": api_key,
|
||||
"timeout": endpoint.timeout_seconds,
|
||||
"thinking_budget": endpoint.thinking_budget,
|
||||
"thinking_level": thinking_level,
|
||||
"include_thoughts": endpoint.include_thoughts,
|
||||
"safety_settings": {
|
||||
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
|
||||
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
|
||||
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
|
||||
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE,
|
||||
},
|
||||
}
|
||||
return ChatGoogleGenerativeAI(**{k: v for k, v in kwargs.items() if v is not None})
|
||||
|
||||
elif provider == ModelProvider.VERTEX_AI:
|
||||
from langchain_google_vertexai import (
|
||||
ChatVertexAI,
|
||||
HarmBlockThreshold,
|
||||
HarmCategory,
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"model_name": endpoint.model_name,
|
||||
"temperature": endpoint.temperature,
|
||||
"max_output_tokens": endpoint.max_tokens,
|
||||
"timeout": endpoint.timeout_seconds,
|
||||
"thinking_budget": endpoint.thinking_budget,
|
||||
"safety_settings": {
|
||||
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
|
||||
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
|
||||
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
|
||||
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE,
|
||||
},
|
||||
}
|
||||
return ChatVertexAI(**{k: v for k, v in kwargs.items() if v is not None})
|
||||
|
||||
elif provider == ModelProvider.OPENAI:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
api_key = (
|
||||
endpoint.api_key
|
||||
or (settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None)
|
||||
or os.environ.get("OPENAI_API_KEY", "EMPTY")
|
||||
)
|
||||
base_url = endpoint.api_base or (
|
||||
str(settings.OPENAI_BASE_URL) if settings.OPENAI_BASE_URL else None
|
||||
)
|
||||
kwargs = {
|
||||
"model": endpoint.model_name,
|
||||
"temperature": endpoint.temperature,
|
||||
"max_tokens": endpoint.max_tokens,
|
||||
"api_key": api_key,
|
||||
"base_url": base_url,
|
||||
"timeout": endpoint.timeout_seconds,
|
||||
}
|
||||
if endpoint.reasoning_effort:
|
||||
kwargs["reasoning_effort"] = endpoint.reasoning_effort
|
||||
return ChatOpenAI(**{k: v for k, v in kwargs.items() if v is not None})
|
||||
|
||||
elif provider == ModelProvider.ANTHROPIC:
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
api_key = (
|
||||
endpoint.api_key
|
||||
or (
|
||||
settings.ANTHROPIC_API_KEY.get_secret_value()
|
||||
if settings.ANTHROPIC_API_KEY
|
||||
else None
|
||||
)
|
||||
or os.environ.get("ANTHROPIC_API_KEY")
|
||||
)
|
||||
kwargs = {
|
||||
"model": endpoint.model_name,
|
||||
"temperature": endpoint.temperature,
|
||||
"api_key": api_key,
|
||||
"timeout": endpoint.timeout_seconds,
|
||||
}
|
||||
budget = endpoint.thinking_budget
|
||||
if not budget and endpoint.reasoning_effort:
|
||||
effort_map = {"low": 2048, "medium": 8192, "high": 32768}
|
||||
budget = effort_map.get(endpoint.reasoning_effort.lower())
|
||||
if budget:
|
||||
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||||
kwargs["temperature"] = 1.0
|
||||
return ChatAnthropic(**{k: v for k, v in kwargs.items() if v is not None})
|
||||
|
||||
elif provider == ModelProvider.OPENROUTER:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
api_key = (
|
||||
endpoint.api_key
|
||||
or (
|
||||
settings.OPEN_ROUTER_API_KEY.get_secret_value()
|
||||
if settings.OPEN_ROUTER_API_KEY
|
||||
else None
|
||||
)
|
||||
or os.environ.get("OPEN_ROUTER_API_KEY")
|
||||
)
|
||||
return ChatOpenAI(
|
||||
model=endpoint.model_name,
|
||||
temperature=endpoint.temperature,
|
||||
api_key=api_key,
|
||||
base_url=endpoint.api_base or "https://openrouter.ai/api/v1",
|
||||
timeout=endpoint.timeout_seconds,
|
||||
)
|
||||
|
||||
elif provider == ModelProvider.XAI:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
api_key = (
|
||||
endpoint.api_key
|
||||
or (settings.XAI_API_KEY.get_secret_value() if settings.XAI_API_KEY else None)
|
||||
or os.environ.get("XAI_API_KEY")
|
||||
)
|
||||
return ChatOpenAI(
|
||||
model=endpoint.model_name,
|
||||
temperature=endpoint.temperature,
|
||||
api_key=api_key,
|
||||
base_url=endpoint.api_base or "https://api.x.ai/v1",
|
||||
timeout=endpoint.timeout_seconds,
|
||||
)
|
||||
|
||||
elif provider in (ModelProvider.OLLAMA, ModelProvider.VLLM, ModelProvider.CUSTOM):
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
api_key = endpoint.api_key or os.environ.get("OPENAI_API_KEY", "EMPTY")
|
||||
base_url = endpoint.api_base or os.environ.get(
|
||||
"OPENAI_BASE_URL", "http://localhost:8000/v1"
|
||||
)
|
||||
kwargs = {
|
||||
"model": endpoint.model_name,
|
||||
"temperature": endpoint.temperature,
|
||||
"max_tokens": endpoint.max_tokens,
|
||||
"api_key": api_key,
|
||||
"base_url": base_url,
|
||||
"timeout": endpoint.timeout_seconds,
|
||||
}
|
||||
if endpoint.reasoning_effort:
|
||||
kwargs["reasoning_effort"] = endpoint.reasoning_effort
|
||||
return ChatOpenAI(**{k: v for k, v in kwargs.items() if v is not None})
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported model provider: {provider}")
|
||||
api_key = endpoint.api_key or (
|
||||
settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError("请在环境变量或 .env 中配置 OPENAI_API_KEY")
|
||||
kwargs = {
|
||||
"model": endpoint.model_name,
|
||||
"api_key": api_key,
|
||||
"base_url": endpoint.api_base or settings.OPENAI_BASE_URL or None,
|
||||
"timeout": endpoint.timeout_seconds,
|
||||
"max_retries": endpoint.max_retries,
|
||||
"temperature": endpoint.temperature,
|
||||
"max_tokens": endpoint.max_tokens,
|
||||
"reasoning_effort": endpoint.reasoning_effort,
|
||||
"use_responses_api": True,
|
||||
"output_version": "responses/v1",
|
||||
"store": False,
|
||||
}
|
||||
return ChatOpenAI(**{k: v for k, v in kwargs.items() if v is not None})
|
||||
File renamed without changes.
Reference in new issue
Block a user