Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 64 additions & 62 deletions web/src/components/routes/ClientTypeRoutesContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1087,71 +1087,73 @@ function ClientTypeRoutesContentInner({
{/* Add Route Section - Grouped by Type */}
{hasAvailableProviders && (
<div className="pt-4 border-t border-border/50 ">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-2">
<Settings2 size={14} style={{ color }} />
<span className="text-caption font-medium text-muted-foreground">
{t('routes.availableProviders')}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border/60 bg-background/80 px-3 py-2 shadow-sm">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={allVisibleAvailableSelected}
aria-checked={
someVisibleAvailableSelected ? 'mixed' : allVisibleAvailableSelected
}
onChange={(event) =>
handleToggleAllVisibleAvailableProviders(event.target.checked)
}
className="h-4 w-4 rounded border-border accent-primary"
/>
<span>
{t('routes.bulkSelectVisibleProviders', {
count: visibleAvailableProviderIds.length,
})}
{normalizedQuery && (
<span className="ml-1 text-muted-foreground/70">
{t('routes.bulkFilteredSelection')}
</span>
)}
<div className="sticky top-0 z-20 -mx-1 mb-6 rounded-xl border border-border/60 bg-background/95 px-1 py-2 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="flex flex-col gap-4 px-2 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-2">
<Settings2 size={14} style={{ color }} />
<span className="text-caption font-medium text-muted-foreground">
{t('routes.availableProviders')}
</span>
</label>
<Button
variant="outline"
size="sm"
disabled={visibleAvailableProviderIds.length === 0 || createRoute.isPending}
onClick={handleInvertVisibleAvailableProviders}
>
{t('routes.bulkInvertVisibleProviders')}
</Button>
{selectedAvailableProviders.length > 0 && (
<span className="text-xs text-muted-foreground">
{t('routes.bulkSelectedProvidersCount', {
count: selectedAvailableProviders.length,
})}
</span>
)}
<Button
variant="outline"
size="sm"
disabled={selectedAvailableProviders.length === 0 || createRoute.isPending}
onClick={handleClearAvailableProviderSelection}
>
{t('common.clear')}
</Button>
<Button
size="sm"
disabled={selectedAvailableProviders.length === 0 || createRoute.isPending}
onClick={handleBulkAddRoutes}
>
{createRoute.isPending
? t('routes.bulkAddingProviders')
: t('routes.bulkAddSelectedProviders', {
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={allVisibleAvailableSelected}
aria-checked={
someVisibleAvailableSelected ? 'mixed' : allVisibleAvailableSelected
}
onChange={(event) =>
handleToggleAllVisibleAvailableProviders(event.target.checked)
}
className="h-4 w-4 rounded border-border accent-primary"
/>
<span>
{t('routes.bulkSelectVisibleProviders', {
count: visibleAvailableProviderIds.length,
})}
{normalizedQuery && (
<span className="ml-1 text-muted-foreground/70">
{t('routes.bulkFilteredSelection')}
</span>
)}
</span>
</label>
<Button
variant="outline"
size="sm"
disabled={visibleAvailableProviderIds.length === 0 || createRoute.isPending}
onClick={handleInvertVisibleAvailableProviders}
>
{t('routes.bulkInvertVisibleProviders')}
</Button>
{selectedAvailableProviders.length > 0 && (
<span className="text-xs text-muted-foreground">
{t('routes.bulkSelectedProvidersCount', {
count: selectedAvailableProviders.length,
})}
</Button>
</span>
)}
<Button
variant="outline"
size="sm"
disabled={selectedAvailableProviders.length === 0 || createRoute.isPending}
onClick={handleClearAvailableProviderSelection}
>
{t('common.clear')}
</Button>
<Button
size="sm"
disabled={selectedAvailableProviders.length === 0 || createRoute.isPending}
onClick={handleBulkAddRoutes}
>
{createRoute.isPending
? t('routes.bulkAddingProviders')
: t('routes.bulkAddSelectedProviders', {
count: selectedAvailableProviders.length,
})}
</Button>
</div>
</div>
</div>
{bulkAddError && (
Expand Down
3 changes: 3 additions & 0 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,9 @@
"apiTokenConcurrencyDesc": "Configure the maximum number of in-flight requests allowed per API token",
"apiTokenConcurrencyLimit": "Concurrent Request Limit",
"apiTokenConcurrencyHint": "When the limit is exceeded, new requests for the same token return a standard 429 rate limit response until an active request completes.",
"rateLimitCooldownDefaultSeconds": "429 default cooldown",
"rateLimitCooldownDefaultSecondsHint": "When upstream 429 / concurrency limits do not return Retry-After, use this value as the automatic cooldown duration. Upstream Retry-After still takes priority when present.",
"rateLimitCooldownDefaultSecondsInvalid": "429 default cooldown must be an integer between 1 and 86400.",
"concurrentRequestsUnit": "requests",
"defaultValue": "default {{value}}",
"minutes": "minutes",
Expand Down
3 changes: 3 additions & 0 deletions web/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,9 @@
"apiTokenConcurrencyDesc": "配置每个 API Token 允许同时进行中的请求数上限",
"apiTokenConcurrencyLimit": "并发请求上限",
"apiTokenConcurrencyHint": "超过限制时,同一 Token 的新请求会返回标准 429 限流响应,直到已有请求完成。",
"rateLimitCooldownDefaultSeconds": "429 默认冻结时间",
"rateLimitCooldownDefaultSecondsHint": "当上游 429 / 并发限制没有返回 Retry-After 时,使用此秒数作为自动冻结时长;上游返回 Retry-After 时仍优先使用上游时间。",
"rateLimitCooldownDefaultSecondsInvalid": "429 默认冻结时间必须是 1 到 86400 之间的整数。",
"concurrentRequestsUnit": "个请求",
"defaultValue": "默认 {{value}}",
"minutes": "分钟",
Expand Down
71 changes: 71 additions & 0 deletions web/src/pages/providers/components/grok-token-import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { normalizeGrokConfig } from './grok-token-import';

describe('normalizeGrokConfig', () => {
it('accepts direct CPA xAI credential JSON', () => {
expect(
normalizeGrokConfig({
type: 'xai',
auth_kind: 'oauth',
email: 'user@example.com',
access_token: 'access-token',
refresh_token: 'refresh-token',
base_url: 'https://cli-chat-proxy.grok.com/v1',
}),
).toMatchObject({
type: 'xai',
authKind: 'oauth',
email: 'user@example.com',
accessToken: 'access-token',
refreshToken: 'refresh-token',
baseURL: 'https://cli-chat-proxy.grok.com/v1',
});
});

it('accepts CLIProxyAPI auth records with credentials in metadata', () => {
expect(
normalizeGrokConfig({
provider: 'xai',
disabled: true,
attributes: { auth_kind: 'oauth' },
metadata: {
type: 'xai',
email: 'record@example.com',
access_token: 'record-access',
refresh_token: 'record-refresh',
},
}),
).toMatchObject({
type: 'xai',
authKind: 'oauth',
email: 'record@example.com',
accessToken: 'record-access',
refreshToken: 'record-refresh',
disabled: true,
});
});

it('accepts Maxx exported Grok providers with camelCase config', () => {
expect(
normalizeGrokConfig({
type: 'grok',
name: 'Grok export',
config: {
grok: {
type: 'xai',
authKind: 'oauth',
email: 'maxx@example.com',
accessToken: 'maxx-access',
refreshToken: 'maxx-refresh',
},
},
}),
).toMatchObject({
type: 'xai',
authKind: 'oauth',
email: 'maxx@example.com',
accessToken: 'maxx-access',
refreshToken: 'maxx-refresh',
});
});
});
Loading
Loading