- PiUpstreamClient: one Models per upstream holding only its provider; credential store adapter maps to that upstream's secret, so several accounts of one provider coexist; host env is only read for authKind none (ambient cloud creds) - provider catalog from builtinProviders() plus static oauth modes, env fields and subscription terms notes; openai-compatible custom endpoints - fake upstream provider with mock OAuth (browser paste / device approval) - PiAgentRuntime accepts an upstream; thinking level clamped to the model Co-Authored-By: Claude Opus 5.5 <[email protected]>
220 lines
9.6 KiB
TypeScript
220 lines
9.6 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("41 个内置 provider + openai-compatible + fake,id 与合同枚举一致", () => {
|
|
const c = providerCatalog({ models: false });
|
|
const ids = c.providers.map((p) => p.id);
|
|
expect(ids).toHaveLength(43);
|
|
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("订阅登录:8 种 OAuth;订阅型带条款提示;Codex 只有 oauth 且优先 device", () => {
|
|
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-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);
|
|
});
|
|
});
|
|
|
|
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("自定义兼容端点: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);
|
|
});
|
|
});
|