@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.
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +31 -0
- package/dist/core/account/account-selector.d.ts +2 -1
- package/dist/core/account/account-selector.js +20 -4
- package/dist/core/account/usage-tracker.d.ts +1 -1
- package/dist/core/account/usage-tracker.js +18 -4
- package/dist/core/request/error-handler.d.ts +3 -2
- package/dist/core/request/error-handler.js +31 -11
- package/dist/core/request/request-handler.d.ts +4 -0
- package/dist/core/request/request-handler.js +206 -93
- package/dist/core/request/response-handler.d.ts +10 -2
- package/dist/core/request/response-handler.js +234 -58
- package/dist/core/request/stream-error.d.ts +20 -0
- package/dist/core/request/stream-error.js +31 -0
- package/dist/plugin/accounts.d.ts +4 -1
- package/dist/plugin/accounts.js +10 -3
- package/dist/plugin/models.d.ts +1 -0
- package/dist/plugin/models.js +20 -2
- package/package.json +1 -1
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
110
|
+
if (!reauthed) {
|
|
111
|
+
throw new Error('All accounts are permanently unhealthy. Please re-authenticate.');
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
94
114
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
169
|
+
this.logSdkRequest(sdkPrep, acc, apiTimestamp);
|
|
147
170
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
|
193
|
+
this.logSdkResponse(sdkPrep, apiTimestamp);
|
|
157
194
|
}
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
222
|
+
else {
|
|
223
|
+
forcedStreamAccount =
|
|
224
|
+
(await this.accountSelector.selectAlternativeAccount(new Set([acc.id]))) ?? acc;
|
|
170
225
|
}
|
|
171
226
|
continue;
|
|
172
227
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
190
|
+
const item = await transformed.next();
|
|
191
|
+
if (item.done) {
|
|
192
|
+
await lifecycle.onComplete?.();
|
|
193
|
+
finish();
|
|
194
|
+
controller.close();
|
|
195
|
+
return;
|
|
40
196
|
}
|
|
41
|
-
|
|
197
|
+
controller.enqueue(encodeSseChunk(item.value));
|
|
42
198
|
}
|
|
43
|
-
catch (
|
|
44
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
61
|
-
finish_reason:
|
|
262
|
+
message,
|
|
263
|
+
finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop'
|
|
62
264
|
}
|
|
63
265
|
],
|
|
64
266
|
usage: {
|
|
65
|
-
prompt_tokens:
|
|
66
|
-
completion_tokens:
|
|
67
|
-
total_tokens:
|
|
267
|
+
prompt_tokens: inputTokens,
|
|
268
|
+
completion_tokens: outputTokens,
|
|
269
|
+
total_tokens: inputTokens + outputTokens
|
|
68
270
|
}
|
|
69
271
|
};
|
|
70
|
-
if (
|
|
71
|
-
oai.choices[0].message.tool_calls =
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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:
|
|
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(
|
|
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;
|
package/dist/plugin/accounts.js
CHANGED
|
@@ -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.
|
|
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/dist/plugin/models.d.ts
CHANGED
|
@@ -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;
|
package/dist/plugin/models.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MODEL_MAPPING, SUPPORTED_MODELS,
|
|
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
|
|
65
|
+
return getModelContextLimit(stripModelSuffix(model));
|
|
48
66
|
}
|