Files
opencode-openai-codex-auth/lib/request/request-transformer.ts
T
Numman AliandHanzo Dev 0a9834c6fc docs: Enforce GPT 5.1 full-opencode.json as only supported configuration
This PR standardizes all documentation and configuration on GPT 5.1 model identifiers, removes deprecated GPT 5.0 models, and enforces full-opencode.json as the only officially supported configuration. The changes address GPT 5 model temperamental behavior and ensure users have the proper setup for reliable operation with OpenCode.

Co-authored-by: Hanzo Dev <[email protected]>
2025-11-16 21:52:44 +00:00

450 lines
14 KiB
TypeScript

import { logDebug, logWarn } from "../logger.js";
import { TOOL_REMAP_MESSAGE } from "../prompts/codex.js";
import { CODEX_OPENCODE_BRIDGE } from "../prompts/codex-opencode-bridge.js";
import { getOpenCodeCodexPrompt } from "../prompts/opencode-codex.js";
import { getNormalizedModel } from "./helpers/model-map.js";
import type {
ConfigOptions,
InputItem,
ReasoningConfig,
RequestBody,
UserConfig,
} from "../types.js";
/**
* Normalize model name to Codex-supported variants
*
* Uses explicit model map for known models, with fallback pattern matching
* for unknown/custom model names.
*
* @param model - Original model name (e.g., "gpt-5.1-codex-low", "openai/gpt-5-codex")
* @returns Normalized model name (e.g., "gpt-5.1-codex", "gpt-5-codex")
*/
export function normalizeModel(model: string | undefined): string {
if (!model) return "gpt-5";
// Strip provider prefix if present (e.g., "openai/gpt-5-codex" → "gpt-5-codex")
const modelId = model.includes("/") ? model.split("/").pop()! : model;
// Try explicit model map first (handles all known model variants)
const mappedModel = getNormalizedModel(modelId);
if (mappedModel) {
return mappedModel;
}
// Fallback: Pattern-based matching for unknown/custom model names
// This preserves backwards compatibility with old verbose names
// like "GPT 5 Codex Low (ChatGPT Subscription)"
const normalized = modelId.toLowerCase();
// Priority order for pattern matching (most specific first):
// 1. GPT-5.1 Codex Mini
if (
normalized.includes("gpt-5.1-codex-mini") ||
normalized.includes("gpt 5.1 codex mini")
) {
return "gpt-5.1-codex-mini";
}
// 2. Legacy Codex Mini
if (
normalized.includes("codex-mini-latest") ||
normalized.includes("gpt-5-codex-mini") ||
normalized.includes("gpt 5 codex mini")
) {
return "codex-mini-latest";
}
// 3. GPT-5.1 Codex
if (
normalized.includes("gpt-5.1-codex") ||
normalized.includes("gpt 5.1 codex")
) {
return "gpt-5.1-codex";
}
// 4. GPT-5.1 (general-purpose)
if (normalized.includes("gpt-5.1") || normalized.includes("gpt 5.1")) {
return "gpt-5.1";
}
// 5. GPT-5 Codex family (any variant with "codex")
if (normalized.includes("codex")) {
return "gpt-5-codex";
}
// 6. GPT-5 family (any variant)
if (normalized.includes("gpt-5") || normalized.includes("gpt 5")) {
return "gpt-5";
}
// Default fallback
return "gpt-5";
}
/**
* Extract configuration for a specific model
* Merges global options with model-specific options (model-specific takes precedence)
* @param modelName - Model name (e.g., "gpt-5-codex")
* @param userConfig - Full user configuration object
* @returns Merged configuration for this model
*/
export function getModelConfig(
modelName: string,
userConfig: UserConfig = { global: {}, models: {} },
): ConfigOptions {
const globalOptions = userConfig.global || {};
const modelOptions = userConfig.models?.[modelName]?.options || {};
// Model-specific options override global options
return { ...globalOptions, ...modelOptions };
}
/**
* Configure reasoning parameters based on model variant and user config
*
* NOTE: This plugin follows Codex CLI defaults instead of opencode defaults because:
* - We're accessing the ChatGPT backend API (not OpenAI Platform API)
* - opencode explicitly excludes gpt-5-codex from automatic reasoning configuration
* - Codex CLI has been thoroughly tested against this backend
*
* @param originalModel - Original model name before normalization
* @param userConfig - User configuration object
* @returns Reasoning configuration
*/
export function getReasoningConfig(
originalModel: string | undefined,
userConfig: ConfigOptions = {},
): ReasoningConfig {
const normalizedOriginal = originalModel?.toLowerCase() ?? "";
const isCodexMini =
normalizedOriginal.includes("codex-mini") ||
normalizedOriginal.includes("codex mini") ||
normalizedOriginal.includes("codex_mini") ||
normalizedOriginal.includes("codex-mini-latest");
const isCodex = normalizedOriginal.includes("codex") && !isCodexMini;
const isLightweight =
!isCodexMini &&
(normalizedOriginal.includes("nano") ||
normalizedOriginal.includes("mini"));
// Default based on model type (Codex CLI defaults)
const defaultEffort: "minimal" | "low" | "medium" | "high" = isCodexMini
? "medium"
: isLightweight
? "minimal"
: "medium";
// Get user-requested effort
let effort = userConfig.reasoningEffort || defaultEffort;
if (isCodexMini) {
if (effort === "minimal" || effort === "low") {
effort = "medium";
}
if (effort !== "high") {
effort = "medium";
}
}
// Normalize "minimal" to "low" for gpt-5-codex
// Codex CLI does not provide a "minimal" preset for gpt-5-codex
// (only low/medium/high - see model_presets.rs:20-40)
if (isCodex && effort === "minimal") {
effort = "low";
}
return {
effort,
summary: userConfig.reasoningSummary || "auto", // Changed from "detailed" to match Codex CLI
};
}
/**
* Filter input array for stateless Codex API (store: false)
*
* Two transformations needed:
* 1. Remove AI SDK-specific items (not supported by Codex API)
* 2. Strip IDs from all remaining items (stateless mode)
*
* AI SDK constructs to REMOVE (not in OpenAI Responses API spec):
* - type: "item_reference" - AI SDK uses this for server-side state lookup
*
* Items to KEEP (strip IDs):
* - type: "message" - Conversation messages (provides context to LLM)
* - type: "function_call" - Tool calls from conversation
* - type: "function_call_output" - Tool results from conversation
*
* Context is maintained through:
* - Full message history (without IDs)
* - reasoning.encrypted_content (for reasoning continuity)
*
* @param input - Original input array from OpenCode/AI SDK
* @returns Filtered input array compatible with Codex API
*/
export function filterInput(
input: InputItem[] | undefined,
): InputItem[] | undefined {
if (!Array.isArray(input)) return input;
return input
.filter((item) => {
// Remove AI SDK constructs not supported by Codex API
if (item.type === "item_reference") {
return false; // AI SDK only - references server state
}
return true; // Keep all other items
})
.map((item) => {
// Strip IDs from all items (Codex API stateless mode)
if (item.id) {
const { id, ...itemWithoutId } = item;
return itemWithoutId as InputItem;
}
return item;
});
}
/**
* Check if an input item is the OpenCode system prompt
* Uses cached OpenCode codex.txt for verification with fallback to text matching
* @param item - Input item to check
* @param cachedPrompt - Cached OpenCode codex.txt content
* @returns True if this is the OpenCode system prompt
*/
export function isOpenCodeSystemPrompt(
item: InputItem,
cachedPrompt: string | null,
): boolean {
const isSystemRole = item.role === "developer" || item.role === "system";
if (!isSystemRole) return false;
const getContentText = (item: InputItem): string => {
if (typeof item.content === "string") {
return item.content;
}
if (Array.isArray(item.content)) {
return item.content
.filter((c) => c.type === "input_text" && c.text)
.map((c) => c.text)
.join("\n");
}
return "";
};
const contentText = getContentText(item);
if (!contentText) return false;
// Primary check: Compare against cached OpenCode prompt
if (cachedPrompt) {
// Exact match (trim whitespace for comparison)
if (contentText.trim() === cachedPrompt.trim()) {
return true;
}
// Partial match: Check if first 200 chars match (handles minor variations)
const contentPrefix = contentText.trim().substring(0, 200);
const cachedPrefix = cachedPrompt.trim().substring(0, 200);
if (contentPrefix === cachedPrefix) {
return true;
}
}
// Fallback check: Known OpenCode prompt signature (for safety)
// This catches the prompt even if cache fails
return contentText.startsWith("You are a coding agent running in");
}
/**
* Filter out OpenCode system prompts from input
* Used in CODEX_MODE to replace OpenCode prompts with Codex-OpenCode bridge
* @param input - Input array
* @returns Input array without OpenCode system prompts
*/
export async function filterOpenCodeSystemPrompts(
input: InputItem[] | undefined,
): Promise<InputItem[] | undefined> {
if (!Array.isArray(input)) return input;
// Fetch cached OpenCode prompt for verification
let cachedPrompt: string | null = null;
try {
cachedPrompt = await getOpenCodeCodexPrompt();
} catch {
// If fetch fails, fallback to text-based detection only
// This is safe because we still have the "starts with" check
}
return input.filter((item) => {
// Keep user messages
if (item.role === "user") return true;
// Filter out OpenCode system prompts
return !isOpenCodeSystemPrompt(item, cachedPrompt);
});
}
/**
* Add Codex-OpenCode bridge message to input if tools are present
* @param input - Input array
* @param hasTools - Whether tools are present in request
* @returns Input array with bridge message prepended if needed
*/
export function addCodexBridgeMessage(
input: InputItem[] | undefined,
hasTools: boolean,
): InputItem[] | undefined {
if (!hasTools || !Array.isArray(input)) return input;
const bridgeMessage: InputItem = {
type: "message",
role: "developer",
content: [
{
type: "input_text",
text: CODEX_OPENCODE_BRIDGE,
},
],
};
return [bridgeMessage, ...input];
}
/**
* Add tool remapping message to input if tools are present
* @param input - Input array
* @param hasTools - Whether tools are present in request
* @returns Input array with tool remap message prepended if needed
*/
export function addToolRemapMessage(
input: InputItem[] | undefined,
hasTools: boolean,
): InputItem[] | undefined {
if (!hasTools || !Array.isArray(input)) return input;
const toolRemapMessage: InputItem = {
type: "message",
role: "developer",
content: [
{
type: "input_text",
text: TOOL_REMAP_MESSAGE,
},
],
};
return [toolRemapMessage, ...input];
}
/**
* Transform request body for Codex API
*
* NOTE: Configuration follows Codex CLI patterns instead of opencode defaults:
* - opencode sets textVerbosity="low" for gpt-5, but Codex CLI uses "medium"
* - opencode excludes gpt-5-codex from reasoning configuration
* - This plugin uses store=false (stateless), requiring encrypted reasoning content
*
* @param body - Original request body
* @param codexInstructions - Codex system instructions
* @param userConfig - User configuration from loader
* @param codexMode - Enable CODEX_MODE (bridge prompt instead of tool remap) - defaults to true
* @returns Transformed request body
*/
export async function transformRequestBody(
body: RequestBody,
codexInstructions: string,
userConfig: UserConfig = { global: {}, models: {} },
codexMode = true,
): Promise<RequestBody> {
const originalModel = body.model;
const normalizedModel = normalizeModel(body.model);
// Get model-specific configuration using ORIGINAL model name (config key)
// This allows per-model options like "gpt-5-codex-low" to work correctly
const lookupModel = originalModel || normalizedModel;
const modelConfig = getModelConfig(lookupModel, userConfig);
// Debug: Log which config was resolved
logDebug(
`Model config lookup: "${lookupModel}" → normalized to "${normalizedModel}" for API`,
{
hasModelSpecificConfig: !!userConfig.models?.[lookupModel],
resolvedConfig: modelConfig,
},
);
// Normalize model name for API call
body.model = normalizedModel;
// Codex required fields
// ChatGPT backend REQUIRES store=false (confirmed via testing)
body.store = false;
body.stream = true;
body.instructions = codexInstructions;
// Prompt caching relies on the host providing a stable prompt_cache_key
// (OpenCode passes its session identifier). We no longer synthesize one here.
// Filter and transform input
if (body.input && Array.isArray(body.input)) {
// Debug: Log original input message IDs before filtering
const originalIds = body.input
.filter((item) => item.id)
.map((item) => item.id);
if (originalIds.length > 0) {
logDebug(
`Filtering ${originalIds.length} message IDs from input:`,
originalIds,
);
}
body.input = filterInput(body.input);
// Debug: Verify all IDs were removed
const remainingIds = (body.input || [])
.filter((item) => item.id)
.map((item) => item.id);
if (remainingIds.length > 0) {
logWarn(
`WARNING: ${remainingIds.length} IDs still present after filtering:`,
remainingIds,
);
} else if (originalIds.length > 0) {
logDebug(`Successfully removed all ${originalIds.length} message IDs`);
}
if (codexMode) {
// CODEX_MODE: Remove OpenCode system prompt, add bridge prompt
body.input = await filterOpenCodeSystemPrompts(body.input);
body.input = addCodexBridgeMessage(body.input, !!body.tools);
} else {
// DEFAULT MODE: Keep original behavior with tool remap message
body.input = addToolRemapMessage(body.input, !!body.tools);
}
}
// Configure reasoning (use model-specific config)
const reasoningConfig = getReasoningConfig(originalModel, modelConfig);
body.reasoning = {
...body.reasoning,
...reasoningConfig,
};
// Configure text verbosity (support user config)
// Default: "medium" (matches Codex CLI default for all GPT-5 models)
body.text = {
...body.text,
verbosity: modelConfig.textVerbosity || "medium",
};
// Add include for encrypted reasoning content
// Default: ["reasoning.encrypted_content"] (required for stateless operation with store=false)
// This allows reasoning context to persist across turns without server-side storage
body.include = modelConfig.include || ["reasoning.encrypted_content"];
// Remove unsupported parameters
body.max_output_tokens = undefined;
body.max_completion_tokens = undefined;
return body;
}