@bman654/clodex 2.2.2 → 2.3.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.
package/README.md CHANGED
@@ -275,6 +275,15 @@ clodex --version # version
275
275
  - Proxied routes forward configured provider headers for API-key and OAuth authentication. Anonymous routes preserve non-credential headers while removing authorization, API-key, cookie, token, secret, and credential-bearing header names before dispatch.
276
276
  - `CLODEX_CLAUDE_PATH` overrides Claude Code binary discovery.
277
277
  - **Outbound proxy:** when `HTTP_PROXY`/`HTTPS_PROXY` (and optionally `NO_PROXY`) are set in clodex's environment, all clodex-originated network calls honor them — OAuth sign-in and token refresh, model-list and models.dev refreshes, upstream OpenAI API calls, and the ChatGPT/Codex OAuth WebSocket transport (tunneled via HTTP CONNECT).
278
+ - **Upstream retries:** set `CLODEX_UPSTREAM_MAX_RETRIES` to an integer from
279
+ `0` through `5` to override the SDK's default of two retries for retryable
280
+ provider failures. `0` disables retries. The SDK honors valid
281
+ `retry-after`/`retry-after-ms` headers and otherwise uses exponential
282
+ backoff. Larger integers clamp to `5` with a one-time warning because a sixth
283
+ retry cannot complete before the translated streaming paths' 120-second
284
+ no-data timeout. Unset, empty, or malformed values preserve the default. A
285
+ stream that fails after output begins cannot be replayed safely and still
286
+ terminates the request.
278
287
 
279
288
  ## Known limitations
280
289
 
package/dist/cli.js CHANGED
@@ -218,7 +218,7 @@ import { join } from "path";
218
218
  // package.json
219
219
  var package_default = {
220
220
  name: "@bman654/clodex",
221
- version: "2.2.2",
221
+ version: "2.3.0",
222
222
  publishConfig: {
223
223
  access: "public"
224
224
  },
@@ -4496,6 +4496,61 @@ function warnReasoningNormalizationGap(fields, log12) {
4496
4496
  } catch {
4497
4497
  }
4498
4498
  }
