diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 7ad65a1e..ce47f9ac 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -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 { diff --git a/packages/agent/src/pi/catalog.ts b/packages/agent/src/pi/catalog.ts new file mode 100644 index 00000000..19a32272 --- /dev/null +++ b/packages/agent/src/pi/catalog.ts @@ -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 = { + 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 = { + anthropic: 53692, + "openai-codex": 1455, + radius: 1456, +}; + +const TERMS_TAIL = "用于自动化后台服务可能违反提供方的用户条款,账号有被限流或封禁的风险。"; +/** 条款提示(研究文档 §5):订阅登录借用官方客户端身份 */ +const TERMS_NOTES: Record = { + 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 = { + "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 = { + 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, 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; +} diff --git a/packages/agent/src/pi/fake-upstream.ts b/packages/agent/src/pi/fake-upstream.ts new file mode 100644 index 00000000..2e7376c0 --- /dev/null +++ b/packages/agent/src/pi/fake-upstream.ts @@ -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((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, + }); +} diff --git a/packages/agent/src/pi/model.ts b/packages/agent/src/pi/model.ts index f5c01bbc..76c0aae0 100644 --- a/packages/agent/src/pi/model.ts +++ b/packages/agent/src/pi/model.ts @@ -8,9 +8,11 @@ import type { ResolvedAgentSettings } from "../settings.ts"; export interface ResolvedPiModel { models: MutableModels; model: Model; + /** 夹到模型支持范围后的思考档位;缺省用设置值 */ + thinkingLevel?: ResolvedAgentSettings["thinkingLevel"]; } -export type PiModelResolver = (settings: ResolvedAgentSettings) => ResolvedPiModel; +export type PiModelResolver = (settings: ResolvedAgentSettings) => ResolvedPiModel | Promise; 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, diff --git a/packages/agent/src/pi/runtime.ts b/packages/agent/src/pi/runtime.ts index 58589115..ff51a6bb 100644 --- a/packages/agent/src/pi/runtime.ts +++ b/packages/agent/src/pi/runtime.ts @@ -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 }, diff --git a/packages/agent/src/pi/upstream.ts b/packages/agent/src/pi/upstream.ts new file mode 100644 index 00000000..6784ea38 --- /dev/null +++ b/packages/agent/src/pi/upstream.ts @@ -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; + api?: string; + compat?: Record; + headers?: Record; + contextWindow?: number; + }; +} + +/** 与 pi Credential 同形(本包自有类型,不暴露 SDK) */ +export type UpstreamCredential = + | { type: "api_key"; key?: string; env?: Record } + | { type: "oauth"; access: string; refresh: string; expires: number; [k: string]: unknown }; + +/** + * 本上游凭据的存取。modify 是唯一写路径,实现必须跨进程互斥(server 用 pi_upstream 行锁), + * 因为 refresh token 会轮换,并发刷新会让其中一方失效。fn 返回 undefined 表示不改。 + */ +export interface UpstreamCredentialStore { + read(): Promise; + modify( + fn: (current: UpstreamCredential | undefined) => Promise, + ): Promise; + delete(): Promise; +} + +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; + 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 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, baseUrl: string): Model { + 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; + 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 => 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>; + 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 { + try { + await this.models.login(this.providerId, "oauth", interaction as AuthInteraction); + } catch (err) { + throw classifyUpstreamError(err); + } + } + + async logout(): Promise { + 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): 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; 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>; + 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 { + 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 = { + ...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 }; + } +} diff --git a/packages/agent/src/settings.ts b/packages/agent/src/settings.ts index 8e87b391..e496236b 100644 --- a/packages/agent/src/settings.ts +++ b/packages/agent/src/settings.ts @@ -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 & { apiKey: "[REDACTED]"; } { + // apiKey 可能为空(按上游运行),展示时一律打码 return { ...settings, apiKey: "[REDACTED]" }; } diff --git a/packages/agent/test/upstream.test.ts b/packages/agent/test/upstream.test.ts new file mode 100644 index 00000000..d662bcf8 --- /dev/null +++ b/packages/agent/test/upstream.test.ts @@ -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 = 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 & Pick): 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(); + const it: UpstreamLoginInteraction = { + prompt: async (p) => { + if (p.type === "select") return mode; + if (p.type === "manual_code") return await new Promise((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); + }); +});