fix: apply Mistral compat across proxy transports

This commit is contained in:
Peter Steinberger
2026-03-29 16:32:01 +09:00
parent 4a5885df3a
commit c664b67796
10 changed files with 342 additions and 11 deletions

View File

@@ -29,6 +29,7 @@ let applyProviderNativeStreamingUsageCompatWithPlugin: typeof import("./provider
let formatProviderAuthProfileApiKeyWithPlugin: typeof import("./provider-runtime.js").formatProviderAuthProfileApiKeyWithPlugin;
let normalizeProviderConfigWithPlugin: typeof import("./provider-runtime.js").normalizeProviderConfigWithPlugin;
let normalizeProviderModelIdWithPlugin: typeof import("./provider-runtime.js").normalizeProviderModelIdWithPlugin;
let applyProviderResolvedModelCompatWithPlugins: typeof import("./provider-runtime.js").applyProviderResolvedModelCompatWithPlugins;
let normalizeProviderTransportWithPlugin: typeof import("./provider-runtime.js").normalizeProviderTransportWithPlugin;
let prepareProviderExtraParams: typeof import("./provider-runtime.js").prepareProviderExtraParams;
let resolveProviderConfigApiKeyWithPlugin: typeof import("./provider-runtime.js").resolveProviderConfigApiKeyWithPlugin;
@@ -211,6 +212,7 @@ describe("provider-runtime", () => {
buildProviderMissingAuthMessageWithPlugin,
buildProviderUnknownModelHintWithPlugin,
applyProviderNativeStreamingUsageCompatWithPlugin,
applyProviderResolvedModelCompatWithPlugins,
formatProviderAuthProfileApiKeyWithPlugin,
normalizeProviderConfigWithPlugin,
normalizeProviderModelIdWithPlugin,
@@ -727,6 +729,13 @@ describe("provider-runtime", () => {
api: "openai-codex-responses",
});
expect(
applyProviderResolvedModelCompatWithPlugins({
provider: DEMO_PROVIDER_ID,
context: createDemoResolvedModelContext({}),
}),
).toBeUndefined();
expect(
formatProviderAuthProfileApiKeyWithPlugin({
provider: DEMO_PROVIDER_ID,
@@ -854,6 +863,53 @@ describe("provider-runtime", () => {
);
});
it("merges compat contributions from owner and foreign provider plugins", () => {
resolveOwningPluginIdsForProviderMock.mockReturnValue(["openrouter"]);
resolvePluginProvidersMock.mockImplementation((params) => {
const onlyPluginIds = params.onlyPluginIds ?? [];
const plugins: ProviderPlugin[] = [
{
id: "openrouter",
label: "OpenRouter",
auth: [],
contributeResolvedModelCompat: () => ({ supportsStrictMode: true }),
},
{
id: "mistral",
label: "Mistral",
auth: [],
contributeResolvedModelCompat: ({ modelId }) =>
modelId.startsWith("mistralai/") ? { supportsStore: false } : undefined,
},
];
return onlyPluginIds.length > 0
? plugins.filter((plugin) => onlyPluginIds.includes(plugin.id))
: plugins;
});
expect(
applyProviderResolvedModelCompatWithPlugins({
provider: "openrouter",
context: createDemoResolvedModelContext({
provider: "openrouter",
modelId: "mistralai/mistral-small-3.2-24b-instruct",
model: {
...MODEL,
provider: "openrouter",
id: "mistralai/mistral-small-3.2-24b-instruct",
compat: { supportsDeveloperRole: false },
},
}),
}),
).toMatchObject({
compat: {
supportsDeveloperRole: false,
supportsStrictMode: true,
supportsStore: false,
},
});
});
it("resolves bundled catalog hooks through provider plugins", async () => {
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]);
resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => {

View File

@@ -22,6 +22,7 @@ import type {
ProviderFetchUsageSnapshotContext,
ProviderNormalizeConfigContext,
ProviderNormalizeModelIdContext,
ProviderNormalizeResolvedModelContext,
ProviderNormalizeTransportContext,
ProviderModernModelPolicyContext,
ProviderPrepareExtraParamsContext,
@@ -224,6 +225,79 @@ export function normalizeProviderResolvedModelWithPlugin(params: {
);
}
function resolveProviderCompatHookPlugins(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin[] {
const candidates = resolveProviderPluginsForHooks(params);
const owner = resolveProviderRuntimePlugin(params);
if (!owner) {
return candidates;
}
const ordered = [owner, ...candidates];
const seen = new Set<string>();
return ordered.filter((candidate) => {
const key = `${candidate.pluginId ?? ""}:${candidate.id}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function applyCompatPatchToModel(
model: ProviderRuntimeModel,
patch: Record<string, unknown>,
): ProviderRuntimeModel {
const compat =
model.compat && typeof model.compat === "object"
? (model.compat as Record<string, unknown>)
: undefined;
if (Object.entries(patch).every(([key, value]) => compat?.[key] === value)) {
return model;
}
return {
...model,
compat: {
...compat,
...patch,
},
};
}
export function applyProviderResolvedModelCompatWithPlugins(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderNormalizeResolvedModelContext;
}): ProviderRuntimeModel | undefined {
let nextModel = params.context.model;
let changed = false;
for (const plugin of resolveProviderCompatHookPlugins(params)) {
const patch = plugin.contributeResolvedModelCompat?.({
...params.context,
model: nextModel,
});
if (!patch || typeof patch !== "object") {
continue;
}
const patchedModel = applyCompatPatchToModel(nextModel, patch as Record<string, unknown>);
if (patchedModel === nextModel) {
continue;
}
nextModel = patchedModel;
changed = true;
}
return changed ? nextModel : undefined;
}
function resolveProviderHookPlugin(params: {
provider: string;
config?: OpenClawConfig;

View File

@@ -26,6 +26,7 @@ import type {
ModelProviderAuthMode,
ModelProviderConfig,
} from "../config/types.js";
import type { ModelCompatConfig } from "../config/types.models.js";
import type { OperatorScope } from "../gateway/method-scopes.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import type { InternalHookHandler } from "../hooks/internal-hooks.js";
@@ -885,6 +886,17 @@ export type ProviderPlugin = {
normalizeResolvedModel?: (
ctx: ProviderNormalizeResolvedModelContext,
) => ProviderRuntimeModel | null | undefined;
/**
* Provider-owned compat contribution for resolved models outside direct
* provider ownership.
*
* Use this when a plugin can recognize its vendor's models behind another
* OpenAI-compatible transport (for example OpenRouter or a custom base URL)
* and needs to contribute compat flags without taking over the provider.
*/
contributeResolvedModelCompat?: (
ctx: ProviderNormalizeResolvedModelContext,
) => Partial<ModelCompatConfig> | null | undefined;
/**
* Provider-owned model-id normalization.
*