Canvas: Support Anthropic Claude based AI agents

Change-Id: Ib74c9672da9a80a4f20d63741a471c728a435b8e
diff --git a/apps/canvas/back/prisma/migrations/20250703041848_anthropic_api_key/migration.sql b/apps/canvas/back/prisma/migrations/20250703041848_anthropic_api_key/migration.sql
new file mode 100644
index 0000000..d42d450
--- /dev/null
+++ b/apps/canvas/back/prisma/migrations/20250703041848_anthropic_api_key/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Project" ADD COLUMN "anthropicApiKey" TEXT;
diff --git a/apps/canvas/back/prisma/schema.prisma b/apps/canvas/back/prisma/schema.prisma
index adc03cf..cee6787 100644
--- a/apps/canvas/back/prisma/schema.prisma
+++ b/apps/canvas/back/prisma/schema.prisma
@@ -24,7 +24,8 @@
   deployKeyPublic String?
   githubToken     String?
   access          String?
-  geminiApiKey     String?
+  geminiApiKey    String?
+  anthropicApiKey String?
   logs            Log[]
 }
 
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index cd18249..b33cbbc 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -326,6 +326,7 @@
 				deployKeyPublic: true,
 				state: true,
 				geminiApiKey: true,
+				anthropicApiKey: true,
 			},
 		});
 		if (p === null) {
@@ -381,6 +382,7 @@
 					private: deployKey!,
 				},
 				geminiApiKey: p.geminiApiKey ?? undefined,
+				anthropicApiKey: p.anthropicApiKey ?? undefined,
 			},
 		};
 		try {
@@ -637,6 +639,26 @@
 	}
 };
 
+const handleUpdateAnthropicToken: express.Handler = async (req, resp) => {
+	try {
+		await db.project.update({
+			where: {
+				id: Number(req.params["projectId"]),
+				userId: resp.locals.userId,
+			},
+			data: {
+				anthropicApiKey: req.body.anthropicApiKey,
+			},
+		});
+		resp.status(200);
+	} catch (e) {
+		console.log(e);
+		resp.status(500);
+	} finally {
+		resp.end();
+	}
+};
+
 const getNetworks = (username?: string | undefined): Network[] => {
 	return [
 		{
@@ -673,6 +695,7 @@
 			deployKeyPublic: true,
 			githubToken: true,
 			geminiApiKey: true,
+			anthropicApiKey: true,
 			access: true,
 			instanceId: true,
 		},
@@ -698,6 +721,7 @@
 		integrations: {
 			github: !!project.githubToken,
 			gemini: !!project.geminiApiKey,
+			anthropic: !!project.anthropicApiKey,
 		},
 		networks: getNetworks(username),
 		services,
@@ -1074,6 +1098,7 @@
 	projectRouter.get("/:projectId/repos/github", handleGithubRepos);
 	projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
 	projectRouter.post("/:projectId/gemini-token", handleUpdateGeminiToken);
+	projectRouter.post("/:projectId/anthropic-token", handleUpdateAnthropicToken);
 	projectRouter.get("/:projectId/env", handleEnv);
 	projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
 	projectRouter.post("/:projectId/reload", handleReload);
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
index 1deeb7a..9568c7e 100644
--- a/apps/canvas/config/src/config.ts
+++ b/apps/canvas/config/src/config.ts
@@ -127,13 +127,18 @@
 						: {
 								enabled: false,
 							},
-					...(n.data.agent != null
-						? {
-								agent: {
-									geminiApiKey: n.data.agent.geminiApiKey,
-								},
-							}
-						: {}),
+					...(n.data.model?.name === "gemini" && {
+						model: {
+							name: "gemini",
+							geminiApiKey: n.data.model.apiKey,
+						},
+					}),
+					...(n.data.model?.name === "claude" && {
+						model: {
+							name: "claude",
+							anthropicApiKey: n.data.model.apiKey,
+						},
+					}),
 				};
 			});
 		return {
@@ -192,7 +197,7 @@
 	if (networks.length === 0) {
 		return ret;
 	}
-	const repoNodes = (config.service || [])
+	const repoNodes = [...(config.service || []), ...(config.agent || [])]
 		.filter((s) => s.source?.repository != null)
 		.map((s): GithubNode | null => {
 			const existing = current.nodes.find(
@@ -292,7 +297,12 @@
 				}),
 				volume: s.volume || [],
 				preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
-				agent: s.agent,
+				...(s.model != null && {
+					model:
+						s.model.name === "gemini"
+							? { name: "gemini", apiKey: s.model.geminiApiKey }
+							: { name: "claude", apiKey: s.model.anthropicApiKey },
+				}),
 				// TODO(gio): dev
 				isChoosingPortToConnect: false,
 			},
@@ -306,54 +316,55 @@
 						},
 		};
 	});
