@ssweens/pi-vertex 1.0.1 → 1.1.3

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.
package/utils.ts CHANGED
@@ -1,8 +1,18 @@
1
1
  /**
2
2
  * Utility functions for pi-vertex extension
3
+ *
4
+ * Message conversion aligns with pi-mono's google-shared.ts to ensure consistent
5
+ * handling of thinking blocks, tool calls, tool results, and thought signatures.
3
6
  */
4
7
 
5
- import type { Message, MessageContent, TextContent, ToolCall, AssistantMessage } from "./types.js";
8
+ import type {
9
+ AssistantMessage,
10
+ Message,
11
+ TextContent,
12
+ ThinkingContent,
13
+ ToolCall,
14
+ ToolResultMessage,
15
+ } from "./types.js";
6
16
 
7
17
  /**
8
18
  * Sanitize text by removing invalid surrogate pairs
@@ -11,12 +21,53 @@ export function sanitizeText(text: string): string {
11
21
  return text.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
12
22
  }
13
23
 
24
+ // --- Thought signature helpers (matching pi-mono google-shared.ts) ---
25
+
26
+ const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/;
27
+
28
+ function isValidThoughtSignature(signature: string | undefined): boolean {
29
+ if (!signature) return false;
30
+ if (signature.length % 4 !== 0) return false;
31
+ return base64SignaturePattern.test(signature);
32
+ }
33
+
34
+ function resolveThoughtSignature(
35
+ isSameProviderAndModel: boolean,
36
+ signature: string | undefined,
37
+ ): string | undefined {
38
+ return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined;
39
+ }
40
+
14
41
  /**
15
- * Convert messages to Gemini format
42
+ * Preserve the last non-empty thought signature during streaming.
43
+ * Some backends only send the signature on the first delta.
16
44
  */
17
- export function convertToGeminiMessages(messages: Message[]): any[] {
45
+ export function retainThoughtSignature(
46
+ existing: string | undefined,
47
+ incoming: string | undefined,
48
+ ): string | undefined {
49
+ if (typeof incoming === "string" && incoming.length > 0) return incoming;
50
+ return existing;
51
+ }
52
+
53
+ /**
54
+ * Whether a model requires explicit tool call IDs in functionCall parts.
55
+ * Claude and GPT-OSS models on Vertex require them; native Gemini models don't.
56
+ */
57
+ function requiresToolCallId(modelId: string): boolean {
58
+ return modelId.startsWith("claude-") || modelId.startsWith("gpt-oss-");
59
+ }
60
+
61
+ /**
62
+ * Convert messages to Gemini format.
63
+ *
64
+ * Handles the full pi-ai Message union: UserMessage, AssistantMessage (with
65
+ * TextContent, ThinkingContent, ToolCall blocks), and ToolResultMessage.
66
+ */
67
+ export function convertToGeminiMessages(messages: Message[], modelId: string): any[] {
18
68
  const result: any[] = [];
19
-
69
+ const isGemini3 = modelId.startsWith("gemini-3");
70
+
20
71
  for (const msg of messages) {
21
72
  if (msg.role === "user") {
22
73
  if (typeof msg.content === "string") {
@@ -39,74 +90,122 @@ export function convertToGeminiMessages(messages: Message[]): any[] {
39
90
  };
40
91
  }
41
92
  });
42
- result.push({ role: "user", parts });
93
+ if (parts.length > 0) {
94
+ result.push({ role: "user", parts });
95
+ }
43
96
  }
44
97
  } else if (msg.role === "assistant") {
45
- // Gemini doesn't have a separate assistant role in the same way
46
- // We'll handle this in the conversation history
47
- if (typeof msg.content === "string") {
48
- if (msg.content.trim()) {
49
- result.push({
50
- role: "model",
51
- parts: [{ text: sanitizeText(msg.content) }],
52
- });
53
- }
98
+ const assistantMsg = msg as AssistantMessage;
99
+
100
+ // Skip errored/aborted messages they're incomplete turns
101
+ if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
102
+ continue;
54
103
  }
55
- }
56
- }
57
-
58
- return result;
59
- }
60
104
 
