Skip to content

feat(providers): add openai-compatible provider with custom URL and API key#1287

Closed
huangjunxin wants to merge 4 commits into
Hmbown:mainfrom
huangjunxin:feat/openai-compatible-provider
Closed

feat(providers): add openai-compatible provider with custom URL and API key#1287
huangjunxin wants to merge 4 commits into
Hmbown:mainfrom
huangjunxin:feat/openai-compatible-provider

Conversation

@huangjunxin
Copy link
Copy Markdown

Summary

Add a new openai-compatible provider type that allows users to connect to any
OpenAI-compatible API endpoint by providing a custom base URL and optional API key.
This covers self-hosted LLM servers (e.g. vLLM, SGLang), third-party API proxies, and the official OpenAI API.

  • deepseek auth set --provider openai-compatible --url <BASE_URL> [--api-key <KEY>]
  • URL is required (interactive prompt if omitted); API key is optional
  • Env vars: OPENAI_COMPATIBLE_API_KEY, OPENAI_COMPATIBLE_BASE_URL, OPENAI_COMPATIBLE_MODEL
  • Config: [providers.openai_compatible] with api_key, base_url, model
  • TUI /provider picker shows "OpenAI Compatible" with "(configured)" when URL is set
  • No model name normalization — passes through whatever the user configures

Changes

10 files, +238 / -16 lines:

File Change
crates/config/src/lib.rs OpenaiCompatible in ProviderKind, ProvidersToml, env forwarding, skip model normalization
crates/cli/src/lib.rs OpenaiCompatible in ProviderArg, --url on auth set, optional-key prompt, env vars
crates/secrets/src/lib.rs OPENAI_COMPATIBLE_API_KEY env mapping
crates/tui/src/config.rs OpenaiCompatible in ApiProvider, picker, env forwarding, empty key allowed
crates/tui/src/client.rs OpenaiCompatible in reasoning-effort match arms (no-op, like Ollama)
crates/tui/src/commands/provider.rs Error message updated
crates/tui/src/tui/provider_picker.rs Env var label + picker ordering
crates/tui/src/tui/ui.rs Provider label "Compat" for status bar
crates/tui/src/core/engine.rs Env var reference for error recovery
crates/tui/src/main.rs Doctor/status match arms

Test plan

  • cargo fmt --check — clean
  • cargo clippy --workspace --all-targets --all-features — clean
  • cargo test --workspace — all tests pass
  • Manual: test against a real OpenAI-compatible endpoint

…PI key

Add a new "openai-compatible" provider that accepts a custom base URL and
API key to access any OpenAI-compatible API endpoint. The URL is required
(via --url flag, config, or interactive prompt) while the API key is
optional for local servers without authentication.

Supports env vars: OPENAI_COMPATIBLE_API_KEY, OPENAI_COMPATIBLE_BASE_URL,
and OPENAI_COMPATIBLE_MODEL.
The openai-compatible provider supports keyless local servers. The TUI
picker now shows "(configured)" when a base URL is set even without a
key, and the key resolver returns an empty string instead of erroring.
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for an 'OpenAI Compatible' provider across the CLI and TUI, allowing users to configure custom base URLs and optional API keys for local or third-party services. The changes include updates to configuration structures, environment variable handling, and the provider picker UI. Feedback suggests simplifying the base URL assignment logic in the CLI to reduce redundant operations and unifying validation in the prompt_base_url function to ensure empty inputs are rejected consistently across both interactive and non-interactive modes.

Comment thread crates/cli/src/lib.rs Outdated
Comment on lines +900 to +911
if provider == ProviderKind::OpenaiCompatible {
let provider_cfg = store.config.providers.for_provider_mut(provider);
if let Some(url) = url.clone() {
provider_cfg.base_url = Some(url);
} else if provider_cfg.base_url.is_none() {
let entered = prompt_base_url(slot)?;
provider_cfg.base_url = Some(entered);
}
} else if let Some(url) = url.clone() {
let provider_cfg = store.config.providers.for_provider_mut(provider);
provider_cfg.base_url = Some(url);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The logic for handling the base URL can be simplified to avoid redundant clones and multiple calls to for_provider_mut. Since url is an owned Option<String> from the match arm, you can use it directly without cloning in an if/else if structure. This also makes the intent clearer: if a URL is provided via CLI, use it; otherwise, if it's an OpenAI-compatible provider and no URL is configured, prompt for one.

            let provider_cfg = store.config.providers.for_provider_mut(provider);
            if let Some(u) = url {
                provider_cfg.base_url = Some(u);
            } else if provider == ProviderKind::OpenaiCompatible && provider_cfg.base_url.is_none() {
                let entered = prompt_base_url(slot)?;
                provider_cfg.base_url = Some(entered);
            }

Comment thread crates/cli/src/lib.rs
Comment on lines +1082 to +1099
fn prompt_base_url(slot: &str) -> Result<String> {
use std::io::{IsTerminal, Write};
eprint!("Enter base URL for {slot}: ");
io::stderr().flush().ok();
if !io::stdin().is_terminal() {
// Non-interactive: read directly without prompting twice.
return read_api_key_from_stdin();
}
let mut buf = String::new();
io::stdin()
.read_line(&mut buf)
.context("failed to read base URL from stdin")?;
let url = buf.trim().to_string();
if url.is_empty() {
bail!("empty base URL provided — openai-compatible requires a base URL");
}
Ok(url)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The prompt_base_url function currently skips validation when stdin is not a terminal. It's better to unify the reading and validation logic to ensure that an empty URL is never accepted, regardless of whether the input is interactive or piped. Additionally, using read_api_key_from_stdin for a URL is semantically confusing, though it works if it simply reads a line.

Suggested change
fn prompt_base_url(slot: &str) -> Result<String> {
use std::io::{IsTerminal, Write};
eprint!("Enter base URL for {slot}: ");
io::stderr().flush().ok();
if !io::stdin().is_terminal() {
// Non-interactive: read directly without prompting twice.
return read_api_key_from_stdin();
}
let mut buf = String::new();
io::stdin()
.read_line(&mut buf)
.context("failed to read base URL from stdin")?;
let url = buf.trim().to_string();
if url.is_empty() {
bail!("empty base URL provided — openai-compatible requires a base URL");
}
Ok(url)
}
fn prompt_base_url(slot: &str) -> Result<String> {
use std::io::{IsTerminal, Write};
eprint!("Enter base URL for {slot}: ");
io::stderr().flush().ok();
let url = if !io::stdin().is_terminal() {
read_api_key_from_stdin()?
} else {
let mut buf = String::new();
io::stdin()
.read_line(&mut buf)
.context("failed to read base URL from stdin")?;
buf.trim().to_string()
};
if url.is_empty() {
bail!("empty base URL provided — openai-compatible requires a base URL");
}
Ok(url)
}

@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented May 10, 2026

Security review (deferred from v0.8.26 hotfix queue)

Doing a careful pass before merge per the v0.8.26 handoff's note that this PR needs security review. Overall the integration is well-structured and consistent across the 10 files; flagging the following before this lands:

URL-shape validation

prompt_base_url and --url accept any string and write it straight into providers.openai_compatible.base_url. The first request then builds a reqwest::Url::parse(...) and fails late. Worth validating up front:

  • Reject URLs whose scheme isn't http/https. A user pasting file://... or javascript:... should fail at config time, not at first request.
  • Reject empty / whitespace-only URLs. There's a single bail in prompt_base_url but the --url flag path (line ~895 of crates/cli/src/lib.rs) accepts any non-None string — --url ' ' slips through.

A small validate_openai_compatible_url(url) -> Result<String> used by both paths plus a unit test would close the gap.

Userinfo / credential leakage in logs

If a user pastes https://user:token@example.com/v1, userinfo survives into provider_config.base_url and into any error string that includes the URL. mcp.rs has a mask_url_secrets helper that already does this for MCP transports — would be good to reuse it (or the same regex) when surfacing the openai-compatible URL in errors and setup_status output.

Empty API key behavior

deepseek_api_key() returns Ok(String::new()) for OpenaiCompatible when no key is set. Two questions:

  1. Does the client suppress the Authorization header entirely when the key is empty, or does it emit Authorization: Bearer (empty bearer)? The latter is an info-leak ("there should be a token here, here's nothing") and some servers may reject it. Worth a smoke test or grep through client.rs for the header-construction site.
  2. The picker's (configured) indicator triggers on either env var or saved base_url (line ~3025 of the diff). Combined with the optional key, a user can end up in a state where deepseek "thinks" it's configured but auth fails on first request. A setup_status line clarifying "API key optional — empty key sends no Authorization header" would help users diagnose.

prompt_base_url non-TTY fallback

fn prompt_base_url(slot: &str) -> Result<String> {
    ...
    if !io::stdin().is_terminal() {
        // Non-interactive: read directly without prompting twice.
        return read_api_key_from_stdin();
    }
    ...
}

This reuses read_api_key_from_stdin() for the non-TTY URL-reading path. It works (both just read stdin) but the function name is misleading and the helper applies API-key trimming/normalization that may mask URL whitespace bugs. Renaming to read_one_line_from_stdin() (or having prompt_base_url use its own reader) would make the intent clearer.

Tests

The diff adds EnvGuard slots for the three new env vars, which is good. I didn't see explicit tests covering:

  • OPENAI_COMPATIBLE_BASE_URL env var actually overrides the config file
  • --url flag overrides any existing [providers.openai_compatible] base_url
  • An empty --url is rejected

Adding those (mirroring ollama_env_overrides_base_url_and_model at line ~4833 of crates/tui/src/config.rs) would lock in the precedence rules.

Out of scope / questions for the maintainer

  • Should the feature default to refusing private/loopback IPs unless an explicit "I know what I'm doing" flag is set? Aligns with the fetch_url SSRF policy but may surprise users running local llama.cpp / vLLM, which is the whole point of this provider.
  • Does provider_capability need an entry that downgrades to chat-only? The default for OpenaiCompatible looks like it inherits the OpenAI capability flags, which assume support for things like reasoning_effort / structured outputs. Most local servers won't honor those, and the apply_reasoning_effort no-op already handles that, but worth a maintainer check.

Happy to spin up the validation helpers if useful — let me know if you want this rebased into a smaller surface or kept as-is for review.

…tests

- Add validate_openai_compatible_url() that rejects empty, whitespace-only,
  and non-http/https URLs at config time instead of failing at first request
- Strip embedded userinfo (user:password@) from base URLs before persisting
- Simplify auth-set URL-handling to avoid redundant clones and borrows
- Fix prompt_base_url to validate in both TTY and non-TTY paths
- Add read_line_from_stdin() helper for single-line stdin reads
- Add unit tests for URL validation (empty, scheme rejection, userinfo,
  whitespace trimming)
- Add TUI config test for OPENAI_COMPATIBLE_BASE_URL/API_KEY/MODEL env vars
@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented May 23, 2026

This PR was opened before the v0.8.41 rebrand and is now stale. Feel free to rebase onto current main and reopen. 鲸鱼兄弟们等你 🐋

@Hmbown Hmbown closed this May 23, 2026
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.

2 participants