@rouvanpm/rouva 0.1.0 → 0.1.3

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
@@ -5,13 +5,13 @@ Official Node.js SDK for [Rouva](https://rouva.io) — managed AI gateway with i
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install rouva
8
+ npm install @rouvanpm/rouva
9
9
  ```
10
10
 
11
11
  ## Quick Start
12
12
 
13
13
  ```typescript
14
- import { Rouva } from 'rouva'
14
+ import { Rouva } from '@rouvanpm/rouva'
15
15
 
16
16
  const rouva = new Rouva({ apiKey: 'rva_...' })
17
17
 
@@ -22,7 +22,31 @@ const response = await rouva.chat.completions.create({
22
22
  console.log(response.choices[0].message.content)
23
23
  ```
24
24
 
25
- ## Drop-in replacement for OpenAI
25
+ ## Provider agnostic
26
+
27
+ Rouva works with all connected providers — Anthropic, OpenAI, Gemini, DeepSeek, and Mistral. You can request a specific model, force a specific provider, or omit both and let Rouva route to the cheapest capable model automatically.
28
+
29
+ ```typescript
30
+ // Request a specific model
31
+ const res = await rouva.chat.completions.create({
32
+ model: 'gpt-4o',
33
+ messages,
34
+ })
35
+
36
+ // Force a specific provider + model
37
+ const res = await rouva.chat.completions.create({
38
+ provider: 'gemini',
39
+ model: 'gemini-2.5-pro',
40
+ messages,
41
+ })
42
+
43
+ // Let Rouva decide — routes to cheapest model for the task
44
+ const res = await rouva.chat.completions.create({
45
+ messages,
46
+ })
47
+ ```
48
+
49
+ ## OpenAI-style request shape
26
50
 
27
51
  ```typescript
28
52
  // Before
@@ -31,7 +55,7 @@ const openai = new OpenAI({ apiKey: '...' })
31
55
  const res = await openai.chat.completions.create({ messages, model: 'gpt-4o' })
32
56
 
33
57
  // After — Rouva routes to the cheapest capable model automatically
34
- import { Rouva } from 'rouva'
58
+ import { Rouva } from '@rouvanpm/rouva'
35
59
  const rouva = new Rouva({ apiKey: 'rva_...' })
36
60
  const res = await rouva.chat.completions.create({ messages })
37
61
  ```
@@ -54,6 +78,8 @@ while (true) {
54
78
  }
55
79
  ```
56
80
 
81
+ Streaming responses are normalized to OpenAI-style SSE chunks, even when Rouva routes the request to Anthropic.
82
+
57
83
  ## Options
58
84
 
59
85
  ```typescript
@@ -65,7 +91,7 @@ const rouva = new Rouva({
65
91
 
66
92
  ## Response metadata
67
93
 
68
- Every response includes a `_rouva` field with routing and cost details:
94
+ Parsed non-stream responses may include a `_rouva` field with gateway header metadata when available:
69
95
 
70
96
  ```typescript
71
97
  const res = await rouva.chat.completions.create({ messages })
@@ -73,9 +99,7 @@ const res = await rouva.chat.completions.create({ messages })
73
99
  console.log(res._rouva)
74
100
  // {
75
101
  // model_used: 'gpt-4o-mini',
76
- // cost: 0.000012,
77
- // savings: 0.000088,
78
- // intelligently_routed: true,
102
+ // provider_used: 'openai',
79
103
  // task_type: 'summarize'
80
104
  // }
81
105
  ```
@@ -88,4 +112,4 @@ console.log(res._rouva)
88
112
 
89
113
  ## License
90
114
 
91
- MIT
115
+ MIT
package/dist/index.d.mts CHANGED
@@ -8,10 +8,24 @@ interface Message {
8
8
  role: 'system' | 'user' | 'assistant';
9
9
  content: string;
10
10
  }
11
+ type RouvaProvider = 'anthropic' | 'openai' | 'gemini' | 'deepseek' | 'mistral' | (string & {});
12
+ /**
13
+ * Supported models across all providers.
14
+ * Omit `model` entirely to let Rouva route to the cheapest capable model automatically.
15
+ */
16
+ type RouvaModel = 'claude-opus-4-6' | 'claude-opus-4-7' | 'claude-opus-4-8' | 'claude-sonnet-4-6' | 'claude-haiku-4-5-20251001' | 'claude-fable-5' | 'gpt-5-nano' | 'gpt-5-mini' | 'gpt-5' | 'gpt-5.5' | 'gpt-5.5-pro' | 'gpt-4.1-nano' | 'gpt-4.1-mini' | 'gpt-4.1' | 'gpt-4o' | 'gpt-4o-mini' | 'gemini-2.5-flash' | 'gemini-2.5-pro' | 'deepseek-chat' | 'deepseek-reasoner' | 'mistral-small-latest' | 'mistral-large-latest' | (string & {});
11
17
  interface ChatCompletionParams {
12
18
  messages: Message[];
13
- /** Target model — omit to let Rouva route intelligently */
14
- model?: string;
19
+ /**
20
+ * Target model — omit to let Rouva route intelligently to the cheapest capable model.
21
+ * Supports models from any connected provider (Anthropic, OpenAI).
22
+ */
23
+ model?: RouvaModel;
24
+ /**
25
+ * Force an exact provider when paired with `model`.
26
+ * Omit to let Rouva auto-route based on your connected keys.
27
+ */
28
+ provider?: RouvaProvider;
15
29
  /** Maximum tokens to generate */
16
30
  max_tokens?: number;
17
31
  /** Sampling temperature 0–1 */
@@ -40,16 +54,14 @@ interface ChatCompletion {
40
54
  _rouva?: RouvaResponseMeta;
41
55
  }
42
56
  interface RouvaResponseMeta {
43
- /** Actual model used (may differ from requested when intelligently routed) */
44
- model_used: string;
45
- /** USD cost for this request */
46
- cost: number;
47
- /** USD saved vs your baseline model */
48
- savings: number;
49
- /** Whether intelligent routing selected the model */
50
- intelligently_routed: boolean;
51
- /** Task type classified by Rouva */
52
- task_type: string;
57
+ /** Actual model used when exposed by the gateway */
58
+ model_used?: string;
59
+ /** Actual provider used when exposed by the gateway */
60
+ provider_used?: string;
61
+ /** Task type classified by Rouva when exposed by the gateway */
62
+ task_type?: string;
63
+ /** Semantic cache status when exposed by the gateway */
64
+ cache?: string;
53
65
  }
54
66
 
55
67
  declare class Rouva {
@@ -64,4 +76,4 @@ declare class Rouva {
64
76
  private _createChatCompletion;
65
77
  }
66
78
 
67
- export { type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type Message, Rouva, type RouvaOptions, type RouvaResponseMeta };
79
+ export { type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type Message, Rouva, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta };
package/dist/index.d.ts CHANGED
@@ -8,10 +8,24 @@ interface Message {
8
8
  role: 'system' | 'user' | 'assistant';
9
9
  content: string;
10
10
  }
11
+ type RouvaProvider = 'anthropic' | 'openai' | 'gemini' | 'deepseek' | 'mistral' | (string & {});
12
+ /**
13
+ * Supported models across all providers.
14
+ * Omit `model` entirely to let Rouva route to the cheapest capable model automatically.
15
+ */
16
+ type RouvaModel = 'claude-opus-4-6' | 'claude-opus-4-7' | 'claude-opus-4-8' | 'claude-sonnet-4-6' | 'claude-haiku-4-5-20251001' | 'claude-fable-5' | 'gpt-5-nano' | 'gpt-5-mini' | 'gpt-5' | 'gpt-5.5' | 'gpt-5.5-pro' | 'gpt-4.1-nano' | 'gpt-4.1-mini' | 'gpt-4.1' | 'gpt-4o' | 'gpt-4o-mini' | 'gemini-2.5-flash' | 'gemini-2.5-pro' | 'deepseek-chat' | 'deepseek-reasoner' | 'mistral-small-latest' | 'mistral-large-latest' | (string & {});
11
17
  interface ChatCompletionParams {
12
18
  messages: Message[];
13
- /** Target model — omit to let Rouva route intelligently */
14
- model?: string;
19
+ /**
20
+ * Target model — omit to let Rouva route intelligently to the cheapest capable model.
21
+ * Supports models from any connected provider (Anthropic, OpenAI).
22
+ */
23
+ model?: RouvaModel;
24
+ /**
25
+ * Force an exact provider when paired with `model`.
26
+ * Omit to let Rouva auto-route based on your connected keys.
27
+ */
28
+ provider?: RouvaProvider;
15
29
  /** Maximum tokens to generate */
16
30
  max_tokens?: number;
17
31
  /** Sampling temperature 0–1 */
@@ -40,16 +54,14 @@ interface ChatCompletion {
40
54
  _rouva?: RouvaResponseMeta;
41
55
  }
42
56
  interface RouvaResponseMeta {
43
- /** Actual model used (may differ from requested when intelligently routed) */
44
- model_used: string;
45
- /** USD cost for this request */
46
- cost: number;
47
- /** USD saved vs your baseline model */
48
- savings: number;
49
- /** Whether intelligent routing selected the model */
50
- intelligently_routed: boolean;
51
- /** Task type classified by Rouva */
52
- task_type: string;
57
+ /** Actual model used when exposed by the gateway */
58
+ model_used?: string;
59
+ /** Actual provider used when exposed by the gateway */
60
+ provider_used?: string;
61
+ /** Task type classified by Rouva when exposed by the gateway */
62
+ task_type?: string;
63
+ /** Semantic cache status when exposed by the gateway */
64
+ cache?: string;
53
65
  }
54
66
 
55
67
  declare class Rouva {
@@ -64,4 +76,4 @@ declare class Rouva {
64
76
  private _createChatCompletion;
65
77
  }
66
78
 
67
- export { type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type Message, Rouva, type RouvaOptions, type RouvaResponseMeta };
79
+ export { type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type Message, Rouva, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta };
package/dist/index.js CHANGED
@@ -24,6 +24,273 @@ __export(index_exports, {
24
24
  });
25
25
  module.exports = __toCommonJS(index_exports);
26
26
 
27
+ // src/sse.ts
28
+ function parseSseEvent(line) {
29
+ const raw = line.trim();
30
+ if (!raw.startsWith("data:")) return null;
31
+ const data = raw.slice(5).trim();
32
+ if (!data) return null;
33
+ try {
34
+ return { raw, data, json: data === "[DONE]" ? null : JSON.parse(data) };
35
+ } catch {
36
+ return { raw, data, json: null };
37
+ }
38
+ }
39
+ function stringifySse(payload) {
40
+ return `data: ${JSON.stringify(payload)}
41
+
42
+ `;
43
+ }
44
+ function detectProvider(payload) {
45
+ if (!payload || typeof payload !== "object") return null;
46
+ if ("choices" in payload || "usage" in payload) return "openai";
47
+ if ("type" in payload) return "anthropic";
48
+ return null;
49
+ }
50
+ function normalizeChatId(id) {
51
+ return id.startsWith("chatcmpl_") ? id : `chatcmpl_${id}`;
52
+ }
53
+ function toFinishReason(stopReason) {
54
+ if (!stopReason) return null;
55
+ if (stopReason === "end_turn" || stopReason === "stop_sequence") return "stop";
56
+ if (stopReason === "max_tokens") return "length";
57
+ return stopReason;
58
+ }
59
+ function initialState() {
60
+ return {
61
+ provider: null,
62
+ id: `chatcmpl_${Date.now()}`,
63
+ model: "unknown",
64
+ created: Math.floor(Date.now() / 1e3),
65
+ promptTokens: 0,
66
+ completionTokens: 0,
67
+ roleEmitted: false,
68
+ finalChunkEmitted: false
69
+ };
70
+ }
71
+ function normalizeAnthropicPayload(payload, state) {
72
+ const eventType = typeof payload.type === "string" ? payload.type : null;
73
+ const chunks = [];
74
+ if (eventType === "message_start") {
75
+ const message = payload.message ?? {};
76
+ const usage = message.usage ?? {};
77
+ state.id = normalizeChatId(typeof message.id === "string" ? message.id : state.id);
78
+ state.model = typeof message.model === "string" ? message.model : state.model;
79
+ state.promptTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
80
+ if (!state.roleEmitted) {
81
+ chunks.push(stringifySse({
82
+ id: state.id,
83
+ object: "chat.completion.chunk",
84
+ created: state.created,
85
+ model: state.model,
86
+ choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
87
+ }));
88
+ state.roleEmitted = true;
89
+ }
90
+ }
91
+ if (eventType === "content_block_delta") {
92
+ const delta = payload.delta ?? {};
93
+ if (delta.type === "text_delta" && typeof delta.text === "string") {
94
+ if (!state.roleEmitted) {
95
+ chunks.push(stringifySse({
96
+ id: state.id,
97
+ object: "chat.completion.chunk",
98
+ created: state.created,
99
+ model: state.model,
100
+ choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
101
+ }));
102
+ state.roleEmitted = true;
103
+ }
104
+ chunks.push(stringifySse({
105
+ id: state.id,
106
+ object: "chat.completion.chunk",
107
+ created: state.created,
108
+ model: state.model,
109
+ choices: [{ index: 0, delta: { content: delta.text }, finish_reason: null }]
110
+ }));
111
+ }
112
+ }
113
+ if (eventType === "message_delta") {
114
+ const delta = payload.delta ?? {};
115
+ const usage = payload.usage ?? {};
116
+ state.completionTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : state.completionTokens;
117
+ chunks.push(stringifySse({
118
+ id: state.id,
119
+ object: "chat.completion.chunk",
120
+ created: state.created,
121
+ model: state.model,
122
+ choices: [{ index: 0, delta: {}, finish_reason: toFinishReason(typeof delta.stop_reason === "string" ? delta.stop_reason : null) }],
123
+ usage: {
124
+ prompt_tokens: state.promptTokens,
125
+ completion_tokens: state.completionTokens,
126
+ total_tokens: state.promptTokens + state.completionTokens
127
+ }
128
+ }));
129
+ state.finalChunkEmitted = true;
130
+ }
131
+ if (eventType === "message_stop") {
132
+ chunks.push("data: [DONE]\n\n");
133
+ }
134
+ if (eventType === "error") {
135
+ chunks.push(stringifySse(payload));
136
+ }
137
+ return chunks;
138
+ }
139
+ function normalizeGatewayStream(stream) {
140
+ const encoder = new TextEncoder();
141
+ const decoder = new TextDecoder();
142
+ let buffer = "";
143
+ const state = initialState();
144
+ return new ReadableStream({
145
+ async start(controller) {
146
+ const reader = stream.getReader();
147
+ try {
148
+ while (true) {
149
+ const { done, value } = await reader.read();
150
+ if (done) break;
151
+ if (!value) continue;
152
+ buffer += decoder.decode(value, { stream: true });
153
+ const lines = buffer.split("\n");
154
+ buffer = lines.pop() ?? "";
155
+ for (const line of lines) {
156
+ const event = parseSseEvent(line);
157
+ if (!event) continue;
158
+ if (event.data === "[DONE]") {
159
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
160
+ continue;
161
+ }
162
+ const provider = detectProvider(event.json);
163
+ if (provider && !state.provider) state.provider = provider;
164
+ if (state.provider === "anthropic" && event.json && typeof event.json === "object") {
165
+ for (const chunk of normalizeAnthropicPayload(event.json, state)) {
166
+ controller.enqueue(encoder.encode(chunk));
167
+ }
168
+ continue;
169
+ }
170
+ controller.enqueue(encoder.encode(`${event.raw}
171
+
172
+ `));
173
+ }
174
+ }
175
+ if (buffer.trim()) {
176
+ const event = parseSseEvent(buffer);
177
+ if (event) {
178
+ if (event.data === "[DONE]") {
179
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
180
+ } else if (state.provider === "anthropic" && event.json && typeof event.json === "object") {
181
+ for (const chunk of normalizeAnthropicPayload(event.json, state)) {
182
+ controller.enqueue(encoder.encode(chunk));
183
+ }
184
+ } else {
185
+ controller.enqueue(encoder.encode(`${event.raw}
186
+
187
+ `));
188
+ }
189
+ }
190
+ }
191
+ } finally {
192
+ controller.close();
193
+ }
194
+ }
195
+ });
196
+ }
197
+ async function readChatCompletionFromSse(stream, metadata) {
198
+ const reader = stream.getReader();
199
+ const decoder = new TextDecoder();
200
+ let buffer = "";
201
+ let id = `chatcmpl_${Date.now()}`;
202
+ let model = metadata?.model_used ?? "unknown";
203
+ let created = Math.floor(Date.now() / 1e3);
204
+ let role = "assistant";
205
+ let content = "";
206
+ let finishReason = null;
207
+ let usage = {
208
+ prompt_tokens: 0,
209
+ completion_tokens: 0,
210
+ total_tokens: 0
211
+ };
212
+ try {
213
+ while (true) {
214
+ const { done, value } = await reader.read();
215
+ if (done) break;
216
+ if (!value) continue;
217
+ buffer += decoder.decode(value, { stream: true });
218
+ const lines = buffer.split("\n");
219
+ buffer = lines.pop() ?? "";
220
+ for (const line of lines) {
221
+ const event = parseSseEvent(line);
222
+ if (!event || event.data === "[DONE]") continue;
223
+ if (!event.json || typeof event.json !== "object") continue;
224
+ const payload = event.json;
225
+ if (payload.type === "error" && payload.error) {
226
+ const error = payload.error;
227
+ throw new Error(
228
+ `[Rouva] Upstream error in stream: ${typeof error.message === "string" ? error.message : "Unknown error"}`
229
+ );
230
+ }
231
+ if (typeof payload.id === "string") id = payload.id;
232
+ if (typeof payload.model === "string") model = payload.model;
233
+ if (typeof payload.created === "number") created = payload.created;
234
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
235
+ const firstChoice = choices[0];
236
+ const delta = firstChoice?.delta;
237
+ if (delta?.role === "assistant") {
238
+ role = "assistant";
239
+ }
240
+ if (typeof delta?.content === "string") {
241
+ content += delta.content;
242
+ }
243
+ if (typeof firstChoice?.finish_reason === "string" || firstChoice?.finish_reason === null) {
244
+ finishReason = firstChoice.finish_reason;
245
+ }
246
+ const payloadUsage = payload.usage;
247
+ if (payloadUsage) {
248
+ const promptTokens = typeof payloadUsage.prompt_tokens === "number" ? payloadUsage.prompt_tokens : 0;
249
+ const completionTokens = typeof payloadUsage.completion_tokens === "number" ? payloadUsage.completion_tokens : 0;
250
+ usage = {
251
+ prompt_tokens: promptTokens,
252
+ completion_tokens: completionTokens,
253
+ total_tokens: typeof payloadUsage.total_tokens === "number" ? payloadUsage.total_tokens : promptTokens + completionTokens
254
+ };
255
+ }
256
+ }
257
+ }
258
+ } finally {
259
+ reader.releaseLock();
260
+ }
261
+ const response = {
262
+ id,
263
+ object: "chat.completion",
264
+ created,
265
+ model,
266
+ choices: [
267
+ {
268
+ index: 0,
269
+ message: { role, content },
270
+ finish_reason: finishReason
271
+ }
272
+ ],
273
+ usage
274
+ };
275
+ if (metadata && Object.keys(metadata).length > 0) {
276
+ response._rouva = metadata;
277
+ }
278
+ return response;
279
+ }
280
+ function getRouvaMetadata(headers) {
281
+ const modelUsed = headers.get("x-rouva-model") ?? void 0;
282
+ const providerUsed = headers.get("x-rouva-provider") ?? void 0;
283
+ const taskType = headers.get("x-rouva-task") ?? void 0;
284
+ const cache = headers.get("x-rouva-cache") ?? void 0;
285
+ if (!modelUsed && !providerUsed && !taskType && !cache) return void 0;
286
+ return {
287
+ model_used: modelUsed,
288
+ provider_used: providerUsed,
289
+ task_type: taskType,
290
+ cache
291
+ };
292
+ }
293
+
27
294
  // src/client.ts
28
295
  var DEFAULT_BASE_URL = "https://app.rouva.io";
29
296
  var Rouva = class {
@@ -46,7 +313,7 @@ var Rouva = class {
46
313
  method: "POST",
47
314
  headers: {
48
315
  "Content-Type": "application/json",
49
- "x-api-key": this.apiKey
316
+ "Authorization": `Bearer ${this.apiKey}`
50
317
  },
51
318
  body: JSON.stringify(params)
52
319
  });
@@ -54,11 +321,10 @@ var Rouva = class {
54
321
  const body = await res.text().catch(() => res.statusText);
55
322
  throw new Error(`[Rouva] Gateway error ${res.status}: ${body}`);
56
323
  }
57
- if (params.stream) {
58
- if (!res.body) throw new Error("[Rouva] No response body for streaming request");
59
- return res.body;
60
- }
61
- return res.json();
324
+ if (!res.body) throw new Error("[Rouva] No response body from gateway");
325
+ const normalizedStream = normalizeGatewayStream(res.body);
326
+ if (params.stream) return normalizedStream;
327
+ return readChatCompletionFromSse(normalizedStream, getRouvaMetadata(res.headers));
62
328
  }
63
329
  };
64
330
  // Annotate the CommonJS export names for ESM import in node:
package/dist/index.mjs CHANGED
@@ -1,3 +1,270 @@
1
+ // src/sse.ts
2
+ function parseSseEvent(line) {
3
+ const raw = line.trim();
4
+ if (!raw.startsWith("data:")) return null;
5
+ const data = raw.slice(5).trim();
6
+ if (!data) return null;
7
+ try {
8
+ return { raw, data, json: data === "[DONE]" ? null : JSON.parse(data) };
9
+ } catch {
10
+ return { raw, data, json: null };
11
+ }
12
+ }
13
+ function stringifySse(payload) {
14
+ return `data: ${JSON.stringify(payload)}
15
+
16
+ `;
17
+ }
18
+ function detectProvider(payload) {
19
+ if (!payload || typeof payload !== "object") return null;
20
+ if ("choices" in payload || "usage" in payload) return "openai";
21
+ if ("type" in payload) return "anthropic";
22
+ return null;
23
+ }
24
+ function normalizeChatId(id) {
25
+ return id.startsWith("chatcmpl_") ? id : `chatcmpl_${id}`;
26
+ }
27
+ function toFinishReason(stopReason) {
28
+ if (!stopReason) return null;
29
+ if (stopReason === "end_turn" || stopReason === "stop_sequence") return "stop";
30
+ if (stopReason === "max_tokens") return "length";
31
+ return stopReason;
32
+ }
33
+ function initialState() {
34
+ return {
35
+ provider: null,
36
+ id: `chatcmpl_${Date.now()}`,
37
+ model: "unknown",
38
+ created: Math.floor(Date.now() / 1e3),
39
+ promptTokens: 0,
40
+ completionTokens: 0,
41
+ roleEmitted: false,
42
+ finalChunkEmitted: false
43
+ };
44
+ }
45
+ function normalizeAnthropicPayload(payload, state) {
46
+ const eventType = typeof payload.type === "string" ? payload.type : null;
47
+ const chunks = [];
48
+ if (eventType === "message_start") {
49
+ const message = payload.message ?? {};
50
+ const usage = message.usage ?? {};
51
+ state.id = normalizeChatId(typeof message.id === "string" ? message.id : state.id);
52
+ state.model = typeof message.model === "string" ? message.model : state.model;
53
+ state.promptTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
54
+ if (!state.roleEmitted) {
55
+ chunks.push(stringifySse({
56
+ id: state.id,
57
+ object: "chat.completion.chunk",
58
+ created: state.created,
59
+ model: state.model,
60
+ choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
61
+ }));
62
+ state.roleEmitted = true;
63
+ }
64
+ }
65
+ if (eventType === "content_block_delta") {
66
+ const delta = payload.delta ?? {};
67
+ if (delta.type === "text_delta" && typeof delta.text === "string") {
68
+ if (!state.roleEmitted) {
69
+ chunks.push(stringifySse({
70
+ id: state.id,
71
+ object: "chat.completion.chunk",
72
+ created: state.created,
73
+ model: state.model,
74
+ choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
75
+ }));
76
+ state.roleEmitted = true;
77
+ }
78
+ chunks.push(stringifySse({
79
+ id: state.id,
80
+ object: "chat.completion.chunk",
81
+ created: state.created,
82
+ model: state.model,
83
+ choices: [{ index: 0, delta: { content: delta.text }, finish_reason: null }]
84
+ }));
85
+ }
86
+ }
87
+ if (eventType === "message_delta") {
88
+ const delta = payload.delta ?? {};
89
+ const usage = payload.usage ?? {};
90
+ state.completionTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : state.completionTokens;
91
+ chunks.push(stringifySse({
92
+ id: state.id,
93
+ object: "chat.completion.chunk",
94
+ created: state.created,
95
+ model: state.model,
96
+ choices: [{ index: 0, delta: {}, finish_reason: toFinishReason(typeof delta.stop_reason === "string" ? delta.stop_reason : null) }],
97
+ usage: {
98
+ prompt_tokens: state.promptTokens,
99
+ completion_tokens: state.completionTokens,
100
+ total_tokens: state.promptTokens + state.completionTokens
101
+ }
102
+ }));
103
+ state.finalChunkEmitted = true;
104
+ }
105
+ if (eventType === "message_stop") {
106
+ chunks.push("data: [DONE]\n\n");
107
+ }
108
+ if (eventType === "error") {
109
+ chunks.push(stringifySse(payload));
110
+ }
111
+ return chunks;
112
+ }
113
+ function normalizeGatewayStream(stream) {
114
+ const encoder = new TextEncoder();
115
+ const decoder = new TextDecoder();
116
+ let buffer = "";
117
+ const state = initialState();
118
+ return new ReadableStream({
119
+ async start(controller) {
120
+ const reader = stream.getReader();
121
+ try {
122
+ while (true) {
123
+ const { done, value } = await reader.read();
124
+ if (done) break;
125
+ if (!value) continue;
126
+ buffer += decoder.decode(value, { stream: true });
127
+ const lines = buffer.split("\n");
128
+ buffer = lines.pop() ?? "";
129
+ for (const line of lines) {
130
+ const event = parseSseEvent(line);
131
+ if (!event) continue;
132
+ if (event.data === "[DONE]") {
133
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
134
+ continue;
135
+ }
136
+ const provider = detectProvider(event.json);
137
+ if (provider && !state.provider) state.provider = provider;
138
+ if (state.provider === "anthropic" && event.json && typeof event.json === "object") {
139
+ for (const chunk of normalizeAnthropicPayload(event.json, state)) {
140
+ controller.enqueue(encoder.encode(chunk));
141
+ }
142
+ continue;
143
+ }
144
+ controller.enqueue(encoder.encode(`${event.raw}
145
+
146
+ `));
147
+ }
148
+ }
149
+ if (buffer.trim()) {
150
+ const event = parseSseEvent(buffer);
151
+ if (event) {
152
+ if (event.data === "[DONE]") {
153
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
154
+ } else if (state.provider === "anthropic" && event.json && typeof event.json === "object") {
155
+ for (const chunk of normalizeAnthropicPayload(event.json, state)) {
156
+ controller.enqueue(encoder.encode(chunk));
157
+ }
158
+ } else {
159
+ controller.enqueue(encoder.encode(`${event.raw}
160
+
161
+ `));
162
+ }
163
+ }
164
+ }
165
+ } finally {
166
+ controller.close();
167
+ }
168
+ }
169
+ });
170
+ }
171
+ async function readChatCompletionFromSse(stream, metadata) {
172
+ const reader = stream.getReader();
173
+ const decoder = new TextDecoder();
174
+ let buffer = "";
175
+ let id = `chatcmpl_${Date.now()}`;
176
+ let model = metadata?.model_used ?? "unknown";
177
+ let created = Math.floor(Date.now() / 1e3);
178
+ let role = "assistant";
179
+ let content = "";
180
+ let finishReason = null;
181
+ let usage = {
182
+ prompt_tokens: 0,
183
+ completion_tokens: 0,
184
+ total_tokens: 0
185
+ };
186
+ try {
187
+ while (true) {
188
+ const { done, value } = await reader.read();
189
+ if (done) break;
190
+ if (!value) continue;
191
+ buffer += decoder.decode(value, { stream: true });
192
+ const lines = buffer.split("\n");
193
+ buffer = lines.pop() ?? "";
194
+ for (const line of lines) {
195
+ const event = parseSseEvent(line);
196
+ if (!event || event.data === "[DONE]") continue;
197
+ if (!event.json || typeof event.json !== "object") continue;
198
+ const payload = event.json;
199
+ if (payload.type === "error" && payload.error) {
200
+ const error = payload.error;
201
+ throw new Error(
202
+ `[Rouva] Upstream error in stream: ${typeof error.message === "string" ? error.message : "Unknown error"}`
203
+ );
204
+ }
205
+ if (typeof payload.id === "string") id = payload.id;
206
+ if (typeof payload.model === "string") model = payload.model;
207
+ if (typeof payload.created === "number") created = payload.created;
208
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
209
+ const firstChoice = choices[0];
210
+ const delta = firstChoice?.delta;
211
+ if (delta?.role === "assistant") {
212
+ role = "assistant";
213
+ }
214
+ if (typeof delta?.content === "string") {
215
+ content += delta.content;
216
+ }
217
+ if (typeof firstChoice?.finish_reason === "string" || firstChoice?.finish_reason === null) {
218
+ finishReason = firstChoice.finish_reason;
219
+ }
220
+ const payloadUsage = payload.usage;
221
+ if (payloadUsage) {
222
+ const promptTokens = typeof payloadUsage.prompt_tokens === "number" ? payloadUsage.prompt_tokens : 0;
223
+ const completionTokens = typeof payloadUsage.completion_tokens === "number" ? payloadUsage.completion_tokens : 0;
224
+ usage = {
225
+ prompt_tokens: promptTokens,
226
+ completion_tokens: completionTokens,
227
+ total_tokens: typeof payloadUsage.total_tokens === "number" ? payloadUsage.total_tokens : promptTokens + completionTokens
228
+ };
229
+ }
230
+ }
231
+ }
232
+ } finally {
233
+ reader.releaseLock();
234
+ }
235
+ const response = {
236
+ id,
237
+ object: "chat.completion",
238
+ created,
239
+ model,
240
+ choices: [
241
+ {
242
+ index: 0,
243
+ message: { role, content },
244
+ finish_reason: finishReason
245
+ }
246
+ ],
247
+ usage
248
+ };
249
+ if (metadata && Object.keys(metadata).length > 0) {
250
+ response._rouva = metadata;
251
+ }
252
+ return response;
253
+ }
254
+ function getRouvaMetadata(headers) {
255
+ const modelUsed = headers.get("x-rouva-model") ?? void 0;
256
+ const providerUsed = headers.get("x-rouva-provider") ?? void 0;
257
+ const taskType = headers.get("x-rouva-task") ?? void 0;
258
+ const cache = headers.get("x-rouva-cache") ?? void 0;
259
+ if (!modelUsed && !providerUsed && !taskType && !cache) return void 0;
260
+ return {
261
+ model_used: modelUsed,
262
+ provider_used: providerUsed,
263
+ task_type: taskType,
264
+ cache
265
+ };
266
+ }
267
+
1
268
  // src/client.ts
2
269
  var DEFAULT_BASE_URL = "https://app.rouva.io";
3
270
  var Rouva = class {
@@ -20,7 +287,7 @@ var Rouva = class {
20
287
  method: "POST",
21
288
  headers: {
22
289
  "Content-Type": "application/json",
23
- "x-api-key": this.apiKey
290
+ "Authorization": `Bearer ${this.apiKey}`
24
291
  },
25
292
  body: JSON.stringify(params)
26
293
  });
@@ -28,11 +295,10 @@ var Rouva = class {
28
295
  const body = await res.text().catch(() => res.statusText);
29
296
  throw new Error(`[Rouva] Gateway error ${res.status}: ${body}`);
30
297
  }
31
- if (params.stream) {
32
- if (!res.body) throw new Error("[Rouva] No response body for streaming request");
33
- return res.body;
34
- }
35
- return res.json();
298
+ if (!res.body) throw new Error("[Rouva] No response body from gateway");
299
+ const normalizedStream = normalizeGatewayStream(res.body);
300
+ if (params.stream) return normalizedStream;
301
+ return readChatCompletionFromSse(normalizedStream, getRouvaMetadata(res.headers));
36
302
  }
37
303
  };
38
304
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rouvanpm/rouva",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
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",
@@ -38,4 +38,4 @@
38
38
  "typescript": "^5.0.0",
39
39
  "vitest": "^1.0.0"
40
40
  }
41
- }
41
+ }