feat: add generation backend status diagnostics (#1883)

This commit is contained in:
Alfred
2026-07-03 19:34:43 +08:00
committed by GitHub
parent a47952047f
commit 7b39b73b9d
28 changed files with 3319 additions and 38 deletions

View File

@@ -121,6 +121,7 @@ STOCK_INDEX_REMOTE_UPDATE_ENABLED=true
# 本地 CLI Backend 不等于离线模型CLI 背后的服务可能处理股票代码、新闻、持仓上下文、分析 prompt 和报告草稿。
# Docker / CI / remote server 不天然拥有桌面 CLI 登录态DSA 不读取 Claude/OpenCode credential 文件。
# DSA 会用最小 env allowlist + provider credential denylist 降低 API keys / webhook tokens 泄漏风险。
# Web 设置页的快速检查只读配置和可执行文件可见性JSON 测试才会发起真实 generation backend smoke 请求。
GENERATION_BACKEND=litellm
# OPENCODE_CLI_MODEL=provider/model
# 后端级 fallback本地 .env 空值禁用 backend-level fallbacklitellm -> litellm 会被解析为 no-op。

View File

@@ -13,11 +13,15 @@ from api.v1.schemas.system_config import (
DiscoverLLMChannelModelsRequest,
DiscoverLLMChannelModelsResponse,
ExportSystemConfigResponse,
GenerationBackendStatusPreviewRequest,
GenerationBackendStatusResponse,
ImportSystemConfigRequest,
SystemConfigConflictResponse,
SystemConfigResponse,
SystemConfigSchemaResponse,
SetupStatusResponse,
TestGenerationBackendRequest,
TestGenerationBackendResponse,
SystemConfigValidationErrorResponse,
TestLLMChannelRequest,
TestLLMChannelResponse,
@@ -184,6 +188,133 @@ def get_setup_status(
)
@router.get(
"/config/generation-backends/status",
response_model=GenerationBackendStatusResponse,
responses={
200: {"description": "Generation backend status loaded"},
500: {"description": "Internal server error", "model": ErrorResponse},
},
summary="Get generation backend status",
description=(
"Read a side-effect-free generation backend cheap-check status from "
"saved and runtime configuration. This endpoint does not run a model request."
),
)
def get_generation_backend_status(
service: SystemConfigService = Depends(get_system_config_service),
) -> GenerationBackendStatusResponse:
"""Return saved/runtime generation backend status without writing config."""
try:
payload = service.get_generation_backend_status()
return GenerationBackendStatusResponse.model_validate(payload)
except Exception as exc:
logger.error("Failed to load generation backend status: %s", exc, exc_info=True)
raise HTTPException(
status_code=500,
detail={
"error": "internal_error",
"message": "Failed to load generation backend status",
},
)
@router.post(
"/config/generation-backends/status/preview",
response_model=GenerationBackendStatusResponse,
responses={
200: {"description": "Generation backend status preview loaded"},
400: {"description": "Validation failed", "model": SystemConfigValidationErrorResponse},
500: {"description": "Internal server error", "model": ErrorResponse},
},
summary="Preview generation backend status",
description="Run a side-effect-free cheap check against unsaved settings draft values.",
)
def preview_generation_backend_status(
request: GenerationBackendStatusPreviewRequest,
service: SystemConfigService = Depends(get_system_config_service),
) -> GenerationBackendStatusResponse:
"""Return generation backend status for unsaved draft values."""
try:
payload = service.preview_generation_backend_status(
items=[item.model_dump() for item in request.items],
mask_token=request.mask_token,
)
return GenerationBackendStatusResponse.model_validate(payload)
except ConfigValidationError as exc:
raise HTTPException(
status_code=400,
detail={
"error": "validation_failed",
"message": "System configuration validation failed",
"issues": exc.issues,
},
)
except Exception as exc:
logger.error("Failed to preview generation backend status: %s", exc, exc_info=True)
raise HTTPException(
status_code=500,
detail={
"error": "internal_error",
"message": "Failed to preview generation backend status",
},
)
@router.post(
"/config/generation-backends/smoke-test",
response_model=TestGenerationBackendResponse,
responses={
200: {"description": "Generation backend smoke test completed"},
400: {"description": "Validation failed", "model": SystemConfigValidationErrorResponse},
422: {"description": "Invalid smoke test request", "model": ErrorResponse},
500: {"description": "Internal server error", "model": ErrorResponse},
},
summary="Smoke test generation backend",
description="Run an explicit fixed-prompt generation backend smoke test without persisting config.",
)
def test_generation_backend(
request: TestGenerationBackendRequest,
service: SystemConfigService = Depends(get_system_config_service),
) -> TestGenerationBackendResponse:
"""Run a fixed generation backend smoke test."""
try:
payload = service.test_generation_backend(
backend_id=request.backend_id,
mode=request.mode,
items=[item.model_dump() for item in request.items],
mask_token=request.mask_token,
timeout_seconds=request.timeout_seconds,
)
return TestGenerationBackendResponse.model_validate(payload)
except ConfigValidationError as exc:
raise HTTPException(
status_code=400,
detail={
"error": "validation_failed",
"message": "System configuration validation failed",
"issues": exc.issues,
},
)
except (ValueError, TypeError) as exc:
raise HTTPException(
status_code=422,
detail={
"error": "validation_error",
"message": str(exc),
},
)
except Exception as exc:
logger.error("Failed to smoke test generation backend: %s", exc, exc_info=True)
raise HTTPException(
status_code=500,
detail={
"error": "internal_error",
"message": "Failed to smoke test generation backend",
},
)
@router.put(
"/config",
response_model=UpdateSystemConfigResponse,

View File

@@ -8,6 +8,8 @@ from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
LLMCapabilityCheck = Literal["json", "tools", "vision", "stream"]
GenerationBackendSmokeMode = Literal["text", "json"]
GenerationBackendHealthStatus = Literal["not_tested", "passed", "failed", "skipped"]
NotificationTestChannel = Literal[
"wechat",
"feishu",
@@ -121,6 +123,40 @@ class SetupStatusResponse(BaseModel):
checks: List[SetupStatusCheck] = Field(default_factory=list)
class GenerationBackendStatus(BaseModel):
"""Cheap status for one generation backend.
``health_status`` and ``last_error_*`` describe the current status request
or the explicit smoke-test request only; they are not persisted history.
"""
backend_id: str
backend_type: Literal["litellm", "local_cli"]
provider_id: str
available: bool
health_status: GenerationBackendHealthStatus = "not_tested"
supports_json: bool
supports_tools: bool
supports_stream: bool
supports_vision: bool
is_primary: bool
fallback_target: Optional[str] = None
max_concurrency: int
usage_available: bool
last_error_code: Optional[str] = None
last_error_message: Optional[str] = None
class GenerationBackendStatusResponse(BaseModel):
"""Generation backend status payload."""
primary_backend_id: str
fallback_backend_id: Optional[str] = None
primary: GenerationBackendStatus
fallback: Optional[GenerationBackendStatus] = None
backends: List[GenerationBackendStatus] = Field(default_factory=list)
class ExportSystemConfigResponse(BaseModel):
"""Export payload for raw `.env` backups."""
@@ -136,6 +172,32 @@ class SystemConfigUpdateItem(BaseModel):
value: str
class GenerationBackendStatusPreviewRequest(BaseModel):
"""Unsaved-draft preview request for generation backend status."""
items: List[SystemConfigUpdateItem] = Field(default_factory=list)
mask_token: str = "******"
class TestGenerationBackendRequest(BaseModel):
"""Explicit generation backend smoke-test request."""
backend_id: Optional[str] = None
mode: GenerationBackendSmokeMode = "json"
items: List[SystemConfigUpdateItem] = Field(default_factory=list)
mask_token: str = "******"
timeout_seconds: Optional[float] = Field(default=None, ge=1.0, le=3600.0)
class TestGenerationBackendResponse(BaseModel):
"""Generation backend smoke-test result."""
success: bool
mode: GenerationBackendSmokeMode
message: str
status: GenerationBackendStatus
class UpdateSystemConfigRequest(BaseModel):
"""Update request payload."""

View File

@@ -522,6 +522,58 @@ test('desktop update backup list preserves AlphaSift caches', (t) => {
assert.ok(files.includes(path.join('data', 'alphasift', 'snapshot.last_good.json')));
});
test('desktop update backup and restore preserve generation backend env keys', (t) => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dsa-desktop-env-backup-'));
const appDir = path.join(tempRoot, 'app');
const userDataDir = path.join(tempRoot, 'userData');
const backupRoot = path.join(userDataDir, '.dsa-desktop-update-backup');
const envPath = path.join(appDir, '.env');
const envContent = [
'GENERATION_BACKEND=codex_cli',
'GENERATION_FALLBACK_BACKEND=litellm',
'CODEX_CLI_PRESET=codex',
'AGENT_GENERATION_BACKEND=codex_cli',
'',
].join('\n');
let currentVersion = '3.12.0';
fs.mkdirSync(appDir, { recursive: true });
fs.mkdirSync(userDataDir, { recursive: true });
fs.writeFileSync(path.join(appDir, 'Uninstall Daily Stock Analysis.exe'), '');
fs.writeFileSync(envPath, envContent, 'utf-8');
const mainModule = loadMainModule(t, {
platform: 'win32',
app: {
isPackaged: true,
getPath: (name) => {
if (name === 'exe') {
return path.join(appDir, 'Daily Stock Analysis.exe');
}
return userDataDir;
},
getVersion: () => currentVersion,
},
});
t.after(() => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
mainModule.backupPackagedRuntimeState();
assert.equal(fs.readFileSync(path.join(backupRoot, '.env'), 'utf-8'), envContent);
assert.ok(JSON.parse(fs.readFileSync(path.join(backupRoot, 'runtime-state.json'), 'utf-8')).files.includes('.env'));
fs.writeFileSync(envPath, 'GENERATION_BACKEND=litellm\n', 'utf-8');
currentVersion = '3.13.0';
const restoreResult = mainModule.restorePackagedRuntimeStateFromBackup();
assert.deepEqual(restoreResult.failed, []);
assert.ok(restoreResult.restored.includes('.env'));
assert.equal(fs.readFileSync(envPath, 'utf-8'), envContent);
assert.equal(fs.existsSync(backupRoot), false);
});
test('desktop update backup and restore preserve AlphaSift detail directories recursively', (t) => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dsa-desktop-dir-backup-'));
const appDir = path.join(tempRoot, 'app');

View File

@@ -142,4 +142,135 @@ describe('systemConfigApi', () => {
expect(result.nextStepKey).toBe('llm_primary');
expect(result.checks[0].nextStep).toBe('打开系统设置');
});
it('loads generation backend status with camelCase fields', async () => {
get.mockResolvedValueOnce({
data: {
primary_backend_id: 'codex_cli',
fallback_backend_id: null,
primary: {
backend_id: 'codex_cli',
backend_type: 'local_cli',
provider_id: 'codex_cli',
available: true,
health_status: 'passed',
supports_json: true,
supports_tools: false,
supports_stream: true,
supports_vision: false,
is_primary: true,
fallback_target: null,
max_concurrency: 1,
usage_available: false,
last_error_code: null,
last_error_message: null,
},
fallback: null,
backends: [],
},
});
const result = await systemConfigApi.getGenerationBackendStatus();
expect(get).toHaveBeenCalledWith('/api/v1/system/config/generation-backends/status');
expect(result.primaryBackendId).toBe('codex_cli');
expect(result.primary.supportsTools).toBe(false);
expect(result.primary.healthStatus).toBe('passed');
});
it('previews generation backend status with draft items and mask token', async () => {
post.mockResolvedValueOnce({
data: {
primary_backend_id: 'opencode_cli',
fallback_backend_id: null,
primary: {
backend_id: 'opencode_cli',
backend_type: 'local_cli',
provider_id: 'opencode_cli',
available: false,
health_status: 'failed',
supports_json: true,
supports_tools: false,
supports_stream: false,
supports_vision: false,
is_primary: true,
fallback_target: null,
max_concurrency: 1,
usage_available: false,
last_error_code: 'command_not_found',
last_error_message: 'Executable not found',
},
fallback: null,
backends: [],
},
});
const result = await systemConfigApi.previewGenerationBackendStatus({
items: [
{ key: 'GENERATION_BACKEND', value: 'opencode_cli' },
{ key: 'OPENAI_API_KEY', value: '******' },
],
maskToken: '******',
});
expect(post).toHaveBeenCalledWith(
'/api/v1/system/config/generation-backends/status/preview',
{
items: [
{ key: 'GENERATION_BACKEND', value: 'opencode_cli' },
{ key: 'OPENAI_API_KEY', value: '******' },
],
mask_token: '******',
},
);
expect(result.primary.lastErrorCode).toBe('command_not_found');
});
it('runs generation backend smoke tests with snake_case fields', async () => {
post.mockResolvedValueOnce({
data: {
success: true,
mode: 'json',
message: 'JSON smoke test passed',
status: {
backend_id: 'litellm',
backend_type: 'litellm',
provider_id: 'litellm',
available: true,
health_status: 'passed',
supports_json: true,
supports_tools: false,
supports_stream: true,
supports_vision: false,
is_primary: true,
fallback_target: null,
max_concurrency: 2,
usage_available: true,
last_error_code: null,
last_error_message: null,
},
},
});
const result = await systemConfigApi.testGenerationBackend({
backendId: 'litellm',
mode: 'json',
items: [{ key: 'LITELLM_MODEL', value: 'openai/gpt-4o-mini' }],
maskToken: '******',
timeoutSeconds: 9,
});
expect(post).toHaveBeenCalledWith(
'/api/v1/system/config/generation-backends/smoke-test',
{
backend_id: 'litellm',
mode: 'json',
items: [{ key: 'LITELLM_MODEL', value: 'openai/gpt-4o-mini' }],
mask_token: '******',
timeout_seconds: 9,
},
);
expect(result.success).toBe(true);
expect(result.status.healthStatus).toBe('passed');
});
});

View File

@@ -5,6 +5,8 @@ import type {
DiscoverLLMChannelModelsRequest,
DiscoverLLMChannelModelsResponse,
ExportSystemConfigResponse,
GenerationBackendStatusPreviewRequest,
GenerationBackendStatusResponse,
ImportSystemConfigRequest,
SchedulerRunNowResponse,
SchedulerStatusResponse,
@@ -15,6 +17,8 @@ import type {
SystemConfigValidationErrorResponse,
TestLLMChannelRequest,
TestLLMChannelResponse,
TestGenerationBackendRequest,
TestGenerationBackendResponse,
TestNotificationChannelRequest,
TestNotificationChannelResponse,
UpdateSystemConfigRequest,
@@ -131,6 +135,36 @@ function toSnakeDiscoverModelsPayload(payload: DiscoverLLMChannelModelsRequest):
};
}
function toSnakeGenerationBackendStatusPreviewPayload(
payload: GenerationBackendStatusPreviewRequest = {},
): Record<string, unknown> {
return {
items: (payload.items || []).map((item) => ({
key: item.key,
value: item.value,
})),
mask_token: payload.maskToken ?? '******',
};
}
function toSnakeGenerationBackendSmokePayload(payload: TestGenerationBackendRequest = {}): Record<string, unknown> {
const request: Record<string, unknown> = {
mode: payload.mode ?? 'json',
items: (payload.items || []).map((item) => ({
key: item.key,
value: item.value,
})),
mask_token: payload.maskToken ?? '******',
};
if (payload.backendId) {
request.backend_id = payload.backendId;
}
if (payload.timeoutSeconds !== undefined && payload.timeoutSeconds !== null) {
request.timeout_seconds = payload.timeoutSeconds;
}
return request;
}
export const systemConfigApi = {
async getConfig(includeSchema = true): Promise<SystemConfigResponse> {
const response = await apiClient.get<Record<string, unknown>>('/api/v1/system/config', {
@@ -158,6 +192,31 @@ export const systemConfigApi = {
return toCamelCase<SetupStatusResponse>(response.data);
},
async getGenerationBackendStatus(): Promise<GenerationBackendStatusResponse> {
const response = await apiClient.get<Record<string, unknown>>(
'/api/v1/system/config/generation-backends/status',
);
return toCamelCase<GenerationBackendStatusResponse>(response.data);
},
async previewGenerationBackendStatus(
payload: GenerationBackendStatusPreviewRequest = {},
): Promise<GenerationBackendStatusResponse> {
const response = await apiClient.post<Record<string, unknown>>(
'/api/v1/system/config/generation-backends/status/preview',
toSnakeGenerationBackendStatusPreviewPayload(payload),
);
return toCamelCase<GenerationBackendStatusResponse>(response.data);
},
async testGenerationBackend(payload: TestGenerationBackendRequest = {}): Promise<TestGenerationBackendResponse> {
const response = await apiClient.post<Record<string, unknown>>(
'/api/v1/system/config/generation-backends/smoke-test',
toSnakeGenerationBackendSmokePayload(payload),
);
return toCamelCase<TestGenerationBackendResponse>(response.data);
},
async getSchedulerStatus(): Promise<SchedulerStatusResponse> {
const response = await apiClient.get<Record<string, unknown>>('/api/v1/system/scheduler/status');
return toCamelCase<SchedulerStatusResponse>(response.data);

View File

@@ -0,0 +1,214 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type React from 'react';
import { CheckCircle2, CircleAlert, CircleDashed, FlaskConical, RefreshCw } from 'lucide-react';
import { systemConfigApi } from '../../api/systemConfig';
import { getParsedApiError, type ParsedApiError } from '../../api/error';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { GenerationBackendStatus, GenerationBackendStatusResponse, SystemConfigUpdateItem, TestGenerationBackendResponse } from '../../types/systemConfig';
import { ApiErrorAlert, Badge, Button } from '../common';
import { SettingsAlert } from './SettingsAlert';
type Translate = ReturnType<typeof useUiLanguage>['t'];
interface GenerationBackendStatusPanelProps {
items: SystemConfigUpdateItem[];
maskToken: string;
disabled?: boolean;
}
function getHealthLabel(status: GenerationBackendStatus, t: Translate) {
if (status.healthStatus === 'passed') return t('settings.generationBackendHealthPassed');
if (status.healthStatus === 'failed') return t('settings.generationBackendHealthFailed');
if (status.healthStatus === 'skipped') return t('settings.generationBackendHealthSkipped');
return status.available ? t('settings.generationBackendRunnable') : t('settings.generationBackendNeedsAction');
}
function getHealthIcon(status: GenerationBackendStatus) {
if (status.healthStatus === 'passed') {
return <CheckCircle2 className="h-4 w-4 text-success" aria-hidden="true" />;
}
if (!status.available || status.healthStatus === 'failed') {
return <CircleAlert className="h-4 w-4 text-warning" aria-hidden="true" />;
}
return <CircleDashed className="h-4 w-4 text-muted-text" aria-hidden="true" />;
}
const BackendStatusRow: React.FC<{ title: string; status: GenerationBackendStatus | null | undefined; t: Translate }> = ({
title,
status,
t,
}) => {
if (!status) {
return null;
}
return (
<div className="rounded-xl border settings-border bg-background/35 px-4 py-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{getHealthIcon(status)}
<span className="text-sm font-semibold text-foreground">{title}</span>
<Badge variant={status.available ? 'success' : 'warning'} size="sm">
{getHealthLabel(status, t)}
</Badge>
<Badge variant="history" size="sm">
{status.backendId}
</Badge>
</div>
<p className="mt-2 text-xs leading-5 text-muted-text">
{status.backendType === 'local_cli'
? t('settings.generationBackendLocalCliDescription')
: t('settings.generationBackendLiteLLMDescription')}
</p>
{status.lastErrorMessage ? (
<p className="mt-2 text-xs leading-5 text-warning">
{status.lastErrorCode ? `${status.lastErrorCode}: ` : ''}
{status.lastErrorMessage}
</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Badge variant={status.supportsJson ? 'success' : 'history'} size="sm">JSON</Badge>
<Badge variant={status.supportsStream ? 'success' : 'history'} size="sm">Stream</Badge>
<Badge variant={status.supportsTools ? 'success' : 'warning'} size="sm">
{status.supportsTools ? t('settings.generationBackendToolsSupported') : t('settings.generationBackendGenerationOnly')}
</Badge>
<Badge variant="history" size="sm">{t('settings.generationBackendConcurrency', { count: status.maxConcurrency })}</Badge>
</div>
</div>
</div>
);
};
export const GenerationBackendStatusPanel: React.FC<GenerationBackendStatusPanelProps> = ({
items,
maskToken,
disabled = false,
}) => {
const { t } = useUiLanguage();
const [status, setStatus] = useState<GenerationBackendStatusResponse | null>(null);
const [smokeResult, setSmokeResult] = useState<TestGenerationBackendResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSmoking, setIsSmoking] = useState(false);
const [error, setError] = useState<ParsedApiError | null>(null);
const refreshRequestIdRef = useRef(0);
const smokeRequestIdRef = useRef(0);
const hasDraft = items.length > 0;
const requestItems = useMemo(() => items.map((item) => ({ key: item.key, value: item.value })), [items]);
const requestItemsFingerprint = useMemo(() => JSON.stringify(requestItems), [requestItems]);
useEffect(() => {
smokeRequestIdRef.current += 1;
setSmokeResult(null);
setIsSmoking(false);
}, [requestItemsFingerprint]);
const refresh = useCallback(async () => {
const requestId = refreshRequestIdRef.current + 1;
refreshRequestIdRef.current = requestId;
smokeRequestIdRef.current += 1;
setIsLoading(true);
setIsSmoking(false);
setError(null);
setSmokeResult(null);
try {
const next = hasDraft
? await systemConfigApi.previewGenerationBackendStatus({ items: requestItems, maskToken })
: await systemConfigApi.getGenerationBackendStatus();
if (refreshRequestIdRef.current !== requestId) {
return;
}
setStatus(next);
} catch (err: unknown) {
if (refreshRequestIdRef.current !== requestId) {
return;
}
setStatus(null);
setSmokeResult(null);
setError(getParsedApiError(err));
} finally {
if (refreshRequestIdRef.current === requestId) {
setIsLoading(false);
}
}
}, [hasDraft, maskToken, requestItems]);
useEffect(() => {
void refresh();
}, [refresh]);
const runSmoke = useCallback(async () => {
const requestId = smokeRequestIdRef.current + 1;
smokeRequestIdRef.current = requestId;
refreshRequestIdRef.current += 1;
setIsLoading(false);
setIsSmoking(true);
setError(null);
setSmokeResult(null);
try {
const result = await systemConfigApi.testGenerationBackend({
mode: 'json',
items: requestItems,
maskToken,
});
if (smokeRequestIdRef.current !== requestId) {
return;
}
setSmokeResult(result);
setStatus((prev) => ({
primaryBackendId: result.status.backendId,
fallbackBackendId: prev?.fallbackBackendId ?? null,
primary: result.status,
fallback: prev?.fallback ?? null,
backends: prev?.backends?.length
? [result.status, ...prev.backends.filter((backend) => backend.backendId !== result.status.backendId)]
: [result.status],
}));
} catch (err: unknown) {
if (smokeRequestIdRef.current !== requestId) {
return;
}
setStatus(null);
setSmokeResult(null);
setError(getParsedApiError(err));
} finally {
if (smokeRequestIdRef.current === requestId) {
setIsSmoking(false);
}
}
}, [maskToken, requestItems]);
return (
<div data-testid="generation-backend-status-panel" className="space-y-3 rounded-xl border settings-border bg-card/70 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<p className="text-sm font-semibold text-foreground">{t('settings.generationBackendStatus')}</p>
<p className="mt-1 text-xs leading-5 text-muted-text">
{t('settings.generationBackendStatusDescription')}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button type="button" variant="settings-secondary" size="sm" disabled={disabled || isLoading} isLoading={isLoading} loadingText={t('settings.generationBackendRefreshing')} onClick={() => void refresh()}>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
{t('settings.generationBackendRefresh')}
</Button>
<Button type="button" variant="settings-secondary" size="sm" disabled={disabled || isSmoking} isLoading={isSmoking} loadingText={t('settings.generationBackendSmokeTesting')} onClick={() => void runSmoke()}>
<FlaskConical className="h-4 w-4" aria-hidden="true" />
{t('settings.generationBackendSmokeTest')}
</Button>
</div>
</div>
{error ? <ApiErrorAlert error={error} /> : null}
{smokeResult ? (
<SettingsAlert
title={smokeResult.success ? t('settings.generationBackendSmokePassed') : t('settings.generationBackendSmokeFailed')}
message={smokeResult.success ? t('settings.generationBackendSmokePassedMessage') : smokeResult.message}
variant={smokeResult.success ? 'success' : 'warning'}
/>
) : null}
<BackendStatusRow title={t('settings.generationBackendPrimary')} status={status?.primary} t={t} />
<BackendStatusRow title={t('settings.generationBackendFallback')} status={status?.fallback} t={t} />
</div>
);
};

View File

@@ -159,6 +159,7 @@ interface LLMChannelEditorProps {
configVersion: string;
maskToken: string;
onSaved: (updatedItems: Array<{ key: string; value: string }>) => void | Promise<void>;
onDraftItemsChange?: (items: Array<{ key: string; value: string }>) => void;
disabled?: boolean;
}
@@ -1440,6 +1441,82 @@ function channelsToUpdateItems(
return updates;
}
function channelNamesAreSafe(channels: ChannelConfig[]): boolean {
return channels.every((channel) => /^[a-z0-9_]+$/.test(channel.name.trim()));
}
function buildFilteredChannelUpdateItems({
channels,
initialChannels,
initialNames,
initialItemSourceByKey,
savedItemMap,
runtimeConfig,
initialRuntimeConfig,
managesRuntimeConfig,
}: {
channels: ChannelConfig[];
initialChannels: ChannelConfig[];
initialNames: string[];
initialItemSourceByKey: Map<string, boolean>;
savedItemMap: Map<string, string>;
runtimeConfig: RuntimeConfig;
initialRuntimeConfig: RuntimeConfig;
managesRuntimeConfig: boolean;
}): Array<{ key: string; value: string }> {
const changedKeys = new Set<string>([
...buildChangedItemKeys(channels, initialChannels, initialItemSourceByKey, savedItemMap),
...runtimeConfigChangedKeys(runtimeConfig, initialRuntimeConfig),
]);
return channelsToUpdateItems(channels, initialNames, runtimeConfig, managesRuntimeConfig).filter((item) => {
const itemKey = item.key.toUpperCase();
const initialItemSource = initialItemSourceByKey.get(itemKey);
if (initialItemSource === false) {
return changedKeys.has(itemKey);
}
if (isChannelSecretFieldKey(itemKey) && initialItemSource === undefined) {
return changedKeys.has(itemKey);
}
return true;
});
}
function buildChannelDraftItems({
hasChanges,
channels,
initialChannels,
initialNames,
initialItemSourceByKey,
savedItemMap,
runtimeConfig,
initialRuntimeConfig,
managesRuntimeConfig,
}: {
hasChanges: boolean;
channels: ChannelConfig[];
initialChannels: ChannelConfig[];
initialNames: string[];
initialItemSourceByKey: Map<string, boolean>;
savedItemMap: Map<string, string>;
runtimeConfig: RuntimeConfig;
initialRuntimeConfig: RuntimeConfig;
managesRuntimeConfig: boolean;
}): Array<{ key: string; value: string }> {
if (!hasChanges || !channelNamesAreSafe(channels)) {
return [];
}
return buildFilteredChannelUpdateItems({
channels,
initialChannels,
initialNames,
initialItemSourceByKey,
savedItemMap,
runtimeConfig,
initialRuntimeConfig,
managesRuntimeConfig,
});
}
function channelsAreEqual(left: ChannelConfig, right: ChannelConfig): boolean {
return (
left.name === right.name
@@ -1456,6 +1533,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
configVersion,
maskToken,
onSaved,
onDraftItemsChange,
disabled = false,
}) => {
const initialItemSourceByKey = useMemo(() => {
@@ -1517,6 +1595,8 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
const [isCollapsed, setIsCollapsed] = useState(false);
const [addPreset, setAddPreset] = useState('aihubmix');
const addChannelIdRef = useRef(0);
const lastDraftFingerprintRef = useRef<string | null>(null);
const onDraftItemsChangeRef = useRef(onDraftItemsChange);
const prevChannelsRef = useRef(channelsFingerprint);
const prevRuntimeRef = useRef(runtimeFingerprint);
@@ -1611,6 +1691,45 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
return channels.some((channel, index) => !channelsAreEqual(channel, initialChannels[index]));
}, [channels, initialChannels, initialRuntimeConfig, runtimeConfig]);
const draftItems = useMemo(() => buildChannelDraftItems({
hasChanges,
channels,
initialChannels,
initialNames,
initialItemSourceByKey,
savedItemMap,
runtimeConfig,
initialRuntimeConfig,
managesRuntimeConfig,
}), [
channels,
hasChanges,
initialChannels,
initialItemSourceByKey,
initialNames,
initialRuntimeConfig,
managesRuntimeConfig,
runtimeConfig,
savedItemMap,
]);
const draftFingerprint = useMemo(() => JSON.stringify(draftItems), [draftItems]);
useEffect(() => {
onDraftItemsChangeRef.current = onDraftItemsChange;
}, [onDraftItemsChange]);
useEffect(() => {
if (!onDraftItemsChange || lastDraftFingerprintRef.current === draftFingerprint) {
return;
}
lastDraftFingerprintRef.current = draftFingerprint;
onDraftItemsChange(draftItems);
}, [draftFingerprint, draftItems, onDraftItemsChange]);
useEffect(() => () => {
onDraftItemsChangeRef.current?.([]);
}, []);
const busy = disabled || isSaving;
const updateChannel = (index: number, field: keyof ChannelConfig, value: string | boolean) => {
@@ -1818,23 +1937,16 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
setSaveWarnings([]);
try {
const changedKeys = new Set<string>([
...buildChangedItemKeys(channels, initialChannels, initialItemSourceByKey, savedItemMap),
...runtimeConfigChangedKeys(runtimeConfigForSave, initialRuntimeConfig),
]);
const updateItems = channelsToUpdateItems(channels, initialNames, runtimeConfigForSave, managesRuntimeConfig).filter(
(item) => {
const itemKey = item.key.toUpperCase();
const initialItemSource = initialItemSourceByKey.get(itemKey);
if (initialItemSource === false) {
return changedKeys.has(itemKey);
}
if (isChannelSecretFieldKey(itemKey) && initialItemSource === undefined) {
return changedKeys.has(itemKey);
}
return true;
},
);
const updateItems = buildFilteredChannelUpdateItems({
channels,
initialChannels,
initialNames,
initialItemSourceByKey,
savedItemMap,
runtimeConfig: runtimeConfigForSave,
initialRuntimeConfig,
managesRuntimeConfig,
});
const response = await systemConfigApi.update({
configVersion,
maskToken,

View File

@@ -0,0 +1,245 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GenerationBackendStatusPanel } from '../GenerationBackendStatusPanel';
import { UiLanguageProvider } from '../../../contexts/UiLanguageContext';
import type { GenerationBackendStatusResponse, TestGenerationBackendResponse } from '../../../types/systemConfig';
import { UI_LANGUAGE_STORAGE_KEY } from '../../../utils/uiLanguage';
const {
getGenerationBackendStatus,
previewGenerationBackendStatus,
testGenerationBackend,
} = vi.hoisted(() => ({
getGenerationBackendStatus: vi.fn(),
previewGenerationBackendStatus: vi.fn(),
testGenerationBackend: vi.fn(),
}));
vi.mock('../../../api/systemConfig', () => ({
systemConfigApi: {
getGenerationBackendStatus: (...args: unknown[]) => getGenerationBackendStatus(...args),
previewGenerationBackendStatus: (...args: unknown[]) => previewGenerationBackendStatus(...args),
testGenerationBackend: (...args: unknown[]) => testGenerationBackend(...args),
},
}));
const localCliStatus: GenerationBackendStatusResponse = {
primaryBackendId: 'codex_cli',
fallbackBackendId: null,
primary: {
backendId: 'codex_cli',
backendType: 'local_cli',
providerId: 'codex_cli',
available: true,
healthStatus: 'passed',
supportsJson: true,
supportsTools: false,
supportsStream: true,
supportsVision: false,
isPrimary: true,
fallbackTarget: null,
maxConcurrency: 1,
usageAvailable: false,
lastErrorCode: null,
lastErrorMessage: null,
},
fallback: null,
backends: [],
};
const smokePassed: TestGenerationBackendResponse = {
success: true,
mode: 'json',
message: '生成后端冒烟测试通过',
status: localCliStatus.primary,
};
const litellmStatus: GenerationBackendStatusResponse = {
primaryBackendId: 'litellm',
fallbackBackendId: null,
primary: {
...localCliStatus.primary,
backendId: 'litellm',
backendType: 'litellm',
providerId: 'litellm',
supportsTools: true,
usageAvailable: true,
},
fallback: null,
backends: [],
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
describe('GenerationBackendStatusPanel', () => {
beforeEach(() => {
window.localStorage.clear();
getGenerationBackendStatus.mockReset();
previewGenerationBackendStatus.mockReset();
testGenerationBackend.mockReset();
getGenerationBackendStatus.mockResolvedValue(localCliStatus);
previewGenerationBackendStatus.mockResolvedValue(localCliStatus);
testGenerationBackend.mockResolvedValue(smokePassed);
});
it('loads saved generation backend status without draft items', async () => {
render(<GenerationBackendStatusPanel items={[]} maskToken="******" />);
await waitFor(() => {
expect(getGenerationBackendStatus).toHaveBeenCalledTimes(1);
});
expect(previewGenerationBackendStatus).not.toHaveBeenCalled();
expect(await screen.findByText('codex_cli')).toBeInTheDocument();
expect(screen.getByText('仅生成')).toBeInTheDocument();
expect(screen.getByText(/本地 CLI 只用于报告和文本生成/)).toBeInTheDocument();
});
it('previews unsaved draft generation backend status', async () => {
render(
<GenerationBackendStatusPanel
items={[{ key: 'GENERATION_BACKEND', value: 'opencode_cli' }]}
maskToken="******"
/>,
);
await waitFor(() => {
expect(previewGenerationBackendStatus).toHaveBeenCalledWith({
items: [{ key: 'GENERATION_BACKEND', value: 'opencode_cli' }],
maskToken: '******',
});
});
expect(getGenerationBackendStatus).not.toHaveBeenCalled();
});
it('runs JSON smoke test with current draft items', async () => {
render(
<GenerationBackendStatusPanel
items={[{ key: 'GENERATION_BACKEND', value: 'codex_cli' }]}
maskToken="******"
/>,
);
fireEvent.click(await screen.findByRole('button', { name: /JSON 冒烟测试/ }));
await waitFor(() => {
expect(testGenerationBackend).toHaveBeenCalledWith({
mode: 'json',
items: [{ key: 'GENERATION_BACKEND', value: 'codex_cli' }],
maskToken: '******',
});
});
expect(await screen.findByText('冒烟测试通过')).toBeInTheDocument();
});
it('clears stale smoke result when draft items change', async () => {
const { rerender } = render(
<GenerationBackendStatusPanel
items={[{ key: 'GENERATION_BACKEND', value: 'codex_cli' }]}
maskToken="******"
/>,
);
fireEvent.click(await screen.findByRole('button', { name: /JSON 冒烟测试/ }));
expect(await screen.findByText('冒烟测试通过')).toBeInTheDocument();
rerender(
<GenerationBackendStatusPanel
items={[{ key: 'GENERATION_BACKEND', value: 'opencode_cli' }]}
maskToken="******"
/>,
);
await waitFor(() => {
expect(screen.queryByText('冒烟测试通过')).not.toBeInTheDocument();
});
});
it('ignores stale generation backend preview responses', async () => {
const firstPreview = deferred<GenerationBackendStatusResponse>();
const secondPreview = deferred<GenerationBackendStatusResponse>();
previewGenerationBackendStatus
.mockReturnValueOnce(firstPreview.promise)
.mockReturnValueOnce(secondPreview.promise);
const { rerender } = render(
<GenerationBackendStatusPanel
items={[{ key: 'GENERATION_BACKEND', value: 'codex_cli' }]}
maskToken="******"
/>,
);
await waitFor(() => expect(previewGenerationBackendStatus).toHaveBeenCalledTimes(1));
rerender(
<GenerationBackendStatusPanel
items={[{ key: 'GENERATION_BACKEND', value: 'litellm' }]}
maskToken="******"
/>,
);
await waitFor(() => expect(previewGenerationBackendStatus).toHaveBeenCalledTimes(2));
await act(async () => {
secondPreview.resolve(litellmStatus);
await secondPreview.promise;
});
expect(await screen.findByText('litellm')).toBeInTheDocument();
await act(async () => {
firstPreview.resolve(localCliStatus);
await firstPreview.promise;
});
await waitFor(() => expect(screen.getByText('litellm')).toBeInTheDocument());
expect(screen.queryByText('codex_cli')).not.toBeInTheDocument();
});
it('clears stale status when preview request fails', async () => {
const { rerender } = render(<GenerationBackendStatusPanel items={[]} maskToken="******" />);
expect(await screen.findByText('codex_cli')).toBeInTheDocument();
previewGenerationBackendStatus.mockRejectedValueOnce(new Error('validation failed'));
rerender(
<GenerationBackendStatusPanel
items={[{ key: 'GENERATION_BACKEND_TIMEOUT_SECONDS', value: 'bad' }]}
maskToken="******"
/>,
);
await waitFor(() => {
expect(screen.queryByText('codex_cli')).not.toBeInTheDocument();
});
});
it('shows smoke status even when initial status has not loaded', async () => {
getGenerationBackendStatus.mockReturnValueOnce(new Promise(() => undefined));
render(<GenerationBackendStatusPanel items={[]} maskToken="******" />);
fireEvent.click(screen.getByRole('button', { name: /JSON 冒烟测试/ }));
expect(await screen.findByText('冒烟测试通过')).toBeInTheDocument();
expect(await screen.findByText('codex_cli')).toBeInTheDocument();
});
it('renders generation backend status labels in English when UI language is English', async () => {
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'en');
render(
<UiLanguageProvider>
<GenerationBackendStatusPanel items={[]} maskToken="******" />
</UiLanguageProvider>,
);
expect(await screen.findByText('Generation backend status')).toBeInTheDocument();
expect(screen.getByText('Primary backend')).toBeInTheDocument();
expect(screen.getByText('Generation only')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /JSON smoke test/ })).toBeInTheDocument();
});
});

View File

@@ -33,6 +33,128 @@ describe('LLMChannelEditor', () => {
return Array.from(select.options).map((option) => option.value);
}
const openAiItems = [
{ key: 'LLM_CHANNELS', value: 'openai' },
{ key: 'LLM_OPENAI_PROTOCOL', value: 'openai' },
{ key: 'LLM_OPENAI_BASE_URL', value: 'https://api.openai.com/v1' },
{ key: 'LLM_OPENAI_ENABLED', value: 'true' },
{ key: 'LLM_OPENAI_API_KEY', value: 'secret-key' },
{ key: 'LLM_OPENAI_MODELS', value: 'gpt-4o-mini' },
{ key: 'LITELLM_MODEL', value: 'openai/gpt-4o-mini' },
];
function lastDraftCall(onDraftItemsChange: ReturnType<typeof vi.fn>) {
const calls = onDraftItemsChange.mock.calls;
return calls[calls.length - 1]?.[0] || [];
}
it('reports an empty generation backend draft when channel settings are unchanged', async () => {
const onDraftItemsChange = vi.fn();
const { rerender } = render(
<LLMChannelEditor
items={openAiItems}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
onDraftItemsChange={onDraftItemsChange}
/>
);
await waitFor(() => expect(onDraftItemsChange).toHaveBeenCalledWith([]));
expect(onDraftItemsChange).toHaveBeenCalledTimes(1);
rerender(
<LLMChannelEditor
items={openAiItems}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
onDraftItemsChange={onDraftItemsChange}
/>
);
expect(onDraftItemsChange).toHaveBeenCalledTimes(1);
});
it('reports unsaved channel edits as generation backend draft items', async () => {
const onDraftItemsChange = vi.fn();
render(
<LLMChannelEditor
items={openAiItems}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
onDraftItemsChange={onDraftItemsChange}
/>
);
fireEvent.click(screen.getByRole('button', { name: /OpenAI 官方/i }));
fireEvent.change(await screen.findByLabelText('Base URL'), {
target: { value: 'https://proxy.example.com/v1' },
});
fireEvent.change(screen.getByLabelText('API Key'), {
target: { value: 'sk-draft' },
});
fireEvent.change(screen.getByLabelText('模型(逗号分隔)'), {
target: { value: 'gpt-4o-mini,gpt-4o' },
});
await waitFor(() => {
const draft = lastDraftCall(onDraftItemsChange);
expect(draft).toContainEqual({ key: 'LLM_OPENAI_BASE_URL', value: 'https://proxy.example.com/v1' });
expect(draft).toContainEqual({ key: 'LLM_OPENAI_API_KEY', value: 'sk-draft' });
expect(draft).toContainEqual({ key: 'LLM_OPENAI_MODELS', value: 'gpt-4o-mini,gpt-4o' });
});
});
it('returns to an empty generation backend draft after channel edits are restored', async () => {
const onDraftItemsChange = vi.fn();
render(
<LLMChannelEditor
items={openAiItems}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
onDraftItemsChange={onDraftItemsChange}
/>
);
fireEvent.click(screen.getByRole('button', { name: /OpenAI 官方/i }));
const baseUrlInput = await screen.findByLabelText('Base URL');
fireEvent.change(baseUrlInput, { target: { value: 'https://proxy.example.com/v1' } });
await waitFor(() => expect(lastDraftCall(onDraftItemsChange)).toContainEqual({
key: 'LLM_OPENAI_BASE_URL',
value: 'https://proxy.example.com/v1',
}));
fireEvent.change(baseUrlInput, { target: { value: 'https://api.openai.com/v1' } });
await waitFor(() => {
expect(lastDraftCall(onDraftItemsChange)).toEqual([]);
});
});
it('does not emit invalid channel env keys while the channel name is empty', async () => {
const onDraftItemsChange = vi.fn();
render(
<LLMChannelEditor
items={openAiItems}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
onDraftItemsChange={onDraftItemsChange}
/>
);
fireEvent.click(screen.getByRole('button', { name: /OpenAI 官方/i }));
fireEvent.change(await screen.findByLabelText('渠道名称'), { target: { value: '' } });
await waitFor(() => {
expect(lastDraftCall(onDraftItemsChange)).toEqual([]);
});
expect(onDraftItemsChange.mock.calls.flatMap((call) => call[0]).some((item) => item.key.startsWith('LLM__'))).toBe(false);
});
it('renders API Key input with controlled visibility', async () => {
render(
<LLMChannelEditor

View File

@@ -10,3 +10,4 @@ export * from './SettingsPanelErrorBoundary';
export * from './SettingsSectionCard';
export * from './SettingsCategoryNav';
export * from './AuthSettingsCard';
export * from './GenerationBackendStatusPanel';

View File

@@ -718,6 +718,27 @@ const zh = {
'settings.intelligentImportLoadConfigFirst': '请先加载配置后再合并',
'settings.intelligentImportConfigUpdated': '配置已更新,请再次点击「合并到自选股」',
'settings.intelligentImportMergeFailed': '合并保存失败',
'settings.generationBackendConcurrency': '并发 {count}',
'settings.generationBackendFallback': '备用后端',
'settings.generationBackendGenerationOnly': '仅生成',
'settings.generationBackendHealthFailed': '检测失败',
'settings.generationBackendHealthPassed': '检测通过',
'settings.generationBackendHealthSkipped': '已跳过',
'settings.generationBackendLiteLLMDescription': '当前后端用于报告生成;问股工具调用仍沿用 LiteLLM Agent 路径。',
'settings.generationBackendLocalCliDescription': '本地 CLI 只用于报告和文本生成,不支持问股工具调用。',
'settings.generationBackendNeedsAction': '需要处理',
'settings.generationBackendPrimary': '主后端',
'settings.generationBackendRefresh': '刷新',
'settings.generationBackendRefreshing': '刷新中',
'settings.generationBackendRunnable': '可尝试运行',
'settings.generationBackendSmokeFailed': '冒烟测试失败',
'settings.generationBackendSmokePassed': '冒烟测试通过',
'settings.generationBackendSmokePassedMessage': '生成后端冒烟测试通过。',
'settings.generationBackendSmokeTest': 'JSON 冒烟测试',
'settings.generationBackendSmokeTesting': '测试中',
'settings.generationBackendStatus': '生成后端状态',
'settings.generationBackendStatusDescription': '快速检查只读取配置,并检查本地 CLI 可执行文件是否可见;要确认真实请求是否能跑通,请运行 JSON 冒烟测试。',
'settings.generationBackendToolsSupported': '工具调用',
'settings.llmAccess': 'AI 模型接入',
'settings.llmAccessDescription': '统一管理模型渠道、基础地址、API Key、主模型与备选模型。',
'settings.notificationSettings': '通知设置',
@@ -1478,6 +1499,27 @@ const en: Record<UiTextKey, string> = {
'settings.intelligentImportLoadConfigFirst': 'Load configuration before merging',
'settings.intelligentImportConfigUpdated': 'Configuration changed. Click "Merge into watchlist" again.',
'settings.intelligentImportMergeFailed': 'Merge save failed',
'settings.generationBackendConcurrency': 'Concurrency {count}',
'settings.generationBackendFallback': 'Fallback backend',
'settings.generationBackendGenerationOnly': 'Generation only',
'settings.generationBackendHealthFailed': 'Check failed',
'settings.generationBackendHealthPassed': 'Check passed',
'settings.generationBackendHealthSkipped': 'Skipped',
'settings.generationBackendLiteLLMDescription': 'This backend is used for report generation. Ask-stock tool calls still use the LiteLLM Agent path.',
'settings.generationBackendLocalCliDescription': 'Local CLI is only used for reports and text generation. It does not support ask-stock tool calls.',
'settings.generationBackendNeedsAction': 'Needs action',
'settings.generationBackendPrimary': 'Primary backend',
'settings.generationBackendRefresh': 'Refresh',
'settings.generationBackendRefreshing': 'Refreshing',
'settings.generationBackendRunnable': 'Ready to try',
'settings.generationBackendSmokeFailed': 'Smoke test failed',
'settings.generationBackendSmokePassed': 'Smoke test passed',
'settings.generationBackendSmokePassedMessage': 'Generation backend smoke test passed.',
'settings.generationBackendSmokeTest': 'JSON smoke test',
'settings.generationBackendSmokeTesting': 'Testing',
'settings.generationBackendStatus': 'Generation backend status',
'settings.generationBackendStatusDescription': 'The quick check only reads configuration and checks whether the local CLI executable is visible. Run the JSON smoke test to verify a real request.',
'settings.generationBackendToolsSupported': 'Tool calls',
'settings.llmAccess': 'AI model access',
'settings.llmAccessDescription': 'Manage model channels, base URLs, API keys, primary models, and fallbacks.',
'settings.notificationSettings': 'Notification settings',

View File

@@ -1,5 +1,5 @@
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CheckCircle2, ChevronDown, CircleAlert, CircleDashed, Clock, Play, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { useAuth, useSystemConfig } from '../hooks';
import { useUiLanguage } from '../contexts/UiLanguageContext';
@@ -11,6 +11,7 @@ import { ApiErrorAlert, Button, ConfirmDialog, EmptyState } from '../components/
import {
AuthSettingsCard,
ChangePasswordCard,
GenerationBackendStatusPanel,
IntelligentImport,
LLMChannelEditor,
NotificationTestPanel,
@@ -85,6 +86,86 @@ type DesktopUpdateNotice = {
actionKind?: 'release' | 'install';
};
const LLM_CHANNEL_EDITOR_RUNTIME_KEYS = new Set([
'LITELLM_MODEL',
'LITELLM_FALLBACK_MODELS',
'AGENT_LITELLM_MODEL',
'VISION_MODEL',
'LLM_TEMPERATURE',
]);
const GENERATION_BACKEND_STATUS_KEYS = new Set([
'GENERATION_BACKEND',
'GENERATION_FALLBACK_BACKEND',
'GENERATION_BACKEND_TIMEOUT_SECONDS',
'GENERATION_BACKEND_MAX_OUTPUT_BYTES',
'GENERATION_BACKEND_MAX_CONCURRENCY',
'LOCAL_CLI_BACKEND_MAX_CONCURRENCY',
'OPENCODE_CLI_MODEL',
'LITELLM_CONFIG',
'LITELLM_MODEL',
'LITELLM_FALLBACK_MODELS',
'GEMINI_API_KEY',
'GEMINI_API_KEYS',
'GEMINI_MODEL',
'GEMINI_MODEL_FALLBACK',
'GEMINI_TEMPERATURE',
'ANTHROPIC_API_KEY',
'ANTHROPIC_API_KEYS',
'ANTHROPIC_MODEL',
'ANTHROPIC_TEMPERATURE',
'ANTHROPIC_MAX_TOKENS',
'OPENAI_API_KEY',
'OPENAI_API_KEYS',
'OPENAI_BASE_URL',
'OPENAI_MODEL',
'OPENAI_VISION_MODEL',
'OPENAI_TEMPERATURE',
'OLLAMA_API_BASE',
'OLLAMA_MODEL',
'DEEPSEEK_API_KEY',
'DEEPSEEK_API_KEYS',
'AIHUBMIX_KEY',
'ANSPIRE_LLM_ENABLED',
'ANSPIRE_LLM_BASE_URL',
'ANSPIRE_LLM_MODEL',
'ANSPIRE_API_KEYS',
]);
const LLM_CHANNEL_STATUS_KEY_PATTERN = /^LLM_[A-Z0-9_]+_(PROTOCOL|BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS|ENABLED)$/;
function isLlmChannelEditorDraftKey(key: string): boolean {
const normalized = key.trim().toUpperCase();
return normalized.startsWith('LLM_') || LLM_CHANNEL_EDITOR_RUNTIME_KEYS.has(normalized);
}
function isGenerationBackendStatusDraftKey(key: string): boolean {
const normalized = key.trim().toUpperCase();
return (
GENERATION_BACKEND_STATUS_KEYS.has(normalized)
|| normalized === 'LLM_CHANNELS'
|| LLM_CHANNEL_STATUS_KEY_PATTERN.test(normalized)
);
}
function mergeGenerationBackendDraftItems(
outerItems: SystemConfigUpdateItem[],
llmChannelItems: SystemConfigUpdateItem[],
): SystemConfigUpdateItem[] {
const merged = new Map<string, SystemConfigUpdateItem>();
for (const item of outerItems) {
const normalizedKey = item.key.trim().toUpperCase();
if (isGenerationBackendStatusDraftKey(normalizedKey)) {
merged.set(normalizedKey, item);
}
}
for (const item of llmChannelItems) {
const normalizedKey = item.key.trim().toUpperCase();
if (isLlmChannelEditorDraftKey(normalizedKey) && isGenerationBackendStatusDraftKey(normalizedKey)) {
merged.set(normalizedKey, item);
}
}
return Array.from(merged.values());
}
const PROMPT_CACHE_ADVANCED_SETTING_KEYS = new Set([
'LLM_PROMPT_CACHE_TELEMETRY_ENABLED',
'LLM_PROMPT_CACHE_HINTS_ENABLED',
@@ -541,17 +622,6 @@ const SchedulerSettingsCard: React.FC<SchedulerSettingsCardProps> = ({
void refreshSchedulerStatus();
}, [hasSchedulerSettings, refreshSchedulerStatus, statusRefreshToken]);
useEffect(() => {
const isRuntimeDerived = isEnabledConfigValue(scheduleEnabledItem?.value) === status?.enabled;
if (!status) {
return;
}
if (scheduleEnabledOverride === null && isRuntimeDerived) {
setScheduleEnabledOverride(null);
}
}, [scheduleEnabledItem?.value, scheduleEnabledOverride, statusRefreshToken]);
useEffect(() => {
if (!onSchedulerStateChange) {
return;
@@ -797,6 +867,7 @@ const SettingsPage: React.FC = () => {
const [isRunningSetupSmoke, setIsRunningSetupSmoke] = useState(false);
const [setupSmokeError, setSetupSmokeError] = useState<ParsedApiError | null>(null);
const [setupSmokeSuccess, setSetupSmokeSuccess] = useState('');
const [llmChannelDraftItems, setLlmChannelDraftItems] = useState<SystemConfigUpdateItem[]>([]);
const envBackupImportRef = useRef<HTMLInputElement | null>(null);
const setupStatusRequestIdRef = useRef(0);
const desktopRuntimeApi = getDesktopRuntimeApi();
@@ -839,6 +910,17 @@ const SettingsPage: React.FC = () => {
} = useSystemConfig();
const currentChangedItems = getChangedItems();
const currentChangedItemsFingerprint = JSON.stringify(currentChangedItems);
const llmChannelDraftItemsFingerprint = JSON.stringify(llmChannelDraftItems);
const generationBackendDraftItems = useMemo(
() => mergeGenerationBackendDraftItems(currentChangedItems, llmChannelDraftItems),
// Fingerprints keep the status panel from refreshing when parent renders do not change draft content.
// eslint-disable-next-line react-hooks/exhaustive-deps
[currentChangedItemsFingerprint, llmChannelDraftItemsFingerprint],
);
const handleLlmChannelDraftItemsChange = useCallback((items: Array<{ key: string; value: string }>) => {
setLlmChannelDraftItems(items);
}, []);
const refreshSetupStatus = useCallback(async () => {
const requestId = setupStatusRequestIdRef.current + 1;
@@ -1664,11 +1746,18 @@ const SettingsPage: React.FC = () => {
title={t('settings.llmAccess')}
description={t('settings.llmAccessDescription')}
>
<GenerationBackendStatusPanel
items={generationBackendDraftItems}
maskToken={maskToken}
disabled={isSaving || isLoading}
/>
<LLMChannelEditor
items={rawActiveItems}
configVersion={configVersion}
maskToken={maskToken}
onDraftItemsChange={handleLlmChannelDraftItemsChange}
onSaved={async (updatedItems) => {
setLlmChannelDraftItems([]);
await refreshAfterExternalSave(updatedItems.map((item) => item.key));
void refreshSetupStatus();
}}

View File

@@ -127,12 +127,24 @@ vi.mock('../../components/settings', () => ({
LLMChannelEditor: ({
items,
onSaved,
onDraftItemsChange,
}: {
items: Array<{ key: string; value: string }>;
onSaved: (items: Array<{ key: string; value: string }>) => void;
onDraftItemsChange?: (items: Array<{ key: string; value: string }>) => void;
}) => (
<div>
<div data-testid="llm-channel-editor-items">{items.map((item) => item.key).join(',')}</div>
<button
type="button"
onClick={() => onDraftItemsChange?.([
{ key: 'LLM_CHANNELS', value: 'draft,backup' },
{ key: 'LITELLM_MODEL', value: 'openai/draft-model' },
{ key: 'GENERATION_BACKEND', value: 'codex_cli' },
])}
>
emit llm draft
</button>
<button
type="button"
onClick={() => onSaved([{ key: 'LLM_CHANNELS', value: 'primary,backup' }])}
@@ -141,6 +153,11 @@ vi.mock('../../components/settings', () => ({
</button>
</div>
),
GenerationBackendStatusPanel: ({ items }: { items: Array<{ key: string; value: string }> }) => (
<div data-testid="generation-backend-status-items">
{items.map((item) => `${item.key}=${item.value}`).join('|')}
</div>
),
NotificationTestPanel: ({ items }: { items: Array<{ key: string; value: string }> }) => (
<div>:{items.map((item) => item.key).join(',')}</div>
),
@@ -712,6 +729,7 @@ describe('SettingsPage', () => {
});
it('allows brief setup smoke when only the Agent channel is incomplete', async () => {
useSystemConfigMock.mockReturnValue(buildSystemConfigState({ activeCategory: 'base' }));
getSetupStatus.mockResolvedValue({
isComplete: false,
readyForSmoke: true,
@@ -1110,6 +1128,52 @@ describe('SettingsPage', () => {
expect(load).toHaveBeenCalledTimes(1);
});
it('passes merged generation backend draft items to the backend status panel', async () => {
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
activeCategory: 'ai_model',
getChangedItems: () => [
{ key: 'GENERATION_BACKEND', value: 'litellm' },
{ key: 'LLM_CHANNELS', value: 'saved' },
{ key: 'OPENAI_MODEL', value: 'gpt-draft' },
{ key: 'GEMINI_MODEL', value: 'gemini-draft' },
{ key: 'OLLAMA_API_BASE', value: 'http://localhost:11434' },
{ key: 'WECHAT_WEBHOOK_URL', value: 'not-a-url' },
],
}));
render(<SettingsPage />);
fireEvent.click(screen.getByRole('button', { name: 'emit llm draft' }));
const statusItems = await screen.findByTestId('generation-backend-status-items');
await waitFor(() => {
expect(statusItems).toHaveTextContent('GENERATION_BACKEND=litellm');
expect(statusItems).toHaveTextContent('LLM_CHANNELS=draft,backup');
expect(statusItems).toHaveTextContent('LITELLM_MODEL=openai/draft-model');
expect(statusItems).toHaveTextContent('OPENAI_MODEL=gpt-draft');
expect(statusItems).toHaveTextContent('GEMINI_MODEL=gemini-draft');
expect(statusItems).toHaveTextContent('OLLAMA_API_BASE=http://localhost:11434');
expect(statusItems).not.toHaveTextContent('GENERATION_BACKEND=codex_cli');
expect(statusItems).not.toHaveTextContent('WECHAT_WEBHOOK_URL=not-a-url');
});
});
it('clears llm channel draft items after llm channel editor saves', async () => {
useSystemConfigMock.mockReturnValue(buildSystemConfigState({ activeCategory: 'ai_model' }));
render(<SettingsPage />);
fireEvent.click(screen.getByRole('button', { name: 'emit llm draft' }));
expect(await screen.findByTestId('generation-backend-status-items')).toHaveTextContent('LLM_CHANNELS=draft,backup');
fireEvent.click(screen.getByRole('button', { name: 'save llm channels' }));
await waitFor(() => {
expect(screen.getByTestId('generation-backend-status-items')).not.toHaveTextContent('LLM_CHANNELS=draft,backup');
});
expect(refreshAfterExternalSave).toHaveBeenCalledWith(['LLM_CHANNELS']);
});
it('keeps prompt cache settings collapsed and expandable at the bottom of AI model settings', () => {
const aiField = (key: string, displayOrder: number, value = '') => ({
key,
@@ -1826,11 +1890,11 @@ describe('SettingsPage', () => {
fireEvent.click(enabledCheckbox);
expect(setDraftValue).toHaveBeenCalledWith('SCHEDULE_ENABLED', 'false');
await waitFor(() => expect(enabledCheckbox).not.toBeChecked());
await waitFor(() => expect(screen.getByTestId('scheduler-enabled-checkbox')).not.toBeChecked());
const refreshButton = screen.getByTestId('scheduler-refresh-status-button');
fireEvent.click(refreshButton);
await waitFor(() => expect(enabledCheckbox).not.toBeChecked());
await waitFor(() => expect(screen.getByTestId('scheduler-enabled-checkbox')).not.toBeChecked());
});
it('can reconcile runtime scheduler state when runtime is enabled but saved value is disabled', async () => {

View File

@@ -102,6 +102,35 @@ export interface SetupStatusResponse {
checks: SetupStatusCheck[];
}
export type GenerationBackendHealthStatus = 'not_tested' | 'passed' | 'failed' | 'skipped';
export type GenerationBackendSmokeMode = 'text' | 'json';
export interface GenerationBackendStatus {
backendId: string;
backendType: 'litellm' | 'local_cli';
providerId: string;
available: boolean;
healthStatus: GenerationBackendHealthStatus;
supportsJson: boolean;
supportsTools: boolean;
supportsStream: boolean;
supportsVision: boolean;
isPrimary: boolean;
fallbackTarget?: string | null;
maxConcurrency: number;
usageAvailable: boolean;
lastErrorCode?: string | null;
lastErrorMessage?: string | null;
}
export interface GenerationBackendStatusResponse {
primaryBackendId: string;
fallbackBackendId?: string | null;
primary: GenerationBackendStatus;
fallback?: GenerationBackendStatus | null;
backends: GenerationBackendStatus[];
}
export interface ExportSystemConfigResponse {
content: string;
configVersion: string;
@@ -113,6 +142,26 @@ export interface SystemConfigUpdateItem {
value: string;
}
export interface GenerationBackendStatusPreviewRequest {
items?: SystemConfigUpdateItem[];
maskToken?: string;
}
export interface TestGenerationBackendRequest {
backendId?: string | null;
mode?: GenerationBackendSmokeMode;
items?: SystemConfigUpdateItem[];
maskToken?: string;
timeoutSeconds?: number | null;
}
export interface TestGenerationBackendResponse {
success: boolean;
mode: GenerationBackendSmokeMode;
message: string;
status: GenerationBackendStatus;
}
export interface UpdateSystemConfigRequest {
configVersion: string;
maskToken?: string;

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { UI_TEXT } from '../src/i18n/uiText';
import { getSettingsHelpContent } from '../src/locales/settingsHelp';
import { getFieldDescriptionZh, getFieldOptionLabelZh, getFieldTitleZh } from '../src/utils/systemConfigI18n';
@@ -328,6 +329,25 @@ describe('generation backend settings help contract', () => {
});
});
describe('generation backend status panel i18n contract', () => {
it('keeps the new status panel copy localized in both UI languages', () => {
expect(UI_TEXT.zh['settings.generationBackendStatus']).toBe('生成后端状态');
expect(UI_TEXT.zh['settings.generationBackendSmokeTest']).toBe('JSON 冒烟测试');
expect(UI_TEXT.zh['settings.generationBackendPrimary']).toBe('主后端');
expect(UI_TEXT.zh['settings.generationBackendFallback']).toBe('备用后端');
expect(UI_TEXT.zh['settings.generationBackendGenerationOnly']).toBe('仅生成');
expect(UI_TEXT.zh['settings.generationBackendStatusDescription']).toContain('快速检查');
expect(UI_TEXT.zh['settings.generationBackendStatusDescription']).not.toContain('cheap check');
expect(UI_TEXT.zh['settings.generationBackendSmokePassed']).not.toContain('Smoke test');
expect(UI_TEXT.en['settings.generationBackendStatus']).toBe('Generation backend status');
expect(UI_TEXT.en['settings.generationBackendSmokeTest']).toBe('JSON smoke test');
expect(UI_TEXT.en['settings.generationBackendPrimary']).toBe('Primary backend');
expect(UI_TEXT.en['settings.generationBackendFallback']).toBe('Fallback backend');
expect(UI_TEXT.en['settings.generationBackendGenerationOnly']).toBe('Generation only');
});
});
describe('decision signal settings guard', () => {
it('does not add placeholder DecisionSignal setting translations without a real schema field', () => {
const placeholderKeys = [

View File

@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 修复 Web 设置页定时任务“立即执行一次”后台线程未传 `stock_codes` 导致任务崩溃的问题。
- [新功能] #1743 Phase 4 新增 `claude_code_cli` generation-only 本地 CLI backend保留 LiteLLM 默认路径、Agent 工具调用边界、per-preset extractor、最小 env allowlist 与结构化错误。
- [新功能] #1743 Phase 4 新增 `opencode_cli` generation-only 本地 CLI backend使用 OpenCode `run --format json --file` prompt-file 路径、JSON event extractor、Agent 边界和 provider credential 不接管约束。
- [改进] #1743 Phase 5 增加生成后端状态、预览和冒烟测试 API 以及 Web 状态面板,区分轻量检查与 JSON 冒烟测试,并保持本地 CLI “仅生成、不支持问股工具调用”的边界。
- [修复] #1743 Phase 4 修正 `opencode_cli` 静态指令,避免全局 JSON-only 约束影响 `generate_text()` 与大盘复盘自由文本输出。
- [文档] #1743 Phase 4 同步本地 CLI backend 隐私/部署边界local CLI 不是离线模型Docker/CI/远端需自行安装登录DSA 不读取 Claude/OpenCode credential 文件。
- [新功能] 台股报告接入三大法人tw 个股分析报告的 institution 区块改为展示 TWSE T86 / TPEx 三大法人原始买卖超净额(外资/投信/自营/合计,单位:股tw-only、严格 additiveA股/港股/美股/日韩股 offshore 流程字节不变、fail-open取不到数据维持 not_supported绝不中断分析不接 Web、不派生 capital_flow_signal、不改评分权重或 schema。

View File

@@ -53,6 +53,8 @@ AGENT_GENERATION_BACKEND=auto
- 本地 CLI 执行上限有硬边界:`GENERATION_BACKEND_TIMEOUT_SECONDS` 最大 `3600``GENERATION_BACKEND_MAX_OUTPUT_BYTES` 最大 `33554432``GENERATION_BACKEND_MAX_CONCURRENCY` 最大 `16``LOCAL_CLI_BACKEND_MAX_CONCURRENCY` 最大 `4`。诊断 stdout/stderr 与最终响应合计超过输出上限时会返回结构化 `output_too_large`;对 `--output-last-message` presetstdout 中重复打印的最终响应不会重复计入,也不会作为 `stdout_preview` 暴露。
- 本地 CLI 默认并发为 1有效并发为 `min(LOCAL_CLI_BACKEND_MAX_CONCURRENCY, GENERATION_BACKEND_MAX_CONCURRENCY)`,不继承 `MAX_WORKERS`
- `AGENT_GENERATION_BACKEND=auto` 不会继承 `GENERATION_BACKEND` 的 local CLI 值Agent 工具调用继续使用 LiteLLM。Web 设置页仅暴露 `auto|litellm`;手写 `AGENT_GENERATION_BACKEND=codex_cli|claude_code_cli|opencode_cli` 不实现 text-only Agent mode会返回明确 unsupported tool-calling 诊断。
- Web 设置页的生成后端快速检查只读取已保存的 `.env`、运行时兜底值和未保存草稿;它不会写配置、重载运行时,也不会发起真实模型请求。`available` 只表示当前配置具备尝试运行的条件。JSON 冒烟测试是单独的显式操作,会使用服务端固定的 JSON 提示词和 schema 发起一次真实的生成后端请求用于验证提取器、JSON 契约、超时、输出限制和 usage-unavailable 语义。
- `GET /api/v1/system/config/generation-backends/status` 只读取已保存配置;未保存草稿需调用 `POST /api/v1/system/config/generation-backends/status/preview``POST /api/v1/system/config/generation-backends/smoke-test`。被遮罩的密钥字段会继续沿用已保存值。`health_status``last_error_code/message` 只代表本次计算结果,不是历史持久健康状态。
### Local CLI 本地 backend 隐私与边界

View File

@@ -46,6 +46,8 @@ AGENT_GENERATION_BACKEND=auto
- Local CLI execution has hard caps: `GENERATION_BACKEND_TIMEOUT_SECONDS` max `3600`, `GENERATION_BACKEND_MAX_OUTPUT_BYTES` max `33554432`, `GENERATION_BACKEND_MAX_CONCURRENCY` max `16`, and `LOCAL_CLI_BACKEND_MAX_CONCURRENCY` max `4`. Diagnostic stdout/stderr plus the final response are counted together; for `--output-last-message` presets, the final response duplicated to stdout is not counted twice and is not exposed in `stdout_preview`.
- Local CLI default concurrency is 1. Effective local CLI concurrency is `min(LOCAL_CLI_BACKEND_MAX_CONCURRENCY, GENERATION_BACKEND_MAX_CONCURRENCY)` and does not inherit `MAX_WORKERS`.
- `AGENT_GENERATION_BACKEND=auto` does not inherit local CLI values from `GENERATION_BACKEND`; Agent tool calling remains on LiteLLM. The Web settings page only exposes `auto|litellm`; a hand-written `AGENT_GENERATION_BACKEND=codex_cli|claude_code_cli|opencode_cli` does not enable Agent text-only mode and returns an explicit unsupported tool-calling diagnostic.
- The Web settings generation-backend quick check only reads saved `.env`, runtime defaults, and unsaved drafts. It does not write config, reload runtime, or send a real model request; `available` only means the current config can be attempted. JSON smoke test is a separate explicit button that sends one real generation-backend request with a server-owned fixed JSON prompt/schema to verify extractor behavior, JSON contract, timeout, output limits, and usage-unavailable semantics.
- `GET /api/v1/system/config/generation-backends/status` only reads saved config. Unsaved drafts use `POST /api/v1/system/config/generation-backends/status/preview` or `POST /api/v1/system/config/generation-backends/smoke-test`; masked secrets preserve saved values. `health_status` and `last_error_code/message` describe only the current computation, not persisted historical health.
### Local CLI Privacy And Boundaries

View File

@@ -268,6 +268,8 @@ daily_stock_analysis/
> GitHub Actions 说明:仓库自带 `00-daily-analysis.yml` 在 `GENERATION_FALLBACK_BACKEND` 未配置时显式使用 `litellm`,避免未设置的 Secret/Variable 被导出为空值并意外禁用 backend fallback。若要在 Actions 中禁用 backend fallback请将 fallback 设为 primary backend让 resolver 走 self no-op。
> 生成后端状态说明Web 设置页的快速检查只读取已保存配置、未保存草稿,并检查本地 CLI 可执行文件是否可见不发起真实模型请求JSON 冒烟测试是单独的显式操作,会使用服务端固定的 JSON 提示词和 schema 发起一次真实请求。`health_status` 与 `last_error_code/message` 只表示本次状态计算或冒烟测试结果,不是历史持久健康状态。
> *注:`ANSPIRE_API_KEYS`、`AIHUBMIX_KEY`、`GEMINI_API_KEY`、`ANTHROPIC_API_KEY`、`OPENAI_API_KEY` 或 `OLLAMA_API_BASE` 至少配置一个。`ANSPIRE_API_KEYS` 与 `AIHUBMIX_KEY` 无需配置 `OPENAI_BASE_URL`,系统自动适配。
> 问股 single-agent 路径会在后台为 DeepSeek V4 thinking + tool-call 保存最近 3 条 provider trace并按原时序回放 `reasoning_content` / tool 结果;该能力不新增配置项,不进入 Web 历史 APIClaude extended thinking 仅覆盖离线 plumbingmulti-agent trace 注入留作后续增强。

View File

@@ -227,6 +227,8 @@ Default schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.
> GitHub Actions note: the bundled `00-daily-analysis.yml` explicitly uses `litellm` when `GENERATION_FALLBACK_BACKEND` is not configured, so an unset Secret/Variable is not exported as an empty value that disables backend fallback. To disable backend fallback in Actions, set the fallback to the primary backend and let the resolver treat it as self no-op.
> Generation backend status note: the Web settings quick check only reads config, drafts, and executable visibility; it does not send a real model request. JSON smoke test is a separate explicit action that sends one real request with a server-owned fixed JSON prompt/schema. `health_status` and `last_error_code/message` describe only the current status computation or smoke result, not persisted historical health.
> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.
### Notification Channel Configuration

View File

@@ -22,6 +22,8 @@
Generation backend 配置是更外层的运行时选择契约。Phase 4 支持 `GENERATION_BACKEND=litellm|codex_cli|claude_code_cli|opencode_cli`,但本地 CLI backend 不是 LiteLLM provider不要配置成 `LITELLM_MODEL=codex_cli/...``LITELLM_MODEL=claude_code_cli/...``LITELLM_MODEL=opencode_cli/...``codex_cli` preset 使用 `codex exec --output-last-message <temp-file> -` 读取最终响应;`claude_code_cli` preset 使用 `claude --safe-mode --tools "" --disallowedTools "mcp__*" --strict-mcp-config --no-session-persistence --output-format json -p <static instruction>`,完整 DSA prompt 走 stdin并只从 JSON envelope 的 `result/success` 字段提取最终文本,参数依据见 [Claude Code CLI reference](https://code.claude.com/docs/en/cli-reference)`opencode_cli` preset 使用 `opencode --pure run --format json [--model <OPENCODE_CLI_MODEL>] <static instruction> --file <temp prompt file>`,仅在显式配置 `OPENCODE_CLI_MODEL` 时追加 `--model`,完整 DSA prompt 走权限受控的临时文件,并只从无工具事件的 JSON event text 输出提取最终文本,参数依据见 [OpenCode CLI reference](https://opencode.ai/docs/cli),配置合并语义见 [OpenCode config reference](https://opencode.ai/docs/config)。诊断 stdout/stderr 与最终响应一起受 `GENERATION_BACKEND_MAX_OUTPUT_BYTES` 总上限约束,超限时返回结构化 `output_too_large``GENERATION_FALLBACK_BACKEND=` 空值会在本地 `.env` 禁用 backend-level fallback未配置时默认回退到 `litellm`;默认 GitHub Actions workflow 未配置该变量时会显式使用 `litellm`,如需禁用 fallback 可设为 primary backend 走 self no-op。Agent 工具调用仍使用 LiteLLMWeb 设置页只暴露 `AGENT_GENERATION_BACKEND=auto|litellm`,手写 `codex_cli|claude_code_cli|opencode_cli` 不会启用 text-only Agent mode只会返回明确 unsupported tool-calling 诊断。
生成后端状态接口与 Web 面板会把轻量检查和冒烟测试分开展示:快速检查只读取已保存 `.env`、运行时兜底值和当前草稿,不写配置、不重载运行时,也不发起真实模型请求;只有 JSON 冒烟测试会使用固定的 JSON 提示词和 schema 发起真实请求。`health_status``last_error_code/message` 是本次计算结果,不表示历史最后错误。本地 CLI preset 的 `supports_tools=false` 仅表示不支持 DSA Agent 工具调用链路,不代表普通文本生成不可用。
本 PR smoke 验证版本为 `claude 2.1.177 (Claude Code)``opencode 1.17.11`,不声明更宽最低版本。如果用户安装的 CLI 不支持这些固定 preset 参数或非交互输出契约DSA 会返回结构化 `capability_unsupported``cli_contract_unsupported``invalid_json``schema_validation_failed` 或对应 backend error并在配置 backend fallback 时回退到 `litellm`
本地 CLI Backend 不等于离线模型。Docker、云服务器和 CI 不天然拥有本机 CLI 登录态macOS 从 Finder/Dock 启动桌面端时不继承 shell PATH打包桌面端会在启动后端时补入常见 Homebrew 路径,如果设置检查仍提示找不到 CLI 可执行文件,需要完全退出并重开 DSA。DSA 不读取 Codex/Claude/OpenCode credential 文件,也不为 OpenCode 生成或搬运 provider API key子进程可能按 CLI 自身机制使用本机登录态或配置,股票代码、新闻、持仓上下文、分析 prompt 和报告草稿可能被对应 CLI 背后的服务处理。DSA 默认只继承最小运行环境,并拒绝通配继承 `CLAUDE_*``ANTHROPIC_*``OPENCODE_*`、provider API key/token/base-url/model env 和 webhook tokens降低父进程配置泄漏风险`CODEX_HOME` 仅作为既有 Codex CLI 登录目录兼容的 exact-name 例外保留。

View File

@@ -2642,6 +2642,31 @@ class GeminiAnalyzer:
return str(exc)
return sanitize_hermes_error_text(exc, redaction_values=redactions)
def _litellm_redaction_values_for_model(self, config: Config, model: str = "") -> set[str]:
redactions = self._hermes_redaction_values_for_model(config, model)
try:
redactions.update(build_hermes_redaction_values(*get_api_keys_for_model(model, config)))
except Exception:
pass
origins = route_deployment_origins(getattr(config, "llm_model_list", []) or [], model)
for deployment in (*origins.hermes_deployments, *origins.non_hermes_deployments):
params = deployment.get("litellm_params") if isinstance(deployment, dict) else None
if isinstance(params, dict):
redactions.update(build_hermes_redaction_values(params.get("api_key")))
return redactions
def _sanitize_litellm_exception_text(
self,
exc: Any,
*,
config: Optional[Config] = None,
model: str = "",
) -> str:
runtime_config = config or self._get_runtime_config()
redactions = self._litellm_redaction_values_for_model(runtime_config, model)
sanitized = sanitize_hermes_error_text(exc, redaction_values=redactions)
return redact_diagnostic_text(sanitized, limit=500)
def _dispatch_litellm_completion(
self,
model: str,
@@ -3012,6 +3037,7 @@ class GeminiAnalyzer:
or 8192
)
requested_temperature = generation_config.get('temperature', 0.7)
requested_timeout = generation_config.get("timeout")
models_to_try = [config.litellm_model] + (config.litellm_fallback_models or [])
models_to_try = [m for m in models_to_try if m]
@@ -3067,6 +3093,8 @@ class GeminiAnalyzer:
],
"max_tokens": max_tokens,
}
if requested_timeout not in (None, ""):
call_kwargs["timeout"] = requested_timeout
if extra:
call_kwargs["extra_body"] = extra
uses_router = (
@@ -3099,6 +3127,8 @@ class GeminiAnalyzer:
)
hint_result = apply_prompt_cache_hints(call_kwargs, route_context, config)
call_kwargs = hint_result.call_kwargs
if requested_timeout not in (None, ""):
call_kwargs["timeout"] = requested_timeout
if hint_result.diagnostics:
logger.debug("[PromptCache] %s", hint_result.diagnostics)
@@ -3129,24 +3159,26 @@ class GeminiAnalyzer:
progress_callback=stream_progress_callback,
)
except _LiteLLMStreamError as exc:
safe_error = self._sanitize_litellm_exception_text(exc, config=config, model=model)
if exc.partial_received:
logger.warning(
"[LiteLLM] %s stream failed after partial output, retrying non-stream for same model: %s",
model,
exc,
safe_error,
)
else:
logger.warning(
"[LiteLLM] %s stream unavailable before first chunk, falling back to non-stream: %s",
model,
exc,
safe_error,
)
last_error = exc
last_error = RuntimeError(f"{type(exc).__name__}: {safe_error}")
except Exception as exc:
safe_error = self._sanitize_litellm_exception_text(exc, config=config, model=model)
logger.warning(
"[LiteLLM] %s stream request failed before first chunk, falling back to non-stream: %s",
model,
exc,
safe_error,
)
if _stream_text is not None:
@@ -3192,9 +3224,9 @@ class GeminiAnalyzer:
raise ValueError("LLM returned empty response")
except Exception as e:
safe_error = self._sanitize_hermes_exception_text(e, config=config, model=model)
safe_error = self._sanitize_litellm_exception_text(e, config=config, model=model)
logger.warning("[LiteLLM] %s failed: %s", model, safe_error)
last_error = RuntimeError(safe_error) if safe_error != str(e) else e
last_error = RuntimeError(f"{type(e).__name__}: {safe_error}")
continue
raise _AllModelsFailedError(

View File

@@ -0,0 +1,909 @@
# -*- coding: utf-8 -*-
"""Read-only diagnostics for configured generation backends."""
from __future__ import annotations
import json
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
from src.analyzer import GeminiAnalyzer
from src.config import (
ANSPIRE_LLM_BASE_URL_DEFAULT,
ANSPIRE_LLM_MODEL_DEFAULT,
Config,
_get_litellm_provider,
_uses_direct_env_provider,
channel_allows_empty_api_key,
get_configured_llm_models,
normalize_llm_channel_model,
parse_env_bool,
resolve_llm_channel_protocol,
)
from src.llm.backend_registry import (
LOCAL_CLI_GENERATION_BACKEND_IDS,
LITELLM_BACKEND_ID,
SUPPORTED_GENERATION_BACKENDS,
normalize_backend_id,
resolve_generation_backend_id,
resolve_generation_fallback_backend_id,
)
from src.llm.generation_backend import GenerationCapabilities, GenerationError, GenerationErrorCode
from src.llm.hermes import (
HERMES_DEFAULT_BASE_URL,
HERMES_DEFAULT_MODEL,
HERMES_DEFAULT_PROTOCOL,
is_reserved_hermes_name,
parse_hermes_channel,
)
from src.llm.local_cli_backend import (
DEFAULT_GENERATION_BACKEND_MAX_CONCURRENCY,
DEFAULT_LOCAL_CLI_BACKEND_MAX_CONCURRENCY,
DEFAULT_LOCAL_CLI_MAX_OUTPUT_BYTES,
DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS,
MAX_GENERATION_BACKEND_MAX_CONCURRENCY,
MAX_LOCAL_CLI_BACKEND_MAX_CONCURRENCY,
MAX_LOCAL_CLI_OUTPUT_BYTES,
MAX_LOCAL_CLI_TIMEOUT_SECONDS,
LocalCliGenerationBackend,
effective_local_cli_concurrency,
redact_diagnostic_text,
resolve_local_cli_preset,
)
HealthStatus = str
@dataclass(frozen=True)
class _SmokeRequest:
backend_id: str
mode: str
timeout_seconds: int
@dataclass(frozen=True)
class _NumericConfigSpec:
key: str
default: int
minimum: int
maximum: int
_GENERATION_BACKEND_MAX_CONCURRENCY_SPEC = _NumericConfigSpec(
"GENERATION_BACKEND_MAX_CONCURRENCY",
DEFAULT_GENERATION_BACKEND_MAX_CONCURRENCY,
1,
MAX_GENERATION_BACKEND_MAX_CONCURRENCY,
)
_LOCAL_CLI_NUMERIC_SPECS = (
_NumericConfigSpec(
"GENERATION_BACKEND_TIMEOUT_SECONDS",
DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS,
1,
MAX_LOCAL_CLI_TIMEOUT_SECONDS,
),
_NumericConfigSpec(
"GENERATION_BACKEND_MAX_OUTPUT_BYTES",
DEFAULT_LOCAL_CLI_MAX_OUTPUT_BYTES,
1,
MAX_LOCAL_CLI_OUTPUT_BYTES,
),
_GENERATION_BACKEND_MAX_CONCURRENCY_SPEC,
_NumericConfigSpec(
"LOCAL_CLI_BACKEND_MAX_CONCURRENCY",
DEFAULT_LOCAL_CLI_BACKEND_MAX_CONCURRENCY,
1,
MAX_LOCAL_CLI_BACKEND_MAX_CONCURRENCY,
),
)
_LITELLM_NUMERIC_SPECS = (_GENERATION_BACKEND_MAX_CONCURRENCY_SPEC,)
def _as_error_code(value: Any) -> Optional[str]:
if isinstance(value, GenerationErrorCode):
return value.value
if value is None:
return None
return str(value)
def _numeric_config_error(*, backend_id: str, spec: _NumericConfigSpec, value: Any, reason: str) -> GenerationError:
return GenerationError(
error_code=GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
backend=backend_id,
details={
"field": spec.key,
"reason": reason,
"minimum": spec.minimum,
"maximum": spec.maximum,
"actual": "" if value is None else str(value),
},
)
def _parse_int_config_value(value: Any, spec: _NumericConfigSpec) -> int:
raw_value = "" if value is None else str(value).strip()
if not raw_value:
return spec.default
try:
parsed = int(raw_value)
except (TypeError, ValueError):
return spec.default
if parsed < spec.minimum or parsed > spec.maximum:
return spec.default
return parsed
def _validate_int_config_value(*, backend_id: str, value: Any, spec: _NumericConfigSpec) -> Optional[GenerationError]:
raw_value = "" if value is None else str(value).strip()
if not raw_value:
return None
try:
parsed = int(raw_value)
except (TypeError, ValueError):
return _numeric_config_error(backend_id=backend_id, spec=spec, value=value, reason="invalid_integer")
if parsed < spec.minimum or parsed > spec.maximum:
return _numeric_config_error(backend_id=backend_id, spec=spec, value=value, reason="out_of_range")
return None
def _parse_smoke_timeout(value: Optional[float], *, backend_id: str) -> int:
spec = _NumericConfigSpec(
"timeout_seconds",
DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS,
1,
MAX_LOCAL_CLI_TIMEOUT_SECONDS,
)
if value is None:
return spec.default
if isinstance(value, float) and not value.is_integer():
raise _numeric_config_error(backend_id=backend_id, spec=spec, value=value, reason="invalid_integer")
error = _validate_int_config_value(backend_id=backend_id, value=value, spec=spec)
if error is not None:
raise error
return int(value)
class GenerationBackendStatusService:
"""Build current generation backend status without persisting state."""
_TEXT_SMOKE_PROMPT = "Reply exactly: DSA_GENERATION_BACKEND_SMOKE_OK"
_JSON_SMOKE_PROMPT = (
"Return only a JSON object with exactly these keys and values: "
'{"ok": true, "backend_smoke": "passed"}.'
)
def __init__(
self,
*,
effective_map: Dict[str, str],
validation_issues: Optional[List[Dict[str, Any]]] = None,
analyzer_factory: Optional[Callable[[Config], GeminiAnalyzer]] = None,
) -> None:
self._effective_map = {str(k).upper(): "" if v is None else str(v) for k, v in effective_map.items()}
self._validation_issues = list(validation_issues or [])
self._analyzer_factory = analyzer_factory or (lambda config: GeminiAnalyzer(config=config))
def get_status(self) -> Dict[str, Any]:
config = self._build_backend_config()
try:
primary_id = resolve_generation_backend_id(config)
except GenerationError as exc:
primary_id = str(exc.details.get("requested_backend") or exc.backend or "")
primary = self._status_for_error(
backend_id=primary_id or "unknown",
is_primary=True,
fallback_target=None,
error=exc,
)
return {
"primary_backend_id": primary["backend_id"],
"fallback_backend_id": None,
"primary": primary,
"fallback": None,
"backends": [primary],
}
fallback_error: Optional[GenerationError] = None
try:
fallback_id = resolve_generation_fallback_backend_id(config)
except GenerationError as exc:
fallback_id = str(
exc.details.get("requested_backend")
or exc.backend
or getattr(config, "generation_fallback_backend", "")
or "unknown"
)
fallback_error = exc
primary = self._build_status(
backend_id=primary_id,
is_primary=True,
fallback_target=fallback_id,
health_status="not_tested",
)
if fallback_error is not None:
fallback = self._status_for_error(
backend_id=fallback_id,
is_primary=False,
fallback_target=None,
error=fallback_error,
)
elif fallback_id:
fallback = self._build_status(
backend_id=fallback_id,
is_primary=False,
fallback_target=None,
health_status="not_tested",
)
else:
fallback = None
backends = [primary]
if fallback is not None:
backends.append(fallback)
return {
"primary_backend_id": primary_id,
"fallback_backend_id": fallback_id,
"primary": primary,
"fallback": fallback,
"backends": backends,
}
def smoke_test(
self,
*,
backend_id: Optional[str] = None,
mode: str = "json",
timeout_seconds: Optional[float] = None,
) -> Dict[str, Any]:
request: Optional[_SmokeRequest] = None
try:
request = self._normalize_smoke_request(
backend_id=backend_id,
mode=mode,
timeout_seconds=timeout_seconds,
)
self._run_smoke(request)
except GenerationError as exc:
failed_backend_id = str(
exc.details.get("requested_backend")
or exc.backend
or backend_id
or self._primary_backend_id()
or "unknown"
)
normalized_mode = str(mode or "json").strip().lower() or "json"
if normalized_mode not in {"text", "json"}:
normalized_mode = "json"
status = self._build_status(
backend_id=failed_backend_id,
is_primary=failed_backend_id == self._primary_backend_id(),
fallback_target=None,
health_status="failed",
error=exc,
)
return {
"success": False,
"mode": normalized_mode,
"message": exc.message,
"status": status,
}
except Exception as exc:
failed_backend_id = request.backend_id if request is not None else str(
backend_id or self._primary_backend_id() or "unknown"
)
normalized_mode = request.mode if request is not None else str(mode or "json").strip().lower() or "json"
if normalized_mode not in {"text", "json"}:
normalized_mode = "json"
error = GenerationError(
error_code=GenerationErrorCode.UNKNOWN_BACKEND_ERROR,
stage="smoke_test",
retryable=False,
fallbackable=False,
backend=failed_backend_id,
details={"reason": type(exc).__name__},
)
status = self._build_status(
backend_id=failed_backend_id,
is_primary=failed_backend_id == self._primary_backend_id(),
fallback_target=None,
health_status="failed",
error=error,
)
return {
"success": False,
"mode": normalized_mode,
"message": redact_diagnostic_text(str(exc) or error.message, limit=500),
"status": status,
}
status = self._build_status(
backend_id=request.backend_id,
is_primary=request.backend_id == self._primary_backend_id(),
fallback_target=None,
health_status="passed",
)
return {
"success": True,
"mode": request.mode,
"message": "生成后端冒烟测试通过",
"status": status,
}
def _primary_backend_id(self) -> str:
return normalize_backend_id(self._effective_map.get("GENERATION_BACKEND"), default=LITELLM_BACKEND_ID)
def _normalize_smoke_request(
self,
*,
backend_id: Optional[str],
mode: str,
timeout_seconds: Optional[float],
) -> _SmokeRequest:
config = self._build_backend_config()
requested_backend = normalize_backend_id(backend_id, default=resolve_generation_backend_id(config))
if requested_backend not in SUPPORTED_GENERATION_BACKENDS:
raise GenerationError(
error_code=GenerationErrorCode.BACKEND_NOT_CONFIGURED,
stage="configuration",
retryable=False,
fallbackable=False,
backend=requested_backend,
details={
"field": "backend_id",
"requested_backend": requested_backend,
"supported_backends": sorted(SUPPORTED_GENERATION_BACKENDS),
},
)
normalized_mode = str(mode or "json").strip().lower() or "json"
if normalized_mode not in {"text", "json"}:
raise GenerationError(
error_code=GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
backend=requested_backend,
details={"field": "mode", "requested_mode": mode, "supported_modes": ["text", "json"]},
)
timeout = _parse_smoke_timeout(timeout_seconds, backend_id=requested_backend)
return _SmokeRequest(backend_id=requested_backend, mode=normalized_mode, timeout_seconds=timeout)
def _run_smoke(self, request: _SmokeRequest) -> None:
config = self._build_config(
self._effective_map,
backend_id=request.backend_id,
timeout_seconds=request.timeout_seconds,
)
preflight_error = self._cheap_check_error(request.backend_id, config)
if preflight_error is not None:
raise preflight_error
analyzer = self._analyzer_factory(config)
prompt = self._JSON_SMOKE_PROMPT if request.mode == "json" else self._TEXT_SMOKE_PROMPT
result = analyzer._get_generation_backend(request.backend_id).generate(
prompt,
{
"max_tokens": 128,
"temperature": 0,
"timeout": request.timeout_seconds,
},
response_validator=self._json_smoke_validator if request.mode == "json" else self._text_smoke_validator,
audit_context={"call_type": "generation_backend_smoke", "backend": request.backend_id},
)
if request.mode == "json":
self._json_smoke_validator(result.text)
else:
self._text_smoke_validator(result.text)
@classmethod
def _json_smoke_validator(cls, text: str) -> None:
try:
payload = json.loads((text or "").strip())
except Exception as exc:
raise GenerationError(
error_code=GenerationErrorCode.INVALID_JSON,
stage="smoke_validation",
retryable=False,
fallbackable=False,
backend="generation_backend",
details={"reason": "invalid_json"},
) from exc
if payload != {"ok": True, "backend_smoke": "passed"}:
raise GenerationError(
error_code=GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
stage="smoke_validation",
retryable=False,
fallbackable=False,
backend="generation_backend",
details={"reason": "unexpected_smoke_payload"},
)
@classmethod
def _text_smoke_validator(cls, text: str) -> None:
if (text or "").strip() != "DSA_GENERATION_BACKEND_SMOKE_OK":
raise GenerationError(
error_code=GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
stage="smoke_validation",
retryable=False,
fallbackable=False,
backend="generation_backend",
details={"reason": "unexpected_smoke_text"},
)
def _build_status(
self,
*,
backend_id: str,
is_primary: bool,
fallback_target: Optional[str],
health_status: HealthStatus,
error: Optional[GenerationError] = None,
) -> Dict[str, Any]:
config = self._build_backend_config()
try:
cheap_error = self._cheap_check_error(backend_id, config)
except GenerationError as exc:
cheap_error = exc
status_error = error or cheap_error
available = cheap_error is None
current_health = health_status
if health_status == "not_tested" and cheap_error is not None:
current_health = "failed"
capabilities = self._capabilities_for_backend(backend_id)
backend_type = "local_cli" if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS else "litellm"
return {
"backend_id": backend_id,
"backend_type": backend_type,
"provider_id": backend_id,
"available": available,
"health_status": current_health,
"supports_json": capabilities.supports_json,
"supports_tools": capabilities.supports_tools,
"supports_stream": capabilities.supports_stream,
"supports_vision": capabilities.supports_vision,
"is_primary": is_primary,
"fallback_target": fallback_target,
"max_concurrency": self._max_concurrency_for_backend(backend_id, config),
"usage_available": backend_id == LITELLM_BACKEND_ID,
"last_error_code": _as_error_code(status_error.error_code) if status_error else None,
"last_error_message": status_error.message if status_error else None,
}
def _status_for_error(
self,
*,
backend_id: str,
is_primary: bool,
fallback_target: Optional[str],
error: GenerationError,
) -> Dict[str, Any]:
return self._build_status(
backend_id=backend_id,
is_primary=is_primary,
fallback_target=fallback_target,
health_status="failed",
error=error,
)
def _cheap_check_error(self, backend_id: str, config: Any) -> Optional[GenerationError]:
numeric_error = self._numeric_config_error_for_backend(backend_id)
if numeric_error is not None:
return numeric_error
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
preset = resolve_local_cli_preset(backend_id)
return LocalCliGenerationBackend(config, preset_id=backend_id, preset=preset).get_config_error()
if backend_id == LITELLM_BACKEND_ID:
validation_error = self._validation_issue_error(backend_id)
if validation_error is not None:
return validation_error
route_error = self._litellm_route_error(config)
if route_error is not None:
return route_error
model = str(getattr(config, "litellm_model", "") or "").strip()
model_list = getattr(config, "llm_model_list", []) or []
has_model_list = bool(model_list)
has_keys = any(
getattr(config, attr, None)
for attr in ("gemini_api_keys", "anthropic_api_keys", "openai_api_keys", "deepseek_api_keys")
)
if model or has_model_list or has_keys:
return None
return GenerationError(
error_code=GenerationErrorCode.BACKEND_NOT_CONFIGURED,
stage="configuration",
retryable=False,
fallbackable=False,
backend=backend_id,
details={"reason": "litellm_model_not_configured"},
)
return GenerationError(
error_code=GenerationErrorCode.BACKEND_NOT_CONFIGURED,
stage="configuration",
retryable=False,
fallbackable=False,
backend=backend_id,
details={"reason": "unsupported_generation_backend"},
)
def _litellm_route_error(self, config: Any) -> Optional[GenerationError]:
model = str(getattr(config, "litellm_model", "") or "").strip()
model_list = getattr(config, "llm_model_list", []) or []
route_models = set(get_configured_llm_models(model_list))
uses_legacy_router = any(str(route).startswith("__legacy_") for route in route_models)
fallback_models = self._split_csv(self._effective_map.get("LITELLM_FALLBACK_MODELS") or "")
if route_models and not uses_legacy_router:
invalid_primary = model and model not in route_models and not _uses_direct_env_provider(model)
if invalid_primary:
return self._litellm_runtime_source_error(
field="LITELLM_MODEL",
model=model,
reason="unknown_model",
)
invalid_fallbacks = [
fallback for fallback in fallback_models
if fallback not in route_models and not _uses_direct_env_provider(fallback)
]
if invalid_fallbacks:
return self._litellm_runtime_source_error(
field="LITELLM_FALLBACK_MODELS",
model=invalid_fallbacks[0],
reason="unknown_model",
)
return None
for field, candidates in (
("LITELLM_MODEL", [model] if model else []),
("LITELLM_FALLBACK_MODELS", fallback_models),
):
for candidate in candidates:
if not self._has_litellm_runtime_source(candidate):
return self._litellm_runtime_source_error(
field=field,
model=candidate,
reason="missing_runtime_source",
)
return None
def _has_litellm_runtime_source(self, model: str) -> bool:
if not model or _uses_direct_env_provider(model):
return True
provider = _get_litellm_provider(model)
if provider in {"gemini", "vertex_ai"}:
return bool(
self._split_csv(
self._effective_map.get("GEMINI_API_KEYS")
or self._effective_map.get("GEMINI_API_KEY")
or ""
)
)
if provider == "anthropic":
return bool(
self._split_csv(
self._effective_map.get("ANTHROPIC_API_KEYS")
or self._effective_map.get("ANTHROPIC_API_KEY")
or ""
)
)
if provider == "deepseek":
return bool(
self._split_csv(
self._effective_map.get("DEEPSEEK_API_KEYS")
or self._effective_map.get("DEEPSEEK_API_KEY")
or ""
)
)
if provider == "openai":
return bool(self._openai_keys_from_map(self._effective_map))
return False
@staticmethod
def _litellm_runtime_source_error(*, field: str, model: str, reason: str) -> GenerationError:
return GenerationError(
error_code=GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
backend=LITELLM_BACKEND_ID,
details={
"field": field,
"reason": reason,
"model": model,
},
)
def _numeric_config_error_for_backend(self, backend_id: str) -> Optional[GenerationError]:
specs = _LOCAL_CLI_NUMERIC_SPECS if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS else _LITELLM_NUMERIC_SPECS
for spec in specs:
error = _validate_int_config_value(
backend_id=backend_id,
value=self._effective_map.get(spec.key),
spec=spec,
)
if error is not None:
return error
return None
def _validation_issue_error(self, backend_id: str) -> Optional[GenerationError]:
if backend_id != LITELLM_BACKEND_ID:
return None
errors = [
issue for issue in self._validation_issues
if str(issue.get("severity", "")).lower() == "error"
]
if not errors:
return None
first = errors[0]
return GenerationError(
error_code=GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
backend=backend_id,
details={
"field": first.get("key") or "generation_backend_config",
"reason": first.get("code") or "validation_failed",
"message": first.get("message") or "",
},
)
@staticmethod
def _capabilities_for_backend(backend_id: str) -> GenerationCapabilities:
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
return LocalCliGenerationBackend.capabilities
return GenerationCapabilities(
supports_json=True,
supports_tools=True,
supports_stream=True,
supports_vision=False,
supports_health_check=False,
supports_smoke_test=True,
)
@staticmethod
def _max_concurrency_for_backend(backend_id: str, config: Any) -> int:
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
return effective_local_cli_concurrency(config)
return _parse_int_config_value(
getattr(config, "generation_backend_max_concurrency", None),
_GENERATION_BACKEND_MAX_CONCURRENCY_SPEC,
)
def _build_backend_config(self) -> SimpleNamespace:
model_list = self._build_litellm_model_list(self._effective_map)
route_models = get_configured_llm_models(model_list)
litellm_model = (self._effective_map.get("LITELLM_MODEL") or "").strip()
uses_legacy_router = bool(model_list) and all(
str(entry.get("model_name") or "").startswith("__legacy_")
for entry in model_list
if isinstance(entry, dict)
)
if not litellm_model and route_models and not uses_legacy_router:
litellm_model = route_models[0]
if not litellm_model and uses_legacy_router:
litellm_model = self._infer_legacy_litellm_model(self._effective_map)
return SimpleNamespace(
generation_backend=normalize_backend_id(
self._effective_map.get("GENERATION_BACKEND"),
default=LITELLM_BACKEND_ID,
),
generation_fallback_backend=self._fallback_from_map(),
generation_backend_timeout_seconds=_parse_int_config_value(
self._effective_map.get("GENERATION_BACKEND_TIMEOUT_SECONDS"),
_LOCAL_CLI_NUMERIC_SPECS[0],
),
generation_backend_max_output_bytes=_parse_int_config_value(
self._effective_map.get("GENERATION_BACKEND_MAX_OUTPUT_BYTES"),
_LOCAL_CLI_NUMERIC_SPECS[1],
),
generation_backend_max_concurrency=_parse_int_config_value(
self._effective_map.get("GENERATION_BACKEND_MAX_CONCURRENCY"),
_GENERATION_BACKEND_MAX_CONCURRENCY_SPEC,
),
local_cli_backend_max_concurrency=_parse_int_config_value(
self._effective_map.get("LOCAL_CLI_BACKEND_MAX_CONCURRENCY"),
_LOCAL_CLI_NUMERIC_SPECS[3],
),
opencode_cli_model=(self._effective_map.get("OPENCODE_CLI_MODEL") or "").strip(),
litellm_model=litellm_model,
llm_model_list=model_list,
)
def _fallback_from_map(self) -> str:
if "GENERATION_FALLBACK_BACKEND" not in self._effective_map:
return LITELLM_BACKEND_ID
return (self._effective_map.get("GENERATION_FALLBACK_BACKEND") or "").strip().lower()
def _build_config(
self,
effective_map: Dict[str, str],
*,
backend_id: Optional[str] = None,
timeout_seconds: Optional[int] = None,
) -> Config:
config = self._build_backend_config()
primary = backend_id or config.generation_backend
openai_keys = self._openai_keys_from_map(effective_map)
return Config(
generation_backend=primary,
generation_fallback_backend="",
generation_backend_timeout_seconds=timeout_seconds or config.generation_backend_timeout_seconds,
generation_backend_max_output_bytes=config.generation_backend_max_output_bytes,
generation_backend_max_concurrency=config.generation_backend_max_concurrency,
local_cli_backend_max_concurrency=config.local_cli_backend_max_concurrency,
opencode_cli_model=config.opencode_cli_model,
litellm_model=config.litellm_model,
litellm_fallback_models=self._split_csv(effective_map.get("LITELLM_FALLBACK_MODELS") or ""),
llm_model_list=config.llm_model_list,
gemini_api_keys=self._split_csv(
effective_map.get("GEMINI_API_KEYS")
or effective_map.get("GEMINI_API_KEY")
or ""
),
anthropic_api_keys=self._split_csv(
effective_map.get("ANTHROPIC_API_KEYS")
or effective_map.get("ANTHROPIC_API_KEY")
or ""
),
openai_api_keys=openai_keys,
deepseek_api_keys=self._split_csv(
effective_map.get("DEEPSEEK_API_KEYS")
or effective_map.get("DEEPSEEK_API_KEY")
or ""
),
gemini_api_key=(effective_map.get("GEMINI_API_KEY") or None),
anthropic_api_key=(effective_map.get("ANTHROPIC_API_KEY") or None),
openai_api_key=(openai_keys[0] if openai_keys else None),
openai_base_url=self._openai_base_url_from_map(effective_map),
)
@classmethod
def _build_litellm_model_list(cls, effective_map: Dict[str, str]) -> List[Dict[str, Any]]:
litellm_config_path = (effective_map.get("LITELLM_CONFIG") or "").strip()
if litellm_config_path:
return Config._parse_litellm_yaml(litellm_config_path)
channels = cls._parse_llm_channels_from_map(effective_map)
if channels:
return Config._channels_to_model_list(channels)
return Config._legacy_keys_to_model_list(
cls._split_csv(effective_map.get("GEMINI_API_KEYS") or effective_map.get("GEMINI_API_KEY") or ""),
cls._split_csv(effective_map.get("ANTHROPIC_API_KEYS") or effective_map.get("ANTHROPIC_API_KEY") or ""),
cls._openai_keys_from_map(effective_map),
cls._openai_base_url_from_map(effective_map),
cls._split_csv(effective_map.get("DEEPSEEK_API_KEYS") or effective_map.get("DEEPSEEK_API_KEY") or ""),
)
@classmethod
def _openai_keys_from_map(cls, effective_map: Dict[str, str]) -> List[str]:
openai_keys = cls._split_csv(effective_map.get("OPENAI_API_KEYS") or "")
if openai_keys:
return openai_keys
aihubmix_key = (effective_map.get("AIHUBMIX_KEY") or "").strip()
if aihubmix_key:
return [aihubmix_key]
return cls._split_csv(effective_map.get("OPENAI_API_KEY") or "")
@staticmethod
def _openai_base_url_from_map(effective_map: Dict[str, str]) -> Optional[str]:
explicit = (effective_map.get("OPENAI_BASE_URL") or "").strip()
if explicit:
return explicit
return "https://aihubmix.com/v1" if (effective_map.get("AIHUBMIX_KEY") or "").strip() else None
@classmethod
def _infer_legacy_litellm_model(cls, effective_map: Dict[str, str]) -> str:
gemini_keys = cls._split_csv(effective_map.get("GEMINI_API_KEYS") or effective_map.get("GEMINI_API_KEY") or "")
if gemini_keys:
model = (effective_map.get("GEMINI_MODEL") or "gemini-3.1-pro-preview").strip()
return model if "/" in model else f"gemini/{model}"
anthropic_keys = cls._split_csv(
effective_map.get("ANTHROPIC_API_KEYS")
or effective_map.get("ANTHROPIC_API_KEY")
or ""
)
if anthropic_keys:
model = (effective_map.get("ANTHROPIC_MODEL") or "claude-sonnet-4-6").strip()
return model if "/" in model else f"anthropic/{model}"
deepseek_keys = cls._split_csv(
effective_map.get("DEEPSEEK_API_KEYS")
or effective_map.get("DEEPSEEK_API_KEY")
or ""
)
if deepseek_keys:
return "deepseek/deepseek-chat"
if cls._openai_keys_from_map(effective_map):
model = (effective_map.get("OPENAI_MODEL") or "gpt-5.5").strip()
return model if "/" in model else f"openai/{model}"
return ""
@classmethod
def _parse_llm_channels_from_map(cls, effective_map: Dict[str, str]) -> List[Dict[str, Any]]:
channels: List[Dict[str, Any]] = []
for raw_name in cls._split_csv(effective_map.get("LLM_CHANNELS") or ""):
name = raw_name.strip()
if not name:
continue
lower = name.lower()
prefix = f"LLM_{name.upper()}"
enabled_raw = effective_map.get(f"{prefix}_ENABLED")
if lower == "anspire" and not (enabled_raw or "").strip():
enabled_raw = effective_map.get("ANSPIRE_LLM_ENABLED")
if not parse_env_bool(enabled_raw, default=True):
continue
base_url = (effective_map.get(f"{prefix}_BASE_URL") or "").strip() or None
if lower == "anspire" and not base_url:
base_url = (effective_map.get("ANSPIRE_LLM_BASE_URL") or ANSPIRE_LLM_BASE_URL_DEFAULT).strip() or None
protocol_raw = (effective_map.get(f"{prefix}_PROTOCOL") or "").strip()
if lower == "anspire" and not protocol_raw:
protocol_raw = "openai"
api_keys = cls._split_csv(effective_map.get(f"{prefix}_API_KEYS") or "")
single_key = (effective_map.get(f"{prefix}_API_KEY") or "").strip()
if not api_keys and single_key:
api_keys = [single_key]
if lower == "anspire" and not api_keys:
api_keys = cls._split_csv(effective_map.get("ANSPIRE_API_KEYS") or "")
raw_models = cls._split_csv(effective_map.get(f"{prefix}_MODELS") or "")
if lower == "anspire" and not raw_models:
raw_models = [(effective_map.get("ANSPIRE_LLM_MODEL") or ANSPIRE_LLM_MODEL_DEFAULT).strip()]
if is_reserved_hermes_name(name):
result = parse_hermes_channel(
enabled=True,
protocol=protocol_raw or HERMES_DEFAULT_PROTOCOL,
base_url=base_url or HERMES_DEFAULT_BASE_URL,
api_key=single_key,
api_keys_raw=(effective_map.get(f"{prefix}_API_KEYS") or "").strip(),
extra_headers_raw=(effective_map.get(f"{prefix}_EXTRA_HEADERS") or "").strip(),
models=raw_models or [HERMES_DEFAULT_MODEL],
)
if result.channel is not None:
channels.append(result.channel)
continue
protocol = resolve_llm_channel_protocol(protocol_raw, base_url=base_url, models=raw_models, channel_name=name)
models = [normalize_llm_channel_model(model, protocol, base_url) for model in raw_models]
if not api_keys and channel_allows_empty_api_key(protocol, base_url):
api_keys = [""]
if not api_keys or not models:
continue
extra_headers = cls._parse_json_object(effective_map.get(f"{prefix}_EXTRA_HEADERS") or "")
channels.append(
{
"name": lower,
"protocol": protocol,
"enabled": True,
"base_url": base_url,
"api_keys": api_keys,
"models": models,
"extra_headers": extra_headers,
}
)
return channels
@staticmethod
def _parse_json_object(value: str) -> Optional[Dict[str, Any]]:
raw = (value or "").strip()
if not raw:
return None
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return None
return payload if isinstance(payload, dict) else None
@staticmethod
def _split_csv(value: str) -> List[str]:
return [item.strip() for item in (value or "").split(",") if item.strip()]

View File

@@ -76,6 +76,7 @@ from src.notification_contracts import (
from src.notification_noise import validate_notification_timezone
from src.notification_sender.gotify_sender import resolve_gotify_message_endpoint
from src.notification_sender.ntfy_sender import resolve_ntfy_endpoint
from src.services.generation_backend_status_service import GenerationBackendStatusService
logger = logging.getLogger(__name__)
@@ -118,6 +119,47 @@ class _LLMDiagnostic:
class SystemConfigService:
"""Service layer for reading, validating, and updating runtime configuration."""
_GENERATION_BACKEND_STATUS_EXACT_KEYS = {
"GENERATION_BACKEND",
"GENERATION_FALLBACK_BACKEND",
"GENERATION_BACKEND_TIMEOUT_SECONDS",
"GENERATION_BACKEND_MAX_OUTPUT_BYTES",
"GENERATION_BACKEND_MAX_CONCURRENCY",
"LOCAL_CLI_BACKEND_MAX_CONCURRENCY",
"OPENCODE_CLI_MODEL",
"LITELLM_CONFIG",
"LITELLM_MODEL",
"LITELLM_FALLBACK_MODELS",
"GEMINI_API_KEY",
"GEMINI_API_KEYS",
"GEMINI_MODEL",
"GEMINI_MODEL_FALLBACK",
"GEMINI_TEMPERATURE",
"ANTHROPIC_API_KEY",
"ANTHROPIC_API_KEYS",
"ANTHROPIC_MODEL",
"ANTHROPIC_TEMPERATURE",
"ANTHROPIC_MAX_TOKENS",
"OPENAI_API_KEY",
"OPENAI_API_KEYS",
"OPENAI_BASE_URL",
"OPENAI_MODEL",
"OPENAI_VISION_MODEL",
"OPENAI_TEMPERATURE",
"OLLAMA_API_BASE",
"OLLAMA_MODEL",
"DEEPSEEK_API_KEY",
"DEEPSEEK_API_KEYS",
"AIHUBMIX_KEY",
"ANSPIRE_LLM_ENABLED",
"ANSPIRE_LLM_BASE_URL",
"ANSPIRE_LLM_MODEL",
"ANSPIRE_API_KEYS",
}
_GENERATION_BACKEND_STATUS_LLM_CHANNEL_RE = re.compile(
r"^LLM_[A-Z0-9_]+_(PROTOCOL|BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS|ENABLED)$"
)
_LLM_CAPABILITY_ORDER: Tuple[str, ...] = ("json", "tools", "stream", "vision")
_LLM_STREAM_CHUNK_LIMIT = 8
_WEB_SETTINGS_LLM_CHANNEL_SUPPORT_KEY_RE = re.compile(
@@ -557,6 +599,64 @@ class SystemConfigService:
"checks": checks,
}
def get_generation_backend_status(self) -> Dict[str, Any]:
"""Return cheap generation backend status for saved/runtime config only."""
effective_map = self._build_generation_backend_base_map()
service = GenerationBackendStatusService(
effective_map=effective_map,
validation_issues=self._collect_generation_backend_issues_from_map(effective_map),
)
return service.get_status()
def preview_generation_backend_status(
self,
*,
items: Sequence[Dict[str, str]],
mask_token: str = "******",
) -> Dict[str, Any]:
"""Return cheap generation backend status for unsaved settings draft."""
issues = self._collect_generation_backend_issues(items=items, mask_token=mask_token)
errors = [issue for issue in issues if issue["severity"] == "error"]
if errors:
raise ConfigValidationError(issues=errors)
effective_map = self._build_generation_backend_effective_map(
items=items,
mask_token=mask_token,
)
service = GenerationBackendStatusService(
effective_map=effective_map,
validation_issues=issues,
)
return service.get_status()
def test_generation_backend(
self,
*,
backend_id: Optional[str] = None,
mode: str = "json",
items: Sequence[Dict[str, str]] = (),
mask_token: str = "******",
timeout_seconds: Optional[float] = None,
) -> Dict[str, Any]:
"""Run an explicit generation backend smoke test without persisting config."""
issues = self._collect_generation_backend_issues(items=items, mask_token=mask_token)
errors = [issue for issue in issues if issue["severity"] == "error"]
if errors:
raise ConfigValidationError(issues=errors)
effective_map = self._build_generation_backend_effective_map(
items=items,
mask_token=mask_token,
)
service = GenerationBackendStatusService(
effective_map=effective_map,
validation_issues=issues,
)
return service.smoke_test(
backend_id=backend_id,
mode=mode,
timeout_seconds=timeout_seconds,
)
def export_env(self) -> Dict[str, Any]:
"""Return the raw active `.env` content for backup."""
if self._manager.env_path.exists():
@@ -2238,6 +2338,127 @@ class SystemConfigService:
issues.extend(self._validate_cross_field(effective_map=effective_map, updated_keys=set(updated_map.keys())))
return issues
@classmethod
def _is_generation_backend_status_key(cls, key: str) -> bool:
normalized = str(key or "").strip().upper()
return (
normalized in cls._GENERATION_BACKEND_STATUS_EXACT_KEYS
or normalized == "LLM_CHANNELS"
or bool(cls._GENERATION_BACKEND_STATUS_LLM_CHANNEL_RE.fullmatch(normalized))
)
@classmethod
def _filter_generation_backend_items(
cls,
items: Sequence[Dict[str, str]],
) -> List[Dict[str, str]]:
filtered: List[Dict[str, str]] = []
for item in items:
key = str(item.get("key", "")).strip().upper()
if not key or not cls._is_generation_backend_status_key(key):
continue
filtered.append({"key": key, "value": "" if item.get("value") is None else str(item.get("value"))})
return filtered
def _collect_generation_backend_issues(
self,
*,
items: Sequence[Dict[str, str]],
mask_token: str,
) -> List[Dict[str, Any]]:
"""Collect only config issues that affect generation backend status/smoke."""
issues = self._collect_issues(
items=self._filter_generation_backend_items(items),
mask_token=mask_token,
)
effective_map = self._build_generation_backend_effective_map(
items=items,
mask_token=mask_token,
)
issues.extend(self._validate_generation_backend_litellm_runtime_source(effective_map))
return [
issue for issue in issues
if self._is_generation_backend_status_key(str(issue.get("key", "")))
]
@staticmethod
def _validate_generation_backend_litellm_runtime_source(effective_map: Dict[str, str]) -> List[Dict[str, Any]]:
"""Validate explicit LiteLLM models when no route list can back them."""
primary_backend = normalize_backend_id(
effective_map.get("GENERATION_BACKEND"),
default=LITELLM_BACKEND_ID,
)
fallback_backend = (
LITELLM_BACKEND_ID
if "GENERATION_FALLBACK_BACKEND" not in effective_map
else (effective_map.get("GENERATION_FALLBACK_BACKEND") or "").strip().lower()
)
litellm_selected = (
primary_backend == LITELLM_BACKEND_ID
or (fallback_backend == LITELLM_BACKEND_ID and primary_backend != LITELLM_BACKEND_ID)
)
if not litellm_selected:
return []
if SystemConfigService._uses_litellm_yaml(effective_map):
return []
if SystemConfigService._collect_llm_channel_models_from_map(effective_map):
return []
if (effective_map.get("LLM_CHANNELS") or "").strip():
return []
issues: List[Dict[str, Any]] = []
primary_model = (effective_map.get("LITELLM_MODEL") or "").strip()
if primary_model and not SystemConfigService._has_runtime_source_for_model(primary_model, effective_map):
issues.append(
{
"key": "LITELLM_MODEL",
"code": "missing_runtime_source",
"message": (
"A primary model is selected, but no usable runtime source was found. "
"Configure a matching provider API key, LLM channel, or LiteLLM YAML route."
),
"severity": "error",
"expected": "matching provider API key, enabled channel model, or YAML model",
"actual": primary_model,
}
)
fallback_models = [
model.strip()
for model in (effective_map.get("LITELLM_FALLBACK_MODELS") or "").split(",")
if model.strip()
]
invalid_fallbacks = [
model for model in fallback_models
if not SystemConfigService._has_runtime_source_for_model(model, effective_map)
]
if invalid_fallbacks:
issues.append(
{
"key": "LITELLM_FALLBACK_MODELS",
"code": "missing_runtime_source",
"message": (
"Some fallback models do not have a matching provider API key, "
"enabled channel, or LiteLLM YAML route."
),
"severity": "error",
"expected": "matching provider API key, enabled channel model, or YAML model",
"actual": ", ".join(invalid_fallbacks[:3]),
}
)
return issues
def _collect_generation_backend_issues_from_map(
self,
effective_map: Dict[str, str],
) -> List[Dict[str, Any]]:
items = [
{"key": key, "value": value}
for key, value in effective_map.items()
if self._is_generation_backend_status_key(key)
]
return self._collect_generation_backend_issues(items=items, mask_token="******")
@staticmethod
def _validate_value(key: str, value: str, field_schema: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Validate a single field value against schema metadata."""
@@ -2990,6 +3211,45 @@ class SystemConfigService:
return self._build_display_config_map(effective_map)
def _build_generation_backend_base_map(self) -> Dict[str, str]:
"""Build generation backend status config with saved values taking precedence."""
saved_map = self._build_display_config_map(self._manager.read_config_map())
effective_map = dict(saved_map)
registered_keys = {key.upper() for key in get_registered_field_keys()}
for raw_key, raw_value in os.environ.items():
key = str(raw_key).upper()
if key in effective_map:
continue
value = "" if raw_value is None else str(raw_value)
if key in registered_keys or self._is_setup_relevant_env_key(key):
effective_map[key] = value
return self._build_display_config_map(effective_map)
def _build_generation_backend_effective_map(
self,
*,
items: Sequence[Dict[str, str]],
mask_token: str,
) -> Dict[str, str]:
"""Merge saved/runtime config with unsaved status/smoke preview items."""
effective_map = self._build_generation_backend_base_map()
saved_map = self._build_display_config_map(self._manager.read_config_map())
for item in self._filter_generation_backend_items(items):
key = str(item.get("key", "")).strip().upper()
if not key:
continue
value = "" if item.get("value") is None else str(item.get("value"))
field_schema = get_field_definition(key, value)
if bool(field_schema.get("is_sensitive", False)) and value == mask_token:
if key in saved_map:
continue
effective_map[key] = value
return self._build_display_config_map(effective_map)
@staticmethod
def _has_any_config_value(effective_map: Dict[str, str], keys: Sequence[str]) -> bool:
return any((effective_map.get(key) or "").strip() for key in keys)

View File

@@ -0,0 +1,388 @@
# -*- coding: utf-8 -*-
"""Tests for generation backend status diagnostics."""
import logging
from unittest.mock import patch
from types import SimpleNamespace
from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
from src.llm.generation_backend import GenerationError, GenerationErrorCode
from src.services.generation_backend_status_service import GenerationBackendStatusService
class _FailingBackend:
def generate(self, *_args, **_kwargs):
raise GenerationError(
error_code=GenerationErrorCode.INVALID_JSON,
stage="smoke_validation",
retryable=False,
fallbackable=False,
backend="codex_cli",
details={"reason": "invalid_json"},
)
class _FakeAnalyzer:
def __init__(self, _config):
pass
def _get_generation_backend(self, _backend_id):
return _FailingBackend()
class _PassingBackend:
seen_configs = []
def __init__(self, config=None):
self.config = config
def generate(self, *_args, **kwargs):
validator = kwargs.get("response_validator")
text = '{"ok": true, "backend_smoke": "passed"}'
if validator:
validator(text)
return SimpleNamespace(text=text)
class _CapturingAnalyzer:
configs = []
def __init__(self, config):
self.configs.append(config)
self.config = config
def _get_generation_backend(self, _backend_id):
return _PassingBackend(self.config)
def _litellm_effective_map(api_key: str = "sk-secret-value") -> dict:
return {
"GENERATION_BACKEND": "litellm",
"GENERATION_FALLBACK_BACKEND": "",
"LITELLM_MODEL": "openai/gpt-5.5",
"OPENAI_API_KEY": api_key,
}
def test_local_cli_missing_executable_reports_current_config_error() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "codex_cli",
"GENERATION_FALLBACK_BACKEND": "",
}
)
with patch("src.llm.local_cli_backend.shutil.which", return_value=None):
payload = service.get_status()
primary = payload["primary"]
assert primary["backend_id"] == "codex_cli"
assert primary["available"] is False
assert primary["health_status"] == "failed"
assert primary["last_error_code"] == "command_not_found"
assert primary["supports_tools"] is False
def test_local_cli_invalid_numeric_config_reports_unsafe_config() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "codex_cli",
"GENERATION_FALLBACK_BACKEND": "",
"GENERATION_BACKEND_TIMEOUT_SECONDS": "not-int",
}
)
payload = service.get_status()
primary = payload["primary"]
assert primary["available"] is False
assert primary["health_status"] == "failed"
assert primary["last_error_code"] == "unsafe_config"
def test_litellm_ignores_local_cli_only_numeric_config() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LITELLM_MODEL": "gemini/gemini-3-flash-preview",
"GEMINI_API_KEY": "secret-key-value",
"GENERATION_BACKEND_TIMEOUT_SECONDS": "not-int",
"GENERATION_BACKEND_MAX_OUTPUT_BYTES": "not-int",
"LOCAL_CLI_BACKEND_MAX_CONCURRENCY": "not-int",
}
)
payload = service.get_status()
assert payload["primary"]["available"] is True
assert payload["primary"]["last_error_code"] is None
def test_local_cli_smoke_failure_keeps_available_true_when_cheap_check_passes() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "codex_cli",
"GENERATION_FALLBACK_BACKEND": "",
},
analyzer_factory=lambda config: _FakeAnalyzer(config),
)
with patch("src.llm.local_cli_backend.shutil.which", return_value="/usr/bin/codex"), \
patch("src.llm.local_cli_backend.os.access", return_value=True):
payload = service.smoke_test(backend_id="codex_cli", mode="json")
status = payload["status"]
assert payload["success"] is False
assert status["available"] is True
assert status["health_status"] == "failed"
assert status["last_error_code"] == "invalid_json"
assert status["supports_tools"] is False
def test_smoke_timeout_overrides_config_timeout_for_local_cli() -> None:
_CapturingAnalyzer.configs = []
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "codex_cli",
"GENERATION_FALLBACK_BACKEND": "",
"GENERATION_BACKEND_TIMEOUT_SECONDS": "300",
},
analyzer_factory=lambda config: _CapturingAnalyzer(config),
)
with patch("src.llm.local_cli_backend.shutil.which", return_value="/usr/bin/codex"), \
patch("src.llm.local_cli_backend.os.access", return_value=True):
payload = service.smoke_test(backend_id="codex_cli", mode="json", timeout_seconds=1)
assert payload["success"] is True
assert _CapturingAnalyzer.configs[-1].generation_backend_timeout_seconds == 1
def test_litellm_smoke_timeout_reaches_final_completion_dispatch() -> None:
captured = {}
def _dispatch(_self, _model, call_kwargs, *, config, use_channel_router, router_model_names):
del config, use_channel_router, router_model_names
captured.update(call_kwargs)
return {
"choices": [
{"message": {"content": '{"ok": true, "backend_smoke": "passed"}'}}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
service = GenerationBackendStatusService(effective_map=_litellm_effective_map())
with patch("src.analyzer.GeminiAnalyzer._dispatch_litellm_completion", new=_dispatch):
payload = service.smoke_test(mode="json", timeout_seconds=1)
assert payload["success"] is True
assert captured["timeout"] == 1
def test_litellm_smoke_redacts_provider_error_from_response_and_logs(caplog) -> None:
secret_error = (
"provider rejected invalid api key plain-provider-secret-value and sk-secret-value "
"Authorization: Bearer sk-review-token-abcdef"
)
def _dispatch(_self, _model, _call_kwargs, *, config, use_channel_router, router_model_names):
del config, use_channel_router, router_model_names
raise RuntimeError(secret_error)
service = GenerationBackendStatusService(
effective_map=_litellm_effective_map("plain-provider-secret-value")
)
caplog.set_level(logging.WARNING, logger="src.analyzer")
with patch("src.analyzer.GeminiAnalyzer._dispatch_litellm_completion", new=_dispatch):
payload = service.smoke_test(mode="json")
assert payload["success"] is False
visible_text = f"{payload['message']} {payload['status']['last_error_message']}"
logged_text = "\n".join(record.getMessage() for record in caplog.records)
for text in (visible_text, logged_text):
assert "plain-provider-secret-value" not in text
assert "sk-secret-value" not in text
assert "sk-review-token-abcdef" not in text
assert "Authorization: Bearer" not in text
assert "[REDACTED]" in payload["message"] or "<redacted" in payload["message"]
def test_generation_fallback_self_is_noop_not_recursive() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "codex_cli",
"GENERATION_FALLBACK_BACKEND": "codex_cli",
}
)
with patch("src.llm.local_cli_backend.shutil.which", return_value="/usr/bin/codex"), \
patch("src.llm.local_cli_backend.os.access", return_value=True):
payload = service.get_status()
assert payload["primary_backend_id"] == "codex_cli"
assert payload["fallback_backend_id"] is None
assert payload["fallback"] is None
assert len(payload["backends"]) == 1
def test_invalid_fallback_does_not_fail_primary() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "codex_cli",
"GENERATION_FALLBACK_BACKEND": "bad_backend",
}
)
with patch("src.llm.local_cli_backend.shutil.which", return_value="/usr/bin/codex"), \
patch("src.llm.local_cli_backend.os.access", return_value=True):
payload = service.get_status()
assert payload["primary"]["available"] is True
assert payload["primary"]["health_status"] == "not_tested"
assert payload["fallback"]["backend_id"] == "bad_backend"
assert payload["fallback"]["available"] is False
assert payload["fallback"]["health_status"] == "failed"
def test_litellm_without_model_source_is_not_available() -> None:
service = GenerationBackendStatusService(effective_map={"GENERATION_BACKEND": "litellm"})
payload = service.get_status()
assert payload["primary"]["available"] is False
assert payload["primary"]["health_status"] == "failed"
assert payload["primary"]["last_error_code"] == "backend_not_configured"
def test_litellm_managed_model_without_provider_key_is_not_available() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LITELLM_MODEL": "gemini/gemini-3-flash-preview",
}
)
payload = service.get_status()
assert payload["primary"]["available"] is False
assert payload["primary"]["health_status"] == "failed"
assert payload["primary"]["last_error_code"] == "unsafe_config"
def test_litellm_aihubmix_key_builds_openai_legacy_route() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LITELLM_MODEL": "openai/gpt-5.5",
"AIHUBMIX_KEY": "sk-aihubmix-secret",
}
)
payload = service.get_status()
assert payload["primary"]["available"] is True
assert payload["primary"]["last_error_code"] is None
def test_litellm_legacy_key_infers_runtime_model_for_smoke_config() -> None:
_CapturingAnalyzer.configs = []
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"GEMINI_API_KEY": "secret-key-value",
},
analyzer_factory=lambda config: _CapturingAnalyzer(config),
)
payload = service.smoke_test(mode="json")
assert payload["success"] is True
assert _CapturingAnalyzer.configs[-1].litellm_model == "gemini/gemini-3.1-pro-preview"
def test_litellm_validation_issues_are_not_available() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LLM_CHANNELS": "remote",
"LLM_REMOTE_PROTOCOL": "openai",
"LLM_REMOTE_BASE_URL": "https://api.example.com/v1",
"LLM_REMOTE_API_KEY": "sk-remote",
"LLM_REMOTE_MODELS": "gpt-4o-mini",
},
validation_issues=[
{
"key": "LITELLM_MODEL",
"code": "unknown_model",
"message": "unknown model",
"severity": "error",
}
],
)
payload = service.get_status()
assert payload["primary"]["available"] is False
assert payload["primary"]["health_status"] == "failed"
assert payload["primary"]["last_error_code"] == "unsafe_config"
def test_smoke_invalid_saved_backend_returns_structured_failure() -> None:
service = GenerationBackendStatusService(effective_map={"GENERATION_BACKEND": "bad_backend"})
payload = service.smoke_test()
assert payload["success"] is False
assert payload["mode"] == "json"
assert payload["status"]["backend_id"] == "bad_backend"
assert payload["status"]["health_status"] == "failed"
assert payload["status"]["last_error_code"] == "backend_not_configured"
def test_smoke_unsupported_requested_backend_returns_structured_failure() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LITELLM_MODEL": "gemini/gemini-3-flash-preview",
"GEMINI_API_KEY": "secret-key-value",
}
)
payload = service.smoke_test(backend_id="bad_backend")
assert payload["success"] is False
assert payload["mode"] == "json"
assert payload["status"]["backend_id"] == "bad_backend"
assert payload["status"]["is_primary"] is False
assert payload["status"]["last_error_code"] == "backend_not_configured"
def test_litellm_channel_route_is_used_for_status_and_smoke_config() -> None:
_CapturingAnalyzer.configs = []
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LLM_CHANNELS": "remote",
"LLM_REMOTE_PROTOCOL": "openai",
"LLM_REMOTE_BASE_URL": "https://api.example.com/v1",
"LLM_REMOTE_API_KEY": "sk-remote",
"LLM_REMOTE_MODELS": "gpt-4o-mini",
},
analyzer_factory=lambda config: _CapturingAnalyzer(config),
)
status = service.get_status()
smoke = service.smoke_test(mode="json")
assert status["primary"]["available"] is True
assert smoke["success"] is True
config = _CapturingAnalyzer.configs[-1]
assert config.litellm_model == "openai/gpt-4o-mini"
assert config.llm_model_list[0]["model_name"] == "openai/gpt-4o-mini"
assert config.llm_model_list[0]["litellm_params"]["api_key"] == "sk-remote"
assert config.llm_model_list[0]["litellm_params"]["api_base"] == "https://api.example.com/v1"

View File

@@ -21,7 +21,9 @@ from api.middlewares.error_handler import add_error_handlers
from api.v1.endpoints import system_config
from api.v1.schemas.system_config import (
DiscoverLLMChannelModelsRequest,
GenerationBackendStatusPreviewRequest,
ImportSystemConfigRequest,
TestGenerationBackendRequest,
TestLLMChannelRequest,
TestNotificationChannelRequest,
UpdateSystemConfigRequest,
@@ -203,6 +205,81 @@ class SystemConfigApiTestCase(unittest.TestCase):
self.assertEqual(check_map["llm_primary"]["status"], "configured")
self.assertEqual(check_map["llm_agent"]["status"], "inherited")
def test_get_generation_backend_status_uses_saved_config_only(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
)
payload = system_config.get_generation_backend_status(service=self.service).model_dump()
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertEqual(payload["primary"]["backend_id"], "litellm")
self.assertTrue(payload["primary"]["available"])
def test_preview_generation_backend_status_uses_draft_items(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
)
with patch("src.llm.local_cli_backend.shutil.which", return_value=None):
payload = system_config.preview_generation_backend_status(
request=GenerationBackendStatusPreviewRequest(
items=[
{"key": "GENERATION_BACKEND", "value": "codex_cli"},
{"key": "GENERATION_FALLBACK_BACKEND", "value": ""},
],
mask_token="******",
),
service=self.service,
).model_dump()
self.assertEqual(payload["primary_backend_id"], "codex_cli")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["last_error_code"], "command_not_found")
saved_payload = system_config.get_generation_backend_status(service=self.service).model_dump()
self.assertEqual(saved_payload["primary_backend_id"], "litellm")
def test_generation_backend_smoke_test_returns_structured_failure(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
)
with patch("src.llm.local_cli_backend.shutil.which", return_value=None):
payload = system_config.test_generation_backend(
request=TestGenerationBackendRequest(backend_id="codex_cli"),
service=self.service,
).model_dump()
self.assertFalse(payload["success"])
self.assertEqual(payload["mode"], "json")
self.assertEqual(payload["status"]["backend_id"], "codex_cli")
self.assertEqual(payload["status"]["last_error_code"], "command_not_found")
def test_preview_generation_backend_status_returns_validation_error_for_bad_draft(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
)
with self.assertRaises(HTTPException) as ctx:
system_config.preview_generation_backend_status(
request=GenerationBackendStatusPreviewRequest(
items=[{"key": "GENERATION_BACKEND_TIMEOUT_SECONDS", "value": "not-int"}],
mask_token="******",
),
service=self.service,
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(ctx.exception.detail["error"], "validation_failed")
self.assertEqual(ctx.exception.detail["issues"][0]["key"], "GENERATION_BACKEND_TIMEOUT_SECONDS")
def test_put_config_updates_secret_and_plain_field(self) -> None:
current = system_config.get_system_config(include_schema=False, service=self.service).model_dump()
payload = system_config.update_system_config(

View File

@@ -21,7 +21,7 @@ ensure_litellm_stub()
from src.config import ANSPIRE_LLM_MODEL_DEFAULT, DEFAULT_ALPHASIFT_INSTALL_SPEC, Config
from src.core.config_manager import ConfigManager
from src.llm.backend_registry import GENERATION_ONLY_BACKEND_IDS
from src.services.system_config_service import ConfigConflictError, ConfigImportError, SystemConfigService
from src.services.system_config_service import ConfigConflictError, ConfigImportError, ConfigValidationError, SystemConfigService
class SystemConfigServiceTestCase(unittest.TestCase):
@@ -948,6 +948,216 @@ class SystemConfigServiceTestCase(unittest.TestCase):
self.assertEqual(checks["stock_list"]["status"], "configured")
self.assertEqual(checks["notification"]["status"], "optional")
def test_generation_backend_status_preview_uses_draft_backend(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
)
with patch("src.llm.local_cli_backend.shutil.which", return_value=None):
payload = self.service.preview_generation_backend_status(
items=[
{"key": "GENERATION_BACKEND", "value": "codex_cli"},
{"key": "GENERATION_FALLBACK_BACKEND", "value": ""},
],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "codex_cli")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["health_status"], "failed")
self.assertEqual(payload["primary"]["last_error_code"], "command_not_found")
def test_generation_backend_status_preserves_masked_saved_secret(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=saved-secret-value",
)
payload = self.service.preview_generation_backend_status(
items=[{"key": "GEMINI_API_KEY", "value": "******"}],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertTrue(payload["primary"]["available"])
def test_generation_backend_status_saved_invalid_numeric_returns_failed_status(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
"GENERATION_BACKEND_TIMEOUT_SECONDS=not-int",
)
payload = self.service.get_generation_backend_status()
self.assertEqual(payload["primary_backend_id"], "codex_cli")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["health_status"], "failed")
self.assertEqual(payload["primary"]["last_error_code"], "unsafe_config")
def test_generation_backend_preview_invalid_numeric_returns_validation_error(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
)
with self.assertRaises(ConfigValidationError) as ctx:
self.service.preview_generation_backend_status(
items=[{"key": "GENERATION_BACKEND_TIMEOUT_SECONDS", "value": "not-int"}],
mask_token="******",
)
self.assertEqual(ctx.exception.issues[0]["key"], "GENERATION_BACKEND_TIMEOUT_SECONDS")
self.assertEqual(ctx.exception.issues[0]["severity"], "error")
def test_generation_backend_status_saved_litellm_invalid_channel_returns_failed_status(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LLM_CHANNELS=remote",
"LLM_REMOTE_PROTOCOL=openai",
"LLM_REMOTE_BASE_URL=http://169.254.169.254/v1",
"LLM_REMOTE_API_KEY=sk-remote",
"LLM_REMOTE_MODELS=gpt-4o-mini",
)
payload = self.service.get_generation_backend_status()
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["health_status"], "failed")
self.assertEqual(payload["primary"]["last_error_code"], "unsafe_config")
def test_generation_backend_status_saved_litellm_model_without_key_returns_failed_status(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
)
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
payload = self.service.get_generation_backend_status()
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["health_status"], "failed")
self.assertEqual(payload["primary"]["last_error_code"], "unsafe_config")
def test_generation_backend_preview_litellm_model_without_key_returns_validation_error(self) -> None:
self._rewrite_env("GENERATION_BACKEND=litellm")
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
with self.assertRaises(ConfigValidationError) as ctx:
self.service.preview_generation_backend_status(
items=[{"key": "LITELLM_MODEL", "value": "gemini/gemini-3-flash-preview"}],
mask_token="******",
)
self.assertEqual(ctx.exception.issues[0]["key"], "LITELLM_MODEL")
self.assertEqual(ctx.exception.issues[0]["code"], "missing_runtime_source")
def test_generation_backend_preview_uses_openai_model_draft(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"OPENAI_API_KEY=secret-key-value",
"OPENAI_MODEL=gpt-5.5",
)
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
payload = self.service.preview_generation_backend_status(
items=[{"key": "OPENAI_MODEL", "value": "gemini/gemini-3-flash-preview"}],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["last_error_code"], "unsafe_config")
def test_generation_backend_preview_uses_gemini_model_draft(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"GEMINI_API_KEY=secret-key-value",
"GEMINI_MODEL=gemini-3.1-pro-preview",
)
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
payload = self.service.preview_generation_backend_status(
items=[{"key": "GEMINI_MODEL", "value": "openai/gpt-5.5"}],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["last_error_code"], "unsafe_config")
def test_generation_backend_status_uses_runtime_provider_key_fallback(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
)
with patch.dict(
os.environ,
{
"ENV_FILE": str(self.env_path),
"GEMINI_API_KEY": "runtime-secret-value",
},
clear=True,
):
payload = self.service.get_generation_backend_status()
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertTrue(payload["primary"]["available"])
self.assertIsNone(payload["primary"]["last_error_code"])
def test_generation_backend_preview_local_cli_ignores_inactive_litellm_model_error(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
)
with patch("src.llm.local_cli_backend.shutil.which", return_value=None):
payload = self.service.preview_generation_backend_status(
items=[
{"key": "GENERATION_BACKEND", "value": "codex_cli"},
{"key": "GENERATION_FALLBACK_BACKEND", "value": ""},
],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "codex_cli")
self.assertEqual(payload["primary"]["last_error_code"], "command_not_found")
def test_generation_backend_preview_ignores_unrelated_draft_errors(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
)
payload = self.service.preview_generation_backend_status(
items=[{"key": "WECHAT_WEBHOOK_URL", "value": "not-a-url"}],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertTrue(payload["primary"]["available"])
def test_generation_backend_status_fallback_error_does_not_fail_primary(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=bad_backend",
)
with patch("src.llm.local_cli_backend.shutil.which", return_value="/usr/bin/codex"), \
patch("src.llm.local_cli_backend.os.access", return_value=True):
payload = self.service.get_generation_backend_status()
self.assertTrue(payload["primary"]["available"])
self.assertEqual(payload["fallback"]["backend_id"], "bad_backend")
self.assertFalse(payload["fallback"]["available"])
def test_get_setup_status_treats_codex_cli_as_primary_runtime_without_api_keys(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",