refactor(agent): runAgent uses Agent upstreams only; deprecate legacy settings.pi fields
pi.provider / baseUrl / model / thinkingLevel / apiKeySecretRef are now DEPRECATED_SETTINGS_FIELDS: kept in the schema for stored JSON, dropped from PATCH /settings and from the settings sections, never read by the server. runAgent fails with a clear message when no upstream is set. The agent demo gate checks the default upstream provider; e2e seeds a fake upstream as default. OpenAPI summaries say Agent upstream. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
fb7727cf66
commit
3e37ac8ae7
15 files changed
+122
-115
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import type { FakeScript, FakeStep, FakeToolResult } from "@umt/agent";
|
||||
import { z } from "zod";
|
||||
|
||||
// fake 模式脚本(pi.provider = "fake"):测试直接注入 FakeScript;设置里的 agent.fakeScript 是可序列化子集(JSON):
|
||||
// fake 模式脚本(默认上游 provider = "fake"):测试直接注入 FakeScript;设置里的 agent.fakeScript 是可序列化子集(JSON):
|
||||
// { "match": "收进来" | { "regex": "..." }, "steps": [{ "text"?, "toolCalls": [{ "capability", "input" }] }], "finalText": "..." }
|
||||
// 或它们的数组。input 里的字符串可以写 "{{download.plan.plan.id}}":取该能力最近一次成功结果里的路径值。
|
||||
|
||||
|
||||
@@ -117,31 +117,17 @@ export interface DriveResult {
|
||||
|
||||
/**
|
||||
* 设置 → @umt/agent 的运行参数。按上游运行时(W7-A)凭据由上游的 CredentialStore 在请求时解密注入,这里不取明文;
|
||||
* 没有任何上游时回退 v0.1 旧字段(pi.provider / pi.apiKeySecretRef,apiKey 现场经 secrets 解密,不落日志)。
|
||||
* 只走上游:v0.1 的旧字段(pi.provider / model / apiKeySecretRef)已弃用,不再读取(W8-A)。
|
||||
*/
|
||||
async function agentSettings(
|
||||
env: AgentEnv,
|
||||
s: GlobalSettings,
|
||||
budget: AgentBudget,
|
||||
fake: boolean,
|
||||
upstream: RunUpstream | null,
|
||||
): Promise<AgentSettings> {
|
||||
function agentSettings(s: GlobalSettings, budget: AgentBudget, fake: boolean, upstream: RunUpstream): AgentSettings {
|
||||
const pi = s.pi;
|
||||
let apiKey: string | undefined;
|
||||
if (!fake && !upstream) {
|
||||
if (!pi.apiKeySecretRef) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)");
|
||||
apiKey = await env.ctx.secrets.resolve(pi.apiKeySecretRef);
|
||||
if (!pi.model) throw new Error("未配置模型(设置 → Agent → 上游)");
|
||||
}
|
||||
const maxTokens = budget.maxTokens ?? s.agent.maxTokensPerRun ?? undefined;
|
||||
const maxCost = budget.maxCostUsd ?? s.agent.maxCostUsdPerRun ?? undefined;
|
||||
const up = upstream?.row;
|
||||
const up = upstream.row;
|
||||
return {
|
||||
provider: up ? up.provider : pi.provider === "openai-compatible" ? "openai-compatible" : "anthropic",
|
||||
...(!up && pi.baseUrl && !fake ? { baseUrl: pi.baseUrl } : {}),
|
||||
...(apiKey ? { apiKey } : fake && !up ? { apiKey: "fake-key-not-used" } : {}),
|
||||
model: (up ? up.default_model : pi.model) ?? (fake ? "fake-model" : "default"),
|
||||
thinkingLevel: up ? up.thinking_level : pi.thinkingLevel,
|
||||
provider: up.provider,
|
||||
model: up.default_model ?? (fake ? "fake-model" : "default"),
|
||||
thinkingLevel: up.thinking_level,
|
||||
contextBudgetTokens: pi.contextBudget,
|
||||
compaction: {
|
||||
mode: pi.compaction.enabled ? "threshold" : "manual",
|
||||
@@ -329,14 +315,16 @@ export async function drive(
|
||||
if (blocked) return { status: "failed", reason: "budget", budget: "daily", message: blocked, finalText: "" };
|
||||
|
||||
const scope = (run.scope ?? {}) as unknown as RunScope;
|
||||
// 上游:run.upstream_id(runAgent 的 upstreamId 覆盖)> settings.pi.defaultUpstreamId > 旧字段兼容
|
||||
let upstream: RunUpstream | null;
|
||||
// 上游:run.upstream_id(runAgent 的 upstreamId 覆盖)> settings.pi.defaultUpstreamId;都没有直接失败
|
||||
let upstream: RunUpstream;
|
||||
let fake: boolean;
|
||||
let settings: AgentSettings;
|
||||
try {
|
||||
upstream = await resolveRunUpstream(ctx, run.upstream_id);
|
||||
fake = upstream ? upstream.row.provider === "fake" : s.pi.provider === "fake";
|
||||
settings = await agentSettings(env, s, (run.budget ?? {}) as AgentBudget, fake, upstream);
|
||||
const resolved = await resolveRunUpstream(ctx, run.upstream_id);
|
||||
if (!resolved) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)");
|
||||
upstream = resolved;
|
||||
fake = upstream.row.provider === "fake";
|
||||
settings = agentSettings(s, (run.budget ?? {}) as AgentBudget, fake, upstream);
|
||||
} catch (err) {
|
||||
return { status: "failed", reason: "error", budget: null, message: (err as Error).message, finalText: "" };
|
||||
}
|
||||
@@ -346,9 +334,9 @@ export async function drive(
|
||||
status: "running",
|
||||
started_at: run.started_at ?? new Date(),
|
||||
provider: fake ? "fake" : settings.provider,
|
||||
model: upstream ? (upstream.row.default_model ?? (fake ? "fake-model" : null)) : fake ? "fake-model" : s.pi.model,
|
||||
model: upstream.row.default_model ?? (fake ? "fake-model" : null),
|
||||
thinking_level: settings.thinkingLevel ?? null,
|
||||
...(upstream ? { upstream_id: upstream.row.id } : {}),
|
||||
upstream_id: upstream.row.id,
|
||||
})
|
||||
.where("id", "=", runId)
|
||||
.execute();
|
||||
|
||||
@@ -5,7 +5,8 @@ import { audit, endpoint } from "./endpoint.ts";
|
||||
import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "./provided.ts";
|
||||
|
||||
// GET/PATCH /settings:按分组部分更新 → mergeSettings → GlobalSettings 整体校验 → 逐组 upsert;
|
||||
// Pi 上游密钥(pi.apiKey 只写)存为 secret,设置里只留 apiKeySecretRef;变更经 SSE 总线广播 settings.changed,
|
||||
// TMDB 密钥(metadata.tmdbApiKey 只写)存为 secret,设置里只留 tmdbApiKeySecretRef(Agent 上游密钥在 pi_upstream,
|
||||
// 旧 pi.apiKey 已弃用,PATCH 里会被 schema 丢弃);变更经 SSE 总线广播 settings.changed,
|
||||
// 各进程的 ctx.settings 缓存随之失效。自动化总开关 automation.enabled / 只读对账 automation.readOnlyReconcile 也走这里。
|
||||
|
||||
const isCached = (s: unknown): s is CachedSettingsReader =>
|
||||
@@ -21,15 +22,7 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
const patch = body as GlobalSettingsUpdate;
|
||||
const current = isCached(ctx.settings) ? await ctx.settings.fresh() : await ctx.settings.all();
|
||||
const merged = mergeSettings(current, patch);
|
||||
const oldRef = current.pi.apiKeySecretRef;
|
||||
let dropRef: string | null = null;
|
||||
let newRef: string | null = null;
|
||||
const apiKey = patch.pi?.apiKey;
|
||||
if (apiKey === null) {
|
||||
merged.pi.apiKeySecretRef = null;
|
||||
dropRef = oldRef;
|
||||
}
|
||||
// TMDB 密钥同样只写:存 secret(metadata_api_key),设置里只留 tmdbApiKeySecretRef
|
||||
// TMDB 密钥只写:存 secret(metadata_api_key),设置里只留 tmdbApiKeySecretRef
|
||||
const oldTmdbRef = current.metadata.tmdbApiKeySecretRef;
|
||||
const tmdbKey = patch.metadata?.tmdbApiKey;
|
||||
let dropTmdbRef: string | null = null;
|
||||
@@ -44,15 +37,6 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
throw new ApiException("validation_failed", msg);
|
||||
}
|
||||
const next = parsed.data;
|
||||
if (typeof apiKey === "string") {
|
||||
if (oldRef) {
|
||||
await ctx.secrets.replace(oldRef, apiKey);
|
||||
next.pi.apiKeySecretRef = oldRef;
|
||||
} else {
|
||||
newRef = await ctx.secrets.create({ purpose: "llm_api_key", plaintext: apiKey });
|
||||
next.pi.apiKeySecretRef = newRef;
|
||||
}
|
||||
}
|
||||
if (typeof tmdbKey === "string") {
|
||||
if (oldTmdbRef) {
|
||||
await ctx.secrets.replace(oldTmdbRef, tmdbKey);
|
||||
@@ -76,17 +60,14 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (newRef) await ctx.secrets.remove(newRef).catch(() => {});
|
||||
if (newTmdbRef) await ctx.secrets.remove(newTmdbRef).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
|
||||
if (dropTmdbRef) await ctx.secrets.remove(dropTmdbRef).catch(() => {});
|
||||
if (isCached(ctx.settings)) ctx.settings.invalidate();
|
||||
void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys });
|
||||
// 审计里不留明文密钥
|
||||
const logged: Record<string, unknown> = { ...patch };
|
||||
if (patch.pi && "apiKey" in patch.pi) logged.pi = { ...patch.pi, apiKey: apiKey === null ? "(cleared)" : "(set)" };
|
||||
if (patch.metadata && "tmdbApiKey" in patch.metadata)
|
||||
logged.metadata = { ...patch.metadata, tmdbApiKey: tmdbKey === null ? "(cleared)" : "(set)" };
|
||||
await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged });
|
||||
|
||||
@@ -205,7 +205,7 @@ export interface RunUpstream {
|
||||
}
|
||||
|
||||
/**
|
||||
* 取运行用的上游:显式 upstreamId 优先,否则 settings.pi.defaultUpstreamId;都没有返回 null(调用方走旧字段兼容)。
|
||||
* 取运行用的上游:显式 upstreamId 优先,否则 settings.pi.defaultUpstreamId;都没有返回 null(runAgent 直接失败,旧 settings.pi 字段已弃用)。
|
||||
* 上游停用 / 未登录 / 登录失效时抛出可读错误。
|
||||
*/
|
||||
export async function resolveRunUpstream(ctx: ServerContext, upstreamId?: string | null): Promise<RunUpstream | null> {
|
||||
|
||||
@@ -94,24 +94,22 @@ describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => {
|
||||
expect((await json(r)).error.message).toMatch(/恢复水位/);
|
||||
});
|
||||
|
||||
it("Pi apiKey 只写:存为 secret,响应/审计/日志不含明文;null 清除并删除 secret", async () => {
|
||||
it("旧 pi.apiKey / provider / model 不再接受:被丢弃,不建 secret,不改设置", async () => {
|
||||
const plain = "sk-live-super-secret-0001";
|
||||
const r = await json(await admin("PATCH", "/settings", { pi: { apiKey: plain, model: "claude-x" } }));
|
||||
const ref = r.data.pi.apiKeySecretRef;
|
||||
expect(ref).toMatch(/^[0-9a-f-]{36}$/);
|
||||
const before = (await json(await admin("GET", "/settings"))).data.pi;
|
||||
const r = await json(
|
||||
await admin("PATCH", "/settings", { pi: { apiKey: plain, provider: "openai", model: "claude-x" } }),
|
||||
);
|
||||
expect(r.data.pi).toMatchObject({
|
||||
provider: before.provider,
|
||||
model: before.model,
|
||||
apiKeySecretRef: before.apiKeySecretRef,
|
||||
});
|
||||
expect(JSON.stringify(r)).not.toContain(plain);
|
||||
expect(await h.ctx.secrets.resolve(ref)).toBe(plain);
|
||||
// 再改一次:原地轮换,ref 不变
|
||||
const r2 = await json(await admin("PATCH", "/settings", { pi: { apiKey: `${plain}-2` } }));
|
||||
expect(r2.data.pi.apiKeySecretRef).toBe(ref);
|
||||
expect(await h.ctx.secrets.resolve(ref)).toBe(`${plain}-2`);
|
||||
const audits = await sql<{
|
||||
payload: unknown;
|
||||
}>`select payload from audit_event where action = 'settings.update'`.execute(h.db);
|
||||
expect(JSON.stringify(audits.rows)).not.toContain(plain);
|
||||
const r3 = await json(await admin("PATCH", "/settings", { pi: { apiKey: null } }));
|
||||
expect(r3.data.pi.apiKeySecretRef).toBeNull();
|
||||
await expect(h.ctx.secrets.resolve(ref)).rejects.toThrow(/不存在/);
|
||||
const n = await sql<{ n: number }>`select count(*)::int as n from secret where purpose = 'llm_api_key'`.execute(
|
||||
h.db,
|
||||
);
|
||||
expect(n.rows[0]!.n).toBe(0);
|
||||
noLeak(plain);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { type Bootstrapped, bootstrap } from "../../src/context.ts";
|
||||
import { createApp } from "../../src/http/app.ts";
|
||||
import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createLogger } from "../../src/logger.ts";
|
||||
import { setDefaultUpstream } from "../../src/modules/pi/store.ts";
|
||||
import type { ServerModule } from "../../src/modules/types.ts";
|
||||
import { type RunningWorker, startWorker } from "../../src/worker/index.ts";
|
||||
import { withSchemaLock } from "../auth-settings/harness.ts";
|
||||
@@ -215,9 +216,23 @@ export async function createE2E(name: string, opts: E2EOptions = {}): Promise<E2
|
||||
});
|
||||
},
|
||||
};
|
||||
// 通用设置:fake 模型、TMDB 走设置里的 secret、结算快一点
|
||||
// 通用设置:fake 上游(脚本化假模型,设为默认)、TMDB 走设置里的 secret、结算快一点
|
||||
const fakeUpstream = await db
|
||||
.insertInto("pi_upstream")
|
||||
.values({
|
||||
name: "fake",
|
||||
provider: "fake",
|
||||
auth_kind: "none",
|
||||
default_model: "fake-model",
|
||||
thinking_level: "medium",
|
||||
extra: JSON.stringify({}),
|
||||
enabled: true,
|
||||
status: "ok",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await setDefaultUpstream(booted.ctx, fakeUpstream.id);
|
||||
await e.settings({
|
||||
pi: { provider: "fake", model: "fake-model" },
|
||||
metadata: { tmdbApiKey: TMDB_KEY, tmdbBaseUrl: `${tmdb.url}/3` },
|
||||
retention: { seedRetentionDays: 0 },
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ensureSetupToken } from "../../src/modules/auth-settings/auth-service.t
|
||||
import { createSecretStore, createSettingsReader } from "../../src/modules/auth-settings/provided.ts";
|
||||
import { createPiModule } from "../../src/modules/pi/index.ts";
|
||||
import { refreshPiTokens } from "../../src/modules/pi/refresh.ts";
|
||||
import { dbCredentialStore } from "../../src/modules/pi/store.ts";
|
||||
import { dbCredentialStore, setDefaultUpstream } from "../../src/modules/pi/store.ts";
|
||||
import type { ServerContext } from "../../src/modules/types.ts";
|
||||
import {
|
||||
Browser,
|
||||
@@ -383,5 +383,13 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
|
||||
const d3 = await drive(env, h.ctx.capabilities, r3, new AbortController().signal);
|
||||
expect(d3).toMatchObject({ status: "failed" });
|
||||
expect(d3.message).toMatch(/已停用/);
|
||||
|
||||
// 没有默认上游:旧 settings.pi 字段不再兜底,直接失败
|
||||
await setDefaultUpstream(h.ctx, null);
|
||||
const r4 = await newRun(null);
|
||||
const d4 = await drive(env, h.ctx.capabilities, r4, new AbortController().signal);
|
||||
expect(d4).toMatchObject({ status: "failed" });
|
||||
expect(d4.message).toMatch(/未配置 Agent 上游/);
|
||||
await setDefaultUpstream(h.ctx, fake.id);
|
||||
});
|
||||
});
|
||||
@@ -24,12 +24,12 @@ import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/compone
|
||||
import { TTL_SECOND } from "~/lib/api.ts";
|
||||
import { useSession } from "./auth.ts";
|
||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||
import { useSettings } from "./settings.ts";
|
||||
import { usePiUpstreams } from "./pi.ts";
|
||||
|
||||
// Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort),
|
||||
// 形状一律取 contracts(AgentRun / AgentRunDetail / AgentGoalReport)。
|
||||
// ?mock=1(或 VITE_AGENT_MOCK=1)只作演示开关:不连后端,用本地假数据预览交互;
|
||||
// 仅开发构建或后端 pi.provider = "fake" 时生效,生产构建连真实模型时忽略(见 agentMockAllowed)。
|
||||
// 仅开发构建或后端默认上游是 fake 时生效,生产构建连真实模型时忽略(见 agentMockAllowed)。
|
||||
|
||||
export const AGENT_BASE = "/api/v1/agent/runs";
|
||||
|
||||
@@ -48,7 +48,7 @@ export type RunSnapshot = Omit<RunDetail, "events" | "nextAfterSeq">;
|
||||
const RunPage = cursorPage(AgentRun);
|
||||
|
||||
/**
|
||||
* 演示开关是否可用:只在开发构建(import.meta.env.DEV)或后端 pi.provider = "fake"(脚本化假模型)时生效。
|
||||
* 演示开关是否可用:只在开发构建(import.meta.env.DEV)或后端默认上游 provider = "fake"(脚本化假模型)时生效。
|
||||
* 生产构建连着真实模型时一律忽略 ?mock=1 / VITE_AGENT_MOCK,并清掉 sessionStorage 里记住的开关,
|
||||
* 避免把演示数据当成真实运行展示(验收 S7 / AT78)。
|
||||
*/
|
||||
@@ -91,7 +91,7 @@ export function resolveAgentMock(o: {
|
||||
}
|
||||
}
|
||||
|
||||
/** 最近一次读到的后端 pi.provider 是否为 fake(由 useAgentMockGate 从 /settings 缓存同步) */
|
||||
/** 最近一次读到的后端默认上游是否为 fake(由 useAgentMockGate 从 /pi/upstreams 缓存同步) */
|
||||
let fakeProvider = false;
|
||||
|
||||
export function agentMockEnabled(): boolean {
|
||||
@@ -112,8 +112,8 @@ export function agentMockEnabled(): boolean {
|
||||
|
||||
/** Agent 页的列表/详情 hook 里调用:同步后端 provider,再判断演示开关 */
|
||||
function useAgentMockGate(): boolean {
|
||||
const settings = useSettings();
|
||||
fakeProvider = settings.data?.pi?.provider === "fake";
|
||||
const upstreams = usePiUpstreams();
|
||||
fakeProvider = upstreams.data?.items.find((u) => u.isDefault)?.provider === "fake";
|
||||
return agentMockEnabled();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
useRemovePiUpstream,
|
||||
useUpdatePiUpstream,
|
||||
} from "~/api/pi.ts";
|
||||
import { useSettings } from "~/api/settings.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
@@ -39,12 +38,10 @@ import { SettingsBlock } from "./settings-block.tsx";
|
||||
export function PiUpstreamsBlock() {
|
||||
const list = usePiUpstreams();
|
||||
const providers = usePiProviders(false);
|
||||
const settings = useSettings();
|
||||
const [wizard, setWizard] = useState(false);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const open = list.data?.items.find((u) => u.id === openId) ?? null;
|
||||
const pv = providers.data?.providers;
|
||||
const legacy = settings.data?.pi.provider;
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
@@ -67,10 +64,7 @@ export function PiUpstreamsBlock() {
|
||||
error={list.error}
|
||||
onRetry={() => list.refetch()}
|
||||
onRowClick={(r) => setOpenId(r.id)}
|
||||
empty={{
|
||||
title: "还没有上游",
|
||||
description: legacy ? `没有上游时按旧设置运行(provider:${legacy})。` : undefined,
|
||||
}}
|
||||
empty={{ title: "还没有上游" }}
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
|
||||
Reference in new issue
Block a user