@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.
Files changed (2) hide show
  1. package/index.js +79 -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.31 - Revert to openai-compatible (issue is in OpenCode/AI SDK)
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,22 +61,26 @@ function cleanResponse(parsed) {
60
61
  }
61
62
  }
62
63
 
63
- // Clean up delta - remove null values and non-standard fields
64
- // Don't include delta on finish chunks at all
65
- if (choice.delta && !choice.finish_reason) {
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
- 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;
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
- 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;
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 keep standard fields
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 any remaining data in buffer
115
- if (buffer.trim()) {
116
- debugLog('Processing remaining buffer:', buffer);
117
- if (buffer.startsWith('data: ')) {
118
- const data = buffer.slice(6).trim();
119
- if (data === '[DONE]') {
120
- controller.enqueue(encoder.encode('data: [DONE]\n\n'));
121
- } else if (data) {
122
- try {
123
- const parsed = JSON.parse(data);
124
- const cleaned = cleanResponse(parsed);
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 empty choices (including usage-only chunks)
164
- // The AI SDK may not handle empty choices arrays correctly
165
- if (!cleaned.choices || cleaned.choices.length === 0) {
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, 'Data:', data);
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
- // Handle empty response
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
- // Clone and return error responses as-is
270
- return response;
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serii84/vertex-partner-provider",
3
- "version": "1.0.31",
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": {