@bitkyc08/opencodex 2.7.20 → 2.7.21

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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BH-RbAg4.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-BpqkH5f_.css">
19
+ <script type="module" crossorigin src="/assets/index-DYIS0tTL.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-CILVKWmx.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.20",
3
+ "version": "2.7.21",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -15,15 +15,17 @@ import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync
15
15
  import { join } from "node:path";
16
16
  import type { OcxConfig } from "../types";
17
17
  import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias";
18
- import { resolveAutoContext, withOneMillionMarker } from "./context-windows";
18
+ import { resolveAutoContext, stripOneMillionMarker, withOneMillionMarker } from "./context-windows";
19
19
  import { claudeConfigDir } from "./gateway-cache";
20
20
  import { DEFAULT_SUBAGENT_MODELS } from "../config";
21
+ import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound";
21
22
 
22
23
  export interface ClaudeAgentDef {
23
24
  file: string;
24
25
  name: string;
25
26
  model: string;
26
27
  description: string;
28
+ blockedSkills: readonly string[];
27
29
  }
28
30
 
29
31
  const OWNED_PREFIX = "ocx-";
@@ -63,6 +65,15 @@ function entryParts(entry: string): { alias: string; id: string; provider: strin
63
65
 
64
66
  export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string, number>, configDir = claudeConfigDir()): ClaudeAgentDef[] {
65
67
  const auto = resolveAutoContext(config.claudeCode);
68
+ const blockedSkills = effectiveBlockedSkillNames(config.claudeCode);
69
+ const blockedSkillsFor = (model: string): readonly string[] => {
70
+ const unmarked = stripOneMillionMarker(model);
71
+ const nativePassthrough = config.claudeCode?.nativePassthrough !== false
72
+ && !unmarked.includes("/")
73
+ && /^(claude|anthropic)(?:-|$)/i.test(unmarked)
74
+ && resolveInboundModel(unmarked, config.claudeCode) === unmarked;
75
+ return nativePassthrough ? [] : blockedSkills;
76
+ };
66
77
  const defs: ClaudeAgentDef[] = [];
67
78
  const usedNames = new Set<string>();
68
79
  const coveredModels = new Set<string>();
@@ -76,7 +87,13 @@ export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string,
76
87
  let unique = name;
77
88
  for (let i = 2; usedNames.has(unique); i++) unique = `${name}-${i}`;
78
89
  usedNames.add(unique);
79
- defs.push({ file: `${OWNED_PREFIX}${unique}.md`, name: `${OWNED_PREFIX}${unique}`, model, description });
90
+ defs.push({
91
+ file: `${OWNED_PREFIX}${unique}.md`,
92
+ name: `${OWNED_PREFIX}${unique}`,
93
+ model,
94
+ description,
95
+ blockedSkills: blockedSkillsFor(model),
96
+ });
80
97
  };
81
98
 
82
99
  // Default roster applies only when the field is UNSET — an explicit [] is
@@ -100,12 +117,25 @@ export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string,
100
117
  name: `${OWNED_PREFIX}self`,
101
118
  model: marked,
102
119
  description: `Self-clone: delegate to your default main model (${marked}), synced from the /model picker at launch. ${NO_MODEL_ARG}`,
120
+ blockedSkills: blockedSkillsFor(marked),
103
121
  });
104
122
  }
105
123
  return defs;
106
124
  }
107
125
 
