Skip to content

Commit 1454d6f

Browse files
author
weiesky.wangc
committed
feat(proxy): ANTHROPIC_MODEL as catch-all fallback for hot-switch model replacement
- resolveProfileModel: ANTHROPIC_MODEL now serves as default when family-specific fields are empty or the model family is unrecognized (incl. K3/kimi, third-party) - Strip [1m] suffix from all profile model field values before comparison - Symmetric strip1m on both target and oldModel for equality check - Fix flaky sse-compression test (endsWith -> includes for brotli decompressor)
1 parent ccd1759 commit 1454d6f

5 files changed

Lines changed: 91 additions & 31 deletions

File tree

server/lib/interceptor-core.js

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -400,34 +400,45 @@ export function replaceTopLevelModel(jsonStr, oldModel, newModel) {
400400
// 家族用**大小写不敏感子串**匹配(/opus/i 等),只认这几个已知家族单词——
401401
// 因此 claude-opus-4-8、未来的 claude-opus-5 等任何版本都命中同一家族,版本升级无需重配。
402402
// 字段直接沿用 Claude Code 的环境变量名以保持一致:
403-
// ANTHROPIC_MODEL —— 主模型;body.model 含 "fable" / "mythos" 时映射到它
403+
// ANTHROPIC_MODEL —— 主模型(catch-all 兜底;body.model 含 "fable"/"mythos"
404+
// 及所有未识别家族均回落到它)
404405
// ANTHROPIC_DEFAULT_OPUS_MODEL —— body.model 含 "opus"
405406
// ANTHROPIC_DEFAULT_SONNET_MODEL —— 含 "sonnet"
406407
// ANTHROPIC_DEFAULT_HAIKU_MODEL —— 含 "haiku"
407-
// 家族字段留空 = 该家族不改写(透传原始 model)
408-
// 未识别家族(既不是 opus/sonnet/haiku 也不是 fable/mythos)不做兜底替换,原样透传
408+
// 家族字段优先:opus/sonnet/haiku 各自命中专属字段;字段留空则回落到 ANTHROPIC_MODEL
409+
// 未识别家族(既不是 opus/sonnet/haiku 也不是 fable/mythos)→ ANTHROPIC_MODEL 兜底
409410
// 兼容旧数据:profile 未设任何新字段但有 activeModel(老结构)时,回退为旧的整体替换语义。
410-
// 返回目标模型字符串;无需改写(无目标 / 目标同旧值 / 入参非法 / 未识别家族)时返回 null。
411+
// 返回目标模型字符串;无需改写(无目标 / 目标同旧值 / 入参非法)时返回 null。
412+
// [1m] 后缀(Claude Code 1M context 标记)默认忽略:所有 profile 模型字段值在比较前先剥除。
411413
export function resolveProfileModel(oldModel, profile) {
412414
if (typeof oldModel !== 'string' || !oldModel || !profile || typeof profile !== 'object') return null;
413-
const opus = typeof profile.ANTHROPIC_DEFAULT_OPUS_MODEL === 'string' ? profile.ANTHROPIC_DEFAULT_OPUS_MODEL.trim() : '';
414-
const sonnet = typeof profile.ANTHROPIC_DEFAULT_SONNET_MODEL === 'string' ? profile.ANTHROPIC_DEFAULT_SONNET_MODEL.trim() : '';
415-
const haiku = typeof profile.ANTHROPIC_DEFAULT_HAIKU_MODEL === 'string' ? profile.ANTHROPIC_DEFAULT_HAIKU_MODEL.trim() : '';
416-
const primary = typeof profile.ANTHROPIC_MODEL === 'string' ? profile.ANTHROPIC_MODEL.trim() : '';
415+
416+
// Strip [1m] suffix (case-insensitive) from model names; Claude Code appends it
417+
// for 1M-context variants and it should not affect model matching/replacement.
418+
const strip1m = (s) => (typeof s === 'string' ? s.replace(/\[1m\]/gi, '').trim() : '');
419+
420+
const opus = strip1m(profile.ANTHROPIC_DEFAULT_OPUS_MODEL);
421+
const sonnet = strip1m(profile.ANTHROPIC_DEFAULT_SONNET_MODEL);
422+
const haiku = strip1m(profile.ANTHROPIC_DEFAULT_HAIKU_MODEL);
423+
const primary = strip1m(profile.ANTHROPIC_MODEL);
417424
const hasNew = !!(primary || opus || sonnet || haiku);
418425

419426
let target = '';
420427
if (hasNew) {
421-
if (/opus/i.test(oldModel)) target = opus;
422-
else if (/sonnet/i.test(oldModel)) target = sonnet;
423-
else if (/haiku/i.test(oldModel)) target = haiku;
424-
else if (/fable/i.test(oldModel) || /mythos/i.test(oldModel)) target = primary; // 显式家族 → 主模型
425-
// 未识别家族:target 保持 '',下方返回 null(不替换、原样透传)
428+
// Family-specific fields take precedence; fall back to ANTHROPIC_MODEL when
429+
// the family field is empty/unset. ANTHROPIC_MODEL itself acts as the
430+
// catch-all default for any unrecognized family (including third-party models
431+
// like gpt-4o, deepseek-v4, K3/kimi, etc.).
432+
if (/opus/i.test(oldModel)) target = opus || primary;
433+
else if (/sonnet/i.test(oldModel)) target = sonnet || primary;
434+
else if (/haiku/i.test(oldModel)) target = haiku || primary;
435+
else if (/fable/i.test(oldModel) || /mythos/i.test(oldModel)) target = primary;
436+
else target = primary; // unrecognized family → ANTHROPIC_MODEL catch-all
426437
} else if (typeof profile.activeModel === 'string') {
427-
target = profile.activeModel.trim(); // 旧数据整体替换语义
438+
target = strip1m(profile.activeModel); // 旧数据整体替换语义
428439
}
429440

430-
if (!target || target === oldModel) return null;
441+
if (!target || target === strip1m(oldModel)) return null;
431442
return target;
432443
}
433444

test/interceptor-profile.test.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,15 +243,18 @@ describe('proxy 请求改写(通过真实 fetch hook)', () => {
243243
}
244244
});
245245

