Files
JX-BPOS/apps/api/src/modules/llm/__tests__/model-binding.test.ts
T
ChaiHaoandClaude Opus 5 da8ac8f9bc 模型实例卡重做:名字不截、密钥不撒谎、不可用就别摆统计;只留 flash 与 pro 两条
三条毛病一次改净:

**① 名字被截断**。用途名是活动里声明的那个名,看不全等于没显示 —— 卡面改成
   40px 图标 + **名字独占一行、折行也不截**(overflow-wrap: anywhere),
   厂商与型号退到第二行小字。

**② 「已绑定」是谎**。种子从前塞一个 `sk-placeholder` 冒充密钥,卡上写着「已绑定」,
   一自检就是 `api key ... is invalid`;抽屉里那格也照「是不是新建」写占位,
   于是没钥匙的实例也显示「已绑定 · 留空保持原值」。现在:
     · 种子**读不到环境密钥就不绑**,如实显示未绑定;
     · 抽屉的占位与判词改看 `keyBound` —— 没绑就写「还没有密钥 —— 不填这条实例调不动」。
   **界面撒谎比缺功能更坏**:缺功能人会去补,撒谎的人会照着假状态做决定。

**③ 不可用还摆一排统计**。没密钥连调都调不起来,「系统用途/端点」那些格没有意义 ——
   未绑定时卡上只剩一句判词 +「去配置」;绑好了才谈端点与「测试」。
   系统用途从卡面撤了(用户:不要管是不是系统用途,两条都能用)。

**只留两条实例**:`deepseek-v4-flash` 与 `deepseek-v4-pro`,都不预设系统用途。
配套两件:
  · `pickSystemUse` 加**回落** —— 没人勾就取第一条能用的(启用且已绑密钥);
    从前非勾不可,没勾那天系统叙述整条哑掉,那是配置疏忽不该变成功能缺失(补了三条测试);
  · 六个活动包里声明的用途名 `quote-extractor` 改指 `deepseek-v4-flash`,各升一个补丁版。

顺带补 `DELETE /api/gov/llm/:id`(卡上 ⋯ 菜单):还有活动声明着这个用途名就拒(409)——
删了那些活动一调模型就是「用途名未绑定」,而人当时以为只是清一条没用的记录。
实测:用它把库里遗留的 quote-extractor 删掉,现在库里就是那两条,卡上如实写「未绑定密钥 · 不可用」。
五闸绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-30 01:53:42 +08:00

