fix(routes): dedupe direct credentials and clarify binding labels
This commit is contained in:
@@ -248,6 +248,55 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('does not scan hidden managed tokens for direct apikey connections', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
|
||||
token === 'sk-direct-credential' ? ['gpt-4.1'] : ['legacy-should-not-be-used']
|
||||
));
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'apikey-direct-site',
|
||||
url: 'https://apikey-direct.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'apikey-direct-user',
|
||||
accessToken: '',
|
||||
apiToken: 'sk-direct-credential',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
const hiddenToken = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'legacy-hidden',
|
||||
token: 'sk-legacy-hidden',
|
||||
source: 'legacy',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
modelCount: 1,
|
||||
modelsPreview: ['gpt-4.1'],
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: true,
|
||||
});
|
||||
|
||||
const tokenRows = await db.select().from(schema.tokenModelAvailability)
|
||||
.where(eq(schema.tokenModelAvailability.tokenId, hiddenToken.id))
|
||||
.all();
|
||||
expect(tokenRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns structured result when account missing', async () => {
|
||||
const result = await refreshModelsForAccount(9999);
|
||||
|
||||
|
||||
@@ -85,6 +85,67 @@ describe('rebuildTokenRoutesFromAvailability', () => {
|
||||
expect(channels[0]?.manualOverride).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores hidden account_tokens for direct apikey connections when rebuilding routes', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'apikey-legacy-site',
|
||||
url: 'https://apikey-legacy.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'apikey-legacy-user',
|
||||
accessToken: '',
|
||||
apiToken: 'sk-direct-credential',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
const hiddenToken = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'legacy-hidden',
|
||||
token: 'sk-hidden-legacy-token',
|
||||
source: 'legacy',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'gpt-4.1',
|
||||
available: true,
|
||||
latencyMs: 200,
|
||||
checkedAt: '2026-03-20T08:00:00.000Z',
|
||||
}).run();
|
||||
|
||||
await db.insert(schema.tokenModelAvailability).values({
|
||||
tokenId: hiddenToken.id,
|
||||
modelName: 'gpt-4.1',
|
||||
available: true,
|
||||
latencyMs: 180,
|
||||
checkedAt: '2026-03-20T08:00:00.000Z',
|
||||
}).run();
|
||||
|
||||
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||||
|
||||
expect(rebuild.models).toBe(1);
|
||||
|
||||
const route = await db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-4.1'))
|
||||
.get();
|
||||
expect(route).toBeDefined();
|
||||
|
||||
const channels = await db.select().from(schema.routeChannels)
|
||||
.where(and(
|
||||
eq(schema.routeChannels.routeId, route!.id),
|
||||
eq(schema.routeChannels.accountId, account.id),
|
||||
))
|
||||
.all();
|
||||
|
||||
expect(channels).toHaveLength(1);
|
||||
expect(channels[0]?.tokenId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('creates an exact route with an account-direct channel for oauth model availability', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'codex-site',
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
isUsableAccountToken,
|
||||
} from './accountTokenService.js';
|
||||
import {
|
||||
getCredentialModeFromExtraConfig,
|
||||
mergeAccountExtraConfig,
|
||||
requiresManagedAccountTokens,
|
||||
resolvePlatformUserId,
|
||||
supportsDirectAccountRoutingConnection,
|
||||
} from './accountExtraConfig.js';
|
||||
@@ -141,12 +141,6 @@ function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return !(account.accessToken || '').trim();
|
||||
}
|
||||
|
||||
function normalizeModels(models: string[]): string[] {
|
||||
const normalizedModels: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -821,18 +815,21 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
||||
} catch { }
|
||||
}
|
||||
|
||||
let enabledTokens = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(and(
|
||||
eq(schema.accountTokens.accountId, account.id),
|
||||
eq(schema.accountTokens.enabled, true),
|
||||
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
|
||||
))
|
||||
.all();
|
||||
const usesManagedTokens = requiresManagedAccountTokens(account);
|
||||
let enabledTokens = usesManagedTokens
|
||||
? await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(and(
|
||||
eq(schema.accountTokens.accountId, account.id),
|
||||
eq(schema.accountTokens.enabled, true),
|
||||
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
|
||||
))
|
||||
.all()
|
||||
: [];
|
||||
enabledTokens = enabledTokens.filter(isUsableAccountToken);
|
||||
|
||||
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
|
||||
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
|
||||
if (usesManagedTokens && enabledTokens.length === 0) {
|
||||
const fallback = discoveredApiToken || account.apiToken || null;
|
||||
if (fallback) {
|
||||
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
||||
@@ -1018,7 +1015,10 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
),
|
||||
)
|
||||
.all();
|
||||
const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens));
|
||||
const usableTokenRows = tokenRows.filter((row) => (
|
||||
isUsableAccountToken(row.account_tokens)
|
||||
&& requiresManagedAccountTokens(row.accounts)
|
||||
));
|
||||
|
||||
const accountRows = await db.select().from(schema.modelAvailability)
|
||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||
|
||||
Reference in New Issue
Block a user