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
54 changes: 34 additions & 20 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { ProviderAdapter } from "./base";
import { debugDroppedFrame } from "../debug";
import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxToolCall, OcxUsage } from "../types";
import { namespacedToolName } from "../types";
import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
import { modelInList, namespacedToolName } from "../types";
import { mapReasoningEffort } from "../reasoning-effort";
import { contentPartsToText } from "./image";

function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
const out: unknown[] = [];
const { context, options } = parsed;
let pendingToolCallIds = new Set<string>();
Expand Down Expand Up @@ -46,11 +47,16 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
case "assistant": {
const aMsg = msg as OcxAssistantMessage;
const textParts = aMsg.content.filter(p => p.type === "text") as OcxTextContent[];
const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[];
const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
const chatMsg: Record<string, unknown> = { role: "assistant" };
if (textParts.length > 0) {
chatMsg.content = textParts.map(p => p.text).join("");
}
const reasoningContent = thinkingParts.map(p => p.thinking).join("");
if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
chatMsg.reasoning_content = reasoningContent;
}
if (toolCalls.length > 0) {
chatMsg.tool_calls = toolCalls.map(tc => ({
id: tc.id,
Expand All @@ -59,9 +65,12 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
}));
if (!chatMsg.content) chatMsg.content = null;
}
// Skip empty assistant messages (e.g. reasoning-only history items): chat APIs
// like DeepSeek reject an assistant message with neither content nor tool_calls.
if (chatMsg.content === undefined && chatMsg.tool_calls === undefined) break;
if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
chatMsg.content = "";
}
// Skip empty assistant messages: chat APIs like DeepSeek reject an assistant message
// with neither content, tool calls, nor a provider-supported reasoning_content field.
if (chatMsg.content === undefined && chatMsg.tool_calls === undefined && chatMsg.reasoning_content === undefined) break;
out.push(chatMsg);
pendingToolCallIds = new Set(toolCalls.map(tc => tc.id).filter(Boolean));
break;
Expand Down Expand Up @@ -141,7 +150,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
name: "openai-chat",

buildRequest(parsed: OcxParsedRequest) {
const messages = messagesToChatFormat(parsed);
const messages = messagesToChatFormat(parsed, provider);
const tools = toolsToChatFormat(parsed);
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice);

Expand All @@ -151,22 +160,27 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
stream: parsed.stream,
};
if (tools) body.tools = tools;
if (toolChoice !== undefined) body.tool_choice = toolChoice;
if (toolChoice !== undefined) {
body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId)
? (toolChoice === "none" ? "none" : "auto")
: toolChoice;
}
if (parsed.options.maxOutputTokens !== undefined) body.max_tokens = parsed.options.maxOutputTokens;
if (parsed.options.temperature !== undefined) body.temperature = parsed.options.temperature;
if (parsed.options.topP !== undefined) body.top_p = parsed.options.topP;
if (parsed.options.temperature !== undefined && !modelInList(provider.noTemperatureModels, parsed.modelId)) {
body.temperature = parsed.options.temperature;
}
if (parsed.options.topP !== undefined && !modelInList(provider.noTopPModels, parsed.modelId)) {
body.top_p = parsed.options.topP;
}
if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
// Some models reject a reasoning/thinking param entirely (e.g. xAI grok-build-0.1,
// grok-composer-2.5-fast). Drop reasoning_effort for them even if Codex selected an effort.
if (parsed.options.reasoning !== undefined && !provider.noReasoningModels?.includes(parsed.modelId)) {
// Forward the reasoning ladder (low/medium/high/xhigh) as-is. "minimal" (Codex-native lowest,
// widely unsupported downstream) maps to "low"; "max" isn't a real tier (no longer advertised)
// so it folds to "xhigh".
const r = parsed.options.reasoning;
body.reasoning_effort = r === "minimal" ? "low" : r === "max" ? "xhigh" : r;
const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
if (reasoningEffort !== undefined) body.reasoning_effort = reasoningEffort;
if (parsed.options.presencePenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
body.presence_penalty = parsed.options.presencePenalty;
}
if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
body.frequency_penalty = parsed.options.frequencyPenalty;
}
if (parsed.options.presencePenalty !== undefined) body.presence_penalty = parsed.options.presencePenalty;
if (parsed.options.frequencyPenalty !== undefined) body.frequency_penalty = parsed.options.frequencyPenalty;

if (parsed.stream) {
body.stream_options = { include_usage: true };
Expand Down
151 changes: 120 additions & 31 deletions src/codex-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readR
import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache";
import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
import type { OcxConfig, OcxProviderConfig } from "./types";
import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, sanitizeCodexReasoningEfforts } from "./reasoning-effort";
import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "./generated/jawcode-model-metadata";
import { shouldCaseFoldMetadataModelId } from "./providers/derive";

