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
2 changes: 1 addition & 1 deletion devlog/_plan/260821_bug_merge_train/000_triage_matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ adversarial xai/grok-4.6 subagent verdicts, and a final green dev CI gate.
| #2294 | fix(release): reject credential-bearing SSH remotes | 71598fa45 + hardening 2cdfba24d (train-stacked) | 3 | yes | green | MERGED to train; grok blocker fixed; re-verdict PASS; landing on dev |
| #2289 | fix(service): restart existing installs w/o re-register | 2df92a270 + locale sync 174f03b60 (train-stacked) | 2 | yes | green incl. Service lifecycle | MERGED to train; grok P2 fixed; re-verdict PASS; Closes #2287 |
| #2295 | fix(codex): recover zero-byte coordinator remnants | 6d5f0cf2c (ingw/fix-zero-byte-coordinator-2291) | 0 | yes | green | MERGED to train 728ca1e8b; suite green; landing on dev |
| #2270 | fix(responses): apply_patch on routed Responses | 398b7ade4 (fix/apply-patch-routed-lowering) | 48 | yes | Ingwannu: two CHANGES_REQUESTED resolved on this head; third review says no remaining technical blocker | Linux shards green |
| #2270 | fix(responses): apply_patch on routed Responses | 398b7ade4 + pin ec32a8d52 (train-stacked) | merged into train | yes | MERGED to train; grok P2 fixed; re-verdict PASS | Linux shards green; lidge full suite green |
| #2281 | fix: call_id thought-signature replay for Claude Code | b31f3dbed (fix/claude-code-thought-signature-replay) | 50 | no (review-ready + hygiene-blocked label) | BLOCKED state | CodeRabbit minor: normalize promptCacheKey via anthropicSessionKeyFromParts before storing as clientThreadId (core.ts ~1888-1896); lidge-jun review priority 63/80 confirms repro |
| #2296 | fix(codex): bind Desktop reconnects to one pool account | e672b0fd0 + scope fix 698228e40 (train-stacked) | 2 | yes | green | MERGED to train; grok major fixed; re-verdict PASS; landing on dev |

Expand Down
32 changes: 32 additions & 0 deletions devlog/_plan/260821_bug_merge_train/060_merge_2270.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,35 @@

48 behind; single rebase onto now-stable dev. Preserve the !isCanonicalOpenAiForwardProvider boundary (already on head 398b7ade4; maintainer review r3 found no remaining technical blocker). Review: supportsResponsesCustomTools capability plumbing (registry/derive/types), compaction-body-last reorder invariant, byte-identical non-compaction pin test.
Fork head (olddonkey/opencodex, maintainerCanModify=true): stacked commits push to the fork remote. Pre-merge: dismiss stale CHANGES_REQUESTED (converged per reviewer's own head-398b7ade4 comment) or record fresh APPROVE. Verify on REBASED head BEFORE merge: bun test tests/custom-tool-compat.test.ts tests/namespace-tool-compat.test.ts tests/openai-responses-passthrough.test.ts tests/responses-custom-tool-repair.test.ts, bun run typecheck, FULL SUITE (shared routing/adapter surface; ssh lidge if local env-limited). grok verdict. Merge, push --no-verify, dev CI green.

## Plan (live PR head 398b7ade4 — 4 commits over base 7881319e, ~50 behind dev)

The fork branch is not directly fetchable as a remote ref (fork: olddonkey);
use the PR ref. The branch carries its own rebase history — do NOT rebase the
fork branch; merge the PR ref into the TRAIN and let the train carry it.
Fork push only needed if we stack new commits on the PR itself. Steps:
1. Merge pr/2270 into train, resolve conflicts there.
2. Adversarial review (inherited model): supportsResponsesCustomTools
plumbing, compaction-body-last reorder invariant, byte-identical
non-compaction pin, !isCanonicalOpenAiForwardProvider boundary.
3. Focused custom-tool tests + typecheck + privacy locally at merged head;
lidge full suite; land via train PR to dev; dismiss stale review state via
merge admin path.

## Review (Bohr, inherited model) — GO-WITH-FIXES (blockers=0) → P2 fixed → re-verdict PASS

Clean: capability plumbing consistent (undefined/true = passthrough, false =
lowering, explicit-override precedence tested); all consumption sites behind
the exact-base-URL canonical gate; reorder fixes the real latent bug
(compaction replayed custom_tool_call reached strict upstreams unlowered)
with byte-identical non-compaction pin intact; response restoration
fail-closed via buildToolBridgeMaps; no privacy/logging regressions.

P2 fixed (commit ec32a8d52): negative pin proving the canonical Codex forward
surface ignores supportsResponsesCustomTools:false. Re-verdict: PASS.
Accepted residuals (P3): composed registry-to-handleResponses e2e,
namespace-child deny dedup coverage, tool_choice + lowered apply_patch case.

Gates at train head ec32a8d52: focused tests 138/138 (+ pin 100/100),
typecheck pass, privacy:scan pass, lidge r9 full suite 14233 pass / 0 fail
exit 0 at 668512a58 + pin-only delta after.
19 changes: 12 additions & 7 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1731,17 +1731,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
// that already recorded a single-query web_search_call replays it every turn, and
// a strict parser rejects the whole request over it (#930).
outBody = backfillWebSearchQueries(outBody);
// Same predicate as the routedCompaction gate in handleResponses(): an
// authMode check would let a noncanonical custom forward provider skip this
// rewrite while the server still routes it as a summarizer turn (#422).
if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
outBody = buildRoutedCompactionBody(outBody);
}
if (!isCanonicalOpenAiForwardProvider(provider)) {
outBody = promoteClientLoadedTools(outBody);
}
if (!isCanonicalOpenAiForwardProvider(provider)) {
const rewritten = rewriteRoutedCustomToolsForUpstream(outBody);
const rewritten = rewriteRoutedCustomToolsForUpstream(
outBody,
provider.supportsResponsesCustomTools,
);
outBody = rewritten.body;
convertedRoutedCustomToolNames = rewritten.names;
}
Expand Down Expand Up @@ -1770,6 +1767,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
// Last, so promoted namespace children are also cleared of Codex-private fields.
outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false);
}
// Same predicate as the routedCompaction gate in handleResponses(): an authMode check would
// let a noncanonical custom forward provider skip this rewrite while the server still routes
// it as a summarizer turn (#422). The compaction body build removes the tool surface and must
// therefore be the last routed transform: anything before it may depend on the declarations;
// anything after it cannot.
if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
outBody = buildRoutedCompactionBody(outBody);
}
const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true;
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(
outBody,
Expand Down
3 changes: 3 additions & 0 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
if (prov.supportsOpenAiWebSearchToolFields === undefined && entry.supportsOpenAiWebSearchToolFields !== undefined) {
prov.supportsOpenAiWebSearchToolFields = entry.supportsOpenAiWebSearchToolFields;
}
if (prov.supportsResponsesCustomTools === undefined && entry.supportsResponsesCustomTools !== undefined) {
prov.supportsResponsesCustomTools = entry.supportsResponsesCustomTools;
}
if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent;
applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries);
applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov));
Expand Down
5 changes: 5 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ export interface ProviderRegistryEntry {
supportsServiceTier?: boolean;
/** Registry default for OpenAI extended hosted web_search field support. */
supportsOpenAiWebSearchToolFields?: boolean;
/** Registry default for native Responses custom-tool support. */
supportsResponsesCustomTools?: boolean;
/** Registry default for exact model service-tier capability; explicit config keys win. */
modelSupportsServiceTier?: Record<string, boolean>;
/**
Expand Down Expand Up @@ -1047,6 +1049,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
oauthId: "xai",
jawcodeBundle: "xai",
supportsOpenAiWebSearchToolFields: false,
// Live A/B on 2026-08-20: xAI rejects native custom/custom_tool_call shapes while accepting
// the otherwise-identical request after the custom tool is lowered to a function.
supportsResponsesCustomTools: false,
note: "Log in with your Grok account",
// Parallel tool calls: officially supported and default-on per docs.x.ai function-calling
// (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole
Expand Down
32 changes: 24 additions & 8 deletions src/responses/custom-tool-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { collectResponsesToolGroups } from "./tool-groups";
const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]);
const BUILTIN_FUNCTIONS_NAMESPACE = "functions";

function routedCustomToolPassesThrough(
name: string,
supportsResponsesCustomTools: boolean | undefined,
): boolean {
return supportsResponsesCustomTools !== false && ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(name);
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
Expand Down Expand Up @@ -34,7 +41,10 @@ export function routedCustomToolWireName(value: unknown): string | undefined {
* Names of converted custom declarations after namespace lowering. Restoration uses these exact
* wire identities so same-named function and custom children in different namespaces stay distinct.
*/
function collectRoutedCustomToolWireNames(body: unknown): Set<string> {
function collectRoutedCustomToolWireNames(
body: unknown,
supportsResponsesCustomTools?: boolean,
): Set<string> {
const names = new Set<string>();
const groups = collectResponsesToolGroups(body);
const bareWireNames = new Set<string>();
Expand All @@ -54,7 +64,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set<string> {
if (
tool.type === "custom"
&& typeof tool.name === "string"
&& !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(tool.name)
&& !routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools)
) {
names.add(tool.name);
continue;
Expand All @@ -67,7 +77,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set<string> {
isPlainObject(child)
&& child.type === "custom"
&& typeof child.name === "string"
&& !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(child.name)
&& !routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools)
&& !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name))
) names.add(customToolWireName(tool.name, child.name));
}
Expand All @@ -81,7 +91,10 @@ export function customToolItemId(id: unknown): unknown {
return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id;
}

export function collectRoutedCustomToolNames(body: unknown): Set<string> {
export function collectRoutedCustomToolNames(
body: unknown,
supportsResponsesCustomTools?: boolean,
): Set<string> {
const names = new Set<string>();
const visit = (value: unknown): void => {
if (Array.isArray(value)) {
Expand All @@ -92,7 +105,7 @@ export function collectRoutedCustomToolNames(body: unknown): Set<string> {
if (
value.type === "custom"
&& typeof value.name === "string"
&& !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name)
&& !routedCustomToolPassesThrough(value.name, supportsResponsesCustomTools)
) {
names.add(value.name);
}
Expand Down Expand Up @@ -184,12 +197,15 @@ function rewriteForUpstream(
return changed ? next : value;
}

export function rewriteRoutedCustomToolsForUpstream(body: unknown): {
export function rewriteRoutedCustomToolsForUpstream(
body: unknown,
supportsResponsesCustomTools?: boolean,
): {
body: unknown;
names: Set<string>;
} {
const conversionNames = collectRoutedCustomToolNames(body);
const names = collectRoutedCustomToolWireNames(body);
const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools);
const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools);
if (conversionNames.size === 0) return { body, names };
const callIds = new Set<string>();
collectConvertedCallIds(body, conversionNames, callIds);
Expand Down
5 changes: 2 additions & 3 deletions src/responses/namespace-tool-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,8 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): {
const groups = collectResponsesToolGroups(body);
const plan = buildRewritePlan(groups);

// Deliberately not gated on the plan being non-empty: a turn whose catalog is gone still replays
// call items carrying a private `namespace`, and the routed compaction turn strips the whole tool
// surface before this runs.
// Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still
// replay call items carrying a private `namespace`.
const emitted = new Set<string>();
const tools = Array.isArray(body.tools) ? rewriteToolList(body.tools, plan, emitted) : body.tools;

Expand Down
3 changes: 3 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
&& registryEntry.supportsOpenAiWebSearchToolFields !== undefined
? { supportsOpenAiWebSearchToolFields: registryEntry.supportsOpenAiWebSearchToolFields }
: {}),
...(provider.supportsResponsesCustomTools === undefined && registryEntry.supportsResponsesCustomTools !== undefined
? { supportsResponsesCustomTools: registryEntry.supportsResponsesCustomTools }
: {}),
...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined
? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent }
: {}),
Expand Down
6 changes: 6 additions & 0 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,12 @@ export interface OcxProviderConfig {
* passthrough compatibility for OpenAI and unclassified gateways.
*/
supportsOpenAiWebSearchToolFields?: boolean;
/**
* Whether the Responses upstream accepts native custom tools and custom_tool_call items.
* Set false only for a provider whose native contract rejects them; absence preserves
* apply_patch passthrough compatibility for OpenAI and unclassified gateways.
*/
supportsResponsesCustomTools?: boolean;
/**
* Provider-local repair for Responses gateways whose lifecycle snapshots omit canonical
* fields or closing events (#893). Disabled by default and applied only to client-facing
Expand Down
11 changes: 6 additions & 5 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Responses-compatible streaming output.
- 기존 구현 및 제약 조건: The request catalog already controlled custom-tool restoration and the non-OpenAI prompt nudge, but an undeclared upstream name still fell through as an ordinary `function_call`; Codex then reduced the mismatch to a bare `aborted` result.
- 검토한 주요 대안: Rely only on prompt guidance; automatically translate undeclared `apply_patch` into Code Mode; validate returned names against the request-visible catalog at the final bridge.
- 선택한 방식: Retain the allowed wire-name set with the existing bridge maps and fail the turn with an explicit compatibility error before emitting any undeclared tool item.
- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` and tools replaced by hosted-provider policy stay in their upstream function-call form.
- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` stays in its upstream function-call form unless the destination explicitly denies Responses custom tools; tools replaced by hosted-provider policy also stay in their upstream function-call form.
- 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation.
- 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior.

Expand All @@ -69,10 +69,11 @@ Two coordinates that lower to the same wire name are treated as one tool when th
a `functions` child of the same name are the duplicate the parser already tolerates — and the one
`promoteClientLoadedTools` produces. The declaration is emitted once instead of failing the request.

Replayed call items are lowered whether or not this turn declares the group they name. A routed
compaction turn strips the whole tool surface before the boundary runs, and a catalog can change
mid-session, but the client is still replaying items this layer's own response restoration stamped
with a private `namespace`. Only `tool_choice` resolves a bare name through the catalog: a history
Replayed call items are lowered whether or not this turn declares the group they name. A catalog can
be absent or change mid-session, but the client is still replaying items this layer's own response
restoration stamped with a private `namespace`. Routed compaction runs this boundary before removing
the tool surface so request-local aliases remain available for response restoration. Only
`tool_choice` resolves a bare name through the catalog: a history
item records which tool actually ran, so re-pointing it at a same-named namespace child would
rewrite that record on a coincidence rather than translate it.

Expand Down
Loading
Loading