Skip to content

Commit f09be4c

Browse files
LZY3538claude
andcommitted
fix(plugin-sdk): return incomplete pagination when malformed next URL has no cursor fallback
When a provider catalog response advertises a next page via the `next` or `links.next` field but the URL is malformed or cross-origin, the previous fix fell through to bodyAdvertisesMoreLiveModelCatalogPages which could silently return `complete` when has_more was absent or false. This truncated catalogs that use only link-based pagination. The fix returns `incomplete` when a provider-advertised next URL is unusable and no cursor-based fallback exists, surfacing a controlled error instead of silently dropping pages. Co-Authored-By: Claude <[email protected]>
1 parent fd07ad4 commit f09be4c

2 files changed

Lines changed: 66 additions & 7 deletions

File tree

src/plugin-sdk/provider-catalog-live-runtime.test.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -700,15 +700,13 @@ describe("provider-catalog-live-runtime", () => {
700700
expect(fetchGuardMock).toHaveBeenCalledTimes(2);
701701
});
702702

703-
it("gracefully ignores a malformed absolute next URL instead of crashing", async () => {
703+
it("reports incomplete pagination on malformed absolute next URL with no usable fallback", async () => {
704704
const release = vi.fn(async () => undefined);
705705
const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi.fn(async () => ({
706706
response: new Response(
707707
JSON.stringify({
708708
data: [{ id: "model-a", object: "model" }],
709-
// Space in hostname makes this a genuinely invalid absolute URL that
710-
// throws TypeError from `new URL()`. Relative URLs like "?page=2" never
711-
// throw and are handled by the existing pagination path.
709+
// Space in hostname makes this a genuinely invalid absolute URL.
712710
next: "http://exa mple.com/models?page=2",
713711
has_more: false,
714712
}),
@@ -717,18 +715,24 @@ describe("provider-catalog-live-runtime", () => {
717715
release,
718716
}));
719717

718+
// The provider explicitly advertised a next page via the `next` field but
719+
// the URL is malformed and there is no cursor fallback. The controlled
720+
// incomplete-pagination error prevents silently returning a truncated
721+
// catalog.
720722
await expect(
721723
fetchLiveProviderModelIds({
722724
providerId: "provider",
723725
endpoint: "https://provider.example.test/v1/models",
724726
fetchGuard: fetchGuardMock,
725727
}),
726-
).resolves.toEqual(["model-a"]);
728+
).rejects.toThrow(
729+
"provider model discovery did not include a supported next page before the catalog completed",
730+
);
727731

728732
expect(fetchGuardMock).toHaveBeenCalledTimes(1);
729733
});
730734

731-
it("gracefully ignores a malformed nested links.next URL", async () => {
735+
it("reports incomplete pagination on malformed nested links.next URL", async () => {
732736
const release = vi.fn(async () => undefined);
733737
const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi.fn(async () => ({
734738
response: new Response(
@@ -748,11 +752,52 @@ describe("provider-catalog-live-runtime", () => {
748752
endpoint: "https://provider.example.test/v1/models",
749753
fetchGuard: fetchGuardMock,
750754
}),
751-
).resolves.toEqual(["model-a"]);
755+
).rejects.toThrow(
756+
"provider model discovery did not include a supported next page before the catalog completed",
757+
);
752758

753759
expect(fetchGuardMock).toHaveBeenCalledTimes(1);
754760
});
755761

762+
it("recovers malformed next URL via cursor fallback", async () => {
763+
const release = vi.fn(async () => undefined);
764+
const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi
765+
.fn()
766+
.mockResolvedValueOnce({
767+
response: new Response(
768+
JSON.stringify({
769+
data: [{ id: "model-a", object: "model" }],
770+
next: "http://exa mple.com/models?page=2",
771+
next_cursor: "cursor-2",
772+
has_more: true,
773+
}),
774+
),
775+
finalUrl: "https://provider.example.test/v1/models",
776+
release,
777+
})
778+
.mockResolvedValueOnce({
779+
response: new Response(
780+
JSON.stringify({ data: [{ id: "model-b", object: "model" }], has_more: false }),
781+
),
782+
finalUrl: "https://provider.example.test/v1/models?after=cursor-2",
783+
release,
784+
});
785+
786+
// The malformed next URL is ignored; cursor-based pagination takes over.
787+
await expect(
788+
fetchLiveProviderModelIds({
789+
providerId: "provider",
790+
endpoint: "https://provider.example.test/v1/models",
791+
fetchGuard: fetchGuardMock,
792+
}),
793+
).resolves.toEqual(["model-a", "model-b"]);
794+
795+
expect(fetchGuardMock).toHaveBeenCalledTimes(2);
796+
expect(fetchGuardMock.mock.calls[1]?.[0].url).toBe(
797+
"https://provider.example.test/v1/models?after=cursor-2",
798+
);
799+
});
800+
756801
it("sets safe replay headers when final URL is unparseable", async () => {
757802
const release = vi.fn(async () => undefined);
758803
const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi.fn(async () => ({

src/plugin-sdk/provider-catalog-live-runtime.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,20 @@ function resolveLiveModelCatalogNextPage(
231231
if (nextUrl && currentParsed && nextUrl.origin === currentParsed.origin) {
232232
return { status: "next", url: nextUrl.toString() };
233233
}
234+
// The provider advertised a next URL but it is malformed or cross-origin.
235+
// Attempt cursor-based pagination as a fallback before giving up.
236+
const cursor = readLiveModelCatalogCursor(body);
237+
if (cursor) {
238+
const cursorUrl = tryParseUrl(currentUrl);
239+
if (cursorUrl) {
240+
cursorUrl.searchParams.set(cursor.name, cursor.value);
241+
return { status: "next", url: cursorUrl.toString() };
242+
}
243+
}
244+
// No usable fallback: the provider explicitly advertised a next page we
245+
// cannot follow. Return incomplete so the caller surfaces a controlled
246+
// error instead of silently returning a truncated catalog.
247+
return { status: "incomplete" };
234248
}
235249
const cursor = readLiveModelCatalogCursor(body);
236250
if (cursor) {

0 commit comments

Comments
 (0)