Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@clawmate/clawmate

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@clawmate/clawmate - npm Package Compare versions

Comparing version
0.2.1
to
0.3.0
+262
packages/clawmate-...anion/src/core/providers/gemini.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { createGeminiProvider } from "./gemini";
import type { GenerateRequest } from "../types";
const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==";
const PNG_DATA_URL = `data:image/png;base64,${PNG_BASE64}`;
function makePayload(referenceImages: string[] = [PNG_DATA_URL], prompt = "draw a portrait"): GenerateRequest {
return {
characterId: "brooke",
prompt,
mode: "mirror",
referencePath: referenceImages.length > 0 ? "C:\\reference.png" : "",
referencePaths: referenceImages.length > 0 ? ["C:\\reference.png"] : [],
referenceImageBase64: referenceImages.length > 0 ? PNG_BASE64 : "",
referenceImageBase64List: referenceImages.length > 0 ? [PNG_BASE64] : [],
referenceImageDataUrl: referenceImages[0] ?? "",
referenceImageDataUrls: referenceImages,
timeState: "night",
meta: {
state: "night",
roleName: "Brooke",
eventSource: "test",
},
};
}
test("gemini uses the SDK default endpoint when baseUrl is not configured", async () => {
let capturedConfig: Record<string, unknown> | null = null;
let capturedRequest: Record<string, unknown> | null = null;
const provider = createGeminiProvider(
{
name: "gemini",
apiKey: "test-key",
model: "gemini-3.1-flash-image-preview",
},
(config) => {
capturedConfig = config as unknown as Record<string, unknown>;
return {
models: {
generateContent: async (request) => {
capturedRequest = request as unknown as Record<string, unknown>;
return {
responseId: "gemini-default-req",
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: "image/png",
data: PNG_BASE64,
},
},
],
},
},
],
};
},
},
};
},
);
const result = await provider.generate(makePayload([]));
assert.equal(capturedConfig?.baseUrl, null);
assert.equal(capturedConfig?.model, "gemini-3.1-flash-image-preview");
assert.deepEqual(capturedRequest?.config, {
responseModalities: ["IMAGE"],
});
assert.deepEqual(capturedRequest?.contents, [
{
role: "user",
parts: [{ text: "draw a portrait" }],
},
]);
assert.equal(result.requestId, "gemini-default-req");
assert.equal(result.imageUrl, PNG_DATA_URL);
});
test("gemini forwards a configured custom BaseURL", async () => {
let capturedConfig: Record<string, unknown> | null = null;
const provider = createGeminiProvider(
{
name: "gemini",
apiKey: "test-key",
model: "custom-gemini-image-model",
baseUrl: "https://proxy.example.com/",
},
(config) => {
capturedConfig = config as unknown as Record<string, unknown>;
return {
models: {
generateContent: async () => ({
responseId: "gemini-custom-req",
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: "image/png",
data: PNG_BASE64,
},
},
],
},
},
],
}),
},
};
},
);
const result = await provider.generate(makePayload([]));
assert.equal(capturedConfig?.baseUrl, "https://proxy.example.com");
assert.equal(capturedConfig?.model, "custom-gemini-image-model");
assert.equal(result.requestId, "gemini-custom-req");
assert.equal(result.imageUrl, PNG_DATA_URL);
});
test("gemini includes reference images as inline image parts", async () => {
let capturedRequest: Record<string, unknown> | null = null;
const provider = createGeminiProvider(
{
name: "gemini",
apiKey: "test-key",
model: "gemini-2.5-flash-image",
},
() => ({
models: {
generateContent: async (request) => {
capturedRequest = request as unknown as Record<string, unknown>;
return {
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: "image/png",
data: PNG_BASE64,
},
},
],
},
},
],
};
},
},
}),
);
await provider.generate(makePayload([PNG_DATA_URL, PNG_DATA_URL]));
assert.deepEqual(capturedRequest?.contents, [
{
role: "user",
parts: [
{
inlineData: {
mimeType: "image/png",
data: PNG_BASE64,
},
},
{
text: "draw a portrait",
},
],
},
]);
});
test("gemini keeps custom model strings unchanged", async () => {
let capturedRequest: Record<string, unknown> | null = null;
const provider = createGeminiProvider(
{
name: "gemini",
apiKey: "test-key",
model: "my-company/gemini-image-proxy",
},
() => ({
models: {
generateContent: async (request) => {
capturedRequest = request as unknown as Record<string, unknown>;
return {
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: "image/png",
data: PNG_BASE64,
},
},
],
},
},
],
};
},
},
}),
);
await provider.generate(makePayload([]));
assert.equal(capturedRequest?.model, "my-company/gemini-image-proxy");
});
test("gemini fails when the response contains no image payload", async () => {
const provider = createGeminiProvider(
{
name: "gemini",
apiKey: "test-key",
model: "gemini-3-pro-image-preview",
},
() => ({
models: {
generateContent: async () => ({
responseId: "gemini-no-image",
candidates: [
{
content: {
parts: [
{
text: "safety filtered",
},
],
},
},
],
}),
},
}),
);
await assert.rejects(
() => provider.generate(makePayload([])),
(error: unknown) => {
assert.equal(typeof error, "object");
const resolved = error as { code?: string; requestId?: string | null; details?: Record<string, unknown> };
assert.equal(resolved.code, "PROVIDER_IMAGE_URL_MISSING");
assert.equal(resolved.requestId, "gemini-no-image");
assert.deepEqual(resolved.details, {
textPreview: ["safety filtered"],
});
return true;
},
);
});
import { GoogleGenAI } from "@google/genai";
import { ProviderError } from "../errors";
import type { GenerateRequest, ProviderAdapter, ProviderConfig } from "../types";
import { dedupeNonEmptyStrings, toOptionalString } from "./shared";
interface GeminiProviderConfig extends ProviderConfig {
name: string;
apiKey?: string;
api_key?: string;
model?: string;
baseUrl?: string;
base_url?: string;
}
interface NormalizedConfig {
name: string;
apiKey: string;
model: string;
baseUrl: string | null;
}
interface GeminiGenerateContentRequest {
model: string;
contents: Array<{
role: "user";
parts: Array<Record<string, unknown>>;
}>;
config: {
responseModalities: string[];
};
}
interface GeminiClient {
models: {
generateContent(params: GeminiGenerateContentRequest): Promise<unknown>;
};
}
export type GeminiClientFactory = (config: NormalizedConfig) => GeminiClient;
interface ParsedDataImage {
mimeType: string;
data: string;
}
function normalizeConfig(config: GeminiProviderConfig): NormalizedConfig {
const name = config.name;
const apiKey = toOptionalString(config.apiKey ?? config.api_key)?.trim();
const model = toOptionalString(config.model)?.trim();
const baseUrl = toOptionalString(config.baseUrl ?? config.base_url)?.trim() ?? null;
if (!apiKey) {
throw new ProviderError(`provider ${name} 缺少 apiKey`, {
code: "PROVIDER_CONFIG_INVALID",
});
}
if (!model) {
throw new ProviderError(`provider ${name} 缺少 model`, {
code: "PROVIDER_CONFIG_INVALID",
});
}
return {
name,
apiKey,
model,
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, "") : null,
};
}
function parseDataImageUrl(value: string): ParsedDataImage | null {
const match = value.trim().match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/i);
if (!match) {
return null;
}
return {
mimeType: match[1],
data: match[2],
};
}
function buildReferenceParts(payload: GenerateRequest): Array<Record<string, unknown>> {
const referenceImages = dedupeNonEmptyStrings(
Array.isArray(payload.referenceImageDataUrls) && payload.referenceImageDataUrls.length > 0
? payload.referenceImageDataUrls
: [payload.referenceImageDataUrl],
);
return referenceImages.map((imageUrl, index) => {
const parsed = parseDataImageUrl(imageUrl);
if (!parsed) {
throw new ProviderError(`provider gemini 收到无效参考图 data URL(index=${index})`, {
code: "PROVIDER_REQUEST_INVALID",
});
}
return {
inlineData: {
mimeType: parsed.mimeType,
data: parsed.data,
},
};
});
}
function buildRequest(config: NormalizedConfig, payload: GenerateRequest): GeminiGenerateContentRequest {
const prompt = payload.prompt.trim();
if (!prompt) {
throw new ProviderError(`provider ${config.name} prompt 不能为空`, {
code: "PROVIDER_REQUEST_INVALID",
});
}
const parts = [...buildReferenceParts(payload), { text: prompt }];
return {
model: config.model,
contents: [
{
role: "user",
parts,
},
],
config: {
responseModalities: ["IMAGE"],
},
};
}
function createGeminiClient(config: NormalizedConfig): GeminiClient {
return new GoogleGenAI({
apiKey: config.apiKey,
...(config.baseUrl
? {
httpOptions: {
baseUrl: config.baseUrl,
},
}
: {}),
}) as GeminiClient;
}
function extractImagePart(value: unknown, depth = 0): ParsedDataImage | null {
if (depth > 8 || value == null) {
return null;
}
if (Array.isArray(value)) {
for (const item of value) {
const resolved = extractImagePart(item, depth + 1);
if (resolved) {
return resolved;
}
}
return null;
}
if (typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
const inlineDataSource = record.inlineData ?? record.inline_data;
if (inlineDataSource && typeof inlineDataSource === "object" && !Array.isArray(inlineDataSource)) {
const inlineData = inlineDataSource as Record<string, unknown>;
const mimeType = toOptionalString(inlineData.mimeType ?? inlineData.mime_type)?.trim();
const data = toOptionalString(inlineData.data)?.trim();
if (mimeType && data && /^image\//i.test(mimeType)) {
return { mimeType, data };
}
}
for (const key of ["candidates", "content", "parts", "response", "result", "data", "output"]) {
const resolved = extractImagePart(record[key], depth + 1);
if (resolved) {
return resolved;
}
}
return null;
}
function collectTextParts(value: unknown, result: Set<string>, depth = 0): void {
if (depth > 8 || value == null) {
return;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed) {
result.add(trimmed);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectTextParts(item, result, depth + 1);
}
return;
}
if (typeof value !== "object") {
return;
}
const record = value as Record<string, unknown>;
if (typeof record.text === "string" && record.text.trim()) {
result.add(record.text.trim());
}
for (const key of ["candidates", "content", "parts", "response", "result", "data", "output", "message", "error"]) {
collectTextParts(record[key], result, depth + 1);
}
}
function extractRequestId(value: unknown, depth = 0): string | null {
if (depth > 6 || value == null) {
return null;
}
if (Array.isArray(value)) {
for (const item of value) {
const resolved = extractRequestId(item, depth + 1);
if (resolved) {
return resolved;
}
}
return null;
}
if (typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
for (const key of ["responseId", "response_id", "requestId", "request_id", "id"]) {
const text = toOptionalString(record[key])?.trim();
if (text) {
return text;
}
}
for (const key of ["candidates", "response", "result", "data"]) {
const resolved = extractRequestId(record[key], depth + 1);
if (resolved) {
return resolved;
}
}
return null;
}
function errorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
return String(error);
}
function errorStatus(error: unknown): number | string | null {
if (!error || typeof error !== "object") {
return null;
}
const status = (error as Record<string, unknown>).status;
if (typeof status === "number" || typeof status === "string") {
return status;
}
return null;
}
export function createGeminiProvider(
rawConfig: GeminiProviderConfig,
clientFactory: GeminiClientFactory = createGeminiClient,
): ProviderAdapter {
const config = normalizeConfig(rawConfig);
return {
name: config.name,
async generate(payload: GenerateRequest) {
const client = clientFactory(config);
const request = buildRequest(config, payload);
try {
const response = await client.models.generateContent(request);
const requestId = extractRequestId(response);
const image = extractImagePart(response);
if (!image) {
const texts = new Set<string>();
collectTextParts(response, texts);
throw new ProviderError(`provider ${config.name} 响应中未找到图片数据`, {
code: "PROVIDER_IMAGE_URL_MISSING",
requestId,
details: {
textPreview: Array.from(texts).slice(0, 3),
},
});
}
return {
requestId,
imageUrl: `data:${image.mimeType};base64,${image.data}`,
};
} catch (error) {
if (error instanceof ProviderError) {
throw error;
}
throw new ProviderError(`provider ${config.name} 请求失败: ${errorMessage(error)}`, {
code: "PROVIDER_REQUEST_FAILED",
transient: true,
details: {
status: errorStatus(error),
},
});
}
},
};
}
+3
-2
{
"name": "@clawmate/clawmate",
"version": "0.2.1",
"version": "0.3.0",
"description": "One-click installer for the ClawMate OpenClaw companion plugin",

@@ -31,3 +31,3 @@ "license": "MIT",

"clawmate:plugin:check": "node packages/clawmate-companion/scripts/check-manifest-id.mjs",
"clawmate:test": "node --import tsx --test packages/clawmate-companion/src/plugin.test.ts packages/clawmate-companion/src/plugin.tts.test.ts packages/clawmate-companion/src/cli.test.cjs packages/clawmate-companion/src/core/providers/openai-compatible.test.ts",
"clawmate:test": "node --import tsx --test packages/clawmate-companion/src/plugin.test.ts packages/clawmate-companion/src/plugin.tts.test.ts packages/clawmate-companion/src/cli.test.cjs packages/clawmate-companion/src/core/providers/openai-compatible.test.ts packages/clawmate-companion/src/core/providers/gemini.test.ts",
"clawmate:probe:openai": "cd packages/clawmate-companion && node --import tsx skills/clawmate-companion/scripts/probe-openai-edits.ts",

@@ -42,2 +42,3 @@ "clawmate:probe:tts": "cd packages/clawmate-companion && node --import tsx scripts/probe-qwen-tts.ts",

"devDependencies": {
"@google/genai": "^1.44.0",
"@types/node": "^24.3.1",

@@ -44,0 +45,0 @@ "openai": "^6.22.0",

+1
-1

@@ -5,3 +5,3 @@ {

"description": "角色化自拍生成插件(Tool + Skill)",
"version": "0.2.1",
"version": "0.3.0",
"skills": [

@@ -8,0 +8,0 @@ "./skills"

{
"name": "@clawmate/clawmate-companion",
"version": "0.2.1",
"version": "0.3.0",
"private": true,

@@ -20,4 +20,5 @@ "type": "module",

"dependencies": {
"@google/genai": "^1.44.0",
"openai": "^6.22.0"
}
}

@@ -9,2 +9,3 @@ import { ProviderError } from "../errors";

import { createModelScopeProvider } from "./modelscope";
import { createGeminiProvider } from "./gemini";
import type { ProviderAdapter, ProviderConfig, ProviderRegistry, ProvidersConfig } from "../types";

@@ -26,2 +27,3 @@

modelscope: "modelscope",
gemini: "gemini",
};

@@ -104,2 +106,4 @@

provider = createModelScopeProvider(config, fetchImpl);
} else if (type === "gemini") {
provider = createGeminiProvider(config);
} else if (type === "fal") {

@@ -106,0 +110,0 @@ provider = createFalProvider(config, fetchImpl);

@@ -144,2 +144,3 @@ # ClawMate

| --- | --- |
| Gemini 官方 SDK | 取决于 Gemini API 或代理服务额度 |
| ModelScope | 完全免费 |

@@ -154,2 +155,45 @@ | 阿里云百炼 | 有免费额度(以官方控制台为准) |

**Gemini 官方 SDK(官方默认地址)**
```json
{
"defaultProvider": "gemini",
"providers": {
"gemini": {
"type": "gemini",
"apiKey": "YOUR_GEMINI_API_KEY",
"model": "gemini-3.1-flash-image-preview"
}
}
}
```
**Gemini 官方 SDK(自定义 BaseURL)**
```json
{
"defaultProvider": "gemini",
"providers": {
"gemini": {
"type": "gemini",
"apiKey": "YOUR_GEMINI_API_KEY",
"baseUrl": "https://your-proxy.example.com",
"model": "gemini-3.1-flash-image-preview"
}
}
}
```
Gemini provider 走 Google GenAI SDK 原生图片请求,不复用 OpenAI-compatible 适配层。
- `baseUrl` 为空或不写时,使用 Gemini SDK 官方默认 API 地址
- `baseUrl` 有值时,改走你提供的自定义地址
- 自定义 `baseUrl` 需要兼容 Google GenAI SDK 的请求路径和鉴权语义
- 有参考图时会把参考图作为 inline image part 一起发给 Gemini
CLI 当前内置以下 Gemini 预设模型,也支持手动输入自定义模型名:
- `gemini-3-pro-image-preview`
- `gemini-3.1-flash-image-preview`
- `gemini-2.5-flash-image`
- `gemini-2.5-flash-image-preview`
**OpenAI 兼容接口**

@@ -156,0 +200,0 @@ ```json

Sorry, the diff of this file is too big to display