From bb06dc7cc9e71fbac29d7888d64323db2acec7ca Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Sat, 14 Mar 2026 19:41:21 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + src/agents/model-compat.test.ts | 19 +++++++++++++------ src/agents/model-compat.ts | 29 ++++++++++++++++------------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0c37e3d543..8c3e963d3ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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 diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 56b9c16203c..f6aece9d674 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -219,11 +219,16 @@ describe("normalizeModelCompat", () => { }); }); - it("forces supportsUsageInStreaming off for generic custom openai-completions provider", () => { - expectSupportsUsageInStreamingForcedOff({ + it("leaves supportsUsageInStreaming at default for generic custom openai-completions provider", () => { + const model = { + ...baseModel(), provider: "custom-cpa", baseUrl: "https://cpa.example.com/v1", - }); + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model as Model); + // 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", () => { @@ -273,7 +278,7 @@ describe("normalizeModelCompat", () => { 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 = { ...baseModel(), provider: "custom-cpa", @@ -282,7 +287,8 @@ describe("normalizeModelCompat", () => { delete (model as { compat?: unknown }).compat; const normalized = normalizeModelCompat(model); 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", () => { @@ -297,7 +303,8 @@ describe("normalizeModelCompat", () => { expect(supportsDeveloperRole(model)).toBeUndefined(); expect(supportsUsageInStreaming(model)).toBeUndefined(); 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", () => { diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 72deb0c655f..c2837f6b83d 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -52,11 +52,16 @@ export function normalizeModelCompat(model: Model): Model { return model; } - // The `developer` role and stream usage chunks are OpenAI-native behaviors. - // Many OpenAI-compatible backends reject `developer` and/or emit usage-only - // chunks that break strict parsers expecting choices[0]. For non-native - // openai-completions endpoints, force both compat flags off — unless the - // user has explicitly opted in via their model config. + // The `developer` role is an OpenAI-native behavior that most compatible + // backends reject. Force it off for non-native endpoints 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; // When baseUrl is empty the pi-ai library defaults to api.openai.com, so // leave compat unchanged and let default native behavior apply. @@ -65,24 +70,22 @@ export function normalizeModelCompat(model: Model): Model { return model; } - // Respect explicit user overrides: if the user has set a compat flag to - // true in their model definition, they know their endpoint supports it. + // Respect explicit user overrides. const forcedDeveloperRole = compat?.supportsDeveloperRole === true; - const forcedUsageStreaming = compat?.supportsUsageInStreaming === true; - if (forcedDeveloperRole && forcedUsageStreaming) { + if (forcedDeveloperRole) { 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 { ...model, compat: compat ? { ...compat, - supportsDeveloperRole: forcedDeveloperRole || false, - supportsUsageInStreaming: forcedUsageStreaming || false, + supportsDeveloperRole: false, } - : { supportsDeveloperRole: false, supportsUsageInStreaming: false }, + : { supportsDeveloperRole: false }, } as typeof model; }