feat(server): pi module with upstream CRUD, server-side OAuth and token refresh
- /pi/providers, /pi/upstreams CRUD, test (auth-only by default, no cost), models - subscription login via pi models.login with a suspended interaction: browser mode waits for the pasted redirect URL, device mode polls server-side; per-provider login lock, readable callback-port-in-use errors - credential store locks the pi_upstream row (select for update) so api and worker refreshes never double-write a rotated refresh token - refreshPiTokens task: refresh before expiry; failure marks expired and opens a relogin decision, closed again after a successful login - runAgent picks upstreamId > agent_run.upstream_id > default upstream > legacy settings; credentials injected by the server, never logged Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
743fe2486d
commit
fd3ffe5a8d
13 files changed
+1617
-26
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import { createMigrator, type DB, migrateToLatest, sql } from "@umt/db";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { createSecretStore } from "../../src/modules/auth-settings/provided.ts";
|
||||
import { MASTER_KEY, PG_URL, pgAvailable, withSchemaLock } from "../auth-settings/harness.ts";
|
||||
|
||||
// 0027:已保存的旧 settings.pi(v0.1 单上游)一次性转成一条上游并设为默认;密钥所有权移交给上游。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("0027 迁移旧 settings.pi → 上游", () => {
|
||||
const schema = `w7a_mig_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const admin = async (q: string) => {
|
||||
const c = new pg.Client({ connectionString: PG_URL });
|
||||
await c.connect();
|
||||
await c.query(q).finally(() => c.end());
|
||||
};
|
||||
let db: Kysely<DB>;
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
await withSchemaLock(() => admin(`drop schema if exists ${schema} cascade`));
|
||||
});
|
||||
|
||||
it("anthropic + baseUrl + apiKeySecretRef → 默认上游(api_key, ok);重复迁移不再生成", async () => {
|
||||
await admin(`create schema ${schema}`);
|
||||
db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 2, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
});
|
||||
await withSchemaLock(async () => {
|
||||
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0026_organization_file_actions");
|
||||
if (r.error) throw r.error;
|
||||
});
|
||||
const secrets = createSecretStore({ db, masterKey: MASTER_KEY });
|
||||
const ref = await secrets.create({ purpose: "llm_api_key", plaintext: "sk-ant-legacy-key" });
|
||||
const legacy = {
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://proxy.local/v1",
|
||||
model: "claude-sonnet-4-5",
|
||||
thinkingLevel: "high",
|
||||
contextBudget: 100_000,
|
||||
compaction: { enabled: true, thresholdRatio: 0.8 },
|
||||
subagentsEnabled: false,
|
||||
apiKeySecretRef: ref,
|
||||
};
|
||||
await sql`insert into settings (key, value) values ('pi', ${JSON.stringify(legacy)}::jsonb)`.execute(db);
|
||||
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
|
||||
|
||||
const rows = await db.selectFrom("pi_upstream").selectAll().execute();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
provider: "anthropic",
|
||||
auth_kind: "api_key",
|
||||
secret_ref: ref,
|
||||
base_url: "https://proxy.local/v1",
|
||||
default_model: "claude-sonnet-4-5",
|
||||
thinking_level: "high",
|
||||
status: "ok",
|
||||
enabled: true,
|
||||
});
|
||||
const s = await sql<{ value: Record<string, unknown> }>`select value from settings where key = 'pi'`.execute(db);
|
||||
expect(s.rows[0]!.value).toMatchObject({
|
||||
defaultUpstreamId: rows[0]!.id,
|
||||
apiKeySecretRef: null,
|
||||
contextBudget: 100_000,
|
||||
});
|
||||
expect(await secrets.resolve(ref)).toBe("sk-ant-legacy-key");
|
||||
|
||||
// 回滚再迁移:密钥交还旧字段,再次迁移得到一条新上游(不会重复)
|
||||
await withSchemaLock(async () => {
|
||||
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0026_organization_file_actions");
|
||||
if (r.error) throw r.error;
|
||||
});
|
||||
const back = await sql<{ value: Record<string, unknown> }>`select value from settings where key = 'pi'`.execute(db);
|
||||
expect(back.rows[0]!.value.apiKeySecretRef).toBe(ref);
|
||||
expect(back.rows[0]!.value.defaultUpstreamId).toBeUndefined();
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
|
||||
expect(await db.selectFrom("pi_upstream").select("id").execute()).toHaveLength(1);
|
||||
}, 120_000);
|
||||
});
|
||||
Reference in new issue
Block a user