Skip to content

Commit d5d6f42

Browse files
authored
Merge pull request #1581 from corbitsdev/cl-11727-prepare-bounded-catalogs-for-the-live-chatgpt-catalog
refactor(provider): prepare live model catalogs for per-account lists
2 parents f8976b6 + bed4037 commit d5d6f42

14 files changed

Lines changed: 574 additions & 181 deletions

‎src/config/codex-providers.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const CODEX_PROVIDER_PREFIX = "codex/";
1313
const projection = createOAuthProviderProjection<CodexProfile>({
1414
prefix: CODEX_PROVIDER_PREFIX,
1515
baseURL: CODEX_BASE_URL,
16-
defaultModels: CODEX_DEFAULT_MODELS,
16+
models: () => CODEX_DEFAULT_MODELS,
1717
catalogExtras: (profile) => ({
1818
codexProfile: profile.name,
1919
...(profile.tokens.accountId !== undefined

‎src/config/meta-providers.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const META_PROVIDER_PREFIX = "meta/";
77
const projection = createOAuthProviderProjection<MetaProfile>({
88
prefix: META_PROVIDER_PREFIX,
99
baseURL: META_BASE_URL,
10-
defaultModels: META_DEFAULT_MODELS,
10+
models: () => META_DEFAULT_MODELS,
1111
catalogExtras: (profile) => ({ metaProfile: profile.name }),
1212
});
1313

‎src/config/oauth-providers.test.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { describe, expect, test } from "bun:test";
33
import type { CodexProfile } from "../auth/codex/store.js";
44
import type { XaiProfile } from "../auth/xai/store.js";
55
import { codexProfilesToCatalogEntries } from "./codex-providers.js";
6+
import { createOAuthProviderProjection } from "./oauth-providers.js";
67
import { xaiProfilesToCatalogEntries } from "./xai-providers.js";
78

89
// Characterization tests pinning the projection behavior both provider
910
// wrappers must preserve. Name round-trips and settings projection are
1011
// characterized in codex-providers.test.ts and xai-providers.test.ts — only
11-
// cross-provider marker isolation lives here.
12+
// cross-provider marker isolation and the per-profile models contract live
13+
// here.
1214

1315
const codexProfile: CodexProfile = {
1416
name: "work",
@@ -50,3 +52,45 @@ describe("catalog projection", () => {
5052
expect(entries[0]?.codexAccountId).toBeUndefined();
5153
});
5254
});
55+
56+
describe("per-profile models", () => {
57+
const profiles = [
58+
{ name: "a", tokens: { access: "ta" } },
59+
{ name: "b", tokens: { access: "tb" } },
60+
];
61+
const perProfile: Record<string, readonly [string, ...string[]]> = {
62+
a: ["m-a1", "m-a2"],
63+
b: ["m-b1"],
64+
};
65+
const projection = createOAuthProviderProjection({
66+
prefix: "p/",
67+
baseURL: "https://example.invalid",
68+
models: (profile) => {
69+
const list = perProfile[profile.name];
70+
if (list === undefined) throw new Error(`no models for ${profile.name}`);
71+
return list;
72+
},
73+
catalogExtras: () => ({}),
74+
});
75+
76+
test("the settings projection lists each profile's models with the first as default", () => {
77+
const settings = projection.providersAsSettings(profiles);
78+
expect(settings["p/a"]?.models).toEqual(["m-a1", "m-a2"]);
79+
expect(settings["p/a"]?.defaultModel).toBe("m-a1");
80+
expect(settings["p/b"]?.models).toEqual(["m-b1"]);
81+
expect(settings["p/b"]?.defaultModel).toBe("m-b1");
82+
});
83+
84+
test("the catalog projection lists each profile's models with the first as default", () => {
85+
const entries = projection.profilesToCatalogEntries(profiles);
86+
expect(entries.map((e) => [e.name, e.models, e.defaultModel])).toEqual([
87+
["p/a", ["m-a1", "m-a2"], "m-a1"],
88+
["p/b", ["m-b1"], "m-b1"],
89+
]);
90+
});
91+
92+
test("projected lists are copies the caller can mutate without touching the source", () => {
93+
projection.providersAsSettings(profiles)["p/a"]?.models.push("mutated");
94+
expect(perProfile["a"]).toEqual(["m-a1", "m-a2"]);
95+
});
96+
});

‎src/config/oauth-providers.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ export interface OAuthProviderProjectionOptions<P extends OAuthProfileLike> {
2626
// Provider namespace including the trailing slash (e.g. "codex/").
2727
prefix: string;
2828
baseURL: string;
29-
// Non-empty: the first entry doubles as the default model.
30-
defaultModels: readonly [string, ...string[]];
29+
// Models a profile can select. Non-empty: the first entry doubles as the
30+
// default model.
31+
models: (profile: P) => readonly [string, ...string[]];
3132
// Provider-specific catalog fields (profile marker, account id, …) merged
3233
// into each catalog entry so the runtime can route it to the right adapter.
3334
catalogExtras: (profile: P) => Partial<ProviderCatalogEntry>;
@@ -60,12 +61,13 @@ export function createOAuthProviderProjection<P extends OAuthProfileLike>(
6061
): Record<string, ProviderSettings> {
6162
const entries: Record<string, ProviderSettings> = {};
6263
for (const profile of profiles) {
64+
const models = options.models(profile);
6365
entries[providerName(profile.name)] = {
6466
name: providerName(profile.name),
6567
baseURL: options.baseURL,
6668
apiKey: profile.tokens.access,
67-
models: [...options.defaultModels],
68-
defaultModel: options.defaultModels[0],
69+
models: [...models],
70+
defaultModel: models[0],
6971
};
7072
}
7173
return entries;
@@ -74,14 +76,17 @@ export function createOAuthProviderProjection<P extends OAuthProfileLike>(
7476
// these carry the provider's marker fields (via catalogExtras) so the
7577
// runtime can route them to the matching adapter and headers.
7678
profilesToCatalogEntries(profiles: readonly P[]): ProviderCatalogEntry[] {
77-
return profiles.map((profile) => ({
78-
name: providerName(profile.name),
79-
baseURL: options.baseURL,
80-
apiKey: profile.tokens.access,
81-
models: [...options.defaultModels],
82-
defaultModel: options.defaultModels[0],
83-
...options.catalogExtras(profile),
84-
}));
79+
return profiles.map((profile) => {
80+
const models = options.models(profile);
81+
return {
82+
name: providerName(profile.name),
83+
baseURL: options.baseURL,
84+
apiKey: profile.tokens.access,
85+
models: [...models],
86+
defaultModel: models[0],
87+
...options.catalogExtras(profile),
88+
};
89+
});
8590
},
8691
};
8792
}

‎src/config/xai-providers.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const XAI_PROVIDER_PREFIX = "xai/";
77
const projection = createOAuthProviderProjection<XaiProfile>({
88
prefix: XAI_PROVIDER_PREFIX,
99
baseURL: XAI_BASE_URL,
10-
defaultModels: XAI_DEFAULT_MODELS,
10+
models: () => XAI_DEFAULT_MODELS,
1111
catalogExtras: (profile) => ({ xaiProfile: profile.name }),
1212
});
1313

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

Lines changed: 105 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { getLogger } from "@intx/log";
2+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
13
import {
24
endpointErrorMessage,
3-
parseModelsData,
45
type ModelsEndpointDiscoveryState,
5-
requestModelsEndpoint,
66
} from "./models-endpoint.js";
77

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

1012
function declaredCatalogBytes(response: Response): number | undefined {
@@ -20,20 +22,24 @@ async function readBoundedCatalogText(
2022
args: { readonly maxBytes: number; readonly oversizeMessage: string },
2123
): Promise<
2224
| { readonly ok: true; readonly text: string }
23-
| { readonly ok: false; readonly message: string }
25+
| {
26+
readonly ok: false;
27+
readonly message: string;
28+
readonly transport: boolean;
29+
}
2430
> {
2531
const declared = declaredCatalogBytes(response);
2632
if (declared !== undefined && declared > args.maxBytes) {
2733
await response.body?.cancel().catch(() => undefined);
28-
return { ok: false, message: args.oversizeMessage };
34+
return { ok: false, message: args.oversizeMessage, transport: false };
2935
}
3036

3137
try {
3238
const body = response.body;
3339
if (body === null) {
3440
const text = await response.text();
3541
if (new TextEncoder().encode(text).byteLength > args.maxBytes) {
36-
return { ok: false, message: args.oversizeMessage };
42+
return { ok: false, message: args.oversizeMessage, transport: false };
3743
}
3844
return { ok: true, text };
3945
}
@@ -48,7 +54,7 @@ async function readBoundedCatalogText(
4854
total += value.byteLength;
4955
if (total > args.maxBytes) {
5056
await reader.cancel().catch(() => undefined);
51-
return { ok: false, message: args.oversizeMessage };
57+
return { ok: false, message: args.oversizeMessage, transport: false };
5258
}
5359
chunks.push(value);
5460
}
@@ -61,29 +67,47 @@ async function readBoundedCatalogText(
6167
}
6268
return { ok: true, text: new TextDecoder().decode(buffer) };
6369
} catch (error) {
64-
return { ok: false, message: endpointErrorMessage(error) };
70+
return {
71+
ok: false,
72+
message: endpointErrorMessage(error),
73+
transport: true,
74+
};
6575
}
6676
}
6777

68-
export function createBoundedModelCatalog(args: {
69-
baseURL: string;
70-
seedIds: readonly string[];
78+
type DiscoveredModels = Extract<
79+
CatalogDiscoveryState,
80+
{ status: "models" }
81+
>["models"];
82+
83+
export function createBoundedModelCatalog<
84+
Seed extends readonly string[],
85+
>(args: {
86+
request: (opts: {
87+
timeoutMs?: number;
88+
signal?: AbortSignal;
89+
}) => Promise<Response>;
90+
parse: (
91+
raw: unknown,
92+
cap: { readonly maxModels: number; readonly oversizeMessage: string },
93+
) => CatalogDiscoveryState;
94+
seedIds: Seed;
7195
catalogLabel: string;
7296
maxBytes: number;
7397
maxModels: number;
7498
}): {
75-
discoverModels: (args?: {
99+
discoverModels: (opts?: {
76100
timeoutMs?: number;
77101
signal?: AbortSignal;
78102
}) => Promise<CatalogDiscoveryState>;
79-
selectableModelIds: () => readonly string[];
80-
prefetchModels: () => Promise<readonly string[]>;
103+
selectableModelIds: () => Seed | DiscoveredModels;
104+
prefetchModels: () => Promise<Seed | DiscoveredModels>;
81105
resetDiscoveryForTests: () => void;
82106
} {
83-
const { baseURL, seedIds, catalogLabel, maxBytes, maxModels } = args;
107+
const { request, parse, seedIds, catalogLabel, maxBytes, maxModels } = args;
84108

85-
let inflight: Promise<readonly string[]> | undefined;
86-
let snapshot: readonly string[] | undefined;
109+
let inflight: Promise<Seed | DiscoveredModels> | undefined;
110+
let snapshot: DiscoveredModels | undefined;
87111

88112
function oversizeMessage(kind: "bytes" | "models"): string {
89113
if (kind === "bytes") {
@@ -92,11 +116,13 @@ export function createBoundedModelCatalog(args: {
92116
return `${catalogLabel} catalog exceeds ${String(maxModels)} models`;
93117
}
94118

95-
async function readCatalogJson(
96-
response: Response,
97-
): Promise<
119+
async function readCatalogJson(response: Response): Promise<
98120
| { readonly ok: true; readonly value: unknown }
99-
| { readonly ok: false; readonly message: string }
121+
| {
122+
readonly ok: false;
123+
readonly message: string;
124+
readonly transport: boolean;
125+
}
100126
> {
101127
const text = await readBoundedCatalogText(response, {
102128
maxBytes,
@@ -107,20 +133,25 @@ export function createBoundedModelCatalog(args: {
107133
const value: unknown = JSON.parse(text.text);
108134
return { ok: true, value };
109135
} catch (error) {
110-
return { ok: false, message: endpointErrorMessage(error) };
136+
return {
137+
ok: false,
138+
message: endpointErrorMessage(error),
139+
transport: false,
140+
};
111141
}
112142
}
113143

114-
async function discoverModels(args?: {
144+
async function discoverModels(opts?: {
115145
timeoutMs?: number;
116146
signal?: AbortSignal;
117147
}): Promise<CatalogDiscoveryState> {
118148
let response: Response;
119149
try {
120-
response = await requestModelsEndpoint({
121-
baseURL,
122-
...(args?.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}),
123-
...(args?.signal !== undefined ? { signal: args.signal } : {}),
150+
// Forward only the transport knobs so a caller's extra keys can never
151+
// reach the request and override its URL or headers.
152+
response = await request({
153+
...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
154+
...(opts?.signal !== undefined ? { signal: opts.signal } : {}),
124155
});
125156
} catch (error) {
126157
return {
@@ -138,29 +169,72 @@ export function createBoundedModelCatalog(args: {
138169

139170
const body = await readCatalogJson(response);
140171
if (!body.ok) {
141-
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+
};
142178
}
143-
return parseModelsData(body.value, {
179+
return parse(body.value, {
144180
maxModels,
145181
oversizeMessage: oversizeMessage("models"),
146182
});
147183
}
148184

149-
function selectableModelIds(): readonly string[] {
185+
function selectableModelIds(): Seed | DiscoveredModels {
150186
return snapshot ?? seedIds;
151187
}
152188

153-
async function runPrefetch(): Promise<readonly string[]> {
189+
async function runPrefetch(): Promise<Seed | DiscoveredModels> {
154190
const state = await discoverModels();
155191
// Empty/unavailable/malformed leave the successful snapshot in place:
156192
// stale-but-live beats empty; a cold failure falls back to the seed.
157193
if (state.status === "models") {
158194
snapshot = state.models;
195+
} else {
196+
logFallback(state);
159197
}
160198
return selectableModelIds();
161199
}
162200

163-
function prefetchModels(): Promise<readonly string[]> {
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+
237+
function prefetchModels(): Promise<Seed | DiscoveredModels> {
164238
if (inflight !== undefined) return inflight;
165239

166240
const pending = runPrefetch();

0 commit comments

Comments
 (0)