From b5c5ffc332735cab4fe0407cf7a881365009e077 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 15 Sep 2026 08:50:42 +0000 Subject: [PATCH 1/4] fix(catalog): apply custom capabilities to combos --- src/codex/catalog/routed-gather.ts | 36 ++++++++++++ structure/catalog.md | 5 ++ tests/codex-integration/codex-catalog.test.ts | 57 +++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 68dd1cc376..907b672f3d 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -359,6 +359,42 @@ async function gatherRoutedModelsUncached( .filter(shouldExposeRoutedModel); const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model])); // [Decision Log] + // - 목적과 의도: combo derivation must see the same explicit custom-model capabilities that the + // final Models inventory publishes. Previously customModels were materialized only after this + // map had already derived every combo, so one row could say image while its combo said text. + // - 기존 구현 및 제약 조건: provider/discovery rows remain the inheritance source, and custom + // rows must not globally invent capabilities for the provider-native model id. + // - 검토한 주요 대안: move the full custom-row materializer ahead of combos, or overlay only the + // explicit custom fields onto this private derivation map. + // - 선택한 방식: overlay the explicit fields here; the existing final materializer stays the + // single owner of public custom-row construction and deduplication. + // - 다른 대안 대신 이 방식을 선택한 이유: moving the large materializer would reorder public + // catalog production and warning behavior, while this map is already private to combo input. + // - 장점, 단점 및 영향: custom context/modality/reasoning/tool-mode declarations now constrain + // their combos without widening unrelated rows; inherited fields still come from the same row. + for (const custom of config.customModels ?? []) { + const key = `${custom.provider}/${custom.modelId}`; + const inherited = memberByKey.get(key) ?? { + provider: custom.provider, + id: custom.modelId, + owned_by: custom.provider, + }; + memberByKey.set(key, { + ...inherited, + catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + ...(typeof custom.contextWindow === "number" && custom.contextWindow > 0 + ? { contextWindow: custom.contextWindow } + : {}), + ...(Array.isArray(custom.inputModalities) + ? { inputModalities: [...custom.inputModalities] } + : {}), + ...(Array.isArray(custom.reasoningEfforts) + ? { reasoningEfforts: [...custom.reasoningEfforts] } + : {}), + ...(custom.codexToolMode !== undefined ? { codexToolMode: custom.codexToolMode } : {}), + }); + } + // [Decision Log] // - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서 // 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login // passthrough)이므로 fetchProviderModels가 항상 []를 반환하고, native slugs는 diff --git a/structure/catalog.md b/structure/catalog.md index 2fd03722df..bf9d6e3751 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -66,6 +66,11 @@ ordinary retained provider rows still receive the existing mock-tier policy. A p marker alone never grants this exemption. Both gather entry points, retained sync, management convergence and direct Codex model discovery use the same producer. The legacy runtime effort union clamp remains separate; it is not a per-model or per-client-version grammar oracle. +Before combo derivation, an explicit custom-model context, modality, reasoning, or +tool-mode declaration overlays the matching provider member in the private combo input map. This +keeps a combo's advertised intersection aligned with the final custom row without changing the +provider-native row or inventing capabilities for other models. Public custom-row materialization +and routed-slug deduplication remain the final catalog owner's responsibility. Codex's native `ultra` mode is preserved and is not a literal API wire promise. When account selectors are enabled, the sync path may also observe exact, visible, API-supported OpenAI-family ids from Codex's user-owned catalog/cache. Only rows with native catalog provenance diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index fe46b224b2..9a446218f2 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -1164,6 +1164,63 @@ describe("combo catalog capability intersection", () => { } }, 15_000); + test("combo derivation uses an explicit custom-model image declaration (#4689)", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "custom-upstream", + providers: { + "custom-upstream": { + adapter: "openai-chat", + baseUrl: "https://custom.example/v1", + liveModels: false, + models: ["manually-added-image-model"], + modelContextWindows: { "manually-added-image-model": 256_000 }, + }, + "known-image": { + adapter: "openai-chat", + baseUrl: "https://image.example/v1", + liveModels: false, + models: ["image-model"], + modelContextWindows: { "image-model": 128_000 }, + modelInputModalities: { "image-model": ["text", "image"] }, + modelReasoningEfforts: { "image-model": ["low", "high"] }, + codexToolMode: "shell", + }, + }, + customModels: [{ + id: "custom-image-row", + provider: "custom-upstream", + modelId: "manually-added-image-model", + contextWindow: 96_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "high"], + codexToolMode: "shell", + }], + combos: { + image_failover: { + strategy: "failover", + targets: [ + { provider: "custom-upstream", model: "manually-added-image-model" }, + { provider: "known-image", model: "image-model" }, + ], + }, + }, + }; + + const models = await gatherRoutedModels(config); + expect(models.find(model => ( + model.provider === "custom-upstream" && model.id === "manually-added-image-model" + ))?.inputModalities).toEqual(["text", "image"]); + expect(models.find(model => ( + model.provider === "combo" && model.id === "image_failover" + ))).toMatchObject({ + contextWindow: 96_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "high"], + codexToolMode: "shell", + }); + }); + test("native aliases use native capability fallbacks when discovery returns only an id", async () => { const config: OcxConfig = { port: 10100, From fc6a41a9de83c63e12085b2831d16324fad5be0c Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 15 Sep 2026 08:57:26 +0000 Subject: [PATCH 2/4] test(catalog): keep custom combo regression scoped --- tests/codex-integration/codex-catalog.test.ts | 57 ----------------- .../flash-route-image-modalities.test.ts | 63 ++++++++++++++++++- 2 files changed, 61 insertions(+), 59 deletions(-) diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 9a446218f2..fe46b224b2 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -1164,63 +1164,6 @@ describe("combo catalog capability intersection", () => { } }, 15_000); - test("combo derivation uses an explicit custom-model image declaration (#4689)", async () => { - const config: OcxConfig = { - port: 10100, - defaultProvider: "custom-upstream", - providers: { - "custom-upstream": { - adapter: "openai-chat", - baseUrl: "https://custom.example/v1", - liveModels: false, - models: ["manually-added-image-model"], - modelContextWindows: { "manually-added-image-model": 256_000 }, - }, - "known-image": { - adapter: "openai-chat", - baseUrl: "https://image.example/v1", - liveModels: false, - models: ["image-model"], - modelContextWindows: { "image-model": 128_000 }, - modelInputModalities: { "image-model": ["text", "image"] }, - modelReasoningEfforts: { "image-model": ["low", "high"] }, - codexToolMode: "shell", - }, - }, - customModels: [{ - id: "custom-image-row", - provider: "custom-upstream", - modelId: "manually-added-image-model", - contextWindow: 96_000, - inputModalities: ["text", "image"], - reasoningEfforts: ["low", "high"], - codexToolMode: "shell", - }], - combos: { - image_failover: { - strategy: "failover", - targets: [ - { provider: "custom-upstream", model: "manually-added-image-model" }, - { provider: "known-image", model: "image-model" }, - ], - }, - }, - }; - - const models = await gatherRoutedModels(config); - expect(models.find(model => ( - model.provider === "custom-upstream" && model.id === "manually-added-image-model" - ))?.inputModalities).toEqual(["text", "image"]); - expect(models.find(model => ( - model.provider === "combo" && model.id === "image_failover" - ))).toMatchObject({ - contextWindow: 96_000, - inputModalities: ["text", "image"], - reasoningEfforts: ["low", "high"], - codexToolMode: "shell", - }); - }); - test("native aliases use native capability fallbacks when discovery returns only an id", async () => { const config: OcxConfig = { port: 10100, diff --git a/tests/providers/flash-route-image-modalities.test.ts b/tests/providers/flash-route-image-modalities.test.ts index 83b79d245f..06c5f1f076 100644 --- a/tests/providers/flash-route-image-modalities.test.ts +++ b/tests/providers/flash-route-image-modalities.test.ts @@ -17,11 +17,11 @@ * and the combo intersection they feed. */ import { describe, expect, test } from "bun:test"; -import { applyProviderConfigHints, deriveComboCatalogModel } from "../../src/codex/catalog"; +import { applyProviderConfigHints, deriveComboCatalogModel, gatherRoutedModels } from "../../src/codex/catalog"; import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../../src/providers/registry"; import { providerConfigSeed } from "../../src/providers/derive"; import { isModelVisionSidecarConsumer } from "../../src/vision/eligibility"; -import type { CatalogModel, OcxProviderConfig } from "../../src/types"; +import type { CatalogModel, OcxConfig, OcxProviderConfig } from "../../src/types"; const OPENCODE_GO_NATIVE = "glm-5.3-flash"; const OPENCODE_GO_SIDECAR = "deepseek-v4.1-flash"; @@ -147,3 +147,62 @@ describe("flash-route combo intersection (#4505)", () => { expect(derived?.inputModalities).toEqual(["text"]); }); }); + +describe("custom-model combo capability alignment (#4689)", () => { + test("combo derivation sees the explicit custom row before intersecting members", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "issue-4689-custom", + providers: { + "issue-4689-custom": { + adapter: "openai-chat", + baseUrl: "https://custom.example/v1", + liveModels: false, + models: ["manually-added-image-model"], + modelContextWindows: { "manually-added-image-model": 256_000 }, + }, + "issue-4689-image": { + adapter: "openai-chat", + baseUrl: "https://image.example/v1", + liveModels: false, + models: ["image-model"], + modelContextWindows: { "image-model": 128_000 }, + modelInputModalities: { "image-model": ["text", "image"] }, + modelReasoningEfforts: { "image-model": ["low", "high"] }, + codexToolMode: "shell", + }, + }, + customModels: [{ + id: "custom-image-row", + provider: "issue-4689-custom", + modelId: "manually-added-image-model", + contextWindow: 96_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "high"], + codexToolMode: "shell", + }], + combos: { + image_failover: { + strategy: "failover", + targets: [ + { provider: "issue-4689-custom", model: "manually-added-image-model" }, + { provider: "issue-4689-image", model: "image-model" }, + ], + }, + }, + }; + + const models = await gatherRoutedModels(config); + expect(models.find(model => ( + model.provider === "issue-4689-custom" && model.id === "manually-added-image-model" + ))?.inputModalities).toEqual(["text", "image"]); + expect(models.find(model => ( + model.provider === "combo" && model.id === "image_failover" + ))).toMatchObject({ + contextWindow: 96_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "high"], + codexToolMode: "shell", + }); + }); +}); From 5b5605d2884e793b77c9aaf5cab9fa9b34b25333 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 15 Sep 2026 09:10:39 +0000 Subject: [PATCH 3/4] fix(catalog): preserve native combo inheritance --- src/codex/catalog/routed-gather.ts | 72 +++++++++---------- .../flash-route-image-modalities.test.ts | 43 ++++++++++- 2 files changed, 78 insertions(+), 37 deletions(-) diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 907b672f3d..c2da1f84f4 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -359,42 +359,6 @@ async function gatherRoutedModelsUncached( .filter(shouldExposeRoutedModel); const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model])); // [Decision Log] - // - 목적과 의도: combo derivation must see the same explicit custom-model capabilities that the - // final Models inventory publishes. Previously customModels were materialized only after this - // map had already derived every combo, so one row could say image while its combo said text. - // - 기존 구현 및 제약 조건: provider/discovery rows remain the inheritance source, and custom - // rows must not globally invent capabilities for the provider-native model id. - // - 검토한 주요 대안: move the full custom-row materializer ahead of combos, or overlay only the - // explicit custom fields onto this private derivation map. - // - 선택한 방식: overlay the explicit fields here; the existing final materializer stays the - // single owner of public custom-row construction and deduplication. - // - 다른 대안 대신 이 방식을 선택한 이유: moving the large materializer would reorder public - // catalog production and warning behavior, while this map is already private to combo input. - // - 장점, 단점 및 영향: custom context/modality/reasoning/tool-mode declarations now constrain - // their combos without widening unrelated rows; inherited fields still come from the same row. - for (const custom of config.customModels ?? []) { - const key = `${custom.provider}/${custom.modelId}`; - const inherited = memberByKey.get(key) ?? { - provider: custom.provider, - id: custom.modelId, - owned_by: custom.provider, - }; - memberByKey.set(key, { - ...inherited, - catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, - ...(typeof custom.contextWindow === "number" && custom.contextWindow > 0 - ? { contextWindow: custom.contextWindow } - : {}), - ...(Array.isArray(custom.inputModalities) - ? { inputModalities: [...custom.inputModalities] } - : {}), - ...(Array.isArray(custom.reasoningEfforts) - ? { reasoningEfforts: [...custom.reasoningEfforts] } - : {}), - ...(custom.codexToolMode !== undefined ? { codexToolMode: custom.codexToolMode } : {}), - }); - } - // [Decision Log] // - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서 // 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login // passthrough)이므로 fetchProviderModels가 항상 []를 반환하고, native slugs는 @@ -456,6 +420,42 @@ async function gatherRoutedModelsUncached( if (!memberByKey.has(key)) memberByKey.set(key, synthetic); } } + // [Decision Log] + // - 목적과 의도: combo derivation must see the same explicit custom-model capabilities that the + // final Models inventory publishes. Previously customModels were materialized only after this + // map had already derived every combo, so one row could say image while its combo said text. + // - 기존 구현 및 제약 조건: provider/discovery rows remain the inheritance source, and native + // OpenAI synthesis must run first so a sparse custom row cannot hide native hard limits. + // - 검토한 주요 대안: move the full custom-row materializer ahead of combos, or overlay only the + // explicit custom fields onto this private derivation map after provider/native inheritance. + // - 선택한 방식: use the scoped post-inheritance overlay; the existing final materializer stays + // the single owner of public custom-row construction and deduplication. + // - 다른 대안 대신 이 방식을 선택한 이유: moving the large materializer would reorder public + // catalog production and warning behavior, while this map is already private to combo input. + // - 장점, 단점 및 영향: custom context/modality/reasoning/tool-mode declarations now constrain + // their combos without widening unrelated rows; omitted fields retain provider/native limits. + for (const custom of config.customModels ?? []) { + const key = `${custom.provider}/${custom.modelId}`; + const inherited = memberByKey.get(key) ?? { + provider: custom.provider, + id: custom.modelId, + owned_by: custom.provider, + }; + memberByKey.set(key, { + ...inherited, + catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + ...(typeof custom.contextWindow === "number" && custom.contextWindow > 0 + ? { contextWindow: custom.contextWindow } + : {}), + ...(Array.isArray(custom.inputModalities) + ? { inputModalities: [...custom.inputModalities] } + : {}), + ...(Array.isArray(custom.reasoningEfforts) + ? { reasoningEfforts: [...custom.reasoningEfforts] } + : {}), + ...(custom.codexToolMode !== undefined ? { codexToolMode: custom.codexToolMode } : {}), + }); + } // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and // custom-model vision-sidecar inheritance so both see the same merged registry view. const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); diff --git a/tests/providers/flash-route-image-modalities.test.ts b/tests/providers/flash-route-image-modalities.test.ts index 06c5f1f076..11212c0329 100644 --- a/tests/providers/flash-route-image-modalities.test.ts +++ b/tests/providers/flash-route-image-modalities.test.ts @@ -17,7 +17,14 @@ * and the combo intersection they feed. */ import { describe, expect, test } from "bun:test"; -import { applyProviderConfigHints, deriveComboCatalogModel, gatherRoutedModels } from "../../src/codex/catalog"; +import { + applyProviderConfigHints, + deriveComboCatalogModel, + gatherRoutedModels, + nativeContextLimits, + nativeOpenAiContextWindow, + nativeOpenAiMaxInputTokens, +} from "../../src/codex/catalog"; import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../../src/providers/registry"; import { providerConfigSeed } from "../../src/providers/derive"; import { isModelVisionSidecarConsumer } from "../../src/vision/eligibility"; @@ -205,4 +212,38 @@ describe("custom-model combo capability alignment (#4689)", () => { codexToolMode: "shell", }); }); + + test("a sparse custom native row retains native limits in an ordinary combo", async () => { + const slug = "gpt-5.6-luna"; + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + customModels: [{ id: "sparse-native-row", provider: "openai", modelId: slug }], + combos: { + luna_failover: { + strategy: "failover", + targets: [{ provider: "openai", model: slug }], + }, + }, + }; + const limits = nativeContextLimits(config); + const expectedContext = nativeOpenAiContextWindow(slug, limits); + const expectedMaxInput = nativeOpenAiMaxInputTokens(slug, limits); + + const models = await gatherRoutedModels(config); + expect(models.find(model => ( + model.provider === "combo" && model.id === "luna_failover" + ))).toMatchObject({ + contextWindow: expectedContext, + maxInputTokens: expectedMaxInput, + inputModalities: ["text", "image"], + }); + }); }); From d470f316e919627f1884d0805f98d0924c3bc51c Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 15 Sep 2026 09:11:58 +0000 Subject: [PATCH 4/4] test(catalog): pin native combo compaction Co-authored-by: Ingwannu --- tests/providers/flash-route-image-modalities.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/providers/flash-route-image-modalities.test.ts b/tests/providers/flash-route-image-modalities.test.ts index 11212c0329..e115cd9724 100644 --- a/tests/providers/flash-route-image-modalities.test.ts +++ b/tests/providers/flash-route-image-modalities.test.ts @@ -28,6 +28,7 @@ import { import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../../src/providers/registry"; import { providerConfigSeed } from "../../src/providers/derive"; import { isModelVisionSidecarConsumer } from "../../src/vision/eligibility"; +import { nativeOpenAiAutoCompactTokenLimit } from "../../src/codex/catalog/metadata"; import type { CatalogModel, OcxConfig, OcxProviderConfig } from "../../src/types"; const OPENCODE_GO_NATIVE = "glm-5.3-flash"; @@ -236,6 +237,7 @@ describe("custom-model combo capability alignment (#4689)", () => { const limits = nativeContextLimits(config); const expectedContext = nativeOpenAiContextWindow(slug, limits); const expectedMaxInput = nativeOpenAiMaxInputTokens(slug, limits); + const expectedAutoCompact = nativeOpenAiAutoCompactTokenLimit(slug, limits); const models = await gatherRoutedModels(config); expect(models.find(model => ( @@ -243,6 +245,7 @@ describe("custom-model combo capability alignment (#4689)", () => { ))).toMatchObject({ contextWindow: expectedContext, maxInputTokens: expectedMaxInput, + autoCompactTokenLimit: expectedAutoCompact, inputModalities: ["text", "image"], }); });