142 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { isBound, pickSystemUse, toPublicView, validateBinding } from '../internal/domain/model-binding.ts';
import type { ModelBinding } from '../internal/domain/model-binding.ts';
import { callFingerprint, replayKey, stableStringify } from '../internal/domain/replay-key.ts';
import type { TaskId } from '@contracts';
const binding = (over: Partial<ModelBinding> = {}): ModelBinding => ({
id: 'i1',
name: 'quote-extractor',
provider: 'openai-compatible',
baseUrl: 'https://api.example.com/v1',
model: 'gpt-4o-mini',
apiKeyCipher: 'v1.aa.bb.cc',
systemUse: false,
enabled: true,
...over,
});
describe('validateBinding —— 四元齐备才算绑定', () => {
it('四元齐备通过', () => {
expect(validateBinding(binding())).toEqual([]);
expect(isBound(binding())).toBe(true);
});
it('缺 API-Key 即未绑定', () => {
const v = validateBinding(binding({ apiKeyCipher: undefined }));
expect(v.map((x) => x.field)).toEqual(['apiKey']);
expect(isBound(binding({ apiKeyCipher: undefined }))).toBe(false);
});
it('URL 必须是 http(s)', () => {
expect(validateBinding(binding({ baseUrl: 'ftp://x' })).map((x) => x.field)).toEqual(['baseUrl']);
expect(validateBinding(binding({ baseUrl: '' })).map((x) => x.field)).toEqual(['baseUrl']);
});
it('模型名不得为空', () => {
expect(validateBinding(binding({ model: ' ' })).map((x) => x.field)).toEqual(['model']);
});
it('自定义模型名有字符集约束', () => {
expect(validateBinding(binding({ name: '模型 一' })).map((x) => x.field)).toEqual(['name']);
});
});
describe('toPublicView —— 结构上不可能泄漏密钥', () => {
it('只回是否已绑定', () => {
const view = toPublicView(binding());
expect(view).toEqual({
id: 'i1',
name: 'quote-extractor',
provider: 'openai-compatible',
baseUrl: 'https://api.example.com/v1',
model: 'gpt-4o-mini',
apiKeyBound: true,
systemUse: false,
enabled: true,
});
expect(JSON.stringify(view)).not.toContain('v1.aa.bb.cc');
});
it('未绑定时 apiKeyBound = false', () => {
expect(toPublicView(binding({ apiKeyCipher: '' })).apiKeyBound).toBe(false);
});
});
describe('pickSystemUse', () => {
it('取启用且标记系统用途的实例', () => {
const picked = pickSystemUse([
binding({ id: 'a', name: 'a', systemUse: false }),
binding({ id: 'b', name: 'b', systemUse: true, enabled: false }),
binding({ id: 'c', name: 'c', systemUse: true }),
]);
expect(picked?.id).toBe('c');
});
it('没有即 undefined', () => {
expect(pickSystemUse([])).toBeUndefined();
});
});
describe('replayKey —— 按任务 × 步序号', () => {
it('有任务与步序号时用定位键', () => {
expect(replayKey({ model: 'm', prompt: 'p', taskId: 't1' as TaskId, stepNo: 3 })).toBe('task:t1#3');
});
it('缺定位时退到内容指纹,且同输入同键', () => {
const a = replayKey({ model: 'm', prompt: 'p', temperature: 0 });
const b = replayKey({ model: 'm', prompt: 'p', temperature: 0 });
expect(a).toBe(b);
expect(a.startsWith('fp:')).toBe(true);
});
it('不同 prompt 得不同键', () => {
expect(replayKey({ model: 'm', prompt: 'p1' })).not.toBe(replayKey({ model: 'm', prompt: 'p2' }));
});
it('指纹不含凭证字段', () => {
expect(callFingerprint({ model: 'm', prompt: 'p' })).not.toMatch(/key|token|authorization/i);
});
});
describe('stableStringify —— 键排序,指纹不随属性顺序漂移', () => {
it('对象键排序', () => {
expect(stableStringify({ b: 1, a: 2 })).toBe(stableStringify({ a: 2, b: 1 }));
});
it('嵌套与数组', () => {
expect(stableStringify({ a: [1, { d: 4, c: 3 }] })).toBe('{"a":[1,{"c":3,"d":4}]}');
});
it('null 与标量', () => {
expect(stableStringify(null)).toBe('null');
expect(stableStringify(3)).toBe('3');
});
});
describe('系统用途:没人勾也要有着落(2026-07-30)', () => {
const mk = (over: Partial<Parameters<typeof pickSystemUse>[0][number]>) => ({
id: 'x',
name: 'n',
provider: 'deepseek' as const,
baseUrl: 'https://api.deepseek.com',
model: 'm',
apiKeyCipher: 'cipher',
systemUse: false,
enabled: true,
...over,
});
it('勾了的优先', () => {
const flagged = mk({ id: 'flagged', systemUse: true });
expect(pickSystemUse([mk({ id: 'first' }), flagged])?.id).toBe('flagged');
});
it('一个没勾 → 取第一条能用的(启用且已绑密钥)', () => {
const picked = pickSystemUse([mk({ id: 'nokey', apiKeyCipher: undefined }), mk({ id: 'off', enabled: false }), mk({ id: 'ok' })]);
expect(picked?.id, '未绑密钥与已停用的都不算数').toBe('ok');
});
it('一条能用的都没有 → 如实返回 undefined,不硬凑一条', () => {
expect(pickSystemUse([mk({ apiKeyCipher: undefined })])).toBeUndefined();
});
});