@rheonic/sdk 0.1.0-beta.6 → 0.1.0-beta.8

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 CHANGED
@@ -1,12 +1,18 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to `rheonic-node` will be documented in this file.
3
+ All notable changes to `@rheonic/sdk` will be documented in this file.
4
4
 
5
5
  ## Unreleased
6
6
 
7
7
  - Publish-ready changelog entries will be added here for the next release.
8
8
 
9
- ## 0.1.0
9
+ ## 0.1.0-beta.7
10
+
11
+ ### Changed
12
+ - `RHEONICBlockedError` now exposes structured block feedback for apps and agents: `reason`, `retry_after_seconds`, `blocked_until`, `trace_id`, `request_id`, and `snapshot`.
13
+ - Fail-closed protect fallback now reports `reason="fail_closed"` instead of the generic `decision_unavailable` on blocked requests.
14
+
15
+ ## 0.1.0-beta.6
10
16
 
11
17
  ### Added
12
18
  - Initial public beta release of the Rheonic Node SDK.
package/README.md CHANGED
@@ -48,7 +48,19 @@ try {
48
48
  });
49
49
  } catch (error) {
50
50
  if (error instanceof RHEONICBlockedError) {
51
- console.log("Blocked by protect preflight");
51
+ console.log(
52
+ JSON.stringify(
53
+ {
54
+ reason: error.reason,
55
+ retry_after_seconds: error.retry_after_seconds,
56
+ blocked_until: error.blocked_until,
57
+ trace_id: error.trace_id,
58
+ request_id: error.request_id,
59
+ },
60
+ null,
61
+ 2,
62
+ ),
63
+ );
52
64
  }
53
65
  }
54
66
  ```
@@ -74,7 +86,13 @@ try {
74
86
  });
75
87
  } catch (error) {
76
88
  if (error instanceof RHEONICBlockedError) {
77
- console.log("Blocked by protect preflight");
89
+ console.log(JSON.stringify({
90
+ reason: error.reason,
91
+ retry_after_seconds: error.retry_after_seconds,
92
+ blocked_until: error.blocked_until,
93
+ trace_id: error.trace_id,
94
+ request_id: error.request_id,
95
+ }, null, 2));
78
96
  }
79
97
  }
80
98
  ```
@@ -97,11 +115,25 @@ try {
97
115
  await model.generateContent("hello");
98
116
  } catch (error) {
99
117
  if (error instanceof RHEONICBlockedError) {
100
- console.log("Blocked by protect preflight");
118
+ console.log(JSON.stringify({
119
+ reason: error.reason,
120
+ retry_after_seconds: error.retry_after_seconds,
121
+ blocked_until: error.blocked_until,
122
+ trace_id: error.trace_id,
123
+ request_id: error.request_id,
124
+ }, null, 2));
101
125
  }
102
126
  }
