@rouvanpm/rouva 0.3.2 → 0.4.0

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/README.md CHANGED
@@ -80,13 +80,13 @@ while (true) {
80
80
 
81
81
  Streaming responses are normalized to OpenAI-style SSE chunks, even when Rouva routes the request to Anthropic.
82
82
 
83
- `stream` is a client-side toggle: `stream: true` returns the raw `ReadableStream`, omitting it (or `stream: false`) returns a buffered `ChatCompletion`. It is never sent to the gateway.
83
+ `stream` is a client-side toggle: `stream: true` returns the raw `ReadableStream` (with the routing metadata on `_rouva` — see [Response metadata](#response-metadata)), omitting it (or `stream: false`) returns a buffered `ChatCompletion`. It is never sent to the gateway.
84
84
 
85
85
  ## Request parameters
86
86
 
87
87
  ```typescript
88
88
  const res = await rouva.chat.completions.create({
89
- model: 'gpt-4o-mini', // required when using seed (OpenAI only)
89
+ model: 'gpt-6-luna', // required when using seed (OpenAI only)
90
90
  messages: [{ role: 'user', content: 'Write a haiku about routing.' }],
91
91
  system: 'You are a concise assistant.', // string or Anthropic-style text blocks
92
92
  max_tokens: 1024, // default 4096 (or max_completion_tokens)
@@ -104,8 +104,9 @@ const res = await rouva.chat.completions.create({
104
104
  - **`stop`** — a string or up to 4 stop sequences (Anthropic receives them as `stop_sequences`). OpenAI and xAI reasoning models (e.g. gpt-5, grok-4.5 / 4.6 / 4.7) reject it: routing avoids them when it's set, and pinning one returns a 400.
105
105
  - **`metadata`** — up to 10 string key-value pairs (keys up to 64 characters, values up to 256, no null bytes) stored with the request's usage record; not sent to the provider.
106
106
  - **`seed`** — best-effort deterministic sampling. Only OpenAI honors it, so it requires an OpenAI `model` and pins the request to that exact model (no routing, no fallbacks).
107
+ - **`response_format`** — JSON mode or a JSON schema; see [Structured output](#structured-output).
107
108
 
108
- Unsupported OpenAI options (`response_format`, `logit_bias`, `n > 1`, the legacy `functions`/`function_call` fields, …) are rejected by the gateway with an explicit 400 rather than silently ignored.
109
+ Unsupported OpenAI options (`logit_bias`, `logprobs`, `reasoning_effort`, `n > 1`, the legacy `functions`/`function_call` fields, …) are rejected by the gateway with an explicit 400 rather than silently ignored.
109
110
 
110
111
  ## Unlisted models
111
112
 
@@ -313,20 +314,67 @@ Sessions are optional — requests without an active session are tracked individ
313
314
 
314
315
  ## Response metadata
315
316
 
316
- Parsed non-stream responses may include a `_rouva` field with gateway header metadata when available:
317
+ Every response says what Rouva decided, in `_rouva` — on a buffered completion, and on the stream `stream: true` returns (the headers arrive before the first chunk):
317
318
 
318
319
  ```typescript
319
320
  const res = await rouva.chat.completions.create({ messages })
320
321
 
321
322
  console.log(res._rouva)
322
323
  // {
323
- // model_used: 'gpt-4o-mini',
324
+ // request_id: '7c0e…', // your request log's id for it
325
+ // model_used: 'gpt-6-luna',
324
326
  // provider_used: 'openai',
327
+ // requested_model: 'gpt-6-sol', // only when Rouva served a different model
325
328
  // task_type: 'summarize',
326
- // dedup: true // only when served from the duplicate-request cache
329
+ // route_reason: 'intelligent', // override | allowlist | fallback | no_key_fallback | direct | escalation | dedup
330
+ // dedup: true // only when served from the duplicate-request cache
327
331
  // }
328
332
  ```
329
333
 
334
+ ### Cost, savings and quality score — `rouva.requests.get(id)`
335
+
336
+ A request's cost is known only once its last chunk has arrived, and its quality score once the judge has run, so read them back by `request_id`:
337
+
338
+ ```typescript
339
+ import type { RouvaStream } from '@rouvanpm/rouva'
340
+
341
+ const stream = await rouva.chat.completions.create({ messages, stream: true }) as RouvaStream
342
+ // ...consume the stream...
343
+
344
+ const id = stream._rouva?.request_id // absent on a duplicate-cache replay (cost 0)
345
+ if (id) {
346
+ const req = await rouva.requests.get(id)
347
+ console.log(req.status) // 'pending' | 'incomplete' | 'settled' | 'scored'
348
+ console.log(req.cost_usd) // e.g. 0.000412
349
+ console.log(req.saved_usd) // vs the model you named (negative when the route cost more)
350
+ console.log(req.quality_score) // 0–1, once scored
351
+ console.log(req.dashboard_url) // opens it in your request log
352
+ }
353
+ ```
354
+
355
+ `pending` means it hasn't settled yet — retry after a moment. `incomplete` means it settled from partial usage (a stream cut short), so the provider may have billed more; `settled` and `scored` costs are final. A key reads only its own requests. A duplicate-cache replay has no `request_id` (nothing new was recorded or charged).
356
+
357
+ ## Structured output
358
+
359
+ `response_format` asks for JSON: `{ type: 'json_object' }` (JSON mode — your prompt must mention "json") or `{ type: 'json_schema', json_schema: { name, schema, strict } }` (the answer follows your schema). Rouva only routes to models that serve the format; see [the docs](https://rouva.io/docs#structured-output) for which models do.
360
+
361
+ ```typescript
362
+ import type { ChatCompletion } from '@rouvanpm/rouva'
363
+
364
+ const res = await rouva.chat.completions.create({
365
+ messages: [{ role: 'user', content: 'Extract the city: "Flying to Paris on Monday."' }],
366
+ response_format: {
367
+ type: 'json_schema',
368
+ json_schema: {
369
+ name: 'city',
370
+ strict: true,
371
+ schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], additionalProperties: false },
372
+ },
373
+ },
374
+ }) as ChatCompletion
375
+ const { city } = JSON.parse(res.choices[0].message.content!)
376
+ ```
377
+
330
378
  ## Errors
331
379
 
332
380
  Any non-2xx response throws a `RouvaError` (a subclass of `Error`, so existing `catch` blocks keep working):
@@ -341,6 +389,7 @@ try {
341
389
  console.log(e.refusal) // 'key_cap' | 'member_cap' | 'rate' | … — Rouva refused before calling the provider
342
390
  console.log(e.retryAfter) // seconds, from Retry-After
343
391
  console.log(e.error) // the gateway's message
392
+ console.log(e.requestId) // when the request was recorded — rouva.requests.get(e.requestId)
344
393
  }
345
394
  }
346
395
  ```
package/dist/index.d.mts CHANGED
@@ -132,6 +132,17 @@ interface ChatCompletionParams {
132
132
  * no cheaper-model substitution, no cross-provider fallbacks.
133
133
  */
134
134
  seed?: number;
135
+ /**
136
+ * Ask for JSON output (gateway support since 2026-10-20). `json_object` is
137
+ * JSON mode: the prompt (a message or `system`) must contain the word
138
+ * "json", or the gateway returns a 400. `json_schema` is Structured Outputs:
139
+ * the answer follows your schema. Routing only picks a model that serves the
140
+ * format; Claude models serve `json_schema` only (every object needs
141
+ * `additionalProperties: false`), and not when the conversation ends with an
142
+ * assistant message. `{ type: 'text' }` or `null` is the default (no-op).
143
+ * `rouva.run()` doesn't accept it. See https://rouva.io/docs#structured-output
144
+ */
145
+ response_format?: ResponseFormat | null;
135
146
  /**
136
147
  * Stream the response. Client-side only — controls whether `create()`
137
148
  * returns a ReadableStream or a buffered ChatCompletion; never sent to
@@ -154,6 +165,22 @@ interface ChatCompletionParams {
154
165
  */
155
166
  tool_choice?: string | Record<string, unknown>;
156
167
  }
168
+ /** `response_format` values the gateway accepts. */
169
+ type ResponseFormat = {
170
+ type: 'text';
171
+ } | {
172
+ type: 'json_object';
173
+ } | {
174
+ type: 'json_schema';
175
+ json_schema: {
176
+ /** 1–64 letters, digits, underscores or dashes. */
177
+ name: string;
178
+ /** A JSON Schema object. */
179
+ schema: Record<string, unknown>;
180
+ strict?: boolean;
181
+ description?: string;
182
+ };
183
+ };
157
184
  /** Assistant message in a completed response; `tool_calls` present when the model requested tools */
158
185
  interface ResponseMessage {
159
186
  role: 'assistant';
@@ -180,13 +207,42 @@ interface ChatCompletion {
180
207
  /** Rouva metadata — cost, savings, routing decision */
181
208
  _rouva?: RouvaResponseMeta;
182
209
  }
210
+ /**
211
+ * Why Rouva sent a request to the model it did (header `X-Rouva-Route-Reason`):
212
+ * `intelligent` (Intelligent Routing chose it), `override` (an account routing
213
+ * override), `allowlist` (a workspace member's allowed-model list),
214
+ * `fallback` (a retry after a provider error), `escalation` (`run()` stepped
215
+ * up a model), `no_key_fallback` (the named provider has no key, so a
216
+ * connected one stood in), `direct` (the model you named, as named), or
217
+ * `dedup` (a replay of an identical request — no new call was made).
218
+ */
219
+ type RouvaRouteReason = 'intelligent' | 'override' | 'allowlist' | 'fallback' | 'escalation' | 'no_key_fallback' | 'direct' | 'dedup' | (string & {});
183
220
  interface RouvaResponseMeta {
184
- /** Actual model used when exposed by the gateway */
221
+ /**
222
+ * The request's id in your Rouva request log (`X-Rouva-Request-Id`). Pass it
223
+ * to `rouva.requests.get(id)` for the request's cost, savings and quality
224
+ * score. Absent on a duplicate-cache replay (no new request was recorded).
225
+ */
226
+ request_id?: string;
227
+ /** The model that served the request — after a fallback, the fallback model. */
185
228
  model_used?: string;
186
- /** Actual provider used when exposed by the gateway */
229
+ /** That model's provider, e.g. `openai`, `anthropic`. */
187
230
  provider_used?: string;
188
- /** Task type classified by Rouva when exposed by the gateway */
231
+ /** The model you named, when Rouva served a different one. */
232
+ requested_model?: string;
233
+ /** Task type classified by Rouva, e.g. `code`; absent when not classified. */
189
234
  task_type?: string;
235
+ /** Why this model was chosen. */
236
+ route_reason?: RouvaRouteReason;
237
+ /**
238
+ * What the request cost in USD, when the gateway sent it in a header: only
239
+ * on non-streaming responses once the cost is final, and as 0 on a
240
+ * duplicate-cache replay (`dedup: true`). The SDK streams, so otherwise read
241
+ * cost with `rouva.requests.get(request_id)`.
242
+ */
243
+ cost_usd?: number;
244
+ /** Saved against the model you named (or the priciest candidate), in USD; negative when the route cost more. Same availability as `cost_usd`. */
245
+ saved_usd?: number;
190
246
  /**
191
247
  * True when the gateway answered from its duplicate-request cache (an
192
248
  * identical request within the last 60 seconds) instead of calling the
@@ -199,6 +255,48 @@ interface RouvaResponseMeta {
199
255
  */
200
256
  cache?: string;
201
257
  }
258
+ /**
259
+ * The stream `chat.completions.create({ stream: true })` returns: OpenAI-shaped
260
+ * SSE bytes, plus the routing metadata from the response headers (known
261
+ * before the first chunk).
262
+ */
263
+ type RouvaStream = ReadableStream<Uint8Array> & {
264
+ _rouva?: RouvaResponseMeta;
265
+ };
266
+ /**
267
+ * A request's settlement state: `pending` (not settled yet — in flight, or it
268
+ * never reached the provider), `incomplete` (settled from partial usage, e.g. a
269
+ * stream cut short — the provider may have billed more), `settled` (tokens and
270
+ * cost are final), `scored` (final, and a quality score is recorded).
271
+ */
272
+ type RouvaRequestStatus = 'pending' | 'incomplete' | 'settled' | 'scored' | (string & {});
273
+ /** One request from your request log — `rouva.requests.get(id)`. */
274
+ interface RouvaRequest {
275
+ id: string;
276
+ /** ISO timestamp. */
277
+ created_at: string;
278
+ status: RouvaRequestStatus;
279
+ provider: string;
280
+ model: string;
281
+ requested_model: string | null;
282
+ task_type: string | null;
283
+ route_reason: RouvaRouteReason | null;
284
+ /** 0 while pending. */
285
+ input_tokens: number;
286
+ output_tokens: number;
287
+ /** USD; 0 while pending. Final once `status` is `settled` or `scored`. */
288
+ cost_usd: number;
289
+ /** USD saved against the model you named (or the priciest candidate); negative when the route cost more. */
290
+ saved_usd: number;
291
+ /** 0–1, or null until a score is recorded (or when none is). */
292
+ quality_score: number | null;
293
+ /** Whether the judge scored it, or why not (then `quality_score` is the heuristic checks'). */
294
+ judge_status: string | null;
295
+ finish_reason: string | null;
296
+ session_id: string | null;
297
+ /** Opens this request in the dashboard's request log. */
298
+ dashboard_url: string;
299
+ }
202
300
  interface RunParams {
203
301
  /**
204
302
  * Conversation history to seed the agent loop with.
@@ -301,9 +399,30 @@ declare class Rouva {
301
399
  private _sessionId;
302
400
  readonly chat: {
303
401
  completions: {
304
- create(params: ChatCompletionParams, options?: RequestOptions): Promise<ChatCompletion | ReadableStream>;
402
+ create(params: ChatCompletionParams, options?: RequestOptions): Promise<ChatCompletion | RouvaStream>;
305
403
  };
306
404
  };
405
+ readonly requests: {
406
+ /**
407
+ * Read one request from your request log (`GET /api/gateway/requests/{id}`)
408
+ * by the id a response carried (`_rouva.request_id`, header
409
+ * `X-Rouva-Request-Id`): its model, routing reason, tokens, cost, savings and
410
+ * quality score. A key reads only its own requests (another's id is a 404).
411
+ *
412
+ * A stream's cost is recorded just after its last chunk, and the quality
413
+ * score once the judge has run (a few seconds), so check `status`:
414
+ * `pending` → not settled yet, retry shortly; `incomplete` → settled from
415
+ * partial usage; `settled` → cost final; `scored` → cost final and scored.
416
+ * Throws `RouvaError` on a non-2xx response (a 503 carries `retryAfter`).
417
+ *
418
+ * @example
419
+ * const stream = await rouva.chat.completions.create({ messages, stream: true }) as RouvaStream
420
+ * // ...consume the stream...
421
+ * const req = await rouva.requests.get(stream._rouva!.request_id!)
422
+ * console.log(req.status, req.cost_usd, req.saved_usd)
423
+ */
424
+ get(id: string, options?: RequestOptions): Promise<RouvaRequest>;
425
+ };
307
426
  constructor(options: RouvaOptions);
308
427
  /**
309
428
  * Start a new agent session. Every subsequent `chat.completions.create()`
@@ -347,7 +466,9 @@ declare class Rouva {
347
466
  * console.log(result.session_id) // dashboard session ID
348
467
  */
349
468
  run(params: RunParams, options?: RequestOptions): Promise<RunResult>;
469
+ private _getRequest;
350
470
  private _post;
471
+ private _fetch;
351
472
  private _createChatCompletion;
352
473
  }
353
474
 
@@ -378,9 +499,16 @@ declare class RouvaError extends Error {
378
499
  readonly retryAfter?: number;
379
500
  /** The gateway's error message (`error` field), when the body is JSON. */
380
501
  readonly error?: string;
502
+ /**
503
+ * The request's id in your Rouva request log (`X-Rouva-Request-Id`), when
504
+ * the error came after the request was recorded — e.g. a provider error.
505
+ * Look it up with `rouva.requests.get(requestId)`. Absent on errors Rouva
506
+ * returns before recording the request (validation 400s, auth).
507
+ */
508
+ readonly requestId?: string;
381
509
  /** The parsed JSON body, or the raw text when it isn't JSON. */
382
510
  readonly body: unknown;
383
- constructor(kind: 'Gateway' | 'Run', status: number, headers: Headers, rawBody: string);
511
+ constructor(kind: 'Gateway' | 'Run' | 'Request', status: number, headers: Headers, rawBody: string);
384
512
  }
385
513
 
386
- export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type FunctionToolCall, type Message, type RequestOptions, type ResponseMessage, Rouva, RouvaError, type RouvaModel, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta, type RunParams, type RunResult, type SystemTextBlock, type ToolResultMessage };
514
+ export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type FunctionToolCall, type Message, type RequestOptions, type ResponseFormat, type ResponseMessage, Rouva, RouvaError, type RouvaModel, type RouvaOptions, type RouvaProvider, type RouvaRequest, type RouvaRequestStatus, type RouvaResponseMeta, type RouvaRouteReason, type RouvaStream, type RunParams, type RunResult, type SystemTextBlock, type ToolResultMessage };
package/dist/index.d.ts CHANGED
@@ -132,6 +132,17 @@ interface ChatCompletionParams {
132
132
  * no cheaper-model substitution, no cross-provider fallbacks.
133
133
  */
134
134
  seed?: number;
135
+ /**
136
+ * Ask for JSON output (gateway support since 2026-10-20). `json_object` is
137
+ * JSON mode: the prompt (a message or `system`) must contain the word
138
+ * "json", or the gateway returns a 400. `json_schema` is Structured Outputs:
139
+ * the answer follows your schema. Routing only picks a model that serves the
140
+ * format; Claude models serve `json_schema` only (every object needs
141
+ * `additionalProperties: false`), and not when the conversation ends with an
142
+ * assistant message. `{ type: 'text' }` or `null` is the default (no-op).
143
+ * `rouva.run()` doesn't accept it. See https://rouva.io/docs#structured-output
144
+ */
145
+ response_format?: ResponseFormat | null;
135
146
  /**
136
147
  * Stream the response. Client-side only — controls whether `create()`
137
148
  * returns a ReadableStream or a buffered ChatCompletion; never sent to
@@ -154,6 +165,22 @@ interface ChatCompletionParams {
154
165
  */
155
166
  tool_choice?: string | Record<string, unknown>;
156
167
  }
