@vymalo/opencode-oauth2 0.11.0 → 0.14.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.
Files changed (57) hide show
  1. package/dist/cache.d.ts +6 -6
  2. package/dist/cache.js +100 -104
  3. package/dist/cache.js.map +1 -1
  4. package/dist/config.d.ts +107 -107
  5. package/dist/config.js +173 -182
  6. package/dist/config.js.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib.js +1 -0
  10. package/dist/lib.js.map +1 -1
  11. package/dist/logging.d.ts +6 -6
  12. package/dist/logging.js +57 -56
  13. package/dist/logging.js.map +1 -1
  14. package/dist/model-discovery.d.ts +3 -3
  15. package/dist/model-discovery.js +61 -66
  16. package/dist/model-discovery.js.map +1 -1
  17. package/dist/model-normalization.js +84 -73
  18. package/dist/model-normalization.js.map +1 -1
  19. package/dist/oauth/browser.js +29 -25
  20. package/dist/oauth/browser.js.map +1 -1
  21. package/dist/oauth/client.d.ts +46 -46
  22. package/dist/oauth/client.js +411 -430
  23. package/dist/oauth/client.js.map +1 -1
  24. package/dist/oauth/device-code.d.ts +23 -23
  25. package/dist/oauth/device-code.js +225 -235
  26. package/dist/oauth/device-code.js.map +1 -1
  27. package/dist/oauth/discovery.d.ts +5 -5
  28. package/dist/oauth/discovery.js +34 -34
  29. package/dist/oauth/discovery.js.map +1 -1
  30. package/dist/oauth/http-utils.d.ts +27 -27
  31. package/dist/oauth/http-utils.js +96 -107
  32. package/dist/oauth/http-utils.js.map +1 -1
  33. package/dist/oauth/local-callback.d.ts +5 -5
  34. package/dist/oauth/local-callback.js +76 -74
  35. package/dist/oauth/local-callback.js.map +1 -1
  36. package/dist/oauth/pkce.d.ts +2 -2
  37. package/dist/oauth/pkce.js +9 -5
  38. package/dist/oauth/pkce.js.map +1 -1
  39. package/dist/oauth/subject-token.d.ts +15 -15
  40. package/dist/oauth/subject-token.js +68 -73
  41. package/dist/oauth/subject-token.js.map +1 -1
  42. package/dist/opencode.d.ts +15 -15
  43. package/dist/opencode.js +477 -497
  44. package/dist/opencode.js.map +1 -1
  45. package/dist/plugin.d.ts +39 -39
  46. package/dist/plugin.js +270 -277
  47. package/dist/plugin.js.map +1 -1
  48. package/dist/responses-repair.d.ts +12 -35
  49. package/dist/responses-repair.js +119 -125
  50. package/dist/responses-repair.js.map +1 -1
  51. package/dist/scheduler.d.ts +5 -5
  52. package/dist/scheduler.js +43 -45
  53. package/dist/scheduler.js.map +1 -1
  54. package/dist/types.d.ts +25 -25
  55. package/dist/types.js +1 -0
  56. package/dist/types.js.map +1 -1
  57. package/package.json +3 -3
package/dist/opencode.js CHANGED
@@ -3,33 +3,28 @@ import { createJsonConsoleLogger, LOG_LEVEL_PRIORITY } from "./logging.js";
3
3
  import { OAuth2ModelSyncPlugin } from "./plugin.js";
4
4
  import { createResponsesRepairFetch } from "./responses-repair.js";
