Skip to content
Merged
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
30 changes: 18 additions & 12 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,22 +183,28 @@ The conversation stays available when switching workspaces or connections.

- Connect a provider and choose a model through the window's **Settings** button.
Use a separate Ranger connection or explicitly reuse the bridge account's
saved Pi credentials with **Shared Pi credentials**. Select a saved provider or
expand **Connect another provider**; both credential sources support sign-in
and API-key entry in the window. **Configure custom models** connects compatible
endpoints by API address, format, key, and model IDs separated by commas or
newlines. Shared mode also reads
Pi's custom models. See [model setup](docs/DEPLOYMENT.md#ranger-model-connection).
saved Pi credentials with **Shared Pi credentials**. Settings is one page with
**Model** and **Workspace access** sections and a single **Save settings**
button, which notes unsaved changes. The searchable **Provider** picker lists
saved credentials first; the selected provider's status and its sign-in or
API-key action appear below it. Under **Custom models**, **Add** opens an
editor for new model IDs (keeping the selected custom endpoint) and **Edit**
opens the selected custom model. Enter IDs separated by commas or newlines and
check the supported efforts from Off, Minimal, Low, Medium, High, Extra high,
and Maximum. Leaving the efforts unchanged preserves existing capabilities.
**Save custom models** returns to Settings; **Back** or **Cancel** abandons
unsaved editor changes. Shared mode also reads Pi's custom models.
See [model setup](docs/DEPLOYMENT.md#ranger-model-connection).
- Change the model and **Thinking effort** with the compact selectors below the
chat input. Search models across connected providers. Effort choices come from
the selected model; **Default** shows its effective level, and models without
adjustable thinking show a disabled control. Changes are saved on the bridge
and apply to the next message, including when a reply is still streaming.
They preserve your draft, conversation, and workspace permissions.
- Allow workspaces in Settings. Each question automatically uses saved
authorized workspaces that are currently available. **Select all** adds the
currently available workspaces; **Clear** removes every selection. Use
**Save connection** to apply permission changes.
- Allow workspaces under **Workspace access** in Settings. Each question
automatically uses saved authorized workspaces that are currently available.
**Select all** adds the currently available workspaces; **Clear** removes every
selection. Use **Save settings** to apply permission changes.
Workspace choices use a compact grid that scrolls when the list is long.
No workspaces are authorized by default. In normal mode, new workspaces need
to be selected separately. Confirmed deleted workspaces are removed from the
Expand Down Expand Up @@ -258,8 +264,8 @@ The conversation stays available when switching workspaces or connections.
- Closing the window hides it while work continues; the topbar indicator shows
activity. **Stop** cancels the current turn. **New chat** saves the current
conversation and starts an empty one, keeping the model connection and workspace
permissions. **History** lists saved conversations by their first question;
select one to read its messages and continue chatting. Switching chats retires
permissions. **History** opens a full-panel list of saved conversations by their
first question; select one to read its messages and continue chatting. Switching chats retires
unconfirmed action previews.
- The wave bar marks messages and highlights the visible portion of the
conversation. It sits below the messages on narrow panels and mobile, and on
Expand Down
87 changes: 47 additions & 40 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,16 +387,18 @@ roamgate --host 0.0.0.0 --port 8443 \
## Ranger model connection

Open the global **Ranger** window and its **Settings** button. Choose
**Ranger connection** or **Shared Pi credentials**, select a provider, and use the available **Sign in**
or **Enter API key** action. Complete the provider's web/device-code or manual
authorization prompt in the window, choose a default model, select allowed
workspaces, and **Save connection**. Each question automatically uses saved
**Ranger connection** or **Shared Pi credentials**, select a provider in the
**Provider** picker, and use the available **Sign in** or **Enter API key**
action. Complete the provider's web/device-code or manual authorization prompt
in the window, choose a default model, select allowed workspaces under
**Workspace access**, and **Save settings**. Each question automatically uses saved
authorized workspaces that are currently available; no workspaces are authorized
by default.

**High-permission mode** is a separate Settings control, off by default. Enabling
it requires an explicit confirmation and authorizes all currently discoverable
workspaces, including new workspaces that appear while the mode is enabled,
**High-permission mode** is a separate switch under **Workspace access**, off by
default. Enabling it requires an explicit confirmation and authorizes all
currently discoverable workspaces, including new workspaces that appear while
the mode is enabled,
without individual selection. Workspace content may be sent to the selected
model provider. Ranger can then execute its supported workspace,
worktree, tab, pane, agent and prompt operations, and create schedules, without
Expand All @@ -422,42 +424,47 @@ concrete scopes are bounded to 512 workspaces. If an inventory is truncated,
Ranger will not silently treat the partial result as all workspaces. Large scopes
can take longer to validate and send more workspace context to the model.

For a compatible endpoint, expand **Configure custom models** and enter a unique
provider ID, API format, API base URL, model IDs, and API key. Enter one model ID
per line or separate IDs with commas, up to 100 IDs of 500 characters each.
For a compatible endpoint, select **Add** under **Custom models**. The editor
shows **Models** (model IDs and supported efforts) and **Endpoint** (provider
ID, API format, API base URL, and API key) together. When a custom provider is
selected, **Add** keeps its endpoint and starts with no model IDs, so new IDs
join that provider; enter a different provider ID for a new endpoint. Enter one
model ID per line or separate IDs with commas, up to 100 IDs of 500 characters
each.
Duplicate IDs are saved once. Supported formats are OpenAI Chat Completions,
OpenAI Responses, and Anthropic Messages. Use a
dummy key for an unauthenticated local server. **Save custom models** saves the
whole batch and selects the first ID; then select allowed workspaces and
**Save connection**. Select a custom provider and model to **Edit custom models**;
a blank key keeps its existing key. Stop running tasks before changing a model
connection. Existing models outside the batch keep their settings.
OpenAI Responses, and Anthropic Messages. Use a dummy key for an unauthenticated
local server. **Save custom models** saves the whole batch and selects the first
ID; then select allowed workspaces and
**Save settings**. Select a custom provider and model and choose **Edit** to
change that model.
**Save custom models** returns to Settings after saving; **Back** or **Cancel**
abandons unsaved editor changes. A blank key keeps its existing key.
Stop running tasks before changing a model connection. Existing models outside
the batch keep their settings.
Use a separate provider ID for a different endpoint so existing providers keep
their connection settings.

Custom model IDs do not declare reasoning support automatically. For a model
whose endpoint supports thinking, choose **Enable reasoning for all IDs** under
**Reasoning capability** and save the custom models. Existing models can be
edited in place; no new provider or API key is needed. The default **Keep
existing / model defaults** preserves each model's declaration, while new IDs
remain non-reasoning. An explicit enable or disable applies to every entered
ID, so use separate batches for models with different capabilities. Existing
Pi thinking-level maps and compatibility settings are preserved. Select the
desired **Thinking effort** and save the connection, or use the chat selector.
Pi determines the available levels and translates the selected effort into the
chosen API format; declaring support does not verify a third-party endpoint's
capabilities. Without a custom thinking-level map, reasoning models expose
Off, Minimal, Low, Medium, and High; imported maps can restrict or extend these.

**Provider** lists OAuth logins and API keys from the selected credential
source. Click a saved provider to select it, or expand **Connect another provider**
to choose one without saved credentials and log in from the window. **Refresh**
reads changes made outside the window. These labels report stored
credentials, not a successful live connection test; credentials are checked or
refreshed when used.
**Search providers** filters saved and other providers by name or ID, ignoring
case. Matching providers without saved credentials expand automatically;
filtering does not change the selected provider.
Under **Supported thinking efforts**, select the levels your endpoint supports:
Off, Minimal, Low, Medium, High, Extra high, and Maximum. New models start with
Off selected. Existing models show their saved supported levels; leaving the
checklist unchanged preserves each model's capabilities and Pi thinking-level
maps. Changing the checklist applies the selected levels to every entered ID,
so save separate batches for models with different capabilities. At least one
level is required. Existing Pi compatibility settings are preserved.

You determine which levels a third-party endpoint supports; saving a selection
does not verify those capabilities. Pi translates the selected effort into the
chosen API format. Select the desired **Thinking effort** and **Save settings**,
or use the chat selector. Existing reasoning models without an explicit level
list or Pi thinking-level map expose Off, Minimal, Low, Medium, and High.

The **Provider** picker lists providers with saved OAuth logins and API keys
from the selected credential source first, each with its credential status,
followed by providers without saved credentials. Type to search by name or ID.
Select a provider to choose it or log in from the window. The refresh button
next to **Provider** reads changes made outside the window. These labels report
stored credentials, not a successful live connection test; credentials are
checked or refreshed when used.

Ranger runs as the Roamgate bridge's OS account, including when the browser
is on a phone or a workspace uses SSH. Provider credentials therefore belong on
Expand Down Expand Up @@ -520,7 +527,7 @@ The catalog uses credentials saved in Pi's `auth.json` and compatible endpoints
in Pi's `models.json`. Custom model changes in shared mode update that file.
**Refresh** rereads both files. Environment-only credentials for built-in
providers are not imported. Model-file keys may use Pi's environment or command
syntax; entering a key in **Configure custom model** accepts literal keys only.
syntax; entering a key in the custom model editor accepts literal keys only.
Pi's extensions, skills, project instructions, and
built-in write/command tools are not loaded into Ranger.

Expand Down
120 changes: 116 additions & 4 deletions server/src/assistant/pi-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ASSISTANT_MAX_TOOL_ARGUMENTS,
ASSISTANT_MAX_TOOL_OUTPUT,
ASSISTANT_MAX_TOOL_DETAILS,
ASSISTANT_THINKING_LEVELS,
type AssistantMessage,
} from "../../../shared/assistant";
import { type AssistantDriver, createPiDriver } from "./pi-driver";
Expand Down Expand Up @@ -1315,6 +1316,93 @@ test("SQLite pauses a committed partial without aborting the durable submission
}
}, 3000);

