refactor(plugins): move remaining channel and provider ownership out of src

This commit is contained in:
Vincent Koc
2026-03-22 19:13:03 -07:00
parent 9ffde8efb2
commit 2131981230
143 changed files with 2079 additions and 1024 deletions

View File

@@ -0,0 +1,86 @@
import type { Context } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { convertMessages } from "../../node_modules/@mariozechner/pi-ai/dist/providers/google-shared.js";
import {
asRecord,
expectConvertedRoles,
makeGeminiCliAssistantMessage,
makeGeminiCliModel,
makeGoogleAssistantMessage,
makeModel,
} from "./google-shared.test-helpers.js";
describe("google-shared convertTools", () => {
it("ensures function call comes after user turn, not after model turn", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
{
role: "user",
content: "Hello",
},
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "Hi!" }]),
makeGoogleAssistantMessage(model.id, [
{
type: "toolCall",
id: "call_1",
name: "myTool",
arguments: {},
},
]),
],
} as unknown as Context;
const contents = convertMessages(model, context);
expectConvertedRoles(contents, ["user", "model", "model"]);
const toolCallPart = contents[2].parts?.find(
(part) => typeof part === "object" && part !== null && "functionCall" in part,
);
const toolCall = asRecord(toolCallPart);
expect(toolCall.functionCall).toBeTruthy();
});
it("strips tool call and response ids for google-gemini-cli", () => {
const model = makeGeminiCliModel("gemini-3-flash");
const context = {
messages: [
{
role: "user",
content: "Use a tool",
},
makeGeminiCliAssistantMessage(model.id, [
{
type: "toolCall",
id: "call_1",
name: "myTool",
arguments: { arg: "value" },
thoughtSignature: "dGVzdA==",
},
]),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "myTool",
content: [{ type: "text", text: "Tool result" }],
isError: false,
timestamp: 0,
},
],
} as unknown as Context;
const contents = convertMessages(model, context);
const parts = contents.flatMap((content) => content.parts ?? []);
const toolCallPart = parts.find(
(part) => typeof part === "object" && part !== null && "functionCall" in part,
);
const toolResponsePart = parts.find(
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
);
const toolCall = asRecord(toolCallPart);
const toolResponse = asRecord(toolResponsePart);
expect(asRecord(toolCall.functionCall).id).toBeUndefined();
expect(asRecord(toolResponse.functionResponse).id).toBeUndefined();
});
});

View File