Expand Down Expand Up @@ -33,7 +34,7 @@ export function nativeOpenAiSlugs(): string[] {
return live.length > 0 ? live : NATIVE_OPENAI_MODELS;
}

export interface CatalogModel { id: string; provider: string; owned_by?: string; }
export interface CatalogModel { id: string; provider: string; owned_by?: string; reasoningEfforts?: string[]; contextWindow?: number; inputModalities?: string[]; }
type RawEntry = Record<string, unknown>;
const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);

Expand Down Expand Up @@ -171,19 +172,42 @@ export function loadCatalogTemplate(): RawEntry | null {
}

/**
* The reasoning ladder advertised for routed models in Codex's picker: low → medium → high → xhigh.
* This matches Codex's NATIVE catalog exactly — Codex's strict parser rejects an unknown effort like
* `max`, so it must not be advertised here. (Previously routed models were clamped down to
* low/medium/high, which dropped the `xhigh` that Codex does support.)
* Codex only accepts its native labels in the catalog. Provider-specific wire values (e.g. Z.AI
* `max`) are mapped at request time by src/reasoning-effort.ts, never advertised directly here.
*/
const ROUTED_REASONING_LEVELS: { effort: string; description: string }[] = [
{ effort: "low", description: "Fast responses with lighter reasoning" },
{ effort: "medium", description: "Balances speed and reasoning depth" },
{ effort: "high", description: "Greater reasoning depth for complex problems" },
{ effort: "xhigh", description: "Extended reasoning for the hardest problems" },
];
const ROUTED_REASONING_LEVELS = CODEX_REASONING_LEVELS;

function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void {
if (!model) return;
if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
entry.context_window = model.contextWindow;
entry.max_context_window = model.contextWindow;
entry.auto_compact_token_limit = Math.floor(model.contextWindow * 0.9);
}
if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) {
entry.input_modalities = model.inputModalities;
}
}

function applyReasoningLevels(entry: RawEntry, effortsOverride?: string[]): void {
const efforts = sanitizeCodexReasoningEfforts(effortsOverride) ?? ROUTED_REASONING_LEVELS.map(l => l.effort);
const byEffort = new Map(
(Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
.map((l: { effort?: string }) => [l.effort, l]),
);
entry.supported_reasoning_levels = efforts.map(effort => {
const native = byEffort.get(effort);
if (native) return native;
return ROUTED_REASONING_LEVELS.find(l => l.effort === effort) ?? { effort, description: `${effort} reasoning` };
});
if (efforts.length === 0) {
delete entry.default_reasoning_level;
return;
}
entry.default_reasoning_level = efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
}

function deriveEntry(template: RawEntry | null, slug: string, desc: string, priority: number): RawEntry {
function deriveEntry(template: RawEntry | null, slug: string, desc: string, priority: number, model?: CatalogModel): RawEntry {
if (template) {
const e = JSON.parse(JSON.stringify(template)) as RawEntry;
e.slug = slug;
Expand All @@ -203,28 +227,24 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
`You are a coding agent powered by the ${modelName} model, served through the opencodex proxy. Do not claim to be GPT-5 or made by OpenAI.`,
);
}
// Reuse the template's level objects where they exist (correct shape/fields), synthesize the rest.
const byEffort = new Map(
(Array.isArray(e.supported_reasoning_levels) ? e.supported_reasoning_levels : [])
.map((l: { effort?: string }) => [l.effort, l]),
);
e.supported_reasoning_levels = ROUTED_REASONING_LEVELS.map(l => byEffort.get(l.effort) ?? { ...l });
e.default_reasoning_level = "medium";
applyReasoningLevels(e, model?.reasoningEfforts);
normalizeRoutedCatalogEntry(e);
applyJawcodeCatalogMetadata(e, slug);
applyCatalogModelMetadata(e, model);
}
return ensureStrictCatalogFields(normalizeServiceTiers(e));
}
// Fallback when no template is available (best-effort; strict parser may need more).
const entry: RawEntry = {
slug, display_name: slug, description: desc,
default_reasoning_level: "medium",
supported_reasoning_levels: ROUTED_REASONING_LEVELS.map(l => ({ ...l })),
shell_type: "shell_command", visibility: "list", supported_in_api: true,
priority, base_instructions: "You are a helpful coding assistant.",
...(slug.includes("/") ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
};
if (slug.includes("/")) applyReasoningLevels(entry, model?.reasoningEfforts);
else applyReasoningLevels(entry);
applyJawcodeCatalogMetadata(entry, slug);
applyCatalogModelMetadata(entry, model);
return ensureStrictCatalogFields(normalizeServiceTiers(entry));
}

