@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.
- package/README.ko.md +6 -3
- package/README.md +6 -4
- package/README.zh-CN.md +4 -3
- package/gui/dist/assets/index-C0xVu72_.css +1 -0
- package/gui/dist/assets/index-DzEDGLZh.js +40 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +30 -2
- package/src/adapters/base.ts +10 -0
- package/src/adapters/google-http.ts +37 -13
- package/src/adapters/google-tool-schema.ts +5 -0
- package/src/adapters/google.ts +3 -0
- package/src/adapters/kiro-retry.ts +33 -13
- package/src/adapters/kiro-tools.ts +4 -0
- package/src/adapters/kiro.ts +5 -1
- package/src/adapters/openai-responses.ts +42 -0
- package/src/codex/catalog.ts +77 -7
- package/src/lib/abort.ts +40 -0
- package/src/lib/bounded-body.ts +202 -0
- package/src/providers/registry.ts +8 -1
- package/src/reasoning-effort.ts +5 -0
- package/src/server/auth-cors.ts +16 -13
- package/src/server/effort-policy.ts +172 -0
- package/src/server/images.ts +218 -0
- package/src/server/index.ts +57 -2
- package/src/server/management-api.ts +28 -0
- package/src/server/responses.ts +29 -1
- package/src/server/search.ts +150 -0
- package/src/types.ts +37 -0
- package/src/web-search/index.ts +44 -10
- package/src/web-search/loop.ts +165 -112
- package/src/web-search/progress-stream.ts +329 -0
- package/gui/dist/assets/index-66dPs6l_.js +0 -40
- package/gui/dist/assets/index-D7o1qwy-.css +0 -1
|
@@ -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
|
-
|
|
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" },
|
package/src/reasoning-effort.ts
CHANGED
|
@@ -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];
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -117,31 +117,34 @@ export function assertServerAuthConfig(config: OcxConfig): void {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const actual =
|
|
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
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
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
|
+
}
|