-	const serviceGateways = config.service?.flatMap((s, index): GatewayHttpsNode[] => {
-		return (s.ingress || []).map((i): GatewayHttpsNode => {
-			let existing: GatewayHttpsNode | null = null;
-			if (i.nodeId !== undefined) {
-				existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
-			}
-			console.log("!!!", i.network, networks);
-			return {
-				id: existing != null ? existing.id : uuidv4(),
-				type: "gateway-https",
-				data: {
-					label: i.subdomain,
-					envVars: [],
-					ports: [],
-					network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
-					subdomain: i.subdomain,
-					https: {
-						serviceId: services![index]!.id,
-						portId: services![index]!.data.ports.find((p) => {
-							const port = i.port;
-							if ("name" in port) {
-								return p.name === port.name;
-							} else {
-								return `${p.value}` === port.value;
-							}
-						})!.id,
+	const serviceGateways = [...(config.service || []), ...(config.agent || [])]?.flatMap(
+		(s, index): GatewayHttpsNode[] => {
+			return (s.ingress || []).map((i): GatewayHttpsNode => {
+				let existing: GatewayHttpsNode | null = null;
+				if (i.nodeId !== undefined) {
+					existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
+				}
+				return {
+					id: existing != null ? existing.id : uuidv4(),
+					type: "gateway-https",
+					data: {
+						label: i.subdomain,
+						envVars: [],
+						ports: [],
+						network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
+						subdomain: i.subdomain,
+						https: {
+							serviceId: services![index]!.id,
+							portId: services![index]!.data.ports.find((p) => {
+								const port = i.port;
+								if ("name" in port) {
+									return p.name === port.name;
+								} else {
+									return `${p.value}` === port.value;
+								}
+							})!.id,
+						},
+						auth: i.auth.enabled
+							? {
+									enabled: true,
+									groups: i.auth.groups || [],
+									noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
+								}
+							: {
+									enabled: false,
+									groups: [],
+									noAuthPathPatterns: [],
+								},
 					},
-					auth: i.auth.enabled
-						? {
-								enabled: true,
-								groups: i.auth.groups || [],
-								noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
-							}
-						: {
-								enabled: false,
-								groups: [],
-								noAuthPathPatterns: [],
-							},
-				},
-				position: {
-					x: 0,
-					y: 0,
-				},
-			};
-		});
-	});
+					position: {
+						x: 0,
+						y: 0,
+					},
+				};
+			});
+		},
+	);
 	const exposures = new Map<string, GatewayTCPNode>();
-	config.service
+	[...(config.service || []), ...(config.agent || [])]
 		?.flatMap((s, index): GatewayTCPNode[] => {
 			return (s.expose || []).map((e): GatewayTCPNode => {
 				let existing: GatewayTCPNode | null = null;
@@ -649,15 +660,20 @@
 			},
 		];
 	});
-	const repoEdges = (services || []).map((s): Edge => {
-		return {
-			id: uuidv4(),
-			source: s.data.repository!.repoNodeId!,
-			sourceHandle: "repository",
-			target: s.id,
-			targetHandle: "repository",
-		};
-	});
+	const repoEdges = (services || [])
+		.map((s): Edge | null => {
+			if (s.data.repository == null) {
+				return null;
+			}
+			return {
+				id: uuidv4(),
+				source: s.data.repository!.repoNodeId!,
+				sourceHandle: "repository",
+				target: s.id,
+				targetHandle: "repository",
+			};
+		})
+		.filter((e) => e != null);
 	ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
 	return ret;
 }
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index 604bcc4..90523d5 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -163,8 +163,9 @@
 				codeServerNodeId: string;
 				sshNodeId: string;
 		  };
-	agent?: {
-		geminiApiKey?: string;
+	model?: {
+		name: "gemini" | "claude";
+		apiKey?: string;
 	};
 	info?: z.infer<typeof serviceAnalyzisSchema>;
 };
