@juspay/neurolink 11.11.7 → 11.13.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 +6 -2
- package/dist/browser/neurolink.min.js +396 -396
- package/dist/cli/commands/proxy.js +42 -0
- package/dist/cli/commands/proxyAnalyze.js +10 -1
- package/dist/cli/proxy-clients/claudeCode.js +42 -10
- package/dist/cli/proxy-clients/openCode.js +37 -15
- package/dist/cli/proxy-clients/qwenCode.js +33 -9
- package/dist/cli/proxy-clients/registry.js +10 -2
- package/dist/cli/proxy-clients/snapshot.d.ts +52 -0
- package/dist/cli/proxy-clients/snapshot.js +98 -0
- package/dist/lib/providers/googleAiStudio/client.js +6 -3
- package/dist/lib/providers/googleVertex/client.d.ts +33 -0
- package/dist/lib/providers/googleVertex/client.js +110 -11
- package/dist/lib/proxy/codexUsage.d.ts +68 -0
- package/dist/lib/proxy/codexUsage.js +247 -0
- package/dist/lib/proxy/proxyAnalysis.js +87 -3
- package/dist/lib/proxy/proxyFetch.d.ts +1 -0
- package/dist/lib/proxy/proxyFetch.js +29 -0
- package/dist/lib/proxy/proxyTracer.d.ts +13 -2
- package/dist/lib/proxy/proxyTracer.js +29 -7
- package/dist/lib/proxy/proxyTranslationEngine.js +22 -5
- package/dist/lib/server/routes/codexProxyRoutes.js +29 -1
- package/dist/lib/server/routes/openaiProxyRoutes.js +5 -0
- package/dist/lib/types/proxy.d.ts +65 -0
- package/dist/lib/utils/pricing.d.ts +9 -0
- package/dist/lib/utils/pricing.js +136 -1
- package/dist/providers/googleAiStudio/client.js +6 -3
- package/dist/providers/googleVertex/client.d.ts +33 -0
- package/dist/providers/googleVertex/client.js +110 -11
- package/dist/proxy/codexUsage.d.ts +68 -0
- package/dist/proxy/codexUsage.js +246 -0
- package/dist/proxy/proxyAnalysis.js +87 -3
- package/dist/proxy/proxyFetch.d.ts +1 -0
- package/dist/proxy/proxyFetch.js +29 -0
- package/dist/proxy/proxyTracer.d.ts +13 -2
- package/dist/proxy/proxyTracer.js +29 -7
- package/dist/proxy/proxyTranslationEngine.js +22 -5
- package/dist/server/routes/codexProxyRoutes.js +29 -1
- package/dist/server/routes/openaiProxyRoutes.js +5 -0
- package/dist/types/proxy.d.ts +65 -0
- package/dist/utils/pricing.d.ts +9 -0
- package/dist/utils/pricing.js +136 -1
- package/package.json +2 -1
|
@@ -11,7 +11,7 @@ import { DEFAULT_GEMINI_STREAM_TIMEOUT_MS, DEFAULT_MAX_STEPS, DEFAULT_TOOL_EXECU
|
|
|
11
11
|
import { ModelConfigurationManager } from "../../core/modelConfiguration.js";
|
|
12
12
|
import { isSchemaComplexityError } from "../../core/modules/structuredOutputPolicy.js";
|
|
13
13
|
import { redactUrlForError, stringifyContentSafe, } from "../../utils/logSanitize.js";
|
|
14
|
-
import {
|
|
14
|
+
import { warnGoogleSdkIgnoresProxy } from "../../proxy/proxyFetch.js";
|
|
15
15
|
import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
|
|
16
16
|
import { classifyProviderError } from "../../utils/errorClassifier.js";
|
|
17
17
|
import { ERROR_CODES, NeuroLinkError } from "../../utils/errorHandling.js";
|
|
@@ -578,6 +578,27 @@ function reclaimVertexAnthropicContext(messages, modelName, observedPromptTokens
|
|
|
578
578
|
export class GoogleVertexProvider extends BaseProvider {
|
|
579
579
|
projectId;
|
|
580
580
|
location;
|
|
581
|
+
/**
|
|
582
|
+
* Vertex AI Express Mode credentials.
|
|
583
|
+
*
|
|
584
|
+
* Vertex supports two authentication modes. The long-standing one pairs a
|
|
585
|
+
* project and location with Application Default Credentials, which makes
|
|
586
|
+
* the SDK mint an OAuth token through google-auth-library before every
|
|
587
|
+
* request. Express Mode instead authenticates with an API key alone.
|
|
588
|
+
*
|
|
589
|
+
* Express is used only when an apiKey is supplied WITHOUT an explicit
|
|
590
|
+
* project or location, so existing ADC callers — including those already
|
|
591
|
+
* passing an apiKey alongside a project — keep exactly the behaviour they
|
|
592
|
+
* have today.
|
|
593
|
+
*/
|
|
594
|
+
expressApiKey;
|
|
595
|
+
/**
|
|
596
|
+
* Optional endpoint override, mirroring AI Studio's
|
|
597
|
+
* `credentials.googleAiStudio.baseURL`: per-request credential first, then
|
|
598
|
+
* the environment, then unset so the SDK applies its own default. Blank
|
|
599
|
+
* values count as unset so an empty override cannot clobber that default.
|
|
600
|
+
*/
|
|
601
|
+
baseURL;
|
|
581
602
|
registeredTools = new Map();
|
|
582
603
|
toolContext = {};
|
|
583
604
|
// Memory-managed cache for model configuration lookups to avoid repeated calls
|
|
@@ -601,14 +622,33 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
601
622
|
}
|
|
602
623
|
if (credentials.apiKey) {
|
|
603
624
|
process.env.GOOGLE_API_KEY = String(credentials.apiKey);
|
|
625
|
+
// Express Mode only when the caller gave a key and NOTHING else to
|
|
626
|
+
// authenticate with. An apiKey passed next to a project is the
|
|
627
|
+
// pre-existing combination and must keep resolving through ADC.
|
|
628
|
+
if (!credentials.projectId && !credentials.location) {
|
|
629
|
+
this.expressApiKey = String(credentials.apiKey);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (credentials.baseURL) {
|
|
633
|
+
this.baseURL = String(credentials.baseURL);
|
|
604
634
|
}
|
|
605
635
|
}
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
636
|
+
// Express Mode authenticates with the key alone, so neither the ADC
|
|
637
|
+
// credential check nor project resolution applies. Both THROW when
|
|
638
|
+
// nothing is configured, which would fail an apiKey-only request before
|
|
639
|
+
// the Express client is ever built — and would do so only on machines
|
|
640
|
+
// without an ambient project, which is exactly where Express is the point.
|
|
641
|
+
const usingExpress = Boolean(this.resolveExpressApiKey());
|
|
642
|
+
if (!usingExpress) {
|
|
643
|
+
// Validate Google Cloud credentials - now using consolidated utility
|
|
644
|
+
if (!hasGoogleCredentials()) {
|
|
645
|
+
validateApiKey(createGoogleAuthConfig());
|
|
646
|
+
}
|
|
609
647
|
}
|
|
610
648
|
// Initialize Google Cloud configuration
|
|
611
|
-
this.projectId =
|
|
649
|
+
this.projectId =
|
|
650
|
+
credentials?.projectId ||
|
|
651
|
+
(usingExpress ? "" : getVertexProjectId());
|
|
612
652
|
this.location =
|
|
613
653
|
region || credentials?.location || getVertexLocation();
|
|
614
654
|
logger.debug("[GoogleVertexProvider] Constructor initialized", {
|
|
@@ -803,7 +843,11 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
803
843
|
* Create @google/genai client configured for Vertex AI
|
|
804
844
|
*/
|
|
805
845
|
async createVertexGenAIClient(regionOverride) {
|
|
806
|
-
|
|
846
|
+
warnGoogleSdkIgnoresProxy("GoogleVertex");
|
|
847
|
+
const expressApiKey = this.resolveExpressApiKey();
|
|
848
|
+
// Resolved only on the ADC path: getVertexProjectId() throws when no
|
|
849
|
+
// project is configured, which an Express request legitimately has none of.
|
|
850
|
+
const project = expressApiKey ? "" : getVertexProjectId();
|
|
807
851
|
const location = regionOverride || this.location || getVertexLocation();
|
|
808
852
|
const mod = await import("@google/genai");
|
|
809
853
|
const ctor = mod.GoogleGenAI;
|
|
@@ -818,17 +862,72 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
818
862
|
});
|
|
819
863
|
}
|
|
820
864
|
const Ctor = ctor;
|
|
821
|
-
|
|
822
|
-
|
|
865
|
+
const baseUrl = this.resolveBaseURL();
|
|
866
|
+
const httpOptions = {
|
|
867
|
+
// The endpoint override and nothing else. This object used to also pass
|
|
868
|
+
// a proxy fetch, which the SDK silently ignored — see
|
|
869
|
+
// warnGoogleSdkIgnoresProxy for why that is not fixable here.
|
|
870
|
+
//
|
|
871
|
+
// Only set when resolved: the SDK falls back to its own default
|
|
872
|
+
// whenever httpOptions.baseUrl is undefined, so omitting the key and
|
|
873
|
+
// passing undefined behave identically.
|
|
874
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
875
|
+
};
|
|
876
|
+
if (expressApiKey) {
|
|
877
|
+
// Express Mode: an API key replaces project/location entirely. Passing
|
|
878
|
+
// them alongside the key would defeat it — the SDK prefers
|
|
879
|
+
// project/location and falls back to ADC, which is the very thing
|
|
880
|
+
// Express exists to avoid.
|
|
881
|
+
return new Ctor({
|
|
882
|
+
vertexai: true,
|
|
883
|
+
apiKey: expressApiKey,
|
|
884
|
+
httpOptions,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
// Project/location mode, authenticating through ADC.
|
|
823
888
|
return new Ctor({
|
|
824
889
|
vertexai: true,
|
|
825
890
|
project,
|
|
826
891
|
location,
|
|
827
|
-
httpOptions
|
|
828
|
-
fetch: createProxyFetch(),
|
|
829
|
-
},
|
|
892
|
+
httpOptions,
|
|
830
893
|
});
|
|
831
894
|
}
|
|
895
|
+
/** Endpoint override: credential, then environment, then unset. */
|
|
896
|
+
resolveBaseURL() {
|
|
897
|
+
const resolved = this.baseURL?.trim() || process.env.GOOGLE_VERTEX_BASE_URL?.trim();
|
|
898
|
+
return resolved && resolved.length > 0 ? resolved : undefined;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Express Mode key, if this provider should use it.
|
|
902
|
+
*
|
|
903
|
+
* Deliberately NOT read from GOOGLE_API_KEY: that variable is already set
|
|
904
|
+
* by callers who also configure a project, and treating it as an Express
|
|
905
|
+
* opt-in would silently switch their authentication mode. Express is opted
|
|
906
|
+
* into per request, or through GOOGLE_VERTEX_API_KEY which exists only for
|
|
907
|
+
* this purpose.
|
|
908
|
+
*/
|
|
909
|
+
resolveExpressApiKey() {
|
|
910
|
+
// A per-request key already carries its own guard: it is only recorded
|
|
911
|
+
// when the caller supplied no project and no location.
|
|
912
|
+
const fromCredentials = this.expressApiKey?.trim();
|
|
913
|
+
if (fromCredentials) {
|
|
914
|
+
return fromCredentials;
|
|
915
|
+
}
|
|
916
|
+
const fromEnv = process.env.GOOGLE_VERTEX_API_KEY?.trim();
|
|
917
|
+
if (!fromEnv) {
|
|
918
|
+
return undefined;
|
|
919
|
+
}
|
|
920
|
+
// The environment key gets the same guard. A process that configures a
|
|
921
|
+
// project has an ADC setup, and letting an ambient key silently switch it
|
|
922
|
+
// to Express would change how that process authenticates.
|
|
923
|
+
const projectConfigured = [
|
|
924
|
+
"GOOGLE_CLOUD_PROJECT_ID",
|
|
925
|
+
"VERTEX_PROJECT_ID",
|
|
926
|
+
"GOOGLE_VERTEX_PROJECT",
|
|
927
|
+
"GOOGLE_CLOUD_PROJECT",
|
|
928
|
+
].some((name) => (process.env[name] ?? "").trim().length > 0);
|
|
929
|
+
return projectConfigured ? undefined : fromEnv;
|
|
930
|
+
}
|
|
832
931
|
/**
|
|
833
932
|
* Convert one AI-SDK tool into a Vertex Gemini function declaration.
|
|
834
933
|
* Single source for the pre-loop snapshot AND the mid-turn discovery
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex (OpenAI Responses) SSE usage tap.
|
|
3
|
+
*
|
|
4
|
+
* The Codex proxy engine relays `upstream.body` to the client untouched and
|
|
5
|
+
* logs before a single byte is read, so no Codex request has ever carried token
|
|
6
|
+
* counts. This module adds a pass-through tap that scrapes `usage` out of the
|
|
7
|
+
* stream without holding back or altering any bytes.
|
|
8
|
+
*
|
|
9
|
+
* ## Safety contract
|
|
10
|
+
*
|
|
11
|
+
* This sits in the hot path of a live proxy, so it is built to be incapable of
|
|
12
|
+
* breaking a stream:
|
|
13
|
+
*
|
|
14
|
+
* - every chunk is enqueued **before** it is inspected;
|
|
15
|
+
* - all parsing runs inside try/catch, and a throw is swallowed;
|
|
16
|
+
* - a stream whose shape is unrecognised resolves `usage` to `null`, which is
|
|
17
|
+
* exactly today's behaviour (a log with no token fields).
|
|
18
|
+
*
|
|
19
|
+
* The worst case is therefore "no tokens recorded", never a truncated or
|
|
20
|
+
* corrupted response.
|
|
21
|
+
*
|
|
22
|
+
* ## Wire shape
|
|
23
|
+
*
|
|
24
|
+
* **Verified against real traffic.** Captured from a live `codex exec` run
|
|
25
|
+
* through the proxy on 2026-08-21; the trimmed sample is at
|
|
26
|
+
* `test/fixtures/codex-response-usage.sse` and is asserted against in the
|
|
27
|
+
* codex suite. The real shape is
|
|
28
|
+
*
|
|
29
|
+
* event: response.completed
|
|
30
|
+
* data: {"type":"response.completed","response":{"usage":{
|
|
31
|
+
* "input_tokens":N,"output_tokens":M,
|
|
32
|
+
* "input_tokens_details":{"cached_tokens":K,"cache_write_tokens":W},
|
|
33
|
+
* "output_tokens_details":{"reasoning_tokens":R}}}}
|
|
34
|
+
*
|
|
35
|
+
* Note that `response.created` arrives first carrying `usage: null`, which is
|
|
36
|
+
* why the scanner keeps the last non-null result rather than the first.
|
|
37
|
+
*
|
|
38
|
+
* It also accepts a `usage` object at the top level of any event and the
|
|
39
|
+
* `prompt_tokens`/`completion_tokens` spellings. A `null` result means "not
|
|
40
|
+
* observed", never "zero tokens".
|
|
41
|
+
*/
|
|
42
|
+
import type { CodexStreamUsage } from "../types/index.js";
|
|
43
|
+
/**
|
|
44
|
+
* Pull usage out of one parsed SSE `data:` payload.
|
|
45
|
+
*
|
|
46
|
+
* Returns null when the payload carries no recognisable usage object, so the
|
|
47
|
+
* caller can keep the last non-null result rather than overwriting it with a
|
|
48
|
+
* later event that happens not to carry usage.
|
|
49
|
+
*/
|
|
50
|
+
export declare function extractCodexUsage(payload: unknown): CodexStreamUsage | null;
|
|
51
|
+
/**
|
|
52
|
+
* Scan a slice of SSE text for usage, returning the last one found.
|
|
53
|
+
*
|
|
54
|
+
* Exported for tests: it is the whole parsing decision, and driving it through
|
|
55
|
+
* a real Codex stream would need a live ChatGPT subscription.
|
|
56
|
+
*/
|
|
57
|
+
export declare function scanCodexSSEForUsage(text: string): CodexStreamUsage | null;
|
|
58
|
+
/**
|
|
59
|
+
* A pass-through TransformStream that reports the usage seen on a Codex SSE
|
|
60
|
+
* stream.
|
|
61
|
+
*
|
|
62
|
+
* `usage` resolves when the stream ends: to the last usage observed, or null if
|
|
63
|
+
* none was. It never rejects.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createCodexUsageTap(): {
|
|
66
|
+
stream: TransformStream<Uint8Array, Uint8Array>;
|
|
67
|
+
usage: Promise<CodexStreamUsage | null>;
|
|
68
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex (OpenAI Responses) SSE usage tap.
|
|
3
|
+
*
|
|
4
|
+
* The Codex proxy engine relays `upstream.body` to the client untouched and
|
|
5
|
+
* logs before a single byte is read, so no Codex request has ever carried token
|
|
6
|
+
* counts. This module adds a pass-through tap that scrapes `usage` out of the
|
|
7
|
+
* stream without holding back or altering any bytes.
|
|
8
|
+
*
|
|
9
|
+
* ## Safety contract
|
|
10
|
+
*
|
|
11
|
+
* This sits in the hot path of a live proxy, so it is built to be incapable of
|
|
12
|
+
* breaking a stream:
|
|
13
|
+
*
|
|
14
|
+
* - every chunk is enqueued **before** it is inspected;
|
|
15
|
+
* - all parsing runs inside try/catch, and a throw is swallowed;
|
|
16
|
+
* - a stream whose shape is unrecognised resolves `usage` to `null`, which is
|
|
17
|
+
* exactly today's behaviour (a log with no token fields).
|
|
18
|
+
*
|
|
19
|
+
* The worst case is therefore "no tokens recorded", never a truncated or
|
|
20
|
+
* corrupted response.
|
|
21
|
+
*
|
|
22
|
+
* ## Wire shape
|
|
23
|
+
*
|
|
24
|
+
* **Verified against real traffic.** Captured from a live `codex exec` run
|
|
25
|
+
* through the proxy on 2026-08-21; the trimmed sample is at
|
|
26
|
+
* `test/fixtures/codex-response-usage.sse` and is asserted against in the
|
|
27
|
+
* codex suite. The real shape is
|
|
28
|
+
*
|
|
29
|
+
* event: response.completed
|
|
30
|
+
* data: {"type":"response.completed","response":{"usage":{
|
|
31
|
+
* "input_tokens":N,"output_tokens":M,
|
|
32
|
+
* "input_tokens_details":{"cached_tokens":K,"cache_write_tokens":W},
|
|
33
|
+
* "output_tokens_details":{"reasoning_tokens":R}}}}
|
|
34
|
+
*
|
|
35
|
+
* Note that `response.created` arrives first carrying `usage: null`, which is
|
|
36
|
+
* why the scanner keeps the last non-null result rather than the first.
|
|
37
|
+
*
|
|
38
|
+
* It also accepts a `usage` object at the top level of any event and the
|
|
39
|
+
* `prompt_tokens`/`completion_tokens` spellings. A `null` result means "not
|
|
40
|
+
* observed", never "zero tokens".
|
|
41
|
+
*/
|
|
42
|
+
import { appendFileSync } from "node:fs";
|
|
43
|
+
const nonNegativeInt = (value) => typeof value === "number" && Number.isFinite(value) && value > 0
|
|
44
|
+
? Math.floor(value)
|
|
45
|
+
: 0;
|
|
46
|
+
/**
|
|
47
|
+
* Pull usage out of one parsed SSE `data:` payload.
|
|
48
|
+
*
|
|
49
|
+
* Returns null when the payload carries no recognisable usage object, so the
|
|
50
|
+
* caller can keep the last non-null result rather than overwriting it with a
|
|
51
|
+
* later event that happens not to carry usage.
|
|
52
|
+
*/
|
|
53
|
+
export function extractCodexUsage(payload) {
|
|
54
|
+
if (payload === null || typeof payload !== "object") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const root = payload;
|
|
58
|
+
const response = root.response;
|
|
59
|
+
const usage = (response && typeof response === "object" && response.usage
|
|
60
|
+
? response.usage
|
|
61
|
+
: root.usage);
|
|
62
|
+
if (!usage || typeof usage !== "object") {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const input = usage.input_tokens ?? usage.prompt_tokens;
|
|
66
|
+
const output = usage.output_tokens ?? usage.completion_tokens;
|
|
67
|
+
if (input === undefined && output === undefined) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const inputDetails = usage.input_tokens_details;
|
|
71
|
+
const outputDetails = usage.output_tokens_details;
|
|
72
|
+
return {
|
|
73
|
+
inputTokens: nonNegativeInt(input),
|
|
74
|
+
outputTokens: nonNegativeInt(output),
|
|
75
|
+
cacheReadTokens: nonNegativeInt(inputDetails?.cached_tokens),
|
|
76
|
+
cacheCreationTokens: nonNegativeInt(inputDetails?.cache_write_tokens),
|
|
77
|
+
reasoningTokens: nonNegativeInt(outputDetails?.reasoning_tokens),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Scan a slice of SSE text for usage, returning the last one found.
|
|
82
|
+
*
|
|
83
|
+
* Exported for tests: it is the whole parsing decision, and driving it through
|
|
84
|
+
* a real Codex stream would need a live ChatGPT subscription.
|
|
85
|
+
*/
|
|
86
|
+
export function scanCodexSSEForUsage(text) {
|
|
87
|
+
let found = null;
|
|
88
|
+
for (const line of text.split("\n")) {
|
|
89
|
+
if (!line.startsWith("data:")) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const raw = line.slice(5).trim();
|
|
93
|
+
if (!raw || raw === "[DONE]") {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const usage = extractCodexUsage(JSON.parse(raw));
|
|
98
|
+
if (usage) {
|
|
99
|
+
found = usage;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Partial or non-JSON payload — the next chunk may complete it. Never
|
|
104
|
+
// let a malformed line escape into the relay.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Maximum bytes written by the opt-in raw capture. One `response.completed`
|
|
111
|
+
* event is a few hundred bytes; 256 KiB is generous and bounds a runaway file.
|
|
112
|
+
*/
|
|
113
|
+
const CAPTURE_LIMIT_BYTES = 256 * 1024;
|
|
114
|
+
/**
|
|
115
|
+
* Opt-in raw capture of one Codex SSE stream, for confirming the `usage` wire
|
|
116
|
+
* shape against real traffic.
|
|
117
|
+
*
|
|
118
|
+
* Off unless `NEUROLINK_PROXY_CODEX_CAPTURE` names a file. It is deliberately
|
|
119
|
+
* env-gated and undocumented in the CLI: the captured bytes are the assistant's
|
|
120
|
+
* actual response, so this is a debugging tool the operator turns on
|
|
121
|
+
* deliberately, not something that runs by default. Capture stops at the first
|
|
122
|
+
* completed stream and is capped.
|
|
123
|
+
*/
|
|
124
|
+
function createCaptureSink() {
|
|
125
|
+
const target = process.env.NEUROLINK_PROXY_CODEX_CAPTURE;
|
|
126
|
+
if (!target) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
let written = 0;
|
|
130
|
+
let started = false;
|
|
131
|
+
return (chunk) => {
|
|
132
|
+
if (written >= CAPTURE_LIMIT_BYTES) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
// Append only the new bytes. Rewriting the accumulated buffer on every
|
|
137
|
+
// chunk is quadratic in stream length and runs in a live relay's
|
|
138
|
+
// transform(), so a long response would do hundreds of growing
|
|
139
|
+
// synchronous writes.
|
|
140
|
+
// Slice to the remaining capacity rather than writing the whole chunk.
|
|
141
|
+
// The guard above only says the cap was not ALREADY reached, so a single
|
|
142
|
+
// large chunk arriving at 255 KiB would otherwise land in full and the
|
|
143
|
+
// file would end up far past its bound — the cap has to hold per write,
|
|
144
|
+
// not per stream.
|
|
145
|
+
const remaining = CAPTURE_LIMIT_BYTES - written;
|
|
146
|
+
const slice = chunk.byteLength > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
147
|
+
appendFileSync(target, slice, { flag: started ? "a" : "w" });
|
|
148
|
+
started = true;
|
|
149
|
+
written += slice.byteLength;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Capture is best-effort telemetry; never let it touch the relay.
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A pass-through TransformStream that reports the usage seen on a Codex SSE
|
|
158
|
+
* stream.
|
|
159
|
+
*
|
|
160
|
+
* `usage` resolves when the stream ends: to the last usage observed, or null if
|
|
161
|
+
* none was. It never rejects.
|
|
162
|
+
*/
|
|
163
|
+
export function createCodexUsageTap() {
|
|
164
|
+
let settleUsage = () => { };
|
|
165
|
+
const usage = new Promise((resolve) => {
|
|
166
|
+
settleUsage = resolve;
|
|
167
|
+
});
|
|
168
|
+
// flush() and cancel() are mutually exclusive in principle, but a
|
|
169
|
+
// double-settle must be harmless rather than relied upon.
|
|
170
|
+
let settled = false;
|
|
171
|
+
const settle = (value) => {
|
|
172
|
+
if (settled) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
settled = true;
|
|
176
|
+
settleUsage(value);
|
|
177
|
+
};
|
|
178
|
+
const decoder = new TextDecoder();
|
|
179
|
+
const capture = createCaptureSink();
|
|
180
|
+
let carry = "";
|
|
181
|
+
let latest = null;
|
|
182
|
+
/**
|
|
183
|
+
* Ceiling on the unterminated tail we are willing to hold.
|
|
184
|
+
*
|
|
185
|
+
* `carry` normally holds a fraction of one SSE line, because every newline
|
|
186
|
+
* flushes it. A stream that never sends one — a hung upstream, a
|
|
187
|
+
* non-SSE body relayed by mistake — would otherwise grow it without bound
|
|
188
|
+
* for the life of the request. One `response.completed` event is a few
|
|
189
|
+
* hundred bytes, so a megabyte is far past any real event, and dropping the
|
|
190
|
+
* tail costs at most the usage reading this tap is allowed to miss anyway.
|
|
191
|
+
*/
|
|
192
|
+
const CARRY_LIMIT_CHARS = 1024 * 1024;
|
|
193
|
+
const transformer = {
|
|
194
|
+
transform(chunk, controller) {
|
|
195
|
+
// Bytes go out first and unconditionally: nothing below can delay or
|
|
196
|
+
// alter what the client receives.
|
|
197
|
+
controller.enqueue(chunk);
|
|
198
|
+
try {
|
|
199
|
+
capture?.(chunk);
|
|
200
|
+
carry += decoder.decode(chunk, { stream: true });
|
|
201
|
+
// Keep only the trailing partial line; events are newline-delimited.
|
|
202
|
+
const lastBreak = carry.lastIndexOf("\n");
|
|
203
|
+
if (lastBreak === -1) {
|
|
204
|
+
if (carry.length > CARRY_LIMIT_CHARS) {
|
|
205
|
+
// No line break in a megabyte: this is not the SSE stream we can
|
|
206
|
+
// read. Give up on the tail rather than grow forever.
|
|
207
|
+
carry = "";
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const complete = carry.slice(0, lastBreak);
|
|
212
|
+
carry = carry.slice(lastBreak + 1);
|
|
213
|
+
const seen = scanCodexSSEForUsage(complete);
|
|
214
|
+
if (seen) {
|
|
215
|
+
latest = seen;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Telemetry must never break the relay.
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
flush() {
|
|
223
|
+
try {
|
|
224
|
+
const seen = scanCodexSSEForUsage(carry);
|
|
225
|
+
if (seen) {
|
|
226
|
+
latest = seen;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// ignored — see above
|
|
231
|
+
}
|
|
232
|
+
settle(latest);
|
|
233
|
+
},
|
|
234
|
+
/**
|
|
235
|
+
* A client hanging up mid-response, or an upstream error, aborts the
|
|
236
|
+
* stream rather than closing it — so flush() never runs. Without this the
|
|
237
|
+
* usage promise would never settle and every aborted request would leak a
|
|
238
|
+
* pending handler. Report whatever was seen before the abort.
|
|
239
|
+
*/
|
|
240
|
+
cancel() {
|
|
241
|
+
settle(latest);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
const stream = new TransformStream(transformer);
|
|
245
|
+
return { stream, usage };
|
|
246
|
+
}
|
|
247
|
+
//# sourceMappingURL=codexUsage.js.map
|
|
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { ACCOUNT_COOLING_REASONS, PROXY_ACCOUNT_TYPES, PROXY_ACCOUNT_ROUTING_MODES, PROXY_ACCOUNT_ROUTING_REASONS, PROXY_ACCOUNT_ROUTING_STRATEGIES, } from "./routingEvidence.js";
|
|
7
|
+
import { calculateCost, hasPricing, isExactPricingMatch, } from "../utils/pricing.js";
|
|
7
8
|
const LIFECYCLE_FILE_PATTERN = /^proxy-lifecycle-\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
8
9
|
const REQUEST_FILE_PATTERN = /^proxy-\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
9
10
|
const ATTEMPT_FILE_PATTERN = /^proxy-attempts-\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
@@ -358,6 +359,13 @@ function summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByR
|
|
|
358
359
|
let cacheReadTokens = 0;
|
|
359
360
|
let cacheCreationTokens = 0;
|
|
360
361
|
let inputTokens = 0;
|
|
362
|
+
let outputTokens = 0;
|
|
363
|
+
let estimatedCostUsd = 0;
|
|
364
|
+
let requestsPriced = 0;
|
|
365
|
+
let requestsPricedByPrefix = 0;
|
|
366
|
+
let requestsUnpriced = 0;
|
|
367
|
+
const modelsPricedByPrefix = new Set();
|
|
368
|
+
const unpricedModels = new Set();
|
|
361
369
|
const finalRequestLatency = [];
|
|
362
370
|
const singleAttemptDelta = [];
|
|
363
371
|
const errorTypes = {};
|
|
@@ -394,13 +402,50 @@ function summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByR
|
|
|
394
402
|
singleAttemptDelta.push(request.durationMs - requestAttempts.totalDurationMs);
|
|
395
403
|
}
|
|
396
404
|
if (request.inputTokens !== null ||
|
|
405
|
+
request.outputTokens !== null ||
|
|
397
406
|
request.cacheReadTokens !== null ||
|
|
398
407
|
request.cacheCreationTokens !== null) {
|
|
399
408
|
requestsWithUsage += 1;
|
|
400
409
|
inputTokens += request.inputTokens ?? 0;
|
|
410
|
+
outputTokens += request.outputTokens ?? 0;
|
|
401
411
|
cacheReadTokens += request.cacheReadTokens ?? 0;
|
|
402
412
|
cacheCreationTokens += request.cacheCreationTokens ?? 0;
|
|
403
413
|
requestsWithCacheRead += (request.cacheReadTokens ?? 0) > 0 ? 1 : 0;
|
|
414
|
+
if (request.model) {
|
|
415
|
+
// Records written before `provider` existed carry only a model name.
|
|
416
|
+
// "openai-compatible" resolves to the cross-provider table search in
|
|
417
|
+
// pricing.ts, which finds the model wherever it lives — a far better
|
|
418
|
+
// guess than assuming Anthropic and pricing a GPT model at $0.
|
|
419
|
+
const cost = calculateCost(request.provider ?? "openai-compatible", request.model, {
|
|
420
|
+
input: request.inputTokens ?? 0,
|
|
421
|
+
output: request.outputTokens ?? 0,
|
|
422
|
+
total: (request.inputTokens ?? 0) +
|
|
423
|
+
(request.outputTokens ?? 0) +
|
|
424
|
+
(request.cacheCreationTokens ?? 0) +
|
|
425
|
+
(request.cacheReadTokens ?? 0),
|
|
426
|
+
cacheCreationTokens: request.cacheCreationTokens ?? 0,
|
|
427
|
+
cacheReadTokens: request.cacheReadTokens ?? 0,
|
|
428
|
+
});
|
|
429
|
+
// Ask the table directly rather than inferring from cost > 0: a real
|
|
430
|
+
// request with trivial usage can round to $0.000000 and is priced, not
|
|
431
|
+
// unpriced.
|
|
432
|
+
const priced = hasPricing(request.provider ?? "openai-compatible", request.model);
|
|
433
|
+
if (priced) {
|
|
434
|
+
estimatedCostUsd += cost;
|
|
435
|
+
requestsPriced += 1;
|
|
436
|
+
// A prefix fallback means the rate was inherited from a
|
|
437
|
+
// similarly-named model, not quoted for this one. Surface it rather
|
|
438
|
+
// than presenting a guess as a figure.
|
|
439
|
+
if (!isExactPricingMatch(request.provider ?? "openai-compatible", request.model)) {
|
|
440
|
+
requestsPricedByPrefix += 1;
|
|
441
|
+
modelsPricedByPrefix.add(request.model);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
requestsUnpriced += 1;
|
|
446
|
+
unpricedModels.add(request.model);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
404
449
|
}
|
|
405
450
|
}
|
|
406
451
|
return {
|
|
@@ -419,6 +464,13 @@ function summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByR
|
|
|
419
464
|
cacheReadTokens,
|
|
420
465
|
cacheCreationTokens,
|
|
421
466
|
inputTokens,
|
|
467
|
+
outputTokens,
|
|
468
|
+
estimatedCostUsd: Number(estimatedCostUsd.toFixed(6)),
|
|
469
|
+
requestsPriced,
|
|
470
|
+
requestsPricedByPrefix,
|
|
471
|
+
modelsPricedByPrefix: [...modelsPricedByPrefix].sort(),
|
|
472
|
+
requestsUnpriced,
|
|
473
|
+
unpricedModels: [...unpricedModels].sort(),
|
|
422
474
|
requestHitRate: requestsWithUsage > 0
|
|
423
475
|
? Number((requestsWithCacheRead / requestsWithUsage).toFixed(4))
|
|
424
476
|
: null,
|
|
@@ -695,13 +747,26 @@ export async function analyzeProxyLogs(options) {
|
|
|
695
747
|
for (const filePath of requestFiles) {
|
|
696
748
|
linesRead += await readJsonLines(filePath, (record) => {
|
|
697
749
|
const timestamp = observeTimestamp("requests", record);
|
|
698
|
-
if (timestamp === null
|
|
750
|
+
if (timestamp === null) {
|
|
699
751
|
return;
|
|
700
752
|
}
|
|
701
753
|
const requestId = stringValue(record.requestId);
|
|
702
754
|
if (!requestId) {
|
|
703
755
|
return;
|
|
704
756
|
}
|
|
757
|
+
// A streamed request is logged twice — once when the response headers
|
|
758
|
+
// are known, again when the body finishes and its token counts arrive
|
|
759
|
+
// — and those two writes can straddle the window edge. A Codex turn
|
|
760
|
+
// whose headers land at 23:59:50 and whose stream ends at 00:00:05 has
|
|
761
|
+
// every token it spent in the second record. Filtering that record out
|
|
762
|
+
// by its own timestamp would leave the request counted as completed
|
|
763
|
+
// but contributing nothing to tokens or cost, with nothing in the
|
|
764
|
+
// report to say so. A request the window already admitted therefore
|
|
765
|
+
// keeps accepting its own later records.
|
|
766
|
+
const alreadyAdmitted = finalRequests.has(requestId) || terminalStreamErrors.has(requestId);
|
|
767
|
+
if (!alreadyAdmitted && (timestamp < sinceMs || timestamp > untilMs)) {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
705
770
|
if (finiteNumber(record.terminalStatus) !== null) {
|
|
706
771
|
terminalStreamErrors.add(requestId);
|
|
707
772
|
return;
|
|
@@ -723,19 +788,38 @@ export async function analyzeProxyLogs(options) {
|
|
|
723
788
|
else {
|
|
724
789
|
absentRoutingDecisions += 1;
|
|
725
790
|
}
|
|
726
|
-
|
|
791
|
+
const parsed = {
|
|
727
792
|
timestamp: new Date(timestamp).toISOString(),
|
|
728
793
|
status,
|
|
729
794
|
durationMs: finiteNumber(record.responseTimeMs),
|
|
730
795
|
account: stringValue(record.account) ?? "unknown",
|
|
731
796
|
accountType: stringValue(record.accountType) ?? "unknown",
|
|
797
|
+
model: stringValue(record.model),
|
|
798
|
+
provider: stringValue(record.provider),
|
|
732
799
|
inputTokens: finiteNumber(record.inputTokens),
|
|
800
|
+
outputTokens: finiteNumber(record.outputTokens),
|
|
733
801
|
cacheReadTokens: finiteNumber(record.cacheReadTokens),
|
|
734
802
|
cacheCreationTokens: finiteNumber(record.cacheCreationTokens),
|
|
735
803
|
errorType: stringValue(record.errorType),
|
|
736
804
|
errorCode: stringValue(record.errorCode),
|
|
737
805
|
routingDecision,
|
|
738
|
-
}
|
|
806
|
+
};
|
|
807
|
+
// A request may be logged twice: once when the response headers are
|
|
808
|
+
// known, and again when a streamed body finishes and its token counts
|
|
809
|
+
// become available (the Codex engine does this). Merge rather than
|
|
810
|
+
// replace, so the later usage-bearing record cannot drop an errorType
|
|
811
|
+
// the first one carried, and vice versa.
|
|
812
|
+
const previous = finalRequests.get(requestId);
|
|
813
|
+
finalRequests.set(requestId, previous
|
|
814
|
+
? {
|
|
815
|
+
...previous,
|
|
816
|
+
...Object.fromEntries(Object.entries(parsed).filter(([, value]) => value !== null && value !== undefined)),
|
|
817
|
+
// Attribute the request to when it was first seen. A late
|
|
818
|
+
// completion record must not move it out of the window that
|
|
819
|
+
// admitted it.
|
|
820
|
+
timestamp: previous.timestamp,
|
|
821
|
+
}
|
|
822
|
+
: parsed);
|
|
739
823
|
}, () => {
|
|
740
824
|
malformedLines += 1;
|
|
741
825
|
});
|
|
@@ -697,4 +697,33 @@ export function getProxyStatus() {
|
|
|
697
697
|
],
|
|
698
698
|
};
|
|
699
699
|
}
|
|
700
|
+
/**
|
|
701
|
+
* One-time warning that the @google/genai SDK cannot honour a configured
|
|
702
|
+
* proxy.
|
|
703
|
+
*
|
|
704
|
+
* Both Google providers passed `httpOptions: { fetch: createProxyFetch() }`,
|
|
705
|
+
* which does nothing. `HttpOptions` in @google/genai 1.46.0 declares only
|
|
706
|
+
* baseUrl, baseUrlResourceScope, apiVersion, headers, timeout, extraBody and
|
|
707
|
+
* retryOptions — there is no `fetch` on it. That property belongs to a
|
|
708
|
+
* different interface (`ClientOptions`), which `GoogleGenAIOptions` does not
|
|
709
|
+
* accept, and the SDK's request path calls global `fetch`. The option was
|
|
710
|
+
* silently dropped and requests went direct.
|
|
711
|
+
*
|
|
712
|
+
* It type-checked only because those constructors are reached through a
|
|
713
|
+
* loosely-typed local alias, which turns off excess-property checking.
|
|
714
|
+
*
|
|
715
|
+
* This SDK version offers no supported injection point, so rather than keep a
|
|
716
|
+
* line that reads like working proxy support, the situation is reported once
|
|
717
|
+
* per process — and only to someone who actually configured a proxy. Silent
|
|
718
|
+
* bypass is the worst outcome available: a corporate user believes their
|
|
719
|
+
* traffic is proxied when it is not.
|
|
720
|
+
*/
|
|
721
|
+
let proxyUnsupportedWarned = false;
|
|
722
|
+
export function warnGoogleSdkIgnoresProxy(providerLabel) {
|
|
723
|
+
if (proxyUnsupportedWarned || !getProxyStatus().enabled) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
proxyUnsupportedWarned = true;
|
|
727
|
+
logger.warn(`[${providerLabel}] A proxy is configured, but the @google/genai SDK provides no way to route its requests through it (HttpOptions has no 'fetch', and GoogleGenAIOptions accepts none). Requests from this provider go direct.`);
|
|
728
|
+
}
|
|
700
729
|
//# sourceMappingURL=proxyFetch.js.map
|