@rouvanpm/rouva 0.3.2 → 0.4.1

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,11 @@ 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).
108
+ - **`reasoning_effort`** — `'none'` … `'xhigh'`, or `'max'` for Claude. Which values a model takes depends on the model; routing only picks one that takes yours; a listed OpenAI or Claude model you name that doesn't gets a 400, and other providers' models get the value as sent. On gpt-6-luna / gpt-6-sol, `'none'` turns reasoning off. See [the docs](https://rouva.io/docs#reasoning-effort).
109
+ - **`parallel_tool_calls`** — with `tools`: `false` allows at most one tool call per turn (Claude included).
107
110
 
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.
111
+ Unsupported OpenAI options (`logit_bias`, `logprobs`, `n > 1`, the legacy `functions`/`function_call` fields, …) are rejected by the gateway with an explicit 400 rather than silently ignored.
109
112
 
110
113
  ## Unlisted models
111
114
 
@@ -313,20 +316,67 @@ Sessions are optional — requests without an active session are tracked individ
313
316
 
314
317
  ## Response metadata
315
318
 
316
- Parsed non-stream responses may include a `_rouva` field with gateway header metadata when available:
319
+ 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
320
 
318
321
  ```typescript
319
322
  const res = await rouva.chat.completions.create({ messages })
320
323
 
321
324
  console.log(res._rouva)
322
325
  // {
323
- // model_used: 'gpt-4o-mini',
326
+ // request_id: '7c0e…', // your request log's id for it
327
+ // model_used: 'gpt-6-luna',
324
328
  // provider_used: 'openai',
329
+ // requested_model: 'gpt-6-sol', // only when Rouva served a different model
325
330
  // task_type: 'summarize',
326
- // dedup: true // only when served from the duplicate-request cache
331
+ // route_reason: 'intelligent', // override | allowlist | fallback | no_key_fallback | direct | escalation | dedup
332
+ // dedup: true // only when served from the duplicate-request cache
327
333
  // }
328
334
  ```
329
335
 
336
+ ### Cost, savings and quality score — `rouva.requests.get(id)`
337
+
338
+ 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`:
339
+
340
+ ```typescript
341
+ import type { RouvaStream } from '@rouvanpm/rouva'
342
+
343
+ const stream = await rouva.chat.completions.create({ messages, stream: true }) as RouvaStream
344
+ // ...consume the stream...
345
+
346
+ const id = stream._rouva?.request_id // absent on a duplicate-cache replay (cost 0)
347
+ if (id) {
348
+ const req = await rouva.requests.get(id)
349
+ console.log(req.status) // 'pending' | 'incomplete' | 'settled' | 'scored'
350
+ console.log(req.cost_usd) // e.g. 0.000412
351
+ console.log(req.saved_usd) // vs the model you named (negative when the route cost more)
352
+ console.log(req.quality_score) // 0–1, once scored
353
+ console.log(req.dashboard_url) // opens it in your request log
354
+ }
355
+ ```
356
+
357
+ `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).
358
+
359
+ ## Structured output
360
+
361
+ `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.
362
+
363
+ ```typescript
364
+ import type { ChatCompletion } from '@rouvanpm/rouva'
365
+
366
+ const res = await rouva.chat.completions.create({
367
+ messages: [{ role: 'user', content: 'Extract the city: "Flying to Paris on Monday."' }],
368
+ response_format: {
369
+ type: 'json_schema',
370
+ json_schema: {
371
+ name: 'city',
372
+ strict: true,
373
+ schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], additionalProperties: false },
374
+ },
375
+ },
376
+ }) as ChatCompletion
377
+ const { city } = JSON.parse(res.choices[0].message.content!)
378
+ ```
379
+
330
380
  ## Errors
331
381
 
332
382
  Any non-2xx response throws a `RouvaError` (a subclass of `Error`, so existing `catch` blocks keep working):
@@ -341,6 +391,7 @@ try {
341
391
  console.log(e.refusal) // 'key_cap' | 'member_cap' | 'rate' | … — Rouva refused before calling the provider
342
392
  console.log(e.retryAfter) // seconds, from Retry-After
343
393
  console.log(e.error) // the gateway's message
394
+ console.log(e.requestId) // when the request was recorded — rouva.requests.get(e.requestId)
344
395
  }
345
396
  }
346
397
  ```
