Skip to content

fix(llm): auto-append /v1 to LLM_BASE_URL for openai_compatible (#1934) - #2310

Closed
ilblackdragon wants to merge 3 commits into
stagingfrom
fix/1934-base-url-v1-suffix
Closed

ilblackdragon wants to merge 3 commits into
stagingfrom
fix/1934-base-url-v1-suffix

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

Summary

Fixes #1934. Local model servers (MLX, vLLM, llama.cpp) returned 404 when LLM_BASE_URL lacked a /v1 suffix because rig-core's openai client appends /chat/completions directly to the base URL.

  • Added normalize_openai_base_url() in src/llm/mod.rs that auto-appends /v1 when not already present
  • Applied in create_openai_compat_from_registry()
  • 7 unit tests covering bare URL, trailing slash, already-has-/v1, path prefixes, etc.

Test plan

  • cargo fmt, cargo clippy --all-features zero warnings
  • All tests pass
  • Manual: LLM_BACKEND=openai_compatible LLM_BASE_URL=http://localhost:8080 ironclaw succeeds against a local server

🤖 Generated with Claude Code

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel Channel infrastructure scope: channel/cli TUI / CLI channel scope: channel/web Web gateway channel scope: channel/wasm WASM channel runtime scope: tool Tool infrastructure scope: tool/builtin Built-in tools scope: tool/wasm WASM tool sandbox scope: tool/builder Dynamic tool builder scope: db Database trait / abstraction scope: db/postgres PostgreSQL backend scope: db/libsql libSQL / Turso backend scope: llm LLM integration scope: orchestrator Container orchestrator scope: worker Container worker scope: config Configuration scope: setup Onboarding / setup scope: sandbox Docker sandbox scope: ci CI/CD workflows scope: docs Documentation size: XL 500+ changed lines risk: high Safety, secrets, auth, or critical infrastructure contributor: core 20+ merged PRs labels Apr 11, 2026

@ilblackdragon ilblackdragon left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patch looks small and coherent, and the intent matches the documented local-server use case.

Residual concern: this now rewrites every OpenAI-compatible base URL by appending /v1 unless it already ends in /v1. That is correct for the local servers named in the PR, but it bakes in an assumption about all custom proxies. If you keep this approach, I would at least add a caller-level test around the provider factory so we assert the constructed client base URL for both a bare local endpoint and an already-versioned custom endpoint. Right now the coverage is helper-only.

ilblackdragon added a commit that referenced this pull request Apr 11, 2026
… handling

Addresses PR #2310 review feedback: prior coverage was helper-only.
Now asserts OpenAICompatibleProvider constructs the correct client
base URL for both bare and pre-versioned LLM_BASE_URL values.

Extracts build_openai_compat_client() from create_openai_compat_from_registry()
so tests can drive the exact construction path (headers, api key,
base URL normalization, rig-core client build, completions_api switch)
and inspect the resulting client's base_url(). Adds three caller-level
tests covering: bare local endpoint (http://localhost:8080 -> /v1),
already-versioned custom proxy (https://custom.proxy/v1 unchanged,
no double suffix), and trailing-slash versioned URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ilblackdragon

Copy link
Copy Markdown
Member Author

Addressed in 25baf2b: added caller-level factory test asserting the constructed OpenAICompatibleProvider base URL for both a bare endpoint (http://localhost:8080 → .../v1) and an already-versioned one (https://proxy/v1 → unchanged, no double suffix).

ilblackdragon and others added 2 commits April 13, 2026 03:50
Local model servers (MLX, vLLM, llama.cpp) expect requests at
/v1/chat/completions, but rig-core's openai client appends
/chat/completions directly to the base URL. Without this fix,
LLM_BASE_URL=http://localhost:8080 produces 404 errors.

Add normalize_openai_base_url() that appends /v1 when not already
present, handling trailing slashes and existing /v1 suffixes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… handling

Addresses PR #2310 review feedback: prior coverage was helper-only.
Now asserts OpenAICompatibleProvider constructs the correct client
base URL for both bare and pre-versioned LLM_BASE_URL values.

Extracts build_openai_compat_client() from create_openai_compat_from_registry()
so tests can drive the exact construction path (headers, api key,
base URL normalization, rig-core client build, completions_api switch)
and inspect the resulting client's base_url(). Adds three caller-level
tests covering: bare local endpoint (http://localhost:8080 -> /v1),
already-versioned custom proxy (https://custom.proxy/v1 unchanged,
no double suffix), and trailing-slash versioned URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ilblackdragon
ilblackdragon force-pushed the fix/1934-base-url-v1-suffix branch from 25baf2b to 275eb7e Compare April 13, 2026 03:50
@github-actions github-actions Bot added size: M 50-199 changed lines risk: low Changes to docs, tests, or low-risk modules and removed size: XL 500+ changed lines risk: high Safety, secrets, auth, or critical infrastructure labels Apr 13, 2026

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #2310 -- 2 findings (both Medium).

Comment thread src/llm/mod.rs
fn normalize_openai_base_url(url: &str) -> String {
let trimmed = url.trim_end_matches('/');
if trimmed.ends_with("/v1") {
trimmed.to_string()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium Severity -- False positive path-segment matching

ends_with("/v1") is a substring check on the full string. A URL ending in /apiv1 or /myv1 would incorrectly match and would NOT get /v1 appended, silently sending requests to the wrong endpoint.

Suggested fix: Check the last path segment explicitly:

trimmed.rsplit_once('/').map(|(_, last)| last) == Some("v1")

Comment thread src/llm/mod.rs
@@ -707,6 +718,21 @@

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium Severity -- Debug log shows pre-normalization URL

This tracing::debug! log emits config.base_url (the original, pre-normalization URL), but actual requests go to the normalized URL with /v1 appended (line 22 above). During incident response, this mismatch between logged URL and actual request URL is a debugging trap.

Suggested fix: Log the normalized URL, or log both original and normalized values.

Addresses PR #2310 review: ends_with("/v1") replaced with proper
last-path-segment check to avoid false positives like /apiv1.
Debug log now shows the normalized base URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ilblackdragon

Copy link
Copy Markdown
Member Author

Pushed 5455e556 addressing review findings:

  • Fix A: ends_with("/v1") replaced with rsplit_once('/').map(|(_, last)| last) == Some("v1") — prevents false positives on paths like /apiv1, /myv1. Added two regression tests.
  • Fix B: tracing::debug! in create_openai_compat_from_registry now logs both original_base_url and normalized_base_url (via client.base_url()).

Quality gate: cargo fmt clean, cargo clippy --all --all-features zero warnings, cargo test --lib -- llm 759/759 pass.

@ilblackdragon ilblackdragon left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All prior review feedback has been addressed in the current diff:

  • rsplit_once('/').map(|(_, last)| last) == Some("v1") correctly handles false positives like /apiv1 and /myv1 -- two dedicated tests confirm this.
  • Debug logging now shows both original_base_url and normalized_base_url -- no more debugging traps.
  • The refactoring to extract build_openai_compat_client() enables clean caller-level testing. Three factory tests verify that normalization actually flows through to the constructed rig-core client.
  • 9 helper-level normalization tests + 3 caller-level factory tests provide thorough coverage.

One minor observation: normalize_openai_base_url with "http://localhost:8080///" (multiple trailing slashes) normalizes to "http://localhost:8080/v1" via trim_end_matches('/'). This is correct behavior but slightly surprising -- trim_end_matches strips all trailing slashes, so the intermediate empty path segments are removed. The test covers it, so this is fine.

LGTM -- ready to merge.

@ilblackdragon

Copy link
Copy Markdown
Member Author

Closing — already fixed on staging, and this diff would regress the fix. Issue #1934 is closed.

Shipped via:

The staging version of `normalize_openai_base_url` only appends `/v1` when the URL has no path (bare `scheme://host[:port]`), and is case-insensitive on the `/v1` match. This PR appends `/v1` whenever the last path segment isn't `v1`, which would wrongly mutate custom-path providers:

Input Staging This PR
`https://api.z.ai/api/paas/v4\` unchanged `.../v4/v1` ❌
`https://generativelanguage.googleapis.com/v1beta/openai\` unchanged `.../openai/v1` ❌
`https://example.com/apiv1\` unchanged `.../apiv1/v1` ❌

The `build_openai_compat_client` extraction + caller-level tests are a nice pattern, but the staging helper already has unit coverage and the policy there is the one we want.

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

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/cli TUI / CLI channel scope: channel/wasm WASM channel runtime scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: ci CI/CD workflows scope: config Configuration scope: db/libsql libSQL / Turso backend scope: db/postgres PostgreSQL backend scope: db Database trait / abstraction scope: docs Documentation scope: llm LLM integration scope: orchestrator Container orchestrator scope: sandbox Docker sandbox scope: setup Onboarding / setup scope: tool/builder Dynamic tool builder scope: tool/builtin Built-in tools scope: tool/wasm WASM tool sandbox scope: tool Tool infrastructure scope: worker Container worker size: M 50-199 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

openai_compatible: LLM_BASE_URL requires /v1 suffix after rig-core migration

2 participants