From 2f844f3e6d7c8a42cf14483261a1588b2f60753e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 26 Sep 2026 14:51:57 -0700 Subject: [PATCH] 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 --- extensions/anthropic-vertex/api.test.ts | 50 +- extensions/anthropic-vertex/index.test.ts | 458 +++++-------- .../anthropic-vertex/region.adc.test.ts | 17 +- extensions/anthropic-vertex/region.test.ts | 39 +- .../anthropic-vertex/stream-runtime.test.ts | 503 ++++---------- extensions/arcee/index.test.ts | 112 +--- .../exa/src/exa-web-search-provider.test.ts | 505 +++++--------- .../kimi-coding/implicit-provider.test.ts | 41 +- extensions/kimi-coding/index.test.ts | 19 +- extensions/kimi-coding/onboard.test.ts | 5 +- .../kimi-coding/provider-catalog.test.ts | 42 +- .../kimi-coding/provider-policy-api.test.ts | 37 -- extensions/kimi-coding/stream.test.ts | 622 +++++------------- .../llm-task/doctor-contract-api.test.ts | 149 ++--- extensions/llm-task/src/llm-task-tool.test.ts | 230 +------ .../runway/video-generation-provider.test.ts | 461 +++---------- extensions/zai/detect.test.ts | 272 +++----- extensions/zai/index.test.ts | 383 +++-------- extensions/zai/model-definitions.test.ts | 110 ---- extensions/zai/onboard.test.ts | 21 +- extensions/zai/provider-policy-api.test.ts | 47 +- packages/net-policy/src/ip.test.ts | 72 +- packages/net-policy/src/ipv4.test.ts | 2 - .../src/redact-sensitive-url.test.ts | 210 ++---- packages/net-policy/src/url-protocol.test.ts | 16 +- ...ovider-auth-literal-parity.test-support.ts | 177 ++--- .../native-reasoning-subscription.test.ts | 4 - 27 files changed, 1197 insertions(+), 3407 deletions(-) delete mode 100644 extensions/kimi-coding/provider-policy-api.test.ts delete mode 100644 extensions/zai/model-definitions.test.ts diff --git a/extensions/anthropic-vertex/api.test.ts b/extensions/anthropic-vertex/api.test.ts index 8a65bd802733..8e5548266dbb 100644 --- a/extensions/anthropic-vertex/api.test.ts +++ b/extensions/anthropic-vertex/api.test.ts @@ -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; - anthropicVertexCtorMock: ReturnType; -} { +function createStreamDeps() { const streamAnthropicMock = vi.fn( (..._args: Parameters) => 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); }); diff --git a/extensions/anthropic-vertex/index.test.ts b/extensions/anthropic-vertex/index.test.ts index 3052555846be..6d353b74318a 100644 --- a/extensions/anthropic-vertex/index.test.ts +++ b/extensions/anthropic-vertex/index.test.ts @@ -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 { + 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); + }, + ); }); diff --git a/extensions/anthropic-vertex/region.adc.test.ts b/extensions/anthropic-vertex/region.adc.test.ts index d9bc9915ebe2..8a4eb5bb6e64 100644 --- a/extensions/anthropic-vertex/region.adc.test.ts +++ b/extensions/anthropic-vertex/region.adc.test.ts @@ -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); diff --git a/extensions/anthropic-vertex/region.test.ts b/extensions/anthropic-vertex/region.test.ts index 30b8686039f1..f29934e7ac84 100644 --- a/extensions/anthropic-vertex/region.test.ts +++ b/extensions/anthropic-vertex/region.test.ts @@ -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(); - }); }); diff --git a/extensions/anthropic-vertex/stream-runtime.test.ts b/extensions/anthropic-vertex/stream-runtime.test.ts index b871de74555a..996a0b1933d9 100644 --- a/extensions/anthropic-vertex/stream-runtime.test.ts +++ b/extensions/anthropic-vertex/stream-runtime.test.ts @@ -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; - anthropicVertexCtorMock: ReturnType; - googleAuthCtorMock: ReturnType; - googleAuthClient: InstanceType; -} { +function createStreamDeps() { const streamAnthropicMock = vi.fn( (..._args: Parameters) => createAssistantMessageEventStream(), @@ -74,43 +67,16 @@ function makeModel(params: { type PayloadHook = (payload: unknown, payloadModel: unknown) => Promise; -function streamAnthropicCall(streamAnthropicMock: ReturnType): unknown[] { - const call = streamAnthropicMock.mock.calls[0]; - if (!call) { - throw new Error("Expected streamAnthropic call"); - } - return call; -} - function streamTransportOptions( streamAnthropicMock: ReturnType, ): Record { - 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; } -function captureTransportPayloadHook( - onPayload: PayloadHook | undefined, - deps: AnthropicVertexStreamDeps, - streamAnthropicMock: ReturnType, -) { - 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; - if (record.cache_control !== undefined) { - count += 1; - } - visit(record.content); +function captureOptions( + model: Model<"anthropic-messages">, + options: Parameters>[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[0], + options: Parameters>[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; - 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).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, }); }); }); diff --git a/extensions/arcee/index.test.ts b/extensions/arcee/index.test.ts index 1ea4cee72992..00a014a37b7a 100644 --- a/extensions/arcee/index.test.ts +++ b/extensions/arcee/index.test.ts @@ -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", () => { diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 88f499daf241..681eb63e56f0 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -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; @@ -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<< { - 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<< { - 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":""', - ), - ).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":""}', - ); - 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<< { + 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":""'), + ).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":""}', + ); + 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<< { @@ -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); }); }); diff --git a/extensions/kimi-coding/implicit-provider.test.ts b/extensions/kimi-coding/implicit-provider.test.ts index 38754aaa97ce..c3bb13f17e9b 100644 --- a/extensions/kimi-coding/implicit-provider.test.ts +++ b/extensions/kimi-coding/implicit-provider.test.ts @@ -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; -}) { - 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" }); - }); }); diff --git a/extensions/kimi-coding/index.test.ts b/extensions/kimi-coding/index.test.ts index 173dbabc24a5..dc9966d4f9cf 100644 --- a/extensions/kimi-coding/index.test.ts +++ b/extensions/kimi-coding/index.test.ts @@ -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", diff --git a/extensions/kimi-coding/onboard.test.ts b/extensions/kimi-coding/onboard.test.ts index d1e27b759fba..9f2e434154a5 100644 --- a/extensions/kimi-coding/onboard.test.ts +++ b/extensions/kimi-coding/onboard.test.ts @@ -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" }); diff --git a/extensions/kimi-coding/provider-catalog.test.ts b/extensions/kimi-coding/provider-catalog.test.ts index eae4f92a4964..c0b8f77802ee 100644 --- a/extensions/kimi-coding/provider-catalog.test.ts +++ b/extensions/kimi-coding/provider-catalog.test.ts @@ -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); - }); }); diff --git a/extensions/kimi-coding/provider-policy-api.test.ts b/extensions/kimi-coding/provider-policy-api.test.ts deleted file mode 100644 index f860933e9ce9..000000000000 --- a/extensions/kimi-coding/provider-policy-api.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/extensions/kimi-coding/stream.test.ts b/extensions/kimi-coding/stream.test.ts index 9005131c77c7..fb996e5d2687 100644 --- a/extensions/kimi-coding/stream.test.ts +++ b/extensions/kimi-coding/stream.test.ts @@ -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 = const streamFn: StreamFn = (model, _context, options) => { capturedModel = model; capturedOptions = options; - const payload: Record = { ...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 = }; } -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 & { + api?: Model["api"]; + }, + initialPayload: Record = {}, + options: Parameters[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; 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; - - 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({ diff --git a/extensions/llm-task/doctor-contract-api.test.ts b/extensions/llm-task/doctor-contract-api.test.ts index afb62649e8cc..526c1908941d 100644 --- a/extensions/llm-task/doctor-contract-api.test.ts +++ b/extensions/llm-task/doctor-contract-api.test.ts @@ -1,38 +1,40 @@ import { describe, expect, it } from "vitest"; import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js"; +type Config = Parameters[0]["cfg"]; +type PluginEntry = NonNullable["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) => 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([]); }, ); diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts index 4b6fa72dd07a..ebc925905b76 100644 --- a/extensions/llm-task/src/llm-task-tool.test.ts +++ b/extensions/llm-task/src/llm-task-tool.test.ts @@ -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[0], text = "{}") { const complete = vi.fn(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 = {}): LlmTaskApi { return { @@ -81,7 +61,6 @@ function fakeApi(overrides: Record = {}): 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) { - const tool = createLlmTaskTool(fakeApi()); +async function executeIsolatedCompletion(input: Record, 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; 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); diff --git a/extensions/runway/video-generation-provider.test.ts b/extensions/runway/video-generation-provider.test.ts index bad1dd05eb2b..6369b64e3e6c 100644 --- a/extensions/runway/video-generation-provider.test.ts +++ b/extensions/runway/video-generation-provider.test.ts @@ -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 }; +function generateVideo(request: Partial = {}) { + 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; - 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: "sign in" }, - { 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 | undefined; - try { - await expect( - Promise.race([ - buildRunwayVideoGenerationProvider().generateVideo({ - provider: "runway", - model: "gen4.5", - prompt: "captured invalid response", - cfg: {}, - }), - new Promise((_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", + ); }); }); diff --git a/extensions/zai/detect.test.ts b/extensions/zai/detect.test.ts index 596005e00333..b7139f59e438 100644 --- a/extensions/zai/detect.test.ts +++ b/extensions/zai/detect.test.ts @@ -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({ 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) { - 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, - calls?: string[], -) { +function makeFetch(map: Record, 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) { - 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; + 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: "gateway error" }, [`${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({ - 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(); 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); }); diff --git a/extensions/zai/index.test.ts b/extensions/zai/index.test.ts index 4cef63e573a0..2bb08574eb59 100644 --- a/extensions/zai/index.test.ts +++ b/extensions/zai/index.test.ts @@ -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 | undefined, - fields: Record, -): 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 | undefined, fields: Record, ): 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 & { + modelId?: string; + } = {}, +) { + const provider = await registerSingleProviderPlugin(plugin); + const modelId = params.modelId ?? "glm-5.1"; + const payload: Record = {}; + const streamFn: StreamFn = (model, _context, options) => { + options?.onPayload?.(payload, model); + return {} as ReturnType; + }; + 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 | 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 | 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 | 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 | 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 | undefined; - const baseStreamFn: StreamFn = (model, _context, options) => { - const payload: Record = {}; - options?.onPayload?.(payload as never, model as never); - capturedPayload = payload; - return {} as ReturnType; - }; - - 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 | undefined; - const baseStreamFn: StreamFn = (model, _context, options) => { - const payload: Record = {}; - options?.onPayload?.(payload as never, model as never); - capturedPayload = payload; - return {} as ReturnType; - }; - - 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 | undefined; - const baseStreamFn: StreamFn = (model, _context, options) => { - const payload: Record = {}; - options?.onPayload?.(payload as never, model as never); - capturedPayload = payload; - return {} as ReturnType; - }; - - 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 = {}; - 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 }; - - 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 | undefined; - const baseStreamFn: StreamFn = (model, _context, options) => { - const payload: Record = {}; - options?.onPayload?.(payload as never, model as never); - capturedPayload = payload; - return {} as ReturnType; - }; - - 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 | 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, diff --git a/extensions/zai/model-definitions.test.ts b/extensions/zai/model-definitions.test.ts deleted file mode 100644 index e415f51f2e6d..000000000000 --- a/extensions/zai/model-definitions.test.ts +++ /dev/null @@ -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 }, - }); - }); -}); diff --git a/extensions/zai/onboard.test.ts b/extensions/zai/onboard.test.ts index f18baafc9d75..b22c80854d4e 100644 --- a/extensions/zai/onboard.test.ts +++ b/extensions/zai/onboard.test.ts @@ -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; let cnFlashCfg: ReturnType; - let cnFlashxCfg: ReturnType; 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", () => { diff --git a/extensions/zai/provider-policy-api.test.ts b/extensions/zai/provider-policy-api.test.ts index e1dec508eac3..810778511895 100644 --- a/extensions/zai/provider-policy-api.test.ts +++ b/extensions/zai/provider-policy-api.test.ts @@ -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); }); diff --git a/packages/net-policy/src/ip.test.ts b/packages/net-policy/src/ip.test.ts index c62eeed5b17e..e0ca8c663198 100644 --- a/packages/net-policy/src/ip.test.ts +++ b/packages/net-policy/src/ip.test.ts @@ -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); diff --git a/packages/net-policy/src/ipv4.test.ts b/packages/net-policy/src/ipv4.test.ts index 3a30f546ebe7..bf1466f61451 100644 --- a/packages/net-policy/src/ipv4.test.ts +++ b/packages/net-policy/src/ipv4.test.ts @@ -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)", diff --git a/packages/net-policy/src/redact-sensitive-url.test.ts b/packages/net-policy/src/redact-sensitive-url.test.ts index 17ac816d8842..195a4f4a9361 100644 --- a/packages/net-policy/src/redact-sensitive-url.test.ts +++ b/packages/net-policy/src/redact-sensitive-url.test.ts @@ -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", () => { diff --git a/packages/net-policy/src/url-protocol.test.ts b/packages/net-policy/src/url-protocol.test.ts index f3bb10eab345..e87fdf415517 100644 --- a/packages/net-policy/src/url-protocol.test.ts +++ b/packages/net-policy/src/url-protocol.test.ts @@ -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", () => { diff --git a/test/plugins/bundled-provider-auth-literal-parity.test-support.ts b/test/plugins/bundled-provider-auth-literal-parity.test-support.ts index 140edc2b80e6..8b3d212cf5a0 100644 --- a/test/plugins/bundled-provider-auth-literal-parity.test-support.ts +++ b/test/plugins/bundled-provider-auth-literal-parity.test-support.ts @@ -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[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 { - 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 { // 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({ pluginId, artifactBasename: "index.js", @@ -131,15 +113,6 @@ async function loadPluginRegister(pluginId: string): Promise { 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(); + 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 - >(); + const registrations = new Map(); 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 diff --git a/test/plugins/native-reasoning-subscription.test.ts b/test/plugins/native-reasoning-subscription.test.ts index 5d18ed2bc832..d0c91726f8ad 100644 --- a/test/plugins/native-reasoning-subscription.test.ts +++ b/test/plugins/native-reasoning-subscription.test.ts @@ -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" }] : []),