Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ece3e31
feat: integrate Duo Workflow Service models into opencode
vglafirov Feb 24, 2026
367cfae
feat(workflow): add dynamic model selection with discovery, picker, a…
vglafirov Feb 25, 2026
38b7544
fix(workflow): update token counter on finish event for workflow models
vglafirov Feb 25, 2026
9286222
fix(processor): handle finish event for workflow model token counting
vglafirov Feb 25, 2026
bd461a7
fix(workflow): dismiss discovery toast on auto-selected model and ren…
vglafirov Feb 26, 2026
f59363b
Merge branch 'feat/duo-workflow-integration' into dev
vglafirov Feb 26, 2026
08d9e8e
feat(workflow): integrate file-based model cache for faster startup
vglafirov Feb 27, 2026
9a9aec3
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Feb 27, 2026
70001ea
chore: update lock
vglafirov Feb 27, 2026
259b061
feat: bump gitlab-ai-provider
vglafirov Feb 27, 2026
a77253f
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Feb 27, 2026
ea0cea0
refactor: rename workflow model selection to gitlab-specific naming a…
vglafirov Feb 27, 2026
2aecbf7
fix: rename user-facing 'GitLab workflow' strings to 'GitLab DAP'
vglafirov Feb 27, 2026
2e532c9
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Mar 1, 2026
f230ff6
chore: generate sdk files
vglafirov Mar 1, 2026
9b7b560
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Mar 1, 2026
8468809
docs: add GitLab Duo Agent Platform (DAP) as experimental feature
vglafirov Mar 1, 2026
a9b6696
docs: reference GitLab Duo Agent Platform and license requirements
vglafirov Mar 1, 2026
6d43f0e
fix(docs): remove invalid MDX heading ID syntax causing compilation e…
vglafirov Mar 1, 2026
206eb92
fix(docs): replace legacy Duo URL with Agent Platform URL across all …
vglafirov Mar 1, 2026
3c0cb7e
docs: remove featureFlags config and revert gitlab.mdx to pre-PR state
vglafirov Mar 1, 2026
58b69f5
feat: bump gitlab-ai-provider version
vglafirov Mar 1, 2026
dc8941c
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Mar 1, 2026
ac05972
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Mar 1, 2026
1663c4c
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Mar 2, 2026
0a06e3d
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Mar 2, 2026
44eb809
feat: migrate from @gitlab/gitlab-ai-provider to gitlab-ai-provider@5…
vglafirov Mar 2, 2026
1921b1e
feat: migrate from @gitlab/opencode-gitlab-auth to opencode-gitlab-au…
vglafirov Mar 2, 2026
4cf4e32
docs: update GitLab Duo provider docs with experimental notice and ne…
vglafirov Mar 2, 2026
ed992ee
fix: update gitlab auth plugin
vglafirov Mar 2, 2026
d838d51
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Mar 2, 2026
ece5352
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Mar 2, 2026
b58007b
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Mar 2, 2026
06d01ad
Merge branch 'dev' into feat/duo-workflow-integration
vglafirov Mar 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
730 changes: 404 additions & 326 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,6 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
Expand Down Expand Up @@ -107,6 +105,7 @@
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.0.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
Expand All @@ -117,6 +116,7 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
Expand Down
102 changes: 102 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogGitLabWorkflowModel } from "@tui/component/dialog-gitlab-workflow-model"
import { isWorkflowModel, GitLabModelCache } from "gitlab-ai-provider"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
Expand Down Expand Up @@ -673,6 +675,67 @@ function App() {
}
})

