@rouvanpm/rouva 0.1.2 → 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
@@ -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 and OpenAI today, more coming. You can request a specific model or omit it entirely and let Rouva route to the cheapest capable model automatically.
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
28
 
29
29
  ```typescript
30
30
  // Request a specific model
@@ -33,9 +33,10 @@ const res = await rouva.chat.completions.create({
33
33
  messages,
34
34
  })
35
35
 
36
- // Request a specific Anthropic model
36
+ // Force a specific provider + model
37
37
  const res = await rouva.chat.completions.create({
38
- model: 'claude-sonnet-4-6',
38
+ provider: 'gemini',
39
+ model: 'gemini-2.5-pro',
39
40
  messages,
40
41
  })
41
42
 
@@ -45,7 +46,7 @@ const res = await rouva.chat.completions.create({
45
46
  })
46
47
  ```
47
48
 
48
- ## Drop-in replacement for OpenAI
49
+ ## OpenAI-style request shape
49
50
 
50
51
  ```typescript
51
52
  // Before
@@ -77,6 +78,8 @@ while (true) {
77
78
  }
78
79
  ```
79
80
 
81
+ Streaming responses are normalized to OpenAI-style SSE chunks, even when Rouva routes the request to Anthropic.
82
+
80
83
  ## Options
81
84
 
82
85
  ```typescript
@@ -88,7 +91,7 @@ const rouva = new Rouva({
88
91
 
89
92
  ## Response metadata
90
93
 
91
- 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:
92
95
 
93
96
  ```typescript
94
97
  const res = await rouva.chat.completions.create({ messages })
@@ -96,9 +99,7 @@ const res = await rouva.chat.completions.create({ messages })
96
99
  console.log(res._rouva)
97
100
  // {
98
101
  // model_used: 'gpt-4o-mini',
99
- // cost: 0.000012,
100
- // savings: 0.000088,
101
- // intelligently_routed: true,
102
+ // provider_used: 'openai',
102
103
  // task_type: 'summarize'
103
104
  // }
