mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 22:32:12 +00:00
refactor(providers): tighten family outlier contracts
This commit is contained in:
@@ -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",
|
||||
|
||||
7
extensions/github-copilot/replay-policy.ts
Normal file
7
extensions/github-copilot/replay-policy.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function buildGithubCopilotReplayPolicy(modelId?: string) {
|
||||
return (modelId?.toLowerCase() ?? "").includes("claude")
|
||||
? {
|
||||
dropThinkingBlocks: true,
|
||||
}
|
||||
: {};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
3
extensions/kimi-coding/replay-policy.ts
Normal file
3
extensions/kimi-coding/replay-policy.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const KIMI_REPLAY_POLICY = {
|
||||
preserveSignatures: false,
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
52
extensions/openrouter/stream.ts
Normal file
52
extensions/openrouter/stream.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user