@rouvanpm/rouva 0.2.13 → 0.3.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
@@ -24,7 +24,7 @@ console.log(response.choices[0].message.content)
24
24
 
25
25
  ## Provider agnostic
26
26
 
27
- Rouva works with all connected providers — Anthropic, OpenAI, Gemini, DeepSeek, Mistral, Moonshot, xAI, and Z.ai. You can request a specific model, force a specific provider, or omit both and let Rouva route to the cheapest capable model automatically.
27
+ Rouva works with all connected providers — Anthropic, OpenAI, Gemini, DeepSeek, Mistral, Moonshot, xAI, Z.ai and Alibaba Qwen. You can request a specific model, force a specific provider, or omit both and let Rouva route to the cheapest capable model automatically.
28
28
 
29
29
  ```typescript
30
30
  // Request a specific model
@@ -89,43 +89,47 @@ const res = await rouva.chat.completions.create({
89
89
  model: 'gpt-4o-mini', // 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
- max_tokens: 1024, // default 4096
92
+ max_tokens: 1024, // default 4096 (or max_completion_tokens)
93
93
  temperature: 0.7, // 0–1
94
94
  top_p: 0.9, // nucleus sampling, (0, 1]
95
95
  stop: ['END'], // up to 4 stop sequences
96
96
  seed: 42, // pins the request to this exact model
97
+ metadata: { feature: 'search' }, // your labels, stored with the usage record
97
98
  })
98
99
  ```
99
100
 
100
- - **`temperature`** — sampling temperature between 0 and 1. Reasoning models (the gpt-5 family) only support their default temperature, so the gateway omits it when routing to one.
101
- - **`max_tokens`** — values below 1024 also steer auto-routing away from reasoning models, which would otherwise spend the whole budget on hidden reasoning tokens.
102
- - **`top_p`** — nucleus sampling, forwarded to every provider; `top_p: 1` is treated as omitted. OpenAI reasoning models don't support it: auto-routing avoids them when it's set, and pinning one returns a 400.
103
- - **`stop`** — a string or up to 4 stop sequences, forwarded to every provider (Anthropic receives them as `stop_sequences`).
101
+ - **`temperature`** — sampling temperature between 0 and 1. The gateway omits it for models that only accept their default: OpenAI reasoning models (gpt-5 / gpt-6 families) and the Claude models that reject it (Opus 4.7 and newer, Sonnet 5 and newer, Fable).
102
+ - **`max_tokens`** / **`max_completion_tokens`** — either name works. Values below 1024 also steer auto-routing away from reasoning models, which would otherwise spend the whole budget on hidden reasoning tokens.
103
+ - **`top_p`** — nucleus sampling; `top_p: 1` is treated as omitted. OpenAI reasoning models don't support it: routing avoids them when it's set, and pinning one returns a 400. The gateway omits it for the Claude models that reject it (as for `temperature`).
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
+ - **`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.
104
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).
105
107
 
106
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.
107
109
 
108
110
  ## Unlisted models
109
111
 
110
- Dated snapshots (`gpt-4o-mini-2024-07-18`) and fine-tunes (`ft:gpt-4o-mini:…`) work when pinned — they are matched to their provider by naming convention and forwarded as-is. Until the gateway's pricing registry knows them, the dashboard records their token counts with zero cost.
111
-
112
- Models not recognised by the gateway return a `400` immediately:
112
+ `chat.completions.create()` serves the models the gateway can price — its built-in list (see `RouvaModel` and rouva.io/docs) plus models added to its synced model registry, such as new snapshots. Any other ID returns a `404` immediately:
113
113
 
114
114
  ```json
115
- { "error": "Model \"<id>\" is not supported. See rouva.io/docs for the supported model list." }
115
+ { "error": "The model `<id>` does not exist or is not supported. See rouva.io/docs for the supported model list." }
116
116
  ```
117
117
 
118
+ `run()` also accepts an ID whose provider it can infer from the name (e.g. a dated snapshot); without a known price it's logged with no cost, and it's refused for spend-capped keys or runs with `session_budget_usd`.
119
+
120
+ `'gpt-5.6'` is a deprecated alias for `'gpt-5.6-sol'` — use the full name.
121
+
118
122
  ## OpenAI-compatible endpoint
119
123
 
120
124
  Using the OpenAI SDK (or LangChain, the Vercel AI SDK, …) instead of this one? The gateway is also served at `POST /v1/chat/completions` — point `baseURL` at `https://app.rouva.io/v1` with your `rva_` key and it behaves exactly like OpenAI: `model` is required, responses are buffered JSON unless `stream: true`, and only OpenAI-format providers are available (use this SDK or the native endpoint for Anthropic models).
121
125
 
122
- Non-tools requests on `/v1` always honor the named model exactly. **Tools requests on `/v1` benefit from within-provider intelligent routing** when Intelligent Routing is enabled in your dashboard — Rouva picks the cheapest capable model within the same provider, using the named `model` as a cost ceiling. The provider never changes (tool schemas are provider-specific), and `response.model` reflects the model that actually served the request. Turn Intelligent Routing off in dashboard Settings → Gateway to always pin the exact model.
126
+ Non-tools requests on `/v1` honor the named model exactly unless the workspace owner has set an account routing override for the task — overrides apply as policy on every endpoint, and on `/v1` one may route to Anthropic (the response is translated back to the OpenAI format). **Tools requests on `/v1` benefit from within-provider intelligent routing** when Intelligent Routing is enabled in your dashboard — Rouva picks the cheapest capable model within the same provider, using the named `model` as a cost ceiling. The provider never changes (tool schemas are provider-specific), and `response.model` reflects the model that actually served the request. Turn Intelligent Routing off in dashboard Settings → Gateway to always pin the exact model.
123
127
 
124
128
  ## Tool use
125
129
 
126
- 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.
130
+ 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.
127
131
 
