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
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ selector 名はユーザーが選ぶ公開 label であり、opencodex はアカ
`codexAccountNamespaces` のキーは長さ 1〜64 文字、先頭と末尾は ASCII
英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。
値は有効な pool account id(内部 `__main__` は不可)、または Codex Desktop アカウントを示す
`"@main"` です。provider と予約済み `openai` / `combo` との衝突は大文字小文字を区別せず検査され、
namespace 付き combo alias はその namespace prefix に selector を再利用できません。設定済み pool id
`"@main"` です。provider と予約済み `openai` / `combo` / `policy` との衝突は大文字小文字を区別せず検査され、
namespace 付き combo または routing-profile alias はその namespace prefix に selector を再利用できません。設定済み pool id
や他の selector target も selector と再利用できません。raw account id と email は
非公開のままにし、selector を公開名として使ってください。明示的な選択の動作と優先順位は
[ルーティング構成](/reference/configuration/routing/)を参照してください。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ selector 이름은 사용자가 정하는 공개 label이며, opencodex는 여
`codexAccountNamespaces` 키는 길이가 1~64자이고 시작과 끝은 ASCII 영숫자여야
하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다.
값은 유효한 pool account id(내부 `__main__` 제외)이거나 Codex Desktop 계정을 나타내는 `"@main"`입니다.
provider 및 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는
combo alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector
provider 및 예약된 `openai` / `combo` / `policy` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는
combo 또는 routing-profile alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector
target도 selector로 재사용할 수 없습니다. raw account id와 email은 비공개로
유지하고 selector를 공개 이름으로 사용하세요. 명시적 선택 동작과 우선순위는
[라우팅 설정](/ko/reference/configuration/routing/)을 참고하십시오.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ Selector names are user-chosen public labels; opencodex assigns no account-role
`codexAccountNamespaces` keys are 1–64 characters, starting and ending with an
ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside. Reserved JavaScript object
names are rejected. Each value is a valid pool-account id (never internal `__main__`) or `"@main"`
for the Codex Desktop account. Provider and reserved `openai` / `combo` collisions are checked
case-insensitively; a namespaced combo alias cannot reuse a selector as its namespace prefix, and
configured pool ids or selector targets also cannot reuse a selector. Keep raw account ids and
emails private; the selector is the public name. See [Routing Configuration](/reference/configuration/routing/)
for the Codex Desktop account. Provider and reserved `openai` / `combo` / `policy` collisions are
checked case-insensitively; a namespaced combo or routing-profile alias cannot reuse a selector as
its namespace prefix, and configured pool ids or selector targets also cannot reuse a selector. Keep
raw account ids and emails private; the selector is the public name. See [Routing Configuration](/reference/configuration/routing/)
for exact-selection behavior and precedence.