@@ -306,12 +307,19 @@
 });
 
 export const envSchema = z.object({
-	instanceId: z.optional(z.string().min(1)),
-	deployKeyPublic: z.optional(z.nullable(z.string().min(1))),
-	networks: z.array(networkSchema).default([]),
+	deployKeyPublic: z.string().optional(),
+	instanceId: z.string().optional(),
+	networks: z.array(
+		z.object({
+			name: z.string(),
+			domain: z.string(),
+			hasAuth: z.boolean(),
+		}),
+	),
 	integrations: z.object({
 		github: z.boolean(),
 		gemini: z.boolean(),
+		anthropic: z.boolean(),
 	}),
 	services: z.array(serviceInfoSchema),
 	user: z.object({
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
index de0725a..e186f4b 100644
--- a/apps/canvas/config/src/types.ts
+++ b/apps/canvas/config/src/types.ts
@@ -54,6 +54,17 @@
 
 const ServiceTypeSchema = z.enum(ServiceTypes);
 
+const ModelSchema = z.discriminatedUnion("name", [
+	z.object({
+		name: z.literal("claude"),
+		anthropicApiKey: z.string().optional(),
+	}),
+	z.object({
+		name: z.literal("gemini"),
+		geminiApiKey: z.string().optional(),
+	}),
+]);
+
 const ServiceSchema = z.object({
 	nodeId: z.string().optional(),
 	type: ServiceTypeSchema,
@@ -94,15 +105,12 @@
 			codeServer: DomainSchema.optional(),
 		})
 		.optional(),
-	agent: z
-		.object({
-			geminiApiKey: z.string().optional(),
-		})
-		.optional(),
+	model: ModelSchema.optional(),
 });
 
 const AgentSchema = ServiceSchema.extend({
 	type: z.literal("sketch:latest"),
+	model: ModelSchema,
 });
 
 const VolumeTypeSchema = z.enum(["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]);
@@ -146,6 +154,7 @@
 		})
 		.optional(),
 	geminiApiKey: z.string().optional(),
+	anthropicApiKey: z.string().optional(),
 });
 
 export const ConfigWithInputSchema = ConfigSchema.extend({
diff --git a/apps/canvas/front/src/Integrations.tsx b/apps/canvas/front/src/Integrations.tsx
index cf4b586..09bc28d 100644
--- a/apps/canvas/front/src/Integrations.tsx
+++ b/apps/canvas/front/src/Integrations.tsx
@@ -1,4 +1,4 @@
-import { useProjectId, useGithubService, useStateStore, useGeminiService } from "@/lib/state";
+import { useProjectId, useGithubService, useStateStore, useGeminiService, useAnthropicService } from "@/lib/state";
 import { Form, FormControl, FormField, FormItem, FormMessage } from "./components/ui/form";
 import { Input } from "./components/ui/input";
 import { useForm } from "react-hook-form";
@@ -17,14 +17,20 @@
 	geminiApiKey: z.string().min(1, "Gemini API token is required"),
 });
 
