@bitkyc08/opencodex 2.7.4 → 2.7.7

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.
@@ -0,0 +1,202 @@
1
+ /** Maximum number of response-body bytes that may be retained for an error. */
2
+ export const BOUNDED_BODY_MAX_BYTES = 65_536;
3
+
4
+ /** Default wall-clock and continuous-silence deadlines. */
5
+ export const BOUNDED_BODY_TIMEOUT_MS = 5_000;
6
+
7
+ export interface BoundedBodyOptions {
8
+ /** Abort the read with this signal. Its reason is rethrown by identity. */
9
+ signal?: AbortSignal;
10
+ /** Total wall-clock deadline. Exposed for focused tests. */
11
+ totalTimeoutMs?: number;
12
+ /** Deadline between non-empty raw chunks. Exposed for focused tests. */
13
+ inactivityTimeoutMs?: number;
14
+ }
15
+
16
+ export interface BoundedBodyResult {
17
+ /** UTF-8 text retained from the response. Empty when the size limit was exceeded. */
18
+ text: string;
19
+ /** True when EOF was not observed. */
20
+ truncated: boolean;
21
+ /** True for either total-deadline or inactivity-deadline expiry. */
22
+ timedOut: boolean;
23
+ /** Distinguishes the wall-clock deadline from an inactivity deadline. */
24
+ totalTimedOut: boolean;
25
+ /** True only when continuous inactivity caused the timeout. */
26
+ inactivityTimedOut: boolean;
27
+ /** True when the body was observed to exceed the byte cap. */
28
+ oversized: boolean;
29
+ /** False means callers should use a status-only fallback, not `text`. */
30
+ displaySafe: boolean;
31
+ }
32
+
33
+ const TOTAL_TIMEOUT = Symbol("bounded body total timeout");
34
+ const INACTIVITY_TIMEOUT = Symbol("bounded body inactivity timeout");
35
+
36
+ function timeoutPromise(ms: number, value: symbol): { promise: Promise<symbol>; clear: () => void } {
37
+ let timer: ReturnType<typeof setTimeout> | undefined;
38
+ const promise = new Promise<symbol>((resolve) => {
39
+ timer = setTimeout(() => resolve(value), Math.max(0, ms));
40
+ });
41
+ return {
42
+ promise,
43
+ clear: () => {
44
+ if (timer !== undefined) clearTimeout(timer);
45
+ },
46
+ };
47
+ }
48
+
49
+ function cancelWithoutWaiting(reader: ReadableStreamDefaultReader<Uint8Array>, reason?: unknown): void {
50
+ // A hostile/broken stream may reject or never settle cancel(). Neither should
51
+ // escape as an unhandled rejection or extend this primitive's own deadline.
52
+ try {
53
+ void reader.cancel(reason).catch(() => undefined);
54
+ } catch {
55
+ // Some stream implementations throw synchronously from cancel().
56
+ }
57
+ }
58
+
59
+ function decodeUtf8(chunks: readonly Uint8Array[]): string {
60
+ const decoder = new TextDecoder();
61
+ let text = "";
62
+ for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
63
+ // Flush an incomplete trailing UTF-8 sequence deterministically.
64
+ text += decoder.decode();
65
+ return text;
66
+ }
67
+
68
+ /**
69
+ * Consume the original response body under strict memory and time bounds.
70
+ *
71
+ * This deliberately calls `getReader()` on `response.body`: it never clones or
72
+ * tees the response. Once an over-limit byte is observed, all retained raw data
73
+ * is discarded so an untrusted prefix can never become a client-facing error.
74
+ */
75
+ export async function readBoundedResponseBody(
76
+ response: Response,
77
+ options: BoundedBodyOptions = {},
78
+ ): Promise<BoundedBodyResult> {
79
+ const signal = options.signal;
80
+ if (signal?.aborted) throw signal.reason;
81
+
82
+ const body = response.body;
83
+ if (!body) {
84
+ return {
85
+ text: "",
86
+ truncated: false,
87
+ timedOut: false,
88
+ totalTimedOut: false,
89
+ inactivityTimedOut: false,
90
+ oversized: false,
91
+ displaySafe: true,
92
+ };
93
+ }
94
+
95
+ const reader = body.getReader();
96
+ const chunks: Uint8Array[] = [];
97
+ let retainedBytes = 0;
98
+ let mustCancel = false;
99
+ let cancelReason: unknown;
100
+ const total = timeoutPromise(options.totalTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS, TOTAL_TIMEOUT);
101
+ let inactivity = timeoutPromise(
102
+ options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
103
+ INACTIVITY_TIMEOUT,
104
+ );
105
+
106
+ let rejectForAbort: ((reason: unknown) => void) | undefined;
107
+ const aborted = new Promise<never>((_resolve, reject) => {
108
+ rejectForAbort = reject;
109
+ });
110
+ const onAbort = () => rejectForAbort?.(signal?.reason);
111
+ signal?.addEventListener("abort", onAbort, { once: true });
112
+ // Close the narrow race between the preflight check and listener install.
113
+ if (signal?.aborted) onAbort();
114
+
115
+ try {
116
+ while (true) {
117
+ // Attach a rejection handler before racing. If a deadline wins and
118
+ // cancellation later rejects this read, it remains observed.
119
+ const read = reader.read();
120
+ void read.catch(() => undefined);
121
+ const outcome = await Promise.race([read, total.promise, inactivity.promise, aborted]);
122
+ // Cancellation owns the body lifetime even when EOF/readability settles in
123
+ // the same turn. Promise.race otherwise lets array order hide the abort.
124
+ if (signal?.aborted) {
125
+ mustCancel = true;
126
+ cancelReason = signal.reason;
127
+ throw signal.reason;
128
+ }
129
+
130
+ if (outcome === TOTAL_TIMEOUT || outcome === INACTIVITY_TIMEOUT) {
131
+ mustCancel = true;
132
+ cancelReason = new DOMException(
133
+ outcome === TOTAL_TIMEOUT ? "Error body total timeout" : "Error body inactivity timeout",
134
+ "TimeoutError",
135
+ );
136
+ return {
137
+ text: decodeUtf8(chunks),
138
+ truncated: true,
139
+ timedOut: true,
140
+ totalTimedOut: outcome === TOTAL_TIMEOUT,
141
+ inactivityTimedOut: outcome === INACTIVITY_TIMEOUT,
142
+ oversized: false,
143
+ displaySafe: false,
144
+ };
145
+ }
146
+
147
+ const { value, done } = outcome as ReadableStreamReadResult<Uint8Array>;
148
+ if (done) {
149
+ return {
150
+ text: decodeUtf8(chunks),
151
+ truncated: false,
152
+ timedOut: false,
153
+ totalTimedOut: false,
154
+ inactivityTimedOut: false,
155
+ oversized: false,
156
+ displaySafe: true,
157
+ };
158
+ }
159
+
160
+ if (!value || value.byteLength === 0) continue;
161
+
162
+ inactivity.clear();
163
+ inactivity = timeoutPromise(
164
+ options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
165
+ INACTIVITY_TIMEOUT,
166
+ );
167
+
168
+ if (value.byteLength > BOUNDED_BODY_MAX_BYTES - retainedBytes) {
169
+ mustCancel = true;
170
+ cancelReason = new DOMException("Error body size limit reached", "QuotaExceededError");
171
+ chunks.length = 0;
172
+ retainedBytes = 0;
173
+ return {
174
+ text: "",
175
+ truncated: true,
176
+ timedOut: false,
177
+ totalTimedOut: false,
178
+ inactivityTimedOut: false,
179
+ oversized: true,
180
+ displaySafe: false,
181
+ };
182
+ }
183
+
184
+ chunks.push(value);
185
+ retainedBytes += value.byteLength;
186
+ }
187
+ } catch (error) {
188
+ mustCancel = true;
189
+ cancelReason = error;
190
+ throw error;
191
+ } finally {
192
+ total.clear();
193
+ inactivity.clear();
194
+ signal?.removeEventListener("abort", onAbort);
195
+ if (mustCancel) cancelWithoutWaiting(reader, cancelReason);
196
+ try {
197
+ reader.releaseLock();
198
+ } catch {
199
+ // A pending read can keep the lock briefly while cancel settles.
200
+ }
201
+ }
202
+ }
@@ -348,11 +348,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
348
348
  "kimi-k2.7-code-highspeed": [],