128
- When using the `/v1` endpoint (OpenAI SDK, LangChain, Vercel AI SDK, …) with Intelligent Routing enabled, tools requests are routed to the cheapest capable model within the same provider — see [OpenAI-compatible endpoint](#openai-compatible-endpoint) for details. On the native SDK endpoint (`/api/gateway/messages`), the named model is always honored exactly.
132
+ 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.
129
133
 
130
134
  ```typescript
131
135
  const res = await rouva.chat.completions.create({
@@ -167,19 +171,19 @@ Responses are normalized to the OpenAI shape regardless of provider: Anthropic `
167
171
 
168
172
  **Don't branch on `finish_reason` to detect tool calls** — check for the presence of `message.tool_calls` instead. `finish_reason` follows each provider's own semantics, and OpenAI notably returns `"stop"` (not `"tool_calls"`) when `tool_choice` forces a specific function. The SDK passes OpenAI's values through unchanged so behavior matches calling OpenAI directly.
169
173
 
170
- Tools requests record usage and cost. Savings are recorded when within-provider routing substitutes a cheaper model on `/v1`. Tools responses are not quality-scored or served from the semantic cache.
174
+ Tools requests record usage and cost. Savings are recorded when within-provider routing substitutes a cheaper model on `/v1`. Tools responses are not quality-scored, and streaming tools requests are never served from the duplicate-request cache.
171
175
 
172
176
  ## Cost optimisations
173
177
 
174
178
  Rouva applies several cost-saving layers automatically on every non-tools request — no configuration required.
175
179
 
176
- **Semantic cache (A)** — repeated or semantically similar prompts are served from cache without hitting the upstream provider. Cache hits record zero cost and appear in your dashboard with a `semantic_cache_hit` flag.
180
+ **Duplicate-request cache** — an identical request repeated within 60 seconds (typically a client retry) gets the earlier response instead of a second provider call, and isn't charged. Such responses carry `_rouva.dedup: true`.
177
181
 
178
- **Prompt caching (B)** — Anthropic requests automatically include cache-control headers on large system prompts and conversation prefixes, reducing input token costs on repeated turns.
182
+ **Prompt caching** — Anthropic requests automatically include cache-control markers on large system prompts, tool definitions and conversation prefixes, reducing input token costs on repeated turns.
179
183
 
180
- **Conversation summarisation (C2)** — long Anthropic conversations are summarised by a cheap model before forwarding, compressing the context window and reducing input tokens. The summarisation cost is included in the request's spend snapshot. Only runs when the conversation exceeds ~2,000 tokens of non-system content and the request is not a tools request.
184
+ **Conversation summarisation** — long Anthropic conversations are summarised by a cheap model before forwarding, compressing the context window and reducing input tokens. The summarisation cost is included in the request's spend snapshot. Only runs when the conversation exceeds ~2,000 tokens of non-system content and the request is not a tools request.
181
185
 
182
- All three are skipped for tools requests, which are forwarded verbatim.
186
+ Tools requests are forwarded verbatim: no summarisation or whitespace trimming.
183
187
 
184
188
  ## Options
185
189
 
@@ -226,16 +230,16 @@ console.log(result.session_cost_usd) // total USD spent
226
230
  ### How it works
227
231
 
228
232
  1. The gateway sends your `messages` to the model (non-streaming)
229
- 2. When the model returns tool calls, all handlers are dispatched **in parallel** via POST — the request body is the tool's JSON input, the response body is used as the tool result
233
+ 2. When the model returns tool calls, all handlers are dispatched **in parallel** via POST — the request body is `{ "name": "<tool>", "arguments": { …the model's input… } }`, and the response body is used as the tool result
230
234
  3. Results are appended and the loop continues
231
- 4. The loop exits when `finish_reason` is `"stop"` / `"end_turn"`, `max_turns` is reached, or `session_budget_usd` is exceeded
235
+ 4. The loop exits when the model stops (`finish_reason: "stop"`, for every provider), `max_turns` is reached (`"max_turns"`), or `session_budget_usd` is reached (`"session_budget_exceeded"`). If the last turn's output is truncated, the run returns with `error` set and the truncation reason (e.g. `"length"`)
232
236
 
233
- Every turn is logged as a usage snapshot in the Rouva dashboard under the shared `session_id`, giving you a full session replay with per-turn cost, model used, tool calls, and quality score.
237
+ Every turn is logged as a usage snapshot in the Rouva dashboard under the shared `session_id`, giving you a full session replay with per-turn cost, model used and tool calls. (Tool-loop turns aren't quality-scored.)
234
238
 
235
239
  ### Handler URL requirements
236
240
 
237
241
  - Must be a public HTTPS URL (private/local addresses are rejected)
238
- - The gateway sends a `POST` with `Content-Type: application/json` and the tool's input object as the body
242
+ - The gateway sends a `POST` with `Content-Type: application/json` and the body `{ "name": "<tool>", "arguments": { … } }`
239
243
  - The response body (text or JSON) is returned to the model as the tool result
240
244
 
241
245
  ### Parameters
@@ -256,16 +260,21 @@ Every turn is logged as a usage snapshot in the Rouva dashboard under the shared
256
260
 
257
261
  ```typescript
258
262
  interface RunResult {
259
- content: string | null // final model response text
260
- turns: number // turns executed
261
- tool_calls_made: number // total tool calls dispatched
262
- session_id: string // groups all turns in the dashboard
263
- finish_reason: string | null // "stop", "end_turn", "max_turns", "budget_exceeded", "length"
264
- cost_usd: number // cost of the last turn
265
- session_cost_usd: number // cumulative cost across all turns
263
+ content: string | null // final model response text
264
+ turns: number // turns executed
265
+ tool_calls_made: number // total tool calls dispatched
266
+ session_id: string // groups all turns in the dashboard
267
+ finish_reason: string | null // "stop", "max_turns", "session_budget_exceeded", or a truncation reason ("length", …)
268
+ session_cost_usd: number // cumulative cost across all turns
269
+ session_cost_incomplete?: boolean // a turn reported no usage — cost is a lower bound
270
+ routed_model?: string // when Intelligent Routing chose a different model
271
+ requested_model?: string
272
+ error?: string // why a run that still returned 200 stopped early
266
273
  }
267
274
  ```
268
275
 
276
+ A non-2xx response throws a `RouvaError` (see [Errors](#errors)). Once the loop has started, its `body` also carries the partial `turns`, `session_id` and `session_cost_usd`; errors before the first turn (validation, no provider key, allowlist) carry only `error`. A run can take several minutes — pass `{ signal }` as the second argument to cancel it.
277
+
269
278
  ### When to use `run()` vs `chat.completions.create()`
270
279
 
271
280
  | | `rouva.run()` | `chat.completions.create()` |
@@ -313,10 +322,31 @@ console.log(res._rouva)
313
322
  // {
314
323
  // model_used: 'gpt-4o-mini',
315
324
  // provider_used: 'openai',
316
- // task_type: 'summarize'
325
+ // task_type: 'summarize',
326
+ // dedup: true // only when served from the duplicate-request cache
317
327
  // }
318
328
  ```
319
329
 
330
+ ## Errors
331
+
332
+ Any non-2xx response throws a `RouvaError` (a subclass of `Error`, so existing `catch` blocks keep working):
333
+
334
+ ```typescript
335
+ import { Rouva, RouvaError } from '@rouvanpm/rouva'
336
+
337
+ try {
338
+ await rouva.chat.completions.create({ messages })
339
+ } catch (e) {
340
+ if (e instanceof RouvaError && e.status === 429) {
341
+ console.log(e.refusal) // 'key_cap' | 'member_cap' | 'rate' — Rouva refused before calling the provider
342
+ console.log(e.retryAfter) // seconds, from Retry-After
343
+ console.log(e.error) // the gateway's message
344
+ }
345
+ }
346
+ ```
347
+
348
+ `refusal` (header `X-Rouva-Refusal`) names why Rouva itself refused the request before contacting the provider, when the gateway marks it: a monthly spend cap would be exceeded (`key_cap`, `member_cap`), the rate limit (`rate`), or — for spend-capped keys — a model with no known price (`unpriced`) or input whose cost can't be bounded (`unbounded`). Every Rouva refusal is marked, so a `429` without `refusal` came from the provider, not from Rouva. Every call also takes `{ signal }` as a second argument to abort it.
349
+
320
350
  ## Getting your API key
321
351
 
322
352
  1. Sign in to [app.rouva.io](https://app.rouva.io)
package/dist/index.d.mts CHANGED
@@ -1,3 +1,8 @@
1
+ /** Per-call options. */
2
+ interface RequestOptions {
3
+ /** Abort the request (e.g. a long `run()` the user navigated away from). */
4
+ signal?: AbortSignal;
5
+ }
1
6
  interface RouvaOptions {
2
7
  /** Your Rouva gateway API key (rva_...) */
3
8
  apiKey: string;
@@ -47,16 +52,19 @@ interface ContentBlockMessage {
47
52
  type ChatMessage = Message | AssistantToolCallMessage | ToolResultMessage | ContentBlockMessage;
48
53
  type RouvaProvider = 'anthropic' | 'openai' | 'gemini' | 'deepseek' | 'mistral' | 'moonshot' | 'xai' | 'zai' | 'alibaba' | (string & {});
49
54
  /**
50
- * Supported models across all providers.
55
+ * Supported models across all providers (the gateway's list as of SDK 0.3.0).
51
56
  * Omit `model` entirely to let Rouva route to the cheapest capable model automatically.
52
57
  *
53
- * IDs not listed here also work when pinned — dated snapshots
54
- * (`gpt-4o-mini-2024-07-18`), fine-tunes (`ft:gpt-4o-mini:…`), and new
55
- * releases are matched to their provider by naming convention and forwarded
56
- * as-is. Until the gateway's registry knows their pricing, such requests are
57
- * logged with token counts but zero cost.
58
+ * Other IDs are accepted by the type for forward compatibility. On
59
+ * `chat.completions.create()` the gateway only serves models it can price
60
+ * (its built-in list plus its synced model registry); any other ID returns a
61
+ * 404. `run()` also accepts IDs whose provider it can infer from the name,
62
+ * logging them without a cost unless the key is spend-capped or the run has a
63
+ * `session_budget_usd` (then an unpriced model is refused).
64
+ *
65
+ * `'gpt-5.6'` is a deprecated alias the gateway maps to `'gpt-5.6-sol'`.
58
66
  */
59
- type RouvaModel = 'claude-opus-4-6' | 'claude-opus-4-7' | 'claude-opus-4-8' | 'claude-sonnet-4-6' | 'claude-sonnet-5' | 'claude-haiku-4-5-20251001' | 'claude-fable-5' | 'gpt-5-nano' | 'gpt-5-mini' | 'gpt-5.4-mini' | 'gpt-5' | 'gpt-5.6' | 'gpt-4.1-nano' | 'gpt-4.1-mini' | 'gpt-4.1' | 'gpt-4o' | 'gpt-4o-mini' | 'gemini-3.7-flash' | 'gemini-3.6-flash' | 'gemini-2.5-flash' | 'gemini-2.5-pro' | 'deepseek-v4-flash' | 'deepseek-v4-flash-think' | 'deepseek-v4-pro' | 'mistral-small-latest' | 'mistral-large-latest' | 'kimi-k2.6' | 'kimi-k3' | 'grok-4.6' | 'grok-4.5' | 'grok-4.3' | 'grok-4.20-0309-reasoning' | 'glm-4.7-flash' | 'glm-5.2' | 'qwen-turbo' | 'qwen-plus' | 'qwen-max' | 'qwen3-30b-a3b' | 'qwen3-32b' | 'qwen3-235b-a22b' | (string & {});
67
+ type RouvaModel = 'claude-fable-5-1' | 'claude-fable-5' | 'claude-opus-5-5' | 'claude-opus-5' | 'claude-opus-4-8' | 'claude-opus-4-7' | 'claude-opus-4-6' | 'claude-sonnet-5-5' | 'claude-sonnet-5' | 'claude-sonnet-4-6' | 'claude-haiku-4-5-20251001' | 'gpt-6.1-sol' | 'gpt-6-sol' | 'gpt-6-luna' | 'gpt-5.6-sol' | 'gpt-5.6-terra' | 'gpt-5.6-luna' | 'gpt-5.6' | 'gpt-5' | 'gpt-5.4-mini' | 'gpt-5-mini' | 'gpt-5-nano' | 'gpt-4.1' | 'gpt-4.1-mini' | 'gpt-4.1-nano' | 'gpt-4o' | 'gpt-4o-mini' | 'gemini-3.8-flash' | 'gemini-3.7-flash' | 'gemini-3.6-flash' | 'gemini-3.5-flash-lite' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'deepseek-flash-4-1' | 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'deepseek-v4-flash-think' | 'mistral-large-latest' | 'mistral-small-latest' | 'kimi-k3' | 'kimi-k2.6' | 'grok-4.7' | 'grok-4.6' | 'grok-4.5' | 'grok-4.3' | 'grok-4.20-0309-reasoning' | 'glm-5.3' | 'glm-5.2' | 'glm-4.7-flash' | 'qwen3.8-omni-flash' | 'qwen3.8-flash' | 'qwen3.7-plus' | 'qwen-max' | 'qwen-plus' | 'qwen-flash' | (string & {});
60
68
  /** Anthropic-style text block — the array form of the `system` param */
61
69
  interface SystemTextBlock {
62
70
  type: 'text';
@@ -72,8 +80,13 @@ interface ChatCompletionParams {
72
80
  */
73
81
  system?: string | SystemTextBlock[];
74
82
  /**
75
- * Target model — omit to let Rouva route intelligently to the cheapest capable model.
76
- * Supports models from any connected provider (Anthropic, OpenAI).
83
+ * Target model — omit to let Rouva route intelligently to the cheapest
84
+ * capable model. With Intelligent Routing on, a named model is a price
85
+ * ceiling for Rouva's own choices: it may substitute a cheaper capable model,
86
+ * never a pricier one (retries after a provider error included). Two
87
+ * exceptions: an account routing override set by the workspace owner applies
88
+ * as policy, even if pricier; and with `provider` also set, the exact model
89
+ * is used (no substitution).
77
90
  */
78
91
  model?: RouvaModel;
79
92
  /**
@@ -83,23 +96,34 @@ interface ChatCompletionParams {
83
96
  provider?: RouvaProvider;
84
97
  /** Maximum tokens to generate */
85
98
  max_tokens?: number;
99
+ /** OpenAI's newer name for `max_tokens`; the gateway accepts either. */
100
+ max_completion_tokens?: number;
101
+ /**
102
+ * Your own labels for the request, stored with its usage record and not
103
+ * sent to the provider: up to 10 string key-value pairs, keys up to 64
104
+ * characters, values up to 256 characters, no null bytes.
105
+ */
106
+ metadata?: Record<string, string>;
86
107
  /**
87
108
  * Sampling temperature 0–1.
88
- * Note: reasoning models (gpt-5 family) only support their default
89
- * temperature, so the gateway omits it for those.
109
+ * The gateway omits it for models that only accept their default: OpenAI
110
+ * reasoning models (gpt-5 / gpt-6 families) and the Claude models that reject
111
+ * it (Opus 4.7 and newer, Sonnet 5 and newer, Fable).
90
112
  */
91
113
  temperature?: number;
92
114
  /**
93
- * Nucleus sampling — greater than 0 and at most 1, forwarded to every
94
- * provider. `top_p: 1` (the no-op default) is treated as omitted. OpenAI
95
- * reasoning models (gpt-5 family, o-series) don't support it: auto-routing
96
- * avoids them when top_p is set, and pinning one returns a 400.
115
+ * Nucleus sampling — greater than 0 and at most 1. `top_p: 1` (the no-op
116
+ * default) is treated as omitted. OpenAI reasoning models (gpt-5 / gpt-6
117
+ * families, o-series) don't support it: routing avoids them when top_p is
118
+ * set, and pinning one returns a 400. The gateway omits it for the Claude
119
+ * models that reject it (Opus 4.7 and newer, Sonnet 5 and newer, Fable).
97
120
  */
98
121
  top_p?: number;
99
122
  /**
100
- * Up to 4 stop sequences — a string or array of non-empty strings,
101
- * forwarded to every provider (Anthropic receives them as
102
- * `stop_sequences`).
123
+ * Up to 4 stop sequences — a string or array of non-empty strings
124
+ * (Anthropic receives them as `stop_sequences`). OpenAI and xAI reasoning
125
+ * models (e.g. gpt-5, grok-4.5 / 4.6 / 4.7) reject `stop`: routing avoids
126
+ * them when it is set, and pinning one returns a 400.
103
127
  */
104
128
  stop?: string | string[];
105
129
  /**
@@ -117,9 +141,11 @@ interface ChatCompletionParams {
117
141
  /**
118
142
  * Tool definitions in your target provider's own format, forwarded to the
119
143
  * provider verbatim (OpenAI `{ type: "function", function: {...} }` or
120
- * Anthropic `{ name, description, input_schema }`). Requires `model` —
121
- * tool schemas are provider-specific, so tools requests are never
122
- * re-routed by the gateway.
144
+ * Anthropic `{ name, description, input_schema }`). Requires `model`.
145
+ * Tool schemas are provider-specific, so a tools request only ever moves to
146
+ * another model of the SAME provider (a routing override, or cheaper-model
147
+ * routing on /v1). Models that don't support tools on Chat Completions
148
+ * (gpt-6.1-sol, gpt-5.6-*) return a 400.
123
149
  */
124
150
  tools?: Array<Record<string, unknown>>;
125
151
  /**
@@ -161,7 +187,16 @@ interface RouvaResponseMeta {
161
187
  provider_used?: string;
162
188
  /** Task type classified by Rouva when exposed by the gateway */
163
189
  task_type?: string;
164
- /** Semantic cache status when exposed by the gateway */
190
+ /**
191
+ * True when the gateway answered from its duplicate-request cache (an
192
+ * identical request within the last 60 seconds) instead of calling the
193
+ * provider — that request wasn't charged. Header: `X-Rouva-Dedup`.
194
+ */
195
+ dedup?: boolean;
196
+ /**
197
+ * @deprecated The semantic cache was removed from the gateway (2026-10-08),
198
+ * so this is never set. Use `dedup`.
199
+ */
165
200
  cache?: string;
166
201
  }
167
202
  interface RunParams {
@@ -186,9 +221,9 @@ interface RunParams {
186
221
  tools: Array<Record<string, unknown>>;
187
222
  /**
188
223
  * Map of tool name → handler URL. When the model calls a tool, the gateway
189
- * POSTs the tool input as JSON to the corresponding URL and feeds the
190
- * response body back as the tool result. All handlers must be public HTTPS
191
- * endpoints.
224
+ * POSTs `{ "name": <tool name>, "arguments": <the model's input object> }`
225
+ * as JSON to the corresponding URL and feeds the response body back as the
226
+ * tool result. All handlers must be public HTTPS endpoints.
192
227
  *
193
228
  * @example
194
229
  * { search: 'https://api.example.com/search', calculator: 'https://api.example.com/calc' }
@@ -199,10 +234,9 @@ interface RunParams {
199
234
  * the loop begins — the session stays on that provider for all turns. When
200
235
  * the session history is fully translatable, routing may cross the
201
236
  * Anthropic ↔ OpenAI-compatible boundary; the canonical layer handles tool
202
- * schema translation automatically. Omit to let Rouva select the cheapest
203
- * capable model automatically.
237
+ * schema translation automatically. Required by /run.
204
238
  */
205
- model?: RouvaModel;
239
+ model: RouvaModel;
206
240
  /**
207
241
  * Force a specific provider when paired with `model`.
208
242
  */
@@ -222,8 +256,9 @@ interface RunParams {
222
256
  */
223
257
  temperature?: number;
224
258
  /**
225
- * Hard cap on total session spend in USD. The loop exits cleanly when the
226
- * cumulative cost of all turns reaches this threshold.
259
+ * Hard cap on total session spend in USD. The loop exits cleanly (with
260
+ * `finish_reason: "session_budget_exceeded"`) when the cumulative cost of
261
+ * all turns reaches this threshold.
227
262
  */
228
263
  session_budget_usd?: number;
229
264
  }
@@ -237,15 +272,27 @@ interface RunResult {
237
272
  /** Session ID — all turns are grouped under this ID in the Rouva dashboard. */
238
273
  session_id: string;
239
274
  /**
240
- * Why the loop exited: `"stop"` (model finished), `"max_turns"` (turn limit
241
- * reached), `"budget_exceeded"` (session_budget_usd hit), or a provider
242
- * finish reason such as `"end_turn"` or `"length"`.
275
+ * Why the loop exited: `"stop"` (the model finished — reported as "stop" for
276
+ * every provider), `"max_turns"` (turn limit reached),
277
+ * `"session_budget_exceeded"` (session_budget_usd hit), or a truncation
278
+ * reason such as `"length"` / `"max_tokens"` (then `error` explains it).
243
279
  */
244
280
  finish_reason: string | null;
245
- /** USD cost of the final turn. */
246
- cost_usd: number;
247
281
  /** Cumulative USD cost of all turns in the session. */
248
282
  session_cost_usd: number;
283
+ /**
284
+ * True when a turn reported no usage, so `session_cost_usd` is a lower
285
+ * bound rather than the full cost.
286
+ */
287
+ session_cost_incomplete?: boolean;
288
+ /** Set when Intelligent Routing chose a different model than requested. */
289
+ routed_model?: string;
290
+ requested_model?: string;
291
+ /**
292
+ * Explanation when the run stopped early but still returned 200 — e.g.
293
+ * output truncated on the last turn (raise `max_tokens`).
294
+ */
295
+ error?: string;
249
296
  }
250
297
 
251
298
  declare class Rouva {
@@ -254,7 +301,7 @@ declare class Rouva {
254
301
  private _sessionId;
255
302
  readonly chat: {
256
303
  completions: {
257
- create(params: ChatCompletionParams): Promise<ChatCompletion | ReadableStream>;
304
+ create(params: ChatCompletionParams, options?: RequestOptions): Promise<ChatCompletion | ReadableStream>;
258
305
  };
259
306
  };
260
307
  constructor(options: RouvaOptions);
@@ -273,9 +320,15 @@ declare class Rouva {
273
320
  /** The active session ID, or undefined if no session is in progress. */
274
321
  get sessionId(): string | undefined;
275
322
  /**
276
- * Run a server-side agentic loop. The gateway calls the model, dispatches
277
- * tool calls in parallel to your handler URLs, and loops until the model
278
- * stops or limits are reached — no streaming, no client-side loop required.
323
+ * Run a server-side agentic loop (`POST /api/gateway/run`). The gateway
324
+ * calls the model, POSTs each tool call to its handler URL as
325
+ * `{ name, arguments }` (in parallel), feeds the response bodies back as tool
326
+ * results, and loops until the model stops or a limit is reached — no
327
+ * streaming, no client-side loop required. Throws `RouvaError` on a non-2xx
328
+ * response; once the loop has started, its `body` also carries the partial
329
+ * `turns`, `session_id` and `session_cost_usd` (errors before the first turn
330
+ * — validation, no key, allowlist — carry only `error`). Pass
331
+ * `{ signal }` to cancel; a run can take several minutes.
279
332
  *
280
333
  * Every turn is logged as a usage snapshot grouped by `session_id` in the
281
334
  * Rouva dashboard, giving you a full session replay with per-turn cost,
@@ -293,8 +346,36 @@ declare class Rouva {
293
346
  * console.log(result.turns) // how many turns it took
294
347
  * console.log(result.session_id) // dashboard session ID
295
348
  */
296
- run(params: RunParams): Promise<RunResult>;
349
+ run(params: RunParams, options?: RequestOptions): Promise<RunResult>;
350
+ private _post;
297
351
  private _createChatCompletion;
298
352
  }
299
353
 
300
- export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type FunctionToolCall, type Message, type ResponseMessage, Rouva, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta, type RunParams, type RunResult, type SystemTextBlock, type ToolResultMessage };
354
+ /**
355
+ * Thrown for any non-2xx gateway response. Extends Error and keeps the
356
+ * message format of earlier versions (`[Rouva] Gateway error 429: …`), so
357
+ * existing `catch` blocks keep working; the fields make refusals actionable.
358
+ */
359
+ declare class RouvaError extends Error {
360
+ /** HTTP status from the gateway. */
361
+ readonly status: number;
362
+ /**
363
+ * Why Rouva itself refused the request before contacting the provider, when
364
+ * the gateway marks it (header `X-Rouva-Refusal`): `"key_cap"` /
365
+ * `"member_cap"` (a monthly spend cap would be exceeded), `"rate"` (rate
366
+ * limit), `"unpriced"` (a capped key can't use a model with no known price),
367
+ * `"unbounded"` (a capped key can't send input whose cost can't be bounded).
368
+ * Every Rouva cap, rate-limit and pricing refusal carries it, so a 429
369
+ * without `refusal` came from the provider, not from Rouva.
370
+ */
371
+ readonly refusal?: string;
372
+ /** Seconds to wait before retrying, from `Retry-After`, when present. */
373
+ readonly retryAfter?: number;
374
+ /** The gateway's error message (`error` field), when the body is JSON. */
375
+ readonly error?: string;
376
+ /** The parsed JSON body, or the raw text when it isn't JSON. */
377
+ readonly body: unknown;
378
+ constructor(kind: 'Gateway' | 'Run', status: number, headers: Headers, rawBody: string);
379
+ }
380
+
381
+ 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 };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /** Per-call options. */
2
+ interface RequestOptions {
3
+ /** Abort the request (e.g. a long `run()` the user navigated away from). */
4
+ signal?: AbortSignal;
5
+ }
1
6
  interface RouvaOptions {
2
7
  /** Your Rouva gateway API key (rva_...) */
3
8
  apiKey: string;
@@ -47,16 +52,19 @@ interface ContentBlockMessage {
47
52
  type ChatMessage = Message | AssistantToolCallMessage | ToolResultMessage | ContentBlockMessage;
48
53
  type RouvaProvider = 'anthropic' | 'openai' | 'gemini' | 'deepseek' | 'mistral' | 'moonshot' | 'xai' | 'zai' | 'alibaba' | (string & {});
49
54
  /**
50
- * Supported models across all providers.
55
+ * Supported models across all providers (the gateway's list as of SDK 0.3.0).
51
56
  * Omit `model` entirely to let Rouva route to the cheapest capable model automatically.
52
57
  *
53
- * IDs not listed here also work when pinned — dated snapshots
54
- * (`gpt-4o-mini-2024-07-18`), fine-tunes (`ft:gpt-4o-mini:…`), and new
55
- * releases are matched to their provider by naming convention and forwarded
56
- * as-is. Until the gateway's registry knows their pricing, such requests are
57
- * logged with token counts but zero cost.
58
+ * Other IDs are accepted by the type for forward compatibility. On
59
+ * `chat.completions.create()` the gateway only serves models it can price
60
+ * (its built-in list plus its synced model registry); any other ID returns a
61
+ * 404. `run()` also accepts IDs whose provider it can infer from the name,
62
+ * logging them without a cost unless the key is spend-capped or the run has a
63
+ * `session_budget_usd` (then an unpriced model is refused).
64
+ *
65
+ * `'gpt-5.6'` is a deprecated alias the gateway maps to `'gpt-5.6-sol'`.
58
66
  */
59
- type RouvaModel = 'claude-opus-4-6' | 'claude-opus-4-7' | 'claude-opus-4-8' | 'claude-sonnet-4-6' | 'claude-sonnet-5' | 'claude-haiku-4-5-20251001' | 'claude-fable-5' | 'gpt-5-nano' | 'gpt-5-mini' | 'gpt-5.4-mini' | 'gpt-5' | 'gpt-5.6' | 'gpt-4.1-nano' | 'gpt-4.1-mini' | 'gpt-4.1' | 'gpt-4o' | 'gpt-4o-mini' | 'gemini-3.7-flash' | 'gemini-3.6-flash' | 'gemini-2.5-flash' | 'gemini-2.5-pro' | 'deepseek-v4-flash' | 'deepseek-v4-flash-think' | 'deepseek-v4-pro' | 'mistral-small-latest' | 'mistral-large-latest' | 'kimi-k2.6' | 'kimi-k3' | 'grok-4.6' | 'grok-4.5' | 'grok-4.3' | 'grok-4.20-0309-reasoning' | 'glm-4.7-flash' | 'glm-5.2' | 'qwen-turbo' | 'qwen-plus' | 'qwen-max' | 'qwen3-30b-a3b' | 'qwen3-32b' | 'qwen3-235b-a22b' | (string & {});
67
+ type RouvaModel = 'claude-fable-5-1' | 'claude-fable-5' | 'claude-opus-5-5' | 'claude-opus-5' | 'claude-opus-4-8' | 'claude-opus-4-7' | 'claude-opus-4-6' | 'claude-sonnet-5-5' | 'claude-sonnet-5' | 'claude-sonnet-4-6' | 'claude-haiku-4-5-20251001' | 'gpt-6.1-sol' | 'gpt-6-sol' | 'gpt-6-luna' | 'gpt-5.6-sol' | 'gpt-5.6-terra' | 'gpt-5.6-luna' | 'gpt-5.6' | 'gpt-5' | 'gpt-5.4-mini' | 'gpt-5-mini' | 'gpt-5-nano' | 'gpt-4.1' | 'gpt-4.1-mini' | 'gpt-4.1-nano' | 'gpt-4o' | 'gpt-4o-mini' | 'gemini-3.8-flash' | 'gemini-3.7-flash' | 'gemini-3.6-flash' | 'gemini-3.5-flash-lite' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'deepseek-flash-4-1' | 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'deepseek-v4-flash-think' | 'mistral-large-latest' | 'mistral-small-latest' | 'kimi-k3' | 'kimi-k2.6' | 'grok-4.7' | 'grok-4.6' | 'grok-4.5' | 'grok-4.3' | 'grok-4.20-0309-reasoning' | 'glm-5.3' | 'glm-5.2' | 'glm-4.7-flash' | 'qwen3.8-omni-flash' | 'qwen3.8-flash' | 'qwen3.7-plus' | 'qwen-max' | 'qwen-plus' | 'qwen-flash' | (string & {});
60
68
  /** Anthropic-style text block — the array form of the `system` param */
61
69
  interface SystemTextBlock {
62
70
  type: 'text';
@@ -72,8 +80,13 @@ interface ChatCompletionParams {
72
80
  */
73
81
  system?: string | SystemTextBlock[];
74
82
  /**
75
- * Target model — omit to let Rouva route intelligently to the cheapest capable model.
76
- * Supports models from any connected provider (Anthropic, OpenAI).
83
+ * Target model — omit to let Rouva route intelligently to the cheapest
84
+ * capable model. With Intelligent Routing on, a named model is a price
85
+ * ceiling for Rouva's own choices: it may substitute a cheaper capable model,
86
+ * never a pricier one (retries after a provider error included). Two
87
+ * exceptions: an account routing override set by the workspace owner applies
88
+ * as policy, even if pricier; and with `provider` also set, the exact model
89
+ * is used (no substitution).
77
90
  */
78
91
  model?: RouvaModel;
79
92
  /**
@@ -83,23 +96,34 @@ interface ChatCompletionParams {
83
96
  provider?: RouvaProvider;
84
97
  /** Maximum tokens to generate */
85
98
  max_tokens?: number;
99
+ /** OpenAI's newer name for `max_tokens`; the gateway accepts either. */
100
+ max_completion_tokens?: number;
101
+ /**
102
+ * Your own labels for the request, stored with its usage record and not
103
+ * sent to the provider: up to 10 string key-value pairs, keys up to 64
104
+ * characters, values up to 256 characters, no null bytes.
105
+ */
106
+ metadata?: Record<string, string>;
86
107
  /**
87
108
  * Sampling temperature 0–1.
88
- * Note: reasoning models (gpt-5 family) only support their default
89
- * temperature, so the gateway omits it for those.
109
+ * The gateway omits it for models that only accept their default: OpenAI
110
+ * reasoning models (gpt-5 / gpt-6 families) and the Claude models that reject
111
+ * it (Opus 4.7 and newer, Sonnet 5 and newer, Fable).
90
112
  */
91
113
  temperature?: number;
92
114
  /**
93
- * Nucleus sampling — greater than 0 and at most 1, forwarded to every
94
- * provider. `top_p: 1` (the no-op default) is treated as omitted. OpenAI
95
- * reasoning models (gpt-5 family, o-series) don't support it: auto-routing
96
- * avoids them when top_p is set, and pinning one returns a 400.
115
+ * Nucleus sampling — greater than 0 and at most 1. `top_p: 1` (the no-op
116
+ * default) is treated as omitted. OpenAI reasoning models (gpt-5 / gpt-6
117
+ * families, o-series) don't support it: routing avoids them when top_p is
118
+ * set, and pinning one returns a 400. The gateway omits it for the Claude
119
+ * models that reject it (Opus 4.7 and newer, Sonnet 5 and newer, Fable).
97
120
  */
98
121
  top_p?: number;
99
122
  /**
100
- * Up to 4 stop sequences — a string or array of non-empty strings,
101
- * forwarded to every provider (Anthropic receives them as
102
- * `stop_sequences`).
123
+ * Up to 4 stop sequences — a string or array of non-empty strings
124
+ * (Anthropic receives them as `stop_sequences`). OpenAI and xAI reasoning
125
+ * models (e.g. gpt-5, grok-4.5 / 4.6 / 4.7) reject `stop`: routing avoids
126
+ * them when it is set, and pinning one returns a 400.
103
127
  */
104
128
  stop?: string | string[];
105
129
  /**
@@ -117,9 +141,11 @@ interface ChatCompletionParams {
117
141
  /**
118
142
  * Tool definitions in your target provider's own format, forwarded to the
119
143
  * provider verbatim (OpenAI `{ type: "function", function: {...} }` or
120
- * Anthropic `{ name, description, input_schema }`). Requires `model` —
121
- * tool schemas are provider-specific, so tools requests are never
122
- * re-routed by the gateway.
144
+ * Anthropic `{ name, description, input_schema }`). Requires `model`.
145
+ * Tool schemas are provider-specific, so a tools request only ever moves to
146
+ * another model of the SAME provider (a routing override, or cheaper-model
147
+ * routing on /v1). Models that don't support tools on Chat Completions
148
+ * (gpt-6.1-sol, gpt-5.6-*) return a 400.
123
149
  */
124
150
  tools?: Array<Record<string, unknown>>;
125
151
  /**
@@ -161,7 +187,16 @@ interface RouvaResponseMeta {
161
187
  provider_used?: string;
162
188
  /** Task type classified by Rouva when exposed by the gateway */
163
189
  task_type?: string;
164
- /** Semantic cache status when exposed by the gateway */
190
+ /**
191
+ * True when the gateway answered from its duplicate-request cache (an
192
+ * identical request within the last 60 seconds) instead of calling the
193
+ * provider — that request wasn't charged. Header: `X-Rouva-Dedup`.
194
+ */
195
+ dedup?: boolean;
196
+ /**
197
+ * @deprecated The semantic cache was removed from the gateway (2026-10-08),
198
+ * so this is never set. Use `dedup`.
199
+ */
165
200
  cache?: string;
166
201
  }
167
202
  interface RunParams {
@@ -186,9 +221,9 @@ interface RunParams {
186
221
  tools: Array<Record<string, unknown>>;
187
222
  /**
188
223
  * Map of tool name → handler URL. When the model calls a tool, the gateway
189
- * POSTs the tool input as JSON to the corresponding URL and feeds the
190
- * response body back as the tool result. All handlers must be public HTTPS
191
- * endpoints.
224
+ * POSTs `{ "name": <tool name>, "arguments": <the model's input object> }`
225
+ * as JSON to the corresponding URL and feeds the response body back as the
226
+ * tool result. All handlers must be public HTTPS endpoints.
192
227
  *
193
228
  * @example
194
229
  * { search: 'https://api.example.com/search', calculator: 'https://api.example.com/calc' }
@@ -199,10 +234,9 @@ interface RunParams {
199
234
  * the loop begins — the session stays on that provider for all turns. When
200
235
  * the session history is fully translatable, routing may cross the
201
236
  * Anthropic ↔ OpenAI-compatible boundary; the canonical layer handles tool
202
- * schema translation automatically. Omit to let Rouva select the cheapest
203
- * capable model automatically.
237
+ * schema translation automatically. Required by /run.
204
238
  */
205
- model?: RouvaModel;
239
+ model: RouvaModel;
206
240
  /**
207
241
  * Force a specific provider when paired with `model`.
208
242
  */
@@ -222,8 +256,9 @@ interface RunParams {
222
256
  */
223
257
  temperature?: number;
224
258
  /**
225
- * Hard cap on total session spend in USD. The loop exits cleanly when the
226
- * cumulative cost of all turns reaches this threshold.
259
+ * Hard cap on total session spend in USD. The loop exits cleanly (with
260
+ * `finish_reason: "session_budget_exceeded"`) when the cumulative cost of
261
+ * all turns reaches this threshold.
227
262
  */
228
263
  session_budget_usd?: number;
229
264
  }
@@ -237,15 +272,27 @@ interface RunResult {
237
272
  /** Session ID — all turns are grouped under this ID in the Rouva dashboard. */
238
273
  session_id: string;
239
274
  /**
240
- * Why the loop exited: `"stop"` (model finished), `"max_turns"` (turn limit
241
- * reached), `"budget_exceeded"` (session_budget_usd hit), or a provider
242
- * finish reason such as `"end_turn"` or `"length"`.
275
+ * Why the loop exited: `"stop"` (the model finished — reported as "stop" for
276
+ * every provider), `"max_turns"` (turn limit reached),
277
+ * `"session_budget_exceeded"` (session_budget_usd hit), or a truncation
278
+ * reason such as `"length"` / `"max_tokens"` (then `error` explains it).
243
279
  */
244
280
  finish_reason: string | null;
245
- /** USD cost of the final turn. */
246
- cost_usd: number;
247
281
  /** Cumulative USD cost of all turns in the session. */
248
282
  session_cost_usd: number;
283
+ /**
284
+ * True when a turn reported no usage, so `session_cost_usd` is a lower
285
+ * bound rather than the full cost.
286
+ */
287
+ session_cost_incomplete?: boolean;
288
+ /** Set when Intelligent Routing chose a different model than requested. */
289
+ routed_model?: string;
290
+ requested_model?: string;
291
+ /**
292
+ * Explanation when the run stopped early but still returned 200 — e.g.
293
+ * output truncated on the last turn (raise `max_tokens`).
294
+ */
295
+ error?: string;
249
296
  }
250
297
 
251
298
  declare class Rouva {
@@ -254,7 +301,7 @@ declare class Rouva {
254
301
  private _sessionId;
255
302
  readonly chat: {
256
303
  completions: {
257
- create(params: ChatCompletionParams): Promise<ChatCompletion | ReadableStream>;
304
+ create(params: ChatCompletionParams, options?: RequestOptions): Promise<ChatCompletion | ReadableStream>;
258
305
  };
259
306
  };
260
307
  constructor(options: RouvaOptions);
@@ -273,9 +320,15 @@ declare class Rouva {
273
320
  /** The active session ID, or undefined if no session is in progress. */
274
321
  get sessionId(): string | undefined;
275
322
  /**
276
- * Run a server-side agentic loop. The gateway calls the model, dispatches
277
- * tool calls in parallel to your handler URLs, and loops until the model
278
- * stops or limits are reached — no streaming, no client-side loop required.
323
+ * Run a server-side agentic loop (`POST /api/gateway/run`). The gateway
324
+ * calls the model, POSTs each tool call to its handler URL as
325
+ * `{ name, arguments }` (in parallel), feeds the response bodies back as tool
326
+ * results, and loops until the model stops or a limit is reached — no
327
+ * streaming, no client-side loop required. Throws `RouvaError` on a non-2xx
328
+ * response; once the loop has started, its `body` also carries the partial
329
+ * `turns`, `session_id` and `session_cost_usd` (errors before the first turn
330
+ * — validation, no key, allowlist — carry only `error`). Pass
331
+ * `{ signal }` to cancel; a run can take several minutes.
279
332
  *
280
333
  * Every turn is logged as a usage snapshot grouped by `session_id` in the
281
334
  * Rouva dashboard, giving you a full session replay with per-turn cost,
@@ -293,8 +346,36 @@ declare class Rouva {
293
346
  * console.log(result.turns) // how many turns it took
294
347
  * console.log(result.session_id) // dashboard session ID
295
348
  */
296
- run(params: RunParams): Promise<RunResult>;
349
+ run(params: RunParams, options?: RequestOptions): Promise<RunResult>;
350
+ private _post;
297
351
  private _createChatCompletion;
298
352
  }
299
353
 
300
- export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type FunctionToolCall, type Message, type ResponseMessage, Rouva, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta, type RunParams, type RunResult, type SystemTextBlock, type ToolResultMessage };
354
+ /**
355
+ * Thrown for any non-2xx gateway response. Extends Error and keeps the
356
+ * message format of earlier versions (`[Rouva] Gateway error 429: …`), so
357
+ * existing `catch` blocks keep working; the fields make refusals actionable.
358
+ */
359
+ declare class RouvaError extends Error {
360
+ /** HTTP status from the gateway. */
361
+ readonly status: number;
362
+ /**
363
+ * Why Rouva itself refused the request before contacting the provider, when
364
+ * the gateway marks it (header `X-Rouva-Refusal`): `"key_cap"` /
365
+ * `"member_cap"` (a monthly spend cap would be exceeded), `"rate"` (rate
366
+ * limit), `"unpriced"` (a capped key can't use a model with no known price),
367
+ * `"unbounded"` (a capped key can't send input whose cost can't be bounded).
368
+ * Every Rouva cap, rate-limit and pricing refusal carries it, so a 429
369
+ * without `refusal` came from the provider, not from Rouva.
370
+ */
371
+ readonly refusal?: string;
372
+ /** Seconds to wait before retrying, from `Retry-After`, when present. */
373
+ readonly retryAfter?: number;
374
+ /** The gateway's error message (`error` field), when the body is JSON. */
375
+ readonly error?: string;
376
+ /** The parsed JSON body, or the raw text when it isn't JSON. */
377
+ readonly body: unknown;
378
+ constructor(kind: 'Gateway' | 'Run', status: number, headers: Headers, rawBody: string);
379
+ }
380
+
381
+ 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 };
package/dist/index.js CHANGED
@@ -20,7 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- Rouva: () => Rouva
23
+ Rouva: () => Rouva,
24
+ RouvaError: () => RouvaError
24
25
  });
25
26
  module.exports = __toCommonJS(index_exports);
26
27
 
@@ -246,8 +247,9 @@ function normalizeGatewayStream(stream) {
246
247
  }
247
248
  }
248
249
  }
249
- } finally {
250
250
  controller.close();
251
+ } catch (err) {
252
+ controller.error(err);
251
253
  }
252
254
  }
253
255
  });
@@ -354,19 +356,47 @@ async function readChatCompletionFromSse(stream, metadata) {
354
356
  return response;
355
357
  }
356
358
  function getRouvaMetadata(headers) {
357
- const modelUsed = headers.get("x-rouva-model") ?? void 0;
358
- const providerUsed = headers.get("x-rouva-provider") ?? void 0;
359
- const taskType = headers.get("x-rouva-task") ?? void 0;
360
- const cache = headers.get("x-rouva-cache") ?? void 0;
361
- if (!modelUsed && !providerUsed && !taskType && !cache) return void 0;
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;
362
365
  return {
363
366
  model_used: modelUsed,
364
367
  provider_used: providerUsed,
365
368
  task_type: taskType,
366
- cache
369
+ ...dedup ? { dedup } : {}
367
370
  };
368
371
  }
369
372
 
373
+ // src/errors.ts
374
+ var RouvaError = class extends Error {
375
+ constructor(kind, status, headers, rawBody) {
376
+ super(`[Rouva] ${kind} error ${status}: ${rawBody}`);
377
+ this.name = "RouvaError";
378
+ this.status = status;
379
+ this.refusal = headers.get("x-rouva-refusal") ?? void 0;
380
+ const retry = Number(headers.get("retry-after"));
381
+ this.retryAfter = headers.get("retry-after") !== null && Number.isFinite(retry) ? retry : void 0;
382
+ let parsed = rawBody;
383
+ try {
384
+ parsed = JSON.parse(rawBody);
385
+ } catch {
386
+ }
387
+ this.body = parsed;
388
+ const e = parsed?.error;
389
+ this.error = typeof e === "string" ? e : void 0;
390
+ }
391
+ };
392
+ async function toRouvaError(kind, res) {
393
+ const raw = await res.text().catch((err) => {
394
+ if (err?.name === "AbortError") throw err;
395
+ return res.statusText;
396
+ });
397
+ return new RouvaError(kind, res.status, res.headers, raw);
398
+ }
399
+
370
400
  // src/client.ts
371
401
  var DEFAULT_BASE_URL = "https://app.rouva.io";
372
402
  var Rouva = class {
@@ -379,7 +409,7 @@ var Rouva = class {
379
409
  this.baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
380
410
  this.chat = {
381
411
  completions: {
382
- create: (params) => this._createChatCompletion(params)
412
+ create: (params, options2) => this._createChatCompletion(params, options2)
383
413
  }
384
414
  };
385
415
  }
@@ -405,9 +435,15 @@ var Rouva = class {
405
435
  return this._sessionId;
406
436
  }
407
437
  /**
408
- * Run a server-side agentic loop. The gateway calls the model, dispatches
409
- * tool calls in parallel to your handler URLs, and loops until the model
410
- * stops or limits are reached — no streaming, no client-side loop required.
438
+ * Run a server-side agentic loop (`POST /api/gateway/run`). The gateway
439
+ * calls the model, POSTs each tool call to its handler URL as
440
+ * `{ name, arguments }` (in parallel), feeds the response bodies back as tool
441
+ * results, and loops until the model stops or a limit is reached — no
442
+ * streaming, no client-side loop required. Throws `RouvaError` on a non-2xx
443
+ * response; once the loop has started, its `body` also carries the partial
444
+ * `turns`, `session_id` and `session_cost_usd` (errors before the first turn
445
+ * — validation, no key, allowlist — carry only `error`). Pass
446
+ * `{ signal }` to cancel; a run can take several minutes.
411
447
  *
412
448
  * Every turn is logged as a usage snapshot grouped by `session_id` in the
413
449
  * Rouva dashboard, giving you a full session replay with per-turn cost,
@@ -425,39 +461,29 @@ var Rouva = class {
425
461
  * console.log(result.turns) // how many turns it took
426
462
  * console.log(result.session_id) // dashboard session ID
427
463
  */
428
- async run(params) {
429
- const url = `${this.baseURL}/api/gateway/run`;
430
- const res = await fetch(url, {
464
+ async run(params, options) {
465
+ const res = await this._post("/api/gateway/run", params, {}, options);
466
+ if (!res.ok) throw await toRouvaError("Run", res);
467
+ return res.json();
468
+ }
469
+ // One request path for every endpoint: auth, JSON body, optional abort signal.
470
+ _post(path, body, extraHeaders, options) {
471
+ return fetch(`${this.baseURL}${path}`, {
431
472
  method: "POST",
432
473
  headers: {
433
474
  "Content-Type": "application/json",
434
- "Authorization": `Bearer ${this.apiKey}`
475
+ "Authorization": `Bearer ${this.apiKey}`,
476
+ ...extraHeaders
435
477
  },
436
- body: JSON.stringify(params)
478
+ body: JSON.stringify(body),
479
+ ...options?.signal ? { signal: options.signal } : {}
437
480
  });
438
- if (!res.ok) {
439
- const body = await res.text().catch(() => res.statusText);
440
- throw new Error(`[Rouva] Run error ${res.status}: ${body}`);
441
- }
442
- return res.json();
443
481
  }
444
- async _createChatCompletion(params) {
445
- const url = `${this.baseURL}/api/gateway/messages`;
482
+ async _createChatCompletion(params, options) {
446
483
  const { stream: _stream, ...body } = params;
447
- const headers = {
448
- "Content-Type": "application/json",
449
- "Authorization": `Bearer ${this.apiKey}`
450
- };
451
- if (this._sessionId) headers["x-rouva-session-id"] = this._sessionId;
452
- const res = await fetch(url, {
453
- method: "POST",
454
- headers,
455
- body: JSON.stringify(body)
456
- });
457
- if (!res.ok) {
458
- const body2 = await res.text().catch(() => res.statusText);
459
- throw new Error(`[Rouva] Gateway error ${res.status}: ${body2}`);
460
- }
484
+ const sessionHeader = this._sessionId ? { "x-rouva-session-id": this._sessionId } : {};
485
+ const res = await this._post("/api/gateway/messages", body, sessionHeader, options);
486
+ if (!res.ok) throw await toRouvaError("Gateway", res);
461
487
  if (!res.body) throw new Error("[Rouva] No response body from gateway");
462
488
  const normalizedStream = normalizeGatewayStream(res.body);
463
489
  if (params.stream) return normalizedStream;
@@ -466,5 +492,6 @@ var Rouva = class {
466
492
  };
467
493
  // Annotate the CommonJS export names for ESM import in node:
468
494
  0 && (module.exports = {
469
- Rouva
495
+ Rouva,
496
+ RouvaError
470
497
  });
package/dist/index.mjs CHANGED
@@ -220,8 +220,9 @@ function normalizeGatewayStream(stream) {
220
220
  }
221
221
  }
222
222
  }
223
- } finally {
224
223
  controller.close();
224
+ } catch (err) {
225
+ controller.error(err);
225
226
  }
226
227
  }
227
228
  });
@@ -328,19 +329,47 @@ async function readChatCompletionFromSse(stream, metadata) {
328
329
  return response;
329
330
  }
330
331
  function getRouvaMetadata(headers) {
331
- const modelUsed = headers.get("x-rouva-model") ?? void 0;
332
- const providerUsed = headers.get("x-rouva-provider") ?? void 0;
333
- const taskType = headers.get("x-rouva-task") ?? void 0;
334
- const cache = headers.get("x-rouva-cache") ?? void 0;
335
- if (!modelUsed && !providerUsed && !taskType && !cache) return void 0;
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;
336
338
  return {
337
339
  model_used: modelUsed,
338
340
  provider_used: providerUsed,
339
341
  task_type: taskType,
340
- cache
342
+ ...dedup ? { dedup } : {}
341
343
  };
342
344
  }
343
345
 
346
+ // src/errors.ts
347
+ var RouvaError = class extends Error {
348
+ constructor(kind, status, headers, rawBody) {
349
+ super(`[Rouva] ${kind} error ${status}: ${rawBody}`);
350
+ this.name = "RouvaError";
351
+ this.status = status;
352
+ this.refusal = headers.get("x-rouva-refusal") ?? void 0;
353
+ const retry = Number(headers.get("retry-after"));
354
+ this.retryAfter = headers.get("retry-after") !== null && Number.isFinite(retry) ? retry : void 0;
355
+ let parsed = rawBody;
356
+ try {
357
+ parsed = JSON.parse(rawBody);
358
+ } catch {
359
+ }
360
+ this.body = parsed;
361
+ const e = parsed?.error;
362
+ this.error = typeof e === "string" ? e : void 0;
363
+ }
364
+ };
365
+ async function toRouvaError(kind, res) {
366
+ const raw = await res.text().catch((err) => {
367
+ if (err?.name === "AbortError") throw err;
368
+ return res.statusText;
369
+ });
370
+ return new RouvaError(kind, res.status, res.headers, raw);
371
+ }
372
+
344
373
  // src/client.ts
345
374
  var DEFAULT_BASE_URL = "https://app.rouva.io";
346
375
  var Rouva = class {
@@ -353,7 +382,7 @@ var Rouva = class {
353
382
  this.baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
354
383
  this.chat = {
355
384
  completions: {
356
- create: (params) => this._createChatCompletion(params)
385
+ create: (params, options2) => this._createChatCompletion(params, options2)
357
386
  }
358
387
  };
359
388
  }
@@ -379,9 +408,15 @@ var Rouva = class {
379
408
  return this._sessionId;
380
409
  }
381
410
  /**
382
- * Run a server-side agentic loop. The gateway calls the model, dispatches
383
- * tool calls in parallel to your handler URLs, and loops until the model
384
- * stops or limits are reached — no streaming, no client-side loop required.
411
+ * Run a server-side agentic loop (`POST /api/gateway/run`). The gateway
412
+ * calls the model, POSTs each tool call to its handler URL as
413
+ * `{ name, arguments }` (in parallel), feeds the response bodies back as tool
414
+ * results, and loops until the model stops or a limit is reached — no
415
+ * streaming, no client-side loop required. Throws `RouvaError` on a non-2xx
416
+ * response; once the loop has started, its `body` also carries the partial
417
+ * `turns`, `session_id` and `session_cost_usd` (errors before the first turn
418
+ * — validation, no key, allowlist — carry only `error`). Pass
419
+ * `{ signal }` to cancel; a run can take several minutes.
385
420
  *
386
421
  * Every turn is logged as a usage snapshot grouped by `session_id` in the
387
422
  * Rouva dashboard, giving you a full session replay with per-turn cost,
@@ -399,39 +434,29 @@ var Rouva = class {
399
434
  * console.log(result.turns) // how many turns it took
400
435
  * console.log(result.session_id) // dashboard session ID
401
436
  */
402
- async run(params) {
403
- const url = `${this.baseURL}/api/gateway/run`;
404
- const res = await fetch(url, {
437
+ async run(params, options) {
438
+ const res = await this._post("/api/gateway/run", params, {}, options);
439
+ if (!res.ok) throw await toRouvaError("Run", res);
440
+ return res.json();
441
+ }
442
+ // One request path for every endpoint: auth, JSON body, optional abort signal.
443
+ _post(path, body, extraHeaders, options) {
444
+ return fetch(`${this.baseURL}${path}`, {
405
445
  method: "POST",
406
446
  headers: {
407
447
  "Content-Type": "application/json",
408
- "Authorization": `Bearer ${this.apiKey}`
448
+ "Authorization": `Bearer ${this.apiKey}`,
449
+ ...extraHeaders
409
450
  },
410
- body: JSON.stringify(params)
451
+ body: JSON.stringify(body),
452
+ ...options?.signal ? { signal: options.signal } : {}
411
453
  });
412
- if (!res.ok) {
413
- const body = await res.text().catch(() => res.statusText);
414
- throw new Error(`[Rouva] Run error ${res.status}: ${body}`);
415
- }
416
- return res.json();
417
454
  }
418
- async _createChatCompletion(params) {
419
- const url = `${this.baseURL}/api/gateway/messages`;
455
+ async _createChatCompletion(params, options) {
420
456
  const { stream: _stream, ...body } = params;
421
- const headers = {
422
- "Content-Type": "application/json",
423
- "Authorization": `Bearer ${this.apiKey}`
424
- };
425
- if (this._sessionId) headers["x-rouva-session-id"] = this._sessionId;
426
- const res = await fetch(url, {
427
- method: "POST",
428
- headers,
429
- body: JSON.stringify(body)
430
- });
431
- if (!res.ok) {
432
- const body2 = await res.text().catch(() => res.statusText);
433
- throw new Error(`[Rouva] Gateway error ${res.status}: ${body2}`);
434
- }
457
+ const sessionHeader = this._sessionId ? { "x-rouva-session-id": this._sessionId } : {};
458
+ const res = await this._post("/api/gateway/messages", body, sessionHeader, options);
459
+ if (!res.ok) throw await toRouvaError("Gateway", res);
435
460
  if (!res.body) throw new Error("[Rouva] No response body from gateway");
436
461
  const normalizedStream = normalizeGatewayStream(res.body);
437
462
  if (params.stream) return normalizedStream;
@@ -439,5 +464,6 @@ var Rouva = class {
439
464
  }
440
465
  };
441
466
  export {
442
- Rouva
467
+ Rouva,
468
+ RouvaError
443
469
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rouvanpm/rouva",
3
- "version": "0.2.13",
3
+ "version": "0.3.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",
@@ -17,7 +17,8 @@
17
17
  "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
18
18
  "test": "vitest run",
19
19
  "test:watch": "vitest",
20
- "lint": "tsc --noEmit"
20
+ "lint": "tsc --noEmit",
21
+ "prepublishOnly": "npm run lint && npm test && npm run build"
21
22
  },
22
23
  "files": [
23
24
  "dist"