@rouvanpm/rouva 0.4.0 → 0.5.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
@@ -105,8 +105,10 @@ const res = await rouva.chat.completions.create({
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
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).
108
110
 
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.
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.
110
112
 
111
113
  ## Unlisted models
112
114
 
@@ -128,7 +130,7 @@ Non-tools requests on `/v1` honor the named model exactly unless the workspace o
128
130
 
129
131
  ## Tool use
130
132
 
131
- Tools are forwarded to your target provider verbatim — define them in the **provider's own format** (OpenAI `{ type: "function", function: {...} }` or Anthropic `{ name, description, input_schema }`) and pin the matching `model`. Tool schemas are provider-specific, so `model` is required and the gateway returns a 400 without it. Models that don't support tools on Chat Completions (`gpt-6.1-sol`, `gpt-5.6-*`) also return a 400.
133
+ Tools are forwarded to your target provider verbatim — define them in the **provider's own format** (OpenAI `{ type: "function", function: {...} }` or Anthropic `{ name, description, input_schema }`) and pin the matching `model`. Tool schemas are provider-specific, so `model` is required and the gateway returns a 400 without it. `gpt-6.1-sol` and `gpt-5.6-*` take tools only through OpenAI's Responses API: the gateway uses it for you and converts both ways, so you still send and receive Chat Completions (`seed`, `stop` and a `max_tokens` below 16 are a 400 there). `rouva.run()` still refuses these models.
132
134
 
133
135
  A tools request only ever moves to another model of the **same provider**: an account routing override for the task (when it targets the same provider), or — on the `/v1` endpoint with Intelligent Routing enabled — the cheapest capable model within that provider, using the named `model` as a cost ceiling (see [OpenAI-compatible endpoint](#openai-compatible-endpoint)). Otherwise the named model is honored exactly.
134
136
 
@@ -354,6 +356,25 @@ if (id) {
354
356
 
355
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).
356
358
 
359
+ ## Explain a routing decision — `rouva.explain()`
360
+
361
+ A dry run: the model Rouva would pick for a request, why, and the most it could cost — without calling a provider, recording a request or reserving spend.
362
+
363
+ ```typescript
364
+ const e = await rouva.explain({ model: 'gpt-6-sol', messages })
365
+
366
+ if (e.would_send) {
367
+ console.log(e.model, e.provider) // e.g. 'gpt-6-luna', 'openai'
368
+ console.log(e.route_reason) // 'intelligent' | 'override' | 'allowlist' | 'direct' | …
369
+ console.log(e.task_type) // e.g. 'summarize'
370
+ console.log(e.max_cost_usd) // what a spend-capped key would reserve
371
+ } else {
372
+ console.log(e.status, e.error) // the refusal the real request would get
373
+ }
374
+ ```
375
+
376
+ It takes the same params as `chat.completions.create()`; pass `{ endpoint: 'v1' }` to explain the same body sent to `/v1/chat/completions`. The prompt is classified as for a real request, and explain has its own per-minute rate limit (a `RouvaError` with `refusal: 'rate'`). Not covered: the duplicate-request cache, a spend cap's remaining budget, and what the provider answers.
377
+
357
378
  ## Structured output
358
379
 
359
380
  `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.
package/dist/index.d.mts CHANGED
@@ -143,6 +143,27 @@ interface ChatCompletionParams {
143
143
  * `rouva.run()` doesn't accept it. See https://rouva.io/docs#structured-output
144
144
  */
145
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 (top_p is then
156
+ * a 400; tools are served through OpenAI's Responses API). `rouva.run()`
157
+ * doesn't accept it.
158
+ * See https://rouva.io/docs#reasoning-effort
159
+ */
160
+ reasoning_effort?: ReasoningEffort | null;
161
+ /**
162
+ * With `tools` only: `false` limits the model to at most one tool call per
163
+ * turn. Forwarded to OpenAI-format providers; for Claude the gateway sets
164
+ * `disable_parallel_tool_use` on `tool_choice`. `rouva.run()` doesn't accept it.
165
+ */
166
+ parallel_tool_calls?: boolean | null;
146
167
  /**
147
168
  * Stream the response. Client-side only — controls whether `create()`
148
169
  * returns a ReadableStream or a buffered ChatCompletion; never sent to
@@ -155,8 +176,10 @@ interface ChatCompletionParams {
155
176
  * Anthropic `{ name, description, input_schema }`). Requires `model`.
156
177
  * Tool schemas are provider-specific, so a tools request only ever moves to
157
178
  * another model of the SAME provider (a routing override, or cheaper-model
158
- * routing on /v1). Models that don't support tools on Chat Completions
159
- * (gpt-6.1-sol, gpt-5.6-*) return a 400.
179
+ * routing on /v1). gpt-6.1-sol and gpt-5.6-* take tools only through
180
+ * OpenAI's Responses API: the gateway uses it for you and converts both ways
181
+ * (you still send and receive Chat Completions), and there `seed`, `stop` and
182
+ * a `max_tokens` below 16 are a 400. `rouva.run()` still refuses these models.
160
183
  */
161
184
  tools?: Array<Record<string, unknown>>;
162
185
  /**
@@ -165,6 +188,66 @@ interface ChatCompletionParams {
165
188
  */
166
189
  tool_choice?: string | Record<string, unknown>;
167
190
  }
191
+ /**
192
+ * What `rouva.explain()` returns when the request would be sent: the model
193
+ * Rouva would pick, why, and the most it could cost — nothing was sent,
194
+ * recorded or reserved.
195
+ */
196
+ interface RouvaExplanation {
197
+ dry_run: true;
198
+ would_send: true;
199
+ endpoint: '/api/gateway/messages' | '/v1/chat/completions';
200
+ provider: string;
201
+ model: string;
202
+ /** The model you named, or null. */
203
+ requested_model: string | null;
204
+ task_type: string | null;
205
+ route_reason: RouvaRouteReason;
206
+ /** How the prompt was classified (e.g. `jev`, `embedding`, `cache`). */
207
+ classified_by: string | null;
208
+ /** Jev's confidence (0–1), when Jev classified it. */
209
+ classifier_confidence: number | null;
210
+ classifier_latency_ms: number | null;
211
+ max_tokens: number;
212
+ stream: boolean;
213
+ /** The model's billed rates in USD per million tokens, or null when it has no known price. */
214
+ price_per_1m: {
215
+ input: number;
216
+ output: number;
217
+ } | null;
218
+ /**
219
+ * The most the request could cost in USD — what a spend-capped key reserves
220
+ * (the input as sent plus max_tokens of output, plus reasoning and
221
+ * summarization allowances where they apply). null: no known price.
222
+ */
223
+ max_cost_usd: number | null;
224
+ /** A long conversation's older turns would be summarized first (Claude). */
225
+ summarize_history: boolean;
226
+ spend_capped: boolean;
227
+ }
228
+ /** What `rouva.explain()` returns when the real request would be refused. */
229
+ interface RouvaExplainedRefusal {
230
+ dry_run: true;
231
+ would_send: false;
232
+ /** The status the real request would get. */
233
+ status: number;
234
+ /** The error it would get. */
235
+ error: unknown;
236
+ /** `X-Rouva-Refusal`, when Rouva marks it (e.g. `unbounded`, `key_cap`). */
237
+ refusal: string | null;
238
+ /** The model Rouva had chosen before refusing, when it had chosen one. */
239
+ provider: string | null;
240
+ model: string | null;
241
+ route_reason: RouvaRouteReason | null;
242
+ }
243
+ type RouvaExplainResult = RouvaExplanation | RouvaExplainedRefusal;
244
+ /** Options for `rouva.explain()`. */
245
+ interface ExplainOptions extends RequestOptions {
246
+ /** `'v1'` explains the same body sent to /v1/chat/completions (which pins the named model). */
247
+ endpoint?: 'messages' | 'v1';
248
+ }
249
+ /** `reasoning_effort` values the gateway accepts (which ones a model takes varies). */
250
+ type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
168
251
  /** `response_format` values the gateway accepts. */
169
252
  type ResponseFormat = {
170
253
  type: 'text';
@@ -468,6 +551,25 @@ declare class Rouva {
468
551
  run(params: RunParams, options?: RequestOptions): Promise<RunResult>;
469
552
  private _getRequest;
470
553
  private _post;
554
+ /**
555
+ * Dry run (`POST /api/gateway/explain`): what Rouva would do with this
556
+ * request — the model and provider it would pick, why (`route_reason`), how
557
+ * the prompt was classified, and the most it could cost (`max_cost_usd`) —
558
+ * without calling a provider, recording a request or reserving spend. Takes
559
+ * the same params as `chat.completions.create()` (`stream` is ignored, as
560
+ * there). When the real request would be refused, resolves with
561
+ * `would_send: false` and the status and error it would get. Throws
562
+ * `RouvaError` for a bad key or the explain rate limit (429, `refusal: 'rate'`).
563
+ *
564
+ * The prompt is classified as for a real request. Not covered: the
565
+ * duplicate-request cache, a spend cap's remaining budget, provider errors.
566
+ *
567
+ * @example
568
+ * const e = await rouva.explain({ model: 'gpt-6-sol', messages })
569
+ * if (e.would_send) console.log(e.model, e.route_reason, e.max_cost_usd)
570
+ * else console.log(e.status, e.error)
571
+ */
572
+ explain(params: ChatCompletionParams, options?: ExplainOptions): Promise<RouvaExplainResult>;
471
573
  private _fetch;
472
574
  private _createChatCompletion;
473
575
  }
@@ -508,7 +610,7 @@ declare class RouvaError extends Error {
508
610
  readonly requestId?: string;
509
611
  /** The parsed JSON body, or the raw text when it isn't JSON. */
510
612
  readonly body: unknown;
511
- constructor(kind: 'Gateway' | 'Run' | 'Request', status: number, headers: Headers, rawBody: string);
613
+ constructor(kind: 'Gateway' | 'Run' | 'Request' | 'Explain', status: number, headers: Headers, rawBody: string);
512
614
  }
513
615
 
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 };
616
+ export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type ExplainOptions, type FunctionToolCall, type Message, type ReasoningEffort, type RequestOptions, type ResponseFormat, type ResponseMessage, Rouva, RouvaError, type RouvaExplainResult, type RouvaExplainedRefusal, type RouvaExplanation, 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
@@ -143,6 +143,27 @@ interface ChatCompletionParams {
143
143
  * `rouva.run()` doesn't accept it. See https://rouva.io/docs#structured-output
144
144
  */
145
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 (top_p is then
156
+ * a 400; tools are served through OpenAI's Responses API). `rouva.run()`
157
+ * doesn't accept it.
158
+ * See https://rouva.io/docs#reasoning-effort
159
+ */
160
+ reasoning_effort?: ReasoningEffort | null;
161
+ /**
162
+ * With `tools` only: `false` limits the model to at most one tool call per
163
+ * turn. Forwarded to OpenAI-format providers; for Claude the gateway sets
164
+ * `disable_parallel_tool_use` on `tool_choice`. `rouva.run()` doesn't accept it.
165
+ */
166
+ parallel_tool_calls?: boolean | null;
146
167
  /**
147
168
  * Stream the response. Client-side only — controls whether `create()`
148
169
  * returns a ReadableStream or a buffered ChatCompletion; never sent to
@@ -155,8 +176,10 @@ interface ChatCompletionParams {
155
176
  * Anthropic `{ name, description, input_schema }`). Requires `model`.
156
177
  * Tool schemas are provider-specific, so a tools request only ever moves to
157
178
  * another model of the SAME provider (a routing override, or cheaper-model
158
- * routing on /v1). Models that don't support tools on Chat Completions
159
- * (gpt-6.1-sol, gpt-5.6-*) return a 400.
179
+ * routing on /v1). gpt-6.1-sol and gpt-5.6-* take tools only through
180
+ * OpenAI's Responses API: the gateway uses it for you and converts both ways
181
+ * (you still send and receive Chat Completions), and there `seed`, `stop` and
182
+ * a `max_tokens` below 16 are a 400. `rouva.run()` still refuses these models.
160
183
  */
161
184
  tools?: Array<Record<string, unknown>>;
162
185
  /**
@@ -165,6 +188,66 @@ interface ChatCompletionParams {
165
188
  */
166
189
  tool_choice?: string | Record<string, unknown>;
167
190
  }
191
+ /**
192
+ * What `rouva.explain()` returns when the request would be sent: the model
193
+ * Rouva would pick, why, and the most it could cost — nothing was sent,
194
+ * recorded or reserved.
195
+ */
196
+ interface RouvaExplanation {
197
+ dry_run: true;
198
+ would_send: true;
199
+ endpoint: '/api/gateway/messages' | '/v1/chat/completions';
200
+ provider: string;
201
+ model: string;
202
+ /** The model you named, or null. */
203
+ requested_model: string | null;
204
+ task_type: string | null;
205
+ route_reason: RouvaRouteReason;
206
+ /** How the prompt was classified (e.g. `jev`, `embedding`, `cache`). */
207
+ classified_by: string | null;
208
+ /** Jev's confidence (0–1), when Jev classified it. */
209
+ classifier_confidence: number | null;
210
+ classifier_latency_ms: number | null;
211
+ max_tokens: number;
212
+ stream: boolean;
213
+ /** The model's billed rates in USD per million tokens, or null when it has no known price. */
214
+ price_per_1m: {
215
+ input: number;
216
+ output: number;
217
+ } | null;
218
+ /**
219
+ * The most the request could cost in USD — what a spend-capped key reserves
220
+ * (the input as sent plus max_tokens of output, plus reasoning and
221
+ * summarization allowances where they apply). null: no known price.
222
+ */
223
+ max_cost_usd: number | null;
224
+ /** A long conversation's older turns would be summarized first (Claude). */
225
+ summarize_history: boolean;
226
+ spend_capped: boolean;
227
+ }
228
+ /** What `rouva.explain()` returns when the real request would be refused. */
229
+ interface RouvaExplainedRefusal {
230
+ dry_run: true;
231
+ would_send: false;
232
+ /** The status the real request would get. */
233
+ status: number;
234
+ /** The error it would get. */
235
+ error: unknown;
236
+ /** `X-Rouva-Refusal`, when Rouva marks it (e.g. `unbounded`, `key_cap`). */
237
+ refusal: string | null;
238
+ /** The model Rouva had chosen before refusing, when it had chosen one. */
239
+ provider: string | null;
240
+ model: string | null;
241
+ route_reason: RouvaRouteReason | null;
242
+ }
243
+ type RouvaExplainResult = RouvaExplanation | RouvaExplainedRefusal;
244
+ /** Options for `rouva.explain()`. */
245
+ interface ExplainOptions extends RequestOptions {
246
+ /** `'v1'` explains the same body sent to /v1/chat/completions (which pins the named model). */
247
+ endpoint?: 'messages' | 'v1';
248
+ }
249
+ /** `reasoning_effort` values the gateway accepts (which ones a model takes varies). */
250
+ type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
168
251
  /** `response_format` values the gateway accepts. */
169
252
  type ResponseFormat = {
170
253
  type: 'text';
@@ -468,6 +551,25 @@ declare class Rouva {
468
551
  run(params: RunParams, options?: RequestOptions): Promise<RunResult>;
469
552
  private _getRequest;
470
553
  private _post;
554
+ /**
555
+ * Dry run (`POST /api/gateway/explain`): what Rouva would do with this
556
+ * request — the model and provider it would pick, why (`route_reason`), how
557
+ * the prompt was classified, and the most it could cost (`max_cost_usd`) —
558
+ * without calling a provider, recording a request or reserving spend. Takes
559
+ * the same params as `chat.completions.create()` (`stream` is ignored, as
560
+ * there). When the real request would be refused, resolves with
561
+ * `would_send: false` and the status and error it would get. Throws
562
+ * `RouvaError` for a bad key or the explain rate limit (429, `refusal: 'rate'`).
563
+ *
564
+ * The prompt is classified as for a real request. Not covered: the
565
+ * duplicate-request cache, a spend cap's remaining budget, provider errors.
566
+ *
567
+ * @example
568
+ * const e = await rouva.explain({ model: 'gpt-6-sol', messages })
569
+ * if (e.would_send) console.log(e.model, e.route_reason, e.max_cost_usd)
570
+ * else console.log(e.status, e.error)
571
+ */
572
+ explain(params: ChatCompletionParams, options?: ExplainOptions): Promise<RouvaExplainResult>;
471
573
  private _fetch;
472
574
  private _createChatCompletion;
473
575
  }
@@ -508,7 +610,7 @@ declare class RouvaError extends Error {
508
610
  readonly requestId?: string;
509
611
  /** The parsed JSON body, or the raw text when it isn't JSON. */
510
612
  readonly body: unknown;
511
- constructor(kind: 'Gateway' | 'Run' | 'Request', status: number, headers: Headers, rawBody: string);
613
+ constructor(kind: 'Gateway' | 'Run' | 'Request' | 'Explain', status: number, headers: Headers, rawBody: string);
512
614
  }
513
615
 
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 };
616
+ export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type ExplainOptions, type FunctionToolCall, type Message, type ReasoningEffort, type RequestOptions, type ResponseFormat, type ResponseMessage, Rouva, RouvaError, type RouvaExplainResult, type RouvaExplainedRefusal, type RouvaExplanation, 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
@@ -492,6 +492,31 @@ var Rouva = class {
492
492
  _post(path, body, extraHeaders, options) {
493
493
  return this._fetch("POST", path, body, extraHeaders, options);
494
494
  }
495
+ /**
496
+ * Dry run (`POST /api/gateway/explain`): what Rouva would do with this
497
+ * request — the model and provider it would pick, why (`route_reason`), how
498
+ * the prompt was classified, and the most it could cost (`max_cost_usd`) —
499
+ * without calling a provider, recording a request or reserving spend. Takes
500
+ * the same params as `chat.completions.create()` (`stream` is ignored, as
501
+ * there). When the real request would be refused, resolves with
502
+ * `would_send: false` and the status and error it would get. Throws
503
+ * `RouvaError` for a bad key or the explain rate limit (429, `refusal: 'rate'`).
504
+ *
505
+ * The prompt is classified as for a real request. Not covered: the
506
+ * duplicate-request cache, a spend cap's remaining budget, provider errors.
507
+ *
508
+ * @example
509
+ * const e = await rouva.explain({ model: 'gpt-6-sol', messages })
510
+ * if (e.would_send) console.log(e.model, e.route_reason, e.max_cost_usd)
511
+ * else console.log(e.status, e.error)
512
+ */
513
+ async explain(params, options) {
514
+ const { stream: _stream, ...body } = params;
515
+ const query = options?.endpoint === "v1" ? "?endpoint=v1" : "";
516
+ const res = await this._post(`/api/gateway/explain${query}`, body, {}, options);
517
+ if (!res.ok) throw await toRouvaError("Explain", res);
518
+ return res.json();
519
+ }
495
520
  // One request path for every endpoint: auth, JSON body, optional abort signal.
496
521
  _fetch(method, path, body, extraHeaders, options) {
497
522
  return fetch(`${this.baseURL}${path}`, {
package/dist/index.mjs CHANGED
@@ -465,6 +465,31 @@ var Rouva = class {
465
465
  _post(path, body, extraHeaders, options) {
466
466
  return this._fetch("POST", path, body, extraHeaders, options);
467
467
  }
468
+ /**
469
+ * Dry run (`POST /api/gateway/explain`): what Rouva would do with this
470
+ * request — the model and provider it would pick, why (`route_reason`), how
471
+ * the prompt was classified, and the most it could cost (`max_cost_usd`) —
472
+ * without calling a provider, recording a request or reserving spend. Takes
473
+ * the same params as `chat.completions.create()` (`stream` is ignored, as
474
+ * there). When the real request would be refused, resolves with
475
+ * `would_send: false` and the status and error it would get. Throws
476
+ * `RouvaError` for a bad key or the explain rate limit (429, `refusal: 'rate'`).
477
+ *
478
+ * The prompt is classified as for a real request. Not covered: the
479
+ * duplicate-request cache, a spend cap's remaining budget, provider errors.
480
+ *
481
+ * @example
482
+ * const e = await rouva.explain({ model: 'gpt-6-sol', messages })
483
+ * if (e.would_send) console.log(e.model, e.route_reason, e.max_cost_usd)
484
+ * else console.log(e.status, e.error)
485
+ */
486
+ async explain(params, options) {
487
+ const { stream: _stream, ...body } = params;
488
+ const query = options?.endpoint === "v1" ? "?endpoint=v1" : "";
489
+ const res = await this._post(`/api/gateway/explain${query}`, body, {}, options);
490
+ if (!res.ok) throw await toRouvaError("Explain", res);
491
+ return res.json();
492
+ }
468
493
  // One request path for every endpoint: auth, JSON body, optional abort signal.
469
494
  _fetch(method, path, body, extraHeaders, options) {
470
495
  return fetch(`${this.baseURL}${path}`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rouvanpm/rouva",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",