Expand All @@ -247,7 +267,7 @@ export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[
}
for (const m of goModels) {
const slug = `${m.provider}/${m.id}`;
const e = deriveEntry(template, slug, `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, 5);
const e = deriveEntry(template, slug, `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, 5, m);
if (rank.has(slug)) e.priority = rank.get(slug)!;
out.push(e);
}
Expand Down Expand Up @@ -285,6 +305,61 @@ function readNativeBaseline(): Map<string, number> {
return out;
}


type ProviderModelsApiItem = {
id: string;
owned_by?: string;
max_model_len?: number;
metadata?: {
capabilities?: Record<string, unknown>;
limits?: Record<string, unknown>;
};
};

function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string): Partial<CatalogModel> {
void name;
const reasoningEfforts = configuredReasoningEfforts(prov, id);
return {
...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
};
}

function applyConfigHintsToCachedModels(name: string, prov: OcxProviderConfig, models: CatalogModel[]): CatalogModel[] {
return models.map(model => ({
...catalogHintsFromProviderConfig(name, prov, model.id),
...model,
}));
}

function isGlm52ModelId(id: string): boolean {
const normalized = id.toLowerCase();
return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
}

function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
const capabilities = item.metadata?.capabilities;
const limits = item.metadata?.limits;
const contextWindow =
typeof limits?.max_context_length === "number" ? limits.max_context_length
: typeof item.max_model_len === "number" ? item.max_model_len
: undefined;
const reasoningEfforts = capabilities && typeof capabilities.reasoning_effort === "boolean"
? (capabilities.reasoning_effort
? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
? ["low", "medium", "high", "xhigh"]
: ["low", "medium", "high"])
: [])
: undefined;
const inputModalities = capabilities && typeof capabilities.vision === "boolean"
? (capabilities.vision ? ["text", "image"] : ["text"])
: undefined;
return {
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
...(inputModalities ? { inputModalities } : {}),
};
}

/**
* Fetch a provider's `/models` (openai-chat style) with a TTL cache + stale fallback. Skips
* forward-auth providers. Fresh cache → no network; live fetch → cache the merged result;
Expand All @@ -296,21 +371,35 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
const apiKey = await resolveModelsAuthToken(name, prov);
if (prov.authMode === "oauth" && !apiKey) return []; // not logged in → skip
const fresh = getFreshCached(name, ttlMs);
if (fresh) return fresh; // dedups Codex's frequent /v1/models polling within the TTL
const configured: CatalogModel[] = (prov.models ?? []).map(id => ({ id, provider: name }));
if (fresh) return applyConfigHintsToCachedModels(name, prov, fresh); // dedups Codex's frequent /v1/models polling within the TTL
const configured: CatalogModel[] = (prov.models ?? []).map(id => ({
id,
provider: name,
...catalogHintsFromProviderConfig(name, prov, id),
}));
const { url, headers } = buildModelsRequest(prov, apiKey);
try {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return getStaleCached(name) ?? configured;
const json = await res.json() as { data?: { id: string; owned_by?: string }[] };
const live = (json.data ?? []).map(m => ({ id: m.id, provider: name, owned_by: m.owned_by }));
if (!res.ok) {
const stale = getStaleCached(name);
return stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured;
}
const json = await res.json() as { data?: ProviderModelsApiItem[] };
const live = (json.data ?? []).map(m => ({
id: m.id,
provider: name,
owned_by: m.owned_by,
...catalogHintsFromProviderConfig(name, prov, m.id),
...catalogHintsFromModelsApiItem(name, m),
}));
const liveIds = new Set(live.map(m => m.id));
// Merge explicit config additions (e.g. a model not in the provider's /models, like a new endpoint).
const merged = [...live, ...configured.filter(m => !liveIds.has(m.id))];
setCached(name, merged);
return merged;
} catch {
return getStaleCached(name) ?? configured;
const stale = getStaleCached(name);
return stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured;
}
}

Expand All @@ -325,12 +414,12 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
const lists = await Promise.all(
Object.entries(config.providers).map(([name, prov]) => fetchProviderModels(name, prov, ttlMs)),
);
const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), Object.keys(config.providers));
const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), Object.keys(config.providers), config.providers);
all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
return all;
}

export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], providerNames: string[]): CatalogModel[] {
export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], providerNames: string[], providers?: Record<string, OcxProviderConfig>): CatalogModel[] {
const out = [...models];
const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
for (const provider of providerNames) {
Expand All @@ -341,7 +430,7 @@ export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], p
const key = `${provider}/${meta.id}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ provider, id: meta.id, owned_by: provider });
out.push({ provider, id: meta.id, owned_by: provider, ...(providers?.[provider] ? catalogHintsFromProviderConfig(provider, providers[provider], meta.id) : {}) });
}
}
return out;
Expand Down
Loading