@intelli-1113/stratos-sdk 1.2.3 → 1.3.2

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,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stratos-inspect — check LangChain version compatibility for Stratos metrics.
4
+ *
5
+ * npx stratos-inspect
6
+ * npx stratos-inspect --json
7
+ * npx stratos-inspect --fix
8
+ * npx stratos-inspect --fix --yes
9
+ * npx stratos-inspect --fix --dry-run
10
+ */
11
+
12
+ import {
13
+ inspectLangChain,
14
+ fixLangChain,
15
+ printInspectReport,
16
+ } from "./inspect-langchain.js";
17
+
18
+ function parseArgs(argv) {
19
+ const flags = new Set(argv);
20
+ return {
21
+ json: flags.has("--json"),
22
+ fix: flags.has("--fix") || flags.has("--repair"),
23
+ yes: flags.has("--yes") || flags.has("-y"),
24
+ dryRun: flags.has("--dry-run"),
25
+ help: flags.has("--help") || flags.has("-h"),
26
+ };
27
+ }
28
+
29
+ function printHelp() {
30
+ console.log(`
31
+ stratos-inspect — LangChain compatibility for @intelli-1113/stratos-sdk
32
+
33
+ Usage:
34
+ stratos-inspect Inspect current project (cwd)
35
+ stratos-inspect --json Machine-readable report
36
+ stratos-inspect --fix Uninstall old LangChain (<1.0) and install 1.x
37
+ stratos-inspect --fix --yes Same, no confirmation prompt
38
+ stratos-inspect --fix --dry-run Show plan only
39
+
40
+ Why:
41
+ Stratos LangChain metrics need @langchain/core >=1.0.0 <2.0.0
42
+ (OpenLLMetry / @traceloop/instrumentation-langchain peer range).
43
+
44
+ Exit codes:
45
+ 0 compatible (or no LangChain) / fix succeeded
46
+ 1 incompatible or fix failed / cancelled
47
+ `);
48
+ }
49
+
50
+ async function main() {
51
+ const opts = parseArgs(process.argv.slice(2));
52
+ if (opts.help) {
53
+ printHelp();
54
+ process.exit(0);
55
+ }
56
+
57
+ const report = inspectLangChain();
58
+
59
+ if (!opts.fix) {
60
+ printInspectReport(report, { json: opts.json });
61
+ process.exit(report.compatible ? 0 : 1);
62
+ }
63
+
64
+ if (!opts.json) {
65
+ printInspectReport(report, { json: false });
66
+ }
67
+
68
+ const result = await fixLangChain({
69
+ yes: opts.yes,
70
+ dryRun: opts.dryRun,
71
+ });
72
+
73
+ if (opts.json) {
74
+ console.log(JSON.stringify(result, null, 2));
75
+ } else if (result.dryRun) {
76
+ console.log("[stratos-inspect] Dry run plan:");
77
+ console.log(JSON.stringify(result.plan, null, 2));
78
+ } else if (result.cancelled) {
79
+ console.log("[stratos-inspect] Cancelled — no changes made.");
80
+ } else if (result.skipped) {
81
+ console.log(`[stratos-inspect] Skipped (${result.reason}). ${result.message || ""}`);
82
+ } else if (result.ok) {
83
+ console.log("[stratos-inspect] Done. LangChain should now be on 1.x.");
84
+ if (result.reportAfter) printInspectReport(result.reportAfter);
85
+ } else {
86
+ console.error(`[stratos-inspect] Fix failed: ${result.message || result.step || "unknown"}`);
87
+ }
88
+
89
+ process.exit(result.ok || result.skipped ? 0 : 1);
90
+ }
91
+
92
+ main().catch((err) => {
93
+ console.error("[stratos-inspect]", err);
94
+ process.exit(1);
95
+ });
@@ -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
@@ -1,38 +1,58 @@
1
1
  // @intelli-1113/stratos-sdk — initialize OpenLLMetry and stream telemetry to Stratos AI.
2
2
  //