103
127
  ```
104
128
 
129
+ `RHEONICBlockedError.reason` is meant to be operator-relevant. The main values are:
130
+ - `tok_cap_breach`
131
+ - `req_cap_breach`
132
+ - `cooldown_active`
133
+ - `fail_closed`
134
+
135
+ If Protect is `fail_open`, timeout or availability problems stay internal and the provider call continues. If Protect is `fail_closed`, the SDK raises `RHEONICBlockedError` with the feedback fields shown above.
136
+
105
137
  Keep one long-lived SDK client per app process. Initialize it during app startup and reuse it for all capture and instrumentation calls so Rheonic can avoid repeated protect cold-start latency.
106
138
 
107
139
  ## Optional: custom event capture
package/dist/client.d.ts CHANGED
@@ -42,6 +42,7 @@ export declare class Client {
42
42
  private warmupCompleted;
43
43
  constructor(config: ClientConfig);
44
44
  captureEvent(event: EventPayload): Promise<void>;
45
+ captureEventAndFlush(event: EventPayload): Promise<void>;
45
46
  getStats(): ClientStats;
46
47
  flush(): Promise<void>;
47
48
  evaluateProtectDecision(context: ProtectContext): Promise<ProtectEvaluation>;
package/dist/client.js CHANGED
@@ -98,6 +98,10 @@ export class Client {
98
98
  return;
99
99
  }
100
100
  }
101
+ async captureEventAndFlush(event) {
102
+ await this.captureEvent(event);
103
+ await this.flushWithTimeout();
104
+ }
101
105
  getStats() {
102
106
  return {
103
107
  queued: this.queue.length,
@@ -10,6 +10,10 @@ export interface ProtectContext {
10
10
  export interface ProtectEvaluation {
11
11
  decision: ProtectDecision;
12
12
  reason: string;
13
+ trace_id: string;
14
+ request_id: string;
15
+ blocked_until?: string;
16
+ retry_after_seconds?: number;
13
17
  snapshot?: Record<string, unknown>;
14
18
  applyClampEnabled?: boolean;
15
19
  clamp?: {
@@ -19,7 +23,19 @@ export interface ProtectEvaluation {
19
23
  }
20
24
  export declare class RHEONICBlockedError extends Error {
21
25
  readonly reason: string;
22
- constructor(reason: string);
26
+ readonly trace_id: string;
27
+ readonly request_id: string;
28
+ readonly blocked_until?: string;
29
+ readonly retry_after_seconds?: number;
30
+ readonly snapshot?: Record<string, unknown>;
31
+ constructor(params: {
32
+ reason: string;
33
+ trace_id: string;
34
+ request_id: string;
35
+ blocked_until?: string;
36
+ retry_after_seconds?: number;
37
+ snapshot?: Record<string, unknown>;
38
+ });
23
39
  }
24
40
  export declare class ProtectEngine {
25
41
  private readonly baseUrl;
@@ -41,6 +57,7 @@ export declare class ProtectEngine {
41
57
  debugLog?: (message: string, meta?: Record<string, unknown>) => void;
42
58
  });
43
59
  evaluate(context: ProtectContext): Promise<ProtectEvaluation>;
60
+ private fallbackEvaluation;
44
61
  bootstrap(): Promise<void>;
45
62
  private reportDecisionTimeout;
46
63
  private reportDecisionUnavailable;
@@ -4,10 +4,20 @@ import { requestJson } from "./httpTransport.js";
4
4
  import { bindTraceContext, generateSpanId, generateTraceId, getTraceId } from "./logger.js";
5
5
  export class RHEONICBlockedError extends Error {
6
6
  reason;
7
- constructor(reason) {
8
- super(`Request blocked by Rheonic: ${reason}`);
7
+ trace_id;
8
+ request_id;
9
+ blocked_until;
10
+ retry_after_seconds;
11
+ snapshot;
12
+ constructor(params) {
13
+ super(`Request blocked by Rheonic: ${params.reason}`);
9
14
  this.name = "RHEONICBlockedError";
10
- this.reason = reason;
15
+ this.reason = params.reason;
16
+ this.trace_id = params.trace_id;
17
+ this.request_id = params.request_id;
18
+ this.blocked_until = params.blocked_until;
19
+ this.retry_after_seconds = params.retry_after_seconds;
20
+ this.snapshot = params.snapshot;
11
21
  }
12
22
  }
13
23
  export class ProtectEngine {
@@ -35,6 +45,8 @@ export class ProtectEngine {
35
45
  this.cooldownReason = null;
36
46
  }
37
47
  async evaluate(context) {
48
+ const requestId = randomUUID();
49
+ const traceId = generateTraceId();
38
50
  const nowMs = Date.now();
39
51
  if (this.cooldownUntilMs !== null && nowMs < this.cooldownUntilMs) {
40
52
  this.debugLog?.("Protect preflight blocked locally from cached cooldown", {
@@ -42,15 +54,20 @@ export class ProtectEngine {
42
54
  decision: "block",
43
55
  reason: this.cooldownReason ?? "cooldown_active",
44
56
  });
45
- return { decision: "block", reason: this.cooldownReason ?? "cooldown_active" };
57
+ return {
58
+ decision: "block",
59
+ reason: this.cooldownReason ?? "cooldown_active",
60
+ trace_id: traceId,
61
+ request_id: requestId,
62
+ blocked_until: formatBlockedUntilMs(this.cooldownUntilMs),
63
+ retry_after_seconds: toRetryAfterSeconds(this.cooldownUntilMs, nowMs),
64
+ };
46
65
  }
47
66
  const controller = new AbortController();
48
67
  const timeoutMs = this.decisionTimeoutMs > 0 ? this.decisionTimeoutMs : this.fallbackRequestTimeoutMs;
49
68
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
50
69
  timeout.unref?.();
51
70
  const startedAt = Date.now();
52
- const requestId = randomUUID();
53
- const traceId = generateTraceId();
54
71
  const spanId = generateSpanId();
55
72
  try {
56
73
  const response = await bindTraceContext(traceId, spanId, async () => await requestJson(`${this.baseUrl}/api/v1/protect/decision`, {
@@ -72,10 +89,8 @@ export class ProtectEngine {
72
89
  status_code: response.status,
73
90
  latency_ms: Date.now() - startedAt,
74
91
  });
75
- void this.reportDecisionUnavailable(context.provider, typeof context.model === "string" ? context.model : undefined, requestId);
76
- return this.failMode === "closed"
77
- ? { decision: "block", reason: "decision_unavailable" }
78
- : { decision: "allow", reason: "decision_unavailable" };
92
+ void this.reportDecisionUnavailable(context.provider, typeof context.model === "string" ? context.model : undefined, requestId, traceId);
93
+ return this.fallbackEvaluation(traceId, requestId);
79
94
  }
80
95
  const parsed = (await response.json());
81
96
  const decision = parseDecision(parsed.decision);
@@ -107,6 +122,10 @@ export class ProtectEngine {
107
122
  return {
108
123
  decision,
109
124
  reason,
125
+ trace_id: traceId,
126
+ request_id: requestId,
127
+ blocked_until: typeof parsed.blocked_until === "string" ? parsed.blocked_until : undefined,
128
+ retry_after_seconds: parseRetryAfterSeconds(parsed.retry_after_seconds),
110
129
  snapshot: parseSnapshot(parsed.snapshot),
111
130
  applyClampEnabled: typeof parsed.apply_clamp_enabled === "boolean" ? parsed.apply_clamp_enabled : undefined,
112
131
  clamp: parseClamp(parsed.clamp),
@@ -120,7 +139,7 @@ export class ProtectEngine {
120
139
  latency_ms: Date.now() - startedAt,
121
140
  timeout_ms: timeoutMs,
122
141
  });
123
- void this.reportDecisionTimeout(context.provider, typeof context.model === "string" ? context.model : undefined, requestId);
142
+ void this.reportDecisionTimeout(context.provider, typeof context.model === "string" ? context.model : undefined, requestId, traceId);
124
143
  }
125
144
  else {
126
145
  this.debugLog?.("Protect preflight failed", {
@@ -128,13 +147,19 @@ export class ProtectEngine {
128
147
  latency_ms: Date.now() - startedAt,
129
148
  error_type: extractErrorType(error),
130
149
  });
131
- void this.reportDecisionUnavailable(context.provider, typeof context.model === "string" ? context.model : undefined, requestId);
150
+ void this.reportDecisionUnavailable(context.provider, typeof context.model === "string" ? context.model : undefined, requestId, traceId);
132
151
  }
133
- return this.failMode === "closed"
134
- ? { decision: "block", reason: "decision_unavailable" }
135
- : { decision: "allow", reason: "decision_unavailable" };
152
+ return this.fallbackEvaluation(traceId, requestId);
136
153
  }
137
154
  }
155
+ fallbackEvaluation(traceId, requestId) {
156
+ return {
157
+ decision: this.failMode === "closed" ? "block" : "allow",
158
+ reason: this.failMode === "closed" ? "fail_closed" : "decision_unavailable",
159
+ trace_id: traceId,
160
+ request_id: requestId,
161
+ };
162
+ }
138
163
  async bootstrap() {
139
164
  try {
140
165
  const response = await requestJson(`${this.baseUrl}/api/v1/protect/config`, {
@@ -162,14 +187,14 @@ export class ProtectEngine {
162
187
  // Best effort only; keep local defaults if bootstrap fails.
163
188
  }
164
189
  }
165
- async reportDecisionTimeout(provider, model, requestId) {
190
+ async reportDecisionTimeout(provider, model, requestId, traceId) {
166
191
  try {
167
192
  await requestJson(`${this.baseUrl}/api/v1/protect/decision-timeout`, {
168
193
  method: "POST",
169
194
  headers: {
170
195
  "Content-Type": "application/json",
171
196
  "X-Project-Ingest-Key": this.ingestKey,
172
- "X-Trace-ID": generateTraceId(),
197
+ "X-Trace-ID": traceId,
173
198
  "X-Span-ID": generateSpanId(),
174
199
  "X-Rheonic-Protect-Request-Id": requestId,
175
200
  },
@@ -180,14 +205,14 @@ export class ProtectEngine {
180
205
  // Swallow timeout reporting errors; protect evaluation must never throw here.
181
206
  }
182
207
  }
183
- async reportDecisionUnavailable(provider, model, requestId) {
208
+ async reportDecisionUnavailable(provider, model, requestId, traceId) {
184
209
  try {
185
210
  await requestJson(`${this.baseUrl}/api/v1/protect/decision-unavailable`, {
186
211
  method: "POST",
187
212
  headers: {
188
213
  "Content-Type": "application/json",
189
214
  "X-Project-Ingest-Key": this.ingestKey,
190
- "X-Trace-ID": generateTraceId(),
215
+ "X-Trace-ID": traceId,
191
216
  "X-Span-ID": generateSpanId(),
192
217
  "X-Rheonic-Protect-Request-Id": requestId,
193
218
  },
@@ -234,6 +259,24 @@ function parseDecision(value) {
234
259
  }
235
260
  return "allow";
236
261
  }
262
+ function parseRetryAfterSeconds(value) {
263
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
264
+ return Math.floor(value);
265
+ }
266
+ return undefined;
267
+ }
268
+ function formatBlockedUntilMs(value) {
269
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
270
+ return undefined;
271
+ }
272
+ return new Date(value).toISOString();
273
+ }
274
+ function toRetryAfterSeconds(blockedUntilMs, nowMs) {
275
+ if (typeof blockedUntilMs !== "number" || !Number.isFinite(blockedUntilMs) || blockedUntilMs <= nowMs) {
276
+ return undefined;
277
+ }
278
+ return Math.max(0, Math.ceil((blockedUntilMs - nowMs) / 1000));
279
+ }
237
280
  function parseFailMode(value) {
238
281
  if (value === "open" || value === "closed") {
239
282
  return value;
@@ -42,13 +42,13 @@ export function instrumentAnthropic(anthropicClient, options) {
42
42
  }
43
43
  const protectDecision = await options.client.evaluateProtectDecision(protectPayload);
44
44
  if (protectDecision.decision === "block") {
45
- throw new RHEONICBlockedError(protectDecision.reason);
45
+ throw new RHEONICBlockedError(protectDecision);
46
46
  }
47
47
  const callArgs = maybeApplyAnthropicClamp(args, protectDecision);
48
48
  markClampAppliedIfChanged(protectDecision, extractMaxOutputTokens(args), extractMaxOutputTokens(callArgs));
49
49
  try {
50
50
  const response = await originalCreate(...callArgs);
51
- void options.client.captureEvent(buildEvent({
51
+ await options.client.captureEventAndFlush(buildEvent({
52
52
  provider: "anthropic",
53
53
  model: extractResponseModel(response) ?? requestedModel,
54
54
  environment: options.environment ?? options.client.environment,
@@ -69,7 +69,7 @@ export function instrumentAnthropic(anthropicClient, options) {
69
69
  return response;
70
70
  }
71
71
  catch (error) {
72
- void options.client.captureEvent(buildEvent({
72
+ await options.client.captureEventAndFlush(buildEvent({
73
73
  provider: "anthropic",
74
74
  model: requestedModel,
75
75
  environment: options.environment ?? options.client.environment,
@@ -42,13 +42,13 @@ export function instrumentGoogle(googleModel, options) {
42
42
  }
43
43
  const protectDecision = await options.client.evaluateProtectDecision(protectPayload);
44
44
  if (protectDecision.decision === "block") {
45
- throw new RHEONICBlockedError(protectDecision.reason);
45
+ throw new RHEONICBlockedError(protectDecision);
46
46
  }
47
47
  const callArgs = maybeApplyGoogleClamp(args, protectDecision);
48
48
  markClampAppliedIfChanged(protectDecision, extractMaxOutputTokens(args), extractMaxOutputTokens(callArgs));
49
49
  try {
50
50
  const response = await originalGenerate(...callArgs);
51
- void options.client.captureEvent(buildEvent({
51
+ await options.client.captureEventAndFlush(buildEvent({
52
52
  provider: "google",
53
53
  model: requestedModel,
54
54
  environment: options.environment ?? options.client.environment,
@@ -69,7 +69,7 @@ export function instrumentGoogle(googleModel, options) {
69
69
  return response;
70
70
  }
71
71
  catch (error) {
72
- void options.client.captureEvent(buildEvent({
72
+ await options.client.captureEventAndFlush(buildEvent({
73
73
  provider: "google",
74
74
  model: requestedModel,
75
75
  environment: options.environment ?? options.client.environment,
@@ -43,13 +43,13 @@ export function instrumentOpenAI(openaiClient, options) {
43
43
  ...protectPayload,
44
44
  });
45
45
  if (protectDecision.decision === "block") {
46
- throw new RHEONICBlockedError(protectDecision.reason);
46
+ throw new RHEONICBlockedError(protectDecision);
47
47
  }
48
48
  const callArgs = maybeApplyOpenAIClamp(args, protectDecision);
49
49
  markClampAppliedIfChanged(protectDecision, extractMaxOutputTokens(args), extractMaxOutputTokens(callArgs));
50
50
  try {
51
51
  const response = await originalCreate(...callArgs);
52
- void options.client.captureEvent(buildEvent({
52
+ await options.client.captureEventAndFlush(buildEvent({
53
53
  provider: "openai",
54
54
  model: extractResponseModel(response) ?? model,
55
55
  environment: options.environment ?? options.client.environment,
@@ -69,7 +69,7 @@ export function instrumentOpenAI(openaiClient, options) {
69
69
  return response;
70
70
  }
71
71
  catch (error) {
72
- void options.client.captureEvent(buildEvent({
72
+ await options.client.captureEventAndFlush(buildEvent({
73
73
  provider: "openai",
74
74
  model,
75
75
  environment: options.environment ?? options.client.environment,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rheonic/sdk",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0-beta.8",
4
4
  "description": "Node.js SDK for Rheonic observability and protect preflight enforcement.",
5
5
  "author": "Rheonic <founder@rheonic.dev>",
6
6
  "license": "MIT",