test("explicit custom thinking efforts override imported restrictions and preserve provider translations", async () => {
const directory = mkdtempSync(join(tmpdir(), "roamgate-custom-efforts-"));
const path = join(directory, "models.json");
const saved = {
providers: {
custom: {
api: "openai-responses",
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "synthetic-test-key",
models: [
{
id: "reasoner",
reasoning: true,
thinkingLevelMap: { off: null, minimal: "low", high: "default" },
contextWindow: 8192,
},
{ id: "untouched", reasoning: false },
],
modelOverrides: {
reasoner: {
reasoning: false,
thinkingLevelMap: { high: "provider-high", max: null },
maxTokens: 2048,
},
},
},
},
};
writeFileSync(path, JSON.stringify(saved));
const driver = createPiDriver(directory);
const save = (path: string, value: unknown) =>
writeFileSync(path, JSON.stringify(value));
const connection = {
credential_source: "assistant" as const,
provider: "custom",
model: "reasoner",
base_url: saved.providers.custom.baseUrl,
api: "openai-responses" as const,
};
try {
await driver.configureModel!(
{
...connection,
models: ["reasoner", "new-reasoner"],
thinking_levels: ["minimal", "high", "max"],
},
save,
);
const updated = JSON.parse(readFileSync(path, "utf8"));
const expectedMap = {
off: null,
minimal: "low",
low: null,
medium: null,
high: "provider-high",
xhigh: null,
max: "max",
};
expect(updated.providers.custom.models[0]).toMatchObject({
contextWindow: 8192,
reasoning: true,
thinkingLevelMap: expectedMap,
});
expect(updated.providers.custom.modelOverrides.reasoner).toEqual({
reasoning: true,
thinkingLevelMap: expectedMap,
maxTokens: 2048,
});
expect(updated.providers.custom.models[1]).toEqual(
saved.providers.custom.models[1],
);
const catalog = await driver.catalog("assistant");
for (const id of ["reasoner", "new-reasoner"])
expect(catalog.models.find((model) => model.id === id)).toMatchObject({
thinking_levels: ["minimal", "high", "max"],
default_thinking_level: "minimal",
custom: { reasoning: true },
});
const before = readFileSync(path, "utf8");
await driver.configureModel!(connection, save);
expect(readFileSync(path, "utf8")).toBe(before);
} finally {
await driver.dispose();
rmSync(directory, { recursive: true, force: true });
}
});

