opencode-gitlab-duo-agentic 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +496 -204
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,13 +26,21 @@ export GITLAB_INSTANCE_URL=https://gitlab.com
|
|
|
26
26
|
|
|
27
27
|
## Optional model configuration
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
The plugin discovers models in this order:
|
|
30
30
|
|
|
31
|
-
1. `
|
|
32
|
-
2. `
|
|
33
|
-
|
|
31
|
+
1. Live GitLab API discovery with cache (TTL default: `86400` seconds)
|
|
32
|
+
2. `models.json` file resolution:
|
|
33
|
+
- `options.modelsPath` in provider config
|
|
34
|
+
- `GITLAB_DUO_MODELS_PATH`
|
|
35
|
+
- `models.json` found by walking upward from `process.cwd()`
|
|
36
|
+
3. Fallback default model `duo-agentic`
|
|
34
37
|
|
|
35
|
-
|
|
38
|
+
Cache settings:
|
|
39
|
+
|
|
40
|
+
- Location: `~/.cache/opencode/gitlab-duo-models-*.json`
|
|
41
|
+
- TTL override: `GITLAB_DUO_MODELS_CACHE_TTL` (seconds)
|
|
42
|
+
|
|
43
|
+
You can still generate a local `models.json` manually using `src/scripts/fetch_models.ts`.
|
|
36
44
|
|
|
37
45
|
## Development
|
|
38
46
|
|
package/dist/index.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ type GitLabDuoAgenticProviderOptions = {
|
|
|
18
18
|
systemRules?: string;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
type GitLabDuoAgenticProviderInput = Partial<GitLabDuoAgenticProviderOptions>;
|
|
22
|
+
declare function createGitLabDuoAgentic(options?: GitLabDuoAgenticProviderInput): ProviderV2;
|
|
22
23
|
|
|
23
24
|
export { GitLabDuoAgenticPlugin, createGitLabDuoAgentic, GitLabDuoAgenticPlugin as default };
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// src/plugin/config.ts
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import path4 from "path";
|
|
3
|
+
import fs4 from "fs";
|
|
4
4
|
|
|
5
5
|
// src/plugin/models.ts
|
|
6
|
-
import
|
|
7
|
-
import
|
|
6
|
+
import path3 from "path";
|
|
7
|
+
import fs3 from "fs";
|
|
8
8
|
|
|
9
9
|
// src/shared/model_entry.ts
|
|
10
10
|
function buildModelEntry(name) {
|
|
@@ -28,12 +28,435 @@ var GITLAB_DUO_DEFAULT_MODEL_NAME = "Duo Agentic";
|
|
|
28
28
|
var GITLAB_DUO_PLUGIN_PACKAGE_NAME = "opencode-gitlab-duo-agentic";
|
|
29
29
|
var GITLAB_DUO_PROVIDER_NPM_ENTRY = GITLAB_DUO_PLUGIN_PACKAGE_NAME;
|
|
30
30
|
|
|
31
|
+
// src/plugin/fetch_models.ts
|
|
32
|
+
import crypto from "crypto";
|
|
33
|
+
import os from "os";
|
|
34
|
+
import path2 from "path";
|
|
35
|
+
import fs2 from "fs/promises";
|
|
36
|
+
|
|
37
|
+
// src/provider/adapters/gitlab_utils.ts
|
|
38
|
+
import fs from "fs/promises";
|
|
39
|
+
import path from "path";
|
|
40
|
+
async function detectProjectPath(cwd, instanceUrl) {
|
|
41
|
+
let current = cwd;
|
|
42
|
+
const instance = new URL(instanceUrl);
|
|
43
|
+
const instanceHost = instance.host;
|
|
44
|
+
const instanceBasePath = instance.pathname.replace(/\/$/, "");
|
|
45
|
+
while (true) {
|
|
46
|
+
try {
|
|
47
|
+
const config = await readGitConfig(current);
|
|
48
|
+
const url = extractGitRemoteUrl(config) || "";
|
|
49
|
+
const remote = parseRemote(url);
|
|
50
|
+
if (!remote) {
|
|
51
|
+
return void 0;
|
|
52
|
+
}
|
|
53
|
+
if (remote.host !== instanceHost) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`GitLab remote host mismatch. Expected ${instanceHost}, got ${remote.host}.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return normalizeProjectPath(remote.path, instanceBasePath);
|
|
59
|
+
} catch {
|
|
60
|
+
const parent = path.dirname(current);
|
|
61
|
+
if (parent === current) return void 0;
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function extractGitRemoteUrl(config) {
|
|
67
|
+
const lines = config.split("\n");
|
|
68
|
+
let inOrigin = false;
|
|
69
|
+
let originUrl;
|
|
70
|
+
let firstUrl;
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
const sectionMatch = /^\[remote\s+"([^"]+)"\]$/.exec(trimmed);
|
|
74
|
+
if (sectionMatch) {
|
|
75
|
+
inOrigin = sectionMatch[1] === "origin";
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const urlMatch = /^url\s*=\s*(.+)$/.exec(trimmed);
|
|
79
|
+
if (urlMatch) {
|
|
80
|
+
const value = urlMatch[1].trim();
|
|
81
|
+
if (!firstUrl) firstUrl = value;
|
|
82
|
+
if (inOrigin) originUrl = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return originUrl ?? firstUrl;
|
|
86
|
+
}
|
|
87
|
+
function parseRemote(remoteUrl) {
|
|
88
|
+
if (!remoteUrl) return void 0;
|
|
89
|
+
if (remoteUrl.startsWith("http")) {
|
|
90
|
+
try {
|
|
91
|
+
const url = new URL(remoteUrl);
|
|
92
|
+
return { host: url.host, path: url.pathname.replace(/^\//, "") };
|
|
93
|
+
} catch {
|
|
94
|
+
return void 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (remoteUrl.startsWith("git@")) {
|
|
98
|
+
const match = /^git@([^:]+):(.+)$/.exec(remoteUrl);
|
|
99
|
+
if (!match) return void 0;
|
|
100
|
+
return { host: match[1], path: match[2] };
|
|
101
|
+
}
|
|
102
|
+
if (remoteUrl.startsWith("ssh://")) {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(remoteUrl);
|
|
105
|
+
return { host: url.host, path: url.pathname.replace(/^\//, "") };
|
|
106
|
+
} catch {
|
|
107
|
+
return void 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return void 0;
|
|
111
|
+
}
|
|
112
|
+
function normalizeProjectPath(remotePath, instanceBasePath) {
|
|
113
|
+
let pathValue = remotePath;
|
|
114
|
+
if (instanceBasePath && instanceBasePath !== "/") {
|
|
115
|
+
const base = instanceBasePath.replace(/^\//, "") + "/";
|
|
116
|
+
if (pathValue.startsWith(base)) {
|
|
117
|
+
pathValue = pathValue.slice(base.length);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const cleaned = stripGitSuffix(pathValue);
|
|
121
|
+
return cleaned.length > 0 ? cleaned : void 0;
|
|
122
|
+
}
|
|
123
|
+
function stripGitSuffix(pathname) {
|
|
124
|
+
return pathname.endsWith(".git") ? pathname.slice(0, -4) : pathname;
|
|
125
|
+
}
|
|
126
|
+
function buildApiUrl(instanceUrl, apiPath) {
|
|
127
|
+
const base = instanceUrl.endsWith("/") ? instanceUrl : `${instanceUrl}/`;
|
|
128
|
+
return new URL(apiPath.replace(/^\//, ""), base).toString();
|
|
129
|
+
}
|
|
130
|
+
function buildAuthHeaders(apiKey) {
|
|
131
|
+
return { authorization: `Bearer ${apiKey}` };
|
|
132
|
+
}
|
|
133
|
+
async function fetchProjectDetails(instanceUrl, apiKey, projectPath) {
|
|
134
|
+
const url = buildApiUrl(instanceUrl, `api/v4/projects/${encodeURIComponent(projectPath)}`);
|
|
135
|
+
const response = await fetch(url, {
|
|
136
|
+
headers: buildAuthHeaders(apiKey)
|
|
137
|
+
});
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new Error(`Failed to fetch project details: ${response.status}`);
|
|
140
|
+
}
|
|
141
|
+
const data = await response.json();
|
|
142
|
+
return {
|
|
143
|
+
projectId: data.id ? String(data.id) : void 0,
|
|
144
|
+
namespaceId: data.namespace?.id ? String(data.namespace.id) : void 0
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async function fetchProjectDetailsWithFallback(instanceUrl, apiKey, projectPath) {
|
|
148
|
+
const candidates = getProjectPathCandidates(projectPath);
|
|
149
|
+
for (const candidate of candidates) {
|
|
150
|
+
try {
|
|
151
|
+
return await fetchProjectDetails(instanceUrl, apiKey, candidate);
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const name = projectPath.split("/").pop() || projectPath;
|
|
158
|
+
const searchUrl = new URL(buildApiUrl(instanceUrl, "api/v4/projects"));
|
|
159
|
+
searchUrl.searchParams.set("search", name);
|
|
160
|
+
searchUrl.searchParams.set("simple", "true");
|
|
161
|
+
searchUrl.searchParams.set("per_page", "100");
|
|
162
|
+
searchUrl.searchParams.set("membership", "true");
|
|
163
|
+
const response = await fetch(searchUrl.toString(), {
|
|
164
|
+
headers: buildAuthHeaders(apiKey)
|
|
165
|
+
});
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(`Failed to search projects: ${response.status}`);
|
|
168
|
+
}
|
|
169
|
+
const data = await response.json();
|
|
170
|
+
const match = data.find((project) => project.path_with_namespace === projectPath);
|
|
171
|
+
if (!match) {
|
|
172
|
+
throw new Error("Project not found via search");
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
projectId: match.id ? String(match.id) : void 0,
|
|
176
|
+
namespaceId: match.namespace?.id ? String(match.namespace.id) : void 0
|
|
177
|
+
};
|
|
178
|
+
} catch {
|
|
179
|
+
throw new Error("Project not found via API");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function getProjectPathCandidates(projectPath) {
|
|
183
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
184
|
+
candidates.add(projectPath);
|
|
185
|
+
const parts = projectPath.split("/");
|
|
186
|
+
if (parts.length > 2) {
|
|
187
|
+
const withoutFirst = parts.slice(1).join("/");
|
|
188
|
+
candidates.add(withoutFirst);
|
|
189
|
+
}
|
|
190
|
+
return Array.from(candidates);
|
|
191
|
+
}
|
|
192
|
+
async function readGitConfig(cwd) {
|
|
193
|
+
const gitPath = path.join(cwd, ".git");
|
|
194
|
+
const stat = await fs.stat(gitPath);
|
|
195
|
+
if (stat.isDirectory()) {
|
|
196
|
+
return fs.readFile(path.join(gitPath, "config"), "utf8");
|
|
197
|
+
}
|
|
198
|
+
const file = await fs.readFile(gitPath, "utf8");
|
|
199
|
+
const match = /^gitdir:\s*(.+)$/m.exec(file);
|
|
200
|
+
if (!match) throw new Error("Invalid .git file");
|
|
201
|
+
const gitdir = match[1].trim();
|
|
202
|
+
const resolved = path.isAbsolute(gitdir) ? gitdir : path.join(cwd, gitdir);
|
|
203
|
+
return fs.readFile(path.join(resolved, "config"), "utf8");
|
|
204
|
+
}
|
|
205
|
+
async function resolveRootNamespaceId(instanceUrl, apiKey, namespaceId) {
|
|
206
|
+
let currentId = namespaceId;
|
|
207
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
208
|
+
const url = buildApiUrl(instanceUrl, `api/v4/namespaces/${currentId}`);
|
|
209
|
+
const response = await fetch(url, {
|
|
210
|
+
headers: buildAuthHeaders(apiKey)
|
|
211
|
+
});
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
const data = await response.json();
|
|
216
|
+
if (!data.parent_id) {
|
|
217
|
+
currentId = String(data.id ?? currentId);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
currentId = String(data.parent_id);
|
|
221
|
+
}
|
|
222
|
+
return `gid://gitlab/Group/${currentId}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/plugin/fetch_models.ts
|
|
226
|
+
var DEFAULT_MODELS_CACHE_TTL_SECONDS = 60 * 60 * 24;
|
|
227
|
+
var AVAILABLE_MODELS_QUERY = `query lsp_aiChatAvailableModels($rootNamespaceId: GroupID!) {
|
|
228
|
+
metadata {
|
|
229
|
+
featureFlags(names: ["ai_user_model_switching"]) {
|
|
230
|
+
enabled
|
|
231
|
+
name
|
|
232
|
+
}
|
|
233
|
+
version
|
|
234
|
+
}
|
|
235
|
+
aiChatAvailableModels(rootNamespaceId: $rootNamespaceId) {
|
|
236
|
+
defaultModel { name ref }
|
|
237
|
+
selectableModels { name ref }
|
|
238
|
+
pinnedModel { name ref }
|
|
239
|
+
}
|
|
240
|
+
}`;
|
|
241
|
+
function resolveModelsCacheTtlSeconds() {
|
|
242
|
+
const raw = process.env.GITLAB_DUO_MODELS_CACHE_TTL;
|
|
243
|
+
if (!raw) return DEFAULT_MODELS_CACHE_TTL_SECONDS;
|
|
244
|
+
const parsed = Number.parseInt(raw, 10);
|
|
245
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
246
|
+
return DEFAULT_MODELS_CACHE_TTL_SECONDS;
|
|
247
|
+
}
|
|
248
|
+
return parsed;
|
|
249
|
+
}
|
|
250
|
+
async function loadModelsFromCache(options) {
|
|
251
|
+
const instanceUrl = normalizeInstanceUrl(options.instanceUrl);
|
|
252
|
+
const cwd = options.cwd ?? process.cwd();
|
|
253
|
+
const ttlSeconds = options.ttlSeconds ?? resolveModelsCacheTtlSeconds();
|
|
254
|
+
const allowStale = options.allowStale ?? false;
|
|
255
|
+
const { cachePath, projectPath } = await resolveCacheLocation(instanceUrl, cwd);
|
|
256
|
+
let raw;
|
|
257
|
+
try {
|
|
258
|
+
raw = await fs2.readFile(cachePath, "utf8");
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const fsError = error;
|
|
261
|
+
if (fsError?.code === "ENOENT") return null;
|
|
262
|
+
console.warn(
|
|
263
|
+
`[gitlab-duo] Failed to read models cache at ${cachePath}: ${fsError?.message ?? String(error)}`
|
|
264
|
+
);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
let parsed;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(raw);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.warn(
|
|
272
|
+
`[gitlab-duo] Failed to parse models cache at ${cachePath}: ${error instanceof Error ? error.message : String(error)}`
|
|
273
|
+
);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const models = parsed.payload?.models;
|
|
277
|
+
if (!models || Object.keys(models).length === 0) return null;
|
|
278
|
+
const cachedAtMs = Date.parse(parsed.cachedAt);
|
|
279
|
+
const ageSeconds = Number.isFinite(cachedAtMs) ? Math.max(0, (Date.now() - cachedAtMs) / 1e3) : Infinity;
|
|
280
|
+
const stale = ageSeconds > ttlSeconds;
|
|
281
|
+
if (stale && !allowStale) return null;
|
|
282
|
+
return {
|
|
283
|
+
payload: parsed.payload,
|
|
284
|
+
stale,
|
|
285
|
+
cachePath,
|
|
286
|
+
projectPath
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async function fetchAndCacheModels(options) {
|
|
290
|
+
const instanceUrl = normalizeInstanceUrl(options.instanceUrl);
|
|
291
|
+
const apiKey = options.apiKey.trim();
|
|
292
|
+
const cwd = options.cwd ?? process.cwd();
|
|
293
|
+
if (!apiKey) {
|
|
294
|
+
throw new Error("GITLAB_TOKEN is required for live model discovery");
|
|
295
|
+
}
|
|
296
|
+
const projectPath = await resolveProjectPath(cwd, instanceUrl);
|
|
297
|
+
if (!projectPath) {
|
|
298
|
+
throw new Error("Could not detect GitLab project from git remote");
|
|
299
|
+
}
|
|
300
|
+
const payload = await fetchModels(instanceUrl, apiKey, projectPath);
|
|
301
|
+
const { cachePath } = await resolveCacheLocation(instanceUrl, cwd, projectPath);
|
|
302
|
+
await writeCache(cachePath, payload);
|
|
303
|
+
return {
|
|
304
|
+
payload,
|
|
305
|
+
stale: false,
|
|
306
|
+
cachePath,
|
|
307
|
+
projectPath
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
async function fetchModels(instanceUrl, apiKey, projectPath) {
|
|
311
|
+
const details = await fetchProjectDetailsWithFallback(instanceUrl, apiKey, projectPath);
|
|
312
|
+
const namespaceId = details.namespaceId;
|
|
313
|
+
if (!namespaceId) {
|
|
314
|
+
throw new Error("Could not determine namespace ID from project");
|
|
315
|
+
}
|
|
316
|
+
const rootNamespaceId = await resolveRootNamespaceId(instanceUrl, apiKey, namespaceId);
|
|
317
|
+
const graphqlUrl = `${instanceUrl}/api/graphql`;
|
|
318
|
+
const response = await fetch(graphqlUrl, {
|
|
319
|
+
method: "POST",
|
|
320
|
+
headers: {
|
|
321
|
+
"content-type": "application/json",
|
|
322
|
+
authorization: `Bearer ${apiKey}`
|
|
323
|
+
},
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
query: AVAILABLE_MODELS_QUERY,
|
|
326
|
+
variables: { rootNamespaceId }
|
|
327
|
+
})
|
|
328
|
+
});
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
const text = await response.text();
|
|
331
|
+
throw new Error(`GraphQL request failed (${response.status}): ${text}`);
|
|
332
|
+
}
|
|
333
|
+
const result = await response.json();
|
|
334
|
+
if (result.errors?.length) {
|
|
335
|
+
throw new Error(result.errors.map((error) => error.message).join("; "));
|
|
336
|
+
}
|
|
337
|
+
const metadata = result.data?.metadata;
|
|
338
|
+
const available = result.data?.aiChatAvailableModels;
|
|
339
|
+
const defaultModel = available?.defaultModel ?? null;
|
|
340
|
+
const pinnedModel = available?.pinnedModel ?? null;
|
|
341
|
+
const selectableModels = available?.selectableModels ?? [];
|
|
342
|
+
const featureFlags = {};
|
|
343
|
+
for (const flag of metadata?.featureFlags ?? []) {
|
|
344
|
+
featureFlags[flag.name] = flag.enabled;
|
|
345
|
+
}
|
|
346
|
+
const models = {};
|
|
347
|
+
if (defaultModel?.ref) {
|
|
348
|
+
models[defaultModel.ref] = buildModelEntry(defaultModel.name || defaultModel.ref);
|
|
349
|
+
}
|
|
350
|
+
if (pinnedModel?.ref) {
|
|
351
|
+
models[pinnedModel.ref] = buildModelEntry(pinnedModel.name || pinnedModel.ref);
|
|
352
|
+
}
|
|
353
|
+
for (const model of selectableModels) {
|
|
354
|
+
if (model.ref && !models[model.ref]) {
|
|
355
|
+
models[model.ref] = buildModelEntry(model.name || model.ref);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (Object.keys(models).length === 0) {
|
|
359
|
+
models[GITLAB_DUO_DEFAULT_MODEL_ID] = buildModelEntry(GITLAB_DUO_DEFAULT_MODEL_NAME);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
metadata: {
|
|
363
|
+
instanceUrl,
|
|
364
|
+
rootNamespaceId,
|
|
365
|
+
gitlabVersion: metadata?.version ?? null,
|
|
366
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
367
|
+
featureFlags,
|
|
368
|
+
defaultModel: defaultModel?.ref ?? null,
|
|
369
|
+
pinnedModel: pinnedModel?.ref ?? null
|
|
370
|
+
},
|
|
371
|
+
models,
|
|
372
|
+
available: {
|
|
373
|
+
defaultModel,
|
|
374
|
+
pinnedModel,
|
|
375
|
+
selectableModels
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
async function resolveCacheLocation(instanceUrl, cwd, projectPathHint) {
|
|
380
|
+
const projectPath = projectPathHint ?? await resolveProjectPath(cwd, instanceUrl);
|
|
381
|
+
const cacheDir = resolveCacheDirectory();
|
|
382
|
+
const cacheKey = `${instanceUrl}::${projectPath ?? "no-project"}`;
|
|
383
|
+
const hash = crypto.createHash("sha256").update(cacheKey).digest("hex").slice(0, 12);
|
|
384
|
+
return {
|
|
385
|
+
cachePath: path2.join(cacheDir, `gitlab-duo-models-${hash}.json`),
|
|
386
|
+
projectPath
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function resolveCacheDirectory() {
|
|
390
|
+
const xdg = process.env.XDG_CACHE_HOME?.trim();
|
|
391
|
+
if (xdg) return path2.join(xdg, "opencode");
|
|
392
|
+
return path2.join(os.homedir(), ".cache", "opencode");
|
|
393
|
+
}
|
|
394
|
+
async function resolveProjectPath(cwd, instanceUrl) {
|
|
395
|
+
try {
|
|
396
|
+
return await detectProjectPath(cwd, instanceUrl);
|
|
397
|
+
} catch {
|
|
398
|
+
return void 0;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function writeCache(cachePath, payload) {
|
|
402
|
+
const data = {
|
|
403
|
+
cachedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
404
|
+
payload
|
|
405
|
+
};
|
|
406
|
+
await fs2.mkdir(path2.dirname(cachePath), { recursive: true });
|
|
407
|
+
await fs2.writeFile(cachePath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
408
|
+
}
|
|
409
|
+
function normalizeInstanceUrl(value) {
|
|
410
|
+
return value.trim().replace(/\/$/, "");
|
|
411
|
+
}
|
|
412
|
+
|
|
31
413
|
// src/plugin/models.ts
|
|
32
414
|
async function loadGitLabModels(options = {}) {
|
|
415
|
+
const instanceUrl = resolveInstanceUrl(options.instanceUrl);
|
|
416
|
+
const apiKey = resolveApiKey(options.apiKey);
|
|
417
|
+
const cacheTtlSeconds = resolveModelsCacheTtlSeconds();
|
|
418
|
+
const cache = await loadModelsFromCache({
|
|
419
|
+
instanceUrl,
|
|
420
|
+
ttlSeconds: cacheTtlSeconds,
|
|
421
|
+
allowStale: true
|
|
422
|
+
});
|
|
423
|
+
if (cache && !cache.stale) {
|
|
424
|
+
console.log(
|
|
425
|
+
`[gitlab-duo] Loaded ${Object.keys(cache.payload.models).length} model(s) from cache ${cache.cachePath}`
|
|
426
|
+
);
|
|
427
|
+
return cache.payload.models;
|
|
428
|
+
}
|
|
429
|
+
if (apiKey) {
|
|
430
|
+
try {
|
|
431
|
+
const fetched = await fetchAndCacheModels({
|
|
432
|
+
instanceUrl,
|
|
433
|
+
apiKey
|
|
434
|
+
});
|
|
435
|
+
console.log(
|
|
436
|
+
`[gitlab-duo] Loaded ${Object.keys(fetched.payload.models).length} model(s) from GitLab API`
|
|
437
|
+
);
|
|
438
|
+
return fetched.payload.models;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
console.warn(
|
|
441
|
+
`[gitlab-duo] Failed to fetch models from GitLab API: ${error instanceof Error ? error.message : String(error)}`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (cache?.stale) {
|
|
446
|
+
console.warn(`[gitlab-duo] Using stale models cache from ${cache.cachePath}`);
|
|
447
|
+
return cache.payload.models;
|
|
448
|
+
}
|
|
33
449
|
const modelsJsonPath = resolveModelsJsonPath(options.modelsPath);
|
|
450
|
+
const modelsFromFile = await loadModelsFromFile(modelsJsonPath);
|
|
451
|
+
if (modelsFromFile) return modelsFromFile;
|
|
452
|
+
return {
|
|
453
|
+
[GITLAB_DUO_DEFAULT_MODEL_ID]: buildModelEntry(GITLAB_DUO_DEFAULT_MODEL_NAME)
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
async function loadModelsFromFile(modelsJsonPath) {
|
|
34
457
|
if (modelsJsonPath) {
|
|
35
458
|
try {
|
|
36
|
-
const raw = await
|
|
459
|
+
const raw = await fs3.promises.readFile(modelsJsonPath, "utf8");
|
|
37
460
|
const data = JSON.parse(raw);
|
|
38
461
|
if (data.models && Object.keys(data.models).length > 0) {
|
|
39
462
|
console.log(
|
|
@@ -48,28 +471,36 @@ async function loadGitLabModels(options = {}) {
|
|
|
48
471
|
);
|
|
49
472
|
}
|
|
50
473
|
}
|
|
51
|
-
return
|
|
52
|
-
[GITLAB_DUO_DEFAULT_MODEL_ID]: buildModelEntry(GITLAB_DUO_DEFAULT_MODEL_NAME)
|
|
53
|
-
};
|
|
474
|
+
return null;
|
|
54
475
|
}
|
|
55
476
|
function resolveModelsJsonPath(overridePath) {
|
|
56
477
|
const override = typeof overridePath === "string" && overridePath.trim() ? overridePath.trim() : process.env.GITLAB_DUO_MODELS_PATH;
|
|
57
478
|
if (override) {
|
|
58
|
-
const resolved =
|
|
59
|
-
if (
|
|
479
|
+
const resolved = path3.isAbsolute(override) ? override : path3.resolve(process.cwd(), override);
|
|
480
|
+
if (fs3.existsSync(resolved)) return resolved;
|
|
60
481
|
console.warn(`[gitlab-duo] models.json not found at override path ${resolved}`);
|
|
61
482
|
return null;
|
|
62
483
|
}
|
|
63
484
|
let current = process.cwd();
|
|
64
485
|
while (true) {
|
|
65
|
-
const candidate =
|
|
66
|
-
if (
|
|
67
|
-
const parent =
|
|
486
|
+
const candidate = path3.join(current, "models.json");
|
|
487
|
+
if (fs3.existsSync(candidate)) return candidate;
|
|
488
|
+
const parent = path3.dirname(current);
|
|
68
489
|
if (parent === current) break;
|
|
69
490
|
current = parent;
|
|
70
491
|
}
|
|
71
492
|
return null;
|
|
72
493
|
}
|
|
494
|
+
function resolveInstanceUrl(override) {
|
|
495
|
+
if (typeof override === "string" && override.trim()) return override.trim();
|
|
496
|
+
const fromEnv = process.env.GITLAB_INSTANCE_URL?.trim();
|
|
497
|
+
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
|
498
|
+
return "https://gitlab.com";
|
|
499
|
+
}
|
|
500
|
+
function resolveApiKey(override) {
|
|
501
|
+
if (typeof override === "string" && override.trim()) return override.trim();
|
|
502
|
+
return process.env.GITLAB_TOKEN || "";
|
|
503
|
+
}
|
|
73
504
|
|
|
74
505
|
// src/plugin/config.ts
|
|
75
506
|
async function configHook(input) {
|
|
@@ -77,8 +508,8 @@ async function configHook(input) {
|
|
|
77
508
|
const existing = input.provider[GITLAB_DUO_PROVIDER_ID];
|
|
78
509
|
const existingOptions = existing?.options ?? {};
|
|
79
510
|
const providerNpm = typeof existing?.npm === "string" && existing.npm.trim() ? existing.npm : GITLAB_DUO_PROVIDER_NPM_ENTRY;
|
|
80
|
-
const apiKey = typeof existingOptions.apiKey === "string" ? existingOptions.apiKey : process.env.GITLAB_TOKEN || "";
|
|
81
|
-
const instanceUrl = typeof existingOptions.instanceUrl === "string" ? existingOptions.instanceUrl : process.env.GITLAB_INSTANCE_URL || "https://gitlab.com";
|
|
511
|
+
const apiKey = typeof existingOptions.apiKey === "string" && existingOptions.apiKey.trim() ? existingOptions.apiKey.trim() : process.env.GITLAB_TOKEN || "";
|
|
512
|
+
const instanceUrl = typeof existingOptions.instanceUrl === "string" && existingOptions.instanceUrl.trim() ? existingOptions.instanceUrl.trim() : process.env.GITLAB_INSTANCE_URL?.trim() || "https://gitlab.com";
|
|
82
513
|
const systemRules = typeof existingOptions.systemRules === "string" ? existingOptions.systemRules : "";
|
|
83
514
|
const systemRulesPath = typeof existingOptions.systemRulesPath === "string" ? existingOptions.systemRulesPath : "";
|
|
84
515
|
const modelsPath = typeof existingOptions.modelsPath === "string" ? existingOptions.modelsPath : void 0;
|
|
@@ -101,15 +532,15 @@ async function configHook(input) {
|
|
|
101
532
|
enableMcp,
|
|
102
533
|
systemRules: mergedSystemRules || void 0
|
|
103
534
|
},
|
|
104
|
-
models: await loadGitLabModels({ modelsPath })
|
|
535
|
+
models: await loadGitLabModels({ modelsPath, instanceUrl, apiKey })
|
|
105
536
|
};
|
|
106
537
|
}
|
|
107
538
|
async function mergeSystemRules(rules, rulesPath) {
|
|
108
539
|
const baseRules = rules.trim();
|
|
109
540
|
if (!rulesPath) return baseRules;
|
|
110
|
-
const resolvedPath =
|
|
541
|
+
const resolvedPath = path4.isAbsolute(rulesPath) ? rulesPath : path4.resolve(process.cwd(), rulesPath);
|
|
111
542
|
try {
|
|
112
|
-
const fileRules = (await
|
|
543
|
+
const fileRules = (await fs4.promises.readFile(resolvedPath, "utf8")).trim();
|
|
113
544
|
if (!fileRules) return baseRules;
|
|
114
545
|
return baseRules ? `${baseRules}
|
|
115
546
|
|
|
@@ -122,8 +553,8 @@ ${fileRules}` : fileRules;
|
|
|
122
553
|
|
|
123
554
|
// src/plugin/tools.ts
|
|
124
555
|
import { tool } from "@opencode-ai/plugin";
|
|
125
|
-
import
|
|
126
|
-
import
|
|
556
|
+
import path5 from "path";
|
|
557
|
+
import fs5 from "fs";
|
|
127
558
|
function createReadTools() {
|
|
128
559
|
return {
|
|
129
560
|
read_file: tool({
|
|
@@ -140,7 +571,7 @@ function createReadTools() {
|
|
|
140
571
|
metadata: {}
|
|
141
572
|
});
|
|
142
573
|
try {
|
|
143
|
-
return await
|
|
574
|
+
return await fs5.promises.readFile(resolvedPath, "utf8");
|
|
144
575
|
} catch (error) {
|
|
145
576
|
throw new Error(formatReadError(displayPath, error));
|
|
146
577
|
}
|
|
@@ -165,7 +596,7 @@ function createReadTools() {
|
|
|
165
596
|
const results = await Promise.all(
|
|
166
597
|
targets.map(async (target) => {
|
|
167
598
|
try {
|
|
168
|
-
const content = await
|
|
599
|
+
const content = await fs5.promises.readFile(target.resolvedPath, "utf8");
|
|
169
600
|
return [target.inputPath, { content }];
|
|
170
601
|
} catch (error) {
|
|
171
602
|
return [target.inputPath, { error: formatReadError(target.displayPath, error) }];
|
|
@@ -183,9 +614,9 @@ function createReadTools() {
|
|
|
183
614
|
}
|
|
184
615
|
function resolveReadPath(filePath, ctx) {
|
|
185
616
|
const displayPath = filePath;
|
|
186
|
-
const resolvedPath =
|
|
187
|
-
const worktreePath =
|
|
188
|
-
if (resolvedPath !== worktreePath && !resolvedPath.startsWith(worktreePath +
|
|
617
|
+
const resolvedPath = path5.isAbsolute(filePath) ? filePath : path5.resolve(ctx.worktree, filePath);
|
|
618
|
+
const worktreePath = path5.resolve(ctx.worktree);
|
|
619
|
+
if (resolvedPath !== worktreePath && !resolvedPath.startsWith(worktreePath + path5.sep)) {
|
|
189
620
|
throw new Error(`File is outside the repository: "${displayPath}"`);
|
|
190
621
|
}
|
|
191
622
|
return { resolvedPath, displayPath };
|
|
@@ -1060,7 +1491,7 @@ function classifyModeReminder2(reminder) {
|
|
|
1060
1491
|
}
|
|
1061
1492
|
|
|
1062
1493
|
// src/provider/application/workflow_event_mapper.ts
|
|
1063
|
-
import
|
|
1494
|
+
import crypto2 from "crypto";
|
|
1064
1495
|
|
|
1065
1496
|
// src/provider/core/ui_chat_log.ts
|
|
1066
1497
|
import { z } from "zod";
|
|
@@ -1201,7 +1632,7 @@ var WorkflowEventMapper = class {
|
|
|
1201
1632
|
break;
|
|
1202
1633
|
}
|
|
1203
1634
|
case "request": {
|
|
1204
|
-
const requestId = latestMessage.correlation_id ||
|
|
1635
|
+
const requestId = latestMessage.correlation_id || crypto2.randomUUID();
|
|
1205
1636
|
events.push({
|
|
1206
1637
|
type: "TOOL_REQUEST",
|
|
1207
1638
|
requestId,
|
|
@@ -1706,175 +2137,6 @@ function parseHttpToolOutput(output) {
|
|
|
1706
2137
|
// src/provider/adapters/default_runtime_dependencies.ts
|
|
1707
2138
|
import { ProxyAgent } from "proxy-agent";
|
|
1708
2139
|
|
|
1709
|
-
// src/provider/adapters/gitlab_utils.ts
|
|
1710
|
-
import fs4 from "fs/promises";
|
|
1711
|
-
import path4 from "path";
|
|
1712
|
-
async function detectProjectPath(cwd, instanceUrl) {
|
|
1713
|
-
let current = cwd;
|
|
1714
|
-
const instance = new URL(instanceUrl);
|
|
1715
|
-
const instanceHost = instance.host;
|
|
1716
|
-
const instanceBasePath = instance.pathname.replace(/\/$/, "");
|
|
1717
|
-
while (true) {
|
|
1718
|
-
try {
|
|
1719
|
-
const config = await readGitConfig(current);
|
|
1720
|
-
const url = extractGitRemoteUrl(config) || "";
|
|
1721
|
-
const remote = parseRemote(url);
|
|
1722
|
-
if (!remote) {
|
|
1723
|
-
return void 0;
|
|
1724
|
-
}
|
|
1725
|
-
if (remote.host !== instanceHost) {
|
|
1726
|
-
throw new Error(
|
|
1727
|
-
`GitLab remote host mismatch. Expected ${instanceHost}, got ${remote.host}.`
|
|
1728
|
-
);
|
|
1729
|
-
}
|
|
1730
|
-
return normalizeProjectPath(remote.path, instanceBasePath);
|
|
1731
|
-
} catch {
|
|
1732
|
-
const parent = path4.dirname(current);
|
|
1733
|
-
if (parent === current) return void 0;
|
|
1734
|
-
current = parent;
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
function extractGitRemoteUrl(config) {
|
|
1739
|
-
const lines = config.split("\n");
|
|
1740
|
-
let inOrigin = false;
|
|
1741
|
-
let originUrl;
|
|
1742
|
-
let firstUrl;
|
|
1743
|
-
for (const line of lines) {
|
|
1744
|
-
const trimmed = line.trim();
|
|
1745
|
-
const sectionMatch = /^\[remote\s+"([^"]+)"\]$/.exec(trimmed);
|
|
1746
|
-
if (sectionMatch) {
|
|
1747
|
-
inOrigin = sectionMatch[1] === "origin";
|
|
1748
|
-
continue;
|
|
1749
|
-
}
|
|
1750
|
-
const urlMatch = /^url\s*=\s*(.+)$/.exec(trimmed);
|
|
1751
|
-
if (urlMatch) {
|
|
1752
|
-
const value = urlMatch[1].trim();
|
|
1753
|
-
if (!firstUrl) firstUrl = value;
|
|
1754
|
-
if (inOrigin) originUrl = value;
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
return originUrl ?? firstUrl;
|
|
1758
|
-
}
|
|
1759
|
-
function parseRemote(remoteUrl) {
|
|
1760
|
-
if (!remoteUrl) return void 0;
|
|
1761
|
-
if (remoteUrl.startsWith("http")) {
|
|
1762
|
-
try {
|
|
1763
|
-
const url = new URL(remoteUrl);
|
|
1764
|
-
return { host: url.host, path: url.pathname.replace(/^\//, "") };
|
|
1765
|
-
} catch {
|
|
1766
|
-
return void 0;
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
if (remoteUrl.startsWith("git@")) {
|
|
1770
|
-
const match = /^git@([^:]+):(.+)$/.exec(remoteUrl);
|
|
1771
|
-
if (!match) return void 0;
|
|
1772
|
-
return { host: match[1], path: match[2] };
|
|
1773
|
-
}
|
|
1774
|
-
if (remoteUrl.startsWith("ssh://")) {
|
|
1775
|
-
try {
|
|
1776
|
-
const url = new URL(remoteUrl);
|
|
1777
|
-
return { host: url.host, path: url.pathname.replace(/^\//, "") };
|
|
1778
|
-
} catch {
|
|
1779
|
-
return void 0;
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
return void 0;
|
|
1783
|
-
}
|
|
1784
|
-
function normalizeProjectPath(remotePath, instanceBasePath) {
|
|
1785
|
-
let pathValue = remotePath;
|
|
1786
|
-
if (instanceBasePath && instanceBasePath !== "/") {
|
|
1787
|
-
const base = instanceBasePath.replace(/^\//, "") + "/";
|
|
1788
|
-
if (pathValue.startsWith(base)) {
|
|
1789
|
-
pathValue = pathValue.slice(base.length);
|
|
1790
|
-
}
|
|
1791
|
-
}
|
|
1792
|
-
const cleaned = stripGitSuffix(pathValue);
|
|
1793
|
-
return cleaned.length > 0 ? cleaned : void 0;
|
|
1794
|
-
}
|
|
1795
|
-
function stripGitSuffix(pathname) {
|
|
1796
|
-
return pathname.endsWith(".git") ? pathname.slice(0, -4) : pathname;
|
|
1797
|
-
}
|
|
1798
|
-
function buildApiUrl(instanceUrl, apiPath) {
|
|
1799
|
-
const base = instanceUrl.endsWith("/") ? instanceUrl : `${instanceUrl}/`;
|
|
1800
|
-
return new URL(apiPath.replace(/^\//, ""), base).toString();
|
|
1801
|
-
}
|
|
1802
|
-
function buildAuthHeaders(apiKey) {
|
|
1803
|
-
return { authorization: `Bearer ${apiKey}` };
|
|
1804
|
-
}
|
|
1805
|
-
async function fetchProjectDetails(instanceUrl, apiKey, projectPath) {
|
|
1806
|
-
const url = buildApiUrl(instanceUrl, `api/v4/projects/${encodeURIComponent(projectPath)}`);
|
|
1807
|
-
const response = await fetch(url, {
|
|
1808
|
-
headers: buildAuthHeaders(apiKey)
|
|
1809
|
-
});
|
|
1810
|
-
if (!response.ok) {
|
|
1811
|
-
throw new Error(`Failed to fetch project details: ${response.status}`);
|
|
1812
|
-
}
|
|
1813
|
-
const data = await response.json();
|
|
1814
|
-
return {
|
|
1815
|
-
projectId: data.id ? String(data.id) : void 0,
|
|
1816
|
-
namespaceId: data.namespace?.id ? String(data.namespace.id) : void 0
|
|
1817
|
-
};
|
|
1818
|
-
}
|
|
1819
|
-
async function fetchProjectDetailsWithFallback(instanceUrl, apiKey, projectPath) {
|
|
1820
|
-
const candidates = getProjectPathCandidates(projectPath);
|
|
1821
|
-
for (const candidate of candidates) {
|
|
1822
|
-
try {
|
|
1823
|
-
return await fetchProjectDetails(instanceUrl, apiKey, candidate);
|
|
1824
|
-
} catch {
|
|
1825
|
-
continue;
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
try {
|
|
1829
|
-
const name = projectPath.split("/").pop() || projectPath;
|
|
1830
|
-
const searchUrl = new URL(buildApiUrl(instanceUrl, "api/v4/projects"));
|
|
1831
|
-
searchUrl.searchParams.set("search", name);
|
|
1832
|
-
searchUrl.searchParams.set("simple", "true");
|
|
1833
|
-
searchUrl.searchParams.set("per_page", "100");
|
|
1834
|
-
searchUrl.searchParams.set("membership", "true");
|
|
1835
|
-
const response = await fetch(searchUrl.toString(), {
|
|
1836
|
-
headers: buildAuthHeaders(apiKey)
|
|
1837
|
-
});
|
|
1838
|
-
if (!response.ok) {
|
|
1839
|
-
throw new Error(`Failed to search projects: ${response.status}`);
|
|
1840
|
-
}
|
|
1841
|
-
const data = await response.json();
|
|
1842
|
-
const match = data.find((project) => project.path_with_namespace === projectPath);
|
|
1843
|
-
if (!match) {
|
|
1844
|
-
throw new Error("Project not found via search");
|
|
1845
|
-
}
|
|
1846
|
-
return {
|
|
1847
|
-
projectId: match.id ? String(match.id) : void 0,
|
|
1848
|
-
namespaceId: match.namespace?.id ? String(match.namespace.id) : void 0
|
|
1849
|
-
};
|
|
1850
|
-
} catch {
|
|
1851
|
-
throw new Error("Project not found via API");
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
function getProjectPathCandidates(projectPath) {
|
|
1855
|
-
const candidates = /* @__PURE__ */ new Set();
|
|
1856
|
-
candidates.add(projectPath);
|
|
1857
|
-
const parts = projectPath.split("/");
|
|
1858
|
-
if (parts.length > 2) {
|
|
1859
|
-
const withoutFirst = parts.slice(1).join("/");
|
|
1860
|
-
candidates.add(withoutFirst);
|
|
1861
|
-
}
|
|
1862
|
-
return Array.from(candidates);
|
|
1863
|
-
}
|
|
1864
|
-
async function readGitConfig(cwd) {
|
|
1865
|
-
const gitPath = path4.join(cwd, ".git");
|
|
1866
|
-
const stat = await fs4.stat(gitPath);
|
|
1867
|
-
if (stat.isDirectory()) {
|
|
1868
|
-
return fs4.readFile(path4.join(gitPath, "config"), "utf8");
|
|
1869
|
-
}
|
|
1870
|
-
const file = await fs4.readFile(gitPath, "utf8");
|
|
1871
|
-
const match = /^gitdir:\s*(.+)$/m.exec(file);
|
|
1872
|
-
if (!match) throw new Error("Invalid .git file");
|
|
1873
|
-
const gitdir = match[1].trim();
|
|
1874
|
-
const resolved = path4.isAbsolute(gitdir) ? gitdir : path4.join(cwd, gitdir);
|
|
1875
|
-
return fs4.readFile(path4.join(resolved, "config"), "utf8");
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
2140
|
// src/provider/adapters/workflow_service.ts
|
|
1879
2141
|
var WorkflowCreateError = class extends Error {
|
|
1880
2142
|
status;
|
|
@@ -2097,10 +2359,10 @@ function buildUserAgent() {
|
|
|
2097
2359
|
}
|
|
2098
2360
|
|
|
2099
2361
|
// src/provider/adapters/system_context.ts
|
|
2100
|
-
import
|
|
2362
|
+
import os2 from "os";
|
|
2101
2363
|
function getSystemContextItems(systemRules) {
|
|
2102
|
-
const platform =
|
|
2103
|
-
const arch =
|
|
2364
|
+
const platform = os2.platform();
|
|
2365
|
+
const arch = os2.arch();
|
|
2104
2366
|
const items = [
|
|
2105
2367
|
{
|
|
2106
2368
|
category: "os_information",
|
|
@@ -2334,24 +2596,54 @@ function assertDependencies() {
|
|
|
2334
2596
|
);
|
|
2335
2597
|
}
|
|
2336
2598
|
}
|
|
2599
|
+
function resolveInstanceUrl2(value) {
|
|
2600
|
+
if (typeof value === "string" && value.trim()) {
|
|
2601
|
+
const normalized = value.trim();
|
|
2602
|
+
assertInstanceUrl(normalized);
|
|
2603
|
+
return normalized;
|
|
2604
|
+
}
|
|
2605
|
+
const fromEnv = process.env.GITLAB_INSTANCE_URL?.trim();
|
|
2606
|
+
if (fromEnv) {
|
|
2607
|
+
assertInstanceUrl(fromEnv);
|
|
2608
|
+
return fromEnv;
|
|
2609
|
+
}
|
|
2610
|
+
return "https://gitlab.com";
|
|
2611
|
+
}
|
|
2337
2612
|
function assertInstanceUrl(value) {
|
|
2338
2613
|
try {
|
|
2339
2614
|
new URL(value);
|
|
2340
2615
|
} catch {
|
|
2341
|
-
throw new Error(
|
|
2616
|
+
throw new Error(
|
|
2617
|
+
`Invalid instanceUrl: "${value}". Expected an absolute URL (for example https://gitlab.com).`
|
|
2618
|
+
);
|
|
2342
2619
|
}
|
|
2343
2620
|
}
|
|
2344
|
-
function
|
|
2621
|
+
function resolveApiKey2(value) {
|
|
2622
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
2623
|
+
return process.env.GITLAB_TOKEN || "";
|
|
2624
|
+
}
|
|
2625
|
+
function createGitLabDuoAgentic(options = {}) {
|
|
2345
2626
|
assertDependencies();
|
|
2346
|
-
|
|
2627
|
+
const resolvedOptions = {
|
|
2628
|
+
instanceUrl: resolveInstanceUrl2(options.instanceUrl),
|
|
2629
|
+
apiKey: resolveApiKey2(options.apiKey),
|
|
2630
|
+
sendSystemContext: typeof options.sendSystemContext === "boolean" ? options.sendSystemContext : true,
|
|
2631
|
+
enableMcp: typeof options.enableMcp === "boolean" ? options.enableMcp : true,
|
|
2632
|
+
systemRules: typeof options.systemRules === "string" ? options.systemRules : void 0
|
|
2633
|
+
};
|
|
2634
|
+
if (!resolvedOptions.apiKey) {
|
|
2635
|
+
console.warn(
|
|
2636
|
+
"[gitlab-duo] GITLAB_TOKEN is empty for the OpenCode process. Ensure it is exported in the same shell."
|
|
2637
|
+
);
|
|
2638
|
+
}
|
|
2347
2639
|
const container = createRuntimeContainer();
|
|
2348
2640
|
const dependencies = container.resolve(
|
|
2349
2641
|
RUNTIME_TOKENS.runtimeDependencies
|
|
2350
2642
|
);
|
|
2351
|
-
const sharedRuntime = new GitLabAgenticRuntime(
|
|
2643
|
+
const sharedRuntime = new GitLabAgenticRuntime(resolvedOptions, dependencies);
|
|
2352
2644
|
return {
|
|
2353
2645
|
languageModel(modelId) {
|
|
2354
|
-
return new GitLabDuoAgenticLanguageModel(modelId,
|
|
2646
|
+
return new GitLabDuoAgenticLanguageModel(modelId, resolvedOptions, sharedRuntime);
|
|
2355
2647
|
},
|
|
2356
2648
|
textEmbeddingModel() {
|
|
2357
2649
|
throw new Error("GitLab Duo Agentic does not support text embedding models");
|