mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
fix(settings): stabilize websearch and quota checks
This commit is contained in:
@@ -180,14 +180,11 @@ export function getCliInstallHints(): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WebSearch readiness status for display.
|
||||
*/
|
||||
export function getWebSearchReadiness(): WebSearchStatus {
|
||||
const wsConfig = getWebSearchConfig();
|
||||
const providers = getWebSearchCliProviders();
|
||||
|
||||
if (!wsConfig.enabled) {
|
||||
export function buildWebSearchReadiness(
|
||||
enabled: boolean,
|
||||
providers: WebSearchCliInfo[]
|
||||
): WebSearchStatus {
|
||||
if (!enabled) {
|
||||
return {
|
||||
readiness: 'unavailable',
|
||||
message: 'Disabled in config',
|
||||
@@ -223,6 +220,15 @@ export function getWebSearchReadiness(): WebSearchStatus {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WebSearch readiness status for display.
|
||||
*/
|
||||
export function getWebSearchReadiness(): WebSearchStatus {
|
||||
const wsConfig = getWebSearchConfig();
|
||||
const providers = getWebSearchCliProviders();
|
||||
return buildWebSearchReadiness(wsConfig.enabled, providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display WebSearch status (single line, equilibrium UX).
|
||||
*/
|
||||
|
||||
@@ -8,16 +8,15 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
buildCodexQuotaWindows,
|
||||
buildCodexCoreUsageSummary,
|
||||
fetchCodexQuota,
|
||||
getUnknownCodexWindowLabels,
|
||||
} from '../../../src/cliproxy/quota-fetcher-codex';
|
||||
|
||||
let tmpDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalFetch: typeof fetch;
|
||||
let moduleVersion = 0;
|
||||
let buildCodexQuotaWindows: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexQuotaWindows;
|
||||
let buildCodexCoreUsageSummary: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexCoreUsageSummary;
|
||||
let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota;
|
||||
let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels;
|
||||
|
||||
function createCodexAccount(
|
||||
accountId: string,
|
||||
@@ -29,7 +28,26 @@ function createCodexAccount(
|
||||
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
moduleVersion += 1;
|
||||
mock.restore();
|
||||
|
||||
const configGenerator = await import(
|
||||
`../../../src/cliproxy/config-generator?codex-config-generator=${moduleVersion}`
|
||||
);
|
||||
const accountManager = await import(
|
||||
`../../../src/cliproxy/account-manager?codex-account-manager=${moduleVersion}`
|
||||
);
|
||||
mock.module('../../../src/cliproxy/config-generator', () => configGenerator);
|
||||
mock.module('../../../src/cliproxy/account-manager', () => accountManager);
|
||||
|
||||
({
|
||||
buildCodexQuotaWindows,
|
||||
buildCodexCoreUsageSummary,
|
||||
fetchCodexQuota,
|
||||
getUnknownCodexWindowLabels,
|
||||
} = await import(`../../../src/cliproxy/quota-fetcher-codex?codex-fetcher=${moduleVersion}`));
|
||||
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-quota-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
@@ -37,6 +55,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
global.fetch = originalFetch;
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
|
||||
@@ -7,21 +7,18 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildGeminiCliBuckets,
|
||||
resolveGeminiCliProjectId,
|
||||
} from '../../../src/cliproxy/quota-fetcher-gemini-cli';
|
||||
import { refreshGeminiToken } from '../../../src/cliproxy/auth/gemini-token-refresh';
|
||||
import { getProviderAuthDir } from '../../../src/cliproxy/config-generator';
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
|
||||
|
||||
describe('Gemini CLI Quota Fetcher', () => {
|
||||
let tempHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalGeminiClientId: string | undefined;
|
||||
let originalGeminiClientSecret: string | undefined;
|
||||
let moduleVersion = 0;
|
||||
let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets;
|
||||
let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId;
|
||||
let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken;
|
||||
let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir;
|
||||
|
||||
function writeGeminiToken(token: Record<string, unknown>): string {
|
||||
const authDir = getProviderAuthDir('gemini');
|
||||
@@ -31,35 +28,51 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
return tokenPath;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
moduleVersion += 1;
|
||||
mock.restore();
|
||||
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-'));
|
||||
originalHome = process.env.HOME;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
|
||||
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
const actualConfigGenerator = await import(
|
||||
`../../../src/cliproxy/config-generator?gemini-config-generator-actual=${moduleVersion}`
|
||||
);
|
||||
const actualAccountManager = await import(
|
||||
`../../../src/cliproxy/account-manager?gemini-account-manager-actual=${moduleVersion}`
|
||||
);
|
||||
mock.module('../../../src/cliproxy/config-generator', () => ({
|
||||
...actualConfigGenerator,
|
||||
getProviderAuthDir: () => authDir,
|
||||
}));
|
||||
mock.module('../../../src/cliproxy/account-manager', () => ({
|
||||
...actualAccountManager,
|
||||
getDefaultAccount: () => null,
|
||||
getProviderAccounts: () => [],
|
||||
}));
|
||||
|
||||
const configGenerator = await import(
|
||||
`../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}`
|
||||
);
|
||||
({ buildGeminiCliBuckets, resolveGeminiCliProjectId } = await import(
|
||||
`../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}`
|
||||
));
|
||||
({ refreshGeminiToken } = await import(
|
||||
`../../../src/cliproxy/auth/gemini-token-refresh?gemini-refresh=${moduleVersion}`
|
||||
));
|
||||
({ getProviderAuthDir } = configGenerator);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
restoreFetch();
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (originalGeminiClientId === undefined) {
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
} else {
|
||||
|
||||
@@ -1,126 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getWebSearchReadiness } from '../../../../src/utils/websearch/status';
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildWebSearchReadiness } from '../../../../src/utils/websearch/status';
|
||||
import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types';
|
||||
|
||||
function writeWebSearchConfig(tempRoot: string, lines: string[]): void {
|
||||
fs.writeFileSync(path.join(tempRoot, '.ccs', 'config.yaml'), lines.join('\n'), 'utf8');
|
||||
function provider(overrides: Partial<WebSearchCliInfo> & Pick<WebSearchCliInfo, 'id' | 'name'>): WebSearchCliInfo {
|
||||
return {
|
||||
id: overrides.id,
|
||||
kind: overrides.kind ?? 'backend',
|
||||
name: overrides.name,
|
||||
enabled: overrides.enabled ?? false,
|
||||
available: overrides.available ?? false,
|
||||
version: overrides.version ?? null,
|
||||
requiresApiKey: overrides.requiresApiKey ?? false,
|
||||
description: overrides.description ?? '',
|
||||
detail: overrides.detail ?? '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('websearch readiness', () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const originalBraveKey = process.env.BRAVE_API_KEY;
|
||||
const originalExaKey = process.env.EXA_API_KEY;
|
||||
const originalTavilyKey = process.env.TAVILY_API_KEY;
|
||||
let tempRoot = '';
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-status-'));
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
delete process.env.EXA_API_KEY;
|
||||
delete process.env.TAVILY_API_KEY;
|
||||
fs.mkdirSync(path.join(tempRoot, '.ccs'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalBraveKey !== undefined) process.env.BRAVE_API_KEY = originalBraveKey;
|
||||
else delete process.env.BRAVE_API_KEY;
|
||||
|
||||
if (originalExaKey !== undefined) process.env.EXA_API_KEY = originalExaKey;
|
||||
else delete process.env.EXA_API_KEY;
|
||||
|
||||
if (originalTavilyKey !== undefined) process.env.TAVILY_API_KEY = originalTavilyKey;
|
||||
else delete process.env.TAVILY_API_KEY;
|
||||
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('is ready by default because DuckDuckGo is enabled', () => {
|
||||
const readiness = getWebSearchReadiness();
|
||||
const readiness = buildWebSearchReadiness(true, [
|
||||
provider({
|
||||
id: 'duckduckgo',
|
||||
name: 'DuckDuckGo',
|
||||
enabled: true,
|
||||
available: true,
|
||||
detail: 'Built-in (5 results)',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(readiness.readiness).toBe('ready');
|
||||
expect(readiness.message).toContain('DuckDuckGo');
|
||||
});
|
||||
|
||||
it('reports setup required when only Tavily is enabled without an API key', () => {
|
||||
writeWebSearchConfig(tempRoot, [
|
||||
'version: 10',
|
||||
'websearch:',
|
||||
' enabled: true',
|
||||
' providers:',
|
||||
' exa:',
|
||||
' enabled: false',
|
||||
' max_results: 5',
|
||||
' tavily:',
|
||||
' enabled: true',
|
||||
' max_results: 5',
|
||||
' duckduckgo:',
|
||||
' enabled: false',
|
||||
' max_results: 5',
|
||||
' brave:',
|
||||
' enabled: false',
|
||||
' max_results: 5',
|
||||
' gemini:',
|
||||
' enabled: false',
|
||||
' model: "gemini-2.5-flash"',
|
||||
' timeout: 55',
|
||||
' opencode:',
|
||||
' enabled: false',
|
||||
' model: "opencode/grok-code"',
|
||||
' timeout: 90',
|
||||
' grok:',
|
||||
' enabled: false',
|
||||
' timeout: 55',
|
||||
'',
|
||||
const readiness = buildWebSearchReadiness(true, [
|
||||
provider({
|
||||
id: 'tavily',
|
||||
name: 'Tavily',
|
||||
enabled: true,
|
||||
available: false,
|
||||
requiresApiKey: true,
|
||||
apiKeyEnvVar: 'TAVILY_API_KEY',
|
||||
detail: 'Set TAVILY_API_KEY',
|
||||
}),
|
||||
provider({
|
||||
id: 'duckduckgo',
|
||||
name: 'DuckDuckGo',
|
||||
enabled: false,
|
||||
available: false,
|
||||
detail: 'Built-in (5 results)',
|
||||
}),
|
||||
]);
|
||||
|
||||
const readiness = getWebSearchReadiness();
|
||||
|
||||
expect(readiness.readiness).toBe('needs_setup');
|
||||
expect(readiness.message).toContain('Tavily');
|
||||
expect(readiness.message).toContain('TAVILY_API_KEY');
|
||||
});
|
||||
|
||||
it('prefers API-backed readiness when Exa is enabled and configured', () => {
|
||||
process.env.EXA_API_KEY = 'exa-test-key';
|
||||
writeWebSearchConfig(tempRoot, [
|
||||
'version: 10',
|
||||
'websearch:',
|
||||
' enabled: true',
|
||||
' providers:',
|
||||
' exa:',
|
||||
' enabled: true',
|
||||
' max_results: 5',
|
||||
' tavily:',
|
||||
' enabled: false',
|
||||
' max_results: 5',
|
||||
' duckduckgo:',
|
||||
' enabled: false',
|
||||
' max_results: 5',
|
||||
' brave:',
|
||||
' enabled: false',
|
||||
' max_results: 5',
|
||||
' gemini:',
|
||||
' enabled: false',
|
||||
' model: "gemini-2.5-flash"',
|
||||
' timeout: 55',
|
||||
' opencode:',
|
||||
' enabled: false',
|
||||
' model: "opencode/grok-code"',
|
||||
' timeout: 90',
|
||||
' grok:',
|
||||
' enabled: false',
|
||||
' timeout: 55',
|
||||
'',
|
||||
const readiness = buildWebSearchReadiness(true, [
|
||||
provider({
|
||||
id: 'exa',
|
||||
name: 'Exa',
|
||||
enabled: true,
|
||||
available: true,
|
||||
requiresApiKey: true,
|
||||
apiKeyEnvVar: 'EXA_API_KEY',
|
||||
detail: 'API key detected (5 results)',
|
||||
}),
|
||||
provider({
|
||||
id: 'duckduckgo',
|
||||
name: 'DuckDuckGo',
|
||||
enabled: false,
|
||||
available: false,
|
||||
detail: 'Built-in (5 results)',
|
||||
}),
|
||||
]);
|
||||
|
||||
const readiness = getWebSearchReadiness();
|
||||
|
||||
expect(readiness.readiness).toBe('ready');
|
||||
expect(readiness.message).toContain('Exa');
|
||||
});
|
||||
|
||||
@@ -350,24 +350,6 @@ export default function WebSearchSection() {
|
||||
fetchRawConfig();
|
||||
}, [fetchConfig, fetchStatus, fetchRawConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.providers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDrafts: Record<string, string> = {};
|
||||
for (const provider of [...BACKEND_PROVIDERS, ...LEGACY_PROVIDERS]) {
|
||||
for (const field of provider.fields ?? []) {
|
||||
nextDrafts[`${provider.id}.${field.key}`] = getConfiguredValue(
|
||||
config.providers,
|
||||
provider.id,
|
||||
field
|
||||
);
|
||||
}
|
||||
}
|
||||
setFieldDrafts(nextDrafts);
|
||||
}, [config]);
|
||||
|
||||
const providerStatus = useMemo(
|
||||
() => new Map((status?.providers ?? []).map((provider) => [provider.id, provider])),
|
||||
[status?.providers]
|
||||
@@ -410,7 +392,10 @@ export default function WebSearchSection() {
|
||||
const currentProviders = (config.providers ?? {}) as WebSearchProvidersConfig;
|
||||
const currentProviderConfig = currentProviders[providerId] ?? {};
|
||||
const currentValue = currentProviderConfig[field.key] ?? field.defaultValue;
|
||||
const normalized = normalizeFieldValue(field, fieldDrafts[fieldId] ?? String(currentValue));
|
||||
const normalized = normalizeFieldValue(
|
||||
field,
|
||||
fieldDrafts[fieldId] ?? getConfiguredValue(currentProviders, providerId, field)
|
||||
);
|
||||
|
||||
setFieldDrafts((current) => ({ ...current, [fieldId]: String(normalized) }));
|
||||
|
||||
@@ -443,7 +428,13 @@ export default function WebSearchSection() {
|
||||
return {
|
||||
id: fieldId,
|
||||
label: field.label,
|
||||
value: fieldDrafts[fieldId] ?? String(field.defaultValue),
|
||||
value:
|
||||
fieldDrafts[fieldId] ??
|
||||
getConfiguredValue(
|
||||
(config?.providers ?? {}) as WebSearchProvidersConfig,
|
||||
provider.id,
|
||||
field
|
||||
),
|
||||
placeholder: field.placeholder,
|
||||
type: field.type,
|
||||
helpText: field.helpText,
|
||||
|
||||
Reference in New Issue
Block a user