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.
This commit is contained in:
@@ -16,13 +16,14 @@ import {
|
|||||||
discoverModels,
|
discoverModels,
|
||||||
effectiveSettings,
|
effectiveSettings,
|
||||||
keyValue,
|
keyValue,
|
||||||
|
mergeManualModels,
|
||||||
parseCustomModelOverridesBody,
|
parseCustomModelOverridesBody,
|
||||||
readSettingsRecord,
|
readSettingsRecord,
|
||||||
saveKey,
|
saveKey,
|
||||||
syncCustomProvider,
|
syncCustomProvider,
|
||||||
writeSettingsRecord,
|
writeSettingsRecord,
|
||||||
} from "@/src/server";
|
} 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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -32,12 +33,13 @@ export async function GET() {
|
|||||||
baseUrl: cp.baseUrl,
|
baseUrl: cp.baseUrl,
|
||||||
models: cp.models,
|
models: cp.models,
|
||||||
overrides: cp.overrides,
|
overrides: cp.overrides,
|
||||||
|
manualModels: cp.manualModels,
|
||||||
keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined,
|
keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
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 : "");
|
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 });
|
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 keyProvided = typeof body?.key === "string";
|
||||||
const key = keyProvided ? (body!.key as string).trim() : undefined;
|
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);
|
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 (keyProvided) {
|
||||||
if (key) {
|
if (key) {
|
||||||
@@ -63,11 +68,11 @@ export async function POST(request: Request) {
|
|||||||
// endpoint still advertises (the single prune law, core/settings.ts).
|
// endpoint still advertises (the single prune law, core/settings.ts).
|
||||||
const overrides = sanitizeCustomModelOverrides(
|
const overrides = sanitizeCustomModelOverrides(
|
||||||
record.customProvider?.overrides,
|
record.customProvider?.overrides,
|
||||||
found.models.map((m) => m.id),
|
models.map((m) => m.id),
|
||||||
);
|
);
|
||||||
const next = writeSettingsRecord({
|
const next = writeSettingsRecord({
|
||||||
...record,
|
...record,
|
||||||
customProvider: { baseUrl: base, models: found.models, overrides },
|
customProvider: { baseUrl: base, models, overrides, ...(manualModels.length ? { manualModels } : {}) },
|
||||||
});
|
});
|
||||||
syncCustomProvider(next.customProvider);
|
syncCustomProvider(next.customProvider);
|
||||||
return Response.json({ ok: true, models: next.customProvider?.models ?? [] });
|
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 record = readSettingsRecord() ?? { version: 1 };
|
||||||
const next = writeSettingsRecord({
|
const next = writeSettingsRecord({
|
||||||
...record,
|
...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);
|
syncCustomProvider(next.customProvider);
|
||||||
return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} });
|
return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} });
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ interface CustomStatus {
|
|||||||
models: CustomModel[];
|
models: CustomModel[];
|
||||||
/** User pins by model id (context / max output / thinking). */
|
/** User pins by model id (context / max output / thinking). */
|
||||||
overrides: Record<string, CustomModelOverride>;
|
overrides: Record<string, CustomModelOverride>;
|
||||||
|
manualModels: string[];
|
||||||
keyPresent: boolean;
|
keyPresent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +414,7 @@ export function KeysSection({
|
|||||||
baseUrl: cp.baseUrl ?? "",
|
baseUrl: cp.baseUrl ?? "",
|
||||||
models: cp.models ?? [],
|
models: cp.models ?? [],
|
||||||
overrides: cp.overrides ?? {},
|
overrides: cp.overrides ?? {},
|
||||||
|
manualModels: cp.manualModels ?? [],
|
||||||
keyPresent: Boolean(cp.keyPresent),
|
keyPresent: Boolean(cp.keyPresent),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -513,6 +515,7 @@ export function KeysSection({
|
|||||||
keyPresent={custom?.keyPresent ?? false}
|
keyPresent={custom?.keyPresent ?? false}
|
||||||
models={custom?.models ?? []}
|
models={custom?.models ?? []}
|
||||||
overrides={custom?.overrides ?? {}}
|
overrides={custom?.overrides ?? {}}
|
||||||
|
manualModels0={custom?.manualModels ?? []}
|
||||||
onSaved={async () => {
|
onSaved={async () => {
|
||||||
setAddingCustom(false);
|
setAddingCustom(false);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -728,6 +731,7 @@ function CustomEndpointEditor({
|
|||||||
keyPresent,
|
keyPresent,
|
||||||
models,
|
models,
|
||||||
overrides,
|
overrides,
|
||||||
|
manualModels0,
|
||||||
onSaved,
|
onSaved,
|
||||||
onPinsSaved,
|
onPinsSaved,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -739,6 +743,7 @@ function CustomEndpointEditor({
|
|||||||
models: readonly CustomModel[];
|
models: readonly CustomModel[];
|
||||||
/** Their stored user pins. */
|
/** Their stored user pins. */
|
||||||
overrides: Record<string, CustomModelOverride>;
|
overrides: Record<string, CustomModelOverride>;
|
||||||
|
manualModels0: readonly string[];
|
||||||
onSaved: () => void | Promise<void>;
|
onSaved: () => void | Promise<void>;
|
||||||
/** Pins saved — the editor stays open (a form, not a connect). */
|
/** Pins saved — the editor stays open (a form, not a connect). */
|
||||||
onPinsSaved: () => void | Promise<void>;
|
onPinsSaved: () => void | Promise<void>;
|
||||||
@@ -748,6 +753,7 @@ function CustomEndpointEditor({
|
|||||||
const [baseUrl, setBaseUrl] = useState(baseUrl0);
|
const [baseUrl, setBaseUrl] = useState(baseUrl0);
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
const [keyTouched, setKeyTouched] = useState(false);
|
const [keyTouched, setKeyTouched] = useState(false);
|
||||||
|
const [manualModels, setManualModels] = useState(manualModels0.join(", "));
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// The limits form: edited drafts over the stored pins, plus the last saved
|
// 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", {
|
const res = await fetch("/api/custom-provider", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ baseUrl, ...(keyTouched ? { key } : {}) }),
|
body: JSON.stringify({ baseUrl, manualModels, ...(keyTouched ? { key } : {}) }),
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
if (!res || !res.ok) {
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manualModels}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export interface EffectiveSettings {
|
|||||||
compaction: Required<CompactionSettings>;
|
compaction: Required<CompactionSettings>;
|
||||||
/** Custom endpoint, resolved concrete: baseUrl defaults "", models and
|
/** Custom endpoint, resolved concrete: baseUrl defaults "", models and
|
||||||
* user pins default to empty. */
|
* user pins default to empty. */
|
||||||
customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record<string, CustomModelOverride> };
|
customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record<string, CustomModelOverride>; manualModels: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own
|
/** 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 ?? "",
|
baseUrl: record?.customProvider?.baseUrl ?? "",
|
||||||
models: record?.customProvider?.models ?? [],
|
models: record?.customProvider?.models ?? [],
|
||||||
overrides: record?.customProvider?.overrides ?? {},
|
overrides: record?.customProvider?.overrides ?? {},
|
||||||
|
manualModels: record?.customProvider?.manualModels ?? [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,6 +259,13 @@ export interface CustomProviderSettings {
|
|||||||
/** User pins keyed by model id — pure user intent, never rewritten by
|
/** User pins keyed by model id — pure user intent, never rewritten by
|
||||||
* discovery, pruned to the endpoint's live ids on connect. */
|
* discovery, pruned to the endpoint's live ids on connect. */
|
||||||
overrides?: Record<string, CustomModelOverride>;
|
overrides?: Record<string, CustomModelOverride>;
|
||||||
|
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),
|
/** Canonical base-URL form (single source): trims, requires absolute http(s),
|
||||||
@@ -644,6 +651,8 @@ function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSet
|
|||||||
}
|
}
|
||||||
const overrides = sanitizeCustomModelOverrides(raw.overrides);
|
const overrides = sanitizeCustomModelOverrides(raw.overrides);
|
||||||
if (Object.keys(overrides).length) out.overrides = overrides;
|
if (Object.keys(overrides).length) out.overrides = overrides;
|
||||||
|
const manualModels = parseModelIds(raw.manualModels);
|
||||||
|
if (manualModels.length) out.manualModels = manualModels;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
/** Model rows from a list response ({data:[…]} | {models:[…]} | bare array),
|
||||||
* or null if unrecognized. */
|
* or null if unrecognized. */
|
||||||
function modelRows(body: unknown): unknown[] | null {
|
function modelRows(body: unknown): unknown[] | null {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
CUSTOM_MODEL_PIN_FLOOR,
|
CUSTOM_MODEL_PIN_FLOOR,
|
||||||
customProviderUsable,
|
customProviderUsable,
|
||||||
normalizeBaseUrl,
|
normalizeBaseUrl,
|
||||||
|
parseModelIds,
|
||||||
sanitizeCustomModelOverrides,
|
sanitizeCustomModelOverrides,
|
||||||
sanitizeUserSettings,
|
sanitizeUserSettings,
|
||||||
} from "../src/core/settings.ts";
|
} from "../src/core/settings.ts";
|
||||||
@@ -24,6 +25,7 @@ import { CUSTOM_PROVIDER } from "../src/core/auth-keys.ts";
|
|||||||
import {
|
import {
|
||||||
buildOpenAiCompatibleProvider,
|
buildOpenAiCompatibleProvider,
|
||||||
discoverModels,
|
discoverModels,
|
||||||
|
mergeManualModels,
|
||||||
syncCustomProvider,
|
syncCustomProvider,
|
||||||
} from "../src/server/custom-provider.ts";
|
} from "../src/server/custom-provider.ts";
|
||||||
import { piModels } from "../src/agent/models.ts";
|
import { piModels } from "../src/agent/models.ts";
|
||||||
@@ -160,7 +162,7 @@ describe("parseCustomModelOverridesBody — the strict write side", () => {
|
|||||||
// ─── resolution ──────────────────────────────────────────────────────────
|
// ─── resolution ──────────────────────────────────────────────────────────
|
||||||
describe("resolveSettings — customProvider", () => {
|
describe("resolveSettings — customProvider", () => {
|
||||||
it("resolves an absent section to concrete defaults", () => {
|
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", () => {
|
it("carries stored pins through to the concrete view", () => {
|
||||||
const resolved = resolveSettings({
|
const resolved = resolveSettings({
|
||||||
@@ -169,6 +171,41 @@ describe("resolveSettings — customProvider", () => {
|
|||||||
});
|
});
|
||||||
assert.deepEqual(resolved.customProvider.overrides, { a: { context: 32768 } });
|
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 ──────────────────────────────────────────────
|
// ─── provider build + live sync ──────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user