summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorVladimir Glafirov <[email protected]>2026-01-13 20:21:39 +0100
committerFrank <[email protected]>2026-01-13 19:50:49 -0500
commita520c4ff9801d96b0c00e6e090023f5dfdb6309b (patch)
tree300b90acc2d046351ac53df1ba9f0b5887283866 /packages
parenta184714f6714e732960aeb1f047427e979ac3115 (diff)
downloadopencode-a520c4ff9801d96b0c00e6e090023f5dfdb6309b.tar.gz
opencode-a520c4ff9801d96b0c00e6e090023f5dfdb6309b.zip
feat: Add GitLab Duo Agentic Chat Provider Support (#7333)
Co-authored-by: Aiden Cline <[email protected]> Co-authored-by: Aiden Cline <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/package.json1
-rw-r--r--packages/opencode/src/plugin/index.ts7
-rw-r--r--packages/opencode/src/provider/provider.ts41
-rw-r--r--packages/opencode/test/provider/amazon-bedrock.test.ts7
-rw-r--r--packages/opencode/test/provider/gitlab-duo.test.ts286
-rw-r--r--packages/web/src/content/docs/providers.mdx93
6 files changed, 433 insertions, 2 deletions
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index f2c95d0b3..c0c4e79b6 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -70,6 +70,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
+ "@gitlab/gitlab-ai-provider": "3.1.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index b0c9eee2c..8ce6dfd3c 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -14,7 +14,11 @@ import { NamedError } from "@opencode-ai/util/error"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
- const BUILTIN = ["[email protected]", "[email protected]"]
+ const BUILTIN = [
+ "@gitlab/[email protected]",
+ ]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
@@ -46,6 +50,7 @@ export namespace Plugin {
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push(...BUILTIN)
}
+
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth")) continue
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 3b76b1e02..9bde1333e 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -1,4 +1,6 @@
import z from "zod"
+import path from "path"
+import os from "os"
import fuzzysort from "fuzzysort"
import { Config } from "../config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
@@ -35,6 +37,7 @@ import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel"
+import { createGitLab } from "@gitlab/gitlab-ai-provider"
import { ProviderTransform } from "./transform"
export namespace Provider {
@@ -60,6 +63,7 @@ export namespace Provider {
"@ai-sdk/togetherai": createTogetherAI,
"@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel,
+ "@gitlab/gitlab-ai-provider": createGitLab,
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
@@ -390,6 +394,43 @@ export namespace Provider {
},
}
},
+ async gitlab(input) {
+ const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
+
+ const auth = await Auth.get(input.id)
+ const apiKey = await (async () => {
+ if (auth?.type === "oauth") return auth.access
+ if (auth?.type === "api") return auth.key
+ return Env.get("GITLAB_TOKEN")
+ })()
+
+ const config = await Config.get()
+ const providerConfig = config.provider?.["gitlab"]
+
+ return {
+ autoload: !!apiKey,
+ options: {
+ instanceUrl,
+ apiKey,
+ featureFlags: {
+ duo_agent_platform_agentic_chat: true,
+ duo_agent_platform: true,
+ ...(providerConfig?.options?.featureFlags || {}),
+ },
+ },
+ async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: { anthropicModel?: string }) {
+ const anthropicModel = options?.anthropicModel
+ return sdk.agenticChat(modelID, {
+ anthropicModel,
+ featureFlags: {
+ duo_agent_platform_agentic_chat: true,
+ duo_agent_platform: true,
+ ...(providerConfig?.options?.featureFlags || {}),
+ },
+ })
+ },
+ }
+ },
"cloudflare-ai-gateway": async (input) => {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts
index d10e85139..05f5bd01f 100644
--- a/packages/opencode/test/provider/amazon-bedrock.test.ts
+++ b/packages/opencode/test/provider/amazon-bedrock.test.ts
@@ -9,7 +9,11 @@ import path from "path"
mock.module("../../src/bun/index", () => ({
BunProc: {
- install: async (pkg: string) => pkg,
+ install: async (pkg: string, _version?: string) => {
+ // Return package name without version for mocking
+ const lastAtIndex = pkg.lastIndexOf("@")
+ return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg
+ },
run: async () => {
throw new Error("BunProc.run should not be called in tests")
},
@@ -28,6 +32,7 @@ mock.module("@aws-sdk/credential-providers", () => ({
const mockPlugin = () => ({})
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
+mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))
// Import after mocks are set up
const { tmpdir } = await import("../fixture/fixture")
diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts
new file mode 100644
index 000000000..4d5aa9c74
--- /dev/null
+++ b/packages/opencode/test/provider/gitlab-duo.test.ts
@@ -0,0 +1,286 @@
+import { test, expect, mock } from "bun:test"
+import path from "path"
+
+// === Mocks ===
+// These mocks prevent real package installations during tests
+
+mock.module("../../src/bun/index", () => ({
+ BunProc: {
+ install: async (pkg: string, _version?: string) => {
+ // Return package name without version for mocking
+ const lastAtIndex = pkg.lastIndexOf("@")
+ return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg
+ },
+ run: async () => {
+ throw new Error("BunProc.run should not be called in tests")
+ },
+ which: () => process.execPath,
+ InstallFailedError: class extends Error {},
+ },
+}))
+
+const mockPlugin = () => ({})
+mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
+mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
+mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))
+
+// Import after mocks are set up
+const { tmpdir } = await import("../fixture/fixture")
+const { Instance } = await import("../../src/project/instance")
+const { Provider } = await import("../../src/provider/provider")
+const { Env } = await import("../../src/env")
+const { Global } = await import("../../src/global")
+
+test("GitLab Duo: loads provider with API key from environment", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GITLAB_TOKEN", "test-gitlab-token")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].key).toBe("test-gitlab-token")
+ },
+ })
+})
+
+test("GitLab Duo: config instanceUrl option sets baseURL", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ gitlab: {
+ options: {
+ instanceUrl: "https://gitlab.example.com",
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GITLAB_TOKEN", "test-token")
+ Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.example.com")
+ },
+ })
+})
+
+test("GitLab Duo: loads with OAuth token from auth.json", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+
+ const authPath = path.join(Global.Path.data, "auth.json")
+ await Bun.write(
+ authPath,
+ JSON.stringify({
+ gitlab: {
+ type: "oauth",
+ access: "test-access-token",
+ refresh: "test-refresh-token",
+ expires: Date.now() + 3600000,
+ },
+ }),
+ )
+
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GITLAB_TOKEN", "")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["gitlab"]).toBeDefined()
+ },
+ })
+})
+
+test("GitLab Duo: loads with Personal Access Token from auth.json", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+
+ const authPath2 = path.join(Global.Path.data, "auth.json")
+ await Bun.write(
+ authPath2,
+ JSON.stringify({
+ gitlab: {
+ type: "api",
+ key: "glpat-test-pat-token",
+ },
+ }),
+ )
+
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GITLAB_TOKEN", "")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].key).toBe("glpat-test-pat-token")
+ },
+ })
+})
+
+test("GitLab Duo: supports self-hosted instance configuration", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ gitlab: {
+ options: {
+ instanceUrl: "https://gitlab.company.internal",
+ apiKey: "glpat-internal-token",
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.company.internal")
+ },
+ })
+})
+
+test("GitLab Duo: config apiKey takes precedence over environment variable", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ gitlab: {
+ options: {
+ apiKey: "config-token",
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GITLAB_TOKEN", "env-token")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["gitlab"]).toBeDefined()
+ },
+ })
+})
+
+test("GitLab Duo: supports feature flags configuration", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ gitlab: {
+ options: {
+ featureFlags: {
+ duo_agent_platform_agentic_chat: true,
+ duo_agent_platform: true,
+ },
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GITLAB_TOKEN", "test-token")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].options?.featureFlags).toBeDefined()
+ expect(providers["gitlab"].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
+ },
+ })
+})
+
+test("GitLab Duo: has multiple agentic chat models available", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GITLAB_TOKEN", "test-token")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["gitlab"]).toBeDefined()
+ const models = Object.keys(providers["gitlab"].models)
+ expect(models.length).toBeGreaterThan(0)
+ expect(models).toContain("duo-chat-haiku-4-5")
+ expect(models).toContain("duo-chat-sonnet-4-5")
+ expect(models).toContain("duo-chat-opus-4-5")
+ },
+ })
+})
diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx
index 80c6f89e1..7af4ab85d 100644
--- a/packages/web/src/content/docs/providers.mdx
+++ b/packages/web/src/content/docs/providers.mdx
@@ -557,6 +557,99 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI,
---
+### GitLab Duo
+
+GitLab Duo provides AI-powered agentic chat with native tool calling capabilities through GitLab's Anthropic proxy.
+
+1. Run the `/connect` command and select GitLab.
+
+ ```txt
+ /connect
+ ```
+
+2. Choose your authentication method:
+
+ ```txt
+ ┌ Select auth method
+ │
+ │ OAuth (Recommended)
+ │ Personal Access Token
+ └
+ ```
+
+ #### Using OAuth (Recommended)
+
+ Select **OAuth** and your browser will open for authorization.
+
+ #### Using Personal Access Token
+ 1. Go to [GitLab User Settings > Access Tokens](https://gitlab.com/-/user_settings/personal_access_tokens)
+ 2. Click **Add new token**
+ 3. Name: `OpenCode`, Scopes: `api`
+ 4. Copy the token (starts with `glpat-`)
+ 5. Enter it in the terminal
+
+3. Run the `/models` command to see available models.
+
+ ```txt
+ /models
+ ```
+
+ Three Claude-based models are available:
+ - **duo-chat-haiku-4-5** (Default) - Fast responses for quick tasks
+ - **duo-chat-sonnet-4-5** - Balanced performance for most workflows
+ - **duo-chat-opus-4-5** - Most capable for complex analysis
+
+##### Self-Hosted GitLab
+
+For self-hosted GitLab instances:
+
+```bash
+GITLAB_INSTANCE_URL=https://gitlab.company.com GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx opencode
+```
+
+Or add to your bash profile:
+
+```bash title="~/.bash_profile"
+export GITLAB_INSTANCE_URL=https://gitlab.company.com
+export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
+```
+
+##### Configuration
+
+Customize through `opencode.json`:
+
+```json title="opencode.json"
+{
+ "$schema": "https://opencode.ai/config.json",
+ "provider": {
+ "gitlab": {
+ "options": {
+ "instanceUrl": "https://gitlab.com",
+ "featureFlags": {
+ "duo_agent_platform_agentic_chat": true,
+ "duo_agent_platform": true
+ }
+ }
+ }
+ }
+}
+```
+
+##### GitLab API Tools (Optional)
+
+To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
+
+```json title="opencode.json"
+{
+ "$schema": "https://opencode.ai/config.json",
+ "plugin": ["@gitlab/opencode-gitlab-plugin"]
+}
+```
+
+This plugin provides comprehensive GitLab repository management capabilities including MR reviews, issue tracking, pipeline monitoring, and more.
+
+---
+
### GitHub Copilot
To use your GitHub Copilot subscription with opencode: