Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions openbot/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions openbot/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 21 additions & 5 deletions openbot/desktop/src-tauri/src/provider.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -104,6 +105,14 @@ pub fn catalogue() -> Vec<Provider> {
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(),
Expand All @@ -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))
Expand All @@ -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"
]
);
}

Expand Down
109 changes: 60 additions & 49 deletions openbot/desktop/src/ProviderPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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) => {
Expand Down
10 changes: 8 additions & 2 deletions openbot/desktop/src/ProviderPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions openbot/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down