
Security News
Happy Birthday, Shai-Hulud
It has been one year since Shai-Hulud made its first appearance on npm.
@argosvix/sdk
Advanced tools
Drop-in observability wrapper for OpenAI, Anthropic, Gemini, Mistral SDK clients. One-line wrap() = cost / latency / quality records.
Transparent observability wrapper for AI provider SDKs. Wrap a single line of code and get cost, latency, token, and error records for every LLM call across OpenAI, Anthropic, Gemini, Mistral, xAI Grok, Moonshot Kimi, and DeepSeek.
๐ข Alpha released โ backend ingest (
ingest.argosvix.com) and dashboard (dashboard.argosvix.com) are live. Published on npm as@argosvix/sdk. The entire sign-up to API-key flow runs in the browser; a Free plan and paid plans (Pro / Team) are available.Design principles:
- No end-user PII is sent. By default, prompt and completion bodies are never recorded; only token counts, cost, latency, and error codes leave your process. Content capture is a separate opt-in (
captureContent, see below) and always applies PII masking before send.- Idempotent wrap. Wrapping the same client twice is a no-op; we track instances via WeakMap / WeakSet.
- No global monkey-patching. Only the client argument you pass in is mutated; the global namespace is untouched.
| Provider | Hook | Streaming | Notes |
|---|---|---|---|
| OpenAI chat.completions | โ | โ | chat.completions.create |
| OpenAI responses | โ | warn + skip (planned) | responses.create |
| Anthropic | โ | โ | messages.create (accumulates message_start + message_delta) |
| Mistral | โ | โ | chat.complete + chat.stream |
Gemini legacy (@google/generative-ai) | โ | โ | getGenerativeModel({...}).generateContent / generateContentStream |
Gemini current (@google/genai) | โ | โ | client.models.generateContent / generateContentStream |
| xAI Grok | โ | โ | Through the OpenAI-compatible endpoint. Detected from baseURL (api.x.ai), or set provider: "xai" explicitly |
| Moonshot Kimi | โ | โ | Through the OpenAI-compatible endpoint. Detected from baseURL (api.moonshot.ai / .cn), or set provider: "moonshot" |
| DeepSeek | โ | โ | Through the OpenAI-compatible endpoint. Detected from baseURL (api.deepseek.com), or set provider: "deepseek" |
Grok, Kimi, and DeepSeek reuse the OpenAI wrapper because they serve an
OpenAI-compatible API. You wrap the same OpenAI client you already have; the
record carries the real provider so cost is priced with that provider's rates.
npm install @argosvix/sdk openai
# Also install the SDKs for any other providers you use (Anthropic, Gemini, Mistral).
# Grok / Kimi / DeepSeek need no extra SDK โ they use the OpenAI client.
argk_โฆ) is displayed once at creation โ copy and store it safely. You can create additional keys or revoke a key from the same page at any time.Alternatively, run npx @argosvix/cli init โ it opens the browser for a one-time approval, issues a least-privilege key, and writes it to .env for you.
export ARGOSVIX_API_KEY=argk_...
# Default ingest endpoint = https://ingest.argosvix.com
# Override with ARGOSVIX_API_BASE if you route requests through a proxy.
import OpenAI from "openai";
import { wrap, getRecorder } from "@argosvix/sdk";
const client = wrap(new OpenAI(), {
apiKey: process.env.ARGOSVIX_API_KEY,
tags: { service: "support-bot", env: "production" },
});
// Use the wrapped client exactly like the original.
const response = await client.chat.completions.create({
model: "gpt-5.5",
messages: [{ role: "user", content: "Hello" }],
});
// Optional: graceful flush before process exit.
const recorder = getRecorder(client);
if (recorder) await recorder.flush();
Each call automatically records:
promptTokens / completionTokens, camelCase).models/gemini-2.0-flash are normalized to their terminal model name.tags: { service: "X", env: "prod" }).statusCode, code, type, retryAfter).Not recorded by default: prompt bodies, completion bodies, system messages, or tool-call argument bodies. Only the metadata required for aggregation is sent. To record bodies as well, see Content capture (opt-in) below.
By default only metadata leaves your process. Set captureContent: true to also record prompt and completion bodies (and tool-call arguments / results) โ useful for quality review, eval datasets, and debugging:
const client = wrap(new OpenAI(), {
apiKey: process.env.ARGOSVIX_API_KEY,
captureContent: true,
});
[REDACTED_*] inside your process, before the record leaves it. (disablePiiRedaction: true turns the filter off โ at your own risk.)See https://argosvix.com/en/docs/sdk-reference for details.
These runtimes kill any outstanding fire-and-forget fetch the moment the handler returns. The SDK normally buffers records in memory and flushes them only when bufferMaxSize is reached (default 100), so a single request with a few LLM calls would lose its records on context exit.
Mitigation: await flushClient(client) in the handler's finally block.
import Anthropic from "@anthropic-ai/sdk";
import { wrap, flushClient } from "@argosvix/sdk";
export default {
async scheduled(_event, env) {
const client = wrap(new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }), {
apiKey: env.ARGOSVIX_API_KEY,
});
try {
const res = await client.messages.create({
model: "claude-opus-4-1",
max_tokens: 200,
messages: [{ role: "user", content: "..." }],
});
// ...
} finally {
await flushClient(client); // Required: guarantees backend delivery before exit.
}
},
};
Long-running Node processes (Express, Next.js dev server, classic servers) do not need this โ the buffer auto-flushes well before process exit.
Observability tells you what an agent did. The runtime control plane lets a human bound what an agent can do โ enforced inside your process, configured from the dashboard / chat / MCP without touching agent code.
There are three gates. The first two are enforced inside wrap(...) by evaluating
cached backend config locally (no proxy, no per-call round trip). The third is an
explicit request/await pair around a sensitive operation.
Full usage details live in the docs: https://argosvix.com/en/docs/sdk-reference.
Opt in with budgetGate: true. Before each wrapped LLM call, the SDK evaluates the
account's monthly limit + spend (cached, TTL from the backend, default 60s). Over the
limit throws ArgosvixBudgetExceededError before the provider request โ so a runaway
loop can't burn the budget.
import { wrap, ArgosvixBudgetExceededError } from "@argosvix/sdk";
const openai = wrap(new OpenAI(), {
apiKey: process.env.ARGOSVIX_API_KEY,
budgetGate: true,
});
try {
await openai.chat.completions.create({ model: "gpt-5-mini", messages });
} catch (err) {
if (err instanceof ArgosvixBudgetExceededError) {
// err.spentUsd / err.limitUsd โ blocked before reaching the provider
}
}
The gate is configured from the dashboard / chat ("create a $5/month budget gate"). On an
account with no gate it's a complete no-op. enforceMode: "fail_open" (default) lets
calls through when the backend is unreachable; "fail_closed" blocks while the cached
state is stale (ArgosvixBudgetGateUnavailableError). Set budgetGateFailClosed: true
to also block before the first successful fetch.
Budget-gate accuracy: the gate's local spend correction is best-effort. It can only account for calls whose cost the SDK can compute. Calls with an unknown model, or calls that error before returning usage, are recorded at $0 and therefore do not accrue toward the local cap between server refreshes. For streaming, the SDK auto-enables OpenAI usage reporting (see Cost accuracy below) so streamed spend is counted.
stream_options.include_usage for
streamed calls so token usage and cost are captured (OpenAI omits usage from streams
otherwise). The extra usage-only chunk OpenAI emits is consumed internally and not
yielded to your code, so your stream shape is unchanged. If you set include_usage
yourself, the SDK respects it and passes the chunk through.Opt in with policyGate: true (shares the same config fetch). Before each call the SDK
checks the request payload against the account's policy โ model allowlist, PII block,
secret block โ and throws ArgosvixPolicyViolationError on a hit. The scan runs in your
process; only the field path + a masked snippet are surfaced, never the plaintext.
import { wrap, ArgosvixPolicyViolationError } from "@argosvix/sdk";
const openai = wrap(new OpenAI(), {
apiKey: process.env.ARGOSVIX_API_KEY,
policyGate: true,
});
// err.reason is "model_not_allowed" | "pii_detected" | "secret_detected"
For destructive or irreversible operations (delete / refund / send), request a human
approval and wait. The owner/admins get an email; a human approves in the dashboard
/approvals or via the email link. API keys cannot approve โ the agent can't
self-approve. Anything other than "approved" (denied / expired / timeout) is
default-deny.
import { requestApproval, waitForApproval } from "@argosvix/sdk";
const approval = await requestApproval({
apiKey: process.env.ARGOSVIX_API_KEY!,
action: "delete_user",
summary: "Delete inactive user usr_123 (90 days no login)",
timeoutSeconds: 3600,
});
const status = await waitForApproval({ apiKey, id: approval.id });
if (status !== "approved") return; // default-deny
// ...perform the operation...
When auto-detection is ambiguous (e.g. for composite or adapter clients), set config.provider explicitly:
const client = wrap(myCompositeClient, { provider: "openai" });
ai package)If your app calls models through the Vercel AI SDK (generateText / streamText /
generateObject), wrap the model with argosvixMiddleware() instead of wrapping a provider
client. Every call made through the AI SDK โ including calls issued internally by the AI SDK
(tool loops, multi-step agents) โ is then recorded: provider, model, tokens, cost, latency,
TTFT (streaming), cache and reasoning tokens.
import { openai } from "@ai-sdk/openai";
import { wrapLanguageModel, generateText } from "ai";
import { argosvixMiddleware, flushClient } from "@argosvix/sdk";
const observed = argosvixMiddleware({ apiKey: process.env.ARGOSVIX_API_KEY });
const model = wrapLanguageModel({
model: openai("gpt-5.5"),
middleware: observed,
});
try {
await generateText({ model, prompt: "Summarize this PR." });
} finally {
// Short-lived runtimes (Workers / Lambda / Edge): flush before the handler returns.
await flushClient(observed); // or: await observed.flush()
}
Notes:
wrap() (OpenAI / Anthropic / Google Gemini /
Mistral). The middleware maps the AI SDK provider id (openai.chat, anthropic.messages,
google.generative-ai, mistral.chat, Azure OpenAI) to the right pricing table. Calls
through other providers run normally but are not recorded โ pass config.provider to force a
mapping.ai@5 (LanguageModelV2) and ai@6 (V4) middleware shapes, and both the old
(cachedInputTokens) and new (inputTokenDetails.cacheReadTokens) usage layouts.config.budgetGate / config.policyGate) and automatic trace grouping
(withTrace) apply here exactly as with wrap().finish).If your app uses LangChain.js, attach argosvixLangChainHandler() as a callback. Every LLM call
made through LangChain โ including calls issued inside chains and agents โ is recorded: provider,
model, tokens, cost, latency, TTFT (streaming), cache and reasoning tokens.
import { ChatOpenAI } from "@langchain/openai";
import { argosvixLangChainHandler, flushClient } from "@argosvix/sdk";
const handler = argosvixLangChainHandler({ apiKey: process.env.ARGOSVIX_API_KEY });
const model = new ChatOpenAI({ model: "gpt-5.5" });
try {
// Per-call:
await model.invoke("Summarize this PR.", { callbacks: [handler] });
// Or attach once at construction: new ChatOpenAI({ ..., callbacks: [handler] });
} finally {
await flushClient(handler); // or: await handler.flush()
}
Notes:
wrap() (OpenAI / Anthropic / Google Gemini /
Mistral). The handler maps the LangChain model class (e.g. ChatOpenAI, ChatAnthropic,
ChatGoogleGenerativeAI, ChatMistralAI) to the right pricing table. Other providers run
normally but are not recorded โ pass config.provider to force a mapping.usage_metadata on the message (with llmOutput.tokenUsage
as a fallback), so cache/reasoning breakdowns are captured where the provider reports them.withTrace) apply here too.Wrap a unit of work in withTrace and every wrapped LLM call inside it is grouped under one
trace automatically โ no manual traceId threading. Each call gets its own span, so chained
agent steps nest in the dashboard's trace view.
import { wrap, withTrace } from "@argosvix/sdk";
const client = wrap(openai);
await withTrace(async () => {
// both calls share one auto-generated traceId; each is its own span
await client.chat.completions.create({ model: "gpt-5.5", messages: [...] });
await client.chat.completions.create({ model: "gpt-5.5", messages: [...] });
});
config.traceId > ambient withTrace > none. Calls made outside any
withTrace behave exactly as before. Opt out with wrap(client, { autoContext: false }).AsyncLocalStorage, resolved without a static node:async_hooks import, so it
never breaks Edge/browser bundles; it self-disables (calls still record) where unavailable
โ on Cloudflare Workers enable the nodejs_als compatibility flag to turn it on.Use withSpan to record non-LLM steps (retrieval / tool / agent / chain) and nest the LLM
calls inside them โ your trace then shows the full agent tree, not just the generations:
import { withTrace, withSpan } from "@argosvix/sdk";
await withTrace(async () => {
const docs = await withSpan("retrieval", "vector_search", async () => search(query));
// this generation nests under the retrieval span
await client.chat.completions.create({ model: "gpt-5.5", messages: buildPrompt(docs) });
});
withSpan records the step's latency/status/error automatically. Keep metadata to
non-sensitive structured attributes (counts, sizes) โ don't put raw documents or args there.
resolvePrompt / withPrompt)If you manage prompts in Argosvix (prompt registry + deployments), resolvePrompt fetches the
currently deployed version of a prompt at runtime, and withPrompt tags every wrapped LLM call
inside it with prompt: {name}@v{version} โ so quality and cost can be compared per prompt
version in the dashboard:
import { resolvePrompt, withPrompt } from "@argosvix/sdk";
const p = await resolvePrompt("support-bot", { apiKey: process.env.ARGOSVIX_API_KEY! });
// p.template = prompt body ยท p.version = deployed version ยท p.tag = "support-bot@v3"
await withPrompt(p, async () => {
// this call is tagged prompt:support-bot@v3 automatically
await client.chat.completions.create({
model: "gpt-5.5",
messages: [{ role: "system", content: p.template }, { role: "user", content: input }],
});
});
resolvePrompt(name, options) resolves the current version for a deploy label (default
"production", override with options.label). Results are cached in-memory with a
60-second TTL (cacheTtlMs, 0 disables), so it is safe on the hot path.withPrompt accepts the resolvePrompt result, a { name, version } object, or a raw
tag string. An explicit tags.prompt on the call wins over the ambient tag.Tags are persisted per record. The backend dashboard supports cross-dimension aggregation such as "cost trend for service=support-bot" (Phase C dashboard and beyond).
const stream = await client.chat.completions.create({
model: "gpt-5.5",
messages: [...],
stream: true,
});
for await (const chunk of stream) {
// Consume as a normal AsyncIterable.
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
// A record is POSTed to the backend once the stream completes.
Integration guides for common stacks (Next.js App Router, Express with streaming and SIGTERM flush, AWS Lambda with @google/genai, Cloudflare Workers with the flushClient pattern) are available in the docs: https://argosvix.com/en/docs/guides.
Argosvix can deliver alert notifications to any HTTPS endpoint of your choice (in addition to email and Slack). Configure a webhook channel from https://dashboard.argosvix.com/en/alerts; this section documents the public contract so you can implement a receiver.
POSTapplication/jsonfetch redirect: "error")| Header | Value |
|---|---|
User-Agent | Argosvix-Webhook/1.0 |
X-Argosvix-Event | alert.triggered (only event type for now) |
X-Argosvix-Signature | sha256=<hex> โ present only when a signing secret is configured |
X-Argosvix-Timestamp | ISO-8601 UTC โ present only when a signing secret is configured |
{
"alertId": "01HXZ...",
"name": "monthly cost > $50",
"alertType": "monthly_budget", // cost_threshold | error_rate | latency_degradation | monthly_budget
"thresholdValue": 50,
"observedValue": 64.20,
"windowMinutes": 60,
"windowDescription": "60 min",
"filterProvider": "openai", // or null
"filterModel": null, // or string
"comparison": "exceeded threshold",
"triggeredAt": "2026-05-25T10:23:45.678Z",
"dashboardUrl": "https://dashboard.argosvix.com/en/alerts?alertId=01HXZ..."
}
thresholdValue and observedValue units are USD for cost alerts, percent for error_rate, and milliseconds for latency_degradation. Schema is stable: new fields may be added (additive only); existing fields keep their names and semantics.
When a signing secret is configured, the signature header is computed as:
X-Argosvix-Signature = "sha256=" + hex(HMAC_SHA256(secret, X-Argosvix-Timestamp + "." + body))
Reject any request whose signature does not match (and optionally drop requests with a timestamp older than your replay window).
// Node 18+ โ verifyArgosvixSignature.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyArgosvixSignature(
secret: string,
body: string, // raw request body string
signatureHeader: string, // "sha256=<hex>"
timestampHeader: string, // ISO-8601 string
maxAgeMs = 5 * 60 * 1000,
): boolean {
const ts = Date.parse(timestampHeader);
if (Number.isNaN(ts) || Math.abs(Date.now() - ts) > maxAgeMs) return false;
const expected = "sha256=" +
createHmac("sha256", secret).update(`${timestampHeader}.${body}`).digest("hex");
const a = Buffer.from(signatureHeader, "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
# Python 3 โ verify_argosvix_signature.py
import hashlib, hmac, time
from datetime import datetime, timezone
def verify_argosvix_signature(secret: str, body: str,
signature_header: str, timestamp_header: str,
max_age_seconds: int = 300) -> bool:
try:
ts = datetime.fromisoformat(timestamp_header.replace("Z", "+00:00"))
except ValueError:
return False
if abs(time.time() - ts.timestamp()) > max_age_seconds:
return False
expected = "sha256=" + hmac.new(
secret.encode(), f"{timestamp_header}.{body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature_header, expected)
Tag keys used in queries (channelTargets.webhook.url request paths, dashboard filters, etc.) must match ^[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,62}[A-Za-z0-9_])?$ โ alphanumeric plus _ -, no leading/trailing hyphen, 1โ64 characters. Sticking to this in the SDK tags: { ... } map keeps every downstream filter / aggregation usable.
Hostname validation happens at registration and at delivery time, but Cloudflare Workers does not expose connect-level IP control, so an attacker-controlled DNS that flips a public name to an RFC1918 / metadata IP between checks can still bypass the policy. Treat webhook receivers as part of your trust boundary; do not host them inside private networks that your perimeter would otherwise protect.
wrap<T>(client: T, config?: ArgosvixConfig): TTransparently wraps an AI SDK client. Returns the same client (mutated) so the call-site shape is unchanged.
getRecorder(client: object): Recorder | nullRetrieves the Recorder instance associated with a wrapped client.
flushClient(client: object): Promise<void>Helper for short-lived runtimes (Cloudflare Workers, AWS Lambda, Vercel Edge). Awaiting this in a finally block explicitly flushes the SDK buffer and waits for backend delivery. Failures are logged via console.error only โ never thrown โ to avoid breaking the host application.
class Recorderrecord(record: LlmCallRecord): voidflush(): Promise<LlmCallRecord[]> โ POSTs the buffer to the backend. Retries on 5xx and network errors; 4xx errors are not retried. On 429 (monthly ingest quota reached) the SDK logs a warning once per process, so records don't stop silently at the end of the month.getBufferSize(): numberresolvePrompt(name: string, options: ResolvePromptOptions): Promise<ResolvedPrompt>Fetches the currently deployed version of a registered prompt (60 s TTL cache + stale fallback on backend failure). See "Deployed prompts" above.
withPrompt(prompt, fn)Runs fn with an ambient prompt tag; every wrapped call inside gets tags.prompt = "{name}@v{version}" automatically.
calculateCost(provider, model, promptTokens, completionTokens): numberInternal pricing calculator returning USD. Exported for unit tests and custom-pricing experimentation. Resource-prefixed model names (e.g. models/gemini-2.0-flash) are looked up by their terminal model name.
Provider ยท ArgosvixConfig ยท LlmCallRecord ยท PricingEntry ยท ResolvedPrompt ยท ResolvePromptOptions
MIT
FAQs
Drop-in observability wrapper for OpenAI, Anthropic, Gemini, Mistral SDK clients. One-line wrap() = cost / latency / quality records.
The npm package @argosvix/sdk receives a total of 88 weekly downloads. As such, @argosvix/sdk popularity was classified as not popular.
We found that @argosvix/sdk demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago.ย It has 1 open source maintainer collaborating on the project.

Security News
It has been one year since Shai-Hulud made its first appearance on npm.

Research
/Security News
Operators behind PolinRider used a compromised GitHub account to plant malware in four development versions of a Packagist package with 700,000+ downloads.

Security News
GitHub Actions now supports cache-mode, a least-privilege control on the Actions cache aimed at the cache poisoning technique behind recent compromises.