## Reserved OpenAI providers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ description: Записи провайдеров, аутентификация,
аккаунтов. Ключи `codexAccountNamespaces` имеют длину 1–64 символа. Они должны начинаться и
заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные
имена объектов JavaScript запрещены. Значение — допустимый id аккаунта пула (кроме внутреннего `__main__`)
либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo`
проверяются без учёта регистра; namespace-префикс namespaced combo alias не может повторять селектор.
либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` / `policy`
проверяются без учёта регистра; namespace-префикс namespaced combo или routing-profile alias не может повторять селектор.
Настроенные id пула и цели других селекторов также нельзя повторно использовать как селектор. Сохраняйте
raw id аккаунтов и email приватными, а селектор используйте как публичное имя. Поведение и приоритет
явного выбора описаны в разделе [Конфигурация маршрутизации](/reference/configuration/routing/).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ selector 名称是用户自定的公开 label;opencodex 不会为其赋予账
`codexAccountNamespaces` 的 key 长度为 1–64 个字符,首尾必须是 ASCII 字母或数字,
中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的
pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex Desktop 账号。与 provider 及
保留的 `openai` / `combo` 冲突时不区分大小写;带 namespace 的 combo alias 不能把 selector 复用为
保留的 `openai` / `combo` / `policy` 冲突时不区分大小写;带 namespace 的 combo 或 routing-profile alias 不能把 selector 复用为
其 namespace prefix,已配置的 pool id 和其他 selector target 也不能复用为 selector。raw account id
与 email 应保持私密,selector 才是公开名称。明确选择的行为和优先级见
[路由配置](/zh-cn/reference/configuration/routing/)。
Expand Down
41 changes: 38 additions & 3 deletions src/codex/account-namespaces.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { CodexAccount, OcxConfig } from "../types";
import { COMBO_NAMESPACE } from "../combos/types";
import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
import {
POLICY_NAMESPACE,
routingProfileAliasNamespacePrefixes,
} from "../routing/profile-namespace";
import {
CODEX_ACCOUNT_LOG_LABEL_RE,
createCodexAccountLogLabel,
Expand All @@ -21,6 +25,7 @@ const RESERVED_NAMESPACE_KEYS = new Set([
"constructor",
COMBO_NAMESPACE,
OPENAI_CODEX_PROVIDER_ID,
POLICY_NAMESPACE,
].map(codexProviderNamespaceKey));
const PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS = 16;

Expand Down Expand Up @@ -68,17 +73,21 @@ function claimNamespace(requested: string, used: Set<string>): string {
return namespace;
}

function occupiedNamespaces(config: Pick<OcxConfig, "combos" | "providers">): Set<string> {
/** Collect every public namespace that a generated account selector must not claim. */
function occupiedNamespaces(
config: Pick<OcxConfig, "combos" | "providers" | "routingProfiles">,
): Set<string> {
return new Set([
...Object.keys(config.providers).map(codexProviderNamespaceKey),
...comboAliasNamespaces(config),
...routingProfileAliasNamespacePrefixes(config),
...RESERVED_NAMESPACE_KEYS,
]);
}

/** Build an initial account-selector map without deriving public selectors from aliases or ids. */
export function defaultCodexAccountNamespaces(
config: Pick<OcxConfig, "codexAccounts" | "combos" | "providers">,
config: Pick<OcxConfig, "codexAccounts" | "combos" | "providers" | "routingProfiles">,
): Record<string, string> {
const namespaces: Record<string, string> = {};
const used = occupiedNamespaces(config);
Expand All @@ -99,13 +108,39 @@ export function defaultCodexAccountNamespaces(
return namespaces;
}

/**
* Initialize generated selectors only for an explicit opt-in with no existing bindings.
* A true result means the map was replaced and the caller must persist the updated config.
*/
export function initializeDefaultCodexAccountNamespaces(
config: Pick<
OcxConfig,
| "codexAccountPickerEnabled"
| "codexAccountNamespaces"
| "codexAccounts"
| "combos"
| "providers"
| "routingProfiles"
>,
): boolean {
if (config.codexAccountPickerEnabled !== true
|| Object.keys(config.codexAccountNamespaces ?? {}).length > 0) return false;

const namespaces = defaultCodexAccountNamespaces(config);
config.codexAccountNamespaces = namespaces;
return true;
}

/**
* Add one account to a generated map without renaming or replacing explicit existing entries.
* The account-creation layer must reject a new id that already equals an existing selector key.
* A true result means the map was mutated in place; callers must persist the updated config.
*/
export function appendDefaultCodexAccountNamespace(
config: Pick<OcxConfig, "codexAccountNamespaces" | "codexAccounts" | "combos" | "providers">,
config: Pick<
OcxConfig,
"codexAccountNamespaces" | "codexAccounts" | "combos" | "providers" | "routingProfiles"
>,
account: Pick<CodexAccount, "id" | "isMain" | "logLabel">,
): boolean {
const namespaces = config.codexAccountNamespaces;
Expand Down
4 changes: 3 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { isCodexAccountPriorityKey } from "./codex/account-priority";
import { parseAccountPriority } from "./codex/pool-rotation";
import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
import { routingProfileIssues } from "./routing/profile";
import { POLICY_NAMESPACE } from "./routing/profile-namespace";
import {
forgetEphemeralSecretPath,
hardenSecretDir,
Expand Down Expand Up @@ -1115,6 +1116,7 @@ const configSchema = z.object({
const configuredProviderNamespaces = new Set([
COMBO_NAMESPACE,
OPENAI_CODEX_PROVIDER_ID,
POLICY_NAMESPACE,
...Object.keys(config.providers),
].map(codexProviderNamespaceKey));
const namespaceTargets = new Set(
Expand All @@ -1126,7 +1128,7 @@ const configSchema = z.object({
ctx.addIssue({
code: "custom",
path: ["codexAccountNamespaces", namespace],
message: "account selectors must not collide with configured provider or combo namespaces",
message: "account selectors must not collide with configured provider, combo, or routing policy namespaces",
});
}
if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) {
Expand Down
15 changes: 15 additions & 0 deletions src/routing/profile-namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { OcxRoutingProfileConfig } from "../types";

/** Canonical public namespace reserved for routing-policy model ids. */
export const POLICY_NAMESPACE = "policy";

/** Public namespace prefixes claimed by slash-qualified routing-profile aliases. */
export function routingProfileAliasNamespacePrefixes(
config: { routingProfiles?: Record<string, OcxRoutingProfileConfig> },
): string[] {
return Object.values(config.routingProfiles ?? {}).flatMap((profile) => {
const alias = typeof profile?.alias === "string" ? profile.alias.trim() : "";
const slash = alias.indexOf("/");
return slash > 0 ? [alias.slice(0, slash)] : [];
});
}
3 changes: 2 additions & 1 deletion src/routing/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import type {
import { codexAccountNamespaceEntries } from "../codex/account-namespaces";
import { listComboIds, resolveComboId } from "../combos";
import { hasOwnProvider } from "../config";
import { POLICY_NAMESPACE } from "./profile-namespace";

export const POLICY_NAMESPACE = "policy";
export { POLICY_NAMESPACE };

export const POLICY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
export const POLICY_ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,63})?$/;
Expand Down
8 changes: 6 additions & 2 deletions structure/03_catalog-and-subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,12 @@ Pool mode routes across main plus added Codex credentials. Key rules:
- **A namespace is a public selector mapped to an internal target.** Generated selectors are how a
caller names an account — the main login's selector is `main` (collision-suffixed if taken),
which maps to the config-only sentinel `@main`; the sentinel deliberately sits outside the
pool-account id grammar. Selectors must not collide with provider or combo ids
(`src/codex/account-namespaces.ts`, `src/codex/account-namespace-match.ts`).
pool-account id grammar. Selector initialization requires an explicit opt-in and fills only an
absent or empty map; a non-empty user map keeps its object identity and insertion order. Generated
selectors avoid provider, combo, routing-policy, and slash-qualified routing-profile namespaces.
Collision checks normalize provider and reserved namespace keys, while account and
routing-profile selector prefixes are exact-case (`src/codex/account-namespaces.ts`,
`src/codex/account-namespace-match.ts`, `src/routing/profile-namespace.ts`).
- **Selector labels carry no account-role semantics.** When at least one selector is advertisable,
the Codex catalog clones each supported native row per selector and hides the bare picker rows;
bare ids remain routable and stay in raw `/v1/models` unless explicitly disabled. Missing stored
Expand Down
142 changes: 142 additions & 0 deletions tests/codex-account-namespaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,22 @@ import {
codexAccountPickerEnabled,
codexAccountNamespaceEntries,
defaultCodexAccountNamespaces,
initializeDefaultCodexAccountNamespaces,
isMainCodexAccountTarget,
isValidCodexAccountNamespaceTarget,
} from "../src/codex/account-namespaces";
import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account";
import type { OcxConfig } from "../src/types";

type InitializableAccountNamespaceConfig = Pick<
OcxConfig,
| "codexAccountPickerEnabled"
| "codexAccountNamespaces"
| "codexAccounts"
| "combos"
| "providers"
| "routingProfiles"
>;

describe("Codex account namespace foundations", () => {
test("uses persisted random labels without deriving selectors from aliases or emails", () => {
Expand Down Expand Up @@ -151,6 +163,41 @@ describe("Codex account namespace foundations", () => {
expect(namespaces).toEqual({ "main-2": "@main", "p111111-2": "stored-account-id" });
});

test("avoids routing-profile alias prefixes when allocating defaults", () => {
const namespaces = defaultCodexAccountNamespaces({
providers: {},
routingProfiles: {
main: {
alias: " main/gpt-5.5 ",
candidates: [{ provider: "openai", model: "gpt-5.5" }],
},
side: {
alias: "p111111/gpt-5.5",
candidates: [{ provider: "openai", model: "gpt-5.5" }],
},
},
codexAccounts: [{
id: "stored-account-id",
logLabel: "p111111",
isMain: false,
}],
});

expect(namespaces).toEqual({ "main-2": "@main", "p111111-2": "stored-account-id" });
});

test("keeps routing-profile alias-prefix matching exact-case", () => {
expect(defaultCodexAccountNamespaces({
providers: {},
routingProfiles: {
mixedCase: {
alias: "Main/gpt-5.5",
candidates: [{ provider: "openai", model: "gpt-5.5" }],
},
},
})).toEqual({ main: "@main" });
});

test("avoids provider names case-insensitively when allocating defaults", () => {
expect(defaultCodexAccountNamespaces({
providers: {
Expand Down Expand Up @@ -179,6 +226,31 @@ describe("Codex account namespace foundations", () => {
expect(appendDefaultCodexAccountNamespace(config, account)).toBe(false);
});

test("append avoids routing-profile alias prefixes without rewriting existing selectors", () => {
const codexAccountNamespaces = { main: "@main" };
const config = {
providers: {},
routingProfiles: {
side: {
alias: "p222222/gpt-5.5",
candidates: [{ provider: "openai", model: "gpt-5.5" }],
},
},
codexAccountNamespaces,
};

expect(appendDefaultCodexAccountNamespace(config, {
id: "new-account-id",
logLabel: "p222222",
isMain: false,
})).toBe(true);
expect(config.codexAccountNamespaces).toBe(codexAccountNamespaces);
expect(config.codexAccountNamespaces).toEqual({
main: "@main",
"p222222-2": "new-account-id",
});
});

test("refuses to append an account id already owned by a selector key", () => {
const codexAccountNamespaces = { work: "existing-account-id", mainAccount: "@main" };
const config = { providers: {}, codexAccountNamespaces };
Expand Down Expand Up @@ -307,6 +379,76 @@ describe("Codex account namespace foundations", () => {
})).toEqual({ "main-2": "@main", p454545: "main" });
});

test("initializes selectors only for an explicit opt-in with no existing bindings", () => {
const absent: InitializableAccountNamespaceConfig = {
providers: {},
codexAccountPickerEnabled: true,
};
expect(initializeDefaultCodexAccountNamespaces(absent)).toBe(true);
expect(absent.codexAccountNamespaces).toEqual({ main: "@main" });

const originalEmpty = {} as Record<string, string>;
const empty = {
providers: {},
codexAccountPickerEnabled: true,
codexAccountNamespaces: originalEmpty,
};
expect(initializeDefaultCodexAccountNamespaces(empty)).toBe(true);
expect(empty.codexAccountNamespaces).not.toBe(originalEmpty);
expect(empty.codexAccountNamespaces).toEqual({ main: "@main" });

for (const codexAccountPickerEnabled of [undefined, false]) {
const inert = { providers: {}, codexAccountPickerEnabled };
expect(initializeDefaultCodexAccountNamespaces(inert)).toBe(false);
expect(inert).not.toHaveProperty("codexAccountNamespaces");
}
});

test("initializer preserves a non-empty selector map by identity and order", () => {
const codexAccountNamespaces = { side: "stored-account", desktop: "@main" };
const config = {
providers: {},
codexAccountPickerEnabled: true,
codexAccountNamespaces,
};

const labelSpy = spyOn(accountLabels, "createCodexAccountLogLabel");
try {
expect(initializeDefaultCodexAccountNamespaces(config)).toBe(false);
expect(config.codexAccountNamespaces).toBe(codexAccountNamespaces);
expect(Object.keys(config.codexAccountNamespaces)).toEqual(["side", "desktop"]);
expect(labelSpy).not.toHaveBeenCalled();
} finally {
labelSpy.mockRestore();
}
});

test("initializer assigns nothing when safe selector allocation fails", () => {
const labelSpy = spyOn(accountLabels, "createCodexAccountLogLabel").mockReturnValue("p111111");
try {
for (const startsWithEmptyMap of [false, true]) {
const originalEmpty = {} as Record<string, string>;
const config: InitializableAccountNamespaceConfig = {
providers: {},
codexAccountPickerEnabled: true,
codexAccounts: [{ id: "p111111", logLabel: "legacy", isMain: false }],
...(startsWithEmptyMap ? { codexAccountNamespaces: originalEmpty } : {}),
};

expect(() => initializeDefaultCodexAccountNamespaces(config))
.toThrow("Unable to allocate a unique Codex account selector");
if (startsWithEmptyMap) {
expect(config.codexAccountNamespaces).toBe(originalEmpty);
expect(config.codexAccountNamespaces).toEqual({});
} else {
expect(config).not.toHaveProperty("codexAccountNamespaces");
}
}
} finally {
labelSpy.mockRestore();
}
});

test("keeps existing selector maps enabled unless the visibility override is false", () => {
expect(codexAccountPickerEnabled({ codexAccountNamespaces: { desktop: "@main" } })).toBe(true);
expect(codexAccountPickerEnabled({
Expand Down
Loading
Loading