fix: never lose custom endpoint models or pins on a failed probe

A connect that fell back to typed model ids replaced the stored model list
with the typed ids alone, then pruned the user pins against it. A transient
/models failure (503, timeout) therefore deleted every discovered model, its
detected metadata, and its pins, and answered 200.

- DiscoverResult carries the endpoint's HTTP status. A 401/403 fails the
  connect even when ids are typed: a rejected key is not a missing model list.
- A failed probe keeps the stored snapshot for the SAME base URL, so the
  prune cannot delete pins for a probe that failed. A new base URL starts
  clean, so an older endpoint's models never follow the URL.
- Comment corrections: the route contract and the CustomProviderSettings
  fields described the behaviour from before the manual-ids change.
- tests/custom-provider-route.test.ts covers the four branches: transient
  failure, rejected key, new endpoint with no model list, working probe.

tsc and eslint clean. Suite: 2554 tests, 2 failures, both also failing on
6959482 (render-sidebar date drift, subscription-auth install artifact).
This commit is contained in:
d-buckner
2026-10-28 13:56:53 -07:00
parent b4a13f5c3b
commit ef37c26433
4 changed files with 175 additions and 15 deletions
+7 -2
View File
@@ -254,11 +254,16 @@ export interface CustomModelOverride {
export interface CustomProviderSettings {
/** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */
baseUrl?: string;
/** Models discovered from the endpoint (refreshed on connect). */
/** Models this endpoint offers: advertised ids merged with the typed
* manual ones (rewritten on connect; a failed probe keeps the same
* endpoint's last snapshot). */
models?: CustomModel[];
/** User pins keyed by model id — pure user intent, never rewritten by
* discovery, pruned to the endpoint's live ids on connect. */
* discovery, pruned on connect to the ids that connect offers. */
overrides?: Record<string, CustomModelOverride>;
/** Model ids the user typed by hand, for an endpoint that publishes no
* usable /models list. Kept apart from the snapshot so a reconnect can
* re-merge them after discovery refreshes. */
manualModels?: string[];
}
+10 -3
View File
@@ -101,8 +101,12 @@ export function syncCustomProvider(cp: CustomProviderSettings | undefined): void
);
}
/** Outcome of a model-discovery probe. */
export type DiscoverResult = { ok: true; models: CustomModel[] } | { ok: false; error: string };
/** Outcome of a model-discovery probe. `status` is the endpoint's HTTP status
* when it answered at all, so callers can tell a rejected key from a missing
* model list. */
export type DiscoverResult =
| { ok: true; models: CustomModel[] }
| { ok: false; error: string; status?: number };
/** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https
* only); keyless endpoints answer without a bearer. Non-2xx or unparseable
@@ -118,7 +122,7 @@ export async function discoverModels(baseUrl: string, apiKey?: string): Promise<
headers: key ? { authorization: `Bearer ${key}` } : {},
signal: controller.signal,
});
if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}` };
if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}`, status: res.status };
const body = (await res.json().catch(() => null)) as unknown;
const list = extractModels(body);
if (list === null) return { ok: false, error: "Endpoint response was not a model list" };
@@ -131,6 +135,9 @@ export async function discoverModels(baseUrl: string, apiKey?: string): Promise<
}
}
/** The connect's model list: what the endpoint advertises, plus every typed id
* it does not advertise (at default limits, so the pins can correct them).
* Discovered metadata always wins over a typed id of the same name. */
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 });