@hilbras/omnihilbras 0.10.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/cline.js +1 -1
- package/dist/adapters/cline.js.map +1 -1
- package/dist/adapters/kiro.d.ts +235 -0
- package/dist/adapters/kiro.d.ts.map +1 -0
- package/dist/adapters/kiro.js +703 -0
- package/dist/adapters/kiro.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/transport.d.ts +8 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +38 -1
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { ProviderError } from '../errors.js';
|
|
2
|
+
import { FetchHttpTransport } from '../transport.js';
|
|
3
|
+
/**
|
|
4
|
+
* Kiro.
|
|
5
|
+
*
|
|
6
|
+
* Kiro signs in through AWS SSO's OIDC device flow rather than a provider-specific one,
|
|
7
|
+
* and once signed in it is not an OpenAI-compatible endpoint at all: it is CodeWhisperer's
|
|
8
|
+
* streaming service, which takes a `conversationState` envelope and answers with an AWS
|
|
9
|
+
* eventstream. So there is no generic adapter to point at this, and the translation below
|
|
10
|
+
* is the whole integration.
|
|
11
|
+
*
|
|
12
|
+
* The auth half is a public OAuth client registered on demand, so nothing secret is
|
|
13
|
+
* embedded here — the same shape as the OpenCode Console device flow.
|
|
14
|
+
*/
|
|
15
|
+
export const KIRO = {
|
|
16
|
+
/** AWS SSO OIDC, where the device flow lives. */
|
|
17
|
+
oidc: 'https://oidc.us-east-1.amazonaws.com',
|
|
18
|
+
clientName: 'kiro-oauth-client',
|
|
19
|
+
clientType: 'public',
|
|
20
|
+
scopes: ['codewhisperer:completions', 'codewhisperer:analysis', 'codewhisperer:conversations'],
|
|
21
|
+
grantTypes: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'],
|
|
22
|
+
issuerUrl: 'https://identitycenter.amazonaws.com/ssoins-722374e8c3c8e6c',
|
|
23
|
+
/** Where the user approves the device. */
|
|
24
|
+
startUrl: 'https://view.awsapps.com/start',
|
|
25
|
+
/** CodeWhisperer's streaming service, which is what actually answers. */
|
|
26
|
+
inferenceUrl: 'https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse',
|
|
27
|
+
streamingTarget: 'AmazonCodeWhispererStreamingService.GenerateAssistantResponse',
|
|
28
|
+
userAgent: 'AWS-SDK-JS/3.0.0 kiro-ide/1.0.0',
|
|
29
|
+
amzUserAgent: 'aws-sdk-js/3.0.0 kiro-ide/1.0.0',
|
|
30
|
+
eventStreamAccept: 'application/vnd.amazon.eventstream',
|
|
31
|
+
};
|
|
32
|
+
export const kiroProviderId = 'kiro';
|
|
33
|
+
/**
|
|
34
|
+
* Kiro's catalog. Ids must match its upstream exactly — an unknown id is refused with
|
|
35
|
+
* `400 Invalid model. Please select a different model`, and there is no wildcard or
|
|
36
|
+
* `auto` id to fall back on.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* The catalog.
|
|
40
|
+
*
|
|
41
|
+
* Kiro publishes no model list over the API, so this is a published set rather than a
|
|
42
|
+
* discovered one, and availability is per account: Kiro answers
|
|
43
|
+
* `400 Invalid model. Please select a different model` for an id the signed-in plan does
|
|
44
|
+
* not carry. That is the account's entitlement, not a broken connection, so it is surfaced
|
|
45
|
+
* as itself and a model id is still the only thing that can be sent.
|
|
46
|
+
*/
|
|
47
|
+
export const KIRO_MODELS = [
|
|
48
|
+
{ id: 'claude-sonnet-5', name: 'Claude Sonnet 5', contextWindow: 1_000_000, maxOutputTokens: 128_000 },
|
|
49
|
+
{ id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', contextWindow: 200_000, maxOutputTokens: 64_000 },
|
|
50
|
+
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', contextWindow: 200_000, maxOutputTokens: 64_000 },
|
|
51
|
+
{ id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', contextWindow: 272_000, maxOutputTokens: 128_000 },
|
|
52
|
+
{ id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra', contextWindow: 272_000, maxOutputTokens: 128_000 },
|
|
53
|
+
{ id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna', contextWindow: 272_000, maxOutputTokens: 128_000 },
|
|
54
|
+
{ id: 'deepseek-3.2', name: 'DeepSeek V3.2' },
|
|
55
|
+
{ id: 'minimax-m2.5', name: 'MiniMax M2.5' },
|
|
56
|
+
{ id: 'minimax-m2.1', name: 'MiniMax M2.1' },
|
|
57
|
+
{ id: 'glm-5', name: 'GLM-5' },
|
|
58
|
+
{ id: 'qwen3-coder-next', name: 'Qwen3 Coder Next' },
|
|
59
|
+
];
|
|
60
|
+
/**
|
|
61
|
+
* OAuth token endpoints are read directly rather than through the provider transport.
|
|
62
|
+
*
|
|
63
|
+
* The transport deliberately flattens an error body into a human sentence, and for a
|
|
64
|
+
* device grant the machine token *is* the state: AWS answers a pending poll with
|
|
65
|
+
* `400 {"error":"authorization_pending","error_description":"Authorization is still
|
|
66
|
+
* pending"}`, and the flattened detail keeps only the description, so the grant reads as
|
|
67
|
+
* a failure. Inference still goes through the transport, where that flattening is what we
|
|
68
|
+
* want.
|
|
69
|
+
*/
|
|
70
|
+
async function postAuthJson(url, body) {
|
|
71
|
+
const response = await fetch(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
const text = await response.text();
|
|
77
|
+
let data;
|
|
78
|
+
try {
|
|
79
|
+
data = (text ? JSON.parse(text) : {});
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
data = { error: 'invalid_response', error_description: text.slice(0, 200) };
|
|
83
|
+
}
|
|
84
|
+
return { status: response.status, data };
|
|
85
|
+
}
|
|
86
|
+
/** Registers the public client the device flow needs. Nothing secret is embedded. */
|
|
87
|
+
export async function registerKiroClient() {
|
|
88
|
+
const { data } = await postAuthJson(`${KIRO.oidc}/client/register`, {
|
|
89
|
+
clientName: KIRO.clientName,
|
|
90
|
+
clientType: KIRO.clientType,
|
|
91
|
+
scopes: [...KIRO.scopes],
|
|
92
|
+
grantTypes: [...KIRO.grantTypes],
|
|
93
|
+
issuerUrl: KIRO.issuerUrl,
|
|
94
|
+
});
|
|
95
|
+
const clientId = typeof data.clientId === 'string' ? data.clientId : '';
|
|
96
|
+
const clientSecret = typeof data.clientSecret === 'string' ? data.clientSecret : '';
|
|
97
|
+
if (!clientId || !clientSecret) {
|
|
98
|
+
throw new ProviderError('AUTHENTICATION_FAILED', 'AWS did not return a client registration for Kiro.', {
|
|
99
|
+
providerId: kiroProviderId,
|
|
100
|
+
publicMessage: 'AWS did not return a client registration for Kiro. Try again in a moment.',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { clientId, clientSecret };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A company IAM Identity Center start URL, as an enterprise sign-in supplies it.
|
|
107
|
+
*
|
|
108
|
+
* Only `*.awsapps.com` is accepted. That is not a general URL check for its own sake:
|
|
109
|
+
* this value is sent to AWS as the `startUrl` of a device grant, so an unvalidated value
|
|
110
|
+
* would turn the sign-in dialog into an open redirect carrying a user's approval.
|
|
111
|
+
*/
|
|
112
|
+
export function isKiroStartUrl(value) {
|
|
113
|
+
let url;
|
|
114
|
+
try {
|
|
115
|
+
url = new URL(value);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (url.protocol !== 'https:')
|
|
121
|
+
return false;
|
|
122
|
+
if (url.username || url.password)
|
|
123
|
+
return false;
|
|
124
|
+
const host = url.hostname.toLowerCase();
|
|
125
|
+
return host === 'awsapps.com' || host.endsWith('.awsapps.com');
|
|
126
|
+
}
|
|
127
|
+
/** Asks AWS for a device code the user approves in a browser. */
|
|
128
|
+
export async function beginKiroSignIn(startUrl = KIRO.startUrl) {
|
|
129
|
+
if (!isKiroStartUrl(startUrl)) {
|
|
130
|
+
throw new ProviderError('INVALID_REQUEST', 'That start URL is not an AWS IAM Identity Center URL.', {
|
|
131
|
+
providerId: kiroProviderId,
|
|
132
|
+
publicMessage: 'That start URL is not an AWS IAM Identity Center URL. It should look like https://your-org.awsapps.com/start.',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const { clientId, clientSecret } = await registerKiroClient();
|
|
136
|
+
// AWS refuses the request without the start URL, answering
|
|
137
|
+
// `400 Start URL is required`, so it is part of the grant rather than a preference.
|
|
138
|
+
const { status, data } = await postAuthJson(`${KIRO.oidc}/device_authorization`, { clientId, clientSecret, startUrl });
|
|
139
|
+
const deviceCode = typeof data.deviceCode === 'string' ? data.deviceCode : '';
|
|
140
|
+
const userCode = typeof data.userCode === 'string' ? data.userCode : '';
|
|
141
|
+
if (!deviceCode || !userCode) {
|
|
142
|
+
/**
|
|
143
|
+
* AWS's own reason is carried through, because "it did not work" is useless to
|
|
144
|
+
* somebody who just typed their company's start URL. A start URL on a real
|
|
145
|
+
* `awsapps.com` host that AWS rejects is almost always a typo in the organisation
|
|
146
|
+
* name, and AWS says so: `Invalid start url provided`.
|
|
147
|
+
*/
|
|
148
|
+
const detail = typeof data.error_description === 'string' ? data.error_description : '';
|
|
149
|
+
const saysInvalidStartUrl = typeof data.error === 'string' && data.error === 'invalid_request';
|
|
150
|
+
throw new ProviderError('AUTHENTICATION_FAILED', detail || `AWS did not return a Kiro device code (HTTP ${status}).`, {
|
|
151
|
+
providerId: kiroProviderId,
|
|
152
|
+
publicMessage: saysInvalidStartUrl
|
|
153
|
+
? 'AWS does not recognise that start URL. Check the organisation name in it, and that it ends in /start.'
|
|
154
|
+
: detail || 'AWS did not return a Kiro device code. Try again in a moment.',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const verificationUri = typeof data.verificationUri === 'string' ? data.verificationUri : startUrl;
|
|
158
|
+
return {
|
|
159
|
+
deviceCode,
|
|
160
|
+
userCode,
|
|
161
|
+
/**
|
|
162
|
+
* The approval page a company start URL leads to is on that company's own domain, not
|
|
163
|
+
* on `view.awsapps.com`. AWS returns an absolute URI in that case and it is used as
|
|
164
|
+
* given; only a relative one is joined, and to the start URL that produced it.
|
|
165
|
+
*/
|
|
166
|
+
verificationUrl: verificationUri.startsWith('http') ? verificationUri : joinVerificationUrl(startUrl, verificationUri),
|
|
167
|
+
intervalSeconds: typeof data.interval === 'number' ? data.interval : 5,
|
|
168
|
+
clientId,
|
|
169
|
+
clientSecret,
|
|
170
|
+
...(typeof data.expiresIn === 'number' ? { expiresIn: data.expiresIn } : {}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function joinVerificationUrl(startUrl, path) {
|
|
174
|
+
try {
|
|
175
|
+
return new URL(path, startUrl).toString();
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return startUrl;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/* ------------------------------------------------------------------ *
|
|
182
|
+
* Social sign-in
|
|
183
|
+
* ------------------------------------------------------------------ */
|
|
184
|
+
/**
|
|
185
|
+
* The social flow (Google, GitHub) is a different service from the AWS one: Kiro's own
|
|
186
|
+
* auth host, not `oidc.*.amazonaws.com`.
|
|
187
|
+
*/
|
|
188
|
+
export const KIRO_SOCIAL = {
|
|
189
|
+
authHost: 'https://prod.us-east-1.auth.desktop.kiro.dev',
|
|
190
|
+
/**
|
|
191
|
+
* The callback is a custom scheme, not a web URL, and it cannot be changed: Kiro's
|
|
192
|
+
* identity provider only has this one redirect registered. A browser will therefore not
|
|
193
|
+
* return to OmniHilbras — it hands the code to the Kiro desktop app — so the code has to
|
|
194
|
+
* be pasted back in. That is a property of the provider, not a gap in this flow, and the
|
|
195
|
+
* dialog says so rather than opening a tab that silently fails.
|
|
196
|
+
*/
|
|
197
|
+
redirectUri: 'kiro://kiro.kiroAgent/authenticate-success',
|
|
198
|
+
};
|
|
199
|
+
function socialIdp(provider) {
|
|
200
|
+
return provider === 'google' ? 'Google' : 'Github';
|
|
201
|
+
}
|
|
202
|
+
function base64Url(bytes) {
|
|
203
|
+
return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The PKCE pair for a social sign-in. The verifier never leaves the gateway.
|
|
207
|
+
*
|
|
208
|
+
* The challenge is the real S256 digest of the verifier, per RFC 7636 — an identity
|
|
209
|
+
* provider that checks it would reject anything else, and one that does not check it still
|
|
210
|
+
* costs nothing.
|
|
211
|
+
*/
|
|
212
|
+
export async function createKiroPkce() {
|
|
213
|
+
const verifier = base64Url(globalThis.crypto.getRandomValues(new Uint8Array(32)));
|
|
214
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
215
|
+
return { verifier, challenge: base64Url(new Uint8Array(digest)) };
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The URL the user opens to sign in with Google or GitHub.
|
|
219
|
+
*
|
|
220
|
+
* `state` is checked on the way back, so a code cannot be pasted from somewhere else.
|
|
221
|
+
*/
|
|
222
|
+
export function buildKiroSocialUrl(provider, codeChallenge, state) {
|
|
223
|
+
const params = new URLSearchParams({
|
|
224
|
+
idp: socialIdp(provider),
|
|
225
|
+
redirect_uri: KIRO_SOCIAL.redirectUri,
|
|
226
|
+
code_challenge: codeChallenge,
|
|
227
|
+
code_challenge_method: 'S256',
|
|
228
|
+
state,
|
|
229
|
+
prompt: 'select_account',
|
|
230
|
+
});
|
|
231
|
+
return `${KIRO_SOCIAL.authHost}/login?${params.toString()}`;
|
|
232
|
+
}
|
|
233
|
+
/** Exchanges a pasted authorization code for a Kiro session. */
|
|
234
|
+
export async function exchangeKiroSocialCode(session, code) {
|
|
235
|
+
const trimmed = code.trim();
|
|
236
|
+
if (!trimmed) {
|
|
237
|
+
throw new ProviderError('INVALID_REQUEST', 'Paste the code Kiro showed you.', {
|
|
238
|
+
providerId: kiroProviderId,
|
|
239
|
+
publicMessage: 'Paste the code Kiro showed you.',
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// The same `redirect_uri` as the authorize request, or the exchange is refused.
|
|
243
|
+
const { data } = await postAuthJson(`${KIRO_SOCIAL.authHost}/oauth/token`, {
|
|
244
|
+
code: trimmed,
|
|
245
|
+
code_verifier: session.verifier,
|
|
246
|
+
redirect_uri: KIRO_SOCIAL.redirectUri,
|
|
247
|
+
});
|
|
248
|
+
const accessToken = typeof data.accessToken === 'string' ? data.accessToken : '';
|
|
249
|
+
if (!accessToken) {
|
|
250
|
+
const detail = typeof data.error_description === 'string' ? data.error_description : '';
|
|
251
|
+
throw new ProviderError('AUTHENTICATION_FAILED', detail || 'Kiro did not accept that code.', {
|
|
252
|
+
providerId: kiroProviderId,
|
|
253
|
+
publicMessage: detail || 'Kiro did not accept that code. Codes expire quickly — sign in again.',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const expiresIn = typeof data.expiresIn === 'number' ? data.expiresIn : undefined;
|
|
257
|
+
return {
|
|
258
|
+
type: 'oauth',
|
|
259
|
+
value: accessToken,
|
|
260
|
+
...(typeof data.refreshToken === 'string' ? { refreshToken: data.refreshToken } : {}),
|
|
261
|
+
...(expiresIn === undefined ? {} : { expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() }),
|
|
262
|
+
...(typeof data.profileArn === 'string' && data.profileArn ? { accountId: data.profileArn } : {}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/** A social session is short: the code it is waiting for expires within minutes. */
|
|
266
|
+
export const kiroSocialTtlMs = 10 * 60_000;
|
|
267
|
+
export function kiroSocialSessionExpired(session, now = Date.now()) {
|
|
268
|
+
return now - session.createdAt > kiroSocialTtlMs;
|
|
269
|
+
}
|
|
270
|
+
/** A random, unguessable `state` for one social sign-in. */
|
|
271
|
+
export function newKiroSocialState() {
|
|
272
|
+
return base64Url(globalThis.crypto.getRandomValues(new Uint8Array(24)));
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Polls for the token. A pending answer is HTTP 400 with `authorization_pending` in the
|
|
276
|
+
* body, so the status is read from the payload rather than treated as a failure.
|
|
277
|
+
*/
|
|
278
|
+
export async function pollKiroSignIn(authorization) {
|
|
279
|
+
const { data } = await postAuthJson(`${KIRO.oidc}/token`, {
|
|
280
|
+
grantType: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
281
|
+
deviceCode: authorization.deviceCode,
|
|
282
|
+
clientId: authorization.clientId,
|
|
283
|
+
clientSecret: authorization.clientSecret,
|
|
284
|
+
});
|
|
285
|
+
// A pending grant is HTTP 400 with the state in the body, so the body decides.
|
|
286
|
+
const error = typeof data.error === 'string' ? data.error : '';
|
|
287
|
+
if (error === 'authorization_pending' || error === 'slow_down')
|
|
288
|
+
return { status: 'pending' };
|
|
289
|
+
if (error) {
|
|
290
|
+
const description = typeof data.error_description === 'string' ? data.error_description : '';
|
|
291
|
+
return { status: 'denied', error: description || `AWS reported ${error.replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 40)}.` };
|
|
292
|
+
}
|
|
293
|
+
const accessToken = typeof data.accessToken === 'string' ? data.accessToken : '';
|
|
294
|
+
if (!accessToken) {
|
|
295
|
+
return { status: 'denied', error: 'AWS returned a Kiro session with no access token. Start again.' };
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
status: 'connected',
|
|
299
|
+
accessToken,
|
|
300
|
+
...(typeof data.refreshToken === 'string' ? { refreshToken: data.refreshToken } : {}),
|
|
301
|
+
...(typeof data.expiresIn === 'number' ? { expiresIn: data.expiresIn } : {}),
|
|
302
|
+
...(typeof data.profileArn === 'string' && data.profileArn ? { profileArn: data.profileArn } : {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Wraps a pasted API key as a credential.
|
|
307
|
+
*
|
|
308
|
+
* It is stored as a bearer credential with no refresh token, so the session cannot be
|
|
309
|
+
* renewed and has to be replaced by hand. That is a property of the key rather than a
|
|
310
|
+
* limitation of the import, so it is stated instead of being hidden behind a fake expiry.
|
|
311
|
+
*/
|
|
312
|
+
export function kiroCredentialFromApiKey(apiKey) {
|
|
313
|
+
const trimmed = apiKey.trim();
|
|
314
|
+
if (!trimmed) {
|
|
315
|
+
throw new ProviderError('INVALID_REQUEST', 'Paste a Kiro API key to connect.', {
|
|
316
|
+
providerId: kiroProviderId,
|
|
317
|
+
publicMessage: 'Paste a Kiro API key to connect.',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return { type: 'api-key', value: trimmed };
|
|
321
|
+
}
|
|
322
|
+
/** Renews the session. Kiro's refresh grant uses the same token endpoint. */
|
|
323
|
+
export async function refreshKiroCredential(credential) {
|
|
324
|
+
if (credential.type !== 'oauth' || !credential.refreshToken) {
|
|
325
|
+
throw new ProviderError('AUTHENTICATION_FAILED', 'This Kiro session cannot be renewed. Sign in again.', {
|
|
326
|
+
providerId: kiroProviderId,
|
|
327
|
+
publicMessage: 'This Kiro session cannot be renewed. Sign in again.',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
const { data } = await postAuthJson(`${KIRO.oidc}/token`, {
|
|
331
|
+
grantType: 'refresh_token',
|
|
332
|
+
refreshToken: credential.refreshToken,
|
|
333
|
+
clientId: credential.accountId ?? KIRO.clientName,
|
|
334
|
+
clientSecret: KIRO.clientName,
|
|
335
|
+
});
|
|
336
|
+
const accessToken = typeof data.accessToken === 'string' ? data.accessToken : '';
|
|
337
|
+
if (!accessToken) {
|
|
338
|
+
throw new ProviderError('AUTHENTICATION_FAILED', 'AWS did not renew the Kiro session. Sign in again.', {
|
|
339
|
+
providerId: kiroProviderId,
|
|
340
|
+
publicMessage: 'AWS did not renew the Kiro session. Sign in again.',
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const expiresIn = typeof data.expiresIn === 'number' ? data.expiresIn : undefined;
|
|
344
|
+
return {
|
|
345
|
+
type: 'oauth',
|
|
346
|
+
value: accessToken,
|
|
347
|
+
...(typeof data.refreshToken === 'string' ? { refreshToken: data.refreshToken } : { refreshToken: credential.refreshToken }),
|
|
348
|
+
...(expiresIn === undefined ? {} : { expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() }),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/** True when the access token is within a minute of expiry. */
|
|
352
|
+
export function kiroCredentialExpired(credential, now = Date.now()) {
|
|
353
|
+
if (credential?.type !== 'oauth' || !credential.expiresAt)
|
|
354
|
+
return false;
|
|
355
|
+
const expiry = Date.parse(credential.expiresAt);
|
|
356
|
+
return Number.isFinite(expiry) && expiry - 60_000 <= now;
|
|
357
|
+
}
|
|
358
|
+
/* ------------------------------------------------------------------ *
|
|
359
|
+
* Inference
|
|
360
|
+
* ------------------------------------------------------------------ */
|
|
361
|
+
/**
|
|
362
|
+
* Message content is either a string or a list of parts, and Kiro's envelope takes plain
|
|
363
|
+
* text, so both are flattened to text rather than one being dropped.
|
|
364
|
+
*/
|
|
365
|
+
function messageText(content) {
|
|
366
|
+
if (typeof content === 'string')
|
|
367
|
+
return content;
|
|
368
|
+
if (!Array.isArray(content))
|
|
369
|
+
return '';
|
|
370
|
+
return content
|
|
371
|
+
.map((part) => {
|
|
372
|
+
if (typeof part === 'string')
|
|
373
|
+
return part;
|
|
374
|
+
if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string')
|
|
375
|
+
return part.text;
|
|
376
|
+
return '';
|
|
377
|
+
})
|
|
378
|
+
.join('');
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Builds the `conversationState` envelope.
|
|
382
|
+
*
|
|
383
|
+
* Kiro takes the whole turn history rather than a message list, the model id is
|
|
384
|
+
* addressed on the current message, and a system turn is folded into the user content
|
|
385
|
+
* rather than sent as its own role, because the envelope has no system role.
|
|
386
|
+
*/
|
|
387
|
+
export function toKiroBody(request, conversationId, credential) {
|
|
388
|
+
const system = request.messages.filter((message) => message.role === 'system').map((message) => messageText(message.content)).filter(Boolean);
|
|
389
|
+
const turns = request.messages.filter((message) => message.role !== 'system');
|
|
390
|
+
const current = turns[turns.length - 1];
|
|
391
|
+
const history = turns.slice(0, -1).map((message) => ({
|
|
392
|
+
[message.role === 'assistant' ? 'assistantResponseMessage' : 'userInputMessage']: {
|
|
393
|
+
content: messageText(message.content),
|
|
394
|
+
...(message.role === 'assistant' ? { modelId: request.model } : { modelId: request.model, origin: 'AI_EDITOR' }),
|
|
395
|
+
},
|
|
396
|
+
}));
|
|
397
|
+
const content = [system.length ? system.join('\n\n') : '', messageText(current?.content ?? '')].filter(Boolean).join('\n\n');
|
|
398
|
+
// A profile scopes the session to an IAM Identity Center profile and is absent for a
|
|
399
|
+
// plain Builder ID or social sign-in. It is sent **only when there is one**: an empty
|
|
400
|
+
// `profileArn` is not the same as no profile, and Kiro answers
|
|
401
|
+
// `400 Improperly formed request` for it.
|
|
402
|
+
const profileArn = credential?.type === 'oauth' ? credential.accountId : undefined;
|
|
403
|
+
return {
|
|
404
|
+
conversationState: {
|
|
405
|
+
chatTriggerType: 'MANUAL',
|
|
406
|
+
conversationId,
|
|
407
|
+
currentMessage: {
|
|
408
|
+
userInputMessage: {
|
|
409
|
+
content,
|
|
410
|
+
modelId: request.model,
|
|
411
|
+
origin: 'AI_EDITOR',
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
history,
|
|
415
|
+
},
|
|
416
|
+
...(profileArn ? { profileArn } : {}),
|
|
417
|
+
...(request.maxOutputTokens === undefined ? {} : { inferenceConfig: { maxTokens: request.maxOutputTokens } }),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function kiroHeaders(credential) {
|
|
421
|
+
// `value` exists on every variant except `none`, which never reaches a request.
|
|
422
|
+
const token = credential.type === 'none' ? '' : credential.value;
|
|
423
|
+
return {
|
|
424
|
+
'content-type': 'application/json',
|
|
425
|
+
accept: KIRO.eventStreamAccept,
|
|
426
|
+
'X-Amz-Target': KIRO.streamingTarget,
|
|
427
|
+
'User-Agent': KIRO.userAgent,
|
|
428
|
+
'X-Amz-User-Agent': KIRO.amzUserAgent,
|
|
429
|
+
Authorization: `Bearer ${token}`,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const kiroEventTypeHeader = ':event-type';
|
|
433
|
+
/** AWS eventstream header type 7: a string, preceded by its u16 length. */
|
|
434
|
+
const kiroStringHeader = 7;
|
|
435
|
+
function readText(bytes, start, length) {
|
|
436
|
+
return new TextDecoder().decode(bytes.subarray(start, start + length));
|
|
437
|
+
}
|
|
438
|
+
/** A single byte, treating an out-of-range read as 0 so a malformed frame cannot throw. */
|
|
439
|
+
function byteAt(bytes, index) {
|
|
440
|
+
return index < 0 || index >= bytes.length ? 0 : bytes[index];
|
|
441
|
+
}
|
|
442
|
+
function readNumber(source, keys) {
|
|
443
|
+
for (const key of keys) {
|
|
444
|
+
const value = source[key];
|
|
445
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
446
|
+
return value;
|
|
447
|
+
}
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
function readString(source, keys) {
|
|
451
|
+
for (const key of keys) {
|
|
452
|
+
const value = source[key];
|
|
453
|
+
if (typeof value === 'string' && value)
|
|
454
|
+
return value;
|
|
455
|
+
}
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Decodes a whole Kiro response.
|
|
460
|
+
*
|
|
461
|
+
* Frames are walked by their declared length rather than scanned for a separator, and a
|
|
462
|
+
* frame that claims an impossible length ends the walk instead of being read past — a
|
|
463
|
+
* truncated body must not be mistaken for a complete one.
|
|
464
|
+
*/
|
|
465
|
+
export function decodeKiroStream(bytes) {
|
|
466
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
467
|
+
const events = [];
|
|
468
|
+
let offset = 0;
|
|
469
|
+
while (offset + 16 <= bytes.length) {
|
|
470
|
+
// `total_length` counts itself, so a frame occupies exactly that many bytes.
|
|
471
|
+
const totalLength = view.getUint32(offset);
|
|
472
|
+
const headersLength = view.getUint32(offset + 4);
|
|
473
|
+
if (totalLength < 16 || offset + totalLength > bytes.length)
|
|
474
|
+
break;
|
|
475
|
+
const headersEnd = offset + 12 + headersLength;
|
|
476
|
+
const frameEnd = offset + totalLength;
|
|
477
|
+
// 12 prelude + headers + 4 trailing CRC must fit inside the frame.
|
|
478
|
+
if (headersEnd + 4 > frameEnd)
|
|
479
|
+
break;
|
|
480
|
+
let cursor = offset + 12;
|
|
481
|
+
let eventType = '';
|
|
482
|
+
while (cursor < headersEnd) {
|
|
483
|
+
const nameLength = byteAt(bytes, cursor);
|
|
484
|
+
cursor += 1;
|
|
485
|
+
const name = readText(bytes, cursor, nameLength);
|
|
486
|
+
cursor += nameLength;
|
|
487
|
+
if (cursor >= headersEnd)
|
|
488
|
+
break;
|
|
489
|
+
const headerType = byteAt(bytes, cursor);
|
|
490
|
+
cursor += 1;
|
|
491
|
+
if (headerType !== kiroStringHeader)
|
|
492
|
+
break;
|
|
493
|
+
if (cursor + 2 > headersEnd)
|
|
494
|
+
break;
|
|
495
|
+
const valueLength = view.getUint16(cursor);
|
|
496
|
+
cursor += 2;
|
|
497
|
+
const value = readText(bytes, cursor, valueLength);
|
|
498
|
+
cursor += valueLength;
|
|
499
|
+
if (name === kiroEventTypeHeader || name === 'event-type')
|
|
500
|
+
eventType = value;
|
|
501
|
+
}
|
|
502
|
+
const payloadText = readText(bytes, headersEnd, frameEnd - 4 - headersEnd);
|
|
503
|
+
let payload = {};
|
|
504
|
+
try {
|
|
505
|
+
const parsed = JSON.parse(payloadText);
|
|
506
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
|
|
507
|
+
payload = parsed;
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// An unreadable payload still yields an event, so the frame count stays honest.
|
|
511
|
+
}
|
|
512
|
+
events.push(toKiroEvent(eventType, payload));
|
|
513
|
+
offset = frameEnd;
|
|
514
|
+
}
|
|
515
|
+
return events;
|
|
516
|
+
}
|
|
517
|
+
/** Maps one decoded frame onto the fields this adapter can use. */
|
|
518
|
+
export function toKiroEvent(eventType, payload) {
|
|
519
|
+
if (eventType === 'assistantResponseEvent') {
|
|
520
|
+
const text = readString(payload, ['content', 'text']);
|
|
521
|
+
return text === undefined ? { type: eventType } : { type: eventType, text };
|
|
522
|
+
}
|
|
523
|
+
if (eventType === 'contextUsageEvent') {
|
|
524
|
+
const percent = readNumber(payload, ['contextUsagePercentage', 'contextUsagePercent']);
|
|
525
|
+
return percent === undefined ? { type: eventType } : { type: eventType, contextUsagePercent: percent };
|
|
526
|
+
}
|
|
527
|
+
if (eventType === 'meteringEvent') {
|
|
528
|
+
const used = readNumber(payload, ['usage', 'creditsUsed']);
|
|
529
|
+
return used === undefined ? { type: eventType } : { type: eventType, creditsUsed: used };
|
|
530
|
+
}
|
|
531
|
+
return { type: eventType };
|
|
532
|
+
}
|
|
533
|
+
/** Kiro's wording for an id the signed-in plan does not carry. */
|
|
534
|
+
function isKiroInvalidModel(error) {
|
|
535
|
+
const detail = error?.details?.providerMessage ?? '';
|
|
536
|
+
return /invalid model|different model/i.test(detail);
|
|
537
|
+
}
|
|
538
|
+
/** The last frame carrying a value of this kind. The target is ES2022, so no `findLast`. */
|
|
539
|
+
function lastOf(events, pick) {
|
|
540
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
541
|
+
const event = events[index];
|
|
542
|
+
if (event === undefined)
|
|
543
|
+
continue;
|
|
544
|
+
const value = pick(event);
|
|
545
|
+
if (value !== undefined)
|
|
546
|
+
return value;
|
|
547
|
+
}
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
function conversationIdFor(request) {
|
|
551
|
+
// Deterministic per request, so a retried request stays in one conversation.
|
|
552
|
+
let hash = 0;
|
|
553
|
+
for (const character of `${request.model}:${request.messages.map((m) => `${m.role}:${messageText(m.content)}`).join('|')}`) {
|
|
554
|
+
hash = (hash * 31 + character.charCodeAt(0)) | 0;
|
|
555
|
+
}
|
|
556
|
+
return `omnihilbras-${(hash >>> 0).toString(16)}`;
|
|
557
|
+
}
|
|
558
|
+
export class KiroAdapter {
|
|
559
|
+
id = kiroProviderId;
|
|
560
|
+
name = 'Kiro';
|
|
561
|
+
capabilities = { chat: true, streaming: false, models: true };
|
|
562
|
+
transport;
|
|
563
|
+
onTokensRefreshed;
|
|
564
|
+
now;
|
|
565
|
+
renewed = new Map();
|
|
566
|
+
constructor(options = {}) {
|
|
567
|
+
this.transport = options.transport ?? new FetchHttpTransport();
|
|
568
|
+
this.onTokensRefreshed = options.onTokensRefreshed;
|
|
569
|
+
this.now = options.now ?? (() => Date.now());
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Kiro publishes no model list over the API, so the catalog is the known set. That is
|
|
573
|
+
* stated rather than implied: a model not in it is refused below instead of being sent
|
|
574
|
+
* and coming back as `400 Invalid model`.
|
|
575
|
+
*/
|
|
576
|
+
async listModels() {
|
|
577
|
+
return KIRO_MODELS.map((model) => ({
|
|
578
|
+
id: model.id,
|
|
579
|
+
providerId: this.id,
|
|
580
|
+
displayName: model.name,
|
|
581
|
+
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
async validateCredential(credential) {
|
|
585
|
+
if (credential?.type !== 'oauth' || !credential.value) {
|
|
586
|
+
throw new ProviderError('AUTHENTICATION_FAILED', 'Sign in to Kiro to use this provider.', {
|
|
587
|
+
providerId: this.id,
|
|
588
|
+
publicMessage: 'Sign in to Kiro to use this provider.',
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
return { status: 'valid', checkedAt: new Date().toISOString() };
|
|
592
|
+
}
|
|
593
|
+
async chat(request, context = {}) {
|
|
594
|
+
const credential = await this.credential(context);
|
|
595
|
+
const model = KIRO_MODELS.find((entry) => entry.id === request.model);
|
|
596
|
+
if (!model) {
|
|
597
|
+
throw new ProviderError('NOT_SUPPORTED', `Kiro does not offer a model called ${request.model}.`, {
|
|
598
|
+
providerId: this.id,
|
|
599
|
+
publicMessage: `Kiro does not offer a model called ${request.model}. Sign in to refresh the catalog.`,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
let response;
|
|
603
|
+
try {
|
|
604
|
+
response = await this.transport.request({
|
|
605
|
+
method: 'POST',
|
|
606
|
+
providerId: this.id,
|
|
607
|
+
url: KIRO.inferenceUrl,
|
|
608
|
+
headers: kiroHeaders(credential),
|
|
609
|
+
body: JSON.stringify(toKiroBody(request, conversationIdFor(request), credential)),
|
|
610
|
+
// Read as bytes: the answer is a binary eventstream and a text decode corrupts it.
|
|
611
|
+
responseAs: 'bytes',
|
|
612
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
/**
|
|
617
|
+
* An id the plan does not carry comes back as a 400 that reads like a malformed
|
|
618
|
+
* request. Naming the model is the difference between "Kiro is broken" and "this
|
|
619
|
+
* account cannot use this model", and only the first of those is our fault.
|
|
620
|
+
*/
|
|
621
|
+
if (isKiroInvalidModel(error)) {
|
|
622
|
+
throw new ProviderError('NOT_SUPPORTED', `Your Kiro plan does not offer ${request.model}.`, {
|
|
623
|
+
providerId: this.id,
|
|
624
|
+
publicMessage: `Your Kiro plan does not offer ${request.model}. Kiro serves it to some accounts and not others.`,
|
|
625
|
+
cause: error,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
const events = decodeKiroStream(response.data ?? new Uint8Array());
|
|
631
|
+
const text = events.map((event) => event.text ?? '').join('');
|
|
632
|
+
// Kiro ends the stream by closing it; it publishes no stop event, so `stop` is the
|
|
633
|
+
// only honest answer rather than a reason read from an event that never arrives.
|
|
634
|
+
const creditsUsed = lastOf(events, (event) => event.creditsUsed);
|
|
635
|
+
const contextUsagePercent = lastOf(events, (event) => event.contextUsagePercent);
|
|
636
|
+
if (!text) {
|
|
637
|
+
throw new ProviderError('INVALID_RESPONSE', 'Kiro returned no answer text.', {
|
|
638
|
+
providerId: this.id,
|
|
639
|
+
publicMessage: 'Kiro returned no answer text.',
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
id: `kiro-${conversationIdFor(request)}`,
|
|
644
|
+
providerId: this.id,
|
|
645
|
+
model: request.model,
|
|
646
|
+
createdAt: new Date().toISOString(),
|
|
647
|
+
message: { role: 'assistant', content: text },
|
|
648
|
+
finishReason: 'stop',
|
|
649
|
+
/**
|
|
650
|
+
* Kiro meters credits and publishes no token counts, so `usage` is left unset
|
|
651
|
+
* rather than reported as zero. The credit cost is carried separately because it is
|
|
652
|
+
* the real figure and dropping it would leave the call looking free.
|
|
653
|
+
*/
|
|
654
|
+
...(creditsUsed === undefined ? {} : { meters: { unit: 'credit', amount: creditsUsed } }),
|
|
655
|
+
...(contextUsagePercent === undefined ? {} : { contextUsagePercent }),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Kiro only speaks the eventstream, so a non-streaming answer is assembled from it and
|
|
660
|
+
* yielded whole. Reporting that honestly beats implying a stream the provider does not
|
|
661
|
+
* have.
|
|
662
|
+
*/
|
|
663
|
+
async *streamChat(request, context = {}) {
|
|
664
|
+
const response = await this.chat(request, context);
|
|
665
|
+
yield {
|
|
666
|
+
id: response.id,
|
|
667
|
+
providerId: response.providerId,
|
|
668
|
+
model: response.model,
|
|
669
|
+
delta: { role: 'assistant', content: messageText(response.message.content) },
|
|
670
|
+
finishReason: response.finishReason,
|
|
671
|
+
...(response.usage ? { usage: response.usage } : {}),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
async healthCheck(context = {}) {
|
|
675
|
+
try {
|
|
676
|
+
await this.validateCredential(context.credential);
|
|
677
|
+
return { status: 'healthy', checkedAt: new Date().toISOString() };
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
return {
|
|
681
|
+
status: 'unavailable',
|
|
682
|
+
checkedAt: new Date().toISOString(),
|
|
683
|
+
message: error instanceof Error ? error.message : 'The Kiro session could not be checked.',
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/** Renews at expiry, memoised so one request never spends two refresh grants. */
|
|
688
|
+
async credential(context) {
|
|
689
|
+
const current = context.credential;
|
|
690
|
+
if (!kiroCredentialExpired(current, this.now()))
|
|
691
|
+
return current;
|
|
692
|
+
// Only an OAuth credential can be expired, so the variant is known here.
|
|
693
|
+
const stale = current.value;
|
|
694
|
+
const memo = this.renewed.get(stale);
|
|
695
|
+
if (memo)
|
|
696
|
+
return memo;
|
|
697
|
+
const renewed = await refreshKiroCredential(current);
|
|
698
|
+
this.renewed.set(stale, renewed);
|
|
699
|
+
await this.onTokensRefreshed?.(renewed);
|
|
700
|
+
return renewed;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
//# sourceMappingURL=kiro.js.map
|