feat: Implement provider groupings with account calculator and compre… (#113)

* feat: Implement provider groupings with account calculator and comprehensive fixes

- Add provider detection utilities (detectProvider, PROVIDER_REGISTRY) for Claude/Gemini/Others categorization
- Implement grouping logic (groupModelsByProvider, calculateProviderStats, calculateAccountStats) with health status calculation
- Create useProviderGrouping hook for collapsible state management and persistence
- Add ProviderGroup component with health indicators, progress bars, and model visibility integration
- Enhance CloudAccountList with account-level calculator showing overall percentage and earliest reset time
- Implement grid layout selector (auto/2-col/3-col/list) with config persistence and tooltips
- Add global quota aggregate bar across all accounts in top statistics
- Fix model visibility settings regression by accessing account.quota.models instead of account.quota
- Show 'Rate Limited' instead of '0%' for exhausted quotas with consistent getQuotaLabel function
- Add comprehensive i18n translations for layout selector and rate limited labels (en, zh-CN, ru)
- Disable batch operations when provider groupings are enabled to prevent conflicts
- Include unit tests for provider detection logic and grouping functionality

Addresses critical issues: model list regression, rate limited display, layout control, global quota visibility, and consistent quota labeling across all components.

* fix: resolve ESLint no-ex-assign violations in proxy service

- Fix exception parameter reassignment in catch blocks
- Rename catch parameters to avoid no-ex-assign rule violations
- Maintain same error handling logic while complying with linting rules

* fix: add missing Models tab to settings page

- Add Models tab between General and Proxy tabs
- Import and integrate ModelVisibilitySettings component
- Fix critical regression where model visibility settings were inaccessible

* fix: add missing React hook imports in settings route

* test: remove obsolete tests for removed controller APIs

---------

Co-authored-by: Draculabo <[email protected]>
This commit is contained in:
ArtemisAIandDraculabo authored and GitHub committed 2026-02-18 16:38:15 +08:00
1 parent 118fad567e
commit 1e59a1c901
12 files changed
+1237 -97

No files matched your search

+34 -31
View File
@@ -119,8 +119,8 @@ export class ProxyService {
);
return this.toAnthropicChatResponse(transformResponse(response));
}
} catch (fallbackError) {
error = fallbackError;
} catch (fallbackErr) {
lastError = fallbackErr;
}
}
@@ -158,8 +158,8 @@ export class ProxyService {
model: request.model,
};
}
} catch (downgradeError) {
error = downgradeError;
} catch (downgradeErr) {
lastError = downgradeErr;
}
}
@@ -313,10 +313,10 @@ export class ProxyService {
);
return this.normalizeGeminiGenerateResponse(response);
} catch (error) {
if (error instanceof Error && this.isProjectContextError(error.message)) {
} catch (err) {
if (err instanceof Error && this.isProjectContextError(err.message)) {
this.logger.warn(
`Gemini request hit project context issue, retrying without project: ${error.message}`,
`Gemini request hit project context issue, retrying without project: ${err.message}`,
);
try {
const fallbackBody = this.createGeminiInternalRequest(
@@ -332,15 +332,16 @@ export class ProxyService {
extraHeaders,
);
return this.normalizeGeminiGenerateResponse(response);
} catch (fallbackError) {
error = fallbackError;
} catch (fallbackErr) {
lastError = fallbackErr;
}
} else {
lastError = err;
}
lastError = error;
if (error instanceof Error) {
this.logger.warn(`Gemini request failed with account ${token.email}: ${error.message}`);
const decision = this.classifyUpstreamError(error.message);
if (lastError instanceof Error) {
this.logger.warn(`Gemini request failed with account ${token.email}: ${lastError.message}`);
const decision = this.classifyUpstreamError(lastError.message);
if (decision.retry) {
if (decision.markAsForbidden) {
this.tokenManager.markAsForbidden(token.id);
@@ -400,10 +401,10 @@ export class ProxyService {
extraHeaders,
);
return this.passthroughSseStream(stream);
} catch (error) {
if (error instanceof Error && this.isProjectContextError(error.message)) {
} catch (err) {
if (err instanceof Error && this.isProjectContextError(err.message)) {
this.logger.warn(
`Gemini stream request hit project context issue, retrying without project: ${error.message}`,
`Gemini stream request hit project context issue, retrying without project: ${err.message}`,
);
try {
const fallbackBody = this.createGeminiInternalRequest(
@@ -419,17 +420,18 @@ export class ProxyService {
extraHeaders,
);
return this.passthroughSseStream(stream);
} catch (fallbackError) {
error = fallbackError;
} catch (fallbackErr) {
lastError = fallbackErr;
}
} else {
lastError = err;
}
lastError = error;
if (error instanceof Error) {
if (lastError instanceof Error) {
this.logger.warn(
`Gemini stream request failed with account ${token.email}: ${error.message}`,
`Gemini stream request failed with account ${token.email}: ${lastError.message}`,
);
const decision = this.classifyUpstreamError(error.message);
const decision = this.classifyUpstreamError(lastError.message);
if (decision.retry) {
if (decision.markAsForbidden) {
this.tokenManager.markAsForbidden(token.id);
@@ -627,10 +629,10 @@ export class ProxyService {
this.logger.log(`Claude Response: ${JSON.stringify(claudeResponse).substring(0, 500)}`);
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
}
} catch (error) {
if (error instanceof Error && this.isProjectContextError(error.message)) {
} catch (err) {
if (err instanceof Error && this.isProjectContextError(err.message)) {
this.logger.warn(
`OpenAI compatibility request hit project context issue, retrying without project: ${error.message}`,
`OpenAI compatibility request hit project context issue, retrying without project: ${err.message}`,
);
try {
const claudeRequest = this.convertOpenAIToClaude(request);
@@ -653,16 +655,17 @@ export class ProxyService {
);
const claudeResponse = transformResponse(response);
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
} catch (fallbackError) {
error = fallbackError;
} catch (fallbackErr) {
lastError = fallbackErr;
}
} else {
lastError = err;
}
lastError = error;
if (error instanceof Error) {
this.logger.warn(`Request failed with account ${token.email}: ${error.message}`);
if (lastError instanceof Error) {
this.logger.warn(`Request failed with account ${token.email}: ${lastError.message}`);
const decision = this.classifyUpstreamError(error.message);
const decision = this.classifyUpstreamError(lastError.message);
if (decision.retry) {
if (decision.markAsForbidden) {
this.tokenManager.markAsForbidden(token.id);