@vymalo/opencode-lightbridge 0.17.0 → 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,51 +1,19 @@
1
1
  import { hostname } from "node:os";
2
2
  import { join } from "node:path";
3
- import { createJsonConsoleLogger, DEFAULT_LOG_LEVEL, LOG_LEVEL_PRIORITY } from "@vymalo/opencode-auth-core/lib";
4
- import { buildResource, createProviders, deferredAttribute, describeError, fromOpenCodeLogLevel, installTracePropagation, readVcsInfo, resolveOtelConfig, TelemetryRecorder } from "@vymalo/opencode-core-otel";
5
- import { applyResponsesApiOptions, mergeDiscoveredModels, ProviderModelSyncEngine, resolveProviderNpm } from "@vymalo/opencode-provider-sync/lib";
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";
6
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";
7
12
  import { LightbridgeRuntime, ROOT_CACHE_NAMESPACE, ROOT_CACHE_SEGMENT } from "./plugin.js";
8
- const PLUGIN_SERVICE_NAME = "opencode-lightbridge-plugin";
9
- /**
10
- * Pipe plugin logs through OpenCode's `client.app.log`, JSON console as
11
- * fallback. Mirrors every other plugin in the suite.
12
- *
13
- * This plugin mutates the session (gateway bearer injection, OTEL export), so
14
- * it might seem like a warn/error printed to the terminal would be actionable
15
- * developer feedback. It is not: no plugin in this suite may write to the
16
- * user's TUI (ADR-0014, superseding ADR-0013's narrower per-plugin scope).
17
- * Every record still reaches `client.app.log` at its true level, so the
18
- * diagnostic is always persisted to OpenCode's own log — just not on the
19
- * developer's screen. Set `VYMALO_PLUGIN_CONSOLE_LOG=1` (`consoleAll` below)
20
- * to opt back into the console mirror for every level.
21
- */
22
- function createOpenCodeLogger(client, getMinLevel) {
23
- const fallback = createJsonConsoleLogger("debug");
24
- const consoleAll = /^(1|true|yes|on)$/i.test(process.env.VYMALO_PLUGIN_CONSOLE_LOG ?? "");
25
- const write = (level, event, fields) => {
26
- if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[getMinLevel()]) {
27
- return;
28
- }
29
- if (consoleAll) {
30
- fallback[level](event, fields);
31
- }
32
- const hostLevel = level === "trace" ? "debug" : level;
33
- void client.app.log({ body: {
34
- service: PLUGIN_SERVICE_NAME,
35
- level: hostLevel,
36
- message: event,
37
- extra: fields
38
- } }).catch(() => {
39
- // best-effort forwarding; console logger is the reliable fallback.
40
- });
41
- };
42
- return {
43
- trace: (event, fields) => write("trace", event, fields),
44
- debug: (event, fields) => write("debug", event, fields),
45
- info: (event, fields) => write("info", event, fields),
46
- warn: (event, fields) => write("warn", event, fields),
47
- error: (event, fields) => write("error", event, fields)
48
- };
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);
49
17
  }
50
18
  function safeHostname() {
51
19
  try {
@@ -55,30 +23,6 @@ function safeHostname() {
55
23
  }
56
24
  }
57
25
  /**
58
- * Drain buffered telemetry on process exit — same rationale as
59
- * `@vymalo/opencode-otel`'s `registerExitHandlers`: the plugin API has no
60
- * dispose hook, so without this a short CLI invocation loses everything still
61
- * in a batch processor.
62
- */
63
- function registerExitHandlers(providers, logger, deferred) {
64
- let done = false;
65
- const drain = () => {
66
- if (done) {
67
- return;
68
- }
69
- done = true;
70
- for (const attribute of deferred) {
71
- attribute.abandon();
72
- }
73
- void providers.shutdown().catch((error) => {
74
- logger.warn("lightbridge_otel_shutdown_failed", { error: describeError(error) });
75
- });
76
- };
77
- process.once("beforeExit", drain);
78
- process.once("SIGINT", drain);
79
- process.once("SIGTERM", drain);
80
- }
81
- /**
82
26
  * Build the umbrella's `TokenSource` for `createProviders`'s 5th argument: an
83
27
  * async factory over the SHARED runtime, superseding otel's standalone
84
28
  * `tokenCommand` path entirely (ADR-0012 — one credential, not two seams).
@@ -111,27 +55,6 @@ function createRuntimeTokenSource(runtime) {
111
55
  };
112
56
  }
113
57
  /**
114
- * Build the gateway's `chat.headers` hook: inject the shared project bearer
115
- * on `gateway.providers` only, fail closed on any exchange error (mirrors
116
- * `@vymalo/opencode-repo-auth`'s `chat.headers`).
117
- */
118
- function createGatewayChatHeaders(providers, runtime, logger) {
119
- return async (input, output) => {
120
- const providerId = input.model?.providerID ?? input.provider?.info?.id;
121
- if (!providerId || !providers.has(providerId)) {
122
- logger.trace("lightbridge_gateway_chat_headers_skipped", { providerId });
123
- return;
124
- }
125
- try {
126
- const token = await runtime.getProjectToken({ interactive: true });
127
- output.headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
128
- logger.trace("lightbridge_gateway_bearer_injected", { providerId });
129
- } catch {
130
- logger.trace("lightbridge_gateway_no_bearer", { providerId });
131
- }
132
- };
133
- }
134
- /**
135
58
  * Translate `auth` (already-validated `AuthServerConfig`) + `register` into
136
59
  * the ONE `ProviderServerConfig` the shared `ProviderModelSyncEngine` needs
137
60
  * (ADR-0017 requirement 1 — "everything oauth2 does"). `auth.id` is the
@@ -160,68 +83,26 @@ function buildRegisterServerConfig(auth, register) {
160
83
  tokenExchangeAudience: auth.tokenExchangeAudience
161
84
  };
162
85
  }
86
+ function asSettings(value) {
87
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
88
+ }
163
89
  /**
164
- * The `register` module (ADR-0017 requirement 1): register `auth`'s IdP as an
165
- * OpenCode provider and keep `config.provider[<auth.id>].models` in sync via
166
- * the SAME `ProviderModelSyncEngine` `@vymalo/opencode-oauth2` uses — built
167
- * ONCE (register's config is fixed at plugin-construction time, unlike
168
- * oauth2's, which is re-derived from the host config on every `config` hook),
169
- * then merged into the host config on every `config` hook call so a later
170
- * OpenCode config pass still sees the discovered models.
171
- *
172
- * Guard (requirement 4a): if `auth.id` is ALSO managed by
173
- * `@vymalo/opencode-oauth2` in this same host config, lightbridge must not
174
- * also register/own it — skip entirely (never construct the engine, never
175
- * touch `config.provider[id]`), logged at `debug` (ADR-0014: nothing reaches
176
- * the terminal), and fail safe (the gateway/OTEL bearer still works via
177
- * `LightbridgeRuntime`'s own lightweight, read/refresh-only participation in
178
- * the same shared cache file). The double-scheduling guard (requirement 4b —
179
- * two engine instances in one process ticking the same cache file) is
180
- * enforced generically inside `ProviderModelSyncEngine.start()` itself.
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.
181
93
  */
182
- async function applyRegisterModule(hostConfig, register, auth, state, logger, factoryOptions) {
183
- const providerId = auth.id;
184
- if (!state.engine && hasOAuth2Conflict(hostConfig, providerId)) {
185
- if (!state.skippedForConflict) {
186
- state.skippedForConflict = true;
187
- logger.debug("lightbridge_register_skipped_oauth2_conflict", { providerId });
188
- }
189
- return;
190
- }
191
- if (!state.engine) {
192
- state.engine = new ProviderModelSyncEngine({
193
- servers: [buildRegisterServerConfig(auth, register)],
194
- cacheNamespace: ROOT_CACHE_NAMESPACE
195
- }, {
196
- logger,
197
- fetchImpl: factoryOptions.fetchImpl,
198
- onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
199
- cacheDir: factoryOptions.cacheDir ? join(factoryOptions.cacheDir, ROOT_CACHE_SEGMENT, ROOT_CACHE_NAMESPACE) : undefined,
200
- cacheNamespaceSegment: ROOT_CACHE_SEGMENT,
201
- serviceLabel: "opencode-lightbridge"
202
- });
203
- }
204
- if (!state.started) {
205
- await state.engine.initialize();
206
- await state.engine.start({ warmup: true });
207
- state.started = true;
208
- }
209
- const providers = hostConfig.provider ??= {};
210
- const providerConfig = providers[providerId] ??= {};
211
- const providerOptions = providerConfig.options ?? {};
212
- providerConfig.npm = resolveProviderNpm(register.responseApi);
213
- providerConfig.name = providerConfig.name ?? register.name ?? providerId;
214
- providerConfig.options = applyResponsesApiOptions({
215
- ...providerOptions,
216
- baseURL: register.baseURL
217
- }, register.responseApi, providerId, logger);
218
- const models = state.engine.getServerModels(providerId);
219
- if (models.length > 0) {
220
- mergeDiscoveredModels(providerConfig, models);
221
- }
222
- logger.trace("lightbridge_register_config_applied", {
223
- providerId,
224
- modelCount: models.length
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)
225
106
  });
226
107
  }
