diff --git a/skills/clawrouter/SKILL.md b/skills/clawrouter/SKILL.md index 14b00e60..3066c563 100644 --- a/skills/clawrouter/SKILL.md +++ b/skills/clawrouter/SKILL.md @@ -129,11 +129,11 @@ Realtime prices and historical OHLC across every asset class. The agent should c ### Image & Video Generation -| Tool | Purpose | Price | -| ----------------------------- | ----------------------------------------------------------------------- | ---------------------- | -| `blockrun_image_generation` | 8 image models — DALL-E 3, Nano Banana / Pro, Flux, Grok Imagine, CogView-4 | $0.015–$0.15 / image | -| `blockrun_image_edit` | Edit / inpaint existing image (openai/gpt-image-1) | $0.02–$0.04 / image | -| `blockrun_video_generation` | Grok Imagine + ByteDance Seedance (1.5-pro / 2.0-fast / 2.0), 5–10s | $0.03–$0.30 / second | +| Tool | Purpose | Price | +| --------------------------- | --------------------------------------------------------------------------- | -------------------- | +| `blockrun_image_generation` | 8 image models — DALL-E 3, Nano Banana / Pro, Flux, Grok Imagine, CogView-4 | $0.015–$0.15 / image | +| `blockrun_image_edit` | Edit / inpaint existing image (openai/gpt-image-1) | $0.02–$0.04 / image | +| `blockrun_video_generation` | Grok Imagine + ByteDance Seedance (1.5-pro / 2.0-fast / 2.0), 5–10s | $0.03–$0.30 / second | ### Polymarket (Predexon) diff --git a/skills/imagegen/SKILL.md b/skills/imagegen/SKILL.md index c372df1d..c47b36ea 100644 --- a/skills/imagegen/SKILL.md +++ b/skills/imagegen/SKILL.md @@ -9,6 +9,7 @@ metadata: { "openclaw": { "emoji": "🖼️", "requires": { "config": ["models.p Generate or edit images through ClawRouter. Payment is automatic via x402. **Shortcuts:** + - Slash: `/imagegen [--model=] [--size=1024x1024] [--n=1]` - Partner tool: `blockrun_image_generation` (LLM-callable) / `blockrun_image_edit` (inpainting) @@ -40,16 +41,16 @@ Display inline: `![generated image](http://localhost:8402/images/abc123.png)` ### Model Selection -| Alias | Full ID | Price | Sizes | Best for | -| ------------------ | ----------------------------- | -------------- | --------------------------------- | ------------------------------------- | -| `nano-banana` | `google/nano-banana` | $0.05 | 1024×1024, 1216×832, 1024×1792 | Default — fast, cheap, good quality | -| `banana-pro` | `google/nano-banana-pro` | $0.10–$0.15 | up to 4096×4096 | High-res, large format | -| `dalle` | `openai/dall-e-3` | $0.04–$0.08 | 1024×1024, 1792×1024, 1024×1792 | Photorealistic, complex scenes | -| `gpt-image` | `openai/gpt-image-1` | $0.02–$0.04 | 1024×1024, 1536×1024, 1024×1536 | Budget option; supports editing | -| `flux` | `black-forest/flux-1.1-pro` | $0.04 | 1024×1024, 1216×832, 832×1216 | Artistic styles, fewer restrictions | -| `grok-imagine` | `xai/grok-imagine-image` | $0.02 | 1024×1024 | xAI Grok image style | -| `grok-imagine-pro` | `xai/grok-imagine-image-pro` | $0.07 | 1024×1024 | Grok high-quality | -| `cogview` | `zai/cogview-4` | $0.015–$0.02 | 512×512 to 1440×1440 | Cheapest — Zhipu CogView | +| Alias | Full ID | Price | Sizes | Best for | +| ------------------ | ---------------------------- | ------------ | ------------------------------- | ----------------------------------- | +| `nano-banana` | `google/nano-banana` | $0.05 | 1024×1024, 1216×832, 1024×1792 | Default — fast, cheap, good quality | +| `banana-pro` | `google/nano-banana-pro` | $0.10–$0.15 | up to 4096×4096 | High-res, large format | +| `dalle` | `openai/dall-e-3` | $0.04–$0.08 | 1024×1024, 1792×1024, 1024×1792 | Photorealistic, complex scenes | +| `gpt-image` | `openai/gpt-image-1` | $0.02–$0.04 | 1024×1024, 1536×1024, 1024×1536 | Budget option; supports editing | +| `flux` | `black-forest/flux-1.1-pro` | $0.04 | 1024×1024, 1216×832, 832×1216 | Artistic styles, fewer restrictions | +| `grok-imagine` | `xai/grok-imagine-image` | $0.02 | 1024×1024 | xAI Grok image style | +| `grok-imagine-pro` | `xai/grok-imagine-image-pro` | $0.07 | 1024×1024 | Grok high-quality | +| `cogview` | `zai/cogview-4` | $0.015–$0.02 | 512×512 to 1440×1440 | Cheapest — Zhipu CogView | **Choosing a model:** diff --git a/src/index.ts b/src/index.ts index ba9ee2aa..232004e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1684,10 +1684,7 @@ const plugin: OpenClawPluginDefinition = { } // Column width — keep tool names + pricing aligned across groups. - const toolWidth = Math.max( - ...PARTNER_SERVICES.map((s) => `blockrun_${s.id}`.length), - 28, - ); + const toolWidth = Math.max(...PARTNER_SERVICES.map((s) => `blockrun_${s.id}`.length), 28); const priceWidth = Math.max( ...PARTNER_SERVICES.map((s) => s.pricing.perUnit === "free" ? 4 : `${s.pricing.perUnit}/${s.pricing.unit}`.length, diff --git a/src/proxy.tool-forwarding.test.ts b/src/proxy.tool-forwarding.test.ts index 5e1fdc0e..2f86f681 100644 --- a/src/proxy.tool-forwarding.test.ts +++ b/src/proxy.tool-forwarding.test.ts @@ -1,6 +1,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { generatePrivateKey } from "viem/accounts"; import { startProxy, type ProxyHandle } from "./proxy.js"; @@ -10,6 +10,20 @@ describe("tool forwarding", () => { let proxy: ProxyHandle; let upstreamUrl = ""; let receivedBody: Record | null = null; + let upstreamResponse: Record = { + id: "chatcmpl-tool-forwarding", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "openai/gpt-4o", + choices: [ + { + index: 0, + message: { role: "assistant", content: "ok" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, + }; beforeAll(async () => { upstream = createServer(async (req: IncomingMessage, res: ServerResponse) => { @@ -21,22 +35,7 @@ describe("tool forwarding", () => { receivedBody = JSON.parse(Buffer.concat(chunks).toString()) as Record; res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - id: "chatcmpl-tool-forwarding", - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: "openai/gpt-4o", - choices: [ - { - index: 0, - message: { role: "assistant", content: "ok" }, - finish_reason: "stop", - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, - }), - ); + res.end(JSON.stringify(upstreamResponse)); }); await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); @@ -51,6 +50,23 @@ describe("tool forwarding", () => { }); }, 10_000); + beforeEach(() => { + upstreamResponse = { + id: "chatcmpl-tool-forwarding", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "openai/gpt-4o", + choices: [ + { + index: 0, + message: { role: "assistant", content: "ok" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, + }; + }); + afterAll(async () => { await proxy?.close(); await new Promise((resolve) => upstream.close(() => resolve())); @@ -98,4 +114,67 @@ describe("tool forwarding", () => { expect(parsedTools).toHaveLength(1); expect(parsedTools[0]?.function?.name).toBe("web_search"); }); + + it("suppresses assistant content when upstream returns tool_calls", async () => { + upstreamResponse = { + id: "chatcmpl-tool-content", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "moonshot/kimi-k2.6", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: + "The user wants the current time. I should call get_current_time with Chicago.", + tool_calls: [ + { + id: "get_current_time:0", + type: "function", + function: { + name: "get_current_time", + arguments: '{"city":"Chicago"}', + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + }; + + const res = await fetch(`${proxy.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "moonshot/kimi-k2.6", + stream: false, + messages: [{ role: "user", content: "What time is it in Chicago? Use the tool." }], + tools: [ + { + type: "function", + function: { + name: "get_current_time", + description: "Get current time", + parameters: { type: "object" }, + }, + }, + ], + }), + }); + + expect(res.status).toBe(200); + const json = (await res.json()) as { + choices?: Array<{ + message?: { + content?: string; + tool_calls?: unknown[]; + }; + }>; + }; + expect(json.choices?.[0]?.message?.content).toBe(""); + expect(json.choices?.[0]?.message?.tool_calls).toHaveLength(1); + }); }); diff --git a/src/proxy.ts b/src/proxy.ts index 4d103eb0..bdde30b6 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2475,7 +2475,11 @@ export async function startProxy(options: ProxyOptions): Promise { pollError = `Non-JSON poll response (${pollResp.status}): ${pollText.slice(0, 200)}`; break; } - if (pollResp.status === 202 || pollBody.status === "queued" || pollBody.status === "in_progress") { + if ( + pollResp.status === 202 || + pollBody.status === "queued" || + pollBody.status === "in_progress" + ) { await new Promise((r) => setTimeout(r, pollInterval)); continue; } @@ -2499,9 +2503,7 @@ export async function startProxy(options: ProxyOptions): Promise { } if (pollError) { res.writeHead(502, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ error: "Video generation failed", details: pollError }), - ); + res.end(JSON.stringify({ error: "Video generation failed", details: pollError })); return; } if (!finalResult.data) { @@ -2571,9 +2573,7 @@ export async function startProxy(options: ProxyOptions): Promise { // --- Handle paid API paths (/v1/partner/*, /v1/pm/*, /v1/exa/*, /v1/modal/*, // /v1/stocks/*, /v1/usstock/*, /v1/crypto/*, /v1/fx/*, /v1/commodity/*) --- - if ( - req.url?.match(/^\/v1\/(?:partner|pm|exa|modal|stocks|usstock|crypto|fx|commodity)\//) - ) { + if (req.url?.match(/^\/v1\/(?:partner|pm|exa|modal|stocks|usstock|crypto|fx|commodity)\//)) { try { await proxyPaidApiRequest( req, @@ -5069,9 +5069,14 @@ async function proxyRequest( // Process each choice (usually just one) if (rsp.choices && Array.isArray(rsp.choices)) { for (const choice of rsp.choices) { + // Some OpenAI-compatible providers include planning prose in content + // alongside tool_calls. Tool execution only needs tool_calls, so do + // not forward that prose to chat channels. + const toolCalls = choice.message?.tool_calls ?? choice.delta?.tool_calls; // Strip thinking tokens (Kimi <|...|> and standard tags) const rawContent = choice.message?.content ?? choice.delta?.content ?? ""; - const content = stripThinkingTokens(rawContent); + const content = + toolCalls && toolCalls.length > 0 ? "" : stripThinkingTokens(rawContent); const role = choice.message?.role ?? choice.delta?.role ?? "assistant"; const index = choice.index ?? 0; @@ -5139,7 +5144,6 @@ async function proxyRequest( } // Chunk 2b: tool_calls (forward tool calls from upstream) - const toolCalls = choice.message?.tool_calls ?? choice.delta?.tool_calls; if (toolCalls && toolCalls.length > 0) { const toolCallChunk = { ...baseChunk, @@ -5295,15 +5299,33 @@ async function proxyRequest( if (responseBody.length > 0) { try { const parsed = JSON.parse(responseBody.toString()) as { - choices?: Array<{ message?: { content?: string } }>; + choices?: Array<{ + message?: { + content?: string; + tool_calls?: unknown[]; + }; + }>; }; - if (parsed.choices?.[0]?.message?.content) { - const stripped = stripThinkingTokens(parsed.choices[0].message.content); - if (stripped !== parsed.choices[0].message.content) { - parsed.choices[0].message.content = stripped; - responseBody = Buffer.from(JSON.stringify(parsed)); + let changed = false; + for (const choice of parsed.choices ?? []) { + const message = choice.message; + if (!message || typeof message.content !== "string") continue; + + if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { + if (message.content !== "") { + message.content = ""; + changed = true; + } + continue; + } + + const stripped = stripThinkingTokens(message.content); + if (stripped !== message.content) { + message.content = stripped; + changed = true; } } + if (changed) responseBody = Buffer.from(JSON.stringify(parsed)); } catch { /* not JSON, skip */ } diff --git a/src/types.ts b/src/types.ts index cfb3e585..faec3909 100644 --- a/src/types.ts +++ b/src/types.ts @@ -383,6 +383,9 @@ export type OpenClawPluginDefinition = { register?: (api: OpenClawPluginApi) => void | Promise; activate?: (api: OpenClawPluginApi) => void | Promise; deactivate?: (api: OpenClawPluginApi) => void | Promise; + reload?: { + noopPrefixes?: string[]; + }; }; // Command types for registerCommand