diff --git a/bun.lock b/bun.lock index 2aa72ea94af6..e25446bee5b0 100644 --- a/bun.lock +++ b/bun.lock @@ -325,7 +325,6 @@ "@aws-sdk/credential-providers": "3.993.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/platform-node": "4.0.0-beta.31", - "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", @@ -357,6 +356,7 @@ "drizzle-orm": "1.0.0-beta.16-ea816b6", "effect": "catalog:", "fuzzysort": "3.1.0", + "gitlab-ai-provider": "5.1.2", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -1105,8 +1105,6 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="], - "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "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" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], @@ -3023,6 +3021,8 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@5.1.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-ItYtVisOCQD88+sRUvVzFcuiB/ZlZTGkVWCP3UzSuXSB06H1WQLjxrxP27cVQ6+erkX9XyGLIO7w4MjMx6qi1A=="], + "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -5047,10 +5047,6 @@ "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], - "@gitlab/gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="], - - "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -5445,6 +5441,10 @@ "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="], + + "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0463cc6d25d1..196707bd0c0e 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -80,7 +80,7 @@ "@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-ai-provider": "5.1.2", "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2537f8949332..c33e9b52b0cc 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -40,7 +40,16 @@ 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, + GitLabModelDiscovery, + GitLabModelConfigRegistry, + GitLabProjectDetector, + MODEL_MAPPINGS, + isWorkflowModel, + type GitLabProject, +} from "gitlab-ai-provider" import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { GoogleAuth } from "google-auth-library" import { ProviderTransform } from "./transform" @@ -126,24 +135,28 @@ 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, } type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise type CustomVarsLoader = (options: Record) => Record + type CustomDiscoverModels = () => Promise> type CustomLoader = (provider: Info) => Promise<{ autoload: boolean getModel?: CustomModelLoader vars?: CustomVarsLoader options?: Record + discoverModels?: CustomDiscoverModels }> function useLanguageModel(sdk: any) { return sdk.responses === undefined && sdk.chat === undefined } + const gitlabModelConfigRegistry = new GitLabModelConfigRegistry() + const CUSTOM_LOADERS: Record = { async anthropic() { return { @@ -527,28 +540,148 @@ export namespace Provider { ...(providerConfig?.options?.aiGatewayHeaders || {}), } + const featureFlags = { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...(providerConfig?.options?.featureFlags || {}), + } + return { autoload: !!apiKey, options: { instanceUrl, apiKey, aiGatewayHeaders, - featureFlags: { - duo_agent_platform_agentic_chat: true, - duo_agent_platform: true, - ...(providerConfig?.options?.featureFlags || {}), - }, + featureFlags, }, - async getModel(sdk: ReturnType, modelID: string) { + async getModel(sdk: ReturnType, modelID: string, options?: Record) { + if (modelID.startsWith("duo-workflow-")) { + const workflowRef = options?.workflowRef as string | undefined + // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef + const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow" + const model = sdk.workflowChat(sdkModelID, { + featureFlags, + }) + if (workflowRef) { + model.selectedModelRef = workflowRef + } + return model + } return sdk.agenticChat(modelID, { aiGatewayHeaders, - featureFlags: { - duo_agent_platform_agentic_chat: true, - duo_agent_platform: true, - ...(providerConfig?.options?.featureFlags || {}), - }, + featureFlags, }) }, + async discoverModels(): Promise> { + if (!apiKey) { + log.info("gitlab model discovery skipped: no apiKey") + return {} + } + + try { + const token = apiKey + const getHeaders = (): Record => + auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` } + + const discovery = new GitLabModelDiscovery({ + instanceUrl, + getHeaders, + }) + + const detector = new GitLabProjectDetector({ + instanceUrl, + getHeaders, + }) + let project: GitLabProject | null = null + try { + project = await detector.detectProject(process.cwd()) + } catch (e) { + log.info("gitlab project detection failed", { error: e, cwd: process.cwd() }) + } + + const namespaceId = project?.namespaceId + if (!namespaceId) { + log.info("gitlab model discovery skipped: no namespaceId", { + project: project ? { id: project.id, path: project.pathWithNamespace } : null, + cwd: process.cwd(), + }) + return {} + } + + log.info("gitlab model discovery starting", { namespaceId, instanceUrl }) + const [discovered, modelConfigs] = await Promise.all([ + discovery.discover(`gid://gitlab/Group/${namespaceId}`), + gitlabModelConfigRegistry.getConfigs(), + ]) + log.info("gitlab model discovery result", { + selectableModels: discovered.selectableModels?.length ?? 0, + defaultModel: discovered.defaultModel?.ref ?? null, + pinnedModel: discovered.pinnedModel?.ref ?? null, + }) + const models: Record = {} + + // Build reverse map: discovery ref → duo-workflow-* model ID + const refToModelID = new Map() + for (const [modelID, mapping] of Object.entries(MODEL_MAPPINGS)) { + if (mapping.provider === "workflow" && modelID !== "duo-workflow" && modelID !== "duo-workflow-default") { + refToModelID.set(mapping.model, modelID) + } + } + + // If a model is pinned by admin, only that model is available + const allModels = discovered.pinnedModel + ? [discovered.pinnedModel] + : [...(discovered.selectableModels ?? []), ...(discovered.defaultModel ? [discovered.defaultModel] : [])] + + const seen = new Set() + for (const model of allModels) { + if (!model.ref || seen.has(model.ref)) continue + seen.add(model.ref) + + // Use static mapping if available, otherwise generate an ID from the ref + const workflowModelID = refToModelID.get(model.ref) ?? `duo-workflow-${model.ref.replace(/[/_]/g, "-")}` + const limits = modelConfigs.get(model.ref) + if (!input.models[workflowModelID]) { + models[workflowModelID] = { + id: ModelID.make(workflowModelID), + providerID: ProviderID.make("gitlab"), + name: `Agent Platform (${model.name})`, + family: "", + api: { + id: workflowModelID, + url: instanceUrl, + npm: "gitlab-ai-provider", + }, + status: "active", + headers: {}, + options: { workflowRef: model.ref }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: limits?.context ?? 200000, output: limits?.output ?? 64000 }, + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + } + } + + log.info("gitlab model discovery complete", { + count: Object.keys(models).length, + models: Object.keys(models), + }) + return models + } catch (e) { + log.warn("gitlab model discovery failed", { error: e }) + return {} + } + }, } }, "cloudflare-workers-ai": async (input) => { @@ -847,6 +980,9 @@ export namespace Provider { const varsLoaders: { [providerID: string]: CustomVarsLoader } = {} + const discoveryLoaders: { + [providerID: string]: CustomDiscoverModels + } = {} const sdk = new Map() log.info("init") @@ -1003,6 +1139,7 @@ export namespace Provider { if (result && (result.autoload || providers[providerID])) { if (result.getModel) modelLoaders[providerID] = result.getModel if (result.vars) varsLoaders[providerID] = result.vars + if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels const opts = result.options ?? {} const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } mergeProvider(providerID, patch) @@ -1070,6 +1207,7 @@ export namespace Provider { sdk, modelLoaders, varsLoaders, + discoveryLoaders, } }) @@ -1077,6 +1215,47 @@ export namespace Provider { return state().then((state) => state.providers) } + const discoveryCache = new Map>() + + export async function discoverModels(providerID: ProviderID): Promise { + log.debug("discoverModels called", { providerID }) + const cached = discoveryCache.get(providerID) + if (cached) { + log.debug("discoverModels returning cached", { providerID }) + return cached + } + + const promise = (async () => { + const s = await state() + const loader = s.discoveryLoaders[providerID] + if (!loader) { + log.debug("discoverModels no loader", { providerID, loaders: Object.keys(s.discoveryLoaders) }) + return + } + + const provider = s.providers[providerID] + if (!provider) { + log.debug("discoverModels no provider", { providerID }) + return + } + + const discovered = await loader() + log.debug("discoverModels discovered", { providerID, count: Object.keys(discovered).length }) + for (const [modelID, model] of Object.entries(discovered)) { + if (!provider.models[modelID]) { + provider.models[modelID] = model + } + } + })() + + promise.catch(() => { + discoveryCache.delete(providerID) + }) + + discoveryCache.set(providerID, promise) + return promise + } + async function getSDK(model: Model) { try { using _ = log.time("getSDK", { @@ -1244,7 +1423,7 @@ export namespace Provider { try { const language = s.modelLoaders[model.providerID] - ? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options) + ? await s.modelLoaders[model.providerID](sdk, model.api.id, { ...provider.options, ...model.options }) : sdk.languageModel(model.api.id) s.models.set(key, language) return language diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/routes/config.ts index 85d28f6aa6b8..97c0a40776b4 100644 --- a/packages/opencode/src/server/routes/config.ts +++ b/packages/opencode/src/server/routes/config.ts @@ -83,6 +83,14 @@ export const ConfigRoutes = lazy(() => async (c) => { using _ = log.time("providers") const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) + // Trigger lazy model discovery for connected providers + await Promise.all( + Object.keys(providers).map((id) => + Provider.discoverModels(id as any).catch((e) => { + log.warn("config.providers discovery error", { id, error: e }) + }), + ), + ) return c.json({ providers: Object.values(providers), default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts index fc716d25cb0e..09ee0b228ebb 100644 --- a/packages/opencode/src/server/routes/provider.ts +++ b/packages/opencode/src/server/routes/provider.ts @@ -9,6 +9,9 @@ import { ProviderID } from "../../provider/schema" import { mapValues } from "remeda" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Log } from "../../util/log" + +const log = Log.create({ service: "server" }) export const ProviderRoutes = lazy(() => new Hono() @@ -49,6 +52,14 @@ export const ProviderRoutes = lazy(() => } const connected = await Provider.list() + // Trigger lazy model discovery for connected providers + await Promise.all( + Object.keys(connected).map((id) => + Provider.discoverModels(id as any).catch((e) => { + log.warn("provider discovery error", { id, error: e }) + }), + ), + ) const providers = Object.assign( mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), connected, diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 88841a30a8c3..76ec23368dbe 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -12,6 +12,7 @@ import { jsonSchema, } from "ai" import { mergeDeep, pipe } from "remeda" +import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" import { Instance } from "@/project/instance" @@ -170,6 +171,34 @@ export namespace LLM { }) } + // Wire up toolExecutor for DWS workflow models so that tool calls + // from the workflow service are executed via opencode's tool system + // and results sent back over the WebSocket. + if (language instanceof GitLabWorkflowLanguageModel) { + const workflowModel = language + workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { + const t = tools[toolName] + if (!t || !t.execute) { + return { result: "", error: `Unknown tool: ${toolName}` } + } + try { + const result = await t.execute!(JSON.parse(argsJson), { + toolCallId: _requestID, + messages: input.messages, + abortSignal: input.abort, + }) + const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result)) + return { + result: output, + metadata: typeof result === "object" ? result?.metadata : undefined, + title: typeof result === "object" ? result?.title : undefined, + } + } catch (e: any) { + return { result: "", error: e.message ?? String(e) } + } + } + } + return streamText({ onError(error) { l.error("stream error", { diff --git a/packages/opencode/test/tool/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json index 391e783699e9..715224cd3a5b 100644 --- a/packages/opencode/test/tool/fixtures/models-api.json +++ b/packages/opencode/test/tool/fixtures/models-api.json @@ -32933,7 +32933,7 @@ "gitlab": { "id": "gitlab", "env": ["GITLAB_TOKEN"], - "npm": "@gitlab/gitlab-ai-provider", + "npm": "gitlab-ai-provider", "name": "GitLab Duo", "doc": "https://docs.gitlab.com/user/duo_agent_platform/", "models": {