349
349
  ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS])),
350
350
  ...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
351
+ ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS])),
351
352
  },
352
353
  // glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map);
353
354
  // the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays.
354
355
  modelReasoningEffortMap: {
355
356
  ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])),
357
+ ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP])),
356
358
  },
357
359
  thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS,
358
360
  thinkingBudgetModels: THINKING_BUDGET_MODELS,
@@ -371,7 +373,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
371
373
  noTopPModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
372
374
  noPenaltyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
373
375
  autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
374
- preserveReasoningContentModels: ["glm-5.2", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
376
+ // Issue #78: DeepSeek V4 thinking mode requires reasoning_content replay on tool-call turns.
377
+ preserveReasoningContentModels: ["glm-5.2", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_THINKING_MODELS],
375
378
  },
376
379
  {
377
380
  id: "neuralwatt",
@@ -456,6 +459,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
456
459
  modelReasoningEfforts: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS])),
457
460
  modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP])),
458
461
  preserveReasoningContentModels: DEEPSEEK_THINKING_MODELS,
462
+ // Issue #88: every DeepSeek API model is text-only input (no image support upstream) — the
463
+ // vision sidecar describes attached images for them, and the catalog advertises image input
464
+ // on their behalf (same treatment as opencode-go's DeepSeek V4 entries above).
465
+ noVisionModels: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_THINKING_MODELS],
459
466
  },
