@telefonica/ai-sdlc-opencode-plugin-v1-dev 0.1.0-snapshot.35846538366

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 ADDED
@@ -0,0 +1,124 @@
1
+ # @telefonica/ai-sdlc-opencode-plugin-v1
2
+
3
+ CDO.AI gateway plugin for **OpenCode 1.x**. Registers the gateway as a provider,
4
+ discovers its models, and renders remaining budget in the sidebar.
5
+
6
+ For installation and the `/connect` walkthrough, see the
7
+ [plugins README](../../README.md). This file covers the internals.
8
+
9
+ > Using OpenCode 2.x? Install [`@telefonica/ai-sdlc-opencode-plugin-v2`](../v2)
10
+ > instead — the two major versions have incompatible plugin APIs.
11
+
12
+ ## What it provides
13
+
14
+ Two flavors are published as separate packages, each built into
15
+ `plugin-dist/<flavor>/` with its own generated manifest — the same layout the v2
16
+ plugin uses.
17
+
18
+ | Package | Provider id | Shown in `/connect` as |
19
+ | --- | --- | --- |
20
+ | `@telefonica/ai-sdlc-opencode-plugin-v1` | `cdo-ai` | CDO.AI |
21
+ | `@telefonica/ai-sdlc-opencode-plugin-v1-dev` | `cdo-ai-dev` | CDO.AI (dev) |
22
+
23
+ Each package exposes two entry points, which OpenCode resolves independently:
24
+
25
+ | Export | Built file | Role |
26
+ | --- | --- | --- |
27
+ | `./server` | `dist/index.js` | Provider registration, `/connect` auth, model discovery |
28
+ | `./tui` | `dist/tui.js` | Sidebar budget panel |
29
+
30
+ Only `exports["./server"]` and `exports["./tui"]` are consulted. OpenCode never
31
+ reads `exports["."]`, and a `main` field would be picked up as an additional
32
+ server entry point, so neither is declared.
33
+
34
+ The production panel also covers the hand-written `litellm*` providers from the
35
+ older manual setup, so the budget display keeps working while users migrate. The
36
+ dev panel matches only `cdo-ai-dev`, so installing both packages does not produce
37
+ two panels for the same gateway.
38
+
39
+ ## Architecture notes
40
+
41
+ These are load-bearing constraints of the OpenCode 1.x plugin API. They look
42
+ arbitrary in isolation, so they are recorded here.
43
+
44
+ **Models come from the `config` hook, not the `provider.models` hook.** OpenCode
45
+ invokes `provider.models` before config-declared providers exist, so it never
46
+ fires for a provider that is not in the models.dev catalog. Discovery therefore
47
+ happens in the `config` hook, which is async and may perform network calls.
48
+
49
+ **A provider with no models is discarded.** OpenCode deletes any provider whose
50
+ model map is empty before `/connect` renders, so the config hook seeds a
51
+ placeholder entry that is replaced once discovery succeeds.
52
+
53
+ **`/connect` does not call `authorize()` for API-key methods.** Values collected
54
+ by `prompts` are stored as credential metadata and the key itself is collected by
55
+ a separate built-in step. The auth method therefore prompts only for the gateway
56
+ URL, and `auth.loader` recombines the stored URL and key into provider options.
57
+
58
+ **Credentials are read from disk.** Stored auth is not exposed in-process when
59
+ the config hook runs, so it reads `auth.json` directly. Resolution order is
60
+ config `options`, then environment variables, then `auth.json`. Resolved values
61
+ are written back into `options` because a fully custom provider builds its
62
+ completion client from them directly.
63
+
64
+ **One environment per package.** `Hooks.auth` binds a single provider id, so each
65
+ environment needs its own plugin instance. Rather than smuggling both into one
66
+ module, each flavor is built and published separately, which keeps the plain
67
+ default-export plugin module shape and matches the v2 plugin.
68
+
69
+ ## Layout
70
+
71
+ ```
72
+ src/
73
+ ├── flavor.ts # prod/dev ids and labels, mirroring the v2 plugin
74
+ ├── entry-prod.ts # server entry, prod -> dist/index.js
75
+ ├── entry-dev.ts # server entry, dev -> dist/index.js
76
+ ├── tui-prod.tsx # TUI entry, prod -> dist/tui.js
77
+ ├── tui-dev.tsx # TUI entry, dev -> dist/tui.js
78
+ ├── tui.tsx # sidebar slot registration and budget polling
79
+ ├── components.tsx # budget panel rendering
80
+ ├── usage-client.ts # /v1/usage fetch for the budget panel
81
+ └── server/
82
+ ├── plugin.ts # assembles the hooks for a flavor
83
+ ├── config-hook.ts # provider registration and model discovery
84
+ ├── auth-hook.ts # /connect prompts and credential loading
85
+ ├── credentials.ts # config / env / auth.json resolution
86
+ ├── models-client.ts # /v1/models and /v1/model/info
87
+ ├── model-cache.ts # 7-day on-disk model cache
88
+ └── constants.ts
89
+ ```
90
+
91
+ `FLAVOR=dev|prod` is the only build flag. Every identity string lives in
92
+ `src/flavor.ts`; there is no runtime environment lookup.
93
+
94
+ ## Develop
95
+
96
+ ```bash
97
+ npm ci
98
+ npm run build:prod # writes plugin-dist/prod/dist/
99
+ npm run build:dev # writes plugin-dist/dev/dist/
100
+ npm run build # both
101
+ ```
102
+
103
+ Point OpenCode at a built flavor to try it:
104
+
105
+ ```jsonc
106
+ { "plugin": ["/absolute/path/to/plugins/opencode/v1/plugin-dist/prod"] }
107
+ ```
108
+
109
+ For OpenCode 1.x, put this entry in both `opencode.jsonc` and `tui.jsonc`.
110
+ The server and TUI plugin loaders use separate config files.
111
+
112
+ Build before launching — OpenCode installs plugins with scripts disabled and will
113
+ not build the package for you. Quit OpenCode fully between attempts, since a
114
+ plugin that fails to load stays cached for the lifetime of the process.
115
+
116
+ ## Release
117
+
118
+ Published by `.github/workflows/opencode-plugin-release.yml` via
119
+ `semantic-release`, tagged `opencode-v1@<version>`.
120
+
121
+ `plugin-dist/` is a build output directory and is not committed. The release
122
+ `prepareCmd` builds both flavors and then runs `scripts/write-manifests.js`,
123
+ which generates each publish manifest; `publishCmd` publishes both. The root
124
+ package is `private` and exists only to hold the sources and release config.
@@ -0,0 +1,13 @@
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 };
@@ -0,0 +1,9 @@
1
+ import * as _opencode_ai_plugin0 from "@opencode-ai/plugin";
2
+
3
+ //#region src/entry-dev.d.ts
4
+ declare const _default: {
5
+ id: string;
6
+ server: _opencode_ai_plugin0.Plugin;
7
+ };
8
+ //#endregion
9
+ export { _default as default };
package/dist/index.js ADDED
@@ -0,0 +1,205 @@
1
+ import { t as DEV } from "./flavor-CMZWvD--.js";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ //#region src/server/auth-hook.ts
7
+ function createAuthHook(flavor) {
8
+ return {
9
+ provider: flavor.providerID,
10
+ async loader(auth) {
11
+ const creds = await auth();
12
+ if (!creds || creds.type !== "api") return {};
13
+ return {
14
+ baseURL: creds.metadata?.["url"],
15
+ apiKey: creds.key
16
+ };
17
+ },
18
+ methods: [{
19
+ type: "api",
20
+ label: flavor.connectLabel,
21
+ prompts: [{
22
+ type: "text",
23
+ key: "url",
24
+ message: `${flavor.displayName} proxy URL`,
25
+ placeholder: "https://litellm.example.com/v1"
26
+ }]
27
+ }]
28
+ };
29
+ }
30
+ //#endregion
31
+ //#region src/server/constants.ts
32
+ const PROVIDER_NPM = "@ai-sdk/openai-compatible";
33
+ const FETCH_TIMEOUT_MS = 8e3;
34
+ const CACHE_TTL_MS = 6048e5;
35
+ //#endregion
36
+ //#region src/server/credentials.ts
37
+ function normalizeRoot(url) {
38
+ return url.trim().replace(/\/+$/, "").replace(/\/v1$/, "");
39
+ }
40
+ function authFilePath() {
41
+ const dataHome = process.env["XDG_DATA_HOME"] || join(homedir(), ".local", "share");
42
+ return join(dataHome, "opencode", "auth.json");
43
+ }
44
+ async function fromAuthFile(providerID) {
45
+ try {
46
+ const raw = await readFile(authFilePath(), "utf8");
47
+ const entry = JSON.parse(raw)[providerID];
48
+ if (!entry || entry.type !== "api") return {};
49
+ return {
50
+ apiKey: entry.key,
51
+ baseURL: entry.metadata?.["url"]
52
+ };
53
+ } catch {
54
+ return {};
55
+ }
56
+ }
57
+ function fromEnv(flavor) {
58
+ for (const name of flavor.apiKeyEnvVars) {
59
+ const value = process.env[name];
60
+ if (value) return value;
61
+ }
62
+ }
63
+ async function resolveCredentials(flavor, options) {
64
+ const stored = await fromAuthFile(flavor.providerID);
65
+ const apiKey = (typeof options.apiKey === "string" ? options.apiKey : void 0) || fromEnv(flavor) || stored.apiKey;
66
+ const baseURL = (typeof options.baseURL === "string" ? options.baseURL : void 0) || stored.baseURL;
67
+ return {
68
+ apiKey,
69
+ baseURL: baseURL ? normalizeRoot(baseURL) : void 0
70
+ };
71
+ }
72
+ //#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
+ //#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) {
133
+ return {
134
+ name: id,
135
+ limit: {
136
+ context: info?.max_input_tokens ?? info?.max_tokens ?? 128e3,
137
+ output: info?.max_output_tokens ?? 16384
138
+ },
139
+ tool_call: info?.supports_function_calling ?? true,
140
+ reasoning: info?.supports_reasoning ?? false,
141
+ attachment: info?.supports_vision ?? false
142
+ };
143
+ }
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;
156
+ }
157
+ //#endregion
158
+ //#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
+ }
170
+ }
171
+ function createConfigHook(flavor) {
172
+ return async function configHook(config) {
173
+ try {
174
+ config.provider ??= {};
175
+ const provider = config.provider[flavor.providerID] ??= {};
176
+ provider.name ??= flavor.displayName;
177
+ provider.npm ??= PROVIDER_NPM;
178
+ provider.options ??= {};
179
+ provider.models ??= { ["_"]: {} };
180
+ const { baseURL, apiKey } = await resolveCredentials(flavor, provider.options);
181
+ if (!baseURL || !apiKey) return;
182
+ provider.options.baseURL ??= `${baseURL}/v1`;
183
+ provider.options.apiKey ??= apiKey;
184
+ const models = await resolveModels(baseURL, apiKey);
185
+ if (!models || Object.keys(models).length === 0) return;
186
+ provider.models = models;
187
+ } catch {}
188
+ };
189
+ }
190
+ //#endregion
191
+ //#region src/server/plugin.ts
192
+ function createServer(flavor) {
193
+ return async (_ctx) => ({
194
+ config: createConfigHook(flavor),
195
+ auth: createAuthHook(flavor)
196
+ });
197
+ }
198
+ //#endregion
199
+ //#region src/entry-dev.ts
200
+ var entry_dev_default = {
201
+ id: DEV.serverPluginID,
202
+ server: createServer(DEV)
203
+ };
204
+ //#endregion
205
+ export { entry_dev_default as default };
@@ -0,0 +1,9 @@
1
+ import * as _opencode_ai_plugin_tui0 from "@opencode-ai/plugin/tui";
2
+
3
+ //#region src/tui-dev.d.ts
4
+ declare const _default: {
5
+ id: string;
6
+ tui: _opencode_ai_plugin_tui0.TuiPlugin;
7
+ };
8
+ //#endregion
9
+ export { _default as default };
package/dist/tui.js ADDED
@@ -0,0 +1,118 @@
1
+ import { t as DEV } from "./flavor-CMZWvD--.js";
2
+ import { For, createSignal } from "solid-js";
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");
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);
23
+ }
24
+ }
25
+ function round1(n) {
26
+ return Math.round(n * 10) / 10;
27
+ }
28
+ function toEntry(providerName, result) {
29
+ if (typeof result === "string") return {
30
+ providerName,
31
+ error: result
32
+ };
33
+ const percent = result.budget != null ? round1(result.spend / result.budget * 100) : null;
34
+ return {
35
+ providerName,
36
+ ...result,
37
+ percent
38
+ };
39
+ }
40
+ //#endregion
41
+ //#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
+ function barColor(entry, theme) {
49
+ const pct = entry.percent ?? 0;
50
+ return pct >= 100 ? theme.error : pct >= 80 ? theme.warning : theme.success;
51
+ }
52
+ 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)} / ∞`;
55
+ }
56
+ function UsagePanel(props) {
57
+ return /* @__PURE__ */ jsx(For, {
58
+ 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
+ });
74
+ }
75
+ //#endregion
76
+ //#region src/tui.tsx
77
+ const REFRESH_MS = 6e4;
78
+ const TIMEOUT_MS = 5e3;
79
+ function createTui(flavor) {
80
+ return (api) => {
81
+ const [entries, setEntries] = createSignal([]);
82
+ async function refresh() {
83
+ try {
84
+ const providers = api.state.provider.filter((p) => flavor.matchesPanelProvider(p.id));
85
+ const results = await Promise.all(providers.map(async (p) => {
86
+ const options = p.options ?? {};
87
+ const baseURL = options["baseURL"];
88
+ 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));
92
+ }));
93
+ setEntries(results);
94
+ } catch {}
95
+ }
96
+ refresh();
97
+ const interval = setInterval(refresh, REFRESH_MS);
98
+ api.lifecycle.onDispose(() => clearInterval(interval));
99
+ api.slots.register({
100
+ order: 101,
101
+ slots: { sidebar_content(ctx) {
102
+ return /* @__PURE__ */ jsx(UsagePanel, {
103
+ entries: entries(),
104
+ theme: ctx.theme.current
105
+ });
106
+ } }
107
+ });
108
+ return Promise.resolve();
109
+ };
110
+ }
111
+ //#endregion
112
+ //#region src/tui-dev.tsx
113
+ var tui_dev_default = {
114
+ id: DEV.tuiPluginID,
115
+ tui: createTui(DEV)
116
+ };
117
+ //#endregion
118
+ export { tui_dev_default as default };
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@telefonica/ai-sdlc-opencode-plugin-v1-dev",
3
+ "version": "0.1.0-snapshot.35846538366",
4
+ "description": "OpenCode 1.x plugin (dev environment): CDO.AI provider registration, model discovery, and budget panel",
5
+ "type": "module",
6
+ "exports": {
7
+ "./server": "./dist/index.js",
8
+ "./tui": "./dist/tui.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ]
13
+ }