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
17 changes: 17 additions & 0 deletions devlog/_plan/260912_cache_lane/025_affinity_native.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Final native affinity after preliminary Go route

Previous D: prefix implemented; confirmed P2 on #4340 requires correction before integration. Source https://github.com/lidge-jun/opencodex/pull/4340#discussion_r3995130580. Class C3 transport identity; same authorized runtime/no-local-suites/no-merge scope. This extends the existing affinity PR, not a new independent feature.

MODIFY src/server/claude-messages.ts: remove preliminary `if (nativeRoute && !opencodeGoRoute)` session_id synthesis. Retain validated metadata UUID privately as new HandleResponsesOptions.claudeNativeSessionId, alongside claudeGoAffinity. Do not derive from system fallback. Explicit session_id is forwarded as before and wins.

MODIFY src/server/responses/core.ts: add optional `claudeNativeSessionId?: string` to internal options. Create a private `withClaudeNativeSession(headers, provider, sessionId)` helper that returns headers unchanged unless canonical OpenAI, private value present, and no explicit session_id/session-id/thread-id header. Then clone Headers and set only the cloned session_id. Apply to both finalAuth.headers and finalAuth.callerAuthHeaders after final auth resolution; alternate-account retries already consume callerAuthHeaders. Reapply to selectedForwardHeaders after a native credential refresh, whose replay result rebuilds from req. Never mutate req.headers. Policy/combo replay sees original headers and carries only the private option. Explicit underscore, hyphenated session and thread-only identity all prevent metadata synthesis. No public serialization: creation Claude handler -> recursive option spreads -> attempt-local auth/header copies -> canonical adapter.

A audit corrections: reject request-header mutation because policy fallback reuses the same request. Reject caller JWT fixture because Claude drops caller auth. Use isolated stored main under an actual admitted turn; no ambient credentials.

MODIFY tests/providers/opencode-go-session-header.test.ts: real handler random/failover Go preflight -> canonical ChatGPT fixture, valid metadata yields expected UUID, explicit native header wins, no metadata/shared-system cannot synthesize. Mock outbound fetch; isolate OPENCODEX_HOME and CODEX_HOME, store synthetic main JWT/account and use tryAdmitTurn lease with real handler logIds so existing claimed-main enrichment is reached. Add canonical failure then noncanonical policy fallback control with original request.headers unchanged; existing runPolicyFallbackHops fixture may be used to inspect header-copy boundary. Retain final non-Go no-header controls. Hosted CI only; local product checks NOT RUN. Assert actual session_id and prompt_cache_key at outbound boundary, not source text.

MODIFY structure/data-planes/inbound-compat.md final affinity contract to describe private native lane at final canonical destination; mapped links already exist. Preserve source authors. C source audit + diff check, then exact final-tip hosted run tracked in verification cycle. D records missed earlier review scenario and repair head.

Test placement amendment: NEW tests/claude-integration/claude-native-affinity.test.ts and both layout mappings instead of enlarging the existing 600-line Go suite. Same real-handler matrix plus policy wrapper with real core and controlled trace.

C review correction: normalizeLogConversationId hashes its input, so native projection retains raw validated UUID separately; only metadataGoLane uses normalized hash. Preserve fixed historical UUID oracle, no cache-identity migration.
7 changes: 7 additions & 0 deletions devlog/_plan/260912_cache_lane/026_affinity_adapt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Repaired affinity current-dev adaptation

Previous D: prefix adaptation completed. Live #4340 now CONFLICTING with current dev. Class C2 same owned-branch adaptation, no local suites/build/typecheck/install, no merge. Rebase own three commits after30d5016a onto5042a376. Preserve exact original affinity runtime patch and native-repair delta at37a4e6b65, all credits. No changes to other lane branches.

MODIFY conflict resolutions in13 mapped structure docs: union complete new-base helper contracts with original Go affinity links/section. Runtime.md also preserves newer continuation paragraph. src/server/responses/core.ts and layout files auto-merge, independently compare patch additions/deletions to old range. Later native-repair append may conflict at inbound-compat tail; preserve both current-base/Go/native paragraphs exactly. Add this026 checkpoint only.

C compares old30d5016a..37a4e6b65 to new5042a376..newhead, exact runtime/tests range-diff and doc-union source audit. Push no-verify with exact old-head force lease, then new-tip hosted CI; parent owns merge.
3 changes: 3 additions & 0 deletions devlog/_plan/260912_cache_lane/027_affinity_slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Serial affinity integration slot

Parent pinned dev10c73569e9141f61c363b5fb61963d5c27e174d9 after4342 and reserved affinity-first integration. Previous D Hermes contract complete, live acceptance open. Rebase only own four commits after5042a376 onto parent-pinned10c73569; old headf58cb87b1c. Same026 audited append-union mechanism and no local suite/build/typecheck/install/merge. Keep all current-base source/docs and preserve own authored runtime/test bytes, credits and025/026 records. Conflict resolution scope is mapped structure docs; stop to audit unexpected runtime conflicts. Range-diff confirms source/tests unchanged; independent reviewer checks exact resulting doc union and head. Lease push pinned to oldf58cb, parent merges next. Prefix stays untouched until parent gives next base.
12 changes: 10 additions & 2 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,8 +476,16 @@ inbound value is treated as client input and
hashed into Go affinity; the internal bridge carries the original value, so native
Chat, bridged Chat, and Responses derive the same result. Explicit provider-config
session headers are operator overrides and are sent unchanged. Clients must keep the
identifier stable within a conversation and distinct across conversations; requests
without a session identifier cannot receive automatic session affinity.
identifier stable within a conversation and distinct across conversations. A request
without any session identifier is not given an inferred cross-request identity; it is
instead sent under a session allocated for that request alone, isolated from every
other request (see the provider reference for how that value is carried).
For Claude Messages, configured OpenCode Go session headers remain authoritative.
Otherwise, valid explicit session or thread headers take precedence, and valid
conversation identity in `metadata.user_id` supplies the fallback. This fallback is
Comment thread
coderabbitai[bot] marked this conversation as resolved.
applied to the final Go destination, including random combo selections and fallback
attempts, rather than the preliminary route. Shared system-prompt cache keys do
not identify conversations, and Go-specific identity is not sent to non-Go targets.
Generated Pi provider configurations enable `compat.sendSessionAffinityHeaders`
so Pi sends its per-session identity to the proxy. Existing manually managed Pi
configurations can set this option on their `opencodex` provider as well.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@
"claude-messages-endpoint.test.ts": "claude-integration",
"claude-model-info.test.ts": "claude-integration",
"claude-models-discovery.test.ts": "claude-integration",
"claude-native-affinity.test.ts": "claude-integration",
"claude-native-passthrough.test.ts": "claude-integration",
"claude-outbound.test.ts": "claude-integration",
"claude-shell-hook.test.ts": "claude-integration",
Expand Down
55 changes: 25 additions & 30 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ import {
import { clearableDeadline, idleDeadline } from "../lib/abort";
import { estimateTokens } from "../lib/token-estimate";
import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
import { registryEntryForProviderDestination } from "../providers/registry";
import { evidenceFromBody } from "../routing/request-evidence";
import { resolveWireProtocolOverride } from "./adapter-resolve";
import type { OcxConfig } from "../types";
import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
import {
conversationIdFromClaudeMetadata,
getOrAllocateRequestSessionLane,
linkRequestSessionLane,
normalizeLogConversationId,
sessionLaneIdFromRequest,
Expand Down Expand Up @@ -637,6 +637,12 @@ export async function handleClaudeMessages(
}
}

/**
* Translate a Claude Messages request, route it through the Responses pipeline,
* and translate the reply back. Runs under a translator budget owned by the
* caller; Go session affinity is derived here and handed to the final Go
* transport out of band rather than through replay headers.
*/
async function handleClaudeMessagesWithBudget(
req: Request,
config: OcxConfig,
Expand Down Expand Up @@ -791,19 +797,13 @@ async function handleClaudeMessagesWithBudget(
// Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped
// bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens",
// verified live 2026-07-11). Strip them for that route; routed providers keep them.
let nativeRoute = false;
let opencodeGoRoute = false;
try {
const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
// Match the fixed key-auth destination before per-model wire overrides, including
// renamed Go providers without treating custom or lookalike URLs as Go.
opencodeGoRoute = registryEntryForProviderDestination(route.provider)?.id === "opencode-go";
// Settle the wire once so the sampling decision below reads the effective
// adapter rather than the provider-wide default (#404).
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic");
logCtx.routeDecision = route.routeDecision;
if (route.provider.adapter === "openai-responses") {
nativeRoute = true;
delete internalBody.max_output_tokens;
delete internalBody.temperature;
delete internalBody.top_p;
Expand Down Expand Up @@ -867,30 +867,23 @@ async function handleClaudeMessagesWithBudget(
};
}
}
if (opencodeGoRoute) {
const session = req.headers.get("x-opencode-session");
if (session) headers.set("x-opencode-session", session);
}
const hasExplicitGoSession = opencodeGoRoute
&& (sessionLaneIdFromRequest(headers) !== undefined
|| normalizeLogConversationId(headers.get("x-opencode-session")) !== undefined);
const synthesizeGoSession = opencodeGoRoute && !hasExplicitGoSession
// Carry Go identity out of band: a combo's preflight target may differ from its
// actual dispatch/fallback target. Never add Go-only identity to replay headers.
const claudeNativeSessionId = cacheKeySource === "metadata"
&& typeof internalBody.prompt_cache_key === "string"
&& isRec(anthropicBody)
&& conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined;
// Go can also use the Responses adapter; its eligibility gate must win on both wires.
if (opencodeGoRoute ? synthesizeGoSession : nativeRoute) {
// ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex
// clients always send their session uuid; devlog 090 follow-up: body-level
// prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends
// the header, so synthesize a stable per-session uuid from the same cache key.
// Routed Go requests need this lane too for their x-opencode-session affinity —
// but ONLY for a real per-session key (metadata.user_id). The system-hash fallback
// key is shared across Desktop conversations, and a shared session_id's backend
// semantics are unproven (audit 133 R2#3): body prompt_cache_key only there.
if (cacheKeySource === "metadata" && (synthesizeGoSession || !headers.has("session_id")) && typeof internalBody.prompt_cache_key === "string") {
headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key));
}
}
&& conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined
? uuidFromHex(internalBody.prompt_cache_key)
: undefined;
const metadataGoLane = normalizeLogConversationId(claudeNativeSessionId);
// Without any valid conversation identity, fall back to the request-scoped lane
// allocated on the admitted client request (#4172): stable across retries and
// route reconstruction, distinct per request, and never derived from a shared
// system-prompt cache key or from a later synthesized native session_id header.
const claudeGoSessionLane = sessionLaneIdFromRequest(headers)
?? normalizeLogConversationId(req.headers.get("x-opencode-session"))
?? metadataGoLane
?? getOrAllocateRequestSessionLane(req);
let internalReq: Request;
try {
// The UTF-16 JSON string and the Request's UTF-8 body coexist until dispatch.
Expand Down Expand Up @@ -934,6 +927,8 @@ async function handleClaudeMessagesWithBudget(
// Without this the replay would look native and a Responses-scoped wire default
// would fire, disagreeing with the pre-flight decision above.
inboundWire: "anthropic",
claudeGoAffinity: { sessionLane: claudeGoSessionLane },
claudeNativeSessionId,
stripClaudeMainAuthForNoncanonicalForward: true,
...(trustedClaudeMainAuth ? { trustedClaudeMainAuth } : {}),
// Claude's internal stored-main enrichment is not an original caller credential.
Expand Down
24 changes: 20 additions & 4 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,10 @@ export interface ConsumedComboFailure {


export interface HandleResponsesOptions {
/** Internal Claude replay identity; consumed only by the final canonical Go transport. */
claudeGoAffinity?: { sessionLane?: string };
/** Validated Claude metadata identity; projected only into final canonical attempt headers. */
claudeNativeSessionId?: string;
/** Original live policy owner; separate from caller-specific routing/sidecar snapshots. */
codexAuthPolicy?: CodexAuthPolicyConfig;
turnAdmissionLease?: AdmissionLease;
Expand Down Expand Up @@ -2051,6 +2055,15 @@ function canPassThroughEncryptedV2AgentTask(
).adapter === "openai-responses";
}

/** Keep synthesized Claude identity out of request headers reused by policy/combo fallback. */
function withClaudeNativeSession(headers: Headers, provider: OcxProviderConfig, sessionId?: string): Headers {
if (!sessionId || !isCanonicalOpenAiForwardProvider(provider)
|| headers.has("session_id") || headers.has("session-id") || headers.has("thread-id")) return headers;
const forwarded = new Headers(headers);
forwarded.set("session_id", sessionId);
return forwarded;
}

type ResponsesAuthResolution =
| { ok: true; authCtx: CodexAuthContext; headers: Headers; callerAuthHeaders: Headers; substituteMainCredential: boolean }
| { ok: false; response: Response };
Expand Down Expand Up @@ -2485,6 +2498,7 @@ async function applyFinalRouteRequestNormalization(args: {
logCtx: RequestLogContext;
inboundWire: InboundWire;
inboundTransport?: "websocket";
claudeGoAffinity?: HandleResponsesOptions["claudeGoAffinity"];
}): Promise<void> {
const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args;
const effortSelector = prepareEffortNormalization(parsed, route);
Expand Down Expand Up @@ -2512,7 +2526,8 @@ async function applyFinalRouteRequestNormalization(args: {

// Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter
// this request will actually use (#404).
route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req));
route.provider = resolveOpenCodeGoTransport(route.provider,
args.claudeGoAffinity ? args.claudeGoAffinity.sessionLane : getOrAllocateRequestSessionLane(req));
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId;
logCtx.model = route.modelId;
Expand Down Expand Up @@ -3934,6 +3949,7 @@ async function handleResponsesInner(
logCtx,
inboundWire,
inboundTransport: options.inboundTransport,
claudeGoAffinity: options.claudeGoAffinity,
});
// Attribute local auth/cooldown failures to the public selector too; exact auth may fail before
// the normal post-resolution provider label is assigned.
Expand Down Expand Up @@ -3995,8 +4011,8 @@ async function handleResponsesInner(
const finalAuth = await resolveResponsesCodexAuth(req, config, route, options, credentialDomainWasRewritten);
if (!finalAuth.ok) return finalAuth.response;
authCtx = finalAuth.authCtx;
selectedForwardHeaders = finalAuth.headers;
callerAuthHeaders = finalAuth.callerAuthHeaders;
selectedForwardHeaders = withClaudeNativeSession(finalAuth.headers, route.provider, options.claudeNativeSessionId);
callerAuthHeaders = withClaudeNativeSession(finalAuth.callerAuthHeaders, route.provider, options.claudeNativeSessionId);
substituteMainCredential = finalAuth.substituteMainCredential;
}

Expand Down Expand Up @@ -5379,7 +5395,7 @@ async function handleResponsesInner(
}
authCtx = replay.authCtx;
route.provider = replay.provider;
selectedForwardHeaders = replay.headers;
selectedForwardHeaders = withClaudeNativeSession(replay.headers, replay.provider, options.claudeNativeSessionId);
const replayAdapter = resolveSelectionAdapter(
resolveWireProtocolOverride(route.providerName, route.modelId, replay.provider, inboundWire),
config.cacheRetention,
Expand Down
3 changes: 3 additions & 0 deletions structure/adapters/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,6 @@ claims stored main, after terminal vision, routed vision and search exclusions.

The management quota DTO keeps Combo editing aligned with scoped inference evidence;
see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota).

Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch)
privately to final dispatch; preliminary route selection does not inject Go-only headers.
3 changes: 3 additions & 0 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,6 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-
## Paginated history writer boundary

`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits.

Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch)
privately to final dispatch; preliminary route selection does not inject Go-only headers.
3 changes: 3 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,6 @@ claims stored main, after terminal vision, routed vision and search exclusions.

The management quota DTO keeps Combo editing aligned with scoped inference evidence;
see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota).

Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch)
privately to final dispatch; preliminary route selection does not inject Go-only headers.
3 changes: 3 additions & 0 deletions structure/data-planes/images.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,6 @@ claims stored main, after terminal vision, routed vision and search exclusions.

The management quota DTO keeps Combo editing aligned with scoped inference evidence;
see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota).

Claude replay carries [Go conversation affinity](inbound-compat.md#claude-affinity-at-final-go-dispatch)
privately to final dispatch; preliminary route selection does not inject Go-only headers.
Loading
Loading