@telefonica/ai-sdlc-opencode-plugin-v1-dev 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 +3 -0
- package/dist/flavor-8QVaVpzl.js +405 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +188 -107
- package/dist/tui.d.ts +7 -0
- package/dist/tui.js +127 -54
- package/package.json +1 -1
- package/dist/flavor-CMZWvD--.js +0 -13
- package/dist/index-T2VhrgAK.d.ts +0 -9
- package/dist/tui-BTWQZHT5.d.ts +0 -9
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
|
+
withV1Behavior(PLUGIN_FLAVORS.prod, (id) => id === PLUGIN_FLAVORS.prod.providerID || id.includes("litellm"));
|
|
403
|
+
const DEV = 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, DEV as t, PLUGIN_COPY as u };
|
package/dist/index.d.ts
ADDED
package/dist/index.js
CHANGED
|
@@ -1,8 +1,129 @@
|
|
|
1
|
-
import { t as DEV } from "./flavor-
|
|
2
|
-
import {
|
|
1
|
+
import { f as unsanitizeModelId, i as normalizeProviderURL, o as discoverModels, t as DEV, u as PLUGIN_COPY } from "./flavor-8QVaVpzl.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
|
-
|
|
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:
|
|
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:
|
|
25
|
-
placeholder:
|
|
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 ?
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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:
|
|
137
|
-
output:
|
|
206
|
+
context: model.contextTokens ?? 128e3,
|
|
207
|
+
output: model.outputTokens ?? 16384
|
|
138
208
|
},
|
|
139
|
-
tool_call:
|
|
140
|
-
reasoning:
|
|
141
|
-
attachment:
|
|
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
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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 (
|
|
194
|
-
|
|
195
|
-
|
|
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-dev.ts
|
package/dist/tui.d.ts
ADDED
package/dist/tui.js
CHANGED
|
@@ -1,76 +1,127 @@
|
|
|
1
|
-
import { t as DEV } from "./flavor-
|
|
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 DEV, u as PLUGIN_COPY } from "./flavor-8QVaVpzl.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
|
|
6
|
-
function
|
|
7
|
-
return
|
|
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(
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
73
|
+
const { percent, thresholdPercent } = resolveUsagePercent(result.spend, result.budget);
|
|
34
74
|
return {
|
|
35
75
|
providerName,
|
|
36
76
|
...result,
|
|
37
|
-
|
|
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.
|
|
50
|
-
return pct >=
|
|
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
|
|
54
|
-
return
|
|
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", {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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-dev",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2-snapshot.37008990101",
|
|
4
4
|
"description": "OpenCode 1.x plugin (dev environment): CDO.AI provider registration, model discovery, and budget panel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/dist/flavor-CMZWvD--.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
//#region src/flavor.ts
|
|
2
|
-
const DEV = {
|
|
3
|
-
environment: "dev",
|
|
4
|
-
serverPluginID: "ai-sdlc-opencode-cdo-ai-dev-plugin",
|
|
5
|
-
tuiPluginID: "ai-sdlc-opencode-cdo-ai-dev-plugin.tui",
|
|
6
|
-
providerID: "cdo-ai-dev",
|
|
7
|
-
displayName: "CDO.AI (dev)",
|
|
8
|
-
connectLabel: "CDO.AI (dev) API key",
|
|
9
|
-
apiKeyEnvVars: ["CDO_AI_DEV_API_KEY"],
|
|
10
|
-
matchesPanelProvider: (id) => id === "cdo-ai-dev"
|
|
11
|
-
};
|
|
12
|
-
//#endregion
|
|
13
|
-
export { DEV as t };
|
package/dist/index-T2VhrgAK.d.ts
DELETED
package/dist/tui-BTWQZHT5.d.ts
DELETED