3
- // Config comes from the environment (so the ingest URL / token can change
4
- // without editing code or re-publishing the SDK):
5
- // STRATOS_TOKEN enrollment token from Stratos "Add agent" (required)
6
- // STRATOS_URL Stratos origin, e.g. https://stratos.lnt.com (default http://localhost:4000)
7
- // STRATOS_APP_NAME display name for this agent (optional)
3
+ // Config (environment):
4
+ // STRATOS_TOKEN enrollment token from Stratos "Add agent" (required)
5
+ // STRATOS_URL Stratos origin (default http://localhost:4000)
6
+ // STRATOS_APP_NAME display name for this agent (optional)
7
+ // STRATOS_VERCEL_AI set 0 to disable auto Vercel AI patching (default on)
8
+ // STRATOS_FRAMEWORK override auto framework detection (optional)
8
9
  //
9
- // Why this exists: traceloop defaults to OTLP *protobuf* at <baseUrl>/v1/traces;
10
- // Stratos ingests OTLP *JSON* at /api/ingest. This wires a JSON exporter to the
11
- // right URL and runs a heartbeat so the agent shows online while it's up.
10
+ // Vercel AI: clients only need this register entry (or --import). No
11
+ // experimental_telemetry in their generateText/streamText calls.
12
12
  import * as traceloop from "@traceloop/node-server-sdk";
13
13
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
14
14
  import { createRequire } from "node:module";
