@ssweens/pi-vertex 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,49 @@
1
1
  /**
2
2
  * Gemini streaming handler using @google/genai SDK
3
+ *
4
+ * Aligned with pi-mono's google-vertex.ts for consistent handling of:
5
+ * - Thinking content (thought blocks with signatures)
6
+ * - Tool calls with unique IDs and deduplication
7
+ * - Thinking configuration (levels for Gemini 3, budgets for Gemini 2.5)
8
+ * - Usage tracking including thinking tokens
3
9
  */
4
10
 
5
- import { GoogleGenAI } from "@google/genai";
6
- import type { VertexModelConfig, Context, StreamOptions } from "../types.js";
11
+ import { GoogleGenAI, FinishReason, ThinkingLevel } from "@google/genai";
12
+ import type { VertexModelConfig, Context, StreamOptions, AssistantMessage } from "../types.js";
7
13
  import { getAuthConfig, resolveLocation } from "../auth.js";
8
- import { sanitizeText, convertToGeminiMessages, calculateCost } from "../utils.js";
9
- import { createAssistantMessageEventStream, type AssistantMessageEventStream, type AssistantMessage } from "@mariozechner/pi-ai";
14
+ import { sanitizeText, convertToGeminiMessages, convertToolsForGemini, retainThoughtSignature, calculateCost } from "../utils.js";
15
+ import { createAssistantMessageEventStream, type AssistantMessageEventStream } from "@mariozechner/pi-ai";
16
+
17
+ // Module-level counter for generating unique tool call IDs (matches pi-mono pattern)
18
+ let toolCallCounter = 0;
19
+
20
+ const THINKING_LEVEL_MAP: Record<string, ThinkingLevel> = {
21
+ minimal: ThinkingLevel.MINIMAL,
22
+ low: ThinkingLevel.LOW,
23
+ medium: ThinkingLevel.MEDIUM,
24
+ high: ThinkingLevel.HIGH,
25
+ };
26
+
27
+ function mapGeminiStopReason(reason: string): "stop" | "length" | "toolUse" | "error" {
28
+ switch (reason) {
29
+ case FinishReason.STOP:
30
+ return "stop";
31
+ case FinishReason.MAX_TOKENS:
32
+ return "length";
33
+ case FinishReason.SAFETY:
34
+ case FinishReason.RECITATION:
35
+ default:
36
+ return "error";
37
+ }
38
+ }
10
39
 
