@bitkyc08/opencodex 2.47.0 → 2.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,12 +30,13 @@ import {
30
30
  import { clearableDeadline, idleDeadline } from "../lib/abort";
31
31
  import { estimateTokens } from "../lib/token-estimate";
32
32
  import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
33
+ import { registryEntryForProviderDestination } from "../providers/registry";
33
34
  import { evidenceFromBody } from "../routing/request-evidence";
34
35
  import { resolveWireProtocolOverride } from "./adapter-resolve";
35
36
  import type { OcxConfig } from "../types";
36
37
  import { readJsonRequestBody } from "./request-decompress";
37
38
  import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
38
- import { conversationIdFromClaudeMetadata } from "./request-log-conversation";
39
+ import { conversationIdFromClaudeMetadata, normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation";
39
40
  import { responseWithDeferredRequestLog } from "./relay";
40
41
  import { handleResponses } from "./responses";
41
42
  import {
@@ -786,8 +787,12 @@ async function handleClaudeMessagesWithBudget(
786
787
  // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens",
787
788
  // verified live 2026-07-11). Strip them for that route; routed providers keep them.
788
789
  let nativeRoute = false;
790
+ let opencodeGoRoute = false;
789
791
  try {
790
792
  const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
793
+ // Match the fixed key-auth destination before per-model wire overrides, including
794
+ // renamed Go providers without treating custom or lookalike URLs as Go.
795
+ opencodeGoRoute = registryEntryForProviderDestination(route.provider)?.id === "opencode-go";
791
796
  // Settle the wire once so the sampling decision below reads the effective
792
797
  // adapter rather than the provider-wide default (#404).
793
798
  route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic");
@@ -851,15 +856,27 @@ async function handleClaudeMessagesWithBudget(
851
856
  headers.set("chatgpt-account-id", token.chatgptAccountId);
852
857
  }
853
858
  }
854
- if (nativeRoute) {
859
+ if (opencodeGoRoute) {
860
+ const session = req.headers.get("x-opencode-session");
861
+ if (session) headers.set("x-opencode-session", session);
862
+ }
863
+ const hasExplicitGoSession = opencodeGoRoute
864
+ && (sessionLaneIdFromRequest(headers) !== undefined
865
+ || normalizeLogConversationId(headers.get("x-opencode-session")) !== undefined);
866
+ const synthesizeGoSession = opencodeGoRoute && !hasExplicitGoSession
867
+ && isRec(anthropicBody)
868
+ && conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined;
869
+ // Go can also use the Responses adapter; its eligibility gate must win on both wires.
870
+ if (opencodeGoRoute ? synthesizeGoSession : nativeRoute) {
855
871
  // ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex
856
872
  // clients always send their session uuid; devlog 090 follow-up: body-level
857
873
  // prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends
858
- // the header, so synthesize a stable per-session uuid from the same cache key —
874
+ // the header, so synthesize a stable per-session uuid from the same cache key.
875
+ // Routed Go requests need this lane too for their x-opencode-session affinity —
859
876
  // but ONLY for a real per-session key (metadata.user_id). The system-hash fallback
860
877
  // key is shared across Desktop conversations, and a shared session_id's backend
861
878
  // semantics are unproven (audit 133 R2#3): body prompt_cache_key only there.
862
- if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") {
879
+ if (cacheKeySource === "metadata" && (synthesizeGoSession || !headers.has("session_id")) && typeof internalBody.prompt_cache_key === "string") {
863
880
  headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key));
864
881
  }
865
882
  }
@@ -86,39 +86,29 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [
86
86
  *
87
87
  * When `OCX_LIVE_FRAME_LOG` is set to a file path, every relayed sideband frame appends one
88
88
  * JSONL record: direction, frame kind, byte length, and whether the payload contains U+FFFD.
89
- * Privacy: full frame payloads are never written — only when U+FFFD is present, a short
90
- * excerpt around the first replacement character is included so the corruption point can be
91
- * attributed (upstream vs relay vs client). Disabled entirely when the env var is unset.
89
+ * Privacy: no frame content is written, including excerpts around replacement characters.
90
+ * For binary frames, U+FFFD may also be introduced by UTF-8 decoding; the flag alone does not
91
+ * identify the source of corruption. Disabled entirely when the env var is unset.
92
92
  */
93
93
  export const LIVE_FRAME_LOG_ENV = "OCX_LIVE_FRAME_LOG";
94
- const LIVE_FRAME_LOG_CONTEXT_CHARS = 24;
95
-
96
- function fffdContext(text: string): string | undefined {
97
- const idx = text.indexOf("\uFFFD");
98
- if (idx < 0) return undefined;
99
- const start = Math.max(0, idx - LIVE_FRAME_LOG_CONTEXT_CHARS);
100
- const end = Math.min(text.length, idx + LIVE_FRAME_LOG_CONTEXT_CHARS);
101
- return text.slice(start, end);
102
- }
103
-
104
94
  export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void {
105
95
  const logPath = process.env[LIVE_FRAME_LOG_ENV];
106
96
  if (!logPath) return;
107
97
  try {
108
98
  let kind: "text" | "binary" = "binary";
109
99
  let bytes = 0;
110
- let context: string | undefined;
100
+ let fffd = false;
111
101
  if (typeof data === "string") {
112
102
  kind = "text";
113
103
  bytes = Buffer.byteLength(data);
114
- context = fffdContext(data);
104
+ fffd = data.includes("\uFFFD");
115
105
  } else if (data instanceof ArrayBuffer) {
116
106
  bytes = data.byteLength;
117
- context = fffdContext(new TextDecoder().decode(new Uint8Array(data)));
107
+ fffd = new TextDecoder().decode(new Uint8Array(data)).includes("\uFFFD");
118
108
  } else if (ArrayBuffer.isView(data)) {
119
109
  const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
120
110
  bytes = data.byteLength;
121
- context = fffdContext(new TextDecoder().decode(view));
111
+ fffd = new TextDecoder().decode(view).includes("\uFFFD");
122
112
  } else {
123
113
  return;
124
114
  }
@@ -127,8 +117,7 @@ export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void {
127
117
  dir,
128
118
  kind,
129
119
  bytes,
130
- fffd: context !== undefined,
131
- ...(context !== undefined ? { context } : {}),
120
+ fffd,
132
121
  };
133
122
  appendFileSync(logPath, `${JSON.stringify(record)}\n`);
134
123
  } catch {
@@ -40,6 +40,7 @@ import { clearThreadAccountMap } from "../../codex/routing";
40
40
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
41
41
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
42
42
  import { resolveCodexHomeDir } from "../../codex/home";
43
+ import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../../codex/multi-agent-mode-policy";
43
44
  import { readUsageEntries } from "../../usage/log";
44
45
  import { getUsageDebugLogEntries } from "../../usage/debug";
45
46
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
@@ -248,6 +249,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
248
249
  agentsMaxDepth: getAgentsMaxDepth(),
249
250
  subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
250
251
  multiAgentModeHintText: getMultiAgentModeHintText(),
252
+ multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION,
251
253
  // max_depth is V1-only upstream; this is the global-flag statement, derived
252
254
  // server-side so no client can present it as an effective V2 limit.
253
255
  agentsMaxDepthAppliesWhenV2Disabled: !enabled,
@@ -421,6 +423,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
421
423
  agentsMaxDepth: getAgentsMaxDepth(),
422
424
  subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
423
425
  multiAgentModeHintText: getMultiAgentModeHintText(),
426
+ multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION,
424
427
  agentsMaxDepthAppliesWhenV2Disabled: !enabled,
425
428
  warnings,
426
429
  catalogRefresh,
@@ -241,6 +241,9 @@ export const PROACTIVE_MULTI_AGENT_MODE_TEXT = [
241
241
  "This mode remains active until a later multi-agent mode developer message changes it.",
242
242
  ].join(" ");
243
243
 
244
+ const OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG = "<opencodex_subagent_guidance>";
245
+ const OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG = "</opencodex_subagent_guidance>";
246
+
244
247
  export function isV1CollabSurface(parsed: OcxParsedRequest): boolean {
245
248
  return collabSurface(parsed) === "v1";
246
249
  }
@@ -468,18 +471,15 @@ export async function multiAgentGuidanceText(
468
471
  // fallback only for explicit routed/account-qualified ids.
469
472
  const promptModel = preferred?.model
470
473
  ?? (injectionModel?.includes("/") ? injectionModel : undefined);
471
- return `<multi_agent_mode>${applyInjectionPlaceholders(injectionPrompt, promptModel, injectionEffort, roster, fallbackGuidance)}</multi_agent_mode>`;
474
+ return `${OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG}${applyInjectionPlaceholders(injectionPrompt, promptModel, injectionEffort, roster, fallbackGuidance)}${OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG}`;
472
475
  }
473
476
  if (!preferred && roster === "" && fallbackGuidance === "") return null;
474
- let text = "When the active spawn_agent tool supports optional \"model\" or \"reasoning_effort\" overrides, "
475
- + "use only models listed for this collaboration surface. "
476
- + "When setting either override, set fork_turns to \"none\" "
477
- + "(or a positive turn count such as \"3\"; full-history forks reject overrides) "
478
- + "and make the task message self-contained.";
477
+ let text = "OpenCodex sub-agent routing metadata for this collaboration surface. "
478
+ + "This metadata does not override Codex delegation or model-selection rules.";
479
479
  if (preferred) {
480
480
  text += ` Preferred sub-agent: model "${preferred.model}"`
481
481
  + (injectionEffort ? `, reasoning_effort "${injectionEffort}"` : "")
482
- + " — use it unless the user names another.";
482
+ + ".";
483
483
  }
484
484
  text += fallbackGuidance;
485
485
  text += roster;
@@ -487,7 +487,7 @@ export async function multiAgentGuidanceText(
487
487
  // Roster is the only unbounded part — drop it before breaking the budget.
488
488
  text = text.slice(0, text.length - roster.length);
489
489
  }
490
- return `<multi_agent_mode>${text}</multi_agent_mode>`;
490
+ return `${OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG}${text}${OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG}`;
491
491
  }
492
492
 
493
493
  const effort = parsed.options.reasoning;
@@ -544,6 +544,17 @@ function isGeneratedDeveloperItem(item: unknown, text: string): boolean {
544
544
  return generatedDeveloperText(item) === text;
545
545
  }
546
546
 
547
+ function generatedGuidanceFamily(text: string): "multi_agent_mode" | "opencodex_subagent_guidance" | undefined {
548
+ if (text.startsWith("<multi_agent_mode>") && text.endsWith("</multi_agent_mode>")) {
549
+ return "multi_agent_mode";
550
+ }
551
+ if (text.startsWith(OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG)
552
+ && text.endsWith(OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG)) {
553
+ return "opencodex_subagent_guidance";
554
+ }
555
+ return undefined;
556
+ }
557
+
547
558
  function isDeveloperPrefixItem(item: unknown): boolean {
548
559
  if (!isRecord(item)) return false;
549
560
  if (item.type === "additional_tools") return item.role === "developer";
@@ -583,13 +594,13 @@ export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string):
583
594
  const devItem = { type: "message", role: "developer", content: [{ type: "input_text", text }] };
584
595
  if (rawInput) {
585
596
  const replayPrefix = rawInput.slice(0, replayPrefixLen);
586
- const taggedGuidance = text.startsWith("<multi_agent_mode>") && text.endsWith("</multi_agent_mode>");
587
- const lastTaggedGuidance = taggedGuidance
597
+ const guidanceFamily = generatedGuidanceFamily(text);
598
+ const lastTaggedGuidance = guidanceFamily
588
599
  ? replayPrefix.map(generatedDeveloperText)
589
- .filter(item => item?.startsWith("<multi_agent_mode>") && item.endsWith("</multi_agent_mode>"))
600
+ .filter(item => item !== undefined && generatedGuidanceFamily(item) === guidanceFamily)
590
601
  .at(-1)
591
602
  : undefined;
592
- if (taggedGuidance ? lastTaggedGuidance === text : replayPrefix.some(item => isGeneratedDeveloperItem(item, text))) {
603
+ if (guidanceFamily ? lastTaggedGuidance === text : replayPrefix.some(item => isGeneratedDeveloperItem(item, text))) {
593
604
  return;
594
605
  }
595
606
  }
@@ -0,0 +1,89 @@
1
+ /** Process-local recall of the last completed combo response on an explicit session lane. */
2
+ import { getCombo, targetKey } from "../../combos/types";
3
+ import { captureConfigGeneration, type GenerationContext } from "../../lib/state-store-sweeper";
4
+ import type { OcxConfig, OcxComboTarget } from "../../types";
5
+
6
+ interface ComboRecallEntry {
7
+ comboId: string;
8
+ target: Pick<OcxComboTarget, "provider" | "model">;
9
+ responseModel: string;
10
+ at: number;
11
+ }
12
+
13
+ const RECALL_CAPACITY = 256;
14
+ const RECALL_TTL_MS = 30 * 60 * 1000;
15
+ const recall = new Map<string, ComboRecallEntry>();
16
+ let lastReconciledGeneration = 0;
17
+ let liveOwners: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames"> | undefined;
18
+
19
+ function ownsEntry(context: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames">, entry: ComboRecallEntry): boolean {
20
+ return context.comboIds.has(entry.comboId)
21
+ && context.providerNames.has(entry.target.provider)
22
+ && context.comboTargets.has(`${entry.comboId}::${targetKey(entry.target)}`);
23
+ }
24
+
25
+ export function rememberComboForLane(
26
+ lane: string | undefined,
27
+ comboId: string,
28
+ target: Pick<OcxComboTarget, "provider" | "model">,
29
+ responseModel: string,
30
+ writerGeneration: number,
31
+ ): void {
32
+ if (!lane || !comboId || !responseModel.trim()) return;
33
+ // Reject even a same-named recreated owner: its previous in-flight turn is obsolete.
34
+ if (writerGeneration < Math.max(lastReconciledGeneration, captureConfigGeneration())) return;
35
+ const entry = { comboId, target: { provider: target.provider, model: target.model }, responseModel, at: Date.now() };
36
+ if (liveOwners && !ownsEntry(liveOwners, entry)) return;
37
+ recall.delete(lane);
38
+ recall.set(lane, entry);
39
+ while (recall.size > RECALL_CAPACITY) {
40
+ const oldest = recall.keys().next().value;
41
+ if (oldest === undefined) break;
42
+ recall.delete(oldest);
43
+ }
44
+ }
45
+
46
+ export function recallComboForLane(
47
+ config: OcxConfig,
48
+ lane: string | undefined,
49
+ model: string,
50
+ ): string | undefined {
51
+ if (!lane || !model || model.includes("/")) return undefined;
52
+ const entry = recall.get(lane);
53
+ if (!entry) return undefined;
54
+ const combo = getCombo(config, entry.comboId);
55
+ const provider = config.providers[entry.target.provider];
56
+ if (Date.now() - entry.at >= RECALL_TTL_MS
57
+ || !Object.hasOwn(config.providers, entry.target.provider)
58
+ || !provider || provider.disabled === true
59
+ || !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) {
60
+ recall.delete(lane);
61
+ return undefined;
62
+ }
63
+ return entry.responseModel === model ? entry.comboId : undefined;
64
+ }
65
+
66
+ export function reconcileComboRecall(context: GenerationContext): number {
67
+ if (context.generation <= lastReconciledGeneration) return 0;
68
+ lastReconciledGeneration = context.generation;
69
+ liveOwners = {
70
+ comboIds: new Set(context.comboIds),
71
+ comboTargets: new Set(context.comboTargets),
72
+ providerNames: new Set(context.providerNames),
73
+ };
74
+ let removed = 0;
75
+ for (const [lane, entry] of recall) {
76
+ if (!ownsEntry(context, entry) || Date.now() - entry.at >= RECALL_TTL_MS) {
77
+ recall.delete(lane);
78
+ removed += 1;
79
+ }
80
+ }
81
+ return removed;
82
+ }
83
+
84
+ /** Test-only reset, alongside the combo rotation/cooldown resets. */
85
+ export function clearComboRecallForTests(): void {
86
+ recall.clear();
87
+ lastReconciledGeneration = 0;
88
+ liveOwners = undefined;
89
+ }
@@ -18,6 +18,7 @@ import {
18
18
  comboIdFromRawBody,
19
19
  concreteComboRequestBody,
20
20
  getCombo,
21
+ resolveComboId,
21
22
  isComboTargetInCooldown,
22
23
  NoAvailableComboTargetsError,
23
24
  noteComboSuccess,
@@ -152,6 +153,7 @@ import {
152
153
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
153
154
  import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error";
154
155
  import { sessionLaneIdFromRequest } from "../request-log-conversation";
156
+ import { recallComboForLane } from "./combo-session-recall";
155
157
 
156
158
  export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;
157
159
 
@@ -536,13 +538,29 @@ export async function handleResponsesCompact(
536
538
  // a local rather than written back to `raw.model`: assigning to the property widens it out
537
539
  // of the `string` narrowing the guard above just established.
538
540
  const compactFastRow = parseFastOnlyRowId(config, () => raw.model as string);
539
- const compactModel = compactFastRow ? compactFastRow.baseId : raw.model;
541
+ let compactModel = compactFastRow ? compactFastRow.baseId : raw.model;
540
542
  if (compactFastRow) (raw as Record<string, unknown>).model = compactModel;
541
543
  // The client's own selector, kept for the request log: `raw.model` is rewritten to the
542
544
  // base id above, and logCtx.requestedModel is assigned from it further down, so without
543
545
  // this the log would lose which id the client actually asked for.
544
546
  const compactRequestedModel = compactFastRow ? compactFastRow.baseId + "--fast" : raw.model;
545
547
 
548
+ // Recall the last completed client-visible bare model after a combo switch (#3891).
549
+ // Configured selectors take precedence over this implicit session hint.
550
+ if (typeof compactModel === "string" && !compactModel.includes("/") && !compactFastRow
551
+ && !resolveComboId(config, compactModel)) {
552
+ const recalledComboId = recallComboForLane(config, sessionLaneIdFromRequest(req.headers), compactModel);
553
+ if (recalledComboId) {
554
+ (raw as Record<string, unknown>).model = `combo/${recalledComboId}`;
555
+ // Keep the routed identity in sync: the bare model can 404 outright (no
556
+ // canonical openai provider) or resolve straight onto a native-compact
557
+ // provider, both bypassing combo failover. The combo selector resolves
558
+ // through tryPickComboModel, whose route.combo skips the native compact
559
+ // endpoint.
560
+ compactModel = `combo/${recalledComboId}`;
561
+ }
562
+ }
563
+
546
564
  let route;
547
565
  try {
548
566
  // Compact requests route through the same policy evaluation as normal