Accept full base URLs in OpenAIClient and collect --llm-endpoint - #1189
surajsharan wants to merge 9 commits into
Conversation
`--llm-endpoint` is documented as a URL, but OpenAIClient built its base
URL as f"{endpoint}:{port}/v1", so a URL that already named a port failed
with "Invalid port: '8000:8000'" and a URL with a path (LiteLLM proxy,
reverse-proxied vLLM, a gateway prefix) could not be expressed at all.
Parse endpoint as a URL: append /v1 only when the URL has no path, append
`port` only when the URL names none, reject a conflicting explicit port,
and raise a clear ValueError for malformed endpoints. The CLI reports
those as usage errors before writing to --output-dir, and --llm-port now
defaults to unset so full URLs without a port are left alone. The
("http://localhost", 8000) form is unchanged.
Document `openenv collect` in the CLI reference and show the self-hosted
teacher form in the SFT warmup tutorial and harness README.
Closes huggingface#1188
There was a problem hiding this comment.
Stale comment
Release-manager review at head
ea38caec. The underlying defect is real and the fix is the right shape:base_urlno longer produceshttp://host:8000:8000or drops a/v1prefix, the port conflict is rejected instead of silently overridden, and_openai_base_urlcorrectly leaves a gateway path alone. Test coverage acrosstest_llm_client.pyandtest_collect.pyis good. Three things to resolve before this can go in.1. The promised malformed-URL validation does not actually validate the URL.
_join_endpoint_portonly checks"://" not in endpointand then whether an existing port parses. That meansftp://hostandhttp:///v1(no authority at all) both pass and are returned as an "endpoint", so theValueError/typer.BadParameterthe docstring and the--llm-endpointerror path promise never fires — the failure just resurfaces later inside the OpenAI SDK with a worse message. Please require anhttp/httpsscheme and a non-empty hostname. While you are there, reject userinfo in the authority (http://user:token@host):--llm-endpointvalues get echoed into logs and persisted into rollout metadata, so a credential-bearing URL should not be accepted silently.2. The appended port is never range-checked. When the URL names no port,
portis interpolated straight into the netloc with no1 <= port <= 65535check, so--llm-port 0and--llm-port 99999build an endpoint that only fails later.urlsplit().portraises for an out-of-range port in a URL, so the two paths disagree about what a valid port is. A shared check would fix both.3.
--llm-portchanging from8000to unset is a public default change. In bothopenenv collectandexamples/ttt_collect_with_llm.pythe default moves from8000toNone. Anyone running--llm-endpoint http://localhosttoday reaches port 8000; after this they reach port 80, with no error and no warning — it will look like the server is down. That is defensible as part of "the endpoint is now a full base URL", but it needs to be deliberate: either keep the old default for the pathless case, or keep the change and call it out in the PR description so it lands in the release notes.One thing to double-check rather than a blocker: the docstring says query and fragment are preserved, and there is a test asserting a query-bearing base URL is supported. The OpenAI SDK joins relative paths (
chat/completions) againstbase_url, and that join discards the query, so a?x=1base URL probably does not behave as the test implies once a real request goes out. Worth either an end-to-end assertion against the SDK's constructed request URL, or rejecting query/fragment and passing those parameters through a supported SDK seam.For context on timing: this is not in the 0.5.0 candidate (#1190) and is not holding the release, so there is room to get it right. Repository CI has not run on this head yet either — it needs a maintainer to approve the workflow run.
Sent by Cursor Automation: Release
Address review on huggingface#1189: - Require an http(s) scheme and a host. `ftp://host` and `http:///v1` used to pass validation and only fail later inside the SDK. - Range-check the port (1-65535) whether it comes from the URL or from `port`/--llm-port, so both paths agree on what a valid port is. - Reject credentials in the URL and never echo them: the endpoint is printed, written to metadata.json and rendered into the dataset card. - Reject query strings and fragments. The OpenAI SDK appends the request path to the raw base URL, so `/v1?api-version=1` was requested as path `/v1` with query `api-version=1/chat/completions`. The tests that claimed this form worked are replaced by one that captures the URL the SDK actually requests through a mock transport. - Print the resolved LLM endpoint when `openenv collect` starts and say in --help that --llm-port has no default, so the move away from the implicit 8000 is visible rather than looking like a dead server.
There was a problem hiding this comment.
Re-reviewed at exact head eccef788. All three items from my 2026-09-17 review are genuinely fixed, and I verified each one by running the code rather than reading it.
1. URL validation is real now. _join_endpoint_port requires an http/https scheme and a non-empty host, rejects userinfo, query and fragment, and catches the empty-port case that urlsplit silently tolerates. Confirmed rejections: ftp://host, http:///v1, localhost:8000, http://user:token@host, http://host?x=1, http://host:. The credential case is reported as Invalid endpoint URL 'http://***@host', so the token never reaches the log line, and there is a test pinning that.
2. Ports are range-checked on both sides. --llm-port 0 and --llm-port 99999 are rejected before any request, and an in-URL out-of-range port is rejected too. One cosmetic wrinkle: the in-URL case surfaces urlsplit's own wording (Port out of range 0-65535) while your own check says 1-65535. Worth normalizing eventually; not worth another cycle.
3. The default change is now deliberate. --llm-port help says "No default: earlier releases assumed 8000", and collect prints the resolved LLM endpoint: before running, so a user who relied on the implicit 8000 sees port 80 immediately instead of debugging a silent connection failure. Moving the model-step construction above RolloutSerializer is a good side effect: an invalid endpoint now fails before any output directory is written.
The earlier query-string question is resolved by rejecting query and fragment outright, which is the right call given the SDK appends relative paths to base_url.
What I ran locally at this head: tests/core/test_llm_client.py and tests/test_cli/test_collect.py — 105 passed. Round-trips: http://localhost + 8000, http://localhost:8000/v1 + None, http://localhost:8000 + 8000, and https://gw.example.com/openai/v1 all produce the expected base URL, and the OpenAI SDK ends up with http://localhost:8000/v1/ in each self-hosted case. Hosted providers are unaffected: create_llm_client("openai", …) still yields https://api.openai.com/v1/.
Still not mergeable, for two reasons outside the code:
- Repository CI has never run on this branch. As a fork PR it sits at
action_required, so only Bugbot reported; a maintainer has to click "Approve and run" before required checks exist. LLMClient.__init__now takesport: int | Noneand the--llm-portdefault moves from8000to unset. That is a public API and CLI behavior change, so it needs a maintainer's explicit sign-off rather than mine, and it needs a release-note line when it ships — I will carry it into the next release notes once it lands.
My earlier change request is withdrawn; the code is ready from my side.
Sent by Cursor Automation: Release
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
Typer renders usage errors in colour when GITHUB_ACTIONS or FORCE_COLOR is set, so on CI the escape codes split "--llm-endpoint" and the error text the new invalid-endpoint tests look for, and all six cases failed. Strip ANSI codes before flattening the error panel.
|
Thanks for running CI. The failures were the six new invalid-endpoint cases in |


