waygate-mmcode 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +196 -2
- package/dist/activity.d.ts +79 -0
- package/dist/activity.js +183 -0
- package/dist/adapter.d.ts +23 -0
- package/dist/adapter.js +150 -0
- package/dist/credential.d.ts +49 -0
- package/dist/credential.js +175 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +16 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14 -0
- package/dist/protocol/stream-decode.d.ts +37 -0
- package/dist/protocol/stream-decode.js +207 -0
- package/dist/protocol/translate.d.ts +42 -0
- package/dist/protocol/translate.js +209 -0
- package/package.json +46 -4
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 出站翻译:OpenAI Chat Completions → Anthropic Messages(v0.5.0)。
|
|
3
|
+
*
|
|
4
|
+
* 服务于 anthropic 协议的上游(MiniMax Code 的 mavis 网关)。与入站翻译
|
|
5
|
+
* `../anthropic/translate-request.ts`(Anthropic→OpenAI)互为镜像:字段映射
|
|
6
|
+
* 从那边逐条对照反推,两边的语义必须保持一致。
|
|
7
|
+
*
|
|
8
|
+
* 设计取舍:
|
|
9
|
+
* · **流式恒开**:与 openai 路径同口径(本服务的管道是流式原生的);
|
|
10
|
+
* · max_tokens 必填(Anthropic 契约):缺省回落 8192;
|
|
11
|
+
* · tool 消息折叠:OpenAI 的 `role:"tool"` 序列必须并成紧跟其后的 user 消息里的
|
|
12
|
+
* tool_result 块(Anthropic 契约);
|
|
13
|
+
* · 连续同角色消息合并(Anthropic 要求 user/assistant 交替,OpenAI 不要求);
|
|
14
|
+
* · 未识别字段静默忽略 —— 与两个入站方向的口径一致。
|
|
15
|
+
*/
|
|
16
|
+
function isTextPart(p) {
|
|
17
|
+
return typeof p === 'object' && p !== null && p.type === 'text';
|
|
18
|
+
}
|
|
19
|
+
function isImagePart(p) {
|
|
20
|
+
return typeof p === 'object' && p !== null && p.type === 'image_url';
|
|
21
|
+
}
|
|
22
|
+
/** OpenAI 多模态 content parts → Anthropic blocks;纯文本折叠成 string(省 token)。 */
|
|
23
|
+
function contentToBlocks(content) {
|
|
24
|
+
if (typeof content === 'string')
|
|
25
|
+
return content;
|
|
26
|
+
if (!Array.isArray(content))
|
|
27
|
+
return '';
|
|
28
|
+
const blocks = [];
|
|
29
|
+
for (const part of content) {
|
|
30
|
+
if (isTextPart(part))
|
|
31
|
+
blocks.push({ type: 'text', text: part.text });
|
|
32
|
+
else if (isImagePart(part)) {
|
|
33
|
+
const url = part.image_url?.url ?? '';
|
|
34
|
+
const m = /^data:([^;]+);base64,(.+)$/s.exec(url);
|
|
35
|
+
if (m) {
|
|
36
|
+
blocks.push({ type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } });
|
|
37
|
+
}
|
|
38
|
+
else if (url) {
|
|
39
|
+
blocks.push({ type: 'image', source: { type: 'url', url } });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/* 其他 part 类型(audio 等)静默忽略 —— mavis 当前不接受 */
|
|
43
|
+
}
|
|
44
|
+
const onlyText = blocks.every((b) => b.type === 'text');
|
|
45
|
+
if (onlyText)
|
|
46
|
+
return blocks.map((b) => String(b.text)).join('');
|
|
47
|
+
return blocks;
|
|
48
|
+
}
|
|
49
|
+
/** assistant 消息:content → text 块;tool_calls → tool_use 块(arguments JSON 解回对象)。 */
|
|
50
|
+
function assistantToBlocks(msg) {
|
|
51
|
+
const blocks = [];
|
|
52
|
+
const content = msg.content;
|
|
53
|
+
const text = typeof content === 'string' ? content : Array.isArray(content)
|
|
54
|
+
? content.filter(isTextPart).map((p) => p.text).join('')
|
|
55
|
+
: '';
|
|
56
|
+
if (text)
|
|
57
|
+
blocks.push({ type: 'text', text });
|
|
58
|
+
const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
59
|
+
for (const call of calls) {
|
|
60
|
+
if (typeof call !== 'object' || call === null)
|
|
61
|
+
continue;
|
|
62
|
+
const fn = call.function ?? {};
|
|
63
|
+
let input = {};
|
|
64
|
+
try {
|
|
65
|
+
input = fn.arguments ? JSON.parse(fn.arguments) : {};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
input = {}; /* 坏 JSON 不传上游 —— Anthropic 会整请求 400 */
|
|
69
|
+
}
|
|
70
|
+
blocks.push({
|
|
71
|
+
type: 'tool_use',
|
|
72
|
+
id: typeof call.id === 'string' ? call.id : '',
|
|
73
|
+
name: typeof fn.name === 'string' ? fn.name : '',
|
|
74
|
+
input,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (blocks.length === 0)
|
|
78
|
+
blocks.push({ type: 'text', text: '' });
|
|
79
|
+
return blocks;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 消息序列转换(核心难点在 tool 消息的归位):
|
|
83
|
+
* OpenAI 的 `role:"tool"`(带 tool_call_id)必须紧跟 assistant(tool_calls) 之后出现;
|
|
84
|
+
* Anthropic 要求它们变成**下一条 user 消息**里的 tool_result 块。这里把连续的
|
|
85
|
+
* tool 消息攒成一批,遇到下一个非 tool 消息时先产出一条只含 tool_result 的 user
|
|
86
|
+
* 消息(若无后续消息则单独产出)——与入站翻译的拆分逻辑严格互逆。
|
|
87
|
+
* system/developer 消息抽出为顶层 system 文本。
|
|
88
|
+
*/
|
|
89
|
+
function convertMessages(messages) {
|
|
90
|
+
const systemTexts = [];
|
|
91
|
+
const out = [];
|
|
92
|
+
let pendingToolResults = [];
|
|
93
|
+
const flushTools = () => {
|
|
94
|
+
if (pendingToolResults.length > 0) {
|
|
95
|
+
out.push({ role: 'user', content: pendingToolResults });
|
|
96
|
+
pendingToolResults = [];
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
for (const msg of messages) {
|
|
100
|
+
if (!msg || typeof msg !== 'object')
|
|
101
|
+
continue;
|
|
102
|
+
const role = msg.role;
|
|
103
|
+
const raw = msg;
|
|
104
|
+
if (role === 'system' || role === 'developer') {
|
|
105
|
+
const t = typeof raw.content === 'string' ? raw.content
|
|
106
|
+
: Array.isArray(raw.content) ? raw.content.filter(isTextPart).map((p) => p.text).join('') : '';
|
|
107
|
+
if (t)
|
|
108
|
+
systemTexts.push(t);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (role === 'tool') {
|
|
112
|
+
pendingToolResults.push({
|
|
113
|
+
type: 'tool_result',
|
|
114
|
+
tool_use_id: typeof raw.tool_call_id === 'string' ? raw.tool_call_id : '',
|
|
115
|
+
content: typeof raw.content === 'string' ? raw.content
|
|
116
|
+
: Array.isArray(raw.content) ? raw.content.filter(isTextPart).map((p) => p.text).join('') : '',
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
flushTools();
|
|
121
|
+
if (role === 'assistant') {
|
|
122
|
+
out.push({ role: 'assistant', content: assistantToBlocks(raw) });
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
out.push({ role: 'user', content: contentToBlocks(raw.content) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
flushTools();
|
|
129
|
+
/* 合并连续同角色(Anthropic 要求交替;OpenAI 常见 system 后连续 user)。
|
|
130
|
+
合并只发生在边界处,块内容原样拼接 —— 语义无损。 */
|
|
131
|
+
const merged = [];
|
|
132
|
+
for (const m of out) {
|
|
133
|
+
const prev = merged[merged.length - 1];
|
|
134
|
+
if (prev && prev.role === m.role && typeof prev.content === 'string' && typeof m.content === 'string') {
|
|
135
|
+
prev.content = `${prev.content}\n${m.content}`;
|
|
136
|
+
}
|
|
137
|
+
else if (prev && prev.role === m.role && Array.isArray(prev.content) && Array.isArray(m.content)) {
|
|
138
|
+
prev.content = [...prev.content, ...m.content];
|
|
139
|
+
}
|
|
140
|
+
else if (prev && prev.role === m.role) {
|
|
141
|
+
/* 一个 string 一个 blocks:拍平成 blocks 再拼 */
|
|
142
|
+
const a = typeof prev.content === 'string' ? [{ type: 'text', text: prev.content }] : prev.content;
|
|
143
|
+
const b = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : m.content;
|
|
144
|
+
prev.content = [...a, ...b];
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
merged.push({ ...m });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/* 首条必须是 user:若首条是 assistant(罕见),垫一条空 user。 */
|
|
151
|
+
const first = merged[0];
|
|
152
|
+
if (first && first.role !== 'user') {
|
|
153
|
+
merged.unshift({ role: 'user', content: '' });
|
|
154
|
+
}
|
|
155
|
+
return { messages: merged, system: systemTexts.join('\n') };
|
|
156
|
+
}
|
|
157
|
+
/** OpenAI tool_choice → Anthropic。兼容字符串函数名(WorkBuddy 方言)。 */
|
|
158
|
+
function convertToolChoice(choice) {
|
|
159
|
+
if (choice === undefined || choice === null)
|
|
160
|
+
return undefined;
|
|
161
|
+
if (typeof choice === 'string') {
|
|
162
|
+
if (choice === 'auto' || choice === 'none')
|
|
163
|
+
return { type: choice };
|
|
164
|
+
if (choice === 'required')
|
|
165
|
+
return { type: 'any' };
|
|
166
|
+
return { type: 'tool', name: choice }; // 裸函数名(方言):按指定工具
|
|
167
|
+
}
|
|
168
|
+
if (typeof choice === 'object') {
|
|
169
|
+
const obj = choice;
|
|
170
|
+
if (obj.type === 'function' && obj.function?.name)
|
|
171
|
+
return { type: 'tool', name: obj.function.name };
|
|
172
|
+
if (obj.type === 'auto' || obj.type === 'any' || obj.type === 'none')
|
|
173
|
+
return { type: obj.type };
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
/** 翻译入口。输入是本服务内部的 ChatRequestBody(已过 rewrite 层)。 */
|
|
178
|
+
export function toAnthropicRequest(body) {
|
|
179
|
+
const { messages, system } = convertMessages(body.messages ?? []);
|
|
180
|
+
const out = {
|
|
181
|
+
model: body.model,
|
|
182
|
+
max_tokens: typeof body.max_tokens === 'number' && Number.isFinite(body.max_tokens) && body.max_tokens > 0
|
|
183
|
+
? body.max_tokens
|
|
184
|
+
: 8192,
|
|
185
|
+
stream: true,
|
|
186
|
+
messages,
|
|
187
|
+
};
|
|
188
|
+
if (system)
|
|
189
|
+
out.system = system;
|
|
190
|
+
if (typeof body.temperature === 'number' && Number.isFinite(body.temperature))
|
|
191
|
+
out.temperature = body.temperature;
|
|
192
|
+
if (typeof body.top_p === 'number' && Number.isFinite(body.top_p))
|
|
193
|
+
out.top_p = body.top_p;
|
|
194
|
+
const tools = Array.isArray(body.tools) ? body.tools : [];
|
|
195
|
+
const converted = tools
|
|
196
|
+
.filter((t) => typeof t === 'object' && t !== null && t.type === 'function')
|
|
197
|
+
.map((t) => ({
|
|
198
|
+
name: t.function?.name ?? '',
|
|
199
|
+
description: t.function?.description ?? '',
|
|
200
|
+
input_schema: t.function?.parameters ?? { type: 'object', properties: {} },
|
|
201
|
+
}))
|
|
202
|
+
.filter((t) => t.name !== '');
|
|
203
|
+
if (converted.length > 0)
|
|
204
|
+
out.tools = converted;
|
|
205
|
+
const choice = convertToolChoice(body.tool_choice);
|
|
206
|
+
if (choice)
|
|
207
|
+
out.tool_choice = choice;
|
|
208
|
+
return out;
|
|
209
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "waygate-mmcode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MiniMax Code 本地反代:读取本机客户端登录态,提供 Anthropic/OpenAI 兼容的本地聊天端点",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"minimax",
|
|
9
|
+
"minimax-code",
|
|
10
|
+
"mmcode2api",
|
|
11
|
+
"reverse-proxy",
|
|
12
|
+
"openai-compatible",
|
|
13
|
+
"anthropic",
|
|
14
|
+
"claude-code",
|
|
15
|
+
"cursor",
|
|
16
|
+
"checkin",
|
|
17
|
+
"credits"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"mmcode": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
35
|
+
"dev": "node --experimental-strip-types src/index.ts"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.10.0",
|
|
39
|
+
"typescript": "^5.7.2",
|
|
40
|
+
"vitest": "^2.1.9"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"waygate-core": "^0.2.0"
|
|
47
|
+
}
|
|
48
|
+
}
|