126
+ function skillNameLiteral(name: string): string {
127
+ return JSON.stringify(name)
128
+ .replaceAll("`", "\\u0060")
129
+ .replaceAll("<", "\\u003c")
130
+ .replaceAll(">", "\\u003e");
131
+ }
132
+
108
133
  function renderAgentDef(def: ClaudeAgentDef): string {
134
+ const blockedSkillGuard = def.blockedSkills.length === 0 ? [] : [
135
+ "",
136
+ `Do not invoke blocked Claude Code skills: ${def.blockedSkills.map(skillNameLiteral).join(", ")}.`,
137
+ "Their document bundles are intentionally omitted for routed models; continue without loading them.",
138
+ ];
109
139
  // YAML frontmatter: model ids carry dots/brackets — always double-quote scalars.
110
140
  return [
111
141
  "---",
@@ -125,6 +155,7 @@ function renderAgentDef(def: ClaudeAgentDef): string {
125
155
  `IDENTITY: your ACTUAL underlying model is \`${def.model}\` — the opencodex proxy routes this`,
126
156
  "session there regardless of what model name the Claude Code harness displays or claims.",
127
157
  "If asked which model you are, answer with the id above; do not guess a Claude model name.",
158
+ ...blockedSkillGuard,
128
159
  "",
129
160
  "Complete the dispatched task directly and report results concisely. This file is",
130
161
  "auto-generated by opencodex (`ocx claude`) from the featured subagent roster —",
@@ -130,6 +130,15 @@ function pushUserMessage(input: Rec[], blocks: Rec[]): void {
130
130
  */
131
131
  export const DEFAULT_BLOCKED_SKILLS = ["claude-api"];
132
132
 
133
+ /** Shared effective policy for proxy elision and generated routed-agent guards. */
134
+ export function effectiveBlockedSkillNames(cc?: Pick<OcxClaudeCodeConfig, "blockedSkills">): string[] {
135
+ const names = cc?.blockedSkills ?? DEFAULT_BLOCKED_SKILLS;
136
+ return [...new Set(names
137
+ .filter((name): name is string => typeof name === "string")
138
+ .map(name => name.trim().toLowerCase())
139
+ .filter(name => name.length > 0))];
140
+ }
141
+
133
142
  /**
134
143
  * ocx-route directive (devlog 072): injected agent-definition bodies carry
135
144
  * `<!-- ocx-route: <model> -->` because Claude Code 2.1.207 ignores custom
@@ -395,7 +404,7 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode
395
404
  const systemParts: string[] = [];
396
405
  const topLevelSystem = systemToInstructions(raw.system);
397
406
  if (topLevelSystem !== undefined) systemParts.push(topLevelSystem);
398
- const blockedNames = (cc?.blockedSkills ?? DEFAULT_BLOCKED_SKILLS).map(n => n.toLowerCase()).filter(n => n.length > 0);
407
+ const blockedNames = effectiveBlockedSkillNames(cc);
399
408
  const elide: SkillElisionContext = {
400
409
  callIds: blockedSkillCallIds(raw.messages, blockedNames),
401
410
  names: blockedNames,
@@ -19,6 +19,7 @@ import {
19
19
  responsesJsonToAnthropicMessage,
20
20
  responsesSseToAnthropicSse,
21
21
  } from "../claude/outbound";
22
+ import { clearableDeadline } from "../lib/abort";
22
23
  import { estimateTokens } from "../lib/token-estimate";
23
24
  import { routeModel } from "../router";
24
25
  import type { OcxConfig } from "../types";
@@ -204,24 +205,22 @@ async function anthropicNativePassthrough(
204
205
  });
205
206
  headers.set("content-type", "application/json");
206
207
 
207
- const timeoutSignal = AbortSignal.timeout(config.connectTimeoutMs ?? 120_000);
208
- const upstreamSignal = AbortSignal.any([req.signal, timeoutSignal]);
209
- let upstream: Response;
210
- try {
211
- upstream = await fetch(`${base}${pathname}${search}`, {
212
- method: "POST",
213
- headers,
214
- body: JSON.stringify(body),
215
- signal: upstreamSignal,
216
- });
217
- } catch (err) {
218
- if (timeoutSignal.aborted && upstreamSignal.reason === timeoutSignal.reason) {
219
- finalize(504, { closeReason: "non_stream" });
220
- return anthropicErrorResponse(504, "anthropic passthrough timed out waiting for response headers", "timeout_error");
221
- }
208
+ const result = await fetchWithHeaderDeadline(
209
+ `${base}${pathname}${search}`,
210
+ { method: "POST", headers, body: JSON.stringify(body) },
211
+ config.connectTimeoutMs ?? 120_000,
212
+ req.signal,
213
+ );
214
+ if (result.kind === "timeout") {
215
+ finalize(504, { closeReason: "non_stream" });
216
+ return anthropicErrorResponse(504, "anthropic passthrough timed out waiting for response headers", "timeout_error");
217
+ }
218
+ if (result.kind === "error") {
219
+ const err = result.error;
222
220
  finalize(502, { closeReason: "non_stream" });
223
221
  return anthropicErrorResponse(502, `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error");
224
222
  }
223
+ const upstream = result.upstream;
225
224
 
226
225
  const contentType = upstream.headers.get("content-type") ?? "application/json";
227
226
  if (upstream.ok && contentType.includes("text/event-stream") && upstream.body) {
@@ -250,6 +249,43 @@ async function anthropicNativePassthrough(
250
249
  });
251
250
  }
252
251
 
252
+ /**
253
+ * Header-phase fetch guarded by a clearable deadline (PR #136 follow-up hardening).
254
+ *
255
+ * The deadline covers ONLY the wait for response headers; once `fetch` settles —
256
+ * fulfilled OR rejected — the timer must die. The `finally` block guarantees
257
+ * `clear()` on every path (success, upstream reject, deadline expiry), fixing the
258
+ * timer leak where a rejected fetch left the deadline running until expiry.
259
+ * `didExpire()` stays truthful after `clear()` (see src/lib/abort.ts), so timeout
260
+ * classification inside the catch is unaffected by the finally cleanup.
261
+ *
262
+ * `makeDeadline`/`fetchImpl` are injectable for deterministic unit tests.
263
+ */
264
+ export type HeaderDeadlineFetchResult =
265
+ | { kind: "response"; upstream: Response }
266
+ | { kind: "timeout" }
267
+ | { kind: "error"; error: unknown };
268
+
269
+ export async function fetchWithHeaderDeadline(
270
+ input: string | URL,
271
+ init: RequestInit,
272
+ timeoutMs: number,
273
+ parent?: AbortSignal,
274
+ makeDeadline: typeof clearableDeadline = clearableDeadline,
275
+ fetchImpl: typeof fetch = fetch,
276
+ ): Promise<HeaderDeadlineFetchResult> {
277
+ const deadline = makeDeadline(timeoutMs, parent);
278
+ try {
279
+ const upstream = await fetchImpl(input, { ...init, signal: deadline.signal });
280
+ return { kind: "response", upstream };
281
+ } catch (error) {
282
+ if (deadline.didExpire()) return { kind: "timeout" };
283
+ return { kind: "error", error };
284
+ } finally {
285
+ deadline.clear();
286
+ }
287
+ }
288
+
253
289
  export async function handleClaudeMessages(
254
290
  req: Request,
255
291
  config: OcxConfig,
@@ -27,7 +27,7 @@ import { fetchProviderQuotaReports } from "../providers/quota";
27
27
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../providers/context-cap";
28
28
  import { readUsageEntries } from "../usage/log";
29
29
  import { getUsageDebugLogEntries } from "../usage/debug";
30
- import { parseRange, summarizeUsage } from "../usage/summary";
30
+ import { parseRange, parseUsageSurface, summarizeUsage } from "../usage/summary";
31
31
  import { stripCodexRuntimeProviderFields } from "../codex/auth-context";
32
32
  import { getProviderRegistryEntry } from "../providers/registry";
33
33
  import { getDebugLogEntries } from "../lib/debug-log-buffer";
@@ -358,12 +358,14 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
358
358
 
359
359
  if (url.pathname === "/api/usage" && req.method === "GET") {
360
360
  const range = parseRange(url.searchParams.get("range"));
361
+ const surface = parseUsageSurface(url.searchParams.get("surface"));
361
362
  const now = Date.now();
362
363
  try {
363
- return jsonResponse(summarizeUsage(readUsageEntries(), range, now));
364
+ return jsonResponse(summarizeUsage(readUsageEntries(), range, now, surface));
364
365
  } catch {
365
366
  return jsonResponse({
366
367
  range,
368
+ surface,
367
369
  since: null,
368
370
  generatedAt: now,
369
371
  summary: {
@@ -89,6 +89,7 @@ export function addRequestLog(entry: RequestLogEntry) {
89
89
  timestamp: entry.timestamp,
90
90
  provider: entry.provider,
91
91
  model: entry.model,
92
+ ...(entry.surface === "claude" ? { surface: entry.surface } : {}),
92
93
  ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
93
94
  status: entry.status,
94
95
  durationMs: entry.durationMs,
package/src/usage/log.ts CHANGED
@@ -11,6 +11,7 @@ export interface PersistedUsageEntry {
11
11
  timestamp: number;
12
12
  provider: string;
13
13
  model: string;
14
+ surface?: "claude";
14
15
  resolvedModel?: string;
15
16
  status: number;
16
17
  durationMs: number;
@@ -68,6 +69,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
68
69
  timestamp: entry.timestamp,
69
70
  provider: entry.provider,
70
71
  model: entry.model,
72
+ ...(entry.surface === "claude" ? { surface: entry.surface } : {}),
71
73
  ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
72
74
  status: entry.status,
73
75
  durationMs: entry.durationMs,
@@ -3,6 +3,7 @@ import { usageDisplayTotalTokens } from "./totals";
3
3
  import type { PersistedUsageEntry, UsageStatus } from "./log";
4
4
 
5
5
  export type UsageRange = "7d" | "30d" | "all";
6
+ export type UsageSurface = "all" | "codex" | "claude";
6
7
 
7
8
  export interface UsageSummaryTotals {
8
9
  requests: number;
@@ -63,6 +64,7 @@ export interface UsageProvider {
63
64
 
64
65
  export interface UsageSummary {
65
66
  range: UsageRange;
67
+ surface: UsageSurface;
66
68
  since: number | null;
67
69
  generatedAt: number;
68
70
  summary: UsageSummaryTotals;
@@ -78,6 +80,11 @@ export function parseRange(input: string | null | undefined): UsageRange {
78
80
  return "30d";
79
81
  }
80
82
 
83
+ export function parseUsageSurface(input: string | null | undefined): UsageSurface {
84
+ if (input === "codex" || input === "claude") return input;
85
+ return "all";
86
+ }
87
+
81
88
  function rangeWindow(range: UsageRange, now: number): { since: number | null; days: number } {
82
89
  if (range === "7d") return { since: now - 7 * DAY_MS, days: 7 };
83
90
  if (range === "30d") return { since: now - 30 * DAY_MS, days: 30 };
@@ -270,22 +277,33 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us
270
277
  return providers.sort((a, b) => b.requests - a.requests);
271
278
  }
272
279
 
273
- export function summarizeUsage(entries: PersistedUsageEntry[], range: UsageRange, now: number): UsageSummary {
280
+ export function summarizeUsage(
281
+ entries: PersistedUsageEntry[],
282
+ range: UsageRange,
283
+ now: number,
284
+ surface: UsageSurface = "all",
285
+ ): UsageSummary {
274
286
  const { since } = rangeWindow(range, now);
275
- const inRange = since === null ? entries : entries.filter(e => e.timestamp >= since);
287
+ const filteredEntries = entries.filter(entry => {
288
+ if (since !== null && entry.timestamp < since) return false;
289
+ if (surface === "claude") return entry.surface === "claude";
290
+ if (surface === "codex") return entry.surface !== "claude";
291
+ return true;
292
+ });
276
293
  const totals = blankTotals();
277
- for (const entry of inRange) {
294
+ for (const entry of filteredEntries) {
278
295
  bumpStatus(totals, entry.usageStatus);
279
296
  addTokens(totals, entry);
280
297
  }
281
298
  finalizeCoverage(totals);
282
299
  return {
283
300
  range,
301
+ surface,
284
302
  since,
285
303
  generatedAt: now,
286
304
  summary: totals,
287
- days: buildDayGrid(range, since, now, inRange),
288
- models: buildModels(inRange, totals.totalTokens),
289
- providers: buildProviders(inRange, totals.totalTokens),
305
+ days: buildDayGrid(range, since, now, filteredEntries),
306
+ models: buildModels(filteredEntries, totals.totalTokens),
307
+ providers: buildProviders(filteredEntries, totals.totalTokens),
290
308
  };
291
309
  }