pi-gitlab-duo 0.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 (3) hide show
  1. package/README.md +61 -0
  2. package/index.ts +402 -0
  3. package/package.json +34 -0
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # pi-gitlab-duo
2
+
3
+ GitLab Duo provider extension for [pi](https://github.com/badlogic/pi-mono).
4
+
5
+ Provides access to GitLab Duo AI models (Claude and GPT) through GitLab's AI Gateway.
6
+
7
+ ## Models
8
+
9
+ | Model ID | Backend |
10
+ |----------|---------|
11
+ | `duo-chat-opus-4-5` | Claude Opus 4.5 |
12
+ | `duo-chat-sonnet-4-5` | Claude Sonnet 4.5 |
13
+ | `duo-chat-haiku-4-5` | Claude Haiku 4.5 |
14
+ | `duo-chat-gpt-5-1` | GPT-5.1 |
15
+ | `duo-chat-gpt-5-mini` | GPT-5 Mini |
16
+ | `duo-chat-gpt-5-codex` | GPT-5 Codex |
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pi install npm:pi-gitlab-duo
22
+ ```
23
+
24
+ ## Authentication
25
+
26
+ ### OAuth (Recommended)
27
+
28
+ ```bash
29
+ pi
30
+ /login gitlab-duo
31
+ ```
32
+
33
+ This will open GitLab's OAuth flow. After authorizing, copy the callback URL and paste it when prompted.
34
+
35
+ ### Personal Access Token
36
+
37
+ Set the `GITLAB_TOKEN` environment variable:
38
+
39
+ ```bash
40
+ export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
41
+ pi --provider gitlab-duo --model duo-chat-sonnet-4-5
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```bash
47
+ # Interactive mode
48
+ pi --provider gitlab-duo --model duo-chat-sonnet-4-5
49
+
50
+ # Print mode
51
+ pi --provider gitlab-duo --model duo-chat-sonnet-4-5 -p "explain git rebase"
52
+ ```
53
+
54
+ ## Requirements
55
+
56
+ - GitLab Duo subscription (Duo Pro or Duo Enterprise)
57
+ - pi >= 0.49.0
58
+
59
+ ## License
60
+
61
+ MIT
package/index.ts ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * GitLab Duo Provider Extension for pi
3
+ *
4
+ * Provides access to GitLab Duo AI models (Claude and GPT) through GitLab's AI Gateway.
5
+ * Delegates to pi-ai's built-in Anthropic and OpenAI streaming implementations.
6
+ *
7
+ * Installation:
8
+ * pi install npm:pi-gitlab-duo
9
+ *
10
+ * 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
16
+ */
17
+
18
+ import {
19
+ type Api,
20
+ type AssistantMessageEventStream,
21
+ type Context,
22
+ createAssistantMessageEventStream,
23
+ type Model,
24
+ type OAuthCredentials,
25
+ type OAuthLoginCallbacks,
26
+ type SimpleStreamOptions,
27
+ streamSimple,
28
+ } from "@mariozechner/pi-ai";
29
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
30
+
31
+ // =============================================================================
32
+ // Constants
33
+ // =============================================================================
34
+
35
+ const GITLAB_COM_URL = "https://gitlab.com";
36
+ const AI_GATEWAY_URL = "https://cloud.gitlab.com";
37
+ const ANTHROPIC_PROXY_URL = `${AI_GATEWAY_URL}/ai/v1/proxy/anthropic/`;
38
+ const OPENAI_PROXY_URL = `${AI_GATEWAY_URL}/ai/v1/proxy/openai/v1`;
39
+
40
+ // Bundled OAuth client ID for gitlab.com (from opencode-gitlab-auth, registered with localhost redirect)
41
+ const BUNDLED_CLIENT_ID = "1d89f9fdb23ee96d4e603201f6861dab6e143c5c3c00469a018a2d94bdc03d4e";
42
+ const OAUTH_SCOPES = ["api"];
43
+ const REDIRECT_URI = "http://127.0.0.1:8080/callback";
44
+
45
+ // Direct access token cache (25 min, tokens expire after 30 min)
46
+ const DIRECT_ACCESS_TTL = 25 * 60 * 1000;
47
+
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",
56
+ baseUrl: ANTHROPIC_PROXY_URL,
57
+ },
58
+ "duo-chat-sonnet-4-5": {
59
+ api: "anthropic-messages",
60
+ backendModel: "claude-sonnet-4-5-20250929",
61
+ baseUrl: ANTHROPIC_PROXY_URL,
62
+ },
63
+ "duo-chat-haiku-4-5": {
64
+ api: "anthropic-messages",
65
+ backendModel: "claude-haiku-4-5-20251001",
66
+ baseUrl: ANTHROPIC_PROXY_URL,
67
+ },
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",
72
+ baseUrl: OPENAI_PROXY_URL,
73
+ },
74
+ "duo-chat-gpt-5-codex": { api: "openai-completions", backendModel: "gpt-5-codex", baseUrl: OPENAI_PROXY_URL },
75
+ };
76
+
77
+ // =============================================================================
78
+ // Direct Access Token Cache
79
+ // =============================================================================
80
+
81
+ interface DirectAccessToken {
82
+ token: string;
83
+ headers: Record<string, string>;
84
+ expiresAt: number;
85
+ }
86
+
87
+ let cachedDirectAccess: DirectAccessToken | null = null;
88
+
89
+ async function getDirectAccessToken(gitlabAccessToken: string): Promise<DirectAccessToken> {
90
+ const now = Date.now();
91
+ if (cachedDirectAccess && cachedDirectAccess.expiresAt > now) {
92
+ return cachedDirectAccess;
93
+ }
94
+
95
+ const url = `${GITLAB_COM_URL}/api/v4/ai/third_party_agents/direct_access`;
96
+ const response = await fetch(url, {
97
+ method: "POST",
98
+ headers: {
99
+ Authorization: `Bearer ${gitlabAccessToken}`,
100
+ "Content-Type": "application/json",
101
+ },
102
+ body: JSON.stringify({ feature_flags: { DuoAgentPlatformNext: true } }),
103
+ });
104
+
105
+ if (!response.ok) {
106
+ const errorText = await response.text();
107
+ if (response.status === 403) {
108
+ throw new Error(
109
+ `GitLab Duo access denied. Ensure GitLab Duo is enabled for your account. Error: ${errorText}`,
110
+ );
111
+ }
112
+ throw new Error(`Failed to get direct access token: ${response.status} ${errorText}`);
113
+ }
114
+
115
+ 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
+ };
121
+ return cachedDirectAccess;
122
+ }
123
+
124
+ function invalidateDirectAccessToken() {
125
+ cachedDirectAccess = null;
126
+ }
127
+
128
+ // =============================================================================
129
+ // OAuth Implementation
130
+ // =============================================================================
131
+
132
+ async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
133
+ const array = new Uint8Array(32);
134
+ crypto.getRandomValues(array);
135
+ const verifier = btoa(String.fromCharCode(...array))
136
+ .replace(/\+/g, "-")
137
+ .replace(/\//g, "_")
138
+ .replace(/=+$/, "");
139
+
140
+ const encoder = new TextEncoder();
141
+ const data = encoder.encode(verifier);
142
+ const hash = await crypto.subtle.digest("SHA-256", data);
143
+ const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
144
+ .replace(/\+/g, "-")
145
+ .replace(/\//g, "_")
146
+ .replace(/=+$/, "");
147
+
148
+ return { verifier, challenge };
149
+ }
150
+
151
+ async function loginGitLab(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
152
+ const { verifier, challenge } = await generatePKCE();
153
+
154
+ const authParams = new URLSearchParams({
155
+ client_id: BUNDLED_CLIENT_ID,
156
+ redirect_uri: REDIRECT_URI,
157
+ response_type: "code",
158
+ scope: OAUTH_SCOPES.join(" "),
159
+ code_challenge: challenge,
160
+ code_challenge_method: "S256",
161
+ state: crypto.randomUUID(),
162
+ });
163
+
164
+ callbacks.onAuth({ url: `${GITLAB_COM_URL}/oauth/authorize?${authParams.toString()}` });
165
+ const callbackUrl = await callbacks.onPrompt({ message: "Paste the callback URL:" });
166
+
167
+ const urlObj = new URL(callbackUrl);
168
+ const code = urlObj.searchParams.get("code");
169
+ if (!code) throw new Error("No authorization code found in callback URL");
170
+
171
+ const tokenResponse = await fetch(`${GITLAB_COM_URL}/oauth/token`, {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
174
+ body: new URLSearchParams({
175
+ client_id: BUNDLED_CLIENT_ID,
176
+ grant_type: "authorization_code",
177
+ code,
178
+ code_verifier: verifier,
179
+ redirect_uri: REDIRECT_URI,
180
+ }).toString(),
181
+ });
182
+
183
+ if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
184
+
185
+ const data = (await tokenResponse.json()) as {
186
+ access_token: string;
187
+ refresh_token: string;
188
+ expires_in: number;
189
+ created_at: number;
190
+ };
191
+
192
+ invalidateDirectAccessToken();
193
+ return {
194
+ refresh: data.refresh_token,
195
+ access: data.access_token,
196
+ expires: (data.created_at + data.expires_in) * 1000 - 5 * 60 * 1000,
197
+ };
198
+ }
199
+
200
+ async function refreshGitLabToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
201
+ const response = await fetch(`${GITLAB_COM_URL}/oauth/token`, {
202
+ method: "POST",
203
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
204
+ body: new URLSearchParams({
205
+ client_id: BUNDLED_CLIENT_ID,
206
+ grant_type: "refresh_token",
207
+ refresh_token: credentials.refresh,
208
+ }).toString(),
209
+ });
210
+
211
+ if (!response.ok) throw new Error(`Token refresh failed: ${await response.text()}`);
212
+
213
+ const data = (await response.json()) as {
214
+ access_token: string;
215
+ refresh_token: string;
216
+ expires_in: number;
217
+ created_at: number;
218
+ };
219
+
220
+ invalidateDirectAccessToken();
221
+ return {
222
+ refresh: data.refresh_token,
223
+ access: data.access_token,
224
+ expires: (data.created_at + data.expires_in) * 1000 - 5 * 60 * 1000,
225
+ };
226
+ }
227
+
228
+ // =============================================================================
229
+ // Main Stream Function - Delegates to pi-ai's built-in implementations
230
+ // =============================================================================
231
+
232
+ function streamGitLabDuo(
233
+ model: Model<Api>,
234
+ context: Context,
235
+ options?: SimpleStreamOptions,
236
+ ): AssistantMessageEventStream {
237
+ const stream = createAssistantMessageEventStream();
238
+
239
+ (async () => {
240
+ try {
241
+ const gitlabAccessToken = options?.apiKey;
242
+ if (!gitlabAccessToken) {
243
+ throw new Error("No GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN");
244
+ }
245
+
246
+ const mapping = MODEL_MAPPINGS[model.id];
247
+ if (!mapping) throw new Error(`Unknown model: ${model.id}`);
248
+
249
+ // Get direct access token (cached)
250
+ const directAccess = await getDirectAccessToken(gitlabAccessToken);
251
+
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
+ });
272
+
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
+ }
293
+ stream.end();
294
+ } catch (error) {
295
+ stream.push({
296
+ type: "error",
297
+ reason: "error",
298
+ error: {
299
+ role: "assistant",
300
+ content: [],
301
+ api: model.api,
302
+ provider: model.provider,
303
+ model: model.id,
304
+ usage: {
305
+ input: 0,
306
+ output: 0,
307
+ cacheRead: 0,
308
+ cacheWrite: 0,
309
+ totalTokens: 0,
310
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
311
+ },
312
+ stopReason: "error",
313
+ errorMessage: error instanceof Error ? error.message : String(error),
314
+ timestamp: Date.now(),
315
+ },
316
+ });
317
+ stream.end();
318
+ }
319
+ })();
320
+
321
+ return stream;
322
+ }
323
+
324
+ // =============================================================================
325
+ // Extension Entry Point
326
+ // =============================================================================
327
+
328
+ export default function (pi: ExtensionAPI) {
329
+ pi.registerProvider("gitlab-duo", {
330
+ baseUrl: AI_GATEWAY_URL,
331
+ apiKey: "GITLAB_TOKEN",
332
+ 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
+
393
+ oauth: {
394
+ name: "GitLab Duo",
395
+ login: loginGitLab,
396
+ refreshToken: refreshGitLabToken,
397
+ getApiKey: (cred) => cred.access,
398
+ },
399
+
400
+ streamSimple: streamGitLabDuo,
401
+ });
402
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "pi-gitlab-duo",
3
+ "version": "0.1.0",
4
+ "description": "GitLab Duo provider extension for pi",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Mario Zechner",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/badlogic/pi-gitlab-duo.git"
11
+ },
12
+ "keywords": [
13
+ "pi",
14
+ "gitlab",
15
+ "gitlab-duo",
16
+ "ai",
17
+ "llm",
18
+ "claude",
19
+ "gpt"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./index.ts"
24
+ ]
25
+ },
26
+ "files": [
27
+ "index.ts",
28
+ "README.md"
29
+ ],
30
+ "peerDependencies": {
31
+ "@mariozechner/pi-ai": ">=0.49.0",
32
+ "@mariozechner/pi-coding-agent": ">=0.49.0"
33
+ }
34
+ }