@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 +33 -9
- package/dist/index.d.mts +25 -13
- package/dist/index.d.ts +25 -13
- package/dist/index.js +272 -6
- package/dist/index.mjs +272 -6
- package/package.json +2 -2
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
|
-
##
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
14
|
-
|
|
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
|
|
44
|
-
model_used
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
|
|
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
|
-
/**
|
|
14
|
-
|
|
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
|
|
44
|
-
model_used
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
|
|
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
|
-
"
|
|
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 (
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
"
|
|
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 (
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
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
|
+
}
|