package/dist/index.d.mts CHANGED
@@ -132,6 +132,37 @@ 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;
146
+ /**
147
+ * How hard a reasoning model thinks (gateway support since 2026-10-21).
148
+ * OpenAI models take it as is; Claude gets it as `output_config.effort`.
149
+ * Values depend on the model: gpt-5 / -mini / -nano take minimal–high;
150
+ * gpt-5.4-mini, gpt-5.6-*, gpt-6-luna / -sol take none–xhigh; gpt-6.1-sol
151
+ * low–xhigh; Claude Sonnet / Opus 4.6 low, medium, high, max; newer Claude
152
+ * models also xhigh; GPT-4 models and Claude Haiku 4.5 don't take it. Routing
153
+ * only picks a model that takes the value; a listed OpenAI or Claude model you
154
+ * name that doesn't is a 400 (other providers' models get the value as sent). On gpt-6-luna / -sol, `'none'` turns reasoning off (temperature,
155
+ * top_p and tools then work) and any other value turns it on (tools and
156
+ * top_p are then a 400). `rouva.run()` doesn't accept it.
157
+ * See https://rouva.io/docs#reasoning-effort
158
+ */
159
+ reasoning_effort?: ReasoningEffort | null;
160
+ /**
161
+ * With `tools` only: `false` limits the model to at most one tool call per
162
+ * turn. Forwarded to OpenAI-format providers; for Claude the gateway sets
163
+ * `disable_parallel_tool_use` on `tool_choice`. `rouva.run()` doesn't accept it.
164
+ */
165
+ parallel_tool_calls?: boolean | null;
135
166
  /**
136
167
  * Stream the response. Client-side only — controls whether `create()`
137
168
  * returns a ReadableStream or a buffered ChatCompletion; never sent to
@@ -154,6 +185,24 @@ interface ChatCompletionParams {
154
185
  */
155
186
  tool_choice?: string | Record<string, unknown>;
156
187
  }
188
+ /** `reasoning_effort` values the gateway accepts (which ones a model takes varies). */
189
+ type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
190
+ /** `response_format` values the gateway accepts. */
191
+ type ResponseFormat = {
192
+ type: 'text';
193
+ } | {
194
+ type: 'json_object';
195
+ } | {
196
+ type: 'json_schema';
197
+ json_schema: {
198
+ /** 1–64 letters, digits, underscores or dashes. */
199
+ name: string;
200
+ /** A JSON Schema object. */
201
+ schema: Record<string, unknown>;
202
+ strict?: boolean;
203
+ description?: string;
204
+ };
205
+ };
157
206
  /** Assistant message in a completed response; `tool_calls` present when the model requested tools */
