@rouvanpm/rouva 0.2.2 → 0.2.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, 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.
27
+ Rouva works with all connected providers — Anthropic, OpenAI, Gemini, DeepSeek, Mistral, Moonshot, xAI, and Z.ai. You can request a specific model, force a specific provider, or omit both and let Rouva route to the cheapest capable model automatically.
28
28
 
29
29
  ```typescript
30
30
  // Request a specific model
@@ -80,6 +80,66 @@ while (true) {
80
80
 
81
81
  Streaming responses are normalized to OpenAI-style SSE chunks, even when Rouva routes the request to Anthropic.
82
82
 
83
+ `stream` is a client-side toggle: `stream: true` returns the raw `ReadableStream`, omitting it (or `stream: false`) returns a buffered `ChatCompletion`. It is never sent to the gateway.
84
+
85
+ ## Request parameters
86
+
87
+ ```typescript
88
+ const res = await rouva.chat.completions.create({
89
+ messages: [{ role: 'user', content: 'Write a haiku about routing.' }],
90
+ system: 'You are a concise assistant.', // string or Anthropic-style text blocks
91
+ max_tokens: 1024, // default 4096
92
+ temperature: 0.7, // 0–1
93
+ })
94
+ ```
95
+
96
+ - **`temperature`** — sampling temperature between 0 and 1. Reasoning models (the gpt-5 family) only support their default temperature, so the gateway omits it when routing to one.
97
+ - **`max_tokens`** — values below 1024 also steer auto-routing away from reasoning models, which would otherwise spend the whole budget on hidden reasoning tokens.
98
+
99
+ ## Tool use
100
+
101
+ Tools are forwarded to your target provider verbatim — define them in the **provider's own format** (OpenAI `{ type: "function", function: {...} }` or Anthropic `{ name, description, input_schema }`) and pin the matching `model`. Tools requests are never re-routed: tool schemas are provider-specific, so `model` is required and the gateway returns a 400 without it.
102
+
103
+ ```typescript
104
+ const res = await rouva.chat.completions.create({
105
+ model: 'gpt-4o',
106
+ messages: [{ role: 'user', content: 'What is the weather in SF?' }],
107
+ tools: [{
108
+ type: 'function',
109
+ function: {
110
+ name: 'get_weather',
111
+ description: 'Get current weather for a city',
112
+ parameters: {
113
+ type: 'object',
114
+ properties: { city: { type: 'string' } },
115
+ required: ['city'],
116
+ },
117
+ },
118
+ }],
119
+ })
120
+
121
+ const toolCall = res.choices[0].message.tool_calls?.[0]
122
+ if (toolCall) {
123
+ const args = JSON.parse(toolCall.function.arguments)
124
+ const weather = await getWeather(args.city)
125
+
126
+ // Send the result back — same messages array plus the tool turn
127
+ const followUp = await rouva.chat.completions.create({
128
+ model: 'gpt-4o',
129
+ messages: [
130
+ { role: 'user', content: 'What is the weather in SF?' },
131
+ res.choices[0].message,
132
+ { role: 'tool', content: JSON.stringify(weather), tool_call_id: toolCall.id },
133
+ ],
134
+ tools: [/* same tools every turn */],
135
+ })
136
+ }
137
+ ```
138
+
139
+ Responses are normalized to the OpenAI shape regardless of provider: Anthropic `tool_use` blocks arrive as `message.tool_calls` (buffered) or `delta.tool_calls` chunks (streaming), with `finish_reason: "tool_calls"`. When sending Anthropic results back, use the Anthropic dialect in your messages (`tool_result` content blocks) — message payloads pass through to the provider verbatim.
140
+
141
+ Tools requests record usage and cost but no savings, and are not quality-scored or served from the semantic cache.
142
+
83
143
  ## Options
84
144
 
85
145
  ```typescript
package/dist/index.d.mts CHANGED
@@ -8,6 +8,43 @@ interface Message {
8
8
  role: 'system' | 'user' | 'assistant';
9
9
  content: string;
10
10
  }
