Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
45 changes: 45 additions & 0 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, truste

export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;

// Priority tier for config.modelPickerOrder rows (#1649). Sits in the same high neighborhood
// account-selector rows already use (1_000+), i.e. well above the featured band (0..N-1) and the
// default routed/native tiers, so reordering here only affects the picker tail and never the
// spawn_agent candidate window (the first MAX_SPAWN_AGENT_MODEL_OVERRIDES rows by priority).
export const PICKER_ORDER_PRIORITY_BASE = 1_000;

export type SpawnAgentSurface = "v1" | "v2";

export type SubagentRosterExclusionReason =
Expand Down Expand Up @@ -362,6 +368,8 @@ export interface ObservedCatalogEntryBuildInput {
readonly gptSlugs: readonly string[];
readonly goModels: readonly CatalogModel[];
readonly featured?: readonly string[];
/** Optional full picker ordering (config.modelPickerOrder); orders non-featured rows. */
readonly modelPickerOrder?: readonly string[];
readonly wsEnabled: boolean;
readonly multiAgentMode: MultiAgentMode;
readonly exactComboSlugs: ReadonlySet<string>;
Expand Down Expand Up @@ -416,6 +424,7 @@ export function buildCatalogEntriesFromObservedState({
gptSlugs,
goModels,
featured,
modelPickerOrder,
wsEnabled,
multiAgentMode,
exactComboSlugs,
Expand All @@ -433,6 +442,34 @@ export function buildCatalogEntriesFromObservedState({
// it sorts to the front. This works for native gpt slugs AND routed slugs alike.
const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const));
const priorityStride = Math.max(accountSelectors.length, 1);
// Optional full picker order (#1649). Independent of the 5-slot spawn_agent cap: it only
// assigns a deterministic priority BAND to non-featured rows so a >5 catalog stays put across
// rebuilds. Featured rows keep their existing 0..N-1 band; when modelPickerOrder is unset the
// helper is a no-op and every priority below is byte-identical to before.
const pickerOrder = (modelPickerOrder ?? []).filter(id => typeof id === "string" && id.length > 0);
const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const));
const pickerOrderActive = pickerOrder.length > 0;
// The picker-order band reuses the existing high priority tier (>= PICKER_ORDER_PRIORITY_BASE,
// same 1_000+ neighborhood that account rows already occupy). Featured rows keep the 0..N-1
// front band, and any default routed/native rows keep their low priorities, so the
// spawn_agent candidate window (the first MAX_SPAWN_AGENT_MODEL_OVERRIDES rows by ascending
// priority; see effectiveSubagentRoster) is populated by featured + default rows first and is
// NOT reordered by modelPickerOrder. modelPickerOrder only reorders rows within this high band,
// i.e. the picker tail below the candidate window.
/**
* Priority for a non-featured routed row that is explicitly LISTED in modelPickerOrder. Listed
* slugs sort in declared order within the high picker-order tier (>= PICKER_ORDER_PRIORITY_BASE),
* which sits above the featured band and the default routed tier, so it reorders the picker tail
* without entering the spawn_agent candidate window. Returns undefined when the feature is off or
* the row is not listed, so those rows keep their original assignment (default 5 / account
* 1_000+) untouched.
*/
const pickerOrderPriority = (slug: string, altSlug?: string): number | undefined => {
if (!pickerOrderActive) return undefined;
const hit = pickerOrderRank.get(slug) ?? (altSlug !== undefined ? pickerOrderRank.get(altSlug) : undefined);
if (hit === undefined) return undefined;
return PICKER_ORDER_PRIORITY_BASE + hit * priorityStride;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const out: RawEntry[] = [];
const nativeEntries: RawEntry[] = [];
const collisionSkipped = resolveSlugAliasCollisions([...goModels]);
Expand Down Expand Up @@ -537,7 +574,13 @@ export function buildCatalogEntriesFromObservedState({
}
// Featured picks may be stored raw (legacy) or encoded — honor both.
const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`);
const pickerPriority = rankHit === undefined ? pickerOrderPriority(slug, `${m.provider}/${m.id}`) : undefined;
if (rankHit !== undefined) e.priority = rankHit * priorityStride;
else if (pickerPriority !== undefined) {
// #1649: rows explicitly listed in modelPickerOrder get the high picker-order tier so they
// reorder the picker tail without displacing default-tier spawn_agent candidates.
e.priority = pickerPriority;
}
Comment thread
tone-leh marked this conversation as resolved.
Outdated
else if (accountSelectors.length > 0) {
// Keep the generated account rows together in Codex's priority-sorted flat picker.
e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5);
Expand Down Expand Up @@ -1324,6 +1367,7 @@ function writeRetainedCatalogSync({
const enabledGo = filterCatalogVisibleModels(goModels, config);
const featured = config.subagentModels ?? [];
const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
const modelPickerOrder = config.modelPickerOrder ?? [];
const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
const exactComboSlugs = exactComboCatalogSlugs(config);
const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config);
Expand Down Expand Up @@ -1355,6 +1399,7 @@ function writeRetainedCatalogSync({
gptSlugs: [],
goModels: orderedGoModels,
featured,
modelPickerOrder,
wsEnabled,
multiAgentMode,
exactComboSlugs,
Expand Down
2 changes: 2 additions & 0 deletions src/codex/convergence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ function prepareCatalog(
const enabled = filterCatalogVisibleModels(routedModels, config);
const featured = config.subagentModels ?? [];
const ordered = orderForSubagents(enabled, featured);
const modelPickerOrder = config.modelPickerOrder ?? [];
const multiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2"
? config.multiAgentMode : "default";
const exactComboSlugs = exactComboCatalogSlugs(config);
Expand Down Expand Up @@ -255,6 +256,7 @@ function prepareCatalog(
gptSlugs: [],
goModels: ordered,
featured,
modelPickerOrder,
wsEnabled: websocketsEnabled(config),
multiAgentMode,
exactComboSlugs,
Expand Down
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,20 @@ export interface OcxConfig {
* into a selector-qualified group; Codex still advertises only the first 5 visible rows.
*/
subagentModels?: string[];
/**
* Optional full picker ordering for the Codex model catalog, independent of the
* 5-slot `subagentModels` spawn_agent cap. Values are catalog ids in the same forms
* accepted by `subagentModels` (bare native ids, account-qualified, or routed
* `<provider>/<model>` slugs). Listed routed rows are ordered among themselves in array
* order, in a reserved high priority tier that sits below the spawn_agent candidate
* window; rows not listed keep their normal priority and are not reordered. Intended for
* pinning a full multi-provider catalog: list every routed model you want ordered.
* `subagentModels`-featured rows keep their existing top priority and win regardless.
* When unset or empty, catalog priority is unchanged. This affects picker display order
* only; it does not widen the spawn_agent candidate set or change which rows are
* spawn_agent candidates.
*/
modelPickerOrder?: string[];
/**
* Priority-ordered fallback models for spawned sub-agents. When the requested
* model is quota-exhausted or recently failed, opencodex rewrites the child
Expand Down
128 changes: 128 additions & 0 deletions tests/codex-catalog-model-picker-order.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, test } from "bun:test";
import {
buildCatalogEntriesFromObservedState,
effectiveSubagentRoster,
MAX_SPAWN_AGENT_MODEL_OVERRIDES,
} from "../src/codex/catalog/sync";
import type { CatalogModel } from "../src/types";

// #1649: config.modelPickerOrder assigns a deterministic priority band to non-featured routed
// rows so a catalog with more than 5 routed models keeps a stable picker order across rebuilds,
// independent of the 5-slot subagentModels spawn_agent cap.

function template(): Record<string, unknown> {
return {
slug: "gpt-5.5",
display_name: "gpt-5.5",
description: "Native GPT model",
priority: 1,
visibility: "list",
tool_mode: "code",
};
}

const goModels = [
{ id: "glm-5.2", provider: "jd-chat", owned_by: "jd" },
{ id: "kimi-k3", provider: "jd-chat", owned_by: "jd" },
{ id: "deepseek-v4-pro", provider: "tyler", owned_by: "tyler" },
{ id: "sonnet-5", provider: "jd-claude", owned_by: "jd" },
] as unknown as CatalogModel[];

function build(overrides: { featured?: string[]; modelPickerOrder?: string[] }) {
const entries = buildCatalogEntriesFromObservedState({
template: template() as never,
gptSlugs: [],
goModels,
featured: overrides.featured,
modelPickerOrder: overrides.modelPickerOrder,
wsEnabled: false,
multiAgentMode: "default",
exactComboSlugs: new Set(),
accountSelectors: [],
suppressedBareNativeSlugs: new Set(),
disabledNativeAccountSlugs: new Set(),
multiAgentV2Enabled: false,
});
return Object.fromEntries(entries.map(e => {
const r = e as Record<string, unknown>;
return [r.slug as string, r.priority as number];
})) as Record<string, number>;
}

describe("modelPickerOrder (#1649)", () => {
test("unset leaves every non-featured routed row at the flat default priority", () => {
const p = build({});
expect(p["jd-chat/glm-5.2"]).toBe(5);
expect(p["jd-chat/kimi-k3"]).toBe(5);
expect(p["tyler/deepseek-v4-pro"]).toBe(5);
expect(p["jd-claude/sonnet-5"]).toBe(5);
});

test("listed rows sort among themselves in declared order, in the high picker tier", () => {
const p = build({
modelPickerOrder: [
"tyler/deepseek-v4-pro",
"jd-chat/kimi-k3",
"jd-chat/glm-5.2",
],
});
// Declared order is honored among the listed rows.
expect(p["tyler/deepseek-v4-pro"]).toBeLessThan(p["jd-chat/kimi-k3"]);
expect(p["jd-chat/kimi-k3"]).toBeLessThan(p["jd-chat/glm-5.2"]);
// Listed rows occupy the high picker tier (>= 1000); an unlisted, non-featured row keeps its
// default priority (5) and therefore is NOT reordered by modelPickerOrder.
expect(p["tyler/deepseek-v4-pro"]).toBeGreaterThanOrEqual(1000);
expect(p["jd-claude/sonnet-5"]).toBe(5);
});

test("featured rows keep their top priority ahead of the picker-order band", () => {
const p = build({
featured: ["jd-claude/sonnet-5"],
modelPickerOrder: ["tyler/deepseek-v4-pro", "jd-chat/kimi-k3"],
});
// Featured wins outright (priority 0).
expect(p["jd-claude/sonnet-5"]).toBe(0);
// Picker-order rows come after the featured band.
expect(p["tyler/deepseek-v4-pro"]).toBeGreaterThan(p["jd-claude/sonnet-5"]);
expect(p["tyler/deepseek-v4-pro"]).toBeLessThan(p["jd-chat/kimi-k3"]);
});

// Regression for the review on #1666: modelPickerOrder must not change spawn_agent candidate
// eligibility. spawn_agent takes the first MAX_SPAWN_AGENT_MODEL_OVERRIDES picker rows by
// ascending priority. The picker-order band lives in the high (>= 1_000) tier, so featured
// rows (0..N-1) and any default-tier routed rows (priority 5) fill the candidate window first;
// a row that is ONLY placed by modelPickerOrder does not displace a default-tier candidate.
test("picker-order-only rows do not displace default-tier spawn_agent candidates", () => {
const manyRouted = [
// Not in modelPickerOrder -> stay at default priority 5 -> fill the candidate window.
{ id: "unlisted-a", provider: "jd-chat", owned_by: "jd" },
{ id: "unlisted-b", provider: "jd-chat", owned_by: "jd" },
{ id: "unlisted-c", provider: "jd-chat", owned_by: "jd" },
{ id: "unlisted-d", provider: "jd-chat", owned_by: "jd" },
{ id: "unlisted-e", provider: "jd-chat", owned_by: "jd" },
// Placed only by modelPickerOrder -> high tier -> must stay out of the candidate window.
{ id: "deepseek-v4-pro", provider: "tyler", owned_by: "tyler" },
{ id: "kimi-k3", provider: "jd-chat", owned_by: "jd" },
] as unknown as CatalogModel[];
const order = ["tyler/deepseek-v4-pro", "jd-chat/kimi-k3"];
const entries = buildCatalogEntriesFromObservedState({
template: template() as never,
gptSlugs: [],
goModels: manyRouted,
featured: [],
modelPickerOrder: order,
wsEnabled: false,
multiAgentMode: "default",
exactComboSlugs: new Set(),
accountSelectors: [],
suppressedBareNativeSlugs: new Set(),
disabledNativeAccountSlugs: new Set(),
multiAgentV2Enabled: false,
});
const candidateSlugs = effectiveSubagentRoster([], "default", entries).candidates.map(c => c.model);
expect(candidateSlugs.length).toBe(MAX_SPAWN_AGENT_MODEL_OVERRIDES);
// The picker-order-only rows are pushed to the high tier and never enter the window.
expect(candidateSlugs).not.toContain("tyler/deepseek-v4-pro");
expect(candidateSlugs).not.toContain("jd-chat/kimi-k3");
});
});
Loading