158
207
  interface ResponseMessage {
159
208
  role: 'assistant';
@@ -180,13 +229,42 @@ interface ChatCompletion {
180
229
  /** Rouva metadata — cost, savings, routing decision */
181
230
  _rouva?: RouvaResponseMeta;
182
231
  }
232
+ /**
233
+ * Why Rouva sent a request to the model it did (header `X-Rouva-Route-Reason`):
234
+ * `intelligent` (Intelligent Routing chose it), `override` (an account routing
235
+ * override), `allowlist` (a workspace member's allowed-model list),
236
+ * `fallback` (a retry after a provider error), `escalation` (`run()` stepped
237
+ * up a model), `no_key_fallback` (the named provider has no key, so a
238
+ * connected one stood in), `direct` (the model you named, as named), or
239
+ * `dedup` (a replay of an identical request — no new call was made).
240
+ */
241
+ type RouvaRouteReason = 'intelligent' | 'override' | 'allowlist' | 'fallback' | 'escalation' | 'no_key_fallback' | 'direct' | 'dedup' | (string & {});
183
242
  interface RouvaResponseMeta {
184
- /** Actual model used when exposed by the gateway */
243
+ /**
244
+ * The request's id in your Rouva request log (`X-Rouva-Request-Id`). Pass it
245
+ * to `rouva.requests.get(id)` for the request's cost, savings and quality
246
+ * score. Absent on a duplicate-cache replay (no new request was recorded).
247
+ */
248
+ request_id?: string;
249
+ /** The model that served the request — after a fallback, the fallback model. */
185
250
  model_used?: string;
186
- /** Actual provider used when exposed by the gateway */
251
+ /** That model's provider, e.g. `openai`, `anthropic`. */
187
252
  provider_used?: string;
188
- /** Task type classified by Rouva when exposed by the gateway */
253
+ /** The model you named, when Rouva served a different one. */
254
+ requested_model?: string;
255
+ /** Task type classified by Rouva, e.g. `code`; absent when not classified. */
189
256
  task_type?: string;
257
+ /** Why this model was chosen. */
258
+ route_reason?: RouvaRouteReason;
259
+ /**
260
+ * What the request cost in USD, when the gateway sent it in a header: only
261
+ * on non-streaming responses once the cost is final, and as 0 on a
262
+ * duplicate-cache replay (`dedup: true`). The SDK streams, so otherwise read
263
+ * cost with `rouva.requests.get(request_id)`.
264
+ */
265
+ cost_usd?: number;
266
+ /** Saved against the model you named (or the priciest candidate), in USD; negative when the route cost more. Same availability as `cost_usd`. */
267
+ saved_usd?: number;
190
268
  /**
191
269
  * True when the gateway answered from its duplicate-request cache (an
192
270
  * identical request within the last 60 seconds) instead of calling the
@@ -199,6 +277,48 @@ interface RouvaResponseMeta {
199
277
  */
200
278
  cache?: string;
201
279
  }
280
+ /**
281
+ * The stream `chat.completions.create({ stream: true })` returns: OpenAI-shaped
282
+ * SSE bytes, plus the routing metadata from the response headers (known
283
+ * before the first chunk).
284
+ */
285
+ type RouvaStream = ReadableStream<Uint8Array> & {
286
+ _rouva?: RouvaResponseMeta;
287
+ };
288
+ /**
289
+ * A request's settlement state: `pending` (not settled yet — in flight, or it
290
+ * never reached the provider), `incomplete` (settled from partial usage, e.g. a
291
+ * stream cut short — the provider may have billed more), `settled` (tokens and
292
+ * cost are final), `scored` (final, and a quality score is recorded).
293
+ */
294
+ type RouvaRequestStatus = 'pending' | 'incomplete' | 'settled' | 'scored' | (string & {});
295
+ /** One request from your request log — `rouva.requests.get(id)`. */
296
+ interface RouvaRequest {
297
+ id: string;
298
+ /** ISO timestamp. */
299
+ created_at: string;
300
+ status: RouvaRequestStatus;
301
+ provider: string;
302
+ model: string;
303
+ requested_model: string | null;
304
+ task_type: string | null;
305
+ route_reason: RouvaRouteReason | null;
306
+ /** 0 while pending. */
307
+ input_tokens: number;
308
+ output_tokens: number;
309
+ /** USD; 0 while pending. Final once `status` is `settled` or `scored`. */
310
+ cost_usd: number;
311
+ /** USD saved against the model you named (or the priciest candidate); negative when the route cost more. */
312
+ saved_usd: number;
313
+ /** 0–1, or null until a score is recorded (or when none is). */
314
+ quality_score: number | null;
315
+ /** Whether the judge scored it, or why not (then `quality_score` is the heuristic checks'). */
316
+ judge_status: string | null;
317
+ finish_reason: string | null;
318
+ session_id: string | null;
319
+ /** Opens this request in the dashboard's request log. */
320
+ dashboard_url: string;
321
+ }
202
322
  interface RunParams {
203
323
  /**
204
324
  * Conversation history to seed the agent loop with.
@@ -301,9 +421,30 @@ declare class Rouva {
301
421
  private _sessionId;
302
422
  readonly chat: {
303
423
  completions: {
304
- create(params: ChatCompletionParams, options?: RequestOptions): Promise<ChatCompletion | ReadableStream>;
424
+ create(params: ChatCompletionParams, options?: RequestOptions): Promise<ChatCompletion | RouvaStream>;
305
425
  };
306
426
  };
427
+ readonly requests: {
428
+ /**
429
+ * Read one request from your request log (`GET /api/gateway/requests/{id}`)
430
+ * by the id a response carried (`_rouva.request_id`, header
431
+ * `X-Rouva-Request-Id`): its model, routing reason, tokens, cost, savings and
432
+ * quality score. A key reads only its own requests (another's id is a 404).
433
+ *
434
+ * A stream's cost is recorded just after its last chunk, and the quality
435
+ * score once the judge has run (a few seconds), so check `status`:
436
+ * `pending` → not settled yet, retry shortly; `incomplete` → settled from
437
+ * partial usage; `settled` → cost final; `scored` → cost final and scored.
438
+ * Throws `RouvaError` on a non-2xx response (a 503 carries `retryAfter`).
439
+ *
440
+ * @example
441
+ * const stream = await rouva.chat.completions.create({ messages, stream: true }) as RouvaStream
442
+ * // ...consume the stream...
443
+ * const req = await rouva.requests.get(stream._rouva!.request_id!)
444
+ * console.log(req.status, req.cost_usd, req.saved_usd)
445
+ */
446
+ get(id: string, options?: RequestOptions): Promise<RouvaRequest>;
447
+ };
307
448
  constructor(options: RouvaOptions);
308
449
  /**
309
450
  * Start a new agent session. Every subsequent `chat.completions.create()`
@@ -347,7 +488,9 @@ declare class Rouva {
347
488
  * console.log(result.session_id) // dashboard session ID
348
489
  */
349
490
  run(params: RunParams, options?: RequestOptions): Promise<RunResult>;
491
+ private _getRequest;
350
492
  private _post;
493
+ private _fetch;
351
494
  private _createChatCompletion;
352
495
  }
