Skip to content

Commit 77d6a61

Browse files
authored
Allow manual models for custom endpoints (#7)
Validate a known model through chat completions when a custom endpoint does not expose /models.
2 parents 4913db7 + 1204594 commit 77d6a61

7 files changed

Lines changed: 162 additions & 16 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rerouted",
3-
"version": "0.4.4",
3+
"version": "0.4.5",
44
"description": "A macOS menu-bar router for accounts, models, and automatic fallback.",
55
"author": "gitcommit90",
66
"homepage": "https://rerouted.dev",

scripts/capture-ui.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -808,13 +808,19 @@ app.whenReady().then(async () => {
808808
document.querySelector('[data-keyed-preset="custom"]')?.click();
809809
if (
810810
!document.querySelector('[data-keyed-field="name"]') ||
811-
!document.querySelector('[data-keyed-field="base"]')
811+
!document.querySelector('[data-keyed-field="base"]') ||
812+
!document.querySelector('[data-keyed-field="model"]')
812813
) {
813814
throw new Error("Custom provider fields did not render");
814815
}
815816
return true;
816817
})()
817818
`);
819+
await capture(
820+
"app-providers-custom-model.png",
821+
"#add-panel [data-keyed-form] .card",
822+
"#add-panel .action-panel"
823+
);
818824
await win.webContents.executeJavaScript(`
819825
(async () => {
820826
document.querySelector('[data-keyed-preset="openrouter"]')?.click();

src/lib/keyed-provider-test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"use strict";
2+
3+
const { runProviderModelTest } = require("./model-test");
4+
const openaiCompat = require("./providers/openai-compat");
5+
6+
async function testKeyedProvider(
7+
{ baseUrl, apiKey, modelId } = {},
8+
{ adapter = openaiCompat, logger } = {}
9+
) {
10+
const finalBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, "");
11+
const finalApiKey = String(apiKey || "").trim();
12+
const exactModelId = String(modelId || "").trim();
13+
14+
if (!finalBaseUrl || !finalApiKey) {
15+
return { ok: false, error: "Base URL and API key required" };
16+
}
17+
18+
const provider = {
19+
type: "openai-compat",
20+
name: "Custom",
21+
baseUrl: finalBaseUrl,
22+
apiKey: finalApiKey,
23+
};
24+
25+
if (exactModelId) {
26+
const result = await runProviderModelTest({
27+
adapter,
28+
provider,
29+
model: exactModelId,
30+
logger,
31+
});
32+
if (!result.ok) return result;
33+
return {
34+
ok: true,
35+
models: [{ id: exactModelId, name: exactModelId }],
36+
validation: "chat-completions",
37+
};
38+
}
39+
40+
try {
41+
const models = await adapter.listModels(provider);
42+
return { ok: true, models, validation: "models" };
43+
} catch (error) {
44+
return { ok: false, error: error.message };
45+
}
46+
}
47+
48+
module.exports = { testKeyedProvider };

src/main.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ const {
4040
redactLockedState,
4141
} = require("./lib/ipc-security");
4242
const { runProviderModelTest } = require("./lib/model-test");
43+
const { testKeyedProvider } = require("./lib/keyed-provider-test");
4344
const { createUpdateService } = require("./lib/updater");
4445
const { acquireSingleInstance } = require("./lib/single-instance");
4546
const { KEYED_PRESETS, ONBOARDING_STEPS, DEFAULT_PORT, OAUTH } = require("./lib/constants");
46-
const openaiCompat = require("./lib/providers/openai-compat");
4747
const { defaultModelsForType, listProviderModels, getAdapter } = require("./lib/providers");
4848
const {
4949
publicCombo,
@@ -624,13 +624,8 @@ function registerIpc() {
624624
return { ok: true, id: prov.id };
625625
});
626626

627-
handle("app:test-keyed-provider", async (_e, { baseUrl, apiKey }) => {
628-
try {
629-
const models = await openaiCompat.listModels({ baseUrl, apiKey });
630-
return { ok: true, models };
631-
} catch (e) {
632-
return { ok: false, error: e.message };
633-
}
627+
handle("app:test-keyed-provider", async (_e, payload) => {
628+
return testKeyedProvider(payload, { logger });
634629
});
635630

636631
handle("app:remove-provider", async (_e, id) => {

src/renderer/app.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,9 @@ function bindKeyedProviderPicker(root, { onAdded, successMessage = "Provider add
453453
${
454454
isCustom
455455
? `<input class="input" data-keyed-field="name" placeholder="Name" />
456-
<input class="input" data-keyed-field="base" placeholder="Base URL (https://…/v1)" />`
456+
<input class="input" data-keyed-field="base" placeholder="Base URL (https://…/v1)" />
457+
<input class="input" data-keyed-field="model" placeholder="Exact model ID (if /models is unavailable)" />
458+
<div class="card-sub">Optional. Tests chat directly without /models.</div>`
457459
: ""
458460
}
459461
${
@@ -491,7 +493,13 @@ function bindKeyedProviderPicker(root, { onAdded, successMessage = "Provider add
491493
: (preset.baseUrl || "").replace("{account_id}", accountId());
492494
const inputs = [...form.querySelectorAll("[data-keyed-field]")];
493495
const fingerprint = () =>
494-
JSON.stringify([presetId, baseUrl(), field("key").value.trim(), accountId()]);
496+
JSON.stringify([
497+
presetId,
498+
baseUrl(),
499+
field("key").value.trim(),
500+
accountId(),
501+
field("model")?.value?.trim() || "",
502+
]);
495503
let models = null;
496504
let testedFingerprint = null;
497505
let testGeneration = 0;
@@ -526,6 +534,7 @@ function bindKeyedProviderPicker(root, { onAdded, successMessage = "Provider add
526534
result = await api.invoke("app:test-keyed-provider", {
527535
baseUrl: baseUrl(),
528536
apiKey: field("key").value.trim(),
537+
modelId: field("model")?.value?.trim() || "",
529538
});
530539
} catch (error) {
531540
result = { ok: false, error: error.message || "Test failed" };
@@ -541,7 +550,10 @@ function bindKeyedProviderPicker(root, { onAdded, successMessage = "Provider add
541550
}
542551
models = result.models || [];
543552
testedFingerprint = testedValues;
544-
status.textContent = `OK — ${models.length} models`;
553+
status.textContent =
554+
result.validation === "chat-completions"
555+
? `OK — ${models[0]?.id || "model"} validated`
556+
: `OK — ${models.length} models`;
545557
status.classList.add("ok");
546558
addButton.disabled = false;
547559
};
@@ -599,7 +611,7 @@ function renderApiKeys() {
599611
view.innerHTML = `
600612
${stepProgress("api-keys")}
601613
<h1 class="h1">Add an API key</h1>
602-
<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>
614+
<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>
603615
${keyedProviderPickerHtml(presets)}
604616
<div class="btn-row">
605617
<button type="button" class="btn btn-secondary" id="btn-skip">Skip</button>

tests/keyed-provider-test.test.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"use strict";
2+
3+
const assert = require("node:assert/strict");
4+
const http = require("node:http");
5+
const { it } = require("node:test");
6+
const { testKeyedProvider } = require("../src/lib/keyed-provider-test");
7+
8+
it("discovers models when no exact custom model ID is supplied", async () => {
9+
let listedProvider;
10+
const result = await testKeyedProvider(
11+
{ baseUrl: "https://example.test/v1/", apiKey: "test-key" },
12+
{
13+
adapter: {
14+
listModels: async (provider) => {
15+
listedProvider = provider;
16+
return [{ id: "model-a", name: "Model A" }];
17+
},
18+
},
19+
}
20+
);
21+
22+
assert.equal(result.ok, true);
23+
assert.equal(result.validation, "models");
24+
assert.equal(listedProvider.baseUrl, "https://example.test/v1");
25+
assert.deepEqual(result.models, [{ id: "model-a", name: "Model A" }]);
26+
});
27+
28+
it("validates a known model through chat completions without calling /models", async () => {
29+
const requests = [];
30+
const server = http.createServer((request, response) => {
31+
let body = "";
32+
request.on("data", (chunk) => {
33+
body += chunk;
34+
});
35+
request.on("end", () => {
36+
requests.push({
37+
path: request.url,
38+
authorization: request.headers.authorization,
39+
body: body ? JSON.parse(body) : null,
40+
});
41+
if (request.url === "/v1/models") {
42+
response.writeHead(404).end("not supported");
43+
return;
44+
}
45+
response.writeHead(200, { "Content-Type": "application/json" });
46+
response.end(JSON.stringify({ choices: [{ message: { content: "ok" } }] }));
47+
});
48+
});
49+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
50+
51+
try {
52+
const result = await testKeyedProvider({
53+
baseUrl: `http://127.0.0.1:${server.address().port}/v1`,
54+
apiKey: "test-key",
55+
modelId: "cline-known-model",
56+
});
57+
58+
assert.equal(result.ok, true);
59+
assert.equal(result.validation, "chat-completions");
60+
assert.deepEqual(requests.map((request) => request.path), ["/v1/chat/completions"]);
61+
assert.equal(requests[0].authorization, "Bearer test-key");
62+
assert.equal(requests[0].body.model, "cline-known-model");
63+
assert.deepEqual(result.models, [{ id: "cline-known-model", name: "cline-known-model" }]);
64+
} finally {
65+
await new Promise((resolve) => server.close(resolve));
66+
}
67+
});
68+
69+
it("does not admit an exact model when its chat-completion test fails", async () => {
70+
const result = await testKeyedProvider(
71+
{
72+
baseUrl: "https://example.test/v1",
73+
apiKey: "test-key",
74+
modelId: "missing-model",
75+
},
76+
{
77+
adapter: {
78+
chat: async () => new Response("unknown model", { status: 404 }),
79+
},
80+
}
81+
);
82+
83+
assert.equal(result.ok, false);
84+
assert.match(result.error, /Model test failed \(404\): unknown model/);
85+
});

0 commit comments

Comments
 (0)