aps-agent/providers/aps/index.ts

253 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* APS Provider(架构设计第 6 节):把 APS 的能力翻译成平台契约。
*
* 只做适配:声明能力、转发调用、归一化回执。不判断权限(那归 APS)、不碰模型上下文。
*
* **能力的权威来源是 APS 自己的 `/api/manifest`**(A5.1):平台启动注册时拉一次,
* 不在平台侧手抄一份,避免两边漂移。APS 起不来时 `describe()` 直接失败——注册不上,
* 比"注册上了但能力是旧的"安全。
*/
import {
ProviderError,
isErrorKind,
type AuthExchangeRequest,
type AuthExchangeResult,
type Capability,
type CapabilityCall,
type CredentialCheck,
type ExecutionContext,
type HealthStatus,
type Provider,
type ProviderManifest,
} from '@aps/provider-sdk';
import type { ExecutionReceipt } from '@aps/contracts';
export const APS_PROVIDER_ID = 'aps';
export interface ApsProviderOptions {
/** APS 服务地址,如 http://127.0.0.1:8000 */
baseUrl: string;
/** 服务间令牌(B5 的鉴权位;首版 APS 未强制) */
token?: string;
/** 单次调用超时,默认 30s;写能力应短于平台的 execution 超时 */
timeoutMs?: number;
/** 便于测试注入 */
fetchImpl?: typeof fetch;
}
interface ApsManifestBody {
id?: unknown;
displayName?: unknown;
version?: unknown;
platformVersion?: unknown;
requiresIdentity?: unknown;
auth?: unknown;
capabilities?: unknown;
}
const DEFAULT_TIMEOUT_MS = 30_000;
const RECEIPT_STATUSES = new Set(['accepted', 'pending-confirmation', 'succeeded', 'failed']);
function asString(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() !== '' ? value : fallback;
}
/** 登录方式声明:只认平台定义的三种,别的当没声明 */
function toAuthSpec(raw: unknown): { kind: 'oauth' | 'password-exchange' | 'apikey'; authorizationUrl?: string } | undefined {
const item = (raw ?? {}) as Record<string, unknown>;
const kind = item.kind;
if (kind !== 'oauth' && kind !== 'password-exchange' && kind !== 'apikey') return undefined;
return {
kind,
...(typeof item.authorizationUrl === 'string' ? { authorizationUrl: item.authorizationUrl } : {}),
};
}
/** APS 的能力声明与平台 provider-sdk 的 Capability 同形,这里只做字段校验与兜底 */
function toCapability(raw: unknown): Capability {
const item = (raw ?? {}) as Record<string, unknown>;
const id = asString(item.id, '');
if (id === '') throw new ProviderError('invalid_argument', 'APS manifest 里有能力缺 id');
const effect = item.effect === 'draft' || item.effect === 'write' ? item.effect : 'read';
const mode = item.mode === 'async' ? 'async' : 'sync';
const idempotency = item.idempotency === 'key' || item.idempotency === 'natural' ? item.idempotency : 'none';
const errors = Array.isArray(item.errors)
? item.errors.filter((kind): kind is Capability['errors'][number] => isErrorKind(kind))
: [];
return {
id,
description: asString(item.description, item.title === undefined ? id : String(item.title)),
effect,
mode,
inputSchema: (item.inputSchema as Record<string, unknown>) ?? { type: 'object' },
outputSchema: (item.outputSchema as Record<string, unknown>) ?? { type: 'object' },
idempotency,
errors: errors.length > 0 ? errors : ['unavailable', 'unknown_result'],
...(typeof item.title === 'string' ? { title: item.title } : {}),
...(typeof item.whenNotToUse === 'string' ? { whenNotToUse: item.whenNotToUse } : {}),
...(Array.isArray(item.synonyms) ? { synonyms: item.synonyms.filter((s): s is string => typeof s === 'string') } : {}),
};
}
export function createApsProvider(options: ApsProviderOptions): Provider {
const baseUrl = options.baseUrl.replace(/\/+$/, '');
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const doFetch = options.fetchImpl ?? fetch;
const headers = (): Record<string, string> => ({
'content-type': 'application/json',
...(options.token === undefined ? {} : { authorization: `Bearer ${options.token}` }),
});
let manifestCache: ProviderManifest | undefined;
async function describe(): Promise<ProviderManifest> {
{
let response: Response;
try {
response = await doFetch(`${baseUrl}/api/manifest`, { headers: headers(), signal: AbortSignal.timeout(timeoutMs) });
} catch (error) {
throw new ProviderError('unavailable', `连不上 APS:${error instanceof Error ? error.message : '未知错误'}`);
}
if (!response.ok) {
throw new ProviderError('unavailable', `APS manifest 返回 HTTP ${response.status}`);
}
const body = (await response.json()) as ApsManifestBody;
const raw = Array.isArray(body.capabilities) ? body.capabilities : [];
const described: ProviderManifest = {
id: asString(body.id, APS_PROVIDER_ID),
displayName: asString(body.displayName, 'APS 排产系统'),
version: asString(body.version, '0.0.0'),
platformVersion: typeof body.platformVersion === 'number' ? body.platformVersion : 1,
requiresIdentity: body.requiresIdentity !== false,
// 登录方式要透传:平台按它决定绑定流程(ADR-0012)
...(toAuthSpec(body.auth) === undefined ? {} : { auth: toAuthSpec(body.auth) as NonNullable<ProviderManifest['auth']> }),
capabilities: raw.map(toCapability),
};
manifestCache = described;
return described;
}
}
async function invoke(context: ExecutionContext, call: CapabilityCall): Promise<ExecutionReceipt> {
{
// 能力白名单:APS 上不存在的能力直接报错,不把一个 404/兜底响应当成回执
const manifest = manifestCache ?? (await describe());
if (!manifest.capabilities.some((capability) => capability.id === call.capabilityId)) {
throw new ProviderError('invalid_argument', `APS 没有能力 ${call.capabilityId}`);
}
const payload = {
params: call.params ?? {},
executionId: context.executionId,
deadlineAt: context.deadlineAt,
...(context.identity === undefined ? {} : { identity: { accountId: context.identity.accountId } }),
...(context.idempotencyKey === undefined ? {} : { idempotencyKey: context.idempotencyKey }),
};
// 登录态只从一次性句柄里取(A4.3),不进参数、不进日志
const secret = context.credentials === undefined ? undefined : await context.credentials.read();
const authHeaders = secret?.token === undefined ? headers() : { ...headers(), authorization: `Bearer ${secret.token}` };
let response: Response;
try {
response = await doFetch(`${baseUrl}/api/capabilities/${encodeURIComponent(call.capabilityId)}`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(Math.max(1_000, Math.min(timeoutMs, context.deadlineAt - Date.now()))),
});
} catch (error) {
throw new ProviderError('unavailable', `调用 APS 失败:${error instanceof Error ? error.message : '未知错误'}`);
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// 业务系统说"你没登录/没权限":平台不覆盖这个结论,交给策略层把绑定标成待重绑
throw new ProviderError('forbidden', `APS 拒绝了这次调用(HTTP ${response.status}):登录态已失效或权限不足,请重新绑定`);
}
const kind = response.status >= 500 ? 'unavailable' : 'invalid_argument';
throw new ProviderError(kind, `APS 返回 HTTP ${response.status}`);
}
const body = (await response.json()) as Partial<ExecutionReceipt> & { errorKind?: unknown };
const status = typeof body.status === 'string' && RECEIPT_STATUSES.has(body.status) ? body.status : 'failed';
return {
executionId: asString(body.executionId, context.executionId),
status: status as ExecutionReceipt['status'],
...(body.result === undefined ? {} : { result: body.result }),
// 分类非法时交给 Core 归一,provider 不猜
...(typeof body.errorKind === 'string' ? { errorKind: body.errorKind } : {}),
...(typeof body.approvalId === 'string' ? { approvalId: body.approvalId } : {}),
};
}
}
async function health(): Promise<HealthStatus> {
{
try {
const response = await doFetch(`${baseUrl}/api/health`, { headers: headers(), signal: AbortSignal.timeout(3_000) });
if (!response.ok) return { status: 'down', detail: `HTTP ${response.status}`, checkedAt: Date.now() };
const body = (await response.json()) as { status?: unknown; detail?: unknown };
const status = body.status === 'degraded' || body.status === 'down' || body.status === 'ok' ? body.status : 'ok';
return {
status,
...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
checkedAt: Date.now(),
};
} catch (error) {
return { status: 'down', detail: error instanceof Error ? error.message : '健康探测失败', checkedAt: Date.now() };
}
}
}
async function exchange(request: AuthExchangeRequest): Promise<AuthExchangeResult> {
if (request.password === undefined) {
throw new ProviderError('invalid_argument', 'APS 走账号密码换取 token,绑定时要给一次密码');
}
let response: Response;
try {
response = await doFetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ accountId: request.accountId, password: request.password }),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
throw new ProviderError('unavailable', `连不上 APS:${error instanceof Error ? error.message : '未知错误'}`);
}
if (response.status === 401) {
throw new ProviderError('forbidden', 'APS 拒绝了这组账号密码');
}
if (!response.ok) {
throw new ProviderError(response.status >= 500 ? 'unavailable' : 'invalid_argument', `APS 登录返回 HTTP ${response.status}`);
}
const body = (await response.json()) as { token?: unknown; accountId?: unknown; displayName?: unknown; expiresAt?: unknown };
if (typeof body.token !== 'string' || body.token === '') {
throw new ProviderError('unknown_result', 'APS 登录成功但没返回 token');
}
return {
accountId: asString(body.accountId, request.accountId),
...(typeof body.displayName === 'string' ? { displayName: body.displayName } : {}),
secret: { token: body.token },
...(typeof body.expiresAt === 'number' ? { expiresAt: body.expiresAt } : {}),
};
}
async function checkCredential(secret: Record<string, string>): Promise<CredentialCheck> {
if (secret.token === undefined) return { ok: false, reason: '凭据里没有 token' };
try {
const response = await doFetch(`${baseUrl}/api/auth/whoami`, {
headers: { ...headers(), authorization: `Bearer ${secret.token}` },
signal: AbortSignal.timeout(3_000),
});
if (response.status === 401 || response.status === 403) return { ok: false, reason: '登录态已失效,请重新绑定' };
if (!response.ok) return { ok: false, reason: `APS 返回 HTTP ${response.status}` };
return { ok: true };
} catch (error) {
// 连不上不等于登录态失效:报 ok=false 会误伤,这里给出原因让上层决定
return { ok: false, reason: error instanceof Error ? error.message : '凭据体检失败' };
}
}
return { describe, invoke, health, exchange, checkCredential };
}
/** 供平台按配置加载:模块导出统一的 createProvider,参数来自配置 */
export function createProvider(options: { baseUrl: string; token?: string; timeoutMs?: number }): Provider {
return createApsProvider(options);
}