[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:
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user