5
5
  /**
6
- * Map OpenCode's host-level `config.logLevel` (uppercase `"DEBUG" | "INFO" |
7
- * "WARN" | "ERROR"`) to this plugin's internal `LogLevel`. Unknown / missing
8
- * values fall through to `undefined` so the caller can apply its own default —
9
- * we never throw on the OpenCode-supplied value because the host owns
10
- * validation of its own field.
11
- *
12
- * Note: host `DEBUG` unlocks this plugin's most-verbose `"trace"` tier (there
13
- * is no separate host `TRACE` level), so running OpenCode with
14
- * `--log-level DEBUG` surfaces the `oauth2_*` trace events emitted across the
15
- * runtime hot paths.
16
- */
6
+ * Map OpenCode's host-level `config.logLevel` (uppercase `"DEBUG" | "INFO" |
7
+ * "WARN" | "ERROR"`) to this plugin's internal `LogLevel`. Unknown / missing
8
+ * values fall through to `undefined` so the caller can apply its own default —
9
+ * we never throw on the OpenCode-supplied value because the host owns
10
+ * validation of its own field.
11
+ *
12
+ * Note: host `DEBUG` unlocks this plugin's most-verbose `"trace"` tier (there
13
+ * is no separate host `TRACE` level), so running OpenCode with
14
+ * `--log-level DEBUG` surfaces the `oauth2_*` trace events emitted across the
15
+ * runtime hot paths.
16
+ */
17
17
  export function fromOpenCodeLogLevel(value) {
18
- if (typeof value !== "string") {
19
- return undefined;
20
- }
21
- switch (value.toUpperCase()) {
22
- case "DEBUG":
23
- return "trace";
24
- case "INFO":
25
- return "info";
26
- case "WARN":
27
- return "warn";
28
- case "ERROR":
29
- return "error";
30
- default:
31
- return undefined;
32
- }
18
+ if (typeof value !== "string") {
19
+ return undefined;
20
+ }
21
+ switch (value.toUpperCase()) {
22
+ case "DEBUG": return "trace";
23
+ case "INFO": return "info";
24
+ case "WARN": return "warn";
25
+ case "ERROR": return "error";
26
+ default: return undefined;
27
+ }
33
28
  }
34
29
  const OPENAI_COMPATIBLE_NPM = "@ai-sdk/openai-compatible";
35
30
  // The native OpenAI provider. Since AI SDK v5 its default `languageModel()`
@@ -47,513 +42,498 @@ const RESPONSES_API_PLACEHOLDER_KEY = "oauth2-managed-bearer";
47
42
  const OAUTH_OPTIONS_KEYS = ["oauth2", "oauth2ModelSync"];
48
43
  const PLUGIN_SERVICE_NAME = "opencode-oauth2-plugin";
49
44
  function resolveProviderNpm(responseApi) {
50
- return responseApi ? OPENAI_RESPONSES_NPM : OPENAI_COMPATIBLE_NPM;
45
+ return responseApi ? OPENAI_RESPONSES_NPM : OPENAI_COMPATIBLE_NPM;
51
46
  }
52
47
  /**
53
- * When a provider opts into the Responses API, ensure its options carry an
54
- * `apiKey` so the native `@ai-sdk/openai` provider can be constructed. A
55
- * user-supplied key is left untouched; otherwise we stamp an inert placeholder
56
- * (the real bearer is injected per-request by `chat.headers`). A no-op for
57
- * Chat-Completions providers, which need no key.
58
- */
48
+ * When a provider opts into the Responses API, ensure its options carry an
49
+ * `apiKey` so the native `@ai-sdk/openai` provider can be constructed. A
50
+ * user-supplied key is left untouched; otherwise we stamp an inert placeholder
51
+ * (the real bearer is injected per-request by `chat.headers`). A no-op for
52
+ * Chat-Completions providers, which need no key.
53
+ */
59
54
  function applyResponsesApiOptions(options, responseApi, providerId, logger) {
60
- if (!responseApi) {
61
- // If the same provider id appears in both config shapes and an earlier pass
62
- // stamped our placeholder for Responses mode, but Responses ultimately loses
63
- // (this shape omits the flag), don't leave the fake key on the resulting
64
- // Chat-Completions provider. Only ever scrub our own placeholder.
65
- if (asString(options.apiKey) === RESPONSES_API_PLACEHOLDER_KEY) {
66
- const cleaned = { ...options };
67
- delete cleaned.apiKey;
68
- return cleaned;
69
- }
70
- return options;
71
- }
72
- logger.debug("oauth2_provider_response_api_enabled", { providerId });
73
- const next = { ...options };
74
- // The native @ai-sdk/openai provider throws at construction without an
75
- // apiKey; stamp an inert placeholder only when the user hasn't set one. The
76
- // real bearer is injected per-request by chat.headers, so it is never sent.
77
- if (!asString(next.apiKey)) {
78
- next.apiKey = RESPONSES_API_PLACEHOLDER_KEY;
79
- }
80
- // Repair the gateway's Responses SSE: some gateways (e.g. Envoy AI Gateway)
81
- // omit `output_index` / `content_index`, which AI-SDK/OpenCode need to
82
- // assemble message parts (absent → "text part <id> not found"). We compose
83
- // with any pre-existing fetch so a later fetch-wrapping plugin (e.g.
84
- // @vymalo/opencode-ratelimit) still wraps ours rather than clobbering it.
85
- const delegate = typeof next.fetch === "function" ? next.fetch : undefined;
86
- next.fetch = createResponsesRepairFetch(delegate);
87
- return next;
55
+ if (!responseApi) {
56
+ // If the same provider id appears in both config shapes and an earlier pass
57
+ // stamped our placeholder for Responses mode, but Responses ultimately loses
58
+ // (this shape omits the flag), don't leave the fake key on the resulting
59
+ // Chat-Completions provider. Only ever scrub our own placeholder.
60
+ if (asString(options.apiKey) === RESPONSES_API_PLACEHOLDER_KEY) {
61
+ const cleaned = { ...options };
62
+ delete cleaned.apiKey;
63
+ return cleaned;
64
+ }
65
+ return options;
66
+ }
67
+ logger.debug("oauth2_provider_response_api_enabled", { providerId });
68
+ const next = { ...options };
69
+ // The native @ai-sdk/openai provider throws at construction without an
70
+ // apiKey; stamp an inert placeholder only when the user hasn't set one. The
71
+ // real bearer is injected per-request by chat.headers, so it is never sent.
72
+ if (!asString(next.apiKey)) {
73
+ next.apiKey = RESPONSES_API_PLACEHOLDER_KEY;
74
+ }
75
+ // Repair the gateway's Responses SSE: some gateways (e.g. Envoy AI Gateway)
76
+ // omit `output_index` / `content_index`, which AI-SDK/OpenCode need to
77
+ // assemble message parts (absent → "text part <id> not found"). We compose
78
+ // with any pre-existing fetch so a later fetch-wrapping plugin (e.g.
79
+ // @vymalo/opencode-ratelimit) still wraps ours rather than clobbering it.
80
+ const delegate = typeof next.fetch === "function" ? next.fetch : undefined;
81
+ next.fetch = createResponsesRepairFetch(delegate);
82
+ return next;
88
83
  }
89
84
  function asBoolean(value, source) {
90
- if (value === undefined || value === null) {
91
- return undefined;
92
- }
93
- if (typeof value !== "boolean") {
94
- throw new Error(`${source} must be a boolean (received ${JSON.stringify(value)})`);
95
- }
96
- return value;
85
+ if (value === undefined || value === null) {
86
+ return undefined;
87
+ }
88
+ if (typeof value !== "boolean") {
89
+ throw new Error(`${source} must be a boolean (received ${JSON.stringify(value)})`);
90
+ }
91
+ return value;
97
92
  }
98
93
  function asAuthFlow(value, source) {
99
- if (value === undefined || value === null) {
100
- return undefined;
101
- }
102
- if (value === "authorization_code" ||
103
- value === "device_code" ||
104
- value === "client_credentials" ||
105
- value === "jwt_bearer" ||
106
- value === "token_exchange") {
107
- return value;
108
- }
109
- throw new Error(`${source}.authFlow must be one of "authorization_code" | "device_code" | "client_credentials" | "jwt_bearer" | "token_exchange" (received ${JSON.stringify(value)})`);
94
+ if (value === undefined || value === null) {
95
+ return undefined;
96
+ }
97
+ if (value === "authorization_code" || value === "device_code" || value === "client_credentials" || value === "jwt_bearer" || value === "token_exchange") {
98
+ return value;
99
+ }
100
+ throw new Error(`${source}.authFlow must be one of "authorization_code" | "device_code" | "client_credentials" | "jwt_bearer" | "token_exchange" (received ${JSON.stringify(value)})`);
110
101
  }
111
102
  function asClientSecret(value, source) {
112
- if (value === undefined || value === null) {
113
- return undefined;
114
- }
115
- if (typeof value !== "string" || value.length === 0) {
116
- throw new Error(`${source}.clientSecret must be a non-empty string when provided`);
117
- }
118
- return value;
103
+ if (value === undefined || value === null) {
104
+ return undefined;
105
+ }
106
+ if (typeof value !== "string" || value.length === 0) {
107
+ throw new Error(`${source}.clientSecret must be a non-empty string when provided`);
108
+ }
109
+ return value;
119
110
  }
120
111
  function asRedirectPort(value, source) {
121
- if (value === undefined || value === null) {
122
- return undefined;
123
- }
124
- if (typeof value === "number" && Number.isInteger(value) && value > 0 && value < 65536) {
125
- return value;
126
- }
127
- throw new Error(`${source}.redirectPort must be an integer in [1, 65535] (received ${JSON.stringify(value)})`);
112
+ if (value === undefined || value === null) {
113
+ return undefined;
114
+ }
115
+ if (typeof value === "number" && Number.isInteger(value) && value > 0 && value < 65536) {
116
+ return value;
117
+ }
118
+ throw new Error(`${source}.redirectPort must be an integer in [1, 65535] (received ${JSON.stringify(value)})`);
128
119
  }
129
120
  function asRecord(value) {
130
- if (!value || typeof value !== "object" || Array.isArray(value)) {
131
- return undefined;
132
- }
133
- return value;
121
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
122
+ return undefined;
123
+ }
124
+ return value;
134
125
  }
135
126
  function asString(value) {
136
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
127
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
137
128
  }
138
129
  function asStringArray(value) {
139
- if (Array.isArray(value)) {
140
- const normalized = value
141
- .map((entry) => asString(entry))
142
- .filter((entry) => Boolean(entry));
143
- return normalized.length > 0 ? normalized : undefined;
144
- }
145
- if (typeof value === "string") {
146
- const normalized = value
147
- .split(/[\s,]+/g)
148
- .map((entry) => entry.trim())
149
- .filter((entry) => entry.length > 0);
150
- return normalized.length > 0 ? normalized : undefined;
151
- }
152
- return undefined;
130
+ if (Array.isArray(value)) {
131
+ const normalized = value.map((entry) => asString(entry)).filter((entry) => Boolean(entry));
132
+ return normalized.length > 0 ? normalized : undefined;
133
+ }
134
+ if (typeof value === "string") {
135
+ const normalized = value.split(/[\s,]+/g).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
136
+ return normalized.length > 0 ? normalized : undefined;
137
+ }
138
+ return undefined;
153
139
  }
154
140
  function asStringMap(value) {
155
- const record = asRecord(value);
156
- if (!record) {
157
- return undefined;
158
- }
159
- const normalized = {};
160
- for (const [key, raw] of Object.entries(record)) {
161
- const text = asString(raw);
162
- if (!text) {
163
- continue;
164
- }
165
- normalized[key] = text;
166
- }
167
- return Object.keys(normalized).length > 0 ? normalized : undefined;
141
+ const record = asRecord(value);
142
+ if (!record) {
143
+ return undefined;
144
+ }
145
+ const normalized = {};
146
+ for (const [key, raw] of Object.entries(record)) {
147
+ const text = asString(raw);
148
+ if (!text) {
149
+ continue;
150
+ }
151
+ normalized[key] = text;
152
+ }
153
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
168
154
  }
169
155
  function parseOAuthExtension(provider) {
170
- const options = asRecord(provider.options);
171
- if (!options) {
172
- return undefined;
173
- }
174
- let raw;
175
- for (const key of OAUTH_OPTIONS_KEYS) {
176
- raw = asRecord(options[key]);
177
- if (raw) {
178
- break;
179
- }
180
- }
181
- if (!raw) {
182
- return undefined;
183
- }
184
- const issuer = asString(raw.issuer);
185
- const clientId = asString(raw.clientId);
186
- const scopes = asStringArray(raw.scopes);
187
- if (!issuer || !clientId || !scopes) {
188
- return undefined;
189
- }
190
- const syncIntervalMinutes = typeof raw.syncIntervalMinutes === "number" &&
191
- Number.isFinite(raw.syncIntervalMinutes) &&
192
- raw.syncIntervalMinutes > 0
193
- ? raw.syncIntervalMinutes
194
- : undefined;
195
- return {
196
- issuer,
197
- clientId,
198
- clientSecret: asClientSecret(raw.clientSecret, "provider.options.oauth2"),
199
- scopes,
200
- syncIntervalMinutes,
201
- nameOverrides: asStringMap(raw.nameOverrides),
202
- authorizationEndpoint: asString(raw.authorizationEndpoint),
203
- tokenEndpoint: asString(raw.tokenEndpoint),
204
- deviceAuthorizationEndpoint: asString(raw.deviceAuthorizationEndpoint),
205
- jwksUri: asString(raw.jwksUri),
206
- redirectPort: asRedirectPort(raw.redirectPort, "provider.options.oauth2"),
207
- authFlow: asAuthFlow(raw.authFlow, "provider.options.oauth2"),
208
- pkce: asBoolean(raw.pkce, "provider.options.oauth2.pkce"),
209
- // Deep validation of subjectTokenSource happens in validateConfig — this
210
- // layer just passes the raw value through so error messages reference
211
- // the canonical config path.
212
- subjectTokenSource: raw.subjectTokenSource,
213
- tokenExchangeAudience: asString(raw.tokenExchangeAudience),
214
- responseApi: asBoolean(raw.responseApi, "provider.options.oauth2.responseApi")
215
- };
156
+ const options = asRecord(provider.options);
157
+ if (!options) {
158
+ return undefined;
159
+ }
160
+ let raw;
161
+ for (const key of OAUTH_OPTIONS_KEYS) {
162
+ raw = asRecord(options[key]);
163
+ if (raw) {
164
+ break;
165
+ }
166
+ }
167
+ if (!raw) {
168
+ return undefined;
169
+ }
170
+ const issuer = asString(raw.issuer);
171
+ const clientId = asString(raw.clientId);
172
+ const scopes = asStringArray(raw.scopes);
173
+ if (!issuer || !clientId || !scopes) {
174
+ return undefined;
175
+ }
176
+ const syncIntervalMinutes = typeof raw.syncIntervalMinutes === "number" && Number.isFinite(raw.syncIntervalMinutes) && raw.syncIntervalMinutes > 0 ? raw.syncIntervalMinutes : undefined;
177
+ return {
178
+ issuer,
179
+ clientId,
180
+ clientSecret: asClientSecret(raw.clientSecret, "provider.options.oauth2"),
181
+ scopes,
182
+ syncIntervalMinutes,
183
+ nameOverrides: asStringMap(raw.nameOverrides),
184
+ authorizationEndpoint: asString(raw.authorizationEndpoint),
185
+ tokenEndpoint: asString(raw.tokenEndpoint),
186
+ deviceAuthorizationEndpoint: asString(raw.deviceAuthorizationEndpoint),
187
+ jwksUri: asString(raw.jwksUri),
188
+ redirectPort: asRedirectPort(raw.redirectPort, "provider.options.oauth2"),
189
+ authFlow: asAuthFlow(raw.authFlow, "provider.options.oauth2"),
190
+ pkce: asBoolean(raw.pkce, "provider.options.oauth2.pkce"),
191
+ // Deep validation of subjectTokenSource happens in validateConfig — this
192
+ // layer just passes the raw value through so error messages reference
193
+ // the canonical config path.
194
+ subjectTokenSource: raw.subjectTokenSource,
195
+ tokenExchangeAudience: asString(raw.tokenExchangeAudience),
196
+ responseApi: asBoolean(raw.responseApi, "provider.options.oauth2.responseApi")
197
+ };
216
198
  }
217
199
  function parsePluginConfigServers(config, logger) {
218
- const root = asRecord(config);
219
- const pluginConfig = asRecord(root?.pluginConfig);
220
- const oauth2ModelSync = asRecord(pluginConfig?.oauth2ModelSync);
221
- const servers = oauth2ModelSync?.servers;
222
- if (!Array.isArray(servers)) {
223
- return [];
224
- }
225
- const parsed = [];
226
- for (const [index, rawServer] of servers.entries()) {
227
- const entry = asRecord(rawServer);
228
- if (!entry) {
229
- logger.warn("plugin_config_server_invalid", { index });
230
- continue;
231
- }
232
- const id = asString(entry.id);
233
- const name = asString(entry.name) ?? id;
234
- const issuer = asString(entry.issuer);
235
- const baseURL = asString(entry.baseURL);
236
- const clientId = asString(entry.clientId);
237
- const scopes = asStringArray(entry.scopes);
238
- if (!id || !issuer || !baseURL || !clientId || !scopes) {
239
- logger.warn("plugin_config_server_missing_fields", { index, id: id ?? "unknown" });
240
- continue;
241
- }
242
- const syncIntervalMinutes = typeof entry.syncIntervalMinutes === "number" &&
243
- Number.isFinite(entry.syncIntervalMinutes) &&
244
- entry.syncIntervalMinutes > 0
245
- ? entry.syncIntervalMinutes
246
- : undefined;
247
- const sourceLabel = `pluginConfig.oauth2ModelSync.servers[${index}] (id=${id})`;
248
- parsed.push({
249
- id,
250
- name: name ?? id,
251
- issuer,
252
- baseURL,
253
- clientId,
254
- clientSecret: asClientSecret(entry.clientSecret, sourceLabel),
255
- scopes,
256
- syncIntervalMinutes,
257
- nameOverrides: asStringMap(entry.nameOverrides),
258
- authorizationEndpoint: asString(entry.authorizationEndpoint),
259
- tokenEndpoint: asString(entry.tokenEndpoint),
260
- deviceAuthorizationEndpoint: asString(entry.deviceAuthorizationEndpoint),
261
- jwksUri: asString(entry.jwksUri),
262
- redirectPort: asRedirectPort(entry.redirectPort, sourceLabel),
263
- authFlow: asAuthFlow(entry.authFlow, sourceLabel),
264
- pkce: asBoolean(entry.pkce, `${sourceLabel}.pkce`),
265
- subjectTokenSource: entry.subjectTokenSource,
266
- tokenExchangeAudience: asString(entry.tokenExchangeAudience),
267
- responseApi: asBoolean(entry.responseApi, `${sourceLabel}.responseApi`)
268
- });
269
- }
270
- return parsed;
200
+ const root = asRecord(config);
201
+ const pluginConfig = asRecord(root?.pluginConfig);
202
+ const oauth2ModelSync = asRecord(pluginConfig?.oauth2ModelSync);
203
+ const servers = oauth2ModelSync?.servers;
204
+ if (!Array.isArray(servers)) {
205
+ return [];
206
+ }
207
+ const parsed = [];
208
+ for (const [index, rawServer] of servers.entries()) {
209
+ const entry = asRecord(rawServer);
210
+ if (!entry) {
211
+ logger.warn("plugin_config_server_invalid", { index });
212
+ continue;
213
+ }
214
+ const id = asString(entry.id);
215
+ const name = asString(entry.name) ?? id;
216
+ const issuer = asString(entry.issuer);
217
+ const baseURL = asString(entry.baseURL);
218
+ const clientId = asString(entry.clientId);
219
+ const scopes = asStringArray(entry.scopes);
220
+ if (!id || !issuer || !baseURL || !clientId || !scopes) {
221
+ logger.warn("plugin_config_server_missing_fields", {
222
+ index,
223
+ id: id ?? "unknown"
224
+ });
225
+ continue;
226
+ }
227
+ const syncIntervalMinutes = typeof entry.syncIntervalMinutes === "number" && Number.isFinite(entry.syncIntervalMinutes) && entry.syncIntervalMinutes > 0 ? entry.syncIntervalMinutes : undefined;
228
+ const sourceLabel = `pluginConfig.oauth2ModelSync.servers[${index}] (id=${id})`;
229
+ parsed.push({
230
+ id,
231
+ name: name ?? id,
232
+ issuer,
233
+ baseURL,
234
+ clientId,
235
+ clientSecret: asClientSecret(entry.clientSecret, sourceLabel),
236
+ scopes,
237
+ syncIntervalMinutes,
238
+ nameOverrides: asStringMap(entry.nameOverrides),
239
+ authorizationEndpoint: asString(entry.authorizationEndpoint),
240
+ tokenEndpoint: asString(entry.tokenEndpoint),
241
+ deviceAuthorizationEndpoint: asString(entry.deviceAuthorizationEndpoint),
242
+ jwksUri: asString(entry.jwksUri),
243
+ redirectPort: asRedirectPort(entry.redirectPort, sourceLabel),
244
+ authFlow: asAuthFlow(entry.authFlow, sourceLabel),
245
+ pkce: asBoolean(entry.pkce, `${sourceLabel}.pkce`),
246
+ subjectTokenSource: entry.subjectTokenSource,
247
+ tokenExchangeAudience: asString(entry.tokenExchangeAudience),
248
+ responseApi: asBoolean(entry.responseApi, `${sourceLabel}.responseApi`)
249
+ });
250
+ }
251
+ return parsed;
271
252
  }
272
253
  function collectManagedProviders(config, logger) {
273
- const providers = (config.provider ??= {});
274
- const byId = new Map();
275
- for (const server of parsePluginConfigServers(config, logger)) {
276
- const providerConfig = (providers[server.id] ??= {});
277
- const providerOptions = asRecord(providerConfig.options) ?? {};
278
- providerConfig.npm = resolveProviderNpm(server.responseApi);
279
- providerConfig.name = asString(providerConfig.name) ?? server.name ?? server.id;
280
- providerConfig.options = applyResponsesApiOptions({ ...providerOptions, baseURL: server.baseURL }, server.responseApi, server.id, logger);
281
- byId.set(server.id, {
282
- ...server,
283
- name: providerConfig.name ?? server.name ?? server.id
284
- });
285
- }
286
- for (const [providerId, providerConfig] of Object.entries(providers)) {
287
- const extension = parseOAuthExtension(providerConfig);
288
- if (!extension) {
289
- continue;
290
- }
291
- const options = asRecord(providerConfig.options) ?? {};
292
- const baseURL = asString(options.baseURL);
293
- if (!baseURL) {
294
- logger.warn("provider_skipped_missing_baseurl", { providerId });
295
- continue;
296
- }
297
- const providerName = asString(providerConfig.name) ?? providerId;
298
- providerConfig.npm = resolveProviderNpm(extension.responseApi);
299
- providerConfig.name = providerName;
300
- providerConfig.options = applyResponsesApiOptions({ ...options, baseURL }, extension.responseApi, providerId, logger);
301
- byId.set(providerId, {
302
- id: providerId,
303
- name: providerName,
304
- issuer: extension.issuer,
305
- baseURL,
306
- clientId: extension.clientId,
307
- clientSecret: extension.clientSecret,
308
- scopes: extension.scopes,
309
- syncIntervalMinutes: extension.syncIntervalMinutes,
310
- nameOverrides: extension.nameOverrides,
311
- authorizationEndpoint: extension.authorizationEndpoint,
312
- tokenEndpoint: extension.tokenEndpoint,
313
- deviceAuthorizationEndpoint: extension.deviceAuthorizationEndpoint,
314
- jwksUri: extension.jwksUri,
315
- redirectPort: extension.redirectPort,
316
- authFlow: extension.authFlow,
317
- pkce: extension.pkce,
318
- subjectTokenSource: extension.subjectTokenSource,
319
- tokenExchangeAudience: extension.tokenExchangeAudience,
320
- responseApi: extension.responseApi
321
- });
322
- }
323
- return { servers: [...byId.values()] };
254
+ const providers = config.provider ??= {};
255
+ const byId = new Map();
256
+ for (const server of parsePluginConfigServers(config, logger)) {
257
+ const providerConfig = providers[server.id] ??= {};
258
+ const providerOptions = asRecord(providerConfig.options) ?? {};
259
+ providerConfig.npm = resolveProviderNpm(server.responseApi);
260
+ providerConfig.name = asString(providerConfig.name) ?? server.name ?? server.id;
261
+ providerConfig.options = applyResponsesApiOptions({
262
+ ...providerOptions,
263
+ baseURL: server.baseURL
264
+ }, server.responseApi, server.id, logger);
265
+ byId.set(server.id, {
266
+ ...server,
267
+ name: providerConfig.name ?? server.name ?? server.id
268
+ });
269
+ }
270
+ for (const [providerId, providerConfig] of Object.entries(providers)) {
271
+ const extension = parseOAuthExtension(providerConfig);
272
+ if (!extension) {
273
+ continue;
274
+ }
275
+ const options = asRecord(providerConfig.options) ?? {};
276
+ const baseURL = asString(options.baseURL);
277
+ if (!baseURL) {
278
+ logger.warn("provider_skipped_missing_baseurl", { providerId });
279
+ continue;
280
+ }
281
+ const providerName = asString(providerConfig.name) ?? providerId;
282
+ providerConfig.npm = resolveProviderNpm(extension.responseApi);
283
+ providerConfig.name = providerName;
284
+ providerConfig.options = applyResponsesApiOptions({
285
+ ...options,
286
+ baseURL
287
+ }, extension.responseApi, providerId, logger);
288
+ byId.set(providerId, {
289
+ id: providerId,
290
+ name: providerName,
291
+ issuer: extension.issuer,
292
+ baseURL,
293
+ clientId: extension.clientId,
294
+ clientSecret: extension.clientSecret,
295
+ scopes: extension.scopes,
296
+ syncIntervalMinutes: extension.syncIntervalMinutes,
297
+ nameOverrides: extension.nameOverrides,
298
+ authorizationEndpoint: extension.authorizationEndpoint,
299
+ tokenEndpoint: extension.tokenEndpoint,
300
+ deviceAuthorizationEndpoint: extension.deviceAuthorizationEndpoint,
301
+ jwksUri: extension.jwksUri,
302
+ redirectPort: extension.redirectPort,
303
+ authFlow: extension.authFlow,
304
+ pkce: extension.pkce,
305
+ subjectTokenSource: extension.subjectTokenSource,
306
+ tokenExchangeAudience: extension.tokenExchangeAudience,
307
+ responseApi: extension.responseApi
308
+ });
309
+ }
310
+ return { servers: [...byId.values()] };
324
311
  }
325
312
  function runtimeSignature(config) {
326
- const sorted = [...config.servers].sort((a, b) => a.id.localeCompare(b.id));
327
- return JSON.stringify(sorted);
313
+ const sorted = [...config.servers].sort((a, b) => a.id.localeCompare(b.id));
314
+ return JSON.stringify(sorted);
328
315
  }
329
316
  function mergeDiscoveredModels(providerConfig, models) {
330
- const existingModels = (providerConfig.models ?? {});
331
- const merged = { ...existingModels };
332
- for (const model of models) {
333
- const existingModel = existingModels[model.id] ?? {};
334
- merged[model.id] = {
335
- ...existingModel,
336
- id: model.id,
337
- name: model.displayName
338
- };
339
- }
340
- providerConfig.models = merged;
317
+ const existingModels = providerConfig.models ?? {};
318
+ const merged = { ...existingModels };
319
+ for (const model of models) {
320
+ const existingModel = existingModels[model.id] ?? {};
321
+ merged[model.id] = {
322
+ ...existingModel,
323
+ id: model.id,
324
+ name: model.displayName
325
+ };
326
+ }
327
+ providerConfig.models = merged;
341
328
  }
342
329
  async function propagateCachedBearer(providerConfig, providerId, runtime, logger) {
343
- const options = (providerConfig.options ??= {});
344
- const headers = (options.headers ??= {});
345
- // Case-insensitive scan so a user-set `authorization:` lowercase entry
346
- // also wins — HTTP header names are case-insensitive but most plugins use
347
- // PascalCase.
348
- const hasUserAuth = Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
349
- if (hasUserAuth) {
350
- logger.debug("oauth2_bearer_propagation_skipped_user_set", { providerId });
351
- return;
352
- }
353
- // Refresh-only ensure: returns the warmed-up token, transparently refreshing
354
- // one that's near expiry, and throws rather than opening a second browser /
355
- // device-code prompt if a fresh login would be required. This is stricter
356
- // than reading the raw cache (the previous behavior) — a token minted moments
357
- // ago for a short-lived realm no longer fails a fixed expiry-skew gate, which
358
- // is exactly the case that left `@vymalo/opencode-models-info` fetching an
359
- // OAuth2-protected `meta.modelsInfoUrl` without a bearer (HTTP 401). A stale
360
- // value here is still harmless: `chat.headers` overwrites per request.
361
- logger.trace("oauth2_bearer_propagation_start", { providerId });
362
- let token;
363
- try {
364
- token = await runtime.ensureAccessToken(providerId, { interactive: false });
365
- }
366
- catch (error) {
367
- logger.debug("oauth2_bearer_propagation_skipped_no_token", {
368
- providerId,
369
- error: error instanceof Error ? error.message : String(error)
370
- });
371
- return;
372
- }
373
- if (!token.accessToken) {
374
- // ensureAccessToken resolved but with no usable token — surface it so a
375
- // downstream 401 (e.g. models-info) isn't a silent mystery.
376
- logger.debug("oauth2_bearer_propagation_skipped_empty_token", { providerId });
377
- return;
378
- }
379
- headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
380
- logger.debug("oauth2_bearer_propagated_to_provider_headers", { providerId });
330
+ const options = providerConfig.options ??= {};
331
+ const headers = options.headers ??= {};
332
+ // Case-insensitive scan so a user-set `authorization:` lowercase entry
333
+ // also wins — HTTP header names are case-insensitive but most plugins use
334
+ // PascalCase.
335
+ const hasUserAuth = Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
336
+ if (hasUserAuth) {
337
+ logger.debug("oauth2_bearer_propagation_skipped_user_set", { providerId });
338
+ return;
339
+ }
340
+ // Refresh-only ensure: returns the warmed-up token, transparently refreshing
341
+ // one that's near expiry, and throws rather than opening a second browser /
342
+ // device-code prompt if a fresh login would be required. This is stricter
343
+ // than reading the raw cache (the previous behavior) — a token minted moments
344
+ // ago for a short-lived realm no longer fails a fixed expiry-skew gate, which
345
+ // is exactly the case that left `@vymalo/opencode-models-info` fetching an
346
+ // OAuth2-protected `meta.modelsInfoUrl` without a bearer (HTTP 401). A stale
347
+ // value here is still harmless: `chat.headers` overwrites per request.
348
+ logger.trace("oauth2_bearer_propagation_start", { providerId });
349
+ let token;
350
+ try {
351
+ token = await runtime.ensureAccessToken(providerId, { interactive: false });
352
+ } catch (error) {
353
+ logger.debug("oauth2_bearer_propagation_skipped_no_token", {
354
+ providerId,
355
+ error: error instanceof Error ? error.message : String(error)
356
+ });
357
+ return;
358
+ }
359
+ if (!token.accessToken) {
360
+ // ensureAccessToken resolved but with no usable token — surface it so a
361
+ // downstream 401 (e.g. models-info) isn't a silent mystery.
362
+ logger.debug("oauth2_bearer_propagation_skipped_empty_token", { providerId });
363
+ return;
364
+ }
365
+ headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
366
+ logger.debug("oauth2_bearer_propagated_to_provider_headers", { providerId });
381
367
  }
382
368
  function createOpenCodeLogger(client, getMinLevel) {
383
- // Bypass createJsonConsoleLogger's own filter so the gate stays driven by
384
- // the current value of getMinLevel() — the level can change once the plugin
385
- // sees `pluginConfig.oauth2ModelSync.logLevel` during the `config` hook.
386
- const fallback = createJsonConsoleLogger("debug");
387
- // OpenCode already captures plugin logs via client.app.log (and filters them
388
- // by its own log level). Mirroring every event to stdout on top of that just
389
- // floods the terminal, so only mirror warn/error to the JSON console by
390
- // default; set VYMALO_PLUGIN_CONSOLE_LOG=1 to restore full console output.
391
- const consoleAll = /^(1|true|yes|on)$/i.test(process.env.VYMALO_PLUGIN_CONSOLE_LOG ?? "");
392
- const write = (level, event, fields) => {
393
- if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[getMinLevel()]) {
394
- return;
395
- }
396
- if (consoleAll || level === "warn" || level === "error") {
397
- fallback[level](event, fields);
398
- }
399
- // OpenCode's host log API has no dedicated `trace` level, so forward our
400
- // most-verbose tier as host `debug` (the trace gate above already ran, so
401
- // host-side filtering only sees records we intended to surface). The
402
- // original event name still carries the `oauth2_*` prefix, so the record
403
- // remains identifiable in the host log stream.
404
- const hostLevel = level === "trace" ? "debug" : level;
405
- void client.app
406
- .log({
407
- body: {
408
- service: PLUGIN_SERVICE_NAME,
409
- level: hostLevel,
410
- message: event,
411
- extra: fields
412
- }
413
- })
414
- .catch(() => {
415
- // Best-effort forwarding; console logger is the reliable fallback.
416
- });
417
- };
418
- return {
419
- trace(event, fields) {
420
- write("trace", event, fields);
421
- },
422
- debug(event, fields) {
423
- write("debug", event, fields);
424
- },
425
- info(event, fields) {
426
- write("info", event, fields);
427
- },
428
- warn(event, fields) {
429
- write("warn", event, fields);
430
- },
431
- error(event, fields) {
432
- write("error", event, fields);
433
- }
434
- };
369
+ // Bypass createJsonConsoleLogger's own filter so the gate stays driven by
370
+ // the current value of getMinLevel() — the level can change once the plugin
371
+ // sees `pluginConfig.oauth2ModelSync.logLevel` during the `config` hook.
372
+ const fallback = createJsonConsoleLogger("debug");
373
+ // OpenCode already captures plugin logs via client.app.log (and filters them
374
+ // by its own log level). Mirroring every event to stdout on top of that just
375
+ // floods the terminal, so only mirror warn/error to the JSON console by
376
+ // default; set VYMALO_PLUGIN_CONSOLE_LOG=1 to restore full console output.
377
+ const consoleAll = /^(1|true|yes|on)$/i.test(process.env.VYMALO_PLUGIN_CONSOLE_LOG ?? "");
378
+ const write = (level, event, fields) => {
379
+ if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[getMinLevel()]) {
380
+ return;
381
+ }
382
+ if (consoleAll || level === "warn" || level === "error") {
383
+ fallback[level](event, fields);
384
+ }
385
+ // OpenCode's host log API has no dedicated `trace` level, so forward our
386
+ // most-verbose tier as host `debug` (the trace gate above already ran, so
387
+ // host-side filtering only sees records we intended to surface). The
388
+ // original event name still carries the `oauth2_*` prefix, so the record
389
+ // remains identifiable in the host log stream.
390
+ const hostLevel = level === "trace" ? "debug" : level;
391
+ void client.app.log({ body: {
392
+ service: PLUGIN_SERVICE_NAME,
393
+ level: hostLevel,
394
+ message: event,
395
+ extra: fields
396
+ } }).catch(() => {
397
+ // Best-effort forwarding; console logger is the reliable fallback.
398
+ });
399
+ };
400
+ return {
401
+ trace(event, fields) {
402
+ write("trace", event, fields);
403
+ },
404
+ debug(event, fields) {
405
+ write("debug", event, fields);
406
+ },
407
+ info(event, fields) {
408
+ write("info", event, fields);
409
+ },
410
+ warn(event, fields) {
411
+ write("warn", event, fields);
412
+ },
413
+ error(event, fields) {
414
+ write("error", event, fields);
415
+ }
416
+ };
435
417
  }
436
418
  export function createOpencodeOauth2Plugin(factoryOptions = {}) {
437
- return async ({ client }) => {
438
- // The plugin defers to OpenCode's own `config.logLevel` for filter
439
- // decisions. Until the first `config` hook fires we don't know what the
440
- // host picked, so we start at the package default (`"info"`) and update
441
- // the holder once we see the real value.
442
- let currentLogLevel = DEFAULT_LOG_LEVEL;
443
- const logger = factoryOptions.logger ?? createOpenCodeLogger(client, () => currentLogLevel);
444
- const state = {
445
- runtime: undefined,
446
- signature: undefined,
447
- managedProviderIds: new Set()
448
- };
449
- return {
450
- config: async (config) => {
451
- // Apply the host's logLevel BEFORE walking the config: parsing emits
452
- // `plugin_config_server_invalid` / `plugin_config_server_missing_fields`
453
- // warnings via `logger`, and those need to be filtered against the
454
- // user's chosen threshold — not the bootstrap default.
455
- currentLogLevel = fromOpenCodeLogLevel(config.logLevel) ?? DEFAULT_LOG_LEVEL;
456
- logger.trace("oauth2_config_hook_start", {
457
- logLevel: currentLogLevel,
458
- hostLogLevel: typeof config.logLevel === "string" ? config.logLevel : undefined
459
- });
460
- const managed = collectManagedProviders(config, logger);
461
- logger.trace("oauth2_config_hook_collected_providers", {
462
- managedCount: managed.servers.length,
463
- providerIds: managed.servers.map((server) => server.id)
464
- });
465
- if (managed.servers.length === 0) {
466
- logger.trace("oauth2_config_hook_no_managed_providers", {});
467
- state.runtime?.stop();
468
- state.runtime = undefined;
469
- state.signature = undefined;
470
- state.managedProviderIds = new Set();
471
- return;
472
- }
473
- const pluginConfig = {
474
- servers: managed.servers,
475
- cacheNamespace: "opencode-oauth2-model-sync",
476
- logLevel: currentLogLevel
477
- };
478
- const signature = runtimeSignature(pluginConfig);
479
- if (!state.runtime || state.signature !== signature) {
480
- logger.trace("oauth2_runtime_rebuild", {
481
- reason: state.runtime ? "signature_changed" : "first_build",
482
- serverCount: pluginConfig.servers.length
483
- });
484
- state.runtime?.stop();
485
- state.runtime = new OAuth2ModelSyncPlugin(pluginConfig, {
486
- logger,
487
- fetchImpl: factoryOptions.fetchImpl,
488
- onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
489
- cacheDir: factoryOptions.cacheDir
490
- });
491
- await state.runtime.initialize();
492
- await state.runtime.start({ warmup: true });
493
- state.signature = signature;
494
- }
495
- else {
496
- logger.trace("oauth2_runtime_reused", { serverCount: pluginConfig.servers.length });
497
- }
498
- state.managedProviderIds = new Set(managed.servers.map((server) => server.id));
499
- const providers = (config.provider ??= {});
500
- const runtime = state.runtime;
501
- // Each provider is independent (distinct config object, distinct
502
- // runtime state), and propagation can do a token-refresh round trip
503
- // (up to httpTimeoutMs). Fan out so one slow IdP doesn't serialize
504
- // startup behind the others. propagateCachedBearer swallows its own
505
- // errors, so this never rejects.
506
- await Promise.all([...state.managedProviderIds].map((providerId) => {
507
- const providerConfig = providers[providerId];
508
- if (!providerConfig) {
509
- return undefined;
510
- }
511
- const models = runtime.getServerModels(providerId);
512
- logger.trace("oauth2_config_hook_provider_models", {
513
- providerId,
514
- modelCount: models.length
515
- });
516
- if (models.length > 0) {
517
- mergeDiscoveredModels(providerConfig, models);
518
- }
519
- // Stamp the cached bearer onto `options.headers.Authorization` so
520
- // subsequent `config` hooks (e.g. @vymalo/opencode-models-info
521
- // fetching a metadata endpoint) can inherit it without depending
522
- // on this plugin. `chat.headers` still overwrites per-request with
523
- // a freshly-ensured token, so a stale value here can only ever
524
- // affect other config-time consumers never the actual inference
525
- // call. We never clobber a user-set Authorization header.
526
- return propagateCachedBearer(providerConfig, providerId, runtime, logger);
527
- }));
528
- logger.trace("oauth2_config_hook_finished", {
529
- managedCount: state.managedProviderIds.size
530
- });
531
- },
532
- "chat.headers": async (input, output) => {
533
- const providerId = input.model?.providerID ?? input.provider?.info?.id;
534
- if (!providerId || !state.runtime || !state.managedProviderIds.has(providerId)) {
535
- logger.trace("oauth2_chat_headers_skipped", {
536
- providerId,
537
- managed: providerId ? state.managedProviderIds.has(providerId) : false
538
- });
539
- return;
540
- }
541
- logger.trace("oauth2_chat_headers_ensure_token", { providerId });
542
- const token = await state.runtime.ensureAccessToken(providerId);
543
- output.headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
544
- logger.trace("oauth2_chat_headers_bearer_injected", {
545
- providerId,
546
- present: Boolean(token.accessToken),
547
- tokenType: token.tokenType || "Bearer"
548
- });
549
- if (state.runtime.getServerModels(providerId).length === 0) {
550
- logger.trace("oauth2_chat_headers_lazy_sync", { providerId });
551
- void state.runtime.syncServer(providerId);
552
- }
553
- }
554
- };
555
- };
419
+ return async ({ client }) => {
420
+ // The plugin defers to OpenCode's own `config.logLevel` for filter
421
+ // decisions. Until the first `config` hook fires we don't know what the
422
+ // host picked, so we start at the package default (`"info"`) and update
423
+ // the holder once we see the real value.
424
+ let currentLogLevel = DEFAULT_LOG_LEVEL;
425
+ const logger = factoryOptions.logger ?? createOpenCodeLogger(client, () => currentLogLevel);
426
+ const state = {
427
+ runtime: undefined,
428
+ signature: undefined,
429
+ managedProviderIds: new Set()
430
+ };
431
+ return {
432
+ config: async (config) => {
433
+ // Apply the host's logLevel BEFORE walking the config: parsing emits
434
+ // `plugin_config_server_invalid` / `plugin_config_server_missing_fields`
435
+ // warnings via `logger`, and those need to be filtered against the
436
+ // user's chosen threshold — not the bootstrap default.
437
+ currentLogLevel = fromOpenCodeLogLevel(config.logLevel) ?? DEFAULT_LOG_LEVEL;
438
+ logger.trace("oauth2_config_hook_start", {
439
+ logLevel: currentLogLevel,
440
+ hostLogLevel: typeof config.logLevel === "string" ? config.logLevel : undefined
441
+ });
442
+ const managed = collectManagedProviders(config, logger);
443
+ logger.trace("oauth2_config_hook_collected_providers", {
444
+ managedCount: managed.servers.length,
445
+ providerIds: managed.servers.map((server) => server.id)
446
+ });
447
+ if (managed.servers.length === 0) {
448
+ logger.trace("oauth2_config_hook_no_managed_providers", {});
449
+ state.runtime?.stop();
450
+ state.runtime = undefined;
451
+ state.signature = undefined;
452
+ state.managedProviderIds = new Set();
453
+ return;
454
+ }
455
+ const pluginConfig = {
456
+ servers: managed.servers,
457
+ cacheNamespace: "opencode-oauth2-model-sync",
458
+ logLevel: currentLogLevel
459
+ };
460
+ const signature = runtimeSignature(pluginConfig);
461
+ if (!state.runtime || state.signature !== signature) {
462
+ logger.trace("oauth2_runtime_rebuild", {
463
+ reason: state.runtime ? "signature_changed" : "first_build",
464
+ serverCount: pluginConfig.servers.length
465
+ });
466
+ state.runtime?.stop();
467
+ state.runtime = new OAuth2ModelSyncPlugin(pluginConfig, {
468
+ logger,
469
+ fetchImpl: factoryOptions.fetchImpl,
470
+ onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
471
+ cacheDir: factoryOptions.cacheDir
472
+ });
473
+ await state.runtime.initialize();
474
+ await state.runtime.start({ warmup: true });
475
+ state.signature = signature;
476
+ } else {
477
+ logger.trace("oauth2_runtime_reused", { serverCount: pluginConfig.servers.length });
478
+ }
479
+ state.managedProviderIds = new Set(managed.servers.map((server) => server.id));
480
+ const providers = config.provider ??= {};
481
+ const runtime = state.runtime;
482
+ // Each provider is independent (distinct config object, distinct
483
+ // runtime state), and propagation can do a token-refresh round trip
484
+ // (up to httpTimeoutMs). Fan out so one slow IdP doesn't serialize
485
+ // startup behind the others. propagateCachedBearer swallows its own
486
+ // errors, so this never rejects.
487
+ await Promise.all([...state.managedProviderIds].map((providerId) => {
488
+ const providerConfig = providers[providerId];
489
+ if (!providerConfig) {
490
+ return undefined;
491
+ }
492
+ const models = runtime.getServerModels(providerId);
493
+ logger.trace("oauth2_config_hook_provider_models", {
494
+ providerId,
495
+ modelCount: models.length
496
+ });
497
+ if (models.length > 0) {
498
+ mergeDiscoveredModels(providerConfig, models);
499
+ }
500
+ // Stamp the cached bearer onto `options.headers.Authorization` so
501
+ // subsequent `config` hooks (e.g. @vymalo/opencode-models-info
502
+ // fetching a metadata endpoint) can inherit it without depending
503
+ // on this plugin. `chat.headers` still overwrites per-request with
504
+ // a freshly-ensured token, so a stale value here can only ever
505
+ // affect other config-time consumers never the actual inference
506
+ // call. We never clobber a user-set Authorization header.
507
+ return propagateCachedBearer(providerConfig, providerId, runtime, logger);
508
+ }));
509
+ logger.trace("oauth2_config_hook_finished", { managedCount: state.managedProviderIds.size });
510
+ },
511
+ "chat.headers": async (input, output) => {
512
+ const providerId = input.model?.providerID ?? input.provider?.info?.id;
513
+ if (!providerId || !state.runtime || !state.managedProviderIds.has(providerId)) {
514
+ logger.trace("oauth2_chat_headers_skipped", {
515
+ providerId,
516
+ managed: providerId ? state.managedProviderIds.has(providerId) : false
517
+ });
518
+ return;
519
+ }
520
+ logger.trace("oauth2_chat_headers_ensure_token", { providerId });
521
+ const token = await state.runtime.ensureAccessToken(providerId);
522
+ output.headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
523
+ logger.trace("oauth2_chat_headers_bearer_injected", {
524
+ providerId,
525
+ present: Boolean(token.accessToken),
526
+ tokenType: token.tokenType || "Bearer"
527
+ });
528
+ if (state.runtime.getServerModels(providerId).length === 0) {
529
+ logger.trace("oauth2_chat_headers_lazy_sync", { providerId });
530
+ void state.runtime.syncServer(providerId);
531
+ }
532
+ }
533
+ };
534
+ };
556
535
  }
557
536
  export const OpencodeOauth2Plugin = createOpencodeOauth2Plugin();
558
537
  export default OpencodeOauth2Plugin;
538
+
559
539
  //# sourceMappingURL=opencode.js.map