重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,6 @@
export { buildOpenrouterProvider } from "./provider-catalog.js";
export {
applyOpenrouterConfig,
applyOpenrouterProviderConfig,
OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";

View file

@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import { expectPassthroughReplayPolicy } from "../../test/helpers/provider-replay-policy.ts";
import openrouterPlugin from "./index.js";
describe("openrouter provider hooks", () => {
it("owns passthrough-gemini replay policy for Gemini-backed models", async () => {
await expectPassthroughReplayPolicy({
plugin: openrouterPlugin,
providerId: "openrouter",
modelId: "gemini-2.5-pro",
sanitizeThoughtSignatures: true,
});
await expectPassthroughReplayPolicy({
plugin: openrouterPlugin,
providerId: "openrouter",
modelId: "openai/gpt-5.4",
});
});
it("owns native reasoning output mode", async () => {
const provider = await registerSingleProviderPlugin(openrouterPlugin);
expect(
provider.resolveReasoningOutputMode?.({
provider: "openrouter",
modelApi: "openai-completions",
modelId: "openai/gpt-5.4",
} as never),
).toBe("native");
});
it("canonicalizes stale OpenRouter /v1 config and runtime metadata", async () => {
const provider = await registerSingleProviderPlugin(openrouterPlugin);
expect(
provider.normalizeConfig?.({
provider: "openrouter",
providerConfig: {
api: "openai-completions",
baseUrl: "https://openrouter.ai/v1/",
models: [],
},
} as never),
).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
});
expect(
provider.normalizeResolvedModel?.({
provider: "openrouter",
model: {
provider: "openrouter",
id: "openai/gpt-5.4",
name: "openai/gpt-5.4",
api: "openai-completions",
baseUrl: "https://openrouter.ai/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
} as never),
).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
});
expect(
provider.normalizeTransport?.({
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/v1",
} as never),
).toEqual({
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
});
});
it("injects provider routing into compat before applying stream wrappers", async () => {
const provider = await registerSingleProviderPlugin(openrouterPlugin);
const baseStreamFn = vi.fn(
(..._args: Parameters<import("@mariozechner/pi-agent-core").StreamFn>) =>
({ async *[Symbol.asyncIterator]() {} }) as never,
);
const wrapped = provider.wrapStreamFn?.({
provider: "openrouter",
modelId: "openai/gpt-5.4",
extraParams: {
provider: {
order: ["moonshot"],
},
},
streamFn: baseStreamFn,
thinkingLevel: "high",
} as never);
void wrapped?.(
{
provider: "openrouter",
api: "openai-completions",
id: "openai/gpt-5.4",
compat: {},
} as never,
{ messages: [] } as never,
{},
);
expect(baseStreamFn).toHaveBeenCalledOnce();
const firstCall = baseStreamFn.mock.calls[0];
const firstModel = firstCall?.[0];
expect(firstModel).toMatchObject({
compat: {
openRouterRouting: {
order: ["moonshot"],
},
},
});
});
});

View file

