@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/README.md +101 -38
- package/dist/config.d.ts +70 -4
- package/dist/config.js +76 -3
- package/dist/config.js.map +1 -1
- package/dist/lib.d.ts +4 -3
- package/dist/lib.js +4 -3
- 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/migration.d.ts +31 -0
- package/dist/migration.js +91 -0
- package/dist/migration.js.map +1 -0
- package/dist/opencode.d.ts +33 -15
- package/dist/opencode.js +390 -194
- 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/dist/plugin.d.ts +77 -23
- package/dist/plugin.js +157 -33
- package/dist/plugin.js.map +1 -1
- package/package.json +15 -9
package/dist/opencode.js
CHANGED
|
@@ -1,40 +1,19 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
76
|
-
*
|
|
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
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
|
101
|
-
return
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
120
|
-
*
|
|
129
|
+
* handlers. Mirrors `@vymalo/opencode-otel`'s orchestration verbatim, minus
|
|
130
|
+
* the standalone `tokenCommand` seam.
|
|
121
131
|
*/
|
|
122
|
-
async function buildOtelModule(
|
|
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(
|
|
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:
|
|
136
|
-
directory:
|
|
137
|
-
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
|
-
|
|
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
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* `
|
|
202
|
-
*
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
logger.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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;
|