@juspay/neurolink 9.79.1 → 9.79.3

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +337 -332
  3. package/dist/core/modules/GenerationHandler.js +43 -1
  4. package/dist/core/modules/structuredOutputPolicy.d.ts +42 -6
  5. package/dist/core/modules/structuredOutputPolicy.js +58 -7
  6. package/dist/lib/core/modules/GenerationHandler.js +43 -1
  7. package/dist/lib/core/modules/structuredOutputPolicy.d.ts +42 -6
  8. package/dist/lib/core/modules/structuredOutputPolicy.js +58 -7
  9. package/dist/lib/processors/document/ExcelProcessor.js +9 -1
  10. package/dist/lib/providers/anthropic.js +37 -41
  11. package/dist/lib/providers/anthropicImageBlocks.d.ts +47 -0
  12. package/dist/lib/providers/anthropicImageBlocks.js +223 -0
  13. package/dist/lib/providers/googleNativeGemini3.d.ts +26 -0
  14. package/dist/lib/providers/googleNativeGemini3.js +48 -0
  15. package/dist/lib/providers/googleVertex.d.ts +16 -0
  16. package/dist/lib/providers/googleVertex.js +200 -24
  17. package/dist/lib/proxy/oauthFetch.js +26 -14
  18. package/dist/lib/proxy/proxyTracer.d.ts +7 -1
  19. package/dist/lib/proxy/proxyTracer.js +29 -0
  20. package/dist/lib/proxy/systemRelocation.d.ts +21 -0
  21. package/dist/lib/proxy/systemRelocation.js +51 -0
  22. package/dist/lib/server/routes/claudeProxyRoutes.js +147 -19
  23. package/dist/lib/types/proxy.d.ts +10 -0
  24. package/dist/processors/document/ExcelProcessor.js +9 -1
  25. package/dist/providers/anthropic.js +37 -41
  26. package/dist/providers/anthropicImageBlocks.d.ts +47 -0
  27. package/dist/providers/anthropicImageBlocks.js +222 -0
  28. package/dist/providers/googleNativeGemini3.d.ts +26 -0
  29. package/dist/providers/googleNativeGemini3.js +48 -0
  30. package/dist/providers/googleVertex.d.ts +16 -0
  31. package/dist/providers/googleVertex.js +200 -24
  32. package/dist/proxy/oauthFetch.js +26 -14
  33. package/dist/proxy/proxyTracer.d.ts +7 -1
  34. package/dist/proxy/proxyTracer.js +29 -0
  35. package/dist/proxy/systemRelocation.d.ts +21 -0
  36. package/dist/proxy/systemRelocation.js +50 -0
  37. package/dist/server/routes/claudeProxyRoutes.js +147 -19
  38. package/dist/types/proxy.d.ts +10 -0
  39. package/package.json +8 -2
@@ -22,7 +22,7 @@ import { convertZodToJsonSchema, inlineJsonSchema, ensureNestedSchemaTypes, } fr
22
22
  import { createNativeThinkingConfig } from "../utils/thinkingConfig.js";
23
23
  import { TimeoutError, withTimeout } from "../utils/async/index.js";
24
24
  import { parseTimeout } from "../utils/timeout.js";