11
+ /** OpenAI-dialect tool call, as emitted on assistant messages */
12
+ interface FunctionToolCall {
13
+ id: string;
14
+ type: 'function';
15
+ function: {
16
+ name: string;
17
+ /** JSON-encoded arguments string */
18
+ arguments: string;
19
+ };
20
+ }
21
+ /** OpenAI-dialect assistant turn that requested tool calls */
22
+ interface AssistantToolCallMessage {
23
+ role: 'assistant';
24
+ content: string | null;
25
+ tool_calls: FunctionToolCall[];
26
+ }
27
+ /** OpenAI-dialect tool result turn */
28
+ interface ToolResultMessage {
29
+ role: 'tool';
30
+ content: string;
31
+ tool_call_id: string;
32
+ }
33
+ /**
34
+ * Anthropic-dialect turn whose content is an array of content blocks
35
+ * (e.g. `tool_use` on assistant turns, `tool_result` on user turns).
36
+ * Blocks are forwarded to the gateway verbatim.
37
+ */
38
+ interface ContentBlockMessage {
39
+ role: 'user' | 'assistant';
40
+ content: Array<Record<string, unknown>>;
41
+ }
42
+ /**
43
+ * Any message the gateway accepts. Plain chat uses `Message`; tool
44
+ * conversations additionally use the tool-shaped variants, which are only
45
+ * valid when `tools` is set on the request.
46
+ */
47
+ type ChatMessage = Message | AssistantToolCallMessage | ToolResultMessage | ContentBlockMessage;
11
48
  type RouvaProvider = 'anthropic' | 'openai' | 'gemini' | 'deepseek' | 'mistral' | 'moonshot' | 'xai' | 'zai' | (string & {});
12
49
  /**
13
50
  * Supported models across all providers.
@@ -20,7 +57,7 @@ interface SystemTextBlock {
20
57
  text: string;
21
58
  }
22
59
  interface ChatCompletionParams {
23
- messages: Message[];
60
+ messages: ChatMessage[];
24
61
  /**
25
62
  * System prompt — a plain string or Anthropic-style text blocks.
26
63
  * Equivalent to a `role: "system"` message at the head of `messages`;
@@ -52,10 +89,29 @@ interface ChatCompletionParams {
52
89
  * the gateway.
53
90
  */
54
91
  stream?: boolean;
92
+ /**
93
+ * Tool definitions in your target provider's own format, forwarded to the
94
+ * provider verbatim (OpenAI `{ type: "function", function: {...} }` or
95
+ * Anthropic `{ name, description, input_schema }`). Requires `model` —
96
+ * tool schemas are provider-specific, so tools requests are never
97
+ * re-routed by the gateway.
98
+ */
99
+ tools?: Array<Record<string, unknown>>;
100
+ /**
101
+ * Tool choice in your target provider's own format, forwarded verbatim.
102
+ * Only valid alongside `tools`.
103
+ */
104
+ tool_choice?: string | Record<string, unknown>;
105
+ }
106
+ /** Assistant message in a completed response; `tool_calls` present when the model requested tools */
107
+ interface ResponseMessage {
108
+ role: 'assistant';
109
+ content: string | null;
110
+ tool_calls?: FunctionToolCall[];
55
111
  }
56
112
  interface ChatCompletionChoice {
57
113
  index: number;
58
- message: Message;
114
+ message: ResponseMessage;
59
115
  finish_reason: string | null;
60
116
  }
61
117
  interface ChatCompletionUsage {
@@ -96,4 +152,4 @@ declare class Rouva {
96
152
  private _createChatCompletion;
97
153
  }
98
154
 
99
- export { type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type Message, Rouva, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta, type SystemTextBlock };
155
+ export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type FunctionToolCall, type Message, type ResponseMessage, Rouva, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta, type SystemTextBlock, type ToolResultMessage };
package/dist/index.d.ts CHANGED
@@ -8,6 +8,43 @@ interface Message {
8
8
  role: 'system' | 'user' | 'assistant';
9
9
  content: string;
10
10
  }
