@cortexkit/anthropic-auth-core 1.10.3 → 1.11.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/logger.js CHANGED
@@ -1,7 +1,205 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
- const LOG_FILE = path.join(os.tmpdir(), 'opencode-anthropic-auth.log');
4
+ const LEVEL_ORDER = {
5
+ error: 0,
6
+ warn: 1,
7
+ info: 2,
8
+ debug: 3,
9
+ trace: 4,
10
+ };
11
+ const VALID_LEVELS = new Set(Object.keys(LEVEL_ORDER));
12
+ function parseLogLevel(value) {
13
+ if (!value)
14
+ return null;
15
+ const normalized = value.trim().toLowerCase();
16
+ return VALID_LEVELS.has(normalized) ? normalized : null;
17
+ }
18
+ const envLevel = parseLogLevel(process.env.OPENCODE_ANTHROPIC_AUTH_LOG_LEVEL);
19
+ let currentLevel = envLevel ?? 'info';
20
+ export function setLogLevel(level) {
21
+ currentLevel = level;
22
+ }
23
+ export function getLogLevel() {
24
+ return currentLevel;
25
+ }
26
+ export { parseLogLevel };
27
+ // -- Emission control ------------------------------------------------------
28
+ export function shouldEmit(level) {
29
+ return LEVEL_ORDER[level] <= LEVEL_ORDER[currentLevel];
30
+ }
31
+ // -- File path -------------------------------------------------------------
32
+ const LOG_FILE = process.env.OPENCODE_ANTHROPIC_AUTH_LOG_FILE ??
33
+ path.join(os.tmpdir(), 'opencode-anthropic-auth.log');
34
+ export function getLogFilePath() {
35
+ return LOG_FILE;
36
+ }
37
+ // -- Rotation --------------------------------------------------------------
38
+ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
39
+ const MAX_GENERATIONS = 3;
40
+ function rotateLogFile() {
41
+ try {
42
+ if (!fs.existsSync(LOG_FILE))
43
+ return;
44
+ const stat = fs.statSync(LOG_FILE);
45
+ if (stat.size < MAX_FILE_SIZE)
46
+ return;
47
+ }
48
+ catch {
49
+ return;
50
+ }
51
+ try {
52
+ for (let i = MAX_GENERATIONS; i >= 1; i--) {
53
+ const oldPath = i === 1 ? LOG_FILE : `${LOG_FILE}.${i - 1}`;
54
+ const newPath = `${LOG_FILE}.${i}`;
55
+ try {
56
+ if (i === MAX_GENERATIONS) {
57
+ try {
58
+ fs.unlinkSync(newPath);
59
+ }
60
+ catch {
61
+ // .3 might not exist
62
+ }
63
+ }
64
+ if (fs.existsSync(oldPath)) {
65
+ fs.renameSync(oldPath, newPath);
66
+ }
67
+ }
68
+ catch {
69
+ // Rotation best-effort — never throw
70
+ }
71
+ }
72
+ }
73
+ catch {
74
+ // Rotation must never throw
75
+ }
76
+ }
77
+ // -- Redaction ------------------------------------------------------------
78
+ export const SECRET_KEY_EXACT = /^(authorization|x-api-key|cookie|set-cookie|refresh|access|token)$/i;
79
+ export function isSecretKey(key) {
80
+ if (SECRET_KEY_EXACT.test(key))
81
+ return true;
82
+ const k = key.toLowerCase().replace(/[-_]/g, '');
83
+ if (k.includes('apikey'))
84
+ return true;
85
+ if (k.endsWith('secret') || k.endsWith('password'))
86
+ return true;
87
+ if (k.endsWith('token') && !k.endsWith('tokens'))
88
+ return true;
89
+ return false;
90
+ }
91
+ const REDACT_VALUE_PATTERNS = [
92
+ /Bearer\s+\S+/,
93
+ /sk-[A-Za-z0-9-]+/,
94
+ /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/,
95
+ ];
96
+ function isRedactableValue(value) {
97
+ if (typeof value !== 'string')
98
+ return false;
99
+ return REDACT_VALUE_PATTERNS.some((pattern) => pattern.test(value));
100
+ }
101
+ export function redactPayload(payload) {
102
+ if (payload == null || Object.keys(payload).length === 0)
103
+ return undefined;
104
+ const seen = new WeakSet();
105
+ function walk(value) {
106
+ if (value == null || typeof value !== 'object') {
107
+ if (isRedactableValue(value))
108
+ return '***REDACTED***';
109
+ return value;
110
+ }
111
+ if (seen.has(value))
112
+ return '[Circular]';
113
+ seen.add(value);
114
+ try {
115
+ if (Array.isArray(value))
116
+ return value.map(walk);
117
+ const obj = value;
118
+ const result = {};
119
+ for (const [key, entry] of Object.entries(obj)) {
120
+ if (isSecretKey(key)) {
121
+ result[key] = '***REDACTED***';
122
+ continue;
123
+ }
124
+ result[key] = walk(entry);
125
+ }
126
+ return result;
127
+ }
128
+ finally {
129
+ seen.delete(value);
130
+ }
131
+ }
132
+ return walk(payload);
133
+ }
134
+ // -- Line formatting ------------------------------------------------------
135
+ export function formatLogLine(level, channel, message, payload) {
136
+ const now = new Date().toISOString();
137
+ const levelUpper = level.toUpperCase().padEnd(5);
138
+ const channelSegment = channel ? ` [${channel}]` : '';
139
+ const payloadJson = (() => {
140
+ try {
141
+ const redacted = redactPayload(payload);
142
+ const withPid = redacted
143
+ ? { ...redacted, pid: process.pid }
144
+ : { pid: process.pid };
145
+ return ` ${JSON.stringify(withPid)}`;
146
+ }
147
+ catch {
148
+ return ` {"pid":${process.pid},"payload":"[unserializable]"}`;
149
+ }
150
+ })();
151
+ return `[${now}] ${levelUpper}${channelSegment} ${message}${payloadJson}`;
152
+ }
153
+ let testSink = null;
154
+ export function __setLogTestSink(sink) {
155
+ testSink = sink;
156
+ }
157
+ // -- Leveled API ----------------------------------------------------------
158
+ function emit(level, channel, message, payload) {
159
+ if (!shouldEmit(level))
160
+ return;
161
+ if (testSink) {
162
+ try {
163
+ testSink({ level, channel, message, payload: redactPayload(payload) });
164
+ }
165
+ catch {
166
+ // Sink must never throw into production.
167
+ }
168
+ }
169
+ if (isTestEnv)
170
+ return;
171
+ try {
172
+ const line = `${formatLogLine(level, channel, message, payload)}\n`;
173
+ buffer.push(line);
174
+ if (buffer.length >= BUFFER_SIZE_LIMIT) {
175
+ flush();
176
+ }
177
+ else {
178
+ scheduleFlush();
179
+ }
180
+ }
181
+ catch {
182
+ // Logging must never throw.
183
+ }
184
+ }
185
+ export const logger = {
186
+ error(channel, message, payload) {
187
+ emit('error', channel, message, payload);
188
+ },
189
+ warn(channel, message, payload) {
190
+ emit('warn', channel, message, payload);
191
+ },
192
+ info(channel, message, payload) {
193
+ emit('info', channel, message, payload);
194
+ },
195
+ debug(channel, message, payload) {
196
+ emit('debug', channel, message, payload);
197
+ },
198
+ trace(channel, message, payload) {
199
+ emit('trace', channel, message, payload);
200
+ },
201
+ };
202
+ // -- Buffered writes ------------------------------------------------------
5
203
  const isTestEnv = process.env.NODE_ENV === 'test';
