mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
test(plugins,net-policy): remove low-value tests (batch d046) (#159181)
* test(plugins): deslop s286 tests * test(plugins): preserve Responses transport coverage * test(kimi): deslop s292 tests * test(llm-task): deslop s297 tests * test(anthropic-vertex): deslop s289 tests * test(zai): deslop s291 tests * test(exa): deslop s300 tests * test(runway): deslop s299 tests * test(arcee): deslop s298 tests * test(net-policy): deslop s295 tests * test(plugins): retain catalog and public export coverage
This commit is contained in:
parent
a3dcaa1a68
commit
2f844f3e6d
27 changed files with 1197 additions and 3407 deletions
|
|
@ -1,13 +1,8 @@
|
|||
// Anthropic Vertex tests cover api plugin behavior.
|
||||
import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
|
||||
|
||||
function createStreamDeps(): {
|
||||
deps: AnthropicVertexStreamDeps;
|
||||
streamAnthropicMock: ReturnType<typeof vi.fn>;
|
||||
anthropicVertexCtorMock: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
function createStreamDeps() {
|
||||
const streamAnthropicMock = vi.fn(
|
||||
(..._args: Parameters<AnthropicVertexStreamDeps["streamAnthropic"]>) =>
|
||||
createAssistantMessageEventStream(),
|
||||
|
|
@ -48,33 +43,30 @@ describe("Anthropic Vertex API stream factories", () => {
|
|||
await import("./api.js"));
|
||||
});
|
||||
|
||||
it("reuses the runtime stream factory across direct stream calls", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "direct",
|
||||
create: (deps: AnthropicVertexStreamDeps) =>
|
||||
createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps),
|
||||
},
|
||||
{
|
||||
name: "model-derived",
|
||||
create: (deps: AnthropicVertexStreamDeps) =>
|
||||
createAnthropicVertexStreamFnForModel(
|
||||
makeModel(),
|
||||
{
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: "vertex-project",
|
||||
GOOGLE_CLOUD_LOCATION: "us-east5",
|
||||
},
|
||||
deps,
|
||||
),
|
||||
},
|
||||
])("reuses the runtime stream factory across $name calls", async ({ create }) => {
|
||||
const { deps, streamAnthropicMock, anthropicVertexCtorMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const streamFn = create(deps);
|
||||
const model = makeModel();
|
||||
|
||||
await streamFn(model, { messages: [] }, {});
|
||||
await streamFn(model, { messages: [] }, {});
|
||||
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
|
||||
expect(streamAnthropicMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reuses the runtime stream factory across model-derived stream calls", async () => {
|
||||
const { deps, streamAnthropicMock, anthropicVertexCtorMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(
|
||||
makeModel(),
|
||||
{
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: "vertex-project",
|
||||
GOOGLE_CLOUD_LOCATION: "us-east5",
|
||||
} as NodeJS.ProcessEnv,
|
||||
deps,
|
||||
);
|
||||
const model = makeModel();
|
||||
|
||||
await streamFn(model, { messages: [] }, {});
|
||||
await streamFn(model, { messages: [] }, {});
|
||||
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
|
||||
expect(streamAnthropicMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Anthropic Vertex tests cover index plugin behavior.
|
||||
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
|
@ -17,6 +17,25 @@ vi.mock("./region.js", async (importOriginal) => {
|
|||
import anthropicVertexPlugin from "./index.js";
|
||||
import { buildAnthropicVertexProvider } from "./provider-catalog.js";
|
||||
|
||||
function staleModel(
|
||||
id: string,
|
||||
overrides: Partial<ProviderRuntimeModel> = {},
|
||||
): ProviderRuntimeModel {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic-vertex",
|
||||
baseUrl: "https://aiplatform.googleapis.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8192,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("anthropic-vertex provider plugin", () => {
|
||||
beforeEach(() => {
|
||||
hasAnthropicVertexAvailableAuthMock.mockReturnValue(true);
|
||||
|
|
@ -78,35 +97,21 @@ describe("anthropic-vertex provider plugin", () => {
|
|||
expect(result.provider.apiKey).toBe("gcp-vertex-credentials");
|
||||
expect(result.provider.baseUrl).toBe("https://us-east5-aiplatform.googleapis.com");
|
||||
expect(result.provider.headers).toBeUndefined();
|
||||
expect(result.provider.models.map((model) => model.id)).toEqual([
|
||||
"claude-fable-5",
|
||||
"claude-mythos-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
expect(
|
||||
result.provider.models.map(({ id, thinkingLevelMap }) => [id, thinkingLevelMap]),
|
||||
).toEqual([
|
||||
["claude-fable-5", { off: "low", minimal: "low", xhigh: "xhigh", max: "max" }],
|
||||
["claude-mythos-5", { off: "low", minimal: "low", xhigh: "xhigh", max: "max" }],
|
||||
["claude-opus-4-8", { xhigh: "xhigh", max: "max" }],
|
||||
["claude-opus-4-6", { xhigh: null, max: "max" }],
|
||||
["claude-sonnet-4-6", { xhigh: null, max: "max" }],
|
||||
]);
|
||||
expect(result.provider.models[0]?.thinkingLevelMap).toEqual({
|
||||
off: "low",
|
||||
minimal: "low",
|
||||
xhigh: "xhigh",
|
||||
max: "max",
|
||||
});
|
||||
expect(result.provider.models[1]?.thinkingLevelMap).toEqual({
|
||||
off: "low",
|
||||
minimal: "low",
|
||||
xhigh: "xhigh",
|
||||
max: "max",
|
||||
});
|
||||
expect(result.provider.models[2]?.thinkingLevelMap).toEqual({ xhigh: "xhigh", max: "max" });
|
||||
expect(result.provider.models[3]?.thinkingLevelMap).toEqual({ xhigh: null, max: "max" });
|
||||
expect(result.provider.models[4]?.thinkingLevelMap).toEqual({ xhigh: null, max: "max" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ region: "global", baseUrl: "https://aiplatform.googleapis.com" },
|
||||
{ region: "us", baseUrl: "https://aiplatform.us.rep.googleapis.com" },
|
||||
{ region: "eu", baseUrl: "https://aiplatform.eu.rep.googleapis.com" },
|
||||
{ region: "us-east5", baseUrl: "https://us-east5-aiplatform.googleapis.com" },
|
||||
])("publishes the SDK endpoint for the $region location", ({ region, baseUrl }) => {
|
||||
expect(
|
||||
buildAnthropicVertexProvider({
|
||||
|
|
@ -115,18 +120,6 @@ describe("anthropic-vertex provider plugin", () => {
|
|||
).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it.each(["global", "us", "eu"])("publishes Opus 5 for the %s endpoint", (region) => {
|
||||
const provider = buildAnthropicVertexProvider({
|
||||
env: { GOOGLE_CLOUD_LOCATION: region },
|
||||
});
|
||||
|
||||
expect(provider.models.find((model) => model.id === "claude-opus-5")).toMatchObject({
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
region: "global",
|
||||
|
|
@ -136,10 +129,6 @@ describe("anthropic-vertex provider plugin", () => {
|
|||
region: "us",
|
||||
cost: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
|
||||
},
|
||||
{
|
||||
region: "eu",
|
||||
cost: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
|
||||
},
|
||||
])("uses the documented Opus 5 pricing for $region", ({ region, cost }) => {
|
||||
const provider = buildAnthropicVertexProvider({
|
||||
env: { GOOGLE_CLOUD_LOCATION: region },
|
||||
|
|
@ -166,113 +155,69 @@ describe("anthropic-vertex provider plugin", () => {
|
|||
cost: { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 },
|
||||
retiredCost: { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 },
|
||||
},
|
||||
{
|
||||
region: "eu",
|
||||
baseUrl: "https://aiplatform.eu.rep.googleapis.com",
|
||||
cost: { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 },
|
||||
retiredCost: { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 },
|
||||
},
|
||||
])("Sonnet 5 pricing for $region", ({ region, baseUrl, cost, retiredCost }) => {
|
||||
describe.each([
|
||||
{ boundary: "before", nowMs: Date.UTC(2026, 8, 1) - 1 },
|
||||
{ boundary: "at", nowMs: Date.UTC(2026, 8, 1) },
|
||||
{ boundary: "after", nowMs: Date.UTC(2026, 8, 1) + 1 },
|
||||
])("$boundary the retired September 1 boundary", ({ nowMs }) => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(nowMs);
|
||||
});
|
||||
const nowMs = Date.UTC(2026, 8, 1);
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(nowMs);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("publishes current pricing with or without the shipped nowMs argument", () => {
|
||||
const env = { GOOGLE_CLOUD_LOCATION: region };
|
||||
const providers = [
|
||||
buildAnthropicVertexProvider({ env }),
|
||||
buildAnthropicVertexProvider({ env, nowMs }),
|
||||
];
|
||||
for (const provider of providers) {
|
||||
expect(provider.models.find((model) => model.id === "claude-sonnet-5")).toMatchObject({
|
||||
cost,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("repairs missing or retired pricing and leaves current pricing unchanged", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
const model = {
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic-vertex",
|
||||
baseUrl,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
it("publishes current pricing with or without the shipped nowMs argument", () => {
|
||||
const env = { GOOGLE_CLOUD_LOCATION: region };
|
||||
const providers = [
|
||||
buildAnthropicVertexProvider({ env }),
|
||||
buildAnthropicVertexProvider({ env, nowMs }),
|
||||
];
|
||||
for (const provider of providers) {
|
||||
expect(provider.models.find((model) => model.id === "claude-sonnet-5")).toMatchObject({
|
||||
cost,
|
||||
contextWindow: 1_000_000,
|
||||
contextTokens: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
||||
};
|
||||
for (const staleCost of [undefined, retiredCost]) {
|
||||
const normalized = provider.normalizeResolvedModel?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: model.id,
|
||||
model: { ...model, cost: staleCost },
|
||||
} as never);
|
||||
expect(normalized?.cost).toEqual(cost);
|
||||
}
|
||||
expect(
|
||||
provider.normalizeResolvedModel?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: model.id,
|
||||
model: { ...model, cost },
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("restores missing or stale Opus 5 metadata during runtime normalization", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
const normalized = provider.normalizeResolvedModel?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: "prod-opus",
|
||||
model: {
|
||||
id: "prod-opus",
|
||||
name: "Claude Opus 5",
|
||||
it("repairs missing or retired pricing and leaves current pricing unchanged", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
const model = {
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic-vertex",
|
||||
baseUrl: "https://aiplatform.us.rep.googleapis.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 200_000,
|
||||
contextTokens: 200_000,
|
||||
maxTokens: 64_000,
|
||||
params: { canonicalModelId: "claude-opus-5" },
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
|
||||
contextWindow: 1_000_000,
|
||||
contextTokens: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
||||
baseUrl,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_000_000,
|
||||
contextTokens: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
||||
};
|
||||
for (const staleCost of [undefined, retiredCost]) {
|
||||
const normalized = provider.normalizeResolvedModel?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: model.id,
|
||||
model: { ...model, cost: staleCost },
|
||||
} as never);
|
||||
expect(normalized?.cost).toEqual(cost);
|
||||
}
|
||||
expect(
|
||||
provider.normalizeResolvedModel?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: model.id,
|
||||
model: { ...model, cost },
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude-sonnet-4-6", false],
|
||||
["claude-fable-5-1@20260801", true],
|
||||
["claude-mythos-5-1@20260801", false],
|
||||
])(
|
||||
"owns Anthropic-style replay policy for Vertex %s",
|
||||
async (modelId, appendOnlyRuntimeContext) => {
|
||||
|
|
@ -305,193 +250,98 @@ describe("anthropic-vertex provider plugin", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("owns Anthropic-style thinking policy", async () => {
|
||||
it("registers the shared thinking policy with canonical alias support", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
|
||||
const opus5Profile = provider.resolveThinkingProfile?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: "claude-opus-5",
|
||||
} as never);
|
||||
|
||||
expect(opus5Profile?.defaultLevel).toBe("high");
|
||||
expect(opus5Profile?.levels.map((level) => level.id)).toContain("max");
|
||||
|
||||
const opus48Profile = provider.resolveThinkingProfile?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: "claude-opus-4-8",
|
||||
} as never);
|
||||
|
||||
expect(opus48Profile?.defaultLevel).toBe("off");
|
||||
expect(opus48Profile?.levels.map((level) => level.id)).toContain("max");
|
||||
|
||||
const fableProfile = provider.resolveThinkingProfile?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: "claude-fable-5",
|
||||
} as never);
|
||||
expect(fableProfile?.defaultLevel).toBe("medium");
|
||||
expect(fableProfile?.preserveWhenCatalogReasoningFalse).toBe(true);
|
||||
|
||||
const aliasProfile = provider.resolveThinkingProfile?.({
|
||||
const profile = provider.resolveThinkingProfile?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: "production-claude",
|
||||
params: { canonicalModelId: "claude-fable-5" },
|
||||
} as never);
|
||||
expect(aliasProfile?.defaultLevel).toBe("medium");
|
||||
});
|
||||
expect(profile).toMatchObject({
|
||||
defaultLevel: "medium",
|
||||
preserveWhenCatalogReasoningFalse: true,
|
||||
});
|
||||
expect(profile?.levels.map((level) => level.id)).toContain("max");
|
||||
});
|
||||
|
||||
it("restores Fable metadata for explicit Vertex catalog rows", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
|
||||
const normalized = provider.normalizeResolvedModel?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: "claude-fable-5",
|
||||
model: {
|
||||
id: "claude-fable-5",
|
||||
name: "Claude Fable 5",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic-vertex",
|
||||
baseUrl: "https://aiplatform.googleapis.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_000_000,
|
||||
contextTokens: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
thinkingLevelMap: {
|
||||
off: "low",
|
||||
minimal: "low",
|
||||
xhigh: "xhigh",
|
||||
max: "max",
|
||||
},
|
||||
});
|
||||
|
||||
const aliasNormalized = provider.normalizeResolvedModel?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: "production-claude",
|
||||
model: {
|
||||
id: "production-claude",
|
||||
name: "Production Claude",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic-vertex",
|
||||
baseUrl: "https://aiplatform.googleapis.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8192,
|
||||
it.each([
|
||||
{
|
||||
id: "claude-fable-5",
|
||||
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
|
||||
},
|
||||
{
|
||||
id: "claude-mythos-5",
|
||||
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
|
||||
},
|
||||
{
|
||||
id: "claude-opus-5",
|
||||
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
||||
overrides: { cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 } },
|
||||
},
|
||||
{
|
||||
id: "production-claude",
|
||||
overrides: {
|
||||
params: { canonicalModelId: "claude-fable-5" },
|
||||
thinkingLevelMap: { max: null },
|
||||
},
|
||||
} as never);
|
||||
expect(aliasNormalized).toMatchObject({
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("restores Opus 5 metadata for explicit Vertex catalog rows", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
|
||||
const normalized = provider.normalizeResolvedModel?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: "claude-opus-5",
|
||||
model: {
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic-vertex",
|
||||
baseUrl: "https://aiplatform.googleapis.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
},
|
||||
{
|
||||
id: "prod-opus",
|
||||
overrides: {
|
||||
baseUrl: "https://aiplatform.us.rep.googleapis.com",
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8192,
|
||||
contextTokens: 200_000,
|
||||
maxTokens: 64_000,
|
||||
params: { canonicalModelId: "claude-opus-5" },
|
||||
},
|
||||
} as never);
|
||||
|
||||
// Opus 5 keeps thinking disableable, so off/minimal must not be remapped.
|
||||
expect(normalized).toMatchObject({
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_000_000,
|
||||
contextTokens: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
cost: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
|
||||
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
||||
});
|
||||
expect(normalized?.thinkingLevelMap).not.toHaveProperty("off");
|
||||
});
|
||||
|
||||
it("restores Mythos 5 metadata for explicit Vertex catalog rows", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
const normalized = provider.normalizeResolvedModel?.({
|
||||
provider: "anthropic-vertex",
|
||||
modelId: "claude-mythos-5",
|
||||
model: {
|
||||
id: "claude-mythos-5",
|
||||
name: "Claude Mythos 5",
|
||||
api: "anthropic-messages",
|
||||
},
|
||||
])(
|
||||
"restores metadata for $id without overriding authored thinking levels",
|
||||
async ({ id, overrides, thinkingLevelMap, cost }) => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
const normalized = provider.normalizeResolvedModel?.({
|
||||
provider: "anthropic-vertex",
|
||||
baseUrl: "https://aiplatform.googleapis.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8192,
|
||||
modelId: id,
|
||||
model: staleModel(id, overrides),
|
||||
});
|
||||
expect(normalized).toMatchObject({
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_000_000,
|
||||
contextTokens: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
...(cost ? { cost } : {}),
|
||||
});
|
||||
// Exact maps also guard Opus's absence of mandatory off/minimal remapping.
|
||||
expect(normalized?.thinkingLevelMap).toEqual(thinkingLevelMap);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
available: true,
|
||||
expected: {
|
||||
apiKey: "gcp-vertex-credentials",
|
||||
source: "gcp-vertex-credentials (ADC)",
|
||||
mode: "api-key",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_000_000,
|
||||
contextTokens: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
thinkingLevelMap: {
|
||||
off: "low",
|
||||
minimal: "low",
|
||||
xhigh: "xhigh",
|
||||
max: "max",
|
||||
},
|
||||
});
|
||||
});
|
||||
it("resolves synthetic auth when ADC is available", async () => {
|
||||
hasAnthropicVertexAvailableAuthMock.mockReturnValue(true);
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
|
||||
const result = provider.resolveSyntheticAuth?.({
|
||||
provider: "anthropic-vertex",
|
||||
config: undefined,
|
||||
providerConfig: undefined,
|
||||
} as never);
|
||||
|
||||
expect(result).toEqual({
|
||||
apiKey: "gcp-vertex-credentials",
|
||||
source: "gcp-vertex-credentials (ADC)",
|
||||
mode: "api-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined when ADC is not available", async () => {
|
||||
hasAnthropicVertexAvailableAuthMock.mockReturnValue(false);
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
|
||||
const result = provider.resolveSyntheticAuth?.({
|
||||
provider: "anthropic-vertex",
|
||||
config: undefined,
|
||||
providerConfig: undefined,
|
||||
} as never);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
},
|
||||
{ available: false, expected: undefined },
|
||||
])(
|
||||
"resolves synthetic auth only when ADC is available=$available",
|
||||
async ({ available, expected }) => {
|
||||
hasAnthropicVertexAvailableAuthMock.mockReturnValue(available);
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
expect(
|
||||
provider.resolveSyntheticAuth?.({
|
||||
provider: "anthropic-vertex",
|
||||
config: undefined,
|
||||
providerConfig: undefined,
|
||||
} as never),
|
||||
).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Anthropic Vertex tests cover region.adc plugin behavior.
|
||||
import { platform } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -49,9 +48,6 @@ describe("anthropic-vertex ADC reads", () => {
|
|||
GOOGLE_APPLICATION_CREDENTIALS: "/tmp/vertex-adc.json",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
existsSyncMock.mockClear();
|
||||
readFileSyncMock.mockClear();
|
||||
|
||||
expect(resolveAnthropicVertexProjectId(env)).toBe("vertex-project");
|
||||
expect(hasAnthropicVertexAvailableAuth(env)).toBe(true);
|
||||
expect(existsSyncMock).not.toHaveBeenCalled();
|
||||
|
|
@ -68,13 +64,12 @@ describe("anthropic-vertex ADC reads", () => {
|
|||
HOME: homeDir,
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
readFileSyncMock.mockImplementation((pathname, options) =>
|
||||
String(pathname) === defaultAdcPath
|
||||
? '{"project_id":"vertex-project"}'
|
||||
: (() => {
|
||||
throw new Error(`unexpected readFileSync(${String(pathname)}, ${String(options)})`);
|
||||
})(),
|
||||
);
|
||||
readFileSyncMock.mockImplementation((pathname) => {
|
||||
if (String(pathname) !== defaultAdcPath) {
|
||||
throw new Error(`unexpected ADC fixture path: ${String(pathname)}`);
|
||||
}
|
||||
return '{"project_id":"vertex-project"}';
|
||||
});
|
||||
|
||||
expect(resolveAnthropicVertexProjectId(env)).toBe("vertex-project");
|
||||
expect(hasAnthropicVertexAvailableAuth(env)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -1,45 +1,10 @@
|
|||
// Anthropic Vertex tests cover region plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAnthropicVertexRegion, resolveAnthropicVertexRegionFromBaseUrl } from "./api.js";
|
||||
import { resolveAnthropicVertexRegion } from "./api.js";
|
||||
|
||||
describe("anthropic vertex region helpers", () => {
|
||||
it("accepts well-formed regional env values", () => {
|
||||
expect(
|
||||
resolveAnthropicVertexRegion({
|
||||
GOOGLE_CLOUD_LOCATION: "us-east1",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("us-east1");
|
||||
});
|
||||
|
||||
it("falls back to the default region for malformed env values", () => {
|
||||
expect(
|
||||
resolveAnthropicVertexRegion({
|
||||
GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example",
|
||||
} as NodeJS.ProcessEnv),
|
||||
resolveAnthropicVertexRegion({ GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example" }),
|
||||
).toBe("global");
|
||||
});
|
||||
|
||||
it("parses regional Vertex endpoints", () => {
|
||||
expect(
|
||||
resolveAnthropicVertexRegionFromBaseUrl("https://europe-west4-aiplatform.googleapis.com"),
|
||||
).toBe("europe-west4");
|
||||
});
|
||||
|
||||
it.each(["us", "eu"])("parses the %s multi-region Vertex endpoint", (region) => {
|
||||
expect(
|
||||
resolveAnthropicVertexRegionFromBaseUrl(`https://aiplatform.${region}.rep.googleapis.com`),
|
||||
).toBe(region);
|
||||
});
|
||||
|
||||
it("treats the global Vertex endpoint as global", () => {
|
||||
expect(resolveAnthropicVertexRegionFromBaseUrl("https://aiplatform.googleapis.com")).toBe(
|
||||
"global",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not infer a Vertex region from custom proxy hosts", () => {
|
||||
expect(
|
||||
resolveAnthropicVertexRegionFromBaseUrl("https://proxy.example.com/google/aiplatform"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Anthropic Vertex tests cover stream runtime plugin behavior.
|
||||
import { once } from "node:events";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
|
|
@ -16,13 +15,7 @@ import {
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
|
||||
|
||||
function createStreamDeps(): {
|
||||
deps: AnthropicVertexStreamDeps;
|
||||
streamAnthropicMock: ReturnType<typeof vi.fn>;
|
||||
anthropicVertexCtorMock: ReturnType<typeof vi.fn>;
|
||||
googleAuthCtorMock: ReturnType<typeof vi.fn>;
|
||||
googleAuthClient: InstanceType<AnthropicVertexStreamDeps["GoogleAuth"]>;
|
||||
} {
|
||||
function createStreamDeps() {
|
||||
const streamAnthropicMock = vi.fn(
|
||||
(..._args: Parameters<AnthropicVertexStreamDeps["streamAnthropic"]>) =>
|
||||
createAssistantMessageEventStream(),
|
||||
|
|
@ -74,43 +67,16 @@ function makeModel(params: {
|
|||
|
||||
type PayloadHook = (payload: unknown, payloadModel: unknown) => Promise<unknown>;
|
||||
|
||||
function streamAnthropicCall(streamAnthropicMock: ReturnType<typeof vi.fn>): unknown[] {
|
||||
const call = streamAnthropicMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("Expected streamAnthropic call");
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function streamTransportOptions(
|
||||
streamAnthropicMock: ReturnType<typeof vi.fn>,
|
||||
): Record<string, unknown> {
|
||||
const options = streamAnthropicCall(streamAnthropicMock)[2];
|
||||
const options = streamAnthropicMock.mock.calls[0]?.[2];
|
||||
if (!options || typeof options !== "object") {
|
||||
throw new Error("Expected streamAnthropic transport options");
|
||||
}
|
||||
return options as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function captureTransportPayloadHook(
|
||||
onPayload: PayloadHook | undefined,
|
||||
deps: AnthropicVertexStreamDeps,
|
||||
streamAnthropicMock: ReturnType<typeof vi.fn>,
|
||||
) {
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 });
|
||||
|
||||
void streamFn(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "Hello" }] } as never,
|
||||
{ cacheRetention: "short", ...(onPayload ? { onPayload } : {}) } as never,
|
||||
);
|
||||
|
||||
const transportOptions = streamTransportOptions(streamAnthropicMock);
|
||||
|
||||
return { model, onPayload: transportOptions.onPayload as PayloadHook | undefined };
|
||||
}
|
||||
|
||||
// Mirrors the shared anthropic-messages transport output: cache boundary already
|
||||
// split (uncached dynamic suffix) and all four cache_control markers allocated.
|
||||
function buildBudgetedTransportPayload() {
|
||||
|
|
@ -143,27 +109,46 @@ function buildBudgetedTransportPayload() {
|
|||
};
|
||||
}
|
||||
|
||||
function countCacheControlMarkers(payload: unknown): number {
|
||||
let count = 0;
|
||||
const visit = (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.cache_control !== undefined) {
|
||||
count += 1;
|
||||
}
|
||||
visit(record.content);
|
||||
function captureOptions(
|
||||
model: Model<"anthropic-messages">,
|
||||
options: Parameters<ReturnType<typeof createAnthropicVertexStreamFn>>[2] = {},
|
||||
) {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
void streamFn(model, { messages: [] }, options);
|
||||
return streamTransportOptions(streamAnthropicMock);
|
||||
}
|
||||
|
||||
async function capturePayload(
|
||||
modelOptions: Parameters<typeof makeModel>[0],
|
||||
options: Parameters<ReturnType<typeof createAnthropicVertexStreamFn>>[2],
|
||||
) {
|
||||
const { deps } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn(
|
||||
"vertex-project",
|
||||
"us-east5",
|
||||
undefined,
|
||||
{ ...deps, streamAnthropic: streamModel },
|
||||
{},
|
||||
);
|
||||
const onPayload = vi.fn((_payload: unknown) => {
|
||||
throw new Error("stop before network");
|
||||
});
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
...makeModel(modelOptions),
|
||||
name: modelOptions.id,
|
||||
input: ["text"],
|
||||
contextWindow: 1_000_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
};
|
||||
const record = payload as Record<string, unknown>;
|
||||
visit(record.system);
|
||||
visit(record.tools);
|
||||
visit(record.messages);
|
||||
return count;
|
||||
const stream = await streamFn(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
|
||||
{ ...options, onPayload },
|
||||
);
|
||||
const result = await stream.result();
|
||||
expect(onPayload, result.errorMessage).toHaveBeenCalledOnce();
|
||||
return onPayload.mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
describe("createAnthropicVertexStreamFn", () => {
|
||||
|
|
@ -182,9 +167,7 @@ describe("createAnthropicVertexStreamFn", () => {
|
|||
|
||||
it("omits projectId when ADC credentials are used without an explicit project", () => {
|
||||
const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn(undefined, "global", undefined, deps);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
|
||||
createAnthropicVertexStreamFn(undefined, "global", undefined, deps);
|
||||
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
googleAuth: googleAuthClient,
|
||||
|
|
@ -214,17 +197,13 @@ describe("createAnthropicVertexStreamFn", () => {
|
|||
});
|
||||
|
||||
writeFileSync(credentialsPath, `${json}${" ".repeat(1024 * 1024 + 1 - json.length)}`);
|
||||
let readError: unknown;
|
||||
try {
|
||||
createAnthropicVertexStreamFnForModel({}, env, deps);
|
||||
} catch (error) {
|
||||
readError = error;
|
||||
}
|
||||
expect(readError).toMatchObject({
|
||||
name: "FsSafeError",
|
||||
code: "too-large",
|
||||
message: `Anthropic Vertex ADC credentials file at ${credentialsPath} exceeds 1048576 bytes.`,
|
||||
});
|
||||
expect(() => createAnthropicVertexStreamFnForModel({}, env, deps)).toThrowError(
|
||||
expect.objectContaining({
|
||||
name: "FsSafeError",
|
||||
code: "too-large",
|
||||
message: `Anthropic Vertex ADC credentials file at ${credentialsPath} exceeds 1048576 bytes.`,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -296,25 +275,6 @@ describe("createAnthropicVertexStreamFn", () => {
|
|||
expect(Object.getOwnPropertyDescriptor(globalThis, "window")).toEqual(windowDescriptor);
|
||||
});
|
||||
|
||||
it("passes an explicit baseURL through to the Vertex client", () => {
|
||||
const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn(
|
||||
"vertex-project",
|
||||
"us-east5",
|
||||
"https://proxy.example.test/vertex/v1",
|
||||
deps,
|
||||
);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
|
||||
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
googleAuth: googleAuthClient,
|
||||
projectId: "vertex-project",
|
||||
region: "us-east5",
|
||||
baseURL: "https://proxy.example.test/vertex/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the canonical API before calling the shared Anthropic transport", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
|
|
@ -325,60 +285,42 @@ describe("createAnthropicVertexStreamFn", () => {
|
|||
|
||||
void streamFn(model as never, { messages: [] }, {});
|
||||
|
||||
expect(streamAnthropicCall(streamAnthropicMock)[0]).toMatchObject({
|
||||
expect(streamAnthropicMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic-vertex",
|
||||
id: "claude-fable-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults maxTokens to the model limit instead of the old 32000 cap", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-opus-4-6", maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, {});
|
||||
|
||||
expect(streamTransportOptions(streamAnthropicMock).maxTokens).toBe(128000);
|
||||
it.each([
|
||||
["defaults to the model limit instead of the old 32000 cap", 128000, undefined, 128000],
|
||||
["clamps requests to the model limit", 128000, 999999, 128000],
|
||||
["omits nonfinite limits", undefined, Number.NaN, undefined],
|
||||
] as const)("maxTokens %s", (_name, limit, requested, expected) => {
|
||||
const options = captureOptions(makeModel({ id: "claude-sonnet-4-6", maxTokens: limit }), {
|
||||
maxTokens: requested,
|
||||
});
|
||||
if (expected === undefined) {
|
||||
expect(options).not.toHaveProperty("maxTokens");
|
||||
} else {
|
||||
expect(options.maxTokens).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps explicit maxTokens to the selected model limit", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { maxTokens: 999999 });
|
||||
|
||||
expect(streamTransportOptions(streamAnthropicMock).maxTokens).toBe(128000);
|
||||
it("omits unsupported temperature without adaptive thinking", () => {
|
||||
const options = captureOptions(makeModel({ id: "claude-opus-4-8" }), { temperature: 0.7 });
|
||||
expect(options).not.toHaveProperty("temperature");
|
||||
});
|
||||
|
||||
it.each(["claude-opus-4-8", "claude-opus-4-7", "claude-fable-5", "claude-mythos-5"])(
|
||||
"omits unsupported temperature for %s",
|
||||
(modelId) => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: modelId, maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { temperature: 0.7 });
|
||||
|
||||
const transportOptions = streamTransportOptions(streamAnthropicMock);
|
||||
expect(Object.hasOwn(transportOptions, "temperature")).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves temperature for Vertex models that support custom sampling", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { temperature: 0.7 });
|
||||
const transportOptions = captureOptions(model, { temperature: 0.7 });
|
||||
|
||||
expect(streamTransportOptions(streamAnthropicMock).temperature).toBe(0.7);
|
||||
expect(transportOptions.temperature).toBe(0.7);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ id: "claude-fable-5", effort: "medium" },
|
||||
{ id: "claude-fable-5-1", effort: "medium" },
|
||||
{
|
||||
id: "production-fable",
|
||||
params: { canonicalModelId: "claude-fable-5-1" },
|
||||
|
|
@ -387,33 +329,10 @@ describe("createAnthropicVertexStreamFn", () => {
|
|||
},
|
||||
{ id: "claude-mythos-5", effort: "high" },
|
||||
])("sends the shared Vertex default for $id", async ({ effort, ...modelOptions }) => {
|
||||
const { deps } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn(
|
||||
"vertex-project",
|
||||
"us-east5",
|
||||
undefined,
|
||||
{ ...deps, streamAnthropic: streamModel },
|
||||
{},
|
||||
const payload = await capturePayload(
|
||||
{ ...modelOptions, maxTokens: 128000 },
|
||||
{ temperature: 0.7 },
|
||||
);
|
||||
const onPayload = vi.fn((_payload: unknown) => {
|
||||
throw new Error("stop before network");
|
||||
});
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
...makeModel({ ...modelOptions, maxTokens: 128000 }),
|
||||
name: modelOptions.id,
|
||||
input: ["text"],
|
||||
contextWindow: 1_000_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
};
|
||||
const stream = await streamFn(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
|
||||
{ temperature: 0.7, onPayload },
|
||||
);
|
||||
const result = await stream.result();
|
||||
|
||||
expect(onPayload, result.errorMessage).toHaveBeenCalledOnce();
|
||||
const payload = onPayload.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({
|
||||
thinking: { type: "adaptive" },
|
||||
output_config: { effort },
|
||||
|
|
@ -428,13 +347,9 @@ describe("createAnthropicVertexStreamFn", () => {
|
|||
])(
|
||||
"supports Sonnet 5 reasoning=$reasoning on Vertex",
|
||||
({ reasoning, thinkingEnabled, effort }) => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-sonnet-5", maxTokens: 128_000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning, temperature: 0.7 });
|
||||
|
||||
const options = streamTransportOptions(streamAnthropicMock);
|
||||
const options = captureOptions(model, { reasoning, temperature: 0.7 });
|
||||
expect(options).toMatchObject({ thinkingEnabled, maxTokens: 128_000 });
|
||||
expect(options).not.toHaveProperty("temperature");
|
||||
if (effort) {
|
||||
|
|
@ -446,166 +361,62 @@ describe("createAnthropicVertexStreamFn", () => {
|
|||
);
|
||||
|
||||
it("uses canonical Claude policy for Vertex deployment aliases", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({
|
||||
id: "production-claude",
|
||||
maxTokens: 128000,
|
||||
params: { canonicalModelId: "claude-opus-4-8" },
|
||||
});
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning: "xhigh", temperature: 0.7 });
|
||||
const transportOptions = captureOptions(model, { reasoning: "xhigh", temperature: 0.7 });
|
||||
|
||||
expect(streamTransportOptions(streamAnthropicMock)).toMatchObject({
|
||||
expect(transportOptions).toMatchObject({
|
||||
thinkingEnabled: true,
|
||||
effort: "xhigh",
|
||||
});
|
||||
expect(streamTransportOptions(streamAnthropicMock)).not.toHaveProperty("temperature");
|
||||
});
|
||||
|
||||
it("preserves Fable 5 low effort on Vertex", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-fable-5", maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning: "low" });
|
||||
|
||||
expect(streamTransportOptions(streamAnthropicMock)).toMatchObject({
|
||||
thinkingEnabled: true,
|
||||
effort: "low",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves Fable 5 xhigh effort on Vertex", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-fable-5", maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
|
||||
|
||||
expect(streamTransportOptions(streamAnthropicMock)).toMatchObject({
|
||||
thinkingEnabled: true,
|
||||
effort: "xhigh",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps unsupported xhigh reasoning to high effort for Opus 4.6", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-opus-4-6", maxTokens: 64000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
|
||||
|
||||
const transportOptions = streamTransportOptions(streamAnthropicMock);
|
||||
expect(transportOptions.thinkingEnabled).toBe(true);
|
||||
expect(transportOptions.effort).toBe("high");
|
||||
});
|
||||
|
||||
it("maps xhigh reasoning to xhigh effort for Opus 4.8", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-opus-4-8", maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
|
||||
|
||||
const transportOptions = streamTransportOptions(streamAnthropicMock);
|
||||
expect(transportOptions.thinkingEnabled).toBe(true);
|
||||
expect(transportOptions.effort).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("preserves max reasoning for Opus 4.8", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-opus-4-8", maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning: "max" });
|
||||
|
||||
const transportOptions = streamTransportOptions(streamAnthropicMock);
|
||||
expect(transportOptions.thinkingEnabled).toBe(true);
|
||||
expect(transportOptions.effort).toBe("max");
|
||||
expect(transportOptions).not.toHaveProperty("temperature");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "low thinking with the model output limit",
|
||||
modelMaxTokens: 8192,
|
||||
options: { reasoning: "low" },
|
||||
thinking: { type: "enabled", budget_tokens: 2048 },
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
name: "high thinking fitted below the model output limit",
|
||||
modelMaxTokens: 8192,
|
||||
options: { reasoning: "high" },
|
||||
thinking: { type: "enabled", budget_tokens: 7168 },
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
name: "low thinking alongside an explicit visible-output cap",
|
||||
modelMaxTokens: 8192,
|
||||
options: { reasoning: "low", maxTokens: 1024 },
|
||||
thinking: { type: "enabled", budget_tokens: 2048 },
|
||||
maxTokens: 3072,
|
||||
},
|
||||
{
|
||||
name: "high thinking alongside an explicit visible-output cap",
|
||||
modelMaxTokens: 32768,
|
||||
options: { reasoning: "high", maxTokens: 1024 },
|
||||
thinking: { type: "enabled", budget_tokens: 16384 },
|
||||
maxTokens: 17408,
|
||||
},
|
||||
{
|
||||
name: "disabled sub-minimum thinking without inflating the output cap",
|
||||
modelMaxTokens: 8192,
|
||||
options: { reasoning: "low", maxTokens: 1024, thinkingBudgets: { low: 512 } },
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 1024,
|
||||
},
|
||||
] as const)("sends $name on Vertex", async ({ modelMaxTokens, options, thinking, maxTokens }) => {
|
||||
const { deps } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn(
|
||||
"vertex-project",
|
||||
"us-east5",
|
||||
undefined,
|
||||
{ ...deps, streamAnthropic: streamModel },
|
||||
{},
|
||||
);
|
||||
const onPayload = vi.fn((_payload: unknown) => {
|
||||
throw new Error("stop before network");
|
||||
});
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
...makeModel({ id: "claude-haiku-4-5", maxTokens: modelMaxTokens }),
|
||||
name: "Claude Haiku 4.5",
|
||||
input: ["text"],
|
||||
contextWindow: 200_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
};
|
||||
const stream = await streamFn(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
|
||||
{ ...options, onPayload },
|
||||
);
|
||||
const result = await stream.result();
|
||||
|
||||
expect(onPayload, result.errorMessage).toHaveBeenCalledOnce();
|
||||
expect(onPayload.mock.calls[0]?.[0]).toMatchObject({ thinking, max_tokens: maxTokens });
|
||||
] as const)("sends $name on Vertex", async ({ options, thinking, maxTokens }) => {
|
||||
const payload = await capturePayload({ id: "claude-haiku-4-5", maxTokens: 8192 }, options);
|
||||
expect(payload).toMatchObject({ thinking, max_tokens: maxTokens });
|
||||
});
|
||||
|
||||
it("preserves native max reasoning for Sonnet 4.6", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning: "max" });
|
||||
|
||||
const transportOptions = streamTransportOptions(streamAnthropicMock);
|
||||
expect(transportOptions.thinkingEnabled).toBe(true);
|
||||
expect(transportOptions.effort).toBe("max");
|
||||
it.each([
|
||||
["claude-opus-4-6", "xhigh", "high"],
|
||||
["claude-sonnet-4-6", "max", "max"],
|
||||
] as const)("maps %s %s reasoning to %s effort", (id, reasoning, effort) => {
|
||||
expect(captureOptions(makeModel({ id, maxTokens: 128000 }), { reasoning })).toMatchObject({
|
||||
thinkingEnabled: true,
|
||||
effort,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors explicit max opt-outs for Vertex aliases", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({
|
||||
id: "production-claude",
|
||||
params: { canonicalModelId: "claude-sonnet-4-6" },
|
||||
|
|
@ -613,145 +424,77 @@ describe("createAnthropicVertexStreamFn", () => {
|
|||
thinkingLevelMap: { xhigh: null, max: null },
|
||||
});
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning: "max", temperature: 0.2 });
|
||||
|
||||
const transportOptions = streamTransportOptions(streamAnthropicMock);
|
||||
const transportOptions = captureOptions(model, { reasoning: "max", temperature: 0.2 });
|
||||
expect(transportOptions.effort).toBe("high");
|
||||
expect(transportOptions).not.toHaveProperty("temperature");
|
||||
});
|
||||
|
||||
it("forwards the private acceptance observer to the shared Anthropic transport", async () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const acceptanceObserver = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
const options = withProviderAcceptanceObserver({ onResponse }, acceptanceObserver);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6" }), { messages: [] }, options);
|
||||
|
||||
const transportOptions = streamTransportOptions(streamAnthropicMock);
|
||||
const transportOptions = captureOptions(makeModel({ id: "claude-sonnet-4-6" }), options);
|
||||
expect(transportOptions.onResponse).toBe(onResponse);
|
||||
await notifyProviderStreamOpened({ options: transportOptions, cancelStream: vi.fn() });
|
||||
expect(acceptanceObserver).toHaveBeenCalledWith({ kind: "provider_stream_opened" });
|
||||
});
|
||||
|
||||
it("keeps already-budgeted cache_control markers intact when forwarding payload hooks", async () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const onPayload = vi.fn(async (payload: unknown) => payload);
|
||||
const { model, onPayload: transportPayloadHook } = captureTransportPayloadHook(
|
||||
onPayload,
|
||||
deps,
|
||||
streamAnthropicMock,
|
||||
);
|
||||
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 });
|
||||
const transportPayloadHook = captureOptions(model, { cacheRetention: "short", onPayload })
|
||||
.onPayload as PayloadHook | undefined;
|
||||
const payload = buildBudgetedTransportPayload();
|
||||
const expectedPayload = structuredClone(payload);
|
||||
|
||||
const nextPayload = await transportPayloadHook?.(payload, model);
|
||||
|
||||
expect(onPayload).toHaveBeenCalledWith(payload, model);
|
||||
expect(countCacheControlMarkers(nextPayload)).toBe(4);
|
||||
expect((nextPayload as ReturnType<typeof buildBudgetedTransportPayload>).system[1]).toEqual({
|
||||
type: "text",
|
||||
text: "Dynamic suffix",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the transport payload hook when the caller provides none", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const { onPayload: transportPayloadHook } = captureTransportPayloadHook(
|
||||
undefined,
|
||||
deps,
|
||||
streamAnthropicMock,
|
||||
);
|
||||
|
||||
expect(transportPayloadHook).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits maxTokens when neither the model nor request provide a finite limit", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-sonnet-4-6" });
|
||||
|
||||
void streamFn(model, { messages: [] }, { maxTokens: Number.NaN });
|
||||
|
||||
expect(streamAnthropicMock).toHaveBeenCalledTimes(1);
|
||||
const [calledModel, payload, transportOptions] = streamAnthropicCall(streamAnthropicMock);
|
||||
expect(calledModel).toBe(model);
|
||||
expect(payload).toEqual({ messages: [] });
|
||||
expect(transportOptions).toBeTypeOf("object");
|
||||
expect(Object.hasOwn(transportOptions as object, "maxTokens")).toBe(false);
|
||||
expect(nextPayload).toEqual(expectedPayload);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAnthropicVertexStreamFnForModel", () => {
|
||||
it.each(["us", "eu"])("preserves the %s multi-region SDK endpoint", (region) => {
|
||||
it.each([
|
||||
[
|
||||
"https://aiplatform.us.rep.googleapis.com",
|
||||
"us",
|
||||
"https://aiplatform.us.rep.googleapis.com/v1",
|
||||
],
|
||||
[
|
||||
"https://aiplatform.eu.rep.googleapis.com",
|
||||
"eu",
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1",
|
||||
],
|
||||
[
|
||||
"https://europe-west4-aiplatform.googleapis.com",
|
||||
"europe-west4",
|
||||
"https://europe-west4-aiplatform.googleapis.com/v1",
|
||||
],
|
||||
[
|
||||
"https://proxy.example.test/custom-root/v1",
|
||||
"global",
|
||||
"https://proxy.example.test/custom-root/v1",
|
||||
],
|
||||
[
|
||||
"https://proxy.example.test/custom-root",
|
||||
"global",
|
||||
"https://proxy.example.test/custom-root/v1",
|
||||
],
|
||||
])("derives the SDK region and versioned endpoint from %s", (baseUrl, region, expected) => {
|
||||
const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(
|
||||
{ baseUrl: `https://aiplatform.${region}.rep.googleapis.com` },
|
||||
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
|
||||
{ baseUrl },
|
||||
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" },
|
||||
deps,
|
||||
);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-5", maxTokens: 128_000 }), { messages: [] }, {});
|
||||
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
googleAuth: googleAuthClient,
|
||||
projectId: "vertex-project",
|
||||
region,
|
||||
baseURL: `https://aiplatform.${region}.rep.googleapis.com/v1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives project and region from the model and env", () => {
|
||||
const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(
|
||||
{ baseUrl: "https://europe-west4-aiplatform.googleapis.com" },
|
||||
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
|
||||
deps,
|
||||
);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
|
||||
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
googleAuth: googleAuthClient,
|
||||
projectId: "vertex-project",
|
||||
region: "europe-west4",
|
||||
baseURL: "https://europe-west4-aiplatform.googleapis.com/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit custom provider base URLs", () => {
|
||||
const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(
|
||||
{ baseUrl: "https://proxy.example.test/custom-root/v1" },
|
||||
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
|
||||
deps,
|
||||
);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
|
||||
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
googleAuth: googleAuthClient,
|
||||
projectId: "vertex-project",
|
||||
region: "global",
|
||||
baseURL: "https://proxy.example.test/custom-root/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds /v1 for path-prefixed custom provider base URLs", () => {
|
||||
const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(
|
||||
{ baseUrl: "https://proxy.example.test/custom-root" },
|
||||
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
|
||||
deps,
|
||||
);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
|
||||
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
googleAuth: googleAuthClient,
|
||||
projectId: "vertex-project",
|
||||
region: "global",
|
||||
baseURL: "https://proxy.example.test/custom-root/v1",
|
||||
baseURL: expected,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Arcee tests cover index plugin behavior.
|
||||
import {
|
||||
createRuntimeEnv,
|
||||
createTestWizardPrompter,
|
||||
|
|
@ -17,34 +16,7 @@ import arceePlugin from "./index.js";
|
|||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
describe("arcee provider plugin", () => {
|
||||
it("registers Arcee AI with direct and OpenRouter auth choices", async () => {
|
||||
const provider = await registerSingleProviderPlugin(arceePlugin);
|
||||
|
||||
expect(provider.id).toBe("arcee");
|
||||
expect(provider.label).toBe("Arcee AI");
|
||||
expect(provider.envVars).toEqual(["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"]);
|
||||
expect(provider.auth).toHaveLength(2);
|
||||
|
||||
const directChoice = resolveProviderPluginChoice({
|
||||
providers: [provider],
|
||||
choice: "arceeai-api-key",
|
||||
});
|
||||
if (!directChoice) {
|
||||
throw new Error("expected direct Arcee auth choice");
|
||||
}
|
||||
expect(directChoice.provider.id).toBe("arcee");
|
||||
expect(directChoice.method.id).toBe("arcee-platform");
|
||||
|
||||
const orChoice = resolveProviderPluginChoice({
|
||||
providers: [provider],
|
||||
choice: "arceeai-openrouter",
|
||||
});
|
||||
if (!orChoice) {
|
||||
throw new Error("expected OpenRouter Arcee auth choice");
|
||||
}
|
||||
expect(orChoice.provider.id).toBe("arcee");
|
||||
expect(orChoice.method.id).toBe("openrouter");
|
||||
|
||||
it("reuses the OpenRouter CLI option without registering a duplicate flag", () => {
|
||||
const openRouterManifestChoice = manifest.providerAuthChoices.find(
|
||||
(choice) => choice.choiceId === "arceeai-openrouter",
|
||||
);
|
||||
|
|
@ -84,7 +56,10 @@ describe("arcee provider plugin", () => {
|
|||
|
||||
async function registeredMethod() {
|
||||
const provider = await registerSingleProviderPlugin(arceePlugin);
|
||||
const method = provider.auth.find((entry) => entry.id === route.methodId);
|
||||
const choice = resolveProviderPluginChoice({ providers: [provider], choice: route.choiceId });
|
||||
expect(choice?.provider.id).toBe("arcee");
|
||||
expect(choice?.method.id).toBe(route.methodId);
|
||||
const method = choice?.method;
|
||||
if (!method?.runNonInteractive) {
|
||||
throw new Error(`Missing registered auth method: ${route.methodId}`);
|
||||
}
|
||||
|
|
@ -108,11 +83,11 @@ describe("arcee provider plugin", () => {
|
|||
return result;
|
||||
}
|
||||
|
||||
it("selects stored credentials from registered setup without crossing accounts", async () => {
|
||||
async function onboardInteractive(config: OpenClawConfig, key = "test-arcee-key") {
|
||||
const { method } = await registeredMethod();
|
||||
const result = await method.run({
|
||||
config: {},
|
||||
opts: { [route.optionKey]: "selected-route-key" },
|
||||
return await method.run({
|
||||
config,
|
||||
opts: { [route.optionKey]: key },
|
||||
env: {},
|
||||
runtime: createRuntimeEnv(),
|
||||
prompter: createTestWizardPrompter(),
|
||||
|
|
@ -127,6 +102,10 @@ describe("arcee provider plugin", () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("selects stored credentials from registered setup without crossing accounts", async () => {
|
||||
const result = await onboardInteractive({}, "selected-route-key");
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
|
|
@ -156,29 +135,11 @@ describe("arcee provider plugin", () => {
|
|||
|
||||
it.each([
|
||||
{ mode: undefined, expectedIds: [] },
|
||||
{ mode: "merge" as const, expectedIds: [] },
|
||||
{ mode: "replace" as const, expectedIds: route.catalogIds },
|
||||
])("keeps the registered row policy in $mode mode", async ({ mode, expectedIds }) => {
|
||||
const input: OpenClawConfig = { models: { mode } };
|
||||
const nonInteractive = await onboard(input);
|
||||
const { method } = await registeredMethod();
|
||||
const interactive = await method.run({
|
||||
config: input,
|
||||
opts: { [route.optionKey]: "test-arcee-key" },
|
||||
env: {},
|
||||
runtime: createRuntimeEnv(),
|
||||
prompter: createTestWizardPrompter(),
|
||||
secretInputMode: "plaintext",
|
||||
isRemote: false,
|
||||
openUrl: async () => {
|
||||
throw new Error("Unexpected browser auth");
|
||||
},
|
||||
oauth: {
|
||||
createVpsAwareHandlers: () => {
|
||||
throw new Error("Unexpected OAuth");
|
||||
},
|
||||
},
|
||||
});
|
||||
const interactive = await onboardInteractive(input);
|
||||
|
||||
expect(interactive.profiles).toEqual([
|
||||
{
|
||||
|
|
@ -213,25 +174,7 @@ describe("arcee provider plugin", () => {
|
|||
expect(input).toEqual({ models: { mode } });
|
||||
});
|
||||
|
||||
it("keeps later replace defaults independent of edits to generated rows", async () => {
|
||||
const first = await onboard({ models: { mode: "replace" } });
|
||||
const generated = first.models?.providers?.arcee?.models?.[0];
|
||||
if (!generated) {
|
||||
throw new Error("Expected a generated Arcee model");
|
||||
}
|
||||
const originalCost = generated.cost.input;
|
||||
onTestFinished(() => {
|
||||
generated.cost.input = originalCost;
|
||||
});
|
||||
generated.cost.input = originalCost + 100;
|
||||
|
||||
const later = await onboard({ models: { mode: "replace" } });
|
||||
|
||||
expect(later.models?.providers?.arcee?.models?.[0]?.cost.input).toBeCloseTo(originalCost);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: undefined, addedIds: [] },
|
||||
{ mode: "merge" as const, addedIds: [] },
|
||||
{ mode: "replace" as const, addedIds: route.addedIds },
|
||||
])("preserves authored rows and aliases in $mode mode", async ({ mode, addedIds }) => {
|
||||
|
|
@ -302,21 +245,18 @@ describe("arcee provider plugin", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it.each([undefined, "merge", "replace"] as const)(
|
||||
"keeps the public catalog helper eager in %s mode",
|
||||
(mode) => {
|
||||
const output = route.applyPublicConfig({ models: { mode } });
|
||||
expect(output.models?.providers?.arcee?.models?.map((model) => model.id)).toEqual(
|
||||
route.catalogIds,
|
||||
);
|
||||
expect(output.models?.providers?.arcee).toMatchObject({
|
||||
baseUrl: route.baseUrl,
|
||||
api: "openai-completions",
|
||||
});
|
||||
expect(output.agents?.defaults?.model).toEqual({ primary: modelRef });
|
||||
expect(output.agents?.defaults?.models?.[modelRef]).toEqual({ alias: route.alias });
|
||||
},
|
||||
);
|
||||
it("keeps the public catalog helper eager without replace mode", () => {
|
||||
const output = route.applyPublicConfig({});
|
||||
expect(output.models?.providers?.arcee?.models?.map((model) => model.id)).toEqual(
|
||||
route.catalogIds,
|
||||
);
|
||||
expect(output.models?.providers?.arcee).toMatchObject({
|
||||
baseUrl: route.baseUrl,
|
||||
api: "openai-completions",
|
||||
});
|
||||
expect(output.agents?.defaults?.model).toEqual({ primary: modelRef });
|
||||
expect(output.agents?.defaults?.models?.[modelRef]).toEqual({ alias: route.alias });
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps direct Arcee auth env candidates separate from OpenRouter", () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cancelTrackedTextResponse,
|
||||
createStreamingResponse,
|
||||
} from "../../test-support/streaming-error-response.js";
|
||||
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import { createExaWebSearchProvider } from "./exa-web-search-provider.js";
|
||||
import { createExaWebSearchProvider } from "../web-search-contract-api.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
|
|
@ -12,7 +11,7 @@ function requireExaTool(webSearch: JsonRecord, searchConfig: JsonRecord = {}) {
|
|||
const tool = createExaWebSearchProvider().createTool({
|
||||
config: { plugins: { entries: { exa: { config: { webSearch } } } } },
|
||||
searchConfig,
|
||||
} as never);
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected Exa tool definition");
|
||||
}
|
||||
|
|
@ -20,73 +19,53 @@ function requireExaTool(webSearch: JsonRecord, searchConfig: JsonRecord = {}) {
|
|||
}
|
||||
|
||||
describe("exa web search provider", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("caps returned and cached results when Exa exceeds the requested count", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{ url: "https://example.com/first", title: "First", highlights: ["first"] },
|
||||
{ url: "https://example.com/second", title: "Second", highlights: ["second"] },
|
||||
{ url: "https://example.com/third", title: "Third", highlights: ["third"] },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
Response.json({
|
||||
results: [
|
||||
{ url: "https://example.com/first", title: "First", highlights: ["first"] },
|
||||
{ url: "https://example.com/second", title: "Second", highlights: ["second"] },
|
||||
{ url: "https://example.com/third", title: "Third", highlights: ["third"] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const tool = requireExaTool({ apiKey: "exa-test-key" });
|
||||
|
||||
try {
|
||||
const args = { query: "exa result count owner", count: 1 };
|
||||
const first = await tool.execute(args);
|
||||
const cached = await tool.execute(args);
|
||||
const args = { query: "exa result count owner", count: 1 };
|
||||
const first = await tool.execute(args);
|
||||
const cached = await tool.execute(args);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const requestBody = fetchMock.mock.calls[0]?.[1]?.body;
|
||||
if (typeof requestBody !== "string") {
|
||||
throw new Error("Expected Exa request body to be a JSON string");
|
||||
}
|
||||
expect(requestBody).toBe(
|
||||
'{"query":"exa result count owner","numResults":1,"type":"auto","contents":{"highlights":true}}',
|
||||
);
|
||||
expect(first).toMatchObject({
|
||||
provider: "exa",
|
||||
count: 1,
|
||||
results: [{ url: "https://example.com/first" }],
|
||||
});
|
||||
expect(first.results).toHaveLength(1);
|
||||
expect(cached).toEqual({ ...first, cached: true });
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.body).toBe(
|
||||
'{"query":"exa result count owner","numResults":1,"type":"auto","contents":{"highlights":true}}',
|
||||
);
|
||||
expect(first).toMatchObject({
|
||||
provider: "exa",
|
||||
count: 1,
|
||||
results: [
|
||||
{ url: "https://example.com/first", title: expect.stringMatching(/\n---\nFirst\n<<<END/) },
|
||||
],
|
||||
});
|
||||
expect(first.results).toHaveLength(1);
|
||||
expect(cached).toEqual({ ...first, cached: true });
|
||||
});
|
||||
|
||||
it("does not send or cache an already canceled search", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ results: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const tool = createExaWebSearchProvider().createTool({
|
||||
config: {
|
||||
plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-test-key" } } } } },
|
||||
},
|
||||
searchConfig: {},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(Response.json({ results: [] }));
|
||||
const tool = requireExaTool({ apiKey: "exa-test-key" });
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("Exa caller canceled"));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
tool.execute({ query: "exa pre-canceled" }, { signal: controller.signal }),
|
||||
).rejects.toThrow("Exa caller canceled");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
await expect(
|
||||
tool.execute({ query: "exa pre-canceled" }, { signal: controller.signal }),
|
||||
).rejects.toThrow("Exa caller canceled");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aborts the guarded Exa request without losing the caller's reason", async ({
|
||||
|
|
@ -138,181 +117,109 @@ describe("exa web search provider", () => {
|
|||
|
||||
it("exposes the expected metadata and selection wiring", () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
if (!provider.applySelectionConfig) {
|
||||
throw new Error("Expected applySelectionConfig to be defined");
|
||||
}
|
||||
const applied = provider.applySelectionConfig({});
|
||||
const applied = provider.applySelectionConfig?.({});
|
||||
|
||||
expect(provider.id).toBe("exa");
|
||||
expect(provider.onboardingScopes).toEqual(["text-inference"]);
|
||||
expect(provider.credentialPath).toBe("plugins.entries.exa.config.webSearch.apiKey");
|
||||
const pluginEntry = applied.plugins?.entries?.exa;
|
||||
if (!pluginEntry) {
|
||||
throw new Error("expected Exa plugin entry");
|
||||
}
|
||||
expect(pluginEntry.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the contract export aligned with provider metadata", () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
const contractProvider = createContractExaWebSearchProvider();
|
||||
if (!contractProvider.applySelectionConfig) {
|
||||
throw new Error("Expected contract applySelectionConfig to be defined");
|
||||
}
|
||||
const applied = contractProvider.applySelectionConfig({});
|
||||
|
||||
expect({
|
||||
id: contractProvider.id,
|
||||
label: contractProvider.label,
|
||||
hint: contractProvider.hint,
|
||||
onboardingScopes: contractProvider.onboardingScopes,
|
||||
credentialLabel: contractProvider.credentialLabel,
|
||||
envVars: contractProvider.envVars,
|
||||
placeholder: contractProvider.placeholder,
|
||||
signupUrl: contractProvider.signupUrl,
|
||||
docsUrl: contractProvider.docsUrl,
|
||||
autoDetectOrder: contractProvider.autoDetectOrder,
|
||||
credentialPath: contractProvider.credentialPath,
|
||||
}).toEqual({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
hint: provider.hint,
|
||||
onboardingScopes: provider.onboardingScopes,
|
||||
credentialLabel: provider.credentialLabel,
|
||||
envVars: provider.envVars,
|
||||
placeholder: provider.placeholder,
|
||||
signupUrl: provider.signupUrl,
|
||||
docsUrl: provider.docsUrl,
|
||||
autoDetectOrder: provider.autoDetectOrder,
|
||||
credentialPath: provider.credentialPath,
|
||||
});
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch");
|
||||
try {
|
||||
expect(contractProvider.createTool({ config: {}, searchConfig: {} })).not.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
const pluginEntry = applied.plugins?.entries?.exa;
|
||||
if (!pluginEntry) {
|
||||
throw new Error("expected contract Exa plugin entry");
|
||||
}
|
||||
expect(pluginEntry.enabled).toBe(true);
|
||||
expect(applied?.plugins?.entries?.exa?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("applies scoped auth, endpoint, contents, freshness, and result normalization at the tool boundary", async () => {
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now());
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
url: "https://example.test/highlights",
|
||||
highlights: ["first", "", "second"],
|
||||
text: "ignored",
|
||||
},
|
||||
{ url: "https://example.test/text", text: "text fallback" },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () =>
|
||||
Response.json({
|
||||
results: [
|
||||
{
|
||||
url: "https://example.test/highlights",
|
||||
highlights: ["first", "", "second"],
|
||||
text: "ignored",
|
||||
},
|
||||
{ url: "https://example.test/text", text: "text fallback" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const tool = requireExaTool(
|
||||
{ apiKey: "exa-config-key", baseUrl: "https://proxy.example/exa/" },
|
||||
{ maxResults: 120 },
|
||||
);
|
||||
|
||||
try {
|
||||
const args = {
|
||||
query: "Exa boundary",
|
||||
freshness: "month",
|
||||
contents: {
|
||||
text: { maxCharacters: 1200 },
|
||||
highlights: {
|
||||
maxCharacters: 4000,
|
||||
query: "latest model launches",
|
||||
numSentences: 4,
|
||||
highlightsPerUrl: 2,
|
||||
},
|
||||
summary: { query: "launch details" },
|
||||
const args = {
|
||||
query: "Exa boundary",
|
||||
freshness: "month",
|
||||
contents: {
|
||||
text: { maxCharacters: 1200 },
|
||||
highlights: {
|
||||
maxCharacters: 4000,
|
||||
query: "latest model launches",
|
||||
numSentences: 4,
|
||||
highlightsPerUrl: 2,
|
||||
},
|
||||
};
|
||||
const result = await tool.execute(args);
|
||||
const descriptions = (result.results as Array<{ description: string }>).map(
|
||||
(entry) => entry.description,
|
||||
);
|
||||
expect(descriptions[0]?.split("\n---\n")[1]?.split("\n<<<END")[0]).toBe("first\nsecond");
|
||||
expect(descriptions[1]?.split("\n---\n")[1]?.split("\n<<<END")[0]).toBe("text fallback");
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://proxy.example/exa/search");
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
|
||||
"x-api-key": "exa-config-key",
|
||||
});
|
||||
const rawBodyAt = (index: number) => {
|
||||
const body = fetchMock.mock.calls[index]?.[1]?.body;
|
||||
if (typeof body !== "string") {
|
||||
throw new Error("Expected Exa JSON request body");
|
||||
}
|
||||
return body;
|
||||
};
|
||||
const bodyAt = (index: number) => JSON.parse(rawBodyAt(index));
|
||||
expect(bodyAt(0)).toMatchObject({
|
||||
query: "Exa boundary",
|
||||
numResults: 100,
|
||||
contents: args.contents,
|
||||
});
|
||||
expect(
|
||||
rawBodyAt(0).replace(
|
||||
/"startPublishedDate":"[^"]*"/,
|
||||
'"startPublishedDate":"<dynamic-date>"',
|
||||
),
|
||||
).toBe(
|
||||
'{"query":"Exa boundary","numResults":100,"type":"auto","contents":{"text":{"maxCharacters":1200},"highlights":{"maxCharacters":4000,"query":"latest model launches","numSentences":4,"highlightsPerUrl":2},"summary":{"query":"launch details"}},"startPublishedDate":"<dynamic-date>"}',
|
||||
);
|
||||
expect(Date.parse(bodyAt(0).startPublishedDate)).not.toBeNaN();
|
||||
|
||||
await tool.execute({ query: "cache partitions" });
|
||||
await tool.execute({ query: "cache partitions", contents: { highlights: true } });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
await tool.execute({ query: "cache partitions", contents: { highlights: false } });
|
||||
await tool.execute({ query: "cache partitions", contents: { text: false } });
|
||||
await tool.execute({ query: "cache partitions", contents: { summary: false } });
|
||||
const defaultTool = requireExaTool({ apiKey: "exa-config-key" }, { maxResults: 120 });
|
||||
await defaultTool.execute(args);
|
||||
await requireExaTool(
|
||||
{ apiKey: "exa-config-key", baseUrl: "proxy.example/exa/search/" },
|
||||
{ maxResults: 120 },
|
||||
).execute({ ...args, query: "bare endpoint" });
|
||||
expect(fetchMock.mock.calls[5]?.[0]).toBe("https://api.exa.ai/search");
|
||||
expect(fetchMock.mock.calls[6]?.[0]).toBe("https://proxy.example/exa/search");
|
||||
|
||||
for (const [count, expected] of [
|
||||
["+05", 5],
|
||||
["2e1", 20],
|
||||
] as const) {
|
||||
await defaultTool.execute({ query: `count ${count}`, count });
|
||||
expect(bodyAt(fetchMock.mock.calls.length - 1).numResults).toBe(expected);
|
||||
summary: { query: "launch details" },
|
||||
},
|
||||
};
|
||||
const result = await tool.execute(args);
|
||||
const descriptions = (result.results as Array<{ description: string }>).map(
|
||||
(entry) => entry.description,
|
||||
);
|
||||
expect(descriptions[0]?.split("\n---\n")[1]?.split("\n<<<END")[0]).toBe("first\nsecond");
|
||||
expect(descriptions[1]?.split("\n---\n")[1]?.split("\n<<<END")[0]).toBe("text fallback");
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://proxy.example/exa/search");
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
|
||||
"x-api-key": "exa-config-key",
|
||||
});
|
||||
const rawBodyAt = (index: number) => {
|
||||
const body = fetchMock.mock.calls[index]?.[1]?.body;
|
||||
if (typeof body !== "string") {
|
||||
throw new Error("Expected Exa JSON request body");
|
||||
}
|
||||
for (const count of ["0x10", 1.5]) {
|
||||
await expect(defaultTool.execute({ query: `count ${count}`, count })).rejects.toThrow(
|
||||
"count must be an integer from 1 to 100",
|
||||
);
|
||||
}
|
||||
const inheritedText = { maxCharacters: 1 };
|
||||
const inheritedPrototype = Object.defineProperty({}, "query", {
|
||||
get: () => {
|
||||
throw new Error("read");
|
||||
},
|
||||
});
|
||||
Object.setPrototypeOf(inheritedText, inheritedPrototype);
|
||||
await defaultTool.execute({ query: "inherited", contents: { text: inheritedText } });
|
||||
expect(bodyAt(fetchMock.mock.calls.length - 1).contents).toEqual({
|
||||
text: { maxCharacters: 1 },
|
||||
});
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
fetchMock.mockRestore();
|
||||
return body;
|
||||
};
|
||||
const bodyAt = (index: number) => JSON.parse(rawBodyAt(index));
|
||||
expect(
|
||||
rawBodyAt(0).replace(/"startPublishedDate":"[^"]*"/, '"startPublishedDate":"<dynamic-date>"'),
|
||||
).toBe(
|
||||
'{"query":"Exa boundary","numResults":100,"type":"auto","contents":{"text":{"maxCharacters":1200},"highlights":{"maxCharacters":4000,"query":"latest model launches","numSentences":4,"highlightsPerUrl":2},"summary":{"query":"launch details"}},"startPublishedDate":"<dynamic-date>"}',
|
||||
);
|
||||
expect(Date.parse(bodyAt(0).startPublishedDate)).not.toBeNaN();
|
||||
|
||||
await tool.execute({ query: "cache partitions" });
|
||||
await tool.execute({ query: "cache partitions", contents: { highlights: true } });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
await tool.execute({ query: "cache partitions", contents: { highlights: false } });
|
||||
await tool.execute({ query: "cache partitions", contents: { text: false } });
|
||||
await tool.execute({ query: "cache partitions", contents: { summary: false } });
|
||||
const defaultTool = requireExaTool({ apiKey: "exa-config-key" }, { maxResults: 120 });
|
||||
await defaultTool.execute(args);
|
||||
await requireExaTool(
|
||||
{ apiKey: "exa-config-key", baseUrl: "proxy.example/exa/search/" },
|
||||
{ maxResults: 120 },
|
||||
).execute({ ...args, query: "bare endpoint" });
|
||||
expect(fetchMock.mock.calls[5]?.[0]).toBe("https://api.exa.ai/search");
|
||||
expect(fetchMock.mock.calls[6]?.[0]).toBe("https://proxy.example/exa/search");
|
||||
|
||||
for (const [count, expected] of [
|
||||
["+05", 5],
|
||||
["2e1", 20],
|
||||
] as const) {
|
||||
await defaultTool.execute({ query: `count ${count}`, count });
|
||||
expect(bodyAt(fetchMock.mock.calls.length - 1).numResults).toBe(expected);
|
||||
}
|
||||
for (const count of ["0x10", 1.5]) {
|
||||
await expect(defaultTool.execute({ query: `count ${count}`, count })).rejects.toThrow(
|
||||
"count must be an integer from 1 to 100",
|
||||
);
|
||||
}
|
||||
const inheritedText = { maxCharacters: 1 };
|
||||
const inheritedPrototype = Object.defineProperty({}, "query", {
|
||||
get: () => {
|
||||
throw new Error("read");
|
||||
},
|
||||
});
|
||||
Object.setPrototypeOf(inheritedText, inheritedPrototype);
|
||||
await defaultTool.execute({ query: "inherited", contents: { text: inheritedText } });
|
||||
expect(bodyAt(fetchMock.mock.calls.length - 1).contents).toEqual({
|
||||
text: { maxCharacters: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -356,64 +263,41 @@ describe("exa web search provider", () => {
|
|||
let requestCount = 0;
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({ results: [{ url: `https://example.com/result-${++requestCount}` }] }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
.mockImplementation(async () =>
|
||||
Response.json({ results: [{ url: `https://example.com/result-${++requestCount}` }] }),
|
||||
);
|
||||
const provider = createExaWebSearchProvider();
|
||||
const config = {
|
||||
plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-test-key" } } } } },
|
||||
};
|
||||
const cachedTool = provider.createTool({ config, searchConfig: { cacheTtlMinutes: 15 } });
|
||||
const currentTool = provider.createTool({ config, searchConfig: { cacheTtlMinutes } });
|
||||
const cachedTool = requireExaTool({ apiKey: "exa-test-key" }, { cacheTtlMinutes: 15 });
|
||||
const currentTool = requireExaTool({ apiKey: "exa-test-key" }, { cacheTtlMinutes });
|
||||
const args = { query: `exa cache TTL ${cacheTtlMinutes}` };
|
||||
|
||||
try {
|
||||
if (!cachedTool || !currentTool) {
|
||||
throw new Error("Expected tool definitions");
|
||||
}
|
||||
const original = await cachedTool.execute(args);
|
||||
expect(original).toMatchObject({ results: [{ url: "https://example.com/result-1" }] });
|
||||
const original = await cachedTool.execute(args);
|
||||
expect(original).toMatchObject({ results: [{ url: "https://example.com/result-1" }] });
|
||||
expect(await cachedTool.execute(args)).toEqual({ ...original, cached: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
clock.mockReturnValue(now + 60_000);
|
||||
const fresh = await currentTool.execute(args);
|
||||
expect(fresh).toMatchObject({ results: [{ url: "https://example.com/result-2" }] });
|
||||
expect(fresh).not.toHaveProperty("cached");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
if (cacheTtlMinutes === 0) {
|
||||
expect(await currentTool.execute(args)).toMatchObject({
|
||||
results: [{ url: "https://example.com/result-3" }],
|
||||
});
|
||||
expect(await cachedTool.execute(args)).toEqual({ ...original, cached: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
clock.mockReturnValue(now + 60_000);
|
||||
const fresh = await currentTool.execute(args);
|
||||
expect(fresh).toMatchObject({ results: [{ url: "https://example.com/result-2" }] });
|
||||
expect(fresh).not.toHaveProperty("cached");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
} else {
|
||||
expect(await currentTool.execute(args)).toEqual({ ...fresh, cached: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
if (cacheTtlMinutes === 0) {
|
||||
expect(await currentTool.execute(args)).toMatchObject({
|
||||
results: [{ url: "https://example.com/result-3" }],
|
||||
});
|
||||
expect(await cachedTool.execute(args)).toEqual({ ...original, cached: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
} else {
|
||||
expect(await currentTool.execute(args)).toEqual({ ...fresh, cached: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes newer documented Exa search types and count limits", () => {
|
||||
const tool = requireExaTool({ apiKey: "exa-secret" });
|
||||
|
||||
const parameters = tool.parameters as {
|
||||
properties?: {
|
||||
count?: { maximum?: number };
|
||||
type?: { enum?: string[] };
|
||||
};
|
||||
};
|
||||
|
||||
expect(parameters.properties?.count?.maximum).toBe(100);
|
||||
expect(parameters.properties?.type?.enum).toEqual([
|
||||
expect(tool.parameters).toHaveProperty("properties.count.maximum", 100);
|
||||
expect(tool.parameters).toHaveProperty("properties.type.enum", [
|
||||
"auto",
|
||||
"neural",
|
||||
"fast",
|
||||
|
|
@ -427,14 +311,10 @@ describe("exa web search provider", () => {
|
|||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{ nope"));
|
||||
const tool = requireExaTool({ apiKey: "exa-test-key" }, { cacheTtlMinutes: 0 });
|
||||
|
||||
try {
|
||||
await expect(tool.execute({ query: "malformed Exa JSON" })).rejects.toThrow(
|
||||
"Exa API returned malformed JSON",
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
await expect(tool.execute({ query: "malformed Exa JSON" })).rejects.toThrow(
|
||||
"Exa API returned malformed JSON",
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects invalid UTF-8 in Exa search JSON", async () => {
|
||||
|
|
@ -449,38 +329,10 @@ describe("exa web search provider", () => {
|
|||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(body));
|
||||
const tool = requireExaTool({ apiKey: "exa-test-key" }, { cacheTtlMinutes: 0 });
|
||||
|
||||
try {
|
||||
await expect(tool.execute({ query: "invalid UTF-8 Exa JSON" })).rejects.toThrow(
|
||||
"Exa API returned malformed JSON",
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses well-formed Exa search JSON under the byte cap", async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({ results: [{ url: "https://example.com", title: "Example" }] }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
await expect(tool.execute({ query: "invalid UTF-8 Exa JSON" })).rejects.toThrow(
|
||||
"Exa API returned malformed JSON",
|
||||
);
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
|
||||
const tool = requireExaTool({ apiKey: "exa-test-key" }, { cacheTtlMinutes: 0 });
|
||||
|
||||
try {
|
||||
const result = await tool.execute({ query: "well-formed Exa JSON" });
|
||||
const rows = result.results as Array<{ url: string; title: string }>;
|
||||
expect(result.count).toBe(1);
|
||||
expect(
|
||||
rows.map((entry) => ({
|
||||
url: entry.url,
|
||||
title: entry.title.split("\n---\n")[1]?.split("\n<<<END")[0],
|
||||
})),
|
||||
).toEqual([{ url: "https://example.com", title: "Example" }]);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("caps oversized Exa search JSON instead of buffering the whole body", async () => {
|
||||
|
|
@ -493,16 +345,12 @@ describe("exa web search provider", () => {
|
|||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(streamed.response);
|
||||
const tool = requireExaTool({ apiKey: "exa-test-key" }, { cacheTtlMinutes: 0 });
|
||||
|
||||
try {
|
||||
await expect(tool.execute({ query: "oversized Exa JSON" })).rejects.toThrow(
|
||||
"Exa API response exceeds 16777216 bytes",
|
||||
);
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
await expect(tool.execute({ query: "oversized Exa JSON" })).rejects.toThrow(
|
||||
"Exa API response exceeds 16777216 bytes",
|
||||
);
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("bounds Exa API error bodies without using response.text()", async () => {
|
||||
|
|
@ -517,22 +365,17 @@ describe("exa web search provider", () => {
|
|||
.mockResolvedValueOnce(new Response("short", { status: 503 }));
|
||||
const tool = requireExaTool({ apiKey: "exa-test-key" }, { cacheTtlMinutes: 0 });
|
||||
|
||||
try {
|
||||
const failure = tool.execute({ query: "bounded Exa error" });
|
||||
await expect(failure).rejects.toThrow("exa upstream unavailable");
|
||||
await expect(failure).rejects.toMatchObject({ status: 503, statusCode: 503 });
|
||||
await expect(failure).rejects.not.toThrow("tail");
|
||||
await expect(tool.execute({ query: "short Exa error" })).rejects.toMatchObject({
|
||||
message: "Exa API error (503): short",
|
||||
status: 503,
|
||||
statusCode: 503,
|
||||
});
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
textSpy.mockRestore();
|
||||
}
|
||||
const failure = tool.execute({ query: "bounded Exa error" });
|
||||
await expect(failure).rejects.toThrow("exa upstream unavailable");
|
||||
await expect(failure).rejects.toMatchObject({ status: 503, statusCode: 503 });
|
||||
await expect(failure).rejects.not.toThrow("tail");
|
||||
await expect(tool.execute({ query: "short Exa error" })).rejects.toMatchObject({
|
||||
message: "Exa API error (503): short",
|
||||
status: 503,
|
||||
statusCode: 503,
|
||||
});
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Kimi Coding tests cover implicit provider plugin behavior.
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
|
@ -23,17 +22,6 @@ async function runKimiCatalog(params: {
|
|||
return catalogResult ?? null;
|
||||
}
|
||||
|
||||
async function runKimiCatalogProvider(params: {
|
||||
apiKey: string;
|
||||
explicitProvider?: Record<string, unknown>;
|
||||
}) {
|
||||
const result = await runKimiCatalog(params);
|
||||
if (!result || !("provider" in result)) {
|
||||
throw new Error("expected Kimi catalog to return one provider");
|
||||
}
|
||||
return result.provider;
|
||||
}
|
||||
|
||||
describe("Kimi implicit provider (#22409)", () => {
|
||||
it("publishes the env vars used by core api-key auto-detection", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
|
@ -45,8 +33,18 @@ describe("Kimi implicit provider (#22409)", () => {
|
|||
await expect(runKimiCatalog({})).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("publishes the Kimi provider when an API key is resolved", async () => {
|
||||
const { models, ...provider } = await runKimiCatalogProvider({ apiKey: "test-key" });
|
||||
it("publishes built-in defaults despite retired kimi-coding provider overrides", async () => {
|
||||
const result = await runKimiCatalog({
|
||||
apiKey: "test-key",
|
||||
explicitProvider: {
|
||||
baseUrl: "https://kimi.example.test/coding/",
|
||||
headers: { "User-Agent": "custom-kimi-client/1.0" },
|
||||
},
|
||||
});
|
||||
if (!result || !("provider" in result)) {
|
||||
throw new Error("expected Kimi catalog to return one provider");
|
||||
}
|
||||
const { models, ...provider } = result.provider;
|
||||
|
||||
expect(provider).toEqual({
|
||||
baseUrl: "https://api.kimi.com/coding/",
|
||||
|
|
@ -64,19 +62,4 @@ describe("Kimi implicit provider (#22409)", () => {
|
|||
"kimi-for-coding-highspeed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores retired kimi-coding provider overrides", async () => {
|
||||
const provider = await runKimiCatalogProvider({
|
||||
apiKey: "test-key",
|
||||
explicitProvider: {
|
||||
baseUrl: "https://kimi.example.test/coding/",
|
||||
headers: {
|
||||
"User-Agent": "custom-kimi-client/1.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.baseUrl).toBe("https://api.kimi.com/coding/");
|
||||
expect(provider.headers).toEqual({ "User-Agent": "claude-code/0.1.0" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Kimi Coding tests cover index plugin behavior.
|
||||
import { streamSimpleAnthropic } from "@openclaw/ai/internal/anthropic";
|
||||
import type { Context, Model } from "openclaw/plugin-sdk/llm";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
|
|
@ -57,14 +56,11 @@ describe("kimi provider plugin", () => {
|
|||
["weekly window", "You've reached your weekly (7-day) usage limit.", "rate_limit"],
|
||||
["seven-day limit", "Your seven-day usage limit has been reached.", "rate_limit"],
|
||||
["7-day limit", "You've reached your 7-day usage limit.", "rate_limit"],
|
||||
["quota reset", "Your quota will reset when the current window ends.", "rate_limit"],
|
||||
[
|
||||
"agent access restriction",
|
||||
"Kimi For Coding is currently only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code, Kilo Code, etc.",
|
||||
undefined,
|
||||
],
|
||||
["type without quota", "Access has been terminated.", undefined],
|
||||
["invalid key", "Invalid API key", undefined],
|
||||
] as const)("classifies the quota signal for %s", async (_name, errorMessage, expected) => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
|
|
@ -78,7 +74,7 @@ describe("kimi provider plugin", () => {
|
|||
).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["kimi", " KIMI ", "kimi-code", "kimi-coding"])(
|
||||
it.each([" KIMI ", "kimi-code", "kimi-coding"])(
|
||||
"declares and classifies quota exhaustion for provider %s",
|
||||
async (providerId) => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
|
@ -110,13 +106,13 @@ describe("kimi provider plugin", () => {
|
|||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["k3", "k3-256k"])("exposes %s adaptive thinking levels", async (modelId) => {
|
||||
it("exposes adaptive thinking levels for case-insensitive K3 ids", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(
|
||||
provider.resolveThinkingProfile?.({
|
||||
provider: "kimi",
|
||||
modelId,
|
||||
modelId: "K3-256K",
|
||||
reasoning: true,
|
||||
} as never),
|
||||
).toEqual({
|
||||
|
|
@ -135,17 +131,10 @@ describe("kimi provider plugin", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("wraps K3 simple completions without changing K2 simple completions", async () => {
|
||||
it("leaves K2 simple completions unchanged", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const streamFn = (() => undefined) as never;
|
||||
|
||||
expect(
|
||||
provider.wrapSimpleCompletionStreamFn?.({
|
||||
provider: "kimi",
|
||||
modelId: "k3",
|
||||
streamFn,
|
||||
} as never),
|
||||
).not.toBe(streamFn);
|
||||
expect(
|
||||
provider.wrapSimpleCompletionStreamFn?.({
|
||||
provider: "kimi",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Kimi Coding tests cover onboard plugin behavior.
|
||||
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF, KIMI_MODEL_REF } from "./onboard.js";
|
||||
|
|
@ -31,8 +30,8 @@ describe("kimi coding onboard", () => {
|
|||
expect(cfg.agents?.defaults?.models?.[KIMI_MODEL_REF]?.alias).toBe("Kimi");
|
||||
});
|
||||
|
||||
it.each([undefined, "merge"] as const)("leaves ordinary %s catalogs runtime-owned", (mode) => {
|
||||
const cfg = applyKimiCodeConfig({ models: { mode } });
|
||||
it("leaves ordinary catalogs runtime-owned", () => {
|
||||
const cfg = applyKimiCodeConfig({});
|
||||
|
||||
expect(cfg.models?.providers?.kimi?.models).toEqual([]);
|
||||
expect(cfg.agents?.defaults?.models?.[KIMI_MODEL_REF]).toEqual({ alias: "Kimi" });
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { clampThinkingLevel, type Model } from "openclaw/plugin-sdk/llm";
|
||||
// Kimi Coding tests cover provider catalog plugin behavior.
|
||||
import { parseModelRef } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
import { buildKimiCodingProvider, normalizeKimiCodingModelId } from "./provider-catalog.js";
|
||||
import { isKimiK3ModelId, KIMI_K3_MODEL_IDS } from "./provider-policy-api.js";
|
||||
import { KIMI_K3_MODEL_IDS } from "./provider-policy-api.js";
|
||||
|
||||
describe("kimi provider catalog", () => {
|
||||
it.each(["k3", "k3-256k"])("keeps documented off thinking selectable for %s", (id) => {
|
||||
|
|
@ -14,14 +13,9 @@ describe("kimi provider catalog", () => {
|
|||
throw new Error(`Missing catalog model ${id}`);
|
||||
}
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
reasoning: row.reasoning,
|
||||
thinkingLevelMap: row.thinkingLevelMap,
|
||||
contextWindow: row.contextWindow,
|
||||
maxTokens: row.maxTokens,
|
||||
cost: row.cost,
|
||||
...row,
|
||||
api: "anthropic-messages",
|
||||
compat: undefined,
|
||||
provider: "kimi",
|
||||
baseUrl: provider.baseUrl,
|
||||
input: ["text", "image"],
|
||||
|
|
@ -41,8 +35,7 @@ describe("kimi provider catalog", () => {
|
|||
"kimi-for-coding",
|
||||
"kimi-for-coding-highspeed",
|
||||
]);
|
||||
expect(provider.models.find((model) => model.id === "k3")).toMatchObject({
|
||||
name: "Kimi K3",
|
||||
const k3Contract = {
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
minimal: "low",
|
||||
|
|
@ -53,25 +46,18 @@ describe("kimi provider catalog", () => {
|
|||
max: "max",
|
||||
},
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 131_072,
|
||||
compat: { codeMode: "preferred" },
|
||||
};
|
||||
expect(provider.models.find((model) => model.id === "k3")).toMatchObject({
|
||||
...k3Contract,
|
||||
name: "Kimi K3",
|
||||
contextWindow: 1_048_576,
|
||||
});
|
||||
expect(provider.models.find((model) => model.id === "k3-256k")).toMatchObject({
|
||||
...k3Contract,
|
||||
name: "Kimi K3 (256k)",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
minimal: "low",
|
||||
low: "low",
|
||||
medium: "high",
|
||||
high: "high",
|
||||
xhigh: "max",
|
||||
max: "max",
|
||||
},
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
|
||||
contextWindow: 262_144,
|
||||
maxTokens: 131_072,
|
||||
compat: { codeMode: "preferred" },
|
||||
});
|
||||
expect(provider.models.find((model) => model.id === "kimi-for-coding-highspeed")).toMatchObject(
|
||||
{
|
||||
|
|
@ -101,9 +87,7 @@ describe("kimi provider catalog", () => {
|
|||
["kimi-code", "kimi-for-coding"],
|
||||
["k2p5", "kimi-for-coding"],
|
||||
["kimi-for-coding", "kimi-for-coding"],
|
||||
["k3", "k3"],
|
||||
["k3[1m]", "k3"],
|
||||
["kimi-for-coding-highspeed", "kimi-for-coding-highspeed"],
|
||||
])("normalizes %s to %s through the helper and static manifest", (input, expected) => {
|
||||
expect(normalizeKimiCodingModelId(input)).toBe(expected);
|
||||
expect(
|
||||
|
|
@ -113,10 +97,4 @@ describe("kimi provider catalog", () => {
|
|||
}),
|
||||
).toEqual({ provider: "kimi", model: expected });
|
||||
});
|
||||
|
||||
it("recognizes K3 thinking-policy models", () => {
|
||||
expect(isKimiK3ModelId("k3")).toBe(true);
|
||||
expect(isKimiK3ModelId("K3-256K")).toBe(true);
|
||||
expect(isKimiK3ModelId("kimi-for-coding")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isKimiK3ModelId, resolveThinkingProfile } from "./provider-policy-api.js";
|
||||
|
||||
describe("Kimi Code provider policy", () => {
|
||||
it.each(["k3", "k3-256k"])("exposes adaptive K3 thinking levels for %s", (modelId) => {
|
||||
expect(resolveThinkingProfile({ provider: "kimi", modelId })).toEqual({
|
||||
levels: [
|
||||
{ id: "off" },
|
||||
{ id: "minimal" },
|
||||
{ id: "low" },
|
||||
{ id: "medium" },
|
||||
{ id: "high" },
|
||||
{ id: "adaptive" },
|
||||
{ id: "xhigh" },
|
||||
{ id: "max" },
|
||||
],
|
||||
defaultLevel: "high",
|
||||
preserveWhenCatalogReasoningFalse: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy Kimi Code thinking binary and off by default", () => {
|
||||
expect(resolveThinkingProfile({ provider: "kimi", modelId: "kimi-for-coding" })).toEqual({
|
||||
levels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "low", label: "on" },
|
||||
],
|
||||
defaultLevel: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes K3 wire ids case-insensitively", () => {
|
||||
expect(isKimiK3ModelId("K3")).toBe(true);
|
||||
expect(isKimiK3ModelId("K3-256K")).toBe(true);
|
||||
expect(isKimiK3ModelId("kimi-for-coding")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Kimi Coding tests cover stream plugin behavior.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { Context, Model } from "openclaw/plugin-sdk/llm";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { wrapKimiProviderStream } from "./stream.js";
|
||||
|
||||
|
|
@ -14,12 +14,8 @@ function createFakeStream(params: { events: unknown[]; resultMessage: unknown })
|
|||
async result() {
|
||||
return params.resultMessage;
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return (async function* () {
|
||||
for (const event of params.events) {
|
||||
yield event;
|
||||
}
|
||||
})();
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield* params.events;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -71,7 +67,7 @@ function createPayloadCapturingStream(initialPayload: Record<string, unknown> =
|
|||
const streamFn: StreamFn = (model, _context, options) => {
|
||||
capturedModel = model;
|
||||
capturedOptions = options;
|
||||
const payload: Record<string, unknown> = { ...initialPayload };
|
||||
const payload = structuredClone(initialPayload);
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
capturedPayload = payload;
|
||||
return createFakeStream({
|
||||
|
|
@ -87,22 +83,37 @@ function createPayloadCapturingStream(initialPayload: Record<string, unknown> =
|
|||
};
|
||||
}
|
||||
|
||||
function wrapKimiStream(streamFn: StreamFn, thinking: "enabled" | "off" = "off"): StreamFn {
|
||||
return wrapKimiProviderStream({ streamFn, extraParams: { thinking } } as never);
|
||||
function wrapKimiStream(streamFn: StreamFn): StreamFn {
|
||||
return wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
modelId: KIMI_MODEL.id,
|
||||
streamFn,
|
||||
extraParams: { thinking: "off" },
|
||||
});
|
||||
}
|
||||
|
||||
function captureKimiPayload(
|
||||
params: Pick<ProviderWrapStreamFnContext, "modelId" | "thinkingLevel" | "extraParams"> & {
|
||||
api?: Model["api"];
|
||||
},
|
||||
initialPayload: Record<string, unknown> = {},
|
||||
options: Parameters<StreamFn>[2] = {},
|
||||
) {
|
||||
const captured = createPayloadCapturingStream(initialPayload);
|
||||
const { api = KIMI_MODEL.api, ...ctx } = params;
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
...ctx,
|
||||
provider: "kimi",
|
||||
streamFn: captured.streamFn,
|
||||
});
|
||||
void wrapped({ ...KIMI_MODEL, api, id: ctx.modelId }, KIMI_CONTEXT, options);
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe("kimi tool-call markup wrapper", () => {
|
||||
it("converts tagged Kimi tool-call text into structured tool calls", async () => {
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: KIMI_TOOL_TEXT }],
|
||||
stopReason: "stop",
|
||||
};
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: KIMI_TOOL_TEXT }],
|
||||
stopReason: "stop",
|
||||
};
|
||||
const partial = createAssistantTextMessage(KIMI_TOOL_TEXT);
|
||||
const message = createAssistantTextMessage(KIMI_TOOL_TEXT);
|
||||
const finalMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
|
|
@ -119,74 +130,39 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
}) as ReturnType<StreamFn>;
|
||||
|
||||
const wrapped = wrapKimiStream(baseStreamFn);
|
||||
const stream = wrapped(
|
||||
{ api: "anthropic-messages", provider: "kimi", id: "k2p5" } as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
) as FakeStream;
|
||||
const stream = await callKimiStream(wrapped);
|
||||
|
||||
const events: unknown[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
const result = (await stream.result()) as {
|
||||
content: unknown[];
|
||||
stopReason: string;
|
||||
const result = await stream.result();
|
||||
const toolMessage = {
|
||||
role: "assistant",
|
||||
content: [createReadToolCall()],
|
||||
stopReason: "toolUse",
|
||||
};
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "message_end",
|
||||
partial: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
...createReadToolCall(),
|
||||
},
|
||||
],
|
||||
stopReason: "toolUse",
|
||||
},
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
...createReadToolCall(),
|
||||
},
|
||||
],
|
||||
stopReason: "toolUse",
|
||||
},
|
||||
partial: toolMessage,
|
||||
message: toolMessage,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Need to read the file first." },
|
||||
{
|
||||
...createReadToolCall(),
|
||||
},
|
||||
createReadToolCall(),
|
||||
],
|
||||
stopReason: "toolUse",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves normal assistant text unchanged", async () => {
|
||||
const finalMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "normal response" }],
|
||||
stopReason: "stop",
|
||||
};
|
||||
const baseStreamFn: StreamFn = () =>
|
||||
createFakeStream({
|
||||
events: [],
|
||||
resultMessage: finalMessage,
|
||||
}) as ReturnType<StreamFn>;
|
||||
|
||||
const wrapped = wrapKimiStream(baseStreamFn);
|
||||
const stream = wrapped(
|
||||
{ api: "anthropic-messages", provider: "kimi", id: "k2p5" } as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
) as FakeStream;
|
||||
const finalMessage = createAssistantTextMessage("normal response");
|
||||
const stream = await callKimiStream(wrapKimiStream(createResultStreamFn(finalMessage)));
|
||||
|
||||
await expect(stream.result()).resolves.toBe(finalMessage);
|
||||
});
|
||||
|
|
@ -201,11 +177,7 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
|
||||
await expect(stream.result()).resolves.toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
...createReadToolCall(),
|
||||
},
|
||||
],
|
||||
content: [createReadToolCall()],
|
||||
stopReason: "toolUse",
|
||||
});
|
||||
});
|
||||
|
|
@ -220,9 +192,7 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
await expect(stream.result()).resolves.toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
...createReadToolCall(),
|
||||
},
|
||||
createReadToolCall(),
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "functions.write:1",
|
||||
|
|
@ -253,56 +223,17 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("forces Kimi thinking disabled and strips proxy reasoning fields", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
|
||||
reasoning: { effort: "high" },
|
||||
reasoning_effort: "high",
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
|
||||
const wrapped = wrapKimiStream(baseStreamFn);
|
||||
void wrapped(
|
||||
it("defaults K3-256k to adaptive high thinking", () => {
|
||||
const modelId = "k3-256k";
|
||||
const { getCapturedModel, getCapturedPayload } = captureKimiPayload(
|
||||
{ modelId },
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: "kimi-code",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
thinking: { type: "disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["k3", "k3-256k"])("defaults %s to adaptive high thinking", (modelId) => {
|
||||
const {
|
||||
streamFn: baseStreamFn,
|
||||
getCapturedModel,
|
||||
getCapturedPayload,
|
||||
} = createPayloadCapturingStream({
|
||||
thinking: { type: "disabled", budget_tokens: 8192 },
|
||||
output_config: { effort: "low", format: { type: "json_schema" } },
|
||||
reasoning: { effort: "low" },
|
||||
reasoning_effort: "low",
|
||||
reasoningEffort: "low",
|
||||
});
|
||||
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
modelId,
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: modelId,
|
||||
} as Model<"anthropic-messages">,
|
||||
KIMI_CONTEXT,
|
||||
{},
|
||||
thinking: { type: "disabled", budget_tokens: 8192 },
|
||||
output_config: { effort: "low", format: { type: "json_schema" } },
|
||||
reasoning: { effort: "low" },
|
||||
reasoning_effort: "low",
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
|
|
@ -314,30 +245,11 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
|
||||
it.each([
|
||||
["minimal", "low"],
|
||||
["low", "low"],
|
||||
["medium", "high"],
|
||||
["high", "high"],
|
||||
["adaptive", "high"],
|
||||
["xhigh", "max"],
|
||||
["max", "max"],
|
||||
] as const)("maps K3 %s thinking to %s effort", (thinkingLevel, effort) => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
modelId: "k3",
|
||||
thinkingLevel,
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: "k3",
|
||||
} as Model<"anthropic-messages">,
|
||||
KIMI_CONTEXT,
|
||||
{},
|
||||
);
|
||||
const { getCapturedPayload } = captureKimiPayload({ modelId: "k3", thinkingLevel });
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
|
|
@ -347,34 +259,17 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
|
||||
it.each([
|
||||
{ modelId: "k3", extraParams: undefined, thinkingLevel: "off" },
|
||||
{ modelId: "k3", extraParams: { thinking: "off" }, thinkingLevel: "max" },
|
||||
{ modelId: "k3-256k", extraParams: undefined, thinkingLevel: "off" },
|
||||
{ modelId: "k3-256k", extraParams: { thinking: "off" }, thinkingLevel: "max" },
|
||||
] as const)("honors $modelId thinking off", ({ modelId, extraParams, thinkingLevel }) => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
|
||||
thinking: { type: "adaptive" },
|
||||
output_config: { effort: "max", format: { type: "json_schema" } },
|
||||
reasoning: { effort: "max" },
|
||||
reasoning_effort: "max",
|
||||
reasoningEffort: "max",
|
||||
});
|
||||
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
modelId,
|
||||
extraParams,
|
||||
thinkingLevel,
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped(
|
||||
const { getCapturedPayload } = captureKimiPayload(
|
||||
{ modelId, extraParams, thinkingLevel },
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: modelId,
|
||||
} as Model<"anthropic-messages">,
|
||||
KIMI_CONTEXT,
|
||||
{},
|
||||
thinking: { type: "adaptive" },
|
||||
output_config: { effort: "max", format: { type: "json_schema" } },
|
||||
reasoning: { effort: "max" },
|
||||
reasoning_effort: "max",
|
||||
reasoningEffort: "max",
|
||||
},
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
|
|
@ -383,123 +278,57 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["k3", "k3-256k"])(
|
||||
"lets explicit %s thinking enablement override session off",
|
||||
(modelId) => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
modelId,
|
||||
extraParams: { thinking: "enabled" },
|
||||
thinkingLevel: "off",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: modelId,
|
||||
} as Model<"anthropic-messages">,
|
||||
KIMI_CONTEXT,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
},
|
||||
);
|
||||
it("lets explicit K3 thinking enablement override session off", () => {
|
||||
const { getCapturedPayload } = captureKimiPayload({
|
||||
modelId: "k3",
|
||||
extraParams: { thinking: "enabled" },
|
||||
thinkingLevel: "off",
|
||||
});
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
});
|
||||
|
||||
it("strips Anthropic cache_control markers before Kimi requests are sent", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
|
||||
system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral", ttl: "1h" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello", cache_control: { type: "ephemeral" } },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "done",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_2",
|
||||
name: "persist",
|
||||
input: {
|
||||
cache_control: "tool argument",
|
||||
nested: { cache_control: "nested argument" },
|
||||
},
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
{ type: "text", text: "bye" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const wrapped = wrapKimiStream(baseStreamFn, "enabled");
|
||||
void wrapped(
|
||||
const text = { type: "text", text: "hello" };
|
||||
const nestedText = { type: "text", text: "done" };
|
||||
const toolResult = { type: "tool_result", tool_use_id: "tool_1", content: [nestedText] };
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "tool_2",
|
||||
name: "persist",
|
||||
input: { cache_control: "tool argument", nested: { cache_control: "nested argument" } },
|
||||
};
|
||||
const plainText = { type: "text", text: "bye" };
|
||||
const cache_control = { type: "ephemeral" };
|
||||
const { getCapturedPayload } = captureKimiPayload(
|
||||
{ modelId: "kimi-code", extraParams: { thinking: "enabled" } },
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: "kimi-code",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
system: [{ type: "text", text: "stable", cache_control: { ...cache_control, ttl: "1h" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ ...text, cache_control },
|
||||
{ ...toolResult, content: [{ ...nestedText, cache_control }], cache_control },
|
||||
{ ...toolUse, cache_control },
|
||||
plainText,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
max_tokens: 16000,
|
||||
system: [{ type: "text", text: "stable" }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_2",
|
||||
name: "persist",
|
||||
input: {
|
||||
cache_control: "tool argument",
|
||||
nested: { cache_control: "nested argument" },
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "bye" },
|
||||
],
|
||||
},
|
||||
],
|
||||
messages: [{ role: "user", content: [text, toolResult, toolUse, plainText] }],
|
||||
thinking: { type: "enabled", budget_tokens: 1024 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "uses per-call thinking before the wrapper default when model params are absent",
|
||||
extraParams: undefined,
|
||||
thinkingLevel: "high",
|
||||
reasoning: "minimal",
|
||||
expected: {
|
||||
max_tokens: 16000,
|
||||
thinking: { type: "enabled", budget_tokens: 1024 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lets explicit model params disable session thinking",
|
||||
extraParams: { thinking: "off" },
|
||||
|
|
@ -518,187 +347,80 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
},
|
||||
},
|
||||
] as const)("$name", ({ extraParams, thinkingLevel, reasoning, expected }) => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
modelId: "kimi-code",
|
||||
extraParams,
|
||||
thinkingLevel,
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped(KIMI_MODEL, KIMI_CONTEXT, { reasoning });
|
||||
const { getCapturedPayload } = captureKimiPayload(
|
||||
{ modelId: "kimi-code", extraParams, thinkingLevel },
|
||||
{},
|
||||
{ reasoning },
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual(expected);
|
||||
});
|
||||
|
||||
it("backfills Kimi OpenAI-compatible tool-call reasoning_content when thinking is enabled", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
|
||||
messages: [
|
||||
{ role: "user", content: "run pwd" },
|
||||
const user = { role: "user", content: "run pwd" };
|
||||
const toolCall = {
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "exec", arguments: '{"command":"pwd"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "kept",
|
||||
reasoning_content: "native reasoning",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_2",
|
||||
type: "function",
|
||||
function: { name: "read", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "exec", arguments: '{"command":"pwd"}' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const wrapped = wrapKimiStream(baseStreamFn, "enabled");
|
||||
void wrapped(
|
||||
};
|
||||
const nativeReasoning = {
|
||||
role: "assistant",
|
||||
content: "kept",
|
||||
reasoning_content: "native reasoning",
|
||||
tool_calls: [{ id: "call_2", type: "function", function: { name: "read", arguments: "{}" } }],
|
||||
};
|
||||
const { getCapturedPayload } = captureKimiPayload(
|
||||
{
|
||||
modelId: "kimi-for-coding",
|
||||
api: "openai-completions",
|
||||
provider: "kimi",
|
||||
id: "kimi-for-coding",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
extraParams: { thinking: "enabled" },
|
||||
},
|
||||
{ messages: [user, toolCall, nativeReasoning] },
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
messages: [
|
||||
{ role: "user", content: "run pwd" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning_content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "exec", arguments: '{"command":"pwd"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "kept",
|
||||
reasoning_content: "native reasoning",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_2",
|
||||
type: "function",
|
||||
function: { name: "read", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
messages: [user, { ...toolCall, reasoning_content: "" }, nativeReasoning],
|
||||
thinking: { type: "enabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("strips Kimi OpenAI-compatible replay reasoning_content when thinking is disabled", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
|
||||
messages: [
|
||||
const toolCall = {
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning_content: "old reasoning",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "exec", arguments: '{"command":"pwd"}' },
|
||||
},
|
||||
],
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "exec", arguments: '{"command":"pwd"}' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const wrapped = wrapKimiStream(baseStreamFn);
|
||||
void wrapped(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "kimi",
|
||||
id: "kimi-for-coding",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
};
|
||||
const { getCapturedPayload } = captureKimiPayload(
|
||||
{ modelId: "kimi-for-coding", api: "openai-completions", extraParams: { thinking: "off" } },
|
||||
{ messages: [{ ...toolCall, reasoning_content: "old reasoning" }] },
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "exec", arguments: '{"command":"pwd"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
messages: [toolCall],
|
||||
thinking: { type: "disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Kimi Anthropic thinking with a high budget and enough output room", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();
|
||||
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
modelId: "kimi-code",
|
||||
thinkingLevel: "high",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: "kimi-code",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
max_tokens: 16000,
|
||||
thinking: { type: "enabled", budget_tokens: 8192 },
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the default Kimi Anthropic thinking budget for explicit enabled params", () => {
|
||||
const cases = ["enabled", true, { type: "enabled" }] as const;
|
||||
|
||||
for (const configuredThinking of cases) {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
const { getCapturedPayload } = captureKimiPayload({
|
||||
modelId: "kimi-code",
|
||||
extraParams: { thinking: configuredThinking },
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: "kimi-code",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
max_tokens: 16000,
|
||||
|
|
@ -708,25 +430,11 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
});
|
||||
|
||||
it("uses the session Kimi Anthropic budget for explicit enabled params when available", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();
|
||||
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
const { getCapturedPayload } = captureKimiPayload({
|
||||
modelId: "kimi-code",
|
||||
extraParams: { thinking: "enabled" },
|
||||
thinkingLevel: "medium",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: "kimi-code",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
max_tokens: 16000,
|
||||
|
|
@ -735,29 +443,11 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
});
|
||||
|
||||
it("preserves explicit Kimi Anthropic thinking budgets", () => {
|
||||
const {
|
||||
streamFn: baseStreamFn,
|
||||
getCapturedOptions,
|
||||
getCapturedPayload,
|
||||
} = createPayloadCapturingStream();
|
||||
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
const { getCapturedOptions, getCapturedPayload } = captureKimiPayload({
|
||||
modelId: "kimi-code",
|
||||
extraParams: { thinking: { type: "enabled", budget_tokens: 4096 } },
|
||||
thinkingLevel: "adaptive",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: "kimi-code",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
expect(getCapturedOptions()?.reasoning).toBe("high");
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
|
|
@ -767,25 +457,9 @@ describe("kimi tool-call markup wrapper", () => {
|
|||
});
|
||||
|
||||
it("preserves larger Kimi Anthropic max_tokens values", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
|
||||
max_tokens: 32768,
|
||||
});
|
||||
|
||||
const wrapped = wrapKimiProviderStream({
|
||||
provider: "kimi",
|
||||
modelId: "kimi-code",
|
||||
thinkingLevel: "high",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: "kimi-code",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
const { getCapturedPayload } = captureKimiPayload(
|
||||
{ modelId: "kimi-code", thinkingLevel: "high" },
|
||||
{ max_tokens: 32768 },
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
|
|
|
|||
|
|
@ -1,38 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
|
||||
|
||||
type Config = Parameters<typeof normalizeCompatibilityConfig>[0]["cfg"];
|
||||
type PluginEntry = NonNullable<NonNullable<Config["plugins"]>["entries"]>[string];
|
||||
|
||||
function migrate(entry: PluginEntry) {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: { plugins: { entries: { "llm-task": entry } } },
|
||||
});
|
||||
return { entry: result.config.plugins?.entries?.["llm-task"], changes: result.changes };
|
||||
}
|
||||
|
||||
describe("llm-task doctor contract", () => {
|
||||
it("surfaces pre-policy entries and converges after migration", () => {
|
||||
const rule = legacyConfigRules.find(
|
||||
(candidate) => candidate.path.join(".") === "plugins.entries.llm-task",
|
||||
) as { match?: (value: unknown, root: Record<string, unknown>) => boolean } | undefined;
|
||||
expect(rule?.match?.({ enabled: true }, {})).toBe(true);
|
||||
);
|
||||
expect(rule?.match?.({ enabled: true })).toBe(true);
|
||||
expect(
|
||||
rule?.match?.({ llm: { allowModelOverride: true, allowAuthProfileOverride: true } }, {}),
|
||||
rule?.match?.({ llm: { allowModelOverride: true, allowAuthProfileOverride: true } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
rule?.match?.({ llm: { allowModelOverride: false, allowAuthProfileOverride: false } }, {}),
|
||||
rule?.match?.({ llm: { allowModelOverride: false, allowAuthProfileOverride: false } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("moves shipped model policy and grants the shipped override capabilities", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"llm-task": {
|
||||
enabled: true,
|
||||
config: {
|
||||
defaultModel: "gpt-5.6-sol",
|
||||
allowedModels: ["openai/gpt-5.6-sol"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
const result = migrate({
|
||||
enabled: true,
|
||||
config: {
|
||||
defaultModel: "gpt-5.6-sol",
|
||||
allowedModels: ["openai/gpt-5.6-sol"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["llm-task"]).toEqual({
|
||||
expect(result.entry).toEqual({
|
||||
enabled: true,
|
||||
llm: {
|
||||
allowModelOverride: true,
|
||||
|
|
@ -45,24 +47,16 @@ describe("llm-task doctor contract", () => {
|
|||
});
|
||||
|
||||
it("keeps shipped override policy and migrates legacy completion policy separately", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"llm-task": {
|
||||
llm: {
|
||||
allowModelOverride: false,
|
||||
allowAuthProfileOverride: false,
|
||||
allowedModels: ["anthropic/claude-haiku-4-5"],
|
||||
},
|
||||
config: { allowedModels: ["openai/gpt-5.6-sol"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
const result = migrate({
|
||||
llm: {
|
||||
allowModelOverride: false,
|
||||
allowAuthProfileOverride: false,
|
||||
allowedModels: ["anthropic/claude-haiku-4-5"],
|
||||
},
|
||||
config: { allowedModels: ["openai/gpt-5.6-sol"] },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["llm-task"]).toEqual({
|
||||
expect(result.entry).toEqual({
|
||||
llm: {
|
||||
allowModelOverride: false,
|
||||
allowAuthProfileOverride: false,
|
||||
|
|
@ -77,46 +71,25 @@ describe("llm-task doctor contract", () => {
|
|||
});
|
||||
|
||||
it("keeps an explicit completion policy authoritative over the legacy key", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"llm-task": {
|
||||
llm: {
|
||||
allowModelOverride: true,
|
||||
allowAuthProfileOverride: true,
|
||||
allowedCompletionModels: ["anthropic/claude-haiku-4-5"],
|
||||
},
|
||||
config: { allowedModels: ["openai/gpt-5.6-sol"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
const result = migrate({
|
||||
llm: {
|
||||
allowModelOverride: true,
|
||||
allowAuthProfileOverride: true,
|
||||
allowedCompletionModels: ["anthropic/claude-haiku-4-5"],
|
||||
},
|
||||
config: { allowedModels: ["openai/gpt-5.6-sol"] },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["llm-task"]?.llm?.allowedCompletionModels).toEqual([
|
||||
"anthropic/claude-haiku-4-5",
|
||||
]);
|
||||
expect(result.entry?.llm?.allowedCompletionModels).toEqual(["anthropic/claude-haiku-4-5"]);
|
||||
expect(result.changes).toEqual([
|
||||
"Removed plugins.entries.llm-task.config.allowedModels; existing plugins.entries.llm-task.llm.allowedCompletionModels remains authoritative.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the unrestricted meaning of an empty legacy allowlist", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"llm-task": {
|
||||
enabled: true,
|
||||
config: { allowedModels: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = migrate({ enabled: true, config: { allowedModels: [] } });
|
||||
|
||||
expect(result.config.plugins?.entries?.["llm-task"]?.llm).toEqual({
|
||||
expect(result.entry?.llm).toEqual({
|
||||
allowModelOverride: true,
|
||||
allowAuthProfileOverride: true,
|
||||
});
|
||||
|
|
@ -126,55 +99,27 @@ describe("llm-task doctor contract", () => {
|
|||
});
|
||||
|
||||
it("does not give legacy literal wildcards or noncanonical refs new meaning", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"llm-task": {
|
||||
config: {
|
||||
allowedModels: ["*", " openai/gpt-5.4 ", "OpenAI/gpt-5.5", "openai/gpt-5.6"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
const result = migrate({
|
||||
config: {
|
||||
allowedModels: ["*", " openai/gpt-5.4 ", "OpenAI/gpt-5.5", "openai/gpt-5.6"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["llm-task"]?.llm?.allowedCompletionModels).toEqual([
|
||||
"openai/gpt-5.6",
|
||||
]);
|
||||
expect(result.entry?.llm?.allowedCompletionModels).toEqual(["openai/gpt-5.6"]);
|
||||
});
|
||||
|
||||
it("keeps a nonempty legacy wildcard-only allowlist fail closed", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"llm-task": { config: { allowedModels: ["*"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = migrate({ config: { allowedModels: ["*"] } });
|
||||
|
||||
expect(result.config.plugins?.entries?.["llm-task"]?.llm?.allowedCompletionModels).toEqual([]);
|
||||
expect(result.entry?.llm?.allowedCompletionModels).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["openai/gpt-5.6", null, [123, null]])(
|
||||
"keeps malformed legacy allowlist %j fail closed",
|
||||
(allowedModels) => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"llm-task": { config: { allowedModels } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
it.each([{ allowedModels: "openai/gpt-5.6" }, { allowedModels: [123, null] }])(
|
||||
"keeps malformed legacy allowlist $allowedModels fail closed",
|
||||
({ allowedModels }) => {
|
||||
const result = migrate({ config: { allowedModels } });
|
||||
|
||||
expect(result.config.plugins?.entries?.["llm-task"]?.llm?.allowedCompletionModels).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(result.entry?.llm?.allowedCompletionModels).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Llm Task tests cover llm task tool plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createLlmTaskTool } from "./llm-task-tool.js";
|
||||
|
||||
|
|
@ -25,26 +24,7 @@ function completionResult(params: Parameters<Complete>[0], text = "{}") {
|
|||
|
||||
const complete = vi.fn<Complete>(async (params) => completionResult(params));
|
||||
|
||||
const resolveThinkingPolicy = vi.fn(
|
||||
({ model, agentRuntime }: { model?: string | null; agentRuntime?: string | null }) => ({
|
||||
levels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "minimal", label: "minimal" },
|
||||
{ id: "low", label: "low" },
|
||||
{ id: "medium", label: "medium" },
|
||||
{ id: "high", label: "high" },
|
||||
...(model?.startsWith("gpt-5.6") &&
|
||||
(agentRuntime === "openclaw" || (agentRuntime === "codex" && !model.endsWith("-luna")))
|
||||
? [
|
||||
{ id: "max", label: "max" },
|
||||
{ id: "ultra", label: "ultra" },
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const normalizeThinkingLevel = vi.fn((raw?: string | null) => {
|
||||
function normalizeThinkingLevel(raw?: string | null) {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (!value) {
|
||||
return undefined;
|
||||
|
|
@ -58,7 +38,7 @@ const normalizeThinkingLevel = vi.fn((raw?: string | null) => {
|
|||
return value;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
function fakeApi(overrides: Record<string, unknown> = {}): LlmTaskApi {
|
||||
return {
|
||||
|
|
@ -81,7 +61,6 @@ function fakeApi(overrides: Record<string, unknown> = {}): LlmTaskApi {
|
|||
version: "test",
|
||||
agent: {
|
||||
defaults: { provider: "openai", model: "gpt-5.5" },
|
||||
resolveThinkingPolicy,
|
||||
normalizeThinkingLevel,
|
||||
},
|
||||
llm: { complete },
|
||||
|
|
@ -98,15 +77,8 @@ function mockIsolatedCompletionJson(payload: unknown) {
|
|||
);
|
||||
}
|
||||
|
||||
function resetRunnerMocks() {
|
||||
complete.mockReset();
|
||||
complete.mockImplementation(async (params) => completionResult(params));
|
||||
resolveThinkingPolicy.mockClear();
|
||||
normalizeThinkingLevel.mockClear();
|
||||
}
|
||||
|
||||
async function executeIsolatedCompletion(input: Record<string, unknown>) {
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
async function executeIsolatedCompletion(input: Record<string, unknown>, api = fakeApi()) {
|
||||
const tool = createLlmTaskTool(api);
|
||||
await tool.execute("id", input);
|
||||
return firstIsolatedCompletionCall();
|
||||
}
|
||||
|
|
@ -119,60 +91,25 @@ function firstIsolatedCompletionCall() {
|
|||
return call;
|
||||
}
|
||||
|
||||
function resultJson(result: unknown): unknown {
|
||||
if (!result || typeof result !== "object" || !("details" in result)) {
|
||||
throw new Error("expected tool result details");
|
||||
}
|
||||
const details = result.details;
|
||||
if (!details || typeof details !== "object" || !("json" in details)) {
|
||||
throw new Error("expected tool result JSON");
|
||||
}
|
||||
return details.json;
|
||||
}
|
||||
|
||||
describe("llm-task tool (json-only)", () => {
|
||||
let tool: ReturnType<typeof createLlmTaskTool>;
|
||||
beforeEach(() => {
|
||||
resetRunnerMocks();
|
||||
});
|
||||
|
||||
it("returns parsed json", async () => {
|
||||
mockIsolatedCompletionJson({ foo: "bar" });
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
const res = await tool.execute("id", { prompt: "return foo" });
|
||||
expect(resultJson(res)).toEqual({ foo: "bar" });
|
||||
complete.mockReset();
|
||||
complete.mockImplementation(async (params) => completionResult(params));
|
||||
tool = createLlmTaskTool(fakeApi());
|
||||
});
|
||||
|
||||
it("strips fenced json", async () => {
|
||||
complete.mockImplementationOnce(async (params) =>
|
||||
completionResult(params, '```json\n{"ok":true}\n```'),
|
||||
);
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
const res = await tool.execute("id", { prompt: "return ok" });
|
||||
expect(resultJson(res)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("validates schema", async () => {
|
||||
mockIsolatedCompletionJson({ foo: "bar" });
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { foo: { type: "string" } },
|
||||
required: ["foo"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
const res = await tool.execute("id", { prompt: "return foo", schema });
|
||||
expect(resultJson(res)).toEqual({ foo: "bar" });
|
||||
expect(res.details.json).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("validates caller schemas with repeated $id independently across calls", async () => {
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
complete
|
||||
.mockImplementationOnce(async (params) =>
|
||||
completionResult(params, JSON.stringify({ foo: "bar" })),
|
||||
)
|
||||
.mockImplementationOnce(async (params) =>
|
||||
completionResult(params, JSON.stringify({ count: 1 })),
|
||||
);
|
||||
mockIsolatedCompletionJson({ foo: "bar" });
|
||||
mockIsolatedCompletionJson({ count: 1 });
|
||||
|
||||
await expect(
|
||||
tool.execute("id", {
|
||||
|
|
@ -209,29 +146,16 @@ describe("llm-task tool (json-only)", () => {
|
|||
|
||||
it("throws on invalid json", async () => {
|
||||
complete.mockImplementationOnce(async (params) => completionResult(params, "not-json"));
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
await expect(tool.execute("id", { prompt: "x" })).rejects.toThrow(/invalid json/i);
|
||||
});
|
||||
|
||||
it("throws on schema mismatch", async () => {
|
||||
mockIsolatedCompletionJson({ foo: 1 });
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
const schema = { type: "object", properties: { foo: { type: "string" } }, required: ["foo"] };
|
||||
await expect(tool.execute("id", { prompt: "x", schema })).rejects.toThrow(/match schema/i);
|
||||
});
|
||||
|
||||
it("passes provider/model overrides to isolated completion", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({
|
||||
prompt: "x",
|
||||
provider: "anthropic",
|
||||
model: "claude-4-sonnet",
|
||||
});
|
||||
expect(call.model).toBe("anthropic/claude-4-sonnet");
|
||||
});
|
||||
|
||||
it("delegates unchanged default model selection to the host", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({ prompt: "x" });
|
||||
expect(call.model).toBeUndefined();
|
||||
});
|
||||
|
|
@ -246,7 +170,7 @@ describe("llm-task tool (json-only)", () => {
|
|||
owner: { kind: "cli", id: "google-gemini-cli" },
|
||||
},
|
||||
}));
|
||||
const result = await createLlmTaskTool(fakeApi()).execute("id", {
|
||||
const result = await tool.execute("id", {
|
||||
prompt: "x",
|
||||
provider: "google-gemini-cli",
|
||||
model: "flash",
|
||||
|
|
@ -263,7 +187,6 @@ describe("llm-task tool (json-only)", () => {
|
|||
});
|
||||
|
||||
it("accepts model overrides that already include the selected provider prefix", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({
|
||||
prompt: "x",
|
||||
provider: "anthropic",
|
||||
|
|
@ -273,7 +196,6 @@ describe("llm-task tool (json-only)", () => {
|
|||
});
|
||||
|
||||
it("does not misparse a slash-containing model id as a provider separator", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({
|
||||
prompt: "x",
|
||||
provider: "groq",
|
||||
|
|
@ -282,41 +204,24 @@ describe("llm-task tool (json-only)", () => {
|
|||
expect(call.model).toBe("groq/openai/gpt-oss-20b");
|
||||
});
|
||||
|
||||
it("preserves a configured provider for a slash-containing default model", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const tool = createLlmTaskTool(
|
||||
fakeApi({
|
||||
pluginConfig: {
|
||||
defaultProvider: "groq",
|
||||
defaultModel: "openai/gpt-oss-20b",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await tool.execute("id", { prompt: "x" });
|
||||
|
||||
expect(firstIsolatedCompletionCall().model).toBe("groq/openai/gpt-oss-20b");
|
||||
});
|
||||
|
||||
it("lets a qualified requested model override a configured provider", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const tool = createLlmTaskTool(
|
||||
fakeApi({
|
||||
pluginConfig: {
|
||||
defaultProvider: "groq",
|
||||
defaultModel: "openai/gpt-oss-20b",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await tool.execute("id", { prompt: "x", model: "google/gemini-3-flash-preview" });
|
||||
|
||||
expect(firstIsolatedCompletionCall().model).toBe("google/gemini-3-flash-preview");
|
||||
});
|
||||
it.each([
|
||||
{ model: undefined, expected: "groq/openai/gpt-oss-20b" },
|
||||
{ model: "google/gemini-3-flash-preview", expected: "google/gemini-3-flash-preview" },
|
||||
])(
|
||||
"resolves requested model $model against the configured provider",
|
||||
async ({ model, expected }) => {
|
||||
const call = await executeIsolatedCompletion(
|
||||
{ prompt: "x", model },
|
||||
fakeApi({
|
||||
pluginConfig: { defaultProvider: "groq", defaultModel: "openai/gpt-oss-20b" },
|
||||
}),
|
||||
);
|
||||
expect(call.model).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves configured model aliases before applying an explicit provider", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const tool = createLlmTaskTool(
|
||||
const aliasTool = createLlmTaskTool(
|
||||
fakeApi({
|
||||
config: {
|
||||
agents: {
|
||||
|
|
@ -332,7 +237,7 @@ describe("llm-task tool (json-only)", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
await tool.execute("id", {
|
||||
await aliasTool.execute("id", {
|
||||
prompt: "x",
|
||||
provider: "groq",
|
||||
model: "gemini-flash",
|
||||
|
|
@ -342,38 +247,7 @@ describe("llm-task tool (json-only)", () => {
|
|||
expect(call.model).toBe("google/gemini-3-flash-preview");
|
||||
});
|
||||
|
||||
it("resolves configured model aliases before dispatching isolated completion", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const tool = createLlmTaskTool(
|
||||
fakeApi({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/tmp",
|
||||
model: { primary: "anthropic/claude-sonnet-4-6" },
|
||||
models: {
|
||||
"google/gemini-3-flash-preview": { alias: "gemini-flash" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await tool.execute("id", { prompt: "x", model: "gemini-flash" });
|
||||
|
||||
const call = firstIsolatedCompletionCall();
|
||||
expect(call.model).toBe("google/gemini-3-flash-preview");
|
||||
});
|
||||
|
||||
it("passes thinking override to isolated completion", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({ prompt: "x", thinking: "high" });
|
||||
expect(call.reasoning).toBe("high");
|
||||
});
|
||||
|
||||
it("delegates model-specific Ultra validation to the host", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
|
|
@ -385,9 +259,9 @@ describe("llm-task tool (json-only)", () => {
|
|||
},
|
||||
},
|
||||
};
|
||||
const tool = createLlmTaskTool(fakeApi({ config }));
|
||||
const runtimeTool = createLlmTaskTool(fakeApi({ config }));
|
||||
|
||||
await tool.execute("id", {
|
||||
await runtimeTool.execute("id", {
|
||||
prompt: "x",
|
||||
provider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
|
|
@ -400,33 +274,23 @@ describe("llm-task tool (json-only)", () => {
|
|||
});
|
||||
|
||||
it("normalizes thinking aliases", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({ prompt: "x", thinking: "on" });
|
||||
expect(call.reasoning).toBe("low");
|
||||
});
|
||||
|
||||
it("throws on invalid thinking level", async () => {
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
await expect(tool.execute("id", { prompt: "x", thinking: "banana" })).rejects.toThrow(
|
||||
/invalid thinking level/i,
|
||||
);
|
||||
expect(complete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates model-specific xhigh validation to the host", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({ prompt: "x", thinking: "xhigh" });
|
||||
expect(call.reasoning).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("does not pass thinkLevel when thinking is omitted", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({ prompt: "x" });
|
||||
expect(call.reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not synthesize sampling hints when they are omitted", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({ prompt: "x" });
|
||||
expect(call.maxTokens).toBeUndefined();
|
||||
expect(call.temperature).toBeUndefined();
|
||||
|
|
@ -438,14 +302,12 @@ describe("llm-task tool (json-only)", () => {
|
|||
code: "LLM_COMPLETION_NOT_AUTHORIZED",
|
||||
}),
|
||||
);
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" }),
|
||||
).rejects.toThrow(/not allowlisted/i);
|
||||
});
|
||||
|
||||
it("uses the isolated-completion operation", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({ prompt: "x" });
|
||||
expect(call.execution).toEqual({ mode: "isolated-agent-runtime", timeoutMs: 30_000 });
|
||||
expect(call.systemPrompt).toContain("JSON-only");
|
||||
|
|
@ -461,14 +323,11 @@ describe("llm-task tool (json-only)", () => {
|
|||
return completionResult(params, '{"ok":true}');
|
||||
});
|
||||
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
await expect(tool.execute("id", { prompt: "x" }, controller.signal)).rejects.toBe(cancellation);
|
||||
expect(firstIsolatedCompletionCall().signal).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it("rejects malformed numeric run options before dispatch", async () => {
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
|
||||
await expect(tool.execute("id", { prompt: "x", temperature: Number.NaN })).rejects.toThrow(
|
||||
"temperature must be a finite number",
|
||||
);
|
||||
|
|
@ -481,28 +340,11 @@ describe("llm-task tool (json-only)", () => {
|
|||
expect(complete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes valid numeric run options before dispatch", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({
|
||||
prompt: "x",
|
||||
temperature: 0.2,
|
||||
maxTokens: 512,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(call.execution).toEqual({ mode: "isolated-agent-runtime", timeoutMs: 10_000 });
|
||||
expect(call.temperature).toBe(0.2);
|
||||
expect(call.maxTokens).toBe(512);
|
||||
});
|
||||
|
||||
it("normalizes numeric string run options before dispatch", async () => {
|
||||
mockIsolatedCompletionJson({ ok: true });
|
||||
const call = await executeIsolatedCompletion({
|
||||
prompt: "x",
|
||||
temperature: "0.2",
|
||||
maxTokens: "512",
|
||||
timeoutMs: "10000",
|
||||
});
|
||||
it.each([
|
||||
{ temperature: 0.2, maxTokens: 512, timeoutMs: 10_000 },
|
||||
{ temperature: "0.2", maxTokens: "512", timeoutMs: "10000" },
|
||||
])("normalizes numeric run options %j before dispatch", async (options) => {
|
||||
const call = await executeIsolatedCompletion({ prompt: "x", ...options });
|
||||
|
||||
expect(call.execution).toEqual({ mode: "isolated-agent-runtime", timeoutMs: 10_000 });
|
||||
expect(call.temperature).toBe(0.2);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Runway tests cover video generation provider plugin behavior.
|
||||
import {
|
||||
capturePluginRegistration,
|
||||
createRuntimeEnv,
|
||||
|
|
@ -9,6 +8,7 @@ import {
|
|||
installProviderHttpMockCleanup,
|
||||
} from "openclaw/plugin-sdk/provider-http-test-mocks";
|
||||
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import type { VideoGenerationRequest } from "openclaw/plugin-sdk/video-generation";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
|
||||
|
|
@ -21,69 +21,33 @@ beforeAll(async () => {
|
|||
|
||||
installProviderHttpMockCleanup();
|
||||
|
||||
function firstPostJsonRequest() {
|
||||
const [call] = postJsonRequestMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected Runway create request");
|
||||
}
|
||||
const [request] = call;
|
||||
if (!request || typeof request !== "object") {
|
||||
throw new Error("expected Runway create request options");
|
||||
}
|
||||
return request as { url?: string; body?: Record<string, unknown> };
|
||||
function generateVideo(request: Partial<VideoGenerationRequest> = {}) {
|
||||
return buildRunwayVideoGenerationProvider().generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "a tiny lobster DJ under neon lights",
|
||||
cfg: {},
|
||||
...request,
|
||||
});
|
||||
}
|
||||
|
||||
function firstFetchWithTimeoutCall() {
|
||||
const [call] = fetchWithTimeoutMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected Runway poll request");
|
||||
}
|
||||
const [url, init, timeoutMs, requestFetch] = call;
|
||||
if (typeof url !== "string") {
|
||||
throw new Error("expected Runway poll request URL");
|
||||
}
|
||||
if (!init || typeof init !== "object" || Array.isArray(init)) {
|
||||
throw new Error("expected Runway poll request init");
|
||||
}
|
||||
if (typeof timeoutMs !== "number") {
|
||||
throw new Error("expected Runway poll request timeout");
|
||||
}
|
||||
return {
|
||||
init: init as { method?: string; headers?: unknown },
|
||||
requestFetch,
|
||||
timeoutMs,
|
||||
url,
|
||||
};
|
||||
function mockTaskResponse(payload: unknown) {
|
||||
postJsonRequestMock.mockResolvedValueOnce({
|
||||
response: Response.json({ id: "task-1" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(Response.json(payload));
|
||||
}
|
||||
|
||||
function streamedVideoResponse(bytes: string): Response {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(bytes));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "video/mp4" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Response.json keeps object fixtures on the standard Response body path so create/poll
|
||||
// reads exercise the byte-bounded reader instead of an unbounded res.json().
|
||||
function streamedJsonResponse(payload: unknown): Response {
|
||||
return Response.json(payload);
|
||||
}
|
||||
|
||||
function streamedRawResponse(text: string): Response {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
function mockSuccessfulTask(
|
||||
video = new Response("mp4-bytes", { headers: { "content-type": "video/webm" } }),
|
||||
) {
|
||||
mockTaskResponse({
|
||||
id: "task-1",
|
||||
status: "SUCCEEDED",
|
||||
output: ["https://example.com/out.mp4"],
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(video);
|
||||
}
|
||||
|
||||
describe("runway video generation provider", () => {
|
||||
|
|
@ -137,365 +101,124 @@ describe("runway video generation provider", () => {
|
|||
it("submits a text-to-video task, polls it, and downloads the output", async () => {
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now());
|
||||
try {
|
||||
postJsonRequestMock.mockImplementation(async () => ({
|
||||
response: streamedJsonResponse({
|
||||
id: "task-1",
|
||||
mockSuccessfulTask();
|
||||
const result = await generateVideo({ durationSeconds: 4, aspectRatio: "16:9" });
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.dev.runwayml.com/v1/text_to_video",
|
||||
body: {
|
||||
model: "gen4.5",
|
||||
promptText: "a tiny lobster DJ under neon lights",
|
||||
ratio: "1280:720",
|
||||
duration: 4,
|
||||
},
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-1",
|
||||
status: "SUCCEEDED",
|
||||
output: ["https://example.com/out.mp4"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(Buffer.from("mp4-bytes"), {
|
||||
headers: new Headers({ "content-type": "video/webm" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = buildRunwayVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "a tiny lobster DJ under neon lights",
|
||||
cfg: {},
|
||||
durationSeconds: 4,
|
||||
aspectRatio: "16:9",
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledTimes(1);
|
||||
const createRequest = firstPostJsonRequest();
|
||||
expect(createRequest.url).toBe("https://api.dev.runwayml.com/v1/text_to_video");
|
||||
expect(JSON.stringify(createRequest.body)).toBe(
|
||||
'{"model":"gen4.5","promptText":"a tiny lobster DJ under neon lights","ratio":"1280:720","duration":4}',
|
||||
);
|
||||
expect(createRequest.body).toEqual({
|
||||
model: "gen4.5",
|
||||
promptText: "a tiny lobster DJ under neon lights",
|
||||
ratio: "1280:720",
|
||||
duration: 4,
|
||||
expect(fetchWithTimeoutMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://api.dev.runwayml.com/v1/tasks/task-1",
|
||||
expect.objectContaining({ method: "GET", headers: expect.any(Headers) }),
|
||||
120000,
|
||||
fetch,
|
||||
);
|
||||
expect(result.videos).toEqual([
|
||||
expect.objectContaining({ fileName: "video-1.webm", buffer: Buffer.from("mp4-bytes") }),
|
||||
]);
|
||||
expect(result.metadata).toMatchObject({
|
||||
taskId: "task-1",
|
||||
status: "SUCCEEDED",
|
||||
endpoint: "/v1/text_to_video",
|
||||
});
|
||||
const pollCall = firstFetchWithTimeoutCall();
|
||||
expect(pollCall.url).toBe("https://api.dev.runwayml.com/v1/tasks/task-1");
|
||||
expect(pollCall.init.method).toBe("GET");
|
||||
expect(pollCall.init.headers).toBeInstanceOf(Headers);
|
||||
expect(pollCall.timeoutMs).toBe(120000);
|
||||
expect(pollCall.requestFetch).toBe(fetch);
|
||||
expect(result.videos).toHaveLength(1);
|
||||
const video = result.videos[0];
|
||||
if (!video) {
|
||||
throw new Error("expected Runway generated video");
|
||||
}
|
||||
expect(video.fileName).toBe("video-1.webm");
|
||||
const metadata = result.metadata as Record<string, unknown>;
|
||||
expect(metadata.taskId).toBe("task-1");
|
||||
expect(metadata.status).toBe("SUCCEEDED");
|
||||
expect(metadata.endpoint).toBe("/v1/text_to_video");
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "JSON error", contentType: "application/json", body: '{"error":"denied"}' },
|
||||
{ name: "problem JSON", contentType: "application/problem+json", body: '{"title":"denied"}' },
|
||||
{ name: "HTML", contentType: "text/html; charset=utf-8", body: "<html>sign in</html>" },
|
||||
{ name: "empty video", contentType: "video/mp4", body: "" },
|
||||
])("rejects a successful $name response as generated video", async ({ contentType, body }) => {
|
||||
postJsonRequestMock.mockImplementation(async () => ({
|
||||
response: streamedJsonResponse({ id: "task-invalid-download" }),
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-invalid-download",
|
||||
status: "SUCCEEDED",
|
||||
output: ["https://example.com/invalid.mp4"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(body, { headers: { "content-type": contentType } }));
|
||||
it("rejects an empty generated video", async () => {
|
||||
mockSuccessfulTask(new Response("", { headers: { "content-type": "video/mp4" } }));
|
||||
|
||||
await expect(
|
||||
buildRunwayVideoGenerationProvider().generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "invalid download",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("Runway generated video download: malformed video response");
|
||||
});
|
||||
|
||||
it("cancels the unread response body when a generated-video MIME type is rejected", async () => {
|
||||
const canceled = vi.fn();
|
||||
postJsonRequestMock.mockImplementation(async () => ({
|
||||
response: streamedJsonResponse({ id: "task-open-response" }),
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-open-response",
|
||||
status: "SUCCEEDED",
|
||||
output: ["https://example.com/invalid.mp4"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}'));
|
||||
},
|
||||
cancel: canceled,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
buildRunwayVideoGenerationProvider().generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "open invalid response",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("Runway generated video download: malformed video response");
|
||||
expect(canceled).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases a rejected download body without awaiting a debug-capture tee branch", async () => {
|
||||
postJsonRequestMock.mockImplementation(async () => ({
|
||||
response: streamedJsonResponse({ id: "task-captured-response" }),
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
// The debug proxy clones every captured response, so the caller-facing body is one
|
||||
// branch of a live tee. Cancelling such a branch settles only once both branches
|
||||
// cancel, so awaiting it here would hang the download instead of surfacing the error.
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}'));
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
await expect(generateVideo()).rejects.toThrow(
|
||||
"Runway generated video download: malformed video response",
|
||||
);
|
||||
const captureClone = response.clone();
|
||||
const captureReader = captureClone.body?.getReader();
|
||||
await captureReader?.read();
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-captured-response",
|
||||
status: "SUCCEEDED",
|
||||
output: ["https://example.com/invalid.mp4"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(response);
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await expect(
|
||||
Promise.race([
|
||||
buildRunwayVideoGenerationProvider().generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "captured invalid response",
|
||||
cfg: {},
|
||||
}),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error("Runway download waited for a captured response clone"));
|
||||
}, 500);
|
||||
}),
|
||||
]),
|
||||
).rejects.toThrow("Runway generated video download: malformed video response");
|
||||
} finally {
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
await captureReader?.cancel().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects generated video downloads that exceed the configured media cap", async () => {
|
||||
postJsonRequestMock.mockImplementation(async () => ({
|
||||
response: streamedJsonResponse({ id: "task-too-large" }),
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-too-large",
|
||||
status: "SUCCEEDED",
|
||||
output: ["https://example.com/out.mp4"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
|
||||
mockSuccessfulTask(new Response("too-large", { headers: { "content-type": "video/mp4" } }));
|
||||
|
||||
const provider = buildRunwayVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "short video",
|
||||
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
|
||||
}),
|
||||
generateVideo({ cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } } }),
|
||||
).rejects.toThrow("Runway generated video download exceeds 1 bytes");
|
||||
});
|
||||
|
||||
it("does not round malformed duration values into create requests", async () => {
|
||||
postJsonRequestMock.mockImplementation(async () => ({
|
||||
response: streamedJsonResponse({ id: "task-duration" }),
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-duration",
|
||||
status: "SUCCEEDED",
|
||||
output: ["https://example.com/out.mp4"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(Buffer.from("mp4-bytes"), {
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
}),
|
||||
);
|
||||
mockSuccessfulTask();
|
||||
await generateVideo({ durationSeconds: 4.5, aspectRatio: "16:9" });
|
||||
|
||||
const provider = buildRunwayVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "a tiny lobster DJ under neon lights",
|
||||
cfg: {},
|
||||
durationSeconds: 4.5,
|
||||
aspectRatio: "16:9",
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstPostJsonRequest().body?.duration).toBe(5);
|
||||
expect(postJsonRequestMock).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ body: expect.objectContaining({ duration: 5 }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts local image buffers by converting them into data URIs", async () => {
|
||||
postJsonRequestMock.mockImplementation(async () => ({
|
||||
response: streamedJsonResponse({ id: "task-2" }),
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-2",
|
||||
status: "SUCCEEDED",
|
||||
output: ["https://example.com/out.mp4"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(Buffer.from("mp4-bytes"), {
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = buildRunwayVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "runway",
|
||||
mockSuccessfulTask();
|
||||
await generateVideo({
|
||||
model: "gen4_turbo",
|
||||
prompt: "animate this frame",
|
||||
cfg: {},
|
||||
inputImages: [{ buffer: Buffer.from("png-bytes"), mimeType: "image/png" }],
|
||||
aspectRatio: "1:1",
|
||||
durationSeconds: 6,
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledTimes(1);
|
||||
const request = firstPostJsonRequest();
|
||||
expect(request.url).toBe("https://api.dev.runwayml.com/v1/image_to_video");
|
||||
expect(request.body?.promptImage).toMatch(/^data:image\/png;base64,/u);
|
||||
expect(request.body?.ratio).toBe("960:960");
|
||||
expect(request.body?.duration).toBe(6);
|
||||
expect(postJsonRequestMock).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.dev.runwayml.com/v1/image_to_video",
|
||||
body: expect.objectContaining({
|
||||
promptImage: "data:image/png;base64,cG5nLWJ5dGVz",
|
||||
ratio: "960:960",
|
||||
duration: 6,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires gen4_aleph for video-to-video", async () => {
|
||||
const provider = buildRunwayVideoGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "restyle this clip",
|
||||
cfg: {},
|
||||
inputVideos: [{ url: "https://example.com/input.mp4" }],
|
||||
}),
|
||||
generateVideo({ inputVideos: [{ url: "https://example.com/input.mp4" }] }),
|
||||
).rejects.toThrow("Runway video-to-video currently requires model gen4_aleph.");
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports malformed create JSON with a provider-owned error", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock.mockImplementation(async () => ({
|
||||
response: streamedRawResponse("{ not json"),
|
||||
postJsonRequestMock.mockResolvedValueOnce({
|
||||
response: new Response("{ not json", { headers: { "content-type": "application/json" } }),
|
||||
release,
|
||||
}));
|
||||
});
|
||||
|
||||
const provider = buildRunwayVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "bad create response",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("Runway video generation failed: malformed JSON response");
|
||||
await expect(generateVideo()).rejects.toThrow(
|
||||
"Runway video generation failed: malformed JSON response",
|
||||
);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects status responses missing a task status", async () => {
|
||||
postJsonRequestMock.mockImplementation(async () => ({
|
||||
response: streamedJsonResponse({ id: "task-missing-status" }),
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-missing-status",
|
||||
output: ["https://example.com/out.mp4"],
|
||||
}),
|
||||
);
|
||||
mockTaskResponse({ id: "task-1", output: ["https://example.com/out.mp4"] });
|
||||
|
||||
const provider = buildRunwayVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "missing status",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("Runway video status response missing task status");
|
||||
await expect(generateVideo()).rejects.toThrow(
|
||||
"Runway video status response missing task status",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed completed output URLs", async () => {
|
||||
postJsonRequestMock.mockImplementation(async () => ({
|
||||
response: streamedJsonResponse({ id: "task-malformed-output" }),
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-malformed-output",
|
||||
status: "SUCCEEDED",
|
||||
output: "https://example.com/out.mp4",
|
||||
}),
|
||||
);
|
||||
mockTaskResponse({
|
||||
id: "task-1",
|
||||
status: "SUCCEEDED",
|
||||
output: "https://example.com/out.mp4",
|
||||
});
|
||||
|
||||
const provider = buildRunwayVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "runway",
|
||||
model: "gen4.5",
|
||||
prompt: "malformed output",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("Runway video generation completed with malformed output URLs");
|
||||
await expect(generateVideo()).rejects.toThrow(
|
||||
"Runway video generation completed with malformed output URLs",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,37 +1,29 @@
|
|||
// Zai tests cover detect plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { detectZaiEndpoint } from "./detect.js";
|
||||
import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js";
|
||||
|
||||
type FetchResponse = { status: number; body?: unknown };
|
||||
type FetchResponse = {
|
||||
status: number;
|
||||
body?: unknown;
|
||||
raw?: string;
|
||||
bytes?: Uint8Array;
|
||||
};
|
||||
|
||||
const ZAI_DETECT_ERROR_BODY_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Builds a streaming error Response whose body is far larger than the 16 MiB cap.
|
||||
* Tracks how many bytes were actually pulled and whether the consumer cancelled
|
||||
* the stream, so tests can prove the read is bounded (fail-closed) rather than
|
||||
* draining the whole untrusted body into memory.
|
||||
*/
|
||||
function makeOversizedStreamFetch(params: {
|
||||
url: string;
|
||||
status: number;
|
||||
chunkBytes?: number;
|
||||
hardCeilingBytes?: number;
|
||||
}) {
|
||||
const chunkBytes = params.chunkBytes ?? 1024 * 1024;
|
||||
const hardCeilingBytes = params.hardCeilingBytes ?? 64 * 1024 * 1024;
|
||||
function makeOversizedStreamFetch() {
|
||||
const chunkBytes = 1024 * 1024;
|
||||
const hardCeilingBytes = 64 * 1024 * 1024;
|
||||
const state = { enqueuedBytes: 0, cancelled: false };
|
||||
|
||||
const fetchFn = (async (url: string) => {
|
||||
if (url !== params.url) {
|
||||
if (url !== "https://api.z.ai/api/paas/v4/chat/completions") {
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
}
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
// Bound the fixture even if the production reader stops enforcing its cap.
|
||||
if (state.enqueuedBytes >= hardCeilingBytes) {
|
||||
// Safety stop: with an unbounded reader this point would be reached
|
||||
// (and the test would fail on the bounded-bytes assertion below).
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
|
@ -43,7 +35,7 @@ function makeOversizedStreamFetch(params: {
|
|||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: params.status,
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
|
@ -51,36 +43,7 @@ function makeOversizedStreamFetch(params: {
|
|||
return { fetchFn, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fetch returning a single raw (possibly non-JSON) error body, keyed by
|
||||
* `${url}::${model}`. Used to drive the new bounded decode path with small,
|
||||
* well-formed, empty, and malformed sub-cap bodies that must behave exactly as
|
||||
* the previous `res.json()` path did.
|
||||
*/
|
||||
function makeRawBodyFetch(map: Record<string, { status: number; raw: string }>) {
|
||||
return (async (url: string, init?: RequestInit) => {
|
||||
const rawBody = typeof init?.body === "string" ? JSON.parse(init.body) : null;
|
||||
const entry = map[`${url}::${rawBody?.model ?? ""}`] ?? map[url];
|
||||
if (!entry) {
|
||||
throw new Error(`unexpected url: ${url} model=${String(rawBody?.model ?? "")}`);
|
||||
}
|
||||
return new Response(entry.raw, {
|
||||
status: entry.status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fetch returning a single raw byte body, keyed by `${url}::${model}`.
|
||||
* Unlike {@link makeRawBodyFetch} this takes bytes, so it can express a body
|
||||
* that is not valid UTF-8 at all. `calls` records the probed model ids so tests
|
||||
* can assert how far the probe advanced.
|
||||
*/
|
||||
function makeRawBytesFetch(
|
||||
map: Record<string, { status: number; bytes: Uint8Array }>,
|
||||
calls?: string[],
|
||||
) {
|
||||
function makeFetch(map: Record<string, FetchResponse>, calls?: string[]) {
|
||||
return (async (url: string, init?: RequestInit) => {
|
||||
const rawBody = typeof init?.body === "string" ? JSON.parse(init.body) : null;
|
||||
calls?.push(String(rawBody?.model ?? ""));
|
||||
|
|
@ -88,10 +51,10 @@ function makeRawBytesFetch(
|
|||
if (!entry) {
|
||||
throw new Error(`unexpected url: ${url} model=${String(rawBody?.model ?? "")}`);
|
||||
}
|
||||
// Copy into a fresh ArrayBuffer-backed view: BodyInit rejects the
|
||||
// ArrayBufferLike-backed Uint8Array that Buffer/TextEncoder can produce.
|
||||
const body = new Uint8Array(new ArrayBuffer(entry.bytes.byteLength));
|
||||
body.set(entry.bytes);
|
||||
// Copy byte fixtures into an ArrayBuffer-backed view accepted by BodyInit.
|
||||
const body = entry.bytes
|
||||
? new Uint8Array(entry.bytes)
|
||||
: (entry.raw ?? JSON.stringify(entry.body ?? {}));
|
||||
return new Response(body, {
|
||||
status: entry.status,
|
||||
headers: { "content-type": "application/json" },
|
||||
|
|
@ -99,133 +62,94 @@ function makeRawBytesFetch(
|
|||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function makeFetch(map: Record<string, FetchResponse>) {
|
||||
return (async (url: string, init?: RequestInit) => {
|
||||
const rawBody = typeof init?.body === "string" ? JSON.parse(init.body) : null;
|
||||
const entry = map[`${url}::${rawBody?.model ?? ""}`] ?? map[url];
|
||||
if (!entry) {
|
||||
throw new Error(`unexpected url: ${url} model=${String(rawBody?.model ?? "")}`);
|
||||
}
|
||||
const json = entry.body ?? {};
|
||||
return new Response(JSON.stringify(json), {
|
||||
status: entry.status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
describe("detectZaiEndpoint", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolves preferred/fallback endpoints and null when probes fail", async () => {
|
||||
const urls = {
|
||||
global: "https://api.z.ai/api/paas/v4/chat/completions",
|
||||
cn: "https://open.bigmodel.cn/api/paas/v4/chat/completions",
|
||||
"coding-global": "https://api.z.ai/api/coding/paas/v4/chat/completions",
|
||||
"coding-cn": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
|
||||
};
|
||||
const scenarios: Array<{
|
||||
endpoint?: "global" | "cn" | "coding-global" | "coding-cn";
|
||||
responses: Record<string, { status: number; body?: unknown }>;
|
||||
endpoint?: ZaiEndpointId;
|
||||
responses: Array<[ZaiEndpointId, string, number, unknown?]>;
|
||||
expected: { endpoint: string; modelId: string } | null;
|
||||
}> = [
|
||||
{
|
||||
responses: {
|
||||
"https://api.z.ai/api/paas/v4/chat/completions::glm-5.2": { status: 200 },
|
||||
},
|
||||
responses: [["global", "glm-5.2", 200]],
|
||||
expected: { endpoint: "global", modelId: "glm-5.2" },
|
||||
},
|
||||
{
|
||||
responses: {
|
||||
"https://api.z.ai/api/paas/v4/chat/completions::glm-5.2": { status: 404 },
|
||||
"https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5.2": { status: 200 },
|
||||
},
|
||||
responses: [
|
||||
["global", "glm-5.2", 404],
|
||||
["cn", "glm-5.2", 200],
|
||||
],
|
||||
expected: { endpoint: "cn", modelId: "glm-5.2" },
|
||||
},
|
||||
{
|
||||
responses: {
|
||||
"https://api.z.ai/api/paas/v4/chat/completions::glm-5.2": { status: 404 },
|
||||
"https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5.2": { status: 404 },
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.3": { status: 200 },
|
||||
},
|
||||
responses: [
|
||||
["global", "glm-5.2", 404],
|
||||
["cn", "glm-5.2", 404],
|
||||
["coding-global", "glm-5.3", 200],
|
||||
],
|
||||
expected: { endpoint: "coding-global", modelId: "glm-5.3" },
|
||||
},
|
||||
{
|
||||
endpoint: "coding-global",
|
||||
responses: {
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.3": {
|
||||
status: 400,
|
||||
body: { code: 1311, msg: "model not included in the current plan" },
|
||||
},
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.1": {
|
||||
status: 400,
|
||||
body: { code: 1211, msg: "model does not exist" },
|
||||
},
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-4.7": { status: 200 },
|
||||
},
|
||||
responses: [
|
||||
[
|
||||
"coding-global",
|
||||
"glm-5.3",
|
||||
400,
|
||||
{ code: 1311, msg: "model not included in the current plan" },
|
||||
],
|
||||
["coding-global", "glm-5.1", 400, { code: 1211, msg: "model does not exist" }],
|
||||
["coding-global", "glm-4.7", 200],
|
||||
],
|
||||
expected: { endpoint: "coding-global", modelId: "glm-4.7" },
|
||||
},
|
||||
{
|
||||
endpoint: "coding-global",
|
||||
responses: {
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.3": {
|
||||
status: 429,
|
||||
body: { error: { message: "rate limited" } },
|
||||
},
|
||||
},
|
||||
responses: [["coding-global", "glm-5.3", 429, { error: { message: "rate limited" } }]],
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
endpoint: "coding-cn",
|
||||
responses: {
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.3": {
|
||||
status: 200,
|
||||
},
|
||||
},
|
||||
responses: [["coding-cn", "glm-5.3", 200]],
|
||||
expected: { endpoint: "coding-cn", modelId: "glm-5.3" },
|
||||
},
|
||||
{
|
||||
endpoint: "coding-cn",
|
||||
responses: {
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.3": {
|
||||
status: 404,
|
||||
},
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.1": {
|
||||
status: 200,
|
||||
},
|
||||
},
|
||||
responses: [
|
||||
["coding-cn", "glm-5.3", 404],
|
||||
["coding-cn", "glm-5.1", 200],
|
||||
],
|
||||
expected: { endpoint: "coding-cn", modelId: "glm-5.1" },
|
||||
},
|
||||
{
|
||||
endpoint: "coding-cn",
|
||||
responses: {
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.3": {
|
||||
status: 404,
|
||||
body: { error: { message: "glm-5.3 unavailable" } },
|
||||
},
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.1": {
|
||||
status: 404,
|
||||
body: { error: { message: "glm-5.1 unavailable" } },
|
||||
},
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-4.7": {
|
||||
status: 200,
|
||||
},
|
||||
},
|
||||
responses: [
|
||||
["coding-cn", "glm-5.3", 404, { error: { message: "glm-5.3 unavailable" } }],
|
||||
["coding-cn", "glm-5.1", 404, { error: { message: "glm-5.1 unavailable" } }],
|
||||
["coding-cn", "glm-4.7", 200],
|
||||
],
|
||||
expected: { endpoint: "coding-cn", modelId: "glm-4.7" },
|
||||
},
|
||||
{
|
||||
responses: {
|
||||
"https://api.z.ai/api/paas/v4/chat/completions::glm-5.2": { status: 401 },
|
||||
"https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5.2": { status: 401 },
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.3": { status: 401 },
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.1": { status: 401 },
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-4.7": { status: 401 },
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.3": {
|
||||
status: 401,
|
||||
},
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.1": {
|
||||
status: 401,
|
||||
},
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-4.7": {
|
||||
status: 401,
|
||||
},
|
||||
},
|
||||
responses: [
|
||||
["global", "glm-5.2", 401],
|
||||
["cn", "glm-5.2", 401],
|
||||
["coding-global", "glm-5.3", 401],
|
||||
["coding-global", "glm-5.1", 401],
|
||||
["coding-global", "glm-4.7", 401],
|
||||
["coding-cn", "glm-5.3", 401],
|
||||
["coding-cn", "glm-5.1", 401],
|
||||
["coding-cn", "glm-4.7", 401],
|
||||
],
|
||||
expected: null,
|
||||
},
|
||||
];
|
||||
|
|
@ -234,7 +158,14 @@ describe("detectZaiEndpoint", () => {
|
|||
const detected = await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
...(scenario.endpoint ? { endpoint: scenario.endpoint } : {}),
|
||||
fetchFn: makeFetch(scenario.responses),
|
||||
fetchFn: makeFetch(
|
||||
Object.fromEntries(
|
||||
scenario.responses.map(([endpoint, model, status, body]) => [
|
||||
`${urls[endpoint]}::${model}`,
|
||||
{ status, body },
|
||||
]),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
if (scenario.expected === null) {
|
||||
|
|
@ -265,15 +196,11 @@ describe("detectZaiEndpoint", () => {
|
|||
});
|
||||
|
||||
it("still parses well-formed sub-cap error bodies to drive endpoint classification", async () => {
|
||||
// Happy path: model-not-found errors must still be decoded from the bounded
|
||||
// body so the probe classifies them as unsupported and walks to the GLM-4.7
|
||||
// fallback. The error message that drives classification lives only inside
|
||||
// the body, so a passing fallback proves the new bounded reader decoded it.
|
||||
const codingGlobal = "https://api.z.ai/api/coding/paas/v4/chat/completions";
|
||||
const detected = await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
endpoint: "coding-global",
|
||||
fetchFn: makeRawBodyFetch({
|
||||
fetchFn: makeFetch({
|
||||
[`${codingGlobal}::glm-5.3`]: {
|
||||
status: 400,
|
||||
raw: JSON.stringify({ error: { message: "model not found for this plan" } }),
|
||||
|
|
@ -291,15 +218,11 @@ describe("detectZaiEndpoint", () => {
|
|||
});
|
||||
|
||||
it("swallows malformed and empty sub-cap error bodies and falls back on status", async () => {
|
||||
// Regression: a non-JSON or empty error body must not throw out of the
|
||||
// probe. JSON.parse fails, the existing try/catch swallows it, and the
|
||||
// probe degrades to status-only classification (404 => unsupported model),
|
||||
// so the GLM-4.7 fallback still resolves exactly as before.
|
||||
const codingGlobal = "https://api.z.ai/api/coding/paas/v4/chat/completions";
|
||||
const detected = await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
endpoint: "coding-global",
|
||||
fetchFn: makeRawBodyFetch({
|
||||
fetchFn: makeFetch({
|
||||
[`${codingGlobal}::glm-5.3`]: { status: 404, raw: "<html>gateway error</html>" },
|
||||
[`${codingGlobal}::glm-5.1`]: { status: 404, raw: "" },
|
||||
[`${codingGlobal}::glm-4.7`]: { status: 200, raw: "{}" },
|
||||
|
|
@ -311,31 +234,21 @@ describe("detectZaiEndpoint", () => {
|
|||
});
|
||||
|
||||
it("rejects sub-cap error bodies that are not valid UTF-8 instead of classifying substituted text", async () => {
|
||||
// Regression: a non-fatal TextDecoder replaced malformed bytes with U+FFFD,
|
||||
// so JSON.parse succeeded on a body that was never valid UTF-8 and the
|
||||
// substituted text was consumed as a genuine error code. A corrupt body must
|
||||
// now be swallowed by the same try/catch as a non-JSON body, so the probe
|
||||
// can no longer treat fabricated text as an "unsupported model" signal.
|
||||
const codingGlobal = "https://api.z.ai/api/coding/paas/v4/chat/completions";
|
||||
// `{"code":1211,...}` with one continuation byte of a multibyte char replaced,
|
||||
// so the body is invalid UTF-8 but becomes parseable once substituted.
|
||||
// Invalid UTF-8 must not turn into a trusted error code via replacement characters.
|
||||
const malformed = new TextEncoder().encode('{"code":1211,"msg":"x\u{1F99E}"}');
|
||||
const corrupt = new Uint8Array(malformed);
|
||||
const lobsterStart = corrupt.indexOf(0xf0);
|
||||
expect(lobsterStart).toBeGreaterThan(-1);
|
||||
corrupt[lobsterStart + 1] = 0x28;
|
||||
// Prove the fixture really is rejected by a fatal decode. (Bare TextDecoder is
|
||||
// the pre-fix behavior under test, so it is asserted via its substitution.)
|
||||
expect(new TextDecoder().decode(corrupt)).toContain("\uFFFD");
|
||||
|
||||
const calls: string[] = [];
|
||||
const detected = await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
endpoint: "coding-global",
|
||||
fetchFn: makeRawBytesFetch(
|
||||
fetchFn: makeFetch(
|
||||
{
|
||||
// Status 400 with a corrupt body: the code inside is NOT trustworthy, so
|
||||
// it must not advance the probe to the next candidate model.
|
||||
[`${codingGlobal}::glm-5.3`]: { status: 400, bytes: corrupt },
|
||||
[`${codingGlobal}::glm-5.1`]: { status: 400, bytes: corrupt },
|
||||
[`${codingGlobal}::glm-4.7`]: { status: 200, bytes: new TextEncoder().encode("{}") },
|
||||
|
|
@ -344,16 +257,11 @@ describe("detectZaiEndpoint", () => {
|
|||
),
|
||||
});
|
||||
|
||||
// The corrupt body classifies nothing, so the probe stops at the first
|
||||
// candidate instead of walking on to the GLM-4.7 fallback.
|
||||
expect(calls).toEqual(["glm-5.3"]);
|
||||
expect(detected).toBeNull();
|
||||
});
|
||||
|
||||
it("still classifies well-formed multibyte error bodies (fatal decode does not regress valid UTF-8)", async () => {
|
||||
// Guard for the fix above: valid multibyte content must keep decoding, so the
|
||||
// fatal decoder cannot be rejecting legitimate non-ASCII bodies. A 400 whose
|
||||
// message says the model does not exist must still advance to the fallback.
|
||||
const codingGlobal = "https://api.z.ai/api/coding/paas/v4/chat/completions";
|
||||
const valid = new TextEncoder().encode(
|
||||
'{"error":{"code":1211,"message":"model \u4e0d\u5b58\u5728 \u{1F99E}"}}',
|
||||
|
|
@ -362,7 +270,7 @@ describe("detectZaiEndpoint", () => {
|
|||
const detected = await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
endpoint: "coding-global",
|
||||
fetchFn: makeRawBytesFetch({
|
||||
fetchFn: makeFetch({
|
||||
[`${codingGlobal}::glm-5.3`]: { status: 400, bytes: valid },
|
||||
[`${codingGlobal}::glm-5.1`]: { status: 400, bytes: valid },
|
||||
[`${codingGlobal}::glm-4.7`]: { status: 200, bytes: new TextEncoder().encode("{}") },
|
||||
|
|
@ -374,10 +282,7 @@ describe("detectZaiEndpoint", () => {
|
|||
});
|
||||
|
||||
it("fails closed on oversized probe error bodies without buffering unbounded", async () => {
|
||||
const { fetchFn, state } = makeOversizedStreamFetch({
|
||||
url: "https://api.z.ai/api/paas/v4/chat/completions",
|
||||
status: 400,
|
||||
});
|
||||
const { fetchFn, state } = makeOversizedStreamFetch();
|
||||
|
||||
const detected = await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
|
|
@ -385,11 +290,7 @@ describe("detectZaiEndpoint", () => {
|
|||
fetchFn,
|
||||
});
|
||||
|
||||
// Probe swallows the bounded-read overflow and falls back to status-only,
|
||||
// so the oversized error body cannot promote this endpoint.
|
||||
expect(detected).toBeNull();
|
||||
// The stream was cancelled (fail-closed) instead of being drained to the
|
||||
// 64 MiB safety ceiling, proving the read stops near the 16 MiB cap.
|
||||
expect(state.cancelled).toBe(true);
|
||||
expect(state.enqueuedBytes).toBeLessThanOrEqual(
|
||||
ZAI_DETECT_ERROR_BODY_MAX_BYTES + 2 * 1024 * 1024,
|
||||
|
|
@ -440,17 +341,12 @@ describe("detectZaiEndpoint", () => {
|
|||
});
|
||||
|
||||
it("fails closed when a probe error body stalls without chunks", async () => {
|
||||
// Headers return 400, but the error body never enqueues. Without
|
||||
// the whole-body deadline the probe would hang indefinitely.
|
||||
const fetchFn = (async (url: string) => {
|
||||
if (url !== "https://api.z.ai/api/paas/v4/chat/completions") {
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
}
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start() {
|
||||
// Intentionally never enqueue or close — idle timeout must fire.
|
||||
},
|
||||
});
|
||||
// Headers arrived, but the body never produces a chunk or closes.
|
||||
const body = new ReadableStream<Uint8Array>();
|
||||
return new Response(body, {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
|
|
@ -468,8 +364,6 @@ describe("detectZaiEndpoint", () => {
|
|||
const elapsedMs = Date.now() - startedAt;
|
||||
|
||||
expect(detected).toBeNull();
|
||||
// The probe must fail within the deadline budget, not hang indefinitely.
|
||||
// Allow 2× the timeout for scheduling overhead; a hang would take seconds.
|
||||
expect(elapsedMs).toBeLessThan(2 * timeoutMs);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// Zai tests cover index plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { Context, Model } from "openclaw/plugin-sdk/llm";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime";
|
||||
|
|
@ -12,7 +12,7 @@ import { describe, expect, it } from "vitest";
|
|||
import plugin from "./index.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
function createGlm47Template() {
|
||||
function createGlm47Template(): Model<"openai-completions"> {
|
||||
return {
|
||||
id: "glm-4.7",
|
||||
name: "GLM-4.7",
|
||||
|
|
@ -27,30 +27,44 @@ function createGlm47Template() {
|
|||
};
|
||||
}
|
||||
|
||||
function expectReplayPolicyFields(
|
||||
policy: Record<string, unknown> | undefined,
|
||||
fields: Record<string, unknown>,
|
||||
): void {
|
||||
if (!policy) {
|
||||
throw new Error("Expected replay policy");
|
||||
}
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
expect(policy[key]).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
function expectModelFields(
|
||||
function expectFields(
|
||||
model: Record<string, unknown> | undefined,
|
||||
fields: Record<string, unknown>,
|
||||
): void {
|
||||
if (!model) {
|
||||
throw new Error("Expected provider model");
|
||||
throw new Error("Expected provider result");
|
||||
}
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
expect(model[key]).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureStreamPayload(
|
||||
params: Pick<ProviderWrapStreamFnContext, "extraParams" | "thinkingLevel"> & {
|
||||
modelId?: string;
|
||||
} = {},
|
||||
) {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const modelId = params.modelId ?? "glm-5.1";
|
||||
const payload: Record<string, unknown> = {};
|
||||
const streamFn: StreamFn = (model, _context, options) => {
|
||||
options?.onPayload?.(payload, model);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId,
|
||||
extraParams: {},
|
||||
...params,
|
||||
streamFn,
|
||||
});
|
||||
if (!wrapped) {
|
||||
throw new Error("Expected Z.AI stream wrapper");
|
||||
}
|
||||
void wrapped({ ...createGlm47Template(), id: modelId }, { messages: [] }, {});
|
||||
return payload;
|
||||
}
|
||||
|
||||
describe("zai provider plugin", () => {
|
||||
it("preserves all regional auth choices and the exact manifest-owned static catalog", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
|
@ -75,7 +89,7 @@ describe("zai provider plugin", () => {
|
|||
it("owns replay policy for OpenAI-compatible Z.ai transports", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expectReplayPolicyFields(
|
||||
expectFields(
|
||||
provider.buildReplayPolicy?.({
|
||||
provider: "zai",
|
||||
modelApi: "openai-completions",
|
||||
|
|
@ -90,7 +104,7 @@ describe("zai provider plugin", () => {
|
|||
},
|
||||
);
|
||||
|
||||
expectReplayPolicyFields(
|
||||
expectFields(
|
||||
provider.buildReplayPolicy?.({
|
||||
provider: "zai",
|
||||
modelApi: "openai-responses",
|
||||
|
|
@ -106,114 +120,63 @@ describe("zai provider plugin", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("resolves persisted GLM-5 family models with provider-owned metadata", async () => {
|
||||
it("resolves persisted GLM-5 metadata through selected provider endpoints", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const template = createGlm47Template();
|
||||
|
||||
const global = "https://api.z.ai/api/paas/v4";
|
||||
const coding = "https://api.z.ai/api/coding/paas/v4";
|
||||
const billedCost = { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 };
|
||||
const turboCost = { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 };
|
||||
const cases = [
|
||||
{
|
||||
modelId: "glm-5.3",
|
||||
providerBaseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
expected: {
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 131_072,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: "glm-5.3-flash",
|
||||
providerBaseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
expected: {
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
input: ["text", "image"],
|
||||
reasoning: true,
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 131_072,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: "glm-5.2",
|
||||
providerBaseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
expected: {
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 131_072,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: "glm-5.1",
|
||||
providerBaseUrl: "https://api.z.ai/api/paas/v4",
|
||||
expected: {
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 131_072,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: "glm-5v-turbo",
|
||||
providerBaseUrl: "https://api.z.ai/api/paas/v4",
|
||||
expected: {
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
input: ["text", "image"],
|
||||
reasoning: true,
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 131_072,
|
||||
},
|
||||
},
|
||||
[
|
||||
"glm-5.3",
|
||||
coding,
|
||||
["text"],
|
||||
1_048_576,
|
||||
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
],
|
||||
[
|
||||
"glm-5.3-flash",
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
["text", "image"],
|
||||
1_048_576,
|
||||
{ input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 },
|
||||
],
|
||||
["glm-5.2", "https://proxy.example.test/zai", ["text"], 1_000_000, billedCost],
|
||||
["glm-5.1", global, ["text"], 200_000, billedCost],
|
||||
["glm-5v-turbo", global, ["text", "image"], 200_000, turboCost],
|
||||
["glm-5-turbo", global, ["text"], 200_000, turboCost],
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
for (const [modelId, baseUrl, input, contextWindow, cost] of cases) {
|
||||
const resolved = provider.resolveDynamicModel?.({
|
||||
provider: "zai",
|
||||
modelId: testCase.modelId,
|
||||
modelId,
|
||||
modelRegistry: {
|
||||
find: (_provider: string, modelId: string) => (modelId === "glm-4.7" ? template : null),
|
||||
},
|
||||
providerConfig: { baseUrl: testCase.providerBaseUrl },
|
||||
} as never) as Record<string, unknown> | undefined;
|
||||
expectModelFields(resolved, {
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
id: testCase.modelId,
|
||||
...testCase.expected,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps selected Coding Plan and proxy endpoints for dynamic GLM-5 models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const template = createGlm47Template();
|
||||
|
||||
for (const baseUrl of [
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
"https://proxy.example.test/zai",
|
||||
]) {
|
||||
const resolved = provider.resolveDynamicModel?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.2",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, modelId: string) => (modelId === "glm-4.7" ? template : null),
|
||||
find: (_provider: string, id: string) => (id === "glm-4.7" ? template : null),
|
||||
},
|
||||
providerConfig: { baseUrl },
|
||||
} as never) as Record<string, unknown> | undefined;
|
||||
expect(resolved?.baseUrl).toBe(baseUrl);
|
||||
expectFields(resolved, {
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
id: modelId,
|
||||
baseUrl,
|
||||
input,
|
||||
reasoning: true,
|
||||
contextWindow,
|
||||
maxTokens: 131_072,
|
||||
cost,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an already-registered GLM-5 variant as-is", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const registered = {
|
||||
...createGlm47Template(),
|
||||
id: "glm-5-turbo",
|
||||
name: "GLM-5-Turbo",
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0 },
|
||||
|
|
@ -244,7 +207,7 @@ describe("zai provider plugin", () => {
|
|||
find: () => null,
|
||||
},
|
||||
} as never) as Record<string, unknown> | undefined;
|
||||
expectModelFields(resolved, {
|
||||
expectFields(resolved, {
|
||||
id: "glm-5.3",
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
|
|
@ -265,7 +228,7 @@ describe("zai provider plugin", () => {
|
|||
find: (_provider: string, modelId: string) => (modelId === "glm-4.7" ? template : null),
|
||||
},
|
||||
} as never) as Record<string, unknown> | undefined;
|
||||
expectModelFields(resolved, {
|
||||
expectFields(resolved, {
|
||||
id: "glm-5.4-preview",
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
|
|
@ -276,52 +239,10 @@ describe("zai provider plugin", () => {
|
|||
});
|
||||
|
||||
it("wires tool-stream defaults through the shared stream family hook", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
capturedPayload = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const defaultWrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: {},
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void defaultWrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
expect((await captureStreamPayload()).tool_stream).toBe(true);
|
||||
expect(await captureStreamPayload({ extraParams: { tool_stream: false } })).not.toHaveProperty(
|
||||
"tool_stream",
|
||||
);
|
||||
|
||||
expect(capturedPayload?.tool_stream).toBe(true);
|
||||
|
||||
const disabledWrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: { tool_stream: false },
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void disabledWrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload).not.toHaveProperty("tool_stream");
|
||||
});
|
||||
|
||||
it("exposes GLM-5.3 thinking levels while keeping older GLM models binary", async () => {
|
||||
|
|
@ -358,76 +279,16 @@ describe("zai provider plugin", () => {
|
|||
});
|
||||
|
||||
it("maps thinking off to Z.AI thinking disabled", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
capturedPayload = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: {},
|
||||
thinkingLevel: "off",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload?.tool_stream).toBe(true);
|
||||
expect(capturedPayload?.thinking).toEqual({ type: "disabled" });
|
||||
const payload = await captureStreamPayload({ thinkingLevel: "off" });
|
||||
expect(payload.tool_stream).toBe(true);
|
||||
expect(payload.thinking).toEqual({ type: "disabled" });
|
||||
});
|
||||
|
||||
it("keeps minimal thinking enabled for binary GLM models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
capturedPayload = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: {},
|
||||
thinkingLevel: "minimal",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload).not.toHaveProperty("thinking");
|
||||
expect(await captureStreamPayload({ thinkingLevel: "minimal" })).not.toHaveProperty("thinking");
|
||||
});
|
||||
|
||||
it("maps GLM-5.3 thinking levels to Z.AI reasoning effort", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
return { payload } as never;
|
||||
};
|
||||
|
||||
for (const [modelId, thinkingLevel, expectedEffort] of [
|
||||
["glm-5.3", "off", "low"],
|
||||
["glm-5.3", "low", "low"],
|
||||
|
|
@ -438,94 +299,32 @@ describe("zai provider plugin", () => {
|
|||
["glm-5.3-flash", "high", "high"],
|
||||
["glm-5.3-flash", "max", "max"],
|
||||
] as const) {
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId,
|
||||
extraParams: {},
|
||||
thinkingLevel,
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
const result = wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: modelId,
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
) as unknown as { payload: Record<string, unknown> };
|
||||
|
||||
expect(result.payload.reasoning_effort).toBe(expectedEffort);
|
||||
expect(result.payload).not.toHaveProperty("thinking");
|
||||
expect(result.payload.tool_stream).toBe(true);
|
||||
const payload = await captureStreamPayload({ modelId, thinkingLevel });
|
||||
expect(payload.reasoning_effort).toBe(expectedEffort);
|
||||
expect(payload).not.toHaveProperty("thinking");
|
||||
expect(payload.tool_stream).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("enables Z.AI preserved thinking only when requested", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
capturedPayload = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrappedWithoutPreserve = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: {},
|
||||
thinkingLevel: "low",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrappedWithoutPreserve?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload?.tool_stream).toBe(true);
|
||||
expect(capturedPayload).not.toHaveProperty("thinking");
|
||||
|
||||
const wrappedWithPreserve = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
const withoutPreserve = await captureStreamPayload({ thinkingLevel: "low" });
|
||||
expect(withoutPreserve.tool_stream).toBe(true);
|
||||
expect(withoutPreserve).not.toHaveProperty("thinking");
|
||||
const withPreserve = await captureStreamPayload({
|
||||
extraParams: { preserveThinking: true },
|
||||
thinkingLevel: "low",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrappedWithPreserve?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload?.tool_stream).toBe(true);
|
||||
expect(capturedPayload?.thinking).toEqual({ type: "enabled", clear_thinking: false });
|
||||
});
|
||||
expect(withPreserve.tool_stream).toBe(true);
|
||||
expect(withPreserve.thinking).toEqual({ type: "enabled", clear_thinking: false });
|
||||
});
|
||||
|
||||
it("preserves replayed reasoning_content for Z.AI preserved thinking", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const model = {
|
||||
provider: "zai",
|
||||
...createGlm47Template(),
|
||||
id: "glm-5.1",
|
||||
name: "GLM 5.1",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 131_072,
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
// Zai tests cover model definitions plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildZaiCatalogModels,
|
||||
buildZaiModelDefinition,
|
||||
ZAI_DEFAULT_COST,
|
||||
} from "./model-definitions.js";
|
||||
|
||||
type ExpectedZaiModelFields = {
|
||||
id: string;
|
||||
reasoning?: boolean;
|
||||
input?: string[];
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
cost?: typeof ZAI_DEFAULT_COST;
|
||||
};
|
||||
|
||||
function expectZaiModelFields(expected: ExpectedZaiModelFields) {
|
||||
const model = buildZaiModelDefinition({ id: expected.id });
|
||||
expect(model.id).toBe(expected.id);
|
||||
if ("reasoning" in expected) {
|
||||
expect(model.reasoning).toBe(expected.reasoning);
|
||||
}
|
||||
if (expected.input) {
|
||||
expect(model.input).toEqual(expected.input);
|
||||
}
|
||||
if (expected.contextWindow !== undefined) {
|
||||
expect(model.contextWindow).toBe(expected.contextWindow);
|
||||
}
|
||||
if (expected.maxTokens !== undefined) {
|
||||
expect(model.maxTokens).toBe(expected.maxTokens);
|
||||
}
|
||||
if (expected.cost) {
|
||||
expect(model.cost).toEqual(expected.cost);
|
||||
}
|
||||
}
|
||||
|
||||
describe("zai model definitions", () => {
|
||||
it("uses GLM-5.3 Coding Plan catalog metadata", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-5.3",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 131_072,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
});
|
||||
expect(buildZaiCatalogModels().find((model) => model.id === "glm-5.3")?.compat).toEqual({
|
||||
codeMode: "preferred",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses official multimodal GLM-5.3 Flash catalog metadata", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-5.3-flash",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 131_072,
|
||||
cost: { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 },
|
||||
});
|
||||
expect(buildZaiCatalogModels().find((model) => model.id === "glm-5.3-flash")?.compat).toEqual({
|
||||
codeMode: "preferred",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses official GLM-5.2 Coding Plan metadata", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-5.2",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 131_072,
|
||||
cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses current OpenClaw metadata for the new GLM-5.1 model", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-5.1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 131_072,
|
||||
cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses official GLM-5-Turbo metadata", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-5-turbo",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 131_072,
|
||||
cost: { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses official GLM-5V-Turbo metadata", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-5v-turbo",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 131_072,
|
||||
cost: { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
// Zai tests cover onboard plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
|
@ -18,12 +17,10 @@ import manifest from "./openclaw.plugin.json" with { type: "json" };
|
|||
describe("zai onboard", () => {
|
||||
let defaultCfg: ReturnType<typeof applyZaiConfig>;
|
||||
let cnFlashCfg: ReturnType<typeof applyZaiConfig>;
|
||||
let cnFlashxCfg: ReturnType<typeof applyZaiConfig>;
|
||||
|
||||
beforeAll(() => {
|
||||
defaultCfg = applyZaiConfig({});
|
||||
cnFlashCfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId: "glm-4.7-flash" });
|
||||
cnFlashxCfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId: "glm-4.7-flashx" });
|
||||
});
|
||||
|
||||
it("adds zai provider with correct settings", () => {
|
||||
|
|
@ -40,6 +37,13 @@ describe("zai onboard", () => {
|
|||
expect(
|
||||
defaultCfg.models?.providers?.zai?.models?.find((model) => model.id === "glm-5.3"),
|
||||
).not.toHaveProperty("baseUrl");
|
||||
for (const id of ["glm-5.3", "glm-5.3-flash"]) {
|
||||
expect(
|
||||
defaultCfg.models?.providers?.zai?.models?.find((model) => model.id === id)?.compat,
|
||||
).toEqual({
|
||||
codeMode: "preferred",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the manifest default and alias for a fresh general endpoint setup", () => {
|
||||
|
|
@ -93,13 +97,10 @@ describe("zai onboard", () => {
|
|||
});
|
||||
|
||||
it("supports CN endpoint for supported coding models", () => {
|
||||
for (const [modelId, cfg] of [
|
||||
["glm-4.7-flash", cnFlashCfg],
|
||||
["glm-4.7-flashx", cnFlashxCfg],
|
||||
] as const) {
|
||||
expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL);
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(`zai/${modelId}`);
|
||||
}
|
||||
expect(cnFlashCfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL);
|
||||
expect(resolveAgentModelPrimaryValue(cnFlashCfg.agents?.defaults?.model)).toBe(
|
||||
"zai/glm-4.7-flash",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults general endpoints to GLM-5.2 and Coding Plan endpoints to GLM-5.3", () => {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,20 @@
|
|||
// Z.AI tests cover its cold provider thinking policy.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveThinkingProfile, resolveZaiReasoningEffort } from "./provider-policy-api.js";
|
||||
|
||||
describe("zai provider thinking policy", () => {
|
||||
it.each(["glm-5.3", "glm-5.3-flash", "glm-5.3-preview"])(
|
||||
"exposes GLM 5.3 effort levels and default for %s",
|
||||
(modelId) => {
|
||||
expect(resolveThinkingProfile({ provider: "zai", modelId })).toEqual({
|
||||
levels: [
|
||||
{ id: "low", label: "low" },
|
||||
{ id: "high", label: "high" },
|
||||
{ id: "max", label: "max" },
|
||||
],
|
||||
defaultLevel: "max",
|
||||
});
|
||||
},
|
||||
);
|
||||
it("exposes GLM 5.3 effort levels for preview models", () => {
|
||||
expect(resolveThinkingProfile({ provider: "zai", modelId: "glm-5.3-preview" })).toEqual({
|
||||
levels: [
|
||||
{ id: "low", label: "low" },
|
||||
{ id: "high", label: "high" },
|
||||
{ id: "max", label: "max" },
|
||||
],
|
||||
defaultLevel: "max",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["glm-5.2", "glm-5.2-flash"])("exposes full GLM 5.2 levels for %s", (modelId) => {
|
||||
expect(resolveThinkingProfile({ provider: "zai", modelId })).toEqual({
|
||||
it("exposes full GLM 5.2 levels for Flash", () => {
|
||||
expect(resolveThinkingProfile({ provider: "zai", modelId: "glm-5.2-flash" })).toEqual({
|
||||
levels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "low", label: "low" },
|
||||
|
|
@ -29,32 +25,13 @@ describe("zai provider thinking policy", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["glm-5.1", "glm-4.7"])("keeps older GLM models binary for %s", (modelId) => {
|
||||
expect(resolveThinkingProfile({ provider: "zai", modelId })).toEqual({
|
||||
levels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "low", label: "on" },
|
||||
],
|
||||
defaultLevel: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["glm-5.3", "off", "low"],
|
||||
["glm-5.3", "minimal", "low"],
|
||||
["glm-5.3", "low", "low"],
|
||||
["glm-5.3", "medium", "high"],
|
||||
["glm-5.3", "high", "high"],
|
||||
["glm-5.3", "adaptive", "max"],
|
||||
["glm-5.3", "xhigh", "max"],
|
||||
["glm-5.3", "max", "max"],
|
||||
["glm-5.3-flash", "off", "low"],
|
||||
["glm-5.3-flash", "low", "low"],
|
||||
["glm-5.3-flash", "high", "high"],
|
||||
["glm-5.3-flash", "max", "max"],
|
||||
["glm-5.2", "low", "high"],
|
||||
["glm-5.2", "max", "max"],
|
||||
["glm-5.1", "high", undefined],
|
||||
] as const)("maps %s %s to reasoning effort %s", (modelId, level, expected) => {
|
||||
expect(resolveZaiReasoningEffort(modelId, level)).toBe(expected);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Network Policy tests cover ip behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { blockedIpv6MulticastLiterals } from "./ip-test-fixtures.js";
|
||||
import {
|
||||
|
|
@ -23,6 +22,14 @@ import {
|
|||
parseLooseIpAddress,
|
||||
} from "./ip.js";
|
||||
|
||||
function ipv6(literal: string) {
|
||||
const parsed = parseCanonicalIpAddress(literal);
|
||||
if (!parsed || !isIpv6Address(parsed)) {
|
||||
throw new Error(`expected IPv6 fixture: ${literal}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
describe("shared ip helpers", () => {
|
||||
it("distinguishes canonical dotted IPv4 from legacy forms", () => {
|
||||
expect(isCanonicalDottedDecimalIPv4("127.0.0.1")).toBe(true);
|
||||
|
|
@ -33,7 +40,6 @@ describe("shared ip helpers", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
["10.42.0.59", "10.42.0.0/24", true],
|
||||
["10.43.0.59", "10.42.0.0/24", false],
|
||||
["2001:db8::1234", "2001:db8::/32", true],
|
||||
["2001:db9::1234", "2001:db8::/32", false],
|
||||
|
|
@ -44,7 +50,6 @@ describe("shared ip helpers", () => {
|
|||
["10.42.0.59", " 10.42.0.0/24 ", true],
|
||||
["10.42.0.59", "10.42.0.0/33", false],
|
||||
["2001:db8::1", "2001:db8::/129", false],
|
||||
["10.42.0.59", "junk", false],
|
||||
["10.42.0.59", "", false],
|
||||
["junk", "10.42.0.0/24", false],
|
||||
["10.42.0.59", "2001:db8::/32", false],
|
||||
|
|
@ -55,11 +60,8 @@ describe("shared ip helpers", () => {
|
|||
["10.1.2.3", "::ffff:10.0.0.0/104", true],
|
||||
["::ffff:10.1.2.3", "::ffff:10.0.0.0/104", true],
|
||||
["11.1.2.3", "::ffff:10.0.0.0/104", false],
|
||||
["10.42.0.59", "::ffff:10.42.0.0/120", true],
|
||||
["10.42.1.59", "::ffff:10.42.0.0/120", false],
|
||||
["10.0.0.1", "::ffff:10.0.0.0/128", false],
|
||||
["203.0.113.9", "::ffff:0:0/96", true],
|
||||
["::ffff:203.0.113.9", "::ffff:0:0/96", true],
|
||||
["203.0.113.9", "::ffff:10.0.0.0/64", true],
|
||||
["2001:db8::1", "::ffff:0:0/96", false],
|
||||
])("matches %s against %s: %s", (ip, range, expected) => {
|
||||
|
|
@ -76,12 +78,9 @@ describe("shared ip helpers", () => {
|
|||
["2001:4860:1::5efe:7f00:1", "127.0.0.1"],
|
||||
] as const;
|
||||
for (const [ipv6Literal, expectedIpv4] of cases) {
|
||||
const parsed = parseCanonicalIpAddress(ipv6Literal);
|
||||
expect(parsed?.kind(), ipv6Literal).toBe("ipv6");
|
||||
if (!parsed || !isIpv6Address(parsed)) {
|
||||
continue;
|
||||
}
|
||||
expect(extractEmbeddedIpv4FromIpv6(parsed)?.toString(), ipv6Literal).toBe(expectedIpv4);
|
||||
expect(extractEmbeddedIpv4FromIpv6(ipv6(ipv6Literal))?.toString(), ipv6Literal).toBe(
|
||||
expectedIpv4,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -94,12 +93,7 @@ describe("shared ip helpers", () => {
|
|||
"64:ff9b:1::8.8.8.8",
|
||||
] as const;
|
||||
for (const ipv6Literal of cases) {
|
||||
const parsed = parseCanonicalIpAddress(ipv6Literal);
|
||||
expect(parsed?.kind(), ipv6Literal).toBe("ipv6");
|
||||
if (!parsed || !isIpv6Address(parsed)) {
|
||||
continue;
|
||||
}
|
||||
expect(extractEmbeddedIpv4FromIpv6(parsed), ipv6Literal).toBeUndefined();
|
||||
expect(extractEmbeddedIpv4FromIpv6(ipv6(ipv6Literal)), ipv6Literal).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -207,51 +201,21 @@ describe("shared ip helpers", () => {
|
|||
});
|
||||
|
||||
it("blocks IPv6 unique-local addresses by default and exempts them on opt-in (#74351)", () => {
|
||||
// fc00::/7 is the IPv6 ULA range. Sing-box / Clash / Surge fake-ip
|
||||
// proxies resolve foreign domains here, alongside the IPv4 198.18.0.0/15
|
||||
// benchmark range. Operators using those proxies need both ranges
|
||||
// exempted to keep web_fetch working.
|
||||
const ula = parseCanonicalIpAddress("fc00::1");
|
||||
const metadata = parseCanonicalIpAddress("fd00:ec2::254");
|
||||
expect(ula?.kind()).toBe("ipv6");
|
||||
expect(metadata?.kind()).toBe("ipv6");
|
||||
if (!ula || !isIpv6Address(ula) || !metadata || !isIpv6Address(metadata)) {
|
||||
throw new Error("expected ipv6 fixture");
|
||||
}
|
||||
const ula = ipv6("fc00::1");
|
||||
const metadata = ipv6("fd00:ec2::254");
|
||||
|
||||
// Default policy (no options) must continue to block the ULA range.
|
||||
expect(isBlockedSpecialUseIpv6Address(ula)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv6Address(ula, {})).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv6Address(ula, { allowUniqueLocalRange: false })).toBe(true);
|
||||
|
||||
// Opt-in flag — the only path the SSRF policy uses to thread fake-ip
|
||||
// proxy intent through to the address classifier.
|
||||
expect(isBlockedSpecialUseIpv6Address(ula, { allowUniqueLocalRange: true })).toBe(false);
|
||||
expect(isBlockedSpecialUseIpv6Address(metadata, { allowUniqueLocalRange: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("opt-in unique-local exemption does NOT bleed into other special-use IPv6 ranges (#74351)", () => {
|
||||
// The exemption must be scoped: loopback (::1), unspecified (::), and
|
||||
// multicast (ff00::/8) all stay blocked even when `allowUniqueLocalRange`
|
||||
// is set, otherwise the flag silently widens the SSRF escape hatch
|
||||
// beyond what operators opted into.
|
||||
const loopback = parseCanonicalIpAddress("::1");
|
||||
const multicast = parseCanonicalIpAddress("ff02::1");
|
||||
const siteLocal = parseCanonicalIpAddress("fec0::1"); // deprecated fec0::/10
|
||||
const localUseNat64 = parseCanonicalIpAddress("64:ff9b:1:808:808:808:a9fe:a9fe");
|
||||
|
||||
if (
|
||||
!loopback ||
|
||||
!isIpv6Address(loopback) ||
|
||||
!multicast ||
|
||||
!isIpv6Address(multicast) ||
|
||||
!siteLocal ||
|
||||
!isIpv6Address(siteLocal) ||
|
||||
!localUseNat64 ||
|
||||
!isIpv6Address(localUseNat64)
|
||||
) {
|
||||
throw new Error("expected ipv6 fixtures");
|
||||
}
|
||||
const loopback = ipv6("::1");
|
||||
const multicast = ipv6("ff02::1");
|
||||
const siteLocal = ipv6("fec0::1");
|
||||
const localUseNat64 = ipv6("64:ff9b:1:808:808:808:a9fe:a9fe");
|
||||
|
||||
for (const options of [{}, { allowUniqueLocalRange: true }] as const) {
|
||||
expect(isBlockedSpecialUseIpv6Address(loopback, options)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Network Policy tests cover ipv4 behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateDottedDecimalIPv4Input } from "./ipv4.js";
|
||||
|
||||
|
|
@ -15,7 +14,6 @@ describe("net-policy/ipv4", () => {
|
|||
|
||||
it("accepts canonical dotted-decimal ipv4 only", () => {
|
||||
expect(validateDottedDecimalIPv4Input("0.0.0.0")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input("192.168.1.100")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input(" 192.168.1.100 ")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input("0177.0.0.1")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Network Policy tests cover redact sensitive url behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isSensitiveUrlQueryParamName,
|
||||
|
|
@ -16,12 +15,6 @@ describe("redactSensitiveUrl", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("treats query param names case-insensitively", () => {
|
||||
expect(redactSensitiveUrl("https://example.com/mcp?Access_Token=secret")).toBe(
|
||||
"https://example.com/mcp?Access_Token=***",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts signed and x-* auth aliases without matching adjacent metadata", () => {
|
||||
expect(
|
||||
redactSensitiveUrl(
|
||||
|
|
@ -54,16 +47,7 @@ describe("redactSensitiveUrl", () => {
|
|||
).toBe("https://example.com/mcp?client_se+cret=***&client_se%00cret=***");
|
||||
});
|
||||
|
||||
it("redacts query names with plus-encoded separators", () => {
|
||||
expect(redactSensitiveUrl("https://example.com/mcp?client_se+cret=secret&safe=value")).toBe(
|
||||
"https://example.com/mcp?client_se+cret=***&safe=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps non-sensitive URLs unchanged", () => {
|
||||
expect(redactSensitiveUrl("https://example.com/mcp?safe=value")).toBe(
|
||||
"https://example.com/mcp?safe=value",
|
||||
);
|
||||
expect(redactSensitiveUrl("https://example.test/?discount=100%25")).toBe(
|
||||
"https://example.test/?discount=100%25",
|
||||
);
|
||||
|
|
@ -179,19 +163,6 @@ describe("redactSensitiveUrl", () => {
|
|||
joinUrlParts("mailto:user@example.com?to", "ken=", "***"),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts embedded credentials in opaque URLs", () => {
|
||||
const value = joinUrlParts(
|
||||
"data:text/plain,https://opaque-user",
|
||||
":",
|
||||
"opaque-pass",
|
||||
"@inner.example",
|
||||
);
|
||||
const redacted = redactSensitiveUrl(value);
|
||||
expect(redacted).not.toContain("opaque-user");
|
||||
expect(redacted).not.toContain("opaque-pass");
|
||||
expect(redacted).toContain("***:***@inner.example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactSensitiveUrlLikeString", () => {
|
||||
|
|
@ -201,22 +172,6 @@ describe("redactSensitiveUrlLikeString", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("redacts signed and x-* auth aliases in invalid URL-like strings", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString(
|
||||
"//example.com/mcp?sig=one&x-api-key=two&x-access-token=three&x-auth-token=four&safe=value",
|
||||
),
|
||||
).toBe(
|
||||
"//example.com/mcp?sig=***&x-api-key=***&x-access-token=***&x-auth-token=***&safe=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts encoded and invisible-spliced query names in invalid URL-like strings", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString("//example.com/mcp?client%5Fse%E2%80%8Bcret=secret&safe=value"),
|
||||
).toBe("//example.com/mcp?client%5Fse%E2%80%8Bcret=***&safe=value");
|
||||
});
|
||||
|
||||
it("redacts encoded query names with decoded whitespace and control separators in invalid URL-like strings", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString(
|
||||
|
|
@ -231,14 +186,6 @@ describe("redactSensitiveUrlLikeString", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("redacts every URL-like userinfo occurrence in arbitrary text", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString(
|
||||
"fatal https://a:b@github.com/one.git and https://c:d@github.com/two.git",
|
||||
),
|
||||
).toBe("fatal https://***:***@github.com/one.git and https://***:***@github.com/two.git");
|
||||
});
|
||||
|
||||
it("redacts protocol URLs that are too malformed to parse", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString(
|
||||
|
|
@ -258,7 +205,6 @@ describe("redactSensitiveUrlLikeString", () => {
|
|||
|
||||
describe("isSensitiveUrlQueryParamName", () => {
|
||||
it("matches the auth-oriented query params used by MCP SSE config redaction", () => {
|
||||
expect(isSensitiveUrlQueryParamName("token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("refresh_token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("access-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("hook-token")).toBe(true);
|
||||
|
|
@ -269,24 +215,11 @@ describe("isSensitiveUrlQueryParamName", () => {
|
|||
expect(isSensitiveUrlQueryParamName("X-Amz-Security-Token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("id_token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("app_secret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("client%5Fse\u200Bcret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("client%5Fse%20cret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("client%5Fse%00cret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("client_se+cret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("client_se\u3164cret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("credential")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("sig")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("X-Api-Key")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("x-access-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("x-auth-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("upstream-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName(`__openclaw_mms_token_${"a".repeat(24)}`)).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("signal")).toBe(false);
|
||||
expect(isSensitiveUrlQueryParamName("sigmoid")).toBe(false);
|
||||
expect(isSensitiveUrlQueryParamName("token_count")).toBe(false);
|
||||
expect(isSensitiveUrlQueryParamName("x-api-version")).toBe(false);
|
||||
expect(isSensitiveUrlQueryParamName("x-request-id")).toBe(false);
|
||||
expect(isSensitiveUrlQueryParamName("safe")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -301,7 +234,6 @@ describe("sensitive URL config metadata", () => {
|
|||
it("recognizes cdpUrl config paths as sensitive (browser CDP URLs can embed credentials)", () => {
|
||||
expect(isSensitiveUrlConfigPath("browser.cdpUrl")).toBe(true);
|
||||
expect(isSensitiveUrlConfigPath("browser.profiles.remote.cdpUrl")).toBe(true);
|
||||
expect(isSensitiveUrlConfigPath("browser.profiles.staging.cdpUrl")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses an explicit url-secret hint tag", () => {
|
||||
|
|
@ -315,6 +247,14 @@ function joinUrlParts(...parts: string[]): string {
|
|||
return parts.join("");
|
||||
}
|
||||
|
||||
function expectRedacted(value: string, secrets: string[], marker = "***"): void {
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
for (const secret of secrets) {
|
||||
expect(redacted).not.toContain(secret);
|
||||
}
|
||||
expect(redacted).toContain(marker);
|
||||
}
|
||||
|
||||
describe("nested URL-like fallback redaction", () => {
|
||||
it("redacts embedded credentials from query parameter names", () => {
|
||||
const nestedKey = joinUrlParts("https://key-user", ":", "key-pass", "@inner.example/");
|
||||
|
|
@ -322,10 +262,7 @@ describe("nested URL-like fallback redaction", () => {
|
|||
`https://outer.example/?${nestedKey}=value`,
|
||||
`https://outer.example/#/cb?${nestedKey}=value`,
|
||||
]) {
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
expect(redacted).not.toContain("key-user");
|
||||
expect(redacted).not.toContain("key-pass");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(value, ["key-user", "key-pass"]);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -336,10 +273,11 @@ describe("nested URL-like fallback redaction", () => {
|
|||
"%3A",
|
||||
`encoded-pass${encodedReserved}part%40inner.example%2F`,
|
||||
);
|
||||
const redacted = redactSensitiveUrlLikeString(`https://outer.example/proxy/${encodedNested}`);
|
||||
expect(redacted).not.toContain("encoded-user");
|
||||
expect(redacted).not.toContain("encoded-pass");
|
||||
expect(redacted).toContain("***:***@inner.example/");
|
||||
expectRedacted(
|
||||
`https://outer.example/proxy/${encodedNested}`,
|
||||
["encoded-user", "encoded-pass"],
|
||||
"***:***@inner.example/",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -350,19 +288,16 @@ describe("nested URL-like fallback redaction", () => {
|
|||
"encoded-user%2Fpart%40",
|
||||
]) {
|
||||
const encodedNested = `%68%74%74%70%73%3A%2F%2F${encodedUserInfo}inner.example%2F`;
|
||||
const redacted = redactSensitiveUrlLikeString(`https://outer.example/proxy/${encodedNested}`);
|
||||
expect(redacted).not.toContain("encoded-user");
|
||||
expect(redacted).not.toContain("encoded-pass");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(`https://outer.example/proxy/${encodedNested}`, [
|
||||
"encoded-user",
|
||||
"encoded-pass",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed for unresolved encoded protocol-relative userinfo", () => {
|
||||
const value = joinUrlParts("//relative-user%2Fpart%3A", "relative-pass", "%40inner.example");
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
expect(redacted).not.toContain("relative-user");
|
||||
expect(redacted).not.toContain("relative-pass");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(value, ["relative-user", "relative-pass"]);
|
||||
});
|
||||
|
||||
it("fails closed after a nested query value decodes into ambiguous userinfo", () => {
|
||||
|
|
@ -371,10 +306,7 @@ describe("nested URL-like fallback redaction", () => {
|
|||
"query-pass",
|
||||
"%40inner.example%2F",
|
||||
);
|
||||
const redacted = redactSensitiveUrlLikeString(`https://outer.example/?next=${nested}`);
|
||||
expect(redacted).not.toContain("query-user");
|
||||
expect(redacted).not.toContain("query-pass");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(`https://outer.example/?next=${nested}`, ["query-user", "query-pass"]);
|
||||
});
|
||||
|
||||
it("preserves host ports and IPv6 hosts when later path segments contain an at sign", () => {
|
||||
|
|
@ -427,32 +359,7 @@ describe("nested URL-like fallback redaction", () => {
|
|||
"@two.example",
|
||||
),
|
||||
),
|
||||
).toBe(
|
||||
joinUrlParts(
|
||||
"fatal: retry https://",
|
||||
"***",
|
||||
":",
|
||||
"***",
|
||||
"@one.example then https://",
|
||||
"***",
|
||||
":",
|
||||
"***",
|
||||
"@two.example",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts a credential-bearing URL embedded in a parsed outer URL path", () => {
|
||||
const value = joinUrlParts(
|
||||
"https://outer.example/proxy/https://path-user",
|
||||
":",
|
||||
"path-pass",
|
||||
"@inner.example/",
|
||||
);
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
expect(redacted).not.toContain("path-user");
|
||||
expect(redacted).not.toContain("path-pass");
|
||||
expect(redacted).toContain(joinUrlParts("https://", "***", ":", "***", "@inner.example/"));
|
||||
).toBe("fatal: retry https://***:***@one.example then https://***:***@two.example");
|
||||
});
|
||||
|
||||
it("redacts a percent-encoded credential-bearing URL in an outer URL path", () => {
|
||||
|
|
@ -469,20 +376,18 @@ describe("nested URL-like fallback redaction", () => {
|
|||
for (let index = 0; index < layers; index += 1) {
|
||||
encoded = encodeURIComponent(encoded);
|
||||
}
|
||||
const redacted = redactSensitiveUrlLikeString(`https://outer.example/proxy/${encoded}`);
|
||||
expect(redacted).not.toContain("path-user");
|
||||
expect(redacted).not.toContain("path-pass");
|
||||
expect(redacted).not.toContain("path-token");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(`https://outer.example/proxy/${encoded}`, [
|
||||
"path-user",
|
||||
"path-pass",
|
||||
"path-token",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts a nested URL in a hash-router query parameter", () => {
|
||||
const nested = joinUrlParts("https://inner.example/?access", "_token", "=", "router-secret");
|
||||
const value = `https://outer.example/#/cb?next=${nested}&keep=visible`;
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
expect(redacted).not.toContain("router-secret");
|
||||
expect(redacted).toContain("keep=visible");
|
||||
expectRedacted(value, ["router-secret"], "keep=visible");
|
||||
});
|
||||
|
||||
it("fails closed when an encoded fragment also has a malformed escape", () => {
|
||||
|
|
@ -507,19 +412,13 @@ describe("nested URL-like fallback redaction", () => {
|
|||
"fallback-token",
|
||||
);
|
||||
const value = `callback=${encodeURIComponent(nested)}`;
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
expect(redacted).not.toContain("fallback-user");
|
||||
expect(redacted).not.toContain("fallback-pass");
|
||||
expect(redacted).not.toContain("fallback-token");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(value, ["fallback-user", "fallback-pass", "fallback-token"]);
|
||||
});
|
||||
|
||||
it("redacts an encoded relative URL fragment in a nested query value", () => {
|
||||
const relative = joinUrlParts("callback#access", "_token", "=", "relative-secret");
|
||||
const value = `https://outer.example/?next=${encodeURIComponent(relative)}`;
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
expect(redacted).not.toContain("relative-secret");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(value, ["relative-secret"]);
|
||||
});
|
||||
|
||||
it("redacts an encoded backslash-form URL authority", () => {
|
||||
|
|
@ -532,10 +431,7 @@ describe("nested URL-like fallback redaction", () => {
|
|||
"@inner.example/",
|
||||
);
|
||||
const value = `https://outer.example/?next=${encodeURIComponent(nested)}`;
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
expect(redacted).not.toContain("backslash-user");
|
||||
expect(redacted).not.toContain("backslash-pass");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(value, ["backslash-user", "backslash-pass"]);
|
||||
});
|
||||
|
||||
it("redacts special-scheme URLs with omitted authority slashes", () => {
|
||||
|
|
@ -549,36 +445,31 @@ describe("nested URL-like fallback redaction", () => {
|
|||
"@inner.example/",
|
||||
);
|
||||
const value = `https://outer.example/?next=${encodeURIComponent(nested)}`;
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
expect(redacted).not.toContain("short-user");
|
||||
expect(redacted).not.toContain("short-pass");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(value, ["short-user", "short-pass"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts slashless special-scheme userinfo embedded in an outer path", () => {
|
||||
const nested = joinUrlParts("https:", "path-user", ":", "path-pass", "@inner.example/");
|
||||
const redacted = redactSensitiveUrlLikeString(`https://outer.example/proxy/${nested}`);
|
||||
expect(redacted).not.toContain("path-user");
|
||||
expect(redacted).not.toContain("path-pass");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(`https://outer.example/proxy/${nested}`, ["path-user", "path-pass"]);
|
||||
});
|
||||
|
||||
it("redacts through the final userinfo delimiter in a protocol-relative URL", () => {
|
||||
const nested = joinUrlParts("//first-user@second-user", ":", "multi-pass", "@inner.example/");
|
||||
const redacted = redactSensitiveUrlLikeString(`https://outer.example/proxy/${nested}`);
|
||||
expect(redacted).not.toContain("first-user");
|
||||
expect(redacted).not.toContain("second-user");
|
||||
expect(redacted).not.toContain("multi-pass");
|
||||
expect(redacted).toContain("***:***@inner.example/");
|
||||
expectRedacted(
|
||||
`https://outer.example/proxy/${nested}`,
|
||||
["first-user", "second-user", "multi-pass"],
|
||||
"***:***@inner.example/",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts an ampersand inside embedded URL userinfo", () => {
|
||||
const nested = joinUrlParts("https://amp-user", ":", "amp&pass", "@inner.example/");
|
||||
const redacted = redactSensitiveUrlLikeString(`https://outer.example/proxy/${nested}`);
|
||||
expect(redacted).not.toContain("amp-user");
|
||||
expect(redacted).not.toContain("amp&pass");
|
||||
expect(redacted).toContain("***:***@inner.example/");
|
||||
expectRedacted(
|
||||
`https://outer.example/proxy/${nested}`,
|
||||
["amp-user", "amp&pass"],
|
||||
"***:***@inner.example/",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts mixed literal and encoded credentials in one URL-like string", () => {
|
||||
|
|
@ -612,10 +503,11 @@ describe("nested URL-like fallback redaction", () => {
|
|||
const nested = encodeURIComponent(
|
||||
joinUrlParts("https://opaque-user", ":", "opaque-pass", "@inner.example/"),
|
||||
);
|
||||
const redacted = redactSensitiveUrlLikeString(`data:text/plain,${nested}`);
|
||||
expect(redacted).not.toContain("opaque-user");
|
||||
expect(redacted).not.toContain("opaque-pass");
|
||||
expect(redacted).toContain("***:***@inner.example/");
|
||||
expectRedacted(
|
||||
`data:text/plain,${nested}`,
|
||||
["opaque-user", "opaque-pass"],
|
||||
"***:***@inner.example/",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts repeatedly encoded sensitive query parameter names", () => {
|
||||
|
|
@ -625,9 +517,7 @@ describe("nested URL-like fallback redaction", () => {
|
|||
key = encodeURIComponent(key);
|
||||
}
|
||||
const value = `https://example.test/?${key}=encoded-name-secret`;
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
expect(redacted).not.toContain("encoded-name-secret");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(value, ["encoded-name-secret"]);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -640,9 +530,7 @@ describe("nested URL-like fallback redaction", () => {
|
|||
"ken=",
|
||||
"mixed-secret",
|
||||
);
|
||||
const redacted = redactSensitiveUrlLikeString(value);
|
||||
expect(redacted).not.toContain("mixed-secret");
|
||||
expect(redacted).toContain("***");
|
||||
expectRedacted(value, ["mixed-secret"]);
|
||||
});
|
||||
|
||||
it("does not consume later query parameters while scanning embedded authorities", () => {
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ describe("hasHttpUrlPrefix", () => {
|
|||
["HTTPS://user:pass@example.com:8443/path", true],
|
||||
["https://", true],
|
||||
[" https://example.com", false],
|
||||
["//example.com", false],
|
||||
["example.com", false],
|
||||
["wss://example.com", false],
|
||||
])("classifies %j", (value, expected) => {
|
||||
expect(hasHttpUrlPrefix(value)).toBe(expected);
|
||||
|
|
@ -23,17 +21,17 @@ describe("hasHttpUrlPrefix", () => {
|
|||
|
||||
describe("parsed URL protocol predicates", () => {
|
||||
it.each([
|
||||
["http://example.com", true, false, false],
|
||||
["HTTPS://user:pass@example.com:8443/path", true, true, false],
|
||||
["ws://example.com", false, false, true],
|
||||
["WSS://example.com:9443/socket", false, false, true],
|
||||
["file:///tmp/example", false, false, false],
|
||||
])("classifies %s", (value, http, https, websocket) => {
|
||||
["http://example.com", true, false, false, false],
|
||||
["HTTPS://user:pass@example.com:8443/path", true, true, false, false],
|
||||
["ws://example.com", false, false, true, false],
|
||||
["WSS://example.com:9443/socket", false, false, true, true],
|
||||
["file:///tmp/example", false, false, false, false],
|
||||
])("classifies %s", (value, http, https, websocket, wss) => {
|
||||
const url = new URL(value);
|
||||
expect(isHttpUrl(url)).toBe(http);
|
||||
expect(isHttpsUrl(url)).toBe(https);
|
||||
expect(isWebSocketUrl(url)).toBe(websocket);
|
||||
expect(isWssUrl(url)).toBe(url.protocol === "wss:");
|
||||
expect(isWssUrl(url)).toBe(wss);
|
||||
});
|
||||
|
||||
it("returns false for malformed and relative strings", () => {
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ import path from "node:path";
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../src/plugin-sdk/plugin-test-runtime.js";
|
||||
import { listBundledPluginMetadata } from "../../src/plugins/bundled-plugin-metadata.js";
|
||||
import type { PluginManifest } from "../../src/plugins/manifest.js";
|
||||
import type {
|
||||
ProviderAuthMethod,
|
||||
ProviderPlugin,
|
||||
ProviderResolveNonInteractiveApiKeyParams,
|
||||
} from "../../src/plugins/types.js";
|
||||
import { createNonExitingRuntime } from "../../src/runtime.js";
|
||||
|
|
@ -43,6 +41,7 @@ const MANIFEST_DERIVED_PLUGIN_IDS = new Set([
|
|||
"opencode-go",
|
||||
"openrouter",
|
||||
"qianfan",
|
||||
"radius",
|
||||
"synthetic",
|
||||
"together",
|
||||
"venice",
|
||||
|
|
@ -52,13 +51,9 @@ const MANIFEST_DERIVED_PLUGIN_IDS = new Set([
|
|||
// GitHub Copilot's owner test derives these literals from its manifest and
|
||||
// exercises the full token setup result in the already-loaded plugin suite.
|
||||
const OWNER_TESTED_PLUGIN_IDS = new Set(["github-copilot"]);
|
||||
|
||||
type ApiKeyStyleChoice = PluginManifestProviderAuthChoice & {
|
||||
optionKey: string;
|
||||
cliFlag: string;
|
||||
};
|
||||
|
||||
type PluginManifestProviderAuthChoice = NonNullable<PluginManifest["providerAuthChoices"]>[number];
|
||||
// These factories share credential literals across regions; the null-key probe
|
||||
// returns before regional configuration. Distinct literal tuples still get probes.
|
||||
const SHARED_LITERAL_FACTORIES = new Set(["minimax", "stepfun", "xiaomi", "zai"]);
|
||||
|
||||
type ParityCase = {
|
||||
pluginId: string;
|
||||
|
|
@ -74,52 +69,39 @@ type CapturedPluginRegistration = ReturnType<typeof createCapturedPluginRegistra
|
|||
|
||||
type PluginEntryModule = {
|
||||
default?: {
|
||||
id?: string;
|
||||
register?: PluginRegister;
|
||||
};
|
||||
register?: PluginRegister;
|
||||
};
|
||||
|
||||
function isApiKeyStyleChoice(
|
||||
choice: PluginManifestProviderAuthChoice,
|
||||
): choice is ApiKeyStyleChoice {
|
||||
return Boolean(choice.optionKey?.trim() && choice.cliFlag?.trim());
|
||||
}
|
||||
|
||||
function listParityCases(): ParityCase[] {
|
||||
return listBundledPluginMetadata({ includeChannelConfigs: false }).flatMap((plugin) => {
|
||||
const choices = plugin.manifest.providerAuthChoices ?? [];
|
||||
if (choices.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const setupEnvByProvider = new Map(
|
||||
(plugin.manifest.setup?.providers ?? []).map((entry) => [
|
||||
entry.id,
|
||||
entry.envVars ?? ([] as readonly string[]),
|
||||
]),
|
||||
);
|
||||
return choices.filter(isApiKeyStyleChoice).map((choice) => ({
|
||||
pluginId: plugin.manifest.id,
|
||||
providerId: choice.provider,
|
||||
methodId: choice.method,
|
||||
optionKey: choice.optionKey,
|
||||
cliFlag: choice.cliFlag,
|
||||
setupEnvVars: setupEnvByProvider.get(choice.provider) ?? [],
|
||||
}));
|
||||
});
|
||||
return listBundledPluginMetadata({ includeChannelConfigs: false }).flatMap(({ manifest }) =>
|
||||
(manifest.providerAuthChoices ?? []).flatMap((choice) => {
|
||||
if (!choice.optionKey?.trim() || !choice.cliFlag?.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
pluginId: manifest.id,
|
||||
providerId: choice.provider,
|
||||
methodId: choice.method,
|
||||
optionKey: choice.optionKey,
|
||||
cliFlag: choice.cliFlag,
|
||||
setupEnvVars:
|
||||
manifest.setup?.providers?.findLast((entry) => entry.id === choice.provider)?.envVars ??
|
||||
[],
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadPluginRegister(pluginId: string): Promise<PluginRegister> {
|
||||
// Dynamic import keeps this file out of the unit-fast lane: loading built
|
||||
// plugin dists pulls large module graphs into the shared worker cache and
|
||||
// breaks co-resident vi.mock-based unit tests (observed with memory-host-sdk).
|
||||
const { loadBundledPluginFacade, resolveBundledPluginPublicModulePath } =
|
||||
const { loadBundledPluginFacade } =
|
||||
await import("../../src/test-utils/bundled-plugin-public-surface.js");
|
||||
// Resolve first so unknown plugin ids fail with a clear path error before import.
|
||||
resolveBundledPluginPublicModulePath({
|
||||
pluginId,
|
||||
artifactBasename: "index.js",
|
||||
});
|
||||
const mod = await loadBundledPluginFacade<PluginEntryModule>({
|
||||
pluginId,
|
||||
artifactBasename: "index.js",
|
||||
|
|
@ -131,15 +113,6 @@ async function loadPluginRegister(pluginId: string): Promise<PluginRegister> {
|
|||
return register;
|
||||
}
|
||||
|
||||
function findRegisteredProvider(
|
||||
providers: readonly ProviderPlugin[],
|
||||
providerId: string,
|
||||
): ProviderPlugin | undefined {
|
||||
return providers.find(
|
||||
(provider) => provider.id === providerId || provider.hookAliases?.includes(providerId) === true,
|
||||
);
|
||||
}
|
||||
|
||||
async function probeRuntimeAuthLiterals(params: {
|
||||
method: ProviderAuthMethod;
|
||||
optionKey: string;
|
||||
|
|
@ -182,17 +155,12 @@ async function probeRuntimeAuthLiterals(params: {
|
|||
return captured;
|
||||
}
|
||||
|
||||
const allParityCases = listParityCases().toSorted((left, right) => {
|
||||
const pluginOrder = left.pluginId.localeCompare(right.pluginId);
|
||||
if (pluginOrder !== 0) {
|
||||
return pluginOrder;
|
||||
}
|
||||
const providerOrder = left.providerId.localeCompare(right.providerId);
|
||||
if (providerOrder !== 0) {
|
||||
return providerOrder;
|
||||
}
|
||||
return left.methodId.localeCompare(right.methodId);
|
||||
});
|
||||
const allParityCases = listParityCases().toSorted(
|
||||
(left, right) =>
|
||||
left.pluginId.localeCompare(right.pluginId) ||
|
||||
left.providerId.localeCompare(right.providerId) ||
|
||||
left.methodId.localeCompare(right.methodId),
|
||||
);
|
||||
|
||||
const allParityPluginIds = [...new Set(allParityCases.map((entry) => entry.pluginId))];
|
||||
export function defineBundledProviderAuthLiteralParityTests(shardIndex: number): void {
|
||||
|
|
@ -204,28 +172,33 @@ export function defineBundledProviderAuthLiteralParityTests(shardIndex: number):
|
|||
);
|
||||
const parityPluginIdSet = new Set(parityPluginIds);
|
||||
const parityCases = allParityCases.filter((entry) => parityPluginIdSet.has(entry.pluginId));
|
||||
const probeGroups = new Map<string, ParityCase[]>();
|
||||
for (const entry of parityCases) {
|
||||
const key = JSON.stringify({
|
||||
...entry,
|
||||
methodId: SHARED_LITERAL_FACTORIES.has(entry.pluginId) ? undefined : entry.methodId,
|
||||
});
|
||||
probeGroups.set(key, [...(probeGroups.get(key) ?? []), entry]);
|
||||
}
|
||||
const probes = [...probeGroups.values()].map((cases) => ({
|
||||
parityCase: cases[0]!,
|
||||
methodIds: cases.map((entry) => entry.methodId),
|
||||
}));
|
||||
const probeAgentDir = mkdtempSync(path.join(tmpdir(), "openclaw-auth-parity-"));
|
||||
const registrationResultByPluginId = new Map<
|
||||
string,
|
||||
PromiseSettledResult<CapturedPluginRegistration>
|
||||
>();
|
||||
const registrations = new Map<string, CapturedPluginRegistration>();
|
||||
|
||||
beforeAll(async () => {
|
||||
// Full plugin entry graphs contend heavily when transformed concurrently.
|
||||
for (const pluginId of parityPluginIds) {
|
||||
try {
|
||||
const register = await loadPluginRegister(pluginId);
|
||||
const captured = createCapturedPluginRegistration({
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
source: `bundled:${pluginId}`,
|
||||
});
|
||||
captured.api.runtime = createPluginRuntimeMock();
|
||||
register(captured.api);
|
||||
registrationResultByPluginId.set(pluginId, { status: "fulfilled", value: captured });
|
||||
} catch (reason) {
|
||||
registrationResultByPluginId.set(pluginId, { status: "rejected", reason });
|
||||
}
|
||||
const register = await loadPluginRegister(pluginId);
|
||||
const captured = createCapturedPluginRegistration({
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
source: `bundled:${pluginId}`,
|
||||
});
|
||||
captured.api.runtime = createPluginRuntimeMock();
|
||||
register(captured.api);
|
||||
registrations.set(pluginId, captured);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -239,22 +212,19 @@ export function defineBundledProviderAuthLiteralParityTests(shardIndex: number):
|
|||
expect(parityCases.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(parityCases)(
|
||||
"$pluginId $providerId/$methodId optionKey=$optionKey",
|
||||
it.each(probes)(
|
||||
"$parityCase.pluginId $parityCase.providerId/$parityCase.methodId optionKey=$parityCase.optionKey",
|
||||
{ timeout: PARITY_TIMEOUT_MS },
|
||||
async (parityCase) => {
|
||||
const registrationResult = registrationResultByPluginId.get(parityCase.pluginId);
|
||||
if (!registrationResult) {
|
||||
async ({ parityCase, methodIds }) => {
|
||||
const captured = registrations.get(parityCase.pluginId);
|
||||
if (!captured) {
|
||||
throw new Error(`bundled plugin ${parityCase.pluginId} was not preloaded`);
|
||||
}
|
||||
if (registrationResult.status === "rejected") {
|
||||
throw new Error(`bundled plugin ${parityCase.pluginId} preload or registration failed`, {
|
||||
cause: registrationResult.reason,
|
||||
});
|
||||
}
|
||||
const captured = registrationResult.value;
|
||||
|
||||
const provider = findRegisteredProvider(captured.providers, parityCase.providerId);
|
||||
const provider = captured.providers.find(
|
||||
(entry) =>
|
||||
entry.id === parityCase.providerId ||
|
||||
entry.hookAliases?.includes(parityCase.providerId),
|
||||
);
|
||||
if (!provider) {
|
||||
// Capability-only plugins (video/image onboard flags) register no text
|
||||
// providers at all. A plugin that registers text providers but not the
|
||||
|
|
@ -266,32 +236,23 @@ export function defineBundledProviderAuthLiteralParityTests(shardIndex: number):
|
|||
return;
|
||||
}
|
||||
|
||||
expect(provider.auth.map((entry) => entry.id)).toEqual(expect.arrayContaining(methodIds));
|
||||
const method = provider.auth.find((entry) => entry.id === parityCase.methodId);
|
||||
expect(
|
||||
method,
|
||||
`${parityCase.pluginId} runtime auth missing method ${parityCase.methodId}`,
|
||||
).toBeDefined();
|
||||
if (!method) {
|
||||
return;
|
||||
throw new Error(
|
||||
`${parityCase.pluginId} runtime auth missing method ${parityCase.methodId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// methodId (manifest `method`) ↔ runtime auth id
|
||||
expect(method.id).toBe(parityCase.methodId);
|
||||
|
||||
const probed = await probeRuntimeAuthLiterals({
|
||||
method,
|
||||
optionKey: parityCase.optionKey,
|
||||
agentDir: probeAgentDir,
|
||||
});
|
||||
// Fail closed: an api-key-style choice whose method cannot be probed
|
||||
// would otherwise leave its flag/env literals unchecked while CI stays
|
||||
// green — the same silent-drift hole this test exists to close.
|
||||
expect(
|
||||
probed,
|
||||
`${parityCase.pluginId} auth method ${parityCase.methodId} did not resolve an API key non-interactively; flag/env literals unverifiable`,
|
||||
).toBeDefined();
|
||||
if (!probed) {
|
||||
return;
|
||||
throw new Error(
|
||||
`${parityCase.pluginId} auth method ${parityCase.methodId} did not resolve an API key non-interactively; flag/env literals unverifiable`,
|
||||
);
|
||||
}
|
||||
|
||||
// cliFlag ↔ flagName; optionKey proven when opts[optionKey] becomes flagValue
|
||||
|
|
|
|||
|
|
@ -183,10 +183,6 @@ describe("native provider reasoning subscription", () => {
|
|||
return response;
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
"bedrock-reasoning-trace",
|
||||
JSON.stringify({ consumption, requestCount, thinking }),
|
||||
);
|
||||
expect(requestCount).toBe(1);
|
||||
expect(thinking).toEqual([
|
||||
...(consumption === "incremental" ? [{ text: "before", delta: "before" }] : []),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue