forked from ChaiHao/BPOS-MVP
三条毛病一次改净:
**① 名字被截断**。用途名是活动里声明的那个名,看不全等于没显示 —— 卡面改成
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
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
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();
|
||
});
|
||
});
|