@vymalo/opencode-lightbridge 0.17.0 → 0.18.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/README.md +61 -19
- package/dist/config.d.ts +18 -15
- package/dist/config.js +19 -38
- package/dist/config.js.map +1 -1
- package/dist/lib.d.ts +2 -2
- package/dist/lib.js +2 -2
- package/dist/lib.js.map +1 -1
- package/dist/logging.d.ts +44 -0
- package/dist/logging.js +100 -0
- package/dist/logging.js.map +1 -0
- package/dist/opencode.d.ts +33 -15
- package/dist/opencode.js +328 -273
- package/dist/opencode.js.map +1 -1
- package/dist/otel-adapter.d.ts +68 -0
- package/dist/otel-adapter.js +315 -0
- package/dist/otel-adapter.js.map +1 -0
- package/package.json +15 -10
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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* the
|
|
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
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
249
|
-
*
|
|
129
|
+
* handlers. Mirrors `@vymalo/opencode-otel`'s orchestration verbatim, minus
|
|
130
|
+
* the standalone `tokenCommand` seam.
|
|
250
131
|
*/
|
|
251
|
-
async function buildOtelModule(
|
|
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(
|
|
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:
|
|
265
|
-
directory:
|
|
266
|
-
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
|
-
|
|
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
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* `
|
|
331
|
-
*
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
logger.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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;
|