Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mesh/src/ai-providers/adapters/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ export const openrouterAdapter: ProviderAdapter = {
mod === "image" ? "vision" : mod,
),
...m.architecture.output_modalities,
...(m.architecture.output_modalities?.includes("image")
? (["image-generation"] as const)
: []),
...(m.supported_parameters?.includes("tools")
? (["tools"] as const)
: []),
Expand Down
3 changes: 3 additions & 0 deletions apps/mesh/src/ai-providers/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ function mapOpenRouterModel(m: OpenRouterAPIModel): ModelInfo {
...new Set([
...m.architecture.input_modalities,
...m.architecture.output_modalities,
...(m.architecture.output_modalities?.includes("image")
? (["image-generation"] as const)
: []),
...(canTools ? (["tools"] as const) : []),
...(canReasoning ? (["reasoning"] as const) : []),
]),
Expand Down
4 changes: 4 additions & 0 deletions apps/mesh/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import oauthProxyRoutes, {
import openaiCompatRoutes from "./routes/openai-compat";
import proxyRoutes from "./routes/proxy";
import publicConfigRoutes from "./routes/public-config";
import filesRoutes from "./routes/files";
import selfRoutes from "./routes/self";
import { shouldSkipMeshContext, SYSTEM_PATHS } from "./utils/paths";
import {
Expand Down Expand Up @@ -1319,6 +1320,9 @@ export async function createApp(options: CreateAppOptions = {}) {
// Downstream token management routes
app.route("/api", downstreamTokenRoutes);

// File serving from object storage (generated images, etc.)
app.route("/api/files", filesRoutes);

// Deco.cx sites list (requires meshContext / auth)
app.route("/api/deco-sites", decoSitesRoutes);

Expand Down
203 changes: 203 additions & 0 deletions apps/mesh/src/api/routes/decopilot/built-in-tools/generate-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* generate_image Built-in Tool
*
* Server-side tool that generates images using the AI SDK's generateImage()
* function. When object storage is available, images are persisted there and
* served via /api/files; otherwise they are inlined as base64 data URLs.
*/

import type { MeshContext } from "@/core/mesh-context";
import type { MeshProvider } from "@/ai-providers/types";
import { monitorLlmCall } from "@/monitoring/emit-llm-call";
import { recordLlmCallMetrics } from "@/monitoring/record-llm-call-metrics";
import type { UIMessageStreamWriter } from "ai";
import { generateImage, tool, zodSchema } from "ai";
import { z } from "zod";
import type { ModelsConfig } from "../types";

const ALLOWED_IMAGE_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
]);

const MEDIA_TYPE_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
};

const GenerateImageInputSchema = z.object({
prompt: z
.string()
.min(1)
.max(10_000)
.describe(
"Detailed description of the image to generate. Be specific about style, composition, colors, and subject.",
),
aspect_ratio: z
.enum(["1:1", "16:9", "9:16", "4:3", "3:4"])
.optional()
.describe("Aspect ratio for the generated image. Defaults to 1:1."),
});

const GENERATE_IMAGE_DESCRIPTION =
"Generate an image from a text description. The generated image is displayed " +
"inline to the user. Use this when the user asks you to create, draw, or " +
"generate an image or picture.";

const GENERATE_IMAGE_ANNOTATIONS = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
} as const;

export interface GenerateImageParams {
provider: MeshProvider;
imageModelId: string;
defaultAspectRatio?: string;
models: ModelsConfig;
organizationId: string;
agentId: string;
userId: string;
threadId: string;
}

