Skip to content
Open
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
26 changes: 23 additions & 3 deletions packages/cli/src/adapters/codex-api-format.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
/**
* CodexAPIFormat — Layer 1 wire format for the OpenAI Responses API (Codex models).
* CodexAPIFormat — Layer 1 wire format for the OpenAI Responses API.
*
* The Codex Responses API is a distinct wire format from Chat Completions:
* The Responses API is a distinct wire format from Chat Completions:
* - Uses 'input' instead of 'messages'
* - Uses 'instructions' instead of 'system' messages
* - Uses 'max_output_tokens' instead of 'max_tokens'
* - Tools are flattened (no 'function' wrapper)
* - SSE events use different event names (response.output_text.delta etc.)
*
* This format handles Codex models only. All other OpenAI models use OpenAIAPIFormat.
* This format handles Codex-family and Responses-only OpenAI models.
*/

import { BaseAPIFormat, type AdapterResult, matchesModelFamily } from "./base-api-format.js";
import type { StreamFormat } from "../providers/transport/types.js";
import { log } from "../logger.js";
import { resolveOpenAIReasoningEffort } from "./openai-reasoning.js";

export class CodexAPIFormat extends BaseAPIFormat {
constructor(modelId: string) {
Expand All @@ -39,6 +41,18 @@ export class CodexAPIFormat extends BaseAPIFormat {
return "openai-responses-sse";
}

override prepareRequest(request: any, originalRequest: any): any {
const reasoning = resolveOpenAIReasoningEffort(this.modelId, originalRequest);
if (reasoning) {
request.reasoning = { effort: reasoning.effort };
delete request.thinking;
log(`[CodexAPIFormat] Mapped ${reasoning.source} -> reasoning.effort: ${reasoning.effort}`);
}

this.truncateToolNames(request);
return request;
}

override getContextWindow(): number {
// Codex models: use a safe default
return 200_000;
Expand All @@ -61,6 +75,12 @@ export class CodexAPIFormat extends BaseAPIFormat {
payload.max_output_tokens = Math.max(16, claudeRequest.max_tokens);
}

const reasoning = resolveOpenAIReasoningEffort(this.modelId, claudeRequest);
if (reasoning) {
payload.reasoning = { effort: reasoning.effort };
log(`[CodexAPIFormat] Mapped ${reasoning.source} -> reasoning.effort: ${reasoning.effort}`);
}

if (tools.length > 0) {
payload.tools = tools.map((tool: any) => {
if (tool.type === "function" && tool.function) {
Expand Down
52 changes: 30 additions & 22 deletions packages/cli/src/adapters/openai-api-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@
* OpenAIAPIFormat — Layer 1 wire format for OpenAI Chat Completions API.
*
* Handles:
* - Context window detection for OpenAI models (gpt-*, o1, o3, codex)
* - Mapping 'thinking.budget_tokens' to 'reasoning_effort' for o1/o3 models
* - Context window detection for OpenAI models (gpt-*, o1, o3)
* - Mapping Claude thinking/output_config to OpenAI reasoning_effort
* - max_completion_tokens vs max_tokens for newer models
* - Codex Responses API message conversion and payload building
* - Tool choice mapping
*
* Also serves as Layer 2 ModelDialect for OpenAI-native models (o1/o3 reasoning params).
* Also serves as Layer 2 ModelDialect for OpenAI-native chat models.
*/

import { BaseAPIFormat, type AdapterResult } from "./base-api-format.js";
import { log } from "../logger.js";
import type { StreamFormat } from "../providers/transport/types.js";
import {
isOpenAIChatModel,
mapBudgetTokensToReasoningEffort,
resolveOpenAIReasoningEffort,
} from "./openai-reasoning.js";

export class OpenAIAPIFormat extends BaseAPIFormat {
constructor(modelId: string) {
Expand All @@ -36,17 +40,21 @@ export class OpenAIAPIFormat extends BaseAPIFormat {
* Handle request preparation — reasoning parameters and tool name truncation
*/
override prepareRequest(request: any, originalRequest: any): any {
// Map thinking.budget_tokens -> reasoning_effort for o1/o3 models
if (originalRequest.thinking && this.isReasoningModel()) {
const { budget_tokens } = originalRequest.thinking;
let effort = "medium";
if (budget_tokens < 4000) effort = "minimal";
else if (budget_tokens < 16000) effort = "low";
else if (budget_tokens >= 32000) effort = "high";

const reasoning = resolveOpenAIReasoningEffort(this.modelId, originalRequest);
if (reasoning) {
request.reasoning_effort = reasoning.effort;
delete request.thinking;
log(`[OpenAIAPIFormat] Mapped ${reasoning.source} -> reasoning_effort: ${reasoning.effort}`);
} else if (originalRequest.thinking?.budget_tokens !== undefined && this.isReasoningModel()) {
const effort = mapBudgetTokensToReasoningEffort(originalRequest.thinking.budget_tokens);
request.reasoning_effort = effort;
delete request.thinking;
log(`[OpenAIAPIFormat] Mapped budget ${budget_tokens} -> reasoning_effort: ${effort}`);
log(
`[OpenAIAPIFormat] Mapped thinking.budget_tokens ${originalRequest.thinking.budget_tokens} -> reasoning_effort: ${effort}`
);
} else if (request.thinking && isOpenAIChatModel(this.modelId)) {
delete request.thinking;
log(`[OpenAIAPIFormat] Stripped unsupported thinking params for ${this.modelId}`);
}

// Truncate tool names if model has a limit
Expand All @@ -59,7 +67,8 @@ export class OpenAIAPIFormat extends BaseAPIFormat {
}

shouldHandle(modelId: string): boolean {
return modelId.startsWith("oai/") || modelId.includes("o1") || modelId.includes("o3");
const model = modelId.toLowerCase();
return isOpenAIChatModel(modelId) || model.includes("o1") || model.includes("o3");
}

getName(): string {
Expand Down Expand Up @@ -130,16 +139,15 @@ export class OpenAIAPIFormat extends BaseAPIFormat {
}
}

// Reasoning params handled in prepareRequest instead
if (claudeRequest.thinking && this.isReasoningModel()) {
const { budget_tokens } = claudeRequest.thinking;
let effort = "medium";
if (budget_tokens < 4000) effort = "minimal";
else if (budget_tokens < 16000) effort = "low";
else if (budget_tokens >= 32000) effort = "high";
const reasoning = resolveOpenAIReasoningEffort(this.modelId, claudeRequest);
if (reasoning) {
payload.reasoning_effort = reasoning.effort;
log(`[OpenAIAPIFormat] Mapped ${reasoning.source} -> reasoning_effort: ${reasoning.effort}`);
} else if (claudeRequest.thinking?.budget_tokens !== undefined && this.isReasoningModel()) {
const effort = mapBudgetTokensToReasoningEffort(claudeRequest.thinking.budget_tokens);
payload.reasoning_effort = effort;
log(
`[OpenAIAPIFormat] Mapped thinking.budget_tokens ${budget_tokens} -> reasoning_effort: ${effort}`
`[OpenAIAPIFormat] Mapped thinking.budget_tokens ${claudeRequest.thinking.budget_tokens} -> reasoning_effort: ${effort}`
);
}

Expand Down
147 changes: 147 additions & 0 deletions packages/cli/src/adapters/openai-reasoning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
export type OpenAIReasoningEffort =
| "none"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";

interface OpenAIReasoningProfile {
supported: OpenAIReasoningEffort[];
transport: "chat" | "responses";
}

const REASONING_ORDER: OpenAIReasoningEffort[] = [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
];

const GPT5_REASONING_PROFILES: Record<string, OpenAIReasoningProfile> = {
"gpt-5": { supported: ["minimal", "low", "medium", "high"], transport: "chat" },
"gpt-5-mini": { supported: ["low", "medium", "high"], transport: "chat" },
"gpt-5-nano": { supported: [], transport: "chat" },
"gpt-5-pro": { supported: ["high"], transport: "responses" },
"gpt-5-codex": { supported: ["low", "medium", "high"], transport: "responses" },
"gpt-5.1": { supported: ["none", "low", "medium", "high"], transport: "chat" },
"gpt-5.1-codex": { supported: ["none", "low", "medium", "high"], transport: "responses" },
"gpt-5.1-codex-mini": {
supported: ["none", "low", "medium", "high"],
transport: "responses",
},
"gpt-5.1-codex-max": {
supported: ["none", "low", "medium", "high", "xhigh"],
transport: "responses",
},
"gpt-5.2": { supported: ["none", "low", "medium", "high", "xhigh"], transport: "chat" },
"gpt-5.2-pro": { supported: ["medium", "high", "xhigh"], transport: "responses" },
"gpt-5.2-codex": { supported: ["low", "medium", "high", "xhigh"], transport: "responses" },
"gpt-5.3-codex": { supported: ["low", "medium", "high", "xhigh"], transport: "responses" },
"gpt-5.4": { supported: ["none", "low", "medium", "high", "xhigh"], transport: "chat" },
"gpt-5.4-pro": { supported: ["medium", "high", "xhigh"], transport: "responses" },
"gpt-5.4-mini": { supported: ["none", "low", "medium", "high"], transport: "chat" },
"gpt-5.4-nano": { supported: [], transport: "chat" },
};

function normalizeModelId(modelId: string): string {
const lower = modelId.toLowerCase();
const bare = lower.split("/").pop() || lower;

const knownModelIds = [
"gpt-5.4-pro",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-5.2-pro",
"gpt-5.2",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.1-codex",
"gpt-5.1",
"gpt-5-codex",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5-pro",
"gpt-5",
];

return knownModelIds.find((id) => bare === id || bare.startsWith(`${id}-`)) || bare;
}

function getReasoningProfile(modelId: string): OpenAIReasoningProfile | null {
return GPT5_REASONING_PROFILES[normalizeModelId(modelId)] || null;
}

function clampReasoningEffortUpward(
desired: OpenAIReasoningEffort,
supported: OpenAIReasoningEffort[]
): OpenAIReasoningEffort | null {
if (supported.length === 0) return null;

const startIndex = REASONING_ORDER.indexOf(desired);
for (let index = startIndex; index < REASONING_ORDER.length; index++) {
const candidate = REASONING_ORDER[index];
if (supported.includes(candidate)) return candidate;
}

if (supported.includes("high")) return "high";
return supported[supported.length - 1] || null;
}

export function mapBudgetTokensToReasoningEffort(budgetTokens: number): OpenAIReasoningEffort {
let effort: OpenAIReasoningEffort = "medium";
if (budgetTokens < 4000) effort = "minimal";
else if (budgetTokens < 16000) effort = "low";
else if (budgetTokens >= 32000) effort = "high";
return effort;
}

export function isOpenAIChatModel(modelId: string): boolean {
return getReasoningProfile(modelId)?.transport === "chat";
}

export function isOpenAIResponsesModel(modelId: string): boolean {
return getReasoningProfile(modelId)?.transport === "responses";
}

export function resolveOpenAIReasoningEffort(
modelId: string,
claudeRequest: any
): { effort: OpenAIReasoningEffort; source: string } | null {
const profile = getReasoningProfile(modelId);
if (!profile || profile.supported.length === 0) return null;

if (claudeRequest?.thinking?.type === "disabled") {
const effort = clampReasoningEffortUpward("none", profile.supported);
return effort ? { effort, source: 'thinking.type="disabled"' } : null;
}

const effortParam = claudeRequest?.output_config?.effort;
if (effortParam === "low" || effortParam === "medium" || effortParam === "high") {
const effort = clampReasoningEffortUpward(effortParam, profile.supported);
return effort ? { effort, source: `output_config.effort=${effortParam}` } : null;
}

if (effortParam === "max") {
const effort = clampReasoningEffortUpward("xhigh", profile.supported);
return effort ? { effort, source: "output_config.effort=max" } : null;
}

const budgetTokens = claudeRequest?.thinking?.budget_tokens;
if (typeof budgetTokens === "number") {
const desired = mapBudgetTokensToReasoningEffort(budgetTokens);
const effort = clampReasoningEffortUpward(desired, profile.supported);
return effort ? { effort, source: `thinking.budget_tokens=${budgetTokens}` } : null;
}

if (claudeRequest?.thinking?.type === "adaptive") {
const effort = clampReasoningEffortUpward("high", profile.supported);
return effort ? { effort, source: 'thinking.type="adaptive"' } : null;
}

return null;
}
Loading