@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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +372 -372
- package/dist/cli/commands/decide.d.ts +22 -0
- package/dist/cli/commands/decide.js +287 -0
- package/dist/cli/parser.js +6 -0
- package/dist/neurolink.js +8 -0
- package/dist/proxy/codexFallback.d.ts +27 -9
- package/dist/proxy/codexFallback.js +84 -13
- package/dist/proxy/openaiFormat.js +8 -1
- package/dist/proxy/vertexAnthropicFallback.d.ts +17 -1
- package/dist/proxy/vertexAnthropicFallback.js +25 -1
- package/dist/routing/classifierStrategies.js +7 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +17 -0
- package/dist/server/routes/claudeProxyRoutes.js +53 -5
- package/dist/types/classifierRouter.d.ts +4 -0
- package/dist/types/cli.d.ts +12 -0
- package/dist/types/providers.d.ts +8 -0
- package/dist/types/proxy.d.ts +20 -4
- package/dist/utils/anthropicCacheBreakpoints.d.ts +22 -1
- package/dist/utils/anthropicCacheBreakpoints.js +113 -0
- package/dist/utils/logger.d.ts +10 -0
- package/dist/utils/logger.js +27 -2
- package/dist/utils/pricing.js +69 -2
- package/docs-site/static/search-index.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
package/dist/cli/parser.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
*
|
|
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
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
|
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,
|