From 0d79e2f029c23e8a46cb7549bcdfb3eb32fcada7 Mon Sep 17 00:00:00 2001 From: knowhy Date: Thu, 29 Oct 2026 07:44:14 +0000 Subject: [PATCH] Offer LLMTR as an OpenAI-compatible model choice Desktop setup lists LLMTR beside Google and xAI, with https://llmtr.com/v1 and qwen/qwen3.8-27b-free filled in. A self-hosted deployment keeps BOT_PROVIDER=openai and sets OPENAI_BASE_URL, OPENAI_API_KEY and BOT_MODEL. --- README.md | 1 + openbot/.env.example | 7 ++ openbot/CHANGELOG.md | 7 ++ openbot/desktop/src-tauri/src/provider.rs | 26 ++++- openbot/desktop/src/ProviderPicker.test.tsx | 109 +++++++++++--------- openbot/desktop/src/ProviderPicker.tsx | 10 +- openbot/docs/configuration.md | 10 ++ 7 files changed, 114 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 01b70b62..0416bd11 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ To use another provider or model: - **Another OpenAI model.** Change `default_model` in `openbot/examples/openduet/model.yaml`, or in your copy of it (see [Make it your own](#make-it-your-own)). `BOT_MODEL` does not change an OpenAI model on its own. It is used only when `OPENAI_BASE_URL` is also set to an OpenAI-compatible endpoint, and then it names the model that endpoint serves. - **Anthropic.** Set `BOT_PROVIDER=anthropic` and `ANTHROPIC_API_KEY=...`. The model is `BOT_MODEL` if you set one, such as `BOT_MODEL=claude-sonnet-5-5`, and `claude-sonnet-4-5` if you do not. +- **LLMTR.** Set `OPENAI_BASE_URL=https://llmtr.com/v1`, `OPENAI_API_KEY` to an LLMTR key (it starts with `llmtr-`), and `BOT_MODEL` to a catalogue id such as `qwen/qwen3.8-27b-free`. Leave `BOT_PROVIDER` as `openai`. The id is sent as published, slash included. Keys are created from [LLMTR's API keys page](https://llmtr.com/docs/api-keys/); `GET https://llmtr.com/v1/models` lists models and needs no key. `llmtr/qwen3-8-27b` is the Turkey-hosted alternative. Leave `BOT_REASONING_EFFORT` unset. Set these before you start it. **Settings → Setup** in the app says whether the model key works. diff --git a/openbot/.env.example b/openbot/.env.example index cabae52f..e5de776e 100644 --- a/openbot/.env.example +++ b/openbot/.env.example @@ -251,6 +251,13 @@ OPENAI_API_KEY= # OPENAI_API_KEY=... # BOT_MODEL=openai/gpt-5.5 # +# LLMTR (https://llmtr.com/docs/api-keys/) is one such gateway. Leave BOT_PROVIDER as openai. +# The model id is a catalogue id, sent verbatim. Leave BOT_REASONING_EFFORT unset. +# +# OPENAI_BASE_URL=https://llmtr.com/v1 +# OPENAI_API_KEY=llmtr-... +# BOT_MODEL=qwen/qwen3.8-27b-free +# # OPENAI_BASE_URL= # Optional dictation, independent of every agent/chat provider. Set provider, base URL, diff --git a/openbot/CHANGELOG.md b/openbot/CHANGELOG.md index da2b0392..4fc88921 100644 --- a/openbot/CHANGELOG.md +++ b/openbot/CHANGELOG.md @@ -35,6 +35,13 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's team account is kept, with who answered it and when. It starts empty and nothing is backfilled. +### Desktop setup offers LLMTR + +Desktop setup offers LLMTR beside Google Gemini and xAI. The row uses `https://llmtr.com/v1` and an +API key, and the model name starts as `qwen/qwen3.8-27b-free`. A deployment configured by hand sets +`OPENAI_BASE_URL` to that address, `OPENAI_API_KEY` to an LLMTR key, and `BOT_MODEL` to a catalogue +id. `BOT_PROVIDER` stays `openai`. + ### Plainer words, and one word for each thing OpenBot now says **Bot** everywhere a person reads it, where screens used to mix Bot, agent, coworker diff --git a/openbot/desktop/src-tauri/src/provider.rs b/openbot/desktop/src-tauri/src/provider.rs index 7a24ad2d..a04b4175 100644 --- a/openbot/desktop/src-tauri/src/provider.rs +++ b/openbot/desktop/src-tauri/src/provider.rs @@ -1,7 +1,8 @@ //! The model provider screen's list, as data. //! -//! OpenAI and Anthropic offer plan sign-in alongside API keys. Google and xAI use named endpoint -//! presets with API keys; their official addresses are supplied by the frontend. +//! OpenAI and Anthropic offer plan sign-in alongside API keys. Google, xAI and LLMTR use named +//! endpoint presets with API keys; their official addresses are supplied by the frontend. Google +//! and xAI also offer sign-in. //! //! The row that asks for a URL is the last one, and it is the only one that asks. Keeping it there //! is what keeps a base URL off the main path, which the audience rule at the top of the build doc @@ -104,6 +105,14 @@ pub fn catalogue() -> Vec { mark: None, caution: None, }, + Provider { + id: "llmtr".into(), + name: "LLMTR".into(), + summary: "Use an LLMTR API key.".into(), + logins: vec![Login::Endpoint], + mark: None, + caution: None, + }, Provider { id: "openai-compatible".into(), name: "Any OpenAI-compatible endpoint".into(), @@ -129,7 +138,7 @@ mod tests { .filter(|p| p.logins.contains(&Login::Endpoint)) .map(|p| p.id) .collect(); - assert_eq!(asking, vec!["google", "xai", "openai-compatible"]); + assert_eq!(asking, vec!["google", "xai", "llmtr", "openai-compatible"]); for provider in catalogue() .into_iter() .filter(|provider| provider.logins.contains(&Login::Endpoint)) @@ -156,12 +165,19 @@ mod tests { /// Named providers stay ahead of the custom endpoint escape hatch. #[test] - fn four_named_providers_and_one_custom_endpoint() { + fn five_named_providers_and_one_custom_endpoint() { let rows = catalogue(); let ids: Vec<&str> = rows.iter().map(|provider| provider.id.as_str()).collect(); assert_eq!( ids, - vec!["openai", "anthropic", "google", "xai", "openai-compatible"] + vec![ + "openai", + "anthropic", + "google", + "xai", + "llmtr", + "openai-compatible" + ] ); } diff --git a/openbot/desktop/src/ProviderPicker.test.tsx b/openbot/desktop/src/ProviderPicker.test.tsx index a976856d..5becc4b5 100644 --- a/openbot/desktop/src/ProviderPicker.test.tsx +++ b/openbot/desktop/src/ProviderPicker.test.tsx @@ -53,6 +53,13 @@ const cloudProviders = [ model: "grok-4.7", keyUrl: "https://console.x.ai/", }, + { + id: "llmtr", + name: "LLMTR", + baseUrl: "https://llmtr.com/v1", + model: "qwen/qwen3.8-27b-free", + keyUrl: "https://llmtr.com/docs/api-keys/", + }, ]; const allProviders: Provider[] = [ @@ -849,56 +856,60 @@ test("OAuth tab switch cancels a late authorization before opening the browser", }); for (const provider of cloudProviders) { - test(`${provider.name} OAuth completes through native storage without exposing tokens`, async () => { - invokeHandler = async (command) => { - if (command === "providers") - return allProviders.map((row) => - row.id === provider.id - ? { ...row, logins: ["endpoint", "oauth"] } - : row, - ); - if (command === "begin_model_oauth") - return { - attemptId: "synthetic-attempt", - url: "https://authorization.example.test/approve", - userCode: "SYNTHETIC", - }; - if ( - command === "plugin:opener|open_url" || - command === "finish_model_oauth" - ) - return null; - throw new Error(`unexpected command ${command}`); - }; - const choices: unknown[] = []; - const view = await renderPicker((choice) => choices.push(choice)); - const user = userEvent.setup({ document: view.container.ownerDocument }); - await user.click( - await view.findByRole("radio", { name: new RegExp(provider.name) }), - ); - await user.click(view.getByRole("tab", { name: "Sign in" })); - await user.click( - view.getByRole("button", { name: `Sign in with ${provider.name}` }), - ); - await view.findByText(`Signed in to ${provider.name}.`); - await user.click(view.getByRole("button", { name: "Continue" })); - expect(choices).toEqual([ - { - provider: provider.id, - login: "oauth", - model: provider.model, - saved: true, - }, - ]); - expect(invokeCalls).toContainEqual({ - command: "begin_model_oauth", - args: { root: "/tmp/openbot-provider-root", provider: provider.id }, - }); - expect(invokeCalls).toContainEqual({ - command: "finish_model_oauth", - args: { attemptId: "synthetic-attempt" }, + // LLMTR's row is an API key only. This case injects a sign-in login, so it runs for the + // providers that offer one. + if (provider.id !== "llmtr") { + test(`${provider.name} OAuth completes through native storage without exposing tokens`, async () => { + invokeHandler = async (command) => { + if (command === "providers") + return allProviders.map((row) => + row.id === provider.id + ? { ...row, logins: ["endpoint", "oauth"] } + : row, + ); + if (command === "begin_model_oauth") + return { + attemptId: "synthetic-attempt", + url: "https://authorization.example.test/approve", + userCode: "SYNTHETIC", + }; + if ( + command === "plugin:opener|open_url" || + command === "finish_model_oauth" + ) + return null; + throw new Error(`unexpected command ${command}`); + }; + const choices: unknown[] = []; + const view = await renderPicker((choice) => choices.push(choice)); + const user = userEvent.setup({ document: view.container.ownerDocument }); + await user.click( + await view.findByRole("radio", { name: new RegExp(provider.name) }), + ); + await user.click(view.getByRole("tab", { name: "Sign in" })); + await user.click( + view.getByRole("button", { name: `Sign in with ${provider.name}` }), + ); + await view.findByText(`Signed in to ${provider.name}.`); + await user.click(view.getByRole("button", { name: "Continue" })); + expect(choices).toEqual([ + { + provider: provider.id, + login: "oauth", + model: provider.model, + saved: true, + }, + ]); + expect(invokeCalls).toContainEqual({ + command: "begin_model_oauth", + args: { root: "/tmp/openbot-provider-root", provider: provider.id }, + }); + expect(invokeCalls).toContainEqual({ + command: "finish_model_oauth", + args: { attemptId: "synthetic-attempt" }, + }); }); - }); + } test(`${provider.name} opens its API key page externally without sending entered credentials`, async () => { invokeHandler = async (command) => { diff --git a/openbot/desktop/src/ProviderPicker.tsx b/openbot/desktop/src/ProviderPicker.tsx index c4cd3eb9..c5615f43 100644 --- a/openbot/desktop/src/ProviderPicker.tsx +++ b/openbot/desktop/src/ProviderPicker.tsx @@ -78,6 +78,12 @@ const endpointPresets: Record< model: "grok-4.7", keyUrl: "https://console.x.ai/", }, + // https://llmtr.com/docs/quickstart/ + llmtr: { + baseUrl: "https://llmtr.com/v1", + model: "qwen/qwen3.8-27b-free", + keyUrl: "https://llmtr.com/docs/api-keys/", + }, }; function endpointIdentity(baseUrl: string | undefined): string { @@ -133,8 +139,8 @@ export function recordedModel(held: HeldConfiguration): ModelChoice | null { /** * Connect a model. * - * Plans remain the default wherever supported. Google and xAI use the same endpoint credential - * route as custom models, while their named rows supply the address for the user. + * Plans remain the default wherever supported. Google, xAI and LLMTR use the same endpoint + * credential route as custom models, while their named rows supply the address for the user. */ export function ProviderPicker({ chosen, diff --git a/openbot/docs/configuration.md b/openbot/docs/configuration.md index b2c5113d..a25d70f8 100644 --- a/openbot/docs/configuration.md +++ b/openbot/docs/configuration.md @@ -235,6 +235,16 @@ model: default_model: openai/gpt-5.6-terra ``` +[LLMTR](https://llmtr.com) is one such gateway. Its address is `https://llmtr.com/v1`, the key goes in `OPENAI_API_KEY` (an LLMTR key starts with `llmtr-`), and `BOT_PROVIDER` stays `openai`: + +```sh +OPENAI_BASE_URL=https://llmtr.com/v1 +OPENAI_API_KEY=llmtr-... +BOT_MODEL=qwen/qwen3.8-27b-free +``` + +`BOT_MODEL` is the catalogue id, sent verbatim, so an id that already contains a slash is written in full. `qwen/qwen3.8-27b-free` accepts chat completions and tools. `llmtr/qwen3-8-27b` is the same shape and is hosted in Turkey. `GET https://llmtr.com/v1/models` lists the catalogue and needs no key. Keys are created from the [API keys page](https://llmtr.com/docs/api-keys/). Leave `BOT_REASONING_EFFORT` unset: that setting is sent as an object, and LLMTR accepts `reasoning` only as a boolean. + Most gateways publish a model list, which is the way to check a name before configuring it. Two things are worth knowing before pointing a deployment at any gateway. Not every catalogue entry accepts tools, and a Bot without tool calling cannot drive its computer; the model list says which do. And `BOT_RESPONSES_API=true` needs an endpoint that implements the Responses API, not only chat completions.