168
+ /** `response_format` values the gateway accepts. */
169
+ type ResponseFormat = {
170
+ type: 'text';
171
+ } | {
172
+ type: 'json_object';
173
+ } | {
174
+ type: 'json_schema';
175
+ json_schema: {
176
+ /** 1–64 letters, digits, underscores or dashes. */
177
+ name: string;
178
+ /** A JSON Schema object. */
179
+ schema: Record<string, unknown>;
180
+ strict?: boolean;
181
+ description?: string;
182
+ };
183
+ };
157
184
  /** Assistant message in a completed response; `tool_calls` present when the model requested tools */
158
185
  interface ResponseMessage {
159
186
  role: 'assistant';
@@ -180,13 +207,42 @@ interface ChatCompletion {
180
207
  /** Rouva metadata — cost, savings, routing decision */
181
208
  _rouva?: RouvaResponseMeta;
182
209
  }
210
+ /**
211
+ * Why Rouva sent a request to the model it did (header `X-Rouva-Route-Reason`):
212
+ * `intelligent` (Intelligent Routing chose it), `override` (an account routing
213
+ * override), `allowlist` (a workspace member's allowed-model list),
214
+ * `fallback` (a retry after a provider error), `escalation` (`run()` stepped
215
+ * up a model), `no_key_fallback` (the named provider has no key, so a
216
+ * connected one stood in), `direct` (the model you named, as named), or
217
+ * `dedup` (a replay of an identical request — no new call was made).
218
+ */
219
+ type RouvaRouteReason = 'intelligent' | 'override' | 'allowlist' | 'fallback' | 'escalation' | 'no_key_fallback' | 'direct' | 'dedup' | (string & {});
183
220
  interface RouvaResponseMeta {
184
- /** Actual model used when exposed by the gateway */
221
+ /**
222
+ * The request's id in your Rouva request log (`X-Rouva-Request-Id`). Pass it
223
+ * to `rouva.requests.get(id)` for the request's cost, savings and quality
224
+ * score. Absent on a duplicate-cache replay (no new request was recorded).
225
+ */
226
+ request_id?: string;
227
+ /** The model that served the request — after a fallback, the fallback model. */
185
228
  model_used?: string;
186
- /** Actual provider used when exposed by the gateway */
229
+ /** That model's provider, e.g. `openai`, `anthropic`. */
187
230
  provider_used?: string;
188
- /** Task type classified by Rouva when exposed by the gateway */
231
+ /** The model you named, when Rouva served a different one. */
232
+ requested_model?: string;
233
+ /** Task type classified by Rouva, e.g. `code`; absent when not classified. */
189
234
  task_type?: string;
235
+ /** Why this model was chosen. */
236
+ route_reason?: RouvaRouteReason;
237
+ /**
238
+ * What the request cost in USD, when the gateway sent it in a header: only
239
+ * on non-streaming responses once the cost is final, and as 0 on a
240
+ * duplicate-cache replay (`dedup: true`). The SDK streams, so otherwise read
241
+ * cost with `rouva.requests.get(request_id)`.
242
+ */
243
+ cost_usd?: number;
244
+ /** Saved against the model you named (or the priciest candidate), in USD; negative when the route cost more. Same availability as `cost_usd`. */
245
+ saved_usd?: number;
190
246
  /**
191
247
  * True when the gateway answered from its duplicate-request cache (an
192
248
  * identical request within the last 60 seconds) instead of calling the
@@ -199,6 +255,48 @@ interface RouvaResponseMeta {
199
255
  */
200
256
  cache?: string;
201
257
  }
258
+ /**
259
+ * The stream `chat.completions.create({ stream: true })` returns: OpenAI-shaped
260
+ * SSE bytes, plus the routing metadata from the response headers (known
261
+ * before the first chunk).
262
+ */
263
+ type RouvaStream = ReadableStream<Uint8Array> & {
264
+ _rouva?: RouvaResponseMeta;
265
+ };
266
+ /**
267
+ * A request's settlement state: `pending` (not settled yet — in flight, or it
268
+ * never reached the provider), `incomplete` (settled from partial usage, e.g. a
269
+ * stream cut short — the provider may have billed more), `settled` (tokens and
270
+ * cost are final), `scored` (final, and a quality score is recorded).
271
+ */
272
+ type RouvaRequestStatus = 'pending' | 'incomplete' | 'settled' | 'scored' | (string & {});
273
+ /** One request from your request log — `rouva.requests.get(id)`. */
274
+ interface RouvaRequest {
275
+ id: string;
276
+ /** ISO timestamp. */
277
+ created_at: string;
278
+ status: RouvaRequestStatus;
279
+ provider: string;
280
+ model: string;
281
+ requested_model: string | null;
282
+ task_type: string | null;
283
+ route_reason: RouvaRouteReason | null;
284
+ /** 0 while pending. */
285
+ input_tokens: number;
286
+ output_tokens: number;
287
+ /** USD; 0 while pending. Final once `status` is `settled` or `scored`. */
288
+ cost_usd: number;
289
+ /** USD saved against the model you named (or the priciest candidate); negative when the route cost more. */
290
+ saved_usd: number;
291
+ /** 0–1, or null until a score is recorded (or when none is). */
292
+ quality_score: number | null;
293
+ /** Whether the judge scored it, or why not (then `quality_score` is the heuristic checks'). */
294
+ judge_status: string | null;
295
+ finish_reason: string | null;
296
+ session_id: string | null;
297
+ /** Opens this request in the dashboard's request log. */
298
+ dashboard_url: string;
299
+ }
202
300
  interface RunParams {
203
301
  /**
204
302
  * Conversation history to seed the agent loop with.
@@ -301,9 +399,30 @@ declare class Rouva {
301
399
  private _sessionId;
302
400
  readonly chat: {
303
401
  completions: {
304
- create(params: ChatCompletionParams, options?: RequestOptions): Promise<ChatCompletion | ReadableStream>;
402
+ create(params: ChatCompletionParams, options?: RequestOptions): Promise<ChatCompletion | RouvaStream>;
305
403
  };
306
404
  };
405
+ readonly requests: {
406
+ /**
407
+ * Read one request from your request log (`GET /api/gateway/requests/{id}`)
408
+ * by the id a response carried (`_rouva.request_id`, header
409
+ * `X-Rouva-Request-Id`): its model, routing reason, tokens, cost, savings and
410
+ * quality score. A key reads only its own requests (another's id is a 404).
411
+ *
412
+ * A stream's cost is recorded just after its last chunk, and the quality
413
+ * score once the judge has run (a few seconds), so check `status`:
414
+ * `pending` → not settled yet, retry shortly; `incomplete` → settled from
415
+ * partial usage; `settled` → cost final; `scored` → cost final and scored.
416
+ * Throws `RouvaError` on a non-2xx response (a 503 carries `retryAfter`).
417
+ *
418
+ * @example
419
+ * const stream = await rouva.chat.completions.create({ messages, stream: true }) as RouvaStream
420
+ * // ...consume the stream...
421
+ * const req = await rouva.requests.get(stream._rouva!.request_id!)
422
+ * console.log(req.status, req.cost_usd, req.saved_usd)
423
+ */
424
+ get(id: string, options?: RequestOptions): Promise<RouvaRequest>;
425
+ };
307
426
  constructor(options: RouvaOptions);
308
427
  /**
309
428
  * Start a new agent session. Every subsequent `chat.completions.create()`
@@ -347,7 +466,9 @@ declare class Rouva {
347
466
  * console.log(result.session_id) // dashboard session ID
348
467
  */
349
468
  run(params: RunParams, options?: RequestOptions): Promise<RunResult>;
469
+ private _getRequest;
350
470
  private _post;
471
+ private _fetch;
351
472
  private _createChatCompletion;
352
473
  }
