diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 1abe189f00b..872f61c2d8c 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -11,18 +11,11 @@ import { resolveCopilotForwardCompatModel, wrapCopilotProviderStream, } from "./register.runtime.js"; +import { buildGithubCopilotReplayPolicy } from "./replay-policy.js"; const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.2", "gpt-5.2-codex"] as const; -function buildGithubCopilotReplayPolicy(modelId?: string) { - return (modelId?.toLowerCase() ?? "").includes("claude") - ? { - dropThinkingBlocks: true, - } - : {}; -} - export default definePluginEntry({ id: "github-copilot", name: "GitHub Copilot Provider", diff --git a/extensions/github-copilot/replay-policy.ts b/extensions/github-copilot/replay-policy.ts new file mode 100644 index 00000000000..6be575f1f2b --- /dev/null +++ b/extensions/github-copilot/replay-policy.ts @@ -0,0 +1,7 @@ +export function buildGithubCopilotReplayPolicy(modelId?: string) { + return (modelId?.toLowerCase() ?? "").includes("claude") + ? { + dropThinkingBlocks: true, + } + : {}; +} diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index bd777677adb..007874b75f5 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -2,6 +2,7 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js"; import { buildKimiCodingProvider } from "./provider-catalog.js"; +import { KIMI_REPLAY_POLICY } from "./replay-policy.js"; import { wrapKimiProviderStream } from "./stream.js"; const PLUGIN_ID = "kimi"; @@ -11,12 +12,6 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function buildKimiReplayPolicy() { - return { - preserveSignatures: false, - }; -} - export default definePluginEntry({ id: PLUGIN_ID, name: "Kimi Provider", @@ -86,7 +81,7 @@ export default definePluginEntry({ }; }, }, - buildReplayPolicy: () => buildKimiReplayPolicy(), + buildReplayPolicy: () => KIMI_REPLAY_POLICY, wrapStreamFn: wrapKimiProviderStream, }); }, diff --git a/extensions/kimi-coding/replay-policy.ts b/extensions/kimi-coding/replay-policy.ts new file mode 100644 index 00000000000..c3c967b9f02 --- /dev/null +++ b/extensions/kimi-coding/replay-policy.ts @@ -0,0 +1,3 @@ +export const KIMI_REPLAY_POLICY = { + preserveSignatures: false, +}; diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 896ec90df33..3adb06a098d 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -3,13 +3,11 @@ import { definePluginEntry, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, - type ProviderWrapStreamFnContext, } from "openclaw/plugin-sdk/plugin-entry"; import { applyOpenrouterConfig, buildOpenrouterProvider, buildProviderReplayFamilyHooks, - buildProviderStreamFamilyHooks, createProviderApiKeyAuthMethod, DEFAULT_CONTEXT_TOKENS, getOpenRouterModelCapabilities, @@ -17,6 +15,7 @@ import { OPENROUTER_DEFAULT_MODEL_REF, openrouterMediaUnderstandingProvider, } from "./register.runtime.js"; +import { wrapOpenRouterProviderStream } from "./stream.js"; const PROVIDER_ID = "openrouter"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; @@ -36,8 +35,6 @@ export default definePluginEntry({ const PASSTHROUGH_GEMINI_REPLAY_HOOKS = buildProviderReplayFamilyHooks({ family: "passthrough-gemini", }); - const OPENROUTER_THINKING_STREAM_HOOKS = buildProviderStreamFamilyHooks("openrouter-thinking"); - function buildDynamicOpenRouterModel( ctx: ProviderResolveDynamicModelContext, ): ProviderRuntimeModel { @@ -56,53 +53,6 @@ export default definePluginEntry({ }; } - function injectOpenRouterRouting( - baseStreamFn: StreamFn | undefined, - providerRouting?: Record, - ): StreamFn | undefined { - if (!providerRouting) { - return baseStreamFn; - } - return (model, context, options) => - ( - baseStreamFn ?? - ((nextModel, nextContext, nextOptions) => { - throw new Error( - `OpenRouter routing wrapper requires an underlying streamFn for ${String(nextModel.id)}.`, - ); - }) - )( - { - ...model, - compat: { ...model.compat, openRouterRouting: providerRouting }, - } as typeof model, - context, - options, - ); - } - - function wrapOpenRouterProviderStream( - ctx: ProviderWrapStreamFnContext, - ): StreamFn | null | undefined { - const providerRouting = - ctx.extraParams?.provider != null && typeof ctx.extraParams.provider === "object" - ? (ctx.extraParams.provider as Record) - : undefined; - const routedStreamFn = providerRouting - ? injectOpenRouterRouting(ctx.streamFn, providerRouting) - : ctx.streamFn; - const wrapStreamFn = OPENROUTER_THINKING_STREAM_HOOKS.wrapStreamFn ?? undefined; - if (!wrapStreamFn) { - return routedStreamFn; - } - return ( - wrapStreamFn({ - ...ctx, - streamFn: routedStreamFn, - }) ?? undefined - ); - } - function isOpenRouterCacheTtlModel(modelId: string): boolean { return OPENROUTER_CACHE_TTL_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)); } diff --git a/extensions/openrouter/stream.ts b/extensions/openrouter/stream.ts new file mode 100644 index 00000000000..74a7d5a012e --- /dev/null +++ b/extensions/openrouter/stream.ts @@ -0,0 +1,52 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { buildProviderStreamFamilyHooks } from "openclaw/plugin-sdk/provider-stream"; +import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; + +const OPENROUTER_THINKING_STREAM_HOOKS = buildProviderStreamFamilyHooks("openrouter-thinking"); + +function injectOpenRouterRouting( + baseStreamFn: StreamFn | undefined, + providerRouting?: Record, +): StreamFn | undefined { + if (!providerRouting) { + return baseStreamFn; + } + return (model, context, options) => + ( + baseStreamFn ?? + ((nextModel) => { + throw new Error( + `OpenRouter routing wrapper requires an underlying streamFn for ${String(nextModel.id)}.`, + ); + }) + )( + { + ...model, + compat: { ...model.compat, openRouterRouting: providerRouting }, + } as typeof model, + context, + options, + ); +} + +export function wrapOpenRouterProviderStream( + ctx: ProviderWrapStreamFnContext, +): StreamFn | null | undefined { + const providerRouting = + ctx.extraParams?.provider != null && typeof ctx.extraParams.provider === "object" + ? (ctx.extraParams.provider as Record) + : undefined; + const routedStreamFn = providerRouting + ? injectOpenRouterRouting(ctx.streamFn, providerRouting) + : ctx.streamFn; + const wrapStreamFn = OPENROUTER_THINKING_STREAM_HOOKS.wrapStreamFn ?? undefined; + if (!wrapStreamFn) { + return routedStreamFn; + } + return ( + wrapStreamFn({ + ...ctx, + streamFn: routedStreamFn, + }) ?? undefined + ); +} diff --git a/src/plugins/contracts/provider-family-plugin-tests.test.ts b/src/plugins/contracts/provider-family-plugin-tests.test.ts index 5fa71ed719e..c164dbaba90 100644 --- a/src/plugins/contracts/provider-family-plugin-tests.test.ts +++ b/src/plugins/contracts/provider-family-plugin-tests.test.ts @@ -11,6 +11,12 @@ type SharedFamilyProviderInventory = { sourceFiles: Set; }; +type ExpectedSharedFamilyContract = { + replayFamilies?: readonly string[]; + streamFamilies?: readonly string[]; + toolCompatFamilies?: readonly string[]; +}; + const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = resolve(SRC_ROOT, ".."); const EXTENSIONS_DIR = resolve(REPO_ROOT, BUNDLED_PLUGIN_ROOT_DIR); @@ -26,6 +32,54 @@ const PROVIDER_BOUNDARY_TEST_SIGNALS = [ /\bregister(?:Single)?ProviderPlugin\s*\(/u, /\bcreateTestPluginApi\s*\(/u, ] as const; +const EXPECTED_SHARED_FAMILY_CONTRACTS: Record = { + "amazon-bedrock": { + replayFamilies: ["anthropic-by-model"], + }, + "anthropic-vertex": { + replayFamilies: ["anthropic-by-model"], + }, + google: { + replayFamilies: ["google-gemini"], + streamFamilies: ["google-thinking"], + toolCompatFamilies: ["gemini"], + }, + kilocode: { + replayFamilies: ["passthrough-gemini"], + streamFamilies: ["kilocode-thinking"], + }, + minimax: { + replayFamilies: ["hybrid-anthropic-openai"], + streamFamilies: ["minimax-fast-mode"], + }, + moonshot: { + replayFamilies: ["openai-compatible"], + streamFamilies: ["moonshot-thinking"], + }, + ollama: { + replayFamilies: ["openai-compatible"], + }, + openai: { + streamFamilies: ["openai-responses-defaults"], + }, + opencode: { + replayFamilies: ["passthrough-gemini"], + }, + "opencode-go": { + replayFamilies: ["passthrough-gemini"], + }, + openrouter: { + replayFamilies: ["passthrough-gemini"], + streamFamilies: ["openrouter-thinking"], + }, + xai: { + replayFamilies: ["openai-compatible"], + }, + zai: { + replayFamilies: ["openai-compatible"], + streamFamilies: ["tool-stream-default-on"], + }, +}; function toRepoRelative(path: string): string { return relative(REPO_ROOT, path).split(sep).join("/"); @@ -108,6 +162,50 @@ function collectProviderBoundaryTests(): Map> { return inventory; } +function listMatchingFamilies(source: string, pattern: RegExp): string[] { + return [...source.matchAll(pattern)].map((match) => match[1] ?? ""); +} + +function collectSharedFamilyAssignments(): Map { + const inventory = new Map(); + const replayPattern = + /buildProviderReplayFamilyHooks\s*\(\s*\{\s*family:\s*"([^"]+)"/gu; + const streamPattern = + /buildProviderStreamFamilyHooks\s*\(\s*"([^"]+)"/gu; + const toolCompatPattern = + /buildProviderToolCompatFamilyHooks\s*\(\s*"([^"]+)"/gu; + + for (const filePath of listFiles(EXTENSIONS_DIR)) { + if (!filePath.endsWith(".ts") || filePath.endsWith(".test.ts")) { + continue; + } + const source = readFileSync(filePath, "utf8"); + const replayFamilies = listMatchingFamilies(source, replayPattern); + const streamFamilies = listMatchingFamilies(source, streamPattern); + const toolCompatFamilies = listMatchingFamilies(source, toolCompatPattern); + if (replayFamilies.length === 0 && streamFamilies.length === 0 && toolCompatFamilies.length === 0) { + continue; + } + const pluginId = resolveBundledPluginId(filePath); + if (!pluginId) { + continue; + } + const entry = inventory.get(pluginId) ?? {}; + if (replayFamilies.length > 0) { + entry.replayFamilies = [...new Set([...(entry.replayFamilies ?? []), ...replayFamilies])].toSorted(); + } + if (streamFamilies.length > 0) { + entry.streamFamilies = [...new Set([...(entry.streamFamilies ?? []), ...streamFamilies])].toSorted(); + } + if (toolCompatFamilies.length > 0) { + entry.toolCompatFamilies = [...new Set([...(entry.toolCompatFamilies ?? []), ...toolCompatFamilies])].toSorted(); + } + inventory.set(pluginId, entry); + } + + return inventory; +} + describe("provider family plugin-boundary inventory", () => { it("keeps shared-family provider hooks covered by at least one plugin-boundary test", () => { const sharedFamilyProviders = collectSharedFamilyProviders(); @@ -123,4 +221,14 @@ describe("provider family plugin-boundary inventory", () => { expect(missing).toEqual([]); }); + + it("keeps shared-family assignments aligned with the curated provider inventory", () => { + const actualAssignments = Object.fromEntries( + [...collectSharedFamilyAssignments().entries()].toSorted(([left], [right]) => + left.localeCompare(right), + ), + ); + + expect(actualAssignments).toEqual(EXPECTED_SHARED_FAMILY_CONTRACTS); + }); });