25
- import { createTextChannel, extractThoughtSignature, prependConversationMessages, } from "./googleNativeGemini3.js";
25
+ import { appendStepText, createTextChannel, extractThoughtSignature, mapGeminiFinishReason, prependConversationMessages, } from "./googleNativeGemini3.js";
26
26
  import { ATTR, LANGFUSE_ATTR, spanJsonAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../telemetry/index.js";
27
27
  import { SpanKind, SpanStatusCode, context as otelContext, trace as otelTrace, } from "@opentelemetry/api";
28
28
  import { calculateCost } from "../utils/pricing.js";
@@ -786,7 +786,7 @@ export class GoogleVertexProvider extends BaseProvider {
786
786
  // lifecycle callbacks. Pipeline A gets these via the AI SDK
787
787
  // wrapStream middleware; the native path has to fire them here.
788
788
  const wrappedResult = this.wrapStreamResultWithLifecycle(options, result, streamStartTime);
789
- this.emitStreamEnd(modelName, streamStartTime, true);
789
+ this.emitStreamEnd(modelName, streamStartTime, true, undefined, result.finishReason);
790
790
  return wrappedResult;
791
791
  }
792
792
  catch (error) {
@@ -801,7 +801,7 @@ export class GoogleVertexProvider extends BaseProvider {
801
801
  * `model.generation` span for native Vertex stream traffic. Mirrors
802
802
  * `emitGenerationEnd` (used by `generate()`).
803
803
  */
804
- emitStreamEnd(modelName, startTime, success, error) {
804
+ emitStreamEnd(modelName, startTime, success, error, resolvedFinishReason) {
805
805
  const emitter = this.neurolink?.getEventEmitter();
806
806
  if (!emitter) {
807
807
  return;
@@ -815,7 +815,7 @@ export class GoogleVertexProvider extends BaseProvider {
815
815
  usage: { input: 0, output: 0, total: 0 },
816
816
  model: modelName,
817
817
  provider: this.providerName,
818
- finishReason: success ? "stop" : "error",
818
+ finishReason: success ? (resolvedFinishReason ?? "stop") : "error",
819
819
  },
820
820
  success,
821
821
  ...(error
@@ -1156,7 +1156,10 @@ export class GoogleVertexProvider extends BaseProvider {
1156
1156
  : Math.min(DEFAULT_MAX_STEPS, 100);
1157
1157
  const currentContents = [...contents];
1158
1158
  let finalText = "";
1159
- let lastStepText = ""; // Track text from last step for maxSteps termination
1159
+ // Last SDK finish reason seen across steps (Bug 2: previously never read,
1160
+ // so callers fell back to "unknown"). Last non-empty value wins — the
1161
+ // terminal chunk is authoritative.
1162
+ let lastFinishReason;
1160
1163
  const allToolCalls = [];
1161
1164
  // Mirrors the generate-path shape so StreamResult.toolExecutions can be
1162
1165
  // populated (parity with AI-SDK-driven providers) and so the storage
@@ -1200,6 +1203,12 @@ export class GoogleVertexProvider extends BaseProvider {
1200
1203
  const chunkRecord = chunk;
1201
1204
  const candidates = chunkRecord.candidates;
1202
1205
  const firstCandidate = candidates?.[0];
1206
+ // Capture the SDK finish reason (Bug 2: previously dropped). Last
1207
+ // non-empty value across chunks wins.
1208
+ const chunkFinishReason = firstCandidate?.finishReason;
1209
+ if (typeof chunkFinishReason === "string" && chunkFinishReason) {
1210
+ lastFinishReason = chunkFinishReason;
1211
+ }
1203
1212
  const chunkContent = firstCandidate?.content;
1204
1213
  if (chunkContent && Array.isArray(chunkContent.parts)) {
1205
1214
  for (const part of chunkContent.parts) {
@@ -1253,8 +1262,6 @@ export class GoogleVertexProvider extends BaseProvider {
1253
1262
  break;
1254
1263
  }
1255
1264
  }
1256
- // Track the last step text for maxSteps termination
1257
- lastStepText = stepText;
1258
1265
  // Execute function calls
1259
1266
  logger.debug(`[GoogleVertex] Executing ${stepFunctionCalls.length} function calls`);
1260
1267
  // Add model response with ALL parts (including thoughtSignature) to history
@@ -1426,14 +1433,50 @@ export class GoogleVertexProvider extends BaseProvider {
1426
1433
  throw this.handleProviderError(error);
1427
1434
  }
1428
1435
  }
1429
- // Handle maxSteps termination - if we exited the loop due to maxSteps being reached
1436
+ // Handle maxSteps termination the loop exited because the step cap was
1437
+ // reached while the model was still calling tools. Surface a real answer
1438
+ // instead of the canned placeholder (Bug 1) and a meaningful finishReason
1439
+ // (Bug 2).
1440
+ let hitStepLimit = false;
1441
+ let synthesizedFinalAnswer = false;
1430
1442
  if (step >= maxSteps && !finalText) {
1431
- logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}). ` +
1432
- `Model was still calling tools. Using accumulated text from last step.`);
1433
- finalText =
1434
- lastStepText ||
1435
- `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
1443
+ hitStepLimit = true;
1444
+ // The consumer receives text via `incrementalTextChunks`; any text the
1445
+ // model emitted across steps is already preserved there. Only synthesize
1446
+ // when NOTHING was produced (the pure-functionCall case that otherwise
1447
+ // surfaces the placeholder) so we never waste a round-trip whose output
1448
+ // the stream would ignore.
1449
+ if (incrementalTextChunks.length === 0) {
1450
+ logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
1451
+ `with no text; synthesizing a final answer with tools disabled.`);
1452
+ const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? 300_000);
1453
+ if (synth.text) {
1454
+ synthesizedFinalAnswer = true;
1455
+ finalText = synth.text;
1456
+ incrementalTextChunks.push(synth.text);
1457
+ totalInputTokens += synth.inputTokens;
1458
+ totalOutputTokens += synth.outputTokens;
1459
+ if (synth.finishReason) {
1460
+ lastFinishReason = synth.finishReason;
1461
+ }
1462
+ }
1463
+ else {
1464
+ finalText = `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
1465
+ }
1466
+ }
1467
+ else {
1468
+ logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
1469
+ `returning text already gathered from prior steps.`);
1470
+ }
1436
1471
  }
1472
+ // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
1473
+ // synthesized answer is reported as "tool-calls" (the model still wanted
1474
+ // tools) — NOT "length", which neurolink.ts treats as token truncation
1475
+ // (jsonTruncated + WARNING span). A clean completion maps from the SDK
1476
+ // finish reason.
1477
+ const resolvedFinishReason = hitStepLimit && !synthesizedFinalAnswer
1478
+ ? "tool-calls"
1479
+ : mapGeminiFinishReason(lastFinishReason);
1437
1480
  const responseTime = Date.now() - startTime;
1438
1481
  // Yield each text part separately so the CLI receives multiple stream
1439
1482
  // chunks instead of a single coalesced buffer. The SDK already gave us
@@ -1460,6 +1503,7 @@ export class GoogleVertexProvider extends BaseProvider {
1460
1503
  stream: createTextStream(),
1461
1504
  provider: this.providerName,
1462
1505
  model: modelName,
1506
+ finishReason: resolvedFinishReason,
1463
1507
  usage: {
1464
1508
  input: totalInputTokens,
1465
1509
  output: totalOutputTokens,
@@ -1791,7 +1835,11 @@ export class GoogleVertexProvider extends BaseProvider {
1791
1835
  : Math.min(DEFAULT_MAX_STEPS, 100);
1792
1836
  const currentContents = [...contents];
1793
1837
  let finalText = "";
1794
- let lastStepText = ""; // Track text from last step for maxSteps termination
1838
+ // Cross-step text accumulation + last SDK finish reason, so the
1839
+ // maxSteps-exhaustion exit can surface real gathered text (Bug 1) and a
1840
+ // meaningful finishReason (Bug 2) instead of a placeholder / "unknown".
1841
+ let accumulatedText = "";
1842
+ let lastFinishReason;
1795
1843
  const allToolCalls = [];
1796
1844
  const toolExecutions = [];
1797
1845
  let step = 0;
@@ -1826,6 +1874,12 @@ export class GoogleVertexProvider extends BaseProvider {
1826
1874
  const chunkRecord = chunk;
1827
1875
  const candidates = chunkRecord.candidates;
1828
1876
  const firstCandidate = candidates?.[0];
1877
+ // Capture the SDK finish reason (Bug 2: previously dropped). Last
1878
+ // non-empty value across chunks wins.
1879
+ const chunkFinishReason = firstCandidate?.finishReason;
1880
+ if (typeof chunkFinishReason === "string" && chunkFinishReason) {
1881
+ lastFinishReason = chunkFinishReason;
1882
+ }
1829
1883
  const chunkContent = firstCandidate?.content;
1830
1884
  if (chunkContent && Array.isArray(chunkContent.parts)) {
1831
1885
  rawResponseParts.push(...chunkContent.parts);
@@ -1874,8 +1928,11 @@ export class GoogleVertexProvider extends BaseProvider {
1874
1928
  break;
1875
1929
  }
1876
1930
  }
1877
- // Track the last step text for maxSteps termination
1878
- lastStepText = stepText;
1931
+ // Accumulate non-empty step text across steps so the
1932
+ // maxSteps-exhaustion exit can surface the prose the model produced
1933
+ // instead of a canned placeholder (Bug 1). Mirrors the Vertex-Claude
1934
+ // loop's text accumulation.
1935
+ accumulatedText = appendStepText(accumulatedText, stepText);
1879
1936
  // Execute function calls
1880
1937
  logger.debug(`[GoogleVertex] Generate executing ${stepFunctionCalls.length} function calls`);
1881
1938
  // Add model response with ALL parts (including thoughtSignature) to history
@@ -2036,14 +2093,49 @@ export class GoogleVertexProvider extends BaseProvider {
2036
2093
  throw this.handleProviderError(error);
2037
2094
  }
2038
2095
  }
2039
- // Handle maxSteps termination - if we exited the loop due to maxSteps being reached
2096
+ // Handle maxSteps termination the loop exited because the step cap was
2097
+ // reached while the model was still calling tools. Surface a real answer
2098
+ // instead of the canned placeholder (Bug 1) and a meaningful finishReason
2099
+ // (Bug 2).
2100
+ let hitStepLimit = false;
2101
+ let synthesizedFinalAnswer = false;
2040
2102
  if (step >= maxSteps && !finalText) {
2041
- logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}). ` +
2042
- `Model was still calling tools. Using accumulated text from last step.`);
2043
- finalText =
2044
- lastStepText ||
2045
- `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
2103
+ hitStepLimit = true;
2104
+ if (accumulatedText) {
2105
+ // Prefer the prose the model already produced across steps.
2106
+ logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
2107
+ `returning text already gathered from prior steps.`);
2108
+ finalText = accumulatedText;
2109
+ }
2110
+ else {
2111
+ // Pure functionCall turns leave no text — make one tools-disabled call
2112
+ // so the model answers from the gathered tool results instead of the
2113
+ // canned placeholder.
2114
+ logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
2115
+ `with no text; synthesizing a final answer with tools disabled.`);
2116
+ const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? 300_000);
2117
+ if (synth.text) {
2118
+ synthesizedFinalAnswer = true;
2119
+ finalText = synth.text;
2120
+ totalInputTokens += synth.inputTokens;
2121
+ totalOutputTokens += synth.outputTokens;
2122
+ if (synth.finishReason) {
2123
+ lastFinishReason = synth.finishReason;
2124
+ }
2125
+ }
2126
+ else {
2127
+ finalText = `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
2128
+ }
2129
+ }
2046
2130
  }
2131
+ // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
2132
+ // synthesized answer is reported as "tool-calls" (the model still wanted
2133
+ // tools) — NOT "length", which neurolink.ts treats as token truncation
2134
+ // (jsonTruncated + WARNING span). A clean completion maps from the SDK
2135
+ // finish reason.
2136
+ const resolvedFinishReason = hitStepLimit && !synthesizedFinalAnswer
2137
+ ? "tool-calls"
2138
+ : mapGeminiFinishReason(lastFinishReason);
2047
2139
  const responseTime = Date.now() - startTime;
2048
2140
  // Filter out final_result from tool calls and executions as it's an internal pattern
2049
2141
  const externalToolCalls = allToolCalls.filter((tc) => tc.toolName !== "final_result");
@@ -2053,6 +2145,7 @@ export class GoogleVertexProvider extends BaseProvider {
2053
2145
  content: finalText,
2054
2146
  provider: this.providerName,
2055
2147
  model: modelName,
2148
+ finishReason: resolvedFinishReason,
2056
2149
  usage: {
2057
2150
  input: totalInputTokens,
2058
2151
  output: totalOutputTokens,
@@ -2073,6 +2166,89 @@ export class GoogleVertexProvider extends BaseProvider {
2073
2166
  // Vertex Gemini generate path.
2074
2167
  return this.enhanceResult(result, options, startTime);
2075
2168
  }
2169
+ /**
2170
+ * One-shot, tools-disabled model call used when a native Gemini agentic loop
2171
+ * is force-terminated by the step cap with no text produced. Lets the model
2172
+ * synthesize a final answer from the function results already in `contents`
2173
+ * instead of returning a canned placeholder (Bug 1, part b).
2174
+ *
2175
+ * Tools are disabled by OMITTING `config.tools` — the codebase's established
2176
+ * mechanism. `@google/genai`'s `FunctionCallingConfigMode.NONE` is documented
2177
+ * as equivalent to passing no function declarations, and `functionCallingConfig`
2178
+ * is not used anywhere in this codebase. When the structured-output
2179
+ * (`final_result`) pattern was active, a trailing instruction countermands the
2180
+ * earlier "you MUST call final_result" directive so the model answers in plain
2181
+ * text. Never throws — returns empty text so the caller falls back to the
2182
+ * placeholder, guaranteeing no new failure path.
2183
+ */
2184
+ async synthesizeFinalAnswerWithoutTools(client, modelName, config, contents, useFinalResultTool, timeoutMs) {
2185
+ try {
2186
+ // Shallow clone so the loop's config is never mutated; dropping the
2187
+ // top-level `tools` key is sufficient (nested thinkingConfig /
2188
+ // systemInstruction are intentionally preserved).
2189
+ const synthConfig = { ...config };
2190
+ delete synthConfig.tools;
2191
+ if (useFinalResultTool) {
2192
+ const baseSystemInstruction = typeof synthConfig.systemInstruction === "string"
2193
+ ? synthConfig.systemInstruction
2194
+ : "";
2195
+ synthConfig.systemInstruction =
2196
+ baseSystemInstruction +
2197
+ "\n\nThe final_result tool is no longer available. Provide your " +
2198
+ "final answer directly as plain text now, using the information " +
2199
+ "gathered so far.";
2200
+ }
2201
+ // Bound the whole connect + drain with a timeout. The surrounding
2202
+ // try/catch only catches throws, not hangs, so without this a stalled
2203
+ // Vertex endpoint would hang the maxSteps recovery path indefinitely.
2204
+ // On timeout withTimeout rejects (TimeoutError) and the catch below
2205
+ // falls back to the placeholder — no new failure path is introduced.
2206
+ return await withTimeout((async () => {
2207
+ const stream = await client.models.generateContentStream({
2208
+ model: modelName,
2209
+ contents,
2210
+ config: synthConfig,
2211
+ });
2212
+ const parts = [];
2213
+ let finishReason;
2214
+ let inputTokens = 0;
2215
+ let outputTokens = 0;
2216
+ for await (const chunk of stream) {
2217
+ const chunkRecord = chunk;
2218
+ const candidates = chunkRecord.candidates;
2219
+ const firstCandidate = candidates?.[0];
2220
+ const chunkFinishReason = firstCandidate?.finishReason;
2221
+ if (typeof chunkFinishReason === "string" && chunkFinishReason) {
2222
+ finishReason = chunkFinishReason;
2223
+ }
2224
+ const chunkContent = firstCandidate?.content;
2225
+ if (chunkContent && Array.isArray(chunkContent.parts)) {
2226
+ parts.push(...chunkContent.parts);
2227
+ }
2228
+ const usageMetadata = chunkRecord.usageMetadata;
2229
+ if (usageMetadata) {
2230
+ if (usageMetadata.promptTokenCount !== undefined &&
2231
+ usageMetadata.promptTokenCount > 0) {
2232
+ inputTokens = usageMetadata.promptTokenCount;
2233
+ }
2234
+ if (usageMetadata.candidatesTokenCount !== undefined &&
2235
+ usageMetadata.candidatesTokenCount > 0) {
2236
+ outputTokens = usageMetadata.candidatesTokenCount;
2237
+ }
2238
+ }
2239
+ }
2240
+ const text = parts
2241
+ .filter((part) => typeof part.text === "string")
2242
+ .map((part) => part.text)
2243
+ .join("");
2244
+ return { text, finishReason, inputTokens, outputTokens };
2245
+ })(), timeoutMs, "Gemini synthesis call timed out");
2246
+ }
2247
+ catch (error) {
2248
+ logger.warn("[GoogleVertex] Tools-disabled synthesis call failed; falling back to placeholder", { error: error instanceof Error ? error.message : String(error) });
2249
+ return { text: "", inputTokens: 0, outputTokens: 0 };
2250
+ }
2251
+ }
2076
2252
  /**
2077
2253
  * Create native AnthropicVertex client for Claude models
2078
2254
  */
@@ -3685,7 +3861,7 @@ export class GoogleVertexProvider extends BaseProvider {
3685
3861
  }
3686
3862
  : undefined,
3687
3863
  duration: Date.now() - startTime,
3688
- finishReason: "stop",
3864
+ finishReason: result?.finishReason ?? "stop",
3689
3865
  });
3690
3866
  Promise.resolve(callbackResult).catch((err) => logger.warn(`[GoogleVertex] onFinish callback rejected: ${err instanceof Error ? err.message : String(err)}`));
3691
3867
  }
@@ -3890,7 +4066,7 @@ export class GoogleVertexProvider extends BaseProvider {
3890
4066
  usage,
3891
4067
  model: modelName,
3892
4068
  provider: this.providerName,
3893
- finishReason: success ? "stop" : "error",
4069
+ finishReason: success ? (result?.finishReason ?? "stop") : "error",
3894
4070
  },
3895
4071
  success,
3896
4072
  ...(error
@@ -13,6 +13,7 @@
13
13
  import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, getOrCreateClaudeCodeIdentity, MCP_TOOL_PREFIX, } from "../auth/anthropicOAuth.js";
14
14
  import { logger } from "../utils/logger.js";
15
15
  import { createProxyFetch } from "./proxyFetch.js";
16
+ import { relocateClientSystemIntoMessages } from "./systemRelocation.js";
16
17
  // Re-export constants for consumers that previously imported them alongside
17
18
  // the function from `providers/anthropic.ts`.
18
19
  export { CLAUDE_CLI_USER_AGENT, MCP_TOOL_PREFIX };
@@ -150,23 +151,21 @@ function transformOAuthJsonBody(body, requestHeaders, getToken, enableMcpPrefix)
150
151
  type: "text",
151
152
  text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
152
153
  };
153
- // Normalise `system` to an array and APPEND billing + agent blocks.
154
- // IMPORTANT: We append (not prepend) to preserve the client's cache
155
- // prefix chain. Anthropic's prompt caching uses prefix matching — if
156
- // we insert anything before the client's system blocks, we invalidate
157
- // all cached content (tools, system prompt, message history).
154
+ // Normalise `system` to an array, then route by client type.
158
155
  //
159
- // Claude Code sends a billing block with a `cch=<hash>` value that
160
- // changes on every request. We remove any existing billing/agent
161
- // blocks from their positions and always append our stable
162
- // Claude-Code-shaped versions at the end.
156
+ // The subscription/OAuth path only accepts a `system` it recognises as the
157
+ // genuine Claude Code prompt. A real CC client sends its own billing + agent
158
+ // identity blocks we keep those and just stabilise the volatile billing
159
+ // `cch`. A custom client sends its own arbitrary system prompt with NO agent
160
+ // block; left in `system` it is rejected as `rate_limit_error: "Error"`, so we
161
+ // relocate it into the message stream and send only the recognised billing +
162
+ // agent blocks as `system`.
163
163
  if (parsed.system) {
164
164
  if (typeof parsed.system === "string") {
165
165
  parsed.system = [{ type: "text", text: parsed.system }];
166
166
  }
167
167
  if (Array.isArray(parsed.system)) {
168
- // Find and remove existing billing/agent blocks from wherever
169
- // the client placed them (typically at system[0])
168
+ // Find existing billing/agent blocks wherever the client placed them.
170
169
  const billingIdx = parsed.system.findIndex((b) => typeof b.text === "string" &&
171
170
  b.text.includes("x-anthropic-billing-header"));
172
171
  const agentIdx = parsed.system.findIndex((b) => typeof b.text === "string" && b.text.includes("Claude Agent SDK"));
@@ -174,15 +173,28 @@ function transformOAuthJsonBody(body, requestHeaders, getToken, enableMcpPrefix)
174
173
  type: "text",
175
174
  text: buildStableClaudeCodeBillingHeader(parsed.system[billingIdx]?.text),
176
175
  };
177
- // Remove in reverse index order so indices stay valid
176
+ // A genuine Claude Code client supplies its own agent-identity block;
177
+ // a custom client does not.
178
+ const isClaudeCodeClient = agentIdx >= 0;
179
+ // Strip billing/agent from their positions (reverse order so indices
180
+ // stay valid). What remains is the client's "extra" system content.
178
181
  const indicesToRemove = [billingIdx, agentIdx]
179
182
  .filter((i) => i >= 0)
180
183
  .sort((a, b) => b - a);
181
184
  for (const idx of indicesToRemove) {
182
185
  parsed.system.splice(idx, 1);
183
186
  }
184
- // Always append deterministic billing + agent blocks at the end
185
- parsed.system = [...parsed.system, billingBlock, agentBlock];
187
+ if (!isClaudeCodeClient && parsed.system.length > 0) {
188
+ // Non-CC client: relocate its system into the message stream so the
189
+ // subscription/OAuth path accepts the request.
190
+ relocateClientSystemIntoMessages(parsed, parsed.system);
191
+ parsed.system = [billingBlock, agentBlock];
192
+ }
193
+ else {
194
+ // Genuine Claude Code (or no extra blocks): keep system and append the
195
+ // deterministic billing + agent blocks at the end.
196
+ parsed.system = [...parsed.system, billingBlock, agentBlock];
197
+ }
186
198
  }
187
199
  }
188
200
  else {
@@ -14,7 +14,7 @@
14
14
  * - TelemetryService for metrics recording
15
15
  */
16
16
  import { type Span } from "@opentelemetry/api";
17
- import type { AccountSelectionContext, ProxyRequestContext, UpstreamAttemptContext, UsageContext } from "../types/index.js";
17
+ import type { AccountSelectionContext, ProxyRequestContext, ResponseInfoContext, UpstreamAttemptContext, UsageContext } from "../types/index.js";
18
18
  declare class ProxyTracer {
19
19
  private readonly rootSpan;
20
20
  private readonly proxyTracer;
@@ -46,6 +46,12 @@ declare class ProxyTracer {
46
46
  setAccountSelection(ctx: AccountSelectionContext): void;
47
47
  /** Record token usage and cost on the root span. */
48
48
  setUsage(ctx: UsageContext): void;
49
+ /**
50
+ * Record response-side details parsed from the upstream reply: the model
51
+ * that actually answered, the finish reason, and which tools the model
52
+ * invoked (tool_use blocks). Uses gen_ai.* semantic-convention attributes.
53
+ */
54
+ setResponseInfo(ctx: ResponseInfoContext): void;
49
55
  /** Record an error on the root span. */
50
56
  setError(errorType: string, errorMessage: string): void;
51
57
  /** Record whether the request was handled in full or passthrough mode. */
@@ -240,6 +240,10 @@ class ProxyTracer {
240
240
  if (ctx.userAgent) {
241
241
  rootSpan.setAttribute("http.user_agent", ctx.userAgent);
242
242
  }
243
+ if (ctx.toolNames && ctx.toolNames.length > 0) {
244
+ // What the caller exposed to the model (tool catalogue for this request).
245
+ rootSpan.setAttribute("gen_ai.request.tool_names", JSON.stringify(ctx.toolNames));
246
+ }
243
247
  // Read x-neurolink-* context headers from calling SDK (e.g., Curator)
244
248
  const nlSessionId = incomingHeaders?.["x-neurolink-session-id"];
245
249
  const nlUserId = incomingHeaders?.["x-neurolink-user-id"];
@@ -387,6 +391,31 @@ class ProxyTracer {
387
391
  this.rootSpan.setAttribute("proxy.ratelimit.after.7d", ctx.rateLimitAfter7d);
388
392
  }
389
393
  }
394
+ /**
395
+ * Record response-side details parsed from the upstream reply: the model
396
+ * that actually answered, the finish reason, and which tools the model
397
+ * invoked (tool_use blocks). Uses gen_ai.* semantic-convention attributes.
398
+ */
399
+ setResponseInfo(ctx) {
400
+ if (ctx.responseModel) {
401
+ this.rootSpan.setAttribute("gen_ai.response.model", ctx.responseModel);
402
+ }
403
+ if (ctx.finishReason) {
404
+ this.rootSpan.setAttribute("gen_ai.response.finish_reason", ctx.finishReason);
405
+ }
406
+ if (ctx.stopSequence) {
407
+ this.rootSpan.setAttribute("gen_ai.response.stop_sequence", ctx.stopSequence);
408
+ }
409
+ if (ctx.toolCalls && ctx.toolCalls.length > 0) {
410
+ this.rootSpan.setAttributes({
411
+ "gen_ai.response.tool_calls": JSON.stringify(ctx.toolCalls),
412
+ "gen_ai.response.tool_call_count": ctx.toolCalls.length,
413
+ });
414
+ this.rootSpan.addEvent("proxy.response.tool_calls", {
415
+ "gen_ai.response.tool_calls": JSON.stringify(ctx.toolCalls),
416
+ });
417
+ }
418
+ }
390
419
  /** Record an error on the root span. */
391
420
  setError(errorType, errorMessage) {
392
421
  this.rootSpan.setAttributes({
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Relocate a non-Claude-Code client's `system` blocks into the message stream.
3
+ *
4
+ * Anthropic's subscription/OAuth path rejects any `system` content it does not
5
+ * recognise as the genuine Claude Code system prompt — anti-abuse fingerprinting
6
+ * surfaced as a header-less `rate_limit_error: "Error"` (NOT a real rate limit).
7
+ * Custom clients (Curator/Tara) send their own system prompt, so we move it into
8
+ * a leading user block and keep only the recognised billing+agent blocks in
9
+ * `system`. The model still honours the instructions, and any `cache_control` is
10
+ * carried over so the prompt prefix stays cacheable.
11
+ *
12
+ * Shared by both proxy entry points (`oauthFetch.ts` and `claudeProxyRoutes.ts`)
13
+ * so the OAuth anti-abuse workaround stays in one place and can't drift between
14
+ * the two paths.
15
+ */
16
+ export declare function relocateClientSystemIntoMessages(parsed: {
17
+ messages?: unknown;
18
+ }, instructionBlocks: Array<{
19
+ text?: unknown;
20
+ cache_control?: unknown;
21
+ }>): void;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Relocate a non-Claude-Code client's `system` blocks into the message stream.
3
+ *
4
+ * Anthropic's subscription/OAuth path rejects any `system` content it does not
5
+ * recognise as the genuine Claude Code system prompt — anti-abuse fingerprinting
6
+ * surfaced as a header-less `rate_limit_error: "Error"` (NOT a real rate limit).
7
+ * Custom clients (Curator/Tara) send their own system prompt, so we move it into
8
+ * a leading user block and keep only the recognised billing+agent blocks in
9
+ * `system`. The model still honours the instructions, and any `cache_control` is
10
+ * carried over so the prompt prefix stays cacheable.
11
+ *
12
+ * Shared by both proxy entry points (`oauthFetch.ts` and `claudeProxyRoutes.ts`)
13
+ * so the OAuth anti-abuse workaround stays in one place and can't drift between
14
+ * the two paths.
15
+ */
16
+ export function relocateClientSystemIntoMessages(parsed, instructionBlocks) {
17
+ if (instructionBlocks.length === 0) {
18
+ return;
19
+ }
20
+ const blocks = instructionBlocks.map((b) => {
21
+ const text = typeof b.text === "string" ? b.text : String(b.text ?? "");
22
+ const out = {
23
+ type: "text",
24
+ text,
25
+ };
26
+ if (b.cache_control) {
27
+ out.cache_control = b.cache_control;
28
+ }
29
+ return out;
30
+ });
31
+ // Wrap the relocated system in an explicit delimiter so the model treats it
32
+ // as authoritative instructions, clearly separated from the user's message.
33
+ blocks[0].text = `<system_instructions>\n${blocks[0].text}`;
34
+ const last = blocks.length - 1;
35
+ blocks[last].text = `${blocks[last].text}\n</system_instructions>`;
36
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
37
+ const first = messages[0];
38
+ if (first && first.role === "user") {
39
+ const existing = typeof first.content === "string"
40
+ ? [{ type: "text", text: first.content }]
41
+ : Array.isArray(first.content)
42
+ ? first.content
43
+ : [];
44
+ first.content = [...blocks, ...existing];
45
+ }
46
+ else {
47
+ messages.unshift({ role: "user", content: blocks });
48
+ }
49
+ parsed.messages = messages;
50
+ }