@sunerpy/opencode-kiro-auth 0.13.0 → 0.13.2

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.
@@ -19,6 +19,9 @@ export declare const KIRO_CONSTANTS: {
19
19
  export declare const MODEL_MAPPING: Record<string, string>;
20
20
  export declare const SUPPORTED_MODELS: string[];
21
21
  export declare function isLongContextModel(model: string): boolean;
22
+ export declare const DEFAULT_MODEL_CONTEXT_LIMIT = 200000;
23
+ export declare const MODEL_CONTEXT_LIMITS: Record<string, number>;
24
+ export declare function getModelContextLimit(baseModel: string): number;
22
25
  export declare const KIRO_AUTH_SERVICE: {
23
26
  ENDPOINT: string;
24
27
  SSO_OIDC_ENDPOINT: string;
package/dist/constants.js CHANGED
@@ -98,6 +98,37 @@ const LONG_CONTEXT_MODELS = new Set(Object.keys(MODEL_MAPPING).filter((k) => k.i
98
98
  export function isLongContextModel(model) {
99
99
  return LONG_CONTEXT_MODELS.has(model);
100
100
  }
101
+ // SSOT for token accounting, keyed by BASE model id (effort/thinking suffix
102
+ // stripped). Values MUST equal the limit.context advertised in src/plugin.ts —
103
+ // enforced by context-window.test.ts. Drift makes OpenCode under-count usage and
104
+ // skip auto-compaction until Kiro hard-rejects with 400 "Input is too long."
105
+ export const DEFAULT_MODEL_CONTEXT_LIMIT = 200000;
106
+ export const MODEL_CONTEXT_LIMITS = {
107
+ auto: 200000,
108
+ 'claude-haiku-4-5': 200000,
109
+ 'claude-sonnet-4': 200000,
110
+ 'claude-sonnet-4-5': 200000,
111
+ 'claude-sonnet-4-5-1m': 1000000,
112
+ 'claude-sonnet-4-6': 1000000,
113
+ 'claude-sonnet-4-6-1m': 1000000,
114
+ 'claude-sonnet-5': 1000000,
115
+ 'claude-opus-4-5': 200000,
116
+ 'claude-opus-4-6': 1000000,
117
+ 'claude-opus-4-6-1m': 1000000,
118
+ 'claude-opus-4-7': 1000000,
119
+ 'claude-opus-4-8': 1000000,
120
+ 'deepseek-3.2': 128000,
121
+ 'glm-5': 200000,
122
+ 'minimax-m2.5': 200000,
123
+ 'minimax-m2.1': 200000,
124
+ 'qwen3-coder-next': 256000,
125
+ 'gpt-5.6-sol': 272000,
126
+ 'gpt-5.6-terra': 272000,
127
+ 'gpt-5.6-luna': 272000
128
+ };
129
+ export function getModelContextLimit(baseModel) {
130
+ return MODEL_CONTEXT_LIMITS[baseModel] ?? DEFAULT_MODEL_CONTEXT_LIMIT;
131
+ }
101
132
  export const KIRO_AUTH_SERVICE = {
102
133
  ENDPOINT: 'https://prod.{{region}}.auth.desktop.kiro.dev',
103
134
  SSO_OIDC_ENDPOINT: 'https://oidc.{{region}}.amazonaws.com',
@@ -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;
@@ -3,6 +3,7 @@ import type { AccountManager } from '../../plugin/accounts.js';
3
3
  import type { ManagedAccount } from '../../plugin/types.js';
4
4
  import type { ForceRefreshResult } from '../auth/token-refresher.js';
5
5
  type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void;
6
+ export declare function isKiroContextOverflowBody(text: string): boolean;
6
7
  export interface RequestContext {
7
8
  retry: number;
8
9
  forcedRefreshAccountIds?: Set<string>;
@@ -18,14 +19,14 @@ export declare class ErrorHandler {
18
19
  private repository;
19
20
  private forceRefresh?;
20
21
  constructor(config: ErrorHandlerConfig, accountManager: AccountManager, repository: AccountRepository, forceRefresh?: ForceRefreshFn | undefined);
21
- 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<{
22
23
  shouldRetry: boolean;
23
24
  newContext?: RequestContext;
24
25
  switchAccount?: boolean;
25
26
  }>;
26
27
  private markDeadAndSwitchOrFail;
27
28
  private transientForbidden;
28
- handleNetworkError(error: any, context: RequestContext, showToast: ToastFunction): Promise<{
29
+ handleNetworkError(error: any, context: RequestContext, showToast: ToastFunction, signal?: AbortSignal): Promise<{
29
30
  shouldRetry: boolean;
30
31
  newContext?: RequestContext;
31
32
  }>;
@@ -1,4 +1,11 @@
1
1
  import { isAccessTokenError, toDeadReason } from '../../plugin/health.js';
2
+ // Unambiguous Kiro size-overflow 400 signals only. The generic "Improperly
3
+ // formed request." is deliberately excluded: it has no size discriminator in
4
+ // the body, so matching it would wrongly reclassify unrelated 400s as overflow.
5
+ const KIRO_CONTEXT_OVERFLOW_PATTERNS = [/input is too long/i, /CONTENT_LENGTH_EXCEEDS_THRESHOLD/i];
6
+ export function isKiroContextOverflowBody(text) {
7
+ return KIRO_CONTEXT_OVERFLOW_PATTERNS.some((p) => p.test(text));
8
+ }
2
9
  export class ErrorHandler {
3
10
  config;
4
11
  accountManager;
@@ -10,7 +17,7 @@ export class ErrorHandler {
10
17
  this.repository = repository;
11
18
  this.forceRefresh = forceRefresh;
12
19
  }
13
- async handle(error, response, account, context, showToast) {
20
+ async handle(error, response, account, context, showToast, signal) {
14
21
  const readBody = async () => {
15
22
  try {
16
23
  const body = JSON.parse(await response.clone().text());
@@ -50,7 +57,7 @@ export class ErrorHandler {
50
57
  if (account.failCount < 5) {
51
58
  const delay = 1000 * Math.pow(2, account.failCount - 1);
52
59
  showToast(`500: ${errorMessage}. Retrying in ${Math.ceil(delay / 1000)}s...`, 'warning');
53
- await this.sleep(delay);
60
+ await this.sleep(delay, signal);
54
61
  return { shouldRetry: true };
55
62
  }
56
63
  else {
@@ -69,7 +76,7 @@ export class ErrorHandler {
69
76
  return { shouldRetry: true, switchAccount: true };
70
77
  }
71
78
  showToast(`429: Rate limited. Waiting ${Math.ceil(w / 1000)}s...`, 'warning');
72
- await this.sleep(w);
79
+ await this.sleep(w, signal);
73
80
  return { shouldRetry: true };
74
81
  }
75
82
  if (response.status === 402 || response.status === 403) {
@@ -111,7 +118,7 @@ export class ErrorHandler {
111
118
  if (result.dead) {
112
119
  return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, nextForced, showToast);
113
120
  }
114
- return this.transientForbidden(errorReason, response.status, { ...context, forcedRefreshAccountIds: nextForced }, showToast);
121
+ return this.transientForbidden(errorReason, response.status, { ...context, forcedRefreshAccountIds: nextForced }, showToast, signal);
115
122
  }
116
123
  return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, forced, showToast);
117
124
  }
@@ -127,7 +134,7 @@ export class ErrorHandler {
127
134
  if (response.status === 403 &&
128
135
  !isPermanent &&
129
136
  context.retry < this.config.rate_limit_max_retries) {
130
- return this.transientForbidden(errorReason, response.status, context, showToast);
137
+ return this.transientForbidden(errorReason, response.status, context, showToast, signal);
131
138
  }
132
139
  showToast(`${response.status}: ${errorReason}`, 'error');
133
140
  return { shouldRetry: false };
@@ -151,24 +158,24 @@ export class ErrorHandler {
151
158
  showToast(`${status}: ${errorReason}. Re-login required.`, 'error');
152
159
  return { shouldRetry: false };
153
160
  }
154
- async transientForbidden(errorReason, status, context, showToast) {
161
+ async transientForbidden(errorReason, status, context, showToast, signal) {
155
162
  if (context.retry >= this.config.rate_limit_max_retries) {
156
163
  showToast(`${status}: ${errorReason}`, 'error');
157
164
  return { shouldRetry: false };
158
165
  }
159
166
  const delay = this.config.rate_limit_retry_delay_ms * Math.pow(2, context.retry);
160
167
  showToast(`${status}: ${errorReason}. Retrying in ${Math.ceil(delay / 1000)}s...`, 'warning');
161
- await this.sleep(delay);
168
+ await this.sleep(delay, signal);
162
169
  return {
163
170
  shouldRetry: true,
164
171
  newContext: { ...context, retry: context.retry + 1 }
165
172
  };
166
173
  }
167
- async handleNetworkError(error, context, showToast) {
174
+ async handleNetworkError(error, context, showToast, signal) {
168
175
  if (this.isNetworkError(error) && context.retry < this.config.rate_limit_max_retries) {
169
176
  const d = this.config.rate_limit_retry_delay_ms * Math.pow(2, context.retry);
170
177
  showToast(`Network error. Retrying in ${Math.ceil(d / 1000)}s...`, 'warning');
171
- await this.sleep(d);
178
+ await this.sleep(d, signal);
172
179
  return {
173
180
  shouldRetry: true,
174
181
  newContext: { ...context, retry: context.retry + 1 }
@@ -179,7 +186,20 @@ export class ErrorHandler {
179
186
  isNetworkError(e) {
180
187
  return (e instanceof Error && /econnreset|etimedout|enotfound|network|fetch failed/i.test(e.message));
181
188
  }
182
- sleep(ms) {
183
- 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
+ });
184
204
  }
185
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';
@@ -7,9 +8,10 @@ import { syncFromKiroCli } from '../../plugin/sync/kiro-cli.js';
7
8
  import { AccountSelector } from '../account/account-selector.js';
8
9
  import { UsageTracker } from '../account/usage-tracker.js';
9
10
  import { TokenRefresher } from '../auth/token-refresher.js';
10
- import { ErrorHandler } from './error-handler.js';
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,24 @@ 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
+ const timeout = setDeadlineTimeout(() => requestController.abort(new DOMException('Kiro request timed out', 'TimeoutError')), this.config.request_timeout_ms);
78
+ let requestCleanupDone = false;
79
+ let responseOwnsLifecycle = false;
80
+ const cleanupRequest = () => {
81
+ if (requestCleanupDone)
82
+ return;
83
+ requestCleanupDone = true;
84
+ clearDeadlineTimeout(timeout);
85
+ inboundSignal?.removeEventListener('abort', abortFromInbound);
86
+ };
87
+ const signal = requestController.signal;
66
88
  const body = init?.body ? JSON.parse(init.body) : {};
67
89
  const model = this.extractModel(url) || body.model || 'claude-sonnet-4-5';
68
90
  const think = model.endsWith('-thinking') || !!body.providerOptions?.thinkingConfig || !!body.thinkingConfig;
@@ -72,116 +94,185 @@ export class RequestHandler {
72
94
  20000;
73
95
  let handlerContext = { retry: 0, forcedRefreshAccountIds: new Set() };
74
96
  let consecutiveNullAccounts = 0;
97
+ let streamFailureCount = 0;
98
+ let forcedStreamAccount = null;
75
99
  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.');
100
+ try {
101
+ while (true) {
102
+ if (signal.aborted)
103
+ throw signal.reason;
104
+ const check = this.retryStrategy.shouldContinue(retryContext);
105
+ if (!check.canContinue) {
106
+ throw new Error(check.error);
85
107
  }
86
- continue;
87
- }
88
- let acc = await this.accountSelector.selectHealthyAccount(showToast).catch(async (e) => {
89
- if (e instanceof Error && e.message.includes('reauth required')) {
108
+ if (this.allAccountsPermanentlyUnhealthy()) {
90
109
  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;
110
+ if (!reauthed) {
111
+ throw new Error('All accounts are permanently unhealthy. Please re-authenticate.');
112
+ }
113
+ continue;
94
114
  }
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
115
+ let acc = forcedStreamAccount;
116
+ forcedStreamAccount = null;
117
+ if (!acc) {
118
+ acc = await this.accountSelector
119
+ .selectHealthyAccount(showToast, signal)
120
+ .catch(async (e) => {
121
+ if (e instanceof Error && e.message.includes('reauth required')) {
122
+ const reauthed = await this.triggerReauth(showToast);
123
+ if (!reauthed)
124
+ throw new Error('All accounts are unhealthy or rate-limited. Please re-authenticate.');
125
+ return null;
129
126
  }
127
+ throw e;
130
128
  });
131
129
  }
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);
130
+ if (!acc) {
131
+ consecutiveNullAccounts++;
132
+ const backoffDelay = Math.min(1000 * Math.pow(2, consecutiveNullAccounts - 1), 10000);
133
+ await this.sleep(backoffDelay, signal);
134
+ continue;
135
+ }
136
+ consecutiveNullAccounts = 0;
137
+ const auth = this.accountManager.toAuthDetails(acc);
138
+ const tokenResult = await this.tokenRefresher.refreshIfNeeded(acc, auth, showToast);
139
+ if (tokenResult.shouldContinue) {
140
+ acc = tokenResult.account;
141
+ await this.sleep(500, signal);
142
+ continue;
143
+ }
144
+ const sdkPrep = this.prepareSdkRequest(init?.body, model, auth, think, budget, showToast);
145
+ if (this.config.enable_log_effort_debug) {
146
+ try {
147
+ logger.log('[effort-debug] request effort resolution', {
148
+ model,
149
+ effectiveModel: sdkPrep.effectiveModel,
150
+ think,
151
+ budget,
152
+ resolvedEffort: sdkPrep.effort ?? 'undefined (not effort-capable or not thinking)',
153
+ inboundBodyKeys: Object.keys(body),
154
+ messagesCount: body.messages?.length,
155
+ reasoningSubtree: {
156
+ reasoningEffort: body.reasoningEffort,
157
+ reasoning_effort: body.reasoning_effort,
158
+ reasoning: body.reasoning,
159
+ providerOptions: body.providerOptions,
160
+ thinkingConfig: body.thinkingConfig,
161
+ providerOptionsThinkingConfig: body.providerOptions?.thinkingConfig
162
+ }
163
+ });
164
+ }
165
+ catch { }
166
+ }
167
+ const apiTimestamp = this.config.enable_log_api_request ? logger.getTimestamp() : null;
145
168
  if (apiTimestamp) {
146
- this.logSdkResponse(sdkPrep, apiTimestamp);
169
+ this.logSdkRequest(sdkPrep, acc, apiTimestamp);
147
170
  }
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) {
171
+ let sendResolved = false;
172
+ try {
173
+ const client = this.makeSdkClient(auth, sdkPrep.region, sdkPrep.effort);
174
+ const command = new GenerateAssistantResponseCommand({
175
+ conversationState: sdkPrep.conversationState,
176
+ profileArn: sdkPrep.profileArn
177
+ });
178
+ const attemptEpoch = this.nextAccountAttemptEpoch(acc.id);
179
+ const isCurrentAttempt = () => this.accountAttemptEpochs.get(acc.id) === attemptEpoch;
180
+ let completionDone = false;
181
+ const completeRequest = async () => {
182
+ if (completionDone)
183
+ return;
184
+ completionDone = true;
185
+ if (!isCurrentAttempt())
186
+ return;
187
+ this.handleSuccessfulRequest(acc);
188
+ await this.usageTracker.syncUsage(acc, auth, isCurrentAttempt);
189
+ };
190
+ const sdkResponse = await client.send(command, { abortSignal: signal });
191
+ sendResolved = true;
155
192
  if (apiTimestamp) {
156
- this.logSdkError(sdkPrep, e, acc, apiTimestamp);
193
+ this.logSdkResponse(sdkPrep, apiTimestamp);
157
194
  }
158
- const mockResponse = new Response(JSON.stringify({ message: e.message, __type: e.name }), {
159
- status: httpStatus,
160
- statusText: e.name || 'Error',
161
- headers: { 'Content-Type': 'application/json' }
195
+ const response = await this.responseHandler.handleSdkSuccess(sdkResponse, model, sdkPrep.conversationId, sdkPrep.streaming, {
196
+ signal,
197
+ onComplete: completeRequest,
198
+ onTerminal: cleanupRequest,
199
+ onCancel: (reason) => requestController.abort(reason),
200
+ mapError: (error) => new UpstreamUnexpectedError(error, true)
162
201
  });
163
- const errorResult = await this.errorHandler.handle(e, mockResponse, acc, handlerContext, showToast);
164
- if (errorResult.shouldRetry) {
165
- if (errorResult.newContext) {
166
- handlerContext = errorResult.newContext;
202
+ if (sdkPrep.streaming) {
203
+ responseOwnsLifecycle = true;
204
+ }
205
+ else {
206
+ await completeRequest();
207
+ }
208
+ return response;
209
+ }
210
+ catch (e) {
211
+ if (signal.aborted)
212
+ throw signal.reason;
213
+ if (e instanceof SdkEventStreamIterationError) {
214
+ streamFailureCount++;
215
+ const streamError = new UpstreamUnexpectedError(e, false);
216
+ if (streamFailureCount >= 3)
217
+ return streamError.toResponse();
218
+ await this.sleep(this.getStreamRetryDelay(streamFailureCount), signal);
219
+ if (streamFailureCount === 1) {
220
+ forcedStreamAccount = acc;
167
221
  }
168
- if (errorResult.switchAccount) {
169
- continue;
222
+ else {
223
+ forcedStreamAccount =
224
+ (await this.accountSelector.selectAlternativeAccount(new Set([acc.id]))) ?? acc;
170
225
  }
171
226
  continue;
172
227
  }
173
- throw new Error(`Kiro Error: ${httpStatus}`);
174
- }
175
- const networkResult = await this.errorHandler.handleNetworkError(e, handlerContext, showToast);
176
- if (networkResult.shouldRetry) {
177
- if (networkResult.newContext) {
178
- handlerContext = { ...handlerContext, ...networkResult.newContext };
228
+ if (sendResolved)
229
+ throw e;
230
+ const httpStatus = e?.$metadata?.httpStatusCode;
231
+ if (httpStatus) {
232
+ if (apiTimestamp) {
233
+ this.logSdkError(sdkPrep, e, acc, apiTimestamp);
234
+ }
235
+ const errorBody = JSON.stringify({ message: e.message, __type: e.name });
236
+ const errorStatusText = e.name || 'Error';
237
+ const jsonHeaders = { 'Content-Type': 'application/json' };
238
+ const errorResult = await this.errorHandler.handle(e, new Response(errorBody, {
239
+ status: httpStatus,
240
+ statusText: errorStatusText,
241
+ headers: jsonHeaders
242
+ }), acc, handlerContext, showToast, signal);
243
+ if (errorResult.shouldRetry) {
244
+ if (errorResult.newContext) {
245
+ handlerContext = errorResult.newContext;
246
+ }
247
+ continue;
248
+ }
249
+ // Terminal, non-retryable HTTP error. Return a fresh Response carrying
250
+ // the real Kiro body so @ai-sdk/openai-compatible produces an
251
+ // APICallError with status+body (not a bare Error that OpenCode
252
+ // degrades to UnknownError). Remap size-overflow 400s to 413 so
253
+ // OpenCode classifies context_overflow and auto-compacts.
254
+ const terminalStatus = httpStatus === 400 && isKiroContextOverflowBody(e.message ?? '') ? 413 : httpStatus;
255
+ return new Response(errorBody, {
256
+ status: terminalStatus,
257
+ statusText: errorStatusText,
258
+ headers: jsonHeaders
259
+ });
179
260
  }
180
- continue;
261
+ const networkResult = await this.errorHandler.handleNetworkError(e, handlerContext, showToast, signal);
262
+ if (networkResult.shouldRetry) {
263
+ if (networkResult.newContext) {
264
+ handlerContext = { ...handlerContext, ...networkResult.newContext };
265
+ }
266
+ continue;
267
+ }
268
+ throw e;
181
269
  }
182
- throw e;
183
270
  }
184
271
  }
272
+ finally {
273
+ if (!responseOwnsLifecycle)
274
+ cleanupRequest();
275
+ }
185
276
  }
186
277
  extractModel(url) {
187
278
  return url.match(/models\/([^/:]+)/)?.[1] || null;
@@ -323,7 +414,29 @@ export class RequestHandler {
323
414
  }
324
415
  return accounts.every((acc) => !acc.isHealthy && isPermanentError(acc.unhealthyReason));
325
416
  }
326
- sleep(ms) {
327
- return new Promise((resolve) => setTimeout(resolve, ms));
417
+ nextAccountAttemptEpoch(accountId) {
418
+ const next = (this.accountAttemptEpochs.get(accountId) ?? 0) + 1;
419
+ this.accountAttemptEpochs.set(accountId, next);
420
+ return next;
421
+ }
422
+ getStreamRetryDelay(failureCount) {
423
+ const base = 250 * Math.pow(2, failureCount - 1);
424
+ return base + base * 0.25 * this.streamRetryRandom();
425
+ }
426
+ sleep(ms, signal) {
427
+ if (signal?.aborted)
428
+ return Promise.reject(signal.reason);
429
+ let onAbort;
430
+ return new Promise((resolve, reject) => {
431
+ const timer = setTimeout(resolve, ms);
432
+ onAbort = () => {
433
+ clearTimeout(timer);
434
+ reject(signal?.reason);
435
+ };
436
+ signal?.addEventListener('abort', onAbort, { once: true });
437
+ }).finally(() => {
438
+ if (onAbort)
439
+ signal?.removeEventListener('abort', onAbort);
440
+ });
328
441
  }
329
442
  }
@@ -1,8 +1,16 @@
1
+ import { SdkEventStreamIterationError } from './stream-error.js';
2
+ export interface SdkResponseLifecycle {
3
+ signal?: AbortSignal;
4
+ onComplete?: () => void | Promise<void>;
5
+ onTerminal?: () => void;
6
+ onCancel?: (reason: unknown) => void;
7
+ mapError?: (error: SdkEventStreamIterationError, emittedOutput: true) => unknown;
8
+ }
1
9
  export declare class ResponseHandler {
2
10
  handleSuccess(response: Response, model: string, conversationId: string, streaming: boolean): Promise<Response>;
3
- handleSdkSuccess(sdkResponse: any, model: string, conversationId: string, streaming: boolean): Promise<Response>;
11
+ handleSdkSuccess(sdkResponse: any, model: string, conversationId: string, streaming: boolean, lifecycle?: SdkResponseLifecycle): Promise<Response>;
4
12
  private handleStreaming;
5
13
  private handleSdkStreaming;
6
- private handleNonStreaming;
7
14
  private handleSdkNonStreaming;
15
+ private handleNonStreaming;
8
16
  }
@@ -1,6 +1,88 @@
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) {
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
+ if (!signal)
29
+ return rawIterator.next();
30
+ if (signal.aborted) {
31
+ await closeRaw();
32
+ throw abortReason(signal);
33
+ }
34
+ return new Promise((resolve, reject) => {
35
+ let settled = false;
36
+ const settle = (callback) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ signal.removeEventListener('abort', onAbort);
41
+ callback();
42
+ };
43
+ const onAbort = () => {
44
+ void closeRaw();
45
+ settle(() => reject(abortReason(signal)));
46
+ };
47
+ signal.addEventListener('abort', onAbort, { once: true });
48
+ Promise.resolve(rawIterator.next()).then((result) => settle(() => resolve(result)), (error) => settle(() => reject(error)));
49
+ });
50
+ };
51
+ const wrappedIterator = {
52
+ async next() {
53
+ try {
54
+ return await nextRaw();
55
+ }
56
+ catch (error) {
57
+ if (signal?.aborted)
58
+ throw abortReason(signal);
59
+ throw new SdkEventStreamIterationError(error);
60
+ }
61
+ },
62
+ async return() {
63
+ await closeRaw();
64
+ return { done: true, value: undefined };
65
+ }
66
+ };
67
+ const wrappedStream = {
68
+ [Symbol.asyncIterator]() {
69
+ return wrappedIterator;
70
+ }
71
+ };
72
+ return {
73
+ response: { ...sdkResponse, generateAssistantResponseResponse: wrappedStream },
74
+ closeRaw
75
+ };
76
+ }
77
+ function isSemanticChunk(chunk) {
78
+ const delta = chunk?.choices?.[0]?.delta;
79
+ return (delta?.content !== undefined ||
80
+ delta?.reasoning_content !== undefined ||
81
+ delta?.tool_calls !== undefined);
82
+ }
83
+ function encodeSseChunk(chunk) {
84
+ return new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`);
85
+ }
4
86
  export class ResponseHandler {
5
87
  async handleSuccess(response, model, conversationId, streaming) {
6
88
  if (streaming) {
@@ -8,11 +90,11 @@ export class ResponseHandler {
8
90
  }
9
91
  return this.handleNonStreaming(response, model, conversationId);
10
92
  }
11
- async handleSdkSuccess(sdkResponse, model, conversationId, streaming) {
93
+ async handleSdkSuccess(sdkResponse, model, conversationId, streaming, lifecycle = {}) {
12
94
  if (streaming) {
13
- return this.handleSdkStreaming(sdkResponse, model, conversationId);
95
+ return this.handleSdkStreaming(sdkResponse, model, conversationId, lifecycle);
14
96
  }
15
- return this.handleSdkNonStreaming(sdkResponse, model, conversationId);
97
+ return this.handleSdkNonStreaming(sdkResponse, model, conversationId, lifecycle.signal);
16
98
  }
17
99
  async handleStreaming(response, model, conversationId) {
18
100
  const s = transformKiroStream(response, model, conversationId);
@@ -30,25 +112,145 @@ export class ResponseHandler {
30
112
  }
31
113
  }), { headers: { 'Content-Type': 'text/event-stream' } });
32
114
  }
33
- async handleSdkStreaming(sdkResponse, model, conversationId) {
34
- const s = transformSdkStream(sdkResponse, model, conversationId);
115
+ async handleSdkStreaming(sdkResponse, model, conversationId, lifecycle) {
116
+ const wrapped = wrapSdkEventStream(sdkResponse, lifecycle.signal);
117
+ const transformed = transformSdkStream(wrapped.response, model, conversationId);
118
+ const buffered = [];
119
+ let firstSemantic;
120
+ while (true) {
121
+ const item = await transformed.next();
122
+ if (item.done) {
123
+ await lifecycle.onComplete?.();
124
+ lifecycle.onTerminal?.();
125
+ let index = 0;
126
+ return new Response(new ReadableStream({
127
+ pull(controller) {
128
+ const chunk = buffered[index++];
129
+ if (chunk)
130
+ controller.enqueue(chunk);
131
+ if (index >= buffered.length)
132
+ controller.close();
133
+ }
134
+ }, { highWaterMark: 0 }), { headers: { 'Content-Type': 'text/event-stream' } });
135
+ }
136
+ const encoded = encodeSseChunk(item.value);
137
+ if (isSemanticChunk(item.value)) {
138
+ firstSemantic = encoded;
139
+ break;
140
+ }
141
+ buffered.push(encoded);
142
+ }
143
+ let terminal = false;
144
+ let firstPull = true;
145
+ let abortListener;
146
+ const finish = () => {
147
+ if (terminal)
148
+ return;
149
+ terminal = true;
150
+ if (abortListener && lifecycle.signal) {
151
+ lifecycle.signal.removeEventListener('abort', abortListener);
152
+ }
153
+ lifecycle.onTerminal?.();
154
+ };
155
+ const cleanupIterators = async () => {
156
+ try {
157
+ await transformed.return(undefined);
158
+ }
159
+ catch { }
160
+ await wrapped.closeRaw();
161
+ };
35
162
  return new Response(new ReadableStream({
36
- async start(c) {
163
+ start(controller) {
164
+ if (!lifecycle.signal)
165
+ return;
166
+ abortListener = () => {
167
+ if (terminal)
168
+ return;
169
+ const reason = abortReason(lifecycle.signal);
170
+ void cleanupIterators();
171
+ finish();
172
+ controller.error(reason);
173
+ };
174
+ if (lifecycle.signal.aborted)
175
+ abortListener();
176
+ else
177
+ lifecycle.signal.addEventListener('abort', abortListener, { once: true });
178
+ },
179
+ async pull(controller) {
180
+ if (terminal)
181
+ return;
182
+ if (firstPull) {
183
+ firstPull = false;
184
+ for (const chunk of buffered)
185
+ controller.enqueue(chunk);
186
+ controller.enqueue(firstSemantic);
187
+ return;
188
+ }
37
189
  try {
38
- for await (const e of s) {
39
- c.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(e)}\n\n`));
190
+ const item = await transformed.next();
191
+ if (item.done) {
192
+ await lifecycle.onComplete?.();
193
+ finish();
194
+ controller.close();
195
+ return;
40
196
  }
41
- c.close();
197
+ controller.enqueue(encodeSseChunk(item.value));
42
198
  }
43
- catch (err) {
44
- c.error(err);
199
+ catch (error) {
200
+ if (terminal)
201
+ return;
202
+ const mapped = error instanceof SdkEventStreamIterationError
203
+ ? (lifecycle.mapError?.(error, true) ?? error)
204
+ : error;
205
+ finish();
206
+ controller.error(mapped);
45
207
  }
208
+ },
209
+ async cancel(reason) {
210
+ if (terminal)
211
+ return;
212
+ finish();
213
+ lifecycle.onCancel?.(reason);
214
+ await cleanupIterators();
46
215
  }
47
- }), { headers: { 'Content-Type': 'text/event-stream' } });
216
+ }, { highWaterMark: 0 }), { headers: { 'Content-Type': 'text/event-stream' } });
48
217
  }
49
- async handleNonStreaming(response, model, conversationId) {
50
- const text = await response.text();
51
- const p = parseEventStream(text, model);
218
+ async handleSdkNonStreaming(sdkResponse, model, conversationId, signal) {
219
+ // For non-streaming SDK responses, collect all events
220
+ let content = '';
221
+ let reasoningContent = '';
222
+ const toolCalls = [];
223
+ let inputTokens = 0;
224
+ let outputTokens = 0;
225
+ const wrapped = wrapSdkEventStream(sdkResponse, signal);
226
+ const eventStream = wrapped.response.generateAssistantResponseResponse;
227
+ if (eventStream) {
228
+ try {
229
+ for await (const event of eventStream) {
230
+ if (event.reasoningContentEvent?.text) {
231
+ reasoningContent += event.reasoningContentEvent.text;
232
+ }
233
+ if (event.assistantResponseEvent?.content) {
234
+ content += event.assistantResponseEvent.content;
235
+ }
236
+ if (event.toolUseEvent) {
237
+ toolCalls.push(event.toolUseEvent);
238
+ }
239
+ if (event.metadataEvent?.tokenUsage) {
240
+ inputTokens = event.metadataEvent.tokenUsage.inputTokens || 0;
241
+ outputTokens = event.metadataEvent.tokenUsage.outputTokens || 0;
242
+ }
243
+ }
244
+ }
245
+ finally {
246
+ if (signal?.aborted)
247
+ await wrapped.closeRaw();
248
+ }
249
+ }
250
+ const message = { role: 'assistant', content };
251
+ if (reasoningContent) {
252
+ message.reasoning_content = reasoningContent;
253
+ }
52
254
  const oai = {
53
255
  id: conversationId,
54
256
  object: 'chat.completion',
@@ -57,18 +259,18 @@ export class ResponseHandler {
57
259
  choices: [
58
260
  {
59
261
  index: 0,
60
- message: { role: 'assistant', content: p.content },
61
- finish_reason: p.stopReason === 'tool_use' ? 'tool_calls' : 'stop'
262
+ message,
263
+ finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop'
62
264
  }
63
265
  ],
64
266
  usage: {
65
- prompt_tokens: p.inputTokens || 0,
66
- completion_tokens: p.outputTokens || 0,
67
- total_tokens: (p.inputTokens || 0) + (p.outputTokens || 0)
267
+ prompt_tokens: inputTokens,
268
+ completion_tokens: outputTokens,
269
+ total_tokens: inputTokens + outputTokens
68
270
  }
69
271
  };
70
- if (p.toolCalls.length > 0) {
71
- oai.choices[0].message.tool_calls = p.toolCalls.map((tc) => ({
272
+ if (toolCalls.length > 0) {
273
+ oai.choices[0].message.tool_calls = toolCalls.map((tc) => ({
72
274
  id: tc.toolUseId,
73
275
  type: 'function',
74
276
  function: {
@@ -81,35 +283,9 @@ export class ResponseHandler {
81
283
  headers: { 'Content-Type': 'application/json' }
82
284
  });
83
285
  }
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
- }
286
+ async handleNonStreaming(response, model, conversationId) {
287
+ const text = await response.text();
288
+ const p = parseEventStream(text, model);
113
289
  const oai = {
114
290
  id: conversationId,
115
291
  object: 'chat.completion',
@@ -118,18 +294,18 @@ export class ResponseHandler {
118
294
  choices: [
119
295
  {
120
296
  index: 0,
121
- message,
122
- finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop'
297
+ message: { role: 'assistant', content: p.content },
298
+ finish_reason: p.stopReason === 'tool_use' ? 'tool_calls' : 'stop'
123
299
  }
124
300
  ],
125
301
  usage: {
126
- prompt_tokens: inputTokens,
127
- completion_tokens: outputTokens,
128
- total_tokens: inputTokens + outputTokens
302
+ prompt_tokens: p.inputTokens || 0,
303
+ completion_tokens: p.outputTokens || 0,
304
+ total_tokens: (p.inputTokens || 0) + (p.outputTokens || 0)
129
305
  }
130
306
  };
131
- if (toolCalls.length > 0) {
132
- oai.choices[0].message.tool_calls = toolCalls.map((tc) => ({
307
+ if (p.toolCalls.length > 0) {
308
+ oai.choices[0].message.tool_calls = p.toolCalls.map((tc) => ({
133
309
  id: tc.toolUseId,
134
310
  type: 'function',
135
311
  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))
@@ -20,4 +20,5 @@ export interface ResolvedModelVariant {
20
20
  * with `effort` left undefined.
21
21
  */
22
22
  export declare function resolveModelVariant(model: string): ResolvedModelVariant;
23
+ export declare function stripModelSuffix(model: string): string;
23
24
  export declare function getContextWindowSize(model: string): number;
@@ -1,4 +1,4 @@
1
- import { MODEL_MAPPING, SUPPORTED_MODELS, isLongContextModel } from '../constants.js';
1
+ import { MODEL_MAPPING, SUPPORTED_MODELS, getModelContextLimit } from '../constants.js';
2
2
  export function resolveKiroModel(model) {
3
3
  const resolved = MODEL_MAPPING[model];
4
4
  if (!resolved) {
@@ -43,6 +43,24 @@ export function resolveModelVariant(model) {
43
43
  }
44
44
  return { wireId: resolveKiroModel(model), effort: undefined };
45
45
  }
46
+ export function stripModelSuffix(model) {
47
+ for (const suffix of EFFORT_SUFFIXES) {
48
+ const marker = `-${suffix}`;
49
+ if (model.endsWith(marker)) {
50
+ const base = model.slice(0, -marker.length);
51
+ if (VARIANT_BASE_ALLOWLIST.has(base)) {
52
+ return base;
53
+ }
54
+ }
55
+ }
56
+ if (model.endsWith('-thinking')) {
57
+ const base = model.slice(0, -'-thinking'.length);
58
+ if (MODEL_MAPPING[base]) {
59
+ return base;
60
+ }
61
+ }
62
+ return model;
63
+ }
46
64
  export function getContextWindowSize(model) {
47
- return isLongContextModel(model) ? 1000000 : 200000;
65
+ return getModelContextLimit(stripModelSuffix(model));
48
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunerpy/opencode-kiro-auth",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude models",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",