353
474
 
@@ -378,9 +499,16 @@ declare class RouvaError extends Error {
378
499
  readonly retryAfter?: number;
379
500
  /** The gateway's error message (`error` field), when the body is JSON. */
380
501
  readonly error?: string;
502
+ /**
503
+ * The request's id in your Rouva request log (`X-Rouva-Request-Id`), when
504
+ * the error came after the request was recorded — e.g. a provider error.
505
+ * Look it up with `rouva.requests.get(requestId)`. Absent on errors Rouva
506
+ * returns before recording the request (validation 400s, auth).
507
+ */
508
+ readonly requestId?: string;
381
509
  /** The parsed JSON body, or the raw text when it isn't JSON. */
382
510
  readonly body: unknown;
383
- constructor(kind: 'Gateway' | 'Run', status: number, headers: Headers, rawBody: string);
511
+ constructor(kind: 'Gateway' | 'Run' | 'Request', status: number, headers: Headers, rawBody: string);
384
512
  }
385
513
 
386
- export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type FunctionToolCall, type Message, type RequestOptions, type ResponseMessage, Rouva, RouvaError, type RouvaModel, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta, type RunParams, type RunResult, type SystemTextBlock, type ToolResultMessage };
514
+ export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type FunctionToolCall, type Message, type RequestOptions, type ResponseFormat, type ResponseMessage, Rouva, RouvaError, type RouvaModel, type RouvaOptions, type RouvaProvider, type RouvaRequest, type RouvaRequestStatus, type RouvaResponseMeta, type RouvaRouteReason, type RouvaStream, type RunParams, type RunResult, type SystemTextBlock, type ToolResultMessage };
package/dist/index.js CHANGED
@@ -357,17 +357,30 @@ async function readChatCompletionFromSse(stream, metadata) {
357
357
  }
