Skip to content

fix(client): fall back to default base_url when OPENAI_BASE_URL is empty - #3884

Open
Sehastrajit-S wants to merge 1 commit into
openai:mainfrom
Sehastrajit-S:fix/empty-openai-base-url-env-fallback
Open

Sehastrajit-S wants to merge 1 commit into
openai:mainfrom
Sehastrajit-S:fix/empty-openai-base-url-env-fallback

Conversation

@Sehastrajit-S

Copy link
Copy Markdown

Summary

Fixes #2927.

OpenAI() and AsyncOpenAI() read OPENAI_BASE_URL from the environment when no base_url is passed explicitly, and fall back to https://api.openai.com/v1 only when that value is None. If the env var is set but empty (OPENAI_BASE_URL=""), it is treated as an explicit value, so the fallback is skipped and the client ends up with an empty base URL, producing a confusing APIConnectionError instead of just using the default endpoint.

This is a common failure mode in deployment tooling: Docker Compose, Kubernetes manifests, and CI templates frequently interpolate optional variables as empty strings rather than omitting them entirely (for example OPENAI_BASE_URL=${SOME_VAR:-}), so this can surface in production without any obviously wrong configuration on the user's part.

Change

Treat an empty OPENAI_BASE_URL the same as an unset one in both the sync and async client constructors, so it correctly falls back to the default endpoint (or the mTLS default, when applicable).

Test plan

  • Added test_base_url_env_empty_string_falls_back_to_default for both TestOpenAI and TestAsyncOpenAI in tests/test_client.py, verifying OPENAI_BASE_URL="" results in the default https://api.openai.com/v1/ base URL.
  • uv run pytest tests/test_client.py -k base_url_env -v passes.
  • Ran the broader related suite (test_client.py, data residency, x509, mTLS, Bedrock provider tests) to confirm no regressions; the only failures observed (test_proxy_environment_variables) are pre-existing on main and unrelated to this change.
  • uv run ruff check / uv run ruff format --diff / uv run mypy src/openai/_client.py all pass on the changed files.

Deployment tooling (Docker, Kubernetes, CI templates) commonly sets
unset env vars to an empty string rather than omitting them, e.g.
OPENAI_BASE_URL=${SOME_VAR:-}. Previously an empty OPENAI_BASE_URL
was treated as an explicit value, producing an invalid base URL and
a confusing APIConnectionError instead of falling back to the
default OpenAI endpoint.

Fixes openai#2927
@jnohclee-rgb

Copy link
Copy Markdown

Independent offline verification (AI-assisted): I checked eight constructor/copy precedence cases for both OpenAI and AsyncOpenAI: unset/empty/custom environment URL, explicit custom URL overriding empty/custom environment, EU residency overriding empty/custom environment, and an explicit empty URL control. Main 919b6236382f3f9f76d8453efb9f9ad03cff1126 passed 14/16; PR head f2505b509877f4dd91613f3a61fddb59c26cacad passed 16/16. The two baseline failures were empty-environment cases. Both the normalized URL and _base_url_was_default survived with_options(timeout=2). The explicit-empty argument remained an empty URL; this patch only changes the environment fallback.

Python 3.14.7, shared existing dependencies, isolated source trees and network-denied runs. A fake key and rejecting mock transport were used; no request occurred. This does not test X.509, TLS, provider routing, a historical dependency matrix or full SDK compatibility.

Runnable separately against each source tree:

import asyncio,itertools,json,os
import httpx2
from openai import OpenAI,AsyncOpenAI
import importlib
source=importlib.import_module('openai._client')
cases=[('unset',None,{},'https://api.openai.com/v1/',True),('empty','',{},'https://api.openai.com/v1/',True),('custom_env','https://example.invalid/env',{},'https://example.invalid/env/',False),('explicit_custom_empty_env','',{'base_url':'https://example.invalid/explicit'},'https://example.invalid/explicit/',False),('explicit_custom_custom_env','https://example.invalid/env',{'base_url':'https://example.invalid/explicit'},'https://example.invalid/explicit/',False),('eu_empty_env','',{'data_residency':'eu'},'https://eu.api.openai.com/v1/',False),('eu_custom_env','https://example.invalid/env',{'data_residency':'eu'},'https://eu.api.openai.com/v1/',False),('explicit_empty','https://example.invalid/env',{'base_url':''},'',False)]
rows=[]
def handler(request):raise AssertionError('No request expected')
async def main():
 for is_async in [False,True]:
  for name,env,kwargs,expected,default in cases:
   if env is None:os.environ.pop('OPENAI_BASE_URL',None)
   else:os.environ['OPENAI_BASE_URL']=env
   http=(httpx2.AsyncClient if is_async else httpx2.Client)(transport=httpx2.MockTransport(handler),trust_env=False)
   client=(AsyncOpenAI if is_async else OpenAI)(api_key='fictional-key',http_client=http,**kwargs)
   copied=client.with_options(timeout=2)
   actual=str(client.base_url);copied_url=str(copied.base_url)
   rows.append({'case':name,'async':is_async,'actual':actual,'expected':expected,'default_flag':client._base_url_was_default,'copied_url':copied_url,'copied_default_flag':copied._base_url_was_default,'pass':actual==expected and copied_url==expected and client._base_url_was_default==default and copied._base_url_was_default==default})
   if is_async:await client.close()
   else:client.close()
 print(json.dumps({'source_module':source.__file__,'cases':rows,'passed':sum(r['pass'] for r in rows),'failed':sum(not r['pass'] for r in rows),'scope':'Constructor/copy precedence and default flags; fake key and transport that rejects calls; no X.509/provider/TLS test.'}))
asyncio.run(main())

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Empty OPENAI_BASE_URL prevents fallback to default API endpoint

2 participants