refactor: move bundled replay policy ownership into plugins (#60452)

* refactor: move bundled replay policy ownership into plugins

* test: preserve replay fallback until providers adopt hooks

* test: cover response replay branches for ollama and zai

---------

Co-authored-by: Shakker <shakkerdroid@gmail.com>
This commit is contained in:
Josh Lehman
2026-04-03 11:08:10 -07:00
committed by GitHub
parent 7fb58afb41
commit c52df32878
12 changed files with 444 additions and 8 deletions

View File

@@ -36,6 +36,43 @@ describe("minimax provider hooks", () => {
).toBe("native");
});
it("owns replay policy for Anthropic and OpenAI-compatible MiniMax transports", () => {
const { providers } = registerProviderPlugin({
plugin: minimaxPlugin,
id: "minimax",
name: "MiniMax Provider",
});
const apiProvider = requireRegisteredProvider(providers, "minimax");
const portalProvider = requireRegisteredProvider(providers, "minimax-portal");
expect(
apiProvider.buildReplayPolicy?.({
provider: "minimax",
modelApi: "anthropic-messages",
modelId: "MiniMax-M2.7",
} as never),
).toMatchObject({
sanitizeMode: "full",
sanitizeToolCallIds: true,
preserveSignatures: true,
validateAnthropicTurns: true,
});
expect(
portalProvider.buildReplayPolicy?.({
provider: "minimax-portal",
modelApi: "openai-completions",
modelId: "MiniMax-M2.7",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
});
it("owns fast-mode stream wrapping for MiniMax transports", () => {
const { providers } = registerProviderPlugin({
plugin: minimaxPlugin,

View File

@@ -3,6 +3,8 @@ import {
type ProviderAuthContext,
type ProviderAuthResult,
type ProviderCatalogContext,
type ProviderReplayPolicy,
type ProviderReplayPolicyContext,
} from "openclaw/plugin-sdk/plugin-entry";
import {
MINIMAX_OAUTH_MARKER,
@@ -39,6 +41,36 @@ function resolveMinimaxReasoningOutputMode(): "native" {
return "native";
}
function buildMinimaxReplayPolicy(
ctx: ProviderReplayPolicyContext,
): ProviderReplayPolicy | undefined {
if (ctx.modelApi === "anthropic-messages" || ctx.modelApi === "bedrock-converse-stream") {
const modelId = ctx.modelId?.toLowerCase() ?? "";
return {
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
...(modelId.includes("claude") ? { dropThinkingBlocks: true } : {}),
};
}
if (ctx.modelApi === "openai-completions") {
return {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
};
}
return undefined;
}
function getDefaultBaseUrl(region: MiniMaxRegion): string {
return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL;
}
@@ -235,6 +267,7 @@ export default definePluginEntry({
});
return apiKey ? { token: apiKey } : null;
},
buildReplayPolicy: (ctx) => buildMinimaxReplayPolicy(ctx),
wrapStreamFn: (ctx) =>
createMinimaxFastModeWrapper(ctx.streamFn, ctx.extraParams?.fastMode === true),
resolveReasoningOutputMode: () => resolveMinimaxReasoningOutputMode(),
@@ -287,6 +320,7 @@ export default definePluginEntry({
run: createOAuthHandler("cn"),
},
],
buildReplayPolicy: (ctx) => buildMinimaxReplayPolicy(ctx),
wrapStreamFn: (ctx) =>
createMinimaxFastModeWrapper(ctx.streamFn, ctx.extraParams?.fastMode === true),
resolveReasoningOutputMode: () => resolveMinimaxReasoningOutputMode(),

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import plugin from "./index.js";
describe("moonshot provider plugin", () => {
it("owns replay policy for OpenAI-compatible Moonshot transports", () => {
const provider = registerSingleProviderPlugin(plugin);
expect(
provider.buildReplayPolicy?.({
provider: "moonshot",
modelApi: "openai-completions",
modelId: "kimi-k2.5",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
});
});

View File

@@ -1,3 +1,7 @@
import type {
ProviderReplayPolicy,
ProviderReplayPolicyContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import {
createMoonshotThinkingWrapper,
@@ -15,6 +19,22 @@ import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
const PROVIDER_ID = "moonshot";
function buildMoonshotReplayPolicy(
ctx: ProviderReplayPolicyContext,
): ProviderReplayPolicy | undefined {
if (ctx.modelApi !== "openai-completions") {
return undefined;
}
return {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
};
}
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Moonshot Provider",
@@ -58,6 +78,7 @@ export default defineSingleProviderPluginEntry({
},
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
applyMoonshotNativeStreamingUsageCompat(providerConfig),
buildReplayPolicy: (ctx) => buildMoonshotReplayPolicy(ctx),
wrapStreamFn: (ctx) => {
const thinkingType = resolveMoonshotThinkingType({
configuredThinking: ctx.extraParams?.thinking,

View File

@@ -142,6 +142,46 @@ describe("ollama plugin", () => {
expect((payloadSeen?.options as Record<string, unknown> | undefined)?.num_ctx).toBe(202752);
});
it("owns replay policy for OpenAI-compatible Ollama routes only", () => {
const provider = registerProvider();
expect(
provider.buildReplayPolicy?.({
provider: "ollama",
modelApi: "openai-completions",
modelId: "qwen3:32b",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
expect(
provider.buildReplayPolicy?.({
provider: "ollama",
modelApi: "openai-responses",
modelId: "qwen3:32b",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
});
expect(
provider.buildReplayPolicy?.({
provider: "ollama",
modelApi: "ollama",
modelId: "qwen3.5:9b",
} as never),
).toBeUndefined();
});
it("wraps native Ollama payloads with top-level think=false when thinking is off", () => {
const provider = registerProvider();
let payloadSeen: Record<string, unknown> | undefined;

View File

@@ -5,6 +5,8 @@ import {
type ProviderAuthMethodNonInteractiveContext,
type ProviderAuthResult,
type ProviderDiscoveryContext,
type ProviderReplayPolicy,
type ProviderReplayPolicyContext,
} from "openclaw/plugin-sdk/plugin-entry";
import {
buildOllamaProvider,
@@ -26,6 +28,35 @@ import {
const PROVIDER_ID = "ollama";
const DEFAULT_API_KEY = "ollama-local";
function buildOllamaReplayPolicy(
ctx: ProviderReplayPolicyContext,
): ProviderReplayPolicy | undefined {
if (
ctx.modelApi !== "openai-completions" &&
ctx.modelApi !== "openai-responses" &&
ctx.modelApi !== "openai-codex-responses" &&
ctx.modelApi !== "azure-openai-responses"
) {
return undefined;
}
return {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
...(ctx.modelApi === "openai-completions"
? {
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
}
: {
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
}),
};
}
function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.VITEST) || env.NODE_ENV === "test";
}
@@ -149,6 +180,7 @@ export default definePluginEntry({
providerBaseUrl: config?.models?.providers?.ollama?.baseUrl,
});
},
buildReplayPolicy: (ctx) => buildOllamaReplayPolicy(ctx),
wrapStreamFn: (ctx) => {
return createConfiguredOllamaCompatStreamWrapper(ctx);
},

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import plugin from "./index.js";
describe("xai provider plugin", () => {
it("owns replay policy for xAI OpenAI-compatible transports", () => {
const provider = registerSingleProviderPlugin(plugin);
expect(
provider.buildReplayPolicy?.({
provider: "xai",
modelApi: "openai-completions",
modelId: "grok-3",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
expect(
provider.buildReplayPolicy?.({
provider: "xai",
modelApi: "openai-responses",
modelId: "grok-4-fast",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
});
});
});

View File

@@ -1,4 +1,8 @@
import { Type } from "@sinclair/typebox";
import type {
ProviderReplayPolicy,
ProviderReplayPolicyContext,
} from "openclaw/plugin-sdk/plugin-entry";
import {
coerceSecretRef,
resolveNonEnvSecretRefApiKeyMarker,
@@ -31,6 +35,33 @@ import { createXaiWebSearchProvider } from "./web-search.js";
const PROVIDER_ID = "xai";
function buildXaiReplayPolicy(ctx: ProviderReplayPolicyContext): ProviderReplayPolicy | undefined {
if (
ctx.modelApi !== "openai-completions" &&
ctx.modelApi !== "openai-responses" &&
ctx.modelApi !== "openai-codex-responses" &&
ctx.modelApi !== "azure-openai-responses"
) {
return undefined;
}
return {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
...(ctx.modelApi === "openai-completions"
? {
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
}
: {
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
}),
};
}
function readConfiguredOrManagedApiKey(value: unknown): string | undefined {
const literal = normalizeSecretInputString(value);
if (literal) {
@@ -250,6 +281,7 @@ export default defineSingleProviderPluginEntry({
catalog: {
buildProvider: buildXaiProvider,
},
buildReplayPolicy: (ctx) => buildXaiReplayPolicy(ctx),
prepareExtraParams: (ctx) => {
if (ctx.extraParams?.tool_stream !== undefined) {
return ctx.extraParams;

View File

@@ -3,6 +3,38 @@ import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-
import plugin from "./index.js";
describe("zai provider plugin", () => {
it("owns replay policy for OpenAI-compatible Z.ai transports", () => {
const provider = registerSingleProviderPlugin(plugin);
expect(
provider.buildReplayPolicy?.({
provider: "zai",
modelApi: "openai-completions",
modelId: "glm-5.1",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
expect(
provider.buildReplayPolicy?.({
provider: "zai",
modelApi: "openai-responses",
modelId: "glm-5.1",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
});
});
it("resolves persisted GLM-5 family models with provider-owned metadata", () => {
const provider = registerSingleProviderPlugin(plugin);
const template = {

View File

@@ -3,6 +3,8 @@ import {
type ProviderAuthContext,
type ProviderAuthMethod,
type ProviderAuthMethodNonInteractiveContext,
type ProviderReplayPolicy,
type ProviderReplayPolicyContext,
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
@@ -28,6 +30,33 @@ const PROVIDER_ID = "zai";
const GLM5_TEMPLATE_MODEL_ID = "glm-4.7";
const PROFILE_ID = "zai:default";
function buildZaiReplayPolicy(ctx: ProviderReplayPolicyContext): ProviderReplayPolicy | undefined {
if (
ctx.modelApi !== "openai-completions" &&
ctx.modelApi !== "openai-responses" &&
ctx.modelApi !== "openai-codex-responses" &&
ctx.modelApi !== "azure-openai-responses"
) {
return undefined;
}
return {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
...(ctx.modelApi === "openai-completions"
? {
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
}
: {
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
}),
};
}
function resolveGlm5ForwardCompatModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel | undefined {
@@ -264,6 +293,7 @@ export default definePluginEntry({
}),
],
resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx),
buildReplayPolicy: (ctx) => buildZaiReplayPolicy(ctx),
prepareExtraParams: (ctx) => {
if (ctx.extraParams?.tool_stream !== undefined) {
return ctx.extraParams;

View File

@@ -11,16 +11,27 @@ vi.mock("../plugins/provider-runtime.js", () => ({
"kilocode",
"kimi",
"kimi-code",
"minimax",
"minimax-portal",
"mistral",
"moonshot",
"openai",
"openai-codex",
"opencode",
"opencode-go",
"ollama",
"openrouter",
"sglang",
"vllm",
"xai",
"zai",
].includes(provider)
) {
return undefined;
}
if (provider === "sglang" || provider === "vllm") {
return {};
}
return {
buildReplayPolicy: (context?: { modelId?: string; modelApi?: string }) => {
const modelId = context?.modelId?.toLowerCase() ?? "";
@@ -37,6 +48,38 @@ vi.mock("../plugins/provider-runtime.js", () => ({
allowSyntheticToolResults: true,
...(modelId.includes("claude") ? { dropThinkingBlocks: true } : {}),
};
case "minimax":
case "minimax-portal":
return context?.modelApi === "openai-completions"
? {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
}
: {
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
...(modelId.includes("claude") ? { dropThinkingBlocks: true } : {}),
};
case "moonshot":
case "ollama":
case "zai":
return context?.modelApi === "openai-completions"
? {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
}
: undefined;
case "google":
return {
sanitizeMode: "full",
@@ -88,6 +131,28 @@ vi.mock("../plugins/provider-runtime.js", () => ({
}
: {}),
};
case "xai":
if (
context?.modelApi === "openai-completions" ||
context?.modelApi === "openai-responses"
) {
return {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
...(context.modelApi === "openai-completions"
? {
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
}
: {
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
}),
};
}
return undefined;
case "kilocode":
return modelId.includes("gemini")
? {
@@ -183,10 +248,10 @@ describe("resolveTranscriptPolicy", () => {
expect(policy.validateAnthropicTurns).toBe(true);
});
it("falls back to transport defaults when a plugin replay hook returns undefined", () => {
it("falls back to unowned transport defaults when no owning plugin exists", () => {
const policy = resolveTranscriptPolicy({
provider: "kilocode",
modelId: "kilocode-default",
provider: "custom-openai-proxy",
modelId: "demo-model",
modelApi: "openai-completions",
});
@@ -197,6 +262,49 @@ describe("resolveTranscriptPolicy", () => {
expect(policy.validateAnthropicTurns).toBe(true);
});
it("preserves transport defaults when a runtime plugin has not adopted replay hooks", () => {
const policy = resolveTranscriptPolicy({
provider: "vllm",
modelId: "demo-model",
modelApi: "openai-completions",
});
expect(policy.sanitizeToolCallIds).toBe(true);
expect(policy.toolCallIdMode).toBe("strict");
expect(policy.applyGoogleTurnOrdering).toBe(true);
expect(policy.validateGeminiTurns).toBe(true);
expect(policy.validateAnthropicTurns).toBe(true);
});
it("uses provider-owned Anthropic replay policy for MiniMax transports", () => {
const policy = resolveTranscriptPolicy({
provider: "minimax",
modelId: "MiniMax-M2.7",
modelApi: "anthropic-messages",
});
expect(policy.sanitizeMode).toBe("full");
expect(policy.sanitizeToolCallIds).toBe(true);
expect(policy.preserveSignatures).toBe(true);
expect(policy.validateAnthropicTurns).toBe(true);
});
it("uses provider-owned OpenAI-compatible replay policy for MiniMax portal completions", () => {
const policy = resolveTranscriptPolicy({
provider: "minimax-portal",
modelId: "MiniMax-M2.7",
modelApi: "openai-completions",
});
expect(policy.sanitizeMode).toBe("images-only");
expect(policy.sanitizeToolCallIds).toBe(true);
expect(policy.toolCallIdMode).toBe("strict");
expect(policy.preserveSignatures).toBe(false);
expect(policy.applyGoogleTurnOrdering).toBe(true);
expect(policy.validateGeminiTurns).toBe(true);
expect(policy.validateAnthropicTurns).toBe(true);
});
it("enables Anthropic-compatible policies for Bedrock provider", () => {
const policy = resolveTranscriptPolicy({
provider: "amazon-bedrock",

View File

@@ -44,7 +44,14 @@ function isAnthropicApi(modelApi?: string | null): boolean {
return modelApi === "anthropic-messages" || modelApi === "bedrock-converse-stream";
}
function buildTransportReplayFallback(params: {
/**
* Provides a narrow replay-policy fallback for providers that do not have an
* owning runtime plugin.
*
* This exists to preserve generic custom-provider behavior. Bundled providers
* should express replay ownership through `buildReplayPolicy` instead.
*/
function buildUnownedProviderTransportReplayFallback(params: {
modelApi?: string | null;
modelId?: string | null;
}): ProviderReplayPolicy | undefined {
@@ -162,13 +169,16 @@ export function resolveTranscriptPolicy(params: {
model: params.model,
};
const pluginPolicy = runtimePlugin?.buildReplayPolicy?.(context);
if (pluginPolicy != null) {
return mergeTranscriptPolicy(pluginPolicy);
// Once a provider adopts the replay-policy hook, replay policy should come
// from the plugin, not from transport-family defaults in core.
const buildReplayPolicy = runtimePlugin?.buildReplayPolicy;
if (buildReplayPolicy) {
const pluginPolicy = buildReplayPolicy(context);
return mergeTranscriptPolicy(pluginPolicy ?? undefined);
}
return mergeTranscriptPolicy(
buildTransportReplayFallback({
buildUnownedProviderTransportReplayFallback({
modelApi: params.modelApi,
modelId: params.modelId,
}),