@vymalo/opencode-lightbridge 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/opencode.js CHANGED
@@ -1,40 +1,19 @@
1
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
- };
2
+ import { join } from "node:path";
3
+ import { Plugin } from "@opencode/plugin/effect";
4
+ import { defaultTextMapSetter } from "@opentelemetry/api";
5
+ import { W3CTraceContextPropagator } from "@opentelemetry/core";
6
+ import { buildResource, createProviders, deferredAttribute, describeError, readVcsInfo, registerExitHandlers, resolveOtelConfig, TelemetryRecorder } from "@vymalo/opencode-core-otel";
7
+ import { applyResponsesApiSettings, mergeDiscoveredModels, propagateCachedBearer, ProviderModelSyncEngine, resolveCachedBearer, resolveProviderPackage, upsertManagedProvider } from "@vymalo/opencode-provider-sync/lib";
8
+ import { Effect, Stream } from "effect";
9
+ import { hasOAuth2Conflict, needsProjectToken, parseLightbridgeOptions } from "./config.js";
10
+ import { captureHostContext, createEffectLogger, DEFAULT_LOG_LEVEL, resolveLogLevel, runInHost } from "./logging.js";
11
+ import { adaptContext, adaptPermission, adaptPrompt, adaptToolAfter, adaptToolBefore, OtelEventAdapter } from "./otel-adapter.js";
12
+ import { LightbridgeRuntime, ROOT_CACHE_NAMESPACE, ROOT_CACHE_SEGMENT } from "./plugin.js";
13
+ /** Stable plugin id. v2 requires one and keys storage/registrations by it. */
14
+ export const PLUGIN_ID = "vymalo.lightbridge";
15
+ function errorMessage(error) {
16
+ return error instanceof Error ? error.message : String(error);
38
17
  }
