From 74b15d481731f46f578a71623df55153cc72ea61 Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Sun, 27 Sep 2026 22:05:35 +0800 Subject: [PATCH] fix(providers): don't fail construction on an empty key with no env var 0.12.0 (#41) passed `None` for an empty api_key so the SDK would read OPENAI_API_KEY. With the variable unset the SDK raises at construction, which breaks hosts that build a default OpenAI client they never call (ApodexHarness BenchmarkSession beside an Anthropic workflow LLM failed every episode at turn 0). Newer SDKs reject "" as well, so fall back to a placeholder key; a client that is really called gets the endpoint's 401. Co-Authored-By: Claude Opus 5.5 (1M context) --- agent_core/providers/_api_key.py | 23 +++++++++++++++++++++++ agent_core/providers/openai_chat.py | 6 ++---- agent_core/providers/openai_responses.py | 6 ++---- changes/openai-empty-key-no-env.fix.md | 1 + tests/test_openai_empty_api_key.py | 14 ++++++++++++++ 5 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 agent_core/providers/_api_key.py create mode 100644 changes/openai-empty-key-no-env.fix.md diff --git a/agent_core/providers/_api_key.py b/agent_core/providers/_api_key.py new file mode 100644 index 0000000..df3c593 --- /dev/null +++ b/agent_core/providers/_api_key.py @@ -0,0 +1,23 @@ +"""API-key resolution shared by the OpenAI-SDK-backed clients.""" + +from __future__ import annotations + +import os + +#: Sent when neither the caller nor the environment supplies a key. +UNSET_OPENAI_API_KEY = "EMPTY" + + +def resolve_openai_api_key(api_key: str | None) -> str: + """Return the key an ``AsyncOpenAI`` client should be constructed with. + + An empty or ``None`` key falls back to ``OPENAI_API_KEY``: an empty string + (a config read before the environment was populated) would otherwise shadow + the variable. With no variable either, return a placeholder rather than + ``None``/``""``: the SDK raises on those at CONSTRUCTION (newer SDKs reject + ``""`` too), which fails hosts that build an OpenAI client they never call, + e.g. a default-provider client beside an Anthropic workflow LLM. A client + that is called fails at request time with the endpoint's 401 instead; + keyless OpenAI-compatible servers (vLLM, a proxy) accept it as-is. + """ + return api_key or os.environ.get("OPENAI_API_KEY") or UNSET_OPENAI_API_KEY diff --git a/agent_core/providers/openai_chat.py b/agent_core/providers/openai_chat.py index 62e6c3e..ec06f36 100644 --- a/agent_core/providers/openai_chat.py +++ b/agent_core/providers/openai_chat.py @@ -21,6 +21,7 @@ from agent_core.llm import LLMClient, LLMResponse, StreamDelta from agent_core.messages import Message, ToolCall, for_wire +from agent_core.providers._api_key import resolve_openai_api_key from agent_core.runtime.llm_request_overrides import ( current_thinking_retry_override, ) @@ -180,10 +181,7 @@ def __init__( # (see ``mirror_session_query``) — mirror a construction-time session # header into ``default_query`` so it rides every request's URL. self._client = AsyncOpenAI( - # The SDK consults OPENAI_API_KEY only for ``None``; an empty - # string (a config read before the environment was populated) - # raises "Missing credentials" even with the variable set. - api_key=api_key or None, + api_key=resolve_openai_api_key(api_key), base_url=base_url or None, timeout=timeout, default_headers=default_headers, diff --git a/agent_core/providers/openai_responses.py b/agent_core/providers/openai_responses.py index 3e3d029..2dc8af3 100644 --- a/agent_core/providers/openai_responses.py +++ b/agent_core/providers/openai_responses.py @@ -37,6 +37,7 @@ from agent_core.llm import LLMClient, LLMResponse, StreamDelta from agent_core.messages import Message, ToolCall, text_of +from agent_core.providers._api_key import resolve_openai_api_key from agent_core.providers.finish_reason import ( normalize_finish_reason, responses_finish_reason, @@ -71,10 +72,7 @@ def __init__( self._reasoning = reasoning or None self._store = store self._client = AsyncOpenAI( - # The SDK consults OPENAI_API_KEY only for ``None``; an empty - # string (a config read before the environment was populated) - # raises "Missing credentials" even with the variable set. - api_key=api_key or None, + api_key=resolve_openai_api_key(api_key), base_url=base_url or None, timeout=timeout, default_headers=default_headers, diff --git a/changes/openai-empty-key-no-env.fix.md b/changes/openai-empty-key-no-env.fix.md new file mode 100644 index 0000000..7f302f6 --- /dev/null +++ b/changes/openai-empty-key-no-env.fix.md @@ -0,0 +1 @@ +OpenAI clients no longer fail at construction when the API key is empty and `OPENAI_API_KEY` is unset (a regression in 0.12.0 that broke hosts building an unused default OpenAI client, e.g. ApodexHarness BenchmarkSession beside an Anthropic workflow LLM). A placeholder key is used instead; a client that is actually called fails with the endpoint's 401. The environment variable still wins over an empty key. diff --git a/tests/test_openai_empty_api_key.py b/tests/test_openai_empty_api_key.py index 198ea6a..bbd07fa 100644 --- a/tests/test_openai_empty_api_key.py +++ b/tests/test_openai_empty_api_key.py @@ -4,6 +4,7 @@ import pytest +from agent_core.providers._api_key import UNSET_OPENAI_API_KEY from agent_core.providers.openai_chat import OpenAIClient from agent_core.providers.openai_responses import OpenAIResponsesClient @@ -26,3 +27,16 @@ def test_explicit_api_key_wins(client_cls: type, monkeypatch: pytest.MonkeyPatch client = client_cls("gpt-test", api_key="explicit") assert client._client.api_key == "explicit" + + +@pytest.mark.parametrize("client_cls", [OpenAIClient, OpenAIResponsesClient]) +def test_empty_api_key_without_environment_still_constructs( + client_cls: type, monkeypatch: pytest.MonkeyPatch +) -> None: + """A host that builds an OpenAI client it never calls (no OPENAI_API_KEY + anywhere) must not fail at construction, as it did not on 0.11.x.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + for key in ("", None): + client = client_cls("gpt-test", api_key=key) + assert client._client.api_key == UNSET_OPENAI_API_KEY