11
40
  export function streamGemini(
12
41
  model: VertexModelConfig,
13
42
  context: Context,
14
- options?: StreamOptions
43
+ options?: StreamOptions,
15
44
  ): AssistantMessageEventStream {
16
45
  const stream = createAssistantMessageEventStream();
17
-
46
+
18
47
  (async () => {
19
48
  const output: AssistantMessage = {
20
49
  role: "assistant",
@@ -33,123 +62,203 @@ export function streamGemini(
33
62
  stopReason: "stop",
34
63
  timestamp: Date.now(),
35
64
  };
36
-
65
+
37
66
  try {
38
67
  // Priority: config file > env var > model region > default
39
68
  const location = resolveLocation(model.region);
40
69
  const auth = getAuthConfig(location);
41
70
 
42
- // Create client
71
+ // Create client with explicit API version (matches pi-mono)
43
72
  const client = new GoogleGenAI({
44
73
  vertexai: true,
45
74
  project: auth.projectId,
46
75
  location: auth.location,
76
+ apiVersion: "v1",
47
77
  });
48
-
49
- // Convert messages
50
- const contents = convertToGeminiMessages(context.messages);
51
-
52
- // Build config
78
+
79
+ // Convert messages with model ID for proper thinking/tool handling
80
+ const contents = convertToGeminiMessages(context.messages, model.apiId);
81
+
82
+ // Build config — only set temperature when explicitly provided
53
83
  const config: any = {
54
84
  maxOutputTokens: options?.maxTokens || Math.floor(model.maxTokens / 2),
55
- temperature: options?.temperature ?? 0.7,
85
+ ...(options?.temperature !== undefined && { temperature: options.temperature }),
56
86
  };
57
-
87
+
58
88
  // Add system prompt if present
59
89
  if (context.systemPrompt) {
60
90
  config.systemInstruction = sanitizeText(context.systemPrompt);
61
91
  }
62
-
63
- // Add tools if present
92
+
93
+ // Add tools if present (using parametersJsonSchema for full JSON Schema support)
64
94
  if (context.tools && context.tools.length > 0) {
65
- config.tools = [
66
- {
67
- functionDeclarations: context.tools.map((tool) => ({
68
- name: tool.name,
69
- description: tool.description,
70
- parameters: tool.parameters,
71
- })),
72
- },
73
- ];
95
+ config.tools = convertToolsForGemini(context.tools);
96
+ }
97
+
98
+ // Add thinking configuration (matches pi-mono's buildParams logic)
99
+ if (model.reasoning && options?.reasoning) {
100
+ const effort = options.reasoning === "xhigh" ? "high" : options.reasoning;
101
+ const isGemini3 = model.apiId.startsWith("gemini-3");
102
+
103
+ const thinkingConfig: any = { includeThoughts: true };
104
+
105
+ if (isGemini3) {
106
+ // Gemini 3 models use thinking levels (MINIMAL/LOW/MEDIUM/HIGH)
107
+ thinkingConfig.thinkingLevel = THINKING_LEVEL_MAP[effort];
108
+ } else {
109
+ // Gemini 2.5 models use thinking budgets (token counts)
110
+ const budgets: Record<string, number> = {
111
+ minimal: 128,
112
+ low: 2048,
113
+ medium: 8192,
114
+ high: model.apiId.includes("2.5-pro") ? 32768 : 24576,
115
+ };
116
+ thinkingConfig.thinkingBudget = budgets[effort] ?? 8192;
117
+ }
118
+
119
+ config.thinkingConfig = thinkingConfig;
120
+ }
121
+
122
+ // Pass abort signal to SDK for in-flight cancellation
123
+ if (options?.signal) {
124
+ if (options.signal.aborted) {
125
+ throw new Error("Request aborted");
126
+ }
127
+ config.abortSignal = options.signal;
74
128
  }
75
-
129
+
76
130
  stream.push({ type: "start", partial: output });
77
-
131
+
78
132
  // Start streaming
79
133
  const response = await client.models.generateContentStream({
80
134
  model: model.apiId,
81
135
  contents,
82
136
  config,
83
137
  });
84
-
85
- let textContent = "";
86
- let textIndex = 0;
87
-
138
+
139
+ // Track current content block for thinking/text transitions
140
+ let currentBlock: any = null;
141
+ let currentBlockType: "text" | "thinking" | null = null;
142
+
88
143
  for await (const chunk of response) {
89
- if (options?.signal?.aborted) {
90
- throw new Error("Request was aborted");
91
- }
92
-
93
- // Update usage
94
- if (chunk.usageMetadata) {
95
- output.usage.input = chunk.usageMetadata.promptTokenCount || output.usage.input;
96
- output.usage.output = chunk.usageMetadata.candidatesTokenCount || output.usage.output;
97
- output.usage.totalTokens = chunk.usageMetadata.totalTokenCount ||
98
- (output.usage.input + output.usage.output);
99
- calculateCost(model.cost.input, model.cost.output, model.cost.cacheRead, model.cost.cacheWrite, output.usage);
100
- }
101
-
102
- // Handle text
103
- const text = chunk.text;
104
- if (text) {
105
- if (!textContent) {
106
- // First text chunk
107
- output.content.push({ type: "text", text: "" });
108
- textIndex = output.content.length - 1;
109
- stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
110
- }
111
- textContent += text;
112
- (output.content[textIndex] as any).text = textContent;
113
- stream.push({ type: "text_delta", contentIndex: textIndex, delta: text, partial: output });
114
- }
115
-
116
- // Handle function calls (tools)
117
- if (chunk.functionCalls && chunk.functionCalls.length > 0) {
118
- for (const call of chunk.functionCalls) {
119
- output.content.push({
120
- type: "toolCall",
121
- id: call.id || `call_${Date.now()}`,
122
- name: call.name,
123
- arguments: call.args || {},
124
- });
125
- stream.push({
126
- type: "toolcall_end",
127
- contentIndex: output.content.length - 1,
128
- toolCall: output.content[output.content.length - 1] as any,
129
- partial: output,
130
- });
144
+ const candidate = chunk.candidates?.[0];
145
+
146
+ // Process individual parts (handles thinking vs text detection)
147
+ if (candidate?.content?.parts) {
148
+ for (const part of candidate.content.parts) {
149
+ if (part.text !== undefined) {
150
+ const isThinking = part.thought === true;
151
+ const targetType = isThinking ? "thinking" : "text";
152
+
153
+ // Check if we need to transition to a new block
154
+ if (currentBlockType !== targetType) {
155
+ // End previous block
156
+ if (currentBlock && currentBlockType) {
157
+ if (currentBlockType === "text") {
158
+ stream.push({ type: "text_end", contentIndex: output.content.length - 1, content: currentBlock.text, partial: output });
159
+ } else {
160
+ stream.push({ type: "thinking_end", contentIndex: output.content.length - 1, content: currentBlock.thinking, partial: output });
161
+ }
162
+ }
163
+
164
+ // Start new block
165
+ if (isThinking) {
166
+ currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
167
+ output.content.push(currentBlock);
168
+ stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output });
169
+ } else {
170
+ currentBlock = { type: "text", text: "", textSignature: undefined };
171
+ output.content.push(currentBlock);
172
+ stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });
173
+ }
174
+ currentBlockType = targetType;
175
+ }
176
+
177
+ // Accumulate content
178
+ if (currentBlockType === "thinking") {
179
+ currentBlock.thinking += part.text;
180
+ currentBlock.thinkingSignature = retainThoughtSignature(currentBlock.thinkingSignature, part.thoughtSignature);
181
+ stream.push({ type: "thinking_delta", contentIndex: output.content.length - 1, delta: part.text, partial: output });
182
+ } else {
183
+ currentBlock.text += part.text;
184
+ currentBlock.textSignature = retainThoughtSignature(currentBlock.textSignature, part.thoughtSignature);
185
+ stream.push({ type: "text_delta", contentIndex: output.content.length - 1, delta: part.text, partial: output });
186
+ }
187
+ }
188
+
189
+ if (part.functionCall) {
190
+ // End current text/thinking block before tool call
191
+ if (currentBlock && currentBlockType) {
192
+ if (currentBlockType === "text") {
193
+ stream.push({ type: "text_end", contentIndex: output.content.length - 1, content: currentBlock.text, partial: output });
194
+ } else {
195
+ stream.push({ type: "thinking_end", contentIndex: output.content.length - 1, content: currentBlock.thinking, partial: output });
196
+ }
197
+ currentBlock = null;
198
+ currentBlockType = null;
199
+ }
200
+
201
+ // Generate unique tool call ID with dedup (matches pi-mono pattern)
202
+ const providedId = part.functionCall.id;
203
+ const needsNewId =
204
+ !providedId || output.content.some((b: any) => b.type === "toolCall" && b.id === providedId);
205
+ const toolCallId = needsNewId
206
+ ? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
207
+ : providedId;
208
+
209
+ const toolCall = {
210
+ type: "toolCall" as const,
211
+ id: toolCallId,
212
+ name: part.functionCall.name || "",
213
+ arguments: (part.functionCall.args as Record<string, any>) ?? {},
214
+ ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
215
+ };
216
+
217
+ output.content.push(toolCall);
218
+ const idx = output.content.length - 1;
219
+ stream.push({ type: "toolcall_start", contentIndex: idx, partial: output });
220
+ stream.push({ type: "toolcall_delta", contentIndex: idx, delta: JSON.stringify(toolCall.arguments), partial: output });
221
+ stream.push({ type: "toolcall_end", contentIndex: idx, toolCall, partial: output });
222
+ }
131
223
  }
132
224
  }
133
-
225
+
134
226
  // Handle finish reason
135
- if (chunk.candidates && chunk.candidates[0]?.finishReason) {
136
- const reason = chunk.candidates[0].finishReason;
137
- if (reason === "STOP") {
138
- output.stopReason = "stop";
139
- } else if (reason === "MAX_TOKENS") {
140
- output.stopReason = "length";
141
- } else if (reason === "SAFETY") {
142
- output.stopReason = "error";
227
+ if (candidate?.finishReason) {
228
+ output.stopReason = mapGeminiStopReason(candidate.finishReason);
229
+ if (candidate.finishReason === FinishReason.SAFETY) {
143
230
  output.errorMessage = "Content blocked by safety filters";
144
231
  }
232
+ // Override to toolUse if any tool calls are present (matches pi-mono)
233
+ if (output.content.some((b: any) => b.type === "toolCall")) {
234
+ output.stopReason = "toolUse";
235
+ }
236
+ }
237
+
238
+ // Update usage — include thoughtsTokenCount in output (matches pi-mono)
239
+ if (chunk.usageMetadata) {
240
+ const meta = chunk.usageMetadata as any;
241
+ output.usage = {
242
+ input: meta.promptTokenCount || 0,
243
+ output: (meta.candidatesTokenCount || 0) + (meta.thoughtsTokenCount || 0),
244
+ cacheRead: meta.cachedContentTokenCount || 0,
245
+ cacheWrite: 0,
246
+ totalTokens: meta.totalTokenCount || 0,
247
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
248
+ };
249
+ calculateCost(model.cost.input, model.cost.output, model.cost.cacheRead, model.cost.cacheWrite, output.usage);
145
250
  }
146
251
  }
147
-
148
- // End text if we had any
149
- if (textContent) {
150
- stream.push({ type: "text_end", contentIndex: textIndex, content: textContent, partial: output });
252
+
253
+ // End final block
254
+ if (currentBlock && currentBlockType) {
255
+ if (currentBlockType === "text") {
256
+ stream.push({ type: "text_end", contentIndex: output.content.length - 1, content: currentBlock.text, partial: output });
257
+ } else {
258
+ stream.push({ type: "thinking_end", contentIndex: output.content.length - 1, content: currentBlock.thinking, partial: output });
259
+ }
151
260
  }
152
-
261
+
153
262
  stream.push({ type: "done", reason: output.stopReason as any, message: output });
154
263
  stream.end();
155
264
  } catch (error) {
@@ -159,6 +268,6 @@ export function streamGemini(
159
268
  stream.end();
160
269
  }
161
270
  })();
162
-
271
+
163
272
  return stream;
164
273
  }
package/streaming/maas.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * MaaS streaming handler for Claude and all other models
3
3
  * Uses OpenAI-compatible Chat Completions endpoint
4
- *
5
- * Delegates to pi-ai's built-in OpenAI streaming implementation
4
+ *
5
+ * Delegates to pi-ai's built-in OpenAI streaming implementation.
6
+ * Uses model.apiId directly in the request (no global fetch interceptor)
7
+ * and patches the model ID back to the friendly name in response events.
6
8
  */
7
9
 
8
10
  import type { VertexModelConfig, Context, StreamOptions } from "../types.js";
@@ -12,12 +14,11 @@ import { createAssistantMessageEventStream, type AssistantMessageEventStream, ty
12
14
  export function streamMaaS(
13
15
  model: VertexModelConfig,
14
16
  context: Context,
15
- options?: StreamOptions
17
+ options?: StreamOptions,
16
18
  ): AssistantMessageEventStream {
17
19
  const stream = createAssistantMessageEventStream();
18
20
 
19
21
  (async () => {
20
- const originalFetch = globalThis.fetch;
21
22
  try {
22
23
  // Priority: config file > env var > model region > default
23
24
  const location = resolveLocation(model.region);
@@ -26,12 +27,12 @@ export function streamMaaS(
26
27
 
27
28
  const baseUrl = buildBaseUrl(auth.projectId, auth.location);
28
29
  const endpoint = `${baseUrl}/endpoints/openapi`;
30
+
29
31
  // Create a model object compatible with pi-ai's OpenAI streaming.
30
- // Note: baseUrl must point to the OpenAPI root; pi-ai appends /chat/completions.
31
- // Use model.id (registered name like "glm-5") so pi can restore sessions correctly.
32
- // The actual API model name (apiId like "zai-org/glm-5-maas") is injected via fetch interceptor below.
32
+ // Use model.apiId directly so the correct model name goes in the request body.
33
+ // The friendly model.id is patched back into response events below for session persistence.
33
34
  const modelForPi: Model<"openai-completions"> = {
34
- id: model.id,
35
+ id: model.apiId,
35
36
  name: model.name,
36
37
  api: "openai-completions",
37
38
  provider: "vertex",
@@ -51,21 +52,6 @@ export function streamMaaS(
51
52
  },
52
53
  };
53
54
 
54
- // Intercept fetch to replace model.id with the actual API model name (apiId)
55
- // pi-ai's streaming uses model.id in the request body, but Vertex MaaS needs the full publisher-prefixed name
56
- globalThis.fetch = async (input: any, init?: any) => {
57
- if (init?.body && typeof init.body === "string") {
58
- try {
59
- const body = JSON.parse(init.body);
60
- if (body.model === model.id) {
61
- body.model = model.apiId;
62
- init = { ...init, body: JSON.stringify(body) };
63
- }
64
- } catch {}
65
- }
66
- return originalFetch(input, init);
67
- };
68
-
69
55
  // Delegate to pi-ai's built-in OpenAI streaming
70
56
  const innerStream = streamSimpleOpenAICompletions(
71
57
  modelForPi,
@@ -74,19 +60,26 @@ export function streamMaaS(
74
60
  ...options,
75
61
  apiKey: accessToken,
76
62
  maxTokens: options?.maxTokens || Math.floor(model.maxTokens / 2),
77
- temperature: options?.temperature ?? 0.7,
78
- }
63
+ temperature: options?.temperature,
64
+ },
79
65
  );
80
66
 
81
- // Forward all events from inner stream to outer stream
67
+ // Forward all events, patching model ID back to the friendly name
68
+ // so pi-coding-agent can restore sessions correctly.
82
69
  for await (const event of innerStream) {
70
+ if ("partial" in event && event.partial) {
71
+ event.partial.model = model.id;
72
+ }
73
+ if ("message" in event && event.message) {
74
+ event.message.model = model.id;
75
+ }
76
+ if ("error" in event && event.error && typeof event.error === "object") {
77
+ (event.error as any).model = model.id;
78
+ }
83
79
  stream.push(event);
84
80
  }
85
- globalThis.fetch = originalFetch;
86
81
  stream.end();
87
-
88
82
  } catch (error) {
89
- globalThis.fetch = originalFetch;
90
83
  stream.push({
91
84
  type: "error",
92
85
  reason: options?.signal?.aborted ? "aborted" : "error",
package/types.ts CHANGED
@@ -1,7 +1,31 @@
1
1
  /**
2
2
  * Type definitions for pi-vertex extension
3
+ *
4
+ * Core message/content types are re-exported from pi-ai to ensure pi-vertex
5
+ * handles the full message structure (thinking blocks, tool calls, tool results)
6
+ * that pi-coding-agent passes through the streamSimple callback.
3
7
  */
4
8
 
9
+ // Re-export core types from pi-ai
10
+ export type {
11
+ AssistantMessage,
12
+ AssistantMessageEvent,
13
+ AssistantMessageEventStream,
14
+ Context,
15
+ ImageContent,
16
+ Message,
17
+ StopReason,
18
+ TextContent,
19
+ ThinkingContent,
20
+ Tool,
21
+ ToolCall,
22
+ ToolResultMessage,
23
+ Usage,
24
+ UserMessage,
25
+ } from "@mariozechner/pi-ai";
26
+
27
+ // Vertex-specific types
28
+
5
29
  export type ModelInputType = "text" | "image";
6
30
  export type EndpointType = "gemini" | "maas";
7
31
 
@@ -33,44 +57,9 @@ export interface AuthConfig {
33
57
  credentials?: string;
34
58
  }
35
59
 
36
- export type MessageRole = "user" | "assistant" | "system";
37
-
38
- export interface TextContent {
39
- type: "text";
40
- text: string;
41
- }
42
-
43
- export interface ImageContent {
44
- type: "image";
45
- mimeType: string;
46
- data: string;
47
- }
48
-
49
- export type MessageContent = TextContent | ImageContent;
50
-
51
- export interface Message {
52
- role: MessageRole;
53
- content: string | MessageContent[];
54
- }
55
-
56
- export interface Tool {
57
- name: string;
58
- description: string;
59
- parameters: Record<string, unknown>;
60
- }
61
-
62
- export interface Context {
63
- systemPrompt?: string;
64
- messages: Message[];
65
- tools?: Tool[];
66
- }
67
-
68
60
  export interface StreamOptions {
69
61
  maxTokens?: number;
70
62
  temperature?: number;
71
63
  reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
72
64
  signal?: AbortSignal;
73
65
  }
74
-
75
- // Re-export types from pi-ai for convenience
76
- export type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream } from "@mariozechner/pi-ai";