feat: configurable path_suffix for OpenAI-compat endpoints#2288
feat: configurable path_suffix for OpenAI-compat endpoints#2288MurasameLover wants to merge 2 commits into
Conversation
Some third-party OpenAI-compatible endpoints reject /v1/chat/completions and serve exclusively at /chat/completions. Add `path_suffix` to `ProviderConfigToml` (config crate) and `ProviderConfig` (TUI crate). When set to an empty string, routes go directly on the unversioned base URL. Custom suffixes like "v2" replace the default /v1 segment. When `None`, current behaviour is unchanged. Closes Hmbown#2089, closes Hmbown#1874
There was a problem hiding this comment.
Code Review
This pull request introduces a new path_suffix configuration option to override the version path segment between the base URL and API routes, allowing users to drop the /v1 prefix or use custom versions like /v2. The changes span configuration structures, merging logic, and the API client URL construction. The feedback highlights a double comment symbol in the example configuration, redundant string operations in the API URL builder, and a missing integration of path_suffix in the runtime options resolution within crates/config/src/lib.rs.
| # api_key = "YOUR_OPENAI_COMPATIBLE_API_KEY" | ||
| # base_url = "https://api.openai.com/v1" | ||
| # model = "gpt-4.1" | ||
| # # path_suffix = "" # Override the version path segment. Use "" to drop |
There was a problem hiding this comment.
Fixed in 4f2adfb — changed to single # comment prefix matching the convention of all other options in this block.
| return format!("{}/{}", unversioned_base_url(base_url), path); | ||
| } | ||
| if let Some(suffix) = path_suffix { | ||
| let base = unversioned_base_url(base_url).trim_end_matches('/').to_string(); |
There was a problem hiding this comment.
The unversioned_base_url function already returns a String with trailing slashes trimmed. Calling .trim_end_matches('/') and .to_string() on its result is redundant and introduces an unnecessary string allocation.
| let base = unversioned_base_url(base_url).trim_end_matches('/').to_string(); | |
| let base = unversioned_base_url(base_url); |
There was a problem hiding this comment.
This is not redundant. unversioned_base_url uses rsplit_once("/") — when the base URL contains double slashes before a version segment (e.g. https://example.com//v1), the left side retains a trailing slash. The .trim_end_matches("/") prevents a double-slash URL like https://example.com//chat/completions. The existing callers of unversioned_base_url in this function either go through versioned_base_url (which trims) or use it in a format string that adds another / afterward. Here we construct the URL directly, so the trim is necessary.
| /// Override the version path segment between base_url and API routes. | ||
| /// When `Some("")`, routes go directly on the unversioned base (e.g. | ||
| /// `https://host/chat/completions`). When `None`, the default `/v1` | ||
| /// versioning logic applies. | ||
| #[serde(default)] | ||
| pub path_suffix: Option<String>, |
There was a problem hiding this comment.
It appears that path_suffix has not been added to ResolvedRuntimeOptions or resolved in resolve_runtime_options_with_secrets within this file. \n\nIf other CLI tools or commands in the workspace rely on ResolvedRuntimeOptions to resolve provider configurations and construct API URLs, they will not respect the configured path_suffix override.\n\nConsider updating ResolvedRuntimeOptions and resolve_runtime_options_with_secrets to include and resolve path_suffix for consistency across the configuration resolution APIs.
There was a problem hiding this comment.
ResolvedRuntimeOptions is consumed by the CLI dispatcher which delegates to the TUI binary. The CLI passes --config to the TUI, and the TUI loads path_suffix from its own TOML deserialization into ProviderConfig. The CLI never constructs API URLs directly — all API calls go through DeepSeekClient in the TUI crate, which reads Config::path_suffix(). Adding path_suffix to ResolvedRuntimeOptions would be dead code unless the CLI begins building its own API URLs.
Summary
Some third-party OpenAI-compatible endpoints reject
/v1/chat/completionsand serve exclusively at/chat/completions. This PR addspath_suffix: Option<String>to bothProviderConfigToml(config crate) andProviderConfig(TUI crate), allowing users to override the version path segment between the base URL and API routes.path_suffix = ""— routes go directly on the unversioned base:https://host/chat/completionspath_suffix = "v2"—https://host/v2/chat/completionspath_suffix = None(default) — current/v1-adding behavior unchangedChanges
crates/config/src/lib.rspath_suffixtoProviderConfigToml, updatedmerge_project_provider_configcrates/tui/src/config.rspath_suffixtoProviderConfig, updatedmerge_provider_config, addedConfig::path_suffix()gettercrates/tui/src/client.rspath_suffixtoDeepSeekClient, threaded throughapi_url(), updated all callers, added testscrates/tui/src/client/chat.rsapi_urlcalls to passpath_suffixconfig.example.toml[providers.openai]Testing
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-featurescargo test --workspace --all-featuresChecklist
config.example.tomlpath_suffixbehaviorCloses #2089, closes #1874
Greptile Summary
This PR adds a
path_suffix: Option<String>field to bothProviderConfigToml(config crate) andProviderConfig(TUI crate), letting users override the version segment injected between the base URL and API routes for OpenAI-compatible providers.api_url()gains a thirdpath_suffixparameter; whenSome(\"\")it omits any version prefix, whenSome(\"v2\")it injects/v2, and whenNoneit falls back to the existing/v1logic unchanged.client.rsandchat.rsare updated;DeepSeekClient, itsCloneimpl, and the constructor are all wired up. Three new unit tests cover the suffix, models endpoint, and the intentional beta-path bypass.[providers.openai].Confidence Score: 5/5
Safe to merge — the change is additive and fully backwards-compatible;
Nonepreserves all existing behaviour.All existing tests are updated, three new tests cover the new code paths, and the
Nonedefault keeps every current call site behaving identically. The logic is isolated toapi_url()and the new field is never written unless explicitly configured.No files require special attention.
Important Files Changed
path_suffix: Option<String>toDeepSeekClient, threads it throughapi_url(), and updates all five call sites plus theCloneimpl. Three new unit tests cover the suffix, models, and beta-bypass cases.path_suffix: Option<String>toProviderConfig, wires it intomerge_provider_configwithor(), and exposes it via a newConfig::path_suffix()getter that reads from the active provider's config.path_suffix: Option<String>toProviderConfigTomlwith a doc comment, and extendsmerge_project_provider_configto propagate it with the sameif source.is_some()guard used for all other fields.api_urlcall sites updated to passself.path_suffix.as_deref(). Mechanical, no logic changes.path_suffixentry under[providers.openai]with an inline explanation of all three modes (empty string, custom value, None default).Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[api_url called\nbase_url, path, path_suffix] --> B{path starts\nwith beta/?} B -- Yes --> C[unversioned_base_url + path\nbeta/ prefix always wins] B -- No --> D{path_suffix\nis Some?} D -- Yes --> E[base = unversioned_base_url] E --> F{suffix\nempty?} F -- Yes --> G[base/path\ne.g. host/chat/completions] F -- No --> H[base/suffix/path\ne.g. host/v2/chat/completions] D -- No --> I[versioned_base_url logic\nappend /v1 if no version] I --> J{ends with\nbeta?} J -- Yes --> K[replace with /v1] J -- No --> L[base/path\ne.g. host/v1/chat/completions]Reviews (2): Last reviewed commit: "fix: remove double comment symbol in con..." | Re-trigger Greptile