feat: Add GitLab Duo Agentic Chat Provider Support (#7333)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -14,7 +14,11 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
const BUILTIN = ["opencode-copilot-auth@0.0.12", "opencode-anthropic-auth@0.0.8"]
|
||||
const BUILTIN = [
|
||||
"opencode-copilot-auth@0.0.12",
|
||||
"opencode-anthropic-auth@0.0.8",
|
||||
"@gitlab/opencode-gitlab-auth@1.3.0",
|
||||
]
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
286
packages/opencode/test/provider/gitlab-duo.test.ts
Normal file
286
packages/opencode/test/provider/gitlab-duo.test.ts
Normal file
@@ -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")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user