@cortexkit/common-auth 0.2.7 → 0.2.8
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/opencode2/errors.d.ts +23 -0
- package/dist/opencode2/errors.js +15 -0
- package/dist/opencode2/index.d.ts +8 -1
- package/dist/opencode2/index.js +4 -2
- package/dist/opencode2/install.d.ts +19 -0
- package/dist/opencode2/install.js +415 -0
- package/dist/opencode2/integration.d.ts +78 -0
- package/dist/opencode2/integration.js +94 -0
- package/dist/opencode2/sse.d.ts +12 -0
- package/dist/opencode2/sse.js +60 -0
- package/dist/opencode2/types.d.ts +202 -0
- package/dist/opencode2/types.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why an OpenCode 2 auth hook refused a request.
|
|
3
|
+
*
|
|
4
|
+
* - `no-account`: the adapter had no account to offer for this request, so
|
|
5
|
+
* the request is stopped before the host picks a transport.
|
|
6
|
+
* - `host-credential-on-wire`: after every rewrite, a header still carried
|
|
7
|
+
* one of the host's placeholder credentials. Sending it would leak a
|
|
8
|
+
* non-routable value to the provider and fail with a confusing error.
|
|
9
|
+
*/
|
|
10
|
+
export type OpenCode2AuthFailureKind = 'no-account' | 'host-credential-on-wire';
|
|
11
|
+
export declare class OpenCode2AuthError extends Error {
|
|
12
|
+
readonly kind: OpenCode2AuthFailureKind;
|
|
13
|
+
readonly providerID: string;
|
|
14
|
+
readonly sessionID: string;
|
|
15
|
+
readonly requestKind: string;
|
|
16
|
+
constructor(details: {
|
|
17
|
+
kind: OpenCode2AuthFailureKind;
|
|
18
|
+
providerID: string;
|
|
19
|
+
sessionID: string;
|
|
20
|
+
requestKind: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class OpenCode2AuthError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
providerID;
|
|
4
|
+
sessionID;
|
|
5
|
+
requestKind;
|
|
6
|
+
constructor(details) {
|
|
7
|
+
super(details.message ??
|
|
8
|
+
`${details.providerID} ${details.requestKind} request refused (${details.kind})`);
|
|
9
|
+
this.name = 'OpenCode2AuthError';
|
|
10
|
+
this.kind = details.kind;
|
|
11
|
+
this.providerID = details.providerID;
|
|
12
|
+
this.sessionID = details.sessionID;
|
|
13
|
+
this.requestKind = details.requestKind;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -1 +1,8 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export type { OpenCode2AuthFailureKind } from './errors.js';
|
|
2
|
+
export { OpenCode2AuthError } from './errors.js';
|
|
3
|
+
export { applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
|
|
4
|
+
export type { FormAnswer, PoolAuthorization, PoolLoginMethod, RegisterOpenCode2AuthMethodsOptions, } from './integration.js';
|
|
5
|
+
export { isPlaceholderCredential, PLACEHOLDER_LIFETIME_MS, PLACEHOLDER_METADATA_KEY, PLACEHOLDER_PREFIX, placeholderCredential, placeholderSecret, registerOpenCode2AuthMethods, } from './integration.js';
|
|
6
|
+
export type { ServerSentEvent } from './sse.js';
|
|
7
|
+
export { watchServerSentEvents } from './sse.js';
|
|
8
|
+
export type { AccountRequest, ChooseAccountInput, EventVerdict, HeaderEdits, HostError, InstallOpenCode2AuthOptions, LimitSignal, OpenCode2AuthAdapter, OpenCode2AuthEventName, OpenCode2AuthEvents, OpenCode2AuthInstallation, OpenCode2AuthLogger, OpenCode2HookContext, RequestKind, RequestScope, RetryReason, SelectingHook, Transport, } from './types.js';
|
package/dist/opencode2/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export {};
|
|
1
|
+
export { OpenCode2AuthError } from './errors.js';
|
|
2
|
+
export { applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
|
|
3
|
+
export { isPlaceholderCredential, PLACEHOLDER_LIFETIME_MS, PLACEHOLDER_METADATA_KEY, PLACEHOLDER_PREFIX, placeholderCredential, placeholderSecret, registerOpenCode2AuthMethods, } from './integration.js';
|
|
4
|
+
export { watchServerSentEvents } from './sse.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HeaderEdits, InstallOpenCode2AuthOptions, OpenCode2AuthAdapter, OpenCode2AuthInstallation, OpenCode2HookContext } from './types.js';
|
|
2
|
+
export declare const DEFAULT_MAX_RECORDS = 512;
|
|
3
|
+
/** Applies edits to a plain header record, replacing every spelling of each name. */
|
|
4
|
+
export declare function applyHeaderEdits(target: Record<string, string>, edits: HeaderEdits): void;
|
|
5
|
+
/**
|
|
6
|
+
* Installs multi-account auth on OpenCode 2's own provider drivers. Every
|
|
7
|
+
* hook is scoped to `adapter.providerID`:
|
|
8
|
+
*
|
|
9
|
+
* - `model.request` picks the account for the request's `sessionID:kind` and
|
|
10
|
+
* sets its headers;
|
|
11
|
+
* - `http.request` and `experimental.ws.handshake` set them again, because
|
|
12
|
+
* the host applies its own credential after `model.request`;
|
|
13
|
+
* - `http.response` and `experimental.ws.receive` read quota, refusals and
|
|
14
|
+
* whether output has started, attributed through the record above;
|
|
15
|
+
* - `retry` asks the host to retry at once when an account was refused
|
|
16
|
+
* before any output, so `model.request` runs again and can pick another
|
|
17
|
+
* account, and refuses to retry once output has started.
|
|
18
|
+
*/
|
|
19
|
+
export declare function installOpenCode2Auth<Q>(ctx: OpenCode2HookContext, adapter: OpenCode2AuthAdapter<Q>, options?: InstallOpenCode2AuthOptions): Promise<OpenCode2AuthInstallation<Q>>;
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { OpenCode2AuthError } from './errors.js';
|
|
2
|
+
import { placeholderSecret } from './integration.js';
|
|
3
|
+
import { watchServerSentEvents } from './sse.js';
|
|
4
|
+
export const DEFAULT_MAX_RECORDS = 512;
|
|
5
|
+
const keyOf = (sessionID, kind) => JSON.stringify([sessionID, kind]);
|
|
6
|
+
const scopeOf = (draft) => ({
|
|
7
|
+
providerID: draft.model.providerID,
|
|
8
|
+
modelID: draft.model.id,
|
|
9
|
+
sessionID: draft.sessionID,
|
|
10
|
+
agent: draft.agent,
|
|
11
|
+
kind: draft.kind,
|
|
12
|
+
});
|
|
13
|
+
/** Applies edits to a plain header record, replacing every spelling of each name. */
|
|
14
|
+
export function applyHeaderEdits(target, edits) {
|
|
15
|
+
for (const [name, value] of Object.entries(edits)) {
|
|
16
|
+
const lower = name.toLowerCase();
|
|
17
|
+
for (const existing of Object.keys(target)) {
|
|
18
|
+
if (existing.toLowerCase() === lower)
|
|
19
|
+
delete target[existing];
|
|
20
|
+
}
|
|
21
|
+
if (value !== null)
|
|
22
|
+
target[name] = value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function applyHeaderEditsTo(target, edits) {
|
|
26
|
+
for (const [name, value] of Object.entries(edits)) {
|
|
27
|
+
if (value === null)
|
|
28
|
+
target.delete(name);
|
|
29
|
+
else
|
|
30
|
+
target.set(name, value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Installs multi-account auth on OpenCode 2's own provider drivers. Every
|
|
35
|
+
* hook is scoped to `adapter.providerID`:
|
|
36
|
+
*
|
|
37
|
+
* - `model.request` picks the account for the request's `sessionID:kind` and
|
|
38
|
+
* sets its headers;
|
|
39
|
+
* - `http.request` and `experimental.ws.handshake` set them again, because
|
|
40
|
+
* the host applies its own credential after `model.request`;
|
|
41
|
+
* - `http.response` and `experimental.ws.receive` read quota, refusals and
|
|
42
|
+
* whether output has started, attributed through the record above;
|
|
43
|
+
* - `retry` asks the host to retry at once when an account was refused
|
|
44
|
+
* before any output, so `model.request` runs again and can pick another
|
|
45
|
+
* account, and refuses to retry once output has started.
|
|
46
|
+
*/
|
|
47
|
+
export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
48
|
+
const { providerID } = adapter;
|
|
49
|
+
const logger = options.logger;
|
|
50
|
+
const maxRecords = Math.max(1, options.maxRecords ?? DEFAULT_MAX_RECORDS);
|
|
51
|
+
const forbidden = (options.hostCredentials ?? [placeholderSecret(providerID)]).filter((value) => value !== '');
|
|
52
|
+
const records = new Map();
|
|
53
|
+
const byRequest = new WeakMap();
|
|
54
|
+
const listeners = new Map();
|
|
55
|
+
let seq = 0;
|
|
56
|
+
const warn = (message, data) => {
|
|
57
|
+
try {
|
|
58
|
+
logger?.warn(message, data);
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
};
|
|
62
|
+
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
63
|
+
const emit = async (event, payload) => {
|
|
64
|
+
const set = listeners.get(event);
|
|
65
|
+
if (!set || set.size === 0)
|
|
66
|
+
return;
|
|
67
|
+
await Promise.all([...set].map(async (listener) => {
|
|
68
|
+
try {
|
|
69
|
+
await listener(payload);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
warn('opencode2 auth listener threw; the request is unaffected', {
|
|
73
|
+
event,
|
|
74
|
+
error: describe(error),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}));
|
|
78
|
+
};
|
|
79
|
+
const remember = (rec) => {
|
|
80
|
+
const key = keyOf(rec.scope.sessionID, rec.scope.kind);
|
|
81
|
+
records.delete(key);
|
|
82
|
+
records.set(key, rec);
|
|
83
|
+
while (records.size > maxRecords) {
|
|
84
|
+
const oldest = records.keys().next().value;
|
|
85
|
+
if (oldest === undefined)
|
|
86
|
+
break;
|
|
87
|
+
records.delete(oldest);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const accountOf = (rec) => rec.accountId === undefined
|
|
91
|
+
? undefined
|
|
92
|
+
: { ...rec.scope, accountId: rec.accountId };
|
|
93
|
+
const noAccount = (scope) => new OpenCode2AuthError({
|
|
94
|
+
kind: 'no-account',
|
|
95
|
+
providerID,
|
|
96
|
+
sessionID: scope.sessionID,
|
|
97
|
+
requestKind: scope.kind,
|
|
98
|
+
});
|
|
99
|
+
const guard = (scope, headers) => {
|
|
100
|
+
for (const [name, value] of headers) {
|
|
101
|
+
if (forbidden.some((secret) => value.includes(secret))) {
|
|
102
|
+
throw new OpenCode2AuthError({
|
|
103
|
+
kind: 'host-credential-on-wire',
|
|
104
|
+
providerID,
|
|
105
|
+
sessionID: scope.sessionID,
|
|
106
|
+
requestKind: scope.kind,
|
|
107
|
+
message: `${providerID} ${scope.kind} request still carries the host's placeholder credential in ${name}; the adapter's account headers must replace it`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const select = async (scope, hook) => {
|
|
113
|
+
const prior = records.get(keyOf(scope.sessionID, scope.kind));
|
|
114
|
+
const input = {
|
|
115
|
+
...scope,
|
|
116
|
+
...(prior?.accountId === undefined
|
|
117
|
+
? {}
|
|
118
|
+
: { previousAccountId: prior.accountId }),
|
|
119
|
+
...(prior?.rerouteFrom === undefined
|
|
120
|
+
? {}
|
|
121
|
+
: { rerouteFrom: prior.rerouteFrom }),
|
|
122
|
+
};
|
|
123
|
+
const accountId = await adapter.chooseAccount(input);
|
|
124
|
+
const headers = accountId === undefined
|
|
125
|
+
? {}
|
|
126
|
+
: await adapter.accountHeaders({ ...scope, accountId });
|
|
127
|
+
const rec = {
|
|
128
|
+
scope,
|
|
129
|
+
accountId,
|
|
130
|
+
headers,
|
|
131
|
+
seq: ++seq,
|
|
132
|
+
outputStarted: false,
|
|
133
|
+
};
|
|
134
|
+
remember(rec);
|
|
135
|
+
if (accountId !== undefined) {
|
|
136
|
+
void emit('select', {
|
|
137
|
+
...scope,
|
|
138
|
+
accountId,
|
|
139
|
+
hook,
|
|
140
|
+
...(input.previousAccountId === undefined
|
|
141
|
+
? {}
|
|
142
|
+
: { previousAccountId: input.previousAccountId }),
|
|
143
|
+
...(input.rerouteFrom === undefined
|
|
144
|
+
? {}
|
|
145
|
+
: { rerouteFrom: input.rerouteFrom }),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return rec;
|
|
149
|
+
};
|
|
150
|
+
// The transport hooks normally follow `model.request`; choosing here too
|
|
151
|
+
// keeps a request that skipped it from going out under the host credential.
|
|
152
|
+
const ensure = async (scope, hook) => records.get(keyOf(scope.sessionID, scope.kind)) ??
|
|
153
|
+
(await select(scope, hook));
|
|
154
|
+
const noteLimit = (rec, signal, via) => {
|
|
155
|
+
const account = accountOf(rec);
|
|
156
|
+
if (!account || rec.limit)
|
|
157
|
+
return;
|
|
158
|
+
rec.limit = {
|
|
159
|
+
signal,
|
|
160
|
+
delivered: emit('limit', {
|
|
161
|
+
...account,
|
|
162
|
+
via,
|
|
163
|
+
limit: signal,
|
|
164
|
+
outputStarted: rec.outputStarted,
|
|
165
|
+
}),
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
const noteQuota = (rec, transport, quota, status) => {
|
|
169
|
+
const account = accountOf(rec);
|
|
170
|
+
if (!account)
|
|
171
|
+
return;
|
|
172
|
+
void emit('quota', {
|
|
173
|
+
...account,
|
|
174
|
+
transport,
|
|
175
|
+
...(status === undefined ? {} : { status }),
|
|
176
|
+
quota,
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
const inspect = (rec, transport, data, event) => {
|
|
180
|
+
const verdict = adapter.inspectEvent?.(event === undefined ? { transport, data } : { transport, data, event });
|
|
181
|
+
if (!verdict)
|
|
182
|
+
return;
|
|
183
|
+
if (verdict.quota !== undefined)
|
|
184
|
+
noteQuota(rec, transport, verdict.quota);
|
|
185
|
+
if (verdict.outputStarted)
|
|
186
|
+
rec.outputStarted = true;
|
|
187
|
+
if (verdict.limit)
|
|
188
|
+
noteLimit(rec, verdict.limit, transport);
|
|
189
|
+
};
|
|
190
|
+
const pickForRetry = (sessionID) => {
|
|
191
|
+
let refused;
|
|
192
|
+
let primary;
|
|
193
|
+
let latest;
|
|
194
|
+
for (const rec of records.values()) {
|
|
195
|
+
if (rec.scope.sessionID !== sessionID)
|
|
196
|
+
continue;
|
|
197
|
+
if (!latest || rec.seq > latest.seq)
|
|
198
|
+
latest = rec;
|
|
199
|
+
if (rec.scope.kind === 'primary')
|
|
200
|
+
primary = rec;
|
|
201
|
+
if (rec.limit && !rec.rerouteFrom && (!refused || rec.seq > refused.seq))
|
|
202
|
+
refused = rec;
|
|
203
|
+
}
|
|
204
|
+
return refused ?? primary ?? latest;
|
|
205
|
+
};
|
|
206
|
+
const scoped = { providerID };
|
|
207
|
+
const registrations = [
|
|
208
|
+
await ctx.session.hook('model.request', async (draft) => {
|
|
209
|
+
const rec = await select(scopeOf(draft), 'model.request');
|
|
210
|
+
if (rec.accountId === undefined)
|
|
211
|
+
throw noAccount(rec.scope);
|
|
212
|
+
applyHeaderEdits(draft.headers, rec.headers);
|
|
213
|
+
}, scoped),
|
|
214
|
+
await ctx.session.hook('http.request', async (draft) => {
|
|
215
|
+
const rec = await ensure(scopeOf(draft), 'http.request');
|
|
216
|
+
const account = accountOf(rec);
|
|
217
|
+
if (!account)
|
|
218
|
+
throw noAccount(rec.scope);
|
|
219
|
+
let request = draft.request;
|
|
220
|
+
if (adapter.rewriteRequest) {
|
|
221
|
+
request =
|
|
222
|
+
(await adapter.rewriteRequest({ ...account, request })) ?? request;
|
|
223
|
+
}
|
|
224
|
+
const headers = new Headers(request.headers);
|
|
225
|
+
applyHeaderEditsTo(headers, rec.headers);
|
|
226
|
+
guard(rec.scope, headers.entries());
|
|
227
|
+
const final = new Request(request, { headers });
|
|
228
|
+
byRequest.set(final, rec);
|
|
229
|
+
draft.request = final;
|
|
230
|
+
}, scoped),
|
|
231
|
+
await ctx.session.hook('http.response', async (draft) => {
|
|
232
|
+
const rec = byRequest.get(draft.request) ??
|
|
233
|
+
records.get(keyOf(draft.sessionID, draft.kind));
|
|
234
|
+
const account = rec && accountOf(rec);
|
|
235
|
+
if (!rec || !account)
|
|
236
|
+
return;
|
|
237
|
+
const original = draft.response;
|
|
238
|
+
const quota = adapter.quotaFromHeaders?.(original.headers, original.status);
|
|
239
|
+
if (quota !== undefined)
|
|
240
|
+
noteQuota(rec, 'http', quota, original.status);
|
|
241
|
+
if (!original.ok && adapter.limitFromResponse) {
|
|
242
|
+
const signal = await adapter.limitFromResponse({
|
|
243
|
+
status: original.status,
|
|
244
|
+
headers: original.headers,
|
|
245
|
+
body: () => original.clone().text(),
|
|
246
|
+
});
|
|
247
|
+
if (signal)
|
|
248
|
+
noteLimit(rec, signal, 'http');
|
|
249
|
+
}
|
|
250
|
+
let response = original;
|
|
251
|
+
if (original.body && adapter.inspectEvent) {
|
|
252
|
+
const watch = watchServerSentEvents((event) => inspect(rec, 'http', event.data, event.event), (error) => warn('opencode2 auth event inspection threw', {
|
|
253
|
+
error: describe(error),
|
|
254
|
+
}));
|
|
255
|
+
response = new Response(original.body.pipeThrough(watch), {
|
|
256
|
+
status: original.status,
|
|
257
|
+
statusText: original.statusText,
|
|
258
|
+
headers: original.headers,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (adapter.rewriteResponse) {
|
|
262
|
+
response =
|
|
263
|
+
(await adapter.rewriteResponse({
|
|
264
|
+
...account,
|
|
265
|
+
request: draft.request,
|
|
266
|
+
response,
|
|
267
|
+
})) ?? response;
|
|
268
|
+
}
|
|
269
|
+
if (response !== original)
|
|
270
|
+
draft.response = response;
|
|
271
|
+
}, scoped),
|
|
272
|
+
await ctx.session.hook('experimental.ws.handshake', async (draft) => {
|
|
273
|
+
const rec = await ensure(scopeOf(draft), 'experimental.ws.handshake');
|
|
274
|
+
const account = accountOf(rec);
|
|
275
|
+
if (!account)
|
|
276
|
+
throw noAccount(rec.scope);
|
|
277
|
+
applyHeaderEdits(draft.headers, rec.headers);
|
|
278
|
+
const url = adapter.rewriteHandshakeURL?.({
|
|
279
|
+
...account,
|
|
280
|
+
url: draft.url,
|
|
281
|
+
});
|
|
282
|
+
if (url !== undefined)
|
|
283
|
+
draft.url = url;
|
|
284
|
+
guard(rec.scope, Object.entries(draft.headers));
|
|
285
|
+
}, scoped),
|
|
286
|
+
await ctx.session.hook('experimental.ws.receive', (draft) => {
|
|
287
|
+
const rec = records.get(keyOf(draft.sessionID, draft.kind));
|
|
288
|
+
if (!rec || rec.accountId === undefined)
|
|
289
|
+
return;
|
|
290
|
+
try {
|
|
291
|
+
inspect(rec, 'ws', draft.frame);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
warn('opencode2 auth frame inspection threw', {
|
|
295
|
+
error: describe(error),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}, scoped),
|
|
299
|
+
await ctx.session.hook('retry', async (draft) => {
|
|
300
|
+
const rec = pickForRetry(draft.sessionID);
|
|
301
|
+
if (!rec)
|
|
302
|
+
return;
|
|
303
|
+
const hostDecision = draft.decision;
|
|
304
|
+
let decision = hostDecision;
|
|
305
|
+
let reason;
|
|
306
|
+
if (rec.accountId === undefined) {
|
|
307
|
+
decision = { retry: false };
|
|
308
|
+
reason = 'no-account';
|
|
309
|
+
}
|
|
310
|
+
else if (rec.outputStarted) {
|
|
311
|
+
// The user has already seen part of this answer; a retry would
|
|
312
|
+
// send it again.
|
|
313
|
+
decision = { retry: false };
|
|
314
|
+
reason = 'output-started';
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
if (!rec.limit) {
|
|
318
|
+
const signal = adapter.limitFromError?.(draft.error);
|
|
319
|
+
if (signal)
|
|
320
|
+
noteLimit(rec, signal, 'error');
|
|
321
|
+
}
|
|
322
|
+
if (rec.limit) {
|
|
323
|
+
await rec.limit.delivered;
|
|
324
|
+
rec.rerouteFrom = {
|
|
325
|
+
accountId: rec.accountId,
|
|
326
|
+
limit: rec.limit.signal,
|
|
327
|
+
};
|
|
328
|
+
// No delay: the next attempt goes to another account, and the
|
|
329
|
+
// host would otherwise wait out the refused account's backoff,
|
|
330
|
+
// or not retry at all for errors it deems final.
|
|
331
|
+
decision = { retry: true, delay: 0 };
|
|
332
|
+
reason = 'reroute';
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
reason = 'host-decides';
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
draft.decision = decision;
|
|
339
|
+
await emit('retry', {
|
|
340
|
+
sessionID: draft.sessionID,
|
|
341
|
+
...(rec.accountId === undefined ? {} : { accountId: rec.accountId }),
|
|
342
|
+
kind: rec.scope.kind,
|
|
343
|
+
attempt: draft.attempt,
|
|
344
|
+
reason,
|
|
345
|
+
hostDecision,
|
|
346
|
+
decision,
|
|
347
|
+
});
|
|
348
|
+
}, scoped),
|
|
349
|
+
];
|
|
350
|
+
const forgetSession = (sessionID) => {
|
|
351
|
+
for (const [key, rec] of records) {
|
|
352
|
+
if (rec.scope.sessionID === sessionID)
|
|
353
|
+
records.delete(key);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const abort = new AbortController();
|
|
357
|
+
const events = ctx.event;
|
|
358
|
+
if (events) {
|
|
359
|
+
void (async () => {
|
|
360
|
+
try {
|
|
361
|
+
for await (const event of events.subscribe({ signal: abort.signal })) {
|
|
362
|
+
if (event.type === 'session.deleted')
|
|
363
|
+
forgetSession(event.data.sessionID);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (!abort.signal.aborted) {
|
|
368
|
+
warn('opencode2 auth stopped listening for session deletion', {
|
|
369
|
+
error: describe(error),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
})();
|
|
374
|
+
}
|
|
375
|
+
let disposed = false;
|
|
376
|
+
return {
|
|
377
|
+
on(event, listener) {
|
|
378
|
+
let set = listeners.get(event);
|
|
379
|
+
if (!set) {
|
|
380
|
+
set = new Set();
|
|
381
|
+
listeners.set(event, set);
|
|
382
|
+
}
|
|
383
|
+
const entry = listener;
|
|
384
|
+
set.add(entry);
|
|
385
|
+
return () => {
|
|
386
|
+
set.delete(entry);
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
accountFor(sessionID, kind) {
|
|
390
|
+
return records.get(keyOf(sessionID, kind))?.accountId;
|
|
391
|
+
},
|
|
392
|
+
forgetSession,
|
|
393
|
+
get size() {
|
|
394
|
+
return records.size;
|
|
395
|
+
},
|
|
396
|
+
async dispose() {
|
|
397
|
+
if (disposed)
|
|
398
|
+
return;
|
|
399
|
+
disposed = true;
|
|
400
|
+
abort.abort();
|
|
401
|
+
records.clear();
|
|
402
|
+
listeners.clear();
|
|
403
|
+
await Promise.all(registrations.map(async (registration) => {
|
|
404
|
+
try {
|
|
405
|
+
await registration.dispose();
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
warn('opencode2 auth hook did not dispose cleanly', {
|
|
409
|
+
error: describe(error),
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}));
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Credential, Plugin } from '@opencode/plugin';
|
|
2
|
+
import type { IntegrationOAuthMethod, IntegrationOAuthMethodRegistration } from '@opencode/plugin/promise/integration';
|
|
3
|
+
import type { Registration } from '@opencode/plugin/promise/registration';
|
|
4
|
+
/** The host's form answer handed to `authorize`. */
|
|
5
|
+
export type FormAnswer = Parameters<IntegrationOAuthMethodRegistration['authorize']>[0];
|
|
6
|
+
/**
|
|
7
|
+
* Prefix of every placeholder secret. No provider issues tokens of this
|
|
8
|
+
* shape, so a placeholder that escaped to the wire is refused by the
|
|
9
|
+
* provider and recognisable in any log.
|
|
10
|
+
*/
|
|
11
|
+
export declare const PLACEHOLDER_PREFIX = "common-auth-placeholder";
|
|
12
|
+
/** How long a placeholder claims to be valid; refresh simply issues another. */
|
|
13
|
+
export declare const PLACEHOLDER_LIFETIME_MS: number;
|
|
14
|
+
/** Metadata key marking a host credential as a placeholder. */
|
|
15
|
+
export declare const PLACEHOLDER_METADATA_KEY = "commonAuthPlaceholder";
|
|
16
|
+
/** The non-routable secret the host holds for an integration. */
|
|
17
|
+
export declare function placeholderSecret(integrationID: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* The credential the host stores for an integration whose real accounts live
|
|
20
|
+
* in the plugin's pool. It keeps the provider catalogued and usable in the
|
|
21
|
+
* host, and carries nothing that works against the provider.
|
|
22
|
+
*/
|
|
23
|
+
export declare function placeholderCredential(input: {
|
|
24
|
+
integrationID: string;
|
|
25
|
+
methodID: string;
|
|
26
|
+
now: number;
|
|
27
|
+
}): Credential.OAuth;
|
|
28
|
+
/** Whether a host credential is a placeholder (for any integration, or the one named). */
|
|
29
|
+
export declare function isPlaceholderCredential(value: Credential.Value | undefined, integrationID?: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The plugin's half of an OAuth login. It has the same shape as the host's
|
|
32
|
+
* authorization, except that `callback` resolves to the plugin's own login
|
|
33
|
+
* result instead of a host credential.
|
|
34
|
+
*/
|
|
35
|
+
export type PoolAuthorization<T> = {
|
|
36
|
+
readonly url: string;
|
|
37
|
+
readonly instructions: string;
|
|
38
|
+
readonly expiresAt?: number;
|
|
39
|
+
} & ({
|
|
40
|
+
readonly mode: 'auto';
|
|
41
|
+
readonly callback: Promise<T>;
|
|
42
|
+
} | {
|
|
43
|
+
readonly mode: 'code';
|
|
44
|
+
readonly callback: (code: string) => Promise<T>;
|
|
45
|
+
});
|
|
46
|
+
export interface PoolLoginMethod<T> {
|
|
47
|
+
readonly method: IntegrationOAuthMethod;
|
|
48
|
+
authorize(answer: FormAnswer): Promise<PoolAuthorization<T>>;
|
|
49
|
+
}
|
|
50
|
+
export interface RegisterOpenCode2AuthMethodsOptions<T> {
|
|
51
|
+
readonly integrationID: string;
|
|
52
|
+
readonly methods: readonly PoolLoginMethod<T>[];
|
|
53
|
+
/**
|
|
54
|
+
* Writes a completed login into the plugin's pool. The host only receives
|
|
55
|
+
* a placeholder once this resolves; if it throws, the login fails in the
|
|
56
|
+
* host and nothing is stored there.
|
|
57
|
+
*/
|
|
58
|
+
onLogin(result: T, context: {
|
|
59
|
+
readonly integrationID: string;
|
|
60
|
+
readonly methodID: string;
|
|
61
|
+
}): Promise<void> | void;
|
|
62
|
+
/** Label the host shows for its (placeholder) connection. */
|
|
63
|
+
readonly label?: string;
|
|
64
|
+
readonly now?: () => number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Registers the plugin's login methods with the host so that a login lands in
|
|
68
|
+
* the plugin's pool and the host keeps only a placeholder credential.
|
|
69
|
+
*
|
|
70
|
+
* Host-driven refresh never reaches the pool: it hands back a fresh
|
|
71
|
+
* placeholder whatever it is given. A real credential stored by an earlier
|
|
72
|
+
* login under one of these method IDs is therefore replaced by a placeholder
|
|
73
|
+
* at its first refresh; a plugin that wants to import such a login must read
|
|
74
|
+
* it (`ctx.integration.connection`) before that happens.
|
|
75
|
+
*/
|
|
76
|
+
export declare function registerOpenCode2AuthMethods<T>(ctx: {
|
|
77
|
+
readonly integration: Pick<Plugin.Context['integration'], 'transform'>;
|
|
78
|
+
}, options: RegisterOpenCode2AuthMethodsOptions<T>): Promise<Registration>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix of every placeholder secret. No provider issues tokens of this
|
|
3
|
+
* shape, so a placeholder that escaped to the wire is refused by the
|
|
4
|
+
* provider and recognisable in any log.
|
|
5
|
+
*/
|
|
6
|
+
export const PLACEHOLDER_PREFIX = 'common-auth-placeholder';
|
|
7
|
+
/** How long a placeholder claims to be valid; refresh simply issues another. */
|
|
8
|
+
export const PLACEHOLDER_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
|
|
9
|
+
/** Metadata key marking a host credential as a placeholder. */
|
|
10
|
+
export const PLACEHOLDER_METADATA_KEY = 'commonAuthPlaceholder';
|
|
11
|
+
/** The non-routable secret the host holds for an integration. */
|
|
12
|
+
export function placeholderSecret(integrationID) {
|
|
13
|
+
return `${PLACEHOLDER_PREFIX}.${integrationID}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The credential the host stores for an integration whose real accounts live
|
|
17
|
+
* in the plugin's pool. It keeps the provider catalogued and usable in the
|
|
18
|
+
* host, and carries nothing that works against the provider.
|
|
19
|
+
*/
|
|
20
|
+
export function placeholderCredential(input) {
|
|
21
|
+
const secret = placeholderSecret(input.integrationID);
|
|
22
|
+
return {
|
|
23
|
+
type: 'oauth',
|
|
24
|
+
methodID: input.methodID,
|
|
25
|
+
access: secret,
|
|
26
|
+
refresh: secret,
|
|
27
|
+
expires: input.now + PLACEHOLDER_LIFETIME_MS,
|
|
28
|
+
metadata: { [PLACEHOLDER_METADATA_KEY]: true },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Whether a host credential is a placeholder (for any integration, or the one named). */
|
|
32
|
+
export function isPlaceholderCredential(value, integrationID) {
|
|
33
|
+
if (value?.type !== 'oauth')
|
|
34
|
+
return false;
|
|
35
|
+
const expected = integrationID === undefined ? undefined : placeholderSecret(integrationID);
|
|
36
|
+
const matches = (secret) => expected === undefined
|
|
37
|
+
? secret.startsWith(`${PLACEHOLDER_PREFIX}.`)
|
|
38
|
+
: secret === expected;
|
|
39
|
+
return matches(value.access) && matches(value.refresh);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Registers the plugin's login methods with the host so that a login lands in
|
|
43
|
+
* the plugin's pool and the host keeps only a placeholder credential.
|
|
44
|
+
*
|
|
45
|
+
* Host-driven refresh never reaches the pool: it hands back a fresh
|
|
46
|
+
* placeholder whatever it is given. A real credential stored by an earlier
|
|
47
|
+
* login under one of these method IDs is therefore replaced by a placeholder
|
|
48
|
+
* at its first refresh; a plugin that wants to import such a login must read
|
|
49
|
+
* it (`ctx.integration.connection`) before that happens.
|
|
50
|
+
*/
|
|
51
|
+
export async function registerOpenCode2AuthMethods(ctx, options) {
|
|
52
|
+
const now = options.now ?? Date.now;
|
|
53
|
+
const { integrationID } = options;
|
|
54
|
+
const placeholderFor = (methodID) => placeholderCredential({ integrationID, methodID, now: now() });
|
|
55
|
+
const store = async (result, methodID) => {
|
|
56
|
+
await options.onLogin(result, { integrationID, methodID });
|
|
57
|
+
return placeholderFor(methodID);
|
|
58
|
+
};
|
|
59
|
+
const authorizeWith = (login) => async (answer) => {
|
|
60
|
+
const methodID = login.method.id;
|
|
61
|
+
const pending = await login.authorize(answer);
|
|
62
|
+
const common = {
|
|
63
|
+
url: pending.url,
|
|
64
|
+
instructions: pending.instructions,
|
|
65
|
+
...(pending.expiresAt === undefined
|
|
66
|
+
? {}
|
|
67
|
+
: { expiresAt: pending.expiresAt }),
|
|
68
|
+
};
|
|
69
|
+
if (pending.mode === 'auto') {
|
|
70
|
+
return {
|
|
71
|
+
...common,
|
|
72
|
+
mode: 'auto',
|
|
73
|
+
callback: pending.callback.then((result) => store(result, methodID)),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const callback = pending.callback;
|
|
77
|
+
return {
|
|
78
|
+
...common,
|
|
79
|
+
mode: 'code',
|
|
80
|
+
callback: async (code) => store(await callback(code), methodID),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
return ctx.integration.transform((editor) => {
|
|
84
|
+
for (const login of options.methods) {
|
|
85
|
+
editor.method.update({
|
|
86
|
+
integrationID,
|
|
87
|
+
method: login.method,
|
|
88
|
+
authorize: authorizeWith(login),
|
|
89
|
+
refresh: async (credential) => placeholderFor(credential.methodID),
|
|
90
|
+
...(options.label === undefined ? {} : { label: () => options.label }),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** One server-sent event: its `event:` name, if any, and its joined `data:` lines. */
|
|
2
|
+
export interface ServerSentEvent {
|
|
3
|
+
readonly event?: string;
|
|
4
|
+
readonly data: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A pass-through stream that hands every complete server-sent event to
|
|
8
|
+
* `onEvent` while forwarding the original bytes unchanged, so the host still
|
|
9
|
+
* consumes the body exactly once. A throwing `onEvent` is reported to
|
|
10
|
+
* `onError` and never breaks the stream.
|
|
11
|
+
*/
|
|
12
|
+
export declare function watchServerSentEvents(onEvent: (event: ServerSentEvent) => void, onError: (error: unknown) => void): TransformStream<Uint8Array, Uint8Array>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
function parseBlock(block) {
|
|
2
|
+
const data = [];
|
|
3
|
+
let event;
|
|
4
|
+
for (const line of block.split(/\r?\n/)) {
|
|
5
|
+
if (line.startsWith('data:'))
|
|
6
|
+
data.push(line.slice(5).replace(/^ /, ''));
|
|
7
|
+
else if (line.startsWith('event:'))
|
|
8
|
+
event = line.slice(6).trim();
|
|
9
|
+
}
|
|
10
|
+
if (data.length === 0)
|
|
11
|
+
return undefined;
|
|
12
|
+
return event === undefined
|
|
13
|
+
? { data: data.join('\n') }
|
|
14
|
+
: { event, data: data.join('\n') };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A pass-through stream that hands every complete server-sent event to
|
|
18
|
+
* `onEvent` while forwarding the original bytes unchanged, so the host still
|
|
19
|
+
* consumes the body exactly once. A throwing `onEvent` is reported to
|
|
20
|
+
* `onError` and never breaks the stream.
|
|
21
|
+
*/
|
|
22
|
+
export function watchServerSentEvents(onEvent, onError) {
|
|
23
|
+
const decoder = new TextDecoder();
|
|
24
|
+
let buffer = '';
|
|
25
|
+
const emit = (block) => {
|
|
26
|
+
const event = parseBlock(block);
|
|
27
|
+
if (!event)
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
onEvent(event);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
onError(error);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const drain = () => {
|
|
37
|
+
for (;;) {
|
|
38
|
+
const match = /\r?\n\r?\n/.exec(buffer);
|
|
39
|
+
if (!match)
|
|
40
|
+
return;
|
|
41
|
+
const block = buffer.slice(0, match.index);
|
|
42
|
+
buffer = buffer.slice(match.index + match[0].length);
|
|
43
|
+
emit(block);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
return new TransformStream({
|
|
47
|
+
transform(chunk, controller) {
|
|
48
|
+
controller.enqueue(chunk);
|
|
49
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
50
|
+
drain();
|
|
51
|
+
},
|
|
52
|
+
flush() {
|
|
53
|
+
buffer += decoder.decode();
|
|
54
|
+
drain();
|
|
55
|
+
if (buffer.trim() !== '')
|
|
56
|
+
emit(buffer);
|
|
57
|
+
buffer = '';
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { Plugin } from '@opencode/plugin';
|
|
2
|
+
import type { SessionRequestKind, SessionRetryDecision } from '@opencode/plugin/promise/session';
|
|
3
|
+
/** The parts of the OpenCode 2 plugin context the installer uses. */
|
|
4
|
+
export type OpenCode2HookContext = {
|
|
5
|
+
readonly session: Pick<Plugin.Context['session'], 'hook'>;
|
|
6
|
+
/** Used only to forget per-session records when a session is deleted. */
|
|
7
|
+
readonly event?: Pick<Plugin.Context['event'], 'subscribe'>;
|
|
8
|
+
};
|
|
9
|
+
export type RequestKind = SessionRequestKind;
|
|
10
|
+
/** Which request a hook call belongs to, as the host reports it. */
|
|
11
|
+
export interface RequestScope {
|
|
12
|
+
readonly providerID: string;
|
|
13
|
+
readonly modelID: string;
|
|
14
|
+
readonly sessionID: string;
|
|
15
|
+
readonly agent: string;
|
|
16
|
+
readonly kind: RequestKind;
|
|
17
|
+
}
|
|
18
|
+
/** Where a reading or a refusal was seen. */
|
|
19
|
+
export type Transport = 'http' | 'ws';
|
|
20
|
+
/**
|
|
21
|
+
* A provider refusal that should move the request to another account: a rate
|
|
22
|
+
* limit, an exhausted usage window, or anything else the adapter decides is
|
|
23
|
+
* tied to the account rather than to the request.
|
|
24
|
+
*/
|
|
25
|
+
export interface LimitSignal {
|
|
26
|
+
/** Short machine-readable reason, such as the provider's error code. */
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
readonly status?: number;
|
|
29
|
+
/** How long the provider asked the account to wait, when it said. */
|
|
30
|
+
readonly retryAfterMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/** What the adapter learned from one streamed event or WebSocket frame. */
|
|
33
|
+
export interface EventVerdict<Q> {
|
|
34
|
+
/** The user has seen output from this response: a retry would repeat it. */
|
|
35
|
+
readonly outputStarted?: boolean;
|
|
36
|
+
readonly quota?: Q;
|
|
37
|
+
readonly limit?: LimitSignal;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Header changes for one account. A string sets the header (replacing every
|
|
41
|
+
* spelling of its name); `null` removes it.
|
|
42
|
+
*/
|
|
43
|
+
export type HeaderEdits = Readonly<Record<string, string | null>>;
|
|
44
|
+
export interface ChooseAccountInput extends RequestScope {
|
|
45
|
+
/** The account the previous attempt of this session and kind used. */
|
|
46
|
+
readonly previousAccountId?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Set when the previous attempt was refused for a limit before any output
|
|
49
|
+
* and the retry hook asked the host to try again. The adapter should not
|
|
50
|
+
* pick `accountId` again unless it has nothing else.
|
|
51
|
+
*/
|
|
52
|
+
readonly rerouteFrom?: {
|
|
53
|
+
readonly accountId: string;
|
|
54
|
+
readonly limit: LimitSignal;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export interface AccountRequest extends RequestScope {
|
|
58
|
+
readonly accountId: string;
|
|
59
|
+
}
|
|
60
|
+
/** A host-supplied error, as the retry hook reports it. */
|
|
61
|
+
export interface HostError {
|
|
62
|
+
readonly type: string;
|
|
63
|
+
readonly message: string;
|
|
64
|
+
readonly status?: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Everything provider-specific the installer needs. `Q` is the plugin's own
|
|
68
|
+
* quota reading type; the installer only carries it to the `quota` event.
|
|
69
|
+
*/
|
|
70
|
+
export interface OpenCode2AuthAdapter<Q = unknown> {
|
|
71
|
+
/** Every hook is scoped to this provider; other providers are untouched. */
|
|
72
|
+
readonly providerID: string;
|
|
73
|
+
/**
|
|
74
|
+
* Picks the account for one model request. Called again for every retry,
|
|
75
|
+
* so a refused account can be skipped. Returning `undefined` stops the
|
|
76
|
+
* request with `OpenCode2AuthError` kind `no-account`.
|
|
77
|
+
*/
|
|
78
|
+
chooseAccount(input: ChooseAccountInput): Promise<string | undefined> | string | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* The credential and per-account headers for an account, applied in
|
|
81
|
+
* `model.request`, `http.request` and `experimental.ws.handshake`. The last
|
|
82
|
+
* two run after the host has applied its own credential, so these headers
|
|
83
|
+
* win on the wire.
|
|
84
|
+
*/
|
|
85
|
+
accountHeaders(input: AccountRequest): Promise<HeaderEdits> | HeaderEdits;
|
|
86
|
+
/**
|
|
87
|
+
* Optional request rewrite (URL, body) before the account headers are
|
|
88
|
+
* applied. Return `undefined` to keep the request.
|
|
89
|
+
*/
|
|
90
|
+
rewriteRequest?(input: AccountRequest & {
|
|
91
|
+
readonly request: Request;
|
|
92
|
+
}): Promise<Request | undefined> | Request | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Optional response rewrite (body stream, status). It receives the
|
|
95
|
+
* response after quota, limit and output detection have been attached, so
|
|
96
|
+
* detection always sees the provider's own events.
|
|
97
|
+
*/
|
|
98
|
+
rewriteResponse?(input: AccountRequest & {
|
|
99
|
+
readonly request: Request;
|
|
100
|
+
readonly response: Response;
|
|
101
|
+
}): Promise<Response | undefined> | Response | undefined;
|
|
102
|
+
/** Optional WebSocket URL rewrite. Return `undefined` to keep the URL. */
|
|
103
|
+
rewriteHandshakeURL?(input: AccountRequest & {
|
|
104
|
+
readonly url: string;
|
|
105
|
+
}): string | undefined;
|
|
106
|
+
/** Quota carried in HTTP response headers. */
|
|
107
|
+
quotaFromHeaders?(headers: Headers, status: number): Q | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* Recognises an account-level refusal from an HTTP response before its
|
|
110
|
+
* body is streamed. `body()` reads a copy, so the host still gets the body.
|
|
111
|
+
*/
|
|
112
|
+
limitFromResponse?(input: {
|
|
113
|
+
readonly status: number;
|
|
114
|
+
readonly headers: Headers;
|
|
115
|
+
readonly body: () => Promise<string>;
|
|
116
|
+
}): Promise<LimitSignal | undefined> | LimitSignal | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* Inspects one server-sent event (`data` payload) or one WebSocket frame.
|
|
119
|
+
* Detects output, quota and refusals inside the stream.
|
|
120
|
+
*/
|
|
121
|
+
inspectEvent?(input: {
|
|
122
|
+
readonly transport: Transport;
|
|
123
|
+
readonly data: string;
|
|
124
|
+
readonly event?: string;
|
|
125
|
+
}): EventVerdict<Q> | undefined;
|
|
126
|
+
/**
|
|
127
|
+
* Recognises an account-level refusal from the error the host hands the
|
|
128
|
+
* retry hook, for refusals no other hook saw.
|
|
129
|
+
*/
|
|
130
|
+
limitFromError?(error: HostError): LimitSignal | undefined;
|
|
131
|
+
}
|
|
132
|
+
export interface OpenCode2AuthLogger {
|
|
133
|
+
warn(message: string, data?: unknown): void;
|
|
134
|
+
}
|
|
135
|
+
export interface InstallOpenCode2AuthOptions {
|
|
136
|
+
/**
|
|
137
|
+
* Values that must never reach the wire, normally the placeholder the host
|
|
138
|
+
* holds as its credential. Defaults to the placeholder secret for the
|
|
139
|
+
* adapter's provider.
|
|
140
|
+
*/
|
|
141
|
+
readonly hostCredentials?: readonly string[];
|
|
142
|
+
/** Most `sessionID:kind` records kept before the oldest is dropped. */
|
|
143
|
+
readonly maxRecords?: number;
|
|
144
|
+
readonly logger?: OpenCode2AuthLogger;
|
|
145
|
+
}
|
|
146
|
+
export type RetryReason = 'reroute' | 'output-started' | 'no-account' | 'host-decides';
|
|
147
|
+
/**
|
|
148
|
+
* The hook that picked an account. Normally `model.request`; a transport hook
|
|
149
|
+
* picks only when the host skipped `model.request` for that request, which a
|
|
150
|
+
* plugin may want to log as a host change.
|
|
151
|
+
*/
|
|
152
|
+
export type SelectingHook = 'model.request' | 'http.request' | 'experimental.ws.handshake';
|
|
153
|
+
export interface OpenCode2AuthEvents<Q> {
|
|
154
|
+
/** An account was picked for a model request. */
|
|
155
|
+
readonly select: AccountRequest & {
|
|
156
|
+
readonly hook: SelectingHook;
|
|
157
|
+
readonly previousAccountId?: string;
|
|
158
|
+
readonly rerouteFrom?: ChooseAccountInput['rerouteFrom'];
|
|
159
|
+
};
|
|
160
|
+
/** A quota reading, attributed through this installer's own record. */
|
|
161
|
+
readonly quota: AccountRequest & {
|
|
162
|
+
readonly transport: Transport;
|
|
163
|
+
readonly status?: number;
|
|
164
|
+
readonly quota: Q;
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* An account-level refusal. The retry hook waits for every listener of
|
|
168
|
+
* this event before asking the host to retry, so a plugin that marks the
|
|
169
|
+
* account limited here has the mark in place when `chooseAccount` runs.
|
|
170
|
+
*/
|
|
171
|
+
readonly limit: AccountRequest & {
|
|
172
|
+
readonly via: Transport | 'error';
|
|
173
|
+
readonly limit: LimitSignal;
|
|
174
|
+
readonly outputStarted: boolean;
|
|
175
|
+
};
|
|
176
|
+
/** The retry hook ran for this provider. */
|
|
177
|
+
readonly retry: {
|
|
178
|
+
readonly sessionID: string;
|
|
179
|
+
readonly accountId?: string;
|
|
180
|
+
readonly kind?: RequestKind;
|
|
181
|
+
readonly attempt: number;
|
|
182
|
+
readonly reason: RetryReason;
|
|
183
|
+
readonly hostDecision: SessionRetryDecision;
|
|
184
|
+
readonly decision: SessionRetryDecision;
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
export type OpenCode2AuthEventName = keyof OpenCode2AuthEvents<unknown>;
|
|
188
|
+
export interface OpenCode2AuthInstallation<Q> {
|
|
189
|
+
/**
|
|
190
|
+
* Listens to an event. Listener errors are logged and never reach the
|
|
191
|
+
* host. Returns a function that removes the listener.
|
|
192
|
+
*/
|
|
193
|
+
on<E extends OpenCode2AuthEventName>(event: E, listener: (payload: OpenCode2AuthEvents<Q>[E]) => void | Promise<void>): () => void;
|
|
194
|
+
/** The account last chosen for a session and request kind. */
|
|
195
|
+
accountFor(sessionID: string, kind: RequestKind): string | undefined;
|
|
196
|
+
/** Drops every record of a session. Session deletion does this itself. */
|
|
197
|
+
forgetSession(sessionID: string): void;
|
|
198
|
+
/** Number of `sessionID:kind` records held. */
|
|
199
|
+
readonly size: number;
|
|
200
|
+
/** Removes every hook and stops listening for session deletion. */
|
|
201
|
+
dispose(): Promise<void>;
|
|
202
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|