fix(agents): restore usage tracking for non-native openai-completions providers

Fixes #46142

Stop forcing supportsUsageInStreaming=false on non-native openai-completions
endpoints. Most OpenAI-compatible APIs (DashScope, DeepSeek, Groq, Together,
etc.) handle stream_options: { include_usage: true } correctly. The blanket
disable broke usage/cost tracking for all non-OpenAI providers.

supportsDeveloperRole is still forced off for non-native endpoints since
the developer message role is genuinely OpenAI-specific.

Users on backends that reject stream_options can opt out with
compat.supportsUsageInStreaming: false in their model config.

Fixes #46142
This commit is contained in:
Andrew Demczuk 2026-03-14 19:41:21 +01:00 committed by GitHub
parent d33f3f843a
commit bb06dc7cc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 30 additions and 19 deletions

View File

@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc. - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. - CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142)
## 2026.3.13 ## 2026.3.13

View File

@ -219,11 +219,16 @@ describe("normalizeModelCompat", () => {
}); });
}); });
it("forces supportsUsageInStreaming off for generic custom openai-completions provider", () => { it("leaves supportsUsageInStreaming at default for generic custom openai-completions provider", () => {
expectSupportsUsageInStreamingForcedOff({ const model = {
...baseModel(),
provider: "custom-cpa", provider: "custom-cpa",
baseUrl: "https://cpa.example.com/v1", baseUrl: "https://cpa.example.com/v1",
}); };
delete (model as { compat?: unknown }).compat;
const normalized = normalizeModelCompat(model as Model<Api>);
// supportsUsageInStreaming is no longer forced off — pi-ai's default (true) applies
expect(supportsUsageInStreaming(normalized)).toBeUndefined();
}); });
it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => { it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => {
@ -273,7 +278,7 @@ describe("normalizeModelCompat", () => {
expect(supportsUsageInStreaming(normalized)).toBe(true); expect(supportsUsageInStreaming(normalized)).toBe(true);
}); });
it("still forces flags off when not explicitly set by user", () => { it("forces supportsDeveloperRole off but leaves supportsUsageInStreaming unset for non-native endpoints", () => {
const model = { const model = {
...baseModel(), ...baseModel(),
provider: "custom-cpa", provider: "custom-cpa",
@ -282,7 +287,8 @@ describe("normalizeModelCompat", () => {
delete (model as { compat?: unknown }).compat; delete (model as { compat?: unknown }).compat;
const normalized = normalizeModelCompat(model); const normalized = normalizeModelCompat(model);
expect(supportsDeveloperRole(normalized)).toBe(false); expect(supportsDeveloperRole(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBe(false); // supportsUsageInStreaming is no longer forced off — pi-ai default applies
expect(supportsUsageInStreaming(normalized)).toBeUndefined();
}); });
it("does not mutate caller model when forcing supportsDeveloperRole off", () => { it("does not mutate caller model when forcing supportsDeveloperRole off", () => {
@ -297,7 +303,8 @@ describe("normalizeModelCompat", () => {
expect(supportsDeveloperRole(model)).toBeUndefined(); expect(supportsDeveloperRole(model)).toBeUndefined();
expect(supportsUsageInStreaming(model)).toBeUndefined(); expect(supportsUsageInStreaming(model)).toBeUndefined();
expect(supportsDeveloperRole(normalized)).toBe(false); expect(supportsDeveloperRole(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBe(false); // supportsUsageInStreaming is not set by normalizeModelCompat — pi-ai default applies
expect(supportsUsageInStreaming(normalized)).toBeUndefined();
}); });
it("does not override explicit compat false", () => { it("does not override explicit compat false", () => {

View File

@ -52,11 +52,16 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
return model; return model;
} }
// The `developer` role and stream usage chunks are OpenAI-native behaviors. // The `developer` role is an OpenAI-native behavior that most compatible
// Many OpenAI-compatible backends reject `developer` and/or emit usage-only // backends reject. Force it off for non-native endpoints unless the user
// chunks that break strict parsers expecting choices[0]. For non-native // has explicitly opted in via their model config.
// openai-completions endpoints, force both compat flags off — unless the //
// user has explicitly opted in via their model config. // `supportsUsageInStreaming` is NOT forced off — most OpenAI-compatible
// backends (DashScope, DeepSeek, Groq, Together, etc.) handle
// `stream_options: { include_usage: true }` correctly, and disabling it
// silently breaks usage/cost tracking for all non-native providers.
// Users can still opt out with `compat.supportsUsageInStreaming: false`
// if their backend rejects the parameter.
const compat = model.compat ?? undefined; const compat = model.compat ?? undefined;
// When baseUrl is empty the pi-ai library defaults to api.openai.com, so // When baseUrl is empty the pi-ai library defaults to api.openai.com, so
// leave compat unchanged and let default native behavior apply. // leave compat unchanged and let default native behavior apply.
@ -65,24 +70,22 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
return model; return model;
} }
// Respect explicit user overrides: if the user has set a compat flag to // Respect explicit user overrides.
// true in their model definition, they know their endpoint supports it.
const forcedDeveloperRole = compat?.supportsDeveloperRole === true; const forcedDeveloperRole = compat?.supportsDeveloperRole === true;
const forcedUsageStreaming = compat?.supportsUsageInStreaming === true;
if (forcedDeveloperRole && forcedUsageStreaming) { if (forcedDeveloperRole) {
return model; return model;
} }
// Return a new object — do not mutate the caller's model reference. // Only force supportsDeveloperRole off. Leave supportsUsageInStreaming
// at whatever the user set or pi-ai's default (true).
return { return {
...model, ...model,
compat: compat compat: compat
? { ? {
...compat, ...compat,
supportsDeveloperRole: forcedDeveloperRole || false, supportsDeveloperRole: false,
supportsUsageInStreaming: forcedUsageStreaming || false,
} }
: { supportsDeveloperRole: false, supportsUsageInStreaming: false }, : { supportsDeveloperRole: false },
} as typeof model; } as typeof model;
} }