@ -0,0 +1,141 @@
import {
definePluginEntry,
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import {
DEFAULT_CONTEXT_TOKENS,
PASSTHROUGH_GEMINI_REPLAY_HOOKS,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
getOpenRouterModelCapabilities,
loadOpenRouterModelCapabilities,
} from "openclaw/plugin-sdk/provider-stream-family";
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
import {
buildOpenrouterProvider,
normalizeOpenRouterBaseUrl,
OPENROUTER_BASE_URL,
} from "./provider-catalog.js";
import { wrapOpenRouterProviderStream } from "./stream.js";
const PROVIDER_ID = "openrouter";
const OPENROUTER_DEFAULT_MAX_TOKENS = 8192;
const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [
"anthropic/",
"moonshot/",
"moonshotai/",
"zai/",
] as const;
function normalizeOpenRouterResolvedModel<T extends ProviderRuntimeModel>(model: T): T | undefined {
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(model.baseUrl);
if (!normalizedBaseUrl || normalizedBaseUrl === model.baseUrl) {
return undefined;
}
return {
...model,
baseUrl: normalizedBaseUrl,
};
}
export default definePluginEntry({
id: "openrouter",
name: "OpenRouter Provider",
description: "Bundled OpenRouter provider plugin",
register(api) {
function buildDynamicOpenRouterModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel {
const capabilities = getOpenRouterModelCapabilities(ctx.modelId);
return {
id: ctx.modelId,
name: capabilities?.name ?? ctx.modelId,
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENROUTER_BASE_URL,
reasoning: capabilities?.reasoning ?? false,
input: capabilities?.input ?? ["text"],
cost: capabilities?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: capabilities?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
maxTokens: capabilities?.maxTokens ?? OPENROUTER_DEFAULT_MAX_TOKENS,
};
}
function isOpenRouterCacheTtlModel(modelId: string): boolean {
return OPENROUTER_CACHE_TTL_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
}
api.registerProvider({
id: PROVIDER_ID,
label: "OpenRouter",
docsPath: "/providers/models",
envVars: ["OPENROUTER_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "OpenRouter API key",
hint: "API key",
optionKey: "openrouterApiKey",
flagName: "--openrouter-api-key",
envVar: "OPENROUTER_API_KEY",
promptMessage: "Enter OpenRouter API key",
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
expectedProviders: ["openrouter"],
applyConfig: (cfg) => applyOpenrouterConfig(cfg),
wizard: {
choiceId: "openrouter-api-key",
choiceLabel: "OpenRouter API key",
groupId: "openrouter",
groupLabel: "OpenRouter",
groupHint: "API key",
},
}),
],
catalog: {
order: "simple",
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
return {
provider: {
...buildOpenrouterProvider(),
apiKey,
},
};
},
},
resolveDynamicModel: (ctx) => buildDynamicOpenRouterModel(ctx),
prepareDynamicModel: async (ctx) => {
await loadOpenRouterModelCapabilities(ctx.modelId);
},
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(providerConfig.baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeResolvedModel: ({ model }) => normalizeOpenRouterResolvedModel(model),
normalizeTransport: ({ api, baseUrl }) => {
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
...PASSTHROUGH_GEMINI_REPLAY_HOOKS,
resolveReasoningOutputMode: () => "native",
isModernModelRef: () => true,
wrapStreamFn: wrapOpenRouterProviderStream,
isCacheTtlEligible: (ctx) => isOpenRouterCacheTtlModel(ctx.modelId),
});
api.registerMediaUnderstandingProvider(openrouterMediaUnderstandingProvider);
},
});

View file

@ -0,0 +1,13 @@
import {
describeImageWithModel,
describeImagesWithModel,
type MediaUnderstandingProvider,
} from "openclaw/plugin-sdk/media-understanding";
export const openrouterMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "openrouter",
capabilities: ["image"],
defaultModels: { image: "auto" },
describeImage: describeImageWithModel,
describeImages: describeImagesWithModel,
};

View file

