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/README.md +90 -0
- package/logger.js +82 -0
- package/package.json +28 -0
- package/q-client.js +501 -0
- package/server.js +377 -0
- package/token-counter.js +33 -0
- package/token-reader.js +246 -0
- package/usage-tracker.js +112 -0
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);
|
package/token-counter.js
ADDED
|
@@ -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
|
+
}
|
package/token-reader.js
ADDED
|
@@ -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
|
+
}
|