@telefonica/ai-sdlc-opencode-plugin-v1 1.0.0 → 1.0.2-snapshot.37008990101

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 CHANGED
@@ -17,4 +17,7 @@ opencode plugin -g @telefonica/ai-sdlc-opencode-plugin-v1-dev
17
17
  Restart OpenCode, run `/connect`, choose **CDO.AI**, and enter the gateway URL
18
18
  and API key. Restart OpenCode once more after connecting so the model list loads.
19
19
 
20
+ The sidebar displays usage, average spend per elapsed workday, and workdays remaining
21
+ this month on a separate line.
22
+
20
23
  Using OpenCode 2.x? Install `@telefonica/ai-sdlc-opencode-plugin-v2` instead.
@@ -0,0 +1,405 @@
1
+ //#region ../common/dist/model-name.js
2
+ const SPECIAL_LABELS = {
3
+ cdo: "CDO",
4
+ deepseek: "DeepSeek",
5
+ "d-seek": "DeepSeek",
6
+ gemi: "Gemini",
7
+ gpt: "GPT",
8
+ km: "Kimi"
9
+ };
10
+ function words(value) {
11
+ return value.trim().split(/[\s-]+/).filter(Boolean);
12
+ }
13
+ function joinAdjacentNumbers(parts) {
14
+ const result = [];
15
+ for (let index = 0; index < parts.length; index += 1) if (/^v\d+(?:\.\d+)*$/i.test(parts[index])) {
16
+ let version = `v${parts[index].slice(1)}`;
17
+ while (index + 1 < parts.length && /^\d+$/.test(parts[index + 1])) {
18
+ version += `.${parts[index + 1]}`;
19
+ index += 1;
20
+ }
21
+ result.push(version);
22
+ } else if (/^\d+(?:\.\d+)*$/.test(parts[index])) {
23
+ let number = parts[index];
24
+ while (index + 1 < parts.length && /^\d+$/.test(parts[index + 1])) {
25
+ number += `.${parts[index + 1]}`;
26
+ index += 1;
27
+ }
28
+ result.push(number);
29
+ } else result.push(parts[index]);
30
+ return result;
31
+ }
32
+ function titleCase(word) {
33
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
34
+ }
35
+ /** Converts a provider model ID into a readable label. */
36
+ function unsanitizeModelId(modelId) {
37
+ const parts = words(modelId.replace(/^claude-/i, ""));
38
+ const normalized = [];
39
+ for (let index = 0; index < parts.length; index += 1) {
40
+ const current = parts[index].toLowerCase();
41
+ const next = parts[index + 1]?.toLowerCase();
42
+ if (current === "g" && next === "p" && parts[index + 2]?.toLowerCase() === "t") {
43
+ normalized.push("GPT");
44
+ index += 2;
45
+ } else if (current === "gp" && next === "t") {
46
+ normalized.push("GPT");
47
+ index += 1;
48
+ } else if (current === "gemi" && next === "n") {
49
+ normalized.push("Gemini");
50
+ index += 1;
51
+ } else if (current === "d" && next === "seek") {
52
+ normalized.push("DeepSeek");
53
+ index += 1;
54
+ } else normalized.push(Object.hasOwn(SPECIAL_LABELS, current) ? SPECIAL_LABELS[current] : current);
55
+ }
56
+ return joinAdjacentNumbers(normalized.at(-1)?.toLowerCase() === "az" ? normalized.slice(0, -1) : normalized).map((part) => {
57
+ if (/^(CDO|GPT|Gemini|Kimi|DeepSeek)$/.test(part) || /^v\d/i.test(part) || /^\d/.test(part)) return part;
58
+ return titleCase(part);
59
+ }).join(" ");
60
+ }
61
+ //#endregion
62
+ //#region ../common/dist/copy.js
63
+ const PLUGIN_UI = {
64
+ barWidth: 20,
65
+ warningPercent: 80,
66
+ exceededPercent: 100
67
+ };
68
+ const formatMoney = (amount) => `$${amount.toFixed(2)}`;
69
+ const PLUGIN_COPY = {
70
+ apiKeyLabel: (displayName) => `${displayName} API key`,
71
+ proxyURLLabel: (displayName) => `${displayName} Proxy URL`,
72
+ proxyURLPlaceholder: "https://litellm.example.com",
73
+ proxyURLRequired: "Proxy URL is required",
74
+ connectCommand: (integrationID) => `/connect ${integrationID}`,
75
+ usageUnavailable: (message) => `Usage unavailable: ${message}`,
76
+ notConnected: (command) => `not connected — run ${command}`,
77
+ formatMoney,
78
+ usageValues: (spend, budget) => `${formatMoney(spend)} / ${budget == null ? "∞" : formatMoney(budget)}`,
79
+ formatPercent: (percent) => `${percent.toFixed(1)}%`,
80
+ budgetSourceLabels: {
81
+ key: "Personal API key budget",
82
+ user: "User budget",
83
+ "team-member": "Team member budget",
84
+ "team-member-default": "Team default budget",
85
+ none: "No budget set"
86
+ },
87
+ progressBar: (percent, width = PLUGIN_UI.barWidth) => {
88
+ const filled = Math.round(Math.max(0, Math.min(100, percent)) / 100 * width);
89
+ return "█".repeat(filled) + "░".repeat(width - filled);
90
+ },
91
+ perWorkday: (amount) => `${amount} per work day`,
92
+ workdaysRemaining: (count) => `${count} work days remaining`
93
+ };
94
+ //#endregion
95
+ //#region ../common/dist/flavors.js
96
+ function defineFlavor(environment, providerID, displayName) {
97
+ const serverPluginID = `ai-sdlc-opencode-${providerID}-plugin`;
98
+ return {
99
+ environment,
100
+ providerID,
101
+ integrationID: providerID,
102
+ rpcID: providerID,
103
+ serverPluginID,
104
+ tuiPluginID: `${serverPluginID}.tui`,
105
+ displayName,
106
+ connectLabel: PLUGIN_COPY.apiKeyLabel(displayName),
107
+ keyEnvironmentPrefix: environment === "dev" ? "CDO_AI_DEV" : "CDO_AI",
108
+ debugFile: `/tmp/${providerID}-debug.json`,
109
+ budgetStorageKey: `${providerID}.budget`
110
+ };
111
+ }
112
+ const PLUGIN_FLAVORS = {
113
+ prod: defineFlavor("prod", "cdo-ai", "CDO.AI"),
114
+ dev: defineFlavor("dev", "cdo-ai-dev", "CDO.AI (dev)")
115
+ };
116
+ //#endregion
117
+ //#region ../common/dist/reasoning-efforts.js
118
+ const EFFORT_ORDER = [
119
+ "none",
120
+ "minimal",
121
+ "low",
122
+ "medium",
123
+ "high",
124
+ "xhigh",
125
+ "max"
126
+ ];
127
+ const BASELINE_EFFORTS = [
128
+ "low",
129
+ "medium",
130
+ "high"
131
+ ];
132
+ const RESTRICTED_EFFORTS = /* @__PURE__ */ new Map([
133
+ ["gpt-5-pro", ["high"]],
134
+ ["gpt-5.2-pro", [
135
+ "medium",
136
+ "high",
137
+ "xhigh"
138
+ ]],
139
+ ["gpt-5.4-pro", [
140
+ "medium",
141
+ "high",
142
+ "xhigh"
143
+ ]]
144
+ ]);
145
+ function restrictedEfforts(...identifiers) {
146
+ for (const identifier of identifiers) {
147
+ if (typeof identifier !== "string") continue;
148
+ const model = identifier.split("/").pop()?.replace(/-\d{4}-\d{2}-\d{2}$/, "");
149
+ const efforts = model ? RESTRICTED_EFFORTS.get(model) : void 0;
150
+ if (efforts) return efforts;
151
+ }
152
+ }
153
+ function isRecord(value) {
154
+ return value !== null && typeof value === "object" && !Array.isArray(value);
155
+ }
156
+ function resolveReasoningEfforts(info, params) {
157
+ const metadata = { ...params ?? {} };
158
+ for (const [key, value] of Object.entries(info)) if (value != null) metadata[key] = value;
159
+ if (metadata.supports_reasoning === false) return [];
160
+ for (const key of ["reasoning_effort_levels", "supports_reasoning_efforts"]) {
161
+ const levels = metadata[key];
162
+ if (Array.isArray(levels)) {
163
+ const explicit = [...new Set(levels.filter((level) => typeof level === "string"))];
164
+ const restricted = restrictedEfforts(metadata.key, metadata.model, metadata.base_model);
165
+ return restricted ? explicit.filter((effort) => restricted.includes(effort)) : explicit;
166
+ }
167
+ }
168
+ const supportedParams = metadata.supported_openai_params;
169
+ if (Array.isArray(supportedParams) && !supportedParams.includes("reasoning_effort") && !supportedParams.includes("reasoning")) return [];
170
+ const flags = /* @__PURE__ */ new Map();
171
+ for (const [key, value] of Object.entries(metadata)) {
172
+ const match = key.match(/^supports_([a-z]+)_reasoning_effort$/);
173
+ if (match && typeof value === "boolean") flags.set(match[1], value);
174
+ }
175
+ if (flags.size === 0 || metadata.supports_reasoning !== true && ![...flags.values()].includes(true)) return;
176
+ const efforts = /* @__PURE__ */ new Set();
177
+ for (const effort of BASELINE_EFFORTS) if (flags.get(effort) !== false) efforts.add(effort);
178
+ for (const [effort, supported] of flags) if (supported) efforts.add(effort);
179
+ const restricted = restrictedEfforts(metadata.key, metadata.model, metadata.base_model);
180
+ if (restricted) {
181
+ for (const effort of [...efforts]) if (!restricted.includes(effort)) efforts.delete(effort);
182
+ }
183
+ return [...EFFORT_ORDER.filter((effort) => efforts.has(effort)), ...[...efforts].filter((effort) => !EFFORT_ORDER.includes(effort))];
184
+ }
185
+ //#endregion
186
+ //#region ../common/dist/models.js
187
+ function finiteNumber(value) {
188
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
189
+ }
190
+ function booleanValue(value) {
191
+ return typeof value === "boolean" ? value : void 0;
192
+ }
193
+ function normalizeModel(entry, info, params) {
194
+ const value = {
195
+ ...entry,
196
+ ...info ?? {}
197
+ };
198
+ const reasoning = resolveReasoningEfforts(info ?? {}, params);
199
+ const mode = typeof value.mode === "string" ? value.mode : void 0;
200
+ return Object.fromEntries(Object.entries({
201
+ id: entry.id,
202
+ name: unsanitizeModelId(entry.id),
203
+ mode,
204
+ contextTokens: finiteNumber(value.max_input_tokens) ?? finiteNumber(value.max_tokens),
205
+ inputTokens: finiteNumber(value.max_input_tokens),
206
+ outputTokens: finiteNumber(value.max_output_tokens),
207
+ inputCostPerToken: finiteNumber(value.input_cost_per_token),
208
+ outputCostPerToken: finiteNumber(value.output_cost_per_token),
209
+ cacheReadCostPerToken: finiteNumber(value.cache_read_input_token_cost),
210
+ cacheWriteCostPerToken: finiteNumber(value.cache_creation_input_token_cost),
211
+ supportsTools: booleanValue(value.supports_function_calling),
212
+ supportsReasoning: booleanValue(info?.supports_reasoning) ?? booleanValue(params?.supports_reasoning),
213
+ supportsVision: booleanValue(value.supports_vision),
214
+ supportsPdf: booleanValue(value.supports_pdf_input),
215
+ supportsAudio: booleanValue(value.supports_audio_input) ?? booleanValue(params?.supports_audio_input),
216
+ reasoningEfforts: reasoning
217
+ }).filter(([, value]) => value !== void 0));
218
+ }
219
+ function invalid(message) {
220
+ return {
221
+ ok: false,
222
+ error: {
223
+ code: "invalid-response",
224
+ message
225
+ }
226
+ };
227
+ }
228
+ function httpError(status) {
229
+ return {
230
+ ok: false,
231
+ error: {
232
+ code: "http",
233
+ status,
234
+ message: `HTTP ${status}`
235
+ }
236
+ };
237
+ }
238
+ function safeEndpoint(endpoint) {
239
+ try {
240
+ const url = new URL(endpoint);
241
+ url.username = "";
242
+ url.password = "";
243
+ url.search = "";
244
+ url.hash = "";
245
+ return url.toString();
246
+ } catch {
247
+ return endpoint.split(/[?#]/, 1)[0] ?? endpoint;
248
+ }
249
+ }
250
+ function emitGatewayLog(options, event) {
251
+ const level = event.ok ? "info" : "error";
252
+ const message = `${event.operation} ${event.phase} ${event.ok ? "succeeded" : "failed"}`;
253
+ try {
254
+ const result = options.logger?.({
255
+ ...event,
256
+ level,
257
+ message,
258
+ endpoint: safeEndpoint(event.endpoint)
259
+ });
260
+ if (result && typeof result.then === "function") result.catch(() => {});
261
+ } catch {}
262
+ }
263
+ async function fetchJSON(url, options, operation = "models.discover") {
264
+ const controller = new AbortController();
265
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8e3);
266
+ const startedAt = Date.now();
267
+ let status;
268
+ let result;
269
+ try {
270
+ const response = await (options.fetchImpl ?? fetch)(url, {
271
+ headers: options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {},
272
+ signal: controller.signal
273
+ });
274
+ status = response.status;
275
+ if (!response.ok) result = httpError(response.status);
276
+ else try {
277
+ result = {
278
+ ok: true,
279
+ value: await response.json()
280
+ };
281
+ } catch {
282
+ result = invalid(`Invalid JSON from ${url}`);
283
+ }
284
+ } catch (error) {
285
+ result = {
286
+ ok: false,
287
+ error: {
288
+ code: controller.signal.aborted ? "timeout" : "network",
289
+ message: error instanceof Error ? error.message : "Network error"
290
+ }
291
+ };
292
+ } finally {
293
+ clearTimeout(timer);
294
+ }
295
+ emitGatewayLog(options, {
296
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
297
+ operation,
298
+ phase: "request",
299
+ endpoint: url,
300
+ ok: result.ok,
301
+ status,
302
+ durationMs: Date.now() - startedAt,
303
+ ...!result.ok ? { errorCode: result.error.code } : {}
304
+ });
305
+ return result;
306
+ }
307
+ function modelRows(payload) {
308
+ if (!isRecord(payload) || !Array.isArray(payload.data) || payload.data.length === 0) return void 0;
309
+ if (!payload.data.every((entry) => isRecord(entry) && typeof entry.id === "string" && entry.id.length > 0)) return void 0;
310
+ return payload.data;
311
+ }
312
+ function infoRows(payload) {
313
+ if (!isRecord(payload) || !Array.isArray(payload.data)) return [];
314
+ return payload.data.filter(isRecord);
315
+ }
316
+ function usableModel(model) {
317
+ return !model.mode || model.mode === "chat";
318
+ }
319
+ async function discoverModelsResult(options) {
320
+ const startedAt = Date.now();
321
+ const baseURL = options.baseURL.trim().replace(/\/+$/, "").replace(/\/v1$/, "");
322
+ const [modelsResponse, infoResponse] = await Promise.all([fetchJSON(`${baseURL}/v1/models`, options, "models.discover"), fetchJSON(`${baseURL}/v1/model/info`, options, "models.discover")]);
323
+ let result;
324
+ if (!modelsResponse.ok) result = modelsResponse;
325
+ else {
326
+ const rows = modelRows(modelsResponse.value);
327
+ if (!rows) result = invalid("Invalid or empty /v1/models response");
328
+ else {
329
+ const infos = /* @__PURE__ */ new Map();
330
+ if (infoResponse.ok) {
331
+ for (const row of infoRows(infoResponse.value)) if (typeof row.model_name === "string") infos.set(row.model_name, row);
332
+ }
333
+ const models = rows.map((row) => {
334
+ const metadata = infos.get(row.id);
335
+ return normalizeModel(row, metadata?.model_info, metadata?.litellm_params);
336
+ }).filter(usableModel);
337
+ result = models.length > 0 ? {
338
+ ok: true,
339
+ value: models
340
+ } : invalid("No usable chat models in /v1/models response");
341
+ }
342
+ }
343
+ emitGatewayLog(options, {
344
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
345
+ operation: "models.discover",
346
+ phase: "operation",
347
+ endpoint: `${baseURL}/v1/models`,
348
+ ok: result.ok,
349
+ durationMs: Date.now() - startedAt,
350
+ ...!result.ok ? { errorCode: result.error.code } : {},
351
+ ...result.ok ? { details: { modelCount: result.value.length } } : {}
352
+ });
353
+ return result;
354
+ }
355
+ async function discoverModels(options) {
356
+ return discoverModelsResult(options);
357
+ }
358
+ //#endregion
359
+ //#region ../common/dist/presentation.js
360
+ function normalizeProviderURL(url) {
361
+ const root = url.trim().replace(/\/+$/, "").replace(/(?:\/v1)+$/i, "");
362
+ return root ? `${root}/v1` : "";
363
+ }
364
+ function resolveUsagePercent(spend, budget) {
365
+ const thresholdPercent = budget != null && budget > 0 ? spend / budget * 100 : null;
366
+ return {
367
+ percent: thresholdPercent == null ? null : Math.round(thresholdPercent * 10) / 10,
368
+ thresholdPercent
369
+ };
370
+ }
371
+ const BUDGET_SOURCE_LABELS = PLUGIN_COPY.budgetSourceLabels;
372
+ function calendarDate(value) {
373
+ return new Date(value.getFullYear(), value.getMonth(), value.getDate());
374
+ }
375
+ function monthlyUsageMetrics(spend, now = /* @__PURE__ */ new Date()) {
376
+ const today = calendarDate(now);
377
+ const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
378
+ let elapsedWeekdays = 0;
379
+ let remainingWorkdays = 0;
380
+ for (let day = 1; day <= monthEnd.getDate(); day++) {
381
+ const weekday = new Date(today.getFullYear(), today.getMonth(), day).getDay();
382
+ if (weekday === 0 || weekday === 6) continue;
383
+ if (day <= today.getDate()) elapsedWeekdays++;
384
+ else remainingWorkdays++;
385
+ }
386
+ return {
387
+ workdayAverage: elapsedWeekdays > 0 ? spend / elapsedWeekdays : null,
388
+ remainingWorkdays
389
+ };
390
+ }
391
+ //#endregion
392
+ //#region src/flavor.ts
393
+ function withV1Behavior(flavor, matchesPanelProvider) {
394
+ const apiKeyEnvVars = [`${flavor.keyEnvironmentPrefix}_API_KEY`, ...flavor.environment === "prod" ? ["LITELLM_API_KEY", "LITELLM_MASTER_KEY"] : []];
395
+ return {
396
+ ...flavor,
397
+ connectLabel: PLUGIN_COPY.apiKeyLabel(flavor.displayName),
398
+ apiKeyEnvVars,
399
+ matchesPanelProvider
400
+ };
401
+ }
402
+ const PROD = withV1Behavior(PLUGIN_FLAVORS.prod, (id) => id === PLUGIN_FLAVORS.prod.providerID || id.includes("litellm"));
403
+ withV1Behavior(PLUGIN_FLAVORS.dev, (id) => id === PLUGIN_FLAVORS.dev.providerID);
404
+ //#endregion
405
+ export { resolveUsagePercent as a, fetchJSON as c, PLUGIN_UI as d, unsanitizeModelId as f, normalizeProviderURL as i, isRecord as l, BUDGET_SOURCE_LABELS as n, discoverModels as o, monthlyUsageMetrics as r, emitGatewayLog as s, PROD as t, PLUGIN_COPY as u };
@@ -0,0 +1,7 @@
1
+ //#region src/entry-prod.d.ts
2
+ declare const _default: {
3
+ id: string;
4
+ server: import("@opencode-ai/plugin").Plugin;
5
+ };
6
+ //#endregion
7
+ export { _default as default };
package/dist/index.js CHANGED
@@ -1,8 +1,129 @@
1
- import { t as PROD } from "./flavor-CAVwtAYo.js";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { f as unsanitizeModelId, i as normalizeProviderURL, o as discoverModels, t as PROD, u as PLUGIN_COPY } from "./flavor-BsY4SlmY.js";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
5
  import { join } from "node:path";
5
- import { createHash } from "node:crypto";
6
+ //#region ../common/dist/cache.js
7
+ function cacheDirectory() {
8
+ const root = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
9
+ return join(root, "opencode");
10
+ }
11
+ function cachePath() {
12
+ return join(cacheDirectory(), "cdo-ai-sdlc-models.json");
13
+ }
14
+ function cacheKey(scope) {
15
+ const baseURL = scope.baseURL.trim().replace(/\/+$/, "").replace(/\/v1$/, "");
16
+ const gateway = createHash("sha256").update(baseURL).digest("hex").slice(0, 16);
17
+ const credential = createHash("sha256").update(scope.apiKey ?? "").digest("hex").slice(0, 16);
18
+ return `${scope.environment}:${gateway}:${credential}`;
19
+ }
20
+ async function readEntries() {
21
+ try {
22
+ const parsed = JSON.parse(await readFile(cachePath(), "utf8"));
23
+ if (!parsed.entries || typeof parsed.entries !== "object" || Array.isArray(parsed.entries)) return {};
24
+ return parsed.entries;
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+ function validModels(value) {
30
+ return Array.isArray(value) && value.length > 0 && value.every((model) => {
31
+ if (!model || typeof model !== "object" || Array.isArray(model)) return false;
32
+ const entry = model;
33
+ return typeof entry.id === "string" && entry.id.length > 0 && [
34
+ "contextTokens",
35
+ "inputTokens",
36
+ "outputTokens",
37
+ "inputCostPerToken",
38
+ "outputCostPerToken",
39
+ "cacheReadCostPerToken",
40
+ "cacheWriteCostPerToken"
41
+ ].every((key) => entry[key] === void 0 || typeof entry[key] === "number" && Number.isFinite(entry[key])) && [
42
+ "supportsTools",
43
+ "supportsReasoning",
44
+ "supportsVision",
45
+ "supportsPdf",
46
+ "supportsAudio"
47
+ ].every((key) => entry[key] === void 0 || typeof entry[key] === "boolean") && (entry.reasoningEfforts === void 0 || Array.isArray(entry.reasoningEfforts) && entry.reasoningEfforts.every((effort) => typeof effort === "string"));
48
+ });
49
+ }
50
+ async function readModelCache(scope) {
51
+ const models = (await readEntries())[cacheKey(scope)]?.models;
52
+ return validModels(models) ? models.map((model) => ({
53
+ ...model,
54
+ name: unsanitizeModelId(model.id)
55
+ })) : void 0;
56
+ }
57
+ async function acquireLock(lockPath) {
58
+ const deadline = Date.now() + 2e3;
59
+ while (Date.now() < deadline) try {
60
+ await mkdir(lockPath);
61
+ return () => rm(lockPath, {
62
+ recursive: true,
63
+ force: true
64
+ });
65
+ } catch {
66
+ try {
67
+ if (Date.now() - (await stat(lockPath)).mtimeMs > 3e4) {
68
+ await rm(lockPath, {
69
+ recursive: true,
70
+ force: true
71
+ });
72
+ continue;
73
+ }
74
+ } catch {
75
+ continue;
76
+ }
77
+ await new Promise((resolve) => setTimeout(resolve, 25));
78
+ }
79
+ }
80
+ const pendingWrites = /* @__PURE__ */ new Map();
81
+ async function writeModelCache(scope, models) {
82
+ if (!validModels(models)) return;
83
+ const path = cachePath();
84
+ const current = (pendingWrites.get(path) ?? Promise.resolve()).catch(() => {}).then(async () => {
85
+ let release;
86
+ try {
87
+ await mkdir(cacheDirectory(), {
88
+ recursive: true,
89
+ mode: 448
90
+ });
91
+ release = await acquireLock(`${path}.lock`);
92
+ if (!release) return;
93
+ const entries = await readEntries();
94
+ const cachedModels = models.map(({ name: _name, ...model }) => model);
95
+ entries[cacheKey(scope)] = {
96
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
97
+ models: cachedModels
98
+ };
99
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
100
+ await writeFile(temp, JSON.stringify({ entries }), {
101
+ encoding: "utf8",
102
+ mode: 384
103
+ });
104
+ await rename(temp, path);
105
+ } catch {} finally {
106
+ await release?.().catch(() => {});
107
+ }
108
+ });
109
+ pendingWrites.set(path, current);
110
+ await current;
111
+ if (pendingWrites.get(path) === current) pendingWrites.delete(path);
112
+ }
113
+ async function resolveModelList(scope, discover, isCurrent = () => true) {
114
+ try {
115
+ const fetched = await discover();
116
+ if (!isCurrent()) return void 0;
117
+ if (validModels(fetched)) {
118
+ if (isCurrent()) await writeModelCache(scope, fetched);
119
+ return fetched;
120
+ }
121
+ } catch {
122
+ if (!isCurrent()) return void 0;
123
+ }
124
+ return readModelCache(scope);
125
+ }
126
+ //#endregion
6
127
  //#region src/server/auth-hook.ts
7
128
  function createAuthHook(flavor) {
8
129
  return {
@@ -10,8 +131,9 @@ function createAuthHook(flavor) {
10
131
  async loader(auth) {
11
132
  const creds = await auth();
12
133
  if (!creds || creds.type !== "api") return {};
134
+ const url = creds.metadata?.["url"];
13
135
  return {
14
- baseURL: creds.metadata?.["url"],
136
+ baseURL: url ? normalizeProviderURL(url) : void 0,
15
137
  apiKey: creds.key
16
138
  };
17
139
  },
@@ -21,8 +143,9 @@ function createAuthHook(flavor) {
21
143
  prompts: [{
22
144
  type: "text",
23
145
  key: "url",
24
- message: `${flavor.displayName} proxy URL`,
25
- placeholder: "https://litellm.example.com/v1"
146
+ message: PLUGIN_COPY.proxyURLLabel(flavor.displayName),
147
+ placeholder: PLUGIN_COPY.proxyURLPlaceholder,
148
+ validate: (value) => value.trim() ? void 0 : PLUGIN_COPY.proxyURLRequired
26
149
  }]
27
150
  }]
28
151
  };
@@ -30,13 +153,8 @@ function createAuthHook(flavor) {
30
153
  //#endregion
31
154
  //#region src/server/constants.ts
32
155
  const PROVIDER_NPM = "@ai-sdk/openai-compatible";
33
- const FETCH_TIMEOUT_MS = 8e3;
34
- const CACHE_TTL_MS = 6048e5;
35
156
  //#endregion
36
157
  //#region src/server/credentials.ts
37
- function normalizeRoot(url) {
38
- return url.trim().replace(/\/+$/, "").replace(/\/v1$/, "");
39
- }
40
158
  function authFilePath() {
41
159
  const dataHome = process.env["XDG_DATA_HOME"] || join(homedir(), ".local", "share");
42
160
  return join(dataHome, "opencode", "auth.json");
@@ -66,109 +184,62 @@ async function resolveCredentials(flavor, options) {
66
184
  const baseURL = (typeof options.baseURL === "string" ? options.baseURL : void 0) || stored.baseURL;
67
185
  return {
68
186
  apiKey,
69
- baseURL: baseURL ? normalizeRoot(baseURL) : void 0
187
+ baseURL: baseURL ? normalizeProviderURL(baseURL) : void 0
70
188
  };
71
189
  }
72
190
  //#endregion
73
- //#region src/server/model-cache.ts
74
- function cacheDir() {
75
- const cacheHome = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
76
- return join(cacheHome, "opencode-cdo-ai");
77
- }
78
- function cachePath(root) {
79
- const key = createHash("sha256").update(root).digest("hex").slice(0, 16);
80
- return join(cacheDir(), `models-${key}.json`);
81
- }
82
- async function readCache(root) {
83
- try {
84
- const parsed = JSON.parse(await readFile(cachePath(root), "utf8"));
85
- if (!parsed.models || Object.keys(parsed.models).length === 0) return void 0;
86
- return {
87
- models: parsed.models,
88
- fresh: Date.now() - parsed.savedAt < CACHE_TTL_MS
89
- };
90
- } catch {
91
- return;
92
- }
93
- }
94
- async function writeCache(root, models) {
95
- try {
96
- await mkdir(cacheDir(), { recursive: true });
97
- const payload = {
98
- savedAt: Date.now(),
99
- models
100
- };
101
- await writeFile(cachePath(root), JSON.stringify(payload), "utf8");
102
- } catch {}
103
- }
104
- //#endregion
105
191
  //#region src/server/models-client.ts
106
- async function getJSON(url, apiKey) {
107
- const controller = new AbortController();
108
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
109
- try {
110
- const res = await fetch(url, {
111
- headers: { Authorization: `Bearer ${apiKey}` },
112
- signal: controller.signal
113
- });
114
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
115
- return await res.json();
116
- } finally {
117
- clearTimeout(timer);
118
- }
119
- }
120
- function parseIds(payload) {
121
- const data = payload?.data;
122
- if (!Array.isArray(data)) return [];
123
- return data.map((m) => m.id).filter((id) => typeof id === "string" && id.length > 0);
124
- }
125
- function parseInfo(payload) {
126
- const data = payload?.data;
127
- if (!Array.isArray(data)) return {};
128
- const out = {};
129
- for (const row of data) if (typeof row.model_name === "string" && row.model_info) out[row.model_name] = row.model_info;
130
- return out;
131
- }
132
- function toEntry(id, info) {
192
+ function toV1ModelEntry(model) {
193
+ const cost = model.inputCostPerToken != null || model.outputCostPerToken != null ? {
194
+ input: (model.inputCostPerToken ?? 0) * 1e6,
195
+ output: (model.outputCostPerToken ?? 0) * 1e6,
196
+ ...model.cacheReadCostPerToken == null ? {} : { cache_read: model.cacheReadCostPerToken * 1e6 },
197
+ ...model.cacheWriteCostPerToken == null ? {} : { cache_write: model.cacheWriteCostPerToken * 1e6 }
198
+ } : void 0;
199
+ const input = ["text"];
200
+ if (model.supportsVision) input.push("image");
201
+ if (model.supportsPdf) input.push("pdf");
202
+ if (model.supportsAudio) input.push("audio");
133
203
  return {
134
- name: id,
204
+ name: model.name ?? model.id,
135
205
  limit: {
136
- context: info?.max_input_tokens ?? info?.max_tokens ?? 128e3,
137
- output: info?.max_output_tokens ?? 16384
206
+ context: model.contextTokens ?? 128e3,
207
+ output: model.outputTokens ?? 16384
138
208
  },
139
- tool_call: info?.supports_function_calling ?? true,
140
- reasoning: info?.supports_reasoning ?? false,
141
- attachment: info?.supports_vision ?? false
209
+ tool_call: model.supportsTools ?? true,
210
+ reasoning: model.supportsReasoning ?? false,
211
+ attachment: model.supportsVision ?? false,
212
+ ...model.reasoningEfforts?.length ? { variants: Object.fromEntries(model.reasoningEfforts.map((effort) => [effort, { reasoningEffort: effort }])) } : {},
213
+ ...cost ? { cost } : {},
214
+ modalities: {
215
+ input,
216
+ output: ["text"]
217
+ }
142
218
  };
143
219
  }
144
- async function discoverModels(root, apiKey) {
145
- const [idsResult, infoResult] = await Promise.allSettled([getJSON(`${root}/v1/models`, apiKey), getJSON(`${root}/v1/model/info`, apiKey)]);
146
- if (idsResult.status !== "fulfilled") throw idsResult.reason;
147
- const ids = parseIds(idsResult.value);
148
- const info = infoResult.status === "fulfilled" ? parseInfo(infoResult.value) : {};
149
- const models = {};
150
- for (const id of ids) {
151
- const meta = info[id];
152
- if (meta?.mode && meta.mode !== "chat") continue;
153
- models[id] = toEntry(id, meta);
154
- }
155
- return models;
220
+ async function discoverGatewayModelsForV1(root, apiKey, logger) {
221
+ const result = await discoverModels({
222
+ baseURL: root,
223
+ apiKey,
224
+ logger
225
+ });
226
+ if (!result.ok) throw new Error(result.error.message);
227
+ return result.value;
228
+ }
229
+ function toV1ModelMap(models) {
230
+ return Object.fromEntries(models.map((model) => [model.id, toV1ModelEntry(model)]));
156
231
  }
157
232
  //#endregion
158
233
  //#region src/server/config-hook.ts
159
- async function resolveModels(root, apiKey) {
160
- const cached = await readCache(root);
161
- if (cached?.fresh) return cached.models;
162
- try {
163
- const discovered = await discoverModels(root, apiKey);
164
- if (Object.keys(discovered).length === 0) return cached?.models;
165
- await writeCache(root, discovered);
166
- return discovered;
167
- } catch {
168
- return cached?.models;
169
- }
234
+ async function resolveModels(root, apiKey, flavor, logger) {
235
+ const models = await resolveModelList({
236
+ environment: flavor.environment,
237
+ baseURL: root,
238
+ apiKey
239
+ }, () => discoverGatewayModelsForV1(root, apiKey, logger));
240
+ return models?.length ? toV1ModelMap(models) : void 0;
170
241
  }
171
- function createConfigHook(flavor) {
242
+ function createConfigHook(flavor, logger) {
172
243
  return async function configHook(config) {
173
244
  try {
174
245
  config.provider ??= {};
@@ -179,9 +250,9 @@ function createConfigHook(flavor) {
179
250
  provider.models ??= { ["_"]: {} };
180
251
  const { baseURL, apiKey } = await resolveCredentials(flavor, provider.options);
181
252
  if (!baseURL || !apiKey) return;
182
- provider.options.baseURL ??= `${baseURL}/v1`;
253
+ provider.options.baseURL = baseURL;
183
254
  provider.options.apiKey ??= apiKey;
184
- const models = await resolveModels(baseURL, apiKey);
255
+ const models = await resolveModels(baseURL, apiKey, flavor, logger);
185
256
  if (!models || Object.keys(models).length === 0) return;
186
257
  provider.models = models;
187
258
  } catch {}
@@ -190,10 +261,20 @@ function createConfigHook(flavor) {
190
261
  //#endregion
191
262
  //#region src/server/plugin.ts
192
263
  function createServer(flavor) {
193
- return async (_ctx) => ({
194
- config: createConfigHook(flavor),
195
- auth: createAuthHook(flavor)
196
- });
264
+ return async (ctx) => {
265
+ const logger = async (event) => {
266
+ await ctx.client.app.log({ body: {
267
+ service: "ai-sdlc-opencode",
268
+ level: event.level,
269
+ message: event.message,
270
+ extra: { ...event }
271
+ } });
272
+ };
273
+ return {
274
+ config: createConfigHook(flavor, logger),
275
+ auth: createAuthHook(flavor)
276
+ };
277
+ };
197
278
  }
198
279
  //#endregion
199
280
  //#region src/entry-prod.ts
package/dist/tui.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ //#region src/tui-prod.d.ts
2
+ declare const _default: {
3
+ id: string;
4
+ tui: import("@opencode-ai/plugin/tui").TuiPlugin;
5
+ };
6
+ //#endregion
7
+ export { _default as default };
package/dist/tui.js CHANGED
@@ -1,76 +1,127 @@
1
- import { t as PROD } from "./flavor-CAVwtAYo.js";
1
+ import { a as resolveUsagePercent, c as fetchJSON, d as PLUGIN_UI, l as isRecord, n as BUDGET_SOURCE_LABELS, r as monthlyUsageMetrics, s as emitGatewayLog, t as PROD, u as PLUGIN_COPY } from "./flavor-BsY4SlmY.js";
2
2
  import { For, createSignal } from "solid-js";
3
3
  import { createTextAttributes } from "@opentui/core";
4
- import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
5
- //#region src/usage-client.ts
6
- function deriveApiUrl(litellmBaseURL) {
7
- return litellmBaseURL.replace(/^(https?:\/\/)litellm(\.[^/]+).*/, "$1api$2");
4
+ import { Fragment, jsx, jsxs } from "@opentui/solid/jsx-runtime";
5
+ //#region ../common/dist/usage.js
6
+ function deriveUsageURL(baseURL) {
7
+ return `${baseURL.replace(/^(https?:\/\/)litellm(\.[^/]+).*/, "$1api$2").replace(/\/+$/, "").replace(/\/v1$/, "")}/v1/usage`;
8
8
  }
9
- async function fetchUsage(baseURL, apiKey, timeoutMs) {
10
- const controller = new AbortController();
11
- const timer = setTimeout(() => controller.abort(), timeoutMs);
12
- try {
13
- const res = await fetch(`${deriveApiUrl(baseURL)}/v1/usage`, {
14
- headers: { Authorization: `Bearer ${apiKey}` },
15
- signal: controller.signal
16
- });
17
- if (!res.ok) return `HTTP ${res.status}`;
18
- return await res.json();
19
- } catch (e) {
20
- return e instanceof Error ? e.message : "network error";
21
- } finally {
22
- clearTimeout(timer);
9
+ async function fetchUsage$1(options) {
10
+ const startedAt = Date.now();
11
+ const endpoint = deriveUsageURL(options.baseURL);
12
+ const response = await fetchJSON(endpoint, options, "usage.fetch");
13
+ let result;
14
+ if (!response.ok) result = response;
15
+ else if (!isRecord(response.value)) result = {
16
+ ok: false,
17
+ error: {
18
+ code: "invalid-response",
19
+ message: "Invalid /v1/usage response"
20
+ }
21
+ };
22
+ else {
23
+ const { spend, budget, type } = response.value;
24
+ result = typeof spend !== "number" || !Number.isFinite(spend) || !(budget === null || typeof budget === "number" && Number.isFinite(budget)) || typeof type !== "string" || ![
25
+ "key",
26
+ "user",
27
+ "team-member",
28
+ "team-member-default",
29
+ "none"
30
+ ].includes(type) ? {
31
+ ok: false,
32
+ error: {
33
+ code: "invalid-response",
34
+ message: "Invalid /v1/usage response fields"
35
+ }
36
+ } : {
37
+ ok: true,
38
+ value: {
39
+ spend,
40
+ budget,
41
+ type
42
+ }
43
+ };
23
44
  }
45
+ emitGatewayLog(options, {
46
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
47
+ operation: "usage.fetch",
48
+ phase: "operation",
49
+ endpoint,
50
+ ok: result.ok,
51
+ durationMs: Date.now() - startedAt,
52
+ ...!result.ok ? { errorCode: result.error.code } : {}
53
+ });
54
+ return result;
24
55
  }
25
- function round1(n) {
26
- return Math.round(n * 10) / 10;
56
+ //#endregion
57
+ //#region src/usage-client.ts
58
+ async function fetchUsage(baseURL, apiKey, timeoutMs, logger) {
59
+ const result = await fetchUsage$1({
60
+ baseURL,
61
+ apiKey,
62
+ timeoutMs,
63
+ logger
64
+ });
65
+ return result.ok ? result.value : result.error.message;
27
66
  }
28
67
  function toEntry(providerName, result) {
29
68
  if (typeof result === "string") return {
30
69
  providerName,
31
- error: result
70
+ error: result,
71
+ connected: true
32
72
  };
33
- const percent = result.budget != null ? round1(result.spend / result.budget * 100) : null;
73
+ const { percent, thresholdPercent } = resolveUsagePercent(result.spend, result.budget);
34
74
  return {
35
75
  providerName,
36
76
  ...result,
37
- percent
77
+ ...monthlyUsageMetrics(result.spend),
78
+ percent,
79
+ thresholdPercent,
80
+ connected: true
38
81
  };
39
82
  }
40
83
  //#endregion
41
84
  //#region src/components.tsx
42
- const BAR_WIDTH = 20;
43
- function bar(percent) {
44
- if (percent == null) return "∞";
45
- const filled = Math.min(Math.round(percent / 100 * BAR_WIDTH), BAR_WIDTH);
46
- return "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
47
- }
48
85
  function barColor(entry, theme) {
49
- const pct = entry.percent ?? 0;
50
- return pct >= 100 ? theme.error : pct >= 80 ? theme.warning : theme.success;
86
+ const pct = entry.thresholdPercent ?? 0;
87
+ return pct >= PLUGIN_UI.exceededPercent ? theme.error : pct >= PLUGIN_UI.warningPercent ? theme.warning : theme.success;
51
88
  }
52
89
  function valuesLine(entry) {
53
- if (entry.error != null) return ` ${entry.error}`;
54
- return entry.budget != null ? ` $${entry.spend.toFixed(2)} / $${entry.budget.toFixed(2)} (${entry.percent?.toFixed(1)}%)` : ` $${entry.spend.toFixed(2)} / ∞`;
90
+ if (entry.error != null) return PLUGIN_COPY.usageUnavailable(entry.error);
91
+ return PLUGIN_COPY.usageValues(entry.spend ?? 0, entry.budget ?? null);
55
92
  }
56
93
  function UsagePanel(props) {
57
- return /* @__PURE__ */ jsx(For, {
94
+ return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx(For, {
58
95
  each: props.entries,
59
- children: (entry) => /* @__PURE__ */ jsxs("box", { children: [
60
- /* @__PURE__ */ jsx("text", {
61
- attributes: createTextAttributes({ bold: true }),
62
- children: entry.providerName
63
- }),
64
- /* @__PURE__ */ jsx("text", {
65
- fg: entry.error != null ? props.theme.error : void 0,
66
- children: valuesLine(entry)
67
- }),
68
- /* @__PURE__ */ jsx("text", {
69
- fg: barColor(entry, props.theme),
70
- children: entry.error == null ? ` ${bar(entry.percent ?? null)}` : ""
71
- })
72
- ] })
73
- });
96
+ children: (entry) => /* @__PURE__ */ jsxs("box", {
97
+ flexDirection: "column",
98
+ children: [
99
+ /* @__PURE__ */ jsx("text", {
100
+ attributes: createTextAttributes({ bold: true }),
101
+ children: entry.providerName
102
+ }),
103
+ /* @__PURE__ */ jsx("text", {
104
+ fg: !entry.connected || entry.error != null ? props.theme.error : void 0,
105
+ children: !entry.connected ? PLUGIN_COPY.notConnected(props.connectCommand) : valuesLine(entry)
106
+ }),
107
+ entry.connected && entry.error == null && entry.percent != null && /* @__PURE__ */ jsx("text", {
108
+ fg: barColor(entry, props.theme),
109
+ children: `${PLUGIN_COPY.progressBar(entry.percent, PLUGIN_UI.barWidth)} ${PLUGIN_COPY.formatPercent(entry.percent)}`
110
+ }),
111
+ entry.connected && entry.error == null && entry.type != null && /* @__PURE__ */ jsx("text", {
112
+ fg: props.theme.textMuted,
113
+ children: BUDGET_SOURCE_LABELS[entry.type]
114
+ }),
115
+ entry.connected && entry.error == null && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
116
+ fg: props.theme.textMuted,
117
+ children: PLUGIN_COPY.perWorkday(entry.workdayAverage == null ? "—" : PLUGIN_COPY.formatMoney(entry.workdayAverage))
118
+ }), /* @__PURE__ */ jsx("text", {
119
+ fg: props.theme.textMuted,
120
+ children: PLUGIN_COPY.workdaysRemaining(entry.remainingWorkdays ?? 0)
121
+ })] })
122
+ ]
123
+ })
124
+ }) });
74
125
  }
75
126
  //#endregion
76
127
  //#region src/tui.tsx
@@ -79,16 +130,37 @@ const TIMEOUT_MS = 5e3;
79
130
  function createTui(flavor) {
80
131
  return (api) => {
81
132
  const [entries, setEntries] = createSignal([]);
133
+ const logger = async (event) => {
134
+ await api.client.app.log({
135
+ service: "ai-sdlc-opencode",
136
+ level: event.level,
137
+ message: event.message,
138
+ extra: { ...event }
139
+ });
140
+ };
82
141
  async function refresh() {
83
142
  try {
84
143
  const providers = api.state.provider.filter((p) => flavor.matchesPanelProvider(p.id));
144
+ if (providers.length === 0) {
145
+ setEntries([{
146
+ providerName: flavor.displayName,
147
+ connected: false
148
+ }]);
149
+ return;
150
+ }
85
151
  const results = await Promise.all(providers.map(async (p) => {
86
152
  const options = p.options ?? {};
87
153
  const baseURL = options["baseURL"];
88
154
  const apiKey = p.key ?? options["apiKey"];
89
- if (!apiKey) return toEntry(p.name, "no API key configured");
90
- if (!baseURL) return toEntry(p.name, "no base URL configured");
91
- return toEntry(p.name, await fetchUsage(baseURL, apiKey, TIMEOUT_MS));
155
+ if (!apiKey) return {
156
+ ...toEntry(p.name, ""),
157
+ connected: false
158
+ };
159
+ if (!baseURL) return {
160
+ ...toEntry(p.name, ""),
161
+ connected: false
162
+ };
163
+ return toEntry(p.name, await fetchUsage(baseURL, apiKey, TIMEOUT_MS, logger));
92
164
  }));
93
165
  setEntries(results);
94
166
  } catch {}
@@ -101,7 +173,8 @@ function createTui(flavor) {
101
173
  slots: { sidebar_content(ctx) {
102
174
  return /* @__PURE__ */ jsx(UsagePanel, {
103
175
  entries: entries(),
104
- theme: ctx.theme.current
176
+ theme: ctx.theme.current,
177
+ connectCommand: PLUGIN_COPY.connectCommand(flavor.providerID)
105
178
  });
106
179
  } }
107
180
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telefonica/ai-sdlc-opencode-plugin-v1",
3
- "version": "1.0.0",
3
+ "version": "1.0.2-snapshot.37008990101",
4
4
  "description": "OpenCode 1.x plugin: CDO.AI provider registration, model discovery, and budget panel",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,17 +0,0 @@
1
- //#region src/flavor.ts
2
- const PROD = {
3
- environment: "prod",
4
- serverPluginID: "ai-sdlc-opencode-cdo-ai-plugin",
5
- tuiPluginID: "ai-sdlc-opencode-cdo-ai-plugin.tui",
6
- providerID: "cdo-ai",
7
- displayName: "CDO.AI",
8
- connectLabel: "CDO.AI API key",
9
- apiKeyEnvVars: [
10
- "CDO_AI_API_KEY",
11
- "LITELLM_API_KEY",
12
- "LITELLM_MASTER_KEY"
13
- ],
14
- matchesPanelProvider: (id) => id === "cdo-ai" || id.includes("litellm")
15
- };
16
- //#endregion
17
- export { PROD as t };
@@ -1,9 +0,0 @@
1
- import * as _opencode_ai_plugin0 from "@opencode-ai/plugin";
2
-
3
- //#region src/entry-prod.d.ts
4
- declare const _default: {
5
- id: string;
6
- server: _opencode_ai_plugin0.Plugin;
7
- };
8
- //#endregion
9
- export { _default as default };
@@ -1,9 +0,0 @@
1
- import * as _opencode_ai_plugin_tui0 from "@opencode-ai/plugin/tui";
2
-
3
- //#region src/tui-prod.d.ts
4
- declare const _default: {
5
- id: string;
6
- tui: _opencode_ai_plugin_tui0.TuiPlugin;
7
- };
8
- //#endregion
9
- export { _default as default };