@clawmate/clawmate
Advanced tools
| 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", |
@@ -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); |
+44
-0
@@ -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
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
17334375
0.12%49
4.26%8084
7.97%345
14.62%5
25%