Commit e711729a189e
Changed files (12)
dots
pi
agent
extensions
vertex-claude
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,
+ },
+});