@vymalo/opencode-oauth2 0.12.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.
- package/dist/cache.d.ts +6 -6
- package/dist/cache.js +100 -104
- package/dist/cache.js.map +1 -1
- package/dist/config.d.ts +107 -107
- package/dist/config.js +173 -182
- package/dist/config.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib.js +1 -0
- package/dist/lib.js.map +1 -1
- package/dist/logging.d.ts +6 -6
- package/dist/logging.js +57 -56
- package/dist/logging.js.map +1 -1
- package/dist/model-discovery.d.ts +3 -3
- package/dist/model-discovery.js +61 -66
- package/dist/model-discovery.js.map +1 -1
- package/dist/model-normalization.js +84 -73
- package/dist/model-normalization.js.map +1 -1
- package/dist/oauth/browser.js +29 -25
- package/dist/oauth/browser.js.map +1 -1
- package/dist/oauth/client.d.ts +46 -46
- package/dist/oauth/client.js +411 -430
- package/dist/oauth/client.js.map +1 -1
- package/dist/oauth/device-code.d.ts +23 -23
- package/dist/oauth/device-code.js +225 -235
- package/dist/oauth/device-code.js.map +1 -1
- package/dist/oauth/discovery.d.ts +5 -5
- package/dist/oauth/discovery.js +34 -34
- package/dist/oauth/discovery.js.map +1 -1
- package/dist/oauth/http-utils.d.ts +27 -27
- package/dist/oauth/http-utils.js +96 -107
- package/dist/oauth/http-utils.js.map +1 -1
- package/dist/oauth/local-callback.d.ts +5 -5
- package/dist/oauth/local-callback.js +76 -74
- package/dist/oauth/local-callback.js.map +1 -1
- package/dist/oauth/pkce.d.ts +2 -2
- package/dist/oauth/pkce.js +9 -5
- package/dist/oauth/pkce.js.map +1 -1
- package/dist/oauth/subject-token.d.ts +15 -15
- package/dist/oauth/subject-token.js +68 -73
- package/dist/oauth/subject-token.js.map +1 -1
- package/dist/opencode.d.ts +15 -15
- package/dist/opencode.js +477 -497
- package/dist/opencode.js.map +1 -1
- package/dist/plugin.d.ts +39 -39
- package/dist/plugin.js +270 -277
- package/dist/plugin.js.map +1 -1
- package/dist/responses-repair.d.ts +12 -35
- package/dist/responses-repair.js +119 -125
- package/dist/responses-repair.js.map +1 -1
- package/dist/scheduler.d.ts +5 -5
- package/dist/scheduler.js +43 -45
- package/dist/scheduler.js.map +1 -1
- package/dist/types.d.ts +25 -25
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
45
|
+
return responseApi ? OPENAI_RESPONSES_NPM : OPENAI_COMPATIBLE_NPM;
|
|
51
46
|
}
|
|
52
47
|
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
121
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
134
125
|
}
|
|
135
126
|
function asString(value) {
|
|
136
|
-
|
|
127
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
137
128
|
}
|
|
138
129
|
function asStringArray(value) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|