4499
+ function toolArgumentNormalizationGap(expected, actual, requiredProps) {
4500
+ if (conversationItemKind(expected) !== "function_call") return void 0;
4501
+ if (conversationItemKind(actual) !== "function_call") return void 0;
4502
+ const left = expected;
4503
+ const right = actual;
4504
+ const callId = left.call_id;
4505
+ if (typeof callId !== "string" || !callId || callId !== right.call_id) return void 0;
4506
+ if (typeof left.name !== "string" || left.name !== right.name) return void 0;
4507
+ if (canonicalJson(normalizeToolCallJson(left)) === canonicalJson(normalizeToolCallJson(right))) {
4508
+ return void 0;
4509
+ }
4510
+ const required = requiredProps().get(left.name);
4511
+ const stripped = (item) => {
4512
+ if (typeof item.arguments !== "string") return void 0;
4513
+ const raw = item.arguments.trim();
4514
+ try {
4515
+ const parsed = raw === "" ? {} : JSON.parse(raw);
4516
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
4517
+ return canonicalJson({
4518
+ ...normalizeToolCallJson(item),
4519
+ arguments: canonicalJson(sanitizeToolInput(parsed, required))
4520
+ });
4521
+ } catch {
4522
+ return void 0;
4523
+ }
4524
+ };
4525
+ const leftStripped = stripped(left);
4526
+ const rightStripped = stripped(right);
4527
+ return {
4528
+ tool: left.name,
4529
+ equalAfterStrip: leftStripped !== void 0 && leftStripped === rightStripped
4530
+ };
4531
+ }
4532
+ var warnedToolArgumentGaps = /* @__PURE__ */ new Set();
4533
+ var MAX_TOOL_ARGUMENT_GAP_WARNINGS = 3;
4534
+ function warnToolArgumentNormalizationGap(gap, log12) {
4535
+ const tool3 = typeof gap.tool === "string" ? gap.tool : "unknown";
4536
+ const signature = `${tool3}:filler`;
4537
+ const message = `clodex: warning: tool call "${tool3}" failed the continuation match, but both sides are identical once clodex's filler-strip rule is applied, so the head should have matched. Prompt caching is degraded for this turn \u2014 please report it, with the adapter debug log from --trace if you can, at https://github.com/bman654/clodex/issues`;
4538
+ try {
4539
+ log12?.(`tool argument normalization gap: ${signature}`);
4540
+ } catch {
4541
+ }
4542
+ if (warnedToolArgumentGaps.has(signature)) return;
4543
+ if (warnedToolArgumentGaps.size >= MAX_TOOL_ARGUMENT_GAP_WARNINGS) return;
4544
+ warnedToolArgumentGaps.add(signature);
4545
+ try {
4546
+ process.stderr.write(`${message}
4547
+ `);
4548
+ if (warnedToolArgumentGaps.size === MAX_TOOL_ARGUMENT_GAP_WARNINGS) {
4549
+ process.stderr.write("clodex: warning: further tool-argument normalization warnings suppressed.\n");
4550
+ }
4551
+ } catch {
4552
+ }
4553
+ }
4499
4554
  function continuationMismatchDetails(entry, payload, log12, warnOnGap = false) {
4500
4555
  const full = inputArray(payload);
4501
4556
  const prefix = [...entry.requestInput ?? [], ...entry.expectedAssistant ?? []];
@@ -4511,6 +4566,34 @@ function continuationMismatchDetails(entry, payload, log12, warnOnGap = false) {
4511
4566
  const actual = mismatch < full.length ? full[mismatch] : void 0;
4512
4567
  const reasoningGap = reasoningNormalizationGap(expected, actual);
4513
4568
  if (reasoningGap && warnOnGap) warnReasoningNormalizationGap(reasoningGap, log12);
4569
+ let gapExpected = expected;
4570
+ if (conversationItemKind(expected) === "reasoning" && conversationItemKind(actual) === "function_call") {
4571
+ for (let index = mismatch; index < prefix.length; index += 1) {
4572
+ if (conversationItemKind(prefix[index]) !== "reasoning") {
4573
+ gapExpected = prefix[index];
4574
+ break;
4575
+ }
4576
+ }
4577
+ }
4578
+ let toolArgumentGap;
4579
+ try {
4580
+ toolArgumentGap = toolArgumentNormalizationGap(
4581
+ gapExpected,
4582
+ actual,
4583
+ // The head's own schema when it has one; the current turn's tools are only a
4584
+ // fallback for a head that predates the snapshot (see headRequiredToolProps).
4585
+ () => entry.headRequiredToolProps ?? requiredToolProps(payload)
4586
+ );
4587
+ } catch {
4588
+ }
4589
+ if (toolArgumentGap?.equalAfterStrip === true) {
4590
+ if (warnOnGap) warnToolArgumentNormalizationGap(toolArgumentGap, log12);
4591
+ } else if (toolArgumentGap && warnOnGap) {
4592
+ try {
4593
+ log12?.(`tool argument mismatch beyond the strip rule: ${String(toolArgumentGap.tool)}`);
4594
+ } catch {
4595
+ }
4596
+ }
4514
4597
  return {
4515
4598
  fullItems: full.length,
4516
4599
  expectedPrefixItems: prefix.length,
@@ -4528,11 +4611,12 @@ function continuationMismatchDetails(entry, payload, log12, warnOnGap = false) {
4528
4611
  entry.expectedAssistant ?? [],
4529
4612
  mismatch
4530
4613
  )
4531
- } : {}
4614
+ } : {},
4615
+ ...toolArgumentGap ? { toolArgumentNormalizationGap: toolArgumentGap } : {}
4532
4616
  };
4533
4617
  }
