diff --git a/CHANGELOG.md b/CHANGELOG.md index 086354ae499..3d0e2ba835f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack. - Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3. - Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason. +- Models/openai-completions developer-role compatibility: force `supportsDeveloperRole=false` for non-native endpoints, treat unparseable `baseUrl` values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez. - OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty `function_call_output.call_id` payloads in the WS conversion path to avoid OpenAI 400 errors (`Invalid 'input[n].call_id': empty string`), with regression coverage for both inbound stream normalization and outbound payload guards. - Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk. - CLI/Config validation and routing hardening: dedupe `openclaw config validate` failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including `--json` fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed `config get/unset` with split root options). Thanks @gumadeiras. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index a2b777020d2..58710d88ee7 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -442,6 +442,9 @@ Notes: - `contextWindow: 200000` - `maxTokens: 8192` - Recommended: set explicit values that match your proxy/model limits. +- For `api: "openai-completions"` on non-native endpoints (any non-empty `baseUrl` whose host is not `api.openai.com`), OpenClaw forces `compat.supportsDeveloperRole: false` to avoid provider 400 errors for unsupported `developer` roles. +- If `baseUrl` is empty/omitted, OpenClaw keeps the default OpenAI behavior (which resolves to `api.openai.com`). +- For safety, an explicit `compat.supportsDeveloperRole: true` is still overridden on non-native `openai-completions` endpoints. ## CLI examples diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 37e64f2f840..b7486d50d9d 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1961,6 +1961,7 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model - `models.providers.*.baseUrl`: upstream API base URL. - `models.providers.*.headers`: extra static headers for proxy/tenant routing. - `models.providers.*.models`: explicit provider model catalog entries. +- `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior. - `models.bedrockDiscovery`: Bedrock auto-discovery settings root. - `models.bedrockDiscovery.enabled`: turn discovery polling on/off. - `models.bedrockDiscovery.region`: AWS region for discovery. diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 8bec5192a11..13a6cc002d9 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -19,6 +19,10 @@ const baseModel = (): Model => maxTokens: 1024, }) as Model; +function supportsDeveloperRole(model: Model): boolean | undefined { + return (model.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole; +} + function createTemplateModel(provider: string, id: string): Model { return { id, @@ -105,9 +109,7 @@ describe("normalizeModelCompat", () => { const model = baseModel(); delete (model as { compat?: unknown }).compat; const normalized = normalizeModelCompat(model); - expect( - (normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole, - ).toBe(false); + expect(supportsDeveloperRole(normalized)).toBe(false); }); it("forces supportsDeveloperRole off for moonshot models", () => { @@ -118,9 +120,7 @@ describe("normalizeModelCompat", () => { }; delete (model as { compat?: unknown }).compat; const normalized = normalizeModelCompat(model); - expect( - (normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole, - ).toBe(false); + expect(supportsDeveloperRole(normalized)).toBe(false); }); it("forces supportsDeveloperRole off for custom moonshot-compatible endpoints", () => { @@ -131,9 +131,7 @@ describe("normalizeModelCompat", () => { }; delete (model as { compat?: unknown }).compat; const normalized = normalizeModelCompat(model); - expect( - (normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole, - ).toBe(false); + expect(supportsDeveloperRole(normalized)).toBe(false); }); it("forces supportsDeveloperRole off for DashScope provider ids", () => { @@ -144,9 +142,7 @@ describe("normalizeModelCompat", () => { }; delete (model as { compat?: unknown }).compat; const normalized = normalizeModelCompat(model); - expect( - (normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole, - ).toBe(false); + expect(supportsDeveloperRole(normalized)).toBe(false); }); it("forces supportsDeveloperRole off for DashScope-compatible endpoints", () => { @@ -157,12 +153,10 @@ describe("normalizeModelCompat", () => { }; delete (model as { compat?: unknown }).compat; const normalized = normalizeModelCompat(model); - expect( - (normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole, - ).toBe(false); + expect(supportsDeveloperRole(normalized)).toBe(false); }); - it("leaves non-zai models untouched", () => { + it("leaves native api.openai.com model untouched", () => { const model = { ...baseModel(), provider: "openai", @@ -173,13 +167,89 @@ describe("normalizeModelCompat", () => { expect(normalized.compat).toBeUndefined(); }); - it("does not override explicit z.ai compat false", () => { + it("forces supportsDeveloperRole off for Azure OpenAI (Chat Completions, not Responses API)", () => { + const model = { + ...baseModel(), + provider: "azure-openai", + baseUrl: "https://my-deployment.openai.azure.com/openai", + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model); + expect(supportsDeveloperRole(normalized)).toBe(false); + }); + it("forces supportsDeveloperRole off 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); + expect(supportsDeveloperRole(normalized)).toBe(false); + }); + + it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => { + const model = { + ...baseModel(), + provider: "qwen-proxy", + baseUrl: "https://qwen-api.example.org/compatible-mode/v1", + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model); + expect(supportsDeveloperRole(normalized)).toBe(false); + }); + + it("leaves openai-completions model with empty baseUrl untouched", () => { + const model = { + ...baseModel(), + provider: "openai", + }; + delete (model as { baseUrl?: unknown }).baseUrl; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model as Model); + expect(normalized.compat).toBeUndefined(); + }); + + it("forces supportsDeveloperRole off for malformed baseUrl values", () => { + const model = { + ...baseModel(), + provider: "custom-cpa", + baseUrl: "://api.openai.com malformed", + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model); + expect(supportsDeveloperRole(normalized)).toBe(false); + }); + + it("overrides explicit supportsDeveloperRole true on non-native endpoints", () => { + const model = { + ...baseModel(), + provider: "custom-cpa", + baseUrl: "https://proxy.example.com/v1", + compat: { supportsDeveloperRole: true }, + }; + const normalized = normalizeModelCompat(model); + expect(supportsDeveloperRole(normalized)).toBe(false); + }); + + it("does not mutate caller model when forcing supportsDeveloperRole off", () => { + const model = { + ...baseModel(), + provider: "custom-cpa", + baseUrl: "https://proxy.example.com/v1", + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model); + expect(normalized).not.toBe(model); + expect(supportsDeveloperRole(model)).toBeUndefined(); + expect(supportsDeveloperRole(normalized)).toBe(false); + }); + + it("does not override explicit compat false", () => { const model = baseModel(); model.compat = { supportsDeveloperRole: false }; const normalized = normalizeModelCompat(model); - expect( - (normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole, - ).toBe(false); + expect(supportsDeveloperRole(normalized)).toBe(false); }); }); diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index fc1c195819a..48990f10bfd 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -4,12 +4,20 @@ function isOpenAiCompletionsModel(model: Model): model is Model<"openai-com return model.api === "openai-completions"; } -function isDashScopeCompatibleEndpoint(baseUrl: string): boolean { - return ( - baseUrl.includes("dashscope.aliyuncs.com") || - baseUrl.includes("dashscope-intl.aliyuncs.com") || - baseUrl.includes("dashscope-us.aliyuncs.com") - ); +/** + * Returns true only for endpoints that are confirmed to be native OpenAI + * infrastructure and therefore accept the `developer` message role. + * Azure OpenAI uses the Chat Completions API and does NOT accept `developer`. + * All other openai-completions backends (proxies, Qwen, GLM, DeepSeek, etc.) + * only support the standard `system` role. + */ +function isOpenAINativeEndpoint(baseUrl: string): boolean { + try { + const host = new URL(baseUrl).hostname.toLowerCase(); + return host === "api.openai.com"; + } catch { + return false; + } } function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> { @@ -40,24 +48,32 @@ export function normalizeModelCompat(model: Model): Model { } } - const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai"); - const isMoonshot = - model.provider === "moonshot" || - baseUrl.includes("moonshot.ai") || - baseUrl.includes("moonshot.cn"); - const isDashScope = model.provider === "dashscope" || isDashScopeCompatibleEndpoint(baseUrl); - if ((!isZai && !isMoonshot && !isDashScope) || !isOpenAiCompletionsModel(model)) { + if (!isOpenAiCompletionsModel(model)) { return model; } - const openaiModel = model; - const compat = openaiModel.compat ?? undefined; + // 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. + 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. + const needsForce = baseUrl ? !isOpenAINativeEndpoint(baseUrl) : false; + if (!needsForce) { + return model; + } - openaiModel.compat = compat - ? { ...compat, supportsDeveloperRole: false } - : { supportsDeveloperRole: false }; - return openaiModel; + // Return a new object — do not mutate the caller's model reference. + return { + ...model, + compat: compat ? { ...compat, supportsDeveloperRole: false } : { supportsDeveloperRole: false }, + } as typeof model; }