let gitlabDiscoveredFor: string | null = null
let gitlabLastDiscoverTrigger = 0
createEffect(() => {
const currentModel = local.model.current()
const trigger = local.model.gitlabWorkflowDiscoverTrigger()
if (!currentModel) return
if (currentModel.providerID !== "gitlab" || !isWorkflowModel(currentModel.modelID)) {
gitlabDiscoveredFor = null
local.model.setGitLabWorkflowSubModelName(null)
return
}
const modelKey = `${currentModel.providerID}/${currentModel.modelID}`
const isRetrigger = trigger > gitlabLastDiscoverTrigger
gitlabLastDiscoverTrigger = trigger
if (gitlabDiscoveredFor === modelKey && !isRetrigger) return
gitlabDiscoveredFor = modelKey
untrack(() => {
const instanceUrl = process.env.GITLAB_INSTANCE_URL || "https://gitlab.com"
const fileCache = new GitLabModelCache(process.cwd(), instanceUrl)
const cached = fileCache.load()
const hasCachedSelection = !!(cached?.selectedModelName && cached?.selectedModelRef)
if (cached?.selectedModelName) {
local.model.setGitLabWorkflowSubModelName(cached.selectedModelName)
}

if (!hasCachedSelection) {
toast.show({ variant: "info", message: "Discovering GitLab DAP models...", duration: 60000 })
}
sdk
.fetch(`${sdk.url}/gitlab-workflow-model-select/discover`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
})
.then((r) => r.json())
.then((data: any) => {
if (data.status === "no_provider") {
toast.show({ variant: "error", message: "GitLab provider not configured", duration: 3000 })
} else if (data.status === "no_models") {
toast.show({ variant: "warning", message: "No GitLab DAP models found", duration: 3000 })
} else if (data.status === "cached") {
toast.dismiss()
if (data.modelName) local.model.setGitLabWorkflowSubModelName(data.modelName)
} else if (data.status === "pinned" || data.status === "default") {
if (data.status === "pinned") {
toast.show({ variant: "info", message: `Using pinned model: ${data.modelRef}`, duration: 3000 })
} else {
toast.dismiss()
}
if (data.modelName) local.model.setGitLabWorkflowSubModelName(data.modelName)
} else if (data.status === "asked" && data.modelName) {
toast.dismiss()
local.model.setGitLabWorkflowSubModelName(data.modelName)
}
})
.catch((err) => {
toast.show({ variant: "error", message: `GitLab DAP discovery failed: ${err}`, duration: 3000 })
})
})
})

sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})
Expand Down Expand Up @@ -734,6 +797,45 @@ function App() {
})
})