353
496
 
@@ -378,9 +521,16 @@ declare class RouvaError extends Error {
378
521
  readonly retryAfter?: number;
379
522
  /** The gateway's error message (`error` field), when the body is JSON. */
380
523
  readonly error?: string;
524
+ /**
525
+ * The request's id in your Rouva request log (`X-Rouva-Request-Id`), when
526
+ * the error came after the request was recorded — e.g. a provider error.
527
+ * Look it up with `rouva.requests.get(requestId)`. Absent on errors Rouva
528
+ * returns before recording the request (validation 400s, auth).
529
+ */
530
+ readonly requestId?: string;
381
531
  /** The parsed JSON body, or the raw text when it isn't JSON. */
382
532
  readonly body: unknown;
383
- constructor(kind: 'Gateway' | 'Run', status: number, headers: Headers, rawBody: string);
533
+ constructor(kind: 'Gateway' | 'Run' | 'Request', status: number, headers: Headers, rawBody: string);
384
534
  }
385
535
 
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 };
536
+ export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type FunctionToolCall, type Message, type ReasoningEffort, 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,37 @@ 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;
146
+ /**
147
+ * How hard a reasoning model thinks (gateway support since 2026-10-21).
148
+ * OpenAI models take it as is; Claude gets it as `output_config.effort`.
149
+ * Values depend on the model: gpt-5 / -mini / -nano take minimal–high;
150
+ * gpt-5.4-mini, gpt-5.6-*, gpt-6-luna / -sol take none–xhigh; gpt-6.1-sol
151
+ * low–xhigh; Claude Sonnet / Opus 4.6 low, medium, high, max; newer Claude
152
+ * models also xhigh; GPT-4 models and Claude Haiku 4.5 don't take it. Routing
153
+ * only picks a model that takes the value; a listed OpenAI or Claude model you
154
+ * name that doesn't is a 400 (other providers' models get the value as sent). On gpt-6-luna / -sol, `'none'` turns reasoning off (temperature,
155
+ * top_p and tools then work) and any other value turns it on (tools and
156
+ * top_p are then a 400). `rouva.run()` doesn't accept it.
157
+ * See https://rouva.io/docs#reasoning-effort
158
+ */
159
+ reasoning_effort?: ReasoningEffort | null;
160
+ /**
161
+ * With `tools` only: `false` limits the model to at most one tool call per
162
+ * turn. Forwarded to OpenAI-format providers; for Claude the gateway sets
163
+ * `disable_parallel_tool_use` on `tool_choice`. `rouva.run()` doesn't accept it.
164
+ */
165
+ parallel_tool_calls?: boolean | null;
135
166
  /**
136
167
  * Stream the response. Client-side only — controls whether `create()`
137
168
  * returns a ReadableStream or a buffered ChatCompletion; never sent to
@@ -154,6 +185,24 @@ interface ChatCompletionParams {
154
185
  */
155
186
  tool_choice?: string | Record<string, unknown>;
156
187
  }
