Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 31 additions & 0 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,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 +418,7 @@ export function buildCatalogEntriesFromObservedState({
gptSlugs,
goModels,
featured,
modelPickerOrder,
wsEnabled,
multiAgentMode,
exactComboSlugs,
Expand All @@ -433,6 +436,28 @@ 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;
// Band base sits just past the featured band so listed rows sort right after featured ones.
const pickerOrderBase = (featured?.length ?? 0) * priorityStride + 1;
let nonFeaturedFallbackSeq = 0;
/**
* Priority for a non-featured routed row when modelPickerOrder is active. Listed slugs sort in
* declared order right after featured; unlisted rows keep their mutual order but sit after every
* listed one. Returns undefined when the feature is off, so the caller's original assignment
* (default 5 / account 1_000+) is preserved 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 pickerOrderBase + hit * priorityStride;
return pickerOrderBase + (pickerOrder.length + nonFeaturedFallbackSeq++) * priorityStride;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const out: RawEntry[] = [];
const nativeEntries: RawEntry[] = [];
const collisionSkipped = resolveSlugAliasCollisions([...goModels]);
Expand Down Expand Up @@ -538,6 +563,10 @@ 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}`);
if (rankHit !== undefined) e.priority = rankHit * priorityStride;
else if (pickerOrderActive) {
// #1649: modelPickerOrder assigns the full non-featured order deterministically.
e.priority = pickerOrderPriority(slug, `${m.provider}/${m.id}`)!;
}
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 +1353,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 +1385,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
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,17 @@ 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). Entries listed here are ordered first (after any
* `subagentModels`-featured rows, which keep their existing top priority), in array
* order; every catalog row not listed keeps its normal relative order but sorts after
* the listed ones. When unset or empty, catalog priority is unchanged. This only
* affects picker display order; it does not widen the spawn_agent candidate set.
*/
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
83 changes: 83 additions & 0 deletions tests/codex-catalog-model-picker-order.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test";
import { buildCatalogEntriesFromObservedState } 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 in declared order; unlisted rows sort after them", () => {
const p = build({
modelPickerOrder: [
"tyler/deepseek-v4-pro",
"jd-chat/kimi-k3",
"jd-chat/glm-5.2",
],
});
// Declared order is honored.
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"]);
// The unlisted row sorts after every listed one.
expect(p["jd-chat/glm-5.2"]).toBeLessThan(p["jd-claude/sonnet-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"]);
});
});
Loading