diff --git a/backend/app/contracts.py b/backend/app/contracts.py index c44e885..9ec94e1 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -443,6 +443,7 @@ class ProviderPreset(Contract): name: str provider_type: ProviderType base_url: str + default_credential_id: str | None = None requires_credential: bool = True diff --git a/backend/app/providers/credentials.py b/backend/app/providers/credentials.py index 0340c3f..7430e6c 100644 --- a/backend/app/providers/credentials.py +++ b/backend/app/providers/credentials.py @@ -10,8 +10,17 @@ class CredentialResolver(Protocol): class EnvironmentCredentialResolver: """解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。""" + _development_aliases = { + "openai": "OPENAI_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + } + def resolve(self, credential_id: str | None) -> str | None: if not credential_id: return None normalized = re.sub(r"[^A-Za-z0-9]", "_", credential_id).upper() - return os.getenv(f"AINOTE_CREDENTIAL_{normalized}") + injected = os.getenv(f"AINOTE_CREDENTIAL_{normalized}") + if injected: + return injected + alias = self._development_aliases.get(credential_id.lower()) + return os.getenv(alias) if alias else None diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index e9f1947..38d1687 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -35,12 +35,14 @@ class ProviderFactory: name="OpenAI", provider_type=ProviderType.openai_chat, base_url="https://api.openai.com/v1", + default_credential_id="openai", ), ProviderPreset( preset_id="deepseek", name="DeepSeek", provider_type=ProviderType.openai_compatible, base_url="https://api.deepseek.com", + default_credential_id="deepseek", ), ProviderPreset( preset_id="ollama", diff --git a/backend/app/providers/openai_compatible.py b/backend/app/providers/openai_compatible.py index 24b07a3..11c9500 100644 --- a/backend/app/providers/openai_compatible.py +++ b/backend/app/providers/openai_compatible.py @@ -264,6 +264,11 @@ class OpenAICompatibleProvider(TurnStreamingMixin): def _headers(self) -> dict[str, str]: headers = {"Content-Type": "application/json"} api_key = self.credentials.resolve(self.credential_id) + if self.credential_id and not api_key: + raise ProviderError( + "PROVIDER_CREDENTIAL_MISSING", + f'Credential "{self.credential_id}" is not available in the AI Core process.', + ) if api_key: headers["Authorization"] = f"Bearer {api_key}" return headers diff --git a/backend/app/routes.py b/backend/app/routes.py index 1fc1994..a14eccb 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -517,6 +517,7 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse: models = await container.providers.list_models(provider_id) except ProviderError as exc: status_code = { + "PROVIDER_CREDENTIAL_MISSING": 422, "PROVIDER_AUTH_FAILED": 401, "MODEL_NOT_FOUND": 404, "PROVIDER_RATE_LIMITED": 429, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index f107842..e7a825e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -67,6 +67,7 @@ def test_provider_presets_include_openai_and_deepseek() -> None: assert by_id["openai"].base_url == "https://api.openai.com/v1" assert by_id["deepseek"].base_url == "https://api.deepseek.com" assert by_id["deepseek"].provider_type == ProviderType.openai_compatible + assert by_id["deepseek"].default_credential_id == "deepseek" def test_provider_presets_static_route_precedes_provider_id_route() -> None: diff --git a/backend/tests/test_provider_adapters.py b/backend/tests/test_provider_adapters.py index f6c5f45..781e818 100644 --- a/backend/tests/test_provider_adapters.py +++ b/backend/tests/test_provider_adapters.py @@ -12,6 +12,8 @@ from app.contracts import ( ToolDefinition, ) from app.providers.ollama import OllamaProvider +from app.providers.base import ProviderError +from app.providers.credentials import EnvironmentCredentialResolver from app.providers.openai_compatible import OpenAICompatibleProvider @@ -156,6 +158,31 @@ def test_openai_compatible_fetches_and_maps_model_list() -> None: assert [item.model for item in models] == ["model-b", "model-a"] +def test_environment_credentials_support_deepseek_development_alias(monkeypatch) -> None: + monkeypatch.setenv("DEEPSEEK_API_KEY", "secret-test-key") + + assert EnvironmentCredentialResolver().resolve("deepseek") == "secret-test-key" + + +def test_openai_compatible_rejects_missing_named_credential_before_request() -> None: + class EmptyCredentials: + def resolve(self, credential_id: str | None) -> str | None: + return None + + provider = OpenAICompatibleProvider( + base_url="https://provider.test/v1", + credential_id="deepseek", + credentials=EmptyCredentials(), + ) + + try: + run(provider.list_models()) + except ProviderError as error: + assert error.code == "PROVIDER_CREDENTIAL_MISSING" + else: + raise AssertionError("Missing credential should fail before the provider request") + + def test_ollama_maps_models_and_completion() -> None: def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/api/tags":