@@ -0,0 +1,288 @@
import type { Context, Tool } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import {
convertMessages,
convertTools,
} from "../../node_modules/@mariozechner/pi-ai/dist/providers/google-shared.js";
import {
asRecord,
expectConvertedRoles,
getFirstToolParameters,
makeGoogleAssistantMessage,
makeModel,
} from "./google-shared.test-helpers.js";
describe("google-shared convertTools", () => {
it("preserves parameters when type is missing", () => {
const tools = [
{
name: "noType",
description: "Tool with properties but no type",
parameters: {
properties: {
action: { type: "string" },
},
required: ["action"],
},
},
] as unknown as Tool[];
const converted = convertTools(tools);
const params = getFirstToolParameters(
converted as Parameters<typeof getFirstToolParameters>[0],
);
expect(params.type).toBeUndefined();
expect(params.properties).toBeDefined();
expect(params.required).toEqual(["action"]);
});
it("keeps unsupported JSON Schema keywords intact", () => {
const tools = [
{
name: "example",
description: "Example tool",
parameters: {
type: "object",
patternProperties: {
"^x-": { type: "string" },
},
additionalProperties: false,
properties: {
mode: {
type: "string",
const: "fast",
},
options: {
anyOf: [{ type: "string" }, { type: "number" }],
},
list: {
type: "array",
items: {
type: "string",
const: "item",
},
},
},
required: ["mode"],
},
},
] as unknown as Tool[];
const converted = convertTools(tools);
const params = getFirstToolParameters(
converted as Parameters<typeof getFirstToolParameters>[0],
);
const properties = asRecord(params.properties);
const mode = asRecord(properties.mode);
const options = asRecord(properties.options);
const list = asRecord(properties.list);
const items = asRecord(list.items);
expect(params.patternProperties).toEqual({ "^x-": { type: "string" } });
expect(params.additionalProperties).toBe(false);
expect(mode.const).toBe("fast");
expect(options.anyOf).toEqual([{ type: "string" }, { type: "number" }]);
expect(items.const).toBe("item");
expect(params.required).toEqual(["mode"]);
});
it("keeps supported schema fields", () => {
const tools = [
{
name: "settings",
description: "Settings tool",
parameters: {
type: "object",
properties: {
config: {
type: "object",
properties: {
retries: { type: "number", minimum: 1 },
tags: {
type: "array",
items: { type: "string" },
},
},
required: ["retries"],
},
},
required: ["config"],
},
},
] as unknown as Tool[];
const converted = convertTools(tools);
const params = getFirstToolParameters(
converted as Parameters<typeof getFirstToolParameters>[0],
);
const config = asRecord(asRecord(params.properties).config);
const configProps = asRecord(config.properties);
const retries = asRecord(configProps.retries);
const tags = asRecord(configProps.tags);
const items = asRecord(tags.items);
expect(params.type).toBe("object");
expect(config.type).toBe("object");
expect(retries.minimum).toBe(1);
expect(tags.type).toBe("array");
expect(items.type).toBe("string");
expect(config.required).toEqual(["retries"]);
expect(params.required).toEqual(["config"]);
});
});
describe("google-shared convertMessages", () => {
function expectConsecutiveMessagesNotMerged(params: {
modelId: string;
first: string;
second: string;
}) {
const model = makeModel(params.modelId);
const context = {
messages: [
{
role: "user",
content: params.first,
},
{
role: "user",
content: params.second,
},
],
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(2);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("user");
expect(contents[0].parts).toHaveLength(1);
expect(contents[1].parts).toHaveLength(1);
}
it("keeps thinking blocks when provider/model match", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
makeGoogleAssistantMessage(model.id, [
{
type: "thinking",
thinking: "hidden",
thinkingSignature: "c2ln",
},
]),
],
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(1);
expect(contents[0].role).toBe("model");
expect(contents[0].parts?.[0]).toMatchObject({
thought: true,
thoughtSignature: "c2ln",
});
});
it("keeps thought signatures for Claude models", () => {
const model = makeModel("claude-3-opus");
const context = {
messages: [
makeGoogleAssistantMessage(model.id, [
{
type: "thinking",
thinking: "structured",
thinkingSignature: "c2ln",
},
]),
],
} as unknown as Context;
const contents = convertMessages(model, context);
const parts = contents?.[0]?.parts ?? [];
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({
thought: true,
thoughtSignature: "c2ln",
});
});
it("does not merge consecutive user messages for Gemini", () => {
expectConsecutiveMessagesNotMerged({
modelId: "gemini-1.5-pro",
first: "Hello",
second: "How are you?",
});
});
it("does not merge consecutive user messages for non-Gemini Google models", () => {
expectConsecutiveMessagesNotMerged({
modelId: "claude-3-opus",
first: "First",
second: "Second",
});
});
it("does not merge consecutive model messages for Gemini", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
{
role: "user",
content: "Hello",
},
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "Hi there!" }]),
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "How can I help?" }]),
],
} as unknown as Context;
const contents = convertMessages(model, context);
expectConvertedRoles(contents, ["user", "model", "model"]);
expect(contents[1].parts).toHaveLength(1);
expect(contents[2].parts).toHaveLength(1);
});
it("handles user message after tool result without model response in between", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
{
role: "user",
content: "Use a tool",
},
makeGoogleAssistantMessage(model.id, [
{
type: "toolCall",
id: "call_1",
name: "myTool",
arguments: { arg: "value" },
},
]),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "myTool",
content: [{ type: "text", text: "Tool result" }],
isError: false,
timestamp: 0,
},
{
role: "user",
content: "Now do something else",
},
],
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(4);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("user");
expect(contents[3].role).toBe("user");
const toolResponsePart = contents[2].parts?.find(
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
);
const toolResponse = asRecord(toolResponsePart);
expect(toolResponse.functionResponse).toBeTruthy();
expect(contents[3].role).toBe("user");
});
});

View File