15
15
  import { readFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
+ import { enableVercelAiTelemetry } from "./vercel-ai.js";
18
+ import { inspectLangChain } from "./inspect-langchain.js";
19
+ import { setBlocked } from "./enforcement.js";
20
+
21
+ export { inspectLangChain, fixLangChain } from "./inspect-langchain.js";
17
22
 
18
23
  const _require = createRequire(import.meta.url);
19
- const resolvable = (pkg) => { try { _require.resolve(pkg); return true; } catch { return false; } };
24
+ const resolvable = (pkg) => {
25
+ try {
26
+ _require.resolve(pkg);
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ };
32
+ const resolvableFromCwd = (pkg) => {
33
+ try {
34
+ createRequire(join(process.cwd(), "package.json")).resolve(pkg);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ };
20
40
 
21
- // The agent's DIRECT dependencies (most reliable signal of the framework in use).
22
- // Resolution alone is unreliable — some packages (e.g. @google/adk) block default
23
- // resolution via "exports", and others appear only transitively.
24
41
  function agentDeps() {
25
42
  try {
26
- const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8"));
43
+ const pkg = JSON.parse(
44
+ readFileSync(join(process.cwd(), "package.json"), "utf8")
45
+ );
27
46
  return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
28
- } catch { return {}; }
47
+ } catch {
48
+ return {};
49
+ }
29
50
  }
30
51
 
31
- // Detect the agent framework from declared deps (preferred) or resolution — no manual input.
32
52
  function detectFramework() {
53
+ if (process.env.STRATOS_FRAMEWORK) return process.env.STRATOS_FRAMEWORK;
33
54
  const deps = agentDeps();
34
- const has = (n) => n in deps || resolvable(n);
35
- // Higher-level agent frameworks FIRST (several pull in @langchain/* transitively).
55
+ const has = (n) => n in deps || resolvableFromCwd(n) || resolvable(n);
36
56
  if (has("@google/adk") || has("google-adk") || has("@iqai/adk")) return "google-adk";
37
57
  if (has("@openai/agents")) return "openai-agents";
38
58
  if (has("llamaindex")) return "llamaindex";
@@ -45,24 +65,44 @@ function detectFramework() {
45
65
  if (has("openai")) return "openai";
46
66
  return null;
47
67
  }
48
- // Best-effort model from common env vars (or opts.model). Spans still refine it.
68
+
49
69
  function detectModel(opts) {
50
- return opts.model || process.env.STRATOS_MODEL || process.env.NVIDIA_MODEL || process.env.OPENAI_MODEL
51
- || process.env.ANTHROPIC_MODEL || process.env.GEMINI_MODEL || process.env.GOOGLE_MODEL
52
- || process.env.LLM_MODEL || process.env.MODEL || null;
70
+ return (
71
+ opts.model ||
72
+ process.env.STRATOS_MODEL ||
73
+ process.env.NVIDIA_MODEL ||
74
+ process.env.OPENAI_MODEL ||
75
+ process.env.ANTHROPIC_MODEL ||
76
+ process.env.GEMINI_MODEL ||
77
+ process.env.GOOGLE_MODEL ||
78
+ process.env.LLM_MODEL ||
79
+ process.env.MODEL ||
80
+ null
81
+ );
53
82
  }
54
83
 
55
84
  let started = false;
56
85
 
57
86
  export function start(opts = {}) {
58
- if (started) return; // idempotent — safe to import/register more than once
87
+ if (started) return;
59
88
  const token = opts.token || process.env.STRATOS_TOKEN || "";
60
- const origin = (opts.url || process.env.STRATOS_URL || "http://localhost:4000").replace(/\/+$/, "");
61
- const appName = opts.appName || process.env.STRATOS_APP_NAME || process.env.npm_package_name || "agent";
62
- const heartbeatMs = Number(opts.heartbeatMs || process.env.STRATOS_HEARTBEAT_MS || 30000);
89
+ const origin = (opts.url || process.env.STRATOS_URL || "http://localhost:4000").replace(
90
+ /\/+$/,
91
+ ""
92
+ );
93
+ const appName =
94
+ opts.appName ||
95
+ process.env.STRATOS_APP_NAME ||
96
+ process.env.npm_package_name ||
97
+ "agent";
98
+ const heartbeatMs = Number(
99
+ opts.heartbeatMs || process.env.STRATOS_HEARTBEAT_MS || 30000
100
+ );
63
101
 
64
102
  if (!token) {
65
- console.warn("[stratos] STRATOS_TOKEN not set — telemetry disabled. Add the token from Stratos > Add agent.");
103
+ console.warn(
104
+ "[stratos] STRATOS_TOKEN not set — telemetry disabled. Add the token from Stratos > Add agent."
105
+ );
66
106
  return;
67
107
  }
68
108
  started = true;
@@ -73,42 +113,98 @@ export function start(opts = {}) {
73
113
  traceloop.initialize({
74
114
  appName,
75
115
  disableBatch: true,
76
- exporter: new OTLPTraceExporter({ url: ingest, headers: { "x-stratos-token": token } }),
116
+ logLevel: process.env.STRATOS_DEBUG ? "debug" : undefined,
117
+ exporter: new OTLPTraceExporter({
118
+ url: ingest,
119
+ headers: { "x-stratos-token": token },
120
+ }),
77
121
  });
78
122
 
79
- // Auto-detected metadata sent on every heartbeat → Stratos auto-fills the
80
- // agent's framework/model/tools (no manual entry, no reliance on span quality).
81
- const toolsEnv = (opts.tools || (process.env.STRATOS_TOOLS || "").split(",")).map((s) => String(s).trim()).filter(Boolean);
82
- const meta = { framework: detectFramework(), model: detectModel(opts), tools: toolsEnv };
123
+ // LangChain version check (warn only — fix via: npx stratos-inspect --fix)
124
+ try {
125
+ const lc = inspectLangChain();
126
+ if (lc.hasLangChain && lc.needsAttention) {
127
+ console.warn(`[stratos] ${lc.message}`);
128
+ console.warn("[stratos] Fix: npx stratos-inspect --fix");
129
+ if (process.env.STRATOS_STRICT_LANGCHAIN === "1" && lc.needsUpgrade) {
130
+ throw new Error(
131
+ "Incompatible @langchain/core (<1.0). Upgrade with: npx stratos-inspect --fix --yes"
132
+ );
133
+ }
134
+ }
135
+ } catch (err) {
136
+ if (process.env.STRATOS_STRICT_LANGCHAIN === "1") throw err;
137
+ }
138
+
139
+ // Vercel AI v7 optional global register; v5/v6 covered by ESM loader in register.js
140
+ enableVercelAiTelemetry()
141
+ .then((r) => {
142
+ if (r.enabled) {
143
+ console.log(`[stratos] vercel-ai auto-telemetry: ${r.mode}`);
144
+ }
145
+ })
146
+ .catch(() => {});
147
+
148
+ const toolsEnv = (
149
+ opts.tools ||
150
+ (process.env.STRATOS_TOOLS || "").split(",")
151
+ )
152
+ .map((s) => String(s).trim())
153
+ .filter(Boolean);
154
+
155
+ const meta = {
156
+ framework: opts.framework || detectFramework(),
157
+ model: detectModel(opts),
158
+ tools: toolsEnv,
159
+ };
83
160
 
84
- // Keep the agent "online" while the process is alive (telemetry alone only
85
- // fires on LLM calls, which would let liveness lapse between requests).
161
+ // Heartbeat is also how the network guard (enforcement.js) learns whether to
162
+ // freeze this agent — budget exhausted, period crossed, or the agent
163
+ // manually disabled from the Control Tower all collapse into the same
164
+ // { blocked, reason } flag on the server, so the SDK does not need to know
165
+ // *why*, only that it is. The response used to be discarded entirely
166
+ // (`.catch(() => {})`, no `.then()`); reading it is the only change here.
167
+ let warnedBlocked = false;
86
168
  const ping = () =>
87
169
  fetch(heartbeatUrl, {
88
170
  method: "POST",
89
- headers: { "x-stratos-token": token, "Content-Type": "application/json" },
171
+ headers: {
172
+ "x-stratos-token": token,
173
+ "Content-Type": "application/json",
174
+ },
90
175
  body: JSON.stringify(meta),
91
- }).catch(() => {});
176
+ })
177
+ .then((r) => r.json().catch(() => null))
178
+ .then((data) => {
179
+ if (!data || typeof data.blocked !== "boolean") return;
180
+ setBlocked(data.blocked, data.reason);
181
+ if (data.blocked && !warnedBlocked) {
182
+ warnedBlocked = true;
183
+ console.warn(
184
+ `[stratos] BLOCKED: ${data.reason || "budget or lifecycle limit reached"} — ` +
185
+ `outbound calls to LLM providers will be refused until this clears.`
186
+ );
187
+ } else if (!data.blocked && warnedBlocked) {
188
+ warnedBlocked = false;
189
+ console.log("[stratos] unblocked — outbound calls to LLM providers resumed.");
190
+ }
191
+ })
192
+ .catch(() => {});
92
193
  ping();
93
194
  if (heartbeatMs > 0) setInterval(ping, heartbeatMs).unref();
94
195
 
95
- // disableBatch exports each span the moment it ends via an async HTTP POST —
96
- // fine for long-running servers, but a one-shot script (no open server/handle)
97
- // drains the event loop and exits the instant it's done, killing that request
98
- // mid-flight before it reaches Stratos. 'beforeExit' fires exactly at that point
99
- // (and, unlike 'exit', still lets us start async work), so flushing there makes
100
- // every consumer — script or server — deliver its spans with zero code changes.
101
196
  process.on("beforeExit", () => {
102
197
  traceloop.forceFlush().catch(() => {});
103
198
  });
104
- // Also flush on Ctrl+C / kill so a killed server doesn't lose its last spans.
105
199
  for (const sig of ["SIGINT", "SIGTERM"]) {
106
200
  process.on(sig, () => {
107
201
  traceloop.forceFlush().finally(() => process.exit(0));
108
202
  });
109
203
  }
110
204
 
111
- console.log(`[stratos] telemetry → ${ingest} (heartbeat ${heartbeatMs}ms, framework=${meta.framework || "?"})`);
205
+ console.log(
206
+ `[stratos] telemetry → ${ingest} (heartbeat ${heartbeatMs}ms, framework=${meta.framework || "?"})`
207
+ );
112
208
  }
113
209
 
114
210
  export default { start };