Commit 7d1f8196a9c4
Changed files (16)
dots
pi
agent
extensions
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"
]
}