@juspay/neurolink 12.20.1 → 12.21.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.
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * NeuroLink CLI Decide Command
4
+ *
5
+ * Calls `NeuroLink.decide()` — the third inference type, alongside `generate`
6
+ * and `stream`. Takes a `state` plus a map of named typed questions and
7
+ * prints one calibrated answer per question. See src/lib/types/decision.ts
8
+ * for the full vocabulary.
9
+ *
10
+ * Credentials are env-only here, exactly as every other CLI command: the
11
+ * decision provider (TypeSafe) reads `TYPESAFE_API_KEY` itself.
12
+ */
13
+ import type { CommandModule } from "yargs";
14
+ import type { CliDecideArgs } from "../../types/index.js";
15
+ export declare const decideCommand: CommandModule<object, CliDecideArgs>;
16
+ /**
17
+ * Create decide command factory for CLICommandFactory-style registration,
18
+ * matching the EvaluateCommandFactory pattern.
19
+ */
20
+ export declare class DecideCommandFactory {
21
+ static createDecideCommand(): CommandModule;
22
+ }
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * NeuroLink CLI Decide Command
4
+ *
5
+ * Calls `NeuroLink.decide()` — the third inference type, alongside `generate`
6
+ * and `stream`. Takes a `state` plus a map of named typed questions and
7
+ * prints one calibrated answer per question. See src/lib/types/decision.ts
8
+ * for the full vocabulary.
9
+ *
10
+ * Credentials are env-only here, exactly as every other CLI command: the
11
+ * decision provider (TypeSafe) reads `TYPESAFE_API_KEY` itself.
12
+ */
13
+ import chalk from "chalk";
14
+ import ora from "ora";
15
+ import fs from "node:fs";
16
+ import { NeuroLink } from "../../neurolink.js";
17
+ import { readDecisionChoice } from "../../utils/decisionAnswers.js";
18
+ import { calculateCost, hasPricing } from "../../utils/pricing.js";
19
+ import { logger } from "../../utils/logger.js";
20
+ const VALID_QUESTION_TYPES = new Set(["boolean", "choice", "score"]);
21
+ function isRecord(value) {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+ /**
25
+ * Validates that parsed JSON is a non-empty map of `{ type, ... }` question
26
+ * objects. Runs before any provider work — a malformed `--questions` payload
27
+ * must never reach the network.
28
+ */
29
+ function isDecisionQuestionMap(value) {
30
+ if (!isRecord(value)) {
31
+ return false;
32
+ }
33
+ const ids = Object.keys(value);
34
+ return (ids.length > 0 &&
35
+ ids.every((id) => {
36
+ const question = value[id];
37
+ return (isRecord(question) &&
38
+ typeof question.type === "string" &&
39
+ VALID_QUESTION_TYPES.has(question.type));
40
+ }));
41
+ }
42
+ /** `state` is the raw text, or its parsed JSON when that JSON is an object or array. */
43
+ function parseState(raw) {
44
+ try {
45
+ const parsed = JSON.parse(raw);
46
+ if (isRecord(parsed) || Array.isArray(parsed)) {
47
+ // Single assertion (CLAUDE.md rule 14 allows this) — `isRecord`/
48
+ // `Array.isArray` narrow `unknown` down to a structural shape TS can't
49
+ // itself equate with the recursive `DecisionInput` union.
50
+ return parsed;
51
+ }
52
+ }
53
+ catch {
54
+ // Not JSON — treat as plain text.
55
+ }
56
+ return raw;
57
+ }
58
+ function isDecisionErrorCause(value) {
59
+ return (isRecord(value) &&
60
+ typeof value.kind === "string" &&
61
+ typeof value.message === "string");
62
+ }
63
+ const ERROR_KIND_MESSAGES = {
64
+ authentication: "Authentication failed — check the decision provider's API key.",
65
+ invalid_request: "The decision provider rejected the request as invalid.",
66
+ max_tokens_exceeded: "The decision request exceeded the provider's token limit.",
67
+ rate_limit: "The decision provider is rate-limiting requests — try again shortly.",
68
+ overloaded: "The decision provider is overloaded — try again shortly.",
69
+ server: "The decision provider returned a server error.",
70
+ timeout: "The decision request timed out.",
71
+ network: "A network error occurred while calling the decision provider.",
72
+ };
73
+ /** One clean line describing a `decide()` failure — never a stack trace. */
74
+ function describeDecideError(error) {
75
+ const cause = error instanceof Error
76
+ ? error.cause
77
+ : undefined;
78
+ if (isDecisionErrorCause(cause)) {
79
+ const generic = ERROR_KIND_MESSAGES[cause.kind];
80
+ if (!generic) {
81
+ return cause.message;
82
+ }
83
+ // The provider's own text is what says which field or question was
84
+ // rejected, so it is kept alongside the generic line.
85
+ const detail = cause.message.trim();
86
+ return detail && detail !== generic ? `${generic} (${detail})` : generic;
87
+ }
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ if (message.includes("No decision provider is configured")) {
90
+ return "No decision provider is configured. Set TYPESAFE_API_KEY, or AI_GATEWAY_API_KEY for the Vercel AI Gateway route.";
91
+ }
92
+ return message;
93
+ }
94
+ function formatNumber(value) {
95
+ return value.toFixed(2);
96
+ }
97
+ function formatAnswerLine(id, answers) {
98
+ const answer = answers[id];
99
+ if (!answer) {
100
+ return `${id}: (no answer)`;
101
+ }
102
+ switch (answer.type) {
103
+ case "boolean":
104
+ return `${id}: probability ${formatNumber(answer.probability)}`;
105
+ case "choice": {
106
+ const reading = readDecisionChoice(answers, id);
107
+ if (!reading) {
108
+ return `${id}: (unreadable choice answer)`;
109
+ }
110
+ const alternatives = reading.ranked
111
+ .filter((entry) => entry.name !== reading.choice)
112
+ .slice(0, 2)
113
+ .map((entry) => `${entry.name} (${formatNumber(entry.probability)})`)
114
+ .join(", ");
115
+ return (`${id}: choice ${reading.choice}, confidence ${formatNumber(reading.confidence)}` +
116
+ (alternatives ? `, alternatives: ${alternatives}` : ""));
117
+ }
118
+ case "score":
119
+ return `${id}: score ${formatNumber(answer.score)}, confidence ${formatNumber(answer.confidence)}`;
120
+ default:
121
+ return `${id}: (unrecognised answer type)`;
122
+ }
123
+ }
124
+ function printTextResult(result) {
125
+ for (const id of Object.keys(result.answers)) {
126
+ logger.always(formatAnswerLine(id, result.answers));
127
+ }
128
+ logger.always("");
129
+ logger.always(chalk.gray(`Model: ${result.model}`));
130
+ logger.always(chalk.gray(`Latency: ${result.latencyMs}ms`));
131
+ if (hasPricing(result.provider, result.model)) {
132
+ const cost = calculateCost(result.provider, result.model, {
133
+ input: result.usage.inputTokens,
134
+ output: result.usage.outputTokens,
135
+ total: result.usage.inputTokens + result.usage.outputTokens,
136
+ });
137
+ logger.always(chalk.gray(`Cost: $${cost.toFixed(6)}`));
138
+ }
139
+ }
140
+ function readQuestionsSource(argv) {
141
+ if (argv.questionsFile) {
142
+ return fs.readFileSync(argv.questionsFile, "utf-8");
143
+ }
144
+ if (!argv.questions) {
145
+ // Unreachable given the exactly-one-of check in the handler; keeps this
146
+ // function sound on its own rather than asserting the type away.
147
+ throw new Error("No questions source provided.");
148
+ }
149
+ return argv.questions;
150
+ }
151
+ function resolveState(argv) {
152
+ if (argv.stateFile) {
153
+ return parseState(fs.readFileSync(argv.stateFile, "utf-8"));
154
+ }
155
+ if (argv.state) {
156
+ return parseState(argv.state);
157
+ }
158
+ return undefined;
159
+ }
160
+ export const decideCommand = {
161
+ command: "decide [state]",
162
+ describe: "Get typed, calibrated judgements from a decision model (no free text)",
163
+ builder: (yargs) => yargs
164
+ .positional("state", {
165
+ type: "string",
166
+ describe: "The content to judge (or use --state-file)",
167
+ })
168
+ .option("state-file", {
169
+ type: "string",
170
+ describe: "Path to a file holding the state (text or JSON)",
171
+ })
172
+ .option("questions", {
173
+ type: "string",
174
+ describe: "Inline JSON map of questions (exactly one of this or --questions-file)",
175
+ })
176
+ .option("questions-file", {
177
+ type: "string",
178
+ describe: "Path to a JSON file holding the questions map",
179
+ })
180
+ .option("provider", {
181
+ type: "string",
182
+ describe: "Decision provider to use",
183
+ })
184
+ .option("model", {
185
+ type: "string",
186
+ describe: "Overrides the provider's configured model for this call",
187
+ })
188
+ .option("timeout", {
189
+ type: "number",
190
+ describe: "Timeout in milliseconds",
191
+ })
192
+ .option("format", {
193
+ type: "string",
194
+ alias: ["f", "output-format"],
195
+ describe: "Output format",
196
+ choices: ["text", "json"],
197
+ default: "text",
198
+ })
199
+ .option("debug", {
200
+ type: "boolean",
201
+ alias: ["v", "verbose"],
202
+ default: false,
203
+ describe: "Enable debug logging (written to stderr when --format json is used)",
204
+ })
205
+ .example('$0 decide "Refund request for a damaged item" --questions \'{"urgent":{"type":"boolean","instructions":"Is this urgent?"}}\'', "Ask a single yes/no question")
206
+ .example("$0 decide --state-file ticket.json --questions-file questions.json --format json", "Read state and questions from files, emit raw JSON"),
207
+ handler: async (argv) => {
208
+ const outputFormat = argv.format ?? "text";
209
+ // --- Validate before any provider work ---------------------------------
210
+ if (!!argv.questions === !!argv.questionsFile) {
211
+ logger.error(chalk.red("Error: pass exactly one of --questions or --questions-file."));
212
+ process.exit(1);
213
+ }
214
+ let questionsRaw;
215
+ try {
216
+ questionsRaw = readQuestionsSource(argv);
217
+ }
218
+ catch (error) {
219
+ logger.error(chalk.red(`Error: could not read questions — ${error instanceof Error ? error.message : String(error)}`));
220
+ process.exit(1);
221
+ }
222
+ let parsedQuestions;
223
+ try {
224
+ parsedQuestions = JSON.parse(questionsRaw);
225
+ }
226
+ catch {
227
+ const flag = argv.questionsFile ? "--questions-file" : "--questions";
228
+ logger.error(chalk.red(`Error: ${flag} is not valid JSON.`));
229
+ process.exit(1);
230
+ }
231
+ if (argv.timeout !== undefined &&
232
+ !(Number.isFinite(argv.timeout) && argv.timeout > 0)) {
233
+ logger.error(chalk.red("Error: --timeout must be a positive number of milliseconds."));
234
+ process.exit(1);
235
+ }
236
+ if (!isDecisionQuestionMap(parsedQuestions)) {
237
+ logger.error(chalk.red('Error: questions must be a non-empty object of { type: "boolean" | "choice" | "score", ... }.'));
238
+ process.exit(1);
239
+ }
240
+ let state;
241
+ try {
242
+ state = resolveState(argv);
243
+ }
244
+ catch (error) {
245
+ logger.error(chalk.red(`Error: could not read state file — ${error instanceof Error ? error.message : String(error)}`));
246
+ process.exit(1);
247
+ }
248
+ if (state === undefined) {
249
+ logger.error(chalk.red("Error: provide state as a positional argument or via --state-file."));
250
+ process.exit(1);
251
+ }
252
+ // --- Provider work -------------------------------------------------
253
+ const spinner = outputFormat === "json" ? null : ora("Deciding...").start();
254
+ try {
255
+ const neurolink = new NeuroLink();
256
+ const result = await neurolink.decide({
257
+ state,
258
+ questions: parsedQuestions,
259
+ provider: argv.provider,
260
+ model: argv.model,
261
+ timeoutMs: argv.timeout,
262
+ });
263
+ spinner?.succeed("Decision complete");
264
+ if (outputFormat === "json") {
265
+ logger.always(JSON.stringify(result, null, 2));
266
+ }
267
+ else {
268
+ printTextResult(result);
269
+ }
270
+ }
271
+ catch (error) {
272
+ spinner?.fail("Decision failed");
273
+ logger.error(chalk.red(`Error: ${describeDecideError(error)}`));
274
+ process.exit(1);
275
+ }
276
+ },
277
+ };
278
+ /**
279
+ * Create decide command factory for CLICommandFactory-style registration,
280
+ * matching the EvaluateCommandFactory pattern.
281
+ */
282
+ export class DecideCommandFactory {
283
+ static createDecideCommand() {
284
+ return decideCommand;
285
+ }
286
+ }
287
+ //# sourceMappingURL=decide.js.map
@@ -29,6 +29,7 @@ import { proxyPeerCommand } from "./commands/proxyPeer.js";
29
29
  import { proxyExposeCommand } from "./commands/proxyExpose.js";
30
30
  import { proxyReplayCommand } from "./commands/proxyReplay.js";
31
31
  import { EvaluateCommandFactory } from "./commands/evaluate.js";
32
+ import { DecideCommandFactory } from "./commands/decide.js";
32
33
  import { TaskCommandFactory } from "./commands/task.js";
33
34
  import { AutoresearchCommandFactory } from "./commands/autoresearch.js";
34
35
  import { voiceServerCommand } from "./commands/voiceServer.js";
@@ -72,6 +73,9 @@ export function initializeCliParser() {
72
73
  // Always set to false when debug is not enabled (including when not provided)
73
74
  process.env.NEUROLINK_DEBUG = "false";
74
75
  }
76
+ // With --format json, stdout carries the payload; a debug or info
77
+ // line there would make it unparseable, so diagnostics go to stderr.
78
+ logger.setDiagnosticsToStderr(argv.format === "json");
75
79
  // Keep existing quiet middleware
76
80
  if (process.env.NEUROLINK_QUIET === "true" &&
77
81
  typeof argv.quiet === "undefined") {
@@ -164,6 +168,8 @@ export function initializeCliParser() {
164
168
  .command(UsageCommandFactory.createUsageCommands())
165
169
  // Stream Text Command - Using CLICommandFactory
166
170
  .command(CLICommandFactory.createStreamCommand())
171
+ // Decide Command - typed, calibrated judgements (no free text)
172
+ .command(DecideCommandFactory.createDecideCommand())
167
173
  // Batch Processing Command - Using CLICommandFactory
168
174
  .command(CLICommandFactory.createBatchCommand())
169
175
  // Provider Command Group - Using CLICommandFactory
package/dist/neurolink.js CHANGED
@@ -4019,6 +4019,12 @@ Current user's request: ${currentInput}`;
4019
4019
  const sessionId = typeof opt.context?.sessionId === "string"
4020
4020
  ? opt.context.sessionId
4021
4021
  : undefined;
4022
+ // Routing runs before conversation memory is loaded, so only the history
4023
+ // the caller passed in is countable here.
4024
+ const sessionBound = sessionId !== undefined;
4025
+ const priorMessageCount = Array.isArray(opt.conversationMessages)
4026
+ ? opt.conversationMessages.length
4027
+ : undefined;
4022
4028
  try {
4023
4029
  const decision = await this.classifierRouter.route({
4024
4030
  prompt: promptText,
@@ -4027,6 +4033,8 @@ Current user's request: ${currentInput}`;
4027
4033
  requiresVision,
4028
4034
  thinkingLevel,
4029
4035
  sessionId,
4036
+ sessionBound,
4037
+ priorMessageCount,
4030
4038
  });
4031
4039
  if (!decision) {
4032
4040
  return;
@@ -23,21 +23,39 @@ export declare class CodexFallbackStreamError extends Error {
23
23
  constructor(payload: Record<string, unknown>, eventType: string, usage?: CodexFallbackResult["usage"]);
24
24
  }
25
25
  /**
26
- * Stable per-conversation routing key for OpenAI prompt caching.
26
+ * Routing key that pins a request to the cache already holding its prefix.
27
27
  *
28
28
  * Anthropic caching is explicit — the client marks breakpoints and the provider
29
29
  * honours them. OpenAI caching is automatic: a prefix is reused only when the
30
30
  * request lands on infrastructure already holding it, and `prompt_cache_key` is
31
- * what pins a conversation to one. Without it this hop takes its chances on
32
- * routing, which is the worst case for a fallback that arrives in bursts
33
- * separated by long gaps.
31
+ * what pins a request to one. Without it this hop takes its chances on routing,
32
+ * which is the worst case for a fallback that arrives in bursts separated by
33
+ * long gaps.
34
34
  *
35
- * Claude Code carries its session id inside `metadata.user_id`, alongside
36
- * account and device identifiers. Only the session id is read, and it is
37
- * hashed, so no account or device identifier reaches the upstream. When no
38
- * session id can be recovered the field is omitted rather than filled with
39
- * something shared: a key common to every conversation would pin unrelated
35
+ * The key must therefore describe the *prefix*, not the caller. Keying it by
36
+ * session id gives every session a private namespace, so N agents that share
37
+ * one system prompt and tool set each pay that shared prefix in full rather
38
+ * than paying it once — the cost is linear in fan-out width and invisible,
39
+ * because each request looks individually well-behaved. Across 42 captured
40
+ * fallback bodies the prefixes collapse to 10 distinct values, the largest
41
+ * shared by 12 requests belonging to different sessions.
42
+ *
43
+ * So derive the key from the prefix itself: the instructions and the tool
44
+ * declarations, which are exactly the leading bytes the upstream caches and
45
+ * the only part stable across a conversation's turns. Identical prefixes then
46
+ * share one cache across sessions and subagents, a prefix that genuinely
47
+ * differs still gets its own key so nothing unrelated contends, and turns
48
+ * within one session keep sharing a key because neither input changes between
49
+ * them.
50
+ *
51
+ * A request carrying neither instructions nor tools has no prefix worth
52
+ * pinning, and falls back to the session id so its turns at least stay
53
+ * together. With neither available the field is omitted rather than filled
54
+ * with something shared: a key common to every request would pin unrelated
40
55
  * prefixes onto one cache instead of separating them.
56
+ *
57
+ * The digest covers only content already sent upstream in `instructions` and
58
+ * `tools`; no account, device or session identifier reaches the wire.
41
59
  */
42
60
  export declare function codexPromptCacheKey(body: ClaudeRequest): string | undefined;
43
61
  /** Convert a Claude Messages request into the ChatGPT Codex Responses shape. */
@@ -237,23 +237,95 @@ function convertClaudeMessage(role, content) {
237
237
  return input;
238
238
  }
239
239
  /**
240
- * Stable per-conversation routing key for OpenAI prompt caching.
240
+ * Routing key that pins a request to the cache already holding its prefix.
241
241
  *
242
242
  * Anthropic caching is explicit — the client marks breakpoints and the provider
243
243
  * honours them. OpenAI caching is automatic: a prefix is reused only when the
244
244
  * request lands on infrastructure already holding it, and `prompt_cache_key` is
245
- * what pins a conversation to one. Without it this hop takes its chances on
246
- * routing, which is the worst case for a fallback that arrives in bursts
247
- * separated by long gaps.
245
+ * what pins a request to one. Without it this hop takes its chances on routing,
246
+ * which is the worst case for a fallback that arrives in bursts separated by
247
+ * long gaps.
248
248
  *
249
- * Claude Code carries its session id inside `metadata.user_id`, alongside
250
- * account and device identifiers. Only the session id is read, and it is
251
- * hashed, so no account or device identifier reaches the upstream. When no
252
- * session id can be recovered the field is omitted rather than filled with
253
- * something shared: a key common to every conversation would pin unrelated
249
+ * The key must therefore describe the *prefix*, not the caller. Keying it by
250
+ * session id gives every session a private namespace, so N agents that share
251
+ * one system prompt and tool set each pay that shared prefix in full rather
252
+ * than paying it once — the cost is linear in fan-out width and invisible,
253
+ * because each request looks individually well-behaved. Across 42 captured
254
+ * fallback bodies the prefixes collapse to 10 distinct values, the largest
255
+ * shared by 12 requests belonging to different sessions.
256
+ *
257
+ * So derive the key from the prefix itself: the instructions and the tool
258
+ * declarations, which are exactly the leading bytes the upstream caches and
259
+ * the only part stable across a conversation's turns. Identical prefixes then
260
+ * share one cache across sessions and subagents, a prefix that genuinely
261
+ * differs still gets its own key so nothing unrelated contends, and turns
262
+ * within one session keep sharing a key because neither input changes between
263
+ * them.
264
+ *
265
+ * A request carrying neither instructions nor tools has no prefix worth
266
+ * pinning, and falls back to the session id so its turns at least stay
267
+ * together. With neither available the field is omitted rather than filled
268
+ * with something shared: a key common to every request would pin unrelated
254
269
  * prefixes onto one cache instead of separating them.
270
+ *
271
+ * The digest covers only content already sent upstream in `instructions` and
272
+ * `tools`; no account, device or session identifier reaches the wire.
255
273
  */
256
274
  export function codexPromptCacheKey(body) {
275
+ const prefix = codexCachePrefix(body);
276
+ if (prefix !== undefined) {
277
+ return createHash("sha256").update(prefix).digest("hex");
278
+ }
279
+ const sessionId = codexSessionId(body);
280
+ if (sessionId !== undefined) {
281
+ return createHash("sha256").update(sessionId).digest("hex");
282
+ }
283
+ return undefined;
284
+ }
285
+ /**
286
+ * How much of the instructions the key covers.
287
+ *
288
+ * Claude Code's system prompt is not byte-stable: measured across live 16,722
289
+ * character prompts, the first 14,768 characters (88.3%) are identical and the
290
+ * divergence sits in a trailing session-memory section that is rewritten every
291
+ * turn. Hashing the whole thing therefore produces a fresh key per request —
292
+ * the opposite of pinning. Hashing a bounded head instead tracks the part the
293
+ * upstream can actually reuse, since its cache is built forward from the first
294
+ * token.
295
+ *
296
+ * 8192 is below the observed divergence point with margin, and the error is
297
+ * asymmetric: too long only costs sharing, while too short pins genuinely
298
+ * unrelated prefixes onto one cache where they evict each other.
299
+ *
300
+ * The bound only helps a prompt long enough to have a tail beyond it. A
301
+ * shorter prompt is hashed whole, so a churning section inside it would move
302
+ * the key every turn — where keying by session used to hold still. Measured on
303
+ * the same corpus, that does not happen: 85 of 92 prompts carrying a system
304
+ * block sit under this bound, and within a session they resolve to a handful
305
+ * of keys rather than one per request (28 requests to 4 keys, 17 to 2, 31 to
306
+ * 2) — those are distinct agents, not one agent churning. Identifying the
307
+ * stable section structurally would remove the assumption, and is worth doing
308
+ * if a prompt ever proves otherwise.
309
+ */
310
+ const CACHE_KEY_PREFIX_CHARS = 8192;
311
+ /**
312
+ * The leading, turn-stable bytes of a Codex request: a bounded head of the
313
+ * instructions plus the tool names, which distinguish one agent's tool surface
314
+ * from another's when their instructions open identically. Descriptions and
315
+ * schemas are deliberately left out — they sit behind the instructions in the
316
+ * prefix, so two requests agreeing on this much already share a cacheable head
317
+ * worth routing together.
318
+ */
319
+ function codexCachePrefix(body) {
320
+ const head = (buildSystemInstructions(body) ?? "").slice(0, CACHE_KEY_PREFIX_CHARS);
321
+ const toolNames = (body.tools ?? []).map((tool) => tool.name).join("\u0000");
322
+ if (!head && !toolNames) {
323
+ return undefined;
324
+ }
325
+ return `${head}\u0001${toolNames}`;
326
+ }
327
+ /** Claude Code carries its session id inside `metadata.user_id`. */
328
+ function codexSessionId(body) {
257
329
  const raw = body.metadata?.user_id;
258
330
  if (typeof raw !== "string" || raw.length === 0) {
259
331
  return undefined;
@@ -270,10 +342,9 @@ export function codexPromptCacheKey(body) {
270
342
  }
271
343
  const fields = parsed;
272
344
  const sessionId = fields.session_id ?? fields.parent_session_id;
273
- if (typeof sessionId !== "string" || sessionId.length === 0) {
274
- return undefined;
275
- }
276
- return createHash("sha256").update(sessionId).digest("hex");
345
+ return typeof sessionId === "string" && sessionId.length > 0
346
+ ? sessionId
347
+ : undefined;
277
348
  }
278
349
  /** Convert a Claude Messages request into the ChatGPT Codex Responses shape. */
279
350
  export function convertClaudeRequestToCodex(body, model, reasoningEffort) {
@@ -11,6 +11,7 @@
11
11
  import { jsonSchema, tool } from "../utils/tool.js";
12
12
  import { randomBytes } from "crypto";
13
13
  import { normalizeJsonSchemaObject } from "../utils/schemaConversion.js";
14
+ import { applyClaudeRequestCacheBreakpoints } from "../utils/anthropicCacheBreakpoints.js";
14
15
  // ---------------------------------------------------------------------------
15
16
  // Helpers
16
17
  // ---------------------------------------------------------------------------
@@ -603,7 +604,13 @@ export function convertOpenAIToClaudeRequest(openai) {
603
604
  ? openai.stop
604
605
  : [openai.stop];
605
606
  }
606
- return result;
607
+ // An OpenAI-shaped caller has no way to ask for caching: its provider does it
608
+ // automatically from the first token, so the format carries no equivalent of
609
+ // `cache_control`. Translating faithfully therefore produces a request
610
+ // Anthropic caches at 0% on every turn — and a caller that arrives here after
611
+ // its own pool ran dry would burn the replacement quota faster than the one
612
+ // it was spared. Synthesise the breakpoints the caller could not express.
613
+ return applyClaudeRequestCacheBreakpoints(result);
607
614
  }
608
615
  /**
609
616
  * Convert a non-streaming Claude Messages response to an OpenAI Chat
@@ -12,7 +12,7 @@
12
12
  * `tool_use`, `tool_result`, `cache_control` and thinking blocks all survive
13
13
  * here because nothing reshapes them.
14
14
  */
15
- import type { UsageContext, VertexPassthroughTerminal, VertexAnthropicPassthroughRequest, VertexAnthropicPassthroughTarget } from "../types/index.js";
15
+ import type { VertexAccessTokenProvider, UsageContext, VertexPassthroughTerminal, VertexAnthropicPassthroughRequest, VertexAnthropicPassthroughTarget } from "../types/index.js";
16
16
  /**
17
17
  * Translate `output_config` for Vertex.
18
18
  *
@@ -29,6 +29,22 @@ export declare function buildVertexAnthropicPayload(body: Readonly<Record<string
29
29
  * caller's `stream` flag selects the verb rather than changing the body.
30
30
  */
31
31
  export declare function buildVertexAnthropicUrl(target: VertexAnthropicPassthroughTarget): string;
32
+ /**
33
+ * Replace the Google credential lookup, for tests only.
34
+ *
35
+ * Without this seam the two-leg fallback chain cannot be driven end to end.
36
+ * The rest of the hop is reachable — `dispatchVertexAnthropicPassthrough`
37
+ * calls the global `fetch`, which a test can replace — but the credential
38
+ * lookup goes through gaxios, and gaxios resolves its transport to
39
+ * `(await import("node-fetch")).default` rather than `globalThis.fetch`. A
40
+ * test that swaps the global therefore cannot intercept the token exchange,
41
+ * and on a machine with no Application Default Credentials the leg throws
42
+ * before it ever reaches the model. That left the one path that bills in real
43
+ * currency with no end-to-end coverage at all.
44
+ *
45
+ * Pass `undefined` to restore the real credential lookup.
46
+ */
47
+ export declare function setVertexAccessTokenProviderForTests(provider: VertexAccessTokenProvider | undefined): void;
32
48
  /**
33
49
  * Send one Anthropic-shaped request to Vertex and return the upstream response
34
50
  * untouched, so the caller can stream its bytes straight to the client.
@@ -135,12 +135,36 @@ function auth() {
135
135
  cachedAuth ??= new GoogleAuth({ scopes: [CLOUD_PLATFORM_SCOPE] });
136
136
  return cachedAuth;
137
137
  }
138
+ let accessTokenProviderForTests;
139
+ async function vertexAccessToken() {
140
+ return accessTokenProviderForTests
141
+ ? accessTokenProviderForTests()
142
+ : auth().getAccessToken();
143
+ }
144
+ /**
145
+ * Replace the Google credential lookup, for tests only.
146
+ *
147
+ * Without this seam the two-leg fallback chain cannot be driven end to end.
148
+ * The rest of the hop is reachable — `dispatchVertexAnthropicPassthrough`
149
+ * calls the global `fetch`, which a test can replace — but the credential
150
+ * lookup goes through gaxios, and gaxios resolves its transport to
151
+ * `(await import("node-fetch")).default` rather than `globalThis.fetch`. A
152
+ * test that swaps the global therefore cannot intercept the token exchange,
153
+ * and on a machine with no Application Default Credentials the leg throws
154
+ * before it ever reaches the model. That left the one path that bills in real
155
+ * currency with no end-to-end coverage at all.
156
+ *
157
+ * Pass `undefined` to restore the real credential lookup.
158
+ */
159
+ export function setVertexAccessTokenProviderForTests(provider) {
160
+ accessTokenProviderForTests = provider;
161
+ }
138
162
  /**
139
163
  * Send one Anthropic-shaped request to Vertex and return the upstream response
140
164
  * untouched, so the caller can stream its bytes straight to the client.
141
165
  */
142
166
  export async function dispatchVertexAnthropicPassthrough(request) {
143
- const token = await auth().getAccessToken();
167
+ const token = await vertexAccessToken();
144
168
  if (!token) {
145
169
  throw new Error("Vertex passthrough could not obtain a Google access token; " +
146
170
  "check Application Default Credentials for this project");
@@ -295,6 +295,9 @@ export async function classifyJev(input, decide, timeoutMs, candidates, threshol
295
295
  // are already assembled by the caller. Sending them costs almost nothing
296
296
  // and they are exactly what distinguishes a long tool-using request from a
297
297
  // short conversational one.
298
+ // Continuity is sent as `session_bound` / `prior_messages`, never as the
299
+ // raw sessionId: the id is an opaque identifier that tells a third-party
300
+ // model nothing, so it stays in-process.
298
301
  const result = await decide({
299
302
  state: {
300
303
  request: (input.prompt ?? "").slice(0, 8000),
@@ -302,6 +305,10 @@ export async function classifyJev(input, decide, timeoutMs, candidates, threshol
302
305
  has_tools_available: input.hasTools ?? false,
303
306
  request_includes_images: input.requiresVision ?? false,
304
307
  caller_requested_thinking: input.thinkingLevel ?? "none",
308
+ session_bound: input.sessionBound ?? false,
309
+ ...(typeof input.priorMessageCount === "number"
310
+ ? { prior_messages: input.priorMessageCount }
311
+ : {}),
305
312
  },
306
313
  questions,
307
314
  timeoutMs,