[codex] support downstream key site and credential exclusions (#431)

* support downstream key site and credential exclusions

* skip electron binary download in non-desktop ci

* fix migrate partial-journal test for 0020
This commit is contained in:
Cita
2026-04-05 23:43:07 +08:00
committed by GitHub
parent 492f43c5dc
commit 38721cb890
30 changed files with 1718 additions and 793 deletions
+193 -1
View File
@@ -224,6 +224,198 @@ describe('downstream api keys routes', () => {
});
});
it('roundtrips excluded sites and excluded credentials through create, update, and list', async () => {
const siteA = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
status: 'active',
platform: 'new-api',
}).returning().get();
const siteB = await db.insert(schema.sites).values({
name: 'site-b',
url: 'https://site-b.example.com',
status: 'active',
platform: 'new-api',
}).returning().get();
const accountA = await db.insert(schema.accounts).values({
siteId: siteA.id,
username: 'account-a',
accessToken: 'access-a',
apiToken: 'sk-default-a',
status: 'active',
}).returning().get();
const accountB = await db.insert(schema.accounts).values({
siteId: siteB.id,
username: 'account-b',
accessToken: 'access-b',
apiToken: 'sk-default-b',
status: 'active',
}).returning().get();
const tokenA = await db.insert(schema.accountTokens).values({
accountId: accountA.id,
name: 'token-a',
token: 'sk-token-a',
enabled: true,
isDefault: true,
valueStatus: 'ready',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
displayName: 'portal-route',
enabled: true,
}).returning().get();
const createRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'exclude-key',
key: 'sk-exclude-key-001',
allowedRouteIds: [route.id],
excludedSiteIds: [siteB.id],
excludedCredentialRefs: [
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
],
},
});
expect(createRes.statusCode).toBe(200);
expect(createRes.json()).toMatchObject({
success: true,
item: {
excludedSiteIds: [siteB.id],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
],
},
});
const keyId = createRes.json().item.id as number;
const updateRes = await app.inject({
method: 'PUT',
url: `/api/downstream-keys/${keyId}`,
payload: {
excludedSiteIds: [siteB.id, siteA.id, siteB.id],
excludedCredentialRefs: [
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
],
},
});
expect(updateRes.statusCode).toBe(200);
expect(updateRes.json()).toMatchObject({
success: true,
item: {
excludedSiteIds: [siteA.id, siteB.id],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
],
},
});
const listRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys',
});
expect(listRes.statusCode).toBe(200);
expect(listRes.json()).toMatchObject({
success: true,
items: [
expect.objectContaining({
id: keyId,
excludedSiteIds: [siteA.id, siteB.id],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
],
}),
],
});
});
it('rejects unknown or mismatched exclusion references', async () => {
const siteA = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
status: 'active',
platform: 'new-api',
}).returning().get();
const siteB = await db.insert(schema.sites).values({
name: 'site-b',
url: 'https://site-b.example.com',
status: 'active',
platform: 'new-api',
}).returning().get();
const accountA = await db.insert(schema.accounts).values({
siteId: siteA.id,
username: 'account-a',
accessToken: 'access-a',
apiToken: 'sk-default-a',
status: 'active',
}).returning().get();
const accountB = await db.insert(schema.accounts).values({
siteId: siteB.id,
username: 'account-b',
accessToken: 'access-b',
apiToken: null,
status: 'active',
}).returning().get();
const tokenA = await db.insert(schema.accountTokens).values({
accountId: accountA.id,
name: 'token-a',
token: 'sk-token-a',
enabled: true,
isDefault: true,
valueStatus: 'ready',
}).returning().get();
const unknownSiteRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'bad-site',
key: 'sk-bad-site-001',
excludedSiteIds: [999999],
},
});
expect(unknownSiteRes.statusCode).toBe(400);
const mismatchedTokenRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'bad-token',
key: 'sk-bad-token-001',
excludedCredentialRefs: [
{ kind: 'account_token', siteId: siteB.id, accountId: accountA.id, tokenId: tokenA.id },
],
},
});
expect(mismatchedTokenRes.statusCode).toBe(400);
const missingDefaultApiKeyRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'bad-default',
key: 'sk-bad-default-001',
excludedCredentialRefs: [
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
],
},
});
expect(missingDefaultApiKeyRes.statusCode).toBe(400);
});
it('supports batch enable/disable/reset/delete operations', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values([
{
@@ -644,7 +836,7 @@ describe('downstream api keys routes', () => {
expect(updateRes.statusCode).toBe(400);
expect(updateRes.json()).toMatchObject({
success: false,
message: 'siteWeightMultipliers 包含不存在的站点: 999',
message: '策略中包含不存在的站点: 999',
});
const filteredSummaryRes = await app.inject({
+82 -2
View File
@@ -9,6 +9,7 @@ import {
toDownstreamApiKeyPolicyView,
toPersistenceJson,
} from '../../services/downstreamApiKeyService.js';
import type { DownstreamExcludedCredentialRef } from '../../services/downstreamPolicyTypes.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import {
parseDownstreamApiKeyBatchPayload,
@@ -159,6 +160,8 @@ function resolveBucketTsExpression(bucketSeconds: number) {
async function validatePolicyReferences(input: {
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
}): Promise<string | null> {
const routeIds = input.allowedRouteIds || [];
if (routeIds.length > 0) {
@@ -173,10 +176,14 @@ async function validatePolicyReferences(input: {
}
}
const siteIds = Object.keys(input.siteWeightMultipliers || {})
const weightedSiteIds = Object.keys(input.siteWeightMultipliers || {})
.map((key) => Number(key))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
const excludedSiteIds = (input.excludedSiteIds || [])
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
const siteIds = Array.from(new Set([...weightedSiteIds, ...excludedSiteIds]));
if (siteIds.length > 0) {
const rows = await db.select({ id: schema.sites.id })
.from(schema.sites)
@@ -185,7 +192,70 @@ async function validatePolicyReferences(input: {
const existingIds = new Set(rows.map((row) => Number(row.id)));
const missingIds = siteIds.filter((id) => !existingIds.has(id));
if (missingIds.length > 0) {
return `siteWeightMultipliers 包含不存在的站点: ${missingIds.join(', ')}`;
return `策略中包含不存在的站点: ${missingIds.join(', ')}`;
}
}
const credentialRefs = input.excludedCredentialRefs || [];
const accountTokenRefs = credentialRefs.filter((ref): ref is Extract<DownstreamExcludedCredentialRef, { kind: 'account_token' }> => ref.kind === 'account_token');
if (accountTokenRefs.length > 0) {
const tokenIds = Array.from(new Set(accountTokenRefs.map((ref) => ref.tokenId)));
const rows = await db.select({
tokenId: schema.accountTokens.id,
accountId: schema.accounts.id,
siteId: schema.accounts.siteId,
})
.from(schema.accountTokens)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.where(inArray(schema.accountTokens.id, tokenIds))
.all();
const tokenById = new Map<number, { tokenId: number; accountId: number; siteId: number }>(
rows.map((row) => [Number(row.tokenId), {
tokenId: Number(row.tokenId),
accountId: Number(row.accountId),
siteId: Number(row.siteId),
}]),
);
for (const ref of accountTokenRefs) {
const matched = tokenById.get(ref.tokenId);
if (!matched) {
return `excludedCredentialRefs 包含不存在的令牌: ${ref.tokenId}`;
}
if (Number(matched.accountId) !== ref.accountId || Number(matched.siteId) !== ref.siteId) {
return `excludedCredentialRefs 中的 account_token 引用与账号/站点不匹配: ${ref.tokenId}`;
}
}
}
const defaultApiKeyRefs = credentialRefs.filter((ref): ref is Extract<DownstreamExcludedCredentialRef, { kind: 'default_api_key' }> => ref.kind === 'default_api_key');
if (defaultApiKeyRefs.length > 0) {
const accountIds = Array.from(new Set(defaultApiKeyRefs.map((ref) => ref.accountId)));
const rows = await db.select({
accountId: schema.accounts.id,
siteId: schema.accounts.siteId,
apiToken: schema.accounts.apiToken,
})
.from(schema.accounts)
.where(inArray(schema.accounts.id, accountIds))
.all();
const accountById = new Map<number, { accountId: number; siteId: number; apiToken: string | null }>(
rows.map((row) => [Number(row.accountId), {
accountId: Number(row.accountId),
siteId: Number(row.siteId),
apiToken: row.apiToken,
}]),
);
for (const ref of defaultApiKeyRefs) {
const matched = accountById.get(ref.accountId);
if (!matched) {
return `excludedCredentialRefs 包含不存在的账号: ${ref.accountId}`;
}
if (Number(matched.siteId) !== ref.siteId) {
return `excludedCredentialRefs 中的 default_api_key 引用与站点不匹配: ${ref.accountId}`;
}
if (!(matched.apiToken || '').trim()) {
return `excludedCredentialRefs 中的 default_api_key 账号缺少默认 API Key: ${ref.accountId}`;
}
}
}
@@ -470,6 +540,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers,
excludedSiteIds: normalized.excludedSiteIds,
excludedCredentialRefs: normalized.excludedCredentialRefs,
});
if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError });
@@ -496,6 +568,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
supportedModels: toPersistenceJson(normalized.supportedModels),
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
excludedSiteIds: toPersistenceJson(normalized.excludedSiteIds),
excludedCredentialRefs: toPersistenceJson(normalized.excludedCredentialRefs),
createdAt: nowIso,
updatedAt: nowIso,
},
@@ -552,6 +626,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
supportedModels: hasOwn('supportedModels') ? body.supportedModels : existingView.supportedModels,
allowedRouteIds: hasOwn('allowedRouteIds') ? body.allowedRouteIds : existingView.allowedRouteIds,
siteWeightMultipliers: hasOwn('siteWeightMultipliers') ? body.siteWeightMultipliers : existingView.siteWeightMultipliers,
excludedSiteIds: hasOwn('excludedSiteIds') ? body.excludedSiteIds : existingView.excludedSiteIds,
excludedCredentialRefs: hasOwn('excludedCredentialRefs') ? body.excludedCredentialRefs : existingView.excludedCredentialRefs,
});
} catch (error: unknown) {
return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' });
@@ -569,6 +645,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers,
excludedSiteIds: normalized.excludedSiteIds,
excludedCredentialRefs: normalized.excludedCredentialRefs,
});
if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError });
@@ -589,6 +667,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
supportedModels: toPersistenceJson(normalized.supportedModels),
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
excludedSiteIds: toPersistenceJson(normalized.excludedSiteIds),
excludedCredentialRefs: toPersistenceJson(normalized.excludedCredentialRefs),
updatedAt: nowIso,
}).where(eq(schema.downstreamApiKeys.id, id)).run();