@ -0,0 +1,27 @@
import { describe, it } from "vitest";
import {
expectProviderOnboardAllowlistAlias,
expectProviderOnboardPrimaryAndFallbacks,
} from "../../test/helpers/plugins/provider-onboard.js";
import {
applyOpenrouterConfig,
applyOpenrouterProviderConfig,
OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";
describe("openrouter onboard", () => {
it("adds allowlist entry and preserves alias", () => {
expectProviderOnboardAllowlistAlias({
applyProviderConfig: applyOpenrouterProviderConfig,
modelRef: OPENROUTER_DEFAULT_MODEL_REF,
alias: "Router",
});
});
it("sets primary model and preserves existing model fallbacks", () => {
expectProviderOnboardPrimaryAndFallbacks({
applyConfig: applyOpenrouterConfig,
modelRef: OPENROUTER_DEFAULT_MODEL_REF,
});
});
});

View file

@ -0,0 +1,32 @@
import {
applyAgentDefaultModelPrimary,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[OPENROUTER_DEFAULT_MODEL_REF] = {
...models[OPENROUTER_DEFAULT_MODEL_REF],
alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter",
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyOpenrouterProviderConfig(cfg),
OPENROUTER_DEFAULT_MODEL_REF,
);
}

View file

@ -0,0 +1,31 @@
{
"id": "openrouter",
"enabledByDefault": true,
"providers": ["openrouter"],
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "openrouter",
"method": "api-key",
"choiceId": "openrouter-api-key",
"choiceLabel": "OpenRouter API key",
"groupId": "openrouter",
"groupLabel": "OpenRouter",
"groupHint": "API key",
"optionKey": "openrouterApiKey",
"cliFlag": "--openrouter-api-key",
"cliOption": "--openrouter-api-key <key>",
"cliDescription": "OpenRouter API key"
}
],
"contracts": {
"mediaUnderstandingProviders": ["openrouter"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1,59 @@
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
import OpenAI from "openai";
import { describe, expect, it } from "vitest";
import {
registerProviderPlugin,
requireRegisteredProvider,
} from "../../test/helpers/plugins/provider-registration.js";
import plugin from "./index.js";
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? "";
const LIVE_MODEL_ID =
process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() || "openai/gpt-5.4-nano";
const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
const describeLive = liveEnabled ? describe : describe.skip;
const ModelRegistryCtor = ModelRegistry as unknown as {
new (authStorage: AuthStorage, modelsJsonPath?: string): ModelRegistry;
};
const registerOpenRouterPlugin = async () =>
registerProviderPlugin({
plugin,
id: "openrouter",
name: "OpenRouter Provider",
});
describeLive("openrouter plugin live", () => {
it("registers an OpenRouter provider that can complete a live request", async () => {
const { providers } = await registerOpenRouterPlugin();
const provider = requireRegisteredProvider(providers, "openrouter");
const resolved = provider.resolveDynamicModel?.({
provider: "openrouter",
modelId: LIVE_MODEL_ID,
modelRegistry: new ModelRegistryCtor(AuthStorage.inMemory()),
});
if (!resolved) {
throw new Error(`openrouter provider did not resolve ${LIVE_MODEL_ID}`);
}
expect(resolved).toMatchObject({
provider: "openrouter",
id: LIVE_MODEL_ID,
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
});
const client = new OpenAI({
apiKey: OPENROUTER_API_KEY,
baseURL: resolved.baseUrl,
});
const response = await client.chat.completions.create({
model: resolved.id,
messages: [{ role: "user", content: "Reply with exactly OK." }],
max_tokens: 16,
});
expect(response.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/);
}, 30_000);
});

View file

@ -0,0 +1,15 @@
{
"name": "@openclaw/openrouter-provider",
"version": "2026.4.20",
"private": true,
"description": "OpenClaw OpenRouter provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -0,0 +1,64 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
const OPENROUTER_LEGACY_BASE_URL = "https://openrouter.ai/v1";
const OPENROUTER_DEFAULT_MODEL_ID = "auto";
const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000;
const OPENROUTER_DEFAULT_MAX_TOKENS = 8192;
const OPENROUTER_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
function normalizeBaseUrl(baseUrl: string | undefined): string {
return (baseUrl ?? "").trim().replace(/\/+$/, "");
}
export function normalizeOpenRouterBaseUrl(baseUrl: string | undefined): string | undefined {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) {
return undefined;
}
if (normalized === OPENROUTER_BASE_URL || normalized === OPENROUTER_LEGACY_BASE_URL) {
return OPENROUTER_BASE_URL;
}
return undefined;
}
export function buildOpenrouterProvider(): ModelProviderConfig {
return {
baseUrl: OPENROUTER_BASE_URL,
api: "openai-completions",
models: [
{
id: OPENROUTER_DEFAULT_MODEL_ID,
name: "OpenRouter Auto",
reasoning: false,
input: ["text", "image"],
cost: OPENROUTER_DEFAULT_COST,
contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW,
maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS,
},
{
id: "openrouter/hunter-alpha",
name: "Hunter Alpha",
reasoning: true,
input: ["text"],
cost: OPENROUTER_DEFAULT_COST,
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "openrouter/healer-alpha",
name: "Healer Alpha",
reasoning: true,
input: ["text", "image"],
cost: OPENROUTER_DEFAULT_COST,
contextWindow: 262144,
maxTokens: 65536,
},
],
};
}

View file

@ -0,0 +1,26 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
export function createOpenrouterProvider(): ProviderPlugin {
return {
id: "openrouter",
label: "OpenRouter",
docsPath: "/providers/models",
envVars: ["OPENROUTER_API_KEY"],
auth: [
{
id: "api-key",
kind: "api_key",
label: "OpenRouter API key",
hint: "API key",
run: async () => ({ profiles: [] }),
wizard: {
choiceId: "openrouter-api-key",
choiceLabel: "OpenRouter API key",
groupId: "openrouter",
groupLabel: "OpenRouter",
groupHint: "API key",
},
},
],
};
}

View file

@ -0,0 +1,32 @@
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import {
buildProviderReplayFamilyHooks,
DEFAULT_CONTEXT_TOKENS,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
buildProviderStreamFamilyHooks,
createOpenRouterSystemCacheWrapper,
createOpenRouterWrapper,
getOpenRouterModelCapabilities,
isProxyReasoningUnsupported,
loadOpenRouterModelCapabilities,
} from "openclaw/plugin-sdk/provider-stream-family";
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildOpenrouterProvider } from "./provider-catalog.js";
export {
applyOpenrouterConfig,
buildOpenrouterProvider,
buildProviderReplayFamilyHooks,
buildProviderStreamFamilyHooks,
createOpenRouterSystemCacheWrapper,
createOpenRouterWrapper,
createProviderApiKeyAuthMethod,
DEFAULT_CONTEXT_TOKENS,
getOpenRouterModelCapabilities,
isProxyReasoningUnsupported,
loadOpenRouterModelCapabilities,
OPENROUTER_DEFAULT_MODEL_REF,
openrouterMediaUnderstandingProvider,
};

View file

@ -0,0 +1,50 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { OPENROUTER_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family";
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 ${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

@ -0,0 +1 @@
export { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";

View file

@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}