@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/CHANGELOG.md +17 -0
- package/README.md +19 -22
- package/TEST_COVERAGE.md +13 -0
- package/index.ts +2 -2
- package/models/claude.ts +21 -75
- package/models/gemini.ts +39 -31
- package/models/index.ts +1 -1
- package/models/maas.ts +39 -76
- package/package.json +4 -1
- package/streaming/gemini.ts +198 -89
- package/streaming/maas.ts +350 -53
- package/types.ts +24 -35
- package/utils.ts +163 -58
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 {
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
93
|
+
if (parts.length > 0) {
|
|
94
|
+
result.push({ role: "user", parts });
|
|
95
|
+
}
|
|
43
96
|
}
|
|
44
97
|
} else if (msg.role === "assistant") {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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(
|
|
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;
|