Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions agent_core/providers/_api_key.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 2 additions & 4 deletions agent_core/providers/openai_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 2 additions & 4 deletions agent_core/providers/openai_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions changes/openai-empty-key-no-env.fix.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions tests/test_openai_empty_api_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Loading