mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-28 08:52:45 +00:00
fix: force supportsDeveloperRole=false for non-native OpenAI endpoints (#29479)
Merged via squash.
Prepared head SHA: 1416c584ac
Co-authored-by: akramcodez <179671552+akramcodez@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -442,6 +442,9 @@ Notes:
|
|||||||
- `contextWindow: 200000`
|
- `contextWindow: 200000`
|
||||||
- `maxTokens: 8192`
|
- `maxTokens: 8192`
|
||||||
- Recommended: set explicit values that match your proxy/model limits.
|
- 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
|
## CLI examples
|
||||||
|
|
||||||
|
|||||||
@@ -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.*.baseUrl`: upstream API base URL.
|
||||||
- `models.providers.*.headers`: extra static headers for proxy/tenant routing.
|
- `models.providers.*.headers`: extra static headers for proxy/tenant routing.
|
||||||
- `models.providers.*.models`: explicit provider model catalog entries.
|
- `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`: Bedrock auto-discovery settings root.
|
||||||
- `models.bedrockDiscovery.enabled`: turn discovery polling on/off.
|
- `models.bedrockDiscovery.enabled`: turn discovery polling on/off.
|
||||||
- `models.bedrockDiscovery.region`: AWS region for discovery.
|
- `models.bedrockDiscovery.region`: AWS region for discovery.
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const baseModel = (): Model<Api> =>
|
|||||||
maxTokens: 1024,
|
maxTokens: 1024,
|
||||||
}) as Model<Api>;
|
}) as Model<Api>;
|
||||||
|
|
||||||
|
function supportsDeveloperRole(model: Model<Api>): boolean | undefined {
|
||||||
|
return (model.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole;
|
||||||
|
}
|
||||||
|
|
||||||
function createTemplateModel(provider: string, id: string): Model<Api> {
|
function createTemplateModel(provider: string, id: string): Model<Api> {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -105,9 +109,7 @@ describe("normalizeModelCompat", () => {
|
|||||||
const model = baseModel();
|
const model = baseModel();
|
||||||
delete (model as { compat?: unknown }).compat;
|
delete (model as { compat?: unknown }).compat;
|
||||||
const normalized = normalizeModelCompat(model);
|
const normalized = normalizeModelCompat(model);
|
||||||
expect(
|
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||||
(normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forces supportsDeveloperRole off for moonshot models", () => {
|
it("forces supportsDeveloperRole off for moonshot models", () => {
|
||||||
@@ -118,9 +120,7 @@ describe("normalizeModelCompat", () => {
|
|||||||
};
|
};
|
||||||
delete (model as { compat?: unknown }).compat;
|
delete (model as { compat?: unknown }).compat;
|
||||||
const normalized = normalizeModelCompat(model);
|
const normalized = normalizeModelCompat(model);
|
||||||
expect(
|
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||||
(normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forces supportsDeveloperRole off for custom moonshot-compatible endpoints", () => {
|
it("forces supportsDeveloperRole off for custom moonshot-compatible endpoints", () => {
|
||||||
@@ -131,9 +131,7 @@ describe("normalizeModelCompat", () => {
|
|||||||
};
|
};
|
||||||
delete (model as { compat?: unknown }).compat;
|
delete (model as { compat?: unknown }).compat;
|
||||||
const normalized = normalizeModelCompat(model);
|
const normalized = normalizeModelCompat(model);
|
||||||
expect(
|
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||||
(normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forces supportsDeveloperRole off for DashScope provider ids", () => {
|
it("forces supportsDeveloperRole off for DashScope provider ids", () => {
|
||||||
@@ -144,9 +142,7 @@ describe("normalizeModelCompat", () => {
|
|||||||
};
|
};
|
||||||
delete (model as { compat?: unknown }).compat;
|
delete (model as { compat?: unknown }).compat;
|
||||||
const normalized = normalizeModelCompat(model);
|
const normalized = normalizeModelCompat(model);
|
||||||
expect(
|
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||||
(normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forces supportsDeveloperRole off for DashScope-compatible endpoints", () => {
|
it("forces supportsDeveloperRole off for DashScope-compatible endpoints", () => {
|
||||||
@@ -157,12 +153,10 @@ describe("normalizeModelCompat", () => {
|
|||||||
};
|
};
|
||||||
delete (model as { compat?: unknown }).compat;
|
delete (model as { compat?: unknown }).compat;
|
||||||
const normalized = normalizeModelCompat(model);
|
const normalized = normalizeModelCompat(model);
|
||||||
expect(
|
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||||
(normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves non-zai models untouched", () => {
|
it("leaves native api.openai.com model untouched", () => {
|
||||||
const model = {
|
const model = {
|
||||||
...baseModel(),
|
...baseModel(),
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
@@ -173,13 +167,89 @@ describe("normalizeModelCompat", () => {
|
|||||||
expect(normalized.compat).toBeUndefined();
|
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<Api>);
|
||||||
|
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();
|
const model = baseModel();
|
||||||
model.compat = { supportsDeveloperRole: false };
|
model.compat = { supportsDeveloperRole: false };
|
||||||
const normalized = normalizeModelCompat(model);
|
const normalized = normalizeModelCompat(model);
|
||||||
expect(
|
expect(supportsDeveloperRole(normalized)).toBe(false);
|
||||||
(normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,20 @@ function isOpenAiCompletionsModel(model: Model<Api>): model is Model<"openai-com
|
|||||||
return model.api === "openai-completions";
|
return model.api === "openai-completions";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDashScopeCompatibleEndpoint(baseUrl: string): boolean {
|
/**
|
||||||
return (
|
* Returns true only for endpoints that are confirmed to be native OpenAI
|
||||||
baseUrl.includes("dashscope.aliyuncs.com") ||
|
* infrastructure and therefore accept the `developer` message role.
|
||||||
baseUrl.includes("dashscope-intl.aliyuncs.com") ||
|
* Azure OpenAI uses the Chat Completions API and does NOT accept `developer`.
|
||||||
baseUrl.includes("dashscope-us.aliyuncs.com")
|
* 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<Api>): model is Model<"anthropic-messages"> {
|
function isAnthropicMessagesModel(model: Model<Api>): model is Model<"anthropic-messages"> {
|
||||||
@@ -40,24 +48,32 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai");
|
if (!isOpenAiCompletionsModel(model)) {
|
||||||
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)) {
|
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openaiModel = model;
|
// The `developer` message role is an OpenAI-native convention. All other
|
||||||
const compat = openaiModel.compat ?? undefined;
|
// 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) {
|
if (compat?.supportsDeveloperRole === false) {
|
||||||
return model;
|
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
|
// Return a new object — do not mutate the caller's model reference.
|
||||||
? { ...compat, supportsDeveloperRole: false }
|
return {
|
||||||
: { supportsDeveloperRole: false };
|
...model,
|
||||||
return openaiModel;
|
compat: compat ? { ...compat, supportsDeveloperRole: false } : { supportsDeveloperRole: false },
|
||||||
|
} as typeof model;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user