@intelli-1113/stratos-sdk 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intelli-1113/stratos-sdk",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Stratos AI agent telemetry (OpenLLMetry), Vercel AI auto-spans, LangChain inspect/fix, MCP proxy.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -37,5 +37,13 @@
37
37
  "@traceloop/node-server-sdk": "^0.27.0",
38
38
  "import-in-the-middle": "^1.15.0",
39
39
  "require-in-the-middle": "^7.5.2"
40
+ },
41
+ "peerDependencies": {
42
+ "@langchain/core": ">=1.0.0 <2.0.0"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@langchain/core": {
46
+ "optional": true
47
+ }
40
48
  }
41
49
  }
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * stratos-inspect — check LangChain version compatibility for Stratos metrics.
4
4
  *
@@ -0,0 +1,163 @@
1
+ // Network-level budget / lifecycle enforcement.
2
+ //
3
+ // WHY THE NETWORK LAYER, NOT THE SDK LAYER
4
+ // -----------------------------------------------------------------------------
5
+ // OpenAI, Anthropic, LangChain, Vercel AI, Google GenAI — every one of them
6
+ // ultimately does the same thing: send an outbound HTTP request to the
7
+ // provider's API. Traceloop's own instrumentation (which this SDK depends on
8
+ // but does not own — @traceloop/instrumentation-openai et al.) exposes no
9
+ // pre-call hook that can refuse a request; its config surface is
10
+ // traceContent / enrichTokens / exceptionLogger / uploadBase64Image only.
11
+ // Wrapping every provider SDK's call function individually is also a moving
12
+ // target — a "custom agent" built directly on a raw SDK gets no protection
13
+ // until someone ships a wrapper for that specific library.
14
+ //
15
+ // Patching global `fetch` (and `http`/`https` as a fallback for older
16
+ // transports) catches all of them in ONE place, before the request leaves the
17
+ // process, with no per-provider code and no dependency on Traceloop's
18
+ // internals. This module is installed from register.js, before any provider
19
+ // SDK is imported by the agent's own code — the same ordering guarantee the
20
+ // Vercel AI ESM loader already relies on.
21
+ //
22
+ // This does NOT cover the MCP proxy path (src/mcp-proxy.js): that spawns a
23
+ // separate child process and proxies its stdio, so patching fetch in THIS
24
+ // process has no effect on the child's own network calls. The proxy has its
25
+ // own, necessary check at the JSON-RPC `tools/call` boundary instead.
26
+
27
+ import http from "node:http";
28
+ import https from "node:https";
29
+
30
+ export class StratosBudgetBlockedError extends Error {
31
+ constructor(reason) {
32
+ super(`[stratos] request blocked: ${reason || "budget or lifecycle limit reached"}`);
33
+ this.name = "StratosBudgetBlockedError";
34
+ this.stratosBlocked = true;
35
+ }
36
+ }
37
+
38
+ // Cached from the heartbeat response (see index.js / mcp-proxy.js). Starts
39
+ // unblocked: an agent must never be frozen just because it hasn't heard back
40
+ // from Stratos yet — same fail-open philosophy as the server's own enforcement
41
+ // check (lib/quotaEnforce.ts: "fail open, loudly").
42
+ let _blocked = false;
43
+ let _reason = null;
44
+
45
+ export function setBlocked(blocked, reason) {
46
+ _blocked = !!blocked;
47
+ _reason = reason || null;
48
+ }
49
+
50
+ export function isBlocked() {
51
+ return { blocked: _blocked, reason: _reason };
52
+ }
53
+
54
+ // Hostnames of LLM/agent providers to guard. Extend without an SDK release via
55
+ // STRATOS_BLOCK_HOSTS (comma-separated substrings) — e.g. a private Azure
56
+ // OpenAI deployment hostname, or a self-hosted gateway.
57
+ const DEFAULT_PROVIDER_HOSTS = [
58
+ "api.openai.com",
59
+ "api.anthropic.com",
60
+ "generativelanguage.googleapis.com",
61
+ "aiplatform.googleapis.com",
62
+ "bedrock-runtime.", // *.amazonaws.com region-scoped, matched by substring
63
+ "api.cohere.ai",
64
+ "api.mistral.ai",
65
+ "openrouter.ai",
66
+ "api.together.xyz",
67
+ "api.groq.com",
68
+ "openai.azure.com",
69
+ ];
70
+
71
+ function providerHosts() {
72
+ const extra = (process.env.STRATOS_BLOCK_HOSTS || "")
73
+ .split(",")
74
+ .map((s) => s.trim())
75
+ .filter(Boolean);
76
+ return [...DEFAULT_PROVIDER_HOSTS, ...extra];
77
+ }
78
+
79
+ // Never guard Stratos's own host: the agent has to be able to reach Stratos to
80
+ // find out it has been unblocked. If this returned the wrong host the agent
81
+ // would deadlock itself — checked defensively, not just assumed.
82
+ function ownHost() {
83
+ try {
84
+ return new URL(process.env.STRATOS_URL || "http://localhost:4000").host;
85
+ } catch {
86
+ return "";
87
+ }
88
+ }
89
+
90
+ function hostFromInput(input) {
91
+ try {
92
+ if (typeof input === "string") return new URL(input).host;
93
+ if (input instanceof URL) return input.host;
94
+ if (input && typeof input === "object") {
95
+ if (input.url) return new URL(input.url).host; // Request-like
96
+ if (input.hostname || input.host) return String(input.hostname || input.host);
97
+ }
98
+ } catch {
99
+ // Not a parseable URL. Let it through — this guard only ever restricts
100
+ // requests to a known provider host, never anything it can't identify.
101
+ }
102
+ return "";
103
+ }
104
+
105
+ function isGuardedHost(host) {
106
+ if (!host) return false;
107
+ if (host === ownHost()) return false;
108
+ return providerHosts().some((h) => host.includes(h));
109
+ }
110
+
111
+ let installed = false;
112
+
113
+ /**
114
+ * Patch global fetch and http(s).request so a blocked agent cannot reach any
115
+ * known LLM provider, regardless of which SDK it is using. Idempotent — safe
116
+ * to call more than once, and safe to call when no provider SDK is present.
117
+ */
118
+ export function installNetworkGuard() {
119
+ if (installed) return;
120
+ installed = true;
121
+
122
+ if (typeof globalThis.fetch === "function" && !globalThis.fetch.__stratosGuarded) {
123
+ const realFetch = globalThis.fetch.bind(globalThis);
124
+ const guarded = function stratosGuardedFetch(input, init) {
125
+ if (isGuardedHost(hostFromInput(input))) {
126
+ const { blocked, reason } = isBlocked();
127
+ if (blocked) return Promise.reject(new StratosBudgetBlockedError(reason));
128
+ }
129
+ return realFetch(input, init);
130
+ };
131
+ guarded.__stratosGuarded = true;
132
+ globalThis.fetch = guarded;
133
+ }
134
+
135
+ patchRequestModule(http);
136
+ patchRequestModule(https);
137
+ }
138
+
139
+ /**
140
+ * Secondary guard for legacy http(s)-based clients — the fetch guard above is
141
+ * the primary path and already covers current OpenAI / Anthropic / Google /
142
+ * Vercel AI SDKs, which all use fetch on Node 18+. A synchronous throw here is
143
+ * non-standard for a network failure (Node normally emits an async 'error' on
144
+ * the returned request), but building a spec-compliant fake ClientRequest for
145
+ * a fallback path most agents never exercise is not worth the complexity —
146
+ * callers of http(s).request that care will have it in a try/catch already,
147
+ * since the real function can also throw synchronously for invalid options.
148
+ */
149
+ function patchRequestModule(mod) {
150
+ if (!mod || !mod.request || mod.request.__stratosGuarded) return;
151
+ const real = mod.request;
152
+ const guarded = function stratosGuardedRequest(...args) {
153
+ const opts = args.find((a) => a && typeof a === "object" && typeof a !== "function");
154
+ const host = opts ? String(opts.hostname || opts.host || "") : hostFromInput(args[0]);
155
+ if (isGuardedHost(host)) {
156
+ const { blocked, reason } = isBlocked();
157
+ if (blocked) throw new StratosBudgetBlockedError(reason);
158
+ }
159
+ return real.apply(mod, args);
160
+ };
161
+ guarded.__stratosGuarded = true;
162
+ mod.request = guarded;
163
+ }
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ import { readFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { enableVercelAiTelemetry } from "./vercel-ai.js";
18
18
  import { inspectLangChain } from "./inspect-langchain.js";
19
+ import { setBlocked } from "./enforcement.js";
19
20
 
20
21
  export { inspectLangChain, fixLangChain } from "./inspect-langchain.js";
21
22
 
@@ -50,21 +51,46 @@ function agentDeps() {
50
51
 
51
52
  function detectFramework() {
52
53
  if (process.env.STRATOS_FRAMEWORK) return process.env.STRATOS_FRAMEWORK;
54
+
53
55
  const deps = agentDeps();
54
- const has = (n) => n in deps || resolvableFromCwd(n) || resolvable(n);
55
- if (has("@google/adk") || has("google-adk") || has("@iqai/adk")) return "google-adk";
56
- if (has("@openai/agents")) return "openai-agents";
57
- if (has("llamaindex")) return "llamaindex";
58
- if (has("crewai")) return "crewai";
59
- if (has("@langchain/langgraph")) return "langgraph";
60
- if (has("langchain") || has("@langchain/core")) return "langchain";
61
- if (has("ai")) return "vercel-ai";
62
- if (has("@google/generative-ai") || has("@google/genai")) return "google-genai";
63
- if (has("@anthropic-ai/sdk")) return "anthropic";
64
- if (has("openai")) return "openai";
56
+ const inDeps = (n) => n in deps;
57
+ const isResolvable = (n) => resolvableFromCwd(n) || resolvable(n);
58
+
59
+ // 1. FIRST PASS: Check explicit dependencies declared in the user's package.json
60
+ if (inDeps("@google/adk") || inDeps("google-adk") || inDeps("@iqai/adk")) return "google-adk";
61
+ if (inDeps("@openai/agents")) return "openai-agents";
62
+ if (inDeps("llamaindex")) return "llamaindex";
63
+ if (inDeps("crewai")) return "crewai";
64
+
65
+ // If the user explicitly declared langgraph in package.json
66
+ if (inDeps("@langchain/langgraph")) return "langgraph";
67
+ // If the user declared langchain or @langchain/core
68
+ if (inDeps("langchain") || inDeps("@langchain/core")) return "langchain";
69
+
70
+ if (inDeps("ai")) return "vercel-ai";
71
+ if (inDeps("@google/generative-ai") || inDeps("@google/genai")) return "google-genai";
72
+ if (inDeps("@anthropic-ai/sdk")) return "anthropic";
73
+ if (inDeps("openai")) return "openai";
74
+
75
+ // 2. SECOND PASS: Fallback to node_modules inspection if package.json has no direct match
76
+ if (isResolvable("@google/adk") || isResolvable("google-adk") || isResolvable("@iqai/adk")) return "google-adk";
77
+ if (isResolvable("@openai/agents")) return "openai-agents";
78
+ if (isResolvable("llamaindex")) return "llamaindex";
79
+ if (isResolvable("crewai")) return "crewai";
80
+
81
+ // Prioritize langchain over internal transitive langgraph
82
+ if (isResolvable("langchain") || isResolvable("@langchain/core")) return "langchain";
83
+ if (isResolvable("@langchain/langgraph")) return "langgraph";
84
+
85
+ if (isResolvable("ai")) return "vercel-ai";
86
+ if (isResolvable("@google/generative-ai") || isResolvable("@google/genai")) return "google-genai";
87
+ if (isResolvable("@anthropic-ai/sdk")) return "anthropic";
88
+ if (isResolvable("openai")) return "openai";
89
+
65
90
  return null;
66
91
  }
67
92
 
93
+
68
94
  function detectModel(opts) {
69
95
  return (
70
96
  opts.model ||
@@ -112,6 +138,7 @@ export function start(opts = {}) {
112
138
  traceloop.initialize({
113
139
  appName,
114
140
  disableBatch: true,
141
+ logLevel: process.env.STRATOS_DEBUG ? "debug" : undefined,
115
142
  exporter: new OTLPTraceExporter({
116
143
  url: ingest,
117
144
  headers: { "x-stratos-token": token },
@@ -156,6 +183,13 @@ export function start(opts = {}) {
156
183
  tools: toolsEnv,
157
184
  };
158
185
 
186
+ // Heartbeat is also how the network guard (enforcement.js) learns whether to
187
+ // freeze this agent — budget exhausted, period crossed, or the agent
188
+ // manually disabled from the Control Tower all collapse into the same
189
+ // { blocked, reason } flag on the server, so the SDK does not need to know
190
+ // *why*, only that it is. The response used to be discarded entirely
191
+ // (`.catch(() => {})`, no `.then()`); reading it is the only change here.
192
+ let warnedBlocked = false;
159
193
  const ping = () =>
160
194
  fetch(heartbeatUrl, {
161
195
  method: "POST",
@@ -164,7 +198,23 @@ export function start(opts = {}) {
164
198
  "Content-Type": "application/json",
165
199
  },
166
200
  body: JSON.stringify(meta),
167
- }).catch(() => {});
201
+ })
202
+ .then((r) => r.json().catch(() => null))
203
+ .then((data) => {
204
+ if (!data || typeof data.blocked !== "boolean") return;
205
+ setBlocked(data.blocked, data.reason);
206
+ if (data.blocked && !warnedBlocked) {
207
+ warnedBlocked = true;
208
+ console.warn(
209
+ `[stratos] BLOCKED: ${data.reason || "budget or lifecycle limit reached"} — ` +
210
+ `outbound calls to LLM providers will be refused until this clears.`
211
+ );
212
+ } else if (!data.blocked && warnedBlocked) {
213
+ warnedBlocked = false;
214
+ console.log("[stratos] unblocked — outbound calls to LLM providers resumed.");
215
+ }
216
+ })
217
+ .catch(() => {});
168
218
  ping();
169
219
  if (heartbeatMs > 0) setInterval(ping, heartbeatMs).unref();
170
220
 
package/src/mcp-proxy.js CHANGED
@@ -23,6 +23,12 @@ console.log = console.info = (...a) => process.stderr.write(a.map(String).join("
23
23
 
24
24
  import { spawn } from "node:child_process";
25
25
  import { randomBytes } from "node:crypto";
26
+ // The network guard installed by register.js (see enforcement.js) patches
27
+ // THIS process's fetch/http — it has no effect on the target MCP server, which
28
+ // runs as a separate child process spawned below, possibly in a different
29
+ // language/runtime entirely. The only place budget/lifecycle enforcement can
30
+ // act on that child is here, at the JSON-RPC boundary between the host and it.
31
+ import { setBlocked, isBlocked } from "./enforcement.js";
26
32
 
27
33
  let argv = process.argv.slice(2);
28
34
  const sep = argv.indexOf("--");
@@ -65,9 +71,26 @@ function reportTool(name, argsObj, out, isErr, startMs, endMs) {
65
71
  fetch(`${URL}/api/ingest`, { method: "POST", headers: hdr, body: JSON.stringify(body) }).catch(() => {});
66
72
  }
67
73
 
74
+ let warnedBlocked = false;
68
75
  function heartbeat() {
69
76
  if (!TOKEN) return;
70
- fetch(`${URL}/api/heartbeat`, { method: "POST", headers: hdr }).catch(() => {});
77
+ fetch(`${URL}/api/heartbeat`, { method: "POST", headers: hdr })
78
+ .then((r) => r.json().catch(() => null))
79
+ .then((data) => {
80
+ if (!data || typeof data.blocked !== "boolean") return;
81
+ setBlocked(data.blocked, data.reason);
82
+ // Visible feedback in the proxy's own log (stderr — stdout is the
83
+ // protocol channel). Without this there is no way to see a block take
84
+ // effect other than a tool call failing with no explanation.
85
+ if (data.blocked && !warnedBlocked) {
86
+ warnedBlocked = true;
87
+ process.stderr.write(`[stratos-proxy] BLOCKED: ${data.reason || "budget or lifecycle limit reached"} — tools/call will be refused until this clears.\n`);
88
+ } else if (!data.blocked && warnedBlocked) {
89
+ warnedBlocked = false;
90
+ process.stderr.write("[stratos-proxy] unblocked — tool calls resumed.\n");
91
+ }
92
+ })
93
+ .catch(() => {});
71
94
  }
72
95
  heartbeat();
73
96
  const hbTimer = setInterval(heartbeat, HEARTBEAT_MS);
@@ -83,7 +106,24 @@ linePump(process.stdin, (line) => {
83
106
  let m; try { m = JSON.parse(line); } catch { child.stdin.write(line + "\n"); return; }
84
107
  if (m && m.id != null && m.method === "tools/call") {
85
108
  const name = (m.params && m.params.name) || "tool";
86
- pendingCalls.set(m.id, { name, args: (m.params && m.params.arguments) || {}, start: Date.now() });
109
+ const args = (m.params && m.params.arguments) || {};
110
+ const { blocked, reason } = isBlocked();
111
+ if (blocked) {
112
+ // Refuse before the child ever sees this call — do NOT forward to
113
+ // child.stdin. The host gets a normal JSON-RPC error response (not a
114
+ // dropped connection), and the refusal itself is still reported so it is
115
+ // visible on Stratos rather than silently vanishing.
116
+ const now = Date.now();
117
+ const errResp = {
118
+ jsonrpc: "2.0",
119
+ id: m.id,
120
+ error: { code: -32001, message: `Stratos: blocked — ${reason || "budget or lifecycle limit reached"}` },
121
+ };
122
+ process.stdout.write(JSON.stringify(errResp) + "\n");
123
+ reportTool(name, args, { blocked: true, reason: reason || null }, true, now, now);
124
+ return;
125
+ }
126
+ pendingCalls.set(m.id, { name, args, start: Date.now() });
87
127
  }
88
128
  child.stdin.write(line + "\n");
89
129
  });
package/src/register.js CHANGED
@@ -4,14 +4,19 @@
4
4
  //
5
5
  // Order matters:
6
6
  // 1) Load .env from cwd (so --import sees STRATOS_TOKEN before start)
7
- // 2) Register Vercel AI ESM loader (intercepts `import … from "ai"`)
8
- // 3) start() → Traceloop + OTLP export to Stratos
7
+ // 2) Install the network guard (see enforcement.js) — before ANY provider SDK
8
+ // (openai, @anthropic-ai/sdk, langchain, ai, …) has a chance to import and
9
+ // capture a reference to the unpatched global fetch/http/https.
10
+ // 3) Register Vercel AI ESM loader (intercepts `import … from "ai"`)
11
+ // 4) start() → Traceloop + OTLP export to Stratos, and the heartbeat that
12
+ // keeps the network guard's blocked/unblocked state up to date.
9
13
 
10
14
  import { register as registerModuleHook } from "node:module";
11
15
  import { pathToFileURL } from "node:url";
12
16
  import { dirname, join } from "node:path";
13
17
  import { fileURLToPath } from "node:url";
14
18
  import { existsSync } from "node:fs";
19
+ import { installNetworkGuard } from "./enforcement.js";
15
20
 
16
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
22
 
@@ -25,6 +30,17 @@ try {
25
30
  /* optional */
26
31
  }
27
32
 
33
+ // Must run before any consumer imports of a provider SDK — it patches the
34
+ // shared, global fetch/http/https that every one of them ultimately calls
35
+ // through, so a single install here covers openai/anthropic/langchain/ai/…
36
+ // without a per-library wrapper. See enforcement.js for why this is the
37
+ // network layer and not the SDK layer.
38
+ try {
39
+ installNetworkGuard();
40
+ } catch (err) {
41
+ console.warn("[stratos] could not install network guard:", err?.message || err);
42
+ }
43
+
28
44
  // Must run before any consumer imports of `ai`
29
45
  try {
30
46
  registerModuleHook(
@@ -14,6 +14,13 @@ import { TELEMETRY_CALL_NAMES } from "./vercel-ai-inject.js";
14
14
  const SHIM_URL = "stratos-sdk:ai";
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
16
  const INJECT_URL = pathToFileURL(join(__dirname, "vercel-ai-inject.js")).href;
17
+ // The network guard (enforcement.js, installed from register.js) already
18
+ // refuses these calls at the fetch layer once blocked — Vercel AI's own client
19
+ // uses fetch internally, same as everyone else. This second check is
20
+ // redundant with that but deliberately kept: it fails before Vercel AI builds
21
+ // its request/retry/streaming machinery, and the error reads as ours rather
22
+ // than as a generic fetch failure surfaced through their error wrapper.
23
+ const ENFORCEMENT_URL = pathToFileURL(join(__dirname, "enforcement.js")).href;
17
24
 
18
25
  function flagEnabled(name, defaultOn = true) {
19
26
  const v = process.env[name];
@@ -67,10 +74,15 @@ export async function load(url, context, nextLoad) {
67
74
  let source = "";
68
75
  source += `import * as __real from ${JSON.stringify(realUrl)};\n`;
69
76
  source += `import { injectTelemetryOptions } from ${JSON.stringify(INJECT_URL)};\n`;
77
+ source += `import { isBlocked } from ${JSON.stringify(ENFORCEMENT_URL)};\n`;
70
78
  source += `
71
79
  function __stratosWrap(fn) {
72
80
  if (typeof fn !== "function" || fn.__stratosWrapped) return fn;
73
81
  const wrapped = function stratosVercelAiWrap(opts, ...rest) {
82
+ const __b = isBlocked();
83
+ if (__b.blocked) {
84
+ throw new Error("[stratos] request blocked: " + (__b.reason || "budget or lifecycle limit reached"));
85
+ }
74
86
  return fn.call(this, injectTelemetryOptions(opts), ...rest);
75
87
  };
76
88
  wrapped.__stratosWrapped = true;