@@ -0,0 +1,99 @@
import type { Model } from "@mariozechner/pi-ai";
import { expect } from "vitest";
function makeZeroUsageSnapshot() {
return {
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
reasoningTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
}
export const asRecord = (value: unknown): Record<string, unknown> => {
expect(value).toBeTruthy();
expect(typeof value).toBe("object");
expect(Array.isArray(value)).toBe(false);
return value as Record<string, unknown>;
};
type ConvertedTools = ReadonlyArray<{
functionDeclarations?: ReadonlyArray<{
parametersJsonSchema?: unknown;
parameters?: unknown;
}>;
}>;
export const getFirstToolParameters = (converted: ConvertedTools): Record<string, unknown> => {
const functionDeclaration = asRecord(converted?.[0]?.functionDeclarations?.[0]);
return asRecord(functionDeclaration.parametersJsonSchema ?? functionDeclaration.parameters);
};
export const makeModel = (id: string): Model<"google-generative-ai"> =>
({
id,
name: id,
api: "google-generative-ai",
provider: "google",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1,
maxTokens: 1,
}) as Model<"google-generative-ai">;
export const makeGeminiCliModel = (id: string): Model<"google-gemini-cli"> =>
({
id,
name: id,
api: "google-gemini-cli",
provider: "google-gemini-cli",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1,
maxTokens: 1,
}) as Model<"google-gemini-cli">;
export function makeGoogleAssistantMessage(model: string, content: unknown) {
return {
role: "assistant",
content,
api: "google-generative-ai",
provider: "google",
model,
usage: makeZeroUsageSnapshot(),
stopReason: "stop",
timestamp: 0,
};
}
export function makeGeminiCliAssistantMessage(model: string, content: unknown) {
return {
role: "assistant",
content,
api: "google-gemini-cli",
provider: "google-gemini-cli",
model,
usage: makeZeroUsageSnapshot(),
stopReason: "stop",
timestamp: 0,
};
}
export function expectConvertedRoles(contents: Array<{ role?: string }>, expectedRoles: string[]) {
expect(contents).toHaveLength(expectedRoles.length);
for (const [index, role] of expectedRoles.entries()) {
expect(contents[index]?.role).toBe(role);
}
}

View File

@@ -0,0 +1,265 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as providerAuth from "openclaw/plugin-sdk/provider-auth";
import { buildGoogleImageGenerationProvider } from "./image-generation-provider.js";
describe("Google image-generation provider", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("generates image buffers from the Gemini generateContent API", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-test-key",
source: "env",
mode: "api-key",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [
{
content: {
parts: [
{ text: "generated" },
{
inlineData: {
mimeType: "image/png",
data: Buffer.from("png-data").toString("base64"),
},
},
],
},
},
],
}),
});
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
const result = await provider.generateImage({
provider: "google",
model: "gemini-3.1-flash-image-preview",
prompt: "draw a cat",
cfg: {},
size: "1536x1024",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
contents: [
{
role: "user",
parts: [{ text: "draw a cat" }],
},
],
generationConfig: {
responseModalities: ["TEXT", "IMAGE"],
imageConfig: {
aspectRatio: "3:2",
imageSize: "2K",
},
},
}),
}),
);
expect(result).toEqual({
images: [
{
buffer: Buffer.from("png-data"),
mimeType: "image/png",
fileName: "image-1.png",
},
],
model: "gemini-3.1-flash-image-preview",
});
});
it("accepts OAuth JSON auth and inline_data responses", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: JSON.stringify({ token: "oauth-token" }),
source: "profile",
mode: "token",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [
{
content: {
parts: [
{
inline_data: {
mime_type: "image/jpeg",
data: Buffer.from("jpg-data").toString("base64"),
},
},
],
},
},
],
}),
});
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
const result = await provider.generateImage({
provider: "google",
model: "gemini-3.1-flash-image-preview",
prompt: "draw a dog",
cfg: {},
});
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.any(Headers),
}),
);
const [, init] = fetchMock.mock.calls[0];
expect(new Headers(init.headers).get("authorization")).toBe("Bearer oauth-token");
expect(result).toEqual({
images: [
{
buffer: Buffer.from("jpg-data"),
mimeType: "image/jpeg",
fileName: "image-1.jpg",
},
],
model: "gemini-3.1-flash-image-preview",
});
});
it("sends reference images and explicit resolution for edit flows", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-test-key",
source: "env",
mode: "api-key",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: "image/png",
data: Buffer.from("png-data").toString("base64"),
},
},
],
},
},
],
}),
});
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
await provider.generateImage({
provider: "google",
model: "gemini-3-pro-image-preview",
prompt: "Change only the sky to a sunset.",
cfg: {},
resolution: "4K",
inputImages: [
{
buffer: Buffer.from("reference-bytes"),
mimeType: "image/png",
fileName: "reference.png",
},
],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
contents: [
{
role: "user",
parts: [
{
inlineData: {
mimeType: "image/png",
data: Buffer.from("reference-bytes").toString("base64"),
},
},
{ text: "Change only the sky to a sunset." },
],
},
],
generationConfig: {
responseModalities: ["TEXT", "IMAGE"],
imageConfig: {
imageSize: "4K",
},
},
}),
}),
);
});
it("forwards explicit aspect ratio without forcing a default when size is omitted", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-test-key",
source: "env",
mode: "api-key",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: "image/png",
data: Buffer.from("png-data").toString("base64"),
},
},
],
},
},
],
}),
});
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
await provider.generateImage({
provider: "google",
model: "gemini-3-pro-image-preview",
prompt: "portrait photo",
cfg: {},
aspectRatio: "9:16",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
contents: [
{
role: "user",
parts: [{ text: "portrait photo" }],
},
],
generationConfig: {
responseModalities: ["TEXT", "IMAGE"],
imageConfig: {
aspectRatio: "9:16",
},
},
}),
}),
);
});
});

View File

@@ -1,14 +1,11 @@
import type { ImageGenerationProviderPlugin } from "openclaw/plugin-sdk/image-generation-core";
import {
normalizeGoogleModelId,
parseGeminiAuth,
resolveApiKeyForProvider,
} from "openclaw/plugin-sdk/image-generation-core";
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import {
assertOkOrThrowHttpError,
normalizeBaseUrl,
postJsonRequest,
} from "openclaw/plugin-sdk/media-understanding";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth";
import { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/provider-google";
const DEFAULT_GOOGLE_IMAGE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview";
@@ -95,7 +92,7 @@ function mapSizeToImageConfig(
};
}
export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlugin {
export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
return {
id: "google",
label: "Google",