Repository navigation
Add Remote AI with live model lists and custom endpoints - #8
Conversation
Fazmin
left a comment
There was a problem hiding this comment.
Thanks for this one! Being able to point Ask at a LiteLLM, vLLM or Azure gateway, and pick from the models a key can actually use, is a big improvement. I can tell how much care went into the security side: binding the key to its URL, no redirects, failing closed and the redaction all hold up well. The URL validation tests are great too.
Since this builds on #7, let's get #7 merged first (I've left a few small notes there), and then this can be rebased so only your three commits are left.
Two things I'd like fixed before merging:
1. An environment key can still end up at a different host. key_for (provider.rs ~L375) falls back to CUSTOM_LLM_API_KEY whatever URL is in use, including one saved in the settings DB. On top of that, the Settings screen pre-fills the Base URL field with the env URL and always sends baseUrl, so just changing the model copies the env URL into the DB. Here's how it plays out:
- The env has
CUSTOM_LLM_BASE_URL=AandCUSTOM_LLM_API_KEY=kA. I change the model in the app, and A is now saved. - Later I move to gateway B and set the env to
BandkB. The saved A still wins, so kB goes to A.
In the same way, with the env pair set, typing a new URL in the app sends the env key there without the "Change the endpoint address?" prompt, because that prompt only appears for a saved key. I think the fix is to only pair the env key with the env URL, and to not send baseUrl from the UI when it came from the env and wasn't edited.
2. The live model list doesn't load for a brand-new custom endpoint. Custom has no default model, so even with the URL and key saved it still counts as "missing". The refresh button stays disabled (p.missing && !p.model, index.html ~L3137) and the auto-load is skipped (~L3156) until you type a model id by hand. The hint also says "Save a key…" when a key is already saved. cve-intel ai models custom works fine, so it's only the UI gate.
A few smaller things, none of them blocking:
without_keyruns on the raw body before the JSON is parsed, so a key echoed back with JSON escapes (e.g./as\/) survives into the messageerror_messagereturns. Running it on the extracted message as well would close that gap.- Env keys skip
validate_key. A very short one (sayx) would then get swapped out all over a successful response body bywithout_key. - Dropping
temperaturefor every Claude model quietly changes the default for existing setups, from 0.2 to the provider's default. Since the new retry already handles the "deprecated" 400, could we keep 0.2 and let the fallback handle the models that reject it? - I think Anthropic's
/v1/modelsreturns 20 models per page by default, so the list may be cut short without alimit(or paging). fold_litellmmigrates alitellmprovider that never shipped on main, so I think it can go.- In
cli.rs,read_hidden_line's doc comment has ended up onrun_ai. UpdatederivesDebugwhile it holdsapi_key. Nothing prints it today, but it'd be nice to matchStoredandLlmEndpoint.
Really nice work overall. Ping me when it's rebased and I'll take another look.
Ask can use OpenAI, Anthropic, Gemini or any OpenAI-compatible endpoint (a LiteLLM proxy, vLLM, Azure OpenAI, ...). The desktop app gets a Remote AI view under Configure (moved out of Settings) with "Save without switching"; the CLI gets `cve-intel ai list|set|models|use|remove-key|test`, with keys read from a hidden prompt or stdin. Model pickers fetch GET <base>/models with the saved key, so every model the key can use is listed (Anthropic gets x-api-key). Embedding, speech and image models are filtered out for OpenAI and Gemini; "Other" accepts any model id. Models differ in the parameters they accept: GPT-5 and o-series take only the default temperature, newer Claude models deprecate temperature, and some servers want max_completion_tokens. Defaults are guessed per provider and model, and a 400/422 naming one of these parameters is retried once without it; what works is remembered per endpoint and model. Errors show the provider's own message instead of truncated JSON, and in the app they stay until dismissed, are selectable, and can be copied. A custom base URL must be https:// (plain http:// only to loopback) and may not carry credentials, a query or a fragment. A saved key is bound to its URL: changing the URL forgets the key. Remote requests don't follow redirects, and loopback endpoints bypass HTTP(S)_PROXY. settings.db gains a providers.base_url column, added on open. Ask notes such a retry through `log`, so cve-core depends on it again and `--debug` shows the cve_core target.
A saved key for the OpenAI-compatible endpoint could reach a host it was
not saved for. With the URL coming from CUSTOM_LLM_BASE_URL, `ai set
custom --key` saved the key alone, so changing the variable later (or a
saved URL that stopped validating) sent the key to the new host. Saving
a key now saves the URL in use with it, and a saved key is only ever
paired with the saved URL. With no URL at all, saving a key is refused.
Provider error text was shown as received, and some providers and
gateways repeat the key (OpenAI on a 401, a gateway debug page showing
the Authorization header). The key is now replaced with "[API key]" in
every response body before any of it reaches an error, for chat and
model lists alike.
A parameter retry remembered its adjustment before knowing it worked, so
a retry that failed for another reason still changed later requests.
It is now remembered only once a retried request succeeds. Swapping
max_tokens and max_completion_tokens also needs the error to say the
field is unsupported, so a complaint about its value ("is too large")
no longer swaps it.
ChatClient::new fell back to reqwest's default client if build() failed. That client follows redirects and uses HTTP(S)_PROXY for loopback, so a build failure would silently drop the two rules that keep a question and key on the configured host. A failed build is now an error: Test and the model list report it, and Ask answers from the gathered lookups with a note, as it does when the model cannot be reached.
- Send CUSTOM_LLM_API_KEY only while requests go to CUSTOM_LLM_BASE_URL, never with a URL saved in the app. Settings sends the base URL only when it was edited, and confirms before an env key stops applying. - Load the live model list once the URL and any required key are set, without needing a model first (new can_list_models in the view). - Redact the key again after decoding a provider's error JSON, before clipping, so an escaped echo cannot survive. - Ignore env keys that fail validate_key, and say why. - Keep temperature 0.2 for Claude; the retry drops it where refused. - Ask Anthropic's /v1/models for limit=1000 instead of the 20 default. - Redact api_key in Update's Debug, drop the unshipped litellm migration, and move read_hidden_line's doc comment back.
666e412 to
a53ff61
Compare
|
Thanks for the thorough review! #7 is merged, so I've rebased this onto main. It's now my three commits plus one commit for the notes below (a53ff61). 1. Env key reaching a different host. One leftover case: a DB where the old UI already saved the env URL (only possible for someone who ran this branch) keeps using that URL, but now without a key, so requests fail with an auth error instead of sending the key elsewhere. Entering the current env URL in Settings once fixes it. 2. Model list for a new custom endpoint. The provider view now has Smaller items:
I also added a sentence to README, SECURITY.md and .env.example saying the env key is only sent to the env URL. fmt, clippy, the tests and the desktop check all pass locally. |
|
Merged! Thanks again, Paul. The fixes are spot on, and I really like how the env key is handled now. Looking forward to #9 and the OAuth one! |
Why
Ask's cloud providers are three fixed endpoints (OpenAI, Anthropic, Gemini), so there is no way to point Ask at an OpenAI-compatible gateway such as a LiteLLM proxy, vLLM or Azure OpenAI. The model choice is a hand-typed id, so users have to guess which models their key can use. Newer models also reject parameters Ask always sends:
temperature.temperature.max_completion_tokensinstead ofmax_tokens.When that happens, Ask fails with a truncated JSON error. Providers can also only be configured in the desktop app's Settings.
Approach
custom. It covers any OpenAI-compatible endpoint, with a user-set base URL and an optional key. The three built-in providers keep their compiled-in URLs.https://. Plainhttp://is allowed only to loopback, and loopback is decided from the parsed host, not by string match.CUSTOM_LLM_BASE_URLis saved together with that URL. A saved key is never paired with a URL from the environment, so changing the variable cannot redirect it.HTTP(S)_PROXY.GET <base>/modelswith the saved key, instead of a hard-coded list.x-api-key.max_tokensandmax_completion_tokensneeds the error to say the field is unsupported, so a complaint about its value does not trigger it. Each retry is logged at thecve_coretarget, which--debugshows.[API key]in every response body first, since some providers and gateways repeat it.How it works
cve-core/src/provider.rsPROVIDERSgainscustom, withCUSTOM_LLM_BASE_URL/CUSTOM_LLM_API_KEYas environment fallbacks.validate_base_url()checks custom URLs.update()saves the model, URL and key together and enforces the key-to-URL binding.list_models()andparse_models()build the model picker.cve-core/src/agent.rsChatClientdisables redirects for remote endpoints and turns off the proxy for loopback ones. A failed build is an error: Test and the model list report it, and Ask answers from the gathered lookups with a note, as it does when the model cannot be reached.Quirks(default_for,adjusted_for) and the per-endpoint cache drive the parameter retry.error_message()extracts the provider's message from OpenAI-, Anthropic-, Gemini- and FastAPI-style error bodies.cve-core/src/settings_db.rs:providersgains abase_urlcolumn.open()adds it to existing databases.cve-intel ai list|set|models|use|remove-key|test. Keys are never taken from argv.ai set --keyreads the key from a hidden prompt, or from stdin when piped, andai listshows only where a key comes from.ai_modelsTauri command backs the picker. The UI still receives only key status, never key values.README.mddocuments the CLI and the custom endpoint.SECURITY.mdcovers where requests go now that a user can choose the host..env.exampleadds the two new variables.login cve-core.cve-coredepends onlogagain, for the retry note, and the--debugfilter shows thecve_coretarget. Verify TLS with the OS trust store and add tls-check diagnostics #7 had dropped both when they were unused.Type of change
Testing
Added 18 unit tests. Endpoints are mocked with a
TcpListeneron 127.0.0.1, so nothing touches the network. They cover:base_urlcolumn;x-api-key), and that a keyless custom endpoint sends noAuthorizationheader;I confirmed each of the four tests added by the fix commits fails when its fix is removed.
All of these pass locally on Linux:
cargo fmt --all --checkcargo clippy -p cve-core -p cve-server --all-targets -- -D warningscargo test -p cve-core -p cve-server(272 core and 16 server tests)cargo check -p cve-intel-desktopscripts/version.sh checkTested
ai listandai set custom --url …through the CLI, against a scratchCVE_INTEL_HOME:http://to a remote host (includinglocalhost.evil.com), credentials in the URL, and a query string.http://127.0.0.1:4000/v1and anhttps://URL.settings.dbgained thebase_urlcolumn.ai set custom --keyis refused when there is no URL anywhere. With the URL inCUSTOM_LLM_BASE_URL, the key is saved with that URL, and changing the variable afterwards does not move the key.Checklist
cve-core(provider.rs,agent.rs). The CLI and Tauri commands only call it.README.mdandSECURITY.md..env.example.