test.each([
"read",
"proposal",
Expand Down Expand Up @@ -2505,7 +2593,7 @@ test.each([
"openai-responses",
"anthropic-messages",
] as const)(
"configured custom reasoning reaches the %s request",
"configured custom thinking efforts reach the %s request",
async (api) => {
const directory = mkdtempSync(join(tmpdir(), "roamgate-reasoning-wire-"));
const requests: {
Expand Down Expand Up @@ -2620,7 +2708,13 @@ test.each([
expect(
(await driver.catalog("assistant")).models[0]?.thinking_levels,
).toEqual(["off"]);
await driver.configureModel!({ ...connection, reasoning: true }, save);
await driver.configureModel!(
{ ...connection, thinking_levels: [...ASSISTANT_THINKING_LEVELS] },
save,
);
expect(
(await driver.catalog("assistant")).models[0]?.thinking_levels,
).toEqual([...ASSISTANT_THINKING_LEVELS]);
const entries = await driver.run({
...input,
config: { ...input.config, thinking_level: "high" },
Expand All @@ -2644,15 +2738,33 @@ test.each([
else if (api === "openai-responses")
expect(requests[1]!.reasoning?.effort).toBe("none");
else expect(requests[1]!.thinking).toEqual({ type: "disabled" });
await driver.configureModel!({ ...connection, reasoning: false }, save);
if (api !== "anthropic-messages")
for (const thinking_level of ["xhigh", "max"] as const) {
await driver.run({
...input,
entries,
requestId: `${thinking_level}-effort`,
config: { ...input.config, thinking_level },
});
expect(
api === "openai-completions"
? requests.at(-1)!.reasoning_effort
: requests.at(-1)!.reasoning?.effort,
).toBe(thinking_level);
}
const requestCount = requests.length;
await driver.configureModel!(
{ ...connection, thinking_levels: ["off"] },
save,
);
await expect(
driver.run({
...input,
requestId: "disabled-effort",
config: { ...input.config, thinking_level: "high" },
}),
).rejects.toThrow("not supported");
expect(requests).toHaveLength(2);
expect(requests).toHaveLength(requestCount);
} finally {
await driver.dispose();
server.stop(true);
Expand Down
Loading
Loading