
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, DeepSeek, Alibaba Qwen, and Meta Muse Spark.
🟢 Live — 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" |
| Alibaba Qwen | ✅ | ✅ | Through the OpenAI-compatible endpoint. Detected from baseURL (dashscope.aliyuncs.com / dashscope-intl.aliyuncs.com), or set provider: "alibaba" |
| Meta Muse Spark | ✅ | ✅ | Through the OpenAI-compatible endpoint. Detected from baseURL (api.meta.ai), or set provider: "meta" |
Grok, Kimi, DeepSeek, Qwen, and Muse Spark 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 / Qwen / Muse Spark 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.
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.