sdk.event.listen((e) => {
if ((e.name as string) !== "gitlab_workflow_model_select.asked") return
const evt = e.details as unknown as {
type: string
properties: { id: string; models: { name: string; ref: string; isDefault?: boolean }[] }
}
const { id, models } = evt.properties
let replied = false
const reply = (modelRef: string | null, modelName?: string | null) => {
if (replied) return
replied = true
sdk
.fetch(`${sdk.url}/gitlab-workflow-model-select/${id}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modelRef, modelName }),
})
.catch(() => {})
}
toast.dismiss()
dialog.replace(
() => (
<DialogGitLabWorkflowModel
requestID={id}
models={models}
onReply={(modelRef: string | null) => {
const selected = models.find((m) => m.ref === modelRef)
reply(modelRef, selected?.name ?? null)
dialog.clear()
if (modelRef && selected) {
local.model.setGitLabWorkflowSubModelName(selected.name)
}
}}
/>
),
() => reply(null),
)
})

return (
<box
width={dimensions().width}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { DialogSelect } from "@tui/ui/dialog-select"

export function DialogGitLabWorkflowModel(props: {
requestID: string
models: { name: string; ref: string; isDefault?: boolean }[]
onReply: (modelRef: string | null) => void
}) {
const options = () =>
props.models.map((m) => ({
title: m.isDefault ? `${m.name} (default)` : m.name,
value: m.ref,
onSelect: () => {
props.onReply(m.ref)
},
}))

return <DialogSelect<string> options={options()} title="Select GitLab DAP model" />
}
31 changes: 29 additions & 2 deletions packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
import { batch, createEffect, createMemo, createSignal } from "solid-js"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { uniqueBy } from "remeda"
Expand All @@ -13,6 +13,7 @@ import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import { isWorkflowModel } from "gitlab-ai-provider"

export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
Expand Down Expand Up @@ -201,6 +202,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
})

const [gitlabWorkflowSubModelName, setGitLabWorkflowSubModelName] = createSignal<string | null>(null)
const [gitlabWorkflowDiscoverTrigger, setGitLabWorkflowDiscoverTrigger] = createSignal(0)
const doGitLabWorkflowRediscover = () => {
setGitLabWorkflowSubModelName(null)
sdk
.fetch(`${sdk.url}/gitlab-workflow-model-select/clear`, { method: "POST" })
.catch(() => {})
.then(() => setGitLabWorkflowDiscoverTrigger((n) => n + 1))
}

return {
current: currentModel,
get ready() {
Expand All @@ -212,6 +223,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
favorite() {
return modelStore.favorite
},
gitlabWorkflowSubModelName,
setGitLabWorkflowSubModelName,
gitlabWorkflowDiscoverTrigger,
triggerGitLabWorkflowDiscover: doGitLabWorkflowRediscover,
parsed: createMemo(() => {
const value = currentModel()
if (!value) {
Expand All @@ -223,9 +238,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const provider = sync.data.provider.find((x) => x.id === value.providerID)
const info = provider?.models[value.modelID]
const baseName = info?.name ?? value.modelID
const sub = isWorkflowModel(value.modelID) ? gitlabWorkflowSubModelName() : null
return {
provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID,
model: sub ? `${baseName} (${sub})` : baseName,
reasoning: info?.capabilities?.reasoning ?? false,
}
}),
Expand Down Expand Up @@ -276,6 +293,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
save()
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
const current = currentModel()
const isReselect =
options?.recent &&
current &&
current.providerID === model.providerID &&
current.modelID === model.modelID &&
isWorkflowModel(model.modelID)
batch(() => {
if (!isModelValid(model)) {
toast.show({
Expand All @@ -295,6 +319,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
save()
}
if (isReselect) {
doGitLabWorkflowRediscover()
}
})
},
toggleFavorite(model: { providerID: string; modelID: string }) {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/tui/context/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (timer) clearTimeout(timer)
})

return { client: sdk, event: emitter, url: props.url }
return { client: sdk, event: emitter, url: props.url, fetch: props.fetch ?? globalThis.fetch }
},
})
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/tui/ui/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ function init() {
setStore("currentToast", null)
}, duration).unref()
},
dismiss() {
if (timeoutHandle) clearTimeout(timeoutHandle)
setStore("currentToast", null)
},
error: (err: any) => {
if (err instanceof Error)
return toast.show({
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"

export namespace Plugin {
const log = Log.create({ service: "plugin" })
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/provider/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export namespace ProviderAuth {
if (result.accountId) {
info.accountId = result.accountId
}
if (result.provider) {
info.enterpriseUrl = result.provider
}
await Auth.set(input.providerID, info)
}
return
Expand Down
21 changes: 18 additions & 3 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,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, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION, isWorkflowModel } from "gitlab-ai-provider"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { GoogleAuth } from "google-auth-library"
import { ProviderTransform } from "./transform"
Expand Down Expand Up @@ -104,7 +104,7 @@ export namespace Provider {
"@ai-sdk/togetherai": createTogetherAI,
"@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel,
"@gitlab/gitlab-ai-provider": createGitLab,
"gitlab-ai-provider": createGitLab,
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
Expand Down Expand Up @@ -466,10 +466,15 @@ export namespace Provider {
},
gitlab: async (input) => {
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
const normalizedInstanceUrl = instanceUrl.replace(/\/$/, "")

const auth = await Auth.get(input.id)
const apiKey = await (async () => {
if (auth?.type === "oauth") return auth.access
if (auth?.type === "oauth") {
const authInstance = (auth.enterpriseUrl || "https://gitlab.com").replace(/\/$/, "")
if (authInstance === normalizedInstanceUrl) return auth.access
return Env.get("GITLAB_TOKEN")
}
if (auth?.type === "api") return auth.key
return Env.get("GITLAB_TOKEN")
})()
Expand All @@ -495,6 +500,16 @@ export namespace Provider {
},
},
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
if (isWorkflowModel(modelID)) {
return sdk.workflowChat(modelID, {
workingDirectory: Instance.directory,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
},
})
}
return sdk.agenticChat(modelID, {
aiGatewayHeaders,
featureFlags: {
Expand Down
Loading
Loading