feat(agent): per-upstream pi Models with isolated credential stores

- 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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 02:23:09 +08:00
1 parent b9dbd54e37
commit 743fe2486d
8 files changed
+1085 -5

No files matched your search

+21
View File
@@ -11,6 +11,14 @@ export {
} from "./capability/registry.ts";
export { capabilityIdToToolName, isCapabilityId, toolNameToCapabilityId } from "./capability/tool-name.ts";
export * from "./capability/types.ts";
export {
ENV_FIELDS,
OAUTH_CALLBACK_PORTS,
OAUTH_MODES,
PI_AI_VERSION,
providerCatalog,
providerInfo,
} from "./pi/catalog.ts";
export {
type CreateFakeRuntimeOptions,
createFakeRuntime,
@@ -22,6 +30,7 @@ export {
type FakeToolCall,
type FakeToolResult,
} from "./pi/fake.ts";
export { DEFAULT_FAKE_OAUTH, FakeOAuthControl } from "./pi/fake-upstream.ts";
export {
checkToolCall,
DEFAULT_COMPACTION_INSTRUCTIONS,
@@ -29,6 +38,18 @@ export {
type PiAgentRuntimeOptions,
} from "./pi/runtime.ts";
export { sessionPathFor } from "./pi/session.ts";
export {
classifyUpstreamError,
PiUpstreamClient,
type UpstreamAuthEvent,
type UpstreamAuthPrompt,
type UpstreamClientOptions,
type UpstreamCredential,
type UpstreamCredentialStore,
UpstreamError,
type UpstreamLoginInteraction,
type UpstreamSpec,
} from "./pi/upstream.ts";
export { isSensitiveKey, REDACTED, redactForModel, redactString, summarizeForLog } from "./redact.ts";
export * from "./runtime/port.ts";
export {
+251
View File
@@ -0,0 +1,251 @@
import { type Api, getSupportedThinkingLevels, type Model, type Provider } from "@earendil-works/pi-ai";
import { builtinProviders, getBuiltinModelDataGeneratedAt } from "@earendil-works/pi-ai/providers/all";
import type {
PiAuthKind,
PiEnvField,
PiModelInfo,
PiOAuthMode,
PiProviderGroup,
PiProviderId,
PiProviderInfo,
PiThinkingLevel,
} from "@umt/contracts";
// pi-ai 内置 provider → 能力表。认证方式、模型表直接读 builtinProviders();
// 登录方式、env 字段、条款提示是研究文档(docs/design/pi-providers.md §1.3/§2/§5)核对 0.87.1 源码后的静态表,
// 升级 pi-ai 时随文档一起复核。
export const PI_AI_VERSION = "0.87.1";
let cached: readonly Provider[] | null = null;
/** 内置 provider 实例(无状态的共享;Radius 这类动态 provider 按上游另建新实例) */
export function builtinProviderList(): readonly Provider[] {
cached ??= builtinProviders();
return cached;
}
export function builtinProvider(id: string, fresh = false): Provider | undefined {
if (fresh) return builtinProviders().find((p) => p.id === id);
return builtinProviderList().find((p) => p.id === id);
}
/** 订阅/OAuth 登录方式(第一个是推荐项;有 device 的优先 device,不用粘 URL) */
export const OAUTH_MODES: Record<string, PiOAuthMode[]> = {
anthropic: ["browser"],
"openai-codex": ["device", "browser"],
"github-copilot": ["device"],
xai: ["device"],
"kimi-coding": ["device"],
meta: ["device"],
openrouter: ["browser"],
radius: ["device", "browser"],
fake: ["browser", "device"],
};
/** 浏览器模式 pi 会在服务端监听的回调端口(占用时 Anthropic 会直接失败) */
export const OAUTH_CALLBACK_PORTS: Record<string, number> = {
anthropic: 53692,
"openai-codex": 1455,
radius: 1456,
};
const TERMS_TAIL = "用于自动化后台服务可能违反提供方的用户条款,账号有被限流或封禁的风险。";
/** 条款提示(研究文档 §5):订阅登录借用官方客户端身份 */
const TERMS_NOTES: Record<string, string> = {
anthropic: `该登录借用 Claude Code 的客户端身份使用你的 Claude 订阅(请求会伪装成 Claude Code),${TERMS_TAIL}`,
"openai-codex": `该登录借用 Codex CLI 的客户端身份使用你的 ChatGPT 订阅,${TERMS_TAIL}`,
"github-copilot": `该登录借用 VS Code Copilot Chat 的客户端身份(请求带其伪装头)使用你的 Copilot 订阅,${TERMS_TAIL}`,
xai: `该登录借用官方客户端身份使用你的 SuperGrok / X Premium 订阅,${TERMS_TAIL}`,
"kimi-coding": `该登录借用 Kimi Code 官方客户端身份使用你的 Kimi 订阅,${TERMS_TAIL}`,
meta: `该登录借用官方客户端身份使用你的 Meta Muse 订阅,${TERMS_TAIL}`,
};
const env = (name: string, label: string, required: boolean, hint: string | null = null): PiEnvField => ({
name,
label,
required,
hint,
});
/** provider 需要的非密配置(存 extra.env;请求时经 pi 的 AuthResult.env 下发) */
export const ENV_FIELDS: Record<string, PiEnvField[]> = {
"azure-openai-responses": [
env("AZURE_OPENAI_BASE_URL", "Azure 端点 URL", false, "与资源名二选一"),
env("AZURE_OPENAI_RESOURCE_NAME", "Azure 资源名", false, "与端点 URL 二选一"),
env("AZURE_OPENAI_API_VERSION", "API 版本", false, null),
env("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", "部署名映射", false, "model=deployment,逗号分隔"),
],
"cloudflare-ai-gateway": [
env("CLOUDFLARE_ACCOUNT_ID", "Cloudflare Account ID", true),
env("CLOUDFLARE_GATEWAY_ID", "AI Gateway ID", true),
],
"cloudflare-workers-ai": [env("CLOUDFLARE_ACCOUNT_ID", "Cloudflare Account ID", true)],
"google-vertex": [
env("GOOGLE_CLOUD_PROJECT", "GCP 项目 ID", false, "用 ADC / 服务账号时必填"),
env("GOOGLE_CLOUD_LOCATION", "区域(location)", false, "用 ADC / 服务账号时必填"),
env("GOOGLE_APPLICATION_CREDENTIALS", "服务账号文件路径", false, "服务端可读的绝对路径"),
],
"amazon-bedrock": [
env("AWS_REGION", "AWS 区域", false),
env("AWS_PROFILE", "AWS profile", false, "使用服务端 ~/.aws 配置时填写"),
],
};
/** 用服务端环境凭据(不存密钥)的 provider:authKind none */
const AMBIENT = new Set(["amazon-bedrock", "google-vertex"]);
const CLOUD = new Set([
"amazon-bedrock",
"google-vertex",
"azure-openai-responses",
"cloudflare-ai-gateway",
"cloudflare-workers-ai",
]);
/** baseUrl 由 token / env 决定,不允许覆盖 */
const NO_BASE_OVERRIDE = new Set([
"openai-codex",
"github-copilot",
"radius",
"amazon-bedrock",
"google-vertex",
"azure-openai-responses",
"cloudflare-ai-gateway",
"cloudflare-workers-ai",
]);
const NOTES: Record<string, string> = {
opencode: "OpenCode Zen:没有订阅登录,用 opencode.ai 控制台生成的 key(订阅体现在 key 上)",
"opencode-go": "OpenCode Go:没有订阅登录,用 opencode.ai 控制台生成的 key(与 Zen 同一个 OPENCODE_API_KEY)",
openrouter: "OAuth 登录换得的是永久 API Key,不需要刷新",
radius: "pi 自家网关,模型表可动态刷新",
"amazon-bedrock": "API Key 填 Bedrock bearer token;或选“无”使用服务端 AWS 凭据链",
"google-vertex": "API Key 填 Google Cloud API key;或选“无”使用服务端 ADC / 服务账号",
};
export function modelInfo(m: Model<Api>, source: PiModelInfo["source"] = "static"): PiModelInfo {
let levels: PiThinkingLevel[] = [];
try {
levels = getSupportedThinkingLevels(m) as PiThinkingLevel[];
} catch {
levels = m.reasoning ? ["off", "low", "medium", "high"] : ["off"];
}
return {
id: m.id,
name: m.name || m.id,
api: m.api,
reasoning: m.reasoning,
thinkingLevels: levels,
contextWindow: Math.max(0, Math.round(m.contextWindow || 0)),
maxTokens: Math.max(0, Math.round(m.maxTokens || 0)),
input: m.input?.length ? [...m.input] : ["text"],
source,
};
}
function builtinInfo(p: Provider, withModels: boolean): PiProviderInfo {
const models = p.getModels();
const oauth = p.auth.oauth;
const subscription = oauth?.isSubscription === true;
const authKinds: PiAuthKind[] = [];
if (subscription) authKinds.push("oauth");
if (p.auth.apiKey) authKinds.push("api_key");
if (oauth && !subscription) authKinds.push("oauth");
if (AMBIENT.has(p.id)) authKinds.push("none");
const termsNote = TERMS_NOTES[p.id] ?? null;
const group: PiProviderGroup = subscription ? "subscription" : CLOUD.has(p.id) ? "cloud" : "api_key";
return {
id: p.id as PiProviderId,
name: p.name,
group,
authKinds,
oauth: oauth
? {
name: oauth.name,
isSubscription: subscription,
modes: OAUTH_MODES[p.id] ?? ["browser"],
termsNote,
}
: null,
termsNote,
baseUrlOverridable: !!p.auth.apiKey && !NO_BASE_OVERRIDE.has(p.id),
defaultBaseUrl: p.baseUrl ?? models[0]?.baseUrl ?? null,
envFields: ENV_FIELDS[p.id] ?? [],
apis: [...new Set(models.map((m) => m.api))],
supportsThinking: models.some((m) => m.reasoning),
dynamicModels: typeof p.refreshModels === "function",
modelCount: models.length,
models: withModels ? models.map((m) => modelInfo(m)) : [],
note: NOTES[p.id] ?? null,
};
}
const CUSTOM_INFO: PiProviderInfo = {
id: "openai-compatible",
name: "自定义兼容端点",
group: "custom",
authKinds: ["api_key", "none"],
oauth: null,
termsNote: null,
baseUrlOverridable: true,
defaultBaseUrl: null,
envFields: [],
apis: ["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"],
supportsThinking: true,
dynamicModels: false,
modelCount: 0,
models: [],
note: "OpenAI / Anthropic / Responses / Gemini 兼容端点(vLLM、Ollama、LiteLLM、各家中转);baseUrl 与模型 id 必填,extra.api 选 API 形状",
};
const FAKE_INFO = (withModels: boolean): PiProviderInfo => ({
id: "fake",
name: "Fake(脚本化假模型)",
group: "test",
authKinds: ["none", "oauth"],
oauth: { name: "Fake OAuth(模拟登录,不联网)", isSubscription: false, modes: ["browser", "device"], termsNote: null },
termsNote: null,
baseUrlOverridable: false,
defaultBaseUrl: null,
envFields: [],
apis: ["faux"],
supportsThinking: true,
dynamicModels: false,
modelCount: 1,
models: withModels
? [
{
id: "fake-model",
name: "fake-model",
api: "faux",
reasoning: true,
thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh"],
contextWindow: 64_000,
maxTokens: 4096,
input: ["text"],
source: "static",
},
]
: [],
note: "演示与测试用:不联网,按脚本回复;OAuth 为模拟流程",
});
/** GET /pi/providers 的能力表 */
export function providerCatalog(opts: { models?: boolean } = {}): {
piVersion: string;
catalogGeneratedAt: string | null;
providers: PiProviderInfo[];
} {
const withModels = opts.models !== false;
const at = getBuiltinModelDataGeneratedAt();
return {
piVersion: PI_AI_VERSION,
catalogGeneratedAt: at ? new Date(at).toISOString() : null,
providers: [...builtinProviderList().map((p) => builtinInfo(p, withModels)), CUSTOM_INFO, FAKE_INFO(withModels)],
};
}
export function providerInfo(id: string): PiProviderInfo | undefined {
if (id === "openai-compatible") return CUSTOM_INFO;
if (id === "fake") return FAKE_INFO(false);
const p = builtinProvider(id);
return p ? builtinInfo(p, false) : undefined;
}
+171
View File
@@ -0,0 +1,171 @@
import { randomBytes } from "node:crypto";
import {
type AssistantMessage,
createProvider,
type FauxResponseFactory,
fauxAssistantMessage,
fauxProvider,
type OAuthAuth,
type OAuthCredential,
type Provider,
type ProviderAuthInteraction,
} from "@earendil-works/pi-ai";
// provider = "fake" 的上游:faux 模型 + 模拟 OAuth(browser 粘 URL / device 轮询),不联网。
// 设置页联调与 OAuth 状态机测试用;行为由 FakeOAuthControl 控制(测试注入,缺省用进程级默认实例)。
export class FakeOAuthControl {
loginCalls = 0;
refreshCalls = 0;
/** refresh 抛 invalid_grant */
failRefresh = false;
/** refresh 前等待(并发测试用) */
refreshDelayMs = 0;
/** 新 token 的有效期 */
tokenTtlMs = 3600_000;
/** device 模式:多久后自动批准(null = 只能 approveDevice() 批准) */
deviceAutoApproveMs: number | null = 3000;
/** browser 模式:模拟回调端口被占用(pi 的 Anthropic 会直接 reject) */
browserListenError: string | null = null;
private approved = false;
approveDevice(): void {
this.approved = true;
}
/** @internal */
consumeApproval(): boolean {
if (!this.approved) return false;
this.approved = false;
return true;
}
}
export const DEFAULT_FAKE_OAUTH = new FakeOAuthControl();
const sleep = (ms: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(t);
reject(new Error("Login cancelled"));
},
{ once: true },
);
});
function parsePasted(input: string): { code?: string; state?: string } {
const v = input.trim();
try {
const u = new URL(v);
return { code: u.searchParams.get("code") ?? undefined, state: u.searchParams.get("state") ?? undefined };
} catch {
// code#state 或裸 code
}
if (v.includes("#")) {
const [code, state] = v.split("#", 2);
return { code: code || undefined, state: state || undefined };
}
if (v.includes("code=")) {
const p = new URLSearchParams(v);
return { code: p.get("code") ?? undefined, state: p.get("state") ?? undefined };
}
return { code: v || undefined };
}
function issue(control: FakeOAuthControl, account: string): OAuthCredential {
const n = randomBytes(6).toString("hex");
return {
type: "oauth",
access: `fake-access-${n}`,
refresh: `fake-refresh-${n}`,
expires: Date.now() + control.tokenTtlMs,
account,
};
}
export function fakeOAuth(control: FakeOAuthControl): OAuthAuth {
return {
name: "Fake OAuth",
isSubscription: false,
async login(interaction: ProviderAuthInteraction) {
control.loginCalls += 1;
const method = await interaction.prompt({
type: "select",
message: "Select fake login method:",
options: [
{ id: "browser", label: "Browser login" },
{ id: "device_code", label: "Device code login" },
],
});
if (method === "device_code") {
const userCode = `FAKE-${randomBytes(2).toString("hex").toUpperCase()}`;
interaction.notify({
type: "device_code",
userCode,
verificationUri: "https://fake-oauth.invalid/device",
intervalSeconds: 1,
expiresInSeconds: 900,
});
const started = Date.now();
for (;;) {
if (control.consumeApproval()) break;
if (control.deviceAutoApproveMs !== null && Date.now() - started >= control.deviceAutoApproveMs) break;
await sleep(25, interaction.signal);
}
interaction.notify({ type: "progress", message: "Device authorized" });
return issue(control, "fake-device-user");
}
if (control.browserListenError) throw new Error(control.browserListenError);
const state = randomBytes(8).toString("hex");
interaction.notify({
type: "auth_url",
url: `https://fake-oauth.invalid/authorize?state=${state}&redirect_uri=${encodeURIComponent("http://localhost:1455/auth/callback")}`,
instructions: "Complete login in your browser, then paste the final redirect URL here.",
});
const input = await interaction.prompt({
type: "manual_code",
message: "Paste the authorization code / redirect URL:",
placeholder: "http://localhost:1455/auth/callback",
});
const parsed = parsePasted(input);
if (!parsed.code) throw new Error("Missing authorization code");
if (parsed.state && parsed.state !== state) throw new Error("OAuth state mismatch");
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
return issue(control, "fake-browser-user");
},
async refresh(credential, signal) {
control.refreshCalls += 1;
if (control.refreshDelayMs > 0) await sleep(control.refreshDelayMs, signal);
if (control.failRefresh) throw new Error("invalid_grant: refresh token revoked");
return issue(control, String(credential.account ?? "fake-user"));
},
async toAuth(credential) {
return { apiKey: credential.access };
},
};
}
/** provider id "fake" 的上游 provider:faux 流 + 始终就绪的 apiKey(authKind none)+ 模拟 OAuth */
export function fakeUpstreamProvider(control: FakeOAuthControl): Provider {
const faux = fauxProvider({
provider: "fake",
models: [{ id: "fake-model", contextWindow: 64_000, maxTokens: 4096, reasoning: true }],
tokensPerSecond: 0,
});
const factory: FauxResponseFactory = (): AssistantMessage => {
faux.appendResponses([factory]);
return fauxAssistantMessage("ok");
};
faux.setResponses([factory]);
const base = faux.provider;
return createProvider({
id: "fake",
name: "Fake",
auth: { apiKey: { name: "Fake", resolve: async () => ({ auth: {}, source: "fake" }) }, oauth: fakeOAuth(control) },
models: base.getModels(),
api: { stream: base.stream.bind(base), streamSimple: base.streamSimple.bind(base) } as never,
});
}
+6 -1
View File
@@ -8,9 +8,11 @@ import type { ResolvedAgentSettings } from "../settings.ts";
export interface ResolvedPiModel {
models: MutableModels;
model: Model<Api>;
/** 夹到模型支持范围后的思考档位;缺省用设置值 */
thinkingLevel?: ResolvedAgentSettings["thinkingLevel"];
}
export type PiModelResolver = (settings: ResolvedAgentSettings) => ResolvedPiModel;
export type PiModelResolver = (settings: ResolvedAgentSettings) => ResolvedPiModel | Promise<ResolvedPiModel>;
const ANTHROPIC_DEFAULT_BASE = "https://api.anthropic.com";
const PROVIDER_ID = { anthropic: "umt-anthropic", "openai-compatible": "umt-openai-compatible" } as const;
@@ -20,6 +22,9 @@ const PROVIDER_ID = { anthropic: "umt-anthropic", "openai-compatible": "umt-open
* Anthropic baseUrl 不带 /v1;OpenAI 兼容 baseUrl 带 /v1(与 spike 抓包结论一致)。
*/
export function resolveModelFromSettings(settings: ResolvedAgentSettings): ResolvedPiModel {
if (settings.provider !== "anthropic" && settings.provider !== "openai-compatible")
throw new Error(`provider ${settings.provider} 需要按上游装配(PiAgentRuntimeOptions.upstream)`);
if (!settings.apiKey) throw new Error("未配置上游密钥");
const providerId = PROVIDER_ID[settings.provider];
const common = {
id: settings.model,
+20 -2
View File
@@ -27,11 +27,22 @@ import {
import { type ResolvedAgentSettings, resolveAgentSettings } from "../settings.ts";
import { compactionFromSettings, type PiModelResolver, resolveModelFromSettings } from "./model.ts";
import { openRunSession } from "./session.ts";
import {
PiUpstreamClient,
type UpstreamClientOptions,
type UpstreamCredentialStore,
type UpstreamSpec,
} from "./upstream.ts";
export interface PiAgentRuntimeOptions {
registry: CapabilityRegistry;
/** 默认按设置构造真实上游;测试用 FakeModelProvider 覆盖 */
resolveModel?: PiModelResolver;
/**
* 按上游实例装配(W7-A):独立 Models + 只映射本上游 secret 的凭据存储(OAuth 按需刷新、锁内写回)。
* 给了 resolveModel 时以 resolveModel 为准。
*/
upstream?: { spec: UpstreamSpec; credentials: UpstreamCredentialStore; options?: UpstreamClientOptions };
/**
* 每个事件产生时回调(server 用它写 agent_run_event 再推 SSE)。
* 同一 run 内按 seq 顺序串行调用(前一个 Promise 结束才调下一个);回调抛错只记入 onEventError,不影响运行。
@@ -56,7 +67,14 @@ export class PiAgentRuntime implements AgentRuntimePort {
const ctx = BACKGROUND_CONTEXT;
const settings = resolveAgentSettings(spec.settings);
const capabilities = this.options.registry.resolve(spec.capabilities);
const { models, model } = (this.options.resolveModel ?? resolveModelFromSettings)(settings);
const upstream = this.options.upstream;
const resolved = this.options.resolveModel
? await this.options.resolveModel(settings)
: upstream
? await new PiUpstreamClient(upstream.spec, upstream.credentials, upstream.options).resolveRunModel(settings)
: resolveModelFromSettings(settings);
const { models, model } = resolved;
const thinkingLevel = resolved.thinkingLevel ?? settings.thinkingLevel;
const opened = await openRunSession(spec.sessionDir, spec.runId, ctx);
const run = new PiAgentRun(spec, settings, capabilities, this.options, opened.path, opened.repo);
@@ -69,7 +87,7 @@ export class PiAgentRuntime implements AgentRuntimePort {
systemPrompt: spec.systemPrompt,
tools: capabilities.map((c) => run.toTool(c)),
toolContext: {},
thinkingLevel: settings.thinkingLevel,
thinkingLevel,
compaction: compactionFromSettings(settings, model.contextWindow),
retry: { enabled: settings.maxRetries > 0, maxRetries: settings.maxRetries, baseDelayMs: 1000 },
streamOptions: { timeoutMs: settings.requestTimeoutMs, maxRetries: settings.maxRetries },
+389
View File
@@ -0,0 +1,389 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import process from "node:process";
import {
type Api,
type AuthContext,
type AuthInteraction,
type Credential,
type CredentialStore,
clampThinkingLevel,
createModels,
createProvider,
type Model,
ModelsError,
type MutableModels,
type Provider,
type ProviderStreams,
} from "@earendil-works/pi-ai";
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy";
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
import type { PiModelInfo, PiTestErrorKind } from "@umt/contracts";
import { redactString } from "../redact.ts";
import type { AgentThinkingLevel, ResolvedAgentSettings } from "../settings.ts";
import { builtinProvider, modelInfo, providerInfo } from "./catalog.ts";
import { DEFAULT_FAKE_OAUTH, type FakeOAuthControl, fakeUpstreamProvider } from "./fake-upstream.ts";
import type { ResolvedPiModel } from "./model.ts";
// 按上游实例装配 pi-ai:每个上游一个独立的 Models,里面只有它那一个 provider,
// credentials 是只映射到本上游 secret 的 CredentialStore 适配器 —— 绕开“pi 按 provider id 只存一份凭据”,
// 同一 provider 的多个账号可以并存。明文 key / token 只经 read() 交给 SDK,不进事件、日志、错误信息。
/** 上游描述(来自 pi_upstream 行,非密) */
export interface UpstreamSpec {
id: string;
provider: string;
authKind: "api_key" | "oauth" | "none";
baseUrl?: string | null;
/** 缺省模型 id;null 用目录第一个 */
model?: string | null;
thinkingLevel?: string;
extra?: {
env?: Record<string, string>;
api?: string;
compat?: Record<string, unknown>;
headers?: Record<string, string>;
contextWindow?: number;
};
}
/** 与 pi Credential 同形(本包自有类型,不暴露 SDK) */
export type UpstreamCredential =
| { type: "api_key"; key?: string; env?: Record<string, string> }
| { type: "oauth"; access: string; refresh: string; expires: number; [k: string]: unknown };
/**
* 本上游凭据的存取。modify 是唯一写路径,实现必须跨进程互斥(server 用 pi_upstream 行锁),
* 因为 refresh token 会轮换,并发刷新会让其中一方失效。fn 返回 undefined 表示不改。
*/
export interface UpstreamCredentialStore {
read(): Promise<UpstreamCredential | undefined>;
modify(
fn: (current: UpstreamCredential | undefined) => Promise<UpstreamCredential | undefined>,
): Promise<UpstreamCredential | undefined>;
delete(): Promise<void>;
}
export interface UpstreamClientOptions {
/** provider = fake 的模拟 OAuth 行为(测试注入) */
fakeOAuth?: FakeOAuthControl;
}
/** 登录交互(与 pi AuthInteraction 同形) */
export type UpstreamAuthPrompt = { signal?: AbortSignal } & (
| { type: "text"; message: string; placeholder?: string }
| { type: "secret"; message: string; placeholder?: string }
| { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] }
| { type: "manual_code"; message: string; placeholder?: string }
);
export type UpstreamAuthEvent =
| { type: "info"; message: string; links?: readonly { url: string; label?: string }[] }
| { type: "auth_url"; url: string; instructions?: string }
| {
type: "device_code";
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}
| { type: "progress"; message: string };
export interface UpstreamLoginInteraction {
signal?: AbortSignal;
prompt(prompt: UpstreamAuthPrompt): Promise<string>;
notify(event: UpstreamAuthEvent): void;
}
/** 上游错误(已脱敏);kind 供 UI 分类与状态机使用 */
export class UpstreamError extends Error {
constructor(
readonly kind: PiTestErrorKind,
message: string,
) {
super(message);
this.name = "UpstreamError";
}
}
export function classifyUpstreamError(err: unknown): UpstreamError {
if (err instanceof UpstreamError) return err;
const raw = err instanceof Error ? err.message : String(err);
const message = redactString(raw).slice(0, 500);
if (err instanceof ModelsError) {
if (err.code === "oauth") return new UpstreamError("oauth", message);
if (err.code === "auth") return new UpstreamError("auth", message);
if (err.code === "provider" || err.code === "model_validation") return new UpstreamError("config", message);
}
if (/\b(401|403)\b|unauthori[sz]ed|invalid[_ ]api[_ ]key|authentication|forbidden/i.test(raw))
return new UpstreamError("auth", message);
if (/\b404\b|model[^\n]{0,40}(not found|does not exist|unknown)/i.test(raw))
return new UpstreamError("model", message);
if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNRESET|fetch failed|network|timed? ?out|socket/i.test(raw))
return new UpstreamError("network", message);
return new UpstreamError("other", message);
}
const CUSTOM_APIS: Record<string, () => ProviderStreams> = {
"openai-completions": openAICompletionsApi,
"openai-responses": openAIResponsesApi,
"anthropic-messages": anthropicMessagesApi,
"google-generative-ai": googleGenerativeAIApi,
};
const trimSlash = (u: string) => u.replace(/\/+$/, "");
/** baseUrl 覆盖:Anthropic 形状去掉 /v1(SDK 自己加),其它按原样 */
function overrideBase(m: Model<Api>, baseUrl: string): Model<Api> {
const b = trimSlash(baseUrl);
return { ...m, baseUrl: m.api === "anthropic-messages" ? b.replace(/\/v1$/, "") : b };
}
function withBaseUrl(base: Provider, baseUrl: string): Provider {
return {
...base,
baseUrl,
getModels: () => base.getModels().map((m) => overrideBase(m, baseUrl)),
};
}
function customProvider(spec: UpstreamSpec): Provider {
const api = spec.extra?.api ?? "openai-completions";
const factory = CUSTOM_APIS[api];
if (!factory) throw new UpstreamError("config", `不支持的 API 形状:${api}`);
if (!spec.baseUrl) throw new UpstreamError("config", "自定义兼容端点必须配置 baseUrl");
if (!spec.model) throw new UpstreamError("config", "自定义兼容端点必须配置模型 id");
const base = api === "anthropic-messages" ? trimSlash(spec.baseUrl).replace(/\/v1$/, "") : trimSlash(spec.baseUrl);
const model = {
id: spec.model,
name: spec.model,
api,
provider: "openai-compatible",
baseUrl: base,
reasoning: (spec.thinkingLevel ?? "off") !== "off",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: spec.extra?.contextWindow ?? 128_000,
maxTokens: 8192,
...(spec.extra?.headers ? { headers: spec.extra.headers } : {}),
...(api === "openai-completions"
? { compat: { supportsDeveloperRole: false, ...(spec.extra?.compat ?? {}) } }
: spec.extra?.compat
? { compat: spec.extra.compat }
: {}),
} as Model<Api>;
const keyless = spec.authKind === "none";
return createProvider({
id: "openai-compatible",
name: "自定义兼容端点",
auth: {
apiKey: {
name: "API key",
resolve: async ({ credential }) => {
if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored credential" };
return keyless ? { auth: {}, source: "none" } : undefined;
},
},
},
models: [model],
api: factory(),
});
}
function providerFor(spec: UpstreamSpec, opts: UpstreamClientOptions): Provider {
if (spec.provider === "fake") return fakeUpstreamProvider(opts.fakeOAuth ?? DEFAULT_FAKE_OAUTH);
if (spec.provider === "openai-compatible") return customProvider(spec);
const info = providerInfo(spec.provider);
// 动态 provider(Radius)持有模型表状态,每个上游用新实例
const base = builtinProvider(spec.provider, info?.dynamicModels === true);
if (!base) throw new UpstreamError("config", `未知 provider:${spec.provider}`);
if (spec.baseUrl && info?.baseUrlOverridable && spec.authKind !== "oauth") return withBaseUrl(base, spec.baseUrl);
return base;
}
const expandHome = (p: string) => (p.startsWith("~") ? `${homedir()}${p.slice(1)}` : p);
/**
* 一个上游的 pi 客户端:凭据解析(OAuth 按需刷新,锁内双重检查)、登录、登出、模型表、测试、运行时模型装配。
*/
export class PiUpstreamClient {
readonly providerId: string;
private readonly models: MutableModels;
private readonly provider: Provider;
constructor(
readonly spec: UpstreamSpec,
private readonly store: UpstreamCredentialStore,
opts: UpstreamClientOptions = {},
) {
this.provider = providerFor(spec, opts);
this.providerId = this.provider.id;
const id = this.providerId;
const envOf = (): Record<string, string> => spec.extra?.env ?? {};
const credentials: CredentialStore = {
read: async (pid) => (pid === id ? this.toPi(await store.read()) : undefined),
list: async () => {
const c = await store.read();
return c ? [{ providerId: id, type: c.type }] : [];
},
modify: async (pid, fn) => {
if (pid !== id) throw new Error(`凭据存储只服务 provider ${id}`);
const out = await store.modify(async (cur) => {
const next = await fn(this.toPi(cur));
return next as UpstreamCredential | undefined;
});
return this.toPi(out);
},
delete: async (pid) => {
if (pid === id) await store.delete();
},
};
// 只有 authKind none(云环境凭据链 / ADC)才读进程环境变量;其它只认本上游 extra.env,避免误用宿主的 key
const ambient = spec.authKind === "none";
const authContext: AuthContext = {
env: async (name) => envOf()[name] || (ambient ? process.env[name] || undefined : undefined),
fileExists: async (path) => (ambient ? existsSync(expandHome(path)) : false),
};
this.models = createModels({ credentials, authContext });
this.models.setProvider(this.provider);
}
private toPi(c: UpstreamCredential | undefined): Credential | undefined {
if (!c) return undefined;
if (c.type === "api_key") {
const env = { ...(this.spec.extra?.env ?? {}), ...(c.env ?? {}) };
return { type: "api_key", ...(c.key ? { key: c.key } : {}), ...(Object.keys(env).length ? { env } : {}) };
}
return c as Credential;
}
/** 解析请求凭据;OAuth 剩余有效期不足 minValidityMs(至少 5 分钟)时在锁内刷新并写回 */
async resolveAuth(minValidityMs?: number): Promise<{ source: string | null }> {
if (this.spec.authKind === "oauth") {
const c = await this.store.read();
if (c?.type !== "oauth") throw new UpstreamError("auth", "尚未登录(需要订阅登录)");
}
let r: Awaited<ReturnType<MutableModels["getAuth"]>>;
try {
r = await this.models.getAuth(
this.providerId,
minValidityMs !== undefined ? { minOAuthValidityMs: minValidityMs } : undefined,
);
} catch (err) {
throw classifyUpstreamError(err);
}
if (!r) throw new UpstreamError("auth", this.spec.authKind === "api_key" ? "未配置 API Key" : "凭据不完整");
return { source: r.source ?? null };
}
/** 运行 provider 的订阅登录流程;成功后经 store.modify 写回 */
async login(interaction: UpstreamLoginInteraction): Promise<void> {
try {
await this.models.login(this.providerId, "oauth", interaction as AuthInteraction);
} catch (err) {
throw classifyUpstreamError(err);
}
}
async logout(): Promise<void> {
await this.models.logout(this.providerId);
}
/** 模型表:动态 provider 先刷新(失败保留静态表) */
async listModels(opts: { refresh?: boolean } = {}): Promise<{
items: PiModelInfo[];
refreshed: boolean;
refreshError: string | null;
}> {
let refreshed = false;
let refreshError: string | null = null;
const dynamic = typeof this.provider.refreshModels === "function";
if (dynamic && opts.refresh !== false) {
try {
const r = await this.models.refresh({ providers: [this.providerId], allowNetwork: true, force: true });
const e = r.errors.get(this.providerId);
if (e) refreshError = redactString(e.message).slice(0, 300);
else refreshed = true;
} catch (err) {
refreshError = classifyUpstreamError(err).message;
}
}
const staticIds = new Set(this.provider === undefined ? [] : this.provider.getModels().map((m) => m.id));
let list = this.models.getModels(this.providerId);
try {
const available = await this.models.getAvailable(this.providerId);
if (available.length) list = available;
} catch {
// 未配置凭据时 getAvailable 为空,退回完整目录
}
const source = (m: Model<Api>): PiModelInfo["source"] =>
this.providerId === "openai-compatible" ? "custom" : refreshed && !staticIds.has(m.id) ? "dynamic" : "static";
return { items: list.map((m) => modelInfo(m, source(m))), refreshed, refreshError };
}
/** 查模型;目录里没有(目录滞后或手填 id)时以同 provider 第一个模型为模板合成 */
private pickModel(modelId: string | null | undefined): { model: Model<Api>; inCatalog: boolean } {
const all = this.models.getModels(this.providerId);
const id = modelId ?? this.spec.model ?? all[0]?.id;
if (!id) throw new UpstreamError("model", "该上游没有可用模型,请填写模型 id");
const found = this.models.getModel(this.providerId, id);
if (found) return { model: found, inCatalog: true };
const template = all[0];
if (!template) throw new UpstreamError("model", `模型 ${id} 不在目录中,且该 provider 没有可参照的模型`);
return { model: { ...template, id, name: id }, inCatalog: false };
}
/**
* 测试:auth = 解析凭据 + 校验模型(不产生费用);completion = 再发一次最小补全(1 个输出 token)。
* 失败抛 UpstreamError(kind 分类,消息已脱敏)。
*/
async test(
opts: { mode?: "auth" | "completion"; model?: string; signal?: AbortSignal } = {},
): Promise<{ source: string | null; model: string; inCatalog: boolean }> {
const { source } = await this.resolveAuth();
if (typeof this.provider.refreshModels === "function")
await this.models
.refresh({ providers: [this.providerId], allowNetwork: true, ...(opts.signal ? { signal: opts.signal } : {}) })
.catch(() => undefined);
const { model, inCatalog } = this.pickModel(opts.model);
if (opts.mode === "completion") {
let res: Awaited<ReturnType<MutableModels["completeSimple"]>>;
try {
res = await this.models.completeSimple(
{ ...model, maxTokens: Math.min(model.maxTokens || 16, 16) },
{ messages: [{ role: "user", content: "Reply with: ok", timestamp: Date.now() }] },
{ maxTokens: 16, ...(opts.signal ? { signal: opts.signal } : {}), timeoutMs: 30_000, maxRetries: 0 },
);
} catch (err) {
throw classifyUpstreamError(err);
}
if (res.stopReason === "error" || res.stopReason === "aborted")
throw classifyUpstreamError(new Error(res.errorMessage ?? "上游返回错误"));
}
return { source, model: model.id, inCatalog };
}
/** 运行时装配:按设置裁剪上下文窗口 / 输出上限,思考档位夹到模型支持的范围 */
async resolveRunModel(settings: ResolvedAgentSettings): Promise<ResolvedPiModel> {
if (typeof this.provider.refreshModels === "function")
await this.models.refresh({ providers: [this.providerId], allowNetwork: true }).catch(() => undefined);
// 上游的 default_model 为准;未配置时用目录第一个模型(settings.model 只是记录用占位)
const { model: m } = this.pickModel(this.spec.model ?? null);
const contextWindow = Math.min(m.contextWindow || settings.contextBudgetTokens, settings.contextBudgetTokens);
const model: Model<Api> = {
...m,
contextWindow,
maxTokens: Math.max(
256,
Math.min(m.maxTokens || settings.maxOutputTokens, settings.maxOutputTokens, Math.floor(contextWindow / 2)),
),
};
let thinkingLevel: AgentThinkingLevel = settings.thinkingLevel;
try {
thinkingLevel = clampThinkingLevel(model, settings.thinkingLevel) as AgentThinkingLevel;
} catch {
// 老模型无 thinking 元数据时保持设置值
}
return { models: this.models, model, thinkingLevel };
}
}
+8 -2
View File
@@ -8,10 +8,15 @@ export type AgentThinkingLevel = (typeof THINKING_LEVELS)[number];
* 只在请求时经 provider auth.resolve 交给 SDK,不进入事件、日志、错误信息。
*/
export const AgentSettingsSchema = z.object({
provider: z.enum(["anthropic", "openai-compatible"]),
/**
* pi provider id。按上游运行(PiAgentRuntimeOptions.upstream)时只作记录;
* 没有上游时只支持旧的 anthropic / openai-compatible(apiKey 必填)
*/
provider: z.string().min(1),
/** Anthropic 写不带 /v1 的根地址;OpenAI 兼容写带 /v1 的地址 */
baseUrl: z.string().url().optional(),
apiKey: z.string().min(1),
/** 旧的单上游密钥;按上游运行时由凭据存储提供,这里留空 */
apiKey: z.string().min(1).optional(),
model: z.string().min(1),
thinkingLevel: z.enum(THINKING_LEVELS).default("off"),
/** 上下文预算 = 模型 contextWindow(可以比上游真实窗口小) */
@@ -54,5 +59,6 @@ export function resolveAgentSettings(settings: AgentSettings): ResolvedAgentSett
export function describeSettings(settings: ResolvedAgentSettings): Omit<ResolvedAgentSettings, "apiKey"> & {
apiKey: "[REDACTED]";
} {
// apiKey 可能为空(按上游运行),展示时一律打码
return { ...settings, apiKey: "[REDACTED]" };
}
+219
View File
@@ -0,0 +1,219 @@
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);
});
});