refactor(providers): add family replay and tool hooks

This commit is contained in:
Vincent Koc
2026-04-04 19:27:36 +09:00
parent 4e099689c0
commit 39d2a719c9
20 changed files with 273 additions and 85 deletions

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { buildProviderReplayFamilyHooks } from "./provider-model-shared.js";
describe("buildProviderReplayFamilyHooks", () => {
it("maps openai-compatible replay families", () => {
const hooks = buildProviderReplayFamilyHooks({
family: "openai-compatible",
});
expect(
hooks.buildReplayPolicy?.({
provider: "xai",
modelApi: "openai-completions",
modelId: "grok-4",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
});
});
it("maps google-gemini replay families", () => {
const hooks = buildProviderReplayFamilyHooks({
family: "google-gemini",
});
expect(hooks.resolveReasoningOutputMode?.({} as never)).toBe("tagged");
expect(
hooks.buildReplayPolicy?.({
provider: "google",
modelApi: "google-generative-ai",
modelId: "gemini-3.1-pro-preview",
} as never),
).toMatchObject({
validateGeminiTurns: true,
allowSyntheticToolResults: true,
});
const sanitized = hooks.sanitizeReplayHistory?.({
provider: "google",
modelApi: "google-generative-ai",
modelId: "gemini-3.1-pro-preview",
sessionId: "session-1",
messages: [
{
role: "assistant",
content: [{ type: "text", text: "hello" }],
},
],
sessionState: {
getCustomEntries: () => [],
appendCustomEntry: () => {},
},
} as never);
expect(sanitized?.[0]).toMatchObject({
role: "user",
content: "(session bootstrap)",
});
});
it("maps hybrid anthropic/openai replay families", () => {
const hooks = buildProviderReplayFamilyHooks({
family: "hybrid-anthropic-openai",
anthropicModelDropThinkingBlocks: true,
});
expect(
hooks.buildReplayPolicy?.({
provider: "minimax",
modelApi: "anthropic-messages",
modelId: "claude-sonnet-4-6",
} as never),
).toMatchObject({
validateAnthropicTurns: true,
dropThinkingBlocks: true,
});
});
});

View File

@@ -4,6 +4,22 @@
// without recursing through provider-specific facades.
import type { BedrockDiscoveryConfig, ModelDefinitionConfig } from "../config/types.models.js";
import type { ProviderPlugin } from "../plugins/types.js";
import {
buildAnthropicReplayPolicyForModel,
buildGoogleGeminiReplayPolicy,
buildHybridAnthropicOrOpenAIReplayPolicy,
buildOpenAICompatibleReplayPolicy,
buildPassthroughGeminiSanitizingReplayPolicy,
buildStrictAnthropicReplayPolicy,
resolveTaggedReasoningOutputMode,
sanitizeGoogleGeminiReplayHistory,
} from "../plugins/provider-replay-helpers.js";
import type {
ProviderReasoningOutputModeContext,
ProviderReplayPolicyContext,
ProviderSanitizeReplayHistoryContext,
} from "./plugin-entry.js";
export type { ModelApi, ModelProviderConfig } from "../config/types.models.js";
export type {
@@ -38,7 +54,7 @@ export {
resolveTaggedReasoningOutputMode,
sanitizeGoogleGeminiReplayHistory,
buildStrictAnthropicReplayPolicy,
} from "../plugins/provider-replay-helpers.js";
};
export {
createMoonshotThinkingWrapper,
resolveMoonshotThinkingType,
@@ -110,3 +126,62 @@ export function normalizeNativeXaiModelId(id: string): string {
}
return id;
}
export type ProviderReplayFamily =
| "openai-compatible"
| "anthropic-by-model"
| "google-gemini"
| "passthrough-gemini"
| "hybrid-anthropic-openai";
type ProviderReplayFamilyHooks = Pick<
ProviderPlugin,
"buildReplayPolicy" | "sanitizeReplayHistory" | "resolveReasoningOutputMode"
>;
type BuildProviderReplayFamilyHooksOptions =
| { family: "openai-compatible" }
| { family: "anthropic-by-model" }
| { family: "google-gemini" }
| { family: "passthrough-gemini" }
| {
family: "hybrid-anthropic-openai";
anthropicModelDropThinkingBlocks?: boolean;
};
export function buildProviderReplayFamilyHooks(
options: BuildProviderReplayFamilyHooksOptions,
): ProviderReplayFamilyHooks {
switch (options.family) {
case "openai-compatible":
return {
buildReplayPolicy: (ctx: ProviderReplayPolicyContext) =>
buildOpenAICompatibleReplayPolicy(ctx.modelApi),
};
case "anthropic-by-model":
return {
buildReplayPolicy: ({ modelId }: ProviderReplayPolicyContext) =>
buildAnthropicReplayPolicyForModel(modelId),
};
case "google-gemini":
return {
buildReplayPolicy: () => buildGoogleGeminiReplayPolicy(),
sanitizeReplayHistory: (ctx: ProviderSanitizeReplayHistoryContext) =>
sanitizeGoogleGeminiReplayHistory(ctx),
resolveReasoningOutputMode: (_ctx: ProviderReasoningOutputModeContext) =>
resolveTaggedReasoningOutputMode(),
};
case "passthrough-gemini":
return {
buildReplayPolicy: ({ modelId }: ProviderReplayPolicyContext) =>
buildPassthroughGeminiSanitizingReplayPolicy(modelId),
};
case "hybrid-anthropic-openai":
return {
buildReplayPolicy: (ctx: ProviderReplayPolicyContext) =>
buildHybridAnthropicOrOpenAIReplayPolicy(ctx, {
anthropicModelDropThinkingBlocks: options.anthropicModelDropThinkingBlocks,
}),
};
}
}

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import {
buildProviderToolCompatFamilyHooks,
inspectGeminiToolSchemas,
normalizeGeminiToolSchemas,
} from "./provider-tools.js";
describe("buildProviderToolCompatFamilyHooks", () => {
it("maps the gemini family to the shared schema helpers", () => {
const hooks = buildProviderToolCompatFamilyHooks("gemini");
expect(hooks.normalizeToolSchemas).toBe(normalizeGeminiToolSchemas);
expect(hooks.inspectToolSchemas).toBe(inspectGeminiToolSchemas);
});
});

View File

@@ -158,3 +158,20 @@ export function inspectGeminiToolSchemas(
return [{ toolName: tool.name, toolIndex, violations }];
});
}
export type ProviderToolCompatFamily = "gemini";
export function buildProviderToolCompatFamilyHooks(family: ProviderToolCompatFamily): {
normalizeToolSchemas: (ctx: ProviderNormalizeToolSchemasContext) => AnyAgentTool[];
inspectToolSchemas: (
ctx: ProviderNormalizeToolSchemasContext,
) => ProviderToolSchemaDiagnostic[];
} {
switch (family) {
case "gemini":
return {
normalizeToolSchemas: normalizeGeminiToolSchemas,
inspectToolSchemas: inspectGeminiToolSchemas,
};
}
}