61
- /**
62
- * Convert messages to OpenAI-compatible format (for Claude and MaaS)
63
- */
64
- export function convertToOpenAIMessages(messages: Message[]): any[] {
65
- const result: any[] = [];
66
-
67
- for (const msg of messages) {
68
- if (msg.role === "user") {
69
- if (typeof msg.content === "string") {
70
- if (msg.content.trim()) {
71
- result.push({
72
- role: "user",
73
- content: sanitizeText(msg.content),
105
+ const isSameProviderAndModel =
106
+ assistantMsg.provider === "vertex" && assistantMsg.model === modelId;
107
+ const parts: any[] = [];
108
+
109
+ for (const block of assistantMsg.content) {
110
+ if (block.type === "text") {
111
+ const textBlock = block as TextContent;
112
+ if (!textBlock.text || textBlock.text.trim() === "") continue;
113
+ const thoughtSig = resolveThoughtSignature(isSameProviderAndModel, textBlock.textSignature);
114
+ parts.push({
115
+ text: sanitizeText(textBlock.text),
116
+ ...(thoughtSig && { thoughtSignature: thoughtSig }),
74
117
  });
75
- }
76
- } else {
77
- const content = msg.content.map((item) => {
78
- if (item.type === "text") {
79
- return { type: "text", text: sanitizeText(item.text) };
118
+ } else if (block.type === "thinking") {
119
+ const thinkingBlock = block as ThinkingContent;
120
+ // Skip redacted thinking only the signature matters, handled by other blocks
121
+ if (thinkingBlock.redacted) continue;
122
+ if (!thinkingBlock.thinking || thinkingBlock.thinking.trim() === "") continue;
123
+
124
+ if (isSameProviderAndModel) {
125
+ const thoughtSig = resolveThoughtSignature(true, thinkingBlock.thinkingSignature);
126
+ parts.push({
127
+ thought: true,
128
+ text: sanitizeText(thinkingBlock.thinking),
129
+ ...(thoughtSig && { thoughtSignature: thoughtSig }),
130
+ });
80
131
  } else {
81
- return {
82
- type: "image_url",
83
- image_url: {
84
- url: `data:${item.mimeType};base64,${item.data}`,
85
- },
86
- };
132
+ // Cross-provider: convert thinking to plain text (no tags to avoid model mimicry)
133
+ parts.push({ text: sanitizeText(thinkingBlock.thinking) });
87
134
  }
88
- });
89
- result.push({ role: "user", content });
90
- }
91
- } else if (msg.role === "assistant") {
92
- if (typeof msg.content === "string") {
93
- if (msg.content.trim()) {
94
- result.push({
95
- role: "assistant",
96
- content: sanitizeText(msg.content),
97
- });
135
+ } else if (block.type === "toolCall") {
136
+ const toolCallBlock = block as ToolCall;
137
+ const thoughtSig = resolveThoughtSignature(isSameProviderAndModel, toolCallBlock.thoughtSignature);
138
+
139
+ const part: any = {
140
+ functionCall: {
141
+ name: toolCallBlock.name,
142
+ args: toolCallBlock.arguments ?? {},
143
+ ...(requiresToolCallId(modelId) ? { id: toolCallBlock.id } : {}),
144
+ },
145
+ };
146
+ if (thoughtSig) {
147
+ part.thoughtSignature = thoughtSig;
148
+ } else if (isGemini3) {
149
+ // Gemini 3 requires thoughtSignature on all functionCall parts.
150
+ // For cross-provider tool calls (or rare same-provider calls without signatures),
151
+ // use the documented escape hatch to bypass validation.
152
+ // See: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/thought-signatures
153
+ part.thoughtSignature = "skip_thought_signature_validator";
154
+ }
155
+ parts.push(part);
98
156
  }
99
157
  }
100
- } else if (msg.role === "system") {
101
- // System messages handled separately
158
+
159
+ if (parts.length > 0) {
160
+ result.push({ role: "model", parts });
161
+ }
162
+ } else if (msg.role === "toolResult") {
163
+ const toolResultMsg = msg as ToolResultMessage;
164
+ const textContent = toolResultMsg.content.filter((c) => c.type === "text") as TextContent[];
165
+ const textResult = textContent.map((c) => c.text).join("\n");
166
+ const responseValue = textResult || "";
167
+
168
+ const includeId = requiresToolCallId(modelId);
169
+ const functionResponsePart: any = {
170
+ functionResponse: {
171
+ name: toolResultMsg.toolName,
172
+ response: toolResultMsg.isError ? { error: responseValue } : { output: responseValue },
173
+ ...(includeId ? { id: toolResultMsg.toolCallId } : {}),
174
+ },
175
+ };
176
+
177
+ // Merge consecutive tool results into a single user turn (required by Gemini API)
178
+ const lastContent = result[result.length - 1];
179
+ if (lastContent?.role === "user" && lastContent.parts?.some((p: any) => p.functionResponse)) {
180
+ lastContent.parts.push(functionResponsePart);
181
+ } else {
182
+ result.push({ role: "user", parts: [functionResponsePart] });
183
+ }
102
184
  }
103
185
  }
104
-
186
+
105
187
  return result;
106
188
  }
107
189
 
108
190
  /**
109
- * Convert tools to OpenAI format
191
+ * Convert tools to Gemini format using parametersJsonSchema (full JSON Schema support).
192
+ * This differs from OpenAI format — Gemini uses functionDeclarations wrapped in an array.
193
+ */
194
+ export function convertToolsForGemini(tools: any[]): any[] | undefined {
195
+ if (!tools || tools.length === 0) return undefined;
196
+ return [
197
+ {
198
+ functionDeclarations: tools.map((tool) => ({
199
+ name: tool.name,
200
+ description: tool.description,
201
+ parametersJsonSchema: tool.parameters,
202
+ })),
203
+ },
204
+ ];
205
+ }
206
+
207
+ /**
208
+ * Convert tools to OpenAI format (for Claude and MaaS models)
110
209
  */
111
210
  export function convertTools(tools: any[]): any[] {
112
211
  return tools.map((tool) => ({
@@ -185,7 +284,13 @@ export function mapStopReason(reason: string): "stop" | "length" | "toolUse" | "
185
284
  /**
186
285
  * Calculate cost based on usage and model cost config
187
286
  */
188
- export function calculateCost(inputCost: number, outputCost: number, cacheReadCost: number, cacheWriteCost: number, usage: AssistantMessage["usage"]): void {
287
+ export function calculateCost(
288
+ inputCost: number,
289
+ outputCost: number,
290
+ cacheReadCost: number,
291
+ cacheWriteCost: number,
292
+ usage: AssistantMessage["usage"],
293
+ ): void {
189
294
  usage.cost.input = (inputCost / 1000000) * usage.input;
190
295
  usage.cost.output = (outputCost / 1000000) * usage.output;
191
296
  usage.cost.cacheRead = (cacheReadCost / 1000000) * usage.cacheRead;