diff --git a/package-lock.json b/package-lock.json index 2e66c6e2..b509b4d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "packages/*", "packages/web-ui/example", "packages/coding-agent/examples/extensions/with-deps", - "packages/coding-agent/examples/extensions/custom-provider" + "packages/coding-agent/examples/extensions/custom-provider", + "packages/coding-agent/examples/extensions/gitlab-duo" ], "dependencies": { "@mariozechner/jiti": "^2.6.5", @@ -51,15 +52,6 @@ "node": ">=18.0.0" } }, - "node_modules/@anthropic-ai/sandbox-runtime/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@anthropic-ai/sdk": { "version": "0.71.2", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", @@ -1602,15 +1594,6 @@ "node": ">=8" } }, - "node_modules/@lmstudio/sdk/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@mariozechner/clipboard": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.0.tgz", @@ -1864,15 +1847,6 @@ "zod-to-json-schema": "^3.24.1" } }, - "node_modules/@mistralai/mistralai/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@napi-rs/canvas": { "version": "0.1.88", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz", @@ -6943,6 +6917,10 @@ "resolved": "packages/coding-agent/examples/extensions/custom-provider", "link": true }, + "node_modules/pi-extension-gitlab-duo": { + "resolved": "packages/coding-agent/examples/extensions/gitlab-duo", + "link": true + }, "node_modules/pi-extension-with-deps": { "resolved": "packages/coding-agent/examples/extensions/with-deps", "link": true @@ -8586,9 +8564,9 @@ } }, "node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "peer": true, "funding": { @@ -8739,6 +8717,10 @@ "@types/node": "^20.11.30" } }, + "packages/coding-agent/examples/extensions/gitlab-duo": { + "name": "pi-extension-gitlab-duo", + "version": "1.0.0" + }, "packages/coding-agent/examples/extensions/pi-dosbox": { "version": "0.0.1", "extraneous": true, diff --git a/package.json b/package.json index 118c659a..43764e9d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "packages/*", "packages/web-ui/example", "packages/coding-agent/examples/extensions/with-deps", - "packages/coding-agent/examples/extensions/custom-provider" + "packages/coding-agent/examples/extensions/custom-provider", + "packages/coding-agent/examples/extensions/gitlab-duo" ], "scripts": { "clean": "npm run clean --workspaces", diff --git a/packages/coding-agent/examples/extensions/gitlab-duo/.gitignore b/packages/coding-agent/examples/extensions/gitlab-duo/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/coding-agent/examples/extensions/gitlab-duo/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/coding-agent/examples/extensions/gitlab-duo/index.ts b/packages/coding-agent/examples/extensions/gitlab-duo/index.ts new file mode 100644 index 00000000..f8979f29 --- /dev/null +++ b/packages/coding-agent/examples/extensions/gitlab-duo/index.ts @@ -0,0 +1,398 @@ +/** + * GitLab Duo Provider Extension + * + * Provides access to GitLab Duo AI models (Claude and GPT) through GitLab's AI Gateway. + * Delegates to pi-ai's built-in Anthropic and OpenAI streaming implementations. + * + * Usage: + * # First install dependencies + * cd packages/coding-agent/examples/extensions/gitlab-duo && npm install + * + * # With OAuth (run /login gitlab-duo first) + * pi -e ./packages/coding-agent/examples/extensions/gitlab-duo + * + * # With PAT + * GITLAB_TOKEN=glpat-... pi -e ./packages/coding-agent/examples/extensions/gitlab-duo + * + * Then use /model to select gitlab-duo/duo-chat-sonnet-4-5 + */ + +import { + type Api, + type AssistantMessageEventStream, + type Context, + createAssistantMessageEventStream, + type Model, + type OAuthCredentials, + type OAuthLoginCallbacks, + type SimpleStreamOptions, + streamSimple, +} from "@mariozechner/pi-ai"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +// ============================================================================= +// Constants +// ============================================================================= + +const GITLAB_COM_URL = "https://gitlab.com"; +const AI_GATEWAY_URL = "https://cloud.gitlab.com"; +const ANTHROPIC_PROXY_URL = `${AI_GATEWAY_URL}/ai/v1/proxy/anthropic/`; +const OPENAI_PROXY_URL = `${AI_GATEWAY_URL}/ai/v1/proxy/openai/v1`; + +// Bundled OAuth client ID for gitlab.com (same as gitlab-vscode-extension) +const BUNDLED_CLIENT_ID = "36f2a70cddeb5a0889d4fd8295c241b7e9848e89cf9e599d0eed2d8e5350fbf5"; +const OAUTH_SCOPES = ["api"]; +const REDIRECT_URI = "http://127.0.0.1/callback"; + +// Direct access token cache (25 min, tokens expire after 30 min) +const DIRECT_ACCESS_TTL = 25 * 60 * 1000; + +// Model mappings: duo model ID -> backend config +const MODEL_MAPPINGS: Record< + string, + { api: "anthropic-messages" | "openai-completions"; backendModel: string; baseUrl: string } +> = { + "duo-chat-opus-4-5": { + api: "anthropic-messages", + backendModel: "claude-opus-4-5-20251101", + baseUrl: ANTHROPIC_PROXY_URL, + }, + "duo-chat-sonnet-4-5": { + api: "anthropic-messages", + backendModel: "claude-sonnet-4-5-20250929", + baseUrl: ANTHROPIC_PROXY_URL, + }, + "duo-chat-haiku-4-5": { + api: "anthropic-messages", + backendModel: "claude-haiku-4-5-20251001", + baseUrl: ANTHROPIC_PROXY_URL, + }, + "duo-chat-gpt-5-1": { api: "openai-completions", backendModel: "gpt-5.1-2025-11-13", baseUrl: OPENAI_PROXY_URL }, + "duo-chat-gpt-5-mini": { + api: "openai-completions", + backendModel: "gpt-5-mini-2025-08-07", + baseUrl: OPENAI_PROXY_URL, + }, + "duo-chat-gpt-5-codex": { api: "openai-completions", backendModel: "gpt-5-codex", baseUrl: OPENAI_PROXY_URL }, +}; + +// ============================================================================= +// Direct Access Token Cache +// ============================================================================= + +interface DirectAccessToken { + token: string; + headers: Record; + expiresAt: number; +} + +let cachedDirectAccess: DirectAccessToken | null = null; + +async function getDirectAccessToken(gitlabAccessToken: string): Promise { + const now = Date.now(); + if (cachedDirectAccess && cachedDirectAccess.expiresAt > now) { + return cachedDirectAccess; + } + + const url = `${GITLAB_COM_URL}/api/v4/ai/third_party_agents/direct_access`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${gitlabAccessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ feature_flags: { DuoAgentPlatformNext: true } }), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (response.status === 403) { + throw new Error( + `GitLab Duo access denied. Ensure GitLab Duo is enabled for your account. Error: ${errorText}`, + ); + } + throw new Error(`Failed to get direct access token: ${response.status} ${errorText}`); + } + + const data = (await response.json()) as { token: string; headers: Record }; + cachedDirectAccess = { + token: data.token, + headers: data.headers, + expiresAt: now + DIRECT_ACCESS_TTL, + }; + return cachedDirectAccess; +} + +function invalidateDirectAccessToken() { + cachedDirectAccess = null; +} + +// ============================================================================= +// OAuth Implementation +// ============================================================================= + +async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + const verifier = btoa(String.fromCharCode(...array)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest("SHA-256", data); + const challenge = btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + return { verifier, challenge }; +} + +async function loginGitLab(callbacks: OAuthLoginCallbacks): Promise { + const { verifier, challenge } = await generatePKCE(); + + const authParams = new URLSearchParams({ + client_id: BUNDLED_CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: OAUTH_SCOPES.join(" "), + code_challenge: challenge, + code_challenge_method: "S256", + state: crypto.randomUUID(), + }); + + callbacks.onAuth({ url: `${GITLAB_COM_URL}/oauth/authorize?${authParams.toString()}` }); + const callbackUrl = await callbacks.onPrompt({ message: "Paste the callback URL:" }); + + const urlObj = new URL(callbackUrl); + const code = urlObj.searchParams.get("code"); + if (!code) throw new Error("No authorization code found in callback URL"); + + const tokenResponse = await fetch(`${GITLAB_COM_URL}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: BUNDLED_CLIENT_ID, + grant_type: "authorization_code", + code, + code_verifier: verifier, + redirect_uri: REDIRECT_URI, + }).toString(), + }); + + if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`); + + const data = (await tokenResponse.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + created_at: number; + }; + + invalidateDirectAccessToken(); + return { + refresh: data.refresh_token, + access: data.access_token, + expires: (data.created_at + data.expires_in) * 1000 - 5 * 60 * 1000, + }; +} + +async function refreshGitLabToken(credentials: OAuthCredentials): Promise { + const response = await fetch(`${GITLAB_COM_URL}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: BUNDLED_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: credentials.refresh, + }).toString(), + }); + + if (!response.ok) throw new Error(`Token refresh failed: ${await response.text()}`); + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + created_at: number; + }; + + invalidateDirectAccessToken(); + return { + refresh: data.refresh_token, + access: data.access_token, + expires: (data.created_at + data.expires_in) * 1000 - 5 * 60 * 1000, + }; +} + +// ============================================================================= +// Main Stream Function - Delegates to pi-ai's built-in implementations +// ============================================================================= + +function streamGitLabDuo( + model: Model, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream { + const stream = createAssistantMessageEventStream(); + + (async () => { + try { + const gitlabAccessToken = options?.apiKey; + if (!gitlabAccessToken) { + throw new Error("No GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN"); + } + + const mapping = MODEL_MAPPINGS[model.id]; + if (!mapping) throw new Error(`Unknown model: ${model.id}`); + + // Get direct access token (cached) + const directAccess = await getDirectAccessToken(gitlabAccessToken); + + // Create a proxy model that uses the backend API + const proxyModel: Model = { + ...model, + id: mapping.backendModel, + api: mapping.api, + baseUrl: mapping.baseUrl, + headers: directAccess.headers, + }; + + // Delegate to pi-ai's built-in streaming + const innerStream = streamSimple(proxyModel, context, { + ...options, + apiKey: directAccess.token, + }); + + // Forward all events + for await (const event of innerStream) { + // Patch the model info back to gitlab-duo + if ("partial" in event && event.partial) { + event.partial.api = model.api; + event.partial.provider = model.provider; + event.partial.model = model.id; + } + if ("message" in event && event.message) { + event.message.api = model.api; + event.message.provider = model.provider; + event.message.model = model.id; + } + if ("error" in event && event.error) { + event.error.api = model.api; + event.error.provider = model.provider; + event.error.model = model.id; + } + stream.push(event); + } + stream.end(); + } catch (error) { + stream.push({ + type: "error", + reason: "error", + error: { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }, + }); + stream.end(); + } + })(); + + return stream; +} + +// ============================================================================= +// Extension Entry Point +// ============================================================================= + +export default function (pi: ExtensionAPI) { + pi.registerProvider("gitlab-duo", { + baseUrl: AI_GATEWAY_URL, + apiKey: "GITLAB_TOKEN", + api: "gitlab-duo-api", + + models: [ + // Anthropic models + { + id: "duo-chat-opus-4-5", + name: "GitLab Duo Claude Opus 4.5", + reasoning: false, + input: ["text"], + cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + contextWindow: 200000, + maxTokens: 32000, + }, + { + id: "duo-chat-sonnet-4-5", + name: "GitLab Duo Claude Sonnet 4.5", + reasoning: false, + input: ["text"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200000, + maxTokens: 16384, + }, + { + id: "duo-chat-haiku-4-5", + name: "GitLab Duo Claude Haiku 4.5", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + contextWindow: 200000, + maxTokens: 8192, + }, + // OpenAI models + { + id: "duo-chat-gpt-5-1", + name: "GitLab Duo GPT-5.1", + reasoning: false, + input: ["text"], + cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + { + id: "duo-chat-gpt-5-mini", + name: "GitLab Duo GPT-5 Mini", + reasoning: false, + input: ["text"], + cost: { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + { + id: "duo-chat-gpt-5-codex", + name: "GitLab Duo GPT-5 Codex", + reasoning: false, + input: ["text"], + cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + + oauth: { + name: "GitLab Duo", + login: loginGitLab, + refreshToken: refreshGitLabToken, + getApiKey: (cred) => cred.access, + }, + + streamSimple: streamGitLabDuo, + }); +} diff --git a/packages/coding-agent/examples/extensions/gitlab-duo/package.json b/packages/coding-agent/examples/extensions/gitlab-duo/package.json new file mode 100644 index 00000000..bd71a0e0 --- /dev/null +++ b/packages/coding-agent/examples/extensions/gitlab-duo/package.json @@ -0,0 +1,16 @@ +{ + "name": "pi-extension-gitlab-duo", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "clean": "echo 'nothing to clean'", + "build": "echo 'nothing to build'", + "check": "echo 'nothing to check'" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + } +}