Commit e711729a189e

Vincent Demeester <vincent@sbr.pm>
2026-02-09 10:20:48
feat(pi): added vertex-claude extension
Added pi extension for using Claude models via Google Vertex AI. Provides integration with GCP's Vertex AI Claude models including streaming support, structured outputs, and proper error handling.
1 parent d8f3581
dots/pi/agent/extensions/vertex-claude/.github/hooks/claude-hooks.json
@@ -0,0 +1,38 @@
+{
+  "version": 1,
+  "hooks": {
+    "sessionStart": [
+      {
+        "type": "command",
+        "bash": "claude-hooks-initialize-session",
+        "timeoutSec": 5
+      }
+    ],
+    "sessionEnd": [
+      {
+        "type": "command",
+        "bash": "claude-hooks-save-session",
+        "timeoutSec": 10
+      }
+    ],
+    "preToolUse": [
+      {
+        "type": "command",
+        "bash": ".github/hooks/copilot-to-claude.sh claude-hooks-validate-git-push",
+        "timeoutSec": 5
+      }
+    ],
+    "postToolUse": [
+      {
+        "type": "command",
+        "bash": ".github/hooks/copilot-to-claude.sh claude-hooks-capture-tool-output",
+        "timeoutSec": 5
+      },
+      {
+        "type": "command",
+        "bash": ".github/hooks/copilot-to-claude.sh claude-hooks-update-terminal-title",
+        "timeoutSec": 2
+      }
+    ]
+  }
+}
dots/pi/agent/extensions/vertex-claude/.github/hooks/copilot-to-claude.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Wrapper script to translate Copilot hook JSON format to Claude Code format
+# Usage: copilot-to-claude.sh <claude-hooks-binary>
+#
+# Copilot format:
+#   { "toolName": "bash", "toolArgs": "{\"command\":\"...\"}", "toolResult": {...} }
+#
+# Claude Code format:
+#   { "tool_name": "Bash", "tool_input": {"command": "..."}, "tool_response": {...} }
+
+set -euo pipefail
+
+BINARY="${1:-}"
+if [[ -z "$BINARY" ]]; then
+    echo "Usage: $0 <claude-hooks-binary>" >&2
+    exit 1
+fi
+
+# Read stdin
+INPUT=$(cat)
+
+# Check if we have input
+if [[ -z "$INPUT" ]]; then
+    exit 0
+fi
+
+# Transform Copilot format to Claude Code format using jq
+# - toolName -> tool_name (capitalize first letter for Claude Code convention)
+# - toolArgs (string) -> tool_input (parsed object)
+# - toolResult -> tool_response
+TRANSFORMED=$(echo "$INPUT" | jq -c '
+{
+  tool_name: (.toolName | split("") | .[0:1] | map(ascii_upcase) | join("") + (.toolName | .[1:])),
+  tool_input: (if .toolArgs then (.toolArgs | fromjson) else {} end),
+  tool_response: (.toolResult // {}),
+  conversation_id: (.sessionId // "copilot")
+}
+' 2>/dev/null) || {
+    # If jq fails, just pass through empty and let binary handle it
+    exit 0
+}
+
+# Call the Claude hooks binary with transformed input
+echo "$TRANSFORMED" | "$BINARY"
+exit $?
dots/pi/agent/extensions/vertex-claude/test/vertex-claude.integration.test.ts
@@ -0,0 +1,60 @@
+import { existsSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+
+let streamVertexClaude: typeof import("../index.js").streamVertexClaude;
+
+function hasAdcCredentials(): boolean {
+	const adcPath =
+		process.env.GOOGLE_APPLICATION_CREDENTIALS ??
+		join(homedir(), ".config", "gcloud", "application_default_credentials.json");
+	return existsSync(adcPath);
+}
+
+const project = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT;
+const location = process.env.GOOGLE_CLOUD_LOCATION || process.env.CLOUD_ML_REGION;
+const shouldRun = !!project && !!location && hasAdcCredentials();
+
+describe.skipIf(!shouldRun)("Vertex Claude integration (ADC)", () => {
+	beforeAll(async () => {
+		vi.resetModules();
+		vi.doUnmock("@mariozechner/pi-ai");
+		const module = await import("../index.js");
+		streamVertexClaude = module.streamVertexClaude;
+	});
+
+	it("streams a response", async () => {
+		const model = {
+			id: "claude-3-5-haiku@20241022",
+			name: "Claude 3.5 Haiku (Vertex)",
+			api: "vertex-claude-api",
+			provider: "google-vertex-claude",
+			reasoning: false,
+			input: ["text"],
+			cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+			contextWindow: 200000,
+			maxTokens: 8192,
+		} as const;
+
+		const context = {
+			messages: [{ role: "user", content: "Say hello in one sentence." }],
+		};
+
+		const stream = streamVertexClaude(model as any, context as any, { maxTokens: 64 });
+		let sawDone = false;
+
+		for await (const event of stream) {
+			if (event.type === "done") {
+				sawDone = true;
+				break;
+			}
+			if (event.type === "error") {
+				const message = event.error.errorMessage || "Vertex Claude stream error";
+				throw new Error(message);
+			}
+		}
+
+		expect(sawDone).toBe(true);
+	});
+});
dots/pi/agent/extensions/vertex-claude/test/vertex-claude.test.ts
@@ -0,0 +1,106 @@
+import { beforeAll, describe, expect, it, vi } from "vitest";
+
+vi.mock(
+	"@mariozechner/pi-ai",
+	() => ({
+		calculateCost: () => undefined,
+		createAssistantMessageEventStream: () => ({
+			push: () => undefined,
+			end: () => undefined,
+			[Symbol.asyncIterator]: () => ({
+				next: async () => ({ done: true, value: undefined }),
+			}),
+		}),
+	}),
+	{ virtual: true },
+);
+
+let convertMessages: typeof import("../index.js").convertMessages;
+let mapStopReason: typeof import("../index.js").mapStopReason;
+let parseStreamingJson: typeof import("../index.js").parseStreamingJson;
+
+beforeAll(async () => {
+	const helpers = await import("../index.js");
+	convertMessages = helpers.convertMessages;
+	mapStopReason = helpers.mapStopReason;
+	parseStreamingJson = helpers.parseStreamingJson;
+});
+
+describe("vertex-claude helpers", () => {
+	it("parses partial JSON", () => {
+		const result = parseStreamingJson("{\"a\": 1");
+		expect(result).toMatchObject({ a: 1 });
+	});
+
+	it("returns empty object for empty input", () => {
+		expect(parseStreamingJson("")).toEqual({});
+	});
+
+	it("maps known stop reasons and throws on unknown", () => {
+		expect(mapStopReason("end_turn")).toBe("stop");
+		expect(mapStopReason("tool_use")).toBe("toolUse");
+		expect(() => mapStopReason("unknown")).toThrow(/Unhandled stop reason/);
+	});
+
+	it("adds cache_control to last tool_result block", () => {
+		const model = {
+			id: "test-model",
+			name: "Test Model",
+			api: "vertex-claude-api",
+			provider: "google-vertex-claude",
+			reasoning: false,
+			input: ["text", "image"],
+			cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+			contextWindow: 1000,
+			maxTokens: 100,
+		} as const;
+
+		const messages = [
+			{ role: "user", content: "hi" },
+			{
+				role: "toolResult",
+				toolCallId: "tool-1",
+				content: [{ type: "text", text: "ok" }],
+				isError: false,
+			},
+		];
+
+		const params = convertMessages(messages as any, model as any);
+		const lastMessage = params[params.length - 1];
+		const lastBlock = lastMessage.content[lastMessage.content.length - 1];
+
+		expect(lastBlock.type).toBe("tool_result");
+		expect(lastBlock.cache_control).toEqual({ type: "ephemeral" });
+	});
+
+	it("adds cache_control to last text block in user content arrays", () => {
+		const model = {
+			id: "test-model",
+			name: "Test Model",
+			api: "vertex-claude-api",
+			provider: "google-vertex-claude",
+			reasoning: false,
+			input: ["text", "image"],
+			cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+			contextWindow: 1000,
+			maxTokens: 100,
+		} as const;
+
+		const messages = [
+			{
+				role: "user",
+				content: [
+					{ type: "text", text: "hello" },
+					{ type: "image", data: "base64", mimeType: "image/png" },
+				],
+			},
+		];
+
+		const params = convertMessages(messages as any, model as any);
+		const lastMessage = params[params.length - 1];
+		const lastBlock = lastMessage.content[lastMessage.content.length - 1];
+
+		expect(lastBlock.type).toBe("image");
+		expect(lastBlock.cache_control).toEqual({ type: "ephemeral" });
+	});
+});
dots/pi/agent/extensions/vertex-claude/.gitignore
@@ -0,0 +1,29 @@
+# Dependencies
+node_modules/
+package-lock.json
+
+# Logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# Test coverage
+coverage/
+.nyc_output/
+
+# Build artifacts
+dist/
+build/
+*.tsbuildinfo
dots/pi/agent/extensions/vertex-claude/.npmignore
@@ -0,0 +1,18 @@
+# Test files
+test/
+*.test.ts
+vitest.config.ts
+
+# Development files
+.git/
+.github/
+node_modules/
+*.log
+.DS_Store
+
+# Documentation (keep README.md)
+docs/
+
+# CI/CD
+.gitlab-ci.yml
+.travis.yml
dots/pi/agent/extensions/vertex-claude/CHANGELOG.md
@@ -0,0 +1,36 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.1.2] - 2025-01-30
+
+### Changed
+- Further simplified README - removed Features and Common Issues sections
+- Cleaner title and structure following Pi extension conventions
+- Removed copyright from LICENSE and README
+
+### Removed
+- GitHub Actions workflow (tests need peer dependencies)
+
+## [0.1.1] - 2025-01-30
+
+### Changed
+- Simplified README - removed unnecessary sections (pricing, fish shell, excessive troubleshooting)
+- Cleaner, more focused documentation
+
+## [0.1.0] - 2025-01-30
+
+### Added
+- Initial release
+- Support for all Vertex AI Claude models (Opus, Sonnet, Haiku)
+- Full streaming support
+- Extended thinking for reasoning models
+- Tool/function calling support
+- Image input support
+- Prompt caching
+- Token usage tracking and cost calculation
+- Comprehensive test suite
+- NPM and GitHub distribution
dots/pi/agent/extensions/vertex-claude/index.ts
@@ -0,0 +1,622 @@
+/**
+ * Google Vertex AI Claude Provider Extension
+ *
+ * Provides access to Anthropic Claude models via Google Vertex AI.
+ * Uses Google Cloud Application Default Credentials (ADC) for authentication.
+ *
+ * Prerequisites:
+ *   1. Install dependencies: cd ~/.pi/agent/extensions/vertex-claude && npm install
+ *   2. Authenticate with Google Cloud: gcloud auth application-default login
+ *   3. Set environment variables:
+ *      - GOOGLE_CLOUD_PROJECT or GCLOUD_PROJECT: Your GCP project ID
+ *      - GOOGLE_CLOUD_LOCATION: Region (optional, defaults to us-east5)
+ *
+ * Usage:
+ *   pi --provider google-vertex-claude --model claude-sonnet-4@20250514
+ *
+ * Or add to your shell config:
+ *   function piv
+ *     set -x GOOGLE_CLOUD_PROJECT your-project-id
+ *     set -x GOOGLE_CLOUD_LOCATION us-east5
+ *     pi --provider google-vertex-claude --model claude-opus-4-5@20251101 $argv
+ *   end
+ */
+
+import { AnthropicVertex } from "@anthropic-ai/vertex-sdk";
+import type { ContentBlockParam, MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/messages.js";
+import {
+	type Api,
+	type AssistantMessage,
+	type AssistantMessageEventStream,
+	calculateCost,
+	type Context,
+	createAssistantMessageEventStream,
+	type ImageContent,
+	type Message,
+	type Model,
+	type SimpleStreamOptions,
+	type StopReason,
+	type TextContent,
+	type ThinkingContent,
+	type Tool,
+	type ToolCall,
+	type ToolResultMessage,
+} from "@mariozechner/pi-ai";
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { existsSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { parse as partialParse } from "partial-json";
+
+// =============================================================================
+// Models from models.dev google-vertex-anthropic
+// Pricing from: https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models
+// =============================================================================
+
+const VERTEX_CLAUDE_MODELS = [
+	{
+		id: "claude-opus-4-6",
+		name: "Claude Opus 4.6 (Vertex)",
+		reasoning: true,
+		input: ["text", "image"] as ("text" | "image")[],
+		cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
+		contextWindow: 200000,
+		maxTokens: 64000,
+	},
+	{
+		id: "claude-opus-4-5@20251101",
+		name: "Claude Opus 4.5 (Vertex)",
+		reasoning: true,
+		input: ["text", "image"] as ("text" | "image")[],
+		cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
+		contextWindow: 200000,
+		maxTokens: 32000,
+	},
+	{
+		id: "claude-opus-4-1@20250805",
+		name: "Claude Opus 4.1 (Vertex)",
+		reasoning: true,
+		input: ["text", "image"] as ("text" | "image")[],
+		cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
+		contextWindow: 200000,
+		maxTokens: 32000,
+	},
+	{
+		id: "claude-opus-4@20250514",
+		name: "Claude Opus 4 (Vertex)",
+		reasoning: true,
+		input: ["text", "image"] as ("text" | "image")[],
+		cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
+		contextWindow: 200000,
+		maxTokens: 32000,
+	},
+	{
+		id: "claude-sonnet-4-5@20250929",
+		name: "Claude Sonnet 4.5 (Vertex)",
+		reasoning: true,
+		input: ["text", "image"] as ("text" | "image")[],
+		cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
+		contextWindow: 200000,
+		maxTokens: 64000,
+	},
+	{
+		id: "claude-sonnet-4@20250514",
+		name: "Claude Sonnet 4 (Vertex)",
+		reasoning: true,
+		input: ["text", "image"] as ("text" | "image")[],
+		cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
+		contextWindow: 200000,
+		maxTokens: 64000,
+	},
+	{
+		id: "claude-3-7-sonnet@20250219",
+		name: "Claude 3.7 Sonnet (Vertex)",
+		reasoning: true,
+		input: ["text", "image"] as ("text" | "image")[],
+		cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
+		contextWindow: 200000,
+		maxTokens: 64000,
+	},
+	{
+		id: "claude-haiku-4-5@20251001",
+		name: "Claude Haiku 4.5 (Vertex)",
+		reasoning: true,
+		input: ["text", "image"] as ("text" | "image")[],
+		cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
+		contextWindow: 200000,
+		maxTokens: 64000,
+	},
+	{
+		id: "claude-3-5-sonnet-v2@20241022",
+		name: "Claude 3.5 Sonnet v2 (Vertex)",
+		reasoning: false,
+		input: ["text", "image"] as ("text" | "image")[],
+		cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
+		contextWindow: 200000,
+		maxTokens: 8192,
+	},
+	{
+		id: "claude-3-5-haiku@20241022",
+		name: "Claude 3.5 Haiku (Vertex)",
+		reasoning: false,
+		input: ["text", "image"] as ("text" | "image")[],
+		cost: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
+		contextWindow: 200000,
+		maxTokens: 8192,
+	},
+];
+
+// =============================================================================
+// Helper Functions
+// =============================================================================
+
+type ProjectEnvVar = "GOOGLE_CLOUD_PROJECT" | "GCLOUD_PROJECT";
+
+const DEFAULT_ADC_PATH = join(homedir(), ".config", "gcloud", "application_default_credentials.json");
+let cachedAdcExists: boolean | null = null;
+
+function hasAdcCredentials(): boolean {
+	if (cachedAdcExists !== null) return cachedAdcExists;
+	const adcPath = process.env.GOOGLE_APPLICATION_CREDENTIALS || DEFAULT_ADC_PATH;
+	cachedAdcExists = existsSync(adcPath);
+	return cachedAdcExists;
+}
+
+function resolveProjectId(): { id: string; envVar: ProjectEnvVar } | undefined {
+	if (process.env.GOOGLE_CLOUD_PROJECT) {
+		return { id: process.env.GOOGLE_CLOUD_PROJECT, envVar: "GOOGLE_CLOUD_PROJECT" };
+	}
+	if (process.env.GCLOUD_PROJECT) {
+		return { id: process.env.GCLOUD_PROJECT, envVar: "GCLOUD_PROJECT" };
+	}
+	return undefined;
+}
+
+function sanitizeSurrogates(text: string): string {
+	return text.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
+}
+
+function convertContentBlocks(
+	content: (TextContent | ImageContent)[],
+): string | Array<{ type: "text"; text: string } | { type: "image"; source: { type: "base64"; media_type: string; data: string } }> {
+	const hasImages = content.some((c) => c.type === "image");
+	if (!hasImages) {
+		return sanitizeSurrogates(content.map((c) => (c as TextContent).text).join("\n"));
+	}
+
+	const blocks = content.map((block) => {
+		if (block.type === "text") {
+			return { type: "text" as const, text: sanitizeSurrogates(block.text) };
+		}
+		return {
+			type: "image" as const,
+			source: {
+				type: "base64" as const,
+				media_type: block.mimeType,
+				data: block.data,
+			},
+		};
+	});
+
+	if (!blocks.some((b) => b.type === "text")) {
+		blocks.unshift({ type: "text" as const, text: "(see attached image)" });
+	}
+
+	return blocks;
+}
+
+export function convertMessages(messages: Message[], model: Model<Api>): any[] {
+	const params: any[] = [];
+
+	for (let i = 0; i < messages.length; i++) {
+		const msg = messages[i];
+
+		if (msg.role === "user") {
+			if (typeof msg.content === "string") {
+				if (msg.content.trim()) {
+					params.push({ role: "user", content: sanitizeSurrogates(msg.content) });
+				}
+			} else {
+				const blocks: ContentBlockParam[] = msg.content.map((item) =>
+					item.type === "text"
+						? { type: "text" as const, text: sanitizeSurrogates(item.text) }
+						: {
+								type: "image" as const,
+								source: { type: "base64" as const, media_type: item.mimeType, data: item.data },
+							},
+				);
+				// Filter out images if model doesn't support them
+				let filteredBlocks = !model?.input.includes("image") ? blocks.filter((b) => b.type !== "image") : blocks;
+				filteredBlocks = filteredBlocks.filter((b) => {
+					if (b.type === "text") {
+						return b.text.trim().length > 0;
+					}
+					return true;
+				});
+				if (filteredBlocks.length > 0) {
+					params.push({ role: "user", content: filteredBlocks });
+				}
+			}
+		} else if (msg.role === "assistant") {
+			const blocks: ContentBlockParam[] = [];
+			for (const block of msg.content) {
+				if (block.type === "text" && block.text.trim()) {
+					blocks.push({ type: "text", text: sanitizeSurrogates(block.text) });
+				} else if (block.type === "thinking" && block.thinking.trim()) {
+					// If thinking signature is missing/empty, convert to plain text
+					if ((block as ThinkingContent).thinkingSignature?.trim()) {
+						blocks.push({
+							type: "thinking" as any,
+							thinking: sanitizeSurrogates(block.thinking),
+							signature: (block as ThinkingContent).thinkingSignature!,
+						});
+					} else {
+						blocks.push({ type: "text", text: sanitizeSurrogates(block.thinking) });
+					}
+				} else if (block.type === "toolCall") {
+					blocks.push({
+						type: "tool_use",
+						id: block.id,
+						name: block.name,
+						input: block.arguments,
+					});
+				}
+			}
+			if (blocks.length > 0) {
+				params.push({ role: "assistant", content: blocks });
+			}
+		} else if (msg.role === "toolResult") {
+			const toolResults: any[] = [];
+			toolResults.push({
+				type: "tool_result",
+				tool_use_id: msg.toolCallId,
+				content: convertContentBlocks(msg.content),
+				is_error: msg.isError,
+			});
+
+			// Collect consecutive tool results
+			let j = i + 1;
+			while (j < messages.length && messages[j].role === "toolResult") {
+				const nextMsg = messages[j] as ToolResultMessage;
+				toolResults.push({
+					type: "tool_result",
+					tool_use_id: nextMsg.toolCallId,
+					content: convertContentBlocks(nextMsg.content),
+					is_error: nextMsg.isError,
+				});
+				j++;
+			}
+			i = j - 1;
+			params.push({ role: "user", content: toolResults });
+		}
+	}
+
+	// Add cache control to last user message
+	if (params.length > 0) {
+		const last = params[params.length - 1];
+		if (last.role === "user" && Array.isArray(last.content)) {
+			const lastBlock = last.content[last.content.length - 1];
+			if (lastBlock && (lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result")) {
+				lastBlock.cache_control = { type: "ephemeral" };
+			}
+		}
+	}
+
+	return params;
+}
+
+function convertTools(tools: Tool[]): any[] {
+	return tools.map((tool) => ({
+		name: tool.name,
+		description: tool.description,
+		input_schema: {
+			type: "object",
+			properties: (tool.parameters as any).properties || {},
+			required: (tool.parameters as any).required || [],
+		},
+	}));
+}
+
+export function mapStopReason(reason: string): StopReason {
+	switch (reason) {
+		case "end_turn":
+		case "pause_turn":
+		case "stop_sequence":
+			return "stop";
+		case "max_tokens":
+			return "length";
+		case "tool_use":
+			return "toolUse";
+		case "refusal":
+			return "error";
+		default: {
+			throw new Error(`Unhandled stop reason: ${reason}`);
+		}
+	}
+}
+
+// Streaming JSON parser for tool arguments
+export function parseStreamingJson(partialJson: string): Record<string, any> {
+	if (!partialJson || partialJson.trim() === "") {
+		return {};
+	}
+	try {
+		return JSON.parse(partialJson);
+	} catch {
+		try {
+			return partialParse(partialJson) ?? {};
+		} catch {
+			return {};
+		}
+	}
+}
+
+// =============================================================================
+// Streaming Implementation
+// =============================================================================
+
+export function streamVertexClaude(
+	model: Model<Api>,
+	context: Context,
+	options?: SimpleStreamOptions,
+): AssistantMessageEventStream {
+	const stream = createAssistantMessageEventStream();
+
+	(async () => {
+		const output: AssistantMessage = {
+			role: "assistant",
+			content: [],
+			api: model.api,
+			provider: model.provider,
+			model: model.id,
+			usage: {
+				input: 0,
+				output: 0,
+				cacheRead: 0,
+				cacheWrite: 0,
+				totalTokens: 0,
+				cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+			},
+			stopReason: "stop",
+			timestamp: Date.now(),
+		};
+
+		try {
+			// Get project and region from environment
+			const projectInfo = resolveProjectId();
+			const region = process.env.GOOGLE_CLOUD_LOCATION || process.env.CLOUD_ML_REGION || "us-east5";
+
+			if (!projectInfo) {
+				throw new Error(
+					"Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT or GCLOUD_PROJECT.\n" +
+						"Also ensure you've run: gcloud auth application-default login",
+				);
+			}
+
+			if (!hasAdcCredentials()) {
+				throw new Error(
+					"Vertex AI requires Application Default Credentials. Run: gcloud auth application-default login\n" +
+						"or set GOOGLE_APPLICATION_CREDENTIALS to a service account key file.",
+				);
+			}
+
+			// Configure beta features for thinking and fine-grained streaming
+			const betaFeatures = ["fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14"];
+
+			// Create AnthropicVertex client - uses Google ADC automatically
+			const client = new AnthropicVertex({
+				projectId: projectInfo.id,
+				region: region,
+				defaultHeaders: {
+					"anthropic-beta": betaFeatures.join(","),
+				},
+			});
+
+			// Build request params
+			const params: MessageCreateParamsStreaming = {
+				model: model.id,
+				messages: convertMessages(context.messages, model),
+				max_tokens: options?.maxTokens || Math.floor(model.maxTokens / 3),
+				stream: true,
+			};
+
+			// Add system prompt with cache control
+			if (context.systemPrompt) {
+				params.system = [
+					{
+						type: "text",
+						text: sanitizeSurrogates(context.systemPrompt),
+						cache_control: { type: "ephemeral" },
+					},
+				];
+			}
+
+			// Add temperature if specified
+			if (options?.temperature !== undefined) {
+				params.temperature = options.temperature;
+			}
+
+			// Add tools if provided
+			if (context.tools && context.tools.length > 0) {
+				params.tools = convertTools(context.tools);
+			}
+
+			// Handle thinking/reasoning
+			if (options?.reasoning && model.reasoning) {
+				const defaultBudgets: Record<string, number> = {
+					minimal: 1024,
+					low: 4096,
+					medium: 10240,
+					high: 20480,
+					xhigh: 32768,
+				};
+				const budgetKey = options.reasoning === "xhigh" ? "high" : options.reasoning;
+				const customBudget = options.thinkingBudgets?.[budgetKey as keyof typeof options.thinkingBudgets];
+				const thinkingBudget = customBudget ?? defaultBudgets[options.reasoning] ?? 10240;
+
+				// Ensure max_tokens > thinking budget
+				const minOutputTokens = 1024;
+				if (params.max_tokens <= thinkingBudget) {
+					params.max_tokens = thinkingBudget + minOutputTokens;
+				}
+
+				params.thinking = {
+					type: "enabled",
+					budget_tokens: thinkingBudget,
+				};
+			}
+
+			// Start streaming
+			const anthropicStream = client.messages.stream({ ...params }, { signal: options?.signal });
+			stream.push({ type: "start", partial: output });
+
+			type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number };
+			const blocks = output.content as Block[];
+
+			for await (const event of anthropicStream) {
+				if (event.type === "message_start") {
+					output.usage.input = event.message.usage.input_tokens || 0;
+					output.usage.output = event.message.usage.output_tokens || 0;
+					output.usage.cacheRead = (event.message.usage as any).cache_read_input_tokens || 0;
+					output.usage.cacheWrite = (event.message.usage as any).cache_creation_input_tokens || 0;
+					output.usage.totalTokens =
+						output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
+					calculateCost(model, output.usage);
+				} else if (event.type === "content_block_start") {
+					if (event.content_block.type === "text") {
+						const block: Block = { type: "text", text: "", index: event.index };
+						output.content.push(block);
+						stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });
+					} else if (event.content_block.type === "thinking") {
+						const block: Block = {
+							type: "thinking",
+							thinking: "",
+							thinkingSignature: "",
+							index: event.index,
+						};
+						output.content.push(block);
+						stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output });
+					} else if (event.content_block.type === "tool_use") {
+						const block: Block = {
+							type: "toolCall",
+							id: event.content_block.id,
+							name: event.content_block.name,
+							arguments: event.content_block.input as Record<string, any>,
+							partialJson: "",
+							index: event.index,
+						};
+						output.content.push(block);
+						stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
+					}
+				} else if (event.type === "content_block_delta") {
+					const index = blocks.findIndex((b) => b.index === event.index);
+					const block = blocks[index];
+					if (!block) continue;
+
+					if (event.delta.type === "text_delta" && block.type === "text") {
+						block.text += event.delta.text;
+						stream.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: output });
+					} else if (event.delta.type === "thinking_delta" && block.type === "thinking") {
+						block.thinking += event.delta.thinking;
+						stream.push({
+							type: "thinking_delta",
+							contentIndex: index,
+							delta: event.delta.thinking,
+							partial: output,
+						});
+					} else if (event.delta.type === "input_json_delta" && block.type === "toolCall") {
+						(block as any).partialJson += event.delta.partial_json;
+						block.arguments = parseStreamingJson((block as any).partialJson);
+						stream.push({
+							type: "toolcall_delta",
+							contentIndex: index,
+							delta: event.delta.partial_json,
+							partial: output,
+						});
+					} else if (event.delta.type === "signature_delta" && block.type === "thinking") {
+						block.thinkingSignature = (block.thinkingSignature || "") + (event.delta as any).signature;
+					}
+				} else if (event.type === "content_block_stop") {
+					const index = blocks.findIndex((b) => b.index === event.index);
+					const block = blocks[index];
+					if (!block) continue;
+
+					delete (block as any).index;
+					if (block.type === "text") {
+						stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output });
+					} else if (block.type === "thinking") {
+						stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
+					} else if (block.type === "toolCall") {
+						block.arguments = parseStreamingJson((block as any).partialJson);
+						delete (block as any).partialJson;
+						stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output });
+					}
+				} else if (event.type === "message_delta") {
+					if ((event.delta as any).stop_reason) {
+						output.stopReason = mapStopReason((event.delta as any).stop_reason);
+					}
+					// Update usage from message_delta
+					if ((event.usage as any).input_tokens != null) {
+						output.usage.input = (event.usage as any).input_tokens;
+					}
+					if ((event.usage as any).output_tokens != null) {
+						output.usage.output = (event.usage as any).output_tokens;
+					}
+					if ((event.usage as any).cache_read_input_tokens != null) {
+						output.usage.cacheRead = (event.usage as any).cache_read_input_tokens;
+					}
+					if ((event.usage as any).cache_creation_input_tokens != null) {
+						output.usage.cacheWrite = (event.usage as any).cache_creation_input_tokens;
+					}
+					output.usage.totalTokens =
+						output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
+					calculateCost(model, output.usage);
+				}
+			}
+
+			if (options?.signal?.aborted) {
+				throw new Error("Request was aborted");
+			}
+
+			if (output.stopReason === "aborted" || output.stopReason === "error") {
+				throw new Error("An unknown error occurred");
+			}
+
+			stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
+			stream.end();
+		} catch (error) {
+			// Clean up any index properties
+			for (const block of output.content) delete (block as any).index;
+			output.stopReason = options?.signal?.aborted ? "aborted" : "error";
+			output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
+			stream.push({ type: "error", reason: output.stopReason, error: output });
+			stream.end();
+		}
+	})();
+
+	return stream;
+}
+
+// =============================================================================
+// Extension Entry Point
+// =============================================================================
+
+export default function (pi: ExtensionAPI) {
+	const projectInfo = resolveProjectId();
+	if (!projectInfo || !hasAdcCredentials()) {
+		return;
+	}
+
+	// Get region from environment for baseUrl (used for display, SDK handles actual endpoint)
+	const region = process.env.GOOGLE_CLOUD_LOCATION || process.env.CLOUD_ML_REGION || "us-east5";
+
+	pi.registerProvider("google-vertex-claude", {
+		baseUrl: `https://${region}-aiplatform.googleapis.com`, // Display URL, SDK handles actual endpoint
+		apiKey: projectInfo.envVar, // Env var for detection
+		api: "vertex-claude-api", // Custom API identifier
+
+		models: VERTEX_CLAUDE_MODELS,
+
+		streamSimple: streamVertexClaude,
+	});
+}
dots/pi/agent/extensions/vertex-claude/LICENSE
@@ -0,0 +1,19 @@
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
dots/pi/agent/extensions/vertex-claude/package.json
@@ -0,0 +1,52 @@
+{
+  "name": "@isaacraja/pi-vertex-claude",
+  "version": "0.1.2",
+  "description": "Google Vertex AI Claude provider for Pi coding agent",
+  "keywords": [
+    "pi-package",
+    "vertex-ai",
+    "claude",
+    "anthropic",
+    "google-cloud",
+    "ai",
+    "llm"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/isaacraja/pi-vertex-claude.git"
+  },
+  "homepage": "https://github.com/isaacraja/pi-vertex-claude#readme",
+  "bugs": {
+    "url": "https://github.com/isaacraja/pi-vertex-claude/issues"
+  },
+  "author": "Isaac Raja",
+  "license": "MIT",
+  "type": "module",
+  "main": "index.ts",
+  "files": [
+    "index.ts",
+    "README.md",
+    "LICENSE"
+  ],
+  "pi": {
+    "extensions": [
+      "./index.ts"
+    ]
+  },
+  "scripts": {
+    "test": "vitest --run",
+    "test:watch": "vitest"
+  },
+  "dependencies": {
+    "@anthropic-ai/sdk": "^0.54.0",
+    "@anthropic-ai/vertex-sdk": "^0.11.4",
+    "partial-json": "^0.1.7"
+  },
+  "peerDependencies": {
+    "@mariozechner/pi-ai": "*",
+    "@mariozechner/pi-coding-agent": "*"
+  },
+  "devDependencies": {
+    "vitest": "^3.2.4"
+  }
+}
dots/pi/agent/extensions/vertex-claude/README.md
@@ -0,0 +1,64 @@
+# Vertex Claude Provider for Pi
+
+Access Claude models via Google Vertex AI.
+
+## Installation
+
+```bash
+pi install npm:@isaacraja/pi-vertex-claude
+```
+
+## Setup
+
+Authenticate with Google Cloud:
+
+```bash
+gcloud auth application-default login
+```
+
+Set your project:
+
+```bash
+export GOOGLE_CLOUD_PROJECT=your-project-id
+```
+
+Use the provider:
+
+```bash
+pi --provider google-vertex-claude --model claude-sonnet-4@20250514
+```
+
+## Shell Helper
+
+Add to `~/.bashrc` or `~/.zshrc`:
+
+```bash
+piv() {
+  GOOGLE_CLOUD_PROJECT=your-project-id \
+  pi --provider google-vertex-claude --model claude-sonnet-4@20250514 "$@"
+}
+```
+
+## Available Models
+
+| Model | Context | Output |
+|-------|---------|--------|
+| `claude-opus-4-5@20251101` | 200K | 32K |
+| `claude-opus-4-1@20250805` | 200K | 32K |
+| `claude-opus-4@20250514` | 200K | 32K |
+| `claude-sonnet-4-5@20250929` | 200K | 64K |
+| `claude-sonnet-4@20250514` | 200K | 64K |
+| `claude-3-7-sonnet@20250219` | 200K | 64K |
+| `claude-haiku-4-5@20251001` | 200K | 64K |
+| `claude-3-5-sonnet-v2@20241022` | 200K | 8K |
+| `claude-3-5-haiku@20241022` | 200K | 8K |
+
+## Prerequisites
+
+- Google Cloud project with Vertex AI API enabled
+- Claude models enabled in [Model Garden](https://console.cloud.google.com/vertex-ai/model-garden)
+- `gcloud` CLI installed
+
+## License
+
+MIT
dots/pi/agent/extensions/vertex-claude/vitest.config.ts
@@ -0,0 +1,19 @@
+import { resolve } from "node:path";
+import { defineConfig } from "vitest/config";
+
+const piMonoRoot = process.env.PI_MONO_ROOT;
+const alias = piMonoRoot
+	? { "@mariozechner/pi-ai": resolve(piMonoRoot, "packages/ai/src/index.ts") }
+	: {};
+
+export default defineConfig({
+	resolve: {
+		alias,
+	},
+	test: {
+		isolate: true,
+		globals: true,
+		environment: "node",
+		testTimeout: 30000,
+	},
+});