diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index b254dfcc2..d2ac1dfc8 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -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/)を参照してください。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index dd72bb4ff..77ba9a95f 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -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/)을 참고하십시오. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 1b8446d1f..4849f1d3a 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -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 diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 5698ffc7c..99ab4fab1 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -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/). diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 164f18015..becca0706 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -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/)。 diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts index 4ebf6affa..e39624366 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -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, @@ -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; @@ -68,17 +73,21 @@ function claimNamespace(requested: string, used: Set): string { return namespace; } -function occupiedNamespaces(config: Pick): Set { +/** Collect every public namespace that a generated account selector must not claim. */ +function occupiedNamespaces( + config: Pick, +): Set { 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, + config: Pick, ): Record { const namespaces: Record = {}; const used = occupiedNamespaces(config); @@ -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, + config: Pick< + OcxConfig, + "codexAccountNamespaces" | "codexAccounts" | "combos" | "providers" | "routingProfiles" + >, account: Pick, ): boolean { const namespaces = config.codexAccountNamespaces; diff --git a/src/config.ts b/src/config.ts index 355da23d0..717b11aea 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, @@ -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( @@ -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)) { diff --git a/src/routing/profile-namespace.ts b/src/routing/profile-namespace.ts new file mode 100644 index 000000000..d02f9023f --- /dev/null +++ b/src/routing/profile-namespace.ts @@ -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[] { + 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)] : []; + }); +} diff --git a/src/routing/profile.ts b/src/routing/profile.ts index 3f746372a..b35705954 100644 --- a/src/routing/profile.ts +++ b/src/routing/profile.ts @@ -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})?$/; diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index fc7739f41..af8182a4d 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -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 diff --git a/tests/codex-account-namespaces.test.ts b/tests/codex-account-namespaces.test.ts index b51011226..948d160db 100644 --- a/tests/codex-account-namespaces.test.ts +++ b/tests/codex-account-namespaces.test.ts @@ -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", () => { @@ -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: { @@ -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 }; @@ -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; + 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; + 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({ diff --git a/tests/config.test.ts b/tests/config.test.ts index ec75200de..cca41a4d6 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1700,6 +1700,8 @@ describe("opencodex config defaults", () => { ], ["the combo namespace", { combo: "side-account" }, {}, "must not collide"], ["the combo namespace with different casing", { Combo: "side-account" }, {}, "must not collide"], + ["the routing policy namespace", { policy: "side-account" }, {}, "must not collide"], + ["the routing policy namespace with different casing", { Policy: "side-account" }, {}, "must not collide"], ["the canonical OpenAI namespace with different casing", { OpenAI: "side-account" }, {}, "must not collide"], [ "the canonical OpenAI provider namespace before legacy migration", diff --git a/tests/routing-profile-management-editor.test.ts b/tests/routing-profile-management-editor.test.ts index 7f92b52e0..08fc82aa2 100644 --- a/tests/routing-profile-management-editor.test.ts +++ b/tests/routing-profile-management-editor.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fallbackCodexAccountLogLabel } from "../src/codex/account-label"; import { handleManagementAPI } from "../src/server/management-api"; import { ManagementRequest } from "./helpers/management-auth"; import type { OcxConfig } from "../src/types"; @@ -145,6 +146,53 @@ describe("routing profile management editor API", () => { expect(saves).toBe(0); }); + test("PUT create rejects an account-selector namespace without side effects or private ids", async () => { + const config = baseConfig(); + const privateAccountId = "private-stored-account-id"; + const privateEmail = "private-account@example.test"; + config.codexAccounts = [{ id: privateAccountId, email: privateEmail, isMain: false }]; + config.codexAccountNamespaces = { side: privateAccountId }; + const before = structuredClone(config); + const profiles = config.routingProfiles; + let saves = 0; + let refreshes = 0; + const req = new ManagementRequest("http://localhost/api/routing-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "colliding", + mode: "create", + profile: { + alias: "side/gpt-5.5", + candidates: [{ provider: "a", model: "m1" }], + }, + }), + }); + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }, () => { refreshes += 1; }), + ); + + expect(response?.status).toBe(400); + const body = await response!.json(); + expect(body).toMatchObject({ + error: { + code: "invalid_profile", + message: expect.stringContaining("codex account namespace"), + }, + }); + const serialized = JSON.stringify(body); + expect(serialized).not.toContain(privateAccountId); + expect(serialized).not.toContain(privateEmail); + expect(serialized).not.toContain(fallbackCodexAccountLogLabel(privateAccountId)); + expect(config).toEqual(before); + expect(config.routingProfiles).toBe(profiles); + expect(saves).toBe(0); + expect(refreshes).toBe(0); + }); + test("PUT create refuses to overwrite an existing profile", async () => { const config = baseConfig(); let saves = 0; @@ -307,6 +355,62 @@ describe("routing profile management editor API", () => { expect(saves).toBe(1); }); + test("PUT update rejects an account-selector namespace before reference migration", async () => { + const config = baseConfig(); + const privateAccountId = "private-stored-account-id"; + const privateEmail = "private-account@example.test"; + config.codexAccounts = [{ id: privateAccountId, email: privateEmail, isMain: false }]; + config.codexAccountNamespaces = { side: privateAccountId }; + const disabledModels = ["ocx/fast"]; + const subagentModels = ["ocx/fast", "a/m1"]; + config.disabledModels = disabledModels; + config.subagentModels = subagentModels; + config.injectionModel = "ocx/fast"; + const before = structuredClone(config); + const profiles = config.routingProfiles; + const fastProfile = config.routingProfiles!.fast; + let saves = 0; + let refreshes = 0; + const req = new ManagementRequest("http://localhost/api/routing-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "fast", + mode: "update", + profile: { + alias: "side/gpt-5.5", + candidates: [{ provider: "a", model: "m1" }], + }, + }), + }); + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }, () => { refreshes += 1; }), + ); + + expect(response?.status).toBe(400); + const body = await response!.json(); + expect(body).toMatchObject({ + error: { + code: "invalid_profile", + message: expect.stringContaining("codex account namespace"), + }, + }); + const serialized = JSON.stringify(body); + expect(serialized).not.toContain(privateAccountId); + expect(serialized).not.toContain(privateEmail); + expect(serialized).not.toContain(fallbackCodexAccountLogLabel(privateAccountId)); + expect(config).toEqual(before); + expect(config.routingProfiles).toBe(profiles); + expect(config.routingProfiles!.fast).toBe(fastProfile); + expect(config.disabledModels).toBe(disabledModels); + expect(config.subagentModels).toBe(subagentModels); + expect(saves).toBe(0); + expect(refreshes).toBe(0); + }); + test("PUT update rejects a modelMap key collision instead of silently dropping a mapping", async () => { const config = baseConfig(); config.claudeCode = {