pi-gitlab-duo 0.1.0 → 0.1.1

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.ts +115 -168
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -1,18 +1,12 @@
1
1
  /**
2
- * GitLab Duo Provider Extension for pi
2
+ * GitLab Duo Provider Extension
3
3
  *
4
4
  * Provides access to GitLab Duo AI models (Claude and GPT) through GitLab's AI Gateway.
5
5
  * Delegates to pi-ai's built-in Anthropic and OpenAI streaming implementations.
6
6
  *
7
- * Installation:
8
- * pi install npm:pi-gitlab-duo
9
- *
10
7
  * Usage:
11
- * # With OAuth (run /login gitlab-duo first)
12
- * pi --provider gitlab-duo --model duo-chat-sonnet-4-5
13
- *
14
- * # With PAT
15
- * GITLAB_TOKEN=glpat-... pi --provider gitlab-duo --model duo-chat-sonnet-4-5
8
+ * pi -e ./packages/coding-agent/examples/extensions/custom-provider-gitlab-duo
9
+ * # Then /login gitlab-duo, or set GITLAB_TOKEN=glpat-...
16
10
  */
17
11
 
18
12
  import {
@@ -24,7 +18,8 @@ import {
24
18
  type OAuthCredentials,
25
19
  type OAuthLoginCallbacks,
26
20
  type SimpleStreamOptions,
27
- streamSimple,
21
+ streamSimpleAnthropic,
22
+ streamSimpleOpenAIResponses,
28
23
  } from "@mariozechner/pi-ai";
29
24
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
30
25
 
@@ -37,42 +32,101 @@ const AI_GATEWAY_URL = "https://cloud.gitlab.com";
37
32
  const ANTHROPIC_PROXY_URL = `${AI_GATEWAY_URL}/ai/v1/proxy/anthropic/`;
38
33
  const OPENAI_PROXY_URL = `${AI_GATEWAY_URL}/ai/v1/proxy/openai/v1`;
39
34
 
40
- // Bundled OAuth client ID for gitlab.com (from opencode-gitlab-auth, registered with localhost redirect)
41
35
  const BUNDLED_CLIENT_ID = "1d89f9fdb23ee96d4e603201f6861dab6e143c5c3c00469a018a2d94bdc03d4e";
42
36
  const OAUTH_SCOPES = ["api"];
43
37
  const REDIRECT_URI = "http://127.0.0.1:8080/callback";
44
-
45
- // Direct access token cache (25 min, tokens expire after 30 min)
46
38
  const DIRECT_ACCESS_TTL = 25 * 60 * 1000;
47
39
 
48
- // Model mappings: duo model ID -> backend config
49
- const MODEL_MAPPINGS: Record<
50
- string,
51
- { api: "anthropic-messages" | "openai-completions"; backendModel: string; baseUrl: string }
52
- > = {
53
- "duo-chat-opus-4-5": {
54
- api: "anthropic-messages",
55
- backendModel: "claude-opus-4-5-20251101",
40
+ // =============================================================================
41
+ // Models - exported for use by tests
42
+ // =============================================================================
43
+
44
+ type Backend = "anthropic" | "openai";
45
+
46
+ interface GitLabModel {
47
+ id: string;
48
+ name: string;
49
+ backend: Backend;
50
+ baseUrl: string;
51
+ reasoning: boolean;
52
+ input: ("text" | "image")[];
53
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
54
+ contextWindow: number;
55
+ maxTokens: number;
56
+ }
57
+
58
+ export const MODELS: GitLabModel[] = [
59
+ // Anthropic
60
+ {
61
+ id: "claude-opus-4-5-20251101",
62
+ name: "Claude Opus 4.5",
63
+ backend: "anthropic",
56
64
  baseUrl: ANTHROPIC_PROXY_URL,
65
+ reasoning: true,
66
+ input: ["text", "image"],
67
+ cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
68
+ contextWindow: 200000,
69
+ maxTokens: 32000,
57
70
  },
58
- "duo-chat-sonnet-4-5": {
59
- api: "anthropic-messages",
60
- backendModel: "claude-sonnet-4-5-20250929",
71
+ {
72
+ id: "claude-sonnet-4-5-20250929",
73
+ name: "Claude Sonnet 4.5",
74
+ backend: "anthropic",
61
75
  baseUrl: ANTHROPIC_PROXY_URL,
76
+ reasoning: true,
77
+ input: ["text", "image"],
78
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
79
+ contextWindow: 200000,
80
+ maxTokens: 16384,
62
81
  },
63
- "duo-chat-haiku-4-5": {
64
- api: "anthropic-messages",
65
- backendModel: "claude-haiku-4-5-20251001",
82
+ {
83
+ id: "claude-haiku-4-5-20251001",
84
+ name: "Claude Haiku 4.5",
85
+ backend: "anthropic",
66
86
  baseUrl: ANTHROPIC_PROXY_URL,
87
+ reasoning: true,
88
+ input: ["text", "image"],
89
+ cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
90
+ contextWindow: 200000,
91
+ maxTokens: 8192,
92
+ },
93
+ // OpenAI (all use Responses API)
94
+ {
95
+ id: "gpt-5.1-2025-11-13",
96
+ name: "GPT-5.1",
97
+ backend: "openai",
98
+ baseUrl: OPENAI_PROXY_URL,
99
+ reasoning: true,
100
+ input: ["text", "image"],
101
+ cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 },
102
+ contextWindow: 128000,
103
+ maxTokens: 16384,
67
104
  },
68
- "duo-chat-gpt-5-1": { api: "openai-completions", backendModel: "gpt-5.1-2025-11-13", baseUrl: OPENAI_PROXY_URL },
69
- "duo-chat-gpt-5-mini": {
70
- api: "openai-completions",
71
- backendModel: "gpt-5-mini-2025-08-07",
105
+ {
106
+ id: "gpt-5-mini-2025-08-07",
107
+ name: "GPT-5 Mini",
108
+ backend: "openai",
72
109
  baseUrl: OPENAI_PROXY_URL,
110
+ reasoning: true,
111
+ input: ["text", "image"],
112
+ cost: { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 },
113
+ contextWindow: 128000,
114
+ maxTokens: 16384,
73
115
  },
74
- "duo-chat-gpt-5-codex": { api: "openai-completions", backendModel: "gpt-5-codex", baseUrl: OPENAI_PROXY_URL },
75
- };
116
+ {
117
+ id: "gpt-5-codex",
118
+ name: "GPT-5 Codex",
119
+ backend: "openai",
120
+ baseUrl: OPENAI_PROXY_URL,
121
+ reasoning: true,
122
+ input: ["text", "image"],
123
+ cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 },
124
+ contextWindow: 128000,
125
+ maxTokens: 16384,
126
+ },
127
+ ];
128
+
129
+ const MODEL_MAP = new Map(MODELS.map((m) => [m.id, m]));
76
130
 
77
131
  // =============================================================================
78
132
  // Direct Access Token Cache
@@ -92,13 +146,9 @@ async function getDirectAccessToken(gitlabAccessToken: string): Promise<DirectAc
92
146
  return cachedDirectAccess;
93
147
  }
94
148
 
95
- const url = `${GITLAB_COM_URL}/api/v4/ai/third_party_agents/direct_access`;
96
- const response = await fetch(url, {
149
+ const response = await fetch(`${GITLAB_COM_URL}/api/v4/ai/third_party_agents/direct_access`, {
97
150
  method: "POST",
98
- headers: {
99
- Authorization: `Bearer ${gitlabAccessToken}`,
100
- "Content-Type": "application/json",
101
- },
151
+ headers: { Authorization: `Bearer ${gitlabAccessToken}`, "Content-Type": "application/json" },
102
152
  body: JSON.stringify({ feature_flags: { DuoAgentPlatformNext: true } }),
103
153
  });
104
154
 
@@ -113,11 +163,7 @@ async function getDirectAccessToken(gitlabAccessToken: string): Promise<DirectAc
113
163
  }
114
164
 
115
165
  const data = (await response.json()) as { token: string; headers: Record<string, string> };
116
- cachedDirectAccess = {
117
- token: data.token,
118
- headers: data.headers,
119
- expiresAt: now + DIRECT_ACCESS_TTL,
120
- };
166
+ cachedDirectAccess = { token: data.token, headers: data.headers, expiresAt: now + DIRECT_ACCESS_TTL };
121
167
  return cachedDirectAccess;
122
168
  }
123
169
 
@@ -126,7 +172,7 @@ function invalidateDirectAccessToken() {
126
172
  }
127
173
 
128
174
  // =============================================================================
129
- // OAuth Implementation
175
+ // OAuth
130
176
  // =============================================================================
131
177
 
132
178
  async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
@@ -136,21 +182,16 @@ async function generatePKCE(): Promise<{ verifier: string; challenge: string }>
136
182
  .replace(/\+/g, "-")
137
183
  .replace(/\//g, "_")
138
184
  .replace(/=+$/, "");
139
-
140
- const encoder = new TextEncoder();
141
- const data = encoder.encode(verifier);
142
- const hash = await crypto.subtle.digest("SHA-256", data);
185
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
143
186
  const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
144
187
  .replace(/\+/g, "-")
145
188
  .replace(/\//g, "_")
146
189
  .replace(/=+$/, "");
147
-
148
190
  return { verifier, challenge };
149
191
  }
150
192
 
151
193
  async function loginGitLab(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
152
194
  const { verifier, challenge } = await generatePKCE();
153
-
154
195
  const authParams = new URLSearchParams({
155
196
  client_id: BUNDLED_CLIENT_ID,
156
197
  redirect_uri: REDIRECT_URI,
@@ -163,9 +204,7 @@ async function loginGitLab(callbacks: OAuthLoginCallbacks): Promise<OAuthCredent
163
204
 
164
205
  callbacks.onAuth({ url: `${GITLAB_COM_URL}/oauth/authorize?${authParams.toString()}` });
165
206
  const callbackUrl = await callbacks.onPrompt({ message: "Paste the callback URL:" });
166
-
167
- const urlObj = new URL(callbackUrl);
168
- const code = urlObj.searchParams.get("code");
207
+ const code = new URL(callbackUrl).searchParams.get("code");
169
208
  if (!code) throw new Error("No authorization code found in callback URL");
170
209
 
171
210
  const tokenResponse = await fetch(`${GITLAB_COM_URL}/oauth/token`, {
@@ -181,14 +220,12 @@ async function loginGitLab(callbacks: OAuthLoginCallbacks): Promise<OAuthCredent
181
220
  });
182
221
 
183
222
  if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
184
-
185
223
  const data = (await tokenResponse.json()) as {
186
224
  access_token: string;
187
225
  refresh_token: string;
188
226
  expires_in: number;
189
227
  created_at: number;
190
228
  };
191
-
192
229
  invalidateDirectAccessToken();
193
230
  return {
194
231
  refresh: data.refresh_token,
@@ -207,16 +244,13 @@ async function refreshGitLabToken(credentials: OAuthCredentials): Promise<OAuthC
207
244
  refresh_token: credentials.refresh,
208
245
  }).toString(),
209
246
  });
210
-
211
247
  if (!response.ok) throw new Error(`Token refresh failed: ${await response.text()}`);
212
-
213
248
  const data = (await response.json()) as {
214
249
  access_token: string;
215
250
  refresh_token: string;
216
251
  expires_in: number;
217
252
  created_at: number;
218
253
  };
219
-
220
254
  invalidateDirectAccessToken();
221
255
  return {
222
256
  refresh: data.refresh_token,
@@ -226,10 +260,10 @@ async function refreshGitLabToken(credentials: OAuthCredentials): Promise<OAuthC
226
260
  }
227
261
 
228
262
  // =============================================================================
229
- // Main Stream Function - Delegates to pi-ai's built-in implementations
263
+ // Stream Function
230
264
  // =============================================================================
231
265
 
232
- function streamGitLabDuo(
266
+ export function streamGitLabDuo(
233
267
  model: Model<Api>,
234
268
  context: Context,
235
269
  options?: SimpleStreamOptions,
@@ -239,57 +273,22 @@ function streamGitLabDuo(
239
273
  (async () => {
240
274
  try {
241
275
  const gitlabAccessToken = options?.apiKey;
242
- if (!gitlabAccessToken) {
243
- throw new Error("No GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN");
244
- }
276
+ if (!gitlabAccessToken) throw new Error("No GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN");
245
277
 
246
- const mapping = MODEL_MAPPINGS[model.id];
247
- if (!mapping) throw new Error(`Unknown model: ${model.id}`);
278
+ const cfg = MODEL_MAP.get(model.id);
279
+ if (!cfg) throw new Error(`Unknown model: ${model.id}`);
248
280
 
249
- // Get direct access token (cached)
250
281
  const directAccess = await getDirectAccessToken(gitlabAccessToken);
282
+ const modelWithBaseUrl = { ...model, baseUrl: cfg.baseUrl };
283
+ const headers = { ...directAccess.headers, Authorization: `Bearer ${directAccess.token}` };
284
+ const streamOptions = { ...options, apiKey: "gitlab-duo", headers };
251
285
 
252
- // Create a proxy model that uses the backend API
253
- const proxyModel: Model<typeof mapping.api> = {
254
- ...model,
255
- id: mapping.backendModel,
256
- api: mapping.api,
257
- baseUrl: mapping.baseUrl,
258
- };
259
-
260
- // Merge GitLab headers with Authorization bearer token
261
- const headers = {
262
- ...directAccess.headers,
263
- Authorization: `Bearer ${directAccess.token}`,
264
- };
265
-
266
- // Delegate to pi-ai's built-in streaming
267
- const innerStream = streamSimple(proxyModel, context, {
268
- ...options,
269
- apiKey: "gitlab-duo", // Dummy value to pass validation
270
- headers,
271
- });
286
+ const innerStream =
287
+ cfg.backend === "anthropic"
288
+ ? streamSimpleAnthropic(modelWithBaseUrl as Model<"anthropic-messages">, context, streamOptions)
289
+ : streamSimpleOpenAIResponses(modelWithBaseUrl as Model<"openai-responses">, context, streamOptions);
272
290
 
273
- // Forward all events
274
- for await (const event of innerStream) {
275
- // Patch the model info back to gitlab-duo
276
- if ("partial" in event && event.partial) {
277
- event.partial.api = model.api;
278
- event.partial.provider = model.provider;
279
- event.partial.model = model.id;
280
- }
281
- if ("message" in event && event.message) {
282
- event.message.api = model.api;
283
- event.message.provider = model.provider;
284
- event.message.model = model.id;
285
- }
286
- if ("error" in event && event.error) {
287
- event.error.api = model.api;
288
- event.error.provider = model.provider;
289
- event.error.model = model.id;
290
- }
291
- stream.push(event);
292
- }
291
+ for await (const event of innerStream) stream.push(event);
293
292
  stream.end();
294
293
  } catch (error) {
295
294
  stream.push({
@@ -330,73 +329,21 @@ export default function (pi: ExtensionAPI) {
330
329
  baseUrl: AI_GATEWAY_URL,
331
330
  apiKey: "GITLAB_TOKEN",
332
331
  api: "gitlab-duo-api",
333
-
334
- models: [
335
- // Anthropic models
336
- {
337
- id: "duo-chat-opus-4-5",
338
- name: "GitLab Duo Claude Opus 4.5",
339
- reasoning: false,
340
- input: ["text"],
341
- cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
342
- contextWindow: 200000,
343
- maxTokens: 32000,
344
- },
345
- {
346
- id: "duo-chat-sonnet-4-5",
347
- name: "GitLab Duo Claude Sonnet 4.5",
348
- reasoning: false,
349
- input: ["text"],
350
- cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
351
- contextWindow: 200000,
352
- maxTokens: 16384,
353
- },
354
- {
355
- id: "duo-chat-haiku-4-5",
356
- name: "GitLab Duo Claude Haiku 4.5",
357
- reasoning: false,
358
- input: ["text"],
359
- cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
360
- contextWindow: 200000,
361
- maxTokens: 8192,
362
- },
363
- // OpenAI models
364
- {
365
- id: "duo-chat-gpt-5-1",
366
- name: "GitLab Duo GPT-5.1",
367
- reasoning: false,
368
- input: ["text"],
369
- cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 },
370
- contextWindow: 128000,
371
- maxTokens: 16384,
372
- },
373
- {
374
- id: "duo-chat-gpt-5-mini",
375
- name: "GitLab Duo GPT-5 Mini",
376
- reasoning: false,
377
- input: ["text"],
378
- cost: { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 },
379
- contextWindow: 128000,
380
- maxTokens: 16384,
381
- },
382
- {
383
- id: "duo-chat-gpt-5-codex",
384
- name: "GitLab Duo GPT-5 Codex",
385
- reasoning: false,
386
- input: ["text"],
387
- cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 },
388
- contextWindow: 128000,
389
- maxTokens: 16384,
390
- },
391
- ],
392
-
332
+ models: MODELS.map(({ id, name, reasoning, input, cost, contextWindow, maxTokens }) => ({
333
+ id,
334
+ name,
335
+ reasoning,
336
+ input,
337
+ cost,
338
+ contextWindow,
339
+ maxTokens,
340
+ })),
393
341
  oauth: {
394
342
  name: "GitLab Duo",
395
343
  login: loginGitLab,
396
344
  refreshToken: refreshGitLabToken,
397
345
  getApiKey: (cred) => cred.access,
398
346
  },
399
-
400
347
  streamSimple: streamGitLabDuo,
401
348
  });
402
349
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "pi-gitlab-duo",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "GitLab Duo provider extension for pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Mario Zechner",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/badlogic/pi-gitlab-duo.git"
10
+ "url": "git+https://github.com/badlogic/pi-gitlab-duo.git"
11
11
  },
12
12
  "keywords": [
13
13
  "pi",