export function createGenerateImageTool(
writer: UIMessageStreamWriter,
params: GenerateImageParams,
ctx: MeshContext,
) {
const {
provider,
imageModelId,
defaultAspectRatio,
models,
organizationId,
agentId,
userId,
threadId,
} = params;

return tool({
description: GENERATE_IMAGE_DESCRIPTION,
inputSchema: zodSchema(GenerateImageInputSchema),
execute: async ({ prompt, aspect_ratio }, { abortSignal, toolCallId }) => {
const aspectRatio = (aspect_ratio ?? defaultAspectRatio ?? "1:1") as
| `${number}:${number}`
| undefined;

const startTime = Date.now();

try {
const result = await generateImage({
model: provider.aiSdk.imageModel(imageModelId),
prompt,
aspectRatio,
abortSignal,
});

const durationMs = Date.now() - startTime;
recordLlmCallMetrics({
ctx,
organizationId,
modelId: imageModelId,
durationMs,
isError: false,
});
monitorLlmCall({
ctx,
organizationId,
agentId,
modelId: imageModelId,
modelTitle: imageModelId,
credentialId: models.credentialId,
threadId,
durationMs,
isError: false,
finishReason: "stop",
userId,
requestId: ctx.metadata.requestId,
userAgent: ctx.metadata.userAgent ?? null,
});

const base64 = result.image.base64;
const rawMediaType = result.image.mediaType ?? "image/png";
if (!ALLOWED_IMAGE_TYPES.has(rawMediaType)) {
return `Image generation failed: unsupported image type "${rawMediaType}". Please try a different model.`;
}

// Try to persist to object storage; fall back to inline base64
let imageUrl: string;
if (ctx.objectStorage) {
const ext = MEDIA_TYPE_EXT[rawMediaType] ?? "png";
const key = `generated-images/${threadId}/${toolCallId}.${ext}`;
await ctx.objectStorage.put(key, Buffer.from(base64, "base64"), {
contentType: rawMediaType,
});
imageUrl = `/api/files/${key}`;
} else {
imageUrl = `data:${rawMediaType};base64,${base64}`;
}

// Write the image as a file part directly to the stream
writer.write({
type: "file",
url: imageUrl,
mediaType: rawMediaType,
});

// Write tool metadata
writer.write({
type: "data-tool-metadata",
id: toolCallId,
data: {
annotations: GENERATE_IMAGE_ANNOTATIONS,
latencyMs: durationMs,
},
});

return `Image generated successfully (${aspectRatio ?? "1:1"}).`;
} catch (error) {
// Don't record abort as an error
if (abortSignal?.aborted) {
throw error;
}

const durationMs = Date.now() - startTime;
recordLlmCallMetrics({
ctx,
organizationId,
modelId: imageModelId,
durationMs,
isError: true,
errorType: error instanceof Error ? error.name : "Error",
});
monitorLlmCall({
ctx,
organizationId,
agentId,
modelId: imageModelId,
modelTitle: imageModelId,
credentialId: models.credentialId,
threadId,
durationMs,
isError: true,
errorMessage: error instanceof Error ? error.message : String(error),
userId,
requestId: ctx.metadata.requestId,
userAgent: ctx.metadata.userAgent ?? null,
});

const errorMsg = error instanceof Error ? error.message : String(error);
// Return error as tool result instead of throwing — throwing from a tool's
// execute crashes the entire stream, while returning lets the model see the
// error and respond with a friendly message to the user.
return `Image generation failed: ${errorMsg}. Please try again or use a different image model.`;
}
},
});
}
36 changes: 35 additions & 1 deletion apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,27 @@ import { createSandboxTool, type VirtualClient } from "./sandbox";
import { createSubtaskTool } from "./subtask";
import { userAskTool } from "./user-ask";
import { proposePlanTool } from "./propose-plan";
import { createGenerateImageTool } from "./generate-image";
import type { ModelsConfig } from "../types";
import { MeshProvider } from "@/ai-providers/types";

export interface ImageConfig {
imageModelId: string;
defaultAspectRatio?: string;
organizationId: string;
agentId: string;
userId: string;
threadId: string;
}

