@modelprofile.com/authswitch 6.0.0 → 6.1.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_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/accounts.js +3 -2
- package/dist_ts/classes.cli.js +3 -3
- package/dist_ts/classes.credentialstore.d.ts +3 -1
- package/dist_ts/classes.credentialstore.js +2 -2
- package/dist_ts/classes.fileharness.d.ts +16 -5
- package/dist_ts/classes.fileharness.js +31 -9
- package/dist_ts/classes.opencodeanthropicrefresh.d.ts +29 -0
- package/dist_ts/classes.opencodeanthropicrefresh.js +59 -0
- package/dist_ts/classes.opencodeharness.d.ts +29 -3
- package/dist_ts/classes.opencodeharness.js +84 -15
- package/dist_ts/interfaces.harness.d.ts +7 -3
- package/package.json +1 -1
- package/readme.md +22 -12
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/accounts.ts +2 -1
- package/ts/classes.cli.ts +3 -3
- package/ts/classes.credentialstore.ts +2 -2
- package/ts/classes.fileharness.ts +28 -8
- package/ts/classes.opencodeanthropicrefresh.ts +64 -0
- package/ts/classes.opencodeharness.ts +79 -14
- package/ts/interfaces.harness.ts +7 -3
|
@@ -4,7 +4,9 @@ import { CredentialStore, credentialHash, credentialRecord, credentialText, read
|
|
|
4
4
|
import { HarnessProcesses } from './classes.harnessprocesses.js';
|
|
5
5
|
import { ClaudeAccountStatus } from './classes.claudestatus.js';
|
|
6
6
|
import { CLAUDE_REFRESH_MARGIN_MS } from './classes.claudetokenrefresh.js';
|
|
7
|
+
import { ClaudeLoginRejectedError, ClaudeRateLimitError } from './claudehttp.js';
|
|
7
8
|
import { CodexAccountStatus } from './classes.codexstatus.js';
|
|
9
|
+
import { OpenCodeAnthropicRefresh } from './classes.opencodeanthropicrefresh.js';
|
|
8
10
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
9
11
|
import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
|
|
10
12
|
import { beginSavedOpenAiLogin, openCodeCredentialFromLogin } from './classes.login.js';
|
|
@@ -21,6 +23,11 @@ export interface IOpenCodeHarnessOptions {
|
|
|
21
23
|
now?: () => number;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
/** Anthropic refused the grant OpenCode's entry holds; no retry and no other account changes that answer. */
|
|
27
|
+
const ANTHROPIC_LOGIN_REJECTED = 'Login expired or was rejected. Log in again with opencode auth login.';
|
|
28
|
+
/** A saved copy is not the login OpenCode uses, so renewing it would rotate a token OpenCode's own entry still holds. */
|
|
29
|
+
const SAVED_ANTHROPIC_EXPIRED = 'This saved Anthropic login\'s access token has expired. authswitch renews the Anthropic login OpenCode has active; activate this one to have it renewed, or log in again with opencode auth login.';
|
|
30
|
+
|
|
24
31
|
/** OpenCode owns one login per provider. Replacing a slot never replaces the whole provider set. */
|
|
25
32
|
export class OpenCodeHarness extends FileHarness {
|
|
26
33
|
public readonly id = 'opencode';
|
|
@@ -30,12 +37,17 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
30
37
|
public readonly liveSwap = false;
|
|
31
38
|
/** Only its OpenAI login reports usage, and a supervisor (AGL) restarts it around a switch, so a watch leaves it alone. */
|
|
32
39
|
public readonly autoSwitch = false;
|
|
40
|
+
/** OpenCode re-reads its credential file on every request; only a request already in flight still holds the previous login. */
|
|
41
|
+
protected override runningRetention(): string {
|
|
42
|
+
return 'They re-read the credential file on every request, but a request already in flight still holds the previous login, and its token refresh can ';
|
|
43
|
+
}
|
|
33
44
|
public readonly loginProviders: IHarnessLoginProvider[] = [{ providerId: 'openai', label: 'OpenAI', flows: ['device'] }];
|
|
34
45
|
protected readonly store: CredentialStore;
|
|
35
46
|
private readonly file: string;
|
|
36
47
|
private readonly env: NodeJS.ProcessEnv;
|
|
37
48
|
private readonly status: CodexAccountStatus;
|
|
38
49
|
private readonly claudeStatus: ClaudeAccountStatus;
|
|
50
|
+
private readonly anthropicRefresh: OpenCodeAnthropicRefresh;
|
|
39
51
|
public readonly processes: IHarnessProcessControl;
|
|
40
52
|
private readonly fetcher?: typeof fetch;
|
|
41
53
|
private readonly now: () => number;
|
|
@@ -47,6 +59,7 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
47
59
|
this.store = new CredentialStore(this.id, options.stashRoot);
|
|
48
60
|
this.status = new CodexAccountStatus(options.fetch);
|
|
49
61
|
this.claudeStatus = new ClaudeAccountStatus(options.fetch, options.now);
|
|
62
|
+
this.anthropicRefresh = new OpenCodeAnthropicRefresh(options.fetch, options.now);
|
|
50
63
|
this.fetcher = options.fetch;
|
|
51
64
|
this.now = options.now ?? Date.now;
|
|
52
65
|
this.processes = options.processes ?? new HarnessProcesses('opencode');
|
|
@@ -112,35 +125,87 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
112
125
|
writeSecretFileAtomically(this.file, raw);
|
|
113
126
|
if (readCredentialDocument(this.file).raw !== raw) throw new Error('Active login verification failed. The outgoing login remains saved.');
|
|
114
127
|
}
|
|
128
|
+
/** The login OpenCode has in a provider slot, read from its file; undefined where the file holds none it can read. */
|
|
129
|
+
private activeSlot(slotIdArg: string): IFileAccount | undefined {
|
|
130
|
+
return this.snapshot().accounts.find(account => account.slotId === slotIdArg);
|
|
131
|
+
}
|
|
132
|
+
/** Whether this login's access token is inside Claude Code's five-minute margin, the margin Claude's own logins use. */
|
|
133
|
+
private renewalDue(credentialArg: Record<string, unknown>): boolean {
|
|
134
|
+
const expires = credentialArg.expires;
|
|
135
|
+
return typeof expires === 'number' && Number.isFinite(expires) && this.now() + CLAUDE_REFRESH_MARGIN_MS >= expires;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Renews OpenCode's active Anthropic login and writes the rotated pair back into OpenCode's own credential file.
|
|
140
|
+
*
|
|
141
|
+
* Nothing is stranded by that write. OpenCode's auth service reads `auth.json` on every `Auth.all()`, `Auth.get()`
|
|
142
|
+
* is that read (1.18.15 @99100961, 1.18.31 @103733929), and its Anthropic provider -- `opencode-anthropic-auth`
|
|
143
|
+
* 0.0.13, since the binary carries no Anthropic OAuth code -- calls the `getAuth()` it was initialised with
|
|
144
|
+
* (@98017331) inside the `fetch` wrapper of every single request. A running OpenCode therefore holds no refresh
|
|
145
|
+
* token in memory and picks up this rotation on its next request, exactly as it picks up its own.
|
|
146
|
+
*
|
|
147
|
+
* The renewal is the only authswitch operation on the store while it runs, and the rotated pair replaces the
|
|
148
|
+
* entry only while OpenCode still holds the refresh token that was sent: a login OpenCode renewed meanwhile is
|
|
149
|
+
* returned as it now stands. The write itself is `apply`'s verified compare-and-swap, which preserves every other
|
|
150
|
+
* provider and replaces the file atomically -- OpenCode writes the same file in place. The slot's switch record
|
|
151
|
+
* follows that write, because an `anthropic` login's identity is its refresh token and this rotation is
|
|
152
|
+
* authswitch's own: without that, the next read would report this write as a different account.
|
|
153
|
+
*/
|
|
154
|
+
private renewAnthropic(accountArg: IFileAccount, signalArg?: AbortSignal): Promise<IFileAccount> {
|
|
155
|
+
return this.store.locked(async () => {
|
|
156
|
+
const current = this.activeSlot(accountArg.slotId) ?? accountArg;
|
|
157
|
+
if (!this.renewalDue(current.credential)) return current;
|
|
158
|
+
const refreshToken = credentialText(current.credential.refresh);
|
|
159
|
+
if (refreshToken === undefined) throw new Error('This Anthropic login holds no refresh token; log in again with opencode auth login.');
|
|
160
|
+
signalArg?.throwIfAborted();
|
|
161
|
+
const tokens = await this.anthropicRefresh.refresh(refreshToken);
|
|
162
|
+
const latest = this.activeSlot(accountArg.slotId);
|
|
163
|
+
if (!latest || credentialText(latest.credential.refresh) !== refreshToken) return latest ?? current;
|
|
164
|
+
const renewed = this.inspect({ ...latest.credential, access: tokens.access, refresh: tokens.refresh, expires: tokens.expires }, latest.slotId);
|
|
165
|
+
this.apply(latest.slotId, latest, renewed);
|
|
166
|
+
this.recordRenewal(latest.slotId);
|
|
167
|
+
return renewed;
|
|
168
|
+
}, { signal: signalArg });
|
|
169
|
+
}
|
|
170
|
+
|
|
115
171
|
/**
|
|
116
172
|
* The subscription and usage of an OpenCode `anthropic` OAuth login.
|
|
117
173
|
*
|
|
118
174
|
* That entry holds the same Claude subscriber grant Claude Code stores, so Claude's own account endpoints answer
|
|
119
175
|
* for it and the account reports real windows instead of no quota API at all. OpenCode keeps no scopes and no
|
|
120
|
-
* account identity with it, so the service decides both
|
|
121
|
-
*
|
|
122
|
-
*
|
|
176
|
+
* account identity with it, so the service decides both. A due access token is renewed first, with OpenCode's own
|
|
177
|
+
* request; a refusal is reported and nothing is written, so the entry OpenCode owns is left exactly as it was.
|
|
178
|
+
*
|
|
179
|
+
* A saved copy is never renewed: OpenCode's entry holds the same refresh token, and rotating it here would leave
|
|
180
|
+
* the login OpenCode actually uses holding a token the service has replaced.
|
|
123
181
|
*/
|
|
124
|
-
private anthropicStatus(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
182
|
+
private async anthropicStatus(accountArg: IFileAccount, isActiveArg: boolean, optionsArg: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
|
|
183
|
+
let account = accountArg;
|
|
184
|
+
if (this.renewalDue(account.credential)) {
|
|
185
|
+
if (!isActiveArg) return this.claudeStatus.unavailable({}, SAVED_ANTHROPIC_EXPIRED);
|
|
186
|
+
try { account = await this.renewAnthropic(account, optionsArg.signal); }
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (optionsArg.signal?.aborted) throw error;
|
|
189
|
+
const problem = error instanceof ClaudeLoginRejectedError ? ANTHROPIC_LOGIN_REJECTED
|
|
190
|
+
: error instanceof Error ? error.message : 'the login could not be renewed.';
|
|
191
|
+
return this.claudeStatus.unavailable({}, `Login refresh: ${problem}`,
|
|
192
|
+
error instanceof ClaudeRateLimitError ? { retryAt: error.retryAt } : undefined);
|
|
193
|
+
}
|
|
132
194
|
}
|
|
195
|
+
const accessToken = credentialText(account.credential.access);
|
|
196
|
+
if (accessToken === undefined) return this.claudeStatus.unavailable({}, 'This Anthropic login holds no usable access token; log in again with opencode auth login.');
|
|
133
197
|
return this.claudeStatus.read({ accessToken, scopes: [] }, optionsArg);
|
|
134
198
|
}
|
|
135
199
|
|
|
136
|
-
public async readAccountStatus(id: string, optionsArg
|
|
137
|
-
const
|
|
200
|
+
public async readAccountStatus(id: string, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
|
|
201
|
+
const active = this.activeCredential(id);
|
|
202
|
+
const account = active ?? this.savedCredential(id);
|
|
138
203
|
if (account.slotId === 'openai' && account.credential.type === 'oauth') {
|
|
139
204
|
const info = this.openAiInfo(account.credential);
|
|
140
205
|
if (!info.accountId) return { facts: [], problems: ['The OpenAI login has no account ID for a status lookup.'] };
|
|
141
206
|
return this.status.readCredential(credentialText(account.credential.access)!, info.accountId, info.plan, optionsArg);
|
|
142
207
|
}
|
|
143
|
-
if (account.slotId === 'anthropic' && account.credential.type === 'oauth') return this.anthropicStatus(account
|
|
208
|
+
if (account.slotId === 'anthropic' && account.credential.type === 'oauth') return this.anthropicStatus(account, active !== undefined, optionsArg);
|
|
144
209
|
return { facts: [
|
|
145
210
|
{ section: 'Authentication', label: 'Provider', value: account.slotId },
|
|
146
211
|
{ section: 'Authentication', label: 'Login type', value: String(account.credential.type) },
|
package/ts/interfaces.harness.ts
CHANGED
|
@@ -239,9 +239,13 @@ export interface IAuthHarness {
|
|
|
239
239
|
*/
|
|
240
240
|
readonly autoSwitch?: boolean;
|
|
241
241
|
/**
|
|
242
|
-
* The account's live status. It must never activate, switch or clear a login.
|
|
243
|
-
*
|
|
244
|
-
*
|
|
242
|
+
* The account's live status. It must never activate, switch or clear a login.
|
|
243
|
+
*
|
|
244
|
+
* It may renew the tokens of the credential it reads when the provider leaves it no other way to answer, under
|
|
245
|
+
* its store's lock and with a compare-and-swap on the refresh token it sent. A saved copy is always its own to
|
|
246
|
+
* renew -- the Claude Code adapter renews one, because Claude Code's active login is Claude Code's. The active
|
|
247
|
+
* login may only be renewed where the owning harness re-reads its credential file on every request, so that the
|
|
248
|
+
* rotation strands nothing: OpenCode's Anthropic provider does, and authswitch renews that entry.
|
|
245
249
|
*/
|
|
246
250
|
readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
|
|
247
251
|
readonly loginProviders?: IHarnessLoginProvider[];
|