Commit 7d1f8196a9c4

Vincent Demeester <vincent@sbr.pm>
2026-04-14 16:29:09
refactor(pi): switch to @twogiants/pi-anthropic-vertex package
Replaced the local vertex-claude extension (~680 lines reimplementing Anthropic streaming) with the @twogiants/pi-anthropic-vertex npm package (~160 lines) that delegates to pi's built-in anthropic-messages provider. Model definitions are auto-pulled from pi at runtime. Updated modes, settings, and ensure-settings.sh to use the new 'anthropic-vertex' provider name.
1 parent d7afeaa
dots/pi/agent/extensions/vertex-claude/.github/hooks/claude-hooks.json
@@ -1,38 +0,0 @@
-{
-  "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
@@ -1,45 +0,0 @@
-#!/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
@@ -1,60 +0,0 @@
-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
@@ -1,106 +0,0 @@
-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
@@ -1,29 +0,0 @@
-# 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
@@ -1,18 +0,0 @@
-# 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/.npmrc
@@ -1,1 +0,0 @@
-legacy-peer-deps=true
dots/pi/agent/extensions/vertex-claude/CHANGELOG.md
@@ -1,36 +0,0 @@
-# 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
@@ -1,678 +0,0 @@
-/**
- * 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: 1000000,
-		maxTokens: 128000,
-	},
-	{
-		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-6",
-		name: "Claude Sonnet 4.6 (Vertex)",
-		reasoning: true,
-		input: ["text", "image"] as ("text" | "image")[],
-		cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
-		contextWindow: 1000000,
-		maxTokens: 128000,
-	},
-	{
-		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 as "image/jpeg" | "image/png" | "image/gif" | "image/webp", 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}`);
-		}
-	}
-}
-
-// Escape control characters that are invalid inside JSON string literals.
-// The Anthropic API sometimes emits literal tabs/newlines inside tool call
-// argument strings (e.g. when the model copies Go or Makefile indentation).
-// JSON.parse rejects these with "Bad control character in string literal".
-function escapeControlCharsInJsonStrings(json: string): string {
-	let result = "";
-	let inString = false;
-	let escape = false;
-	for (let i = 0; i < json.length; i++) {
-		const ch = json[i];
-		if (escape) {
-			result += ch;
-			escape = false;
-			continue;
-		}
-		if (ch === "\\" && inString) {
-			result += ch;
-			escape = true;
-			continue;
-		}
-		if (ch === '"') {
-			inString = !inString;
-			result += ch;
-			continue;
-		}
-		if (inString) {
-			const code = ch.charCodeAt(0);
-			if (code < 0x20) {
-				// Replace control chars with their JSON escape sequences
-				switch (code) {
-					case 0x09: result += "\\t"; break;
-					case 0x0a: result += "\\n"; break;
-					case 0x0d: result += "\\r"; break;
-					case 0x08: result += "\\b"; break;
-					case 0x0c: result += "\\f"; break;
-					default:   result += "\\u" + code.toString(16).padStart(4, "0"); break;
-				}
-				continue;
-			}
-		}
-		result += ch;
-	}
-	return result;
-}
-
-// Streaming JSON parser for tool arguments
-export function parseStreamingJson(partialJson: string): Record<string, any> {
-	if (!partialJson || partialJson.trim() === "") {
-		return {};
-	}
-	// Escape bare control characters that the model may emit inside strings
-	const sanitized = escapeControlCharsInJsonStrings(partialJson);
-	try {
-		return JSON.parse(sanitized);
-	} catch {
-		try {
-			return partialParse(sanitized) ?? {};
-		} 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 = await client.messages.create({ ...params, stream: true }, { 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
@@ -1,19 +0,0 @@
-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
@@ -1,52 +0,0 @@
-{
-  "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
@@ -1,65 +0,0 @@
-# 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-6` | 200K | 64K |
-| `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
@@ -1,19 +0,0 @@
-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,
-	},
-});
dots/pi/agent/ensure-settings.sh
@@ -14,12 +14,13 @@ REQUIRED_SETTINGS='{
   "treeFilterMode": "no-tools",
   "skills": ["~/.config/claude/skills"],
   "subagentProviderPreference": [
-    "google-vertex-claude",
+    "anthropic-vertex",
     "google",
     "llama-cpp"
   ],
   "packages": [
-    "npm:@aliou/pi-processes@0.4.4"
+    "npm:@aliou/pi-processes@0.4.4",
+    "npm:@twogiants/pi-anthropic-vertex"
   ]
 }'
 
dots/pi/agent/modes.json
@@ -3,18 +3,18 @@
   "currentMode": "code-work",
   "modes": {
     "default": {
-      "provider": "google-vertex-claude",
+      "provider": "anthropic-vertex",
       "modelId": "claude-sonnet-4-6",
       "thinkingLevel": "minimal"
     },
     "fast-work": {
-      "provider": "google-vertex-claude",
+      "provider": "anthropic-vertex",
       "modelId": "claude-sonnet-4-6",
       "thinkingLevel": "off",
       "color": "#2e8b57"
     },
     "code-work": {
-      "provider": "google-vertex-claude",
+      "provider": "anthropic-vertex",
       "modelId": "claude-opus-4-6",
       "thinkingLevel": "low",
       "color": "#b45309"
dots/pi/agent/settings.json
@@ -1,7 +1,7 @@
 {
   "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/settings-schema.json",
-  "defaultProvider": "vertex",
-  "defaultModel": "claude-sonnet-4-20250514",
+  "defaultProvider": "anthropic-vertex",
+  "defaultModel": "claude-sonnet-4-6",
   "enabledModels": [
     "claude-sonnet-4-*",
     "claude-opus-4-*",
@@ -16,14 +16,14 @@
     "~/.config/claude/skills"
   ],
   "subagentProviderPreference": [
-    "google-vertex-claude",
-    "vertex",
+    "anthropic-vertex",
     "google",
     "llama-cpp",
     "anthropic",
     "openai"
   ],
   "packages": [
-    "npm:@aliou/pi-processes@0.4.4"
+    "npm:@aliou/pi-processes@0.4.4",
+    "npm:@twogiants/pi-anthropic-vertex"
   ]
 }