# OpenAI Base URL Override: Which Setting Wins and What to Paste

> In the official Python and Node SDKs, base_url beats OPENAI_BASE_URL and endpoint paths are appended to it, so paste the base ending in /v1, not an endpoint.

- URL: https://blog.laozhang.ai/en/posts/openai-base-url-override
- Published: 2026-09-26
- Updated: 2026-09-26
- Author: LaoZhang AI Team (https://blog.laozhang.ai/en/about)
- Category: API Guides
- Tags: OpenAI API, OpenAI Base URL, OPENAI_BASE_URL, OpenAI-compatible API, Cursor

---
The default OpenAI base URL is `https://api.openai.com/v1`. In the official SDKs, a `base_url` (Python) or `baseURL` (Node) argument in your code beats the `OPENAI_BASE_URL` environment variable, and the environment variable beats the default. The SDK then appends the endpoint path, such as `chat/completions` or `responses`, to whatever base you gave it. So the value to paste is the provider's base up to and including its version segment (`/v1`, `/openai/v1`, `/v1beta/openai`), never a full endpoint URL.

Changing the base URL only changes where the request goes. It does not make the target implement every OpenAI API. Gemini's OpenAI compatibility layer, for example, documents Chat Completions but not the Responses API, so the same client can work for `chat.completions.create` and fail on `responses.create`.

The SDK behavior below was tested with openai-python 3.19.2 and the `openai` npm package 7.23.0, the latest releases as of September 26, 2026, against a local server that logged every request path. Cursor's "Override OpenAI Base URL" field is a separate mechanism with different rules, covered in its own section.

## Which setting wins in the Python and Node SDKs

Both SDKs resolve the base URL in the same order. The first match decides where the request goes:

| Setting | Python | Node | What happens |
| --- | --- | --- | --- |
| Per-request override | `client.with_options(base_url=...)` | `client.withOptions({ baseURL })` | Only that call is rerouted; the client keeps its own base |
| OpenAI regional endpoint | `data_residency="eu"` | `dataResidency: 'eu'` | Beats `OPENAI_BASE_URL` and sends requests to `https://eu.api.openai.com/v1` |
| Constructor argument | `OpenAI(base_url=...)` | `new OpenAI({ baseURL })` | Beats `OPENAI_BASE_URL` |
| Environment variable | `OPENAI_BASE_URL` | `OPENAI_BASE_URL` | Used when no argument is given |
| Default | `https://api.openai.com/v1` | `https://api.openai.com/v1` | Used when nothing else is set |

In the Python client, `data_residency` and `base_url` are mutually exclusive: passing both raises an error instead of picking one. The `provider=` option is also exclusive with `base_url` in both SDKs.

Three edge cases make a value you set look ignored:

- **An empty variable behaves differently per language.** With `OPENAI_BASE_URL=""` exported, Python keeps the empty string as the base and every call fails with `APIConnectionError: Connection error.` Node treats the empty value as unset and silently falls back to `https://api.openai.com/v1`, so a non-OpenAI key gets sent to OpenAI.
- **`baseURL: null` in Node turns off the environment lookup.** The client goes to the default even if `OPENAI_BASE_URL` is set.
- **`OPENAI_API_BASE` is ignored by current SDKs.** It was the pre-v1 Python name (`openai.api_base`), and many older tutorials still use it. With only that variable set, both SDKs stayed on `api.openai.com`. Some third-party frameworks read their own variable names, so check your framework's docs rather than assuming it forwards `OPENAI_BASE_URL`.

## What to paste: how the SDK builds the request URL

The Python SDK normalizes the base to end with a slash and then appends the relative endpoint path. The Node SDK produced the same paths. This is what the local server logged for a plain `chat.completions.create` call:

| Base URL you set | Path the server received |
| --- | --- |
| `http://[::1]:8765/v1` | `/v1/chat/completions` |
| `http://[::1]:8765/v1/` (trailing slash) | `/v1/chat/completions` |
| `http://[::1]:8765` (no `/v1`) | `/chat/completions` |
| `http://[::1]:8765/v1/chat/completions` | `/v1/chat/completions/chat/completions` |
| `http://[::1]:8765/v1`, calling `responses.create` | `/v1/responses` |

A trailing slash is harmless. A missing version segment or a pasted endpoint is not: both produce a path the provider does not serve, which usually surfaces as a 404. The rule is to stop right before the endpoint name. If a provider's docs show a curl request to `https://host/some/prefix/chat/completions`, the base URL is `https://host/some/prefix`.

![Four base URLs and the path a local server received for each: /v1 and /v1/ both give /v1/chat/completions, a base without /v1 gives /chat/completions, and a pasted endpoint doubles the path](https://blog.laozhang.ai/posts/en/openai-base-url-override/img/base-url-path-building.webp)

Here is what that looks like for common targets. The values come from each provider's own documentation:

| Target | Base URL | Key and `model` value | Limits worth knowing |
| --- | --- | --- | --- |
| OpenAI (default) | `https://api.openai.com/v1` | OpenAI API key; OpenAI model ID | — |
| OpenAI regional endpoint | Set `data_residency` to `us`, `eu`, or `ae` instead of pasting a URL | Same OpenAI key | The SDK knowing a regional hostname does not mean your project can use it |
| Azure OpenAI (v1 API) | `https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/` or `https://YOUR-RESOURCE-NAME.services.ai.azure.com/openai/v1/` | Azure key or an Entra ID token provider; `model` is your deployment name | No `api-version` needed with v1; Microsoft lists only a subset of capabilities in the v1 GA API |
| Gemini API | `https://generativelanguage.googleapis.com/v1beta/openai/` | Gemini API key; Gemini model ID | Google labels OpenAI library support as beta; Responses is not documented |
| [laozhang.ai](https://docs.laozhang.ai/en) gateway | `https://api.laozhang.ai/v1` | laozhang.ai key; model ID from its model list | Its docs state Responses support for the GPT-6 models (Astra, Sol, Luna), not for every model |
| Local or self-hosted server | The prefix its docs give for OpenAI-compatible routes | Whatever the server expects | Check which endpoints it actually implements |

For Azure's v1 API, Microsoft's docs use the standard `OpenAI()` client, not `AzureOpenAI`, and setting `OPENAI_BASE_URL` plus `OPENAI_API_KEY` is enough for `OpenAI()` with no arguments. The older `AzureOpenAI(azure_endpoint=..., api_version=...)` style still exists in the SDK.

Setting the override in code looks like this in Python:

```python
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
    api_key=os.environ["GEMINI_API_KEY"],
)
print(client.base_url)  # confirm before the first request
```

And in Node:

```js
import OpenAI from 'openai';

const client = new OpenAI({
  baseURL: 'https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/',
  apiKey: process.env.AZURE_OPENAI_API_KEY,
});
console.log(client.baseURL);
```

If you prefer configuration over code, export `OPENAI_BASE_URL` and `OPENAI_API_KEY` and call `OpenAI()` with no arguments. Just remember that any `base_url` argument elsewhere in the codebase will quietly win.

## A proxy and a base URL are different settings

The word "proxy" covers two different things, and the SDKs keep them apart.

A **base URL** is the API origin: the server that receives `/chat/completions` and answers in OpenAI's JSON format. A gateway or relay that speaks the OpenAI API is configured here.

An **HTTP proxy** is a network hop between you and that origin, such as a corporate egress proxy. It forwards the request without changing its destination path. In openai-python 3.19.2 you set it on the HTTP client, `OpenAI(http_client=DefaultHttpx2Client(proxy="http://proxy.example.com:8080"))`; older releases name the class `DefaultHttpxClient`. In Node, you pass an undici `ProxyAgent` as `dispatcher` inside `fetchOptions`.

If you put a network proxy's address into `base_url`, the SDK sends API paths straight to the proxy, which does not serve them. If you put a gateway into the proxy setting, requests still target `api.openai.com`.

## Check that the target implements the API you call

The SDK does not know what the target supports. It builds `{base}/chat/completions` for `chat.completions.create` and `{base}/responses` for `responses.create`, and it sends them regardless.

That matters because "OpenAI-compatible" usually describes Chat Completions. Gemini's compatibility page covers chat completions (including streaming, function calling, and `reasoning_effort`), structured outputs, embeddings, images, audio, video, batch, and model listing, but it has no Responses endpoint. In a November 2025 OpenAI community thread, a developer pointing `responses.create` at Gemini's compatibility base URL got a 404, and a community reply noted that other providers generally offer only Chat Completions compatibility. Missing documentation does not prove an endpoint can never work, but a 404 on only one of the two methods points to this layer rather than to your URL.

Before switching code to Responses, confirm that every target you route to documents it. If you are moving an older Assistants integration, the [Assistants to Responses API migration guide](https://blog.laozhang.ai/en/posts/openai-assistants-api-to-responses-api) covers the cutover itself.

## Prove where your requests actually go

Guessing from error messages is slow. Four checks, from cheapest to most thorough:

1. **Print the effective base.** `print(client.base_url)` in Python or `console.log(client.baseURL)` in Node shows what the client resolved after arguments, environment, and defaults. Python prints it with a trailing slash.
2. **Turn on SDK logging.** Set `OPENAI_LOG=debug` for Python. For Node, set `OPENAI_LOG` or pass `logLevel: 'debug'` to the client.
3. **Point the SDK at a local echo server.** This shows the exact path, host, and key prefix your real code sends, without calling any provider.
4. **Check provider-side logs.** If the provider's dashboard shows no request at all, the request never reached it, and the problem is on your side of the network.

The echo server needs only the Python standard library. It prints each request and returns a minimal chat completion so the SDK does not error out:

```python
# echo_server.py
import json
import socket
from http.server import BaseHTTPRequestHandler, HTTPServer

class Echo(BaseHTTPRequestHandler):
    def do_POST(self):
        self.rfile.read(int(self.headers.get("Content-Length", 0)))
        auth = self.headers.get("Authorization", "")
        print(f"{self.command} {self.path}  host={self.headers.get('Host')}  auth={auth[:12]}...", flush=True)
        body = json.dumps({
            "id": "echo", "object": "chat.completion", "created": 0, "model": "echo",
            "choices": [{"index": 0, "finish_reason": "stop",
                         "message": {"role": "assistant", "content": "ok"}}],
        }).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass

class LoopbackServer(HTTPServer):
    address_family = socket.AF_INET6  # listen on the IPv6 loopback address only

LoopbackServer(("::1", 8765), Echo).serve_forever()
```

Run `python echo_server.py` in one terminal. In another, run your application with `OPENAI_BASE_URL='http://[::1]:8765/v1'` (quoted, because zsh treats the brackets as a pattern) and a dummy key. A correct setup prints a line such as `POST /v1/chat/completions  host=[::1]:8765  auth=Bearer sk-te...`. If the terminal stays silent, something in your code overrides the environment variable, or the variable never reaches the process. A doubled path means the base contains an endpoint. If you see `/responses` and your target only documents chat completions, you have found the mismatch before paying for a single call.

## Map the symptom to the layer to fix

| What you see | Where the problem is | Fix |
| --- | --- | --- |
| 404 on every call; logged path lacks the version segment | Base URL is missing `/v1` (or `/openai/v1`, `/v1beta/openai`) | Add the provider's full prefix |
| 404 with a doubled path such as `/v1/chat/completions/chat/completions` | A full endpoint was pasted as the base | Trim the base to stop before the endpoint name |
| 404 only on `responses.create`; chat completions work | Target does not implement Responses | Use `chat.completions.create` or a target that documents Responses |
| 401 `Incorrect API key provided` while you use a non-OpenAI key | The request went to `api.openai.com`, so the override was not applied | Print the effective base; look for a legacy `OPENAI_API_BASE`, an empty `OPENAI_BASE_URL` in Node, or a variable not exported to the process |
| 401 from the intended provider | Wrong key or auth type for that provider | Use that provider's key; for Azure, check key versus Entra ID |
| Model not found | The `model` value belongs to another provider | Use the target's model ID; on Azure, the deployment name |
| `APIConnectionError`, no HTTP status | The request got no response: bad host or port, TLS, a proxy, or an empty base in Python | Test the network path first, as in [OpenAI API Connection Error: Fix APIConnectionError by Testing the Route First](https://blog.laozhang.ai/en/posts/openai-api-error-connection-error) |
| 429 on Azure below your quota | Rate limiting at the deployment | See [Azure OpenAI TPM Rate Limits: Diagnose 429s Below Quota](https://blog.laozhang.ai/en/posts/azure-openai-tpm-rate-limit) |

When the target really is OpenAI and authentication fails, the question is which key, organization, and project IDs you need, covered in [OpenAI API Key and Organization ID: What You Actually Need in 2026](https://blog.laozhang.ai/en/posts/openai-api-key-organization-id).

## Cursor's "Override OpenAI Base URL" follows its own rules

The Cursor setting shares a name with the SDK concept but works differently, and Cursor's help page on API keys does not document it. The routing facts below come from Cursor staff replies on the Cursor forum between August and September 2026. They are forum answers rather than documentation, some describe open issues, and they reflect Cursor as of September 26, 2026.

**Requests go through Cursor's servers, not from your machine.** The help page says all requests are routed through Cursor's backend to build the final prompt, with your key sent along each time. That has three consequences:

- Cursor cannot reach a local or LAN address. Staff replies from February, April, and May 2026 say the override needs a publicly accessible HTTPS endpoint, and suggest a tunnel such as ngrok or Cloudflare Tunnel for local models like those served by Ollama. A tunnel makes your model server reachable by anyone who finds the URL, so put authentication in front of it.
- Your own key applies to chat models only. Tab completion keeps using Cursor's models.
- Cursor's zero data retention does not cover requests made with your own key. On Team and Enterprise plans, these requests are still billed at Cursor's token rate, listed at $0.25 per 1M tokens (input, output, and cache) as of September 26, 2026.

**The override is global.** Staff replies from August 21 and September 23, 2026 state that the OpenAI API key and the override apply to every model that is not a Claude or Gemini model. That includes OpenAI models in the built-in picker, Composer, and Grok. Composer and Grok run on Cursor's infrastructure and reject custom keys with "This model does not support custom API keys". You cannot keep custom models on your endpoint and Cursor-hosted models on Cursor at the same time. Staff say per-model routing is being tracked with no timeline. Until then, turn the override off to use Cursor's models and back on for your custom ones.

**Some model names are reserved.** If a custom model ID matches one Cursor hosts itself, Cursor routes it internally no matter what base URL you set. In August 2026, staff gave `kimi-k2.6`, `kimi-k3`, and `kimi-latest` as examples; the list changes as Cursor adds models. The fix is to use a different, real model ID from your provider that does not collide.

![How Cursor handles the base URL override: requests go through Cursor's backend, which can reach a public HTTPS endpoint but not a local or LAN address; the override covers every model except Claude and Gemini, and Composer and Grok reject custom keys](https://blog.laozhang.ai/posts/en/openai-base-url-override/img/cursor-override-routing.webp)

A setup sequence that avoids the known traps, based on those staff replies:

1. Open Cursor Settings > Models and paste your provider key into the OpenAI API key field.
2. Turn on the toggle that uses that key. Pasting the key is not enough; without the toggle, the override is ignored and requests fail with `BAD_MODEL_NAME`.
3. Turn on Override OpenAI Base URL. The field pre-fills `https://api.openai.com/v1`, and turning the toggle off and on again resets it to that value.
4. Replace it with your provider's base URL, for example `https://api.fireworks.ai/inference/v1`, then press Enter or click outside the field to save.
5. Close and reopen Settings to confirm the URL stuck, add your custom model ID, and start a new chat.

If the key and URL fields ignore mouse clicks, that was a known regression in Cursor 3.15.x; staff suggested turning on the toggle and pressing Tab to move focus into the field. An `Incorrect API key provided` error after you toggled the override usually means the URL reset to OpenAI's default. If your provider's logs show no incoming request, the call never left Cursor, and the cause is Cursor's routing, not your endpoint.

## Other tools that read the base URL

A tool that creates an official SDK client without passing its own base URL inherits `OPENAI_BASE_URL`. If it exposes a base URL field instead, the same path rule applies: stop before the endpoint name. Codex is the exception worth separating out, because it distinguishes an OpenAI base URL override from a separate custom provider and has its own `config.toml` keys. [Codex Custom Provider Setup: API Key, Base URL, and Auth](https://blog.laozhang.ai/en/posts/codex-config-toml) walks through that choice and its 401 and 404 cases.

Whatever the tool, the fastest check is the same: point it at the echo server once and read the path it sends. That single line tells you whether the override took effect, whether the prefix is right, and which API surface the tool calls.