358
358
  function getRouvaMetadata(headers) {
359
359
  const read = (name) => headers.get(name) || void 0;
360
- const modelUsed = read("x-rouva-model");
361
- const providerUsed = read("x-rouva-provider");
362
- const taskType = read("x-rouva-task");
363
- const dedup = headers.get("x-rouva-dedup") === "1" ? true : void 0;
364
- if (!modelUsed && !providerUsed && !taskType && !dedup) return void 0;
365
- return {
366
- model_used: modelUsed,
367
- provider_used: providerUsed,
368
- task_type: taskType,
369
- ...dedup ? { dedup } : {}
360
+ const usd = (name) => {
361
+ const raw = read(name);
362
+ const n = raw === void 0 ? NaN : Number(raw);
363
+ return Number.isFinite(n) ? n : void 0;
364
+ };
365
+ const meta = {
366
+ // X-Rouva-Row-Id is the same id under its older name.
367
+ request_id: read("x-rouva-request-id") ?? read("x-rouva-row-id"),
368
+ model_used: read("x-rouva-model"),
369
+ provider_used: read("x-rouva-provider"),
370
+ requested_model: read("x-rouva-requested-model"),
371
+ // X-Rouva-Task is the older name, still sent alongside.
372
+ task_type: read("x-rouva-task-type") ?? read("x-rouva-task"),
373
+ route_reason: read("x-rouva-route-reason"),
374
+ cost_usd: usd("x-rouva-cost-usd"),
375
+ saved_usd: usd("x-rouva-saved-usd"),
376
+ // Duplicate-request cache hit (an identical request within 60s, not charged).
377
+ // (The old x-rouva-cache header went away with the semantic cache.)
378
+ dedup: headers.get("x-rouva-dedup") === "1" ? true : void 0
370
379
  };
380
+ for (const key of Object.keys(meta)) {
381
+ if (meta[key] === void 0) delete meta[key];
382
+ }
383
+ return Object.keys(meta).length > 0 ? meta : void 0;
371
384
  }
372
385
 
373
386
  // src/errors.ts
@@ -379,6 +392,7 @@ var RouvaError = class extends Error {
379
392
  this.refusal = headers.get("x-rouva-refusal") ?? void 0;
380
393
  const retry = Number(headers.get("retry-after"));
381
394
  this.retryAfter = headers.get("retry-after") !== null && Number.isFinite(retry) ? retry : void 0;
395
+ this.requestId = headers.get("x-rouva-request-id") || headers.get("x-rouva-row-id") || void 0;
382
396
  let parsed = rawBody;
383
397
  try {
384
398
  parsed = JSON.parse(rawBody);
@@ -412,6 +426,9 @@ var Rouva = class {
412
426
  create: (params, options2) => this._createChatCompletion(params, options2)
413
427
  }
414
428
  };
429
+ this.requests = {
430
+ get: (id, options2) => this._getRequest(id, options2)
431
+ };
415
432
  }
416
433
  /**
417
434
  * Start a new agent session. Every subsequent `chat.completions.create()`
@@ -466,16 +483,25 @@ var Rouva = class {
466
483
  if (!res.ok) throw await toRouvaError("Run", res);
467
484
  return res.json();
468
485
  }
469
- // One request path for every endpoint: auth, JSON body, optional abort signal.
486
+ async _getRequest(id, options) {
487
+ if (typeof id !== "string" || !id) throw new Error("[Rouva] request id is required");
488
+ const res = await this._fetch("GET", `/api/gateway/requests/${encodeURIComponent(id)}`, void 0, {}, options);
489
+ if (!res.ok) throw await toRouvaError("Request", res);
490
+ return res.json();
491
+ }
470
492
  _post(path, body, extraHeaders, options) {
493
+ return this._fetch("POST", path, body, extraHeaders, options);
494
+ }
495
+ // One request path for every endpoint: auth, JSON body, optional abort signal.
496
+ _fetch(method, path, body, extraHeaders, options) {
471
497
  return fetch(`${this.baseURL}${path}`, {
472
- method: "POST",
498
+ method,
473
499
  headers: {
474
- "Content-Type": "application/json",
500
+ ...method === "POST" ? { "Content-Type": "application/json" } : {},
475
501
  "Authorization": `Bearer ${this.apiKey}`,
476
502
  ...extraHeaders
477
503
  },
478
- body: JSON.stringify(body),
504
+ ...method === "POST" ? { body: JSON.stringify(body) } : {},
479
505
  ...options?.signal ? { signal: options.signal } : {}
480
506
  });
481
507
  }
@@ -486,8 +512,12 @@ var Rouva = class {
486
512
  if (!res.ok) throw await toRouvaError("Gateway", res);
487
513
  if (!res.body) throw new Error("[Rouva] No response body from gateway");
488
514
  const normalizedStream = normalizeGatewayStream(res.body);
489
- if (params.stream) return normalizedStream;
490
- return readChatCompletionFromSse(normalizedStream, getRouvaMetadata(res.headers));
515
+ const metadata = getRouvaMetadata(res.headers);
516
+ if (params.stream) {
517
+ if (metadata) normalizedStream._rouva = metadata;
518
+ return normalizedStream;
519
+ }
520
+ return readChatCompletionFromSse(normalizedStream, metadata);
491
521
  }
492
522
  };
493
523
  // Annotate the CommonJS export names for ESM import in node:
package/dist/index.mjs CHANGED
@@ -330,17 +330,30 @@ async function readChatCompletionFromSse(stream, metadata) {
330
330
  }
331
331
  function getRouvaMetadata(headers) {
332
332
  const read = (name) => headers.get(name) || void 0;
333
- const modelUsed = read("x-rouva-model");
334
- const providerUsed = read("x-rouva-provider");
335
- const taskType = read("x-rouva-task");
336
- const dedup = headers.get("x-rouva-dedup") === "1" ? true : void 0;
337
- if (!modelUsed && !providerUsed && !taskType && !dedup) return void 0;
338
- return {
339
- model_used: modelUsed,
340
- provider_used: providerUsed,
341
- task_type: taskType,
342
- ...dedup ? { dedup } : {}
333
+ const usd = (name) => {
334
+ const raw = read(name);
335
+ const n = raw === void 0 ? NaN : Number(raw);
336
+ return Number.isFinite(n) ? n : void 0;
337
+ };
338
+ const meta = {
339
+ // X-Rouva-Row-Id is the same id under its older name.
340
+ request_id: read("x-rouva-request-id") ?? read("x-rouva-row-id"),
341
+ model_used: read("x-rouva-model"),
342
+ provider_used: read("x-rouva-provider"),
343
+ requested_model: read("x-rouva-requested-model"),
344
+ // X-Rouva-Task is the older name, still sent alongside.
345
+ task_type: read("x-rouva-task-type") ?? read("x-rouva-task"),
346
+ route_reason: read("x-rouva-route-reason"),
347
+ cost_usd: usd("x-rouva-cost-usd"),
348
+ saved_usd: usd("x-rouva-saved-usd"),
349
+ // Duplicate-request cache hit (an identical request within 60s, not charged).
350
+ // (The old x-rouva-cache header went away with the semantic cache.)
351
+ dedup: headers.get("x-rouva-dedup") === "1" ? true : void 0
343
352
  };
353
+ for (const key of Object.keys(meta)) {
354
+ if (meta[key] === void 0) delete meta[key];
355
+ }
356
+ return Object.keys(meta).length > 0 ? meta : void 0;
344
357
  }
345
358
 
346
359
  // src/errors.ts
@@ -352,6 +365,7 @@ var RouvaError = class extends Error {
352
365
  this.refusal = headers.get("x-rouva-refusal") ?? void 0;
353
366
  const retry = Number(headers.get("retry-after"));
354
367
  this.retryAfter = headers.get("retry-after") !== null && Number.isFinite(retry) ? retry : void 0;
368
+ this.requestId = headers.get("x-rouva-request-id") || headers.get("x-rouva-row-id") || void 0;
355
369
  let parsed = rawBody;
356
370
  try {
357
371
  parsed = JSON.parse(rawBody);
@@ -385,6 +399,9 @@ var Rouva = class {
385
399
  create: (params, options2) => this._createChatCompletion(params, options2)
386
400
  }
387
401
  };
402
+ this.requests = {
403
+ get: (id, options2) => this._getRequest(id, options2)
404
+ };
388
405
  }
389
406
  /**
390
407
  * Start a new agent session. Every subsequent `chat.completions.create()`
@@ -439,16 +456,25 @@ var Rouva = class {
439
456
  if (!res.ok) throw await toRouvaError("Run", res);
440
457
  return res.json();
441
458
  }
442
- // One request path for every endpoint: auth, JSON body, optional abort signal.
459
+ async _getRequest(id, options) {
460
+ if (typeof id !== "string" || !id) throw new Error("[Rouva] request id is required");
461
+ const res = await this._fetch("GET", `/api/gateway/requests/${encodeURIComponent(id)}`, void 0, {}, options);
462
+ if (!res.ok) throw await toRouvaError("Request", res);
463
+ return res.json();
464
+ }
443
465
  _post(path, body, extraHeaders, options) {
466
+ return this._fetch("POST", path, body, extraHeaders, options);
467
+ }
468
+ // One request path for every endpoint: auth, JSON body, optional abort signal.
469
+ _fetch(method, path, body, extraHeaders, options) {
444
470
  return fetch(`${this.baseURL}${path}`, {
445
- method: "POST",
471
+ method,
446
472
  headers: {
447
- "Content-Type": "application/json",
473
+ ...method === "POST" ? { "Content-Type": "application/json" } : {},
448
474
  "Authorization": `Bearer ${this.apiKey}`,
449
475
  ...extraHeaders
450
476
  },
451
- body: JSON.stringify(body),
477
+ ...method === "POST" ? { body: JSON.stringify(body) } : {},
452
478
  ...options?.signal ? { signal: options.signal } : {}
453
479
  });
454
480
  }
@@ -459,8 +485,12 @@ var Rouva = class {
459
485
  if (!res.ok) throw await toRouvaError("Gateway", res);
460
486
  if (!res.body) throw new Error("[Rouva] No response body from gateway");
461
487
  const normalizedStream = normalizeGatewayStream(res.body);
462
- if (params.stream) return normalizedStream;
463
- return readChatCompletionFromSse(normalizedStream, getRouvaMetadata(res.headers));
488
+ const metadata = getRouvaMetadata(res.headers);
489
+ if (params.stream) {
490
+ if (metadata) normalizedStream._rouva = metadata;
491
+ return normalizedStream;
492
+ }
493
+ return readChatCompletionFromSse(normalizedStream, metadata);
464
494
  }
465
495
  };
466
496
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rouvanpm/rouva",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Official Node.js SDK for Rouva — managed AI gateway with intelligent routing and spend tracking",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",