460
467
  // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
461
468
  { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" },
@@ -19,6 +19,11 @@ export function isCodexReasoningEffort(effort: string): boolean {
19
19
  return CODEX_REASONING_SET.has(effort);
20
20
  }
21
21
 
22
+ /** Position of `effort` in the Codex ladder (low=0 .. ultra=5), or -1 when not a ladder member. */
23
+ export function codexEffortRank(effort: string): number {
24
+ return CODEX_REASONING_ORDER.indexOf(effort);
25
+ }
26
+
22
27
  export function modelRecordValue<T>(record: Record<string, T> | undefined, modelId: string): T | undefined {
23
28
  if (!record) return undefined;
24
29
  if (Object.prototype.hasOwnProperty.call(record, modelId)) return record[modelId];
@@ -117,31 +117,34 @@ export function assertServerAuthConfig(config: OcxConfig): void {
117
117
  }
118
118
  }
119
119
 
120
- export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
121
- if (!isApiAuthRequired(config)) return true;
122
- const actual = req.headers.get("x-opencodex-api-key")?.trim()
123
- || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
120
+ /** Whether `token` is one of the proxy's own admission secrets (env token or config API keys). */
121
+ export function isProxyAdmissionSecret(token: string, config: OcxConfig): boolean {
122
+ const actual = token.trim();
124
123
  if (!actual) return false;
124
+ const enc = new TextEncoder();
125
+ const actualBytes = enc.encode(actual);
125
126
  // Check env-based token
126
127
  const expected = configuredApiAuthToken(config);
127
128
  if (expected) {
128
- const enc = new TextEncoder();
129
129
  const expectedBytes = enc.encode(expected);
130
- const actualBytes = enc.encode(actual);
131
130
  if (expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes)) return true;
132
131
  }
133
132
  // Check config-based API keys
134
- if (config.apiKeys?.length) {
135
- const enc = new TextEncoder();
136
- const actualBytes = enc.encode(actual);
137
- for (const k of config.apiKeys) {
138
- const keyBytes = enc.encode(k.key);
139
- if (keyBytes.length === actualBytes.length && timingSafeEqual(actualBytes, keyBytes)) return true;
140
- }
133
+ for (const k of config.apiKeys ?? []) {
134
+ const keyBytes = enc.encode(k.key);
135
+ if (keyBytes.length === actualBytes.length && timingSafeEqual(actualBytes, keyBytes)) return true;
141
136
  }
142
137
  return false;
143
138
  }
144
139
 
140
+ export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
141
+ if (!isApiAuthRequired(config)) return true;
142
+ const actual = req.headers.get("x-opencodex-api-key")?.trim()
143
+ || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
144
+ if (!actual) return false;
145
+ return isProxyAdmissionSecret(actual, config);
146
+ }
147
+
145
148
  export function requireApiAuth(req: Request, config: OcxConfig, kind: "management" | "data-plane"): Response | null {
146
149
  if (hasValidApiAuth(req, config)) return null;
147
150
  if (kind === "management") return jsonResponse({ error: "opencodex API key required" }, 401);
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Hard reasoning-effort caps (devlog/260710_subagent_effort_intercept).
3
+ *
4
+ * Prompt-side effort designation (injectionEffort) is advisory only: codex-rs inherits the
5
+ * parent's effective effort when spawn_agent carries no model/effort args
6
+ * (multi_agents_common.rs resolve defaults), rejects overrides on full-history forks, and a
7
+ * non-empty agent-role file rebuilds the child Config and silently drops spawn-time
8
+ * model/effort. So a session whose config default is ultra leaks max-tier children whenever
9
+ * the parent model spawns bare. This module is the enforcement path: it rewrites the effort
10
+ * of proxied turns at the single choke point every HTTP/WS turn passes through
11
+ * (handleResponses), using the same dual-shape rewrite contract as nativeEffortClamp —
12
+ * parsed.options.reasoning feeds routed adapters, _rawBody.reasoning.effort feeds the
13
+ * ChatGPT passthrough serializer.
14
+ */
15
+ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
16
+ import { modelInList } from "../types";
17
+ import { codexEffortRank, configuredReasoningEfforts, isCodexReasoningEffort, modelRecordValue } from "../reasoning-effort";
18
+ import { catalogModelEfforts } from "../codex/catalog";
19
+
20
+ /**
21
+ * True when the request carries codex-rs's spawned-child markers, matched EXACTLY.
22
+ * Source of truth (openai/codex @ 6138909d): every collab-spawned child turn sends
23
+ * `x-openai-subagent: collab_spawn` (core/src/responses_metadata.rs) and embeds
24
+ * `"subagent_kind":"thread_spawn"` in the JSON `x-codex-turn-metadata` compatibility
25
+ * header. Both are checked: the WS bridge rebuilds internal requests from the
26
+ * FORWARD_HEADERS allowlist, so either header alone is sufficient evidence.
27
+ *
28
+ * Exact matching matters: upstream emits `x-openai-subagent` for OTHER internal
29
+ * turn categories too (review, compact, memory_consolidation, arbitrary "other"
30
+ * sources — responses_metadata.rs subagent_source). Those are maintenance turns,
31
+ * not spawned children, and must never trip subagentEffortCap.
32
+ */
33
+ export function isThreadSpawnRequest(headers: Headers): boolean {
34
+ if (headers.get("x-openai-subagent") === "collab_spawn") return true;
35
+ const turnMeta = headers.get("x-codex-turn-metadata");
36
+ if (!turnMeta) return false;
37
+ try {
38
+ const parsed = JSON.parse(turnMeta) as { subagent_kind?: unknown };
39
+ return parsed.subagent_kind === "thread_spawn";
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /** The effective ceiling for this turn, or undefined when no configured cap applies. */
46
+ export function effortCapFor(config: OcxConfig, subagent: boolean): string | undefined {
47
+ const caps: string[] = [];
48
+ if (config.effortCap && isCodexReasoningEffort(config.effortCap)) caps.push(config.effortCap);
49
+ if (subagent && config.subagentEffortCap && isCodexReasoningEffort(config.subagentEffortCap)) {
50
+ caps.push(config.subagentEffortCap);
51
+ }
52
+ if (caps.length === 0) return undefined;
53
+ return caps.reduce((low, cap) => (codexEffortRank(cap) < codexEffortRank(low) ? cap : low));
54
+ }
55
+
56
+ /**
57
+ * Whether the effort caps apply to this turn at all. Caps are a V2-surface feature
58
+ * (v1 sub-agents are pinned via explicit spawn args + injectionEffort prompting, so
59
+ * the ultra-default leak this module intercepts is v2-specific):
60
+ * - compaction turns are maintenance, not agent turns: they bypass caps entirely so
61
+ * native /v1/responses/compact (forwarded, never enters handleResponses) and routed
62
+ * compaction (synthesized internal request) get identical cap semantics.
63
+ * - multiAgentMode "v1" disables caps entirely (mirrors the GUI hiding the panel).
64
+ * - a main turn qualifies when its own tool list carries the v2 collab surface.
65
+ * - a CHILD turn is admitted by its spawned-child markers (isThreadSpawnRequest)
66
+ * REGARDLESS of tool surface: depth-limited leaves carry no collab tools (surface
67
+ * null) while children below the spawn-depth limit retain collab tools (spec_plan.rs
68
+ * leaf guard), so tool sniffing alone would cap siblings inconsistently.
69
+ * - a v1-surface MAIN turn (no child markers) never qualifies.
70
+ */
71
+ export function effortCapAppliesTo(
72
+ surface: "v1" | "v2" | null,
73
+ headers: Headers,
74
+ config: OcxConfig,
75
+ compaction = false,
76
+ ): boolean {
77
+ if (compaction) return false;
78
+ if (config.multiAgentMode === "v1") return false;
79
+ return surface === "v2" || isThreadSpawnRequest(headers);
80
+ }
81
+
82
+ /**
83
+ * The routed model's supported effort ladder for cap resolution, from the ROUTE's
84
+ * registry-merged provider (router.ts routedProviderConfig) — the persisted
85
+ * config.providers entry misses registry seeds, and bare ids can route via
86
+ * defaultModel/model-list/default-provider, so no "/" heuristic anywhere.
87
+ *
88
+ * - `[]` -> the model intentionally exposes no effort control (noReasoningModels or
89
+ * an explicitly empty configured ladder): cap resolution strips.
90
+ * - list -> sanitized + healed ladder (configuredReasoningEfforts).
91
+ * - undefined -> unknown. Includes the raw-nonempty-but-non-rankable case (e.g. a
92
+ * thinking-toggle ladder of ["enabled"]): sanitizing would flatten it to []
93
+ * and mis-classify it as "no effort control", so it stays unknown.
94
+ *
95
+ * Catalog fallback fires only for the ChatGPT-backend native passthrough IDENTITY
96
+ * (adapter "openai-responses" + authMode "forward", the fresh-install `openai`
97
+ * provider shape): the injected catalog is authoritative exactly for models Codex
98
+ * validates against that backend. A custom responses provider (key mode) serving a
99
+ * native-looking bare id must NOT inherit the unrelated native ladder.
100
+ */
101
+ export function supportedLadderFor(route: { provider: OcxProviderConfig; modelId: string }): string[] | undefined {
102
+ const { provider, modelId } = route;
103
+ if (modelInList(provider.noReasoningModels, modelId)) return [];
104
+ const raw = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts;
105
+ if (raw !== undefined) {
106
+ const sanitized = configuredReasoningEfforts(provider, modelId) ?? [];
107
+ if (sanitized.length === 0 && raw.length > 0) return undefined;
108
+ return sanitized;
109
+ }
110
+ if (provider.adapter === "openai-responses" && provider.authMode === "forward") {
111
+ const efforts = catalogModelEfforts([modelId]).get(modelId);
112
+ if (efforts && efforts.length > 0) return efforts;
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ /**
118
+ * Resolve the configured cap against the model's supported ladder. Returns the effective
119
+ * ceiling rung, or null when the turn must be STRIPPED of its effort entirely. The cap
120
+ * NEVER raises: when rankable rungs exist but none sits at or below the cap, the model
121
+ * cannot run within the ceiling, so the effort is stripped and the provider default
122
+ * applies (never a rung above the cap).
123
+ */
124
+ export function resolveCappedEffort(cap: string, supported: readonly string[] | undefined): string | null {
125
+ if (supported === undefined) return cap;
126
+ const rankable = supported.filter(isCodexReasoningEffort);
127
+ if (rankable.length === 0) {
128
+ // Nonempty but non-rankable (e.g. ["enabled"]) -> unknown ladder, cap as-is.
129
+ // Genuinely empty -> no effort control at all -> strip.
130
+ return supported.length > 0 ? cap : null;
131
+ }
132
+ const capRank = codexEffortRank(cap);
133
+ let best: string | null = null;
134
+ for (const rung of rankable) {
135
+ const rank = codexEffortRank(rung);
136
+ if (rank <= capRank && (best === null || rank > codexEffortRank(best))) best = rung;
137
+ }
138
+ return best;
139
+ }
140
+
141
+ /**
142
+ * Cap the turn's reasoning effort in BOTH request shapes. Non-strip resolution only
143
+ * lowers: efforts at or below the resolved ceiling (and non-ladder/absent efforts) pass
144
+ * untouched. Strip resolution (model exposes no effort control, or no supported rung
145
+ * fits under the cap) removes whatever effort is present — regardless of its rank —
146
+ * from both shapes while preserving `reasoning.summary`. Returns the applied rewrite
147
+ * for request-log annotation (`to: "none"` on strip), or null when nothing changed.
148
+ */
149
+ export function applyEffortCap(
150
+ parsed: OcxParsedRequest,
151
+ headers: Headers,
152
+ config: OcxConfig,
153
+ supported?: readonly string[] | undefined,
154
+ ): { from: string; to: string; subagent: boolean } | null {
155
+ const subagent = isThreadSpawnRequest(headers);
156
+ const cap = effortCapFor(config, subagent);
157
+ if (!cap) return null;
158
+ const resolved = resolveCappedEffort(cap, supported);
159
+ const requested = parsed.options.reasoning;
160
+ const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined;
161
+ if (resolved === null) {
162
+ if (!requested) return null;
163
+ parsed.options.reasoning = undefined;
164
+ if (raw?.reasoning && typeof raw.reasoning === "object") delete raw.reasoning.effort;
165
+ return { from: requested, to: "none", subagent };
166
+ }
167
+ if (!requested || !isCodexReasoningEffort(requested)) return null;
168
+ if (codexEffortRank(requested) <= codexEffortRank(resolved)) return null;
169
+ parsed.options.reasoning = resolved;
170
+ if (raw?.reasoning && typeof raw.reasoning === "object") raw.reasoning.effort = resolved;
171
+ return { from: requested, to: resolved, subagent };
172
+ }