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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rerouted",
"version": "0.4.4",
"version": "0.4.5",
"description": "A macOS menu-bar router for accounts, models, and automatic fallback.",
"author": "gitcommit90",
"homepage": "https://rerouted.dev",
Expand Down
8 changes: 7 additions & 1 deletion scripts/capture-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -808,13 +808,19 @@ app.whenReady().then(async () => {
document.querySelector('[data-keyed-preset="custom"]')?.click();
if (
!document.querySelector('[data-keyed-field="name"]') ||
!document.querySelector('[data-keyed-field="base"]')
!document.querySelector('[data-keyed-field="base"]') ||
!document.querySelector('[data-keyed-field="model"]')
) {
throw new Error("Custom provider fields did not render");
}
return true;
})()
`);
await capture(
"app-providers-custom-model.png",
"#add-panel [data-keyed-form] .card",
"#add-panel .action-panel"
);
await win.webContents.executeJavaScript(`
(async () => {
document.querySelector('[data-keyed-preset="openrouter"]')?.click();
Expand Down
48 changes: 48 additions & 0 deletions src/lib/keyed-provider-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use strict";

const { runProviderModelTest } = require("./model-test");
const openaiCompat = require("./providers/openai-compat");

async function testKeyedProvider(
{ baseUrl, apiKey, modelId } = {},
{ adapter = openaiCompat, logger } = {}
) {
const finalBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, "");
const finalApiKey = String(apiKey || "").trim();
const exactModelId = String(modelId || "").trim();

if (!finalBaseUrl || !finalApiKey) {
return { ok: false, error: "Base URL and API key required" };
}

const provider = {
type: "openai-compat",
name: "Custom",
baseUrl: finalBaseUrl,
apiKey: finalApiKey,
};

if (exactModelId) {
const result = await runProviderModelTest({
adapter,
provider,
model: exactModelId,
logger,
});
if (!result.ok) return result;
return {
ok: true,
models: [{ id: exactModelId, name: exactModelId }],
validation: "chat-completions",
};
}

try {
const models = await adapter.listModels(provider);
return { ok: true, models, validation: "models" };
} catch (error) {
return { ok: false, error: error.message };
}
}

module.exports = { testKeyedProvider };
11 changes: 3 additions & 8 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ const {
redactLockedState,
} = require("./lib/ipc-security");
const { runProviderModelTest } = require("./lib/model-test");
const { testKeyedProvider } = require("./lib/keyed-provider-test");
const { createUpdateService } = require("./lib/updater");
const { acquireSingleInstance } = require("./lib/single-instance");
const { KEYED_PRESETS, ONBOARDING_STEPS, DEFAULT_PORT, OAUTH } = require("./lib/constants");
const openaiCompat = require("./lib/providers/openai-compat");
const { defaultModelsForType, listProviderModels, getAdapter } = require("./lib/providers");
const {
publicCombo,
Expand Down Expand Up @@ -624,13 +624,8 @@ function registerIpc() {
return { ok: true, id: prov.id };
});

handle("app:test-keyed-provider", async (_e, { baseUrl, apiKey }) => {
try {
const models = await openaiCompat.listModels({ baseUrl, apiKey });
return { ok: true, models };
} catch (e) {
return { ok: false, error: e.message };
}
handle("app:test-keyed-provider", async (_e, payload) => {
return testKeyedProvider(payload, { logger });
});

handle("app:remove-provider", async (_e, id) => {
Expand Down
20 changes: 16 additions & 4 deletions src/renderer/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,9 @@ function bindKeyedProviderPicker(root, { onAdded, successMessage = "Provider add
${
isCustom
? `<input class="input" data-keyed-field="name" placeholder="Name" />
<input class="input" data-keyed-field="base" placeholder="Base URL (https://…/v1)" />`
<input class="input" data-keyed-field="base" placeholder="Base URL (https://…/v1)" />
<input class="input" data-keyed-field="model" placeholder="Exact model ID (if /models is unavailable)" />
<div class="card-sub">Optional. Tests chat directly without /models.</div>`
: ""
}
${
Expand Down Expand Up @@ -491,7 +493,13 @@ function bindKeyedProviderPicker(root, { onAdded, successMessage = "Provider add
: (preset.baseUrl || "").replace("{account_id}", accountId());
const inputs = [...form.querySelectorAll("[data-keyed-field]")];
const fingerprint = () =>
JSON.stringify([presetId, baseUrl(), field("key").value.trim(), accountId()]);
JSON.stringify([
presetId,
baseUrl(),
field("key").value.trim(),
accountId(),
field("model")?.value?.trim() || "",
]);
let models = null;
let testedFingerprint = null;
let testGeneration = 0;
Expand Down Expand Up @@ -526,6 +534,7 @@ function bindKeyedProviderPicker(root, { onAdded, successMessage = "Provider add
result = await api.invoke("app:test-keyed-provider", {
baseUrl: baseUrl(),
apiKey: field("key").value.trim(),
modelId: field("model")?.value?.trim() || "",
});
} catch (error) {
result = { ok: false, error: error.message || "Test failed" };
Expand All @@ -541,7 +550,10 @@ function bindKeyedProviderPicker(root, { onAdded, successMessage = "Provider add
}
models = result.models || [];
testedFingerprint = testedValues;
status.textContent = `OK — ${models.length} models`;
status.textContent =
result.validation === "chat-completions"
? `OK — ${models[0]?.id || "model"} validated`
: `OK — ${models.length} models`;
status.classList.add("ok");
addButton.disabled = false;
};
Expand Down Expand Up @@ -599,7 +611,7 @@ function renderApiKeys() {
view.innerHTML = `
${stepProgress("api-keys")}
<h1 class="h1">Add an API key</h1>
<p class="lead">Optional. Quick-add a known chat-completions provider, or enter a custom base URL. Custom providers must pass Fetch models before Add.</p>
<p class="lead">Optional. Quick-add a known chat-completions provider, or enter a custom base URL. Discover its models or validate one exact model ID before Add.</p>
${keyedProviderPickerHtml(presets)}
<div class="btn-row">
<button type="button" class="btn btn-secondary" id="btn-skip">Skip</button>
Expand Down
85 changes: 85 additions & 0 deletions tests/keyed-provider-test.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use strict";

const assert = require("node:assert/strict");
const http = require("node:http");
const { it } = require("node:test");
const { testKeyedProvider } = require("../src/lib/keyed-provider-test");

it("discovers models when no exact custom model ID is supplied", async () => {
let listedProvider;
const result = await testKeyedProvider(
{ baseUrl: "https://example.test/v1/", apiKey: "test-key" },
{
adapter: {
listModels: async (provider) => {
listedProvider = provider;
return [{ id: "model-a", name: "Model A" }];
},
},
}
);

assert.equal(result.ok, true);
assert.equal(result.validation, "models");
assert.equal(listedProvider.baseUrl, "https://example.test/v1");
assert.deepEqual(result.models, [{ id: "model-a", name: "Model A" }]);
});

it("validates a known model through chat completions without calling /models", async () => {
const requests = [];
const server = http.createServer((request, response) => {
let body = "";
request.on("data", (chunk) => {
body += chunk;
});
request.on("end", () => {
requests.push({
path: request.url,
authorization: request.headers.authorization,
body: body ? JSON.parse(body) : null,
});
if (request.url === "/v1/models") {
response.writeHead(404).end("not supported");
return;
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ choices: [{ message: { content: "ok" } }] }));
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));

try {
const result = await testKeyedProvider({
baseUrl: `http://127.0.0.1:${server.address().port}/v1`,
apiKey: "test-key",
modelId: "cline-known-model",
});

assert.equal(result.ok, true);
assert.equal(result.validation, "chat-completions");
assert.deepEqual(requests.map((request) => request.path), ["/v1/chat/completions"]);
assert.equal(requests[0].authorization, "Bearer test-key");
assert.equal(requests[0].body.model, "cline-known-model");
assert.deepEqual(result.models, [{ id: "cline-known-model", name: "cline-known-model" }]);
} finally {
await new Promise((resolve) => server.close(resolve));
}
});

it("does not admit an exact model when its chat-completion test fails", async () => {
const result = await testKeyedProvider(
{
baseUrl: "https://example.test/v1",
apiKey: "test-key",
modelId: "missing-model",
},
{
adapter: {
chat: async () => new Response("unknown model", { status: 404 }),
},
}
);

assert.equal(result.ok, false);
assert.match(result.error, /Model test failed \(404\): unknown model/);
});
Loading