4534
- function continuationMismatchSummary(entry, payload, log12, mismatchDump = false) {
4535
- const details = continuationMismatchDetails(entry, payload, log12, true);
4618
+ function continuationMismatchSummary(entry, payload, log12, mismatchDump = false, precomputedDetails) {
4619
+ const details = precomputedDetails ?? continuationMismatchDetails(entry, payload, log12, true);
4536
4620
  let summary = `full_items=${details.fullItems} expected_prefix_items=${details.expectedPrefixItems} first_mismatch=${details.firstMismatch} expected=${details.expectedKind} actual=${details.actualKind}`;
4537
4621
  if (details.expectedHash || details.actualHash) {
4538
4622
  summary += ` expected_hash=${details.expectedHash ?? "none"} actual_hash=${details.actualHash ?? "none"}`;
@@ -4668,6 +4752,7 @@ function emitContextDiagnostic(entry, ctx, details) {
4668
4752
  retried: ctx.retried,
4669
4753
  frameCount: ctx.frameCount,
4670
4754
  emittedModelData: ctx.emittedModelData,
4755
+ emittedDownstreamData: ctx.emittedDownstreamData,
4671
4756
  responseIdReceived: Boolean(ctx.responseId),
4672
4757
  inFlightMs: entry.inFlightStartedAt === void 0 ? void 0 : Math.max(0, entry.options.now() - entry.inFlightStartedAt),
4673
4758
  ...details
@@ -4922,6 +5007,7 @@ function expectedAssistantItems(ctx) {
4922
5007
  }
4923
5008
  function encodeSse(ctx, event) {
4924
5009
  if (ctx.closed) return;
5010
+ ctx.emittedDownstreamData = true;
4925
5011
  ctx.controller.enqueue(ctx.encoder.encode(`data: ${JSON.stringify(event)}
4926
5012
 
4927
5013
  `));
@@ -4973,12 +5059,12 @@ function failContext(entry, ctx, message, diagnosticDetails, statusCode, retryAf
4973
5059
  closeContext(ctx);
4974
5060
  }
4975
5061
  function retryTransportFailure(entry, ctx, diagnosticDetails) {
4976
- if (ctx.closed || entry.current !== ctx || ctx.retried || ctx.frameCount !== 0 || ctx.emittedModelData) {
5062
+ if (ctx.closed || entry.current !== ctx || ctx.retried || !transportReplaySafe(ctx)) {
4977
5063
  return false;
4978
5064
  }
4979
5065
  ctx.retried = true;
4980
5066
  ctx.transportRetryPending = true;
4981
- entry.debug("transport failed before any response frame; retrying once with full context");
5067
+ entry.debug("transport failed before downstream output; retrying once with full context");
4982
5068
  emitContextDiagnostic(entry, ctx, {
4983
5069
  event: "ws_transport_retry",
4984
5070
  outcome: "started",
@@ -5012,9 +5098,9 @@ function retryTransportFailure(entry, ctx, diagnosticDetails) {
5012
5098
  function handleTransportFailure(entry, ctx, message, diagnosticDetails) {
5013
5099
  if (retryTransportFailure(entry, ctx, diagnosticDetails)) return;
5014
5100
  if (ctx.closed || entry.current !== ctx) return;
5015
- if (ctx.retried && ctx.frameCount === 0 && !ctx.emittedModelData) {
5101
+ if (ctx.retried && ctx.transportRetryPending && transportReplaySafe(ctx)) {
5016
5102
  ctx.transportRetryPending = false;
5017
- entry.debug("transport retry exhausted before any response frame");
5103
+ entry.debug("transport retry exhausted before downstream output");
5018
5104
  emitContextDiagnostic(entry, ctx, {
5019
5105
  event: "ws_transport_retry",
5020
5106
  outcome: "exhausted",
@@ -5118,6 +5204,9 @@ function resetContextForRetry(ctx) {
5118
5204
  ctx.recentUpstreamEventTypes = [];
5119
5205
  ctx.emittedProtocolAnomalies.clear();
5120
5206
  }
5207
+ function transportReplaySafe(ctx) {
5208
+ return !ctx.emittedDownstreamData && !ctx.emittedModelData && ctx.outputByIndex.size === 0;
5209
+ }
5121
5210
  function handleSocketMessage(entry, data) {
5122
5211
  const ctx = entry.current;
5123
5212
  if (!ctx || ctx.closed) return;
@@ -5232,6 +5321,7 @@ function handleSocketMessage(entry, data) {
5232
5321
  entry.responseId = ctx.responseId;
5233
5322
  entry.requestInput = inputArray(ctx.originalPayload);
5234
5323
  entry.expectedAssistant = expectedAssistantItems(ctx);
5324
+ entry.headRequiredToolProps = requiredToolProps(ctx.originalPayload);
5235
5325
  entry.canonicalPrefix = void 0;
5236
5326
  entry.canonicalEchoablePrefix = void 0;
5237
5327
  entry.promptFieldHashes = ctx.promptFieldHashes;
@@ -5417,6 +5507,7 @@ function createResponsesWebSocketFetch(wsUrl, log12, options = {}) {
5417
5507
  let persistent = Boolean(partitionKey);
5418
5508
  let promotedConnectionId;
5419
5509
  let decision;
5510
+ let candidateMismatchDetails;
5420
5511
  if (selected && selectedDelta) {
5421
5512
  sendPayload = { ...payload, input: selectedDelta, previous_response_id: selected.responseId };
5422
5513
  continued = true;
@@ -5439,8 +5530,23 @@ function createResponsesWebSocketFetch(wsUrl, log12, options = {}) {
5439
5530
  decision = "parallel_isolated";
5440
5531
  debug("parallel request using an isolated socket");
5441
5532
  } else if (diagnosticEntry) {
5533
+ const diagnosticMismatch = continuationMismatchDetails(diagnosticEntry, payload, debug, true);
5534
+ candidateMismatchDetails = /* @__PURE__ */ new Map([[diagnosticEntry, diagnosticMismatch]]);
5535
+ for (const candidate of candidates) {
5536
+ if (candidate === diagnosticEntry) continue;
5537
+ candidateMismatchDetails.set(
5538
+ candidate,
5539
+ continuationMismatchDetails(candidate, payload, debug, true)
5540
+ );
5541
+ }
5442
5542
  debug(
5443
- `history mismatch starting an additional chain; retained ${candidates.length} existing head(s) (${continuationMismatchSummary(diagnosticEntry, payload, debug, mismatchDump)})`
5543
+ `history mismatch starting an additional chain; retained ${candidates.length} existing head(s) (${continuationMismatchSummary(
5544
+ diagnosticEntry,
5545
+ payload,
5546
+ debug,
5547
+ mismatchDump,
5548
+ diagnosticMismatch
5549
+ )})`
5444
5550
  );
5445
5551
  decision = "history_mismatch_new_head";
5446
5552
  } else if (partitionKey) {
@@ -5500,7 +5606,7 @@ function createResponsesWebSocketFetch(wsUrl, log12, options = {}) {
5500
5606
  ttlPausedMs: entry.ttlPausedMs,
5501
5607
  idleMs: Math.max(0, now - entry.lastUsedAt),
5502
5608
  promptChanges: changedPromptFields(entry.promptFieldHashes, promptFieldHashes),
5503
- mismatch: continuationMismatchDetails(entry, payload, debug)
5609
+ mismatch: candidateMismatchDetails?.get(entry) ?? continuationMismatchDetails(entry, payload, debug)
5504
5610
  })),
5505
5611
  evictions
5506
5612
  }, diagnosticCorrelation);
@@ -5520,6 +5626,7 @@ function createResponsesWebSocketFetch(wsUrl, log12, options = {}) {
5520
5626
  frameCount: 0,
5521
5627
  pendingEvents: [],
5522
5628
  emittedModelData: false,
5629
+ emittedDownstreamData: false,
5523
5630
  transportRetryPending: false,
5524
5631
  outputByIndex: /* @__PURE__ */ new Map(),
5525
5632
  outputIndexByItemId: /* @__PURE__ */ new Map(),
@@ -9882,6 +9989,41 @@ function resolveUpstreamTools(tools, messages) {
9882
9989
  return upstream;
9883
9990
  }
9884
9991
 
9992
+ // src/upstream-retry.ts
9993
+ var UPSTREAM_MAX_RETRIES_ENV = "CLODEX_UPSTREAM_MAX_RETRIES";
9994
+ var MAX_UPSTREAM_MAX_RETRIES = 5;
9995
+ var reportedValues = /* @__PURE__ */ new Set();
9996
+ function reportOnce(raw, message, warn) {
9997
+ if (reportedValues.has(raw)) return;
9998
+ reportedValues.add(raw);
9999
+ try {
10000
+ warn(message);
10001
+ } catch {
10002
+ }
10003
+ }
10004
+ function upstreamMaxRetries(env = process.env, warn = (message) => console.error(`clodex: ${message}`)) {
10005
+ const raw = env[UPSTREAM_MAX_RETRIES_ENV]?.trim();
10006
+ if (raw === void 0 || raw === "") return void 0;
10007
+ const value = Number(raw);
10008
+ if (!Number.isInteger(value) || value < 0) {
10009
+ reportOnce(
10010
+ raw,
10011
+ `ignoring ${UPSTREAM_MAX_RETRIES_ENV}=${raw} (expected a non-negative integer)`,
10012
+ warn
10013
+ );
10014
+ return void 0;
10015
+ }
10016
+ if (value > MAX_UPSTREAM_MAX_RETRIES) {
10017
+ reportOnce(
10018
+ raw,
10019
+ `clamping ${UPSTREAM_MAX_RETRIES_ENV}=${raw} to ${MAX_UPSTREAM_MAX_RETRIES} (higher values exceed the 120s streaming idle budget)`,
10020
+ warn
10021
+ );
10022
+ return MAX_UPSTREAM_MAX_RETRIES;
10023
+ }
10024
+ return value;
10025
+ }
10026
+
9885
10027
  // src/sdk-adapter.ts
9886
10028
  function sdkTranslationErrorSignature(error) {
9887
10029
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : void 0;
@@ -9952,10 +10094,12 @@ function openAiCacheBreakpoint(block, enabled) {
9952
10094
  function translateTopLevelSystemForOpenAi(system) {
9953
10095
  if (!system) return [];
9954
10096
  if (typeof system === "string") {
9955
- return system.trim() ? [{ role: "system", content: system }] : [];
10097
+ const stripped = stripClaudeCodeBillingHeader(system);
10098
+ return stripped?.trim() ? [{ role: "system", content: stripped }] : [];
9956
10099
  }
9957
10100
  return system.flatMap((block) => {
9958
- const text4 = typeof block === "string" ? block : block.text ?? "";
10101
+ const raw = typeof block === "string" ? block : block.text ?? "";
10102
+ const text4 = stripClaudeCodeBillingHeader(raw) ?? "";
9959
10103
  if (!text4.trim()) return [];
9960
10104
  const cacheControl = typeof block === "string" ? void 0 : block.cache_control;
9961
10105
  return [{
@@ -10141,7 +10285,7 @@ function isClaudeCodeStructuredOutputCompactRequest(body) {
10141
10285
  function translateRequest(body, npm, options) {
10142
10286
  const messages = body.messages ?? [];
10143
10287
  annotateToolNames(messages);
10144
- const baseSystem = systemToString(body.system, options?.openAiOAuth === true);
10288
+ const baseSystem = systemToString(body.system, true);
10145
10289
  const systemText = baseSystem?.trim() || (options?.openAiOAuth ? "You are a coding assistant." : void 0);
10146
10290
  const compactRequest = isClaudeCodeStructuredOutputCompactRequest(body);
10147
10291
  let upstreamTools = resolveUpstreamTools(
@@ -10432,6 +10576,7 @@ async function streamAnthropicResponse(model, params, modelId, write, log12, obs
10432
10576
  const result = streamText({
10433
10577
  model,
10434
10578
  ...params,
10579
+ maxRetries: upstreamMaxRetries(),
10435
10580
  abortSignal,
10436
10581
  onError: () => {
10437
10582
  }
@@ -10480,6 +10625,7 @@ async function generateAnthropicResponse(model, params, modelId, options) {
10480
10625
  const r = streamText({
10481
10626
  model,
10482
10627
  ...params,
10628
+ maxRetries: upstreamMaxRetries(),
10483
10629
  abortSignal,
10484
10630
  onError: () => {
10485
10631
  }
@@ -10536,6 +10682,7 @@ async function generateAnthropicResponse(model, params, modelId, options) {
10536
10682
  const r = await generateText({
10537
10683
  model,
10538
10684
  ...params,
10685
+ maxRetries: upstreamMaxRetries(),
10539
10686
  abortSignal: generateAbort.signal
10540
10687
  });
10541
10688
  ({ text: text4, toolCalls, finishReason, usage } = r);
@@ -11490,11 +11637,20 @@ async function collectOpenAiStream(stream) {
11490
11637
  async function generateOpenAiResponse(model, params, responseModelId, options) {
11491
11638
  let result;
11492
11639
  if (options?.forceStream) {
11493
- const { stream } = streamText2({ model, ...params, onError: () => {
11494
- } });
11640
+ const { stream } = streamText2({
11641
+ model,
11642
+ ...params,
11643
+ maxRetries: upstreamMaxRetries(),
11644
+ onError: () => {
11645
+ }
11646
+ });
11495
11647
  result = await collectOpenAiStream(stream);
11496
11648
  } else {
11497
- result = await generateText2({ model, ...params });
11649
+ result = await generateText2({
11650
+ model,
11651
+ ...params,
11652
+ maxRetries: upstreamMaxRetries()
11653
+ });
11498
11654
  }
11499
11655
  const message = { role: "assistant", content: result.text || null };
11500
11656
  if (result.toolCalls?.length) {
@@ -11518,7 +11674,11 @@ async function generateOpenAiResponse(model, params, responseModelId, options) {
11518
11674
  };
11519
11675
  }
11520
11676
  async function streamOpenAiResponse(model, params, responseModelId, onChunk) {
11521
- const { stream } = streamText2({ model, ...params });
11677
+ const { stream } = streamText2({
11678
+ model,
11679
+ ...params,
11680
+ maxRetries: upstreamMaxRetries()
11681
+ });
11522
11682
  const baseData = {
11523
11683
  id: `chatcmpl-${Date.now()}`,
11524
11684
  object: "chat.completion.chunk",