39
18
  function safeHostname() {
40
19
  try {
@@ -44,36 +23,19 @@ function safeHostname() {
44
23
  }
45
24
  }
46
25
  /**
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
26
  * Build the umbrella's `TokenSource` for `createProviders`'s 5th argument: an
72
27
  * async factory over the SHARED runtime, superseding otel's standalone
73
28
  * `tokenCommand` path entirely (ADR-0012 — one credential, not two seams).
74
29
  * 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).
30
+ * so an exchange failure resolves `headers()` to `undefined` rather than `{}`.
31
+ * That distinction is load-bearing (ADR-0015): `{}` would still let the export
32
+ * fire with no `Authorization` header, which the collector then rejects at the
33
+ * network — the exact bug this fixes. `undefined` tells the shared gate in
34
+ * `@vymalo/opencode-core-otel` (`withFailureLogging`) to skip the export
35
+ * before any request goes out, so a logged-out session goes quiet instead of
36
+ * hammering the collector with 401s. Because `headers()` is called fresh on
37
+ * every export attempt, a later successful login resumes exporting on the
38
+ * next flush with nothing to restart.
77
39
  */
78
40
  function createRuntimeTokenSource(runtime) {
79
41
  return {
@@ -82,7 +44,7 @@ function createRuntimeTokenSource(runtime) {
82
44
  const token = await runtime.getProjectToken({ interactive: false });
83
45
  return { Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}` };
84
46
  } catch {
85
- return {};
47
+ return undefined;
86
48
  }
87
49
  },
88
50
  // v1: no-op. The project token is short-lived and `getProjectToken`
@@ -93,48 +55,96 @@ function createRuntimeTokenSource(runtime) {
93
55
  };
94
56
  }
95
57
  /**
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`).
58
+ * Translate `auth` (already-validated `AuthServerConfig`) + `register` into
59
+ * the ONE `ProviderServerConfig` the shared `ProviderModelSyncEngine` needs
60
+ * (ADR-0017 requirement 1 — "everything oauth2 does"). `auth.id` is the
61
+ * provider id AND the shared cache identity (see `plugin.ts`'s
62
+ * `ROOT_CACHE_SEGMENT`/`ROOT_CACHE_NAMESPACE`).
99
63
  */
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
- }
64
+ function buildRegisterServerConfig(auth, register) {
65
+ return {
66
+ id: auth.id,
67
+ name: register.name ?? auth.id,
68
+ issuer: auth.issuer,
69
+ baseURL: register.baseURL,
70
+ clientId: auth.clientId,
71
+ clientSecret: auth.clientSecret,
72
+ scopes: auth.scopes,
73
+ syncIntervalMinutes: register.syncIntervalMinutes ?? 60,
74
+ nameOverrides: register.nameOverrides ?? {},
75
+ authorizationEndpoint: auth.authorizationEndpoint,
76
+ tokenEndpoint: auth.tokenEndpoint,
77
+ deviceAuthorizationEndpoint: auth.deviceAuthorizationEndpoint,
78
+ jwksUri: auth.jwksUri,
79
+ redirectPort: auth.redirectPort,
80
+ authFlow: auth.authFlow,
81
+ pkce: auth.pkce,
82
+ subjectTokenSource: auth.subjectTokenSource,
83
+ tokenExchangeAudience: auth.tokenExchangeAudience
114
84
  };
115
85
  }
86
+ function asSettings(value) {
87
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
88
+ }
89
+ /**
90
+ * Resolve the one registered provider's name/package/settings from
91
+ * `register` and hand them to provider-sync's `upsertManagedProvider`, which
92
+ * owns the add-or-update-in-place behaviour.
93
+ */
94
+ function upsertRegisteredProvider(editor, providerId, register, logger) {
95
+ const existing = editor.get(providerId)?.provider;
96
+ // `Provider.Info.name` is required, so a provider declared without a name
97
+ // reads back as its own id — treat that as "unset" and let `register.name` win.
98
+ const existingName = existing && existing.name !== providerId ? existing.name : undefined;
99
+ upsertManagedProvider(editor, providerId, {
100
+ name: existingName ?? register.name ?? providerId,
101
+ package: resolveProviderPackage(register.responseApi),
102
+ settings: applyResponsesApiSettings({
103
+ ...asSettings(existing?.settings),
104
+ baseURL: register.baseURL
105
+ }, register.responseApi, providerId, logger)
106
+ });
107
+ }
108
+ /**
109
+ * `otel.tokenCommand`/`tokenHeader`/`tokenPrefix` are accepted by
110
+ * `parseLightbridgeOptions` (they are just `OtelPluginOptions` fields) but
111
+ * never take effect here: `buildOtelModule` always injects the shared
112
+ * runtime-backed `TokenSource` whenever a runtime exists, and that injected
113
+ * source unconditionally wins over `config.tokenCommand` in
114
+ * `createProviders` (see `providers.ts`). Silently accepting and ignoring a
115
+ * credential-helper config is exactly the kind of no-op ADR-0015 calls out, so
116
+ * this logs once at `debug` rather than staying quiet — `debug` because it's
117
+ * an informational no-op, not a warning-worthy misconfiguration, and no
118
+ * plugin in this suite prints to the terminal by default anyway (ADR-0014).
119
+ */
120
+ function warnIfTokenCommandConfigIgnored(otel, env, logger) {
121
+ const explicit = otel.tokenCommand !== undefined || otel.tokenHeader !== undefined || otel.tokenPrefix !== undefined || Boolean(env.OPENCODE_OTEL_TOKEN_COMMAND) || Boolean(env.OPENCODE_OTEL_TOKEN_HEADER) || Boolean(env.OPENCODE_OTEL_TOKEN_PREFIX);
122
+ if (explicit) {
123
+ logger.debug("lightbridge_otel_token_command_ignored", { reason: "otel.tokenCommand/tokenHeader/tokenPrefix are ignored — the shared lightbridge auth token always supersedes them" });
124
+ }
125
+ }
116
126
  /**
117
127
  * Build the OTEL module: resource, providers (with the runtime-backed
118
128
  * `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.
129
+ * handlers. Mirrors `@vymalo/opencode-otel`'s orchestration verbatim, minus
130
+ * the standalone `tokenCommand` seam.
121
131
  */
122
- async function buildOtelModule(input, otelConfig, logger, factoryOptions, runtime) {
132
+ async function buildOtelModule(location, otelConfig, logger, factoryOptions, runtime) {
123
133
  const version = deferredAttribute(factoryOptions.deferredTimeoutMs);
124
134
  const branch = deferredAttribute(factoryOptions.deferredTimeoutMs);
125
135
  if (factoryOptions.hostInfo?.version) {
126
136
  version.settle(factoryOptions.hostInfo.version);
127
137
  }
128
- const vcs = otelConfig.collectVcs ? await readVcsInfo(input.worktree ?? input.directory).catch(() => ({})) : {};
138
+ const vcs = otelConfig.collectVcs ? await readVcsInfo(location.directory).catch(() => ({})) : {};
129
139
  if (vcs.ref) {
130
140
  branch.settle(vcs.ref);
131
141
  }
132
142
  const resource = buildResource(otelConfig, {
133
143
  version: version.value,
134
144
  hostname: factoryOptions.hostInfo?.hostname ?? safeHostname(),
135
- projectName: input.project?.id,
136
- directory: input.directory,
137
- worktree: input.worktree,
145
+ projectName: location.projectId,
146
+ directory: location.directory,
147
+ worktree: location.directory,
138
148
  branch: branch.value,
139
149
  vcs
140
150
  });
@@ -151,7 +161,8 @@ async function buildOtelModule(input, otelConfig, logger, factoryOptions, runtim
151
161
  }
152
162
  });
153
163
  if (factoryOptions.registerProcessHandlers !== false) {
154
- registerExitHandlers(providers, logger, [version, branch]);
164
+ // Flush on exit, and hand the default signal action back (ADR-0018).
165
+ registerExitHandlers(providers, logger, [version, branch], { eventPrefix: "lightbridge_otel" });
155
166
  }
156
167
  logger.info("lightbridge_otel_enabled", {
157
168
  serviceName: otelConfig.serviceName,
@@ -161,121 +172,306 @@ async function buildOtelModule(input, otelConfig, logger, factoryOptions, runtim
161
172
  });
162
173
  return {
163
174
  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);
175
+ recorder,
176
+ version
192
177
  };
193
178
  }
179
+ const propagator = new W3CTraceContextPropagator();
180
+ /**
181
+ * Subscribe to the host event stream and drive the recorder. Only the event
182
+ * types the adapter understands are requested.
183
+ */
184
+ const OTEL_EVENT_TYPES = [
185
+ "session.created",
186
+ "session.deleted",
187
+ "session.step.started",
188
+ "session.step.ended",
189
+ "session.step.failed",
190
+ "session.text.ended",
191
+ "session.retry.scheduled",
192
+ "session.compaction.ended",
193
+ "session.execution.succeeded",
194
+ "session.execution.failed",
195
+ "session.execution.interrupted",
196
+ "permission.asked",
197
+ "permission.replied",
198
+ "vcs.branch.updated",
199
+ "installation.updated"
200
+ ];
194
201
  /**
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.
202
+ * Create the `@vymalo/opencode-lightbridge` umbrella plugin (ADR-0012,
203
+ * ADR-0017) for the OpenCode v2 plugin API (ADR-0019): ONE shared
204
+ * `TokenRuntime`, built once from `auth`, drives both the gateway bearer
205
+ * (`gateway`) and the OTEL export credential (`otel`) — each module activates
206
+ * independently, and an `auth`-only config is a valid, inert plugin.
207
+ *
208
+ * - `register`: `provider.transform` registers the provider and merges the
209
+ * discovered models and cached bearer; a queued background build runs the
210
+ * `ProviderModelSyncEngine` and calls `provider.reload()` (the transform is
211
+ * synchronous and cheap by contract — same shape as oauth2).
212
+ * - `gateway`: `session.hook("model.request")` injects the shared bearer and
213
+ * fails closed by dying the request when no bearer can be produced.
214
+ * - `otel`: `event.subscribe` + `prompt`/`context`/`tool`/`permission` hooks
215
+ * feed the recorder through `otel-adapter.ts`; `http.request` carries W3C
216
+ * trace context.
203
217
  */
204
218
  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"
219
+ return Plugin.define({
220
+ id: PLUGIN_ID,
221
+ effect: Effect.fn(function* (ctx) {
222
+ // v2 gives plugins no `config` object: this plugin's own `logLevel`
223
+ // option, then the host's `OPENCODE_LOG_LEVEL`.
224
+ const logLevel = resolveLogLevel(ctx.options.logLevel) ?? resolveLogLevel(process.env.OPENCODE_LOG_LEVEL) ?? DEFAULT_LOG_LEVEL;
225
+ // Captured INSIDE the plugin's own Effect so records reach the host's file
226
+ // logger and not the terminal (ADR-0014 / ADR-0019).
227
+ const hostContext = yield* captureHostContext;
228
+ const logger = factoryOptions.logger ?? createEffectLogger(hostContext, () => logLevel);
229
+ let parsed;
230
+ try {
231
+ parsed = parseLightbridgeOptions(ctx.options);
232
+ } catch (error) {
233
+ // A malformed config must not take down every other plugin's load —
234
+ // log and register nothing rather than throwing out of the plugin.
235
+ logger.error("lightbridge_config_invalid", { error: describeError(error) });
236
+ return;
237
+ }
238
+ const wantsProjectToken = needsProjectToken(parsed);
239
+ if (wantsProjectToken && !parsed.projectId) {
240
+ // `projectId` is fully optional — the exchange omits `project_id` and the
241
+ // backend mints a token for the caller's default project (ADR-0012).
242
+ logger.info("lightbridge_default_project", {
243
+ gateway: Boolean(parsed.gateway),
244
+ otel: Boolean(parsed.otel)
258
245
  });
259
246
  }
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
247
+ // The ONE shared runtime (ADR-0012) — constructed exactly once whenever a
248
+ // module (gateway/otel) needs a token, reused by both the gateway injector
249
+ // and the OTEL token source below. `undefined` only for a register/auth-only
250
+ // config.
251
+ const buildRuntime = factoryOptions.runtimeFactory ?? ((auth, pid, options) => new LightbridgeRuntime(auth, pid, options));
252
+ const sharedRuntime = wantsProjectToken ? buildRuntime(parsed.auth, parsed.projectId, {
253
+ logger,
254
+ fetchImpl: factoryOptions.fetchImpl,
255
+ onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
256
+ cacheDir: factoryOptions.cacheDir,
257
+ tokenExpirySkewMs: factoryOptions.tokenExpirySkewMs,
258
+ exchange: parsed.gateway?.exchange ?? false
259
+ }) : undefined;
260
+ const reloadProviders = () => runInHost(hostContext, ctx.provider.reload());
261
+ // ---- register module (ADR-0017 requirement 1) ----
262
+ let engine;
263
+ let registerChain = Promise.resolve();
264
+ // A build has been queued for the current non-collision stretch.
265
+ let registerQueued = false;
266
+ // oauth2 owns the provider id: register must neither build nor run an engine.
267
+ let registerSuppressed = false;
268
+ let skipLogged = false;
269
+ let registerBearer;
270
+ const stamped = new Map();
271
+ const stopEngine = () => {
272
+ engine?.stop();
273
+ engine = undefined;
274
+ registerBearer = undefined;
275
+ };
276
+ const buildRegisterEngine = async (register) => {
277
+ // The collision may have been detected after this build was queued.
278
+ if (registerSuppressed) {
279
+ return;
280
+ }
281
+ const next = new ProviderModelSyncEngine({
282
+ servers: [buildRegisterServerConfig(parsed.auth, register)],
283
+ cacheNamespace: ROOT_CACHE_NAMESPACE
284
+ }, {
285
+ logger,
286
+ fetchImpl: factoryOptions.fetchImpl,
287
+ onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
288
+ cacheDir: factoryOptions.cacheDir ? join(factoryOptions.cacheDir, ROOT_CACHE_SEGMENT, ROOT_CACHE_NAMESPACE) : undefined,
289
+ cacheNamespaceSegment: ROOT_CACHE_SEGMENT,
290
+ serviceLabel: "opencode-lightbridge"
291
+ });
292
+ await next.initialize();
293
+ if (registerSuppressed) {
294
+ return;
295
+ }
296
+ await next.start({ warmup: true });
297
+ if (registerSuppressed) {
298
+ // oauth2 claimed the id while we warmed up: release our scheduler slot.
299
+ next.stop();
300
+ return;
301
+ }
302
+ engine = next;
303
+ registerBearer = await resolveCachedBearer(parsed.auth.id, next, logger);
304
+ // Models and bearer are now known: ask the host to re-run transforms.
305
+ await reloadProviders();
306
+ };
307
+ if (parsed.register) {
308
+ const register = parsed.register;
309
+ const providerId = parsed.auth.id;
310
+ factoryOptions.onRegister?.({
311
+ async settled() {
312
+ let current;
313
+ do {
314
+ current = registerChain;
315
+ await current;
316
+ } while (current !== registerChain);
317
+ },
318
+ engine: () => engine
319
+ });
320
+ yield* ctx.provider.transform((editor) => {
321
+ if (hasOAuth2Conflict(editor, providerId)) {
322
+ registerSuppressed = true;
323
+ registerQueued = false;
324
+ stopEngine();
325
+ if (!skipLogged) {
326
+ skipLogged = true;
327
+ logger.debug("lightbridge_register_skipped_oauth2_conflict", { providerId });
328
+ }
329
+ return;
330
+ }
331
+ registerSuppressed = false;
332
+ upsertRegisteredProvider(editor, providerId, register, logger);
333
+ let modelCount = 0;
334
+ if (engine) {
335
+ try {
336
+ const models = engine.getServerModels(providerId);
337
+ modelCount = models.length;
338
+ if (models.length > 0) {
339
+ mergeDiscoveredModels(editor, providerId, models);
340
+ }
341
+ } catch {}
342
+ }
343
+ propagateCachedBearer(editor, providerId, registerBearer, logger, stamped);
344
+ logger.trace("lightbridge_register_config_applied", {
345
+ providerId,
346
+ modelCount
347
+ });
348
+ if (!registerQueued) {
349
+ registerQueued = true;
350
+ registerChain = registerChain.then(() => buildRegisterEngine(register)).catch((error) => {
351
+ // A failed build must not wedge the queue; a later distinct pass retries.
352
+ registerQueued = false;
353
+ logger.error("lightbridge_register_build_failed", { error: errorMessage(error) });
354
+ });
355
+ }
268
356
  });
269
- logger.debug("lightbridge_otel_trace_propagation_ready", { providerCount: wrapped });
270
357
  }
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
- };
358
+ // ---- gateway module ----
359
+ // `sharedRuntime` is always defined here: it is built whenever `gateway`
360
+ // or `otel` is configured (`wantsProjectToken`).
361
+ if (parsed.gateway && sharedRuntime) {
362
+ const providers = new Set(parsed.gateway.providers);
363
+ const runtime = sharedRuntime;
364
+ // `gateway` never stamps a bearer onto the provider record, so a stale
365
+ // value cannot ride out of the record. It can still coexist with a
366
+ // `register` stamp (the raw IdP token) or an oauth2 stamp for the same
367
+ // provider; to keep that from leaking through when this module's own
368
+ // token (notably the RFC 8693 exchange, a remote call) cannot be
369
+ // produced, a failure here kills the request rather than leaving the
370
+ // stamped header in place — the same fail-closed mechanism oauth2 uses.
371
+ yield* ctx.session.hook("model.request", (event) => {
372
+ const providerId = event.model.providerID;
373
+ if (!providers.has(providerId)) {
374
+ logger.trace("lightbridge_gateway_model_request_skipped", { providerId });
375
+ return Effect.void;
376
+ }
377
+ return Effect.tryPromise({
378
+ try: async () => {
379
+ const token = await runtime.getProjectToken({ interactive: true });
380
+ // Header names are case-insensitive: drop any variant already set
381
+ // (a stamped record header, the placeholder key's, a lowercase copy).
382
+ for (const key of Object.keys(event.headers)) {
383
+ if (key.toLowerCase() === "authorization") {
384
+ delete event.headers[key];
385
+ }
386
+ }
387
+ event.headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
388
+ logger.trace("lightbridge_gateway_bearer_injected", { providerId });
389
+ },
390
+ catch: (error) => error
391
+ }).pipe(Effect.tapError((error) => Effect.sync(() => logger.warn("lightbridge_gateway_no_bearer", {
392
+ providerId,
393
+ error: errorMessage(error)
394
+ }))), Effect.orDie);
395
+ });
396
+ }
397
+ // ---- otel module ----
398
+ let otel;
399
+ if (parsed.otel) {
400
+ const env = factoryOptions.env ?? process.env;
401
+ const otelConfig = resolveOtelConfig(parsed.otel, env);
402
+ if (otelConfig.active) {
403
+ warnIfTokenCommandConfigIgnored(parsed.otel, env, logger);
404
+ const built = yield* Effect.promise(() => buildOtelModule({
405
+ directory: ctx.location.directory,
406
+ projectId: ctx.location.project?.id
407
+ }, otelConfig, logger, factoryOptions, sharedRuntime));
408
+ otel = built;
409
+ const { recorder } = built;
410
+ const adapter = new OtelEventAdapter(recorder, factoryOptions.now);
411
+ yield* Effect.forkScoped(Stream.runForEach(ctx.event.subscribe({ types: OTEL_EVENT_TYPES }), (event) => Effect.sync(() => adapter.handle(event))).pipe(Effect.tapError((error) => Effect.sync(() => logger.warn("lightbridge_otel_event_stream_failed", { error: errorMessage(error) }))), Effect.ignore));
412
+ yield* ctx.session.hook("prompt", (event) => Effect.sync(() => {
413
+ const [input, output] = adaptPrompt(event);
414
+ recorder.onChatMessage(input, output);
415
+ }));
416
+ yield* ctx.session.hook("context", (event) => Effect.sync(() => {
417
+ const [input, output] = adaptContext(event);
418
+ recorder.onChatParams(input, output);
419
+ }));
420
+ yield* ctx.tool.hook("execute.before", (event) => Effect.sync(() => recorder.onToolBefore(adaptToolBefore(event))));
421
+ yield* ctx.tool.hook("execute.after", (event) => Effect.sync(() => {
422
+ const [input, output] = adaptToolAfter({
423
+ tool: event.tool,
424
+ sessionID: event.sessionID,
425
+ id: event.id,
426
+ status: event.status,
427
+ result: event.status === "completed" ? event.result : undefined
428
+ });
429
+ recorder.onToolAfter(input, output);
430
+ }));
431
+ yield* ctx.permission.hook("evaluate", (event) => Effect.sync(() => {
432
+ const [input, output] = adaptPermission(event);
433
+ recorder.onPermissionAsk(input, output);
434
+ }));
435
+ if (otelConfig.propagateTraceContext) {
436
+ // v1 wrapped `provider.options.fetch`; `Provider.Info` has no `fetch`,
437
+ // so the trace context rides the outgoing request instead.
438
+ yield* ctx.session.hook("http.request", (event) => Effect.sync(() => {
439
+ const context = recorder.currentChatContext();
440
+ if (!context) {
441
+ return;
442
+ }
443
+ const carrier = {};
444
+ propagator.inject(context, carrier, defaultTextMapSetter);
445
+ for (const [key, value] of Object.entries(carrier)) {
446
+ // Never clobber an upstream traceparent.
447
+ if (!event.request.headers.has(key)) {
448
+ event.request.headers.set(key, value);
449
+ }
450
+ }
451
+ }));
452
+ logger.debug("lightbridge_otel_trace_propagation_ready", {});
453
+ }
454
+ } else {
455
+ logger.info("lightbridge_otel_inactive", {
456
+ enabled: otelConfig.enabled,
457
+ reason: otelConfig.enabled ? "no_exporter_configured" : "disabled"
458
+ });
459
+ }
460
+ }
461
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
462
+ stopEngine();
463
+ // A real shutdown signal from the host: drain the exporters.
464
+ otel?.recorder.onEvent({ type: "server.instance.disposed" });
465
+ }));
466
+ logger.info("lightbridge_plugin_ready", {
467
+ gateway: Boolean(parsed.gateway && sharedRuntime),
468
+ exchange: parsed.gateway?.exchange ?? false,
469
+ otel: Boolean(otel),
470
+ register: Boolean(parsed.register),
471
+ projectId: parsed.projectId ?? (wantsProjectToken ? "(default)" : undefined)
472
+ });
473
+ })
474
+ });
279
475
  }
280
476
  export const LightbridgePlugin = createLightbridgePlugin();
281
477
  export default LightbridgePlugin;