生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。
- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
(metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)
同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距
依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
import { PI_BUILTIN_PROVIDER_IDS, PiProviderId } from "@umt/contracts";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
FakeOAuthControl,
|
|
PiUpstreamClient,
|
|
providerCatalog,
|
|
resolveAgentSettings,
|
|
type UpstreamAuthEvent,
|
|
type UpstreamCredential,
|
|
type UpstreamCredentialStore,
|
|
UpstreamError,
|
|
type UpstreamLoginInteraction,
|
|
type UpstreamSpec,
|
|
} from "../src/index.ts";
|
|
|
|
/** 内存凭据存储:promise 链串行化 modify(模拟行锁) */
|
|
function memStore(initial?: UpstreamCredential) {
|
|
let cur = initial;
|
|
let chain: Promise<unknown> = Promise.resolve();
|
|
const store: UpstreamCredentialStore & { get(): UpstreamCredential | undefined; writes: number } = {
|
|
writes: 0,
|
|
get: () => cur,
|
|
read: async () => cur,
|
|
modify: (fn) => {
|
|
const run = chain.then(async () => {
|
|
const next = await fn(cur);
|
|
if (next !== undefined) {
|
|
cur = next;
|
|
store.writes += 1;
|
|
}
|
|
return cur;
|
|
});
|
|
chain = run.catch(() => undefined);
|
|
return run;
|
|
},
|
|
delete: async () => {
|
|
cur = undefined;
|
|
},
|
|
};
|
|
return store;
|
|
}
|
|
|
|
const spec = (s: Partial<UpstreamSpec> & Pick<UpstreamSpec, "provider" | "authKind">): UpstreamSpec => ({
|
|
id: "u1",
|
|
...s,
|
|
});
|
|
|
|
describe("能力表", () => {
|
|
it("42 个内置 provider + openai-compatible + fake,id 与合同枚举一致", () => {
|
|
const c = providerCatalog({ models: false });
|
|
const ids = c.providers.map((p) => p.id);
|
|
expect(ids).toHaveLength(44);
|
|
expect(new Set(ids)).toEqual(new Set(PiProviderId.options));
|
|
expect(ids.filter((i) => i !== "fake" && i !== "openai-compatible").sort()).toEqual(
|
|
[...PI_BUILTIN_PROVIDER_IDS].sort(),
|
|
);
|
|
expect(c.catalogGeneratedAt).toMatch(/^\d{4}-/);
|
|
expect(c.providers.every((p) => p.models.length === 0)).toBe(true);
|
|
});
|
|
|
|
it("订阅登录:9 种 OAuth;Codex 只有 oauth 且优先 device;OpenAI 支持原生 ChatGPT 登录", () => {
|
|
const c = providerCatalog();
|
|
const oauth = c.providers.filter((p) => p.oauth && p.id !== "fake").map((p) => p.id);
|
|
expect(oauth.sort()).toEqual(
|
|
[
|
|
"anthropic",
|
|
"github-copilot",
|
|
"kimi-coding",
|
|
"meta",
|
|
"openai",
|
|
"openai-codex",
|
|
"openrouter",
|
|
"radius",
|
|
"xai",
|
|
].sort(),
|
|
);
|
|
const byId = Object.fromEntries(c.providers.map((p) => [p.id, p]));
|
|
expect(byId["openai-codex"]!.authKinds).toEqual(["oauth"]);
|
|
expect(byId["openai-codex"]!.oauth!.modes).toEqual(["device", "browser"]);
|
|
expect(byId["openai-codex"]!.baseUrlOverridable).toBe(false);
|
|
expect(byId.anthropic!.termsNote).toMatch(/Claude Code.*封禁/);
|
|
expect(byId.anthropic!.group).toBe("subscription");
|
|
for (const id of ["anthropic", "openai-codex", "github-copilot", "xai", "kimi-coding", "meta"])
|
|
expect(byId[id]!.termsNote, id).toBeTruthy();
|
|
expect(byId.openrouter!.termsNote).toBeNull();
|
|
expect(byId.opencode!.oauth).toBeNull();
|
|
expect(byId["opencode-go"]!.authKinds).toEqual(["api_key"]);
|
|
expect(byId.radius!.dynamicModels).toBe(true);
|
|
expect(byId["cloudflare-workers-ai"]!.envFields.map((f) => f.name)).toEqual(["CLOUDFLARE_ACCOUNT_ID"]);
|
|
expect(byId["amazon-bedrock"]!.authKinds).toContain("none");
|
|
expect(byId.openai!.models.length).toBeGreaterThan(10);
|
|
expect(byId.openai!.baseUrlOverridable).toBe(true);
|
|
expect(byId.openai!.authKinds).toEqual(["oauth", "api_key"]);
|
|
expect(byId.openai!.oauth!.modes).toEqual(["browser"]);
|
|
expect(byId.openai!.termsNote).toBeNull();
|
|
expect(byId.typesafe!.models).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("按上游装配", () => {
|
|
it("同一 provider 两个账号并存:各自的 key 只进各自的请求;不读宿主环境变量", async () => {
|
|
process.env.OPENAI_API_KEY = "sk-host-env-should-not-leak";
|
|
try {
|
|
const s = resolveAgentSettings({ provider: "openai", model: "gpt-5", contextBudgetTokens: 64_000 });
|
|
const a = new PiUpstreamClient(
|
|
spec({ id: "a", provider: "openai", authKind: "api_key", model: "gpt-5" }),
|
|
memStore({ type: "api_key", key: "sk-account-a" }),
|
|
);
|
|
const b = new PiUpstreamClient(
|
|
spec({ id: "b", provider: "openai", authKind: "api_key", model: "gpt-5", baseUrl: "https://proxy.local/v1/" }),
|
|
memStore({ type: "api_key", key: "sk-account-b" }),
|
|
);
|
|
const ra = await a.resolveRunModel(s);
|
|
const rb = await b.resolveRunModel(s);
|
|
expect(JSON.stringify(await ra.models.getAuth(ra.model))).toContain("sk-account-a");
|
|
expect(JSON.stringify(await rb.models.getAuth(rb.model))).toContain("sk-account-b");
|
|
expect(rb.model.baseUrl).toBe("https://proxy.local/v1");
|
|
expect(ra.model.baseUrl).toBe("https://api.openai.com/v1");
|
|
expect(ra.model.contextWindow).toBe(64_000);
|
|
expect(JSON.stringify(ra.model)).not.toContain("sk-account");
|
|
|
|
const empty = new PiUpstreamClient(spec({ provider: "openai", authKind: "api_key" }), memStore());
|
|
await expect(empty.resolveAuth()).rejects.toMatchObject({ kind: "auth" });
|
|
} finally {
|
|
delete process.env.OPENAI_API_KEY;
|
|
}
|
|
});
|
|
|
|
it("目录外的模型 id 以同 provider 模型为模板合成;思考档位夹到模型支持范围", async () => {
|
|
const c = new PiUpstreamClient(
|
|
spec({ provider: "anthropic", authKind: "api_key", model: "claude-future-9", thinkingLevel: "max" }),
|
|
memStore({ type: "api_key", key: "sk-ant-x" }),
|
|
);
|
|
const r = await c.resolveRunModel(
|
|
resolveAgentSettings({ provider: "anthropic", model: "ignored", thinkingLevel: "xhigh" }),
|
|
);
|
|
expect(r.model.id).toBe("claude-future-9");
|
|
expect(r.thinkingLevel).toBeDefined();
|
|
const codex = new PiUpstreamClient(
|
|
spec({ provider: "openai-codex", authKind: "oauth", model: "gpt-5.1-codex-mini" }),
|
|
memStore(),
|
|
);
|
|
const rc = await codex.resolveRunModel(resolveAgentSettings({ provider: "x", model: "m", thinkingLevel: "max" }));
|
|
expect(rc.thinkingLevel).not.toBe("max");
|
|
expect(r.model.api).toBe("anthropic-messages");
|
|
const t = await c.test({ mode: "auth" });
|
|
expect(t).toMatchObject({ model: "claude-future-9", inCatalog: false, source: "stored credential" });
|
|
});
|
|
|
|
it("测试补全带 sessionId:OpenCode Go 请求头有 x-opencode-session", async () => {
|
|
const seen: { url: string; headers: Headers }[] = [];
|
|
const orig = globalThis.fetch;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const req = new Request(input, init);
|
|
seen.push({ url: req.url, headers: req.headers });
|
|
return new Response(JSON.stringify({ error: { message: "stubbed" } }), {
|
|
status: 500,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}) as typeof fetch;
|
|
try {
|
|
const c = new PiUpstreamClient(
|
|
spec({ provider: "opencode-go", authKind: "api_key" }),
|
|
memStore({ type: "api_key", key: "sk-opencode-test" }),
|
|
);
|
|
await expect(c.test({ mode: "completion" })).rejects.toBeInstanceOf(UpstreamError);
|
|
} finally {
|
|
globalThis.fetch = orig;
|
|
}
|
|
const call = seen.find((x) => x.headers.get("x-opencode-session"));
|
|
expect(call, `请求:${seen.map((x) => x.url).join(", ")}`).toBeDefined();
|
|
expect(call?.headers.get("x-opencode-session")).toMatch(/^umt-upstream-test-/);
|
|
});
|
|
|
|
it("自定义兼容端点:baseUrl/模型必填;authKind none 不需要 key", async () => {
|
|
expect(() => new PiUpstreamClient(spec({ provider: "openai-compatible", authKind: "none" }), memStore())).toThrow(
|
|
UpstreamError,
|
|
);
|
|
const c = new PiUpstreamClient(
|
|
spec({ provider: "openai-compatible", authKind: "none", baseUrl: "http://llm.local/v1", model: "qwen3" }),
|
|
memStore(),
|
|
);
|
|
expect((await c.resolveAuth()).source).toBe("none");
|
|
const m = await c.listModels();
|
|
expect(m.items).toMatchObject([{ id: "qwen3", source: "custom", api: "openai-completions" }]);
|
|
});
|
|
});
|
|
|
|
/** 挂起式交互:按模式回答 select,manual_code 等外部粘贴 */
|
|
function interaction(mode: "browser" | "device_code") {
|
|
const events: UpstreamAuthEvent[] = [];
|
|
let paste: ((v: string) => void) | null = null;
|
|
const first = Promise.withResolvers<UpstreamAuthEvent>();
|
|
const it: UpstreamLoginInteraction = {
|
|
prompt: async (p) => {
|
|
if (p.type === "select") return mode;
|
|
if (p.type === "manual_code") return await new Promise<string>((r) => (paste = r));
|
|
throw new Error(`unexpected prompt ${p.type}`);
|
|
},
|
|
notify: (e) => {
|
|
events.push(e);
|
|
first.resolve(e);
|
|
},
|
|
};
|
|
return { it, events, first: first.promise, paste: (v: string) => paste?.(v) };
|
|
}
|
|
|
|
describe("fake provider 的模拟 OAuth", () => {
|
|
it("browser:粘回地址栏 URL(state 校验)→ 写入 token 包;device:批准后写入", async () => {
|
|
const control = new FakeOAuthControl();
|
|
control.deviceAutoApproveMs = null;
|
|
const store = memStore();
|
|
const c = new PiUpstreamClient(spec({ provider: "fake", authKind: "oauth" }), store, { fakeOAuth: control });
|
|
await expect(c.resolveAuth()).rejects.toMatchObject({ kind: "auth" });
|
|
|
|
const b = interaction("browser");
|
|
const login = c.login(b.it);
|
|
const e = await b.first;
|
|
expect(e.type).toBe("auth_url");
|
|
const state = new URL((e as { url: string }).url).searchParams.get("state");
|
|
b.paste(`http://localhost:1455/auth/callback?code=abc&state=${state}`);
|
|
await login;
|
|
expect(store.get()).toMatchObject({ type: "oauth", account: "fake-browser-user" });
|
|
expect((await c.resolveAuth()).source).toBe("OAuth");
|
|
|
|
const bad = interaction("browser");
|
|
const badLogin = c.login(bad.it);
|
|
await bad.first;
|
|
bad.paste("http://localhost:1455/auth/callback?code=abc&state=wrong");
|
|
await expect(badLogin).rejects.toThrow(/state mismatch/);
|
|
|
|
const d = interaction("device_code");
|
|
const dl = c.login(d.it);
|
|
const de = await d.first;
|
|
expect(de).toMatchObject({ type: "device_code", verificationUri: "https://fake-oauth.invalid/device" });
|
|
control.approveDevice();
|
|
await dl;
|
|
expect(store.get()).toMatchObject({ account: "fake-device-user" });
|
|
});
|
|
|
|
it("刷新:过期前锁内刷新,并发只刷新一次;refresh token 轮换后写回;失败保留旧凭据并报 oauth", async () => {
|
|
const control = new FakeOAuthControl();
|
|
control.refreshDelayMs = 30;
|
|
const store = memStore({ type: "oauth", access: "old-a", refresh: "old-r", expires: Date.now() + 60_000 });
|
|
const c1 = new PiUpstreamClient(spec({ provider: "fake", authKind: "oauth" }), store, { fakeOAuth: control });
|
|
const c2 = new PiUpstreamClient(spec({ provider: "fake", authKind: "oauth" }), store, { fakeOAuth: control });
|
|
await Promise.all([c1.resolveAuth(30 * 60_000), c2.resolveAuth(30 * 60_000), c1.resolveAuth(30 * 60_000)]);
|
|
expect(control.refreshCalls).toBe(1);
|
|
const cur = store.get() as { refresh: string; expires: number };
|
|
expect(cur.refresh).not.toBe("old-r");
|
|
expect(cur.expires).toBeGreaterThan(Date.now() + 30 * 60_000);
|
|
|
|
control.failRefresh = true;
|
|
const before = store.get();
|
|
await expect(c1.resolveAuth(2 * 3600_000)).rejects.toMatchObject({ kind: "oauth" });
|
|
expect(store.get()).toEqual(before);
|
|
});
|
|
});
|