227
108
  /**
@@ -245,25 +126,25 @@ function warnIfTokenCommandConfigIgnored(otel, env, logger) {
245
126
  /**
246
127
  * Build the OTEL module: resource, providers (with the runtime-backed
247
128
  * `TokenSource` when a shared runtime is available), recorder and exit
248
- * handlers. Mirrors `@vymalo/opencode-otel`'s `createOtelPlugin` orchestration
249
- * verbatim, minus the standalone `tokenCommand` seam.
129
+ * handlers. Mirrors `@vymalo/opencode-otel`'s orchestration verbatim, minus
130
+ * the standalone `tokenCommand` seam.
250
131
  */
251
- async function buildOtelModule(input, otelConfig, logger, factoryOptions, runtime) {
132
+ async function buildOtelModule(location, otelConfig, logger, factoryOptions, runtime) {
252
133
  const version = deferredAttribute(factoryOptions.deferredTimeoutMs);
253
134
  const branch = deferredAttribute(factoryOptions.deferredTimeoutMs);
254
135
  if (factoryOptions.hostInfo?.version) {
255
136
  version.settle(factoryOptions.hostInfo.version);
256
137
  }
257
- const vcs = otelConfig.collectVcs ? await readVcsInfo(input.worktree ?? input.directory).catch(() => ({})) : {};
138
+ const vcs = otelConfig.collectVcs ? await readVcsInfo(location.directory).catch(() => ({})) : {};
258
139
  if (vcs.ref) {
259
140
  branch.settle(vcs.ref);
260
141
  }
261
142
  const resource = buildResource(otelConfig, {
262
143
  version: version.value,
263
144
  hostname: factoryOptions.hostInfo?.hostname ?? safeHostname(),
264
- projectName: input.project?.id,
265
- directory: input.directory,
266
- worktree: input.worktree,
145
+ projectName: location.projectId,
146
+ directory: location.directory,
147
+ worktree: location.directory,
267
148
  branch: branch.value,
268
149
  vcs
269
150
  });
@@ -280,7 +161,8 @@ async function buildOtelModule(input, otelConfig, logger, factoryOptions, runtim
280
161
  }
281
162
  });
282
163
  if (factoryOptions.registerProcessHandlers !== false) {
283
- 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" });
284
166
  }
285
167
  logger.info("lightbridge_otel_enabled", {
286
168
  serviceName: otelConfig.serviceName,
@@ -290,133 +172,306 @@ async function buildOtelModule(input, otelConfig, logger, factoryOptions, runtim
290
172
  });
291
173
  return {
292
174
  config: otelConfig,
293
- recorder
294
- };
295
- }
296
- function registerOtelHooks(hooks, otel) {
297
- const { recorder } = otel;
298
- hooks.event = async ({ event }) => {
299
- recorder.onEvent(event);
300
- };
301
- hooks["chat.message"] = async (chatInput, chatOutput) => {
302
- recorder.onChatMessage(chatInput, chatOutput);
303
- };
304
- hooks["tool.execute.before"] = async (toolInput) => {
305
- recorder.onToolBefore(toolInput);
306
- };
307
- hooks["tool.execute.after"] = async (toolInput, toolOutput) => {
308
- recorder.onToolAfter(toolInput, toolOutput);
309
- };
310
- hooks["chat.params"] = async (paramsInput, paramsOutput) => {
311
- recorder.onChatParams(paramsInput, paramsOutput);
312
- };
313
- hooks["permission.ask"] = async (permissionInput, permissionOutput) => {
314
- recorder.onPermissionAsk(permissionInput, permissionOutput);
315
- };
316
- hooks["experimental.text.complete"] = async (textInput, textOutput) => {
317
- recorder.onTextComplete(textInput, textOutput);
318
- };
319
- hooks["experimental.compaction.autocontinue"] = async (compactionInput, compactionOutput) => {
320
- recorder.onCompactionAutocontinue(compactionInput, compactionOutput);
175
+ recorder,
176
+ version
321
177
  };
322
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
+ ];
323
201
  /**
324
- * Create the `@vymalo/opencode-lightbridge` umbrella plugin (ADR-0012): ONE
325
- * shared `TokenRuntime`, built once from `auth`, drives both the gateway
326
- * bearer (`gateway`) and the OTEL export credential (`otel`) — each module
327
- * activates independently, and an `auth`-only config is a valid, inert
328
- * plugin. `config` runs both modules' config-time logic (host log level +
329
- * OTEL trace propagation); `chat.headers` is the gateway injector;
330
- * `event`/`chat.message`/`tool.execute.*`/`chat.params`/`permission.ask`/
331
- * `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.
332
217
  */
