feat: Implement compatibility strategies for responses and chat endpoints

- Added `routeCompatibility.ts` to handle response compatibility for different endpoints.
- Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic.
- Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`.
- Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints.
- Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`.
- Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests.
- Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
This commit is contained in:
cita
2026-03-09 22:48:10 +08:00
parent 15d4b207cc
commit 0f8176cd04
74 changed files with 4645 additions and 1565 deletions
@@ -119,6 +119,44 @@ describe('/api/models/token-candidates', () => {
expect(body.modelsWithoutToken['claude-haiku-4-5-20251001']).toBeUndefined();
});
it('does not report apikey connections as missing account tokens', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-apikey',
url: 'https://site-apikey.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'shenmo-direct',
accessToken: '',
apiToken: 'sk-shenmo-direct',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
modelsWithoutToken: Record<string, Array<{ accountId: number }>>;
modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
};
expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined();
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
});
it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-b',
@@ -364,7 +402,7 @@ describe('/api/models/token-candidates', () => {
]);
});
it('does not pass deprecated site apiKey into pricing catalog lookup', async () => {
it('does not request token-group hints for apikey connections', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-e',
url: 'https://site-e.example.com',
@@ -425,19 +463,11 @@ describe('/api/models/token-candidates', () => {
});
expect(response.statusCode).toBe(200);
expect(fetchModelPricingCatalogMock).toHaveBeenCalled();
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]).toMatchObject({
site: {
id: site.id,
url: site.url,
platform: site.platform,
},
account: {
id: account.id,
accessToken: '',
apiToken: null,
},
});
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]?.site?.apiKey).toBeUndefined();
const body = response.json() as {
modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
};
expect(fetchModelPricingCatalogMock).not.toHaveBeenCalled();
expect(body.modelsMissingTokenGroups['claude-opus-4-6']).toBeUndefined();
});
});
+16 -1
View File
@@ -17,6 +17,7 @@ import {
parseProxyLogBillingDetails,
withProxyLogSelectFields,
} from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import {
formatUtcSqlDateTime,
getLocalDayRangeUtc,
@@ -30,6 +31,12 @@ function parseBooleanFlag(raw?: string): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function isApiKeyConnection(account: { accessToken?: string | null; extraConfig?: string | null }): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
@@ -711,6 +718,8 @@ export async function statsRoutes(app: FastifyInstance) {
username: schema.accounts.username,
siteId: schema.sites.id,
siteName: schema.sites.name,
accessToken: schema.accounts.accessToken,
extraConfig: schema.accounts.extraConfig,
})
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -788,6 +797,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`;
@@ -802,7 +812,11 @@ export async function statsRoutes(app: FastifyInstance) {
});
}
const accountIdsForGroupHints = new Set(availableModelRows.map((row) => row.accountId));
const accountIdsForGroupHints = new Set(
availableModelRows
.filter((row) => !isApiKeyConnection(row))
.map((row) => row.accountId),
);
const requiredGroupsByAccountModel = new Map<string, Map<string, string>>();
const hasPotentialGroupHints = hasAnyTokenGroupSignals || unknownGroupCoverageByAccountModel.size > 0;
@@ -862,6 +876,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`;