Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/data/ollama-cloud-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ const CATALOG: OllamaCloudModel[] = ([
maxTokensField: 'max_tokens' as const,
// Effort mapping is unverified against ollama.com; send none.
supportsReasoningEffort: false,
// ollama.com sends a streamed usage chunk only when the request carries
// stream_options.include_usage (observed 2026-10-13); without it every
// streamed turn reports output_tokens 0.
supportsStreamUsage: true,
},
}));

Expand Down
7 changes: 7 additions & 0 deletions src/model-runtime-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export interface ModelRuntimeCompatibility {
* endpoint that does implement it is unaffected.
*/
supportsCountTokens?: boolean;
/**
* Whether the upstream sends a streamed usage chunk only when the request
* carries `stream_options: {"include_usage": true}`. Unset keeps upstream's
* per-provider scoping, so existing providers are unaffected until their
* catalog opts in.
*/
supportsStreamUsage?: boolean;
}

export type OpenAiCompatibleRequestBody = Record<string, unknown>;
Expand Down
7 changes: 4 additions & 3 deletions src/provider-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
} from './oauth/claude-identity.js';
import { isCredentialBearingHeader } from './credential-headers.js';
import { withAccessPrograms, type AccessPrograms } from './daybreak.js';
import { OLLAMA_CLOUD_PROVIDER_ID } from './data/ollama-cloud-models.js';
import {
transformOpenAiCompatibleRequestBody,
type ModelRuntimeCompatibility,
Expand Down Expand Up @@ -268,8 +267,10 @@ export async function createLanguageModel(spec: ProviderModelSpec): Promise<Lang
...(spec.headers ? { headers: spec.headers } : {}),
// ollama.com sends a streamed usage chunk only when the request carries
// stream_options.include_usage (observed 2026-10-13); without it every
// streamed turn reports output_tokens 0. Scoped to this provider only.
...(spec.providerId === OLLAMA_CLOUD_PROVIDER_ID ? { includeUsage: true } : {}),
// streamed turn reports output_tokens 0. Opted in per model catalog
// (compatibility.supportsStreamUsage), so any openai-compatible provider
// can declare it; unset keeps the option off.
...(spec.compatibility?.supportsStreamUsage === true ? { includeUsage: true } : {}),
...(spec.compatibility
? {
transformRequestBody: (body: Record<string, unknown>) =>
Expand Down
13 changes: 13 additions & 0 deletions src/providers-custom-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ export async function runCustomEndpointAddFlow(
return 0;
}

// Servers that only report token usage on streamed replies when the request
// asks for it need stream_options.include_usage; without it every streamed
// turn reports output_tokens 0.
const streamUsage = await p.confirm({
message: 'Send stream_options.include_usage with streamed requests? (Needed when this server reports token usage only when the request asks for it.)',
initialValue: false,
});
if (p.isCancel(streamUsage)) {
p.cancel('Cancelled.');
return 0;
}

const spinner = p.spinner();
spinner.start('Testing connection...');
const result = await addCustomEndpointProvider({
Expand All @@ -66,6 +78,7 @@ export async function runCustomEndpointAddFlow(
apiKey: String(apiKey ?? '').trim(),
kind: 'openai',
allowInsecureLocal,
supportsStreamUsage: streamUsage === true,
});
spinner.stop('');
onResult(result);
Expand Down
5 changes: 5 additions & 0 deletions src/registry/custom-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface AddCustomEndpointInput {
allowInsecureLocal?: boolean;
/** Static headers this endpoint requires on every request (e.g. a plan/auth-tracking header). */
headers?: Record<string, string>;
/** Opt this openai-compatible endpoint into stream_options.include_usage on streamed requests. */
supportsStreamUsage?: boolean;
}

export interface AddCustomEndpointResult {
Expand Down Expand Up @@ -249,6 +251,9 @@ export async function addCustomEndpointProvider(input: AddCustomEndpointInput):
modelFormat: modelFormatForKind(input.kind),
npm,
apiUrl: fetched.baseUrl,
...(input.supportsStreamUsage
? { compatibility: { ...m.compatibility, supportsStreamUsage: true } }
: {}),
})),
},
};
Expand Down
26 changes: 22 additions & 4 deletions tests/ollama-cloud-stream-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createServer } from 'node:http';
import { createLanguageModel } from '../src/provider-factory.js';
import { generateAnthropicResponse, streamAnthropicResponse } from '../src/sdk-adapter.js';
import { buildOllamaCloudModels } from '../src/data/ollama-cloud-models.js';
import type { ModelRuntimeCompatibility } from '../src/model-runtime-compatibility.js';