export interface BuiltinToolParams {
provider: MeshProvider;
organization: OrganizationScope;
models: ModelsConfig;
toolApprovalLevel?: ToolApprovalLevel;
toolOutputMap: Map<string, string>;
passthroughClient: VirtualClient;
imageConfig?: ImageConfig;
}

/**
Expand All @@ -47,8 +58,9 @@ function buildAllTools(
toolApprovalLevel = "readonly",
toolOutputMap,
passthroughClient,
imageConfig,
} = params;
return {
const tools = {
user_ask: userAskTool,
propose_plan: proposePlanTool,
subtask: createSubtaskTool(
Expand Down Expand Up @@ -86,6 +98,28 @@ function buildAllTools(
toolOutputMap,
}),
} as const;

if (imageConfig && typeof provider.aiSdk.imageModel === "function") {
return {
...tools,
generate_image: createGenerateImageTool(
writer,
{
provider,
imageModelId: imageConfig.imageModelId,
defaultAspectRatio: imageConfig.defaultAspectRatio,
models,
organizationId: imageConfig.organizationId,
agentId: imageConfig.agentId,
userId: imageConfig.userId,
threadId: imageConfig.threadId,
},
ctx,
),
} as const;
}

return tools;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions apps/mesh/src/api/routes/decopilot/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) {
memory: memoryConfig,
thread_id,
toolApprovalLevel,
imageModel,
} = await validateRequest(c);

const userId = ctx.auth?.user?.id;
Expand Down Expand Up @@ -200,6 +201,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) {
userId,
threadId: resolvedThreadId,
windowSize,
imageModel,
},
ctx,
{ runRegistry, streamBuffer, cancelBroadcast },
Expand Down
6 changes: 6 additions & 0 deletions apps/mesh/src/api/routes/decopilot/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ export const StreamRequestSchema = z.object({
temperature: z.number().default(0.5),
thread_id: z.string().optional(),
toolApprovalLevel: z.enum(["auto", "readonly", "plan"]).default("readonly"),
imageModel: z
.object({
id: z.string(),
aspectRatio: z.enum(["1:1", "16:9", "9:16", "4:3", "3:4"]).optional(),
})
.optional(),
});

export type StreamRequest = z.infer<typeof StreamRequestSchema>;
19 changes: 19 additions & 0 deletions apps/mesh/src/api/routes/decopilot/stream-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export interface StreamCoreInput {
windowSize?: number;
abortSignal?: AbortSignal;
isResume?: boolean;
imageModel?: { id: string; aspectRatio?: string };
}

export interface StreamCoreDeps {
Expand Down Expand Up @@ -301,6 +302,8 @@ async function streamCoreInner(
await saveMessagesToThread(requestMessage);
}

await saveMessagesToThread(requestMessage);

// Close MCP clients on abort
registrySignal.addEventListener("abort", () => {
closeClients?.();
Expand Down Expand Up @@ -377,6 +380,16 @@ async function streamCoreInner(
toolApprovalLevel: input.toolApprovalLevel,
toolOutputMap,
passthroughClient,
...(input.imageModel && {
imageConfig: {
imageModelId: input.imageModel.id,
defaultAspectRatio: input.imageModel.aspectRatio,
organizationId: input.organizationId,
agentId: input.agent.id,
userId: input.userId,
threadId: mem.thread.id,
},
}),
},
ctx,
);
Expand Down Expand Up @@ -446,12 +459,18 @@ async function streamCoreInner(
"</plan-mode>"
: null;

// Image generation hint when an image model is selected
const imagePrompt = input.imageModel
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
? `<image-generation>\nThe user has selected an image generation model. When they describe something they want as an image, use the generate_image tool immediately without asking for confirmation.\n</image-generation>`
: null;

const systemPrompts = [
basePrompt,
planModePrompt,
toolCatalog,
promptCatalog,
agentPrompt,
imagePrompt,
].filter((s): s is string => Boolean(s?.trim()));

const {
Expand Down
Loading
Loading