6
204
  const FLUSH_INTERVAL_MS = 500;
7
205
  const BUFFER_SIZE_LIMIT = 50;
@@ -17,6 +215,7 @@ function flush() {
17
215
  const data = buffer.join('');
18
216
  buffer = [];
19
217
  try {
218
+ rotateLogFile();
20
219
  fs.appendFileSync(LOG_FILE, data);
21
220
  }
22
221
  catch {
@@ -31,36 +230,57 @@ function scheduleFlush() {
31
230
  flush();
32
231
  }, FLUSH_INTERVAL_MS);
33
232
  }
34
- function serialize(data) {
35
- if (data === undefined)
36
- return '';
37
- if (data instanceof Error) {
38
- return ` ${data.message}${data.stack ? `\n${data.stack}` : ''}`;
39
- }
40
- return ` ${JSON.stringify(data)}`;
41
- }
233
+ // -- Back-compat shims ----------------------------------------------------
234
+ const CHANNEL_REGEX = /^\[([\w-]+)\]\s*/;
42
235
  export function log(message, data) {
236
+ if (!shouldEmit('debug'))
237
+ return;
43
238
  if (isTestEnv)
44
239
  return;
45
240
  try {
46
- buffer.push(`[${new Date().toISOString()}] ${message}${serialize(data)}\n`);
47
- if (buffer.length >= BUFFER_SIZE_LIMIT) {
48
- flush();
241
+ let channel = '';
242
+ let msg = message;
243
+ const match = message.match(CHANNEL_REGEX);
244
+ if (match) {
245
+ channel = match[1] ?? '';
246
+ msg = message.slice(match[0].length);
49
247
  }
50
- else {
51
- scheduleFlush();
248
+ let payload;
249
+ if (data != null) {
250
+ if (data instanceof Error) {
251
+ payload = {
252
+ error: `${data.message}${data.stack ? `\n${data.stack}` : ''}`,
253
+ };
254
+ }
255
+ else if (typeof data === 'object') {
256
+ payload = data;
257
+ }
52
258
  }
259
+ emit('debug', channel, msg, payload);
53
260
  }
54
261
  catch {
55
262
  // Logging must never throw.
56
263
  }
57
264
  }
58
265
  export function relayLog(message, data) {
59
- log(`[relay] ${message}`, data);
60
- }
61
- export function getLogFilePath() {
62
- return LOG_FILE;
266
+ if (!shouldEmit('debug'))
267
+ return;
268
+ if (isTestEnv)
269
+ return;
270
+ try {
271
+ let payload;
272
+ if (data != null) {
273
+ if (typeof data === 'object' && !(data instanceof Error)) {
274
+ payload = data;
275
+ }
276
+ }
277
+ emit('debug', 'relay', message, payload);
278
+ }
279
+ catch {
280
+ // Logging must never throw.
281
+ }
63
282
  }
283
+ // -- Exit flush -----------------------------------------------------------
64
284
  if (!isTestEnv) {
65
285
  process.on('exit', flush);
66
286
  }
@@ -0,0 +1,19 @@
1
+ import type { LogLevel } from './logger.ts';
2
+ export declare const CLAUDE_LOGGING_COMMAND_NAME = "claude-logging";
3
+ export declare const LOGGING_LEVELS: LogLevel[];
4
+ export type LoggingCommandAction = {
5
+ type: 'status';
6
+ } | {
7
+ type: 'level';
8
+ level: LogLevel;
9
+ } | {
10
+ type: 'usage';
11
+ };
12
+ export declare function parseLoggingCommandAction(argumentsText: string): LoggingCommandAction;
13
+ export declare function buildLoggingStatusSummary(input?: {
14
+ level?: LogLevel;
15
+ }): string;
16
+ export declare function executeLoggingCommand(input: {
17
+ argumentsText: string;
18
+ level?: LogLevel;
19
+ }): string;
@@ -0,0 +1,59 @@
1
+ // Known limitation: the TUI bundle is a SEPARATE module instance from the loader,
2
+ // so /claude-logging sets the SERVER-side (loader) logger level and persists it
3
+ // (applied on next boot); the TUI-side logger level is only raised via the
4
+ // OPENCODE_ANTHROPIC_AUTH_LOG_LEVEL env override. The command still correctly
5
+ // persists and applies server-side — just note this asymmetry.
6
+ export const CLAUDE_LOGGING_COMMAND_NAME = 'claude-logging';
7
+ export const LOGGING_LEVELS = [
8
+ 'error',
9
+ 'warn',
10
+ 'info',
11
+ 'debug',
12
+ 'trace',
13
+ ];
14
+ const LOGGING_USAGE = 'Usage: `/claude-logging`, `/claude-logging error|warn|info|debug|trace`.';
15
+ export function parseLoggingCommandAction(argumentsText) {
16
+ const tokens = argumentsText.trim().toLowerCase().split(/\s+/).filter(Boolean);
17
+ if (tokens.length === 0)
18
+ return { type: 'status' };
19
+ if (tokens.length === 1 && LOGGING_LEVELS.includes(tokens[0])) {
20
+ return { type: 'level', level: tokens[0] };
21
+ }
22
+ return { type: 'usage' };
23
+ }
24
+ export function buildLoggingStatusSummary(input) {
25
+ const level = input?.level ?? 'info';
26
+ return [
27
+ '## Claude Log Level',
28
+ '',
29
+ `Current log level: \`${level}\``,
30
+ '- Persisted: ~/.config/opencode/anthropic-auth.json',
31
+ '- Valid levels: error, warn, info, debug, trace',
32
+ '',
33
+ LOGGING_USAGE,
34
+ ].join('\n');
35
+ }
36
+ export function executeLoggingCommand(input) {
37
+ const action = parseLoggingCommandAction(input.argumentsText);
38
+ const level = input.level ?? 'info';
39
+ if (action.type === 'status')
40
+ return buildLoggingStatusSummary({ level });
41
+ if (action.type === 'level') {
42
+ return [
43
+ '## Claude Log Level Updated',
44
+ '',
45
+ `Level set to \`${action.level}\`.`,
46
+ '',
47
+ ...buildLoggingStatusSummary({ level: action.level })
48
+ .split('\n')
49
+ .slice(2),
50
+ ].join('\n');
51
+ }
52
+ return [
53
+ '## Claude Log Level Usage',
54
+ '',
55
+ LOGGING_USAGE,
56
+ '',
57
+ ...buildLoggingStatusSummary({ level }).split('\n').slice(2),
58
+ ].join('\n');
59
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Duck-typed error contract for provider HTTP errors.
3
+ *
4
+ * Shared-core extraction of anthropic-auth and openai-auth is mechanical
5
+ * when all error classification uses this contract instead of instanceof
6
+ * or message-regex matching on provider-specific error classes.
7
+ */
8
+ export type ProviderHttpError = {
9
+ /** HTTP status code. */
10
+ status?: number;
11
+ /** Parsed Retry-After header value in seconds, if the server provided one. */
12
+ retryAfter?: number;
13
+ /**
14
+ * Provider-agnostic marker that the error originated from a token-REFRESH
15
+ * step (as opposed to a quota-endpoint or other non-refresh error). Consumers
16
+ * use this flag to arm the refresh backoff without instanceof checks.
17
+ */
18
+ isRefreshError?: boolean;
19
+ };
File without changes
@@ -30,6 +30,7 @@ export declare class QuotaManager {
30
30
  private fallbacks;
31
31
  private fallbackTokenFps;
32
32
  private inflightMain;
33
+ private inflightMainFp;
33
34
  private inflightFallbacks;
34
35
  private mainLastApiError;
35
36
  private fallbackApiErrors;
@@ -6,7 +6,7 @@
6
6
  * Handles deduplication, rate-limiting (429 backoff), and staleness.
7
7
  */
8
8
  import { createHash } from 'node:crypto';
9
- import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, quotaBackoffActive, } from "./accounts.js";
9
+ import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, isQuotaPolicyAuthError, quotaBackoffActive, } from "./accounts.js";
10
10
  // Capture real setTimeout before tests can mock globalThis.setTimeout
11
11
  const nativeSetTimeout = globalThis.setTimeout;
12
12
  /**
@@ -31,6 +31,7 @@ export class QuotaManager {
31
31
  fallbackTokenFps = new Map();
32
32
  // --- Inflight deduplication ---
33
33
  inflightMain = null;
34
+ inflightMainFp = null;
34
35
  inflightFallbacks = new Map();
35
36
  // --- Rate-limiting (scoped per route so a fallback 429 never backs off the
36
37
  // main account or vice versa) ---
@@ -127,8 +128,8 @@ export class QuotaManager {
127
128
  this.main = null;
128
129
  this.mainTokenFp = null;
129
130
  }
130
- // Deduplicate — return in-flight promise if already fetching
131
- if (this.inflightMain)
131
+ // Deduplicate — return in-flight promise only if same token fingerprint
132
+ if (this.inflightMain && this.inflightMainFp === fp)
132
133
  return this.inflightMain;
133
134
  // Rate-limit — if API recently 429'd, return stale or throw
134
135
  if (this.isBackedOff()) {
@@ -136,6 +137,7 @@ export class QuotaManager {
136
137
  return this.main.quota;
137
138
  throw new Error('Quota API rate-limited — try again later');
138
139
  }
140
+ this.inflightMainFp = fp;
139
141
  this.inflightMain = this._fetchMain(accessToken);
140
142
  return this.inflightMain;
141
143
  }
@@ -342,6 +344,7 @@ export class QuotaManager {
342
344
  return queued;
343
345
  }
344
346
  async _fetchMain(accessToken) {
347
+ const thisFetchFp = tokenFingerprint(accessToken);
345
348
  return this._enqueueApiFetch(async () => {
346
349
  try {
347
350
  // Re-check backoff inside gate — may have been set by
@@ -388,7 +391,10 @@ export class QuotaManager {
388
391
  }
389
392
  }
390
393
  finally {
391
- this.inflightMain = null;
394
+ if (this.inflightMainFp === thisFetchFp) {
395
+ this.inflightMain = null;
396
+ this.inflightMainFp = null;
397
+ }
392
398
  }
393
399
  });
394
400
  }
@@ -441,10 +447,13 @@ export class QuotaManager {
441
447
  }
442
448
  });
443
449
  }
444
- // A 401 is an auth/token problem, not a rate limit. The caller refreshes the
445
- // token and retries; backing off the quota API here would block that retry,
446
- // so surface 401s without recording backoff state.
450
+ // A 401 is an auth/token problem and a 403 is an account/org policy problem,
451
+ // not quota endpoint saturation. Surface both without recording quota backoff
452
+ // so callers can refresh, re-auth, or try another account immediately.
447
453
  static isAuthError(error) {
454
+ const status = error.status;
455
+ if (status === 401 || isQuotaPolicyAuthError(error))
456
+ return true;
448
457
  const message = error instanceof Error ? error.message : String(error);
449
458
  return /quota check failed: 401\b/.test(message);
450
459
  }
package/dist/routing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getAccountStoragePath, loadAccounts, saveAccounts, } from "./accounts.js";
1
+ import { createEmptyStorage, getAccountStoragePath, loadAccounts, saveAccounts, } from "./accounts.js";
2
2
  export const CLAUDE_ROUTING_COMMAND_NAME = 'claude-routing';
3
3
  export const ROUTING_MODES = ['main-first', 'fallback-first'];
4
4
  export const DEFAULT_ROUTING_MODE = 'main-first';
@@ -13,11 +13,7 @@ export function getRoutingMode(storage) {
13
13
  return normalizeRoutingMode(storage?.routing?.mode);
14
14
  }
15
15
  export async function setRoutingMode(mode, path = getAccountStoragePath()) {
16
- const storage = (await loadAccounts(path)) ?? {
17
- version: 1,
18
- main: { type: 'opencode', provider: 'anthropic' },
19
- accounts: [],
20
- };
16
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
21
17
  storage.routing = {
22
18
  ...(storage.routing ?? {}),
23
19
  mode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.10.3",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",