opencode-gitlab-duo-agentic 0.1.13 → 0.1.15
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/dist/index.js +253 -27
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,9 +1,243 @@
|
|
|
1
1
|
// src/constants.ts
|
|
2
2
|
var PROVIDER_ID = "gitlab";
|
|
3
|
-
var
|
|
3
|
+
var DEFAULT_MODEL_ID = "duo-chat-sonnet-4-5";
|
|
4
4
|
var DEFAULT_INSTANCE_URL = "https://gitlab.com";
|
|
5
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
5
6
|
var NOT_IMPLEMENTED_MESSAGE = "GitLab Duo Agentic fallback model is configured, but the Duo Workflow runtime is not implemented yet.";
|
|
6
7
|
|
|
8
|
+
// src/gitlab/models.ts
|
|
9
|
+
import crypto from "crypto";
|
|
10
|
+
import fs2 from "fs/promises";
|
|
11
|
+
import os from "os";
|
|
12
|
+
import path2 from "path";
|
|
13
|
+
|
|
14
|
+
// src/gitlab/client.ts
|
|
15
|
+
var GitLabApiError = class extends Error {
|
|
16
|
+
constructor(status, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.name = "GitLabApiError";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
async function get(options, path3) {
|
|
23
|
+
const url = `${options.instanceUrl}/api/v4/${path3}`;
|
|
24
|
+
const response = await fetch(url, {
|
|
25
|
+
headers: { authorization: `Bearer ${options.token}` }
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
const text2 = await response.text().catch(() => "");
|
|
29
|
+
throw new GitLabApiError(response.status, `GET ${path3} failed (${response.status}): ${text2}`);
|
|
30
|
+
}
|
|
31
|
+
return response.json();
|
|
32
|
+
}
|
|
33
|
+
async function graphql(options, query, variables) {
|
|
34
|
+
const url = `${options.instanceUrl}/api/graphql`;
|
|
35
|
+
const response = await fetch(url, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: {
|
|
38
|
+
"content-type": "application/json",
|
|
39
|
+
authorization: `Bearer ${options.token}`
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify({ query, variables })
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const text2 = await response.text().catch(() => "");
|
|
45
|
+
throw new GitLabApiError(response.status, `GraphQL request failed (${response.status}): ${text2}`);
|
|
46
|
+
}
|
|
47
|
+
const result = await response.json();
|
|
48
|
+
if (result.errors?.length) {
|
|
49
|
+
throw new GitLabApiError(0, result.errors.map((e) => e.message).join("; "));
|
|
50
|
+
}
|
|
51
|
+
if (!result.data) {
|
|
52
|
+
throw new GitLabApiError(0, "GraphQL response missing data");
|
|
53
|
+
}
|
|
54
|
+
return result.data;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/gitlab/project.ts
|
|
58
|
+
import fs from "fs/promises";
|
|
59
|
+
import path from "path";
|
|
60
|
+
async function detectProjectPath(cwd, instanceUrl) {
|
|
61
|
+
const instance = new URL(instanceUrl);
|
|
62
|
+
const instanceHost = instance.host;
|
|
63
|
+
const instanceBasePath = instance.pathname.replace(/\/$/, "");
|
|
64
|
+
let current = cwd;
|
|
65
|
+
for (; ; ) {
|
|
66
|
+
try {
|
|
67
|
+
const config = await readGitConfig(current);
|
|
68
|
+
const url = extractOriginUrl(config);
|
|
69
|
+
if (!url) return void 0;
|
|
70
|
+
const remote = parseRemoteUrl(url);
|
|
71
|
+
if (!remote || remote.host !== instanceHost) return void 0;
|
|
72
|
+
return normalizeProjectPath(remote.path, instanceBasePath);
|
|
73
|
+
} catch {
|
|
74
|
+
const parent = path.dirname(current);
|
|
75
|
+
if (parent === current) return void 0;
|
|
76
|
+
current = parent;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function fetchProjectDetails(client, projectPath) {
|
|
81
|
+
const encoded = encodeURIComponent(projectPath);
|
|
82
|
+
const data = await get(client, `projects/${encoded}`);
|
|
83
|
+
if (!data.id || !data.namespace?.id) {
|
|
84
|
+
throw new Error(`Project ${projectPath}: missing id or namespace`);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
projectId: String(data.id),
|
|
88
|
+
namespaceId: String(data.namespace.id)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async function resolveRootNamespaceId(client, namespaceId) {
|
|
92
|
+
let currentId = namespaceId;
|
|
93
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
94
|
+
let ns;
|
|
95
|
+
try {
|
|
96
|
+
ns = await get(client, `namespaces/${currentId}`);
|
|
97
|
+
} catch {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
if (!ns.parent_id) {
|
|
101
|
+
currentId = String(ns.id ?? currentId);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
currentId = String(ns.parent_id);
|
|
105
|
+
}
|
|
106
|
+
return `gid://gitlab/Group/${currentId}`;
|
|
107
|
+
}
|
|
108
|
+
async function readGitConfig(cwd) {
|
|
109
|
+
const gitPath = path.join(cwd, ".git");
|
|
110
|
+
const stat = await fs.stat(gitPath);
|
|
111
|
+
if (stat.isDirectory()) {
|
|
112
|
+
return fs.readFile(path.join(gitPath, "config"), "utf8");
|
|
113
|
+
}
|
|
114
|
+
const content = await fs.readFile(gitPath, "utf8");
|
|
115
|
+
const match = /^gitdir:\s*(.+)$/m.exec(content);
|
|
116
|
+
if (!match) throw new Error("Invalid .git file");
|
|
117
|
+
const gitdir = match[1].trim();
|
|
118
|
+
const resolved = path.isAbsolute(gitdir) ? gitdir : path.join(cwd, gitdir);
|
|
119
|
+
return fs.readFile(path.join(resolved, "config"), "utf8");
|
|
120
|
+
}
|
|
121
|
+
function extractOriginUrl(config) {
|
|
122
|
+
const lines = config.split("\n");
|
|
123
|
+
let inOrigin = false;
|
|
124
|
+
let originUrl;
|
|
125
|
+
let firstUrl;
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
const section = /^\[remote\s+"([^"]+)"\]$/.exec(trimmed);
|
|
129
|
+
if (section) {
|
|
130
|
+
inOrigin = section[1] === "origin";
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const urlMatch = /^url\s*=\s*(.+)$/.exec(trimmed);
|
|
134
|
+
if (urlMatch) {
|
|
135
|
+
const value = urlMatch[1].trim();
|
|
136
|
+
if (!firstUrl) firstUrl = value;
|
|
137
|
+
if (inOrigin) originUrl = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return originUrl ?? firstUrl;
|
|
141
|
+
}
|
|
142
|
+
function parseRemoteUrl(url) {
|
|
143
|
+
if (url.startsWith("git@")) {
|
|
144
|
+
const match = /^git@([^:]+):(.+)$/.exec(url);
|
|
145
|
+
return match ? { host: match[1], path: match[2] } : void 0;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const parsed = new URL(url);
|
|
149
|
+
return { host: parsed.host, path: parsed.pathname.replace(/^\//, "") };
|
|
150
|
+
} catch {
|
|
151
|
+
return void 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function normalizeProjectPath(remotePath, instanceBasePath) {
|
|
155
|
+
let p = remotePath;
|
|
156
|
+
if (instanceBasePath && instanceBasePath !== "/") {
|
|
157
|
+
const base = instanceBasePath.replace(/^\//, "") + "/";
|
|
158
|
+
if (p.startsWith(base)) p = p.slice(base.length);
|
|
159
|
+
}
|
|
160
|
+
if (p.endsWith(".git")) p = p.slice(0, -4);
|
|
161
|
+
return p.length > 0 ? p : void 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/gitlab/models.ts
|
|
165
|
+
var QUERY = `query lsp_aiChatAvailableModels($rootNamespaceId: GroupID!) {
|
|
166
|
+
aiChatAvailableModels(rootNamespaceId: $rootNamespaceId) {
|
|
167
|
+
defaultModel { name ref }
|
|
168
|
+
selectableModels { name ref }
|
|
169
|
+
pinnedModel { name ref }
|
|
170
|
+
}
|
|
171
|
+
}`;
|
|
172
|
+
async function loadAvailableModels(instanceUrl, token, cwd) {
|
|
173
|
+
const cachePath = getCachePath(instanceUrl, cwd);
|
|
174
|
+
const cached = await readCache(cachePath);
|
|
175
|
+
if (cached && !isStale(cached)) {
|
|
176
|
+
return cached.models;
|
|
177
|
+
}
|
|
178
|
+
if (token) {
|
|
179
|
+
try {
|
|
180
|
+
const models = await fetchModelsFromApi({ instanceUrl, token }, cwd);
|
|
181
|
+
if (models.length > 0) {
|
|
182
|
+
await writeCache(cachePath, { cachedAt: (/* @__PURE__ */ new Date()).toISOString(), instanceUrl, models });
|
|
183
|
+
return models;
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (cached) {
|
|
189
|
+
return cached.models;
|
|
190
|
+
}
|
|
191
|
+
return [{ id: DEFAULT_MODEL_ID, name: DEFAULT_MODEL_ID }];
|
|
192
|
+
}
|
|
193
|
+
async function fetchModelsFromApi(client, cwd) {
|
|
194
|
+
const projectPath = await detectProjectPath(cwd, client.instanceUrl);
|
|
195
|
+
if (!projectPath) return [];
|
|
196
|
+
const project = await fetchProjectDetails(client, projectPath);
|
|
197
|
+
const rootNamespaceId = await resolveRootNamespaceId(client, project.namespaceId);
|
|
198
|
+
const data = await graphql(client, QUERY, { rootNamespaceId });
|
|
199
|
+
const available = data.aiChatAvailableModels;
|
|
200
|
+
if (!available) return [];
|
|
201
|
+
const seen = /* @__PURE__ */ new Set();
|
|
202
|
+
const models = [];
|
|
203
|
+
function add(entry2) {
|
|
204
|
+
if (!entry2?.ref || seen.has(entry2.ref)) return;
|
|
205
|
+
seen.add(entry2.ref);
|
|
206
|
+
models.push({ id: entry2.ref, name: entry2.name || entry2.ref });
|
|
207
|
+
}
|
|
208
|
+
add(available.defaultModel);
|
|
209
|
+
add(available.pinnedModel);
|
|
210
|
+
for (const m of available.selectableModels ?? []) add(m);
|
|
211
|
+
return models;
|
|
212
|
+
}
|
|
213
|
+
function getCachePath(instanceUrl, cwd) {
|
|
214
|
+
const key = `${instanceUrl}::${cwd}`;
|
|
215
|
+
const hash = crypto.createHash("sha256").update(key).digest("hex").slice(0, 12);
|
|
216
|
+
const dir = process.env.XDG_CACHE_HOME?.trim() ? path2.join(process.env.XDG_CACHE_HOME, "opencode") : path2.join(os.homedir(), ".cache", "opencode");
|
|
217
|
+
return path2.join(dir, `gitlab-duo-models-${hash}.json`);
|
|
218
|
+
}
|
|
219
|
+
function isStale(payload) {
|
|
220
|
+
const age = Date.now() - Date.parse(payload.cachedAt);
|
|
221
|
+
return age > CACHE_TTL_MS;
|
|
222
|
+
}
|
|
223
|
+
async function readCache(cachePath) {
|
|
224
|
+
try {
|
|
225
|
+
const raw = await fs2.readFile(cachePath, "utf8");
|
|
226
|
+
const parsed = JSON.parse(raw);
|
|
227
|
+
if (!parsed.models?.length) return null;
|
|
228
|
+
return parsed;
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function writeCache(cachePath, payload) {
|
|
234
|
+
try {
|
|
235
|
+
await fs2.mkdir(path2.dirname(cachePath), { recursive: true });
|
|
236
|
+
await fs2.writeFile(cachePath, JSON.stringify(payload, null, 2), "utf8");
|
|
237
|
+
} catch {
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
7
241
|
// src/utils/url.ts
|
|
8
242
|
function text(value) {
|
|
9
243
|
if (typeof value !== "string") return void 0;
|
|
@@ -25,45 +259,40 @@ function normalizeInstanceUrl(value) {
|
|
|
25
259
|
}
|
|
26
260
|
|
|
27
261
|
// src/plugin/config.ts
|
|
28
|
-
function applyRuntimeConfig(config,
|
|
262
|
+
async function applyRuntimeConfig(config, directory) {
|
|
29
263
|
config.provider ??= {};
|
|
30
264
|
const current = config.provider[PROVIDER_ID] ?? {};
|
|
31
265
|
const options = current.options ?? {};
|
|
32
|
-
const models = current.models ?? {};
|
|
33
|
-
const fallbackModel2 = {
|
|
34
|
-
[MODEL_ID]: {
|
|
35
|
-
id: MODEL_ID,
|
|
36
|
-
name: "GitLab Duo Agentic (fallback)"
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
266
|
const instanceUrl = normalizeInstanceUrl(options.instanceUrl ?? envInstanceUrl());
|
|
267
|
+
const token = (typeof options.apiKey === "string" ? options.apiKey : void 0) ?? process.env.GITLAB_TOKEN ?? "";
|
|
268
|
+
const available = await loadAvailableModels(instanceUrl, token, directory);
|
|
269
|
+
const modelIds = available.map((m) => m.id);
|
|
270
|
+
const models = toModelsConfig(available);
|
|
40
271
|
config.provider[PROVIDER_ID] = {
|
|
41
272
|
...current,
|
|
42
|
-
|
|
43
|
-
npm: current.npm ?? moduleUrl,
|
|
44
|
-
env: current.env ?? ["GITLAB_TOKEN", "GITLAB_INSTANCE_URL"],
|
|
45
|
-
whitelist: [MODEL_ID],
|
|
273
|
+
whitelist: modelIds,
|
|
46
274
|
options: {
|
|
47
275
|
...options,
|
|
48
276
|
instanceUrl
|
|
49
277
|
},
|
|
50
278
|
models: {
|
|
51
|
-
...
|
|
279
|
+
...current.models ?? {},
|
|
52
280
|
...models
|
|
53
281
|
}
|
|
54
282
|
};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
283
|
+
}
|
|
284
|
+
function toModelsConfig(available) {
|
|
285
|
+
const out = {};
|
|
286
|
+
for (const m of available) {
|
|
287
|
+
out[m.id] = { id: m.id, name: m.name };
|
|
60
288
|
}
|
|
289
|
+
return out;
|
|
61
290
|
}
|
|
62
291
|
|
|
63
292
|
// src/plugin/hooks.ts
|
|
64
|
-
async function createPluginHooks(
|
|
293
|
+
async function createPluginHooks(input) {
|
|
65
294
|
return {
|
|
66
|
-
config: async (config) => applyRuntimeConfig(config,
|
|
295
|
+
config: async (config) => applyRuntimeConfig(config, input.directory)
|
|
67
296
|
};
|
|
68
297
|
}
|
|
69
298
|
|
|
@@ -75,7 +304,7 @@ function notImplemented() {
|
|
|
75
304
|
message: NOT_IMPLEMENTED_MESSAGE
|
|
76
305
|
});
|
|
77
306
|
}
|
|
78
|
-
function
|
|
307
|
+
function placeholderModel(modelId) {
|
|
79
308
|
return {
|
|
80
309
|
specificationVersion: "v2",
|
|
81
310
|
provider: PROVIDER_ID,
|
|
@@ -92,10 +321,7 @@ function fallbackModel(modelId) {
|
|
|
92
321
|
function createFallbackProvider() {
|
|
93
322
|
return {
|
|
94
323
|
languageModel(modelId) {
|
|
95
|
-
|
|
96
|
-
throw new NoSuchModelError({ modelId, modelType: "languageModel" });
|
|
97
|
-
}
|
|
98
|
-
return fallbackModel(modelId);
|
|
324
|
+
return placeholderModel(modelId);
|
|
99
325
|
},
|
|
100
326
|
textEmbeddingModel(modelId) {
|
|
101
327
|
throw new NoSuchModelError({ modelId, modelType: "textEmbeddingModel" });
|
|
@@ -114,7 +340,7 @@ function isPluginInput(value) {
|
|
|
114
340
|
}
|
|
115
341
|
var entry = (input) => {
|
|
116
342
|
if (isPluginInput(input)) {
|
|
117
|
-
return createPluginHooks(input
|
|
343
|
+
return createPluginHooks(input);
|
|
118
344
|
}
|
|
119
345
|
return createFallbackProvider();
|
|
120
346
|
};
|