Summary
--llm-endpointis documented as an OpenAI-compatible endpoint URL, butOpenAIClientbuilt its base URL asf"{endpoint}:{port}/v1", so--llm-endpoint http://localhost:8000failed withInvalid port: '8000:8000'and a URL with a path (LiteLLM proxy, vLLM behind a reverse proxy, a gateway prefix) could not be expressed at all.endpointis now parsed as a URL:/v1is appended when the URL has no path; a URL with a path is used as-is (the OpenAI SDK convention).http://localhost:8000andhttp://localhost:8000/v1are equivalent.portis appended only when the URL names none. An explicit port that differs from the URL's port raises instead of being silently overridden.http(s)URLs with a host are accepted. A double or empty port, a port outside 1-65535 (in the URL or viaport), credentials, query strings and fragments raise aValueErrornaming the URL with any credentials redacted;openenv collectreports them as a usage error before anything is written to--output-dir. Query strings are rejected because the OpenAI SDK appends the request path to the raw base URL, which turns/v1?api-version=1into path/v1with queryapi-version=1/chat/completions.("http://localhost", 8000)form used by existing callers, the rubrics tutorial and the factory is unchanged.Behaviour change to note:
--llm-portnow defaults to unset instead of 8000, so a bare--llm-endpoint http://localhostno longer gets:8000injected. That default was never documented and it broke every full URL without an explicit port (https://api.groq.com/openaibecamehttps://api.groq.com:8000/openai). The example's--llm-portfollows the same rule. To keep this visible,openenv collectprints the resolvedLLM endpoint:when it starts, and--helpand the CLI reference state that there is no default and that earlier releases assumed 8000.Also documents the
collectcommand in the CLI reference (it was missing) and shows the self-hosted teacher form in the SFT warmup tutorial and the harness README.Closes #1188
Type of Change
Alignment Checklist
.claude/docs/PRINCIPLES.mdand this PR aligns with our principles.claude/docs/INVARIANTS.mdand no invariants are violatedbash .claude/hooks/lint.shand tests and addressed all issuesRFC Status
No new API:
LLMClient.portbecomes optional andendpointaccepts what its docstring already called "the base URL".Test Plan
Unit (
PYTHONPATH=src:envs pytest tests/core/test_llm_client.py tests/test_cli/test_collect.py):LLMClient.base_urlfor endpoints with a port, a path, a trailing slash, IPv6; invalid forms (double port, empty port, missing or non-http scheme, missing host, unclosed IPv6, query string, fragment, credentials, out-of-range port in the URL or viaport) raiseInvalid endpoint URL; the error never echoes a credential; a conflicting explicit port raises.OpenAIClientresolveshttp://localhost:8000,.../v1,.../v1/and("http://localhost", 8001)to a/v1base URL, and leaveshttp://proxy:4000/litellm,https://api.groq.com/openai/v1and Gemini's/v1beta/openaiuntouched.AsyncOpenAIwith anhttpx.MockTransport:complete()requests<base>/chat/completionsfor each accepted endpoint form.openenv collect --llm-endpointwith each URL form reachesAsyncOpenAIwith the expected base URL and prints the resolved endpoint; an invalid or conflicting endpoint or port exits 2 with a--llm-endpointusage error, without echoing credentials, and neithermetadata.jsonnor a rollout is written.Full suite: 2551 passed, 157 skipped, 2 failed (
PYTHONPATH=src:envs pytest tests/ -q). The twoTestProtocolWebSocketClientfailures are pre-existing on main (integration tests, excluded in CI) and unrelated.Live: vLLM serving
Qwen/Qwen3.5-4Bonhttp://localhost:8000,reasoning_gym_envserver on:8001.openenv collect reasoning_gym:chain_sum --llm-endpoint http://localhost:8000 ...→Error: Invalid port: '8000:8000'.--llm-endpoint http://localhost:8000,--llm-endpoint http://localhost:8000/v1and the old--llm-endpoint http://localhost --llm-port 8000form:Collected=2 failed=1 avg_reward=1.000 success_rate=100%over 3 episodes, identicalresults.jsonlshape (prompt, parsedanswertool call, reward 1.0 from the env). vLLM ran with--enable-auto-tool-choice --tool-call-parser qwen3_xml --reasoning-parser qwen3.RuntimeError: Event loop is closedon the second episode; it reproduces on unpatchedmainwith the split form, so it is a separate collector issue and not touched here.Claude Code Review
N/A