Skip to content

Commit bed4037

Browse files
committed
feat(provider): log why a live model catalog kept its fallback
A connection that drops or times out mid-body now counts as unavailable rather than malformed, so routine network trouble stays at debug while an empty or malformed catalog warns.
1 parent 00a44cb commit bed4037

2 files changed

Lines changed: 178 additions & 11 deletions

File tree

‎src/provider/bounded-model-catalog.ts‎

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import { getLogger } from "@intx/log";
2+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
13
import {
24
endpointErrorMessage,
35
type ModelsEndpointDiscoveryState,
46
} from "./models-endpoint.js";
57

8+
const catalogLogger = getLogger([LOG_NAMESPACE_ROOT, "provider", "catalog"]);
9+
610
export type CatalogDiscoveryState = ModelsEndpointDiscoveryState;
711

812
function declaredCatalogBytes(response: Response): number | undefined {
@@ -18,20 +22,24 @@ async function readBoundedCatalogText(
1822
args: { readonly maxBytes: number; readonly oversizeMessage: string },
1923
): Promise<
2024
| { readonly ok: true; readonly text: string }
21-
| { readonly ok: false; readonly message: string }
25+
| {
26+
readonly ok: false;
27+
readonly message: string;
28+
readonly transport: boolean;
29+
}
2230
> {
2331
const declared = declaredCatalogBytes(response);
2432
if (declared !== undefined && declared > args.maxBytes) {
2533
await response.body?.cancel().catch(() => undefined);
26-
return { ok: false, message: args.oversizeMessage };
34+
return { ok: false, message: args.oversizeMessage, transport: false };
2735
}
2836

2937
try {
3038
const body = response.body;
3139
if (body === null) {
3240
const text = await response.text();
3341
if (new TextEncoder().encode(text).byteLength > args.maxBytes) {
34-
return { ok: false, message: args.oversizeMessage };
42+
return { ok: false, message: args.oversizeMessage, transport: false };
3543
}
3644
return { ok: true, text };
3745
}
@@ -46,7 +54,7 @@ async function readBoundedCatalogText(
4654
total += value.byteLength;
4755
if (total > args.maxBytes) {
4856
await reader.cancel().catch(() => undefined);
49-
return { ok: false, message: args.oversizeMessage };
57+
return { ok: false, message: args.oversizeMessage, transport: false };
5058
}
5159
chunks.push(value);
5260
}
@@ -59,7 +67,11 @@ async function readBoundedCatalogText(
5967
}
6068
return { ok: true, text: new TextDecoder().decode(buffer) };
6169
} catch (error) {
62-
return { ok: false, message: endpointErrorMessage(error) };
70+
return {
71+
ok: false,
72+
message: endpointErrorMessage(error),
73+
transport: true,
74+
};
6375
}
6476
}
6577

@@ -104,11 +116,13 @@ export function createBoundedModelCatalog<
104116
return `${catalogLabel} catalog exceeds ${String(maxModels)} models`;
105117
}
106118

