@serii84/vertex-partner-provider 1.0.31 → 1.1.0
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/index.js +79 -82
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vertex Partner Provider
|
|
3
|
-
*
|
|
2
|
+
* Vertex Partner Provider
|
|
3
|
+
* Enables Vertex AI partner models (GLM, Kimi, DeepSeek, MiniMax, Qwen) with AI SDK
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
|
-
const
|
|
7
|
+
const { createOpenAICompatible } = require('@ai-sdk/openai-compatible');
|
|
8
|
+
const { GoogleAuth } = require('google-auth-library');
|
|
8
9
|
|
|
10
|
+
// Optional debug logging
|
|
9
11
|
const DEBUG = process.env.VERTEX_DEBUG === 'true';
|
|
10
12
|
const DEBUG_FILE = process.env.VERTEX_DEBUG_FILE || '/tmp/vertex-debug.log';
|
|
11
13
|
|
|
@@ -16,9 +18,7 @@ function debugLog(...args) {
|
|
|
16
18
|
fs.appendFileSync(DEBUG_FILE, `[${timestamp}] ${message}\n`);
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
const { GoogleAuth } = require('google-auth-library');
|
|
21
|
-
|
|
21
|
+
// Auth client singleton
|
|
22
22
|
let authClient = null;
|
|
23
23
|
|
|
24
24
|
async function getAuthToken(googleAuthOptions) {
|
|
@@ -33,8 +33,11 @@ async function getAuthToken(googleAuthOptions) {
|
|
|
33
33
|
return token.token;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Clean response to standard OpenAI format
|
|
38
|
+
* Removes vendor-specific fields and normalizes structure
|
|
39
|
+
*/
|
|
36
40
|
function cleanResponse(parsed) {
|
|
37
|
-
// Build a clean response with only standard OpenAI fields
|
|
38
41
|
const cleaned = {
|
|
39
42
|
id: parsed.id,
|
|
40
43
|
object: parsed.object,
|
|
@@ -44,11 +47,9 @@ function cleanResponse(parsed) {
|
|
|
44
47
|
|
|
45
48
|
if (parsed.choices) {
|
|
46
49
|
cleaned.choices = parsed.choices.map(choice => {
|
|
47
|
-
const cleanChoice = {
|
|
48
|
-
index: choice.index,
|
|
49
|
-
};
|
|
50
|
+
const cleanChoice = { index: choice.index };
|
|
50
51
|
|
|
51
|
-
//
|
|
52
|
+
// Handle finish_reason (normalize if object, ensure string)
|
|
52
53
|
if (choice.finish_reason != null) {
|
|
53
54
|
if (typeof choice.finish_reason === 'object') {
|
|
54
55
|
cleanChoice.finish_reason = choice.finish_reason.type
|
|
@@ -60,22 +61,26 @@ function cleanResponse(parsed) {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// Keep logprobs if present (standard OpenAI field)
|
|
65
|
+
if (choice.logprobs != null) cleanChoice.logprobs = choice.logprobs;
|
|
66
|
+
|
|
67
|
+
// Clean delta for streaming responses
|
|
68
|
+
if (choice.delta) {
|
|
66
69
|
const cleanDelta = {};
|
|
67
70
|
if (choice.delta.role) cleanDelta.role = choice.delta.role;
|
|
68
71
|
if (choice.delta.content) cleanDelta.content = choice.delta.content;
|
|
69
|
-
|
|
72
|
+
// Preserve reasoning_content for AI SDK's native reasoning support
|
|
73
|
+
if (choice.delta.reasoning_content) cleanDelta.reasoning_content = choice.delta.reasoning_content;
|
|
70
74
|
if (choice.delta.tool_calls) cleanDelta.tool_calls = choice.delta.tool_calls;
|
|
71
75
|
cleanChoice.delta = cleanDelta;
|
|
72
76
|
}
|
|
73
|
-
// Note: intentionally NOT including delta on finish chunks
|
|
74
77
|
|
|
78
|
+
// Clean message for non-streaming responses
|
|
75
79
|
if (choice.message) {
|
|
76
80
|
const cleanMessage = { role: choice.message.role };
|
|
77
81
|
if (choice.message.content) cleanMessage.content = choice.message.content;
|
|
78
|
-
|
|
82
|
+
// Preserve reasoning_content for AI SDK's native reasoning support
|
|
83
|
+
if (choice.message.reasoning_content) cleanMessage.reasoning_content = choice.message.reasoning_content;
|
|
79
84
|
if (choice.message.tool_calls) cleanMessage.tool_calls = choice.message.tool_calls;
|
|
80
85
|
cleanChoice.message = cleanMessage;
|
|
81
86
|
}
|
|
@@ -84,7 +89,7 @@ function cleanResponse(parsed) {
|
|
|
84
89
|
});
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
// Clean usage - only
|
|
92
|
+
// Clean usage - only standard fields
|
|
88
93
|
if (parsed.usage) {
|
|
89
94
|
const { prompt_tokens, completion_tokens, total_tokens } = parsed.usage;
|
|
90
95
|
if (prompt_tokens != null || completion_tokens != null || total_tokens != null) {
|
|
@@ -95,84 +100,78 @@ function cleanResponse(parsed) {
|
|
|
95
100
|
return cleaned;
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Transform SSE stream to clean format
|
|
105
|
+
*/
|
|
98
106
|
function transformStream(response) {
|
|
99
|
-
// Log response headers for debugging
|
|
100
107
|
debugLog('Response headers:', Object.fromEntries(response.headers.entries()));
|
|
101
108
|
|
|
102
109
|
const reader = response.body.getReader();
|
|
103
110
|
const decoder = new TextDecoder();
|
|
104
111
|
const encoder = new TextEncoder();
|
|
105
|
-
|
|
106
112
|
let buffer = '';
|
|
107
|
-
|
|
113
|
+
|
|
108
114
|
const transformedStream = new ReadableStream({
|
|
109
115
|
async pull(controller) {
|
|
110
116
|
try {
|
|
111
117
|
const { done, value } = await reader.read();
|
|
112
|
-
|
|
118
|
+
|
|
113
119
|
if (done) {
|
|
114
|
-
// Process
|
|
115
|
-
if (buffer.trim()) {
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (cleaned.choices && cleaned.choices.length > 0) {
|
|
126
|
-
controller.enqueue(encoder.encode('data: ' + JSON.stringify(cleaned) + '\n\n'));
|
|
127
|
-
}
|
|
128
|
-
} catch (e) {
|
|
129
|
-
debugLog('Final buffer parse error:', e.message);
|
|
120
|
+
// Process remaining buffer
|
|
121
|
+
if (buffer.trim() && buffer.startsWith('data: ')) {
|
|
122
|
+
const data = buffer.slice(6).trim();
|
|
123
|
+
if (data === '[DONE]') {
|
|
124
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
|
125
|
+
} else if (data) {
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(data);
|
|
128
|
+
const cleaned = cleanResponse(parsed);
|
|
129
|
+
if (cleaned.choices?.length > 0) {
|
|
130
|
+
controller.enqueue(encoder.encode('data: ' + JSON.stringify(cleaned) + '\n\n'));
|
|
130
131
|
}
|
|
132
|
+
} catch (e) {
|
|
133
|
+
debugLog('Final buffer parse error:', e.message);
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
}
|
|
134
137
|
controller.close();
|
|
135
138
|
return;
|
|
136
139
|
}
|
|
137
|
-
|
|
140
|
+
|
|
138
141
|
buffer += decoder.decode(value, { stream: true });
|
|
139
|
-
|
|
142
|
+
|
|
140
143
|
let boundary;
|
|
141
144
|
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
|
142
145
|
const message = buffer.slice(0, boundary);
|
|
143
146
|
buffer = buffer.slice(boundary + 2);
|
|
144
|
-
|
|
147
|
+
|
|
145
148
|
if (!message.trim()) continue;
|
|
146
|
-
|
|
149
|
+
|
|
147
150
|
if (message.startsWith('data: ')) {
|
|
148
151
|
const data = message.slice(6);
|
|
149
|
-
|
|
152
|
+
|
|
150
153
|
if (data === '[DONE]') {
|
|
151
154
|
debugLog('Forwarding [DONE]');
|
|
152
155
|
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
|
153
156
|
continue;
|
|
154
157
|
}
|
|
155
|
-
|
|
158
|
+
|
|
156
159
|
try {
|
|
157
160
|
const parsed = JSON.parse(data);
|
|
158
|
-
|
|
159
161
|
debugLog('Raw chunk:', parsed);
|
|
160
162
|
|
|
161
163
|
const cleaned = cleanResponse(parsed);
|
|
162
164
|
|
|
163
|
-
// Skip chunks with
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
debugLog('Skipping chunk with empty choices');
|
|
165
|
+
// Skip chunks with no useful data (no choices AND no usage)
|
|
166
|
+
if (!cleaned.choices?.length && !cleaned.usage) {
|
|
167
|
+
debugLog('Skipping empty chunk');
|
|
167
168
|
continue;
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
debugLog('Cleaned chunk:', cleaned);
|
|
171
|
-
|
|
172
172
|
controller.enqueue(encoder.encode('data: ' + JSON.stringify(cleaned) + '\n\n'));
|
|
173
173
|
} catch (e) {
|
|
174
|
-
debugLog('JSON parse error:', e.message
|
|
175
|
-
// Skip invalid JSON
|
|
174
|
+
debugLog('JSON parse error:', e.message);
|
|
176
175
|
}
|
|
177
176
|
}
|
|
178
177
|
}
|
|
@@ -180,12 +179,12 @@ function transformStream(response) {
|
|
|
180
179
|
controller.error(err);
|
|
181
180
|
}
|
|
182
181
|
},
|
|
183
|
-
|
|
182
|
+
|
|
184
183
|
cancel() {
|
|
185
184
|
reader.cancel();
|
|
186
185
|
}
|
|
187
186
|
});
|
|
188
|
-
|
|
187
|
+
|
|
189
188
|
return new Response(transformedStream, {
|
|
190
189
|
headers: response.headers,
|
|
191
190
|
status: response.status,
|
|
@@ -193,19 +192,17 @@ function transformStream(response) {
|
|
|
193
192
|
});
|
|
194
193
|
}
|
|
195
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Transform non-streaming response to clean format
|
|
197
|
+
*/
|
|
196
198
|
async function transformNonStreamingResponse(response) {
|
|
197
199
|
const text = await response.text();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (!text || text.trim() === '') {
|
|
200
|
+
|
|
201
|
+
if (!text?.trim()) {
|
|
201
202
|
return new Response(JSON.stringify({
|
|
202
203
|
id: 'empty',
|
|
203
204
|
object: 'chat.completion',
|
|
204
|
-
choices: [{
|
|
205
|
-
index: 0,
|
|
206
|
-
message: { role: 'assistant', content: '' },
|
|
207
|
-
finish_reason: 'stop'
|
|
208
|
-
}],
|
|
205
|
+
choices: [{ index: 0, message: { role: 'assistant', content: '' }, finish_reason: 'stop' }],
|
|
209
206
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
210
207
|
}), {
|
|
211
208
|
headers: { 'content-type': 'application/json' },
|
|
@@ -213,18 +210,16 @@ async function transformNonStreamingResponse(response) {
|
|
|
213
210
|
statusText: response.statusText,
|
|
214
211
|
});
|
|
215
212
|
}
|
|
216
|
-
|
|
213
|
+
|
|
217
214
|
try {
|
|
218
215
|
const data = JSON.parse(text);
|
|
219
216
|
const cleaned = cleanResponse(data);
|
|
220
|
-
|
|
221
217
|
return new Response(JSON.stringify(cleaned), {
|
|
222
218
|
headers: { 'content-type': 'application/json' },
|
|
223
219
|
status: response.status,
|
|
224
220
|
statusText: response.statusText,
|
|
225
221
|
});
|
|
226
222
|
} catch (e) {
|
|
227
|
-
// If JSON parse fails, return original text as-is
|
|
228
223
|
return new Response(text, {
|
|
229
224
|
headers: response.headers,
|
|
230
225
|
status: response.status,
|
|
@@ -233,6 +228,14 @@ async function transformNonStreamingResponse(response) {
|
|
|
233
228
|
}
|
|
234
229
|
}
|
|
235
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Create a Vertex AI partner model provider
|
|
233
|
+
* @param {Object} options
|
|
234
|
+
* @param {string} options.project - GCP project ID (or GOOGLE_VERTEX_PROJECT env var)
|
|
235
|
+
* @param {string} options.location - GCP region (default: 'global')
|
|
236
|
+
* @param {string} options.publisher - Model publisher (e.g., 'zai-org', 'moonshotai', 'deepseek-ai')
|
|
237
|
+
* @param {Object} options.googleAuthOptions - Additional GoogleAuth options
|
|
238
|
+
*/
|
|
236
239
|
function createVertexPartner(options = {}) {
|
|
237
240
|
const {
|
|
238
241
|
project = process.env.GOOGLE_VERTEX_PROJECT,
|
|
@@ -244,17 +247,17 @@ function createVertexPartner(options = {}) {
|
|
|
244
247
|
if (!project) throw new Error('project is required');
|
|
245
248
|
if (!publisher) throw new Error('publisher is required');
|
|
246
249
|
|
|
247
|
-
const baseHost = location === 'global'
|
|
248
|
-
? 'aiplatform.googleapis.com'
|
|
250
|
+
const baseHost = location === 'global'
|
|
251
|
+
? 'aiplatform.googleapis.com'
|
|
249
252
|
: `${location}-aiplatform.googleapis.com`;
|
|
250
|
-
|
|
253
|
+
|
|
251
254
|
const baseURL = `https://${baseHost}/v1/projects/${project}/locations/${location}/endpoints/openapi`;
|
|
252
255
|
|
|
253
256
|
const authFetch = async (url, init) => {
|
|
254
257
|
const token = await getAuthToken(googleAuthOptions);
|
|
255
258
|
const headers = new Headers(init?.headers);
|
|
256
259
|
headers.set('Authorization', `Bearer ${token}`);
|
|
257
|
-
|
|
260
|
+
|
|
258
261
|
let isStreaming = false;
|
|
259
262
|
if (init?.body) {
|
|
260
263
|
try {
|
|
@@ -262,19 +265,12 @@ function createVertexPartner(options = {}) {
|
|
|
262
265
|
isStreaming = body.stream === true;
|
|
263
266
|
} catch (e) {}
|
|
264
267
|
}
|
|
265
|
-
|
|
268
|
+
|
|
266
269
|
const response = await fetch(url, { ...init, headers });
|
|
267
|
-
|
|
268
|
-
if (!response.ok)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (isStreaming) {
|
|
274
|
-
return transformStream(response);
|
|
275
|
-
} else {
|
|
276
|
-
return transformNonStreamingResponse(response);
|
|
277
|
-
}
|
|
270
|
+
|
|
271
|
+
if (!response.ok) return response;
|
|
272
|
+
|
|
273
|
+
return isStreaming ? transformStream(response) : transformNonStreamingResponse(response);
|
|
278
274
|
};
|
|
279
275
|
|
|
280
276
|
const provider = createOpenAICompatible({
|
|
@@ -283,6 +279,7 @@ function createVertexPartner(options = {}) {
|
|
|
283
279
|
fetch: authFetch,
|
|
284
280
|
});
|
|
285
281
|
|
|
282
|
+
// Wrap provider to auto-prefix model IDs with publisher
|
|
286
283
|
const wrappedProvider = (modelId) => {
|
|
287
284
|
const fullModelId = modelId.includes('/') ? modelId : `${publisher}/${modelId}`;
|
|
288
285
|
return provider(fullModelId);
|
|
@@ -299,4 +296,4 @@ function createVertexPartner(options = {}) {
|
|
|
299
296
|
return wrappedProvider;
|
|
300
297
|
}
|
|
301
298
|
|
|
302
|
-
module.exports = { createVertexPartner };
|
|
299
|
+
module.exports = { createVertexPartner };
|