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
36 changes: 36 additions & 0 deletions src/codex/catalog/routed-gather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,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]));
Expand Down
5 changes: 5 additions & 0 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 105 additions & 2 deletions tests/providers/flash-route-image-modalities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@
* and the combo intersection they feed.
*/
import { describe, expect, test } from "bun:test";
import { applyProviderConfigHints, deriveComboCatalogModel } 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";
import type { CatalogModel, OcxProviderConfig } from "../../src/types";
import { nativeOpenAiAutoCompactTokenLimit } from "../../src/codex/catalog/metadata";
import type { CatalogModel, OcxConfig, OcxProviderConfig } from "../../src/types";

const OPENCODE_GO_NATIVE = "glm-5.3-flash";
const OPENCODE_GO_SIDECAR = "deepseek-v4.1-flash";
Expand Down Expand Up @@ -147,3 +155,98 @@ 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",
});
});

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 expectedAutoCompact = nativeOpenAiAutoCompactTokenLimit(slug, limits);

const models = await gatherRoutedModels(config);
expect(models.find(model => (
model.provider === "combo" && model.id === "luna_failover"
))).toMatchObject({
contextWindow: expectedContext,
maxInputTokens: expectedMaxInput,
autoCompactTokenLimit: expectedAutoCompact,
inputModalities: ["text", "image"],
});
});
});
Loading