@sunerpy/opencode-kiro-auth 0.13.1 → 0.13.3

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.
@@ -15,7 +15,8 @@ export declare class AccountSelector {
15
15
  private circuitBreakerTrips;
16
16
  private lastCircuitBreakerReset;
17
17
  constructor(accountManager: AccountManager, config: AccountSelectorConfig, syncFromKiroCli: () => Promise<void>, repository: AccountRepository);
18
- selectHealthyAccount(showToast: ToastFunction): Promise<ManagedAccount | null>;
18
+ selectHealthyAccount(showToast: ToastFunction, signal?: AbortSignal): Promise<ManagedAccount | null>;
19
+ selectAlternativeAccount(excludedIds: ReadonlySet<string>): Promise<ManagedAccount | null>;
19
20
  private handleEmptyAccounts;
20
21
  private formatUsageMessage;
21
22
  private checkCircuitBreaker;
@@ -12,7 +12,7 @@ export class AccountSelector {
12
12
  this.syncFromKiroCli = syncFromKiroCli;
13
13
  this.repository = repository;
14
14
  }
15
- async selectHealthyAccount(showToast) {
15
+ async selectHealthyAccount(showToast, signal) {
16
16
  this.checkCircuitBreaker();
17
17
  let count = this.accountManager.getAccountCount();
18
18
  if (count === 0 && this.config.auto_sync_kiro_cli && !this.triedEmptySync) {
@@ -34,7 +34,7 @@ export class AccountSelector {
34
34
  if (this.accountManager.shouldShowToast()) {
35
35
  showToast(`All accounts rate-limited. Waiting ${Math.ceil(wait / 1000)}s...`, 'warning');
36
36
  }
37
- await this.sleep(wait);
37
+ await this.sleep(wait, signal);
38
38
  return null;
39
39
  }
40
40
  throw new Error('All accounts are unhealthy or rate-limited: reauth required');
@@ -47,6 +47,9 @@ export class AccountSelector {
47
47
  }
48
48
  return acc;
49
49
  }
50
+ async selectAlternativeAccount(excludedIds) {
51
+ return this.accountManager.getCurrentOrNext({ excludedIds, recoverUnhealthy: false });
52
+ }
50
53
  async handleEmptyAccounts() {
51
54
  await this.syncFromKiroCli();
52
55
  this.repository.invalidateCache();
@@ -77,7 +80,20 @@ export class AccountSelector {
77
80
  this.lastCircuitBreakerReset = Date.now();
78
81
  }
79
82
  }
80
- sleep(ms) {
81
- return new Promise((r) => setTimeout(r, ms));
83
+ sleep(ms, signal) {
84
+ if (signal?.aborted)
85
+ return Promise.reject(signal.reason);
86
+ let onAbort;
87
+ return new Promise((resolve, reject) => {
88
+ const timer = setTimeout(resolve, ms);
89
+ onAbort = () => {
90
+ clearTimeout(timer);
91
+ reject(signal?.reason);
92
+ };
93
+ signal?.addEventListener('abort', onAbort, { once: true });
94
+ }).finally(() => {
95
+ if (onAbort)
96
+ signal?.removeEventListener('abort', onAbort);
97
+ });
82
98
  }
83
99
  }
@@ -13,7 +13,7 @@ export declare class UsageTracker {
13
13
  private lastSyncTime;
14
14
  private readonly cooldownMs;
15
15
  constructor(config: UsageTrackerConfig, accountManager: AccountManager, repository: AccountRepository);
16
- syncUsage(account: ManagedAccount, auth: KiroAuthDetails): Promise<void>;
16
+ syncUsage(account: ManagedAccount, auth: KiroAuthDetails, isValid?: () => boolean): Promise<void>;
17
17
  private syncWithRetry;
18
18
  private sleep;
19
19
  }
@@ -12,30 +12,40 @@ export class UsageTracker {
12
12
  this.repository = repository;
13
13
  this.cooldownMs = config.usage_sync_cooldown_ms ?? 60000;
14
14
  }
15
- async syncUsage(account, auth) {
15
+ async syncUsage(account, auth, isValid = () => true) {
16
16
  if (!this.config.usage_tracking_enabled)
17
17
  return;
18
+ if (!isValid())
19
+ return;
18
20
  const last = this.lastSyncTime.get(account.id) ?? 0;
19
21
  if (Date.now() - last < this.cooldownMs)
20
22
  return;
21
23
  this.lastSyncTime.set(account.id, Date.now());
22
- this.syncWithRetry(account, auth, 0).catch((e) => {
24
+ this.syncWithRetry(account, auth, 0, isValid).catch((e) => {
23
25
  logger.warn('Usage sync failed after all retries', {
24
26
  accountId: account.id,
25
27
  error: e instanceof Error ? e.message : String(e)
26
28
  });
27
29
  });
28
30
  }
29
- async syncWithRetry(account, auth, attempt) {
31
+ async syncWithRetry(account, auth, attempt, isValid) {
30
32
  try {
31
33
  const u = await fetchUsageLimits(auth);
34
+ if (!isValid())
35
+ return;
32
36
  updateAccountQuota(account, u, this.accountManager);
37
+ if (!isValid())
38
+ return;
33
39
  await this.repository.batchSave(this.accountManager.getAccounts());
34
40
  }
35
41
  catch (e) {
42
+ if (!isValid())
43
+ return;
36
44
  if (attempt < this.config.usage_sync_max_retries) {
37
45
  await this.sleep(1000 * Math.pow(2, attempt));
38
- return this.syncWithRetry(account, auth, attempt + 1);
46
+ if (!isValid())
47
+ return;
48
+ return this.syncWithRetry(account, auth, attempt + 1, isValid);
39
49
  }
40
50
  if (e.message?.includes('FEATURE_NOT_SUPPORTED')) {
41
51
  // Some IDC profiles don't support getUsageLimits; don't penalize the account.
@@ -44,7 +54,11 @@ export class UsageTracker {
44
54
  if (e.message?.includes('403') ||
45
55
  e.message?.includes('invalid') ||
46
56
  e.message?.includes('bearer token')) {
57
+ if (!isValid())
58
+ return;
47
59
  this.accountManager.markUnhealthy(account, e.message);
60
+ if (!isValid())
61
+ return;
48
62
  this.repository.save(account).catch(() => { });
49
63
  }
50
64
  throw e;
@@ -19,14 +19,14 @@ export declare class ErrorHandler {
19
19
  private repository;
20
20
  private forceRefresh?;
21
21
  constructor(config: ErrorHandlerConfig, accountManager: AccountManager, repository: AccountRepository, forceRefresh?: ForceRefreshFn | undefined);
22
- handle(error: any, response: Response, account: ManagedAccount, context: RequestContext, showToast: ToastFunction): Promise<{
22
+ handle(error: any, response: Response, account: ManagedAccount, context: RequestContext, showToast: ToastFunction, signal?: AbortSignal): Promise<{
23
23
  shouldRetry: boolean;
24
24
  newContext?: RequestContext;
25
25
  switchAccount?: boolean;
26
26
  }>;
27
27
  private markDeadAndSwitchOrFail;
28
28
  private transientForbidden;
29
- handleNetworkError(error: any, context: RequestContext, showToast: ToastFunction): Promise<{
29
+ handleNetworkError(error: any, context: RequestContext, showToast: ToastFunction, signal?: AbortSignal): Promise<{
30
30
  shouldRetry: boolean;
31
31
  newContext?: RequestContext;
32
32
  }>;
@@ -17,7 +17,7 @@ export class ErrorHandler {
17
17
  this.repository = repository;
18
18
  this.forceRefresh = forceRefresh;
19
19
  }
20
- async handle(error, response, account, context, showToast) {
20
+ async handle(error, response, account, context, showToast, signal) {
21
21
  const readBody = async () => {
22
22
  try {
23
23
  const body = JSON.parse(await response.clone().text());
@@ -57,7 +57,7 @@ export class ErrorHandler {
57
57
  if (account.failCount < 5) {
58
58
  const delay = 1000 * Math.pow(2, account.failCount - 1);
59
59
  showToast(`500: ${errorMessage}. Retrying in ${Math.ceil(delay / 1000)}s...`, 'warning');
60
- await this.sleep(delay);
60
+ await this.sleep(delay, signal);
61
61
  return { shouldRetry: true };
62
62
  }
63
63
  else {
@@ -76,7 +76,7 @@ export class ErrorHandler {
76
76
  return { shouldRetry: true, switchAccount: true };
77
77
  }
78
78
  showToast(`429: Rate limited. Waiting ${Math.ceil(w / 1000)}s...`, 'warning');
79
- await this.sleep(w);
79
+ await this.sleep(w, signal);
80
80
  return { shouldRetry: true };
81
81
  }
82
82
  if (response.status === 402 || response.status === 403) {
@@ -118,7 +118,7 @@ export class ErrorHandler {
118
118
  if (result.dead) {
119
119
  return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, nextForced, showToast);
120
120
  }
121
- return this.transientForbidden(errorReason, response.status, { ...context, forcedRefreshAccountIds: nextForced }, showToast);
121
+ return this.transientForbidden(errorReason, response.status, { ...context, forcedRefreshAccountIds: nextForced }, showToast, signal);
122
122
  }
123
123
  return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, forced, showToast);
124
124
  }
@@ -134,7 +134,7 @@ export class ErrorHandler {
134
134
  if (response.status === 403 &&
135
135
  !isPermanent &&
136
136
  context.retry < this.config.rate_limit_max_retries) {
137
- return this.transientForbidden(errorReason, response.status, context, showToast);
137
+ return this.transientForbidden(errorReason, response.status, context, showToast, signal);
138
138
  }
139
139
  showToast(`${response.status}: ${errorReason}`, 'error');
140
140
  return { shouldRetry: false };
@@ -158,24 +158,24 @@ export class ErrorHandler {
158
158
  showToast(`${status}: ${errorReason}. Re-login required.`, 'error');
159
159
  return { shouldRetry: false };
160
160
  }
161
- async transientForbidden(errorReason, status, context, showToast) {
161
+ async transientForbidden(errorReason, status, context, showToast, signal) {
162
162
  if (context.retry >= this.config.rate_limit_max_retries) {
163
163
  showToast(`${status}: ${errorReason}`, 'error');
164
164
  return { shouldRetry: false };
165
165
  }
166
166
  const delay = this.config.rate_limit_retry_delay_ms * Math.pow(2, context.retry);
167
167
  showToast(`${status}: ${errorReason}. Retrying in ${Math.ceil(delay / 1000)}s...`, 'warning');
168
- await this.sleep(delay);
168
+ await this.sleep(delay, signal);
169
169
  return {
170
170
  shouldRetry: true,
171
171
  newContext: { ...context, retry: context.retry + 1 }
172
172
  };
173
173
  }
174
- async handleNetworkError(error, context, showToast) {
174
+ async handleNetworkError(error, context, showToast, signal) {
175
175
  if (this.isNetworkError(error) && context.retry < this.config.rate_limit_max_retries) {
176
176
  const d = this.config.rate_limit_retry_delay_ms * Math.pow(2, context.retry);
177
177
  showToast(`Network error. Retrying in ${Math.ceil(d / 1000)}s...`, 'warning');
178
- await this.sleep(d);
178
+ await this.sleep(d, signal);
179
179
  return {
180
180
  shouldRetry: true,
181
181
  newContext: { ...context, retry: context.retry + 1 }
@@ -186,7 +186,20 @@ export class ErrorHandler {
186
186
  isNetworkError(e) {
187
187
  return (e instanceof Error && /econnreset|etimedout|enotfound|network|fetch failed/i.test(e.message));
188
188
  }
189
- sleep(ms) {
190
- return new Promise((r) => setTimeout(r, ms));
189
+ sleep(ms, signal) {
190
+ if (signal?.aborted)
191
+ return Promise.reject(signal.reason);
192
+ let onAbort;
193
+ return new Promise((resolve, reject) => {
194
+ const timer = setTimeout(resolve, ms);
195
+ onAbort = () => {
196
+ clearTimeout(timer);
197
+ reject(signal?.reason);
198
+ };
199
+ signal?.addEventListener('abort', onAbort, { once: true });
200
+ }).finally(() => {
201
+ if (onAbort)
202
+ signal?.removeEventListener('abort', onAbort);
203
+ });
191
204
  }
192
205
  }
@@ -16,6 +16,8 @@ export declare class RequestHandler {
16
16
  private retryStrategy;
17
17
  private reauthInFlight;
18
18
  private lastFailedReauthAt;
19
+ private accountAttemptEpochs;
20
+ private streamRetryRandom;
19
21
  private static kiroRequestQueue;
20
22
  constructor(accountManager: AccountManager, config: KiroConfig, repository: AccountRepository, client?: any | undefined);
21
23
  get sharedTokenRefresher(): TokenRefresher;
@@ -38,6 +40,8 @@ export declare class RequestHandler {
38
40
  private performReauth;
39
41
  private hasUsableAccount;
40
42
  private allAccountsPermanentlyUnhealthy;
43
+ private nextAccountAttemptEpoch;
44
+ private getStreamRetryDelay;
41
45
  private sleep;
42
46
  }
43
47
  export {};
@@ -1,4 +1,5 @@
1
1
  import { GenerateAssistantResponseCommand } from '@aws/codewhisperer-streaming-client';
2
+ import { clearTimeout as clearDeadlineTimeout, setTimeout as setDeadlineTimeout } from 'node:timers';
2
3
  import { isPermanentError } from '../../plugin/health.js';
3
4
  import * as logger from '../../plugin/logger.js';
4
5
  import { transformToSdkRequest } from '../../plugin/request.js';
@@ -10,6 +11,7 @@ import { TokenRefresher } from '../auth/token-refresher.js';
10
11
  import { ErrorHandler, isKiroContextOverflowBody } from './error-handler.js';
11
12
  import { ResponseHandler } from './response-handler.js';
12
13
  import { RetryStrategy } from './retry-strategy.js';
14
+ import { SdkEventStreamIterationError, UpstreamUnexpectedError } from './stream-error.js';
13
15
  const KIRO_API_PATTERN = /^(https?:\/\/)?q\.[a-z0-9-]+\.amazonaws\.com/;
14
16
  const REAUTH_FAILURE_COOLDOWN_MS = 60000;
15
17
  export class RequestHandler {
@@ -25,6 +27,8 @@ export class RequestHandler {
25
27
  retryStrategy;
26
28
  reauthInFlight = null;
27
29
  lastFailedReauthAt = 0;
30
+ accountAttemptEpochs = new Map();
31
+ streamRetryRandom = Math.random;
28
32
  static kiroRequestQueue = Promise.resolve();
29
33
  constructor(accountManager, config, repository, client) {
30
34
  this.accountManager = accountManager;
@@ -63,6 +67,33 @@ export class RequestHandler {
63
67
  }
64
68
  }
65
69
  async handleKiroRequest(url, init, showToast) {
70
+ const requestController = new AbortController();
71
+ const inboundSignal = init?.signal;
72
+ const abortFromInbound = () => requestController.abort(inboundSignal?.reason);
73
+ if (inboundSignal?.aborted)
74
+ abortFromInbound();
75
+ else
76
+ inboundSignal?.addEventListener('abort', abortFromInbound, { once: true });
77
+ let timeout;
78
+ const refreshRequestDeadline = () => {
79
+ if (requestController.signal.aborted)
80
+ return;
81
+ if (timeout)
82
+ clearDeadlineTimeout(timeout);
83
+ timeout = setDeadlineTimeout(() => requestController.abort(new DOMException('Kiro request timed out', 'TimeoutError')), this.config.request_timeout_ms);
84
+ };
85
+ refreshRequestDeadline();
86
+ let requestCleanupDone = false;
87
+ let responseOwnsLifecycle = false;
88
+ const cleanupRequest = () => {
89
+ if (requestCleanupDone)
90
+ return;
91
+ requestCleanupDone = true;
92
+ if (timeout)
93
+ clearDeadlineTimeout(timeout);
94
+ inboundSignal?.removeEventListener('abort', abortFromInbound);
95
+ };
96
+ const signal = requestController.signal;
66
97
  const body = init?.body ? JSON.parse(init.body) : {};
67
98
  const model = this.extractModel(url) || body.model || 'claude-sonnet-4-5';
68
99
  const think = model.endsWith('-thinking') || !!body.providerOptions?.thinkingConfig || !!body.thinkingConfig;
@@ -72,128 +103,187 @@ export class RequestHandler {
72
103
  20000;
73
104
  let handlerContext = { retry: 0, forcedRefreshAccountIds: new Set() };
74
105
  let consecutiveNullAccounts = 0;
106
+ let streamFailureCount = 0;
107
+ let forcedStreamAccount = null;
75
108
  const retryContext = this.retryStrategy.createContext();
76
- while (true) {
77
- const check = this.retryStrategy.shouldContinue(retryContext);
78
- if (!check.canContinue) {
79
- throw new Error(check.error);
80
- }
81
- if (this.allAccountsPermanentlyUnhealthy()) {
82
- const reauthed = await this.triggerReauth(showToast);
83
- if (!reauthed) {
84
- throw new Error('All accounts are permanently unhealthy. Please re-authenticate.');
109
+ try {
110
+ while (true) {
111
+ if (signal.aborted)
112
+ throw signal.reason;
113
+ const check = this.retryStrategy.shouldContinue(retryContext);
114
+ if (!check.canContinue) {
115
+ throw new Error(check.error);
85
116
  }
86
- continue;
87
- }
88
- let acc = await this.accountSelector.selectHealthyAccount(showToast).catch(async (e) => {
89
- if (e instanceof Error && e.message.includes('reauth required')) {
117
+ if (this.allAccountsPermanentlyUnhealthy()) {
90
118
  const reauthed = await this.triggerReauth(showToast);
91
- if (!reauthed)
92
- throw new Error('All accounts are unhealthy or rate-limited. Please re-authenticate.');
93
- return null;
119
+ if (!reauthed) {
120
+ throw new Error('All accounts are permanently unhealthy. Please re-authenticate.');
121
+ }
122
+ continue;
94
123
  }
95
- throw e;
96
- });
97
- if (!acc) {
98
- consecutiveNullAccounts++;
99
- const backoffDelay = Math.min(1000 * Math.pow(2, consecutiveNullAccounts - 1), 10000);
100
- await this.sleep(backoffDelay);
101
- continue;
102
- }
103
- consecutiveNullAccounts = 0;
104
- const auth = this.accountManager.toAuthDetails(acc);
105
- const tokenResult = await this.tokenRefresher.refreshIfNeeded(acc, auth, showToast);
106
- if (tokenResult.shouldContinue) {
107
- acc = tokenResult.account;
108
- await this.sleep(500);
109
- continue;
110
- }
111
- const sdkPrep = this.prepareSdkRequest(init?.body, model, auth, think, budget, showToast);
112
- if (this.config.enable_log_effort_debug) {
113
- try {
114
- logger.log('[effort-debug] request effort resolution', {
115
- model,
116
- effectiveModel: sdkPrep.effectiveModel,
117
- think,
118
- budget,
119
- resolvedEffort: sdkPrep.effort ?? 'undefined (not effort-capable or not thinking)',
120
- inboundBodyKeys: Object.keys(body),
121
- messagesCount: body.messages?.length,
122
- reasoningSubtree: {
123
- reasoningEffort: body.reasoningEffort,
124
- reasoning_effort: body.reasoning_effort,
125
- reasoning: body.reasoning,
126
- providerOptions: body.providerOptions,
127
- thinkingConfig: body.thinkingConfig,
128
- providerOptionsThinkingConfig: body.providerOptions?.thinkingConfig
124
+ let acc = forcedStreamAccount;
125
+ forcedStreamAccount = null;
126
+ if (!acc) {
127
+ acc = await this.accountSelector
128
+ .selectHealthyAccount(showToast, signal)
129
+ .catch(async (e) => {
130
+ if (e instanceof Error && e.message.includes('reauth required')) {
131
+ const reauthed = await this.triggerReauth(showToast);
132
+ if (!reauthed)
133
+ throw new Error('All accounts are unhealthy or rate-limited. Please re-authenticate.');
134
+ return null;
129
135
  }
136
+ throw e;
130
137
  });
131
138
  }
132
- catch { }
133
- }
134
- const apiTimestamp = this.config.enable_log_api_request ? logger.getTimestamp() : null;
135
- if (apiTimestamp) {
136
- this.logSdkRequest(sdkPrep, acc, apiTimestamp);
137
- }
138
- try {
139
- const client = this.makeSdkClient(auth, sdkPrep.region, sdkPrep.effort);
140
- const command = new GenerateAssistantResponseCommand({
141
- conversationState: sdkPrep.conversationState,
142
- profileArn: sdkPrep.profileArn
143
- });
144
- const sdkResponse = await client.send(command);
139
+ if (!acc) {
140
+ consecutiveNullAccounts++;
141
+ const backoffDelay = Math.min(1000 * Math.pow(2, consecutiveNullAccounts - 1), 10000);
142
+ await this.sleep(backoffDelay, signal);
143
+ continue;
144
+ }
145
+ consecutiveNullAccounts = 0;
146
+ const auth = this.accountManager.toAuthDetails(acc);
147
+ const tokenResult = await this.tokenRefresher.refreshIfNeeded(acc, auth, showToast);
148
+ if (tokenResult.shouldContinue) {
149
+ acc = tokenResult.account;
150
+ await this.sleep(500, signal);
151
+ continue;
152
+ }
153
+ const sdkPrep = this.prepareSdkRequest(init?.body, model, auth, think, budget, showToast);
154
+ if (this.config.enable_log_effort_debug) {
155
+ try {
156
+ logger.log('[effort-debug] request effort resolution', {
157
+ model,
158
+ effectiveModel: sdkPrep.effectiveModel,
159
+ think,
160
+ budget,
161
+ resolvedEffort: sdkPrep.effort ?? 'undefined (not effort-capable or not thinking)',
162
+ inboundBodyKeys: Object.keys(body),
163
+ messagesCount: body.messages?.length,
164
+ reasoningSubtree: {
165
+ reasoningEffort: body.reasoningEffort,
166
+ reasoning_effort: body.reasoning_effort,
167
+ reasoning: body.reasoning,
168
+ providerOptions: body.providerOptions,
169
+ thinkingConfig: body.thinkingConfig,
170
+ providerOptionsThinkingConfig: body.providerOptions?.thinkingConfig
171
+ }
172
+ });
173
+ }
174
+ catch { }
175
+ }
176
+ const apiTimestamp = this.config.enable_log_api_request ? logger.getTimestamp() : null;
145
177
  if (apiTimestamp) {
146
- this.logSdkResponse(sdkPrep, apiTimestamp);
178
+ this.logSdkRequest(sdkPrep, acc, apiTimestamp);
147
179
  }
148
- this.handleSuccessfulRequest(acc);
149
- this.usageTracker.syncUsage(acc, auth);
150
- return await this.responseHandler.handleSdkSuccess(sdkResponse, model, sdkPrep.conversationId, sdkPrep.streaming);
151
- }
152
- catch (e) {
153
- const httpStatus = e?.$metadata?.httpStatusCode;
154
- if (httpStatus) {
180
+ let sendResolved = false;
181
+ try {
182
+ const client = this.makeSdkClient(auth, sdkPrep.region, sdkPrep.effort);
183
+ const command = new GenerateAssistantResponseCommand({
184
+ conversationState: sdkPrep.conversationState,
185
+ profileArn: sdkPrep.profileArn
186
+ });
187
+ const attemptEpoch = this.nextAccountAttemptEpoch(acc.id);
188
+ const isCurrentAttempt = () => this.accountAttemptEpochs.get(acc.id) === attemptEpoch;
189
+ let completionDone = false;
190
+ const completeRequest = async () => {
191
+ if (completionDone)
192
+ return;
193
+ completionDone = true;
194
+ if (!isCurrentAttempt())
195
+ return;
196
+ this.handleSuccessfulRequest(acc);
197
+ await this.usageTracker.syncUsage(acc, auth, isCurrentAttempt);
198
+ };
199
+ const sdkResponse = await client.send(command, { abortSignal: signal });
200
+ sendResolved = true;
201
+ refreshRequestDeadline();
155
202
  if (apiTimestamp) {
156
- this.logSdkError(sdkPrep, e, acc, apiTimestamp);
203
+ this.logSdkResponse(sdkPrep, apiTimestamp);
204
+ }
205
+ const response = await this.responseHandler.handleSdkSuccess(sdkResponse, model, sdkPrep.conversationId, sdkPrep.streaming, {
206
+ signal,
207
+ onActivity: refreshRequestDeadline,
208
+ onComplete: completeRequest,
209
+ onTerminal: cleanupRequest,
210
+ onCancel: (reason) => requestController.abort(reason),
211
+ mapError: (error) => new UpstreamUnexpectedError(error, true)
212
+ });
213
+ if (sdkPrep.streaming) {
214
+ responseOwnsLifecycle = true;
157
215
  }
158
- const errorBody = JSON.stringify({ message: e.message, __type: e.name });
159
- const errorStatusText = e.name || 'Error';
160
- const jsonHeaders = { 'Content-Type': 'application/json' };
161
- const errorResult = await this.errorHandler.handle(e, new Response(errorBody, {
162
- status: httpStatus,
163
- statusText: errorStatusText,
164
- headers: jsonHeaders
165
- }), acc, handlerContext, showToast);
166
- if (errorResult.shouldRetry) {
167
- if (errorResult.newContext) {
168
- handlerContext = errorResult.newContext;
216
+ else {
217
+ await completeRequest();
218
+ }
219
+ return response;
220
+ }
221
+ catch (e) {
222
+ if (signal.aborted)
223
+ throw signal.reason;
224
+ if (e instanceof SdkEventStreamIterationError) {
225
+ streamFailureCount++;
226
+ const streamError = new UpstreamUnexpectedError(e, false);
227
+ if (streamFailureCount >= 3)
228
+ return streamError.toResponse();
229
+ await this.sleep(this.getStreamRetryDelay(streamFailureCount), signal);
230
+ if (streamFailureCount === 1) {
231
+ forcedStreamAccount = acc;
169
232
  }
170
- if (errorResult.switchAccount) {
171
- continue;
233
+ else {
234
+ forcedStreamAccount =
235
+ (await this.accountSelector.selectAlternativeAccount(new Set([acc.id]))) ?? acc;
172
236
  }
173
237
  continue;
174
238
  }
175
- // Terminal, non-retryable HTTP error. Return a fresh Response carrying
176
- // the real Kiro body so @ai-sdk/openai-compatible produces an
177
- // APICallError with status+body (not a bare Error that OpenCode
178
- // degrades to UnknownError). Remap size-overflow 400s to 413 so
179
- // OpenCode classifies context_overflow and auto-compacts.
180
- const terminalStatus = httpStatus === 400 && isKiroContextOverflowBody(e.message ?? '') ? 413 : httpStatus;
181
- return new Response(errorBody, {
182
- status: terminalStatus,
183
- statusText: errorStatusText,
184
- headers: jsonHeaders
185
- });
186
- }
187
- const networkResult = await this.errorHandler.handleNetworkError(e, handlerContext, showToast);
188
- if (networkResult.shouldRetry) {
189
- if (networkResult.newContext) {
190
- handlerContext = { ...handlerContext, ...networkResult.newContext };
239
+ if (sendResolved)
240
+ throw e;
241
+ const httpStatus = e?.$metadata?.httpStatusCode;
242
+ if (httpStatus) {
243
+ if (apiTimestamp) {
244
+ this.logSdkError(sdkPrep, e, acc, apiTimestamp);
245
+ }
246
+ const errorBody = JSON.stringify({ message: e.message, __type: e.name });
247
+ const errorStatusText = e.name || 'Error';
248
+ const jsonHeaders = { 'Content-Type': 'application/json' };
249
+ const errorResult = await this.errorHandler.handle(e, new Response(errorBody, {
250
+ status: httpStatus,
251
+ statusText: errorStatusText,
252
+ headers: jsonHeaders
253
+ }), acc, handlerContext, showToast, signal);
254
+ if (errorResult.shouldRetry) {
255
+ if (errorResult.newContext) {
256
+ handlerContext = errorResult.newContext;
257
+ }
258
+ continue;
259
+ }
260
+ // Terminal, non-retryable HTTP error. Return a fresh Response carrying
261
+ // the real Kiro body so @ai-sdk/openai-compatible produces an
262
+ // APICallError with status+body (not a bare Error that OpenCode
263
+ // degrades to UnknownError). Remap size-overflow 400s to 413 so
264
+ // OpenCode classifies context_overflow and auto-compacts.
265
+ const terminalStatus = httpStatus === 400 && isKiroContextOverflowBody(e.message ?? '') ? 413 : httpStatus;
266
+ return new Response(errorBody, {
267
+ status: terminalStatus,
268
+ statusText: errorStatusText,
269
+ headers: jsonHeaders
270
+ });
191
271
  }
192
- continue;
272
+ const networkResult = await this.errorHandler.handleNetworkError(e, handlerContext, showToast, signal);
273
+ if (networkResult.shouldRetry) {
274
+ if (networkResult.newContext) {
275
+ handlerContext = { ...handlerContext, ...networkResult.newContext };
276
+ }
277
+ continue;
278
+ }
279
+ throw e;
193
280
  }
194
- throw e;
195
281
  }
196
282
  }
283
+ finally {
284
+ if (!responseOwnsLifecycle)
285
+ cleanupRequest();
286
+ }
197
287
  }
198
288
  extractModel(url) {
199
289
  return url.match(/models\/([^/:]+)/)?.[1] || null;
@@ -335,7 +425,29 @@ export class RequestHandler {
335
425
  }
336
426
  return accounts.every((acc) => !acc.isHealthy && isPermanentError(acc.unhealthyReason));
337
427
  }
338
- sleep(ms) {
339
- return new Promise((resolve) => setTimeout(resolve, ms));
428
+ nextAccountAttemptEpoch(accountId) {
429
+ const next = (this.accountAttemptEpochs.get(accountId) ?? 0) + 1;
430
+ this.accountAttemptEpochs.set(accountId, next);
431
+ return next;
432
+ }
433
+ getStreamRetryDelay(failureCount) {
434
+ const base = 250 * Math.pow(2, failureCount - 1);
435
+ return base + base * 0.25 * this.streamRetryRandom();
436
+ }
437
+ sleep(ms, signal) {
438
+ if (signal?.aborted)
439
+ return Promise.reject(signal.reason);
440
+ let onAbort;
441
+ return new Promise((resolve, reject) => {
442
+ const timer = setTimeout(resolve, ms);
443
+ onAbort = () => {
444
+ clearTimeout(timer);
445
+ reject(signal?.reason);
446
+ };
447
+ signal?.addEventListener('abort', onAbort, { once: true });
448
+ }).finally(() => {
449
+ if (onAbort)
450
+ signal?.removeEventListener('abort', onAbort);
451
+ });
340
452
  }
341
453
  }
@@ -1,8 +1,17 @@
1
+ import { SdkEventStreamIterationError } from './stream-error.js';
2
+ export interface SdkResponseLifecycle {
3
+ signal?: AbortSignal;
4
+ onActivity?: () => void;
5
+ onComplete?: () => void | Promise<void>;
6
+ onTerminal?: () => void;
7
+ onCancel?: (reason: unknown) => void;
8
+ mapError?: (error: SdkEventStreamIterationError, emittedOutput: true) => unknown;
9
+ }
1
10
  export declare class ResponseHandler {
2
11
  handleSuccess(response: Response, model: string, conversationId: string, streaming: boolean): Promise<Response>;
3
- handleSdkSuccess(sdkResponse: any, model: string, conversationId: string, streaming: boolean): Promise<Response>;
12
+ handleSdkSuccess(sdkResponse: any, model: string, conversationId: string, streaming: boolean, lifecycle?: SdkResponseLifecycle): Promise<Response>;
4
13
  private handleStreaming;
5
14
  private handleSdkStreaming;
6
- private handleNonStreaming;
7
15
  private handleSdkNonStreaming;
16
+ private handleNonStreaming;
8
17
  }
@@ -1,6 +1,93 @@
1
1
  import { parseEventStream } from '../../plugin/response.js';
2
2
  import { transformKiroStream } from '../../plugin/streaming/index.js';
3
3
  import { transformSdkStream } from '../../plugin/streaming/sdk-stream-transformer.js';
4
+ import { SdkEventStreamIterationError } from './stream-error.js';
5
+ function abortReason(signal) {
6
+ return signal.reason ?? new DOMException('The request was aborted', 'AbortError');
7
+ }
8
+ async function closeIterator(iterator) {
9
+ try {
10
+ await iterator.return?.();
11
+ }
12
+ catch { }
13
+ }
14
+ function wrapSdkEventStream(sdkResponse, signal, onActivity) {
15
+ const eventStream = sdkResponse.generateAssistantResponseResponse;
16
+ if (!eventStream || typeof eventStream[Symbol.asyncIterator] !== 'function') {
17
+ return { response: sdkResponse, closeRaw: async () => { } };
18
+ }
19
+ const rawIterator = eventStream[Symbol.asyncIterator]();
20
+ let closed = false;
21
+ const closeRaw = async () => {
22
+ if (closed)
23
+ return;
24
+ closed = true;
25
+ await closeIterator(rawIterator);
26
+ };
27
+ const nextRaw = async () => {
28
+ const recordActivity = (result) => {
29
+ if (!result.done)
30
+ onActivity?.();
31
+ return result;
32
+ };
33
+ if (!signal)
34
+ return rawIterator.next().then(recordActivity);
35
+ if (signal.aborted) {
36
+ await closeRaw();
37
+ throw abortReason(signal);
38
+ }
39
+ return new Promise((resolve, reject) => {
40
+ let settled = false;
41
+ const settle = (callback) => {
42
+ if (settled)
43
+ return;
44
+ settled = true;
45
+ signal.removeEventListener('abort', onAbort);
46
+ callback();
47
+ };
48
+ const onAbort = () => {
49
+ void closeRaw();
50
+ settle(() => reject(abortReason(signal)));
51
+ };
52
+ signal.addEventListener('abort', onAbort, { once: true });
53
+ Promise.resolve(rawIterator.next()).then((result) => settle(() => resolve(recordActivity(result))), (error) => settle(() => reject(error)));
54
+ });
55
+ };
56
+ const wrappedIterator = {
57
+ async next() {
58
+ try {
59
+ return await nextRaw();
60
+ }
61
+ catch (error) {
62
+ if (signal?.aborted)
63
+ throw abortReason(signal);
64
+ throw new SdkEventStreamIterationError(error);
65
+ }
66
+ },
67
+ async return() {
68
+ await closeRaw();
69
+ return { done: true, value: undefined };
70
+ }
71
+ };
72
+ const wrappedStream = {
73
+ [Symbol.asyncIterator]() {
74
+ return wrappedIterator;
75
+ }
76
+ };
77
+ return {
78
+ response: { ...sdkResponse, generateAssistantResponseResponse: wrappedStream },
79
+ closeRaw
80
+ };
81
+ }
82
+ function isSemanticChunk(chunk) {
83
+ const delta = chunk?.choices?.[0]?.delta;
84
+ return (delta?.content !== undefined ||
85
+ delta?.reasoning_content !== undefined ||
86
+ delta?.tool_calls !== undefined);
87
+ }
88
+ function encodeSseChunk(chunk) {
89
+ return new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`);
90
+ }
4
91
  export class ResponseHandler {
5
92
  async handleSuccess(response, model, conversationId, streaming) {
6
93
  if (streaming) {
@@ -8,11 +95,11 @@ export class ResponseHandler {
8
95
  }
9
96
  return this.handleNonStreaming(response, model, conversationId);
10
97
  }
11
- async handleSdkSuccess(sdkResponse, model, conversationId, streaming) {
98
+ async handleSdkSuccess(sdkResponse, model, conversationId, streaming, lifecycle = {}) {
12
99
  if (streaming) {
13
- return this.handleSdkStreaming(sdkResponse, model, conversationId);
100
+ return this.handleSdkStreaming(sdkResponse, model, conversationId, lifecycle);
14
101
  }
15
- return this.handleSdkNonStreaming(sdkResponse, model, conversationId);
102
+ return this.handleSdkNonStreaming(sdkResponse, model, conversationId, lifecycle);
16
103
  }
17
104
  async handleStreaming(response, model, conversationId) {
18
105
  const s = transformKiroStream(response, model, conversationId);
@@ -30,25 +117,145 @@ export class ResponseHandler {
30
117
  }
31
118
  }), { headers: { 'Content-Type': 'text/event-stream' } });
32
119
  }
33
- async handleSdkStreaming(sdkResponse, model, conversationId) {
34
- const s = transformSdkStream(sdkResponse, model, conversationId);
120
+ async handleSdkStreaming(sdkResponse, model, conversationId, lifecycle) {
121
+ const wrapped = wrapSdkEventStream(sdkResponse, lifecycle.signal, lifecycle.onActivity);
122
+ const transformed = transformSdkStream(wrapped.response, model, conversationId);
123
+ const buffered = [];
124
+ let firstSemantic;
125
+ while (true) {
126
+ const item = await transformed.next();
127
+ if (item.done) {
128
+ await lifecycle.onComplete?.();
129
+ lifecycle.onTerminal?.();
130
+ let index = 0;
131
+ return new Response(new ReadableStream({
132
+ pull(controller) {
133
+ const chunk = buffered[index++];
134
+ if (chunk)
135
+ controller.enqueue(chunk);
136
+ if (index >= buffered.length)
137
+ controller.close();
138
+ }
139
+ }, { highWaterMark: 0 }), { headers: { 'Content-Type': 'text/event-stream' } });
140
+ }
141
+ const encoded = encodeSseChunk(item.value);
142
+ if (isSemanticChunk(item.value)) {
143
+ firstSemantic = encoded;
144
+ break;
145
+ }
146
+ buffered.push(encoded);
147
+ }
148
+ let terminal = false;
149
+ let firstPull = true;
150
+ let abortListener;
151
+ const finish = () => {
152
+ if (terminal)
153
+ return;
154
+ terminal = true;
155
+ if (abortListener && lifecycle.signal) {
156
+ lifecycle.signal.removeEventListener('abort', abortListener);
157
+ }
158
+ lifecycle.onTerminal?.();
159
+ };
160
+ const cleanupIterators = async () => {
161
+ try {
162
+ await transformed.return(undefined);
163
+ }
164
+ catch { }
165
+ await wrapped.closeRaw();
166
+ };
35
167
  return new Response(new ReadableStream({
36
- async start(c) {
168
+ start(controller) {
169
+ if (!lifecycle.signal)
170
+ return;
171
+ abortListener = () => {
172
+ if (terminal)
173
+ return;
174
+ const reason = abortReason(lifecycle.signal);
175
+ void cleanupIterators();
176
+ finish();
177
+ controller.error(reason);
178
+ };
179
+ if (lifecycle.signal.aborted)
180
+ abortListener();
181
+ else
182
+ lifecycle.signal.addEventListener('abort', abortListener, { once: true });
183
+ },
184
+ async pull(controller) {
185
+ if (terminal)
186
+ return;
187
+ if (firstPull) {
188
+ firstPull = false;
189
+ for (const chunk of buffered)
190
+ controller.enqueue(chunk);
191
+ controller.enqueue(firstSemantic);
192
+ return;
193
+ }
37
194
  try {
38
- for await (const e of s) {
39
- c.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(e)}\n\n`));
195
+ const item = await transformed.next();
196
+ if (item.done) {
197
+ await lifecycle.onComplete?.();
198
+ finish();
199
+ controller.close();
200
+ return;
40
201
  }
41
- c.close();
202
+ controller.enqueue(encodeSseChunk(item.value));
42
203
  }
43
- catch (err) {
44
- c.error(err);
204
+ catch (error) {
205
+ if (terminal)
206
+ return;
207
+ const mapped = error instanceof SdkEventStreamIterationError
208
+ ? (lifecycle.mapError?.(error, true) ?? error)
209
+ : error;
210
+ finish();
211
+ controller.error(mapped);
45
212
  }
213
+ },
214
+ async cancel(reason) {
215
+ if (terminal)
216
+ return;
217
+ finish();
218
+ lifecycle.onCancel?.(reason);
219
+ await cleanupIterators();
46
220
  }
47
- }), { headers: { 'Content-Type': 'text/event-stream' } });
221
+ }, { highWaterMark: 0 }), { headers: { 'Content-Type': 'text/event-stream' } });
48
222
  }
49
- async handleNonStreaming(response, model, conversationId) {
50
- const text = await response.text();
51
- const p = parseEventStream(text, model);
223
+ async handleSdkNonStreaming(sdkResponse, model, conversationId, lifecycle) {
224
+ // For non-streaming SDK responses, collect all events
225
+ let content = '';
226
+ let reasoningContent = '';
227
+ const toolCalls = [];
228
+ let inputTokens = 0;
229
+ let outputTokens = 0;
230
+ const wrapped = wrapSdkEventStream(sdkResponse, lifecycle.signal, lifecycle.onActivity);
231
+ const eventStream = wrapped.response.generateAssistantResponseResponse;
232
+ if (eventStream) {
233
+ try {
234
+ for await (const event of eventStream) {
235
+ if (event.reasoningContentEvent?.text) {
236
+ reasoningContent += event.reasoningContentEvent.text;
237
+ }
238
+ if (event.assistantResponseEvent?.content) {
239
+ content += event.assistantResponseEvent.content;
240
+ }
241
+ if (event.toolUseEvent) {
242
+ toolCalls.push(event.toolUseEvent);
243
+ }
244
+ if (event.metadataEvent?.tokenUsage) {
245
+ inputTokens = event.metadataEvent.tokenUsage.inputTokens || 0;
246
+ outputTokens = event.metadataEvent.tokenUsage.outputTokens || 0;
247
+ }
248
+ }
249
+ }
250
+ finally {
251
+ if (lifecycle.signal?.aborted)
252
+ await wrapped.closeRaw();
253
+ }
254
+ }
255
+ const message = { role: 'assistant', content };
256
+ if (reasoningContent) {
257
+ message.reasoning_content = reasoningContent;
258
+ }
52
259
  const oai = {
53
260
  id: conversationId,
54
261
  object: 'chat.completion',
@@ -57,18 +264,18 @@ export class ResponseHandler {
57
264
  choices: [
58
265
  {
59
266
  index: 0,
60
- message: { role: 'assistant', content: p.content },
61
- finish_reason: p.stopReason === 'tool_use' ? 'tool_calls' : 'stop'
267
+ message,
268
+ finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop'
62
269
  }
63
270
  ],
64
271
  usage: {
65
- prompt_tokens: p.inputTokens || 0,
66
- completion_tokens: p.outputTokens || 0,
67
- total_tokens: (p.inputTokens || 0) + (p.outputTokens || 0)
272
+ prompt_tokens: inputTokens,
273
+ completion_tokens: outputTokens,
274
+ total_tokens: inputTokens + outputTokens
68
275
  }
69
276
  };
70
- if (p.toolCalls.length > 0) {
71
- oai.choices[0].message.tool_calls = p.toolCalls.map((tc) => ({
277
+ if (toolCalls.length > 0) {
278
+ oai.choices[0].message.tool_calls = toolCalls.map((tc) => ({
72
279
  id: tc.toolUseId,
73
280
  type: 'function',
74
281
  function: {
@@ -81,35 +288,9 @@ export class ResponseHandler {
81
288
  headers: { 'Content-Type': 'application/json' }
82
289
  });
83
290
  }
84
- async handleSdkNonStreaming(sdkResponse, model, conversationId) {
85
- // For non-streaming SDK responses, collect all events
86
- let content = '';
87
- let reasoningContent = '';
88
- const toolCalls = [];
89
- let inputTokens = 0;
90
- let outputTokens = 0;
91
- const eventStream = sdkResponse.generateAssistantResponseResponse;
92
- if (eventStream) {
93
- for await (const event of eventStream) {
94
- if (event.reasoningContentEvent?.text) {
95
- reasoningContent += event.reasoningContentEvent.text;
96
- }
97
- if (event.assistantResponseEvent?.content) {
98
- content += event.assistantResponseEvent.content;
99
- }
100
- if (event.toolUseEvent) {
101
- toolCalls.push(event.toolUseEvent);
102
- }
103
- if (event.metadataEvent?.tokenUsage) {
104
- inputTokens = event.metadataEvent.tokenUsage.inputTokens || 0;
105
- outputTokens = event.metadataEvent.tokenUsage.outputTokens || 0;
106
- }
107
- }
108
- }
109
- const message = { role: 'assistant', content };
110
- if (reasoningContent) {
111
- message.reasoning_content = reasoningContent;
112
- }
291
+ async handleNonStreaming(response, model, conversationId) {
292
+ const text = await response.text();
293
+ const p = parseEventStream(text, model);
113
294
  const oai = {
114
295
  id: conversationId,
115
296
  object: 'chat.completion',
@@ -118,18 +299,18 @@ export class ResponseHandler {
118
299
  choices: [
119
300
  {
120
301
  index: 0,
121
- message,
122
- finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop'
302
+ message: { role: 'assistant', content: p.content },
303
+ finish_reason: p.stopReason === 'tool_use' ? 'tool_calls' : 'stop'
123
304
  }
124
305
  ],
125
306
  usage: {
126
- prompt_tokens: inputTokens,
127
- completion_tokens: outputTokens,
128
- total_tokens: inputTokens + outputTokens
307
+ prompt_tokens: p.inputTokens || 0,
308
+ completion_tokens: p.outputTokens || 0,
309
+ total_tokens: (p.inputTokens || 0) + (p.outputTokens || 0)
129
310
  }
130
311
  };
131
- if (toolCalls.length > 0) {
132
- oai.choices[0].message.tool_calls = toolCalls.map((tc) => ({
312
+ if (p.toolCalls.length > 0) {
313
+ oai.choices[0].message.tool_calls = p.toolCalls.map((tc) => ({
133
314
  id: tc.toolUseId,
134
315
  type: 'function',
135
316
  function: {
@@ -0,0 +1,20 @@
1
+ export interface UpstreamUnexpectedPayload {
2
+ retryable: true;
3
+ phase: 'stream';
4
+ emittedOutput: boolean;
5
+ code: 'UPSTREAM_UNEXPECTED';
6
+ }
7
+ export declare class SdkEventStreamIterationError extends Error {
8
+ readonly name = "SdkEventStreamIterationError";
9
+ constructor(cause: unknown);
10
+ }
11
+ export declare class UpstreamUnexpectedError extends Error {
12
+ readonly emittedOutput: boolean;
13
+ readonly name = "UpstreamUnexpectedError";
14
+ readonly retryable = true;
15
+ readonly phase = "stream";
16
+ readonly code = "UPSTREAM_UNEXPECTED";
17
+ constructor(cause: unknown, emittedOutput: boolean);
18
+ toPayload(): UpstreamUnexpectedPayload;
19
+ toResponse(): Response;
20
+ }
@@ -0,0 +1,31 @@
1
+ export class SdkEventStreamIterationError extends Error {
2
+ name = 'SdkEventStreamIterationError';
3
+ constructor(cause) {
4
+ super('Kiro SDK event stream iteration failed', { cause });
5
+ }
6
+ }
7
+ export class UpstreamUnexpectedError extends Error {
8
+ emittedOutput;
9
+ name = 'UpstreamUnexpectedError';
10
+ retryable = true;
11
+ phase = 'stream';
12
+ code = 'UPSTREAM_UNEXPECTED';
13
+ constructor(cause, emittedOutput) {
14
+ super('Kiro upstream event stream failed unexpectedly', { cause });
15
+ this.emittedOutput = emittedOutput;
16
+ }
17
+ toPayload() {
18
+ return {
19
+ retryable: true,
20
+ phase: 'stream',
21
+ emittedOutput: this.emittedOutput,
22
+ code: 'UPSTREAM_UNEXPECTED'
23
+ };
24
+ }
25
+ toResponse() {
26
+ return new Response(JSON.stringify(this.toPayload()), {
27
+ status: 503,
28
+ headers: { 'Content-Type': 'application/json' }
29
+ });
30
+ }
31
+ }
@@ -36,7 +36,10 @@ export declare class AccountManager {
36
36
  shouldShowUsageToast(debounce?: number): boolean;
37
37
  getMinWaitTime(): number;
38
38
  allSelectableBlockedByOverage(): boolean;
39
- getCurrentOrNext(): ManagedAccount | null;
39
+ getCurrentOrNext(options?: {
40
+ excludedIds?: ReadonlySet<string>;
41
+ recoverUnhealthy?: boolean;
42
+ }): ManagedAccount | null;
40
43
  updateUsage(id: string, meta: {
41
44
  usedCount: number;
42
45
  limitCount: number;
@@ -121,13 +121,19 @@ export class AccountManager {
121
121
  const hasHealthySelectable = this.accounts.some((a) => a.isHealthy && !rateLimited(a) && !this.isOverageBlocked(a));
122
122
  return hasOverageBlockedEligible && !hasCleanRateLimited && !hasHealthySelectable;
123
123
  }
124
- getCurrentOrNext() {
124
+ getCurrentOrNext(options = {}) {
125
125
  const now = Date.now();
126
+ const excludedIds = options.excludedIds ?? new Set();
127
+ const recoverUnhealthy = options.recoverUnhealthy ?? true;
126
128
  const overageBlocked = (a) => this.isOverageBlocked(a);
127
129
  const available = this.accounts.filter((a) => {
130
+ if (excludedIds.has(a.id))
131
+ return false;
128
132
  if (overageBlocked(a))
129
133
  return false;
130
134
  if (!a.isHealthy) {
135
+ if (!recoverUnhealthy)
136
+ return false;
131
137
  if (isPermanentError(a.unhealthyReason)) {
132
138
  return false;
133
139
  }
@@ -205,9 +211,10 @@ export class AccountManager {
205
211
  })[0];
206
212
  }
207
213
  }
208
- if (!selected) {
214
+ if (!selected && recoverUnhealthy) {
209
215
  const fallback = this.accounts
210
- .filter((a) => !a.isHealthy &&
216
+ .filter((a) => !excludedIds.has(a.id) &&
217
+ !a.isHealthy &&
211
218
  a.failCount < 10 &&
212
219
  !isPermanentError(a.unhealthyReason) &&
213
220
  !overageBlocked(a))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunerpy/opencode-kiro-auth",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude models",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",