diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb50ac6c64..62a533849d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Models/openai-completions streaming compatibility: force `compat.supportsUsageInStreaming=false` for non-native OpenAI-compatible endpoints during model normalization, preventing usage-only stream chunks from triggering `choices[0]` parser crashes in provider streams. (#8714) Thanks @nonanon1. - TUI/token copy-safety rendering: treat long credential-like mixed alphanumeric tokens (including quoted forms) as copy-sensitive in render sanitization so formatter hard-wrap guards no longer inject visible spaces into auth-style values before display. (#26710) Thanks @jasonthane. - WhatsApp/self-chat response prefix fallback: stop forcing `"[openclaw]"` as the implicit outbound response prefix when no identity name or response prefix is configured, so blank/default prefix settings no longer inject branding text unexpectedly in self-chat flows. (#27962) Thanks @ecanmor. - Memory/QMD search result decoding: accept `qmd search` hits that only include `file` URIs (for example `qmd://collection/path.md`) without `docid`, resolve them through managed collection roots, and keep multi-collection results keyed by file fallback so valid QMD hits no longer collapse to empty `memory_search` output. (#28181) Thanks @0x76696265. diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 2fd5df0883c..24361c0a534 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -23,6 +23,11 @@ function supportsDeveloperRole(model: Model): boolean | undefined { return (model.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole; } +function supportsUsageInStreaming(model: Model): boolean | undefined { + return (model.compat as { supportsUsageInStreaming?: boolean } | undefined) + ?.supportsUsageInStreaming; +} + function createTemplateModel(provider: string, id: string): Model { return { id, @@ -82,6 +87,13 @@ function expectSupportsDeveloperRoleForcedOff(overrides?: Partial>): expect(supportsDeveloperRole(normalized)).toBe(false); } +function expectSupportsUsageInStreamingForcedOff(overrides?: Partial>): void { + const model = { ...baseModel(), ...overrides }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model as Model); + expect(supportsUsageInStreaming(normalized)).toBe(false); +} + function expectResolvedForwardCompat( model: Model | undefined, expected: { provider: string; id: string }, @@ -207,6 +219,13 @@ describe("normalizeModelCompat", () => { }); }); + it("forces supportsUsageInStreaming off for generic custom openai-completions provider", () => { + expectSupportsUsageInStreamingForcedOff({ + provider: "custom-cpa", + baseUrl: "https://cpa.example.com/v1", + }); + }); + it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => { expectSupportsDeveloperRoleForcedOff({ provider: "qwen-proxy", @@ -243,6 +262,17 @@ describe("normalizeModelCompat", () => { expect(supportsDeveloperRole(normalized)).toBe(false); }); + it("overrides explicit supportsUsageInStreaming true on non-native endpoints", () => { + const model = { + ...baseModel(), + provider: "custom-cpa", + baseUrl: "https://proxy.example.com/v1", + compat: { supportsUsageInStreaming: true }, + }; + const normalized = normalizeModelCompat(model); + expect(supportsUsageInStreaming(normalized)).toBe(false); + }); + it("does not mutate caller model when forcing supportsDeveloperRole off", () => { const model = { ...baseModel(), @@ -253,14 +283,17 @@ describe("normalizeModelCompat", () => { const normalized = normalizeModelCompat(model); expect(normalized).not.toBe(model); expect(supportsDeveloperRole(model)).toBeUndefined(); + expect(supportsUsageInStreaming(model)).toBeUndefined(); expect(supportsDeveloperRole(normalized)).toBe(false); + expect(supportsUsageInStreaming(normalized)).toBe(false); }); it("does not override explicit compat false", () => { const model = baseModel(); - model.compat = { supportsDeveloperRole: false }; + model.compat = { supportsDeveloperRole: false, supportsUsageInStreaming: false }; const normalized = normalizeModelCompat(model); expect(supportsDeveloperRole(normalized)).toBe(false); + expect(supportsUsageInStreaming(normalized)).toBe(false); }); }); diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 48990f10bfd..7bad084fe57 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -52,28 +52,28 @@ export function normalizeModelCompat(model: Model): Model { return model; } - // The `developer` message role is an OpenAI-native convention. All other - // openai-completions backends (proxies, Qwen, GLM, DeepSeek, Kimi, etc.) - // only recognise `system`. Force supportsDeveloperRole=false for any model - // whose baseUrl is not a known native OpenAI endpoint, unless the caller - // has already pinned the value explicitly. + // 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. const compat = model.compat ?? undefined; - if (compat?.supportsDeveloperRole === false) { - return model; - } // When baseUrl is empty the pi-ai library defaults to api.openai.com, so - // leave compat unchanged and let the existing default behaviour apply. - // Note: an explicit supportsDeveloperRole: true is intentionally overridden - // here for non-native endpoints — those backends would return a 400 if we - // sent `developer`, so safety takes precedence over the caller's hint. + // leave compat unchanged and let default native behavior apply. + // Note: explicit true values are intentionally overridden for non-native + // endpoints for safety. const needsForce = baseUrl ? !isOpenAINativeEndpoint(baseUrl) : false; if (!needsForce) { return model; } + if (compat?.supportsDeveloperRole === false && compat?.supportsUsageInStreaming === false) { + return model; + } // Return a new object — do not mutate the caller's model reference. return { ...model, - compat: compat ? { ...compat, supportsDeveloperRole: false } : { supportsDeveloperRole: false }, + compat: compat + ? { ...compat, supportsDeveloperRole: false, supportsUsageInStreaming: false } + : { supportsDeveloperRole: false, supportsUsageInStreaming: false }, } as typeof model; }