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:
1 parent
b9dbd54e37
commit
743fe2486d
8 files changed
+1085
-5
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,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]" };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user