104
105
  ```
@@ -111,4 +112,4 @@ console.log(res._rouva)
111
112
 
112
113
  ## License
113
114
 
114
- MIT
115
+ MIT
@@ -0,0 +1,79 @@
1
+ interface RouvaOptions {
2
+ /** Your Rouva gateway API key (rva_...) */
3
+ apiKey: string;
4
+ /** Override the default gateway URL — useful for self-hosted or testing */
5
+ baseURL?: string;
6
+ }
7
+ interface Message {
8
+ role: 'system' | 'user' | 'assistant';
9
+ content: string;
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 & {});
17
+ interface ChatCompletionParams {
18
+ messages: Message[];
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;
29
+ /** Maximum tokens to generate */
30
+ max_tokens?: number;
31
+ /** Sampling temperature 0–1 */
32
+ temperature?: number;
33
+ /** Stream the response */
34
+ stream?: boolean;
35
+ }
36
+ interface ChatCompletionChoice {
37
+ index: number;
38
+ message: Message;
39
+ finish_reason: string | null;
40
+ }
41
+ interface ChatCompletionUsage {
42
+ prompt_tokens: number;
43
+ completion_tokens: number;
44
+ total_tokens: number;
45
+ }
46
+ interface ChatCompletion {
47
+ id: string;
48
+ object: 'chat.completion';
49
+ created: number;
50
+ model: string;
51
+ choices: ChatCompletionChoice[];
52
+ usage: ChatCompletionUsage;
53
+ /** Rouva metadata — cost, savings, routing decision */
54
+ _rouva?: RouvaResponseMeta;
55
+ }
56
+ interface RouvaResponseMeta {
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;
65
+ }
66
+
67
+ declare class Rouva {
68
+ private apiKey;
69
+ private baseURL;
70
+ readonly chat: {
71
+ completions: {
72
+ create(params: ChatCompletionParams): Promise<ChatCompletion | ReadableStream>;
73
+ };
74
+ };
75
+ constructor(options: RouvaOptions);
76
+ private _createChatCompletion;
77
+ }
78
+
79
+ export { type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type Message, Rouva, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta };
@@ -0,0 +1,79 @@
1
+ interface RouvaOptions {
2
+ /** Your Rouva gateway API key (rva_...) */
3
+ apiKey: string;
4
+ /** Override the default gateway URL — useful for self-hosted or testing */
5
+ baseURL?: string;
6
+ }
7
+ interface Message {
8
+ role: 'system' | 'user' | 'assistant';
9
+ content: string;
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 & {});
17
+ interface ChatCompletionParams {
18
+ messages: Message[];
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;
29
+ /** Maximum tokens to generate */
30
+ max_tokens?: number;
31
+ /** Sampling temperature 0–1 */
32
+ temperature?: number;
33
+ /** Stream the response */
34
+ stream?: boolean;
35
+ }
36
+ interface ChatCompletionChoice {
37
+ index: number;
38
+ message: Message;
39
+ finish_reason: string | null;
40
+ }
41
+ interface ChatCompletionUsage {
42
+ prompt_tokens: number;
43
+ completion_tokens: number;
44
+ total_tokens: number;
45
+ }
46
+ interface ChatCompletion {
47
+ id: string;
48
+ object: 'chat.completion';
49
+ created: number;
50
+ model: string;
51
+ choices: ChatCompletionChoice[];
52
+ usage: ChatCompletionUsage;
53
+ /** Rouva metadata — cost, savings, routing decision */
54
+ _rouva?: RouvaResponseMeta;
55
+ }
56
+ interface RouvaResponseMeta {
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;
65
+ }
66
+
67
+ declare class Rouva {
68
+ private apiKey;
69
+ private baseURL;
70
+ readonly chat: {
71
+ completions: {
72
+ create(params: ChatCompletionParams): Promise<ChatCompletion | ReadableStream>;
73
+ };
74
+ };
75
+ constructor(options: RouvaOptions);
76
+ private _createChatCompletion;
77
+ }
78
+
79
+ export { type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type Message, Rouva, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta };
package/dist/index.js ADDED
@@ -0,0 +1,333 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ Rouva: () => Rouva
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
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
+
294
+ // src/client.ts
295
+ var DEFAULT_BASE_URL = "https://app.rouva.io";
296
+ var Rouva = class {
297
+ constructor(options) {
298
+ if (!options.apiKey) throw new Error("[Rouva] apiKey is required");
299
+ if (!options.apiKey.startsWith("rva_")) {
300
+ throw new Error("[Rouva] apiKey must start with rva_");
301
+ }
302
+ this.apiKey = options.apiKey;
303
+ this.baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
304
+ this.chat = {
305
+ completions: {
306
+ create: (params) => this._createChatCompletion(params)
307
+ }
308
+ };
309
+ }
310
+ async _createChatCompletion(params) {
311
+ const url = `${this.baseURL}/api/gateway/messages`;
312
+ const res = await fetch(url, {
313
+ method: "POST",
314
+ headers: {
315
+ "Content-Type": "application/json",
316
+ "Authorization": `Bearer ${this.apiKey}`
317
+ },
318
+ body: JSON.stringify(params)
319
+ });
320
+ if (!res.ok) {
321
+ const body = await res.text().catch(() => res.statusText);
322
+ throw new Error(`[Rouva] Gateway error ${res.status}: ${body}`);
323
+ }
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));
328
+ }
329
+ };
330
+ // Annotate the CommonJS export names for ESM import in node:
331
+ 0 && (module.exports = {
332
+ Rouva
333
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,306 @@
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
+
268
+ // src/client.ts
269
+ var DEFAULT_BASE_URL = "https://app.rouva.io";
270
+ var Rouva = class {
271
+ constructor(options) {
272
+ if (!options.apiKey) throw new Error("[Rouva] apiKey is required");
273
+ if (!options.apiKey.startsWith("rva_")) {
274
+ throw new Error("[Rouva] apiKey must start with rva_");
275
+ }
276
+ this.apiKey = options.apiKey;
277
+ this.baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
278
+ this.chat = {
279
+ completions: {
280
+ create: (params) => this._createChatCompletion(params)
281
+ }
282
+ };
283
+ }
284
+ async _createChatCompletion(params) {
285
+ const url = `${this.baseURL}/api/gateway/messages`;
286
+ const res = await fetch(url, {
287
+ method: "POST",
288
+ headers: {
289
+ "Content-Type": "application/json",
290
+ "Authorization": `Bearer ${this.apiKey}`
291
+ },
292
+ body: JSON.stringify(params)
293
+ });
294
+ if (!res.ok) {
295
+ const body = await res.text().catch(() => res.statusText);
296
+ throw new Error(`[Rouva] Gateway error ${res.status}: ${body}`);
297
+ }
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));
302
+ }
303
+ };
304
+ export {
305
+ Rouva
306
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rouvanpm/rouva",
3
- "version": "0.1.2",
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",