@vymalo/opencode-lightbridge 0.14.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vymalo contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # `@vymalo/opencode-lightbridge`
2
+
3
+ [![npm](https://img.shields.io/npm/v/@vymalo/opencode-lightbridge?label=npm&color=CB3837&logo=npm)](https://www.npmjs.com/package/@vymalo/opencode-lightbridge)
4
+
5
+ **One login, every egress.** The umbrella OpenCode plugin for [ADR-0012](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/adr/0012-single-auth-across-gateway-and-otel.md):
6
+ a single shared `TokenRuntime` drives **both** the LLM gateway bearer and the OTEL export
7
+ credential, because both validate the same `lightbridge-authz` issuer + `aud=lightbridge-api-key`.
8
+ Log in once as yourself; every egress that needs a project-scoped credential rides the same token.
9
+
10
+ Part of the [OpenCode Toolbelt](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit).
11
+
12
+ ## Why this instead of `opencode-repo-auth` + `opencode-otel`
13
+
14
+ Running both standalone plugins works, but each holds its **own** credential in its **own** cache
15
+ namespace — two logins, two token stores, no relationship between them. `@vymalo/opencode-lightbridge`
16
+ composes `@vymalo/opencode-auth-core` (the OAuth/token-exchange primitive) and
17
+ `@vymalo/opencode-core-otel` (the OTel engine) over **one** `TokenRuntime`, so the project-scoped
18
+ token minted for the gateway is the exact same token OTEL presents to the collector. No forked
19
+ engine logic — this package is a thin composition layer over its two sibling libraries.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ npm install @vymalo/opencode-lightbridge
25
+ ```
26
+
27
+ ```jsonc
28
+ // opencode.json
29
+ {
30
+ "plugin": [
31
+ ["@vymalo/opencode-lightbridge", {
32
+ "auth": {
33
+ "id": "lightbridge",
34
+ "issuer": "https://authz.example.com/realms/lightbridge",
35
+ "clientId": "opencode-cli",
36
+ "scopes": ["openid", "offline_access"],
37
+ "authFlow": "device_code"
38
+ },
39
+ "gateway": {
40
+ "projectId": "proj-123",
41
+ "providers": ["gateway"]
42
+ },
43
+ "otel": {
44
+ "endpoint": "http://localhost:4318"
45
+ }
46
+ }]
47
+ ]
48
+ }
49
+ ```
50
+
51
+ - **`auth`** (required) — the one IdP login, an `AuthServerConfigInput` (same shape as
52
+ `opencode-oauth2`/`opencode-repo-auth`'s per-provider auth block).
53
+ - **`gateway`** (optional) — the OpenCode provider ids to inject `Authorization: Bearer
54
+ <project-token>` on, per request (plus an optional `projectId`).
55
+ - **`otel`** (optional) — the same `OtelPluginOptions` shape as `@vymalo/opencode-otel`
56
+ (`endpoint`, `exporters`, `serviceName`, …), minus `tokenCommand`/`tokenHeader`/`tokenPrefix`: the
57
+ shared runtime supersedes that seam entirely.
58
+ - **`projectId`** (optional, top-level or under `gateway`) — **fully optional**: omit it and the
59
+ exchange sends no `project_id`, so the backend mints a token for your **default project**. An
60
+ explicit top-level `projectId` wins over `gateway.projectId`.
61
+
62
+ Omitting both `gateway` and `otel` is a valid, inert config — the plugin logs and no-ops.
63
+
64
+ ## Scope: gateway + OTEL, not MCP
65
+
66
+ MCP is deliberately out of scope. OpenCode mints its own per-server MCP OAuth token into
67
+ `mcp-auth.json`, and `McpRemoteConfig.headers` is a static map with no per-request hook — sharing
68
+ this plugin's credential there would need a stdio credential-proxy sidecar, which isn't worth the
69
+ complexity today. MCP keeps using OpenCode's native per-server OAuth. See
70
+ [ADR-0012](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/adr/0012-single-auth-across-gateway-and-otel.md)
71
+ for the full reasoning.
72
+
73
+ ## Full reference
74
+
75
+ - [`docs/lightbridge.md`](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/lightbridge.md)
76
+ — the one-credential design, full config reference, the three egresses.
77
+ - [ADR-0012](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/adr/0012-single-auth-across-gateway-and-otel.md)
78
+ — why one runtime, why MCP is excluded, the alternatives considered.
79
+ - [`docs/repo-auth.md`](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/repo-auth.md)
80
+ and [`docs/otel.md`](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/otel.md)
81
+ — the two standalone plugins this package composes; useful for the config shapes it reuses.
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,55 @@
1
+ import { type AuthServerConfig, type AuthServerConfigInput } from "@vymalo/opencode-auth-core/lib";
2
+ import type { OtelPluginOptions } from "@vymalo/opencode-core-otel";
3
+ /** The `gateway` opt-in: which providers get the shared project bearer. */
4
+ export interface LightbridgeGatewayOptions {
5
+ /** OpenCode provider ids to inject `Authorization: Bearer <project-token>` on. */
6
+ providers: string[];
7
+ /**
8
+ * Optional project id for the shared RFC 8693 exchange (ADR-0012). When
9
+ * omitted, the exchange sends no `project_id` and the backend mints a token
10
+ * for the caller's **default project**.
11
+ */
12
+ projectId?: string;
13
+ }
14
+ /**
15
+ * Plugin options as written under the `opencode-lightbridge` entry of
16
+ * `plugin` in `opencode.json` (or served through `.well-known/opencode`).
17
+ * `auth` is the one IdP login every egress rides on; `gateway` and `otel` are
18
+ * each independently optional — omitting both is a valid, inert config (the
19
+ * plugin logs and no-ops). See ADR-0012.
20
+ */
21
+ export interface LightbridgeOptions {
22
+ auth: AuthServerConfigInput;
23
+ gateway?: LightbridgeGatewayOptions;
24
+ otel?: OtelPluginOptions;
25
+ /**
26
+ * Optional project id for the shared project-scoped token exchange. Fully
27
+ * optional: when omitted, the exchange sends no `project_id` and the backend
28
+ * mints a token for the caller's **default project**. An explicit `projectId`
29
+ * here wins over `gateway.projectId` when both are set.
30
+ */
31
+ projectId?: string;
32
+ }
33
+ /** `LightbridgeOptions` after parsing: `auth` fully validated + defaulted. */
34
+ export interface ParsedLightbridgeOptions {
35
+ auth: AuthServerConfig;
36
+ gateway?: LightbridgeGatewayOptions;
37
+ otel?: OtelPluginOptions;
38
+ /** Resolved project id (`projectId` ?? `gateway.projectId`), if either was set. */
39
+ projectId?: string;
40
+ }
41
+ /**
42
+ * Parse + validate the plugin's `pluginOptions` (2nd factory arg). `auth` is
43
+ * required and validated eagerly via auth-core's `validateAuthConfig` so a
44
+ * malformed IdP block fails fast with a field-level error — mirroring
45
+ * repo-auth/oauth2. `gateway` and `otel` are each optional and independently
46
+ * activate their module (see `opencode.ts`); an only-`auth` config is valid
47
+ * and inert.
48
+ */
49
+ export declare function parseLightbridgeOptions(raw: unknown): ParsedLightbridgeOptions;
50
+ /**
51
+ * Whether the parsed config has a module (gateway or otel) that needs the
52
+ * shared token — project-scoped when `projectId` is set, else a default-project
53
+ * token. Determines whether the one shared runtime is built.
54
+ */
55
+ export declare function needsProjectToken(options: ParsedLightbridgeOptions): boolean;
package/dist/config.js ADDED
@@ -0,0 +1,78 @@
1
+ import { validateAuthConfig } from "@vymalo/opencode-auth-core/lib";
2
+ function isRecord(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+ function asNonEmptyString(value, path) {
6
+ if (typeof value !== "string" || value.trim().length === 0) {
7
+ throw new Error(`${path} must be a non-empty string`);
8
+ }
9
+ return value.trim();
10
+ }
11
+ function asProviderList(value, path) {
12
+ if (!Array.isArray(value) || value.length === 0) {
13
+ throw new Error(`${path} must be a non-empty array of provider ids`);
14
+ }
15
+ return value.map((entry, index) => asNonEmptyString(entry, `${path}[${index}]`));
16
+ }
17
+ function parseGateway(raw, path) {
18
+ if (raw === undefined || raw === null) {
19
+ return undefined;
20
+ }
21
+ if (!isRecord(raw)) {
22
+ throw new Error(`${path} must be an object with \`providers\` (and an optional \`projectId\`)`);
23
+ }
24
+ const projectId = raw.projectId === undefined || raw.projectId === null ? undefined : asNonEmptyString(raw.projectId, `${path}.projectId`);
25
+ return {
26
+ providers: asProviderList(raw.providers, `${path}.providers`),
27
+ ...projectId ? { projectId } : {}
28
+ };
29
+ }
30
+ function parseOtel(raw, path) {
31
+ if (raw === undefined || raw === null) {
32
+ return undefined;
33
+ }
34
+ if (!isRecord(raw)) {
35
+ throw new Error(`${path} must be an object (OtelPluginOptions)`);
36
+ }
37
+ // Deep validation of the otel block itself is `resolveOtelConfig`'s job
38
+ // (it never throws — malformed/missing fields fall back to defaults); this
39
+ // parser only guards the block's own shape.
40
+ return raw;
41
+ }
42
+ /**
43
+ * Parse + validate the plugin's `pluginOptions` (2nd factory arg). `auth` is
44
+ * required and validated eagerly via auth-core's `validateAuthConfig` so a
45
+ * malformed IdP block fails fast with a field-level error — mirroring
46
+ * repo-auth/oauth2. `gateway` and `otel` are each optional and independently
47
+ * activate their module (see `opencode.ts`); an only-`auth` config is valid
48
+ * and inert.
49
+ */
50
+ export function parseLightbridgeOptions(raw) {
51
+ if (!isRecord(raw)) {
52
+ throw new Error("@vymalo/opencode-lightbridge options must be an object with at least an `auth` block");
53
+ }
54
+ if (!isRecord(raw.auth)) {
55
+ throw new Error("lightbridge.auth is required and must be an object (AuthServerConfigInput)");
56
+ }
57
+ const auth = validateAuthConfig(raw.auth);
58
+ const gateway = parseGateway(raw.gateway, "lightbridge.gateway");
59
+ const otel = parseOtel(raw.otel, "lightbridge.otel");
60
+ const explicitProjectId = typeof raw.projectId === "string" && raw.projectId.trim().length > 0 ? raw.projectId.trim() : undefined;
61
+ const projectId = explicitProjectId ?? gateway?.projectId;
62
+ return {
63
+ auth,
64
+ gateway,
65
+ otel,
66
+ projectId
67
+ };
68
+ }
69
+ /**
70
+ * Whether the parsed config has a module (gateway or otel) that needs the
71
+ * shared token — project-scoped when `projectId` is set, else a default-project
72
+ * token. Determines whether the one shared runtime is built.
73
+ */
74
+ export function needsProjectToken(options) {
75
+ return Boolean(options.gateway) || Boolean(options.otel);
76
+ }
77
+
78
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"mappings":"AAAA,SACE,0BAGK;AA4CP,SAAS,SAAS,OAAkD;CAClE,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,iBAAiB,OAAgB,MAAsB;CAC9D,IAAI,OAAO,UAAU,YAAY,MAAM,KAAK,CAAC,CAAC,WAAW,GAAG;EAC1D,MAAM,IAAI,MAAM,GAAG,KAAK,4BAA4B;CACtD;CACA,OAAO,MAAM,KAAK;AACpB;AAEA,SAAS,eAAe,OAAgB,MAAwB;CAC9D,IAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,GAAG;EAC/C,MAAM,IAAI,MAAM,GAAG,KAAK,2CAA2C;CACrE;CACA,OAAO,MAAM,KAAK,OAAO,UAAU,iBAAiB,OAAO,GAAG,KAAK,GAAG,MAAM,EAAE,CAAC;AACjF;AAEA,SAAS,aAAa,KAAc,MAAqD;CACvF,IAAI,QAAQ,aAAa,QAAQ,MAAM;EACrC,OAAO;CACT;CACA,IAAI,CAAC,SAAS,GAAG,GAAG;EAClB,MAAM,IAAI,MAAM,GAAG,KAAK,sEAAsE;CAChG;CACA,MAAM,YACJ,IAAI,cAAc,aAAa,IAAI,cAAc,OAC7C,YACA,iBAAiB,IAAI,WAAW,GAAG,KAAK,WAAW;CACzD,OAAO;EACL,WAAW,eAAe,IAAI,WAAW,GAAG,KAAK,WAAW;EAC5D,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;CACnC;AACF;AAEA,SAAS,UAAU,KAAc,MAA6C;CAC5E,IAAI,QAAQ,aAAa,QAAQ,MAAM;EACrC,OAAO;CACT;CACA,IAAI,CAAC,SAAS,GAAG,GAAG;EAClB,MAAM,IAAI,MAAM,GAAG,KAAK,uCAAuC;CACjE;;;;CAIA,OAAO;AACT;;;;;;;;;AAUA,OAAO,SAAS,wBAAwB,KAAwC;CAC9E,IAAI,CAAC,SAAS,GAAG,GAAG;EAClB,MAAM,IAAI,MACR,sFACF;CACF;CACA,IAAI,CAAC,SAAS,IAAI,IAAI,GAAG;EACvB,MAAM,IAAI,MAAM,4EAA4E;CAC9F;CAEA,MAAM,OAAO,mBAAmB,IAAI,IAAwC;CAC5E,MAAM,UAAU,aAAa,IAAI,SAAS,qBAAqB;CAC/D,MAAM,OAAO,UAAU,IAAI,MAAM,kBAAkB;CAEnD,MAAM,oBACJ,OAAO,IAAI,cAAc,YAAY,IAAI,UAAU,KAAK,CAAC,CAAC,SAAS,IAC/D,IAAI,UAAU,KAAK,IACnB;CACN,MAAM,YAAY,qBAAqB,SAAS;CAEhD,OAAO;EAAE;EAAM;EAAS;EAAM;CAAU;AAC1C;;;;;;AAOA,OAAO,SAAS,kBAAkB,SAA4C;CAC5E,OAAO,QAAQ,QAAQ,OAAO,KAAK,QAAQ,QAAQ,IAAI;AACzD","names":[],"sources":["../src/config.ts"],"version":3,"file":"config.js","sourceRoot":""}
@@ -0,0 +1 @@
1
+ export { default } from "./opencode.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // Intentionally tiny. OpenCode iterates every named export of the main entry
2
+ // and rejects anything that isn't a `Plugin` function, so the only thing this
3
+ // module exposes is the default plugin. Library/utility exports live in
4
+ // `./lib` (see lib.ts), which OpenCode never inspects.
5
+ export { default } from "./opencode.js";
6
+
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"mappings":";;;;AAIA,SAAS,eAAe","names":[],"sources":["../src/index.ts"],"version":3,"file":"index.js","sourceRoot":""}
package/dist/lib.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { createLightbridgePlugin, LightbridgePlugin, type LightbridgePluginFactoryOptions } from "./opencode.js";
2
+ export { LightbridgeRuntime, LIGHTBRIDGE_IDENTITY, DEFAULT_CACHE_NAMESPACE, DEFAULT_PROJECT_KEY, lightbridgeCacheDir, type LightbridgeRuntimeFactory, type LightbridgeRuntimeLike, type LightbridgeRuntimeOptions } from "./plugin.js";
3
+ export { parseLightbridgeOptions, needsProjectToken, type LightbridgeGatewayOptions, type LightbridgeOptions, type ParsedLightbridgeOptions } from "./config.js";
package/dist/lib.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createLightbridgePlugin, LightbridgePlugin } from "./opencode.js";
2
+ export { LightbridgeRuntime, LIGHTBRIDGE_IDENTITY, DEFAULT_CACHE_NAMESPACE, DEFAULT_PROJECT_KEY, lightbridgeCacheDir } from "./plugin.js";
3
+ export { parseLightbridgeOptions, needsProjectToken } from "./config.js";
4
+
5
+ //# sourceMappingURL=lib.js.map
@@ -0,0 +1 @@
1
+ {"mappings":"AAAA,SACE,yBACA,yBAEK;AAEP,SACE,oBACA,sBACA,yBACA,qBACA,2BAIK;AAEP,SACE,yBACA,yBAIK","names":[],"sources":["../src/lib.ts"],"version":3,"file":"lib.js","sourceRoot":""}
@@ -0,0 +1,49 @@
1
+ import type { Hooks, Plugin } from "@opencode-ai/plugin";
2
+ import { type Logger } from "@vymalo/opencode-auth-core/lib";
3
+ import { type EnvSource, type ExporterFactories } from "@vymalo/opencode-core-otel";
4
+ import { type LightbridgeRuntimeFactory } from "./plugin.js";
5
+ type OpenCodeConfig = Parameters<NonNullable<Hooks["config"]>>[0];
6
+ export interface LightbridgePluginFactoryOptions {
7
+ logger?: Logger;
8
+ /** Shared-runtime HTTP client (auth-core `TokenRuntime`). Tests only. */
9
+ fetchImpl?: typeof fetch;
10
+ onAuthorizationUrl?: (url: string) => Promise<void> | void;
11
+ /** Override the shared-runtime cache root (defaults to the OS cache dir). */
12
+ cacheDir?: string;
13
+ tokenExpirySkewMs?: number;
14
+ /**
15
+ * Build the shared `LightbridgeRuntimeLike` for `(auth, projectId)`.
16
+ * Defaults to a real `LightbridgeRuntime`; tests substitute a spy/fake so
17
+ * neither module ever touches the network.
18
+ */
19
+ runtimeFactory?: LightbridgeRuntimeFactory;
20
+ /** Injected environment; defaults to `process.env`. */
21
+ env?: EnvSource;
22
+ /** Substitute exporters (tests use in-memory ones). */
23
+ exporters?: ExporterFactories;
24
+ /** Injectable clock; defaults to `Date.now`. */
25
+ now?: () => number;
26
+ /** Skip `beforeExit`/`SIGINT`/`SIGTERM` registration (tests). */
27
+ registerProcessHandlers?: boolean;
28
+ /** Override the resolved host metadata (hostname, version) for tests. */
29
+ hostInfo?: {
30
+ hostname?: string;
31
+ version?: string;
32
+ };
33
+ /** How long a deferred resource attribute waits for its event. */
34
+ deferredTimeoutMs?: number;
35
+ }
36
+ /**
37
+ * Create the `@vymalo/opencode-lightbridge` umbrella plugin (ADR-0012): ONE
38
+ * shared `TokenRuntime`, built once from `auth`, drives both the gateway
39
+ * bearer (`gateway`) and the OTEL export credential (`otel`) — each module
40
+ * activates independently, and an `auth`-only config is a valid, inert
41
+ * plugin. `config` runs both modules' config-time logic (host log level +
42
+ * OTEL trace propagation); `chat.headers` is the gateway injector;
43
+ * `event`/`chat.message`/`tool.execute.*`/`chat.params`/`permission.ask`/
44
+ * `experimental.*` are the OTEL observers.
45
+ */
46
+ export declare function createLightbridgePlugin(factoryOptions?: LightbridgePluginFactoryOptions): Plugin;
47
+ export declare const LightbridgePlugin: Plugin;
48
+ export default LightbridgePlugin;
49
+ export type { OpenCodeConfig };
@@ -0,0 +1,283 @@
1
+ import { hostname } from "node:os";
2
+ import { createJsonConsoleLogger, DEFAULT_LOG_LEVEL, LOG_LEVEL_PRIORITY } from "@vymalo/opencode-auth-core/lib";
3
+ import { buildResource, createProviders, deferredAttribute, describeError, fromOpenCodeLogLevel, installTracePropagation, readVcsInfo, resolveOtelConfig, TelemetryRecorder } from "@vymalo/opencode-core-otel";
4
+ import { needsProjectToken, parseLightbridgeOptions } from "./config.js";
5
+ import { LightbridgeRuntime } from "./plugin.js";
6
+ const PLUGIN_SERVICE_NAME = "opencode-lightbridge-plugin";
7
+ /**
8
+ * Pipe plugin logs through OpenCode's `client.app.log`, JSON console as
9
+ * fallback. Mirrors every other plugin in the suite.
10
+ */
11
+ function createOpenCodeLogger(client, getMinLevel) {
12
+ const fallback = createJsonConsoleLogger("debug");
13
+ const consoleAll = /^(1|true|yes|on)$/i.test(process.env.VYMALO_PLUGIN_CONSOLE_LOG ?? "");
14
+ const write = (level, event, fields) => {
15
+ if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[getMinLevel()]) {
16
+ return;
17
+ }
18
+ if (consoleAll || level === "warn" || level === "error") {
19
+ fallback[level](event, fields);
20
+ }
21
+ const hostLevel = level === "trace" ? "debug" : level;
22
+ void client.app.log({ body: {
23
+ service: PLUGIN_SERVICE_NAME,
24
+ level: hostLevel,
25
+ message: event,
26
+ extra: fields
27
+ } }).catch(() => {
28
+ // best-effort forwarding; console logger is the reliable fallback.
29
+ });
30
+ };
31
+ return {
32
+ trace: (event, fields) => write("trace", event, fields),
33
+ debug: (event, fields) => write("debug", event, fields),
34
+ info: (event, fields) => write("info", event, fields),
35
+ warn: (event, fields) => write("warn", event, fields),
36
+ error: (event, fields) => write("error", event, fields)
37
+ };
38
+ }
39
+ function safeHostname() {
40
+ try {
41
+ return hostname() || undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ /**
47
+ * Drain buffered telemetry on process exit — same rationale as
48
+ * `@vymalo/opencode-otel`'s `registerExitHandlers`: the plugin API has no
49
+ * dispose hook, so without this a short CLI invocation loses everything still
50
+ * in a batch processor.
51
+ */
52
+ function registerExitHandlers(providers, logger, deferred) {
53
+ let done = false;
54
+ const drain = () => {
55
+ if (done) {
56
+ return;
57
+ }
58
+ done = true;
59
+ for (const attribute of deferred) {
60
+ attribute.abandon();
61
+ }
62
+ void providers.shutdown().catch((error) => {
63
+ logger.warn("lightbridge_otel_shutdown_failed", { error: describeError(error) });
64
+ });
65
+ };
66
+ process.once("beforeExit", drain);
67
+ process.once("SIGINT", drain);
68
+ process.once("SIGTERM", drain);
69
+ }
70
+ /**
71
+ * Build the umbrella's `TokenSource` for `createProviders`'s 5th argument: an
72
+ * async factory over the SHARED runtime, superseding otel's standalone
73
+ * `tokenCommand` path entirely (ADR-0012 — one credential, not two seams).
74
+ * Never throws — the OTLP exporters require that of every `headers()` call —
75
+ * so an exchange failure degrades to an unauthenticated export, which the
76
+ * collector then rejects (fail closed, same posture as the gateway header).
77
+ */
78
+ function createRuntimeTokenSource(runtime) {
79
+ return {
80
+ headers: async () => {
81
+ try {
82
+ const token = await runtime.getProjectToken({ interactive: false });
83
+ return { Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}` };
84
+ } catch {
85
+ return {};
86
+ }
87
+ },
88
+ // v1: no-op. The project token is short-lived and `getProjectToken`
89
+ // re-exchanges on its own expiry check every call — there is no stale
90
+ // in-memory copy for `invalidate` to drop, unlike the credential-helper
91
+ // `TokenSource` this replaces. See ADR-0012.
92
+ invalidate: () => {}
93
+ };
94
+ }
95
+ /**
96
+ * Build the gateway's `chat.headers` hook: inject the shared project bearer
97
+ * on `gateway.providers` only, fail closed on any exchange error (mirrors
98
+ * `@vymalo/opencode-repo-auth`'s `chat.headers`).
99
+ */
100
+ function createGatewayChatHeaders(providers, runtime, logger) {
101
+ return async (input, output) => {
102
+ const providerId = input.model?.providerID ?? input.provider?.info?.id;
103
+ if (!providerId || !providers.has(providerId)) {
104
+ logger.trace("lightbridge_gateway_chat_headers_skipped", { providerId });
105
+ return;
106
+ }
107
+ try {
108
+ const token = await runtime.getProjectToken({ interactive: true });
109
+ output.headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
110
+ logger.trace("lightbridge_gateway_bearer_injected", { providerId });
111
+ } catch {
112
+ logger.trace("lightbridge_gateway_no_bearer", { providerId });
113
+ }
114
+ };
115
+ }
116
+ /**
117
+ * Build the OTEL module: resource, providers (with the runtime-backed
118
+ * `TokenSource` when a shared runtime is available), recorder and exit
119
+ * handlers. Mirrors `@vymalo/opencode-otel`'s `createOtelPlugin` orchestration
120
+ * verbatim, minus the standalone `tokenCommand` seam.
121
+ */
122
+ async function buildOtelModule(input, otelConfig, logger, factoryOptions, runtime) {
123
+ const version = deferredAttribute(factoryOptions.deferredTimeoutMs);
124
+ const branch = deferredAttribute(factoryOptions.deferredTimeoutMs);
125
+ if (factoryOptions.hostInfo?.version) {
126
+ version.settle(factoryOptions.hostInfo.version);
127
+ }
128
+ const vcs = otelConfig.collectVcs ? await readVcsInfo(input.worktree ?? input.directory).catch(() => ({})) : {};
129
+ if (vcs.ref) {
130
+ branch.settle(vcs.ref);
131
+ }
132
+ const resource = buildResource(otelConfig, {
133
+ version: version.value,
134
+ hostname: factoryOptions.hostInfo?.hostname ?? safeHostname(),
135
+ projectName: input.project?.id,
136
+ directory: input.directory,
137
+ worktree: input.worktree,
138
+ branch: branch.value,
139
+ vcs
140
+ });
141
+ const tokenSource = runtime ? createRuntimeTokenSource(runtime) : undefined;
142
+ const providers = createProviders(otelConfig, resource, logger, factoryOptions.exporters, tokenSource);
143
+ const recorder = new TelemetryRecorder({
144
+ providers,
145
+ config: otelConfig,
146
+ logger,
147
+ now: factoryOptions.now,
148
+ resourceSinks: {
149
+ version: (value) => version.settle(value),
150
+ branch: (value) => branch.settle(value)
151
+ }
152
+ });
153
+ if (factoryOptions.registerProcessHandlers !== false) {
154
+ registerExitHandlers(providers, logger, [version, branch]);
155
+ }
156
+ logger.info("lightbridge_otel_enabled", {
157
+ serviceName: otelConfig.serviceName,
158
+ exporters: otelConfig.exporters,
159
+ endpoint: otelConfig.endpoint,
160
+ runtimeBackedTokenSource: Boolean(tokenSource)
161
+ });
162
+ return {
163
+ config: otelConfig,
164
+ recorder
165
+ };
166
+ }
167
+ function registerOtelHooks(hooks, otel) {
168
+ const { recorder } = otel;
169
+ hooks.event = async ({ event }) => {
170
+ recorder.onEvent(event);
171
+ };
172
+ hooks["chat.message"] = async (chatInput, chatOutput) => {
173
+ recorder.onChatMessage(chatInput, chatOutput);
174
+ };
175
+ hooks["tool.execute.before"] = async (toolInput) => {
176
+ recorder.onToolBefore(toolInput);
177
+ };
178
+ hooks["tool.execute.after"] = async (toolInput, toolOutput) => {
179
+ recorder.onToolAfter(toolInput, toolOutput);
180
+ };
181
+ hooks["chat.params"] = async (paramsInput, paramsOutput) => {
182
+ recorder.onChatParams(paramsInput, paramsOutput);
183
+ };
184
+ hooks["permission.ask"] = async (permissionInput, permissionOutput) => {
185
+ recorder.onPermissionAsk(permissionInput, permissionOutput);
186
+ };
187
+ hooks["experimental.text.complete"] = async (textInput, textOutput) => {
188
+ recorder.onTextComplete(textInput, textOutput);
189
+ };
190
+ hooks["experimental.compaction.autocontinue"] = async (compactionInput, compactionOutput) => {
191
+ recorder.onCompactionAutocontinue(compactionInput, compactionOutput);
192
+ };
193
+ }
194
+ /**
195
+ * Create the `@vymalo/opencode-lightbridge` umbrella plugin (ADR-0012): ONE
196
+ * shared `TokenRuntime`, built once from `auth`, drives both the gateway
197
+ * bearer (`gateway`) and the OTEL export credential (`otel`) — each module
198
+ * activates independently, and an `auth`-only config is a valid, inert
199
+ * plugin. `config` runs both modules' config-time logic (host log level +
200
+ * OTEL trace propagation); `chat.headers` is the gateway injector;
201
+ * `event`/`chat.message`/`tool.execute.*`/`chat.params`/`permission.ask`/
202
+ * `experimental.*` are the OTEL observers.
203
+ */
204
+ export function createLightbridgePlugin(factoryOptions = {}) {
205
+ return async (input, pluginOptions) => {
206
+ let currentLogLevel = DEFAULT_LOG_LEVEL;
207
+ const logger = factoryOptions.logger ?? createOpenCodeLogger(input.client, () => currentLogLevel);
208
+ let parsed;
209
+ try {
210
+ parsed = parseLightbridgeOptions(pluginOptions);
211
+ } catch (error) {
212
+ // A malformed config must not take down every other plugin's load —
213
+ // log and return inert hooks rather than throwing out of the factory.
214
+ logger.error("lightbridge_config_invalid", { error: describeError(error) });
215
+ return { config: async (hostConfig) => {
216
+ currentLogLevel = fromOpenCodeLogLevel(hostConfig.logLevel) ?? DEFAULT_LOG_LEVEL;
217
+ } };
218
+ }
219
+ const wantsProjectToken = needsProjectToken(parsed);
220
+ if (wantsProjectToken && !parsed.projectId) {
221
+ // `projectId` is fully optional — the exchange omits `project_id` and the
222
+ // backend mints a token for the caller's default project (ADR-0012).
223
+ logger.info("lightbridge_default_project", {
224
+ gateway: Boolean(parsed.gateway),
225
+ otel: Boolean(parsed.otel)
226
+ });
227
+ }
228
+ // The ONE shared runtime (ADR-0012) — constructed exactly once whenever a
229
+ // module (gateway/otel) needs a token, reused by both the gateway injector
230
+ // and the OTEL token source below. `projectId` is optional (undefined →
231
+ // default project). `undefined` runtime only for an auth-only config.
232
+ const buildRuntime = factoryOptions.runtimeFactory ?? ((auth, pid, options) => new LightbridgeRuntime(auth, pid, options));
233
+ const sharedRuntime = wantsProjectToken ? buildRuntime(parsed.auth, parsed.projectId, {
234
+ logger,
235
+ fetchImpl: factoryOptions.fetchImpl,
236
+ onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
237
+ cacheDir: factoryOptions.cacheDir,
238
+ tokenExpirySkewMs: factoryOptions.tokenExpirySkewMs
239
+ }) : undefined;
240
+ const hooks = {};
241
+ // ---- gateway module ----
242
+ // `sharedRuntime` is always defined here: it is built whenever `gateway`
243
+ // or `otel` is configured (`wantsProjectToken`).
244
+ if (parsed.gateway && sharedRuntime) {
245
+ hooks["chat.headers"] = createGatewayChatHeaders(new Set(parsed.gateway.providers), sharedRuntime, logger);
246
+ }
247
+ // ---- otel module ----
248
+ let otel;
249
+ if (parsed.otel) {
250
+ const otelConfig = resolveOtelConfig(parsed.otel, factoryOptions.env ?? process.env);
251
+ if (otelConfig.active) {
252
+ otel = await buildOtelModule(input, otelConfig, logger, factoryOptions, sharedRuntime);
253
+ registerOtelHooks(hooks, otel);
254
+ } else {
255
+ logger.info("lightbridge_otel_inactive", {
256
+ enabled: otelConfig.enabled,
257
+ reason: otelConfig.enabled ? "no_exporter_configured" : "disabled"
258
+ });
259
+ }
260
+ }
261
+ hooks.config = async (hostConfig) => {
262
+ currentLogLevel = fromOpenCodeLogLevel(hostConfig.logLevel) ?? DEFAULT_LOG_LEVEL;
263
+ if (otel?.config.propagateTraceContext) {
264
+ const activeOtel = otel;
265
+ const wrapped = installTracePropagation(hostConfig, {
266
+ getContext: () => activeOtel.recorder.currentChatContext(),
267
+ logger
268
+ });
269
+ logger.debug("lightbridge_otel_trace_propagation_ready", { providerCount: wrapped });
270
+ }
271
+ };
272
+ logger.info("lightbridge_plugin_ready", {
273
+ gateway: Boolean(hooks["chat.headers"]),
274
+ otel: Boolean(otel),
275
+ projectId: parsed.projectId ?? (wantsProjectToken ? "(default)" : undefined)
276
+ });
277
+ return hooks;
278
+ };
279
+ }
280
+ export const LightbridgePlugin = createLightbridgePlugin();
281
+ export default LightbridgePlugin;
282
+
283
+ //# sourceMappingURL=opencode.js.map
@@ -0,0 +1 @@
1
+ {"mappings":"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","names":[],"sources":["../src/opencode.ts"],"version":3,"file":"opencode.js","sourceRoot":""}
@@ -0,0 +1,87 @@
1
+ import { type AuthServerConfig, type Logger, type TokenSet } from "@vymalo/opencode-auth-core/lib";
2
+ /**
3
+ * Identity for the umbrella's single `TokenRuntime` (ADR-0012). Constant
4
+ * because v1 is single-IdP, same rationale as repo-auth's `HUMAN_IDENTITY`.
5
+ */
6
+ export declare const LIGHTBRIDGE_IDENTITY = "lightbridge";
7
+ /** Own cache namespace — separate from oauth2/repo-auth/otel's stores. */
8
+ export declare const DEFAULT_CACHE_NAMESPACE = "opencode-lightbridge";
9
+ /**
10
+ * Cache key for the default-project token — used when no `projectId` is
11
+ * configured, so the exchange sends no `project_id` and the backend mints a
12
+ * token for the caller's default project (ADR-0012). Distinct from any real
13
+ * project id so the two never collide in the token cache.
14
+ */
15
+ export declare const DEFAULT_PROJECT_KEY = "__default__";
16
+ export interface LightbridgeRuntimeOptions {
17
+ logger?: Logger;
18
+ fetchImpl?: typeof fetch;
19
+ onAuthorizationUrl?: (url: string) => Promise<void> | void;
20
+ /** Override the cache root (defaults to the OS cache dir convention). */
21
+ cacheDir?: string;
22
+ tokenExpirySkewMs?: number;
23
+ }
24
+ /**
25
+ * The minimal surface `opencode.ts` needs from the shared runtime — narrow on
26
+ * purpose so tests can inject a spy/fake without constructing a real
27
+ * `TokenRuntime` (no network, no disk).
28
+ */
29
+ export interface LightbridgeRuntimeLike {
30
+ getProjectToken(options?: {
31
+ interactive?: boolean;
32
+ }): Promise<TokenSet>;
33
+ reset?(): Promise<void>;
34
+ }
35
+ /**
36
+ * Builds a `LightbridgeRuntimeLike` for a given `(auth, projectId)` pair.
37
+ * `projectId` is optional — `undefined` mints a default-project token.
38
+ */
39
+ export type LightbridgeRuntimeFactory = (auth: AuthServerConfig, projectId: string | undefined, options: LightbridgeRuntimeOptions) => LightbridgeRuntimeLike;
40
+ /**
41
+ * The ONE shared `TokenRuntime` the umbrella plugin builds (ADR-0012): a
42
+ * single human login, reused as the subject token for a project-scoped RFC
43
+ * 8693 exchange whose result is consumed by BOTH the gateway (`chat.headers`)
44
+ * and OTEL (`TokenSource.headers()`) — that sharing is the entire point of
45
+ * the plugin. Lifecycle mirrors `@vymalo/opencode-repo-auth`'s "model b"
46
+ * (`RepoAuthPlugin.resolveProjectToken`): auth-core has no higher-level
47
+ * primitive for this yet, so the umbrella keeps its own thin copy rather than
48
+ * reaching into a sibling package's internals.
49
+ */
50
+ export declare class LightbridgeRuntime implements LightbridgeRuntimeLike {
51
+ private readonly projectId;
52
+ private readonly runtime;
53
+ private readonly logger;
54
+ private readonly tokenExpirySkewMs;
55
+ /** Token-cache key: the project id, or `DEFAULT_PROJECT_KEY` when unset. */
56
+ private readonly projectKey;
57
+ private inFlightExchange?;
58
+ constructor(auth: AuthServerConfig, projectId: string | undefined, options?: LightbridgeRuntimeOptions);
59
+ /** Non-network read of the cached project token, if any. */
60
+ getCachedProjectToken(): Promise<TokenSet | undefined>;
61
+ /**
62
+ * Resolve a usable project token, shared by every caller (gateway
63
+ * `chat.headers`, OTEL `TokenSource.headers()`):
64
+ *
65
+ * cached usable? ──yes──▶ return it
66
+ * │ no
67
+ * ▼
68
+ * ensure human root (refresh-only unless `interactive`)
69
+ * ▼
70
+ * exchangeTo(projectKey, humanToken, projectId ? { project_id } : {})
71
+ * ▼ (no projectId → backend picks the default project)
72
+ * cache + return
73
+ *
74
+ * Concurrent callers (a chat request racing an OTEL export) share the
75
+ * in-flight exchange — at most one exchange POST per cache-miss window.
76
+ * Never invents a token: an exchange failure propagates to the caller,
77
+ * which is expected to fail closed (no header / empty `headers()`).
78
+ */
79
+ getProjectToken(options?: {
80
+ interactive?: boolean;
81
+ }): Promise<TokenSet>;
82
+ private performExchange;
83
+ /** Drop the on-disk human + project tokens for this identity. */
84
+ reset(): Promise<void>;
85
+ }
86
+ /** Absolute path to the lightbridge cache directory (for diagnostics / tests). */
87
+ export declare function lightbridgeCacheDir(cacheRoot: string): string;
package/dist/plugin.js ADDED
@@ -0,0 +1,132 @@
1
+ import { join } from "node:path";
2
+ import { createJsonConsoleLogger, DEFAULT_TOKEN_EXPIRY_SKEW_MS, resolveCacheRoot, TokenRuntime } from "@vymalo/opencode-auth-core/lib";
3
+ /**
4
+ * Identity for the umbrella's single `TokenRuntime` (ADR-0012). Constant
5
+ * because v1 is single-IdP, same rationale as repo-auth's `HUMAN_IDENTITY`.
6
+ */
7
+ export const LIGHTBRIDGE_IDENTITY = "lightbridge";
8
+ /** Own cache namespace — separate from oauth2/repo-auth/otel's stores. */
9
+ export const DEFAULT_CACHE_NAMESPACE = "opencode-lightbridge";
10
+ /**
11
+ * Cache key for the default-project token — used when no `projectId` is
12
+ * configured, so the exchange sends no `project_id` and the backend mints a
13
+ * token for the caller's default project (ADR-0012). Distinct from any real
14
+ * project id so the two never collide in the token cache.
15
+ */
16
+ export const DEFAULT_PROJECT_KEY = "__default__";
17
+ /**
18
+ * Whether a cached project token is usable. Mirrors repo-auth's
19
+ * `isProjectTokenUsable`: the project token carries no refresh token, so an
20
+ * undefined `expiresAt` must be treated as expired (re-exchange), never as
21
+ * non-expiring.
22
+ */
23
+ function isProjectTokenUsable(token, skewMs) {
24
+ if (!token?.accessToken) {
25
+ return false;
26
+ }
27
+ if (token.expiresAt === undefined) {
28
+ return false;
29
+ }
30
+ return Date.now() + skewMs < token.expiresAt;
31
+ }
32
+ /**
33
+ * The ONE shared `TokenRuntime` the umbrella plugin builds (ADR-0012): a
34
+ * single human login, reused as the subject token for a project-scoped RFC
35
+ * 8693 exchange whose result is consumed by BOTH the gateway (`chat.headers`)
36
+ * and OTEL (`TokenSource.headers()`) — that sharing is the entire point of
37
+ * the plugin. Lifecycle mirrors `@vymalo/opencode-repo-auth`'s "model b"
38
+ * (`RepoAuthPlugin.resolveProjectToken`): auth-core has no higher-level
39
+ * primitive for this yet, so the umbrella keeps its own thin copy rather than
40
+ * reaching into a sibling package's internals.
41
+ */
42
+ export class LightbridgeRuntime {
43
+ projectId;
44
+ runtime;
45
+ logger;
46
+ tokenExpirySkewMs;
47
+ /** Token-cache key: the project id, or `DEFAULT_PROJECT_KEY` when unset. */
48
+ projectKey;
49
+ inFlightExchange;
50
+ constructor(auth, projectId, options = {}) {
51
+ this.projectId = projectId;
52
+ this.projectKey = projectId ?? DEFAULT_PROJECT_KEY;
53
+ this.logger = options.logger ?? createJsonConsoleLogger("info");
54
+ this.tokenExpirySkewMs = typeof options.tokenExpirySkewMs === "number" && Number.isFinite(options.tokenExpirySkewMs) && options.tokenExpirySkewMs > 0 ? options.tokenExpirySkewMs : DEFAULT_TOKEN_EXPIRY_SKEW_MS;
55
+ this.runtime = new TokenRuntime(LIGHTBRIDGE_IDENTITY, auth, {
56
+ logger: this.logger,
57
+ fetchImpl: options.fetchImpl,
58
+ onAuthorizationUrl: options.onAuthorizationUrl,
59
+ cacheDir: options.cacheDir ?? join(resolveCacheRoot(), DEFAULT_CACHE_NAMESPACE),
60
+ tokenExpirySkewMs: this.tokenExpirySkewMs
61
+ });
62
+ }
63
+ /** Non-network read of the cached project token, if any. */
64
+ async getCachedProjectToken() {
65
+ return this.runtime.getExchangedByKey(this.projectKey);
66
+ }
67
+ /**
68
+ * Resolve a usable project token, shared by every caller (gateway
69
+ * `chat.headers`, OTEL `TokenSource.headers()`):
70
+ *
71
+ * cached usable? ──yes──▶ return it
72
+ * │ no
73
+ * ▼
74
+ * ensure human root (refresh-only unless `interactive`)
75
+ * ▼
76
+ * exchangeTo(projectKey, humanToken, projectId ? { project_id } : {})
77
+ * ▼ (no projectId → backend picks the default project)
78
+ * cache + return
79
+ *
80
+ * Concurrent callers (a chat request racing an OTEL export) share the
81
+ * in-flight exchange — at most one exchange POST per cache-miss window.
82
+ * Never invents a token: an exchange failure propagates to the caller,
83
+ * which is expected to fail closed (no header / empty `headers()`).
84
+ */
85
+ async getProjectToken(options = {}) {
86
+ const cached = await this.getCachedProjectToken();
87
+ if (isProjectTokenUsable(cached, this.tokenExpirySkewMs)) {
88
+ this.logger.trace("lightbridge_exchange_cache_hit", { projectId: this.projectId });
89
+ return cached;
90
+ }
91
+ this.logger.trace("lightbridge_exchange_cache_miss", { projectId: this.projectId });
92
+ if (this.inFlightExchange) {
93
+ return this.inFlightExchange;
94
+ }
95
+ const exchange = this.performExchange(options);
96
+ this.inFlightExchange = exchange;
97
+ try {
98
+ return await exchange;
99
+ } finally {
100
+ if (this.inFlightExchange === exchange) {
101
+ this.inFlightExchange = undefined;
102
+ }
103
+ }
104
+ }
105
+ async performExchange(options) {
106
+ const human = await this.runtime.ensure({ interactive: options.interactive });
107
+ this.logger.info("lightbridge_exchange_started", { projectId: this.projectId ?? "(default)" });
108
+ try {
109
+ // No `projectId` → send no `project_id` param; the backend mints a token
110
+ // for the caller's default project (ADR-0012).
111
+ const exchanged = await this.runtime.exchangeTo(this.projectKey, human.accessToken, this.projectId ? { project_id: this.projectId } : {});
112
+ this.logger.info("lightbridge_exchange_success", { projectId: this.projectId ?? "(default)" });
113
+ return exchanged;
114
+ } catch (error) {
115
+ this.logger.error("lightbridge_exchange_failed", {
116
+ projectId: this.projectId,
117
+ error: error instanceof Error ? error.message : String(error)
118
+ });
119
+ throw error;
120
+ }
121
+ }
122
+ /** Drop the on-disk human + project tokens for this identity. */
123
+ async reset() {
124
+ await this.runtime.reset();
125
+ }
126
+ }
127
+ /** Absolute path to the lightbridge cache directory (for diagnostics / tests). */
128
+ export function lightbridgeCacheDir(cacheRoot) {
129
+ return join(cacheRoot, DEFAULT_CACHE_NAMESPACE);
130
+ }
131
+
132
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"mappings":"AAAA,SAAS,YAAY;AAErB,SACE,yBACA,8BACA,kBACA,oBAIK;;;;;AAMP,OAAO,MAAM,uBAAuB;;AAGpC,OAAO,MAAM,0BAA0B;;;;;;;AAQvC,OAAO,MAAM,sBAAsB;;;;;;;AAqCnC,SAAS,qBAAqB,OAA6B,QAAmC;CAC5F,IAAI,CAAC,OAAO,aAAa;EACvB,OAAO;CACT;CACA,IAAI,MAAM,cAAc,WAAW;EACjC,OAAO;CACT;CACA,OAAO,KAAK,IAAI,IAAI,SAAS,MAAM;AACrC;;;;;;;;;;;AAYA,OAAO,MAAM,mBAAqD;CAU7C;CATnB,AAAiB;CACjB,AAAiB;CACjB,AAAiB;;CAEjB,AAAiB;CACjB,AAAQ;CAER,YACE,MACA,AAAiB,WACjB,UAAqC,CAAC,GACtC;EAFiB;EAGjB,KAAK,aAAa,aAAa;EAC/B,KAAK,SAAS,QAAQ,UAAU,wBAAwB,MAAM;EAC9D,KAAK,oBACH,OAAO,QAAQ,sBAAsB,YACrC,OAAO,SAAS,QAAQ,iBAAiB,KACzC,QAAQ,oBAAoB,IACxB,QAAQ,oBACR;EAEN,KAAK,UAAU,IAAI,aAAa,sBAAsB,MAAM;GAC1D,QAAQ,KAAK;GACb,WAAW,QAAQ;GACnB,oBAAoB,QAAQ;GAC5B,UAAU,QAAQ,YAAY,KAAK,iBAAiB,GAAG,uBAAuB;GAC9E,mBAAmB,KAAK;EAC1B,CAAC;CACH;;CAGA,MAAM,wBAAuD;EAC3D,OAAO,KAAK,QAAQ,kBAAkB,KAAK,UAAU;CACvD;;;;;;;;;;;;;;;;;;;CAoBA,MAAM,gBAAgB,UAAqC,CAAC,GAAsB;EAChF,MAAM,SAAS,MAAM,KAAK,sBAAsB;EAChD,IAAI,qBAAqB,QAAQ,KAAK,iBAAiB,GAAG;GACxD,KAAK,OAAO,MAAM,kCAAkC,EAAE,WAAW,KAAK,UAAU,CAAC;GACjF,OAAO;EACT;EACA,KAAK,OAAO,MAAM,mCAAmC,EAAE,WAAW,KAAK,UAAU,CAAC;EAElF,IAAI,KAAK,kBAAkB;GACzB,OAAO,KAAK;EACd;EAEA,MAAM,WAAW,KAAK,gBAAgB,OAAO;EAC7C,KAAK,mBAAmB;EACxB,IAAI;GACF,OAAO,MAAM;EACf,UAAU;GACR,IAAI,KAAK,qBAAqB,UAAU;IACtC,KAAK,mBAAmB;GAC1B;EACF;CACF;CAEA,MAAc,gBAAgB,SAAuD;EACnF,MAAM,QAAQ,MAAM,KAAK,QAAQ,OAAO,EAAE,aAAa,QAAQ,YAAY,CAAC;EAC5E,KAAK,OAAO,KAAK,gCAAgC,EAAE,WAAW,KAAK,aAAa,YAAY,CAAC;EAC7F,IAAI;;;GAGF,MAAM,YAAY,MAAM,KAAK,QAAQ,WACnC,KAAK,YACL,MAAM,aACN,KAAK,YAAY,EAAE,YAAY,KAAK,UAAU,IAAI,CAAC,CACrD;GACA,KAAK,OAAO,KAAK,gCAAgC,EAC/C,WAAW,KAAK,aAAa,YAC/B,CAAC;GACD,OAAO;EACT,SAAS,OAAO;GACd,KAAK,OAAO,MAAM,+BAA+B;IAC/C,WAAW,KAAK;IAChB,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GAC9D,CAAC;GACD,MAAM;EACR;CACF;;CAGA,MAAM,QAAuB;EAC3B,MAAM,KAAK,QAAQ,MAAM;CAC3B;AACF;;AAGA,OAAO,SAAS,oBAAoB,WAA2B;CAC7D,OAAO,KAAK,WAAW,uBAAuB;AAChD","names":[],"sources":["../src/plugin.ts"],"version":3,"file":"plugin.js","sourceRoot":""}
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@vymalo/opencode-lightbridge",
3
+ "version": "0.14.1",
4
+ "description": "Umbrella OpenCode plugin for ADR-0012: one shared credential (TokenRuntime) drives both the LLM gateway bearer and OTEL export, so a developer authenticates once and every egress rides the same project-scoped token.",
5
+ "license": "MIT",
6
+ "author": "vymalo contributors",
7
+ "homepage": "https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit.git",
11
+ "directory": "packages/opencode-lightbridge"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "oauth2",
20
+ "token-exchange",
21
+ "project",
22
+ "gateway",
23
+ "opentelemetry",
24
+ "otel",
25
+ "otlp",
26
+ "observability"
27
+ ],
28
+ "type": "module",
29
+ "main": "dist/index.js",
30
+ "types": "dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js"
35
+ },
36
+ "./lib": {
37
+ "types": "./dist/lib.d.ts",
38
+ "import": "./dist/lib.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "sideEffects": false,
43
+ "files": [
44
+ "dist"
45
+ ],
46
+ "engines": {
47
+ "node": ">=22"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "dependencies": {
53
+ "@opencode-ai/plugin": "1.15.10",
54
+ "@opentelemetry/api": "^1.9.1",
55
+ "@opentelemetry/api-logs": "^0.221.0",
56
+ "@vymalo/opencode-auth-core": "0.14.1",
57
+ "@vymalo/opencode-core-otel": "0.14.1"
58
+ },
59
+ "devDependencies": {
60
+ "@opentelemetry/sdk-logs": "^0.221.0",
61
+ "@opentelemetry/sdk-trace": "^2.10.0",
62
+ "vite": "^8.2.1",
63
+ "vitest": "^4.1.7"
64
+ },
65
+ "scripts": {
66
+ "build": "node ../../scripts/build-package.mjs",
67
+ "lint": "biome lint .",
68
+ "typecheck": "tsc -p tsconfig.json --noEmit",
69
+ "test": "vitest run",
70
+ "coverage": "vitest run --coverage",
71
+ "format": "biome format --write .",
72
+ "format:check": "biome format ."
73
+ }
74
+ }