11
+ /** OpenAI-dialect tool call, as emitted on assistant messages */
12
+ interface FunctionToolCall {
13
+ id: string;
14
+ type: 'function';
15
+ function: {
16
+ name: string;
17
+ /** JSON-encoded arguments string */
18
+ arguments: string;
19
+ };
20
+ }
21
+ /** OpenAI-dialect assistant turn that requested tool calls */
22
+ interface AssistantToolCallMessage {
23
+ role: 'assistant';
24
+ content: string | null;
25
+ tool_calls: FunctionToolCall[];
26
+ }
27
+ /** OpenAI-dialect tool result turn */
28
+ interface ToolResultMessage {
29
+ role: 'tool';
30
+ content: string;
31
+ tool_call_id: string;
32
+ }
33
+ /**
34
+ * Anthropic-dialect turn whose content is an array of content blocks
35
+ * (e.g. `tool_use` on assistant turns, `tool_result` on user turns).
36
+ * Blocks are forwarded to the gateway verbatim.
37
+ */
38
+ interface ContentBlockMessage {
39
+ role: 'user' | 'assistant';
40
+ content: Array<Record<string, unknown>>;
41
+ }
42
+ /**
43
+ * Any message the gateway accepts. Plain chat uses `Message`; tool
44
+ * conversations additionally use the tool-shaped variants, which are only
45
+ * valid when `tools` is set on the request.
46
+ */
47
+ type ChatMessage = Message | AssistantToolCallMessage | ToolResultMessage | ContentBlockMessage;
11
48
  type RouvaProvider = 'anthropic' | 'openai' | 'gemini' | 'deepseek' | 'mistral' | 'moonshot' | 'xai' | 'zai' | (string & {});
12
49
  /**
13
50
  * Supported models across all providers.
@@ -20,7 +57,7 @@ interface SystemTextBlock {
20
57
  text: string;
21
58
  }
22
59
  interface ChatCompletionParams {
23
- messages: Message[];
60
+ messages: ChatMessage[];
24
61
  /**
25
62
  * System prompt — a plain string or Anthropic-style text blocks.
26
63
  * Equivalent to a `role: "system"` message at the head of `messages`;
@@ -52,10 +89,29 @@ interface ChatCompletionParams {
52
89
  * the gateway.
53
90
  */
54
91
  stream?: boolean;
92
+ /**
93
+ * Tool definitions in your target provider's own format, forwarded to the
94
+ * provider verbatim (OpenAI `{ type: "function", function: {...} }` or
95
+ * Anthropic `{ name, description, input_schema }`). Requires `model` —
96
+ * tool schemas are provider-specific, so tools requests are never
97
+ * re-routed by the gateway.
98
+ */
99
+ tools?: Array<Record<string, unknown>>;
100
+ /**
101
+ * Tool choice in your target provider's own format, forwarded verbatim.
102
+ * Only valid alongside `tools`.
103
+ */
104
+ tool_choice?: string | Record<string, unknown>;
105
+ }
106
+ /** Assistant message in a completed response; `tool_calls` present when the model requested tools */
107
+ interface ResponseMessage {
108
+ role: 'assistant';
109
+ content: string | null;
110
+ tool_calls?: FunctionToolCall[];
55
111
  }
56
112
  interface ChatCompletionChoice {
57
113
  index: number;
58
- message: Message;
114
+ message: ResponseMessage;
59
115
  finish_reason: string | null;
60
116
  }
