diff --git a/.env.example b/.env.example index 6667205d..e02361a6 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,12 @@ # ANTHROPIC_AUTH_TOKEN= # ANTHROPIC_BASE_URL= +# Atlas Cloud(OpenAI 兼容);启用时设置 LLM_PROVIDER=atlascloud +# LLM_PROVIDER=atlascloud +# ATLASCLOUD_API_KEY= +# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1 +# ATLASCLOUD_MODEL=deepseek-ai/deepseek-v4-pro + # 与 aivideo 共用方舟凭证 ARK_API_KEY= ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3/chat/completions diff --git a/application/ai/llm_control_service.py b/application/ai/llm_control_service.py index 62cbf79e..b3ac8386 100644 --- a/application/ai/llm_control_service.py +++ b/application/ai/llm_control_service.py @@ -8,7 +8,11 @@ from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator from domain.ai.services.llm_service import DEFAULT_MAX_OUTPUT_TOKENS -from infrastructure.ai.llm_environment import LLMEnvironmentSettings +from infrastructure.ai.llm_environment import ( + ATLAS_CLOUD_DEFAULT_BASE_URL, + ATLAS_CLOUD_DEFAULT_MODEL, + LLMEnvironmentSettings, +) from infrastructure.persistence.database.connection import get_database from infrastructure.ai.url_utils import ( normalize_anthropic_base_url, @@ -207,6 +211,15 @@ def get_presets(self) -> List[LLMPreset]: description='OpenAI 官方接口(自动兼容底层 Responses API 与 Chat Completions)。导入后请在「模型名」填写所用模型 ID。', tags=['official'], ), + LLMPreset( + key='atlascloud', + label='Atlas Cloud', + protocol='openai', + default_base_url=ATLAS_CLOUD_DEFAULT_BASE_URL, + default_model=ATLAS_CLOUD_DEFAULT_MODEL, + description='Atlas Cloud OpenAI-compatible 接口, 支持统一访问多种文本模型。', + tags=['cloud', 'openai-compatible', 'preset'], + ), LLMPreset( key='claude-official', label='Claude / Anthropic 官方', @@ -388,12 +401,16 @@ def resolve_profile(self, profile: LLMProfile) -> LLMProfile: profile.base_url.strip() or (preset.default_base_url if preset else ''), ) model = profile.model.strip() or (preset.default_model if preset else '') + use_legacy_chat_completions = ( + profile.use_legacy_chat_completions or profile.preset_key == 'atlascloud' + ) return LLMProfile( **{ **profile.model_dump(), 'protocol': protocol, 'base_url': base_url, 'model': model, + 'use_legacy_chat_completions': use_legacy_chat_completions, } ) @@ -574,8 +591,24 @@ def _build_initial_config(self) -> LLMControlConfig: openai_key = env.openai_api_key gemini_key = env.gemini_api_key ark_key = env.ark_api_key + atlascloud_key = env.atlascloud_api_key - if anthropic_key and (env.provider == 'anthropic' or not env.provider): + if atlascloud_key and ( + env.provider == 'atlascloud' + or ( + not env.provider + and not any((anthropic_key, openai_key, gemini_key, ark_key)) + ) + ): + profiles[0] = profiles[0].model_copy(update={ + 'name': 'Atlas Cloud', + 'preset_key': 'atlascloud', + 'api_key': atlascloud_key, + 'base_url': env.atlascloud_base_url_or_default, + 'model': env.atlascloud_model_or_default, + }) + active_profile_id = profiles[0].id + elif anthropic_key and (env.provider == 'anthropic' or not env.provider): profiles[1] = profiles[1].model_copy(update={ 'api_key': anthropic_key, 'base_url': env.anthropic_base_url or profiles[1].base_url, diff --git a/infrastructure/ai/llm_environment.py b/infrastructure/ai/llm_environment.py index 65d76439..19e8b8fb 100644 --- a/infrastructure/ai/llm_environment.py +++ b/infrastructure/ai/llm_environment.py @@ -6,6 +6,8 @@ ARK_DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3" +ATLAS_CLOUD_DEFAULT_BASE_URL = "https://api.atlascloud.ai/v1" +ATLAS_CLOUD_DEFAULT_MODEL = "deepseek-ai/deepseek-v4-pro" def _env_text(name: str, default: str = "") -> str: @@ -29,6 +31,9 @@ class LLMEnvironmentSettings: gemini_api_key: str = "" gemini_base_url: str = "" gemini_model: str = "" + atlascloud_api_key: str = "" + atlascloud_base_url: str = "" + atlascloud_model: str = "" ark_api_key: str = "" ark_base_url: str = "" ark_model: str = "" @@ -49,6 +54,9 @@ def from_env(cls) -> "LLMEnvironmentSettings": gemini_api_key=_env_text("GEMINI_API_KEY"), gemini_base_url=_env_text("GEMINI_BASE_URL"), gemini_model=_env_text("GEMINI_MODEL"), + atlascloud_api_key=_env_text("ATLASCLOUD_API_KEY"), + atlascloud_base_url=_env_text("ATLASCLOUD_BASE_URL"), + atlascloud_model=_env_text("ATLASCLOUD_MODEL"), ark_api_key=_env_text("ARK_API_KEY"), ark_base_url=_env_text("ARK_BASE_URL"), ark_model=_env_text("ARK_MODEL"), @@ -64,6 +72,14 @@ def openai_preset_key(self) -> str: return "custom-openai-compatible" return "openai-official" + @property + def atlascloud_base_url_or_default(self) -> str: + return self.atlascloud_base_url or ATLAS_CLOUD_DEFAULT_BASE_URL + + @property + def atlascloud_model_or_default(self) -> str: + return self.atlascloud_model or ATLAS_CLOUD_DEFAULT_MODEL + @property def ark_base_url_or_default(self) -> str: return self.ark_base_url or ARK_DEFAULT_BASE_URL diff --git a/tests/unit/application/ai/test_llm_control_service.py b/tests/unit/application/ai/test_llm_control_service.py index 894748e8..4fdadde8 100644 --- a/tests/unit/application/ai/test_llm_control_service.py +++ b/tests/unit/application/ai/test_llm_control_service.py @@ -1,6 +1,10 @@ from application.ai.llm_control_service import LLMControlService, LLMProfile from domain.ai.services.llm_service import DEFAULT_MAX_OUTPUT_TOKENS -from infrastructure.ai.llm_environment import ARK_DEFAULT_BASE_URL +from infrastructure.ai.llm_environment import ( + ARK_DEFAULT_BASE_URL, + ATLAS_CLOUD_DEFAULT_BASE_URL, + ATLAS_CLOUD_DEFAULT_MODEL, +) LLM_ENV_NAMES = ( @@ -15,6 +19,9 @@ "GEMINI_API_KEY", "GEMINI_BASE_URL", "GEMINI_MODEL", + "ATLASCLOUD_API_KEY", + "ATLASCLOUD_BASE_URL", + "ATLASCLOUD_MODEL", "ARK_API_KEY", "ARK_BASE_URL", "ARK_MODEL", @@ -79,6 +86,50 @@ def test_initial_config_keeps_ark_default_base_url(monkeypatch): assert active.model == "ark-model" +def test_atlascloud_preset_has_working_defaults(): + preset = LLMControlService().get_preset_map()["atlascloud"] + + assert preset.protocol == "openai" + assert preset.default_base_url == ATLAS_CLOUD_DEFAULT_BASE_URL + assert preset.default_model == ATLAS_CLOUD_DEFAULT_MODEL + + +def test_initial_config_uses_atlascloud_environment(monkeypatch): + _clear_llm_env(monkeypatch) + monkeypatch.setenv("LLM_PROVIDER", "atlascloud") + monkeypatch.setenv("ATLASCLOUD_API_KEY", "atlas-key") + + config = LLMControlService()._build_initial_config() + + active = config.profiles[0] + assert config.active_profile_id == active.id + assert active.name == "Atlas Cloud" + assert active.preset_key == "atlascloud" + assert active.base_url == ATLAS_CLOUD_DEFAULT_BASE_URL + assert active.model == ATLAS_CLOUD_DEFAULT_MODEL + + +def test_atlascloud_key_does_not_change_existing_default_priority(monkeypatch): + _clear_llm_env(monkeypatch) + monkeypatch.setenv("ATLASCLOUD_API_KEY", "atlas-key") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "anthropic-token") + + config = LLMControlService()._build_initial_config() + + assert config.active_profile_id == "claude-official-default" + assert config.profiles[1].api_key == "anthropic-token" + + +def test_resolve_atlascloud_profile_uses_chat_completions(): + resolved = LLMControlService().resolve_profile( + LLMProfile(id="atlas", name="Atlas Cloud", preset_key="atlascloud") + ) + + assert resolved.base_url == ATLAS_CLOUD_DEFAULT_BASE_URL + assert resolved.model == ATLAS_CLOUD_DEFAULT_MODEL + assert resolved.use_legacy_chat_completions is True + + def test_profile_lifts_small_max_tokens_to_global_floor(): profile = LLMProfile(id="p", name="Profile", max_tokens=4096) diff --git a/tests/unit/infrastructure/ai/test_llm_environment.py b/tests/unit/infrastructure/ai/test_llm_environment.py index 8150dda3..4f134c17 100644 --- a/tests/unit/infrastructure/ai/test_llm_environment.py +++ b/tests/unit/infrastructure/ai/test_llm_environment.py @@ -1,5 +1,7 @@ from infrastructure.ai.llm_environment import ( ARK_DEFAULT_BASE_URL, + ATLAS_CLOUD_DEFAULT_BASE_URL, + ATLAS_CLOUD_DEFAULT_MODEL, LLMEnvironmentSettings, ) @@ -18,6 +20,9 @@ "GEMINI_API_KEY", "GEMINI_BASE_URL", "GEMINI_MODEL", + "ATLASCLOUD_API_KEY", + "ATLASCLOUD_BASE_URL", + "ATLASCLOUD_MODEL", "ARK_API_KEY", "ARK_BASE_URL", "ARK_MODEL", @@ -37,6 +42,8 @@ def test_llm_environment_defaults(monkeypatch): assert settings.provider == "" assert settings.anthropic_api_key_with_token_fallback == "" assert settings.openai_preset_key == "openai-official" + assert settings.atlascloud_base_url_or_default == ATLAS_CLOUD_DEFAULT_BASE_URL + assert settings.atlascloud_model_or_default == ATLAS_CLOUD_DEFAULT_MODEL assert settings.ark_base_url_or_default == ARK_DEFAULT_BASE_URL @@ -88,3 +95,16 @@ def test_llm_environment_ark_base_url_default_and_override(monkeypatch): monkeypatch.setenv("ARK_BASE_URL", "https://ark.example/api/v3") assert LLMEnvironmentSettings.from_env().ark_base_url_or_default == "https://ark.example/api/v3" + + +def test_llm_environment_atlascloud_overrides(monkeypatch): + _clear_llm_env(monkeypatch) + monkeypatch.setenv("ATLASCLOUD_API_KEY", "atlas-key") + monkeypatch.setenv("ATLASCLOUD_BASE_URL", "https://atlas.example/v1") + monkeypatch.setenv("ATLASCLOUD_MODEL", "vendor/model") + + settings = LLMEnvironmentSettings.from_env() + + assert settings.atlascloud_api_key == "atlas-key" + assert settings.atlascloud_base_url_or_default == "https://atlas.example/v1" + assert settings.atlascloud_model_or_default == "vendor/model"