refactor(providers): tighten family outlier contracts

This commit is contained in:
Vincent Koc
2026-04-05 11:09:26 +09:00
parent 6ab1b43081
commit b56517b0ee
7 changed files with 174 additions and 66 deletions

View File

@@ -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",

View File

@@ -0,0 +1,7 @@
export function buildGithubCopilotReplayPolicy(modelId?: string) {
return (modelId?.toLowerCase() ?? "").includes("claude")
? {
dropThinkingBlocks: true,
}
: {};
}

View File

@@ -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<string, unknown> {
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,
});
},

View File

@@ -0,0 +1,3 @@
export const KIMI_REPLAY_POLICY = {
preserveSignatures: false,
};

View File

@@ -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<string, unknown>,
): 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<string, unknown>)
: 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));
}

View File

@@ -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<string, unknown>,
): 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<string, unknown>)
: 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
);
}

View File

@@ -11,6 +11,12 @@ type SharedFamilyProviderInventory = {
sourceFiles: Set<string>;
};
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<string, ExpectedSharedFamilyContract> = {
"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<string, Set<string>> {
return inventory;
}
function listMatchingFamilies(source: string, pattern: RegExp): string[] {
return [...source.matchAll(pattern)].map((match) => match[1] ?? "");
}
function collectSharedFamilyAssignments(): Map<string, ExpectedSharedFamilyContract> {
const inventory = new Map<string, ExpectedSharedFamilyContract>();
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);
});
});