diff --git a/package-lock.json b/package-lock.json index 0de754c03..ea63161d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,37 @@ "node": ">=20.0.0" } }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", + "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.20.tgz", + "integrity": "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@anthropic-ai/sandbox-runtime": { "version": "0.0.16", "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.16.tgz", @@ -79,6 +110,18 @@ } } }, + "node_modules/@anycable/core": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@anycable/core/-/core-0.9.2.tgz", + "integrity": "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA==", + "license": "MIT", + "dependencies": { + "nanoevents": "^7.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1417,6 +1460,37 @@ "node": ">=18" } }, + "node_modules/@gitlab/gitlab-ai-provider": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@gitlab/gitlab-ai-provider/-/gitlab-ai-provider-3.1.1.tgz", + "integrity": "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.71.0", + "@anycable/core": "^0.9.2", + "graphql-request": "^6.1.0", + "isomorphic-ws": "^5.0.0", + "socket.io-client": "^4.8.1", + "vscode-jsonrpc": "^8.2.1", + "zod": "^3.25.76" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@ai-sdk/provider": ">=2.0.0", + "@ai-sdk/provider-utils": ">=3.0.0" + } + }, + "node_modules/@gitlab/gitlab-ai-provider/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/@google/genai": { "version": "1.34.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.34.0.tgz", @@ -1438,6 +1512,15 @@ } } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -3560,6 +3643,18 @@ "node": ">=18.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tailwindcss/cli": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz", @@ -4909,6 +5004,35 @@ "node": ">=18.0" } }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5066,6 +5190,49 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -5197,6 +5364,15 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", @@ -5748,6 +5924,29 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz", + "integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "cross-fetch": "^3.1.5" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, "node_modules/gtoken": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", @@ -5990,6 +6189,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/jackspeak": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", @@ -6030,6 +6238,12 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-to-ts": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", @@ -6620,6 +6834,15 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoevents": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/nanoevents/-/nanoevents-7.0.1.tgz", + "integrity": "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q==", + "license": "MIT", + "engines": { + "node": "^14.0.0 || ^16.0.0 || >=18.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -7541,6 +7764,34 @@ "simple-concat": "^1.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7981,6 +8232,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -8298,6 +8555,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -8307,12 +8573,28 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8448,6 +8730,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -8476,6 +8759,14 @@ "node": ">=0.8" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -8625,6 +8916,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.71.2", "@aws-sdk/client-bedrock-runtime": "^3.966.0", + "@gitlab/gitlab-ai-provider": "^3.1.1", "@google/genai": "1.34.0", "@mistralai/mistralai": "1.10.0", "@sinclair/typebox": "^0.34.41", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 61cdfec36..93bbdec74 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added GitLab Duo provider support using `@gitlab/gitlab-ai-provider` for native tool calling via GitLab's Anthropic proxy. Supports Claude Opus 4.5, Sonnet 4.5, and Haiku 4.5 models with automatic OAuth token management. + ### Fixed - Fixed OpenCode provider's `/v1` endpoint to use `system` role instead of `developer` role, fixing `400 Incorrect role information` error for models using `openai-completions` API ([#755](https://github.com/badlogic/pi-mono/pull/755) by [@melihmucuk](https://github.com/melihmucuk)) diff --git a/packages/ai/README.md b/packages/ai/README.md index 3759fc9c9..3f2e1d95e 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -51,6 +51,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **Anthropic** - **Google** - **Vertex AI** (Gemini via Vertex AI) +- **GitLab Duo** - **Mistral** - **Groq** - **Cerebras** @@ -478,6 +479,13 @@ await complete(googleModel, context, { budgetTokens: 8192 // -1 for dynamic, 0 to disable } }); + +// GitLab Duo (uses Claude via GitLab's Anthropic proxy) +const gitlabDuoModel = getModel('gitlab-duo', 'duo-chat'); +await complete(gitlabDuoModel, context, { + thinking: true, + instanceUrl: 'https://gitlab.example.com', // Optional: self-hosted GitLab URL +}); ``` ### Streaming Thinking Content @@ -858,6 +866,7 @@ In Node.js environments, you can set environment variables to avoid passing API | OpenAI | `OPENAI_API_KEY` | | Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` | | Google | `GEMINI_API_KEY` | +| GitLab Duo | `GITLAB_DUO_TOKEN` or `GITLAB_TOKEN` | | Vertex AI | `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) + `GOOGLE_CLOUD_LOCATION` + ADC | | Mistral | `MISTRAL_API_KEY` | | Groq | `GROQ_API_KEY` | @@ -898,6 +907,7 @@ Several providers require OAuth authentication instead of static API keys: - **Anthropic** (Claude Pro/Max subscription) - **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models) - **GitHub Copilot** (Copilot subscription) +- **GitLab Duo** (GitLab subscription with Duo access) - **Google Gemini CLI** (Gemini 2.0/2.5 via Google Cloud Code Assist; free tier or paid subscription) - **Antigravity** (Free Gemini 3, Claude, GPT-OSS via Google Cloud) diff --git a/packages/ai/package.json b/packages/ai/package.json index 5c510e2d4..762410400 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -24,6 +24,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.71.2", "@aws-sdk/client-bedrock-runtime": "^3.966.0", + "@gitlab/gitlab-ai-provider": "^3.1.1", "@google/genai": "1.34.0", "@mistralai/mistralai": "1.10.0", "@sinclair/typebox": "^0.34.41", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 41e34de45..dda738223 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -4,6 +4,7 @@ import { writeFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { Api, KnownProvider, Model } from "../src/types.js"; +import { getGitLabDuoModels } from "../src/providers/gitlab-duo-models.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -618,7 +619,10 @@ async function generateModels() { const aiGatewayModels = await fetchAiGatewayModels(); // Combine models (models.dev has priority) - const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels]; + // Get GitLab Duo models + const gitLabDuoModels = getGitLabDuoModels(); + + const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels, ...gitLabDuoModels]; // Fix incorrect cache pricing for Claude Opus 4.5 from models.dev // models.dev has 3x the correct pricing (1.5/18.75 instead of 0.5/6.25) diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 1a865c1c4..ed8d20f50 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -4,6 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; import { createInterface } from "readline"; import { loginAnthropic } from "./utils/oauth/anthropic.js"; import { loginGitHubCopilot } from "./utils/oauth/github-copilot.js"; +import { loginGitLabDuo } from "./utils/oauth/gitlab-duo.js"; import { loginAntigravity } from "./utils/oauth/google-antigravity.js"; import { loginGeminiCli } from "./utils/oauth/google-gemini-cli.js"; import { getOAuthProviders } from "./utils/oauth/index.js"; @@ -64,6 +65,19 @@ async function login(provider: OAuthProvider): Promise { }); break; + case "gitlab-duo": + credentials = await loginGitLabDuo( + (info) => { + console.log(`\nOpen this URL in your browser:\n${info.url}`); + if (info.instructions) console.log(info.instructions); + console.log(); + }, + async (p) => { + return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); + }, + ); + break; + case "google-gemini-cli": credentials = await loginGeminiCli( (info) => { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 0600e415c..6bf38e96c 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1723,13 +1723,14 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "oswe-vscode-prime": { - id: "oswe-vscode-prime", - name: "Raptor Mini (Preview)", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + }, + "gitlab-duo": { + "duo-chat": { + id: "duo-chat", + name: "GitLab Duo Chat (Claude Sonnet 4.5)", + api: "gitlab-duo", + provider: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/anthropic/", reasoning: true, input: ["text", "image"], cost: { @@ -1739,8 +1740,59 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"openai-responses">, + maxTokens: 8192, + } satisfies Model<"gitlab-duo">, + "duo-chat-haiku-4-5": { + id: "duo-chat-haiku-4-5", + name: "GitLab Duo Chat (Claude Haiku 4.5)", + api: "gitlab-duo", + provider: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/anthropic/", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"gitlab-duo">, + "duo-chat-opus-4-5": { + id: "duo-chat-opus-4-5", + name: "GitLab Duo Chat (Claude Opus 4.5)", + api: "gitlab-duo", + provider: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/anthropic/", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"gitlab-duo">, + "duo-chat-sonnet-4-5": { + id: "duo-chat-sonnet-4-5", + name: "GitLab Duo Chat (Claude Sonnet 4.5)", + api: "gitlab-duo", + provider: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/anthropic/", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"gitlab-duo">, }, "google": { "gemini-1.5-flash": { @@ -4434,23 +4486,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, - "allenai/olmo-3-7b-instruct": { - id: "allenai/olmo-3-7b-instruct", - name: "AllenAI: Olmo 3 7B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.19999999999999998, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "allenai/olmo-3.1-32b-instruct": { id: "allenai/olmo-3.1-32b-instruct", name: "AllenAI: Olmo 3.1 32B Instruct", @@ -5216,6 +5251,23 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"openai-completions">, + "google/gemini-2.5-flash-preview-09-2025": { + id: "google/gemini-2.5-flash-preview-09-2025", + name: "Google: Gemini 2.5 Flash Preview 09-2025", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.075, + cacheWrite: 0.3833, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"openai-completions">, "google/gemini-2.5-pro": { id: "google/gemini-2.5-pro", name: "Google: Gemini 2.5 Pro", @@ -7290,108 +7342,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "qwen/qwen-max": { - id: "qwen/qwen-max", - name: "Qwen: Qwen-Max ", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.5999999999999999, - output: 6.3999999999999995, - cacheRead: 0.64, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-plus": { - id: "qwen/qwen-plus", - name: "Qwen: Qwen-Plus", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.2, - cacheRead: 0.16, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-plus-2025-07-28": { - id: "qwen/qwen-plus-2025-07-28", - name: "Qwen: Qwen Plus 0728", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen-plus-2025-07-28:thinking": { - id: "qwen/qwen-plus-2025-07-28:thinking", - name: "Qwen: Qwen Plus 0728 (thinking)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 4, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen-turbo": { - id: "qwen/qwen-turbo", - name: "Qwen: Qwen-Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.049999999999999996, - output: 0.19999999999999998, - cacheRead: 0.02, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-vl-max": { - id: "qwen/qwen-vl-max", - name: "Qwen: Qwen VL Max", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.7999999999999999, - output: 3.1999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, "qwen/qwen3-14b": { id: "qwen/qwen3-14b", name: "Qwen: Qwen3 14B", @@ -7596,40 +7546,6 @@ export const MODELS = { contextWindow: 160000, maxTokens: 32768, } satisfies Model<"openai-completions">, - "qwen/qwen3-coder-flash": { - id: "qwen/qwen3-coder-flash", - name: "Qwen: Qwen3 Coder Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 1.5, - cacheRead: 0.08, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "qwen/qwen3-coder-plus": { - id: "qwen/qwen3-coder-plus", - name: "Qwen: Qwen3 Coder Plus", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 5, - cacheRead: 0.09999999999999999, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "qwen/qwen3-coder:exacto": { id: "qwen/qwen3-coder:exacto", name: "Qwen: Qwen3 Coder 480B A35B (exacto)", @@ -7664,34 +7580,34 @@ export const MODELS = { contextWindow: 262000, maxTokens: 262000, } satisfies Model<"openai-completions">, - "qwen/qwen3-max": { - id: "qwen/qwen3-max", - name: "Qwen: Qwen3 Max", + "qwen/qwen3-next-80b-a3b-instruct": { + id: "qwen/qwen3-next-80b-a3b-instruct", + name: "Qwen: Qwen3 Next 80B A3B Instruct", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 1.2, - output: 6, - cacheRead: 0.24, + input: 0.09, + output: 1.1, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 32768, + contextWindow: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, - "qwen/qwen3-next-80b-a3b-instruct": { - id: "qwen/qwen3-next-80b-a3b-instruct", - name: "Qwen: Qwen3 Next 80B A3B Instruct", + "qwen/qwen3-next-80b-a3b-instruct:free": { + id: "qwen/qwen3-next-80b-a3b-instruct:free", + name: "Qwen: Qwen3 Next 80B A3B Instruct (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 0.09, - output: 1.1, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, @@ -7800,23 +7716,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-8b-thinking": { - id: "qwen/qwen3-vl-8b-thinking", - name: "Qwen: Qwen3 VL 8B Thinking", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.18, - output: 2.0999999999999996, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, "qwen/qwq-32b": { id: "qwen/qwq-32b", name: "Qwen: QwQ 32B", diff --git a/packages/ai/src/providers/gitlab-duo-models.ts b/packages/ai/src/providers/gitlab-duo-models.ts new file mode 100644 index 000000000..56d8557b3 --- /dev/null +++ b/packages/ai/src/providers/gitlab-duo-models.ts @@ -0,0 +1,85 @@ +import type { Model } from "../types.js"; + +/** + * Get GitLab Duo models + * + * GitLab Duo uses GitLab's Anthropic proxy to provide access to Claude models. + * The model IDs map to specific Anthropic models: + * - duo-chat-opus-4-5 → claude-opus-4-5-20251101 + * - duo-chat-sonnet-4-5 → claude-sonnet-4-5-20250929 + * - duo-chat-haiku-4-5 → claude-haiku-4-5-20251001 + * - duo-chat → claude-sonnet-4-5-20250929 (default) + */ +export function getGitLabDuoModels(): Model<"gitlab-duo">[] { + return [ + { + id: "duo-chat", + name: "GitLab Duo Chat (Claude Sonnet 4.5)", + api: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/anthropic/", + provider: "gitlab-duo", + reasoning: true, + input: ["text", "image"], + cost: { + // Costs are handled by GitLab subscription, not per-token + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8192, + }, + { + id: "duo-chat-opus-4-5", + name: "GitLab Duo Chat (Claude Opus 4.5)", + api: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/anthropic/", + provider: "gitlab-duo", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8192, + }, + { + id: "duo-chat-sonnet-4-5", + name: "GitLab Duo Chat (Claude Sonnet 4.5)", + api: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/anthropic/", + provider: "gitlab-duo", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8192, + }, + { + id: "duo-chat-haiku-4-5", + name: "GitLab Duo Chat (Claude Haiku 4.5)", + api: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/anthropic/", + provider: "gitlab-duo", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8192, + }, + ]; +} diff --git a/packages/ai/src/providers/gitlab-duo.ts b/packages/ai/src/providers/gitlab-duo.ts new file mode 100644 index 000000000..04205c943 --- /dev/null +++ b/packages/ai/src/providers/gitlab-duo.ts @@ -0,0 +1,488 @@ +import { createGitLab, type GitLabAgenticOptions, type GitLabProvider } from "@gitlab/gitlab-ai-provider"; +import { calculateCost } from "../models.js"; +import { getEnvApiKey } from "../stream.js"; +import type { + Api, + AssistantMessage, + Context, + Model, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { transformMessages } from "./transform-messages.js"; + +export interface GitLabDuoOptions extends StreamOptions { + /** Enable thinking/reasoning mode */ + thinking?: { + enabled: boolean; + }; + /** GitLab instance URL (defaults to https://gitlab.com) */ + instanceUrl?: string; + /** The Anthropic model to use via GitLab's proxy */ + anthropicModel?: string; + /** Feature flags to pass to GitLab API */ + featureFlags?: Record; +} + +// Cache the GitLab provider instance +let cachedProvider: GitLabProvider | null = null; +let cachedInstanceUrl: string | null = null; +let cachedApiKey: string | null = null; + +function getGitLabProvider(instanceUrl: string, apiKey: string): GitLabProvider { + // Return cached provider if settings match + if (cachedProvider && cachedInstanceUrl === instanceUrl && cachedApiKey === apiKey) { + return cachedProvider; + } + + cachedProvider = createGitLab({ + instanceUrl, + apiKey, + }); + cachedInstanceUrl = instanceUrl; + cachedApiKey = apiKey; + + return cachedProvider; +} + +/** + * Convert Pi's Context to Vercel AI SDK prompt format + */ +function convertToAiSdkPrompt(context: Context, model: Model<"gitlab-duo">) { + const prompt: Array<{ + role: "system" | "user" | "assistant" | "tool"; + content: any; + }> = []; + + // Add system prompt + if (context.systemPrompt) { + prompt.push({ + role: "system", + content: context.systemPrompt, + }); + } + + // Transform messages for cross-provider compatibility + const transformedMessages = transformMessages(context.messages, model); + + for (const msg of transformedMessages) { + if (msg.role === "user") { + const content: Array<{ type: "text"; text: string } | { type: "file"; data: string; mimeType: string }> = []; + + if (typeof msg.content === "string") { + content.push({ type: "text", text: msg.content }); + } else { + for (const part of msg.content) { + if (part.type === "text") { + content.push({ type: "text", text: part.text }); + } else if (part.type === "image") { + content.push({ + type: "file", + data: part.data, + mimeType: part.mimeType, + }); + } + } + } + + prompt.push({ role: "user", content }); + } else if (msg.role === "assistant") { + const content: Array< + { type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; input: string } + > = []; + + for (const part of msg.content) { + if (part.type === "text") { + content.push({ type: "text", text: part.text }); + } else if (part.type === "toolCall") { + content.push({ + type: "tool-call", + toolCallId: part.id, + toolName: part.name, + input: JSON.stringify(part.arguments), + }); + } + // Note: thinking blocks are not typically sent back to the model + } + + if (content.length > 0) { + prompt.push({ role: "assistant", content }); + } + } else if (msg.role === "toolResult") { + const resultContent = msg.content + .map((c) => (c.type === "text" ? c.text : `[Image: ${c.mimeType}]`)) + .join("\n"); + + prompt.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: msg.toolCallId, + output: msg.isError + ? { type: "error-text", value: resultContent } + : { type: "text", value: resultContent }, + }, + ], + }); + } + } + + return prompt; +} + +/** + * Convert Pi's tools to Vercel AI SDK format + */ +function convertTools(tools?: Tool[]): Array<{ + type: "function"; + name: string; + description: string; + inputSchema: object; +}> { + if (!tools || tools.length === 0) { + return []; + } + + return tools.map((tool) => ({ + type: "function" as const, + name: tool.name, + description: tool.description, + inputSchema: tool.parameters as object, + })); +} + +/** + * Convert Vercel AI SDK finish reason to Pi's StopReason + */ +function convertFinishReason(reason: string): StopReason { + switch (reason) { + case "stop": + return "stop"; + case "length": + return "length"; + case "tool-calls": + return "toolUse"; + case "error": + return "error"; + default: + return "stop"; + } +} + +/** + * Stream messages from GitLab Duo using the official GitLab AI Provider + */ +export const streamGitLabDuo: StreamFunction<"gitlab-duo"> = ( + model: Model<"gitlab-duo">, + context: Context, + options?: GitLabDuoOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "gitlab-duo" as Api, + provider: "gitlab-duo", + 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: "stop", + timestamp: Date.now(), + }; + + try { + const apiKey = options?.apiKey ?? getEnvApiKey("gitlab-duo") ?? ""; + if (!apiKey) { + throw new Error("GitLab Duo API key not found. Set GITLAB_TOKEN or GITLAB_DUO_TOKEN environment variable."); + } + + const instanceUrl = options?.instanceUrl || "https://gitlab.com"; + + // Get or create the GitLab provider + const gitlab = getGitLabProvider(instanceUrl, apiKey); + + // Create the agentic model with options + const agenticOptions: GitLabAgenticOptions = { + maxTokens: options?.maxTokens || model.maxTokens, + }; + + if (options?.anthropicModel) { + agenticOptions.anthropicModel = options.anthropicModel; + } + + if (options?.featureFlags) { + agenticOptions.featureFlags = options.featureFlags; + } + + const languageModel = gitlab.agenticChat(model.id, agenticOptions); + + // Convert context to AI SDK format + const prompt = convertToAiSdkPrompt(context, model); + const tools = convertTools(context.tools); + + // Start streaming + stream.push({ type: "start", partial: output }); + + // Call doStream on the language model + const { stream: aiStream } = await languageModel.doStream({ + prompt, + tools: tools.length > 0 ? tools : undefined, + temperature: options?.temperature, + maxOutputTokens: options?.maxTokens || model.maxTokens, + abortSignal: options?.signal, + }); + + // Track current content blocks + const textBlocks: Map = new Map(); + const toolBlocks: Map = new Map(); + const reasoningBlocks: Map = new Map(); + const processedToolCallIds: Set = new Set(); + + // Read from the stream + const reader = aiStream.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const event = value as { type: string; [key: string]: any }; + + switch (event.type) { + case "stream-start": + // Already emitted start event + break; + + case "response-metadata": + // Could use for logging + break; + + case "text-start": { + const textBlock: TextContent = { type: "text", text: "" }; + output.content.push(textBlock); + const contentIndex = output.content.length - 1; + textBlocks.set(event.id, { index: contentIndex, text: "" }); + stream.push({ type: "text_start", contentIndex, partial: output }); + break; + } + + case "text-delta": { + const block = textBlocks.get(event.id); + if (block) { + block.text += event.delta; + const textContent = output.content[block.index] as TextContent; + textContent.text = block.text; + stream.push({ + type: "text_delta", + contentIndex: block.index, + delta: event.delta, + partial: output, + }); + } + break; + } + + case "text-end": { + const block = textBlocks.get(event.id); + if (block) { + stream.push({ + type: "text_end", + contentIndex: block.index, + content: block.text, + partial: output, + }); + textBlocks.delete(event.id); + } + break; + } + + case "reasoning-start": { + const thinkingBlock: ThinkingContent = { type: "thinking", thinking: "" }; + output.content.push(thinkingBlock); + const contentIndex = output.content.length - 1; + reasoningBlocks.set(event.id, { index: contentIndex, thinking: "" }); + stream.push({ type: "thinking_start", contentIndex, partial: output }); + break; + } + + case "reasoning-delta": { + const block = reasoningBlocks.get(event.id); + if (block && "delta" in event) { + block.thinking += event.delta; + const thinkingContent = output.content[block.index] as ThinkingContent; + thinkingContent.thinking = block.thinking; + stream.push({ + type: "thinking_delta", + contentIndex: block.index, + delta: event.delta as string, + partial: output, + }); + } + break; + } + + case "reasoning-end": { + const block = reasoningBlocks.get(event.id); + if (block) { + stream.push({ + type: "thinking_end", + contentIndex: block.index, + content: block.thinking, + partial: output, + }); + reasoningBlocks.delete(event.id); + } + break; + } + + case "tool-input-start": { + const toolCall: ToolCall = { + type: "toolCall", + id: event.id, + name: event.toolName, + arguments: {}, + }; + output.content.push(toolCall); + const contentIndex = output.content.length - 1; + toolBlocks.set(event.id, { index: contentIndex, name: event.toolName, input: "" }); + stream.push({ type: "toolcall_start", contentIndex, partial: output }); + break; + } + + case "tool-input-delta": { + const block = toolBlocks.get(event.id); + if (block) { + block.input += event.delta; + stream.push({ + type: "toolcall_delta", + contentIndex: block.index, + delta: event.delta, + partial: output, + }); + } + break; + } + + case "tool-input-end": { + const block = toolBlocks.get(event.id); + if (block) { + // Parse the accumulated input + try { + const toolCall = output.content[block.index] as ToolCall; + toolCall.arguments = JSON.parse(block.input || "{}"); + stream.push({ + type: "toolcall_end", + contentIndex: block.index, + toolCall, + partial: output, + }); + } catch { + // If JSON parsing fails, keep empty arguments + const toolCall = output.content[block.index] as ToolCall; + stream.push({ + type: "toolcall_end", + contentIndex: block.index, + toolCall, + partial: output, + }); + } + // Mark this tool call ID as processed + processedToolCallIds.add(event.id); + toolBlocks.delete(event.id); + } + break; + } + + case "tool-call": { + // Complete tool call event (non-streaming) + // Skip if we already processed this tool call from streaming events + if (processedToolCallIds.has(event.toolCallId)) { + break; + } + const toolCall: ToolCall = { + type: "toolCall", + id: event.toolCallId, + name: event.toolName, + arguments: typeof event.input === "string" ? JSON.parse(event.input) : event.input, + }; + output.content.push(toolCall); + const contentIndex = output.content.length - 1; + stream.push({ type: "toolcall_start", contentIndex, partial: output }); + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall, + partial: output, + }); + processedToolCallIds.add(event.toolCallId); + break; + } + + case "finish": { + output.stopReason = convertFinishReason(event.finishReason); + if (event.usage) { + output.usage.input = event.usage.inputTokens || 0; + output.usage.output = event.usage.outputTokens || 0; + output.usage.totalTokens = event.usage.totalTokens || output.usage.input + output.usage.output; + calculateCost(model, output.usage); + } + break; + } + + case "error": { + output.stopReason = "error"; + output.errorMessage = event.error instanceof Error ? event.error.message : String(event.error); + + // Check if this is a token refresh needed error + if (output.errorMessage === "TOKEN_REFRESH_NEEDED") { + // The provider handles retry internally, but if we get here it means retry failed + output.errorMessage = "Authentication failed. Please re-authenticate with GitLab."; + } + + stream.push({ type: "error", reason: "error", error: output }); + stream.end(); + return; + } + } + } + } finally { + reader.releaseLock(); + } + + // Check for abort + if (options?.signal?.aborted) { + output.stopReason = "aborted"; + output.errorMessage = "Request was aborted"; + stream.push({ type: "error", reason: "aborted", error: output }); + stream.end(); + return; + } + + // Emit done event + stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output }); + stream.end(); + } catch (error) { + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : String(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index e1548f40b..3b3ef8510 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { supportsXhigh } from "./models.js"; import { type BedrockOptions, streamBedrock } from "./providers/amazon-bedrock.js"; import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic.js"; +import { type GitLabDuoOptions, streamGitLabDuo } from "./providers/gitlab-duo.js"; import { type GoogleOptions, streamGoogle } from "./providers/google.js"; import { type GoogleGeminiCliOptions, @@ -63,6 +64,11 @@ export function getEnvApiKey(provider: any): string | undefined { return process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; } + // GitLab Duo uses GITLAB_TOKEN or GITLAB_DUO_TOKEN + if (provider === "gitlab-duo") { + return process.env.GITLAB_DUO_TOKEN || process.env.GITLAB_TOKEN; + } + // Vertex AI uses Application Default Credentials, not API keys. // Auth is configured via `gcloud auth application-default login`. if (provider === "google-vertex") { @@ -151,6 +157,9 @@ export function stream( providerOptions as GoogleGeminiCliOptions, ); + case "gitlab-duo": + return streamGitLabDuo(model as Model<"gitlab-duo">, context, providerOptions as GitLabDuoOptions); + default: { // This should never be reached if all Api cases are handled const _exhaustive: never = api; @@ -387,6 +396,15 @@ function mapOptionsForApi( } satisfies GoogleVertexOptions; } + case "gitlab-duo": { + return { + ...base, + thinking: { + enabled: options?.reasoning !== undefined && options.reasoning !== "minimal", + }, + } satisfies GitLabDuoOptions; + } + default: { // Exhaustiveness check const _exhaustive: never = model.api; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 936e6b6d6..693e8c360 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,5 +1,6 @@ import type { BedrockOptions } from "./providers/amazon-bedrock.js"; import type { AnthropicOptions } from "./providers/anthropic.js"; +import type { GitLabDuoOptions } from "./providers/gitlab-duo.js"; import type { GoogleOptions } from "./providers/google.js"; import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli.js"; import type { GoogleVertexOptions } from "./providers/google-vertex.js"; @@ -18,11 +19,13 @@ export type Api = | "bedrock-converse-stream" | "google-generative-ai" | "google-gemini-cli" - | "google-vertex"; + | "google-vertex" + | "gitlab-duo"; export interface ApiOptionsMap { "anthropic-messages": AnthropicOptions; "bedrock-converse-stream": BedrockOptions; + "gitlab-duo": GitLabDuoOptions; "openai-completions": OpenAICompletionsOptions; "openai-responses": OpenAIResponsesOptions; "openai-codex-responses": OpenAICodexResponsesOptions; @@ -45,6 +48,7 @@ export type OptionsForApi = ApiOptionsMap[TApi]; export type KnownProvider = | "amazon-bedrock" | "anthropic" + | "gitlab-duo" | "google" | "google-gemini-cli" | "google-antigravity" diff --git a/packages/ai/src/utils/oauth/gitlab-duo.ts b/packages/ai/src/utils/oauth/gitlab-duo.ts new file mode 100644 index 000000000..9d7d4c639 --- /dev/null +++ b/packages/ai/src/utils/oauth/gitlab-duo.ts @@ -0,0 +1,281 @@ +/** + * GitLab Duo OAuth flow + * Uses a local callback server for OAuth authorization. + * Requires GITLAB_OAUTH_CLIENT_ID env var with a registered OAuth app, + * or uses the opencode-gitlab-auth bundled client ID by default. + */ + +import http from "http"; +import { generatePKCE } from "./pkce.js"; +import type { OAuthCredentials } from "./types.js"; + +// Default client ID from opencode-gitlab-auth (registered with http://127.0.0.1:8080/callback) +const DEFAULT_CLIENT_ID = + process.env.GITLAB_OAUTH_CLIENT_ID || "1d89f9fdb23ee96d4e603201f6861dab6e143c5c3c00469a018a2d94bdc03d4e"; +const DEFAULT_INSTANCE_URL = "https://gitlab.com"; +const CALLBACK_PORT = 8080; +const CALLBACK_HOST = "127.0.0.1"; +const SCOPES = "api"; + +interface CallbackResult { + code: string; + state: string; +} + +/** + * Create a local HTTP server to handle OAuth callback + */ +function createCallbackServer( + expectedState: string, + timeout: number, +): Promise<{ result: Promise; url: string; close: () => void }> { + return new Promise((resolveServer, rejectServer) => { + let resultResolve: (result: CallbackResult) => void; + let resultReject: (error: Error) => void; + let timeoutHandle: NodeJS.Timeout | undefined; + + const resultPromise = new Promise((resolve, reject) => { + resultResolve = resolve; + resultReject = reject; + }); + + const server = http.createServer((req, res) => { + const url = new URL(req.url || "/", `http://${CALLBACK_HOST}:${CALLBACK_PORT}`); + + if (url.pathname === "/callback") { + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + if (error) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + + Authentication Failed + +

Authentication Failed

+

${errorDescription || error}

+

You can close this window.

+ + + `); + resultReject(new Error(`OAuth error: ${errorDescription || error}`)); + return; + } + + if (!code || !state) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + + Authentication Failed + +

Authentication Failed

+

Missing required parameters.

+

You can close this window.

+ + + `); + resultReject(new Error("Missing code or state parameter")); + return; + } + + if (state !== expectedState) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + + Authentication Failed + +

Authentication Failed

+

State mismatch - possible CSRF attack.

+

You can close this window.

+ + + `); + resultReject(new Error("State mismatch - possible CSRF attack")); + return; + } + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + + Authentication Successful + +

Authentication Successful

+

You can close this window and return to your terminal.

+ + + `); + resultResolve({ code, state }); + } else { + res.writeHead(404); + res.end("Not found"); + } + }); + + server.on("error", (err) => { + rejectServer(err); + }); + + server.listen(CALLBACK_PORT, CALLBACK_HOST, () => { + timeoutHandle = setTimeout(() => { + resultReject(new Error("OAuth callback timeout - authorization took too long")); + server.close(); + }, timeout); + + resolveServer({ + result: resultPromise, + url: `http://${CALLBACK_HOST}:${CALLBACK_PORT}/callback`, + close: () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + server.close(); + }, + }); + }); + }); +} + +/** + * Login with GitLab OAuth + * + * @param onAuth - Callback to show the authorization URL + * @param onPromptToken - Not used for OAuth flow but kept for API compatibility + * @param instanceUrl - GitLab instance URL (defaults to gitlab.com) + */ +export async function loginGitLabDuo( + onAuth: (info: { url: string; instructions?: string }) => void, + _onPromptToken: (prompt: { message: string; placeholder?: string }) => Promise, + instanceUrl: string = DEFAULT_INSTANCE_URL, +): Promise { + const { verifier, challenge } = await generatePKCE(); + + // Generate random state for CSRF protection + const stateBytes = new Uint8Array(32); + crypto.getRandomValues(stateBytes); + const state = Array.from(stateBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + // Start callback server + const callbackServer = await createCallbackServer(state, 120000); // 2 minute timeout + const redirectUri = callbackServer.url; + + try { + // Build authorization URL + const authParams = new URLSearchParams({ + client_id: DEFAULT_CLIENT_ID, + response_type: "code", + redirect_uri: redirectUri, + scope: SCOPES, + code_challenge: challenge, + code_challenge_method: "S256", + state: state, + }); + + const normalizedUrl = instanceUrl.replace(/\/$/, ""); + const authUrl = `${normalizedUrl}/oauth/authorize?${authParams.toString()}`; + + // Show URL and open browser + onAuth({ + url: authUrl, + instructions: "Your browser will open for authentication. The callback will be handled automatically.", + }); + + // Wait for callback + const result = await callbackServer.result; + + // Exchange code for tokens + const tokenResponse = await fetch(`${normalizedUrl}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: DEFAULT_CLIENT_ID, + grant_type: "authorization_code", + code: result.code, + redirect_uri: redirectUri, + code_verifier: verifier, + }).toString(), + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + throw new Error(`GitLab token exchange failed: ${tokenResponse.status} ${error}`); + } + + const tokenData = (await tokenResponse.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + created_at: number; + }; + + // GitLab returns created_at (Unix timestamp in seconds) and expires_in (seconds) + const createdAt = tokenData.created_at * 1000; + const expiresIn = tokenData.expires_in * 1000; + const expiresAt = createdAt + expiresIn - 5 * 60 * 1000; // 5 min buffer + + return { + refresh: tokenData.refresh_token, + access: tokenData.access_token, + expires: expiresAt, + enterpriseUrl: instanceUrl !== DEFAULT_INSTANCE_URL ? instanceUrl : undefined, + }; + } finally { + callbackServer.close(); + } +} + +/** + * Refresh GitLab OAuth token + */ +export async function refreshGitLabDuoToken( + refreshToken: string, + instanceUrl: string = DEFAULT_INSTANCE_URL, +): Promise { + const normalizedUrl = instanceUrl.replace(/\/$/, ""); + + const response = await fetch(`${normalizedUrl}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: DEFAULT_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + }).toString(), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`GitLab token refresh failed: ${response.status} ${error}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + created_at: number; + }; + + const createdAt = data.created_at * 1000; + const expiresIn = data.expires_in * 1000; + const expiresAt = createdAt + expiresIn - 5 * 60 * 1000; + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: expiresAt, + enterpriseUrl: instanceUrl !== DEFAULT_INSTANCE_URL ? instanceUrl : undefined, + }; +} diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 1f9ff1a20..8b3e8dafa 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -18,6 +18,11 @@ export { normalizeDomain, refreshGitHubCopilotToken, } from "./github-copilot.js"; +// GitLab Duo +export { + loginGitLabDuo, + refreshGitLabDuoToken, +} from "./gitlab-duo.js"; // Google Antigravity export { loginAntigravity, @@ -42,6 +47,7 @@ export * from "./types.js"; import { refreshAnthropicToken } from "./anthropic.js"; import { refreshGitHubCopilotToken } from "./github-copilot.js"; +import { refreshGitLabDuoToken } from "./gitlab-duo.js"; import { refreshAntigravityToken } from "./google-antigravity.js"; import { refreshGoogleCloudToken } from "./google-gemini-cli.js"; import { refreshOpenAICodexToken } from "./openai-codex.js"; @@ -68,6 +74,9 @@ export async function refreshOAuthToken( case "github-copilot": newCredentials = await refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl); break; + case "gitlab-duo": + newCredentials = await refreshGitLabDuoToken(credentials.refresh, credentials.enterpriseUrl); + break; case "google-gemini-cli": if (!credentials.projectId) { throw new Error("Google Cloud credentials missing projectId"); @@ -143,6 +152,11 @@ export function getOAuthProviders(): OAuthProviderInfo[] { name: "GitHub Copilot", available: true, }, + { + id: "gitlab-duo", + name: "GitLab Duo", + available: true, + }, { id: "google-gemini-cli", name: "Google Cloud Code Assist (Gemini CLI)", diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index 245d93f6a..d116e85c9 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -11,6 +11,7 @@ export type OAuthCredentials = { export type OAuthProvider = | "anthropic" | "github-copilot" + | "gitlab-duo" | "google-gemini-cli" | "google-antigravity" | "openai-codex"; diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index bf1e66c7c..a37fe206f 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -176,6 +176,7 @@ Add API keys to `~/.pi/agent/auth.json`: | Groq | `groq` | `GROQ_API_KEY` | | Cerebras | `cerebras` | `CEREBRAS_API_KEY` | | xAI | `xai` | `XAI_API_KEY` | +| GitLab Duo | `gitlab-duo` | `GITLAB_TOKEN` or `GITLAB_DUO_TOKEN` | | OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | | Vercel AI Gateway | `vercel-ai-gateway` | `AI_GATEWAY_API_KEY` | | ZAI | `zai` | `ZAI_API_KEY` | @@ -248,6 +249,38 @@ pi --provider amazon-bedrock --model global.anthropic.claude-sonnet-4-5-20250929 See [Supported foundation models in Amazon Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html). +**GitLab Duo:** + +GitLab Duo provides access to Claude models via GitLab's Anthropic proxy. Authenticate via OAuth or API token: + +```bash +# Option 1: OAuth (recommended) +pi +/login gitlab-duo + +# Option 2: GitLab token +export GITLAB_TOKEN=glpat-... + +# Option 3: Dedicated Duo token (takes precedence over GITLAB_TOKEN) +export GITLAB_DUO_TOKEN=glpat-... + +# Use GitLab Duo (defaults to Claude Sonnet 4.5) +pi --provider gitlab-duo --model duo-chat + +# Use specific Claude model variants +pi --provider gitlab-duo --model duo-chat-opus-4-5 # Claude Opus 4.5 +pi --provider gitlab-duo --model duo-chat-sonnet-4-5 # Claude Sonnet 4.5 +pi --provider gitlab-duo --model duo-chat-haiku-4-5 # Claude Haiku 4.5 +``` + +Available models map to Anthropic Claude models: +- `duo-chat` - Claude Sonnet 4.5 (default) +- `duo-chat-opus-4-5` - Claude Opus 4.5 +- `duo-chat-sonnet-4-5` - Claude Sonnet 4.5 +- `duo-chat-haiku-4-5` - Claude Haiku 4.5 + +For self-hosted GitLab instances, set `GITLAB_INSTANCE_URL` environment variable. + ### Quick Start ```bash diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 4c7349edd..81c7cdb34 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -236,6 +236,8 @@ ${chalk.bold("Examples:")} ${chalk.bold("Environment Variables:")} ANTHROPIC_API_KEY - Anthropic Claude API key ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key) + GITLAB_TOKEN - GitLab API token for GitLab Duo + GITLAB_DUO_TOKEN - GitLab Duo API token (alternative to GITLAB_TOKEN) OPENAI_API_KEY - OpenAI GPT API key GEMINI_API_KEY - Google Gemini API key GROQ_API_KEY - Groq API key diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 09c0ac02c..384aad329 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -13,6 +13,7 @@ import { loginAntigravity, loginGeminiCli, loginGitHubCopilot, + loginGitLabDuo, loginOpenAICodex, type OAuthCredentials, type OAuthProvider, @@ -184,6 +185,9 @@ export class AuthStorage { signal: callbacks.signal, }); break; + case "gitlab-duo": + credentials = await loginGitLabDuo((info) => callbacks.onAuth(info), callbacks.onPrompt); + break; case "google-gemini-cli": credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput); break; diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index c28a8b0eb..8291b2585 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -13,6 +13,7 @@ import type { ModelRegistry } from "./model-registry.js"; export const defaultModelPerProvider: Record = { "amazon-bedrock": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", anthropic: "claude-sonnet-4-5", + "gitlab-duo": "duo-chat-sonnet-4-5", openai: "gpt-5.1-codex", "openai-codex": "gpt-5.2-codex", google: "gemini-2.5-pro",