@serii84/vertex-partner-provider 1.0.32 → 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.
Files changed (2) hide show
  1. package/index.js +78 -82
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Vertex Partner Provider for OpenCode
3
- * v1.0.32 - Always include delta (required by OpenAI format)
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 path = require('path');
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
- const { createOpenAICompatible } = require('@ai-sdk/openai-compatible');
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
- // Normalize finish_reason to a string (some models return an object)
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,23 +61,26 @@ function cleanResponse(parsed) {
60
61
  }
61
62
  }
62
63
 
63
- // Clean up delta - always include it (OpenAI format requires delta on all streaming chunks)
64
+ // Keep logprobs if present (standard OpenAI field)
65
+ if (choice.logprobs != null) cleanChoice.logprobs = choice.logprobs;
66
+
67
+ // Clean delta for streaming responses
64
68
  if (choice.delta) {
65
69
  const cleanDelta = {};
66
70
  if (choice.delta.role) cleanDelta.role = choice.delta.role;
67
71
  if (choice.delta.content) cleanDelta.content = choice.delta.content;
68
- else if (choice.delta.reasoning_content) cleanDelta.content = choice.delta.reasoning_content;
72
+ // Preserve reasoning_content for AI SDK's native reasoning support
73
+ if (choice.delta.reasoning_content) cleanDelta.reasoning_content = choice.delta.reasoning_content;
69
74
  if (choice.delta.tool_calls) cleanDelta.tool_calls = choice.delta.tool_calls;
70
75
  cleanChoice.delta = cleanDelta;
71
- } else {
72
- // Always include delta, even if empty (required by OpenAI format)
73
- cleanChoice.delta = {};
74
76
  }
75
77
 
78
+ // Clean message for non-streaming responses
76
79
  if (choice.message) {
77
80
  const cleanMessage = { role: choice.message.role };
78
81
  if (choice.message.content) cleanMessage.content = choice.message.content;
79
- else if (choice.message.reasoning_content) cleanMessage.content = choice.message.reasoning_content;
82
+ // Preserve reasoning_content for AI SDK's native reasoning support
83
+ if (choice.message.reasoning_content) cleanMessage.reasoning_content = choice.message.reasoning_content;
80
84
  if (choice.message.tool_calls) cleanMessage.tool_calls = choice.message.tool_calls;
81
85
  cleanChoice.message = cleanMessage;
82
86
  }
@@ -85,7 +89,7 @@ function cleanResponse(parsed) {
85
89
  });
86
90
  }
87
91
 
88
- // Clean usage - only keep standard fields
92
+ // Clean usage - only standard fields
89
93
  if (parsed.usage) {
90
94
  const { prompt_tokens, completion_tokens, total_tokens } = parsed.usage;
91
95
  if (prompt_tokens != null || completion_tokens != null || total_tokens != null) {
@@ -96,84 +100,78 @@ function cleanResponse(parsed) {
96
100
  return cleaned;
97
101
  }
98
102
 
103
+ /**
104
+ * Transform SSE stream to clean format
105
+ */
99
106
  function transformStream(response) {
100
- // Log response headers for debugging
101
107
  debugLog('Response headers:', Object.fromEntries(response.headers.entries()));
102
108
 
103
109
  const reader = response.body.getReader();
104
110
  const decoder = new TextDecoder();
105
111
  const encoder = new TextEncoder();
106
-
107
112
  let buffer = '';
108
-
113
+
109
114
  const transformedStream = new ReadableStream({
110
115
  async pull(controller) {
111
116
  try {
112
117
  const { done, value } = await reader.read();
113
-
118
+
114
119
  if (done) {
115
- // Process any remaining data in buffer
116
- if (buffer.trim()) {
117
- debugLog('Processing remaining buffer:', buffer);
118
- if (buffer.startsWith('data: ')) {
119
- const data = buffer.slice(6).trim();
120
- if (data === '[DONE]') {
121
- controller.enqueue(encoder.encode('data: [DONE]\n\n'));
122
- } else if (data) {
123
- try {
124
- const parsed = JSON.parse(data);
125
- const cleaned = cleanResponse(parsed);
126
- if (cleaned.choices && cleaned.choices.length > 0) {
127
- controller.enqueue(encoder.encode('data: ' + JSON.stringify(cleaned) + '\n\n'));
128
- }
129
- } catch (e) {
130
- 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'));
131
131
  }
132
+ } catch (e) {
133
+ debugLog('Final buffer parse error:', e.message);
132
134
  }
133
135
  }
134
136
  }
135
137
  controller.close();
136
138
  return;
137
139
  }
138
-
140
+
139
141
  buffer += decoder.decode(value, { stream: true });
140
-
142
+
141
143
  let boundary;
142
144
  while ((boundary = buffer.indexOf('\n\n')) !== -1) {
143
145
  const message = buffer.slice(0, boundary);
144
146
  buffer = buffer.slice(boundary + 2);
145
-
147
+
146
148
  if (!message.trim()) continue;
147
-
149
+
148
150
  if (message.startsWith('data: ')) {
149
151
  const data = message.slice(6);
150
-
152
+
151
153
  if (data === '[DONE]') {
152
154
  debugLog('Forwarding [DONE]');
153
155
  controller.enqueue(encoder.encode('data: [DONE]\n\n'));
154
156
  continue;
155
157
  }
156
-
158
+
157
159
  try {
158
160
  const parsed = JSON.parse(data);
159
-
160
161
  debugLog('Raw chunk:', parsed);
161
162
 
162
163
  const cleaned = cleanResponse(parsed);
163
164
 
164
- // Skip chunks with empty choices (including usage-only chunks)
165
- // The AI SDK may not handle empty choices arrays correctly
166
- if (!cleaned.choices || cleaned.choices.length === 0) {
167
- 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');
168
168
  continue;
169
169
  }
170
170
 
171
171
  debugLog('Cleaned chunk:', cleaned);
172
-
173
172
  controller.enqueue(encoder.encode('data: ' + JSON.stringify(cleaned) + '\n\n'));
174
173
  } catch (e) {
175
- debugLog('JSON parse error:', e.message, 'Data:', data);
176
- // Skip invalid JSON
174
+ debugLog('JSON parse error:', e.message);
177
175
  }
178
176
  }
179
177
  }
@@ -181,12 +179,12 @@ function transformStream(response) {
181
179
  controller.error(err);
182
180
  }
183
181
  },
184
-
182
+
185
183
  cancel() {
186
184
  reader.cancel();
187
185
  }
188
186
  });
189
-
187
+
190
188
  return new Response(transformedStream, {
191
189
  headers: response.headers,
192
190
  status: response.status,
@@ -194,19 +192,17 @@ function transformStream(response) {
194
192
  });
195
193
  }
196
194
 
195
+ /**
196
+ * Transform non-streaming response to clean format
197
+ */
197
198
  async function transformNonStreamingResponse(response) {
198
199
  const text = await response.text();
199
-
200
- // Handle empty response
201
- if (!text || text.trim() === '') {
200
+
201
+ if (!text?.trim()) {
202
202
  return new Response(JSON.stringify({
203
203
  id: 'empty',
204
204
  object: 'chat.completion',
205
- choices: [{
206
- index: 0,
207
- message: { role: 'assistant', content: '' },
208
- finish_reason: 'stop'
209
- }],
205
+ choices: [{ index: 0, message: { role: 'assistant', content: '' }, finish_reason: 'stop' }],
210
206
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
211
207
  }), {
212
208
  headers: { 'content-type': 'application/json' },
@@ -214,18 +210,16 @@ async function transformNonStreamingResponse(response) {
214
210
  statusText: response.statusText,
215
211
  });
216
212
  }
217
-
213
+
218
214
  try {
219
215
  const data = JSON.parse(text);
220
216
  const cleaned = cleanResponse(data);
221
-
222
217
  return new Response(JSON.stringify(cleaned), {
223
218
  headers: { 'content-type': 'application/json' },
224
219
  status: response.status,
225
220
  statusText: response.statusText,
226
221
  });
227
222
  } catch (e) {
228
- // If JSON parse fails, return original text as-is
229
223
  return new Response(text, {
230
224
  headers: response.headers,
231
225
  status: response.status,
@@ -234,6 +228,14 @@ async function transformNonStreamingResponse(response) {
234
228
  }
235
229
  }
236
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
+ */
237
239
  function createVertexPartner(options = {}) {
238
240
  const {
239
241
  project = process.env.GOOGLE_VERTEX_PROJECT,
@@ -245,17 +247,17 @@ function createVertexPartner(options = {}) {
245
247
  if (!project) throw new Error('project is required');
246
248
  if (!publisher) throw new Error('publisher is required');
247
249
 
248
- const baseHost = location === 'global'
249
- ? 'aiplatform.googleapis.com'
250
+ const baseHost = location === 'global'
251
+ ? 'aiplatform.googleapis.com'
250
252
  : `${location}-aiplatform.googleapis.com`;
251
-
253
+
252
254
  const baseURL = `https://${baseHost}/v1/projects/${project}/locations/${location}/endpoints/openapi`;
253
255
 
254
256
  const authFetch = async (url, init) => {
255
257
  const token = await getAuthToken(googleAuthOptions);
256
258
  const headers = new Headers(init?.headers);
257
259
  headers.set('Authorization', `Bearer ${token}`);
258
-
260
+
259
261
  let isStreaming = false;
260
262
  if (init?.body) {
261
263
  try {
@@ -263,19 +265,12 @@ function createVertexPartner(options = {}) {
263
265
  isStreaming = body.stream === true;
264
266
  } catch (e) {}
265
267
  }
266
-
268
+
267
269
  const response = await fetch(url, { ...init, headers });
268
-
269
- if (!response.ok) {
270
- // Clone and return error responses as-is
271
- return response;
272
- }
273
-
274
- if (isStreaming) {
275
- return transformStream(response);
276
- } else {
277
- return transformNonStreamingResponse(response);
278
- }
270
+
271
+ if (!response.ok) return response;
272
+
273
+ return isStreaming ? transformStream(response) : transformNonStreamingResponse(response);
279
274
  };
280
275
 
281
276
  const provider = createOpenAICompatible({
@@ -284,6 +279,7 @@ function createVertexPartner(options = {}) {
284
279
  fetch: authFetch,
285
280
  });
286
281
 
282
+ // Wrap provider to auto-prefix model IDs with publisher
287
283
  const wrappedProvider = (modelId) => {
288
284
  const fullModelId = modelId.includes('/') ? modelId : `${publisher}/${modelId}`;
289
285
  return provider(fullModelId);
@@ -300,4 +296,4 @@ function createVertexPartner(options = {}) {
300
296
  return wrappedProvider;
301
297
  }
302
298
 
303
- module.exports = { createVertexPartner };
299
+ module.exports = { createVertexPartner };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serii84/vertex-partner-provider",
3
- "version": "1.0.32",
3
+ "version": "1.1.0",
4
4
  "description": "Vertex AI partner models (GLM, Kimi, DeepSeek) for OpenCode",
5
5
  "main": "index.js",
6
6
  "scripts": {