/**
* ollama.com emits a streamed usage chunk (`choices: []` plus `usage`) only when the
Expand Down Expand Up @@ -59,7 +60,10 @@ async function startListener(): Promise<Listener> {
};
}

async function streamOnce(providerId: string): Promise<{
async function streamOnce(
providerId: string,
compatOverride?: ModelRuntimeCompatibility,
): Promise<{
body: Record<string, unknown>;
outputTokens: unknown;
}> {
Expand All @@ -73,7 +77,7 @@ async function streamOnce(providerId: string): Promise<{
baseURL: listener.baseUrl,
providerId,
authType: 'api',
compatibility: catalog.compatibility,
compatibility: compatOverride ?? catalog.compatibility,
});
let sse = '';
await streamAnthropicResponse(
Expand Down Expand Up @@ -101,10 +105,24 @@ describe('Ollama Cloud streamed usage', () => {
expect(outputTokens).toBe(USAGE.completion_tokens);
});

it('asks for the usage chunk for any provider whose spec declares stream usage', async () => {
// The same ollama-cloud compatibility under a different provider id still
// sends stream_options: the flag on the catalog drives it, not the id.
const { body, outputTokens } = await streamOnce('openai');
expect(body.stream_options).toEqual({ include_usage: true });
expect(outputTokens).toBe(USAGE.completion_tokens);
});

it.each(['opencode-go', 'custom-local', 'openai'])(
'leaves the %s request without stream_options',
'leaves the %s request without stream_options when its catalog does not opt in',
async providerId => {
const { body } = await streamOnce(providerId);
// A catalog without supportsStreamUsage, like the real opencode-go one.
const { body } = await streamOnce(providerId, {
supportsStore: false,
supportsDeveloperRole: false,
maxTokensField: 'max_tokens' as const,
supportsReasoningEffort: false,
});
expect(body.stream).toBe(true);
expect(body).not.toHaveProperty('stream_options');
},
Expand Down
1 change: 1 addition & 0 deletions tests/ollama-cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ describe('Ollama Cloud per-model metadata', () => {
supportsDeveloperRole: false,
maxTokensField: 'max_tokens',
supportsReasoningEffort: false,
supportsStreamUsage: true,
},
});
}
Expand Down
18 changes: 16 additions & 2 deletions tests/providers-custom-add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,11 @@ describe('providers add: custom OpenAI-compatible server', () => {
apiKey: 'sk-or-test',
kind: 'openai',
allowInsecureLocal: false,
supportsStreamUsage: false,
});
expect(confirmMock).not.toHaveBeenCalled();
// Only the stream-usage confirm is asked on the https path; the plain-http
// one is not.
expect(confirmMock).toHaveBeenCalledTimes(1);
});

it('reports the provider name and how many models were found', async () => {
Expand Down Expand Up @@ -205,7 +208,8 @@ describe('providers add: custom OpenAI-compatible server', () => {

await runProvidersAdd();

expect(confirmMock).toHaveBeenCalledTimes(1);
// Both confirms are asked now: plain http, then stream usage.
expect(confirmMock).toHaveBeenCalledTimes(2);
});

it('does not allow plain http when the user declines', async () => {
Expand Down Expand Up @@ -283,6 +287,16 @@ describe('providers add: custom OpenAI-compatible server', () => {
confirmMock.mockResolvedValue(CANCEL);
},
},
{
at: 'the stream-usage confirmation',
answer: () => {
textMock.mockResolvedValueOnce('OpenRouter').mockResolvedValueOnce('https://openrouter.ai/api/v1');
passwordMock.mockResolvedValue('');
// On the https path the stream-usage confirm is the only confirm,
// so cancelling at it is the first confirm answer.
confirmMock.mockResolvedValueOnce(CANCEL);
},
},
])('cancelling at $at adds nothing and exits 0', async ({ answer }) => {
selectMock.mockResolvedValue('custom');
answer();
Expand Down
Loading