Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/config/codex-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const CODEX_PROVIDER_PREFIX = "codex/";
const projection = createOAuthProviderProjection<CodexProfile>({
prefix: CODEX_PROVIDER_PREFIX,
baseURL: CODEX_BASE_URL,
defaultModels: CODEX_DEFAULT_MODELS,
models: () => CODEX_DEFAULT_MODELS,
catalogExtras: (profile) => ({
codexProfile: profile.name,
...(profile.tokens.accountId !== undefined
Expand Down
2 changes: 1 addition & 1 deletion src/config/meta-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const META_PROVIDER_PREFIX = "meta/";
const projection = createOAuthProviderProjection<MetaProfile>({
prefix: META_PROVIDER_PREFIX,
baseURL: META_BASE_URL,
defaultModels: META_DEFAULT_MODELS,
models: () => META_DEFAULT_MODELS,
catalogExtras: (profile) => ({ metaProfile: profile.name }),
});

Expand Down
46 changes: 45 additions & 1 deletion src/config/oauth-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { describe, expect, test } from "bun:test";
import type { CodexProfile } from "../auth/codex/store.js";
import type { XaiProfile } from "../auth/xai/store.js";
import { codexProfilesToCatalogEntries } from "./codex-providers.js";
import { createOAuthProviderProjection } from "./oauth-providers.js";
import { xaiProfilesToCatalogEntries } from "./xai-providers.js";

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

const codexProfile: CodexProfile = {
name: "work",
Expand Down Expand Up @@ -50,3 +52,45 @@ describe("catalog projection", () => {
expect(entries[0]?.codexAccountId).toBeUndefined();
});
});

describe("per-profile models", () => {
const profiles = [
{ name: "a", tokens: { access: "ta" } },
{ name: "b", tokens: { access: "tb" } },
];
const perProfile: Record<string, readonly [string, ...string[]]> = {
a: ["m-a1", "m-a2"],
b: ["m-b1"],
};
const projection = createOAuthProviderProjection({
prefix: "p/",
baseURL: "https://example.invalid",
models: (profile) => {
const list = perProfile[profile.name];
if (list === undefined) throw new Error(`no models for ${profile.name}`);
return list;
},
catalogExtras: () => ({}),
});

test("the settings projection lists each profile's models with the first as default", () => {
const settings = projection.providersAsSettings(profiles);
expect(settings["p/a"]?.models).toEqual(["m-a1", "m-a2"]);
expect(settings["p/a"]?.defaultModel).toBe("m-a1");
expect(settings["p/b"]?.models).toEqual(["m-b1"]);
expect(settings["p/b"]?.defaultModel).toBe("m-b1");
});

test("the catalog projection lists each profile's models with the first as default", () => {
const entries = projection.profilesToCatalogEntries(profiles);
expect(entries.map((e) => [e.name, e.models, e.defaultModel])).toEqual([
["p/a", ["m-a1", "m-a2"], "m-a1"],
["p/b", ["m-b1"], "m-b1"],
]);
});

test("projected lists are copies the caller can mutate without touching the source", () => {
projection.providersAsSettings(profiles)["p/a"]?.models.push("mutated");
expect(perProfile["a"]).toEqual(["m-a1", "m-a2"]);
});
});
29 changes: 17 additions & 12 deletions src/config/oauth-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ export interface OAuthProviderProjectionOptions<P extends OAuthProfileLike> {
// Provider namespace including the trailing slash (e.g. "codex/").
prefix: string;
baseURL: string;
// Non-empty: the first entry doubles as the default model.
defaultModels: readonly [string, ...string[]];
// Models a profile can select. Non-empty: the first entry doubles as the
// default model.
models: (profile: P) => readonly [string, ...string[]];
// Provider-specific catalog fields (profile marker, account id, …) merged
// into each catalog entry so the runtime can route it to the right adapter.
catalogExtras: (profile: P) => Partial<ProviderCatalogEntry>;
Expand Down Expand Up @@ -60,12 +61,13 @@ export function createOAuthProviderProjection<P extends OAuthProfileLike>(
): Record<string, ProviderSettings> {
const entries: Record<string, ProviderSettings> = {};
for (const profile of profiles) {
const models = options.models(profile);
entries[providerName(profile.name)] = {
name: providerName(profile.name),
baseURL: options.baseURL,
apiKey: profile.tokens.access,
models: [...options.defaultModels],
defaultModel: options.defaultModels[0],
models: [...models],
defaultModel: models[0],
};
}
return entries;
Expand All @@ -74,14 +76,17 @@ export function createOAuthProviderProjection<P extends OAuthProfileLike>(
// these carry the provider's marker fields (via catalogExtras) so the
// runtime can route them to the matching adapter and headers.
profilesToCatalogEntries(profiles: readonly P[]): ProviderCatalogEntry[] {
return profiles.map((profile) => ({
name: providerName(profile.name),
baseURL: options.baseURL,
apiKey: profile.tokens.access,
models: [...options.defaultModels],
defaultModel: options.defaultModels[0],
...options.catalogExtras(profile),
}));
return profiles.map((profile) => {
const models = options.models(profile);
return {
name: providerName(profile.name),
baseURL: options.baseURL,
apiKey: profile.tokens.access,
models: [...models],
defaultModel: models[0],
...options.catalogExtras(profile),
};
});
},
};
}
2 changes: 1 addition & 1 deletion src/config/xai-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const XAI_PROVIDER_PREFIX = "xai/";
const projection = createOAuthProviderProjection<XaiProfile>({
prefix: XAI_PROVIDER_PREFIX,
baseURL: XAI_BASE_URL,
defaultModels: XAI_DEFAULT_MODELS,
models: () => XAI_DEFAULT_MODELS,
catalogExtras: (profile) => ({ xaiProfile: profile.name }),
});

Expand Down
136 changes: 105 additions & 31 deletions src/provider/bounded-model-catalog.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { getLogger } from "@intx/log";
import { LOG_NAMESPACE_ROOT } from "../branding.js";
import {
endpointErrorMessage,
parseModelsData,
type ModelsEndpointDiscoveryState,
requestModelsEndpoint,
} from "./models-endpoint.js";

const catalogLogger = getLogger([LOG_NAMESPACE_ROOT, "provider", "catalog"]);

export type CatalogDiscoveryState = ModelsEndpointDiscoveryState;

function declaredCatalogBytes(response: Response): number | undefined {
Expand All @@ -20,20 +22,24 @@ async function readBoundedCatalogText(
args: { readonly maxBytes: number; readonly oversizeMessage: string },
): Promise<
| { readonly ok: true; readonly text: string }
| { readonly ok: false; readonly message: string }
| {
readonly ok: false;
readonly message: string;
readonly transport: boolean;
}
> {
const declared = declaredCatalogBytes(response);
if (declared !== undefined && declared > args.maxBytes) {
await response.body?.cancel().catch(() => undefined);
return { ok: false, message: args.oversizeMessage };
return { ok: false, message: args.oversizeMessage, transport: false };
}

try {
const body = response.body;
if (body === null) {
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > args.maxBytes) {
return { ok: false, message: args.oversizeMessage };
return { ok: false, message: args.oversizeMessage, transport: false };
}
return { ok: true, text };
}
Expand All @@ -48,7 +54,7 @@ async function readBoundedCatalogText(
total += value.byteLength;
if (total > args.maxBytes) {
await reader.cancel().catch(() => undefined);
return { ok: false, message: args.oversizeMessage };
return { ok: false, message: args.oversizeMessage, transport: false };
}
chunks.push(value);
}
Expand All @@ -61,29 +67,47 @@ async function readBoundedCatalogText(
}
return { ok: true, text: new TextDecoder().decode(buffer) };
} catch (error) {
return { ok: false, message: endpointErrorMessage(error) };
return {
ok: false,
message: endpointErrorMessage(error),
transport: true,
};
}
}

export function createBoundedModelCatalog(args: {
baseURL: string;
seedIds: readonly string[];
type DiscoveredModels = Extract<
CatalogDiscoveryState,
{ status: "models" }
>["models"];

export function createBoundedModelCatalog<
Seed extends readonly string[],
>(args: {
request: (opts: {
timeoutMs?: number;
signal?: AbortSignal;
}) => Promise<Response>;
parse: (
raw: unknown,
cap: { readonly maxModels: number; readonly oversizeMessage: string },
) => CatalogDiscoveryState;
seedIds: Seed;
catalogLabel: string;
maxBytes: number;
maxModels: number;
}): {
discoverModels: (args?: {
discoverModels: (opts?: {
timeoutMs?: number;
signal?: AbortSignal;
}) => Promise<CatalogDiscoveryState>;
selectableModelIds: () => readonly string[];
prefetchModels: () => Promise<readonly string[]>;
selectableModelIds: () => Seed | DiscoveredModels;
prefetchModels: () => Promise<Seed | DiscoveredModels>;
resetDiscoveryForTests: () => void;
} {
const { baseURL, seedIds, catalogLabel, maxBytes, maxModels } = args;
const { request, parse, seedIds, catalogLabel, maxBytes, maxModels } = args;

let inflight: Promise<readonly string[]> | undefined;
let snapshot: readonly string[] | undefined;
let inflight: Promise<Seed | DiscoveredModels> | undefined;
let snapshot: DiscoveredModels | undefined;

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

async function readCatalogJson(
response: Response,
): Promise<
async function readCatalogJson(response: Response): Promise<
| { readonly ok: true; readonly value: unknown }
| { readonly ok: false; readonly message: string }
| {
readonly ok: false;
readonly message: string;
readonly transport: boolean;
}
> {
const text = await readBoundedCatalogText(response, {
maxBytes,
Expand All @@ -107,20 +133,25 @@ export function createBoundedModelCatalog(args: {
const value: unknown = JSON.parse(text.text);
return { ok: true, value };
} catch (error) {
return { ok: false, message: endpointErrorMessage(error) };
return {
ok: false,
message: endpointErrorMessage(error),
transport: false,
};
}
}

async function discoverModels(args?: {
async function discoverModels(opts?: {
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<CatalogDiscoveryState> {
let response: Response;
try {
response = await requestModelsEndpoint({
baseURL,
...(args?.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}),
...(args?.signal !== undefined ? { signal: args.signal } : {}),
// Forward only the transport knobs so a caller's extra keys can never
// reach the request and override its URL or headers.
response = await request({
...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
...(opts?.signal !== undefined ? { signal: opts.signal } : {}),
});
} catch (error) {
return {
Expand All @@ -138,29 +169,72 @@ export function createBoundedModelCatalog(args: {

const body = await readCatalogJson(response);
if (!body.ok) {
return { status: "malformed", message: body.message };
// A connection that drops or times out mid-body never delivered an
// answer; only a body that arrived and fails the contract is malformed.
return {
status: body.transport ? "unavailable" : "malformed",
message: body.message,
};
}
return parseModelsData(body.value, {
return parse(body.value, {
maxModels,
oversizeMessage: oversizeMessage("models"),
});
}

function selectableModelIds(): readonly string[] {
function selectableModelIds(): Seed | DiscoveredModels {
return snapshot ?? seedIds;
}

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

function prefetchModels(): Promise<readonly string[]> {
// An unreachable catalog is routine (offline, transient outage), so it
// stays at debug. An empty or malformed answer means the endpoint's
// contract moved, which only shows up here, so it warns.
function logFallback(
state: Exclude<CatalogDiscoveryState, { status: "models" }>,
): void {
const fallback =
snapshot === undefined ? "the built-in list" : "the last live list";
// HTTP and size failures already name the catalog; transport and parse
// errors do not, so the catalog is named exactly once either way.
const reason = (message: string): string =>
message.startsWith(catalogLabel)
? message
: `${catalogLabel}: ${message}`;
switch (state.status) {
case "unavailable":
catalogLogger.debug(
"Model catalog unavailable ({reason}); keeping {fallback}",
{ reason: reason(state.message), fallback },
);
return;
case "empty":
catalogLogger.warn(
"Model catalog listed no models ({catalog}); keeping {fallback}",
{ catalog: catalogLabel, fallback },
);
return;
case "malformed":
catalogLogger.warn(
"Model catalog response rejected ({reason}); keeping {fallback}",
{ reason: reason(state.message), fallback },
);
return;
}
}

function prefetchModels(): Promise<Seed | DiscoveredModels> {
if (inflight !== undefined) return inflight;

const pending = runPrefetch();
Expand Down
Loading
Loading