188
+ /** `reasoning_effort` values the gateway accepts (which ones a model takes varies). */
189
+ type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
190
+ /** `response_format` values the gateway accepts. */
191
+ type ResponseFormat = {
192
+ type: 'text';
193
+ } | {
194
+ type: 'json_object';
195
+ } | {
196
+ type: 'json_schema';
197
+ json_schema: {
198
+ /** 1–64 letters, digits, underscores or dashes. */
199
+ name: string;
200
+ /** A JSON Schema object. */
201
+ schema: Record<string, unknown>;
202
+ strict?: boolean;
203
+ description?: string;
204
+ };
205
+ };
157
206
  /** Assistant message in a completed response; `tool_calls` present when the model requested tools */
158
207
  interface ResponseMessage {
159
208
  role: 'assistant';
@@ -180,13 +229,42 @@ interface ChatCompletion {
180
229
  /** Rouva metadata — cost, savings, routing decision */
181
230
  _rouva?: RouvaResponseMeta;
182
231
  }
232
+ /**
233
+ * Why Rouva sent a request to the model it did (header `X-Rouva-Route-Reason`):
234
+ * `intelligent` (Intelligent Routing chose it), `override` (an account routing
235
+ * override), `allowlist` (a workspace member's allowed-model list),
236
+ * `fallback` (a retry after a provider error), `escalation` (`run()` stepped
237
+ * up a model), `no_key_fallback` (the named provider has no key, so a
238
+ * connected one stood in), `direct` (the model you named, as named), or
239
+ * `dedup` (a replay of an identical request — no new call was made).
240
+ */
241
+ type RouvaRouteReason = 'intelligent' | 'override' | 'allowlist' | 'fallback' | 'escalation' | 'no_key_fallback' | 'direct' | 'dedup' | (string & {});
183
242
  interface RouvaResponseMeta {
184
- /** Actual model used when exposed by the gateway */
243
+ /**
244
+ * The request's id in your Rouva request log (`X-Rouva-Request-Id`). Pass it
245
+ * to `rouva.requests.get(id)` for the request's cost, savings and quality
246
+ * score. Absent on a duplicate-cache replay (no new request was recorded).
247
+ */
248
+ request_id?: string;
249
+ /** The model that served the request — after a fallback, the fallback model. */
185
250
  model_used?: string;
186
- /** Actual provider used when exposed by the gateway */
251
+ /** That model's provider, e.g. `openai`, `anthropic`. */
187
252
  provider_used?: string;
188
- /** Task type classified by Rouva when exposed by the gateway */
253
+ /** The model you named, when Rouva served a different one. */
254
+ requested_model?: string;
255
+ /** Task type classified by Rouva, e.g. `code`; absent when not classified. */
189
256
  task_type?: string;
257
+ /** Why this model was chosen. */
258
+ route_reason?: RouvaRouteReason;
259
+ /**
260
+ * What the request cost in USD, when the gateway sent it in a header: only
261
+ * on non-streaming responses once the cost is final, and as 0 on a
262
+ * duplicate-cache replay (`dedup: true`). The SDK streams, so otherwise read
263
+ * cost with `rouva.requests.get(request_id)`.
264
+ */
265
+ cost_usd?: number;
266
+ /** Saved against the model you named (or the priciest candidate), in USD; negative when the route cost more. Same availability as `cost_usd`. */
267
+ saved_usd?: number;
190
268
  /**
191
269
  * True when the gateway answered from its duplicate-request cache (an
192
270
  * identical request within the last 60 seconds) instead of calling the
@@ -199,6 +277,48 @@ interface RouvaResponseMeta {
199
277
  */
200
278
  cache?: string;
201
279
  }
280
+ /**
281
+ * The stream `chat.completions.create({ stream: true })` returns: OpenAI-shaped
282
+ * SSE bytes, plus the routing metadata from the response headers (known
283
+ * before the first chunk).
284
+ */
285
+ type RouvaStream = ReadableStream<Uint8Array> & {
286
+ _rouva?: RouvaResponseMeta;
287
+ };
288
+ /**
289
+ * A request's settlement state: `pending` (not settled yet — in flight, or it
290
+ * never reached the provider), `incomplete` (settled from partial usage, e.g. a
291
+ * stream cut short — the provider may have billed more), `settled` (tokens and
292
+ * cost are final), `scored` (final, and a quality score is recorded).
293
+ */
294
+ type RouvaRequestStatus = 'pending' | 'incomplete' | 'settled' | 'scored' | (string & {});
295
+ /** One request from your request log — `rouva.requests.get(id)`. */
296
+ interface RouvaRequest {
297
+ id: string;
298
+ /** ISO timestamp. */
299
+ created_at: string;
300
+ status: RouvaRequestStatus;
301
+ provider: string;
302
+ model: string;
303
+ requested_model: string | null;
304
+ task_type: string | null;
305
+ route_reason: RouvaRouteReason | null;
306
+ /** 0 while pending. */
307
+ input_tokens: number;
308
+ output_tokens: number;
309
+ /** USD; 0 while pending. Final once `status` is `settled` or `scored`. */
310
+ cost_usd: number;
311
+ /** USD saved against the model you named (or the priciest candidate); negative when the route cost more. */
312
+ saved_usd: number;
313
+ /** 0–1, or null until a score is recorded (or when none is). */
314
+ quality_score: number | null;
315
+ /** Whether the judge scored it, or why not (then `quality_score` is the heuristic checks'). */
316
+ judge_status: string | null;
317
+ finish_reason: string | null;
318
+ session_id: string | null;
319
+ /** Opens this request in the dashboard's request log. */
320
+ dashboard_url: string;
321
+ }
202
322
  interface RunParams {
203
323
  /**
204
324
  * Conversation history to seed the agent loop with.
@@ -301,9 +421,30 @@ declare class Rouva {
301
421
  private _sessionId;
302
422
  readonly chat: {
303
423
  completions: {
304
- create(params: ChatCompletionParams, options?: RequestOptions): Promise<ChatCompletion | ReadableStream>;
424
+ create(params: ChatCompletionParams, options?: RequestOptions): Promise<ChatCompletion | RouvaStream>;
305
425
  };
306
426
  };
427
+ readonly requests: {
428
+ /**
429
+ * Read one request from your request log (`GET /api/gateway/requests/{id}`)
430
+ * by the id a response carried (`_rouva.request_id`, header
431
+ * `X-Rouva-Request-Id`): its model, routing reason, tokens, cost, savings and
432
+ * quality score. A key reads only its own requests (another's id is a 404).
433
+ *
434
+ * A stream's cost is recorded just after its last chunk, and the quality
435
+ * score once the judge has run (a few seconds), so check `status`:
436
+ * `pending` → not settled yet, retry shortly; `incomplete` → settled from
437
+ * partial usage; `settled` → cost final; `scored` → cost final and scored.
438
+ * Throws `RouvaError` on a non-2xx response (a 503 carries `retryAfter`).
439
+ *
440
+ * @example
441
+ * const stream = await rouva.chat.completions.create({ messages, stream: true }) as RouvaStream
442
+ * // ...consume the stream...
443
+ * const req = await rouva.requests.get(stream._rouva!.request_id!)
444
+ * console.log(req.status, req.cost_usd, req.saved_usd)
445
+ */
446
+ get(id: string, options?: RequestOptions): Promise<RouvaRequest>;
447
+ };
307
448
  constructor(options: RouvaOptions);
308
449
  /**
309
450
  * Start a new agent session. Every subsequent `chat.completions.create()`
@@ -347,7 +488,9 @@ declare class Rouva {
347
488
  * console.log(result.session_id) // dashboard session ID
348
489
  */
349
490
  run(params: RunParams, options?: RequestOptions): Promise<RunResult>;
491
+ private _getRequest;
350
492
  private _post;
493
+ private _fetch;
351
494
  private _createChatCompletion;
352
495
  }
353
496
 
@@ -378,9 +521,16 @@ declare class RouvaError extends Error {
378
521
  readonly retryAfter?: number;
379
522
  /** The gateway's error message (`error` field), when the body is JSON. */
380
523
  readonly error?: string;
524
+ /**
525
+ * The request's id in your Rouva request log (`X-Rouva-Request-Id`), when
526
+ * the error came after the request was recorded — e.g. a provider error.
527
+ * Look it up with `rouva.requests.get(requestId)`. Absent on errors Rouva
528
+ * returns before recording the request (validation 400s, auth).
529
+ */
530
+ readonly requestId?: string;
381
531
  /** The parsed JSON body, or the raw text when it isn't JSON. */
382
532
  readonly body: unknown;
383
- constructor(kind: 'Gateway' | 'Run', status: number, headers: Headers, rawBody: string);
533
+ constructor(kind: 'Gateway' | 'Run' | 'Request', status: number, headers: Headers, rawBody: string);
384
534
  }
385
535
 
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 };
536
+ export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type FunctionToolCall, type Message, type ReasoningEffort, 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.1",
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",