kiro-proxy 0.1.15

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/server.js ADDED
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+ import express from 'express';
3
+ import crypto from 'crypto';
4
+ import { getAccessToken } from './token-reader.js';
5
+ import { createClient, chat, chatStream, listAvailableModels } from './q-client.js';
6
+ import { c, log, logSummary, reqId, tagError } from './logger.js';
7
+ import { countMessages, countContent } from './token-counter.js';
8
+ import { recordUsage, queryUsage, todaySummary } from './usage-tracker.js';
9
+
10
+ const app = express();
11
+ app.use(express.json({ limit: '10mb' }));
12
+
13
+ const PORT = process.env.PORT || 3456;
14
+
15
+ let cachedClient = null;
16
+ let cachedToken = null;
17
+
18
+ async function getClient() {
19
+ const tokenData = await getAccessToken();
20
+ if (!cachedClient || cachedToken !== tokenData.accessToken) {
21
+ cachedClient = createClient(tokenData.accessToken, {
22
+ authMethod: tokenData.authMethod,
23
+ profileArn: tokenData.profileArn,
24
+ provider: tokenData.provider,
25
+ });
26
+ cachedToken = tokenData.accessToken;
27
+ }
28
+ return { client: cachedClient, tokenData };
29
+ }
30
+
31
+ function msgId() {
32
+ return `msg_${crypto.randomUUID().replace(/-/g, '').slice(0, 20)}`;
33
+ }
34
+
35
+ // ============================================================
36
+ // POST /v1/messages — Anthropic Messages API (with tool support)
37
+ // ============================================================
38
+ app.post('/v1/messages', async (req, res) => {
39
+ try {
40
+ const { model, messages, system, tools, stream, max_tokens, tool_choice } = req.body;
41
+ if (!messages?.length) {
42
+ return res.status(400).json({ type: 'error', error: { type: 'invalid_request_error', message: 'messages required' } });
43
+ }
44
+
45
+ const { client, tokenData } = await getClient();
46
+ const opts = { messages, system, tools, profileArn: tokenData.profileArn, modelId: model };
47
+ const rid = reqId();
48
+ const start = Date.now();
49
+
50
+ log('POST', '/v1/messages', rid, {
51
+ model: model || 'default',
52
+ stream: !!stream,
53
+ messages: messages.length,
54
+ tools: tools?.length || 0,
55
+ });
56
+
57
+ if (stream) {
58
+ res.setHeader('Content-Type', 'text/event-stream');
59
+ res.setHeader('Cache-Control', 'no-cache');
60
+ res.setHeader('Connection', 'keep-alive');
61
+
62
+ const id = msgId();
63
+ const usedModel = model || 'q-developer';
64
+ let blockIndex = 0;
65
+ let hasTextBlock = false;
66
+ const inputTokens = countMessages(messages, system);
67
+
68
+ // message_start
69
+ const send = (event, data) => { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); };
70
+
71
+ send('message_start', {
72
+ type: 'message_start',
73
+ message: {
74
+ id, type: 'message', role: 'assistant', content: [],
75
+ model: usedModel, stop_reason: null, stop_sequence: null,
76
+ usage: { input_tokens: inputTokens, output_tokens: 0 },
77
+ },
78
+ });
79
+ send('ping', { type: 'ping' });
80
+
81
+ try {
82
+ let hasToolUse = false;
83
+ let hasThinkingBlock = false;
84
+ let summary;
85
+ const outputParts = [];
86
+
87
+ for await (const chunk of chatStream(client, opts)) {
88
+ if (chunk.type === 'thinking') {
89
+ // 开启 thinking 块(如果还没有)
90
+ if (!hasThinkingBlock) {
91
+ send('content_block_start', {
92
+ type: 'content_block_start', index: blockIndex,
93
+ content_block: { type: 'thinking', thinking: '' },
94
+ });
95
+ hasThinkingBlock = true;
96
+ }
97
+ outputParts.push(chunk.text);
98
+ send('content_block_delta', {
99
+ type: 'content_block_delta', index: blockIndex,
100
+ delta: { type: 'thinking_delta', thinking: chunk.text },
101
+ });
102
+ } else if (chunk.type === 'thinking_signature') {
103
+ // 关闭 thinking 块,附带 signature
104
+ if (hasThinkingBlock) {
105
+ send('content_block_delta', {
106
+ type: 'content_block_delta', index: blockIndex,
107
+ delta: { type: 'signature_delta', signature: chunk.signature },
108
+ });
109
+ send('content_block_stop', { type: 'content_block_stop', index: blockIndex });
110
+ blockIndex++;
111
+ hasThinkingBlock = false;
112
+ }
113
+ } else if (chunk.type === 'content') {
114
+ // 关闭未关闭的 thinking 块
115
+ if (hasThinkingBlock) {
116
+ send('content_block_stop', { type: 'content_block_stop', index: blockIndex });
117
+ blockIndex++;
118
+ hasThinkingBlock = false;
119
+ }
120
+ // 开启文本块(如果还没有)
121
+ if (!hasTextBlock) {
122
+ send('content_block_start', {
123
+ type: 'content_block_start', index: blockIndex,
124
+ content_block: { type: 'text', text: '' },
125
+ });
126
+ hasTextBlock = true;
127
+ }
128
+ outputParts.push(chunk.content);
129
+ send('content_block_delta', {
130
+ type: 'content_block_delta', index: blockIndex,
131
+ delta: { type: 'text_delta', text: chunk.content },
132
+ });
133
+ } else if (chunk.type === 'tool_use_start') {
134
+ // 关闭之前的 thinking 块
135
+ if (hasThinkingBlock) {
136
+ send('content_block_stop', { type: 'content_block_stop', index: blockIndex });
137
+ blockIndex++;
138
+ hasThinkingBlock = false;
139
+ }
140
+ // 关闭之前的文本块
141
+ if (hasTextBlock) {
142
+ send('content_block_stop', { type: 'content_block_stop', index: blockIndex });
143
+ blockIndex++;
144
+ hasTextBlock = false;
145
+ }
146
+ } else if (chunk.type === 'tool_use_end') {
147
+ hasToolUse = true;
148
+ outputParts.push(JSON.stringify(chunk.input));
149
+ // 发送完整的 tool_use content block
150
+ send('content_block_start', {
151
+ type: 'content_block_start', index: blockIndex,
152
+ content_block: { type: 'tool_use', id: chunk.toolUseId, name: chunk.name, input: {} },
153
+ });
154
+ // 发送 input_json_delta(完整 JSON 一次性发送)
155
+ send('content_block_delta', {
156
+ type: 'content_block_delta', index: blockIndex,
157
+ delta: { type: 'input_json_delta', partial_json: JSON.stringify(chunk.input) },
158
+ });
159
+ send('content_block_stop', { type: 'content_block_stop', index: blockIndex });
160
+ blockIndex++;
161
+ } else if (chunk.type === 'summary') {
162
+ summary = chunk.stats;
163
+ if (typeof chunk.meteringUsage === 'number') recordUsage(chunk.meteringUsage, model);
164
+ }
165
+ }
166
+
167
+ // 关闭最后的 thinking 块
168
+ if (hasThinkingBlock) {
169
+ send('content_block_stop', { type: 'content_block_stop', index: blockIndex });
170
+ }
171
+
172
+ // 关闭最后的文本块
173
+ if (hasTextBlock) {
174
+ send('content_block_stop', { type: 'content_block_stop', index: blockIndex });
175
+ }
176
+
177
+ const stopReason = hasToolUse ? 'tool_use' : 'end_turn';
178
+ const outputTokens = countContent(outputParts.join(''));
179
+ send('message_delta', {
180
+ type: 'message_delta',
181
+ delta: { stop_reason: stopReason, stop_sequence: null },
182
+ usage: { output_tokens: outputTokens },
183
+ });
184
+ send('message_stop', { type: 'message_stop' });
185
+ res.end();
186
+ const s = summary || {};
187
+ s.estTokens = `~tokens: in=${inputTokens} out=${outputTokens}`;
188
+ logSummary(rid, Date.now() - start, s);
189
+ } catch (err) {
190
+ tagError('stream', err.message);
191
+ res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'api_error', message: err.message } })}\n\n`);
192
+ res.end();
193
+ }
194
+ } else {
195
+ // 非流式
196
+ const result = await chat(client, opts);
197
+ if (typeof result.meteringUsage === 'number') recordUsage(result.meteringUsage, model);
198
+ const inputTokens = countMessages(messages, system);
199
+ const outputTokens = countContent(result.content);
200
+ const s = result.stats || {};
201
+ s.estTokens = `~tokens: in=${inputTokens} out=${outputTokens}`;
202
+ logSummary(rid, Date.now() - start, s);
203
+ res.json({
204
+ id: msgId(), type: 'message', role: 'assistant',
205
+ content: result.content,
206
+ model: model || 'q-developer',
207
+ stop_reason: result.stopReason,
208
+ stop_sequence: null,
209
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens },
210
+ });
211
+ }
212
+ } catch (err) {
213
+ tagError('anthropic', err.message || err);
214
+ const status = err.message?.includes('expired') ? 401 : 500;
215
+ res.status(status).json({ type: 'error', error: { type: status === 401 ? 'authentication_error' : 'api_error', message: err.message } });
216
+ }
217
+ });
218
+
219
+ // ============================================================
220
+ // POST /v1/chat/completions — OpenAI compatible
221
+ // ============================================================
222
+ app.post('/v1/chat/completions', async (req, res) => {
223
+ try {
224
+ const { messages: rawMsgs, model, stream } = req.body;
225
+ if (!rawMsgs?.length) return res.status(400).json({ error: 'messages required' });
226
+
227
+ // 简单转换 OpenAI → Anthropic 格式
228
+ let system;
229
+ const messages = [];
230
+ for (const m of rawMsgs) {
231
+ if (m.role === 'system') { system = m.content; continue; }
232
+ messages.push({ role: m.role, content: m.content });
233
+ }
234
+
235
+ const { client, tokenData } = await getClient();
236
+ const opts = { messages, system, profileArn: tokenData.profileArn, modelId: model };
237
+ const rid = reqId();
238
+ const start = Date.now();
239
+
240
+ log('POST', '/v1/chat/completions', rid, {
241
+ model: model || 'default',
242
+ stream: !!stream,
243
+ messages: messages.length,
244
+ });
245
+
246
+ if (stream) {
247
+ res.setHeader('Content-Type', 'text/event-stream');
248
+ res.setHeader('Cache-Control', 'no-cache');
249
+ res.setHeader('Connection', 'keep-alive');
250
+ const responseId = `chatcmpl-${crypto.randomUUID()}`;
251
+ const created = Math.floor(Date.now() / 1000);
252
+ const inputTokens = countMessages(messages, system);
253
+ let summary;
254
+ const outputParts = [];
255
+
256
+ for await (const chunk of chatStream(client, opts)) {
257
+ if (chunk.type === 'content') {
258
+ outputParts.push(chunk.content);
259
+ res.write(`data: ${JSON.stringify({
260
+ id: responseId, object: 'chat.completion.chunk', created,
261
+ model: model || 'q-developer',
262
+ choices: [{ index: 0, delta: { content: chunk.content }, finish_reason: null }],
263
+ })}\n\n`);
264
+ } else if (chunk.type === 'summary') {
265
+ summary = chunk.stats;
266
+ if (typeof chunk.meteringUsage === 'number') recordUsage(chunk.meteringUsage, model);
267
+ }
268
+ }
269
+ res.write(`data: ${JSON.stringify({
270
+ id: responseId, object: 'chat.completion.chunk', created,
271
+ model: model || 'q-developer',
272
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
273
+ })}\n\n`);
274
+ res.write('data: [DONE]\n\n');
275
+ res.end();
276
+ const outputTokens = countContent(outputParts.join(''));
277
+ const s = summary || {};
278
+ s.estTokens = `~tokens: in=${inputTokens} out=${outputTokens}`;
279
+ logSummary(rid, Date.now() - start, s);
280
+ } else {
281
+ const result = await chat(client, opts);
282
+ if (typeof result.meteringUsage === 'number') recordUsage(result.meteringUsage, model);
283
+ const text = result.content.filter(b => b.type === 'text').map(b => b.text).join('');
284
+ const promptTokens = countMessages(messages, system);
285
+ const completionTokens = countContent(text);
286
+ const s = result.stats || {};
287
+ s.estTokens = `~tokens: in=${promptTokens} out=${completionTokens}`;
288
+ logSummary(rid, Date.now() - start, s);
289
+ res.json({
290
+ id: `chatcmpl-${crypto.randomUUID()}`, object: 'chat.completion',
291
+ created: Math.floor(Date.now() / 1000), model: model || 'q-developer',
292
+ choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
293
+ usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens },
294
+ });
295
+ }
296
+ } catch (err) {
297
+ tagError('openai', err.message || err);
298
+ res.status(500).json({ error: { message: err.message } });
299
+ }
300
+ });
301
+
302
+ // ============================================================
303
+ // GET /v1/models
304
+ // ============================================================
305
+ app.get('/v1/models', async (_req, res) => {
306
+ try {
307
+ const tokenData = await getAccessToken();
308
+ const { models, defaultModel } = await listAvailableModels(tokenData.accessToken, {
309
+ profileArn: tokenData.profileArn, authMethod: tokenData.authMethod, provider: tokenData.provider,
310
+ });
311
+ res.json({
312
+ object: 'list',
313
+ data: models.map(m => ({
314
+ id: m.modelId, object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'amazon',
315
+ name: m.modelName || m.modelId, description: m.description,
316
+ is_default: defaultModel?.modelId === m.modelId,
317
+ })),
318
+ });
319
+ } catch (err) {
320
+ res.status(500).json({ error: { message: err.message } });
321
+ }
322
+ });
323
+
324
+ app.get('/q/models', async (_req, res) => {
325
+ try {
326
+ const tokenData = await getAccessToken();
327
+ const result = await listAvailableModels(tokenData.accessToken, {
328
+ profileArn: tokenData.profileArn, authMethod: tokenData.authMethod, provider: tokenData.provider,
329
+ });
330
+ res.json(result);
331
+ } catch (err) {
332
+ res.status(500).json({ error: { message: err.message } });
333
+ }
334
+ });
335
+
336
+ app.get('/health', async (_req, res) => {
337
+ try {
338
+ const tokenData = await getAccessToken();
339
+ const expired = tokenData.expiresAt && new Date(tokenData.expiresAt) < new Date();
340
+ res.json({ status: expired ? 'token_expired' : 'ok', provider: tokenData.provider || 'unknown', expiresAt: tokenData.expiresAt });
341
+ } catch (err) {
342
+ res.status(503).json({ status: 'error', message: err.message });
343
+ }
344
+ });
345
+
346
+ // ============================================================
347
+ // GET /credits — Usage statistics
348
+ // ============================================================
349
+ app.get('/credits', (_req, res) => {
350
+ const period = _req.query.period || 'today';
351
+ res.json(queryUsage(period));
352
+ });
353
+
354
+ app.listen(PORT, async () => {
355
+ console.log(`${c.cyan}Kiro Proxy${c.reset} running on ${c.green}http://localhost:${PORT}${c.reset}`);
356
+ console.log(` ${c.gray}Anthropic:${c.reset} http://localhost:${PORT}/v1/messages`);
357
+ console.log(` ${c.gray}OpenAI: ${c.reset} http://localhost:${PORT}/v1/chat/completions`);
358
+ console.log(` ${c.gray}Models: ${c.reset} http://localhost:${PORT}/v1/models`);
359
+ console.log(` ${c.gray}Credits: ${c.reset} http://localhost:${PORT}/credits`);
360
+ try {
361
+ const t = await getAccessToken();
362
+ console.log(` ${c.gray}Provider: ${c.yellow}${t.provider || 'unknown'}${c.reset}, Expires: ${c.dim}${t.expiresAt || 'unknown'}${c.reset}`);
363
+ } catch (err) {
364
+ console.warn(` ${c.yellow}Warning:${c.reset} ${err.message}`);
365
+ }
366
+ });
367
+
368
+ function shutdown() {
369
+ const today = todaySummary();
370
+ if (today.requests > 0) {
371
+ console.log(`\n${c.cyan}Today:${c.reset} ${c.yellow}${today.credits.toFixed(4)} credits${c.reset} (${today.requests} requests)`);
372
+ }
373
+ process.exit(0);
374
+ }
375
+
376
+ process.on('SIGINT', shutdown);
377
+ process.on('SIGTERM', shutdown);
@@ -0,0 +1,33 @@
1
+ function countText(text) {
2
+ if (!text) return 0;
3
+ return Math.round(text.length / 4);
4
+ }
5
+
6
+ export function countMessages(messages, system) {
7
+ let tokens = 0;
8
+ if (system) tokens += countText(typeof system === 'string' ? system : JSON.stringify(system));
9
+ for (const msg of messages || []) {
10
+ tokens += 4;
11
+ if (typeof msg.content === 'string') {
12
+ tokens += countText(msg.content);
13
+ } else if (Array.isArray(msg.content)) {
14
+ for (const block of msg.content) {
15
+ if (block.type === 'text') tokens += countText(block.text);
16
+ else if (block.type === 'tool_result') tokens += countText(typeof block.content === 'string' ? block.content : JSON.stringify(block.content));
17
+ else if (block.type === 'tool_use') tokens += countText(JSON.stringify(block.input));
18
+ }
19
+ }
20
+ }
21
+ return tokens;
22
+ }
23
+
24
+ export function countContent(content) {
25
+ if (typeof content === 'string') return countText(content);
26
+ let tokens = 0;
27
+ for (const block of content || []) {
28
+ if (block.type === 'text') tokens += countText(block.text);
29
+ else if (block.type === 'tool_use') tokens += countText(JSON.stringify(block.input));
30
+ else if (block.type === 'thinking') tokens += countText(block.thinking);
31
+ }
32
+ return tokens;
33
+ }
@@ -0,0 +1,246 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { tagLog, tagWarn, tagError } from './logger.js';
5
+
6
+ const SSO_CACHE_DIR = path.join(os.homedir(), '.aws', 'sso', 'cache');
7
+ const KIRO_TOKEN_FILE = 'kiro-auth-token.json';
8
+
9
+ // Social 刷新 URL
10
+ const SOCIAL_REFRESH_URL = 'https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken';
11
+
12
+ // token 提前刷新的缓冲时间(5 分钟)
13
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000;
14
+
15
+ // Kiro profile 缓存路径
16
+ const KIRO_PROFILE_PATHS = [
17
+ path.join(os.homedir(), 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent', 'profile.json'),
18
+ path.join(os.homedir(), '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent', 'profile.json'),
19
+ path.join(os.homedir(), 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent', 'profile.json'),
20
+ ];
21
+
22
+ // 内存缓存
23
+ let cachedToken = null;
24
+ let refreshPromise = null;
25
+
26
+ /**
27
+ * 读取 ~/.aws/sso/cache/kiro-auth-token.json
28
+ */
29
+ function readKiroToken() {
30
+ const tokenPath = path.join(SSO_CACHE_DIR, KIRO_TOKEN_FILE);
31
+ if (!fs.existsSync(tokenPath)) return null;
32
+ try {
33
+ return JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
34
+ } catch { return null; }
35
+ }
36
+
37
+ /**
38
+ * 写回 token 到磁盘(让 Kiro 也能用刷新后的 token)
39
+ */
40
+ function writeKiroToken(tokenData) {
41
+ try {
42
+ const tokenPath = path.join(SSO_CACHE_DIR, KIRO_TOKEN_FILE);
43
+ fs.mkdirSync(SSO_CACHE_DIR, { recursive: true });
44
+ fs.writeFileSync(tokenPath, JSON.stringify(tokenData, null, 2));
45
+ } catch (err) {
46
+ tagWarn('token', 'Failed to write token to disk:', err.message);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * 读取 Kiro profile 缓存
52
+ */
53
+ function readKiroProfile() {
54
+ for (const p of KIRO_PROFILE_PATHS) {
55
+ try {
56
+ if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8'));
57
+ } catch { /* skip */ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * 读取 IdC 的 client registration(从 ~/.aws/sso/cache/{hash}.json)
64
+ */
65
+ function readClientRegistration(clientIdHash) {
66
+ if (!clientIdHash) return null;
67
+ const filePath = path.join(SSO_CACHE_DIR, `${clientIdHash}.json`);
68
+ try {
69
+ if (fs.existsSync(filePath)) return JSON.parse(fs.readFileSync(filePath, 'utf8'));
70
+ } catch { /* skip */ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * 判断 token 是否过期或即将过期
76
+ */
77
+ function isTokenExpired(tokenData) {
78
+ if (!tokenData?.expiresAt) return true;
79
+ return new Date(tokenData.expiresAt).getTime() < Date.now() + REFRESH_BUFFER_MS;
80
+ }
81
+
82
+ // ============================================================
83
+ // Social token 刷新(Google / Github 登录)
84
+ // POST https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken
85
+ // ============================================================
86
+ async function refreshSocialToken(tokenData) {
87
+ tagLog('token', 'Refreshing Social token...');
88
+ const res = await fetch(SOCIAL_REFRESH_URL, {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ refreshToken: tokenData.refreshToken }),
92
+ });
93
+
94
+ if (!res.ok) {
95
+ const body = await res.text();
96
+ throw new Error(`Social token refresh failed (${res.status}): ${body}`);
97
+ }
98
+
99
+ const data = await res.json();
100
+ // 响应: { accessToken, refreshToken?, expiresIn, profileArn? }
101
+ const now = new Date();
102
+ const expiresAt = new Date(now.getTime() + (data.expiresIn || 3600) * 1000).toISOString();
103
+
104
+ return {
105
+ ...tokenData,
106
+ accessToken: data.accessToken,
107
+ // refreshToken 可能会更新
108
+ ...(data.refreshToken && { refreshToken: data.refreshToken }),
109
+ ...(data.profileArn && { profileArn: data.profileArn }),
110
+ expiresAt,
111
+ };
112
+ }
113
+
114
+ // ============================================================
115
+ // IdC token 刷新(Enterprise / BuilderId)
116
+ // POST https://oidc.us-east-1.amazonaws.com/token
117
+ // ============================================================
118
+ async function refreshIdCToken(tokenData) {
119
+ tagLog('token', 'Refreshing IdC token...');
120
+
121
+ // 读取 client registration
122
+ const clientReg = readClientRegistration(tokenData.clientIdHash);
123
+ if (!clientReg?.clientId || !clientReg?.clientSecret) {
124
+ throw new Error('IdC refresh failed: no valid client registration found. Please re-login in Kiro.');
125
+ }
126
+
127
+ const region = tokenData.region || 'us-east-1';
128
+ const endpoint = `https://oidc.${region}.amazonaws.com/token`;
129
+
130
+ const res = await fetch(endpoint, {
131
+ method: 'POST',
132
+ headers: { 'Content-Type': 'application/json' },
133
+ body: JSON.stringify({
134
+ clientId: clientReg.clientId,
135
+ clientSecret: clientReg.clientSecret,
136
+ grantType: 'refresh_token',
137
+ refreshToken: tokenData.refreshToken,
138
+ }),
139
+ });
140
+
141
+ if (!res.ok) {
142
+ const body = await res.text();
143
+ throw new Error(`IdC token refresh failed (${res.status}): ${body}`);
144
+ }
145
+
146
+ const data = await res.json();
147
+ const now = new Date();
148
+ const expiresAt = new Date(now.getTime() + (data.expiresIn || 3600) * 1000).toISOString();
149
+
150
+ return {
151
+ ...tokenData,
152
+ accessToken: data.accessToken,
153
+ ...(data.refreshToken && { refreshToken: data.refreshToken }),
154
+ expiresAt,
155
+ };
156
+ }
157
+
158
+ // ============================================================
159
+ // 刷新调度
160
+ // ============================================================
161
+ async function refreshToken(tokenData) {
162
+ const method = tokenData.authMethod;
163
+ if (method === 'social' || method === 'Social') {
164
+ return refreshSocialToken(tokenData);
165
+ }
166
+ if (method === 'IdC' || method === 'idc') {
167
+ return refreshIdCToken(tokenData);
168
+ }
169
+ throw new Error(`Unknown auth method: ${method}. Cannot refresh token.`);
170
+ }
171
+
172
+ /**
173
+ * 获取可用的 access token
174
+ * - 优先使用内存缓存
175
+ * - 过期时自动刷新(带去重,避免并发刷新)
176
+ * - 刷新后写回磁盘
177
+ * - 如果没有 profileArn,从 Kiro profile 缓存补充
178
+ */
179
+ export async function getAccessToken() {
180
+ // 1. 内存缓存未过期,直接返回
181
+ if (cachedToken && !isTokenExpired(cachedToken)) {
182
+ return cachedToken;
183
+ }
184
+
185
+ // 2. 从磁盘读取
186
+ let tokenData = readKiroToken();
187
+ if (!tokenData?.accessToken) {
188
+ throw new Error('No token found in ~/.aws/sso/cache/kiro-auth-token.json. Please login in Kiro first.');
189
+ }
190
+
191
+ // 3. 如果未过期,缓存并返回
192
+ if (!isTokenExpired(tokenData)) {
193
+ tokenData = enrichWithProfile(tokenData);
194
+ cachedToken = tokenData;
195
+ return tokenData;
196
+ }
197
+
198
+ // 4. 过期了,需要刷新
199
+ if (!tokenData.refreshToken) {
200
+ throw new Error('Token expired and no refreshToken available. Please re-login in Kiro.');
201
+ }
202
+
203
+ // 去重:如果已经有刷新在进行,等待它完成
204
+ if (refreshPromise) {
205
+ tagLog('token', 'Waiting for ongoing refresh...');
206
+ return refreshPromise;
207
+ }
208
+
209
+ refreshPromise = (async () => {
210
+ try {
211
+ tagLog('token', `Token expired (${tokenData.expiresAt}), refreshing...`);
212
+ const newToken = await refreshToken(tokenData);
213
+ const enriched = enrichWithProfile(newToken);
214
+
215
+ // 写回磁盘
216
+ writeKiroToken(enriched);
217
+ cachedToken = enriched;
218
+
219
+ tagLog('token', `Token refreshed, new expiry: ${enriched.expiresAt}`);
220
+ return enriched;
221
+ } catch (err) {
222
+ tagError('token', 'Refresh failed:', err.message);
223
+ // 刷新失败,如果旧 token 还没完全过期(只是在缓冲期内),仍然可以用
224
+ if (tokenData.expiresAt && new Date(tokenData.expiresAt) > new Date()) {
225
+ tagWarn('token', 'Using existing token despite refresh failure');
226
+ cachedToken = enrichWithProfile(tokenData);
227
+ return cachedToken;
228
+ }
229
+ throw err;
230
+ } finally {
231
+ refreshPromise = null;
232
+ }
233
+ })();
234
+
235
+ return refreshPromise;
236
+ }
237
+
238
+ function enrichWithProfile(tokenData) {
239
+ if (!tokenData.profileArn) {
240
+ const profile = readKiroProfile();
241
+ if (profile?.arn) {
242
+ tokenData.profileArn = profile.arn;
243
+ }
244
+ }
245
+ return tokenData;
246
+ }