+const anthropicSchema = z.object({
+	anthropicApiKey: z.string().min(1, "Anthropic API token is required"),
+});
+
 export function Integrations() {
 	const { toast } = useToast();
 	const store = useStateStore();
 	const projectId = useProjectId();
 	const [isEditingGithub, setIsEditingGithub] = useState(false);
 	const [isEditingGemini, setIsEditingGemini] = useState(false);
+	const [isEditingAnthropic, setIsEditingAnthropic] = useState(false);
 	const githubService = useGithubService();
 	const geminiService = useGeminiService();
+	const anthropicService = useAnthropicService();
 	const [isSaving, setIsSaving] = useState(false);
 
 	const githubForm = useForm<z.infer<typeof githubSchema>>({
@@ -37,6 +43,11 @@
 		mode: "onChange",
 	});
 
+	const anthropicForm = useForm<z.infer<typeof anthropicSchema>>({
+		resolver: zodResolver(anthropicSchema),
+		mode: "onChange",
+	});
+
 	const onGithubSubmit = useCallback(
 		async (data: z.infer<typeof githubSchema>) => {
 			if (!projectId) return;
@@ -109,6 +120,40 @@
 		[projectId, store, geminiForm, toast, setIsEditingGemini, setIsSaving],
 	);
 
+	const onAnthropicSubmit = useCallback(
+		async (data: z.infer<typeof anthropicSchema>) => {
+			if (!projectId) return;
+			setIsSaving(true);
+			try {
+				const response = await fetch(`/api/project/${projectId}/anthropic-token`, {
+					method: "POST",
+					headers: {
+						"Content-Type": "application/json",
+					},
+					body: JSON.stringify({ anthropicApiKey: data.anthropicApiKey }),
+				});
+				if (!response.ok) {
+					throw new Error("Failed to save Anthropic token");
+				}
+				await store.refreshEnv();
+				setIsEditingAnthropic(false);
+				anthropicForm.reset();
+				toast({
+					title: "Anthropic token saved successfully",
+				});
+			} catch (error) {
+				toast({
+					variant: "destructive",
+					title: "Failed to save Anthropic token",
+					description: error instanceof Error ? error.message : "Unknown error",
+				});
+			} finally {
+				setIsSaving(false);
+			}
+		},
+		[projectId, store, anthropicForm, toast, setIsEditingAnthropic, setIsSaving],
+	);
+
 	const handleCancelGithub = () => {
 		setIsEditingGithub(false);
 		githubForm.reset();
@@ -119,6 +164,11 @@
 		geminiForm.reset();
 	};
 
+	const handleCancelAnthropic = () => {
+		setIsEditingAnthropic(false);
+		anthropicForm.reset();
+	};
+
 	return (
 		<div className="px-4 py-1">
 			<div className="flex flex-col gap-1">
@@ -261,6 +311,67 @@
 					</Form>
 				)}
 			</div>
+			<div className="flex flex-col gap-1">
+				<div className="flex flex-row items-center gap-1">
+					{anthropicService ? <CircleCheck /> : <CircleX />}
+					<div>Anthropic</div>
+				</div>
+
+				{!!anthropicService && !isEditingAnthropic && (
+					<Button variant="outline" className="w-fit" onClick={() => setIsEditingAnthropic(true)}>
+						Update API Key
+					</Button>
+				)}
+
+				{(!anthropicService || isEditingAnthropic) && (
+					<div className="flex flex-row items-center gap-1 text-sm">
+						<div>
+							Follow the link to generate new API Key:{" "}
+							<a href="https://console.anthropic.com/settings/keys" target="_blank">
+								https://console.anthropic.com/settings/keys
+							</a>
+						</div>
+					</div>
+				)}
+				{(!anthropicService || isEditingAnthropic) && (
+					<Form {...anthropicForm}>
+						<form className="space-y-2" onSubmit={anthropicForm.handleSubmit(onAnthropicSubmit)}>
+							<FormField
+								control={anthropicForm.control}
+								name="anthropicApiKey"
+								render={({ field }) => (
+									<FormItem>
+										<FormControl>
+											<Input
+												type="password"
+												placeholder="Anthropic API Token"
+												className="w-1/4"
+												{...field}
+											/>
+										</FormControl>
+										<FormMessage />
+									</FormItem>
+								)}
+							/>
+							<div className="flex flex-row items-center gap-1">
+								<Button type="submit" disabled={isSaving}>
+									{isSaving ? "Saving..." : "Save"}
+								</Button>
+								{!!anthropicService && (
+									<Button
+										type="button"
+										variant="outline"
+										onClick={handleCancelAnthropic}
+										disabled={isSaving}
+									>
+										Cancel
+									</Button>
+								)}
+							</div>
+						</form>
+					</Form>
+				)}
+			</div>
 		</div>
 	);
 }
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index fa08977..a01709f 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -6,7 +6,7 @@
 import { z } from "zod";
 import { useForm, EventType, DeepPartial } from "react-hook-form";
 import { zodResolver } from "@hookform/resolvers/zod";
-import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
+import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from "./ui/form";
 import { Button } from "./ui/button";
 import { Handle, Position, useNodes } from "@xyflow/react";
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
@@ -80,7 +80,8 @@
 });
 
 const agentSchema = z.object({
-	geminiApiKey: z.string().optional(),
+	model: z.enum(["gemini", "claude"]),
+	apiKey: z.string().optional(),
 });
 
 export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