246-
it('家族字段留空 → 该家族不替换(透传原始 model)', async () => {
246+
it('家族字段留空 → 回落到 ANTHROPIC_MODEL', async () => {
247247
writeProfile({ active: 'p1', profiles: [
248248
{ id: 'p1', name: 'PartialFam', baseURL: 'https://m.example.com', apiKey: 'sk-m',
249249
ANTHROPIC_MODEL: 'PRIMARY', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'HAIKU-T' },
250250
] });
251251
mod._loadProxyProfile();
252-
// opus 家族无字段 → 但有 ANTHROPIC_MODEL?不:家族命中 opus 走 opus 字段(空)→ 不替换
252+
// opus 家族无字段 → ANTHROPIC_MODEL(PRIMARY) 兜底替换
253253
await postJson('https://api.anthropic.com/v1/messages', { model: 'claude-opus-4-8', messages: [] });
254-
assert.equal(JSON.parse(lastFetchArgs[1].body).model, 'claude-opus-4-8', 'opus 字段空 → 不替换');
254+
assert.equal(JSON.parse(lastFetchArgs[1].body).model, 'PRIMARY', 'opus 字段空 → PRIMARY 兜底');
255+
// sonnet 家族也无字段 → PRIMARY 兜底
256+
await postJson('https://api.anthropic.com/v1/messages', { model: 'claude-sonnet-4', messages: [] });
257+
assert.equal(JSON.parse(lastFetchArgs[1].body).model, 'PRIMARY', 'sonnet 字段空 → PRIMARY 兜底');
255258
// haiku 家族有字段 → 替换
256259
await postJson('https://api.anthropic.com/v1/messages', { model: 'claude-3-5-haiku', messages: [] });
257260
assert.equal(JSON.parse(lastFetchArgs[1].body).model, 'HAIKU-T');

test/resolve-profile-model.test.js

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
* resolveProfileModel + migrateProxyProfile 单测 —— proxy 热切换按家族映射模型 + 旧配置迁移。
33
*
44
* resolveProfileModel:按 body.model 家族(opus/sonnet/haiku)映射到 profile 对应字段,
5-
* fable/mythos/未识别 → ANTHROPIC_MODEL;无新字段但有 activeModel(旧数据)→ 整体替换回退。
5+
* fable/mythos/未识别 → ANTHROPIC_MODEL 兜底;无新字段但有 activeModel(旧数据)→ 整体替换回退。
6+
* 家族字段优先于 ANTHROPIC_MODEL;字段留空时回落到 ANTHROPIC_MODEL。
7+
* [1m] 后缀默认忽略(剥除后比较)。
68
* 目标为空 / 等于旧值 / 入参非法 → null(不改写)。
79
* migrate*:老 { models, activeModel } → ANTHROPIC_MODEL,幂等。
810
*/
@@ -31,16 +33,60 @@ describe('resolveProfileModel', () => {
3133
assert.equal(resolveProfileModel('claude-fable-5', fam), 'PRIMARY');
3234
assert.equal(resolveProfileModel('mythos-1', fam), 'PRIMARY');
3335
});
34-
it('未识别家族 → null(原样透传,不兜底替换)', () => {
35-
assert.equal(resolveProfileModel('some-random-model', fam), null);
36-
assert.equal(resolveProfileModel('gpt-4o', fam), null);
36+
it('未识别家族 → ANTHROPIC_MODEL 兜底(热切换开启时)', () => {
37+
assert.equal(resolveProfileModel('some-random-model', fam), 'PRIMARY');
38+
assert.equal(resolveProfileModel('gpt-4o', fam), 'PRIMARY');
39+
assert.equal(resolveProfileModel('K3', fam), 'PRIMARY'); // K3/kimi alias → PRIMARY
40+
assert.equal(resolveProfileModel('deepseek-v4', fam), 'PRIMARY');
3741
});
38-
it('家族字段留空 → 该家族不替换(null)', () => {
42+
it('未识别家族 + 无 ANTHROPIC_MODEL → null(无兜底目标)', () => {
43+
const noPrimary = { ANTHROPIC_DEFAULT_OPUS_MODEL: 'OPUS-T' };
44+
assert.equal(resolveProfileModel('some-random-model', noPrimary), null);
45+
assert.equal(resolveProfileModel('gpt-4o', noPrimary), null);
46+
});
47+
it('家族字段留空 → 回落到 ANTHROPIC_MODEL', () => {
3948
const partial = { ANTHROPIC_MODEL: 'PRIMARY', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'HAIKU-T' };
40-
assert.equal(resolveProfileModel('claude-opus-4-8', partial), null); // opus 空 → 不回落到 PRIMARY
41-
assert.equal(resolveProfileModel('claude-3-5-haiku', partial), 'HAIKU-T');
49+
assert.equal(resolveProfileModel('claude-opus-4-8', partial), 'PRIMARY'); // opus 空 → PRIMARY 兜底
50+
assert.equal(resolveProfileModel('claude-sonnet-4', partial), 'PRIMARY'); // sonnet 空 → PRIMARY 兜底
51+
assert.equal(resolveProfileModel('claude-3-5-haiku', partial), 'HAIKU-T'); // haiku 有专属字段
4252
assert.equal(resolveProfileModel('claude-fable-5', partial), 'PRIMARY');
4353
});
54+
it('ANTHROPIC_MODEL 留空 + 家族字段留空 → 该家族不替换(null)', () => {
55+
// Only HAIKU field set, no PRIMARY, opus/sonnet both empty → no fallback target
56+
const haikuOnly = { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'HAIKU-T' };
57+
assert.equal(resolveProfileModel('claude-opus-4-8', haikuOnly), null);
58+
assert.equal(resolveProfileModel('claude-sonnet-4', haikuOnly), null);
59+
assert.equal(resolveProfileModel('claude-3-5-haiku', haikuOnly), 'HAIKU-T');
60+
});
61+
it('[1m] suffix in profile fields is stripped before comparison', () => {
62+
const with1m = {
63+
ANTHROPIC_MODEL: 'claude-sonnet-5[1m]',
64+
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-8[1m]',
65+
};
66+
assert.equal(resolveProfileModel('claude-fable-5', with1m), 'claude-sonnet-5');
67+
// opus field strips to 'claude-opus-4-8' which equals oldModel → null (no-op)
68+
assert.equal(resolveProfileModel('claude-opus-4-8', with1m), null);
69+
});
70+
it('[1m] suffix case-insensitive strip', () => {
71+
assert.equal(
72+
resolveProfileModel('claude-opus-4-8', { ANTHROPIC_DEFAULT_OPUS_MODEL: 'CLAUDE-OPUS-4[1M]' }),
73+
'CLAUDE-OPUS-4'
74+
);
75+
});
76+
it('K3[1m] → strips to K3', () => {
77+
assert.equal(
78+
resolveProfileModel('gpt-4o', { ANTHROPIC_MODEL: 'K3[1m]' }),
79+
'K3'
80+
);
81+
});
82+
it('target equals oldModel after [1m] strip → null (no-op)', () => {
83+
// Profile field carries [1m], oldModel doesn't → no-op
84+
assert.equal(resolveProfileModel('my-model', { ANTHROPIC_MODEL: 'my-model[1m]' }), null);
85+
// Both oldModel and profile field carry [1m] → symmetric strip → no-op
86+
assert.equal(resolveProfileModel('claude-opus-4-8[1m]', { ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-8[1m]' }), null);
87+
// oldModel carries [1m], profile field doesn't → no-op (oldModel also stripped before compare)
88+
assert.equal(resolveProfileModel('claude-opus-4-8[1m]', { ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-8' }), null);
89+
});
4490
it('目标等于旧值 → null(无需改写)', () => {
4591
// fable 命中 primary,且旧值已是 PRIMARY
4692
assert.equal(resolveProfileModel('PRIMARY', fam), null);
@@ -61,8 +107,8 @@ describe('resolveProfileModel', () => {
61107
it('新字段存在时忽略 legacy activeModel', () => {
62108
const mixed = { ANTHROPIC_MODEL: 'PRIMARY', activeModel: 'LEGACY-T' };
63109
assert.equal(resolveProfileModel('claude-fable-5', mixed), 'PRIMARY');
64-
// opus 无字段 → 不替换(不回退到 legacy)
65-
assert.equal(resolveProfileModel('claude-opus-4-8', mixed), null);
110+
// opus 无字段 → PRIMARY 兜底(不回退到 legacy)
111+
assert.equal(resolveProfileModel('claude-opus-4-8', mixed), 'PRIMARY');
66112
});
67113
it('空 profile / 空 model / 非法入参 → null', () => {
68114
assert.equal(resolveProfileModel('claude-opus-4-8', {}), null);

test/spawn-model-resolver.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,12 @@ describe('resolveSpawnModel: 三方 profile', () => {
8282
assert.equal(resolveSpawnModel('/ws', {}, opts), 'deepseek-v4-pro');
8383
});
8484

85-
it('base 属 opus 家族 → 映射到 opus 槽位;家族槽位留空 → 透传 base', () => {
85+
it('base 属 opus 家族 → 映射到 opus 槽位;家族槽位留空 → ANTHROPIC_MODEL 兜底', () => {
8686
const { logDir, opts } = mkRoots();
8787
writeProfiles(logDir, PROFILES);
8888
assert.equal(resolveSpawnModel('/ws', { ANTHROPIC_MODEL: 'claude-opus-4-8' }, opts), 'deepseek-v4-opus-slot');
89-
// sonnet 槽位未配置 → resolveProfileModel 返回 null → 透传 base
90-
assert.equal(resolveSpawnModel('/ws', { ANTHROPIC_MODEL: 'claude-sonnet-5' }, opts), 'claude-sonnet-5');
89+
// sonnet 槽位未配置 → ANTHROPIC_MODEL(deepseek-v4-pro) 兜底
90+
assert.equal(resolveSpawnModel('/ws', { ANTHROPIC_MODEL: 'claude-sonnet-5' }, opts), 'deepseek-v4-pro');
9191
});
9292

9393
it('base 无信号 + profile 激活 → 用 profile 主模型;主模型空则回退旧 activeModel', () => {

test/sse-compression.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ describe('GET /events wire compression', () => {
166166
const plainTail = Buffer.concat(plainRes.chunks.slice(plainMark)).toString();
167167
assert.equal(plainTail, expected);
168168
const decoded = await decodeBr(brRes, (t) => t.includes(expected));
169-
assert.ok(decoded.endsWith(expected), 'compressed client received the broadcast frame');
169+
assert.ok(decoded.includes(expected), 'compressed client received the broadcast frame');
170170
plainRes.emit('close');
171171
brRes.emit('close');
172172
});

0 commit comments

Comments
 (0)