Skip to content
Closed
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
31 changes: 30 additions & 1 deletion src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { isCyberPolicyCode } from "../lib/errors";
import { redactSecretString } from "../lib/redact";
import { contentPartsToText } from "./image";
import { identifyRoutedModel } from "./identity";
import { peekReasoningForCall } from "../responses/reasoning-replay-cache";
import {
getReasoningReplayStats,
peekReasoningForCall,
recordBareToolCallSerialization,
} from "../responses/reasoning-replay-cache";
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
import {
Expand Down Expand Up @@ -193,6 +197,22 @@ function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[]
return parts;
}

// #950 diagnostics: a bare tool-call continuation for a preserveReasoningContentModels
// provider is the exact 400 shape this fix eliminates. Count every occurrence
// (privacy-safe) and surface a throttled counter line — never reasoning text.
let lastBareToolCallWarnAt = 0;
const BARE_TOOL_CALL_WARN_MIN_INTERVAL_MS = 60_000;
function noteBareToolCallSerialization(modelId: string): void {
recordBareToolCallSerialization(modelId);
const at = Date.now();
if (at - lastBareToolCallWarnAt < BARE_TOOL_CALL_WARN_MIN_INTERVAL_MS) return;
lastBareToolCallWarnAt = at;
const stats = getReasoningReplayStats();
console.warn(
`[opencodex] reasoning replay miss: bare tool-call continuation for preserveReasoningContentModels model="${modelId}" (cache hits=${stats.hits}, misses=${stats.misses}); reasoning text is never logged`,
);
}

function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
const out: unknown[] = [];
const { context, options } = parsed;
Expand Down Expand Up @@ -346,6 +366,8 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
// recorded under every call id — join unique texts only.
if (cached.length > 0) {
reasoningContent = [...new Set(cached)].join("\n");
} else {
noteBareToolCallSerialization(parsed.modelId);
}
}
if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
Expand Down Expand Up @@ -412,6 +434,13 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
? peekReasoningForCall(toolCallId, replayCacheScope)
: undefined;
if (
!cachedReasoning
&& toolCallId
&& modelInList(provider.preserveReasoningContentModels, parsed.modelId)
) {
noteBareToolCallSerialization(parsed.modelId);
}
out.push({
role: "assistant",
content: emptyAssistantContent(provider),
Expand Down
14 changes: 9 additions & 5 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,8 +827,10 @@ export function bridgeToResponsesSSE(
if (currentReasoning) closeCurrentReasoning();
if (currentRawReasoning) closeCurrentRawReasoning();
flushHiddenRawReasoning();
// Reasoning consumed by a text turn, not a tool call: no cache target.
rawReasoningForNextToolCall = "";
// Reasoning consumed by a REAL text turn, not a tool call: no cache target.
// Empty text deltas must not wipe reasoning that precedes a tool call
// (chat-completions providers emit empty content deltas mid-tool-turn).
if (event.text.length > 0) rawReasoningForNextToolCall = "";
if (currentToolCall) closeCurrentToolCall();
// Only flush on an explicit phase change. A later delta that omits `phase` must
// keep appending to the current message rather than wiping the earlier phase.
Expand Down Expand Up @@ -880,7 +882,7 @@ export function bridgeToResponsesSSE(
if (currentMsg) closeCurrentMessage("commentary");
if (currentRawReasoning) closeCurrentRawReasoning();
flushHiddenRawReasoning();
rawReasoningForNextToolCall = "";
if (event.thinking.length > 0) rawReasoningForNextToolCall = "";
if (currentToolCall) closeCurrentToolCall();
if (!currentReasoning) {
const itemId = `rs_${uuid()}`;
Expand Down Expand Up @@ -1561,7 +1563,9 @@ function buildResponseJSONWithBudget(
if (currentText && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary");
if (currentSummaryReasoning) flushSummaryReasoning();
if (currentRawReasoning) flushRawReasoning();
rawReasoningForNextToolCall = "";
// Empty text deltas (batch chat responses always carry content, often "") must
// not wipe reasoning that precedes a tool call (#950 non-streaming path).
if (e.text.length > 0) rawReasoningForNextToolCall = "";
if (currentToolCallId) flushToolCall();
// Compaction turns keep the summary out of normal message output (replay dedup — see
// bridgeToResponsesSSE); it ships only inside the synthetic compaction item below.
Expand All @@ -1580,7 +1584,7 @@ function buildResponseJSONWithBudget(
case "thinking_delta":
if (currentText) flushText("commentary");
if (currentRawReasoning) flushRawReasoning();
rawReasoningForNextToolCall = "";
if (e.thinking.length > 0) rawReasoningForNextToolCall = "";
if (currentToolCallId) flushToolCall();
{
({ value: currentSummaryReasoning, bytes: currentSummaryReasoningBytes } = appendBatchString(
Expand Down
3 changes: 2 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { Database } from "bun:sqlite";
import * as z from "zod/v4";
import { resolveOpenCodexConfigDir } from "./lib/config-dir";
import {
bumpConfigGenerationAtPath,
bumpCurrentConfigGeneration,
Expand Down Expand Up @@ -549,7 +550,7 @@ let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = n
function resolveConfigDir(): string {
const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path;
const path = raw ? resolve(expandUserPath(raw)) : join(homedir(), ".opencodex");
const path = resolveOpenCodexConfigDir();
resolvedConfigDirCache = { raw, path };
return path;
}
Expand Down
18 changes: 18 additions & 0 deletions src/lib/config-dir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Dependency-free config-directory resolution shared by config.ts and the
* reasoning replay spill (PR #1126): OPENCODEX_HOME (with `~` expansion) wins,
* otherwise <home>/.opencodex. Kept primitive on purpose so leaf modules can
* import it without pulling in the whole config surface.
*/
import { homedir } from "node:os";
import { join, resolve } from "node:path";

export function resolveOpenCodexConfigDir(
env: Record<string, string | undefined> = process.env,
): string {
const raw = env["OPENCODEX_HOME"]?.trim() || undefined;
if (!raw) return join(homedir(), ".opencodex");
if (raw === "~") return homedir();
if (raw.startsWith("~/") || raw.startsWith("~\\")) return join(homedir(), raw.slice(2));
return resolve(raw);
}
Loading
Loading