@@ -253,19 +254,34 @@
 		resolver: zodResolver(agentSchema),
 		mode: "onChange",
 		defaultValues: {
-			geminiApiKey: data.agent?.geminiApiKey,
+			apiKey: data.model?.apiKey,
+			model: data.model?.name,
 		},
 	});
 	useEffect(() => {
-		const sub = agentForm.watch((value) => {
-			store.updateNodeData<"app">(id, {
-				agent: {
-					geminiApiKey: value.geminiApiKey,
-				},
-			});
+		const sub = agentForm.watch((value, { name }: { name?: keyof z.infer<typeof agentSchema> | undefined }) => {
+			switch (name) {
+				case "model":
+					agentForm.setValue("apiKey", "", { shouldDirty: true });
+					store.updateNodeData<"app">(id, {
+						model: {
+							name: value.model,
+							apiKey: undefined,
+						},
+					});
+					break;
+				case "apiKey":
+					store.updateNodeData<"app">(id, {
+						model: {
+							name: data.model?.name,
+							apiKey: value.apiKey,
+						},
+					});
+					break;
+			}
 		});
 		return () => sub.unsubscribe();
-	}, [id, agentForm, store]);
+	}, [id, agentForm, store, data]);
 	return (
 		<>
 			<SourceRepo node={node} disabled={disabled} />
@@ -307,16 +323,41 @@
 			{node.data.type === "sketch:latest" && (
 				<Form {...agentForm}>
 					<form className="space-y-2">
-						<Label>Gemini API Key</Label>
 						<FormField
 							control={agentForm.control}
-							name="geminiApiKey"
+							name="model"
+							render={({ field }) => (
+								<FormItem>
+									<FormLabel>AI Model</FormLabel>
+									<Select
+										onValueChange={field.onChange}
+										defaultValue={field.value}
+										disabled={disabled}
+									>
+										<FormControl>
+											<SelectTrigger>
+												<SelectValue placeholder="Select a model" />
+											</SelectTrigger>
+										</FormControl>
+										<SelectContent>
+											<SelectItem value="gemini">Gemini</SelectItem>
+											<SelectItem value="claude">Claude</SelectItem>
+										</SelectContent>
+									</Select>
+									<FormMessage />
+								</FormItem>
+							)}
+						/>
+						<Label>API Key</Label>
+						<FormField
+							control={agentForm.control}
+							name="apiKey"
 							render={({ field }) => (
 								<FormItem>
 									<FormControl>
 										<Input
 											type="password"
-											placeholder="Override Gemini API key"
+											placeholder="Override AI Model API key"
 											{...field}
 											value={field.value || ""}
 											disabled={disabled}
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index e2ad19d..1736916 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -396,11 +396,38 @@
 function AgentApiKeyValidator(nodes: AppNode[], env: Env): Message[] {
 	return nodes
 		.filter((n): n is ServiceNode => n.type === "app" && n.data.type === "sketch:latest")
-		.filter((n) => n.data.agent?.geminiApiKey == null && !env.integrations.gemini)
-		.map((n) => ({
-			id: `${n.id}-no-agent-api-key`,
-			type: "FATAL",
-			nodeId: n.id,
-			message: "Configure Gemini API key either on the service or in the project integrations",
-		}));
+		.flatMap((n) => {
+			const messages: Message[] = [];
+			const model = n.data.model;
+
+			if (!model || !model.name) {
+				messages.push({
+					id: `${n.id}-no-agent-model`,
+					type: "FATAL",
+					nodeId: n.id,
+					message: "Select an AI model for the agent (Gemini or Claude).",
+				});
+				return messages;
+			}
+
+			if (model.name === "gemini" && !model.apiKey && !env.integrations.gemini) {
+				messages.push({
+					id: `${n.id}-no-gemini-api-key`,
+					type: "FATAL",
+					nodeId: n.id,
+					message: "Configure Gemini API key either on the service or in the project integrations.",
+				});
+			}
+
+			if (model.name === "claude" && !model.apiKey && !env.integrations.anthropic) {
+				messages.push({
+					id: `${n.id}-no-anthropic-api-key`,
+					type: "FATAL",
+					nodeId: n.id,
+					message: "Configure Anthropic API key either on the service or in the project integrations.",
+				});
+			}
+
+			return messages;
+		});
 }
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 8f21c26..e0a858a 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -161,6 +161,7 @@
 	integrations: {
 		github: false,
 		gemini: false,
+		anthropic: false,
 	},
 	services: [],
 	user: {
@@ -283,6 +284,10 @@
 	return useStateStore(envSelector).integrations.gemini;
 }
 
+export function useAnthropicService(): boolean {
+	return useStateStore(envSelector).integrations.anthropic;
+}
+
 export function useGithubRepositories(): GitHubRepository[] {
 	return useStateStore(githubRepositoriesSelector);
 }