107-
async function readCatalogJson(
108-
response: Response,
109-
): Promise<
119+
async function readCatalogJson(response: Response): Promise<
110120
| { readonly ok: true; readonly value: unknown }
111-
| { readonly ok: false; readonly message: string }
121+
| {
122+
readonly ok: false;
123+
readonly message: string;
124+
readonly transport: boolean;
125+
}
112126
> {
113127
const text = await readBoundedCatalogText(response, {
114128
maxBytes,
@@ -119,7 +133,11 @@ export function createBoundedModelCatalog<
119133
const value: unknown = JSON.parse(text.text);
120134
return { ok: true, value };
121135
} catch (error) {
122-
return { ok: false, message: endpointErrorMessage(error) };
136+
return {
137+
ok: false,
138+
message: endpointErrorMessage(error),
139+
transport: false,
140+
};
123141
}
124142
}
125143

@@ -151,7 +169,12 @@ export function createBoundedModelCatalog<
151169

152170
const body = await readCatalogJson(response);
153171
if (!body.ok) {
154-
return { status: "malformed", message: body.message };
172+
// A connection that drops or times out mid-body never delivered an
173+
// answer; only a body that arrived and fails the contract is malformed.
174+
return {
175+
status: body.transport ? "unavailable" : "malformed",
176+
message: body.message,
177+
};
155178
}
156179
return parse(body.value, {
157180
maxModels,
@@ -169,10 +192,48 @@ export function createBoundedModelCatalog<
169192
// stale-but-live beats empty; a cold failure falls back to the seed.
170193
if (state.status === "models") {
171194
snapshot = state.models;
195+
} else {
196+
logFallback(state);
172197
}
173198
return selectableModelIds();
174199
}
175200

201+
// An unreachable catalog is routine (offline, transient outage), so it
202+
// stays at debug. An empty or malformed answer means the endpoint's
203+
// contract moved, which only shows up here, so it warns.
204+
function logFallback(
205+
state: Exclude<CatalogDiscoveryState, { status: "models" }>,
206+
): void {
207+
const fallback =
208+
snapshot === undefined ? "the built-in list" : "the last live list";
209+
// HTTP and size failures already name the catalog; transport and parse
210+
// errors do not, so the catalog is named exactly once either way.
211+
const reason = (message: string): string =>
212+
message.startsWith(catalogLabel)
213+
? message
214+
: `${catalogLabel}: ${message}`;
215+
switch (state.status) {
216+
case "unavailable":
217+
catalogLogger.debug(
218+
"Model catalog unavailable ({reason}); keeping {fallback}",
219+
{ reason: reason(state.message), fallback },
220+
);
221+
return;
222+
case "empty":
223+
catalogLogger.warn(
224+
"Model catalog listed no models ({catalog}); keeping {fallback}",
225+
{ catalog: catalogLabel, fallback },
226+
);
227+
return;
228+
case "malformed":
229+
catalogLogger.warn(
230+
"Model catalog response rejected ({reason}); keeping {fallback}",
231+
{ reason: reason(state.message), fallback },
232+
);
233+
return;
234+
}
235+
}
236+
176237
function prefetchModels(): Promise<Seed | DiscoveredModels> {
177238
if (inflight !== undefined) return inflight;
178239

‎src/provider/model-catalogs.test.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { configureSync, getConfig, resetSync } from "@intx/log";
3+
4+
import { defined } from "../../testkit/defined.js";
25

36
import { OPENCODE_GO_MODEL_IDS } from "../../packages/opencode-go/src/index.js";
47
import { ZEN_MODEL_IDS } from "../../packages/zen/src/index.js";
@@ -92,6 +95,35 @@ afterEach(() => {
9295
resetZenModelDiscoveryForTests();
9396
});
9497

98+
// Records the catalog's own log lines; restores the previous log config even
99+
// when `run` throws so other files keep their sinks.
100+
async function captureCatalogLogs(
101+
catalogLabel: string,
102+
run: () => Promise<void>,
103+
): Promise<{ level: string; text: string }[]> {
104+
const records: { level: string; text: string }[] = [];
105+
const previous = getConfig();
106+
configureSync({
107+
reset: true,
108+
sinks: {
109+
test: (record) => {
110+
records.push({
111+
level: record.level,
112+
text: record.message.map(String).join(""),
113+
});
114+
},
115+
},
116+
loggers: [{ category: [], lowestLevel: "debug", sinks: ["test"] }],
117+
});
118+
try {
119+
await run();
120+
} finally {
121+
if (previous === null) resetSync();
122+
else configureSync({ ...previous, reset: true });
123+
}
124+
return records.filter((r) => r.text.includes(catalogLabel));
125+
}
126+
95127
function oversizedCatalogResponse(byteLength: number): Response {
96128
const chunk = new Uint8Array(64 * 1024).fill(0x61);
97129
let remaining = byteLength;
@@ -324,6 +356,80 @@ for (const harness of harnesses) {
324356
expect(selectable()).toEqual(seedIds);
325357
});
326358

359+
test("logs why a prefetch kept the fallback, warning only when the catalog answered badly", async () => {
360+
const records = await captureCatalogLogs(catalogLabel, async () => {
361+
globalThis.fetch = (async () =>
362+
new Response("no", { status: 503 })) as unknown as typeof fetch;
363+
await prefetch();
364+
globalThis.fetch = (async () =>
365+
Response.json({ data: [] })) as unknown as typeof fetch;
366+
await prefetch();
367+
globalThis.fetch = (async () =>
368+
Response.json({ models: [] })) as unknown as typeof fetch;
369+
await prefetch();
370+
});
371+
372+
expect(records.map((r) => r.level)).toEqual([
373+
"debug",
374+
"warning",
375+
"warning",
376+
]);
377+
for (const record of records) {
378+
expect(record.text).toContain("keeping the built-in list");
379+
expect(record.text.split(catalogLabel)).toHaveLength(2);
380+
}
381+
});
382+
383+
test("a connection that drops mid-body logs as unavailable, not as a contract change", async () => {
384+
const records = await captureCatalogLogs(catalogLabel, async () => {
385+
globalThis.fetch = (async () => {
386+
let sent = false;
387+
const stream = new ReadableStream<Uint8Array>({
388+
pull(controller) {
389+
if (!sent) {
390+
sent = true;
391+
controller.enqueue(new TextEncoder().encode('{"data":['));
392+
return;
393+
}
394+
controller.error(new Error("socket connection was closed"));
395+
},
396+
});
397+
return new Response(stream, { status: 200 });
398+
}) as unknown as typeof fetch;
399+
await prefetch();
400+
});
401+
402+
expect(records.map((r) => r.level)).toEqual(["debug"]);
403+
expect(defined(records[0]).text).toContain("unavailable");
404+
});
405+
406+
test("a failed refresh after a live list says it kept the last live list", async () => {
407+
globalThis.fetch = (async () =>
408+
Response.json({
409+
data: [{ id: liveOnlyId }],
410+
})) as unknown as typeof fetch;
411+
await prefetch();
412+
413+
const records = await captureCatalogLogs(catalogLabel, async () => {
414+
globalThis.fetch = (async () =>
415+
Response.json({ data: [] })) as unknown as typeof fetch;
416+
await prefetch();
417+
});
418+
419+
expect(records).toHaveLength(1);
420+
expect(defined(records[0]).text).toContain("keeping the last live list");
421+
});
422+
423+
test("concurrent callers of one prefetch run produce one fallback record", async () => {
424+
const records = await captureCatalogLogs(catalogLabel, async () => {
425+
globalThis.fetch = (async () =>
426+
Response.json({ data: [] })) as unknown as typeof fetch;
427+
await Promise.all([prefetch(), prefetch(), prefetch()]);
428+
});
429+
430+
expect(records).toHaveLength(1);
431+
});
432+
327433
test("oversized live catalog does not replace the seed with a truncated prefix", async () => {
328434
globalThis.fetch = (async () =>
329435
oversizedCatalogResponse(maxBytes + 1)) as unknown as typeof fetch;

0 commit comments

Comments
 (0)