Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -623,4 +623,57 @@ describe('provider-client-converters', () => {
expect(result).not.toHaveProperty('max_completion_tokens');
});
});

describe('ollama', () => {
it('should inject stream_options.include_usage when not present', () => {
const body = {
model: 'qwen2.5:7b',
messages: [{ role: 'user', content: 'hello' }],
stream: true,
};

const result = sanitizeOpenAiBody(body, 'ollama', 'qwen2.5:7b');

expect(result).toHaveProperty('stream_options');
expect(result['stream_options']).toEqual({ include_usage: true });
});

it('should inject stream_options.include_usage for ollama-cloud', () => {
const body = {
model: 'llama3',
messages: [{ role: 'user', content: 'hello' }],
};

const result = sanitizeOpenAiBody(body, 'ollama-cloud', 'llama3');

expect(result).toHaveProperty('stream_options');
expect(result['stream_options']).toEqual({ include_usage: true });
});

it('should overwrite stream_options.include_usage to true regardless of user value', () => {
const body = {
model: 'qwen2.5:7b',
messages: [{ role: 'user', content: 'hello' }],
stream_options: { include_usage: false },
};

const result = sanitizeOpenAiBody(body, 'ollama', 'qwen2.5:7b');

expect(result['stream_options']).toEqual({ include_usage: true });
});

it('should pass through messages and model fields', () => {
const body = {
model: 'qwen2.5:7b',
messages: [{ role: 'user', content: 'hello' }],
temperature: 0.7,
};

const result = sanitizeOpenAiBody(body, 'ollama', 'qwen2.5:7b');

expect(result).toHaveProperty('model', 'qwen2.5:7b');
expect(result).toHaveProperty('messages');
expect(result).toHaveProperty('temperature', 0.7);
});
});
});
14 changes: 14 additions & 0 deletions packages/backend/src/routing/proxy/__tests__/stream-writer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,20 @@ describe('pipeStream', () => {
});
});

it('should capture usage from leftover passthroughBuffer without trailing newline', async () => {
const { res } = mockResponse();
const contentChunk = `data: ${JSON.stringify({ choices: [{ delta: { content: 'hi' } }] })}\n\n`;
const usageChunk = `data: ${JSON.stringify({
choices: [],
usage: { prompt_tokens: 99, completion_tokens: 42 },
})}`;
const stream = createReadableStream([contentChunk, usageChunk]);

const usage = await pipeStream(stream, res as never);

expect(usage).toEqual({ prompt_tokens: 99, completion_tokens: 42 });
});

it('should return null usage when stream has no usage data', async () => {
const { res } = mockResponse();
const stream = createReadableStream([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,5 +260,8 @@ export function sanitizeOpenAiBody(
cleaned[key] = value;
}
if (endpointKey === 'deepseek') normalizeDeepSeekMaxTokens(cleaned);
if (endpointKey === 'ollama' || endpointKey === 'ollama-cloud') {
cleaned['stream_options'] = { include_usage: true };
}
return cleaned;
}
24 changes: 24 additions & 0 deletions packages/backend/src/routing/proxy/stream-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,30 @@ export async function pipeStream(
}
}

if (passthroughBuffer.trim()) {
const payload = passthroughBuffer
.trim()
.split('\n')
.map((line) => (line.startsWith('data: ') ? line.slice(6) : line))
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: End-of-stream passthrough parsing only strips data: and can miss usage when SSE lines are data:<json> without a space.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/backend/src/routing/proxy/stream-writer.ts, line 151:

<comment>End-of-stream passthrough parsing only strips `data: ` and can miss usage when SSE lines are `data:<json>` without a space.</comment>

<file context>
@@ -144,6 +144,30 @@ export async function pipeStream(
+      const payload = passthroughBuffer
+        .trim()
+        .split('\n')
+        .map((line) => (line.startsWith('data: ') ? line.slice(6) : line))
+        .join('\n')
+        .trim();
</file context>
Suggested change
.map((line) => (line.startsWith('data: ') ? line.slice(6) : line))
.map((line) => (line.startsWith('data:') ? line.slice(5).trimStart() : line))
Fix with Cubic

.join('\n')
.trim();
if (payload && payload !== '[DONE]') {
try {
const obj = JSON.parse(payload);
if (obj.usage && typeof obj.usage.prompt_tokens === 'number') {
capturedUsage = {
prompt_tokens: obj.usage.prompt_tokens,
completion_tokens: obj.usage.completion_tokens ?? 0,
cache_read_tokens: obj.usage.cache_read_tokens,
cache_creation_tokens: obj.usage.cache_creation_tokens,
};
}
} catch {
/* ignore non-JSON */
}
}
}

// Flush any remaining buffer content through the transform
if (transform && sseBuffer.trim()) {
const payload = sseBuffer
Expand Down