61
117
  interface ChatCompletionUsage {
@@ -96,4 +152,4 @@ declare class Rouva {
96
152
  private _createChatCompletion;
97
153
  }
98
154
 
99
- export { type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type Message, Rouva, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta, type SystemTextBlock };
155
+ export { type AssistantToolCallMessage, type ChatCompletion, type ChatCompletionChoice, type ChatCompletionParams, type ChatCompletionUsage, type ChatMessage, type ContentBlockMessage, type FunctionToolCall, type Message, type ResponseMessage, Rouva, type RouvaOptions, type RouvaProvider, type RouvaResponseMeta, type SystemTextBlock, type ToolResultMessage };
package/dist/index.js CHANGED
@@ -54,6 +54,7 @@ function toFinishReason(stopReason) {
54
54
  if (!stopReason) return null;
55
55
  if (stopReason === "end_turn" || stopReason === "stop_sequence") return "stop";
56
56
  if (stopReason === "max_tokens") return "length";
57
+ if (stopReason === "tool_use") return "tool_calls";
57
58
  return stopReason;
58
59
  }
59
60
  function initialState() {
@@ -65,7 +66,9 @@ function initialState() {
65
66
  promptTokens: 0,
66
67
  completionTokens: 0,
67
68
  roleEmitted: false,
68
- finalChunkEmitted: false
69
+ finalChunkEmitted: false,
70
+ toolCallIndexByBlock: {},
71
+ nextToolCallIndex: 0
69
72
  };
70
73
  }
71
74
  function normalizeAnthropicPayload(payload, state) {
@@ -88,8 +91,45 @@ function normalizeAnthropicPayload(payload, state) {
88
91
  state.roleEmitted = true;
89
92
  }
90
93
  }
94
+ if (eventType === "content_block_start") {
95
+ const block = payload.content_block ?? {};
96
+ const blockIndex = typeof payload.index === "number" ? payload.index : null;
97
+ if (block.type === "tool_use" && blockIndex !== null) {
98
+ const toolCallIndex = state.nextToolCallIndex++;
99
+ state.toolCallIndexByBlock[blockIndex] = toolCallIndex;
100
+ if (!state.roleEmitted) {
101
+ chunks.push(stringifySse({
102
+ id: state.id,
103
+ object: "chat.completion.chunk",
104
+ created: state.created,
105
+ model: state.model,
106
+ choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
107
+ }));
108
+ state.roleEmitted = true;
109
+ }
110
+ chunks.push(stringifySse({
111
+ id: state.id,
112
+ object: "chat.completion.chunk",
113
+ created: state.created,
114
+ model: state.model,
115
+ choices: [{
116
+ index: 0,
117
+ delta: {
118
+ tool_calls: [{
119
+ index: toolCallIndex,
120
+ id: typeof block.id === "string" ? block.id : `call_${toolCallIndex}`,
121
+ type: "function",
122
+ function: { name: typeof block.name === "string" ? block.name : "", arguments: "" }
123
+ }]
124
+ },
125
+ finish_reason: null
126
+ }]
127
+ }));
128
+ }
129
+ }
91
130
  if (eventType === "content_block_delta") {
92
131
  const delta = payload.delta ?? {};
132
+ const blockIndex = typeof payload.index === "number" ? payload.index : null;
93
133
  if (delta.type === "text_delta" && typeof delta.text === "string") {
94
134
  if (!state.roleEmitted) {
95
135
  chunks.push(stringifySse({
@@ -109,6 +149,24 @@ function normalizeAnthropicPayload(payload, state) {
109
149
  choices: [{ index: 0, delta: { content: delta.text }, finish_reason: null }]
110
150
  }));
111
151
  }
152
+ if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && blockIndex !== null && state.toolCallIndexByBlock[blockIndex] !== void 0) {
153
+ chunks.push(stringifySse({
154
+ id: state.id,
155
+ object: "chat.completion.chunk",
156
+ created: state.created,
157
+ model: state.model,
158
+ choices: [{
159
+ index: 0,
160
+ delta: {
161
+ tool_calls: [{
162
+ index: state.toolCallIndexByBlock[blockIndex],
163
+ function: { arguments: delta.partial_json }
164
+ }]
165
+ },
166
+ finish_reason: null
167
+ }]
168
+ }));
169
+ }
112
170
  }
113
171
  if (eventType === "message_delta") {
114
172
  const delta = payload.delta ?? {};
@@ -201,9 +259,9 @@ async function readChatCompletionFromSse(stream, metadata) {
201
259
  let id = `chatcmpl_${Date.now()}`;
202
260
  let model = metadata?.model_used ?? "unknown";
203
261
  let created = Math.floor(Date.now() / 1e3);
204
- let role = "assistant";
205
262
  let content = "";
206
263
  let finishReason = null;
264
+ const toolCalls = [];
207
265
  let usage = {
208
266
  prompt_tokens: 0,
209
267
  completion_tokens: 0,
@@ -234,12 +292,24 @@ async function readChatCompletionFromSse(stream, metadata) {
234
292
  const choices = Array.isArray(payload.choices) ? payload.choices : [];
235
293
  const firstChoice = choices[0];
236
294
  const delta = firstChoice?.delta;
237
- if (delta?.role === "assistant") {
238
- role = "assistant";
239
- }
240
295
  if (typeof delta?.content === "string") {
241
296
  content += delta.content;
242
297
  }
298
+ const deltaToolCalls = Array.isArray(delta?.tool_calls) ? delta.tool_calls : [];
299
+ for (const raw of deltaToolCalls) {
300
+ const tc = raw;
301
+ const index = typeof tc.index === "number" ? tc.index : 0;
302
+ const fn = tc.function ?? {};
303
+ const existing = toolCalls[index] ?? {
304
+ id: "",
305
+ type: "function",
306
+ function: { name: "", arguments: "" }
307
+ };
308
+ if (typeof tc.id === "string" && tc.id) existing.id = tc.id;
309
+ if (typeof fn.name === "string" && fn.name) existing.function.name = fn.name;
310
+ if (typeof fn.arguments === "string") existing.function.arguments += fn.arguments;
311
+ toolCalls[index] = existing;
312
+ }
243
313
  if (typeof firstChoice?.finish_reason === "string" || firstChoice?.finish_reason === null) {
244
314
  finishReason = firstChoice.finish_reason;
245
315
  }
@@ -258,6 +328,7 @@ async function readChatCompletionFromSse(stream, metadata) {
258
328
  } finally {
259
329
  reader.releaseLock();
260
330
  }
331
+ const assembledToolCalls = toolCalls.filter(Boolean);
261
332
  const response = {
262
333
  id,
263
334
  object: "chat.completion",
@@ -266,7 +337,12 @@ async function readChatCompletionFromSse(stream, metadata) {
266
337
  choices: [
267
338
  {
268
339
  index: 0,
269
- message: { role, content },
340
+ message: {
341
+ role: "assistant",
342
+ // OpenAI convention: content is null on pure tool-call turns
343
+ content: content || (assembledToolCalls.length > 0 ? null : ""),
344
+ ...assembledToolCalls.length > 0 ? { tool_calls: assembledToolCalls } : {}
345
+ },
270
346
  finish_reason: finishReason
271
347
  }
272
348
  ],
package/dist/index.mjs CHANGED
@@ -28,6 +28,7 @@ function toFinishReason(stopReason) {
28
28
  if (!stopReason) return null;
29
29
  if (stopReason === "end_turn" || stopReason === "stop_sequence") return "stop";
30
30
  if (stopReason === "max_tokens") return "length";
31
+ if (stopReason === "tool_use") return "tool_calls";
31
32
  return stopReason;
32
33
  }
33
34
  function initialState() {
@@ -39,7 +40,9 @@ function initialState() {
39
40
  promptTokens: 0,
40
41
  completionTokens: 0,
41
42
  roleEmitted: false,
42
- finalChunkEmitted: false
43
+ finalChunkEmitted: false,
44
+ toolCallIndexByBlock: {},
45
+ nextToolCallIndex: 0
43
46
  };
44
47
  }
45
48
  function normalizeAnthropicPayload(payload, state) {
@@ -62,8 +65,45 @@ function normalizeAnthropicPayload(payload, state) {
62
65
  state.roleEmitted = true;
63
66
  }
64
67
  }
68
+ if (eventType === "content_block_start") {
69
+ const block = payload.content_block ?? {};
70
+ const blockIndex = typeof payload.index === "number" ? payload.index : null;
71
+ if (block.type === "tool_use" && blockIndex !== null) {
72
+ const toolCallIndex = state.nextToolCallIndex++;
73
+ state.toolCallIndexByBlock[blockIndex] = toolCallIndex;
74
+ if (!state.roleEmitted) {
75
+ chunks.push(stringifySse({
76
+ id: state.id,
77
+ object: "chat.completion.chunk",
78
+ created: state.created,
79
+ model: state.model,
80
+ choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
81
+ }));
82
+ state.roleEmitted = true;
83
+ }
84
+ chunks.push(stringifySse({
85
+ id: state.id,
86
+ object: "chat.completion.chunk",
87
+ created: state.created,
88
+ model: state.model,
89
+ choices: [{
90
+ index: 0,
91
+ delta: {
92
+ tool_calls: [{
93
+ index: toolCallIndex,
94
+ id: typeof block.id === "string" ? block.id : `call_${toolCallIndex}`,
95
+ type: "function",
96
+ function: { name: typeof block.name === "string" ? block.name : "", arguments: "" }
97
+ }]
98
+ },
99
+ finish_reason: null
100
+ }]
101
+ }));
102
+ }
103
+ }
65
104
  if (eventType === "content_block_delta") {
66
105
  const delta = payload.delta ?? {};
106
+ const blockIndex = typeof payload.index === "number" ? payload.index : null;
67
107
  if (delta.type === "text_delta" && typeof delta.text === "string") {
68
108
  if (!state.roleEmitted) {
69
109
  chunks.push(stringifySse({
@@ -83,6 +123,24 @@ function normalizeAnthropicPayload(payload, state) {
83
123
  choices: [{ index: 0, delta: { content: delta.text }, finish_reason: null }]
84
124
  }));
85
125
  }
126
+ if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && blockIndex !== null && state.toolCallIndexByBlock[blockIndex] !== void 0) {
127
+ chunks.push(stringifySse({
128
+ id: state.id,
129
+ object: "chat.completion.chunk",
130
+ created: state.created,
131
+ model: state.model,
132
+ choices: [{
133
+ index: 0,
134
+ delta: {
135
+ tool_calls: [{
136
+ index: state.toolCallIndexByBlock[blockIndex],
137
+ function: { arguments: delta.partial_json }
138
+ }]
139
+ },
140
+ finish_reason: null
141
+ }]
142
+ }));
143
+ }
86
144
  }
87
145
  if (eventType === "message_delta") {
88
146
  const delta = payload.delta ?? {};
@@ -175,9 +233,9 @@ async function readChatCompletionFromSse(stream, metadata) {
175
233
  let id = `chatcmpl_${Date.now()}`;
176
234
  let model = metadata?.model_used ?? "unknown";
177
235
  let created = Math.floor(Date.now() / 1e3);
178
- let role = "assistant";
179
236
  let content = "";
180
237
  let finishReason = null;
238
+ const toolCalls = [];
181
239
  let usage = {
182
240
  prompt_tokens: 0,
183
241
  completion_tokens: 0,
@@ -208,12 +266,24 @@ async function readChatCompletionFromSse(stream, metadata) {
208
266
  const choices = Array.isArray(payload.choices) ? payload.choices : [];
209
267
  const firstChoice = choices[0];
210
268
  const delta = firstChoice?.delta;
211
- if (delta?.role === "assistant") {
212
- role = "assistant";
213
- }
214
269
  if (typeof delta?.content === "string") {
215
270
  content += delta.content;
216
271
  }
272
+ const deltaToolCalls = Array.isArray(delta?.tool_calls) ? delta.tool_calls : [];
273
+ for (const raw of deltaToolCalls) {
274
+ const tc = raw;
275
+ const index = typeof tc.index === "number" ? tc.index : 0;
276
+ const fn = tc.function ?? {};
277
+ const existing = toolCalls[index] ?? {
278
+ id: "",
279
+ type: "function",
280
+ function: { name: "", arguments: "" }
281
+ };
282
+ if (typeof tc.id === "string" && tc.id) existing.id = tc.id;
283
+ if (typeof fn.name === "string" && fn.name) existing.function.name = fn.name;
284
+ if (typeof fn.arguments === "string") existing.function.arguments += fn.arguments;
285
+ toolCalls[index] = existing;
286
+ }
217
287
  if (typeof firstChoice?.finish_reason === "string" || firstChoice?.finish_reason === null) {
218
288
  finishReason = firstChoice.finish_reason;
219
289
  }
@@ -232,6 +302,7 @@ async function readChatCompletionFromSse(stream, metadata) {
232
302
  } finally {
233
303
  reader.releaseLock();
234
304
  }
305
+ const assembledToolCalls = toolCalls.filter(Boolean);
235
306
  const response = {
236
307
  id,
237
308
  object: "chat.completion",
@@ -240,7 +311,12 @@ async function readChatCompletionFromSse(stream, metadata) {
240
311
  choices: [
241
312
  {
242
313
  index: 0,
243
- message: { role, content },
314
+ message: {
315
+ role: "assistant",
316
+ // OpenAI convention: content is null on pure tool-call turns
317
+ content: content || (assembledToolCalls.length > 0 ? null : ""),
318
+ ...assembledToolCalls.length > 0 ? { tool_calls: assembledToolCalls } : {}
319
+ },
244
320
  finish_reason: finishReason
245
321
  }
246
322
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rouvanpm/rouva",
3
- "version": "0.2.2",
3
+ "version": "0.2.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",