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/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
[English](README_EN.md) | 中文
|
|
2
|
+
|
|
3
|
+
# kiro-proxy
|
|
4
|
+
|
|
5
|
+
让 [Kiro](https://kiro.dev) 订阅内含的 Claude 模型可以在 Claude Code 中使用。
|
|
6
|
+
|
|
7
|
+
通过读取 Kiro 的认证 token,代理请求到 Amazon Q Developer,暴露 OpenAI 和 Anthropic 兼容的 API 接口。
|
|
8
|
+
|
|
9
|
+
## 前提
|
|
10
|
+
|
|
11
|
+
需要先安装并登录 Kiro,确保 `~/.aws/sso/cache/kiro-auth-token.json` 存在且未过期。
|
|
12
|
+
|
|
13
|
+
## 快速开始
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @colin3191/kiro-proxy
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
服务默认监听 `http://localhost:3456`。
|
|
20
|
+
|
|
21
|
+
## 配置
|
|
22
|
+
|
|
23
|
+
| 环境变量 | 默认值 | 说明 |
|
|
24
|
+
|----------|--------|------|
|
|
25
|
+
| `PORT` | `3456` | 监听端口 |
|
|
26
|
+
|
|
27
|
+
## API
|
|
28
|
+
|
|
29
|
+
### GET /v1/models — 查询可用模型
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
curl http://localhost:3456/v1/models
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### POST /v1/chat/completions — OpenAI 兼容
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# 非流式
|
|
39
|
+
curl http://localhost:3456/v1/chat/completions \
|
|
40
|
+
-H "Content-Type: application/json" \
|
|
41
|
+
-d '{"model": "claude-sonnet-4.6", "messages": [{"role": "user", "content": "Hello"}]}'
|
|
42
|
+
|
|
43
|
+
# 流式
|
|
44
|
+
curl http://localhost:3456/v1/chat/completions \
|
|
45
|
+
-H "Content-Type: application/json" \
|
|
46
|
+
-d '{"model": "claude-sonnet-4.6", "messages": [{"role": "user", "content": "Hello"}], "stream": true}'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### POST /v1/messages — Anthropic 兼容
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# 非流式
|
|
53
|
+
curl http://localhost:3456/v1/messages \
|
|
54
|
+
-H "Content-Type: application/json" \
|
|
55
|
+
-H "x-api-key: any" \
|
|
56
|
+
-d '{"model": "claude-sonnet-4.6", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello"}]}'
|
|
57
|
+
|
|
58
|
+
# 流式
|
|
59
|
+
curl http://localhost:3456/v1/messages \
|
|
60
|
+
-H "Content-Type: application/json" \
|
|
61
|
+
-H "x-api-key: any" \
|
|
62
|
+
-d '{"model": "claude-sonnet-4.6", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello"}], "stream": true}'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### GET /health
|
|
66
|
+
|
|
67
|
+
检查 token 状态及过期时间。
|
|
68
|
+
|
|
69
|
+
## 与 Claude Code 集成
|
|
70
|
+
|
|
71
|
+
Claude Code 默认使用 Anthropic 官方 model ID,需要通过环境变量映射到 Q Developer 的 model ID。
|
|
72
|
+
|
|
73
|
+
在 `~/.claude/settings.json` 中添加:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"env": {
|
|
78
|
+
"ANTHROPIC_AUTH_TOKEN": "any",
|
|
79
|
+
"ANTHROPIC_BASE_URL": "http://localhost:3456",
|
|
80
|
+
"ANTHROPIC_MODEL": "claude-sonnet-4.6",
|
|
81
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4.6",
|
|
82
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4.6",
|
|
83
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4.5"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## 相关项目
|
|
89
|
+
|
|
90
|
+
- [kiro-web-search](https://github.com/Colin3191/kiro-web-search) — 将 Kiro 内置的联网搜索封装为 MCP server,可在 Claude Code 等客户端中使用
|
package/logger.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
export const c = {
|
|
4
|
+
reset: '\x1b[0m',
|
|
5
|
+
dim: '\x1b[2m',
|
|
6
|
+
cyan: '\x1b[36m',
|
|
7
|
+
green: '\x1b[32m',
|
|
8
|
+
yellow: '\x1b[33m',
|
|
9
|
+
blue: '\x1b[34m',
|
|
10
|
+
magenta: '\x1b[35m',
|
|
11
|
+
gray: '\x1b[90m',
|
|
12
|
+
red: '\x1b[31m',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const METHOD_COLORS = {
|
|
16
|
+
POST: c.green,
|
|
17
|
+
GET: c.blue,
|
|
18
|
+
DELETE: c.yellow,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function timestamp() {
|
|
22
|
+
const now = new Date();
|
|
23
|
+
return c.gray + now.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(now.getMilliseconds()).padStart(3, '0') + c.reset;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 生成 3 字符的短请求 ID
|
|
28
|
+
*/
|
|
29
|
+
export function reqId() {
|
|
30
|
+
return crypto.randomBytes(2).toString('hex').slice(0, 3);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* HTTP 请求日志(server.js 用)
|
|
35
|
+
* 格式: 12:34:56.789 POST /v1/messages [a3f] model=xxx
|
|
36
|
+
*/
|
|
37
|
+
export function log(method, path, id, info) {
|
|
38
|
+
const methodColor = METHOD_COLORS[method] || c.magenta;
|
|
39
|
+
const parts = [timestamp()];
|
|
40
|
+
if (id) parts.push(c.magenta + `[${id}]` + c.reset);
|
|
41
|
+
parts.push(methodColor + method + c.reset, c.cyan + path + c.reset);
|
|
42
|
+
if (info) parts.push(c.dim + (typeof info === 'string' ? info : JSON.stringify(info)) + c.reset);
|
|
43
|
+
console.log(parts.join(' '));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 带标签的日志(q-client.js / token-reader.js 用)
|
|
48
|
+
* 格式: 12:34:56.789 [token] Refreshing Social token...
|
|
49
|
+
*/
|
|
50
|
+
export function tagLog(tag, ...args) {
|
|
51
|
+
const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
|
52
|
+
console.log(`${timestamp()} ${c.cyan}[${tag}]${c.reset} ${msg}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function tagWarn(tag, ...args) {
|
|
56
|
+
const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
|
57
|
+
console.warn(`${timestamp()} ${c.yellow}[${tag}]${c.reset} ${msg}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function tagError(tag, ...args) {
|
|
61
|
+
const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
|
62
|
+
console.error(`${timestamp()} ${c.red}[${tag}]${c.reset} ${msg}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 请求完成汇总行
|
|
67
|
+
* 格式: 12:34:56.789 └─ [a3f] 4.07s context=0.21% 0.0096 credits
|
|
68
|
+
*/
|
|
69
|
+
export function logSummary(id, elapsed, stats) {
|
|
70
|
+
const duration = elapsed >= 1000 ? `${(elapsed / 1000).toFixed(2)}s` : `${elapsed}ms`;
|
|
71
|
+
const parts = [];
|
|
72
|
+
parts.push(duration);
|
|
73
|
+
if (stats.context) parts.push(`context=${stats.context}`);
|
|
74
|
+
if (stats.metering) parts.push(stats.metering);
|
|
75
|
+
if (stats.tokens) parts.push(stats.tokens);
|
|
76
|
+
if (stats.estTokens) parts.push(stats.estTokens);
|
|
77
|
+
if (stats.links) parts.push(stats.links);
|
|
78
|
+
if (stats.invalid) parts.push(`invalid: ${stats.invalid}`);
|
|
79
|
+
if (stats.codeRef) parts.push(`codeRef: ${typeof stats.codeRef === 'string' ? stats.codeRef : JSON.stringify(stats.codeRef)}`);
|
|
80
|
+
const tag = id ? `${c.magenta}[${id}]${c.reset} ` : '';
|
|
81
|
+
console.log(`${timestamp()} ${tag}${c.dim}${parts.join(' | ')}${c.reset}`);
|
|
82
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kiro-proxy",
|
|
3
|
+
"version": "0.1.15",
|
|
4
|
+
"description": "Kiro API proxy with OpenAI and Anthropic compatible endpoints",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kiro-proxy": "./server.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"server.js",
|
|
14
|
+
"q-client.js",
|
|
15
|
+
"token-reader.js",
|
|
16
|
+
"token-counter.js",
|
|
17
|
+
"usage-tracker.js",
|
|
18
|
+
"logger.js"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/Colin3191/kiro-proxy.git"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@aws/codewhisperer-streaming-client": "^1.0.34",
|
|
26
|
+
"express": "^4.21.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/q-client.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { CodeWhispererStreaming, GenerateAssistantResponseCommand } from '@aws/codewhisperer-streaming-client';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
// region → endpoint 映射
|
|
6
|
+
const REGION_ENDPOINTS = {
|
|
7
|
+
'us-east-1': 'https://q.us-east-1.amazonaws.com',
|
|
8
|
+
'eu-west-1': 'https://q.eu-west-1.amazonaws.com',
|
|
9
|
+
'ap-southeast-1': 'https://q.ap-southeast-1.amazonaws.com',
|
|
10
|
+
'ap-northeast-1': 'https://q.ap-northeast-1.amazonaws.com',
|
|
11
|
+
'eu-central-1': 'https://q.eu-central-1.amazonaws.com',
|
|
12
|
+
'ap-south-1': 'https://q.ap-south-1.amazonaws.com',
|
|
13
|
+
'ca-central-1': 'https://q.ca-central-1.amazonaws.com',
|
|
14
|
+
};
|
|
15
|
+
const DEFAULT_REGION = 'us-east-1';
|
|
16
|
+
const KIRO_VERSION = process.env.KIRO_VERSION || '0.11.107';
|
|
17
|
+
|
|
18
|
+
function buildUserAgent(machineId) {
|
|
19
|
+
return `KiroIDE ${KIRO_VERSION} ${machineId || os.hostname()}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function regionFromArn(arn) {
|
|
23
|
+
if (!arn) return null;
|
|
24
|
+
const parts = arn.split(':');
|
|
25
|
+
return parts.length >= 4 ? parts[3] : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function endpointForRegion(region) {
|
|
29
|
+
return REGION_ENDPOINTS[region] || `https://q.${region}.amazonaws.com`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function addRequiredHeaders(client, { agentMode = 'vibe', optOut = true, authMethod, provider } = {}) {
|
|
33
|
+
if (optOut) {
|
|
34
|
+
client.middlewareStack.add(
|
|
35
|
+
(next) => async (args) => {
|
|
36
|
+
args.request.headers = { ...args.request.headers, 'x-amzn-codewhisperer-optout': 'true' };
|
|
37
|
+
return next(args);
|
|
38
|
+
},
|
|
39
|
+
{ step: 'build', name: 'optOutHeader' }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
client.middlewareStack.add(
|
|
43
|
+
(next) => async (args) => {
|
|
44
|
+
args.request.headers = { ...args.request.headers, 'x-amzn-kiro-agent-mode': agentMode };
|
|
45
|
+
return next(args);
|
|
46
|
+
},
|
|
47
|
+
{ step: 'build', name: 'agentModeHeader' }
|
|
48
|
+
);
|
|
49
|
+
if (authMethod === 'external_idp') {
|
|
50
|
+
client.middlewareStack.add(
|
|
51
|
+
(next) => async (args) => {
|
|
52
|
+
args.request.headers = { ...args.request.headers, TokenType: 'EXTERNAL_IDP' };
|
|
53
|
+
return next(args);
|
|
54
|
+
},
|
|
55
|
+
{ step: 'build', name: 'tokenTypeHeader' }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (provider === 'Internal') {
|
|
59
|
+
client.middlewareStack.add(
|
|
60
|
+
(next) => async (args) => {
|
|
61
|
+
args.request.headers = { ...args.request.headers, 'redirect-for-internal': 'true' };
|
|
62
|
+
return next(args);
|
|
63
|
+
},
|
|
64
|
+
{ step: 'build', name: 'redirectForInternal' }
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createClient(accessToken, { endpoint, region, authMethod, profileArn, provider, machineId } = {}) {
|
|
70
|
+
const arnRegion = regionFromArn(profileArn);
|
|
71
|
+
const finalRegion = region || arnRegion || DEFAULT_REGION;
|
|
72
|
+
const finalEndpoint = endpoint || endpointForRegion(finalRegion);
|
|
73
|
+
|
|
74
|
+
const client = new CodeWhispererStreaming({
|
|
75
|
+
region: finalRegion,
|
|
76
|
+
endpoint: finalEndpoint,
|
|
77
|
+
token: { token: accessToken },
|
|
78
|
+
customUserAgent: buildUserAgent(machineId),
|
|
79
|
+
});
|
|
80
|
+
addRequiredHeaders(client, { authMethod, provider });
|
|
81
|
+
return client;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================
|
|
85
|
+
// Anthropic tools → CodeWhisperer toolSpecification
|
|
86
|
+
// ============================================================
|
|
87
|
+
function convertTools(tools) {
|
|
88
|
+
if (!tools || tools.length === 0) return undefined;
|
|
89
|
+
return tools
|
|
90
|
+
.filter(t => t.name !== 'web_search' && t.name !== 'websearch')
|
|
91
|
+
.map(t => ({
|
|
92
|
+
toolSpecification: {
|
|
93
|
+
name: t.name,
|
|
94
|
+
description: (t.description || '').slice(0, 10000),
|
|
95
|
+
inputSchema: { json: t.input_schema || t.parameters || {} },
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ============================================================
|
|
101
|
+
// Anthropic messages → CodeWhisperer conversationState
|
|
102
|
+
// ============================================================
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 从 Anthropic content blocks 中提取图片,转换为 CodeWhisperer 格式
|
|
106
|
+
* Anthropic 格式: { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }
|
|
107
|
+
* CodeWhisperer 格式: { format: "png", source: { bytes: Buffer } }
|
|
108
|
+
*/
|
|
109
|
+
function extractImages(content) {
|
|
110
|
+
if (!Array.isArray(content)) return [];
|
|
111
|
+
const formatMap = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/gif': 'gif', 'image/webp': 'webp' };
|
|
112
|
+
const images = [];
|
|
113
|
+
|
|
114
|
+
for (const block of content) {
|
|
115
|
+
if (block.type === 'image' && block.source) {
|
|
116
|
+
if (block.source.type === 'base64' && block.source.data) {
|
|
117
|
+
const format = formatMap[block.source.media_type] || 'jpeg';
|
|
118
|
+
images.push({ format, source: { bytes: Buffer.from(block.source.data, 'base64') } });
|
|
119
|
+
} else if (block.source.type === 'url' && block.source.url) {
|
|
120
|
+
// data URL: data:image/png;base64,iVBOR...
|
|
121
|
+
const url = block.source.url;
|
|
122
|
+
if (url.startsWith('data:')) {
|
|
123
|
+
const parts = url.split(',');
|
|
124
|
+
if (parts.length >= 2) {
|
|
125
|
+
const mimeMatch = parts[0].match(/data:(image\/\w+)/);
|
|
126
|
+
const format = mimeMatch ? (formatMap[mimeMatch[1]] || 'jpeg') : 'jpeg';
|
|
127
|
+
images.push({ format, source: { bytes: Buffer.from(parts[1], 'base64') } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// LangChain/OpenAI 格式: { type: "image_url", image_url: { url: "data:..." } }
|
|
133
|
+
if (block.type === 'image_url' && block.image_url) {
|
|
134
|
+
const url = typeof block.image_url === 'string' ? block.image_url : block.image_url.url;
|
|
135
|
+
if (url?.startsWith('data:')) {
|
|
136
|
+
const parts = url.split(',');
|
|
137
|
+
if (parts.length >= 2) {
|
|
138
|
+
const mimeMatch = parts[0].match(/data:(image\/\w+)/);
|
|
139
|
+
const format = mimeMatch ? (formatMap[mimeMatch[1]] || 'jpeg') : 'jpeg';
|
|
140
|
+
images.push({ format, source: { bytes: Buffer.from(parts[1], 'base64') } });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return images;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 从 Anthropic content blocks 中提取文本
|
|
150
|
+
*/
|
|
151
|
+
function extractText(content) {
|
|
152
|
+
if (typeof content === 'string') return content;
|
|
153
|
+
if (!Array.isArray(content)) return '';
|
|
154
|
+
return content
|
|
155
|
+
.filter(b => b.type === 'text')
|
|
156
|
+
.map(b => b.text)
|
|
157
|
+
.join('');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 从 assistant content blocks 中提取 tool_use 调用
|
|
162
|
+
*/
|
|
163
|
+
function extractToolUses(content) {
|
|
164
|
+
if (!Array.isArray(content)) return [];
|
|
165
|
+
return content
|
|
166
|
+
.filter(b => b.type === 'tool_use')
|
|
167
|
+
.map(b => ({ toolUseId: b.id, name: b.name, input: b.input || {} }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 从 user content blocks 中提取 tool_result
|
|
172
|
+
*/
|
|
173
|
+
function extractToolResults(content) {
|
|
174
|
+
if (!Array.isArray(content)) return [];
|
|
175
|
+
return content
|
|
176
|
+
.filter(b => b.type === 'tool_result')
|
|
177
|
+
.map(b => {
|
|
178
|
+
let resultContent;
|
|
179
|
+
if (typeof b.content === 'string') {
|
|
180
|
+
resultContent = [{ text: b.content }];
|
|
181
|
+
} else if (Array.isArray(b.content)) {
|
|
182
|
+
resultContent = b.content.map(c => {
|
|
183
|
+
if (typeof c === 'string') return { text: c };
|
|
184
|
+
if (c.type === 'text') return { text: c.text };
|
|
185
|
+
return { text: JSON.stringify(c) };
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
resultContent = [{ text: '' }];
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
toolUseId: b.tool_use_id,
|
|
192
|
+
content: resultContent,
|
|
193
|
+
status: b.is_error ? 'error' : 'success',
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 将 Anthropic 格式的 messages + tools + system 转换为 CodeWhisperer conversationState
|
|
200
|
+
* 支持完整的工具调用循环
|
|
201
|
+
*/
|
|
202
|
+
export function convertMessages(messages, { modelId, system, tools } = {}) {
|
|
203
|
+
const validModelId = modelId || undefined;
|
|
204
|
+
const cwTools = convertTools(tools);
|
|
205
|
+
const history = [];
|
|
206
|
+
|
|
207
|
+
// system → 注入为第一轮 user/assistant 对
|
|
208
|
+
if (system) {
|
|
209
|
+
const sysText = typeof system === 'string' ? system : system.map(b => b.text || '').join('\n');
|
|
210
|
+
if (sysText) {
|
|
211
|
+
history.push({
|
|
212
|
+
userInputMessage: {
|
|
213
|
+
content: sysText, modelId: validModelId, origin: 'AI_EDITOR',
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
history.push({ assistantResponseMessage: { content: 'OK' } });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 遍历 messages,构建 history
|
|
221
|
+
for (const msg of messages) {
|
|
222
|
+
if (msg.role === 'user') {
|
|
223
|
+
const text = extractText(msg.content);
|
|
224
|
+
const toolResults = extractToolResults(msg.content);
|
|
225
|
+
const images = extractImages(msg.content);
|
|
226
|
+
|
|
227
|
+
if (toolResults.length > 0) {
|
|
228
|
+
// tool_result 消息:content 为空,结果放在 userInputMessageContext.toolResults
|
|
229
|
+
history.push({
|
|
230
|
+
userInputMessage: {
|
|
231
|
+
content: '',
|
|
232
|
+
modelId: validModelId,
|
|
233
|
+
origin: 'AI_EDITOR',
|
|
234
|
+
userInputMessageContext: { toolResults },
|
|
235
|
+
...(images.length > 0 && { images }),
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
} else {
|
|
239
|
+
history.push({
|
|
240
|
+
userInputMessage: {
|
|
241
|
+
content: text, modelId: validModelId, origin: 'AI_EDITOR',
|
|
242
|
+
...(images.length > 0 && { images }),
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
} else if (msg.role === 'assistant') {
|
|
247
|
+
const text = extractText(msg.content);
|
|
248
|
+
const toolUses = extractToolUses(msg.content);
|
|
249
|
+
history.push({
|
|
250
|
+
assistantResponseMessage: {
|
|
251
|
+
content: text,
|
|
252
|
+
toolUses: toolUses.length > 0 ? toolUses : undefined,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// system 已在上面处理
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 确保 history 以 user→assistant 交替,末尾是 user
|
|
260
|
+
// 如果末尾是 assistant(不应该发生),追加空 user
|
|
261
|
+
const last = history.at(-1);
|
|
262
|
+
if (last?.assistantResponseMessage) {
|
|
263
|
+
history.push({
|
|
264
|
+
userInputMessage: { content: 'Continue.', modelId: validModelId, origin: 'AI_EDITOR' },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const currentMessage = history.at(-1);
|
|
269
|
+
// 将 tools 注入到 currentMessage
|
|
270
|
+
if (cwTools && currentMessage?.userInputMessage) {
|
|
271
|
+
currentMessage.userInputMessage.userInputMessageContext = {
|
|
272
|
+
...currentMessage.userInputMessage.userInputMessageContext,
|
|
273
|
+
tools: cwTools,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
conversationId: crypto.randomUUID(),
|
|
279
|
+
currentMessage,
|
|
280
|
+
history: history.slice(0, -1),
|
|
281
|
+
chatTriggerType: 'MANUAL',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ============================================================
|
|
286
|
+
// 流式调用,返回 text + tool_use 事件
|
|
287
|
+
// ============================================================
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 流式调用 Q Developer,yield text 和 tool_use 事件
|
|
291
|
+
* Claude Code 需要完整的 tool_use 块来驱动工具循环
|
|
292
|
+
*/
|
|
293
|
+
export async function* chatStream(client, { messages, system, tools, profileArn, modelId } = {}) {
|
|
294
|
+
const conversationState = convertMessages(messages, { modelId, system, tools });
|
|
295
|
+
const command = new GenerateAssistantResponseCommand({
|
|
296
|
+
conversationState,
|
|
297
|
+
profileArn,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const response = await client.send(command);
|
|
301
|
+
if (!response.generateAssistantResponseResponse) {
|
|
302
|
+
throw new Error('Empty response from Q Developer');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 跟踪当前的 tool_use 状态
|
|
306
|
+
const activeTools = new Map(); // toolUseId → { name, inputChunks }
|
|
307
|
+
// 收集统计信息,流结束后汇总输出
|
|
308
|
+
const stats = {};
|
|
309
|
+
let meteringUsage = 0;
|
|
310
|
+
|
|
311
|
+
for await (const event of response.generateAssistantResponseResponse) {
|
|
312
|
+
// 文本内容
|
|
313
|
+
if (event.assistantResponseEvent?.content) {
|
|
314
|
+
yield {
|
|
315
|
+
type: 'content',
|
|
316
|
+
content: event.assistantResponseEvent.content,
|
|
317
|
+
modelId: event.assistantResponseEvent.modelId,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// thinking/reasoning 内容
|
|
322
|
+
if (event.reasoningContentEvent) {
|
|
323
|
+
if (event.reasoningContentEvent.text) {
|
|
324
|
+
yield { type: 'thinking', text: event.reasoningContentEvent.text };
|
|
325
|
+
}
|
|
326
|
+
if (event.reasoningContentEvent.signature) {
|
|
327
|
+
yield { type: 'thinking_signature', signature: event.reasoningContentEvent.signature };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 计费/用量事件
|
|
332
|
+
if (event.meteringEvent) {
|
|
333
|
+
const m = event.meteringEvent;
|
|
334
|
+
stats.metering = `${m.usage?.toFixed(4) ?? '?'} ${m.unitPlural || m.unit || 'units'}`;
|
|
335
|
+
if (typeof m.usage === 'number') meteringUsage = m.usage;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 代码引用/许可证事件
|
|
339
|
+
if (event.codeReferenceEvent) {
|
|
340
|
+
stats.codeRef = event.codeReferenceEvent;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 上下文使用率事件
|
|
344
|
+
if (event.contextUsageEvent) {
|
|
345
|
+
stats.context = `${(event.contextUsageEvent.contextUsagePercentage ?? 0).toFixed(2)}%`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// token 用量事件
|
|
349
|
+
if (event.metadataEvent?.tokenUsage) {
|
|
350
|
+
const t = event.metadataEvent.tokenUsage;
|
|
351
|
+
const parts = [`in=${t.uncachedInputTokens ?? 0}`, `out=${t.outputTokens ?? 0}`];
|
|
352
|
+
if (t.cacheReadInputTokens) parts.push(`cache_read=${t.cacheReadInputTokens}`);
|
|
353
|
+
if (t.cacheWriteInputTokens) parts.push(`cache_write=${t.cacheWriteInputTokens}`);
|
|
354
|
+
parts.push(`total=${t.totalTokens ?? 0}`);
|
|
355
|
+
stats.tokens = parts.join(' ');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 无效状态事件(错误)
|
|
359
|
+
if (event.invalidStateEvent) {
|
|
360
|
+
stats.invalid = `${event.invalidStateEvent.reason}: ${event.invalidStateEvent.message}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 补充链接事件
|
|
364
|
+
if (event.supplementaryWebLinksEvent?.supplementaryWebLinks?.length) {
|
|
365
|
+
const links = event.supplementaryWebLinksEvent.supplementaryWebLinks;
|
|
366
|
+
stats.links = `${links.length} ref(s): ${links.map(l => l.url || l.title).join(', ')}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 工具调用事件
|
|
370
|
+
if (event.toolUseEvent) {
|
|
371
|
+
const { toolUseId, name, input, stop } = event.toolUseEvent;
|
|
372
|
+
|
|
373
|
+
if (toolUseId && name && !activeTools.has(toolUseId)) {
|
|
374
|
+
// 新工具调用开始
|
|
375
|
+
activeTools.set(toolUseId, { name, inputChunks: [] });
|
|
376
|
+
yield { type: 'tool_use_start', toolUseId, name };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 累积 input 片段
|
|
380
|
+
if (toolUseId && input) {
|
|
381
|
+
const tool = activeTools.get(toolUseId);
|
|
382
|
+
if (tool) tool.inputChunks.push(input);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 工具调用结束
|
|
386
|
+
if (stop) {
|
|
387
|
+
for (const [id, tool] of activeTools) {
|
|
388
|
+
// 合并 input 片段并解析
|
|
389
|
+
let parsedInput = {};
|
|
390
|
+
const raw = tool.inputChunks.join('');
|
|
391
|
+
if (raw) {
|
|
392
|
+
try { parsedInput = JSON.parse(raw); } catch { parsedInput = { raw }; }
|
|
393
|
+
}
|
|
394
|
+
yield { type: 'tool_use_end', toolUseId: id, name: tool.name, input: parsedInput };
|
|
395
|
+
}
|
|
396
|
+
activeTools.clear();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 如果流结束时还有未关闭的工具调用,强制关闭
|
|
402
|
+
for (const [id, tool] of activeTools) {
|
|
403
|
+
let parsedInput = {};
|
|
404
|
+
const raw = tool.inputChunks.join('');
|
|
405
|
+
if (raw) {
|
|
406
|
+
try { parsedInput = JSON.parse(raw); } catch { parsedInput = { raw }; }
|
|
407
|
+
}
|
|
408
|
+
yield { type: 'tool_use_end', toolUseId: id, name: tool.name, input: parsedInput };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 汇总统计信息
|
|
412
|
+
yield { type: 'summary', stats, meteringUsage };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 非流式调用
|
|
417
|
+
*/
|
|
418
|
+
export async function chat(client, { messages, system, tools, profileArn, modelId } = {}) {
|
|
419
|
+
const content = [];
|
|
420
|
+
let usedModelId;
|
|
421
|
+
let thinkingText = '';
|
|
422
|
+
let thinkingSignature;
|
|
423
|
+
let stats;
|
|
424
|
+
let meteringUsage = 0;
|
|
425
|
+
|
|
426
|
+
for await (const event of chatStream(client, { messages, system, tools, profileArn, modelId })) {
|
|
427
|
+
if (event.type === 'thinking') {
|
|
428
|
+
thinkingText += event.text;
|
|
429
|
+
} else if (event.type === 'thinking_signature') {
|
|
430
|
+
thinkingSignature = event.signature;
|
|
431
|
+
} else if (event.type === 'content') {
|
|
432
|
+
// 如果有累积的 thinking,先输出 thinking block
|
|
433
|
+
if (thinkingText && !content.some(b => b.type === 'thinking')) {
|
|
434
|
+
content.push({ type: 'thinking', thinking: thinkingText, signature: thinkingSignature || '' });
|
|
435
|
+
}
|
|
436
|
+
if (!content.length || content.at(-1).type !== 'text') {
|
|
437
|
+
content.push({ type: 'text', text: '' });
|
|
438
|
+
}
|
|
439
|
+
content.at(-1).text += event.content;
|
|
440
|
+
usedModelId = event.modelId;
|
|
441
|
+
} else if (event.type === 'tool_use_end') {
|
|
442
|
+
// 如果有累积的 thinking,先输出
|
|
443
|
+
if (thinkingText && !content.some(b => b.type === 'thinking')) {
|
|
444
|
+
content.push({ type: 'thinking', thinking: thinkingText, signature: thinkingSignature || '' });
|
|
445
|
+
}
|
|
446
|
+
content.push({ type: 'tool_use', id: event.toolUseId, name: event.name, input: event.input });
|
|
447
|
+
} else if (event.type === 'summary') {
|
|
448
|
+
stats = event.stats;
|
|
449
|
+
meteringUsage = event.meteringUsage;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 如果只有 thinking 没有其他内容
|
|
454
|
+
if (thinkingText && !content.some(b => b.type === 'thinking')) {
|
|
455
|
+
content.unshift({ type: 'thinking', thinking: thinkingText, signature: thinkingSignature || '' });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const hasToolUse = content.some(b => b.type === 'tool_use');
|
|
459
|
+
return { content, stopReason: hasToolUse ? 'tool_use' : 'end_turn', modelId: usedModelId, stats, meteringUsage };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ============================================================
|
|
463
|
+
// ListAvailableModels
|
|
464
|
+
// ============================================================
|
|
465
|
+
|
|
466
|
+
export async function listAvailableModels(accessToken, { profileArn, authMethod, provider, machineId } = {}) {
|
|
467
|
+
const arnRegion = regionFromArn(profileArn);
|
|
468
|
+
const region = arnRegion || DEFAULT_REGION;
|
|
469
|
+
const endpoint = endpointForRegion(region);
|
|
470
|
+
|
|
471
|
+
const params = new URLSearchParams({ origin: 'AI_EDITOR' });
|
|
472
|
+
if (profileArn) params.set('profileArn', profileArn);
|
|
473
|
+
|
|
474
|
+
const headers = {
|
|
475
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
476
|
+
'User-Agent': buildUserAgent(machineId),
|
|
477
|
+
'x-amzn-codewhisperer-optout': 'true',
|
|
478
|
+
};
|
|
479
|
+
if (authMethod === 'external_idp') headers['TokenType'] = 'EXTERNAL_IDP';
|
|
480
|
+
if (provider === 'Internal') headers['redirect-for-internal'] = 'true';
|
|
481
|
+
|
|
482
|
+
const allModels = [];
|
|
483
|
+
let defaultModel = null;
|
|
484
|
+
let nextToken;
|
|
485
|
+
|
|
486
|
+
do {
|
|
487
|
+
if (nextToken) params.set('nextToken', nextToken);
|
|
488
|
+
const url = `${endpoint}/ListAvailableModels?${params}`;
|
|
489
|
+
const res = await fetch(url, { headers });
|
|
490
|
+
if (!res.ok) {
|
|
491
|
+
const body = await res.text();
|
|
492
|
+
throw new Error(`ListAvailableModels failed (${res.status}): ${body}`);
|
|
493
|
+
}
|
|
494
|
+
const data = await res.json();
|
|
495
|
+
allModels.push(...(data.models || []));
|
|
496
|
+
if (data.defaultModel && !defaultModel) defaultModel = data.defaultModel;
|
|
497
|
+
nextToken = data.nextToken;
|
|
498
|
+
} while (nextToken);
|
|
499
|
+
|
|
500
|
+
return { models: allModels, defaultModel };
|
|
501
|
+
}
|