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,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MiniMax Code 凭证:读本机客户端登录态 + access token 生命周期。
|
|
3
|
+
*
|
|
4
|
+
* ## 客户端登录态(明文 JSON,比 Qoder 简单)
|
|
5
|
+
* %USERPROFILE%/.minimax/auth/prod/cn/mcode-public/auth.json
|
|
6
|
+
* { schemaVersion, records: { "<clientKey>": { accessToken, refreshToken, expiresAtMs, … } } }
|
|
7
|
+
* —— `records` 只有一个条目(当前登录账号)。**明文**,无需解密。
|
|
8
|
+
*
|
|
9
|
+
* ## ⚠️ 凭证互斥(本工具最大的坑,比 Qoder 严重)
|
|
10
|
+
* MiniMax 的 refresh token 是**一次性**的:刷新成功后旧 token 立刻作废。
|
|
11
|
+
* 且**同一账号同时只有一个有效会话** —— 本工具与官方客户端同时用一个账号会**互相踢下线**。
|
|
12
|
+
* ⇒ 强烈建议给本工具配**专用账号**(与日常客户端分开)。
|
|
13
|
+
*
|
|
14
|
+
* ## token 生命周期
|
|
15
|
+
* · access token(`mmoat_`)寿命短(约 1–4 小时),过期前 2 分钟自动刷新;
|
|
16
|
+
* · refresh token(`mmort_`)刷新后会被上游**轮换**,必须写回本地(否则下次刷新失败)。
|
|
17
|
+
*/
|
|
18
|
+
/** 客户端登录态文件(可用 MINIMAX_AUTH_FILE 覆盖)。 */
|
|
19
|
+
export declare function clientAuthFile(): string;
|
|
20
|
+
/** 本工具自己的凭证落盘位置(`~/.waygate/mmcode/credential.json`)。 */
|
|
21
|
+
export declare function credentialPath(): string;
|
|
22
|
+
export interface MinimaxCredential {
|
|
23
|
+
accessToken: string;
|
|
24
|
+
refreshToken: string;
|
|
25
|
+
/** access token 过期时刻(epoch ms)。 */
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
/** 账号标识(客户端文件里的 uid/uin,若有;仅用于展示)。 */
|
|
28
|
+
account?: string;
|
|
29
|
+
}
|
|
30
|
+
/** 从客户端 auth.json 读出第一条记录(当前登录账号)。 */
|
|
31
|
+
export declare function readClientCredential(): MinimaxCredential;
|
|
32
|
+
/** 读本工具已落盘的凭证(无则 null)。 */
|
|
33
|
+
export declare function readSavedCredential(): MinimaxCredential | null;
|
|
34
|
+
/** 落盘(0600)。refresh token 轮换后也要调用本函数写回。 */
|
|
35
|
+
export declare function saveCredential(cred: MinimaxCredential): string;
|
|
36
|
+
/**
|
|
37
|
+
* token 管理器:缓存 access token,过期前自动刷新(并发去重),轮换时写回。
|
|
38
|
+
*
|
|
39
|
+
* 与 relay 里那个 Nest 服务同语义,但去掉了框架依赖 —— 核心就是「到期就换、换完写回」。
|
|
40
|
+
*/
|
|
41
|
+
export declare class MinimaxTokenManager {
|
|
42
|
+
private readonly onLog;
|
|
43
|
+
private cached;
|
|
44
|
+
private inFlight;
|
|
45
|
+
constructor(onLog?: (line: string) => void);
|
|
46
|
+
/** 取可用 access token(必要时刷新并写回)。 */
|
|
47
|
+
accessToken(): Promise<string>;
|
|
48
|
+
private refresh;
|
|
49
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MiniMax Code 凭证:读本机客户端登录态 + access token 生命周期。
|
|
3
|
+
*
|
|
4
|
+
* ## 客户端登录态(明文 JSON,比 Qoder 简单)
|
|
5
|
+
* %USERPROFILE%/.minimax/auth/prod/cn/mcode-public/auth.json
|
|
6
|
+
* { schemaVersion, records: { "<clientKey>": { accessToken, refreshToken, expiresAtMs, … } } }
|
|
7
|
+
* —— `records` 只有一个条目(当前登录账号)。**明文**,无需解密。
|
|
8
|
+
*
|
|
9
|
+
* ## ⚠️ 凭证互斥(本工具最大的坑,比 Qoder 严重)
|
|
10
|
+
* MiniMax 的 refresh token 是**一次性**的:刷新成功后旧 token 立刻作废。
|
|
11
|
+
* 且**同一账号同时只有一个有效会话** —— 本工具与官方客户端同时用一个账号会**互相踢下线**。
|
|
12
|
+
* ⇒ 强烈建议给本工具配**专用账号**(与日常客户端分开)。
|
|
13
|
+
*
|
|
14
|
+
* ## token 生命周期
|
|
15
|
+
* · access token(`mmoat_`)寿命短(约 1–4 小时),过期前 2 分钟自动刷新;
|
|
16
|
+
* · refresh token(`mmort_`)刷新后会被上游**轮换**,必须写回本地(否则下次刷新失败)。
|
|
17
|
+
*/
|
|
18
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { commandDir } from 'waygate-core';
|
|
22
|
+
import { CredentialError, UpstreamError } from './errors.js';
|
|
23
|
+
const MINIMAX_TOKEN_ENDPOINT = 'https://account.minimax.cn/oauth2/token';
|
|
24
|
+
/** 提前刷新余量:过期前 120s 内的 token 视为不可用(时钟偏差 + 请求耗时)。 */
|
|
25
|
+
const REFRESH_MARGIN_MS = 120_000;
|
|
26
|
+
/** MiniMax Code 客户端公开的 OAuth 客户端参数(与桌面端同源)。 */
|
|
27
|
+
const OAUTH_FORM = {
|
|
28
|
+
client_id: 'mcode-public',
|
|
29
|
+
scope: 'agent.default',
|
|
30
|
+
audience: 'agent-backend',
|
|
31
|
+
};
|
|
32
|
+
/** 客户端登录态文件(可用 MINIMAX_AUTH_FILE 覆盖)。 */
|
|
33
|
+
export function clientAuthFile() {
|
|
34
|
+
const override = (process.env.MINIMAX_AUTH_FILE ?? '').trim();
|
|
35
|
+
if (override)
|
|
36
|
+
return override;
|
|
37
|
+
const home = process.env.USERPROFILE ?? homedir();
|
|
38
|
+
return join(home, '.minimax', 'auth', 'prod', 'cn', 'mcode-public', 'auth.json');
|
|
39
|
+
}
|
|
40
|
+
/** 本工具自己的凭证落盘位置(`~/.waygate/mmcode/credential.json`)。 */
|
|
41
|
+
export function credentialPath() {
|
|
42
|
+
return join(commandDir('mmcode'), 'credential.json');
|
|
43
|
+
}
|
|
44
|
+
/** 从客户端 auth.json 读出第一条记录(当前登录账号)。 */
|
|
45
|
+
export function readClientCredential() {
|
|
46
|
+
const file = clientAuthFile();
|
|
47
|
+
if (!existsSync(file)) {
|
|
48
|
+
throw new CredentialError(`找不到客户端登录态 ${file} —— 请先在 MiniMax Code 客户端登录;或用 MINIMAX_AUTH_FILE 指定文件`);
|
|
49
|
+
}
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw new CredentialError(`客户端登录态不是合法 JSON:${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
const records = parsed.records ?? {};
|
|
58
|
+
const keys = Object.keys(records);
|
|
59
|
+
if (keys.length === 0) {
|
|
60
|
+
throw new CredentialError('客户端当前未登录(auth.json 无记录)—— 请在客户端登录后重试');
|
|
61
|
+
}
|
|
62
|
+
const rec = records[keys[0]];
|
|
63
|
+
const accessToken = typeof rec.accessToken === 'string' ? rec.accessToken : '';
|
|
64
|
+
const refreshToken = typeof rec.refreshToken === 'string' ? rec.refreshToken : '';
|
|
65
|
+
if (!accessToken || !refreshToken) {
|
|
66
|
+
throw new CredentialError('登录态缺少 accessToken 或 refreshToken —— 客户端未登录?请在客户端登录后重试');
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
accessToken,
|
|
70
|
+
refreshToken,
|
|
71
|
+
expiresAt: typeof rec.expiresAtMs === 'number' ? rec.expiresAtMs : 0,
|
|
72
|
+
account: typeof rec.loginEpoch === 'string' ? rec.loginEpoch.slice(0, 8) : undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** 读本工具已落盘的凭证(无则 null)。 */
|
|
76
|
+
export function readSavedCredential() {
|
|
77
|
+
const file = credentialPath();
|
|
78
|
+
if (!existsSync(file))
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const c = JSON.parse(readFileSync(file, 'utf8'));
|
|
82
|
+
return c.accessToken && c.refreshToken ? c : null;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** 落盘(0600)。refresh token 轮换后也要调用本函数写回。 */
|
|
89
|
+
export function saveCredential(cred) {
|
|
90
|
+
const path = credentialPath();
|
|
91
|
+
writeFileSync(path, JSON.stringify(cred, null, 2) + '\n', { mode: 0o600 });
|
|
92
|
+
try {
|
|
93
|
+
chmodSync(path, 0o600);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* Windows 语义有限 */
|
|
97
|
+
}
|
|
98
|
+
return path;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* token 管理器:缓存 access token,过期前自动刷新(并发去重),轮换时写回。
|
|
102
|
+
*
|
|
103
|
+
* 与 relay 里那个 Nest 服务同语义,但去掉了框架依赖 —— 核心就是「到期就换、换完写回」。
|
|
104
|
+
*/
|
|
105
|
+
export class MinimaxTokenManager {
|
|
106
|
+
onLog;
|
|
107
|
+
cached = null;
|
|
108
|
+
inFlight = null;
|
|
109
|
+
constructor(onLog = () => { }) {
|
|
110
|
+
this.onLog = onLog;
|
|
111
|
+
}
|
|
112
|
+
/** 取可用 access token(必要时刷新并写回)。 */
|
|
113
|
+
async accessToken() {
|
|
114
|
+
const cred = this.cached ?? readSavedCredential() ?? readClientCredential();
|
|
115
|
+
this.cached = cred;
|
|
116
|
+
if (Date.now() + REFRESH_MARGIN_MS < cred.expiresAt)
|
|
117
|
+
return cred.accessToken;
|
|
118
|
+
if (this.inFlight)
|
|
119
|
+
return this.inFlight;
|
|
120
|
+
const job = this.refresh(cred).finally(() => {
|
|
121
|
+
this.inFlight = null;
|
|
122
|
+
});
|
|
123
|
+
this.inFlight = job;
|
|
124
|
+
return job;
|
|
125
|
+
}
|
|
126
|
+
async refresh(cred) {
|
|
127
|
+
if (!cred.refreshToken.startsWith('mmort_')) {
|
|
128
|
+
throw new CredentialError('凭证不是有效的 MiniMax refresh token(应以 mmort_ 开头)—— 请用 mmcode login 重新导入');
|
|
129
|
+
}
|
|
130
|
+
const form = new URLSearchParams({
|
|
131
|
+
grant_type: 'refresh_token',
|
|
132
|
+
refresh_token: cred.refreshToken,
|
|
133
|
+
...OAUTH_FORM,
|
|
134
|
+
});
|
|
135
|
+
let res;
|
|
136
|
+
try {
|
|
137
|
+
res = await fetch(MINIMAX_TOKEN_ENDPOINT, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
140
|
+
body: form.toString(),
|
|
141
|
+
signal: AbortSignal.timeout(20_000),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
throw new UpstreamError(`刷新令牌失败(account.minimax.cn 不可达):${err.message}`);
|
|
146
|
+
}
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
const raw = await res.text().catch(() => '');
|
|
149
|
+
throw new CredentialError(`刷新令牌被拒(HTTP ${res.status}):${raw.slice(0, 200) || '无响应体'}。` +
|
|
150
|
+
`refresh token 可能已失效或已被官方客户端占用 —— 在客户端重新登录后跑 mmcode login。`);
|
|
151
|
+
}
|
|
152
|
+
const grant = (await res.json().catch(() => null));
|
|
153
|
+
if (!grant || typeof grant.access_token !== 'string') {
|
|
154
|
+
throw new CredentialError('令牌刷新响应缺少 access_token');
|
|
155
|
+
}
|
|
156
|
+
const expiresAt = typeof grant.expires_at_ms === 'number' && grant.expires_at_ms > Date.now()
|
|
157
|
+
? grant.expires_at_ms
|
|
158
|
+
: typeof grant.expires_in === 'number' && grant.expires_in > 0
|
|
159
|
+
? Date.now() + (grant.expires_in > 100_000 ? grant.expires_in : grant.expires_in * 1000)
|
|
160
|
+
: Date.now() + 3_600_000;
|
|
161
|
+
const next = {
|
|
162
|
+
...cred,
|
|
163
|
+
accessToken: grant.access_token,
|
|
164
|
+
expiresAt,
|
|
165
|
+
};
|
|
166
|
+
/* refresh token 轮换(一次性)—— 必须写回,否则下次刷新用的是已作废的旧值。 */
|
|
167
|
+
if (typeof grant.refresh_token === 'string' && grant.refresh_token.startsWith('mmort_')) {
|
|
168
|
+
next.refreshToken = grant.refresh_token;
|
|
169
|
+
}
|
|
170
|
+
this.cached = next;
|
|
171
|
+
saveCredential(next);
|
|
172
|
+
this.onLog(`令牌已刷新(有效至 ${new Date(expiresAt).toISOString()})`);
|
|
173
|
+
return next.accessToken;
|
|
174
|
+
}
|
|
175
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上游错误(core 会兜成 502 + OpenAI 形状错误体给客户端)。
|
|
3
|
+
*/
|
|
4
|
+
export declare class UpstreamError extends Error {
|
|
5
|
+
readonly name = "UpstreamError";
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
/** 凭证类错误(启动/doctor 用:提示用户跑 login,而不是抛栈)。 */
|
|
9
|
+
export declare class CredentialError extends Error {
|
|
10
|
+
readonly name = "CredentialError";
|
|
11
|
+
constructor(message: string);
|
|
12
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上游错误(core 会兜成 502 + OpenAI 形状错误体给客户端)。
|
|
3
|
+
*/
|
|
4
|
+
export class UpstreamError extends Error {
|
|
5
|
+
name = 'UpstreamError';
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** 凭证类错误(启动/doctor 用:提示用户跑 login,而不是抛栈)。 */
|
|
11
|
+
export class CredentialError extends Error {
|
|
12
|
+
name = 'CredentialError';
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
}
|
|
16
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* waygate-mmcode —— MiniMax Code 本地反代(命令 `mmcode`)。
|
|
4
|
+
*
|
|
5
|
+
* 读取本机 MiniMax Code 客户端的登录态,在本机提供 Anthropic/OpenAI 兼容的聊天端点。
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ 凭证互斥:MiniMax 同一账号只能有一个有效会话 —— 本工具与官方客户端同账号会互相踢下线。
|
|
8
|
+
* 建议给本工具配专用账号。详见 README。
|
|
9
|
+
*/
|
|
10
|
+
import { runCli } from 'waygate-core';
|
|
11
|
+
import { createMmcodeAdapter } from './adapter.js';
|
|
12
|
+
const VERSION = '0.2.0';
|
|
13
|
+
const code = await runCli(createMmcodeAdapter(), { version: VERSION });
|
|
14
|
+
process.exit(code);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic SSE → OpenAI SSE 字节流转换器(v0.5.0,出站 anthropic 协议的响应方向)。
|
|
3
|
+
*
|
|
4
|
+
* 形态是 **TransformStream**:吃 mavis 上游的 Anthropic 事件流,吐 OpenAI 形状的
|
|
5
|
+
* `data: {chat.completion.chunk}` 行。这样 `readUpstreamSse`(首字超时/空闲看门狗/
|
|
6
|
+
* 聚合/落库/断流判定)**一行不改**地复用 —— mavis 上游在管道里"看起来"就是 OpenAI
|
|
7
|
+
* 兼容上游。与入站编码器 `../anthropic/stream-events.ts` 互为镜像。
|
|
8
|
+
*
|
|
9
|
+
* 事件映射:
|
|
10
|
+
* message_start → 首 chunk(delta.role;记录 id/model/input usage)
|
|
11
|
+
* content_block_start{tool_use} → chunk delta.tool_calls[{index,id,type,name}]
|
|
12
|
+
* content_block_delta{text_delta} → chunk delta.content
|
|
13
|
+
* content_block_delta{thinking_delta} → chunk delta.reasoning_content
|
|
14
|
+
* content_block_delta{input_json_delta} → chunk delta.tool_calls[].function.arguments
|
|
15
|
+
* message_delta → 记录 stop_reason / usage(output 权威累计值)
|
|
16
|
+
* message_stop → 收尾 chunk(finish_reason + usage 全量)+ [DONE]
|
|
17
|
+
* error → 抛错(流错误 → readUpstreamSse 的失败路径)
|
|
18
|
+
*
|
|
19
|
+
* usage 口径(与 usage-tokens.ts 的权威字段对齐):
|
|
20
|
+
* prompt_tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens
|
|
21
|
+
* completion_tokens = output_tokens
|
|
22
|
+
* prompt_tokens_details.cached_tokens = cache_read_input_tokens
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ [v0.5.10 纠偏] Anthropic 的 `input_tokens` **不含**缓存部分(生产实测:
|
|
25
|
+
* input=43 而 cache_read=128 —— 43 个 token 的 prompt 读不出 128 个缓存 token)。
|
|
26
|
+
* 此前头注写"input_tokens 是全量,含缓存命中"是错的,照它实现导致
|
|
27
|
+
* prompt_tokens 被低估、且 cached_tokens > prompt_tokens(缓存命中率 297%)。
|
|
28
|
+
* 三个输入字段相加才是真输入;cached_tokens 只取**读**命中(cache_creation 是写,不算命中)。
|
|
29
|
+
* ⚠️ 不读顶层 cached_tokens —— 与 usage-tokens.ts 同理由:顶层字段有历史坑。
|
|
30
|
+
*
|
|
31
|
+
* 未收到 message_stop 就断流 → 不补 [DONE] → readUpstreamSse 按断流失败处理(语义正确)。
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* 转换流。收到 `message_stop` 后在收尾 chunk 之后补 `data: [DONE]`;
|
|
35
|
+
* 上游提前断流则不补 —— readUpstreamSse 按断流失败处理。
|
|
36
|
+
*/
|
|
37
|
+
export declare function anthropicToOpenAiSse(): TransformStream<Uint8Array, Uint8Array>;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic SSE → OpenAI SSE 字节流转换器(v0.5.0,出站 anthropic 协议的响应方向)。
|
|
3
|
+
*
|
|
4
|
+
* 形态是 **TransformStream**:吃 mavis 上游的 Anthropic 事件流,吐 OpenAI 形状的
|
|
5
|
+
* `data: {chat.completion.chunk}` 行。这样 `readUpstreamSse`(首字超时/空闲看门狗/
|
|
6
|
+
* 聚合/落库/断流判定)**一行不改**地复用 —— mavis 上游在管道里"看起来"就是 OpenAI
|
|
7
|
+
* 兼容上游。与入站编码器 `../anthropic/stream-events.ts` 互为镜像。
|
|
8
|
+
*
|
|
9
|
+
* 事件映射:
|
|
10
|
+
* message_start → 首 chunk(delta.role;记录 id/model/input usage)
|
|
11
|
+
* content_block_start{tool_use} → chunk delta.tool_calls[{index,id,type,name}]
|
|
12
|
+
* content_block_delta{text_delta} → chunk delta.content
|
|
13
|
+
* content_block_delta{thinking_delta} → chunk delta.reasoning_content
|
|
14
|
+
* content_block_delta{input_json_delta} → chunk delta.tool_calls[].function.arguments
|
|
15
|
+
* message_delta → 记录 stop_reason / usage(output 权威累计值)
|
|
16
|
+
* message_stop → 收尾 chunk(finish_reason + usage 全量)+ [DONE]
|
|
17
|
+
* error → 抛错(流错误 → readUpstreamSse 的失败路径)
|
|
18
|
+
*
|
|
19
|
+
* usage 口径(与 usage-tokens.ts 的权威字段对齐):
|
|
20
|
+
* prompt_tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens
|
|
21
|
+
* completion_tokens = output_tokens
|
|
22
|
+
* prompt_tokens_details.cached_tokens = cache_read_input_tokens
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ [v0.5.10 纠偏] Anthropic 的 `input_tokens` **不含**缓存部分(生产实测:
|
|
25
|
+
* input=43 而 cache_read=128 —— 43 个 token 的 prompt 读不出 128 个缓存 token)。
|
|
26
|
+
* 此前头注写"input_tokens 是全量,含缓存命中"是错的,照它实现导致
|
|
27
|
+
* prompt_tokens 被低估、且 cached_tokens > prompt_tokens(缓存命中率 297%)。
|
|
28
|
+
* 三个输入字段相加才是真输入;cached_tokens 只取**读**命中(cache_creation 是写,不算命中)。
|
|
29
|
+
* ⚠️ 不读顶层 cached_tokens —— 与 usage-tokens.ts 同理由:顶层字段有历史坑。
|
|
30
|
+
*
|
|
31
|
+
* 未收到 message_stop 就断流 → 不补 [DONE] → readUpstreamSse 按断流失败处理(语义正确)。
|
|
32
|
+
*/
|
|
33
|
+
import { UpstreamError } from '../errors.js';
|
|
34
|
+
/** Anthropic stop_reason → OpenAI finish_reason(与入站 mapStopReason 互逆)。 */
|
|
35
|
+
function mapFinish(stop) {
|
|
36
|
+
switch (stop) {
|
|
37
|
+
case 'end_turn':
|
|
38
|
+
case 'stop_sequence':
|
|
39
|
+
return 'stop';
|
|
40
|
+
case 'max_tokens':
|
|
41
|
+
return 'length';
|
|
42
|
+
case 'tool_use':
|
|
43
|
+
return 'tool_calls';
|
|
44
|
+
case 'refusal':
|
|
45
|
+
return 'content_filter';
|
|
46
|
+
default:
|
|
47
|
+
return stop ? 'stop' : null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** 真输入 = 非缓存输入 + 缓存写入 + 缓存读取(三者都过 prompt,见头注纠偏)。 */
|
|
51
|
+
function totalInput(state) {
|
|
52
|
+
return state.inputTokens + state.cacheCreation + state.cacheRead;
|
|
53
|
+
}
|
|
54
|
+
function chunkJson(state, delta, finish = null, withUsage = false) {
|
|
55
|
+
const chunk = {
|
|
56
|
+
id: state.id,
|
|
57
|
+
object: 'chat.completion.chunk',
|
|
58
|
+
created: state.created,
|
|
59
|
+
model: state.model,
|
|
60
|
+
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
61
|
+
};
|
|
62
|
+
if (withUsage) {
|
|
63
|
+
const prompt = totalInput(state);
|
|
64
|
+
chunk.usage = {
|
|
65
|
+
prompt_tokens: prompt,
|
|
66
|
+
completion_tokens: state.outputTokens,
|
|
67
|
+
total_tokens: prompt + state.outputTokens,
|
|
68
|
+
prompt_tokens_details: { cached_tokens: state.cacheRead },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return JSON.stringify(chunk);
|
|
72
|
+
}
|
|
73
|
+
/** 处理单个 Anthropic 事件 JSON,返回应下发的 OpenAI chunk 行(null = 无输出)。 */
|
|
74
|
+
function onData(state, ev) {
|
|
75
|
+
switch (ev.type) {
|
|
76
|
+
case 'message_start': {
|
|
77
|
+
const msg = (ev.message ?? {});
|
|
78
|
+
state.id = typeof msg.id === 'string' ? msg.id : `msg_${state.created}`;
|
|
79
|
+
state.model = typeof msg.model === 'string' ? msg.model : '';
|
|
80
|
+
const usage = (msg.usage ?? {});
|
|
81
|
+
state.inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
|
|
82
|
+
state.outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0;
|
|
83
|
+
state.cacheRead = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0;
|
|
84
|
+
state.cacheCreation = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0;
|
|
85
|
+
return chunkJson(state, { role: 'assistant', content: '' });
|
|
86
|
+
}
|
|
87
|
+
case 'content_block_start': {
|
|
88
|
+
const index = typeof ev.index === 'number' ? ev.index : 0;
|
|
89
|
+
const block = (ev.content_block ?? {});
|
|
90
|
+
if (block.type === 'tool_use') {
|
|
91
|
+
const toolIndex = state.nextToolIndex++;
|
|
92
|
+
state.toolIndexByBlock.set(index, toolIndex);
|
|
93
|
+
return chunkJson(state, {
|
|
94
|
+
tool_calls: [{
|
|
95
|
+
index: toolIndex,
|
|
96
|
+
id: typeof block.id === 'string' ? block.id : '',
|
|
97
|
+
type: 'function',
|
|
98
|
+
function: { name: typeof block.name === 'string' ? block.name : '', arguments: '' },
|
|
99
|
+
}],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return null; // text/thinking 块首帧无内容,delta 里会带
|
|
103
|
+
}
|
|
104
|
+
case 'content_block_delta': {
|
|
105
|
+
const index = typeof ev.index === 'number' ? ev.index : 0;
|
|
106
|
+
const delta = (ev.delta ?? {});
|
|
107
|
+
if (delta.type === 'text_delta' && typeof delta.text === 'string' && delta.text !== '') {
|
|
108
|
+
return chunkJson(state, { content: delta.text });
|
|
109
|
+
}
|
|
110
|
+
if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string' && delta.thinking !== '') {
|
|
111
|
+
return chunkJson(state, { reasoning_content: delta.thinking });
|
|
112
|
+
}
|
|
113
|
+
if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string' && delta.partial_json !== '') {
|
|
114
|
+
const toolIndex = state.toolIndexByBlock.get(index) ?? 0;
|
|
115
|
+
return chunkJson(state, { tool_calls: [{ index: toolIndex, function: { arguments: delta.partial_json } }] });
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
case 'message_delta': {
|
|
120
|
+
const delta = (ev.delta ?? {});
|
|
121
|
+
if (typeof delta.stop_reason === 'string')
|
|
122
|
+
state.finish = mapFinish(delta.stop_reason);
|
|
123
|
+
const usage = (ev.usage ?? {});
|
|
124
|
+
if (typeof usage.output_tokens === 'number')
|
|
125
|
+
state.outputTokens = usage.output_tokens;
|
|
126
|
+
if (typeof usage.input_tokens === 'number')
|
|
127
|
+
state.inputTokens = usage.input_tokens;
|
|
128
|
+
if (typeof usage.cache_read_input_tokens === 'number')
|
|
129
|
+
state.cacheRead = usage.cache_read_input_tokens;
|
|
130
|
+
if (typeof usage.cache_creation_input_tokens === 'number')
|
|
131
|
+
state.cacheCreation = usage.cache_creation_input_tokens;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
case 'message_stop':
|
|
135
|
+
return chunkJson(state, {}, state.finish ?? 'stop', true);
|
|
136
|
+
case 'error': {
|
|
137
|
+
const err = (ev.error ?? {});
|
|
138
|
+
throw new UpstreamError(`上游(anthropic 协议)流内错误:${String(err.message ?? 'unknown')}`);
|
|
139
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
return null; // ping / content_block_stop / 未知事件:忽略
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 转换流。收到 `message_stop` 后在收尾 chunk 之后补 `data: [DONE]`;
|
|
146
|
+
* 上游提前断流则不补 —— readUpstreamSse 按断流失败处理。
|
|
147
|
+
*/
|
|
148
|
+
export function anthropicToOpenAiSse() {
|
|
149
|
+
const state = {
|
|
150
|
+
id: '',
|
|
151
|
+
model: '',
|
|
152
|
+
created: Math.floor(Date.now() / 1000),
|
|
153
|
+
finish: null,
|
|
154
|
+
inputTokens: 0,
|
|
155
|
+
outputTokens: 0,
|
|
156
|
+
cacheRead: 0,
|
|
157
|
+
cacheCreation: 0,
|
|
158
|
+
toolIndexByBlock: new Map(),
|
|
159
|
+
nextToolIndex: 0,
|
|
160
|
+
};
|
|
161
|
+
const encoder = new TextEncoder();
|
|
162
|
+
const decoder = new TextDecoder();
|
|
163
|
+
let buffer = '';
|
|
164
|
+
let sawStop = false;
|
|
165
|
+
const handleFrame = (frame, enqueue) => {
|
|
166
|
+
for (const line of frame.split('\n')) {
|
|
167
|
+
if (!line.startsWith('data:'))
|
|
168
|
+
continue;
|
|
169
|
+
const payload = line.slice(5).trim();
|
|
170
|
+
if (!payload || payload === '[DONE]')
|
|
171
|
+
continue;
|
|
172
|
+
let ev;
|
|
173
|
+
try {
|
|
174
|
+
ev = JSON.parse(payload);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
continue; // 坏帧跳过(与 openai 路径同口径)
|
|
178
|
+
}
|
|
179
|
+
const chunk = onData(state, ev);
|
|
180
|
+
if (chunk !== null)
|
|
181
|
+
enqueue(`data: ${chunk}\n\n`);
|
|
182
|
+
if (ev.type === 'message_stop') {
|
|
183
|
+
sawStop = true;
|
|
184
|
+
enqueue('data: [DONE]\n\n');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
return new TransformStream({
|
|
189
|
+
transform(chunk, controller) {
|
|
190
|
+
const emit = (s) => controller.enqueue(encoder.encode(s));
|
|
191
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
192
|
+
let sep;
|
|
193
|
+
while ((sep = buffer.indexOf('\n\n')) >= 0) {
|
|
194
|
+
const frame = buffer.slice(0, sep);
|
|
195
|
+
buffer = buffer.slice(sep + 2);
|
|
196
|
+
handleFrame(frame, emit);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
flush(controller) {
|
|
200
|
+
/* 尾部残帧(无空行结尾的最后一帧)也处理;未见到 message_stop 不补 DONE。 */
|
|
201
|
+
const tail = buffer + decoder.decode();
|
|
202
|
+
if (tail.trim() !== '')
|
|
203
|
+
handleFrame(tail, controller ? (s) => controller.enqueue(encoder.encode(s)) : () => undefined);
|
|
204
|
+
void sawStop;
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
import type { ChatRequestBody } from 'waygate-core';
|
|
17
|
+
/** Anthropic Messages 请求体(出站最小集)。 */
|
|
18
|
+
export interface AnthropicOutRequest {
|
|
19
|
+
model: string;
|
|
20
|
+
max_tokens: number;
|
|
21
|
+
stream: true;
|
|
22
|
+
messages: Array<{
|
|
23
|
+
role: 'user' | 'assistant';
|
|
24
|
+
content: unknown;
|
|
25
|
+
}>;
|
|
26
|
+
system?: string;
|
|
27
|
+
temperature?: number;
|
|
28
|
+
top_p?: number;
|
|
29
|
+
tools?: Array<{
|
|
30
|
+
name: string;
|
|
31
|
+
description: string;
|
|
32
|
+
input_schema: unknown;
|
|
33
|
+
}>;
|
|
34
|
+
tool_choice?: {
|
|
35
|
+
type: 'auto' | 'any' | 'none';
|
|
36
|
+
} | {
|
|
37
|
+
type: 'tool';
|
|
38
|
+
name: string;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** 翻译入口。输入是本服务内部的 ChatRequestBody(已过 rewrite 层)。 */
|
|
42
|
+
export declare function toAnthropicRequest(body: ChatRequestBody): AnthropicOutRequest;
|