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
6 changes: 3 additions & 3 deletions open-sse/config/providers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,15 +295,15 @@ export const GPT_5_6_API_CAPABILITIES = {
maxOutputTokens: 128000,
} as const;

// Codex's live catalog reports a 372K usable input budget for GPT-5.6.
// Keep the reserved 128K output budget explicit, matching the GPT-5.5 catalog contract.
// Codex's live catalog reports a 372K context window for GPT-5.6.
// Keep the input and output limits explicit for catalog consumers that expose them separately.
export const GPT_5_6_CODEX_CAPABILITIES = {
targetFormat: "openai-responses",
toolCalling: true,
supportsReasoning: true,
supportsVision: true,
supportsXHighEffort: true,
contextLength: 500000,
contextLength: 372000,
maxInputTokens: 372000,
maxOutputTokens: 128000,
} as const;
Expand Down
16 changes: 14 additions & 2 deletions open-sse/translator/request/openai-responses/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,21 @@ export function imageUrlToText(value: unknown): string {
return toString(record.url);
}

export function normalizeResponsesReasoningEffort(value: unknown): string {
const CODEX_GPT_5_6_MODEL_PATTERN =
/^gpt-5\.6-(?:sol|terra|luna)(?:-(?:none|low|medium|high|xhigh|max|ultra))?$/;

function supportsNativeMaxReasoningEffort(model: unknown): boolean {
const normalizedModel = toString(model)
.trim()
.toLowerCase()
.replace(/^(?:codex|cx)\//, "");
return CODEX_GPT_5_6_MODEL_PATTERN.test(normalizedModel);
}

export function normalizeResponsesReasoningEffort(value: unknown, model?: unknown): string {
const effort = toString(value).toLowerCase();
return effort === "max" ? "xhigh" : effort;
if (effort !== "max") return effort;
return supportsNativeMaxReasoningEffort(model) ? "max" : "xhigh";
}

export function shouldRequestClaudeSummarizedThinking(value: unknown): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ export function openaiToOpenAIResponsesRequest(
if (root.reasoning !== undefined) {
result.reasoning = root.reasoning;
} else if (root.reasoning_effort !== undefined) {
const effort = normalizeResponsesReasoningEffort(root.reasoning_effort);
const effort = normalizeResponsesReasoningEffort(root.reasoning_effort, model ?? root.model);
if (effort && effort !== "none") {
// Effort-only chat request: default a reasoning summary so the upstream
// streams thinking back (see the constant's note above).
Expand Down
58 changes: 57 additions & 1 deletion src/app/api/providers/[id]/models/discovery/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,62 @@ function localCatalogModelToCodexDiscoveryModel(
};
}

/**
* Capacity limits (input/output token caps) merge CONSERVATIVELY: the smaller
* of the pinned local-catalog value and the live-discovery value wins, never
* the larger. Overpromising context lets a request run past what the account
* can actually serve — the upstream truncates mid-conversation and can burn a
* combo fallback. Underpromising only leaves capacity on the table, which is
* a performance loss, not a broken request. When only one side has a value,
* that value passes through unchanged (nothing to reconcile).
*
* This is the ONE deliberate exception to "live wins on overlapping fields"
* below — name/apiFormat/supportedEndpoints/supportsThinking/supportsVision
* etc. still take the live value unconditionally. Don't extend this
* conservative rule to other fields without updating the policy comment on
* mergeCodexLiveModelsWithLocalCatalog (#7012).
*/
function mergeCapacityLimitConservatively(
pinnedValue: number | undefined,
liveValue: number | undefined
): number | undefined {
if (typeof pinnedValue === "number" && typeof liveValue === "number") {
return Math.min(pinnedValue, liveValue);
}
return typeof liveValue === "number" ? liveValue : pinnedValue;
}

function mergeLiveAndLocalCodexModel(
liveModel: CodexDiscoveryModel,
localModel: CodexDiscoveryModel
): CodexDiscoveryModel {
const merged: CodexDiscoveryModel = { ...localModel, ...liveModel };
const inputTokenLimit = mergeCapacityLimitConservatively(
localModel.inputTokenLimit,
liveModel.inputTokenLimit
);
const outputTokenLimit = mergeCapacityLimitConservatively(
localModel.outputTokenLimit,
liveModel.outputTokenLimit
);
if (typeof inputTokenLimit === "number") {
merged.inputTokenLimit = inputTokenLimit;
} else {
delete merged.inputTokenLimit;
}
if (typeof outputTokenLimit === "number") {
merged.outputTokenLimit = outputTokenLimit;
} else {
delete merged.outputTokenLimit;
}
return merged;
}

/**
* Live/GitHub discovery is the source of truth for "what exists".
* Explicit filters (denylist / predicates) are the policy layer for "what we show".
* Live wins on overlapping fields, EXCEPT capacity limits (input/output token
* caps) — those merge conservatively, see mergeCapacityLimitConservatively.
* Do NOT reintroduce curated-only allowlisting as the default path (#6862 / #6859).
*/
export function mergeCodexLiveModelsWithLocalCatalog(
Expand All @@ -312,7 +365,10 @@ export function mergeCodexLiveModelsWithLocalCatalog(
if (!localModel.id) continue;
const normalizedLocal = localCatalogModelToCodexDiscoveryModel(localModel);
const existing = merged.get(localModel.id);
merged.set(localModel.id, existing ? { ...normalizedLocal, ...existing } : normalizedLocal);
merged.set(
localModel.id,
existing ? mergeLiveAndLocalCodexModel(existing, normalizedLocal) : normalizedLocal
);
}

return Array.from(merged.values());
Expand Down
55 changes: 39 additions & 16 deletions src/app/api/v1/vscode/[token]/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,29 +72,51 @@ type EnrichModelForVscodeOptions = {
preserveNativeId?: boolean;
};

const TEXT_GENERATION_API_FORMATS = new Set([
"chat-completions",
"responses",
"openai-responses",
]);

function usesResponsesApi(model: CatalogModelEntry) {
return (
model.api_format === "responses" ||
model.api_format === "openai-responses" ||
model.supported_endpoints?.includes("responses") === true
);
}

function excludesChatAndResponsesEndpoints(model: CatalogModelEntry) {
return (
Array.isArray(model.supported_endpoints) &&
model.supported_endpoints.length > 0 &&
!model.supported_endpoints.includes("chat") &&
!model.supported_endpoints.includes("responses")
);
}

function excludesTextOutputModality(model: CatalogModelEntry) {
return (
Array.isArray(model.output_modalities) &&
model.output_modalities.length > 0 &&
!model.output_modalities.includes("text")
);
}

function isUsableChatModel(model: CatalogModelEntry) {
if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") {
return false;
}
if (typeof model.parent === "string" && model.parent.length > 0) return false;
if (typeof model.type === "string" && model.type !== "chat") return false;
if (typeof model.api_format === "string" && model.api_format !== "chat-completions") {
return false;
}
if (
Array.isArray(model.supported_endpoints) &&
model.supported_endpoints.length > 0 &&
!model.supported_endpoints.includes("chat")
) {
return false;
}
if (
Array.isArray(model.output_modalities) &&
model.output_modalities.length > 0 &&
!model.output_modalities.includes("text")
typeof model.api_format === "string" &&
!TEXT_GENERATION_API_FORMATS.has(model.api_format)
) {
return false;
}
if (excludesChatAndResponsesEndpoints(model)) return false;
if (excludesTextOutputModality(model)) return false;

return true;
}
Expand Down Expand Up @@ -230,9 +252,10 @@ export function enrichModelForVscode(
name: options.preserveNativeId
? getVscodeRawModelDisplayName(presentationModel)
: getVscodeModelDisplayName(presentationModel),
url: reasoningEffortValues
? `${tokenBaseUrl}/responses#models.ai.azure.com`
: `${tokenBaseUrl}/chat/completions#models.ai.azure.com`,
url:
reasoningEffortValues || usesResponsesApi(model)
? `${tokenBaseUrl}/responses#models.ai.azure.com`
: `${tokenBaseUrl}/chat/completions#models.ai.azure.com`,
toolCalling: resolvedCapabilities.toolCalling === true,
vision: resolvedCapabilities.supportsVision === true,
maxInputTokens:
Expand Down
22 changes: 21 additions & 1 deletion src/lib/vscode/reasoningMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,28 @@ export function getReasoningVariantBaseModelId(modelId: string) {
return matchReasoningEffortSuffix(modelId)?.baseModelId || modelId;
}

function getCodexGpt56DefaultReasoningEffort(model: VscodeCatalogModel) {
const modelId = getCatalogModelName(model);
const parsed = parseModel(modelId, "");
const providerId = (parsed.provider || model.owned_by || "").trim().toLowerCase();
if (providerId !== "codex" && providerId !== "cx") return undefined;

const providerModelId = (parsed.model || model.root || modelId.split("/").pop() || modelId)
.trim()
.toLowerCase();
const match = providerModelId.match(
/^gpt-5\.6-(sol|terra|luna)(?:-(?:none|low|medium|high|xhigh|max|ultra))?$/
);
if (!match) return undefined;
return match[1] === "sol" ? "low" : "medium";
}

export function getDefaultReasoningEffort(model: VscodeCatalogModel, supportedValues?: string[]) {
return inferSelectedReasoningEffort(model, supportedValues) || DEFAULT_REASONING_EFFORT;
return (
inferSelectedReasoningEffort(model, supportedValues) ||
getCodexGpt56DefaultReasoningEffort(model) ||
DEFAULT_REASONING_EFFORT
);
}

export function buildReasoningConfigSchema(
Expand Down
6 changes: 3 additions & 3 deletions src/shared/reasoning/effortStandardization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const CANONICAL_EFFORT_VALUES = ["none", "low", "medium", "high", "xhigh"

export type CanonicalEffort = (typeof CANONICAL_EFFORT_VALUES)[number];

/** Add provider-native GPT-5.6 effort levels without widening the global request vocabulary. */
/** Use provider-native GPT-5.6 effort levels without widening the global request vocabulary. */
export function extendCodexGpt56EffortValues(
provider: string | null | undefined,
model: string | null | undefined,
Expand All @@ -39,8 +39,8 @@ export function extendCodexGpt56EffortValues(
);
if (!match) return values;

const additions = match[1] === "luna" ? ["max"] : ["max", "ultra"];
return [...new Set([...values, ...additions])];
const nativeValues = ["low", "medium", "high", "xhigh", "max"];
return match[1] === "luna" ? nativeValues : [...nativeValues, "ultra"];
}

/**
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/codex-capacity-limit-merge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";

// Isolated unit coverage for the capacity-limit merge policy, independent of
// the /api/providers/[id]/models route. See
// src/app/api/providers/[id]/models/discovery/codex.ts::mergeCapacityLimitConservatively
// and the guard test in tests/unit/provider-models-route-codex.test.ts (#7012).
const codexDiscovery = await import(
"../../src/app/api/providers/[id]/models/discovery/codex.ts"
);

function liveModel(overrides: Record<string, unknown>) {
return {
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol Live",
owned_by: "codex" as const,
apiFormat: "responses" as const,
supportedEndpoints: ["responses"] as ["responses"],
...overrides,
};
}

test("mergeCodexLiveModelsWithLocalCatalog: pinned (local) limit wins when it is SMALLER than live", () => {
const merged = codexDiscovery.mergeCodexLiveModelsWithLocalCatalog(
[liveModel({ inputTokenLimit: 999999, outputTokenLimit: 999999 })],
[
{
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
maxInputTokens: 372000,
maxOutputTokens: 128000,
},
]
);
const model = merged.find((m) => m.id === "gpt-5.6-sol");
// Pinned (372000/128000) is smaller than live (999999/999999) — the smaller,
// safer value must win so requests never overrun the account's real budget.
assert.equal(model?.inputTokenLimit, 372000);
assert.equal(model?.outputTokenLimit, 128000);
// Non-capacity fields are unaffected — live still wins on those.
assert.equal(model?.name, "GPT 5.6 Sol Live");
});

test("mergeCodexLiveModelsWithLocalCatalog: live limit wins when the pinned (local) value is LARGER", () => {
const merged = codexDiscovery.mergeCodexLiveModelsWithLocalCatalog(
[liveModel({ inputTokenLimit: 100000, outputTokenLimit: 50000 })],
[
{
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
maxInputTokens: 372000,
maxOutputTokens: 128000,
},
]
);
const model = merged.find((m) => m.id === "gpt-5.6-sol");
// Live (100000/50000) is smaller than pinned (372000/128000) here — the
// smaller live value must win, not the larger pinned contract.
assert.equal(model?.inputTokenLimit, 100000);
assert.equal(model?.outputTokenLimit, 50000);
});

test("mergeCodexLiveModelsWithLocalCatalog: uses whichever side has a value when only one side defines it", () => {
const liveOnly = codexDiscovery.mergeCodexLiveModelsWithLocalCatalog(
[liveModel({ inputTokenLimit: 200000 })],
[{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }]
);
assert.equal(liveOnly.find((m) => m.id === "gpt-5.6-sol")?.inputTokenLimit, 200000);

const pinnedOnly = codexDiscovery.mergeCodexLiveModelsWithLocalCatalog(
[liveModel({ inputTokenLimit: undefined })],
[{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", maxInputTokens: 372000 }]
);
assert.equal(pinnedOnly.find((m) => m.id === "gpt-5.6-sol")?.inputTokenLimit, 372000);
});
2 changes: 1 addition & 1 deletion tests/unit/codex-gpt56-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ test("Codex catalog exposes the GPT-5.6 lineup in configured priority order", ()
for (const modelId of expectedIds) {
const model = models.find((entry) => entry.id === modelId);
assert.ok(model, `codex must expose ${modelId}`);
assert.equal(model.contextLength, 500000);
assert.equal(model.contextLength, 372000);
assert.equal(model.maxInputTokens, 372000);
assert.equal(model.maxOutputTokens, 128000);
assert.equal(model.targetFormat, "openai-responses");
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/executor-codex-gpt56.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@ import test from "node:test";
import assert from "node:assert/strict";

import { CodexExecutor } from "../../open-sse/executors/codex.ts";
import { openaiToOpenAIResponsesRequest } from "../../open-sse/translator/request/openai-responses/toResponses.ts";

test("Chat-to-Codex translation preserves max only for GPT-5.6", () => {
const executor = new CodexExecutor();
const cases = [
{ model: "gpt-5.6-sol", expectedEffort: "max" },
{ model: "gpt-5.6-terra", expectedEffort: "max" },
{ model: "gpt-5.6-luna", expectedEffort: "max" },
{ model: "gpt-5.5", expectedEffort: "xhigh" },
];

for (const { model, expectedEffort } of cases) {
const translated = openaiToOpenAIResponsesRequest(
model,
{
model,
messages: [{ role: "user", content: "test" }],
reasoning_effort: "max",
},
true,
{}
);
const result = executor.transformRequest(model, translated, true, {
requestEndpointPath: "/chat/completions",
});

assert.equal(result.model, model);
assert.equal(result.reasoning.effort, expectedEffort, model);
assert.equal(result.reasoning_effort, undefined);
}
});

test("CodexExecutor.transformRequest preserves max effort for GPT-5.6", () => {
const executor = new CodexExecutor();
Expand Down
Loading
Loading