333
218
  export function createLightbridgePlugin(factoryOptions = {}) {
334
- return async (input, pluginOptions) => {
335
- let currentLogLevel = DEFAULT_LOG_LEVEL;
336
- const logger = factoryOptions.logger ?? createOpenCodeLogger(input.client, () => currentLogLevel);
337
- let parsed;
338
- try {
339
- parsed = parseLightbridgeOptions(pluginOptions);
340
- } catch (error) {
341
- // A malformed config must not take down every other plugin's load —
342
- // log and return inert hooks rather than throwing out of the factory.
343
- logger.error("lightbridge_config_invalid", { error: describeError(error) });
344
- return { config: async (hostConfig) => {
345
- currentLogLevel = fromOpenCodeLogLevel(hostConfig.logLevel) ?? DEFAULT_LOG_LEVEL;
346
- } };
347
- }
348
- const wantsProjectToken = needsProjectToken(parsed);
349
- if (wantsProjectToken && !parsed.projectId) {
350
- // `projectId` is fully optional — the exchange omits `project_id` and the
351
- // backend mints a token for the caller's default project (ADR-0012).
352
- logger.info("lightbridge_default_project", {
353
- gateway: Boolean(parsed.gateway),
354
- otel: Boolean(parsed.otel)
355
- });
356
- }
357
- // The ONE shared runtime (ADR-0012) — constructed exactly once whenever a
358
- // module (gateway/otel) needs a token, reused by both the gateway injector
359
- // and the OTEL token source below. `projectId` is optional (undefined →
360
- // default project). `undefined` runtime only for an auth-only config.
361
- const buildRuntime = factoryOptions.runtimeFactory ?? ((auth, pid, options) => new LightbridgeRuntime(auth, pid, options));
362
- const sharedRuntime = wantsProjectToken ? buildRuntime(parsed.auth, parsed.projectId, {
363
- logger,
364
- fetchImpl: factoryOptions.fetchImpl,
365
- onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
366
- cacheDir: factoryOptions.cacheDir,
367
- tokenExpirySkewMs: factoryOptions.tokenExpirySkewMs,
368
- exchange: parsed.gateway?.exchange ?? false
369
- }) : undefined;
370
- const hooks = {};
371
- // ---- register module (ADR-0017 requirement 1) ----
372
- const registerState = {
373
- started: false,
374
- skippedForConflict: false
375
- };
376
- // ---- gateway module ----
377
- // `sharedRuntime` is always defined here: it is built whenever `gateway`
378
- // or `otel` is configured (`wantsProjectToken`).
379
- if (parsed.gateway && sharedRuntime) {
380
- hooks["chat.headers"] = createGatewayChatHeaders(new Set(parsed.gateway.providers), sharedRuntime, logger);
381
- }
382
- // ---- otel module ----
383
- let otel;
384
- if (parsed.otel) {
385
- const otelConfig = resolveOtelConfig(parsed.otel, factoryOptions.env ?? process.env);
386
- if (otelConfig.active) {
387
- warnIfTokenCommandConfigIgnored(parsed.otel, factoryOptions.env ?? process.env, logger);
388
- otel = await buildOtelModule(input, otelConfig, logger, factoryOptions, sharedRuntime);
389
- registerOtelHooks(hooks, otel);
390
- } else {
391
- logger.info("lightbridge_otel_inactive", {
392
- enabled: otelConfig.enabled,
393
- 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)
394
245
  });
395
246
  }
396
- }
397
- hooks.config = async (hostConfig) => {
398
- currentLogLevel = fromOpenCodeLogLevel(hostConfig.logLevel) ?? DEFAULT_LOG_LEVEL;
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
+ };
399
307
  if (parsed.register) {
400
- await applyRegisterModule(hostConfig, parsed.register, parsed.auth, registerState, logger, factoryOptions);
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
+ }
356
+ });
401
357
  }
402
- if (otel?.config.propagateTraceContext) {
403
- const activeOtel = otel;
404
- const wrapped = installTracePropagation(hostConfig, {
405
- getContext: () => activeOtel.recorder.currentChatContext(),
406
- logger
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);
407
395
  });
408
- logger.debug("lightbridge_otel_trace_propagation_ready", { providerCount: wrapped });
409
396
  }
410
- };
411
- logger.info("lightbridge_plugin_ready", {
412
- gateway: Boolean(hooks["chat.headers"]),
413
- exchange: parsed.gateway?.exchange ?? false,
414
- otel: Boolean(otel),
415
- register: Boolean(parsed.register),
416
- projectId: parsed.projectId ?? (wantsProjectToken ? "(default)" : undefined)
417
- });
418
- return hooks;
419
- };
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
+ });
420
475
  }
421
476
  export const LightbridgePlugin = createLightbridgePlugin();
422
477
  export default LightbridgePlugin;