From b4a13f5c3b8e1cab861e0a7574ef49d2fefb1fab Mon Sep 17 00:00:00 2001 From: iamabhishekt Date: Wed, 28 Oct 2026 16:16:30 -0400 Subject: [PATCH] feat: manual model IDs when /models is unavailable Hosted OpenAI-compatible APIs often serve chat but not GET /models (Volcengine Ark Agent Plan returns 404), so connect failed. Accept a typed list of model IDs: merged into discovered models when /models works, used alone when it does not. The typed list is stored as customProvider.manualModels so reconnects and pin saves keep it. --- app/api/custom-provider/route.ts | 19 +++++++++----- components/settings/keys-section.tsx | 18 ++++++++++++- src/core/settings-resolution.ts | 3 ++- src/core/settings.ts | 9 +++++++ src/server/custom-provider.ts | 6 +++++ tests/custom-endpoint.test.ts | 39 +++++++++++++++++++++++++++- 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts index 193dd2c..5280609 100644 --- a/app/api/custom-provider/route.ts +++ b/app/api/custom-provider/route.ts @@ -16,13 +16,14 @@ import { discoverModels, effectiveSettings, keyValue, + mergeManualModels, parseCustomModelOverridesBody, readSettingsRecord, saveKey, syncCustomProvider, writeSettingsRecord, } from "@/src/server"; -import { CUSTOM_PROVIDER, normalizeBaseUrl, sanitizeCustomModelOverrides } from "@/src/core"; +import { CUSTOM_PROVIDER, normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides } from "@/src/core"; export const dynamic = "force-dynamic"; @@ -32,12 +33,13 @@ export async function GET() { baseUrl: cp.baseUrl, models: cp.models, overrides: cp.overrides, + manualModels: cp.manualModels, keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined, }); } export async function POST(request: Request) { - const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown } | null; + const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown; manualModels?: unknown } | null; const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : ""); if (!base) return Response.json({ error: "Base URL must be a valid http(s) URL" }, { status: 400 }); @@ -45,9 +47,12 @@ export async function POST(request: Request) { const keyProvided = typeof body?.key === "string"; const key = keyProvided ? (body!.key as string).trim() : undefined; - // Probe first: a URL that doesn't answer with a model list never persists. + const manualModels = parseModelIds(body?.manualModels); const found = await discoverModels(base, key ? key : undefined); - if (!found.ok) return Response.json({ error: found.error }, { status: 502 }); + if (!found.ok && !manualModels.length) { + return Response.json({ error: `${found.error}. If this endpoint has no /models list, add model IDs manually.` }, { status: 502 }); + } + const models = mergeManualModels(found.ok ? found.models : [], manualModels); if (keyProvided) { if (key) { @@ -63,11 +68,11 @@ export async function POST(request: Request) { // endpoint still advertises (the single prune law, core/settings.ts). const overrides = sanitizeCustomModelOverrides( record.customProvider?.overrides, - found.models.map((m) => m.id), + models.map((m) => m.id), ); const next = writeSettingsRecord({ ...record, - customProvider: { baseUrl: base, models: found.models, overrides }, + customProvider: { baseUrl: base, models, overrides, ...(manualModels.length ? { manualModels } : {}) }, }); syncCustomProvider(next.customProvider); return Response.json({ ok: true, models: next.customProvider?.models ?? [] }); @@ -89,7 +94,7 @@ export async function PATCH(request: Request) { const record = readSettingsRecord() ?? { version: 1 }; const next = writeSettingsRecord({ ...record, - customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides }, + customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides, ...(cp.manualModels.length ? { manualModels: cp.manualModels } : {}) }, }); syncCustomProvider(next.customProvider); return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} }); diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 5aac78d..95a848f 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -59,6 +59,7 @@ interface CustomStatus { models: CustomModel[]; /** User pins by model id (context / max output / thinking). */ overrides: Record; + manualModels: string[]; keyPresent: boolean; } @@ -413,6 +414,7 @@ export function KeysSection({ baseUrl: cp.baseUrl ?? "", models: cp.models ?? [], overrides: cp.overrides ?? {}, + manualModels: cp.manualModels ?? [], keyPresent: Boolean(cp.keyPresent), }); } catch { @@ -513,6 +515,7 @@ export function KeysSection({ keyPresent={custom?.keyPresent ?? false} models={custom?.models ?? []} overrides={custom?.overrides ?? {}} + manualModels0={custom?.manualModels ?? []} onSaved={async () => { setAddingCustom(false); await reload(); @@ -728,6 +731,7 @@ function CustomEndpointEditor({ keyPresent, models, overrides, + manualModels0, onSaved, onPinsSaved, onCancel, @@ -739,6 +743,7 @@ function CustomEndpointEditor({ models: readonly CustomModel[]; /** Their stored user pins. */ overrides: Record; + manualModels0: readonly string[]; onSaved: () => void | Promise; /** Pins saved — the editor stays open (a form, not a connect). */ onPinsSaved: () => void | Promise; @@ -748,6 +753,7 @@ function CustomEndpointEditor({ const [baseUrl, setBaseUrl] = useState(baseUrl0); const [key, setKey] = useState(""); const [keyTouched, setKeyTouched] = useState(false); + const [manualModels, setManualModels] = useState(manualModels0.join(", ")); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // The limits form: edited drafts over the stored pins, plus the last saved @@ -797,7 +803,7 @@ function CustomEndpointEditor({ const res = await fetch("/api/custom-provider", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ baseUrl, ...(keyTouched ? { key } : {}) }), + body: JSON.stringify({ baseUrl, manualModels, ...(keyTouched ? { key } : {}) }), }).catch(() => null); setBusy(false); if (!res || !res.ok) { @@ -824,6 +830,16 @@ function CustomEndpointEditor({ className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" /> + setManualModels(e.target.value)} + placeholder="Model IDs, comma-separated — optional, needed when the endpoint has no /models list" + aria-label="Custom endpoint model IDs" + data-testid="custom-models-input" + disabled={busy} + className="h-7 min-w-0 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + />
; /** Custom endpoint, resolved concrete: baseUrl defaults "", models and * user pins default to empty. */ - customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record }; + customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record; manualModels: string[] }; } /** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own @@ -230,6 +230,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings baseUrl: record?.customProvider?.baseUrl ?? "", models: record?.customProvider?.models ?? [], overrides: record?.customProvider?.overrides ?? {}, + manualModels: record?.customProvider?.manualModels ?? [], }, }; } diff --git a/src/core/settings.ts b/src/core/settings.ts index 8e5d638..18b3e1d 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -259,6 +259,13 @@ export interface CustomProviderSettings { /** User pins keyed by model id — pure user intent, never rewritten by * discovery, pruned to the endpoint's live ids on connect. */ overrides?: Record; + manualModels?: string[]; +} + +export function parseModelIds(raw: unknown): string[] { + const items = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : []; + const ids = items.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean); + return [...new Set(ids)]; } /** Canonical base-URL form (single source): trims, requires absolute http(s), @@ -644,6 +651,8 @@ function sanitizeCustomProvider(raw: Record): CustomProviderSet } const overrides = sanitizeCustomModelOverrides(raw.overrides); if (Object.keys(overrides).length) out.overrides = overrides; + const manualModels = parseModelIds(raw.manualModels); + if (manualModels.length) out.manualModels = manualModels; return out; } diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts index b9f9b8c..4dc3eb6 100644 --- a/src/server/custom-provider.ts +++ b/src/server/custom-provider.ts @@ -131,6 +131,12 @@ export async function discoverModels(baseUrl: string, apiKey?: string): Promise< } } +export function mergeManualModels(discovered: readonly CustomModel[], manualIds: readonly string[]): CustomModel[] { + const byId = new Map(discovered.map((m) => [m.id, m])); + for (const id of manualIds) if (!byId.has(id)) byId.set(id, { id }); + return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)); +} + /** Model rows from a list response ({data:[…]} | {models:[…]} | bare array), * or null if unrecognized. */ function modelRows(body: unknown): unknown[] | null { diff --git a/tests/custom-endpoint.test.ts b/tests/custom-endpoint.test.ts index 8b64253..d477b79 100644 --- a/tests/custom-endpoint.test.ts +++ b/tests/custom-endpoint.test.ts @@ -15,6 +15,7 @@ import { CUSTOM_MODEL_PIN_FLOOR, customProviderUsable, normalizeBaseUrl, + parseModelIds, sanitizeCustomModelOverrides, sanitizeUserSettings, } from "../src/core/settings.ts"; @@ -24,6 +25,7 @@ import { CUSTOM_PROVIDER } from "../src/core/auth-keys.ts"; import { buildOpenAiCompatibleProvider, discoverModels, + mergeManualModels, syncCustomProvider, } from "../src/server/custom-provider.ts"; import { piModels } from "../src/agent/models.ts"; @@ -160,7 +162,7 @@ describe("parseCustomModelOverridesBody — the strict write side", () => { // ─── resolution ────────────────────────────────────────────────────────── describe("resolveSettings — customProvider", () => { it("resolves an absent section to concrete defaults", () => { - assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [], overrides: {} }); + assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [], overrides: {}, manualModels: [] }); }); it("carries stored pins through to the concrete view", () => { const resolved = resolveSettings({ @@ -169,6 +171,41 @@ describe("resolveSettings — customProvider", () => { }); assert.deepEqual(resolved.customProvider.overrides, { a: { context: 32768 } }); }); + it("carries stored manual model ids through to the concrete view", () => { + const resolved = resolveSettings({ + version: 1, + customProvider: { baseUrl: "http://h/v1", models: [{ id: "a" }], manualModels: ["a"] }, + }); + assert.deepEqual(resolved.customProvider.manualModels, ["a"]); + }); +}); + +describe("manual model ids", () => { + it("parseModelIds splits a typed list on commas and whitespace, trims and dedupes", () => { + assert.deepEqual(parseModelIds(" doubao-seed-2.1-pro, kimi-k3.1-pro\nqwen/qwen3-max ,, kimi-k3.1-pro "), [ + "doubao-seed-2.1-pro", + "kimi-k3.1-pro", + "qwen/qwen3-max", + ]); + }); + it("parseModelIds accepts a string array and ignores non-strings and junk", () => { + assert.deepEqual(parseModelIds(["a", " b ", "", 3, null, "a"]), ["a", "b"]); + assert.deepEqual(parseModelIds(undefined), []); + assert.deepEqual(parseModelIds({ id: "a" }), []); + }); + it("the sanitizer keeps a clean manual list and drops an empty one", () => { + const kept = sanitizeUserSettings({ version: 1, customProvider: { baseUrl: "http://h/v1", manualModels: [" a ", "a", "b"] } }); + assert.deepEqual(kept?.customProvider?.manualModels, ["a", "b"]); + const dropped = sanitizeUserSettings({ version: 1, customProvider: { baseUrl: "http://h/v1", manualModels: [] } }); + assert.equal(dropped?.customProvider?.manualModels, undefined); + }); + it("mergeManualModels adds typed ids with defaults and never replaces discovered metadata", () => { + const merged = mergeManualModels([{ id: "b", image: true, context: 65536 }], ["c", "b", "a"]); + assert.deepEqual(merged, [{ id: "a" }, { id: "b", image: true, context: 65536 }, { id: "c" }]); + }); + it("mergeManualModels alone builds the list when discovery found nothing", () => { + assert.deepEqual(mergeManualModels([], ["x"]), [{ id: "x" }]); + }); }); // ─── provider build + live sync ──────────────────────────────────────────────