feat: add explicit Responses API channel routing (#2157)

This commit is contained in:
zhulinsen
2026-08-05 19:15:08 +08:00
committed by GitHub
parent 8052d1a0ac
commit 4dda5d7148
34 changed files with 2172 additions and 112 deletions

View File

@@ -135,6 +135,12 @@ STOCK_INDEX_REMOTE_UPDATE_ENABLED=true
# AIHubmix聚合平台→ 填 AIHUBMIX_KEY
#
# 【进阶】需要多模型 / 多平台 fallback → 配置下方「多渠道」或在 Web 设置页可视化管理。
# 每个渠道可通过 LLM_<CHANNEL>_API_SURFACE 显式选择 chat_completions默认或 responses。
# responses 要求 PROTOCOL=openai且 MODELS 中不能使用 anthropic/、gemini/、xai/ 等 LiteLLM 直连 provider 前缀;
# 网关自有的带斜杠模型 ID如 deepseek-ai/DeepSeek-V3会作为 OpenAI-compatible 模型 ID 路由。
# 同一个规范化模型别名不能同时出现在 chat_completions 和 responses 渠道;需要两种 Surface 时请使用不同别名。
# 同一渠道中的模型必须使用同一种 API Surface不要依赖失败后自动切换 endpoint。
# 若 Anspire `/models` 与连接测试确认目标模型走 Responses例如当前观测到的 GPT-5.6 sol/terra/luna使用渠道模式并设置 LLM_ANSPIRE_API_SURFACE=responses。
# ===================================
# 生成后端:默认 litellmcodex_cli / claude_code_cli / opencode_cli 为显式 opt-in 的本地 CLI backendexperimental/limited

View File

@@ -92,6 +92,7 @@ jobs:
# Hermes 是本地 loopback generation presetAPI Key 只从 Secrets 注入。
# GitHub-hosted runner 的 127.0.0.1 不是用户电脑,本配置通常只适合 self-hosted runner。
LLM_HERMES_PROTOCOL: ${{ vars.LLM_HERMES_PROTOCOL || secrets.LLM_HERMES_PROTOCOL }}
LLM_HERMES_API_SURFACE: ${{ vars.LLM_HERMES_API_SURFACE || secrets.LLM_HERMES_API_SURFACE }}
LLM_HERMES_BASE_URL: ${{ vars.LLM_HERMES_BASE_URL || secrets.LLM_HERMES_BASE_URL }}
LLM_HERMES_API_KEY: ${{ secrets.LLM_HERMES_API_KEY }}
LLM_HERMES_MODELS: ${{ vars.LLM_HERMES_MODELS || secrets.LLM_HERMES_MODELS }}
@@ -124,6 +125,7 @@ jobs:
# LLM 渠道模式常见渠道名需显式映射GitHub Actions 不会自动导入任意 Secret/Variable
LLM_PRIMARY_PROTOCOL: ${{ vars.LLM_PRIMARY_PROTOCOL || secrets.LLM_PRIMARY_PROTOCOL }}
LLM_PRIMARY_API_SURFACE: ${{ vars.LLM_PRIMARY_API_SURFACE || secrets.LLM_PRIMARY_API_SURFACE }}
LLM_PRIMARY_BASE_URL: ${{ vars.LLM_PRIMARY_BASE_URL || secrets.LLM_PRIMARY_BASE_URL }}
LLM_PRIMARY_API_KEY: ${{ secrets.LLM_PRIMARY_API_KEY }}
LLM_PRIMARY_API_KEYS: ${{ secrets.LLM_PRIMARY_API_KEYS }}
@@ -132,6 +134,7 @@ jobs:
LLM_PRIMARY_EXTRA_HEADERS: ${{ vars.LLM_PRIMARY_EXTRA_HEADERS || secrets.LLM_PRIMARY_EXTRA_HEADERS }}
LLM_SECONDARY_PROTOCOL: ${{ vars.LLM_SECONDARY_PROTOCOL || secrets.LLM_SECONDARY_PROTOCOL }}
LLM_SECONDARY_API_SURFACE: ${{ vars.LLM_SECONDARY_API_SURFACE || secrets.LLM_SECONDARY_API_SURFACE }}
LLM_SECONDARY_BASE_URL: ${{ vars.LLM_SECONDARY_BASE_URL || secrets.LLM_SECONDARY_BASE_URL }}
LLM_SECONDARY_API_KEY: ${{ secrets.LLM_SECONDARY_API_KEY }}
LLM_SECONDARY_API_KEYS: ${{ secrets.LLM_SECONDARY_API_KEYS }}
@@ -140,6 +143,7 @@ jobs:
LLM_SECONDARY_EXTRA_HEADERS: ${{ vars.LLM_SECONDARY_EXTRA_HEADERS || secrets.LLM_SECONDARY_EXTRA_HEADERS }}
LLM_GEMINI_PROTOCOL: ${{ vars.LLM_GEMINI_PROTOCOL || secrets.LLM_GEMINI_PROTOCOL }}
LLM_GEMINI_API_SURFACE: ${{ vars.LLM_GEMINI_API_SURFACE || secrets.LLM_GEMINI_API_SURFACE }}
LLM_GEMINI_BASE_URL: ${{ vars.LLM_GEMINI_BASE_URL || secrets.LLM_GEMINI_BASE_URL }}
LLM_GEMINI_API_KEY: ${{ secrets.LLM_GEMINI_API_KEY }}
LLM_GEMINI_API_KEYS: ${{ secrets.LLM_GEMINI_API_KEYS }}
@@ -148,6 +152,7 @@ jobs:
LLM_GEMINI_EXTRA_HEADERS: ${{ vars.LLM_GEMINI_EXTRA_HEADERS || secrets.LLM_GEMINI_EXTRA_HEADERS }}
LLM_DEEPSEEK_PROTOCOL: ${{ vars.LLM_DEEPSEEK_PROTOCOL || secrets.LLM_DEEPSEEK_PROTOCOL }}
LLM_DEEPSEEK_API_SURFACE: ${{ vars.LLM_DEEPSEEK_API_SURFACE || secrets.LLM_DEEPSEEK_API_SURFACE }}
LLM_DEEPSEEK_BASE_URL: ${{ vars.LLM_DEEPSEEK_BASE_URL || secrets.LLM_DEEPSEEK_BASE_URL }}
LLM_DEEPSEEK_API_KEY: ${{ secrets.LLM_DEEPSEEK_API_KEY }}
LLM_DEEPSEEK_API_KEYS: ${{ secrets.LLM_DEEPSEEK_API_KEYS }}
@@ -156,6 +161,7 @@ jobs:
LLM_DEEPSEEK_EXTRA_HEADERS: ${{ vars.LLM_DEEPSEEK_EXTRA_HEADERS || secrets.LLM_DEEPSEEK_EXTRA_HEADERS }}
LLM_AIHUBMIX_PROTOCOL: ${{ vars.LLM_AIHUBMIX_PROTOCOL || secrets.LLM_AIHUBMIX_PROTOCOL }}
LLM_AIHUBMIX_API_SURFACE: ${{ vars.LLM_AIHUBMIX_API_SURFACE || secrets.LLM_AIHUBMIX_API_SURFACE }}
LLM_AIHUBMIX_BASE_URL: ${{ vars.LLM_AIHUBMIX_BASE_URL || secrets.LLM_AIHUBMIX_BASE_URL }}
LLM_AIHUBMIX_API_KEY: ${{ secrets.LLM_AIHUBMIX_API_KEY }}
LLM_AIHUBMIX_API_KEYS: ${{ secrets.LLM_AIHUBMIX_API_KEYS }}
@@ -164,6 +170,7 @@ jobs:
LLM_AIHUBMIX_EXTRA_HEADERS: ${{ vars.LLM_AIHUBMIX_EXTRA_HEADERS || secrets.LLM_AIHUBMIX_EXTRA_HEADERS }}
LLM_ANSPIRE_PROTOCOL: ${{ vars.LLM_ANSPIRE_PROTOCOL || secrets.LLM_ANSPIRE_PROTOCOL }}
LLM_ANSPIRE_API_SURFACE: ${{ vars.LLM_ANSPIRE_API_SURFACE || secrets.LLM_ANSPIRE_API_SURFACE }}
LLM_ANSPIRE_BASE_URL: ${{ vars.LLM_ANSPIRE_BASE_URL || secrets.LLM_ANSPIRE_BASE_URL }}
LLM_ANSPIRE_API_KEY: ${{ secrets.LLM_ANSPIRE_API_KEY }}
LLM_ANSPIRE_API_KEYS: ${{ secrets.LLM_ANSPIRE_API_KEYS }}
@@ -172,6 +179,7 @@ jobs:
LLM_ANSPIRE_EXTRA_HEADERS: ${{ vars.LLM_ANSPIRE_EXTRA_HEADERS || secrets.LLM_ANSPIRE_EXTRA_HEADERS }}
LLM_OPENAI_PROTOCOL: ${{ vars.LLM_OPENAI_PROTOCOL || secrets.LLM_OPENAI_PROTOCOL }}
LLM_OPENAI_API_SURFACE: ${{ vars.LLM_OPENAI_API_SURFACE || secrets.LLM_OPENAI_API_SURFACE }}
LLM_OPENAI_BASE_URL: ${{ vars.LLM_OPENAI_BASE_URL || secrets.LLM_OPENAI_BASE_URL }}
LLM_OPENAI_API_KEY: ${{ secrets.LLM_OPENAI_API_KEY }}
LLM_OPENAI_API_KEYS: ${{ secrets.LLM_OPENAI_API_KEYS }}
@@ -180,6 +188,7 @@ jobs:
LLM_OPENAI_EXTRA_HEADERS: ${{ vars.LLM_OPENAI_EXTRA_HEADERS || secrets.LLM_OPENAI_EXTRA_HEADERS }}
LLM_ANTHROPIC_PROTOCOL: ${{ vars.LLM_ANTHROPIC_PROTOCOL || secrets.LLM_ANTHROPIC_PROTOCOL }}
LLM_ANTHROPIC_API_SURFACE: ${{ vars.LLM_ANTHROPIC_API_SURFACE || secrets.LLM_ANTHROPIC_API_SURFACE }}
LLM_ANTHROPIC_BASE_URL: ${{ vars.LLM_ANTHROPIC_BASE_URL || secrets.LLM_ANTHROPIC_BASE_URL }}
LLM_ANTHROPIC_API_KEY: ${{ secrets.LLM_ANTHROPIC_API_KEY }}
LLM_ANTHROPIC_API_KEYS: ${{ secrets.LLM_ANTHROPIC_API_KEYS }}
@@ -188,6 +197,7 @@ jobs:
LLM_ANTHROPIC_EXTRA_HEADERS: ${{ vars.LLM_ANTHROPIC_EXTRA_HEADERS || secrets.LLM_ANTHROPIC_EXTRA_HEADERS }}
LLM_MOONSHOT_PROTOCOL: ${{ vars.LLM_MOONSHOT_PROTOCOL || secrets.LLM_MOONSHOT_PROTOCOL }}
LLM_MOONSHOT_API_SURFACE: ${{ vars.LLM_MOONSHOT_API_SURFACE || secrets.LLM_MOONSHOT_API_SURFACE }}
LLM_MOONSHOT_BASE_URL: ${{ vars.LLM_MOONSHOT_BASE_URL || secrets.LLM_MOONSHOT_BASE_URL }}
LLM_MOONSHOT_API_KEY: ${{ secrets.LLM_MOONSHOT_API_KEY }}
LLM_MOONSHOT_API_KEYS: ${{ secrets.LLM_MOONSHOT_API_KEYS }}
@@ -196,6 +206,7 @@ jobs:
LLM_MOONSHOT_EXTRA_HEADERS: ${{ vars.LLM_MOONSHOT_EXTRA_HEADERS || secrets.LLM_MOONSHOT_EXTRA_HEADERS }}
LLM_DASHSCOPE_PROTOCOL: ${{ vars.LLM_DASHSCOPE_PROTOCOL || secrets.LLM_DASHSCOPE_PROTOCOL }}
LLM_DASHSCOPE_API_SURFACE: ${{ vars.LLM_DASHSCOPE_API_SURFACE || secrets.LLM_DASHSCOPE_API_SURFACE }}
LLM_DASHSCOPE_BASE_URL: ${{ vars.LLM_DASHSCOPE_BASE_URL || secrets.LLM_DASHSCOPE_BASE_URL }}
LLM_DASHSCOPE_API_KEY: ${{ secrets.LLM_DASHSCOPE_API_KEY }}
LLM_DASHSCOPE_API_KEYS: ${{ secrets.LLM_DASHSCOPE_API_KEYS }}
@@ -204,6 +215,7 @@ jobs:
LLM_DASHSCOPE_EXTRA_HEADERS: ${{ vars.LLM_DASHSCOPE_EXTRA_HEADERS || secrets.LLM_DASHSCOPE_EXTRA_HEADERS }}
LLM_ZHIPU_PROTOCOL: ${{ vars.LLM_ZHIPU_PROTOCOL || secrets.LLM_ZHIPU_PROTOCOL }}
LLM_ZHIPU_API_SURFACE: ${{ vars.LLM_ZHIPU_API_SURFACE || secrets.LLM_ZHIPU_API_SURFACE }}
LLM_ZHIPU_BASE_URL: ${{ vars.LLM_ZHIPU_BASE_URL || secrets.LLM_ZHIPU_BASE_URL }}
LLM_ZHIPU_API_KEY: ${{ secrets.LLM_ZHIPU_API_KEY }}
LLM_ZHIPU_API_KEYS: ${{ secrets.LLM_ZHIPU_API_KEYS }}
@@ -212,6 +224,7 @@ jobs:
LLM_ZHIPU_EXTRA_HEADERS: ${{ vars.LLM_ZHIPU_EXTRA_HEADERS || secrets.LLM_ZHIPU_EXTRA_HEADERS }}
LLM_MINIMAX_PROTOCOL: ${{ vars.LLM_MINIMAX_PROTOCOL || secrets.LLM_MINIMAX_PROTOCOL }}
LLM_MINIMAX_API_SURFACE: ${{ vars.LLM_MINIMAX_API_SURFACE || secrets.LLM_MINIMAX_API_SURFACE }}
LLM_MINIMAX_BASE_URL: ${{ vars.LLM_MINIMAX_BASE_URL || secrets.LLM_MINIMAX_BASE_URL }}
LLM_MINIMAX_API_KEY: ${{ secrets.LLM_MINIMAX_API_KEY }}
LLM_MINIMAX_API_KEYS: ${{ secrets.LLM_MINIMAX_API_KEYS }}
@@ -220,6 +233,7 @@ jobs:
LLM_MINIMAX_EXTRA_HEADERS: ${{ vars.LLM_MINIMAX_EXTRA_HEADERS || secrets.LLM_MINIMAX_EXTRA_HEADERS }}
LLM_VOLCENGINE_PROTOCOL: ${{ vars.LLM_VOLCENGINE_PROTOCOL || secrets.LLM_VOLCENGINE_PROTOCOL }}
LLM_VOLCENGINE_API_SURFACE: ${{ vars.LLM_VOLCENGINE_API_SURFACE || secrets.LLM_VOLCENGINE_API_SURFACE }}
LLM_VOLCENGINE_BASE_URL: ${{ vars.LLM_VOLCENGINE_BASE_URL || secrets.LLM_VOLCENGINE_BASE_URL }}
LLM_VOLCENGINE_API_KEY: ${{ secrets.LLM_VOLCENGINE_API_KEY }}
LLM_VOLCENGINE_API_KEYS: ${{ secrets.LLM_VOLCENGINE_API_KEYS }}
@@ -228,6 +242,7 @@ jobs:
LLM_VOLCENGINE_EXTRA_HEADERS: ${{ vars.LLM_VOLCENGINE_EXTRA_HEADERS || secrets.LLM_VOLCENGINE_EXTRA_HEADERS }}
LLM_SILICONFLOW_PROTOCOL: ${{ vars.LLM_SILICONFLOW_PROTOCOL || secrets.LLM_SILICONFLOW_PROTOCOL }}
LLM_SILICONFLOW_API_SURFACE: ${{ vars.LLM_SILICONFLOW_API_SURFACE || secrets.LLM_SILICONFLOW_API_SURFACE }}
LLM_SILICONFLOW_BASE_URL: ${{ vars.LLM_SILICONFLOW_BASE_URL || secrets.LLM_SILICONFLOW_BASE_URL }}
LLM_SILICONFLOW_API_KEY: ${{ secrets.LLM_SILICONFLOW_API_KEY }}
LLM_SILICONFLOW_API_KEYS: ${{ secrets.LLM_SILICONFLOW_API_KEYS }}
@@ -236,6 +251,7 @@ jobs:
LLM_SILICONFLOW_EXTRA_HEADERS: ${{ vars.LLM_SILICONFLOW_EXTRA_HEADERS || secrets.LLM_SILICONFLOW_EXTRA_HEADERS }}
LLM_OPENROUTER_PROTOCOL: ${{ vars.LLM_OPENROUTER_PROTOCOL || secrets.LLM_OPENROUTER_PROTOCOL }}
LLM_OPENROUTER_API_SURFACE: ${{ vars.LLM_OPENROUTER_API_SURFACE || secrets.LLM_OPENROUTER_API_SURFACE }}
LLM_OPENROUTER_BASE_URL: ${{ vars.LLM_OPENROUTER_BASE_URL || secrets.LLM_OPENROUTER_BASE_URL }}
LLM_OPENROUTER_API_KEY: ${{ secrets.LLM_OPENROUTER_API_KEY }}
LLM_OPENROUTER_API_KEYS: ${{ secrets.LLM_OPENROUTER_API_KEYS }}
@@ -244,6 +260,7 @@ jobs:
LLM_OPENROUTER_EXTRA_HEADERS: ${{ vars.LLM_OPENROUTER_EXTRA_HEADERS || secrets.LLM_OPENROUTER_EXTRA_HEADERS }}
LLM_OLLAMA_PROTOCOL: ${{ vars.LLM_OLLAMA_PROTOCOL || secrets.LLM_OLLAMA_PROTOCOL }}
LLM_OLLAMA_API_SURFACE: ${{ vars.LLM_OLLAMA_API_SURFACE || secrets.LLM_OLLAMA_API_SURFACE }}
LLM_OLLAMA_BASE_URL: ${{ vars.LLM_OLLAMA_BASE_URL || secrets.LLM_OLLAMA_BASE_URL }}
LLM_OLLAMA_API_KEY: ${{ secrets.LLM_OLLAMA_API_KEY }}
LLM_OLLAMA_API_KEYS: ${{ secrets.LLM_OLLAMA_API_KEYS }}

View File

@@ -582,6 +582,7 @@ def test_llm_channel(
payload = service.test_llm_channel(
name=request.name,
protocol=request.protocol,
api_surface=request.api_surface,
base_url=request.base_url,
api_key=request.api_key,
models=request.models,

View File

@@ -99,6 +99,7 @@ class SystemConfigResponse(BaseModel):
config_version: str
mask_token: str
items: List[SystemConfigItem]
llm_model_providers: List[str] = Field(default_factory=list)
updated_at: Optional[str] = None
@@ -275,6 +276,7 @@ class TestLLMChannelRequest(BaseModel):
name: str = "channel"
protocol: str = "openai"
api_surface: Literal["chat_completions", "responses"] = "chat_completions"
base_url: str = ""
api_key: str = ""
models: List[str] = Field(default_factory=list)
@@ -307,6 +309,7 @@ class TestLLMChannelResponse(BaseModel):
retryable: Optional[bool] = None
details: Dict[str, Any] = Field(default_factory=dict)
resolved_protocol: Optional[str] = None
resolved_api_surface: Optional[str] = None
resolved_model: Optional[str] = None
latency_ms: Optional[int] = None
capability_results: Dict[str, LLMCapabilityCheckResult] = Field(default_factory=dict)

View File

@@ -33,6 +33,25 @@ describe('systemConfigApi', () => {
});
});
it('maps backend LiteLLM provider metadata into the Web config contract', async () => {
get.mockResolvedValueOnce({
data: {
config_version: 'v1',
mask_token: '******',
items: [],
llm_model_providers: ['openai', 'xai'],
updated_at: null,
},
});
const config = await systemConfigApi.getConfig(false);
expect(get).toHaveBeenCalledWith('/api/v1/system/config', {
params: { include_schema: false },
});
expect(config.llmModelProviders).toEqual(['openai', 'xai']);
});
it('omits capability_checks from basic LLM channel test payloads', async () => {
await systemConfigApi.testLLMChannel({
name: 'openai',
@@ -64,6 +83,22 @@ describe('systemConfigApi', () => {
);
});
it('sends the selected LLM API surface', async () => {
await systemConfigApi.testLLMChannel({
name: 'anspire',
protocol: 'openai',
apiSurface: 'responses',
baseUrl: 'https://open-gateway.anspire.cn/v6',
apiKey: 'sk-test',
models: ['gpt-5.6-sol'],
});
expect(post).toHaveBeenCalledWith(
'/api/v1/system/config/llm/test-channel',
expect.objectContaining({ api_surface: 'responses' }),
);
});
it('sends notification channel test payloads with snake_case fields', async () => {
post.mockResolvedValueOnce({
data: {

View File

@@ -98,6 +98,7 @@ function toSnakeTestChannelPayload(payload: TestLLMChannelRequest): Record<strin
const request: Record<string, unknown> = {
name: payload.name,
protocol: payload.protocol,
api_surface: payload.apiSurface ?? 'chat_completions',
base_url: payload.baseUrl ?? '',
api_key: payload.apiKey ?? '',
models: payload.models,

View File

@@ -3,7 +3,7 @@ import type React from 'react';
import type { ParsedApiError } from '../../api/error';
import { getParsedApiError } from '../../api/error';
import { systemConfigApi } from '../../api/systemConfig';
import type { LLMCapabilityCheck, LLMCapabilityCheckResult } from '../../types/systemConfig';
import type { LLMApiSurface, LLMCapabilityCheck, LLMCapabilityCheckResult } from '../../types/systemConfig';
import { ApiErrorAlert, Badge, Button, InlineAlert, Input, Select, StatusDot, Tooltip } from '../common';
import type { ChannelProtocol } from './llmProviderTemplates';
import {
@@ -24,32 +24,13 @@ const PROTOCOL_OPTIONS: Array<{ value: ChannelProtocol; label: string }> = [
{ value: 'ollama', label: 'Ollama' },
];
const KNOWN_MODEL_PREFIXES = new Set([
'openai',
'anthropic',
'gemini',
'vertex_ai',
'deepseek',
'minimax',
'ollama',
'cohere',
'huggingface',
'bedrock',
'sagemaker',
'azure',
'replicate',
'together_ai',
'palm',
'text-completion-openai',
'command-r',
'groq',
'cerebras',
'fireworks_ai',
'friendliai',
]);
const API_SURFACE_OPTIONS: Array<{ value: LLMApiSurface; label: string }> = [
{ value: 'chat_completions', label: 'Chat Completions默认' },
{ value: 'responses', label: 'Responses API' },
];
const CHANNEL_FIELD_SUFFIXES = ['PROTOCOL', 'BASE_URL', 'API_KEY', 'API_KEYS', 'MODELS', 'EXTRA_HEADERS', 'ENABLED'] as const;
const CHANNEL_FIELD_KEY_PATTERN = /^LLM_([A-Z0-9_]+)_(PROTOCOL|BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS|ENABLED)$/;
const CHANNEL_FIELD_SUFFIXES = ['PROTOCOL', 'API_SURFACE', 'BASE_URL', 'API_KEY', 'API_KEYS', 'MODELS', 'EXTRA_HEADERS', 'ENABLED'] as const;
const CHANNEL_FIELD_KEY_PATTERN = /^LLM_([A-Z0-9_]+)_(PROTOCOL|API_SURFACE|BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS|ENABLED)$/;
const FALSEY_VALUES = new Set(['0', 'false', 'no', 'off']);
const HERMES_CHANNEL_NAME = 'hermes';
const HERMES_DEFAULT_MODEL = 'hermes-agent';
@@ -119,6 +100,7 @@ interface ChannelConfig {
id: string;
name: string;
protocol: ChannelProtocol;
apiSurface: string;
baseUrl: string;
apiKey: string;
models: string;
@@ -158,6 +140,7 @@ interface LLMChannelEditorProps {
items: Array<{ key: string; value: string; rawValueExists?: boolean }>;
configVersion: string;
maskToken: string;
modelProviderPrefixes?: string[];
onSaved: (updatedItems: Array<{ key: string; value: string }>) => void | Promise<void>;
onDraftItemsChange?: (items: Array<{ key: string; value: string }>) => void;
disabled?: boolean;
@@ -172,6 +155,7 @@ interface ChannelRowProps {
testState?: ChannelTestState;
discoveryState?: ChannelDiscoveryState;
capabilityState?: ChannelCapabilityState;
modelProviderPrefixes: ReadonlySet<string>;
onUpdate: (index: number, field: keyof ChannelConfig, value: string | boolean) => void;
onRemove: (index: number) => void;
onToggleExpand: (index: number) => void;
@@ -231,6 +215,7 @@ function parseChannelFieldKeys(channel: ChannelConfig): string[] {
const upperName = channel.name.trim().toUpperCase();
return [
`LLM_${upperName}_PROTOCOL`,
`LLM_${upperName}_API_SURFACE`,
`LLM_${upperName}_BASE_URL`,
`LLM_${upperName}_ENABLED`,
`LLM_${upperName}_API_KEY`,
@@ -379,6 +364,9 @@ function buildChangedItemKeys(
if (current.protocol !== previous.protocol) {
changedKeys.add(`${prefix}_PROTOCOL`);
}
if (current.apiSurface !== previous.apiSurface) {
changedKeys.add(`${prefix}_API_SURFACE`);
}
if (current.baseUrl !== previous.baseUrl) {
changedKeys.add(`${prefix}_BASE_URL`);
}
@@ -406,6 +394,7 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
testState,
discoveryState,
capabilityState,
modelProviderPrefixes,
onUpdate,
onRemove,
onToggleExpand,
@@ -427,7 +416,9 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
: RUNTIME_CAPABILITY_OPTIONS;
const discoveredModels = discoveryState?.models || [];
const manualOnlyModels = selectedModels.filter(
(model) => !discoveredModels.some((discoveredModel) => areModelsEquivalent(model, discoveredModel, channel.protocol)),
(model) => !discoveredModels.some((discoveredModel) => (
areModelsEquivalent(model, discoveredModel, channel.protocol, modelProviderPrefixes)
)),
);
const modelCount = selectedModels.length;
const hasKey = channel.apiKey.length > 0;
@@ -443,9 +434,16 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
const capabilityBusy = capabilityState?.status === 'loading';
const channelNameInputId = `llm-channel-${channel.id}-name`;
const protocolInputId = `llm-channel-${channel.id}-protocol`;
const apiSurfaceInputId = `llm-channel-${channel.id}-api-surface`;
const baseUrlInputId = `llm-channel-${channel.id}-base-url`;
const apiKeyInputId = `llm-channel-${channel.id}-api-key`;
const modelsInputId = `llm-channel-${channel.id}-models`;
const apiSurfaceOptions = API_SURFACE_OPTIONS.some((option) => option.value === channel.apiSurface)
? API_SURFACE_OPTIONS
: [
{ value: channel.apiSurface, label: `无效配置:${channel.apiSurface}` },
...API_SURFACE_OPTIONS,
];
return (
<div className="mb-2 overflow-hidden rounded-xl border border-[var(--settings-border)] bg-[var(--settings-surface)] shadow-soft-card transition-[background-color,border-color,box-shadow] duration-200 hover:border-[var(--settings-border-strong)] hover:bg-[var(--settings-surface-hover)]">
@@ -535,7 +533,7 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
{expanded ? (
<div className="settings-surface-overlay-soft space-y-4 px-4 py-4">
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2 sm:grid-cols-3">
<div>
<HelpLabel
htmlFor={channelNameInputId}
@@ -569,6 +567,23 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
placeholder="选择协议"
/>
</div>
<div className="space-y-2">
<HelpLabel
htmlFor={apiSurfaceInputId}
label="API Surface"
fieldKey="LLM_CHANNEL_API_SURFACE"
helpKey="settings.llm_channel.api_surface"
examples={['LLM_ANSPIRE_API_SURFACE=responses', 'LLM_OPENAI_API_SURFACE=chat_completions']}
/>
<Select
id={apiSurfaceInputId}
value={channel.apiSurface}
onChange={(value) => onUpdate(index, 'apiSurface', value)}
options={apiSurfaceOptions}
disabled={busy || (isHermesChannel(channel) && channel.apiSurface === 'chat_completions')}
placeholder="选择 API Surface"
/>
</div>
</div>
<div>
@@ -699,10 +714,14 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
<input
type="checkbox"
checked={selectedModels.some((selectedModel) => (
areModelsEquivalent(selectedModel, model, channel.protocol)
areModelsEquivalent(selectedModel, model, channel.protocol, modelProviderPrefixes)
))}
disabled={busy}
onChange={() => onUpdate(index, 'models', toggleModelSelection(channel.models, model, channel.protocol))}
onChange={() => onUpdate(
index,
'models',
toggleModelSelection(channel.models, model, channel.protocol, modelProviderPrefixes),
)}
className="settings-input-checkbox h-4 w-4 rounded border-border/70 bg-base"
/>
<span>{model}</span>
@@ -958,8 +977,12 @@ function parseModelRef(model: string): ParsedModelRef {
};
}
function getModelComparisonKey(model: string, protocol: ChannelProtocol): string {
const normalizedModel = normalizeModelForRuntime(model, protocol).trim();
function getModelComparisonKey(
model: string,
protocol: ChannelProtocol,
modelProviderPrefixes: ReadonlySet<string>,
): string {
const normalizedModel = normalizeModelForRuntime(model, protocol, modelProviderPrefixes).trim();
const parsed = parseModelRef(normalizedModel);
if (!parsed.name) {
return '';
@@ -967,15 +990,27 @@ function getModelComparisonKey(model: string, protocol: ChannelProtocol): string
return `${parsed.provider}/${parsed.name}`;
}
function areModelsEquivalent(a: string, b: string, protocol: ChannelProtocol): boolean {
const left = getModelComparisonKey(a, protocol);
const right = getModelComparisonKey(b, protocol);
function areModelsEquivalent(
a: string,
b: string,
protocol: ChannelProtocol,
modelProviderPrefixes: ReadonlySet<string>,
): boolean {
const left = getModelComparisonKey(a, protocol, modelProviderPrefixes);
const right = getModelComparisonKey(b, protocol, modelProviderPrefixes);
return left !== '' && left === right;
}
function toggleModelSelection(models: string, targetModel: string, protocol: ChannelProtocol): string {
function toggleModelSelection(
models: string,
targetModel: string,
protocol: ChannelProtocol,
modelProviderPrefixes: ReadonlySet<string>,
): string {
const selectedModels = splitModels(models);
const index = selectedModels.findIndex((model) => areModelsEquivalent(model, targetModel, protocol));
const index = selectedModels.findIndex((model) => (
areModelsEquivalent(model, targetModel, protocol, modelProviderPrefixes)
));
if (index >= 0) {
return selectedModels.filter((_, itemIndex) => itemIndex !== index).join(',');
}
@@ -991,7 +1026,11 @@ const PROTOCOL_ALIASES: Record<string, string> = {
openai_compat: 'openai',
};
function normalizeModelForRuntime(model: string, protocol: ChannelProtocol): string {
function normalizeModelForRuntime(
model: string,
protocol: ChannelProtocol,
modelProviderPrefixes: ReadonlySet<string>,
): string {
const trimmedModel = model.trim();
if (!trimmedModel) {
return trimmedModel;
@@ -1001,8 +1040,9 @@ function normalizeModelForRuntime(model: string, protocol: ChannelProtocol): str
const rawPrefix = trimmedModel.split('/', 1)[0].trim();
const lowerPrefix = rawPrefix.toLowerCase();
const canonicalPrefix = PROTOCOL_ALIASES[lowerPrefix] || lowerPrefix;
if (KNOWN_MODEL_PREFIXES.has(lowerPrefix) || KNOWN_MODEL_PREFIXES.has(canonicalPrefix)) {
if (canonicalPrefix !== lowerPrefix && KNOWN_MODEL_PREFIXES.has(canonicalPrefix)) {
const isProtocolPrefix = canonicalPrefix === protocol;
if (isProtocolPrefix || modelProviderPrefixes.has(lowerPrefix) || modelProviderPrefixes.has(canonicalPrefix)) {
if (canonicalPrefix !== lowerPrefix && (isProtocolPrefix || modelProviderPrefixes.has(canonicalPrefix))) {
return `${canonicalPrefix}/${trimmedModel.split('/').slice(1).join('/')}`;
}
return trimmedModel;
@@ -1013,8 +1053,14 @@ function normalizeModelForRuntime(model: string, protocol: ChannelProtocol): str
return `${protocol}/${trimmedModel}`;
}
function resolveModelPreview(models: string, protocol: ChannelProtocol): string[] {
return splitModels(models).map((model) => normalizeModelForRuntime(model, protocol));
function resolveModelPreview(
models: string,
protocol: ChannelProtocol,
modelProviderPrefixes: ReadonlySet<string>,
): string[] {
return splitModels(models).map((model) => (
normalizeModelForRuntime(model, protocol, modelProviderPrefixes)
));
}
interface RouteProvenance {
@@ -1023,22 +1069,28 @@ interface RouteProvenance {
hasNonHermes: boolean;
}
function resolveChannelRouteModels(channel: ChannelConfig): string[] {
function resolveChannelRouteModels(
channel: ChannelConfig,
modelProviderPrefixes: ReadonlySet<string>,
): string[] {
if (isHermesChannel(channel)) {
const models = splitModels(channel.models);
return (models.length > 0 ? models : [HERMES_DEFAULT_MODEL]).map(canonicalizeHermesRouteModel);
}
return resolveModelPreview(channel.models, channel.protocol);
return resolveModelPreview(channel.models, channel.protocol, modelProviderPrefixes);
}
function buildRouteProvenanceMap(channels: ChannelConfig[]): Map<string, RouteProvenance> {
function buildRouteProvenanceMap(
channels: ChannelConfig[],
modelProviderPrefixes: ReadonlySet<string>,
): Map<string, RouteProvenance> {
const provenance = new Map<string, RouteProvenance>();
for (const channel of channels) {
if (!channel.enabled || !channel.name.trim()) {
continue;
}
const hermes = isHermesChannel(channel);
for (const routeName of resolveChannelRouteModels(channel)) {
for (const routeName of resolveChannelRouteModels(channel, modelProviderPrefixes)) {
if (!routeName) continue;
const existing = provenance.get(routeName) || {
routeName,
@@ -1069,6 +1121,7 @@ function buildModelOptions(models: string[], selectedModel: string, autoLabel: s
const LLM_STAGE_LABELS: Record<string, string> = {
model_discovery: '模型发现',
chat_completion: '聊天调用',
responses: 'Responses 调用',
response_parse: '响应解析',
capability_json: 'JSON 能力',
capability_tools: 'Tools 能力',
@@ -1359,6 +1412,17 @@ function parseRuntimeConfigFromItems(items: Array<{ key: string; value: string }
};
}
function normalizeApiSurface(value: string | undefined): string {
const normalized = (value || '').trim().toLowerCase().replaceAll('-', '_');
if (normalized === 'responses' || normalized === 'response' || normalized === 'responses_api') {
return 'responses';
}
if (!normalized || normalized === 'chat' || normalized === 'chat_completion' || normalized === 'completions') {
return 'chat_completions';
}
return normalized;
}
function parseChannelsFromItems(
items: Array<{ key: string; value: string }>,
itemSourceByKey: Map<string, boolean> = new Map(),
@@ -1379,6 +1443,7 @@ function parseChannelsFromItems(
id: `parsed:${index}:${upperName}`,
name: name.toLowerCase(),
protocol: inferProtocol(itemMap.get(`LLM_${upperName}_PROTOCOL`) || '', baseUrl, models),
apiSurface: normalizeApiSurface(itemMap.get(`LLM_${upperName}_API_SURFACE`)),
baseUrl,
apiKey: resolveInitialChannelApiKeyValue(name, itemMap, itemSourceByKey),
models: rawModels,
@@ -1409,6 +1474,7 @@ function channelsToUpdateItems(
const prefix = `LLM_${channel.name.toUpperCase()}`;
const isMultiKey = channel.apiKey.includes(',');
updates.push({ key: `${prefix}_PROTOCOL`, value: channel.protocol });
updates.push({ key: `${prefix}_API_SURFACE`, value: channel.apiSurface });
updates.push({ key: `${prefix}_BASE_URL`, value: channel.baseUrl });
updates.push({ key: `${prefix}_ENABLED`, value: channel.enabled ? 'true' : 'false' });
if (isHermesChannel(channel)) {
@@ -1430,6 +1496,7 @@ function channelsToUpdateItems(
const prefix = `LLM_${upperName}`;
updates.push({ key: `${prefix}_PROTOCOL`, value: '' });
updates.push({ key: `${prefix}_API_SURFACE`, value: '' });
updates.push({ key: `${prefix}_BASE_URL`, value: '' });
updates.push({ key: `${prefix}_ENABLED`, value: '' });
updates.push({ key: `${prefix}_API_KEY`, value: '' });
@@ -1521,6 +1588,7 @@ function channelsAreEqual(left: ChannelConfig, right: ChannelConfig): boolean {
return (
left.name === right.name
&& left.protocol === right.protocol
&& left.apiSurface === right.apiSurface
&& left.baseUrl === right.baseUrl
&& left.apiKey === right.apiKey
&& left.models === right.models
@@ -1532,6 +1600,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
items,
configVersion,
maskToken,
modelProviderPrefixes = [],
onSaved,
onDraftItemsChange,
disabled = false,
@@ -1573,6 +1642,10 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
[items],
);
const managesRuntimeConfig = !hasLitellmConfig;
const modelProviderPrefixSet = useMemo(
() => new Set(modelProviderPrefixes.map((provider) => provider.trim().toLowerCase()).filter(Boolean)),
[modelProviderPrefixes],
);
const channelsFingerprint = useMemo(() => JSON.stringify(initialChannels), [initialChannels]);
const runtimeFingerprint = useMemo(() => JSON.stringify(initialRuntimeConfig), [initialRuntimeConfig]);
@@ -1636,8 +1709,8 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
if (!managesRuntimeConfig) {
return new Map<string, RouteProvenance>();
}
return buildRouteProvenanceMap(channels);
}, [channels, managesRuntimeConfig]);
return buildRouteProvenanceMap(channels, modelProviderPrefixSet);
}, [channels, managesRuntimeConfig, modelProviderPrefixSet]);
const availableModels = useMemo(
() => Array.from(routeProvenanceMap.values())
@@ -1746,6 +1819,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
updated.baseUrl = newPreset.baseUrl;
}
updated.protocol = newPreset.protocol;
updated.apiSurface = 'chat_completions';
if (!updated.models || updated.models === (oldPreset?.placeholderModels ?? '')) {
updated.models = newPreset.placeholderModels;
}
@@ -1846,6 +1920,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
id: `added:${addChannelIdRef.current += 1}`,
name: nextName,
protocol: preset.protocol,
apiSurface: 'chat_completions',
baseUrl: preset.baseUrl,
apiKey: '',
models: preset.placeholderModels || '',
@@ -1987,6 +2062,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
const result = await systemConfigApi.testLLMChannel({
name: channel.name,
protocol: channel.protocol,
apiSurface: channel.apiSurface as LLMApiSurface,
baseUrl: channel.baseUrl,
apiKey: channel.apiKey,
models: splitModels(channel.models),
@@ -2144,6 +2220,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
const result = await systemConfigApi.testLLMChannel({
name: channel.name,
protocol: channel.protocol,
apiSurface: channel.apiSurface as LLMApiSurface,
baseUrl: channel.baseUrl,
apiKey: channel.apiKey,
models: splitModels(channel.models),
@@ -2288,6 +2365,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
testState={testStates[index]}
discoveryState={discoveryStates[channel.id]}
capabilityState={capabilityStates[channel.id]}
modelProviderPrefixes={modelProviderPrefixSet}
onUpdate={updateChannel}
onRemove={removeChannel}
onToggleExpand={toggleExpand}

View File

@@ -107,6 +107,139 @@ describe('LLMChannelEditor', () => {
});
});
it('loads and tests a multi-model Responses API channel without changing public model names', async () => {
testLLMChannel.mockResolvedValue({ success: true });
render(
<LLMChannelEditor
items={[
{ key: 'LLM_CHANNELS', value: 'anspire' },
{ key: 'LLM_ANSPIRE_PROTOCOL', value: 'openai' },
{ key: 'LLM_ANSPIRE_API_SURFACE', value: 'responses' },
{ key: 'LLM_ANSPIRE_BASE_URL', value: 'https://open-gateway.anspire.cn/v6' },
{ key: 'LLM_ANSPIRE_ENABLED', value: 'true' },
{ key: 'LLM_ANSPIRE_API_KEY', value: 'sk-test' },
{ key: 'LLM_ANSPIRE_MODELS', value: 'gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna' },
]}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole('button', { name: /Anspire Open/i }));
expect(await screen.findByLabelText('API Surface')).toHaveValue('responses');
fireEvent.click(screen.getByRole('button', { name: '测试连接' }));
await waitFor(() => expect(testLLMChannel).toHaveBeenCalledWith(expect.objectContaining({
apiSurface: 'responses',
models: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'],
})));
});
it.each(['response', 'responses_api', 'responses-api'])(
'canonicalizes the saved %s API-surface alias before an unrelated save',
async (apiSurfaceAlias) => {
update.mockResolvedValue({
success: true,
configVersion: 'v2',
appliedCount: 1,
skippedMaskedCount: 0,
reloadTriggered: true,
updatedKeys: ['LLM_OPENAI_API_SURFACE', 'LLM_OPENAI_BASE_URL'],
warnings: [],
});
render(
<LLMChannelEditor
items={[
...openAiItems,
{ key: 'LLM_OPENAI_API_SURFACE', value: apiSurfaceAlias },
]}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole('button', { name: /OpenAI/i }));
expect(await screen.findByLabelText('API Surface')).toHaveValue('responses');
fireEvent.change(screen.getByLabelText('Base URL'), {
target: { value: 'https://proxy.example.com/v1' },
});
fireEvent.click(screen.getByRole('button', { name: '保存 AI 配置' }));
await waitFor(() => expect(update).toHaveBeenCalled());
const updateItemMap = new Map(
update.mock.calls[0][0].items.map((item: { key: string; value: string }) => [item.key, item.value]),
);
expect(updateItemMap.get('LLM_OPENAI_API_SURFACE')).toBe('responses');
},
);
it('preserves an invalid saved API surface during an unrelated save', async () => {
update.mockResolvedValue({
success: true,
configVersion: 'v2',
appliedCount: 1,
skippedMaskedCount: 0,
reloadTriggered: true,
updatedKeys: ['LLM_OPENAI_BASE_URL'],
warnings: [],
});
render(
<LLMChannelEditor
items={[
...openAiItems,
{ key: 'LLM_OPENAI_API_SURFACE', value: 'respones' },
]}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole('button', { name: /OpenAI/i }));
expect(await screen.findByLabelText('API Surface')).toHaveValue('respones');
expect(screen.getByRole('option', { name: '无效配置respones' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Base URL'), {
target: { value: 'https://proxy.example.com/v1' },
});
fireEvent.click(screen.getByRole('button', { name: '保存 AI 配置' }));
await waitFor(() => expect(update).toHaveBeenCalled());
const updateItemMap = new Map(
update.mock.calls[0][0].items.map((item: { key: string; value: string }) => [item.key, item.value]),
);
expect(updateItemMap.get('LLM_OPENAI_API_SURFACE')).toBe('respones');
});
it('allows an invalid Hermes API surface to be repaired to chat completions', async () => {
render(
<LLMChannelEditor
items={[
{ key: 'LLM_CHANNELS', value: 'hermes' },
{ key: 'LLM_HERMES_PROTOCOL', value: 'openai' },
{ key: 'LLM_HERMES_API_SURFACE', value: 'responses' },
{ key: 'LLM_HERMES_ENABLED', value: 'true' },
{ key: 'LLM_HERMES_API_KEY', value: 'sk-hermes-test-value' },
{ key: 'LLM_HERMES_MODELS', value: 'hermes-agent' },
]}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole('button', { name: /Hermes/i }));
const apiSurface = await screen.findByLabelText('API Surface');
expect(apiSurface).toHaveValue('responses');
expect(apiSurface).toBeEnabled();
fireEvent.change(apiSurface, { target: { value: 'chat_completions' } });
expect(apiSurface).toHaveValue('chat_completions');
});
it('returns to an empty generation backend draft after channel edits are restored', async () => {
const onDraftItemsChange = vi.fn();
render(
@@ -391,6 +524,7 @@ describe('LLMChannelEditor', () => {
]}
configVersion="v1"
maskToken="******"
modelProviderPrefixes={['minimax', 'openai']}
onSaved={() => {}}
/>
);
@@ -1009,6 +1143,7 @@ describe('LLMChannelEditor', () => {
]}
configVersion="v1"
maskToken="******"
modelProviderPrefixes={['cohere', 'openai']}
onSaved={() => {}}
/>,
);
@@ -1031,6 +1166,56 @@ describe('LLMChannelEditor', () => {
);
});
it('uses backend provider metadata to preserve direct provider routes in runtime selections', async () => {
update.mockResolvedValue({
success: true,
configVersion: 'v2',
appliedCount: 1,
skippedMaskedCount: 0,
reloadTriggered: true,
updatedKeys: ['LITELLM_MODEL'],
warnings: [],
});
render(
<LLMChannelEditor
items={[
{ key: 'LLM_CHANNELS', value: 'gateway' },
{ key: 'LLM_GATEWAY_PROTOCOL', value: 'openai' },
{ key: 'LLM_GATEWAY_BASE_URL', value: 'https://gateway.example.com/v1' },
{ key: 'LLM_GATEWAY_ENABLED', value: 'true' },
{ key: 'LLM_GATEWAY_API_KEY', value: 'sk-test' },
{ key: 'LLM_GATEWAY_MODELS', value: 'xai/grok-beta,deepseek-ai/DeepSeek-V3' },
{ key: 'LITELLM_MODEL', value: '' },
{ key: 'AGENT_LITELLM_MODEL', value: '' },
{ key: 'LITELLM_FALLBACK_MODELS', value: '' },
{ key: 'VISION_MODEL', value: '' },
]}
configVersion="v1"
maskToken="******"
modelProviderPrefixes={['openai', 'xai']}
onSaved={() => {}}
/>,
);
expect(selectOptionValues('\u4e3b\u6a21\u578b')).toEqual(expect.arrayContaining([
'xai/grok-beta',
'openai/deepseek-ai/DeepSeek-V3',
]));
expect(selectOptionValues('\u4e3b\u6a21\u578b')).not.toContain('openai/xai/grok-beta');
fireEvent.change(screen.getByLabelText('\u4e3b\u6a21\u578b'), {
target: { value: 'xai/grok-beta' },
});
fireEvent.click(screen.getByRole('button', { name: /\u4fdd\u5b58 AI \u914d\u7f6e/ }));
await waitFor(() => expect(update).toHaveBeenCalled());
expect(update.mock.calls[0][0].items).toContainEqual({
key: 'LITELLM_MODEL',
value: 'xai/grok-beta',
});
});
it('sanitizes stale runtime models when enabled channels have no available models', async () => {
update.mockResolvedValue({
success: true,

View File

@@ -73,6 +73,7 @@ export function useSystemConfig() {
const [configVersion, setConfigVersion] = useState<string>('');
const [maskToken, setMaskToken] = useState<string>('******');
const [serverItems, setServerItems] = useState<SystemConfigItem[]>([]);
const [llmModelProviders, setLlmModelProviders] = useState<string[]>([]);
// UI state
const [draftValues, setDraftValues] = useState<Record<string, string>>({});
@@ -226,6 +227,7 @@ export function useSystemConfig() {
try {
const config = await systemConfigApi.getConfig(true);
setLlmModelProviders(config.llmModelProviders || []);
applyServerPayload(config.items, config.configVersion, config.maskToken);
setToast(null);
return true;
@@ -261,6 +263,7 @@ export function useSystemConfig() {
const refreshAfterExternalSave = useCallback(
async (committedKeys: string[]) => {
const config = await systemConfigApi.getConfig(true);
setLlmModelProviders(config.llmModelProviders || []);
applyServerPayload(config.items, config.configVersion, config.maskToken, {
preserveDirty: true,
committedKeys,
@@ -338,6 +341,7 @@ export function useSystemConfig() {
});
const refreshed = await systemConfigApi.getConfig(true);
setLlmModelProviders(refreshed.llmModelProviders || []);
applyServerPayload(refreshed.items, refreshed.configVersion, refreshed.maskToken);
const warningText = updateResult.warnings?.length
@@ -394,6 +398,7 @@ export function useSystemConfig() {
configVersion,
maskToken,
serverItems,
llmModelProviders,
categories,
itemsByCategory,
issueByKey,

View File

@@ -728,6 +728,14 @@ const settingsHelpZhCN: SettingsHelpMap = {
impact: ['影响请求适配器、模型列表解析和运行时模型引用。'],
notes: ['协议与 Base URL、API Key 所属服务必须匹配。'],
},
'settings.llm_channel.api_surface': {
title: 'API Surface',
summary: '选择该渠道实际调用 Chat Completions 还是 Responses API。',
usage: '绝大多数兼容服务保持默认;仅在模型明确要求 Responses API 时选择 Responses。',
valueNotes: ['一个渠道内的模型共享同一 API Surface同一模型别名跨渠道也不能混用两种 Surface需要时请使用不同别名。'],
impact: ['影响连接测试、普通分析、Agent、流式输出和工具调用的实际端点。'],
notes: ['Responses 当前仅支持 OpenAI Compatible 协议,模型不能显式使用 anthropic/、gemini/、xai/ 等其他 LiteLLM provider 前缀;不会在失败后自动切换端点。'],
},
'settings.llm_channel.base_url': {
title: 'Base URL',
summary: '该渠道的接口根地址。',
@@ -1907,6 +1915,14 @@ const settingsHelpEnUS: SettingsHelpMap = {
impact: ['Affects request adapters, model parsing, and runtime model references.'],
notes: ['Protocol, Base URL, and API Key must belong to the same service.'],
},
'settings.llm_channel.api_surface': {
title: 'API Surface',
summary: 'Selects whether the channel calls Chat Completions or the Responses API.',
usage: 'Keep the default for most compatible services. Select Responses only when the model requires it.',
valueNotes: ['All models in one channel share the same API surface. A route alias also cannot mix surfaces across channels; use distinct aliases when both are needed.'],
impact: ['Affects the actual endpoint used by connection tests, analysis, Agent, streaming, and tool calls.'],
notes: ['Responses currently requires the OpenAI Compatible protocol, and models cannot explicitly use other LiteLLM provider prefixes such as anthropic/, gemini/, or xai/. It never auto-switches after a failure.'],
},
'settings.llm_channel.base_url': {
title: 'Base URL',
summary: 'Endpoint root for this channel.',

View File

@@ -132,7 +132,7 @@ const GENERATION_BACKEND_STATUS_KEYS = new Set([
'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)$/;
const LLM_CHANNEL_STATUS_KEY_PATTERN = /^LLM_[A-Z0-9_]+_(PROTOCOL|API_SURFACE|BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS|ENABLED)$/;
const AGENT_BACKEND_STATUS_KEYS = new Set([
'AGENT_BACKEND',
'AGENT_GENERATION_BACKEND',
@@ -914,6 +914,7 @@ const SettingsPage: React.FC = () => {
refreshAfterExternalSave,
configVersion,
maskToken,
llmModelProviders,
} = useSystemConfig();
const currentChangedItems = getChangedItems();
@@ -1071,7 +1072,7 @@ const SettingsPage: React.FC = () => {
// UI rendering rule only: hide channel-managed and legacy provider-specific
// LLM keys from generic fields when channel mode is active. This does not
// alter save/refresh payloads or config migration/rollback behavior.
const LLM_CHANNEL_KEY_RE = /^LLM_[A-Z0-9_]+_(PROTOCOL|BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS|ENABLED)$/;
const LLM_CHANNEL_KEY_RE = /^LLM_[A-Z0-9_]+_(PROTOCOL|API_SURFACE|BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS|ENABLED)$/;
const AI_MODEL_HIDDEN_KEYS = new Set([
'LLM_CHANNELS',
'LLM_TEMPERATURE',
@@ -1807,6 +1808,7 @@ const SettingsPage: React.FC = () => {
items={rawActiveItems}
configVersion={configVersion}
maskToken={maskToken}
modelProviderPrefixes={llmModelProviders}
onDraftItemsChange={handleLlmChannelDraftItemsChange}
onSaved={async (updatedItems) => {
setLlmChannelDraftItems([]);

View File

@@ -137,6 +137,7 @@ vi.mock('../../components/settings', () => ({
type="button"
onClick={() => onDraftItemsChange?.([
{ key: 'LLM_CHANNELS', value: 'draft,backup' },
{ key: 'LLM_DRAFT_API_SURFACE', value: 'responses' },
{ key: 'LITELLM_MODEL', value: 'openai/draft-model' },
{ key: 'GENERATION_BACKEND', value: 'codex_cli' },
])}
@@ -1241,6 +1242,7 @@ describe('SettingsPage', () => {
await waitFor(() => {
expect(statusItems).toHaveTextContent('GENERATION_BACKEND=litellm');
expect(statusItems).toHaveTextContent('LLM_CHANNELS=draft,backup');
expect(statusItems).toHaveTextContent('LLM_DRAFT_API_SURFACE=responses');
expect(statusItems).toHaveTextContent('LITELLM_MODEL=openai/draft-model');
expect(statusItems).toHaveTextContent('OPENAI_MODEL=gpt-draft');
expect(statusItems).toHaveTextContent('GEMINI_MODEL=gemini-draft');

View File

@@ -81,6 +81,7 @@ export interface SystemConfigResponse {
configVersion: string;
maskToken: string;
items: SystemConfigItem[];
llmModelProviders?: string[];
updatedAt?: string;
}
@@ -238,6 +239,7 @@ export interface SchedulerRunNowResponse {
export interface TestLLMChannelRequest {
name: string;
protocol: string;
apiSurface?: LLMApiSurface;
baseUrl?: string;
apiKey?: string;
models: string[];
@@ -247,6 +249,8 @@ export interface TestLLMChannelRequest {
useSavedSecret?: boolean;
}
export type LLMApiSurface = 'chat_completions' | 'responses';
export type LLMCapabilityCheck = 'json' | 'tools' | 'vision' | 'stream';
export interface LLMCapabilityCheckResult {
@@ -268,6 +272,7 @@ export interface TestLLMChannelResponse {
retryable?: boolean | null;
details?: Record<string, unknown>;
resolvedProtocol?: string | null;
resolvedApiSurface?: LLMApiSurface | null;
resolvedModel?: string | null;
latencyMs?: number | null;
capabilityResults?: Partial<Record<LLMCapabilityCheck, LLMCapabilityCheckResult>>;

View File

@@ -44,6 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [文档] FAQ 补充 macOS 桌面应用被 Gatekeeper quarantine 阻止启动时的受信任安装包临时放行步骤refs #2113)。
- [新功能] LLM 渠道新增显式 Chat Completions / Responses API Surface支持 Anspire GPT-5.6 系列等 Responses-only 模型,并统一连接测试、主分析、筛选、图片识别与状态诊断路由;所有运行路径先按同一规则解析协议再校验 Surface混合 Surface 的同名路由按未知能力保守处理;显式 Anspire 渠道独占共享 Key非法 Surface 或协议不匹配时不会把该 Key 回退为旧版 Chat 部署,同时保留无关的 Gemini/OpenAI 等 legacy provider本地 loopback 渠道可在图片识别路径继续无 Key 调用,远端渠道仍要求凭据;禁用渠道不会因残留 Surface 配置阻断其他兼容 fallbackWeb 编辑器不会静默改写非法历史值,并允许将 Hermes 非法 Surface 修复为 Chat Completions。
- [修复] 将 Responses 渠道的协议、模型 provider、公开 route alias 与 wire-model 构造收敛为统一路由契约,保存校验、运行时加载、状态诊断、选股入口和 Web 编辑器共同使用当前安装的 LiteLLM provider registry拒绝 `openai` 协议下显式非 OpenAI provider 的模型、拒绝同一 alias 混用 Chat/Responses并保留 OpenAI-compatible 网关自有的带斜杠模型 ID。
## [3.29.0] - 2026-08-02

View File

@@ -177,8 +177,8 @@ LITELLM_MODEL=ollama/qwen3:8b
### Web 渠道编辑器的兼容性 / 迁移 / 回退规则
- 预设里的 provider / Base URL / 示例模型只用于**初始化表单**;真正落盘时仍是你当前输入的 `LLM_{CHANNEL}_PROTOCOL``LLM_{CHANNEL}_BASE_URL``LLM_{CHANNEL}_MODELS``LLM_{CHANNEL}_API_KEY(S)`,不会在后台偷偷改成别的 provider 名或 URL。
- 设置页的“获取模型”只对 `OpenAI Compatible` / `DeepSeek` 渠道调用 `{base_url}/models`;“测试连接”默认只对模型列表首项发起一次最小聊天请求,并在结果中展示后端规范化后的 `resolved_model`。若返回 `details.reason=model_access_denied`(例如 Issue #1208 中已观测到的 SiliconFlow / OpenAI Compatible 经 LiteLLM 返回 `Model disabled`),请把它视为基于 provider 文案的 best-effort 模型可用性诊断,优先确认该模型是否已在当前账号/key 下开通,必要时调整模型顺序或移除不可用模型后重试;未覆盖或语义不同的 provider 文案会继续走兜底诊断。可选的“运行时能力检测”必须由用户显式选择后触发,会额外发起 JSON / tools / stream / vision smoke 请求,结果仅代表当前账号、模型和 endpoint 的一次 best-effort 检测。上述检测返回的 `stage / error_code / details / latency_ms / capability_results` 仅用于结构化诊断提示,**不会写回** `.env`,也不会阻止保存。
- 预设里的 provider / API Surface / Base URL / 示例模型只用于**初始化表单**;真正落盘时仍是你当前输入的 `LLM_{CHANNEL}_PROTOCOL``LLM_{CHANNEL}_API_SURFACE``LLM_{CHANNEL}_BASE_URL``LLM_{CHANNEL}_MODELS``LLM_{CHANNEL}_API_KEY(S)`,不会在后台偷偷改成别的 provider 名、Surface 或 URL。
- `LLM_{CHANNEL}_API_SURFACE` 可选 `chat_completions`(默认)或 `responses`Responses 当前只支持 OpenAI-compatible 协议,而且渠道内每个模型的实际 LiteLLM provider 都必须是 `openai`。系统从当前安装的 LiteLLM provider registry 识别直连前缀,并通过 `GET /api/v1/system/config``llm_model_providers` 返回给 Web 编辑器;前后端不再各自维护 provider 表。因此 `anthropic/claude-*``xai/grok-*` 以及未来 LiteLLM 新增的直连 provider 不会因本地白名单滞后而被错误包装成 OpenAI route冲突配置会在保存、启动、状态诊断和筛选入口一致拒绝不会生成 `anthropic/responses/...``deepseek-ai/DeepSeek-V3``Qwen/...` 这类不在 registry 中的网关自有模型 ID 会规范化为 `openai/<网关模型 ID>`。同一渠道中的模型必须使用同一种 Surface并且同一个规范化 route alias 不能跨渠道混用 Chat 与 Responses否则 Router 可能把同一次公开 alias 调度到错误 endpoint需要两种 Surface 时必须使用不同别名。设置页的“获取模型”仍只调用 `{base_url}/models`;“测试连接”会按选中的 Surface 对模型列表首项发起一次最小请求,并展示 `resolved_model``resolved_api_surface`,不会在失败后静默重试另一 endpoint。若返回 `details.reason=model_access_denied`(例如 Issue #1208 中已观测到的 SiliconFlow / OpenAI Compatible 经 LiteLLM 返回 `Model disabled`),请把它视为基于 provider 文案的 best-effort 模型可用性诊断,优先确认该模型是否已在当前账号/key 下开通,必要时调整模型顺序或移除不可用模型后重试;未覆盖或语义不同的 provider 文案会继续走兜底诊断。可选的“运行时能力检测”必须由用户显式选择后触发,会额外发起 JSON / tools / stream / vision smoke 请求,结果仅代表当前账号、模型和 endpoint 的一次 best-effort 检测。上述检测返回的结构化诊断字段**不会写回** `.env`,也不会阻止保存。
- 若返回 `details.reason=provider_blocked`,表示服务商或中转网关明确拦截了本次请求;它区别于本地网络 / TLS 异常和 `model_access_denied`,应优先检查账号风控、地域或请求来源限制、模型权限、代理商网关策略和内容安全策略。
- 运行时能力检测会产生真实 LLM 请求,可能带来 token / 图像输入费用、RPM/TPM 限流、余额不足或超时。检测失败可能来自账号权限、模型未开通、endpoint 区域、余额、服务商兼容层或 LiteLLM 转换路径,不等于该 provider 全局不支持对应能力。P3 未对所有真实 provider 做在线 smoke兼容依据来自当前依赖约束 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` 下的 LiteLLM `completion()` / OpenAI I/O format / streaming / exception mapping以及 OpenAI Chat Completions 的 JSON mode、tool calling、streaming 和 vision input 形状。
- 相关外部来源LiteLLM Python SDK / OpenAI I/O format / streaming / exception mapping<https://docs.litellm.ai/>LiteLLM OpenAI-compatible 路由:<https://docs.litellm.ai/docs/providers/openai_compatible>OpenAI Chat Completions<https://platform.openai.com/docs/api-reference/chat/create>JSON mode<https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat>tool calling<https://platform.openai.com/docs/guides/function-calling?api-mode=chat>streaming<https://platform.openai.com/docs/guides/streaming-responses?api-mode=chat>vision input<https://platform.openai.com/docs/guides/images-vision?api-mode=chat>
@@ -222,6 +222,21 @@ LITELLM_MODEL=ollama/qwen3:8b
1. **先声明你有几个渠道**`LLM_CHANNELS=渠道名称1,渠道名称2`
2. **给每个渠道分别填写配置**(注意全大写):`LLM_{渠道名}_XXX`
### 示例Anspire Responses APIGPT-5.6 名称为观测样本)
```env
LLM_CHANNELS=anspire
LLM_ANSPIRE_PROTOCOL=openai
LLM_ANSPIRE_API_SURFACE=responses
LLM_ANSPIRE_BASE_URL=https://open-gateway.anspire.cn/v6
LLM_ANSPIRE_API_KEY=sk-xxx
LLM_ANSPIRE_MODELS=gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna
LITELLM_MODEL=openai/gpt-5.6-sol
LITELLM_FALLBACK_MODELS=openai/gpt-5.6-terra,openai/gpt-5.6-luna
```
Anspire 官方接入页公开了 `https://open-gateway.anspire.cn/v6/responses` 调用方式:<https://open.anspire.cn/model?link=sample&tab=models>。Responses 路由不按模型名写死;上面的 GPT-5.6 名称只是当前网关观测样本不作为长期模型清单承诺。模型、Surface 与账号权限请以渠道实时 `/models` 返回、服务商说明和连接测试为准。
### 示例:同时配置 DeepSeek 和某中转平台,并设置备用切换
```env
# 1. 开启渠道模式声明这里有两个渠道deepseek 和 aihubmix

View File

@@ -170,8 +170,8 @@ The backend exposes a read-only status endpoint at `GET /api/v1/system/config/se
### Web channel editor: compatibility, migration, and rollback rules
- The preset provider / Base URL / sample models are **form defaults only**. What gets persisted is still exactly what you submit in `LLM_{CHANNEL}_PROTOCOL`, `LLM_{CHANNEL}_BASE_URL`, `LLM_{CHANNEL}_MODELS`, and `LLM_{CHANNEL}_API_KEY(S)`; the editor does not silently rewrite them to a different provider name or URL.
- "Discover models" only calls `{base_url}/models` for `OpenAI Compatible` / `DeepSeek` channels, and the default "Test connection" action sends one minimal chat completion request against the first model in the list and shows the backend-normalized `resolved_model` in the result. If the response includes `details.reason=model_access_denied` (for example, the observed Issue #1208 SiliconFlow / OpenAI Compatible sample returned `Model disabled` through LiteLLM), treat it as a best-effort model availability diagnostic based on provider wording: first confirm that the tested model is enabled for the current account/key, then adjust the model order or remove unavailable models before retrying. Provider messages not covered by this conservative rule, or provider messages with different semantics, continue to use the fallback diagnostic path. Optional runtime capability checks must be explicitly selected by the user and send additional JSON / tools / stream / vision smoke requests; the result only represents a best-effort check for the current account, model, and endpoint at that moment. The returned `stage / error_code / details / latency_ms / capability_results` fields are for structured diagnostics only, are **never persisted** back into `.env`, and do not block saving.
- The preset provider / API surface / Base URL / sample models are **form defaults only**. What gets persisted is exactly what you submit in `LLM_{CHANNEL}_PROTOCOL`, `LLM_{CHANNEL}_API_SURFACE`, `LLM_{CHANNEL}_BASE_URL`, `LLM_{CHANNEL}_MODELS`, and `LLM_{CHANNEL}_API_KEY(S)`; the editor does not silently rewrite them to a different provider, surface, or URL.
- `LLM_{CHANNEL}_API_SURFACE` accepts `chat_completions` (default) or `responses`; Responses currently requires the OpenAI-compatible protocol, and every model's actual LiteLLM provider must be `openai`. Direct prefixes come from the installed LiteLLM provider registry and are returned to the Web editor as `llm_model_providers` by `GET /api/v1/system/config`, so the frontend and backend do not maintain separate provider tables. This prevents `anthropic/claude-*`, `xai/grok-*`, and providers added by future LiteLLM versions from being miswrapped as OpenAI routes because of a stale client allow-list. Conflicts are rejected consistently by save validation, runtime loading, status diagnostics, and screening instead of producing `anthropic/responses/...`. Gateway-owned IDs such as `deepseek-ai/DeepSeek-V3` or `Qwen/...` that are absent from the registry are normalized under `openai/<gateway model ID>`. All models in one channel must use the same surface, and one normalized route alias cannot mix Chat and Responses across channels because Router load balancing could select the wrong endpoint; use distinct aliases when both surfaces are required. "Discover models" still calls `{base_url}/models`; "Test connection" sends one minimal request through the selected surface and reports `resolved_model` plus `resolved_api_surface`. It never retries another endpoint after failure. If the response includes `details.reason=model_access_denied` (for example, the observed Issue #1208 SiliconFlow / OpenAI Compatible sample returned `Model disabled` through LiteLLM), treat it as a best-effort availability diagnostic: confirm model entitlement for the current key, then adjust the model order or remove unavailable models. Optional capability checks send additional real requests and remain best-effort. Their structured diagnostic fields are **never persisted** into `.env` and do not block saving.
- If the response includes `details.reason=provider_blocked`, the provider or relay gateway explicitly blocked this request. This is distinct from local network / TLS failures and `model_access_denied`; first check account risk controls, region or request-source restrictions, model entitlement, relay gateway policy, and content-safety policy.
- Runtime capability checks send real LLM requests and may incur token / image-input cost, RPM/TPM rate limiting, insufficient balance errors, or timeouts. A failed check may come from account permissions, model entitlement, endpoint region, balance, provider compatibility layers, or LiteLLM translation behavior; it does not prove that the provider globally lacks that capability. P3 does not include online smoke coverage for every real provider. Its compatibility basis is the repository dependency constraint `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`, LiteLLM `completion()` / OpenAI I/O format / streaming / exception mapping, and the OpenAI Chat Completions shapes for JSON mode, tool calling, streaming, and vision input.
- External references: LiteLLM Python SDK / OpenAI I/O format / streaming / exception mapping: <https://docs.litellm.ai/>; LiteLLM OpenAI-compatible routing: <https://docs.litellm.ai/docs/providers/openai_compatible>; OpenAI Chat Completions: <https://platform.openai.com/docs/api-reference/chat/create>; JSON mode: <https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat>; tool calling: <https://platform.openai.com/docs/guides/function-calling?api-mode=chat>; streaming: <https://platform.openai.com/docs/guides/streaming-responses?api-mode=chat>; vision input: <https://platform.openai.com/docs/guides/images-vision?api-mode=chat>.
@@ -215,6 +215,21 @@ If you prefer modifying files, configuring this in the `.env` file is also very
1. **Declare your channels first**: `LLM_CHANNELS=channel_name_1,channel_name_2`
2. **Provide configurations for each channel** (Note the uppercase): `LLM_{CHANNEL_NAME}_XXX`
### Example: Anspire Responses API (observed GPT-5.6 model names)
```env
LLM_CHANNELS=anspire
LLM_ANSPIRE_PROTOCOL=openai
LLM_ANSPIRE_API_SURFACE=responses
LLM_ANSPIRE_BASE_URL=https://open-gateway.anspire.cn/v6
LLM_ANSPIRE_API_KEY=sk-xxx
LLM_ANSPIRE_MODELS=gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna
LITELLM_MODEL=openai/gpt-5.6-sol
LITELLM_FALLBACK_MODELS=openai/gpt-5.6-terra,openai/gpt-5.6-luna
```
Anspire's official integration page documents `https://open-gateway.anspire.cn/v6/responses`: <https://open.anspire.cn/model?link=sample&tab=models>. Responses routing is not hard-coded to model names; the GPT-5.6 names above are current gateway observations rather than a durable model-catalog promise. Treat the live `/models` response, provider guidance, and connection test as authoritative for the model, Surface, availability, and account access.
### Example: Configuring DeepSeek and a Third-party Relay with Fallbacks
```env
# 1. Enable channel mode, declare two channels here: deepseek and aihubmix

View File

@@ -76,6 +76,7 @@ LITELLM_MODEL=deepseek/deepseek-v4-flash
```env
LLM_CHANNELS=my_proxy
LLM_MY_PROXY_PROTOCOL=openai
LLM_MY_PROXY_API_SURFACE=chat_completions
LLM_MY_PROXY_BASE_URL=https://your-proxy.example.com/v1
LLM_MY_PROXY_API_KEY=sk-xxx
LLM_MY_PROXY_MODELS=gpt-5.5,claude-sonnet-4-6
@@ -84,6 +85,23 @@ LLM_MY_PROXY_MODELS=gpt-5.5,claude-sonnet-4-6
OpenAI-compatible Base URL 只填到服务商兼容入口,不额外拼接 `/chat/completions`。本地 `.env`、Docker 和自托管脚本可以直接使用自定义 channelGitHub Actions 需要 workflow 显式透传同名 `LLM_MY_PROXY_*` 变量。
小米 MiMo 示例同理:适用于本地 `.env`、Docker 或自托管脚本;若在 GitHub Actions 使用 `LLM_CHANNELS=mimo`,需要在 workflow 中手动补齐 `LLM_MIMO_*` 映射后方可生效。
### Anspire Responses APIGPT-5.6 名称为观测样本)
Anspire 官方接入页公开了 `https://open-gateway.anspire.cn/v6/responses` 调用方式:<https://open.anspire.cn/model?link=sample&tab=models>。如果实时 `/models`、服务商说明和连接测试确认目标模型使用 Responses可把模型放在同一渠道并显式把 API Surface 设为 `responses`。下面的 `gpt-5.6-sol``gpt-5.6-terra``gpt-5.6-luna` 是当前网关观测样本,不作为长期模型清单承诺:
```env
LLM_CHANNELS=anspire
LLM_ANSPIRE_PROTOCOL=openai
LLM_ANSPIRE_API_SURFACE=responses
LLM_ANSPIRE_BASE_URL=https://open-gateway.anspire.cn/v6
LLM_ANSPIRE_API_KEY=sk-xxx
LLM_ANSPIRE_MODELS=gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna
LITELLM_MODEL=openai/gpt-5.6-sol
LITELLM_FALLBACK_MODELS=openai/gpt-5.6-terra,openai/gpt-5.6-luna
```
这里的能力不依赖 `gpt-5.6-*` 命名:任何服务商明确声明为 Responses-only 的模型都可使用同一配置方式。模型清单和账号权限可能变化,应以服务商 `/models` 返回、官方说明及实际连接测试为准。一个渠道只能使用一种 API Surface若还要同时使用 Anspire 的 Chat Completions 模型,请为它们建立另一个 OpenAI-compatible 渠道不要把两类模型混在同一渠道中。Web 设置页的「测试连接」会使用当前选择的 Surface运行时不会在失败后静默改用另一个 endpoint。
## 常用服务商预设
| 服务商 | 渠道名 | 协议 | Base URL | 模型示例 |
@@ -128,6 +146,8 @@ OpenAI-compatible Base URL 只填到服务商兼容入口,不额外拼接 `/ch
## OpenAI-compatible 与 LiteLLM 规则
- OpenAI-compatible provider 的 channel `protocol` 通常是 `openai`
- `LLM_<CHANNEL>_API_SURFACE` 默认是 `chat_completions`Responses-only 模型显式设为 `responses`。Responses 渠道要求协议及每个模型的实际 LiteLLM provider 都是 `openai`;直连 provider 从当前安装的 LiteLLM registry 获取,并由系统配置 API 返回给 Web 编辑器作为共同真源。因此显式 `anthropic/``gemini/``xai/` 以及未来新增的冲突前缀都会在所有配置入口被一致识别,网关自有的 `deepseek-ai/...``Qwen/...` 等带斜杠模型 ID 则会规范化到 `openai/<model>`。同一个规范化公开 alias 不得跨渠道混用 Chat 与 Responses避免 Router 在不同 Surface deployment 间负载均衡。运行时保留公开模型别名 `openai/<model>`,并通过 LiteLLM 的 `openai/responses/<model>` 桥接调用 `/responses`
- 该设计与主流项目的显式路由方式一致:[LiteLLM](https://github.com/BerriAI/litellm/blob/main/litellm/responses/main.py) 提供 Chat-to-Responses bridge[OpenAI Agents SDK](https://openai.github.io/openai-agents-python/models/) 使用独立 Responses/Chat 模型类,[LangChain](https://docs.langchain.com/oss/python/integrations/chat/openai) 使用 `use_responses_api` 显式选择并仅在已知条件下自动路由。DSA 不在请求失败后猜测 endpoint避免双请求、重复计费及掩盖真实服务端错误。
- 运行时模型名通常写成 `openai/<model>`;例如自定义网关里的 `gpt-5.5` 可以作为 `openai/gpt-5.5` 被 LiteLLM 路由。
- `Qwen/...``deepseek-ai/...` 这类是服务商或模型仓库组织名前缀,不等同于 LiteLLM provider prefix不要因为它们包含斜杠就误判为 `provider/model` 路由。
- Base URL 只填官方或网关给出的兼容入口,通常到 `/v1``/api/v3` 或厂商文档指定路径;不要手动追加 `/chat/completions`
@@ -141,6 +161,7 @@ OpenAI-compatible Base URL 只填到服务商兼容入口,不额外拼接 `/ch
| --- | --- | --- |
| `LLM_CHANNELS` | Variables 或 Secrets | 逗号分隔渠道名,例如 `deepseek,minimax,volcengine`。 |
| `LLM_<CHANNEL>_PROTOCOL` | Variables 或 Secrets | 非敏感,通常为 `openai``deepseek``gemini``anthropic``ollama`。 |
| `LLM_<CHANNEL>_API_SURFACE` | Variables 或 Secrets | 可选;`chat_completions`(默认)或 `responses`。Responses 当前只支持 `openai` 协议。 |
| `LLM_<CHANNEL>_BASE_URL` | Variables 或 Secrets | 非敏感时优先放 Variables私有网关地址可放 Secrets。 |
| `LLM_<CHANNEL>_MODELS` | Variables 或 Secrets | 非敏感模型列表,逗号分隔。 |
| `LLM_<CHANNEL>_ENABLED` | Variables 或 Secrets | 可选,未配置时默认启用;设为 `false` 可跳过该渠道。 |

View File

@@ -14,6 +14,7 @@ import json
import logging
import os
import re
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional, Tuple
from urllib.parse import unquote, urlparse
@@ -63,6 +64,7 @@ from src.llm.hermes import (
HERMES_DEFAULT_MODEL,
HERMES_DEFAULT_PROTOCOL,
HermesConfigIssue,
hermes_blocked_route_candidates,
hermes_model_info,
is_reserved_hermes_name,
parse_hermes_channel,
@@ -97,6 +99,26 @@ class ConfigIssue:
_MANAGED_LITELLM_KEY_PROVIDERS = {"gemini", "vertex_ai", "anthropic", "openai", "deepseek"}
SUPPORTED_LLM_CHANNEL_PROTOCOLS = ("openai", "anthropic", "gemini", "vertex_ai", "deepseek", "ollama")
SUPPORTED_LLM_CHANNEL_API_SURFACES = ("chat_completions", "responses")
_FALLBACK_LITELLM_MODEL_PROVIDERS = _MANAGED_LITELLM_KEY_PROVIDERS | set(SUPPORTED_LLM_CHANNEL_PROTOCOLS) | {
"minimax",
"cohere",
"huggingface",
"bedrock",
"sagemaker",
"azure",
"replicate",
"together_ai",
"palm",
"text-completion-openai",
"command-r",
"groq",
"cerebras",
"fireworks_ai",
"friendliai",
"openrouter",
"xai",
}
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
PROMPT_CACHE_DIAGNOSTICS_LEVELS = {"off", "basic", "debug"}
SUPPORTED_AGENT_BACKENDS = {"auto", "litellm", "codex_app_server"}
@@ -382,6 +404,100 @@ def canonicalize_llm_channel_protocol(value: Optional[str]) -> str:
return aliases.get(candidate, candidate)
def canonicalize_llm_channel_api_surface(value: Optional[str]) -> str:
"""Normalize an LLM channel endpoint surface label."""
candidate = (value or "").strip().lower().replace("-", "_")
aliases = {
"chat": "chat_completions",
"chat_completion": "chat_completions",
"completions": "chat_completions",
"response": "responses",
"responses_api": "responses",
}
return aliases.get(candidate, candidate)
def normalize_llm_channel_api_surface(value: Optional[str]) -> str:
"""Return a supported endpoint surface, defaulting to Chat Completions."""
normalized = canonicalize_llm_channel_api_surface(value)
if normalized in SUPPORTED_LLM_CHANNEL_API_SURFACES:
return normalized
return "chat_completions"
def is_supported_llm_channel_api_surface_value(value: Optional[str]) -> bool:
"""Return whether a raw API surface is blank or recognized."""
canonical = canonicalize_llm_channel_api_surface(value)
return not canonical or canonical in SUPPORTED_LLM_CHANNEL_API_SURFACES
@lru_cache(maxsize=1)
def get_litellm_model_providers() -> frozenset[str]:
"""Return provider identifiers from the installed LiteLLM routing enum.
LiteLLM adds direct providers independently of this repository. Loading
its enum keeps channel validation aligned with the actual router instead
of relying on a permanently incomplete local allow-list. The fallback is
only for lightweight test stubs or a broken optional import; a production
installation gets the complete provider set from its pinned LiteLLM.
"""
providers = set(_FALLBACK_LITELLM_MODEL_PROVIDERS)
try:
from litellm.types.utils import LlmProviders
providers.update(
str(provider.value).strip().lower()
for provider in LlmProviders
if str(getattr(provider, "value", "")).strip()
)
except (ImportError, AttributeError, TypeError):
logger.debug("LiteLLM provider metadata unavailable; using the compatibility fallback")
return frozenset(providers)
def get_explicit_llm_channel_model_provider(model: str) -> str:
"""Return the explicit LiteLLM provider prefix, if the model has one.
A slash alone does not establish a provider: OpenAI-compatible gateways
commonly expose provider-owned IDs such as ``Qwen/Qwen3`` or
``deepseek-ai/DeepSeek-V3``. Only prefixes understood as LiteLLM providers
are treated as routing declarations.
"""
normalized_model = (model or "").strip()
if "/" not in normalized_model:
return ""
raw_prefix = normalized_model.split("/", 1)[0].lower()
canonical_prefix = canonicalize_llm_channel_protocol(raw_prefix)
providers = get_litellm_model_providers()
if raw_prefix in providers:
return raw_prefix
if canonical_prefix in providers:
return canonical_prefix
return ""
def apply_litellm_api_surface(model: str, api_surface: Optional[str]) -> str:
"""Encode an explicit API surface in a LiteLLM wire model.
LiteLLM's ``provider/responses/model`` convention keeps the public Router
alias stable while letting ``completion()`` bridge messages, streaming,
tools, responses, and usage through the provider's Responses endpoint.
"""
normalized_model = (model or "").strip()
if not normalized_model or normalize_llm_channel_api_surface(api_surface) != "responses":
return normalized_model
provider = get_explicit_llm_channel_model_provider(normalized_model)
if provider != "openai":
raise ValueError(
"Responses API surface requires a normalized openai/<model> route; "
f"got {normalized_model!r}"
)
provider, remainder = normalized_model.split("/", 1)
if remainder.startswith("responses/"):
return normalized_model
return f"{provider}/responses/{remainder}"
def resolve_llm_channel_protocol(
protocol: Optional[str],
*,
@@ -443,15 +559,10 @@ def normalize_llm_channel_model(model: str, protocol: Optional[str], base_url: O
raw_prefix, remainder = normalized_model.split("/", 1)
prefix = raw_prefix.lower()
canonical_prefix = canonicalize_llm_channel_protocol(prefix)
known_providers = _MANAGED_LITELLM_KEY_PROVIDERS | set(SUPPORTED_LLM_CHANNEL_PROTOCOLS) | {
"minimax",
"cohere", "huggingface", "bedrock", "sagemaker", "azure",
"replicate", "together_ai", "palm", "text-completion-openai",
"command-r", "groq", "cerebras", "fireworks_ai", "friendliai",
}
if prefix in known_providers:
providers = get_litellm_model_providers()
if prefix in providers:
return normalized_model
if canonical_prefix in known_providers:
if canonical_prefix in providers:
return f"{canonical_prefix}/{remainder}"
# Not a real provider prefix — add one so LiteLLM routes correctly.
if resolved_protocol:
@@ -463,6 +574,58 @@ def normalize_llm_channel_model(model: str, protocol: Optional[str], base_url: O
return f"{resolved_protocol}/{normalized_model}"
def find_incompatible_llm_channel_models(
models: List[str],
protocol: Optional[str],
api_surface: Optional[str],
base_url: Optional[str] = None,
) -> List[str]:
"""Return models whose actual LiteLLM route conflicts with the surface.
Responses routing is implemented through LiteLLM's OpenAI bridge, so both
the channel protocol and every normalized model route must resolve to the
OpenAI provider. This is the shared invariant used by validation, runtime
loading, diagnostics, and screening.
"""
if normalize_llm_channel_api_surface(api_surface) != "responses":
return []
resolved_protocol = resolve_llm_channel_protocol(
protocol,
base_url=base_url,
models=models,
)
if resolved_protocol != "openai":
return [model for model in models if (model or "").strip()]
incompatible: List[str] = []
for model in models:
normalized_model = normalize_llm_channel_model(model, resolved_protocol, base_url)
if normalized_model and get_explicit_llm_channel_model_provider(normalized_model) != "openai":
incompatible.append(model)
return incompatible
def find_llm_channel_surface_conflicts(
channels: List[Dict[str, Any]],
) -> Dict[str, Tuple[str, ...]]:
"""Return public route aliases declared with more than one API surface."""
route_surfaces: Dict[str, set[str]] = {}
for channel in channels:
if not isinstance(channel, dict) or not channel.get("enabled", True):
continue
protocol = str(channel.get("protocol") or "")
base_url = str(channel.get("base_url") or "")
surface = normalize_llm_channel_api_surface(channel.get("api_surface"))
for raw_model in channel.get("models") or []:
model = normalize_llm_channel_model(str(raw_model), protocol, base_url)
if model:
route_surfaces.setdefault(model, set()).add(surface)
return {
model: tuple(sorted(surfaces))
for model, surfaces in route_surfaces.items()
if len(surfaces) > 1
}
def get_configured_llm_models(model_list: List[Dict[str, Any]]) -> List[str]:
"""Return non-legacy model names declared in Router model_list order.
@@ -1315,19 +1478,15 @@ class Config:
os.getenv('ANSPIRE_LLM_BASE_URL') or ANSPIRE_LLM_BASE_URL_DEFAULT
).strip()
_anspire_llm_model_env = os.getenv('ANSPIRE_LLM_MODEL', '').strip()
anspire_channel_disabled = False
anspire_channel_declared = False
for _raw_channel in os.getenv('LLM_CHANNELS', '').split(','):
if _raw_channel.strip().lower() != "anspire":
continue
_channel_enabled_raw = os.getenv('LLM_ANSPIRE_ENABLED')
if _channel_enabled_raw is not None and _channel_enabled_raw.strip():
anspire_channel_disabled = not parse_env_bool(_channel_enabled_raw, default=True)
else:
anspire_channel_disabled = not anspire_llm_enabled
anspire_channel_declared = True
break
using_anspire_llm_legacy = bool(
anspire_llm_enabled
and not anspire_channel_disabled
and not anspire_channel_declared
and anspire_api_keys
and not openai_api_keys
)
@@ -2163,6 +2322,7 @@ class Config:
Format:
LLM_CHANNELS=aihubmix,deepseek,gemini
LLM_AIHUBMIX_PROTOCOL=openai
LLM_AIHUBMIX_API_SURFACE=chat_completions
LLM_AIHUBMIX_BASE_URL=https://aihubmix.com/v1
LLM_AIHUBMIX_API_KEY=sk-xxx (or LLM_AIHUBMIX_API_KEYS=k1,k2)
LLM_AIHUBMIX_MODELS=gpt-5.5,claude-sonnet-4-6
@@ -2175,6 +2335,15 @@ class Config:
issues: List[HermesConfigIssue] = []
blocks_legacy_fallback = False
blocked_hermes_routes: List[str] = []
def record_blocked_hermes_routes(raw_models: List[str]) -> None:
nonlocal blocks_legacy_fallback
blocks_legacy_fallback = True
for raw_model in raw_models or [HERMES_DEFAULT_MODEL]:
for route_name in hermes_blocked_route_candidates(raw_model):
if route_name not in blocked_hermes_routes:
blocked_hermes_routes.append(route_name)
for raw_name in channels_str.split(','):
ch_name = raw_name.strip()
if not ch_name:
@@ -2190,6 +2359,7 @@ class Config:
protocol_raw = os.getenv(f'LLM_{ch_upper}_PROTOCOL', '').strip()
if ch_lower == "anspire" and not protocol_raw:
protocol_raw = "openai"
api_surface_raw = os.getenv(f'LLM_{ch_upper}_API_SURFACE', '').strip()
enabled_raw = os.getenv(f'LLM_{ch_upper}_ENABLED')
if ch_lower == "anspire" and (enabled_raw is None or not enabled_raw.strip()):
enabled_raw = os.getenv('ANSPIRE_LLM_ENABLED')
@@ -2216,7 +2386,45 @@ class Config:
if anspire_model:
raw_models = [anspire_model]
# Disabled channels are inert. In particular, stale values such as
# LLM_HERMES_API_SURFACE=responses must not block valid legacy
# deployments after Hermes has been explicitly disabled.
if not enabled:
_logger.info("LLM channel '%s': disabled, skipped", ch_name)
continue
if not is_supported_llm_channel_api_surface_value(api_surface_raw):
issues.append(HermesConfigIssue(
f"LLM_{ch_upper}_API_SURFACE",
"invalid_api_surface",
(
f"Unsupported LLM API surface '{api_surface_raw}'. "
f"Supported: {', '.join(SUPPORTED_LLM_CHANNEL_API_SURFACES)}"
),
))
if is_reserved_hermes_name(ch_name):
record_blocked_hermes_routes(raw_models)
_logger.warning(
"LLM_%s_API_SURFACE=%s is unsupported; channel skipped",
ch_upper,
api_surface_raw,
)
continue
api_surface = normalize_llm_channel_api_surface(api_surface_raw)
if is_reserved_hermes_name(ch_name):
if api_surface == "responses":
issues.append(HermesConfigIssue(
f"LLM_{ch_upper}_API_SURFACE",
"hermes_responses_unsupported",
"The reserved Hermes channel does not support the Responses API surface",
))
record_blocked_hermes_routes(raw_models)
_logger.warning(
"LLM_%s_API_SURFACE=responses is unsupported for reserved Hermes channel; channel skipped",
ch_upper,
)
continue
if not raw_models:
raw_models = [HERMES_DEFAULT_MODEL]
result = parse_hermes_channel(
@@ -2244,6 +2452,38 @@ class Config:
continue
protocol = resolve_llm_channel_protocol(protocol_raw, base_url=base_url, models=raw_models, channel_name=ch_name)
if api_surface == "responses" and protocol != "openai":
issues.append(HermesConfigIssue(
f"LLM_{ch_upper}_API_SURFACE",
"responses_requires_openai_protocol",
"Responses API surface currently requires the openai protocol",
))
_logger.warning(
"LLM_%s_API_SURFACE=responses requires protocol=openai; channel skipped",
ch_upper,
)
continue
incompatible_models = find_incompatible_llm_channel_models(
raw_models,
protocol,
api_surface,
base_url,
)
if incompatible_models:
issues.append(HermesConfigIssue(
f"LLM_{ch_upper}_MODELS",
"responses_requires_openai_model_provider",
(
"Responses API surface requires every model to use the OpenAI "
f"provider route; incompatible: {', '.join(incompatible_models[:3])}"
),
))
_logger.warning(
"LLM_%s_API_SURFACE=responses has non-OpenAI model routes (%s); channel skipped",
ch_upper,
", ".join(incompatible_models[:3]),
)
continue
models = [normalize_llm_channel_model(m, protocol, base_url) for m in raw_models]
# Extra headers (JSON string, optional)
@@ -2255,10 +2495,6 @@ class Config:
except json.JSONDecodeError:
_logger.warning(f"LLM_{ch_upper}_EXTRA_HEADERS: invalid JSON, ignored")
if not enabled:
_logger.info(f"LLM channel '{ch_name}': disabled, skipped")
continue
if protocol_raw and canonicalize_llm_channel_protocol(protocol_raw) not in SUPPORTED_LLM_CHANNEL_PROTOCOLS:
_logger.warning(
"LLM_%s_PROTOCOL=%s is unsupported; auto-detected protocol=%s",
@@ -2280,6 +2516,7 @@ class Config:
channels.append({
'name': ch_name.lower(),
'protocol': protocol,
'api_surface': api_surface,
'enabled': enabled,
'base_url': base_url,
'api_keys': api_keys,
@@ -2288,6 +2525,36 @@ class Config:
})
_logger.info(f"LLM channel '{ch_name}': {len(models)} model(s), {len(api_keys)} key(s)")
surface_conflicts = find_llm_channel_surface_conflicts(channels)
if surface_conflicts:
conflicting_models = set(surface_conflicts)
for model, surfaces in surface_conflicts.items():
issues.append(HermesConfigIssue(
"LLM_CHANNELS",
"mixed_api_surfaces_for_route",
(
f"LLM route alias '{model}' is declared with multiple API surfaces: "
f"{', '.join(surfaces)}"
),
))
_logger.warning(
"LLM route alias '%s' mixes API surfaces (%s); conflicting channels skipped",
model,
", ".join(surfaces),
)
channels = [
channel
for channel in channels
if not {
normalize_llm_channel_model(
str(model),
str(channel.get("protocol") or ""),
str(channel.get("base_url") or ""),
)
for model in channel.get("models") or []
}.intersection(conflicting_models)
]
return channels, issues, blocks_legacy_fallback, blocked_hermes_routes
@classmethod
@@ -2298,6 +2565,12 @@ class Config:
- LiteLLM providers: https://docs.litellm.ai/docs/providers
- LiteLLM model_list 语义: https://docs.litellm.ai/docs/proxy/configs#the-model_list-key
"""
surface_conflicts = find_llm_channel_surface_conflicts(channels)
if surface_conflicts:
raise ValueError(
"LLM route aliases cannot mix API surfaces: "
+ ", ".join(sorted(surface_conflicts))
)
model_list: List[Dict[str, Any]] = []
for ch in channels:
hermes_refs = {
@@ -2309,6 +2582,8 @@ class Config:
for api_key in ch['api_keys']:
model_ref = hermes_refs.get(str(model_name))
wire_model = str((model_ref or {}).get("wire_model") or model_name)
api_surface = normalize_llm_channel_api_surface(ch.get("api_surface"))
wire_model = apply_litellm_api_surface(wire_model, api_surface)
litellm_params: Dict[str, Any] = {
'model': wire_model,
}
@@ -2331,6 +2606,8 @@ class Config:
entry["model_info"] = hermes_model_info(
str((model_ref or {}).get("display_model") or "")
)
elif api_surface == "responses":
entry["model_info"] = {"dsa_api_surface": "responses"}
model_list.append(entry)
return model_list

View File

@@ -393,11 +393,12 @@ def build_provider_cache_route_context(
_model_list_api_base(model, model_list),
)
family = infer_provider_family(model=model, provider=provider, api_base=api_base)
configured_api_surface = _model_list_api_surface(model, model_list)
return ProviderCacheRouteContext(
model=model,
provider=provider or family,
api_base=api_base,
api_surface=_infer_api_surface(family, api_base),
api_surface=configured_api_surface or _infer_api_surface(family, api_base),
gateway=_infer_gateway(api_base, family),
cloud_platform=_infer_cloud_platform(api_base, family),
call_type=call_type,
@@ -727,6 +728,38 @@ def _model_list_api_base(model: str, model_list: Optional[List[Dict[str, Any]]])
return None
def _model_list_api_surface(model: str, model_list: Optional[List[Dict[str, Any]]]) -> Optional[ApiSurface]:
"""Return the endpoint surface attached to a matching Router deployment."""
normalized_model = (model or "").strip()
if not normalized_model or not model_list:
return None
surfaces: set[str] = set()
for entry in model_list:
if not isinstance(entry, Mapping):
continue
params = entry.get("litellm_params", {}) or {}
if not isinstance(params, Mapping):
params = {}
names = {
str(entry.get("model_name") or "").strip(),
str(params.get("model") or "").strip(),
}
if normalized_model not in names:
continue
model_info = entry.get("model_info", {}) or {}
if not isinstance(model_info, Mapping):
model_info = {}
surface = str(model_info.get("dsa_api_surface") or "chat_completions").strip().lower()
surfaces.add(surface)
if len(surfaces) == 1:
surface = next(iter(surfaces))
if surface in {"responses", "chat_completions"}:
return surface
if len(surfaces) > 1:
return "unknown"
return None
def _first_non_empty(*values: Any) -> Optional[str]:
for value in values:
text = str(value or "").strip()

View File

@@ -17,6 +17,10 @@ from src.config import (
_uses_direct_env_provider,
channel_allows_empty_api_key,
get_configured_llm_models,
is_supported_llm_channel_api_surface_value,
find_incompatible_llm_channel_models,
find_llm_channel_surface_conflicts,
normalize_llm_channel_api_surface,
normalize_llm_channel_model,
parse_env_bool,
resolve_llm_channel_protocol,
@@ -844,6 +848,10 @@ class GenerationBackendStatusService:
protocol_raw = (effective_map.get(f"{prefix}_PROTOCOL") or "").strip()
if lower == "anspire" and not protocol_raw:
protocol_raw = "openai"
api_surface_raw = (effective_map.get(f"{prefix}_API_SURFACE") or "").strip()
if not is_supported_llm_channel_api_surface_value(api_surface_raw):
continue
api_surface = normalize_llm_channel_api_surface(api_surface_raw)
api_keys = cls._split_csv(effective_map.get(f"{prefix}_API_KEYS") or "")
single_key = (effective_map.get(f"{prefix}_API_KEY") or "").strip()
@@ -857,6 +865,8 @@ class GenerationBackendStatusService:
raw_models = [(effective_map.get("ANSPIRE_LLM_MODEL") or ANSPIRE_LLM_MODEL_DEFAULT).strip()]
if is_reserved_hermes_name(name):
if api_surface == "responses":
continue
result = parse_hermes_channel(
enabled=True,
protocol=protocol_raw or HERMES_DEFAULT_PROTOCOL,
@@ -871,6 +881,10 @@ class GenerationBackendStatusService:
continue
protocol = resolve_llm_channel_protocol(protocol_raw, base_url=base_url, models=raw_models, channel_name=name)
if api_surface == "responses" and protocol != "openai":
continue
if find_incompatible_llm_channel_models(raw_models, protocol, api_surface, base_url):
continue
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 = [""]
@@ -882,6 +896,7 @@ class GenerationBackendStatusService:
{
"name": lower,
"protocol": protocol,
"api_surface": api_surface,
"enabled": True,
"base_url": base_url,
"api_keys": api_keys,
@@ -889,7 +904,14 @@ class GenerationBackendStatusService:
"extra_headers": extra_headers,
}
)
surface_conflicts = set(find_llm_channel_surface_conflicts(channels))
if not surface_conflicts:
return channels
return [
channel
for channel in channels
if not set(channel.get("models") or []).intersection(surface_conflicts)
]
@staticmethod
def _parse_json_object(value: str) -> Optional[Dict[str, Any]]:

View File

@@ -17,9 +17,9 @@ import random
import re
import sys
import time
from typing import List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
from src.config import Config, get_config
from src.config import Config, channel_allows_empty_api_key, get_config
from src.llm.hermes import route_has_hermes
logger = logging.getLogger(__name__)
@@ -227,8 +227,29 @@ def _resolve_vision_model() -> str:
return model
def _matching_vision_deployments(model: str, cfg: Config) -> List[Dict[str, Any]]:
"""Return configured LiteLLM deployments for a public vision route."""
normalized_model = (model or "").strip()
if not normalized_model:
return []
return [
entry
for entry in (getattr(cfg, "llm_model_list", []) or [])
if isinstance(entry, dict)
and str(entry.get("model_name") or "").strip() == normalized_model
and isinstance(entry.get("litellm_params"), dict)
]
def _get_api_keys_for_model(model: str, cfg: Config) -> List[str]:
"""Return available API keys for the given litellm model."""
deployment_keys: List[str] = []
for deployment in _matching_vision_deployments(model, cfg):
key = str((deployment.get("litellm_params") or {}).get("api_key") or "").strip()
if key and len(key) >= 8 and key not in deployment_keys:
deployment_keys.append(key)
if deployment_keys:
return deployment_keys
if model.startswith("gemini/") or model.startswith("vertex_ai/"):
return [k for k in cfg.gemini_api_keys if k and len(k) >= 8]
if model.startswith("anthropic/"):
@@ -236,6 +257,16 @@ def _get_api_keys_for_model(model: str, cfg: Config) -> List[str]:
return [k for k in cfg.openai_api_keys if k and len(k) >= 8]
def _deployment_allows_empty_api_key(deployment: Dict[str, Any]) -> bool:
"""Return whether a configured vision deployment is a supported keyless endpoint."""
params = deployment.get("litellm_params") or {}
if str(params.get("api_key") or "").strip():
return False
wire_model = str(params.get("model") or "").strip()
protocol = wire_model.split("/", 1)[0] if "/" in wire_model else None
return channel_allows_empty_api_key(protocol, params.get("api_base"))
def _call_litellm_vision(image_b64: str, mime_type: str, api_key: Optional[str] = None) -> str:
"""Extract stock codes from an image using litellm (all providers via OpenAI vision format)."""
global litellm
@@ -246,14 +277,36 @@ def _call_litellm_vision(image_b64: str, mime_type: str, api_key: Optional[str]
if route_has_hermes(getattr(cfg, "llm_model_list", []) or [], model):
raise ValueError("Hermes Vision 未验证VISION_MODEL 不能选择包含 Hermes deployment 的 route。")
deployments = _matching_vision_deployments(model, cfg)
keys = _get_api_keys_for_model(model, cfg)
if not keys:
key = api_key if api_key and api_key in keys else (random.choice(keys) if keys else None)
deployment_params: Dict[str, Any] = {}
if deployments:
deployment = next(
(
item
for item in deployments
if str((item.get("litellm_params") or {}).get("api_key") or "").strip() == key
),
None,
)
if deployment is None:
deployment = next(
(item for item in deployments if _deployment_allows_empty_api_key(item)),
None,
)
if deployment is not None:
key = None
if deployment is not None:
deployment_params = dict(deployment.get("litellm_params") or {})
if key is None and not deployment_params:
raise ValueError(f"No API key found for vision model {model}")
key = api_key if api_key and api_key in keys else random.choice(keys)
wire_model = str(deployment_params.get("model") or model).strip()
data_url = f"data:{mime_type};base64,{image_b64}"
call_kwargs: dict = {
"model": model,
"model": wire_model,
"messages": [
{
"role": "user",
@@ -264,11 +317,17 @@ def _call_litellm_vision(image_b64: str, mime_type: str, api_key: Optional[str]
}
],
"max_tokens": 1024,
"api_key": key,
"timeout": VISION_API_TIMEOUT,
}
effective_api_key = str(deployment_params.get("api_key") or key or "").strip()
if effective_api_key:
call_kwargs["api_key"] = effective_api_key
if deployment_params.get("api_base"):
call_kwargs["api_base"] = deployment_params["api_base"]
if deployment_params.get("extra_headers"):
call_kwargs["extra_headers"] = dict(deployment_params["extra_headers"])
# Add api_base and custom headers for OpenAI-compatible providers
if not model.startswith("gemini/") and not model.startswith("anthropic/") and not model.startswith("vertex_ai/"):
if not deployment_params and not model.startswith("gemini/") and not model.startswith("anthropic/") and not model.startswith("vertex_ai/"):
if cfg.openai_base_url:
call_kwargs["api_base"] = cfg.openai_base_url
if cfg.openai_base_url and "aihubmix.com" in cfg.openai_base_url:

View File

@@ -7,6 +7,16 @@ import os
from dataclasses import dataclass, field
from pathlib import Path
from src.config import (
is_supported_llm_channel_api_surface_value,
find_incompatible_llm_channel_models,
find_llm_channel_surface_conflicts,
normalize_llm_channel_api_surface,
normalize_llm_channel_model,
resolve_llm_channel_protocol,
)
from src.llm.hermes import is_reserved_hermes_name
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
_PACKAGE_DIR = Path(__file__).resolve().parent
DEFAULT_POST_ANALYZERS = ["scorecard"]
@@ -409,19 +419,52 @@ def _parse_llm_channels_env() -> list[dict[str, object]]:
continue
key = name.upper()
enabled = _parse_bool_env(f"LLM_{key}_ENABLED", True)
base_url = os.getenv(f"LLM_{key}_BASE_URL", "").strip()
protocol = os.getenv(f"LLM_{key}_PROTOCOL", "").strip().lower()
api_surface_raw = os.getenv(f"LLM_{key}_API_SURFACE", "")
api_surface = normalize_llm_channel_api_surface(api_surface_raw)
api_keys = (
_parse_csv_env(f"LLM_{key}_API_KEYS", [])
or _parse_csv_env(f"LLM_{key}_API_KEY", [])
)
models = _parse_csv_env(f"LLM_{key}_MODELS", [])
resolved_protocol = resolve_llm_channel_protocol(
protocol,
base_url=base_url,
models=models,
channel_name=name,
)
if not is_supported_llm_channel_api_surface_value(api_surface_raw):
continue
effective_protocol = resolved_protocol or "openai"
if api_surface == "responses" and effective_protocol != "openai":
continue
if is_reserved_hermes_name(name) and api_surface == "responses":
continue
if find_incompatible_llm_channel_models(models, effective_protocol, api_surface, base_url):
continue
normalized_models = [
normalize_llm_channel_model(model, effective_protocol, base_url)
for model in models
]
channels.append({
"name": name.lower(),
"protocol": os.getenv(f"LLM_{key}_PROTOCOL", "openai").strip().lower(),
"base_url": os.getenv(f"LLM_{key}_BASE_URL", "").strip(),
"protocol": effective_protocol,
"api_surface": api_surface,
"base_url": base_url,
"api_keys": api_keys,
"models": _parse_csv_env(f"LLM_{key}_MODELS", []),
"models": normalized_models,
"enabled": enabled,
})
return [channel for channel in channels if channel["enabled"]]
enabled_channels = [channel for channel in channels if channel["enabled"]]
surface_conflicts = set(find_llm_channel_surface_conflicts(enabled_channels))
if not surface_conflicts:
return enabled_channels
return [
channel
for channel in enabled_channels
if not set(channel.get("models", [])).intersection(surface_conflicts)
]
def _resolve_llm_model(channels: list[dict[str, object]]) -> str:

View File

@@ -9,6 +9,7 @@ import logging
import os
from dataclasses import dataclass
from src.config import apply_litellm_api_surface
from src.llm.errors import call_litellm_with_param_recovery
from src.llm.generation_params import apply_litellm_generation_params
from src.services.screening.models import Pick
@@ -1042,19 +1043,26 @@ def _build_litellm_attempts(
channels: list[dict[str, object]],
) -> list[dict[str, object]]:
attempts = []
matched_channel = False
for channel in channels:
if not _channel_matches_model(channel, model):
continue
matched_channel = True
api_keys = channel.get("api_keys", [])
if not isinstance(api_keys, list) or not api_keys:
api_keys = [api_key] if api_key else [""]
wire_model = apply_litellm_api_surface(
model,
str(channel.get("api_surface", "") or ""),
)
for channel_key in api_keys:
attempts.append(_completion_kwargs(
model,
wire_model,
api_key=str(channel_key or ""),
base_url=str(channel.get("base_url", "") or base_url or ""),
))
if not matched_channel:
attempts.append(_completion_kwargs(model, api_key=api_key, base_url=base_url))
return _unique_attempts(attempts)

View File

@@ -29,7 +29,7 @@ from urllib.parse import urlparse
from fastapi import HTTPException
from pydantic import BaseModel, Field
from src.config import Config, get_configured_llm_models
from src.config import Config, get_configured_llm_models, normalize_llm_channel_api_surface
from src.services.screening import REFERENCE_PROJECT, REFERENCE_REVISION, __version__ as SCREENING_VERSION
from src.services.screening import hotspot as screening_hotspot
from src.services.screening.config import Config as ScreeningPipelineConfig
@@ -1895,6 +1895,7 @@ def _build_screening_runtime_env(config: Config, *, max_results: Optional[int] =
prefix = channel["name"].upper()
put(f"LLM_{prefix}_ENABLED", "true")
put(f"LLM_{prefix}_PROTOCOL", channel.get("protocol"))
put(f"LLM_{prefix}_API_SURFACE", channel.get("api_surface"))
put(f"LLM_{prefix}_BASE_URL", channel.get("base_url"))
put(f"LLM_{prefix}_API_KEYS", ",".join(channel.get("api_keys") or []))
put(f"LLM_{prefix}_MODELS", ",".join(channel.get("models") or []))
@@ -3179,6 +3180,7 @@ def _normalize_dsa_llm_channels(config: Config) -> List[Dict[str, Any]]:
channel = {
"name": name,
"protocol": _env_text(raw.get("protocol")),
"api_surface": normalize_llm_channel_api_surface(raw.get("api_surface")),
"base_url": _env_text(raw.get("base_url")),
"api_keys": api_keys,
"models": models,

View File

@@ -20,15 +20,23 @@ import requests
from src.config import (
ANSPIRE_LLM_BASE_URL_DEFAULT,
ANSPIRE_LLM_MODEL_DEFAULT,
SUPPORTED_LLM_CHANNEL_API_SURFACES,
SUPPORTED_LLM_CHANNEL_PROTOCOLS,
Config,
_get_litellm_provider,
_uses_direct_env_provider,
apply_litellm_api_surface,
canonicalize_llm_channel_api_surface,
canonicalize_llm_channel_protocol,
channel_allows_empty_api_key,
find_incompatible_llm_channel_models,
find_llm_channel_surface_conflicts,
get_litellm_model_providers,
get_configured_llm_models,
is_supported_llm_channel_api_surface_value,
normalize_agent_litellm_model,
normalize_news_strategy_profile,
normalize_llm_channel_api_surface,
normalize_llm_channel_model,
parse_env_bool,
parse_env_int,
@@ -160,7 +168,7 @@ class SystemConfigService:
"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)$"
r"^LLM_[A-Z0-9_]+_(PROTOCOL|API_SURFACE|BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS|ENABLED)$"
)
_AGENT_BACKEND_STATUS_EXACT_KEYS = {
"AGENT_BACKEND",
@@ -174,7 +182,7 @@ class SystemConfigService:
_LLM_CAPABILITY_ORDER: Tuple[str, ...] = ("json", "tools", "stream", "vision")
_LLM_STREAM_CHUNK_LIMIT = 8
_WEB_SETTINGS_LLM_CHANNEL_SUPPORT_KEY_RE = re.compile(
r"^LLM_([A-Z0-9_]+)_(PROTOCOL|BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS|ENABLED)$"
r"^LLM_([A-Z0-9_]+)_(PROTOCOL|API_SURFACE|BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS|ENABLED)$"
)
_LLM_CAPABILITY_PROBE_IMAGE = (
"data:image/png;base64,"
@@ -492,6 +500,7 @@ class SystemConfigService:
"config_version": self._manager.get_config_version(),
"mask_token": mask_token,
"items": items,
"llm_model_providers": sorted(get_litellm_model_providers()),
"updated_at": self._manager.get_updated_at(),
}
@@ -1193,6 +1202,7 @@ class SystemConfigService:
*,
name: str,
protocol: str,
api_surface: str = "chat_completions",
base_url: str,
api_key: str,
models: Sequence[str],
@@ -1205,13 +1215,15 @@ class SystemConfigService:
requested_capabilities = self._normalize_llm_capability_checks(capability_checks)
raw_models = [str(model).strip() for model in models if str(model).strip()]
channel_name = name.strip() or "channel"
resolved_api_surface = normalize_llm_channel_api_surface(api_surface)
generation_stage = "responses" if resolved_api_surface == "responses" else "chat_completion"
resolved_secret, secret_error, redaction_values = self._resolve_hermes_saved_secret(
channel_name=channel_name,
protocol=protocol,
base_url=base_url,
submitted_api_key=api_key,
use_saved_secret=use_saved_secret,
stage="chat_completion",
stage=generation_stage,
)
if resolved_secret is None:
result = secret_error
@@ -1229,7 +1241,7 @@ class SystemConfigService:
secret_error = self._validate_hermes_submitted_secret(
api_key=api_key,
use_saved_secret=use_saved_secret,
stage="chat_completion",
stage=generation_stage,
capability_checks=requested_capabilities,
redaction_values=redaction_values,
)
@@ -1242,7 +1254,7 @@ class SystemConfigService:
success=False,
message="Hermes Base URL is invalid",
error=str(exc),
stage="chat_completion",
stage=generation_stage,
error_code="invalid_config",
retryable=False,
details={
@@ -1264,6 +1276,7 @@ class SystemConfigService:
validation_issues = self._validate_llm_channel_definition(
channel_name=channel_name,
protocol_value=protocol,
api_surface_value=api_surface,
base_url_value=base_url,
api_key_value=api_key,
model_values=raw_models,
@@ -1277,7 +1290,7 @@ class SystemConfigService:
success=False,
message="LLM channel configuration is invalid",
error=errors[0]["message"],
stage="chat_completion",
stage=generation_stage,
error_code="invalid_config",
retryable=False,
details={
@@ -1302,12 +1315,13 @@ class SystemConfigService:
resolved_model = resolved_models[0]
if is_reserved_hermes_name(channel_name):
resolved_model = canonicalize_hermes_model_ref(raw_models[0]).wire_model
wire_model = apply_litellm_api_surface(resolved_model, resolved_api_surface)
api_keys = [segment.strip() for segment in api_key.split(",") if segment.strip()]
selected_api_key = api_keys[0] if api_keys else ""
redaction_values.update(self._build_redaction_values(selected_api_key))
call_kwargs: Dict[str, Any] = {
"model": resolved_model,
"model": wire_model,
"messages": [{"role": "user", "content": "Reply with OK"}],
"max_tokens": 256, # Increased to allow MiniMax-M3 thinking process + response
"timeout": max(5.0, float(timeout_seconds)),
@@ -1318,7 +1332,7 @@ class SystemConfigService:
call_kwargs["api_base"] = base_url.strip()
call_kwargs = apply_litellm_generation_params(
call_kwargs,
resolved_model,
wire_model,
self._get_runtime_llm_temperature(),
)
@@ -1354,7 +1368,7 @@ class SystemConfigService:
hermes_call_kwargs.pop("api_base", None)
response = call_litellm_with_param_recovery(
lambda kwargs: litellm.completion(**kwargs),
model=resolved_model,
model=wire_model,
call_kwargs=hermes_call_kwargs,
logger=logger,
log_label="[Hermes channel test]",
@@ -1362,7 +1376,7 @@ class SystemConfigService:
else:
response = call_litellm_with_param_recovery(
lambda kwargs: litellm.completion(**kwargs),
model=resolved_model,
model=wire_model,
call_kwargs=call_kwargs,
logger=logger,
log_label="[LLM channel test]",
@@ -1385,6 +1399,7 @@ class SystemConfigService:
details={"response_error": parse_error, "reason": parse_reason},
resolved_protocol=resolved_protocol or None,
resolved_model=resolved_model,
resolved_api_surface=resolved_api_surface,
latency_ms=latency_ms,
capability_results=self._build_skipped_capability_results(
requested_capabilities,
@@ -1409,7 +1424,7 @@ class SystemConfigService:
elif requested_capabilities:
capability_results = self._run_llm_capability_checks(
litellm_module=litellm,
resolved_model=resolved_model,
resolved_model=wire_model,
selected_api_key=selected_api_key,
base_url=base_url,
timeout_seconds=timeout_seconds,
@@ -1419,12 +1434,13 @@ class SystemConfigService:
success=True,
message="LLM channel test succeeded",
error=None,
stage="chat_completion",
stage=generation_stage,
error_code=None,
retryable=False,
details={"response_preview": content[:80]},
resolved_protocol=resolved_protocol or None,
resolved_model=resolved_model,
resolved_api_surface=resolved_api_surface,
latency_ms=latency_ms,
capability_results=capability_results,
redaction_values=redaction_values,
@@ -1440,12 +1456,13 @@ class SystemConfigService:
success=False,
message=diagnostic.message,
error=str(exc),
stage="chat_completion",
stage=generation_stage,
error_code=diagnostic.error_code,
retryable=diagnostic.retryable,
details=self._merge_llm_diagnostic_details({"model": resolved_model}, diagnostic),
resolved_protocol=resolved_protocol or None,
resolved_model=resolved_model,
resolved_api_surface=resolved_api_surface,
latency_ms=None,
redaction_values=redaction_values,
capability_results=self._build_skipped_capability_results(
@@ -3432,6 +3449,9 @@ class SystemConfigService:
protocol = (effective_map.get(f"{prefix}_PROTOCOL") or "").strip()
if name.lower() == "anspire" and not protocol:
protocol = "openai"
api_surface = (effective_map.get(f"{prefix}_API_SURFACE") or "").strip()
if not is_supported_llm_channel_api_surface_value(api_surface):
continue
api_key = (
(effective_map.get(f"{prefix}_API_KEYS") or "").strip()
or (effective_map.get(f"{prefix}_API_KEY") or "").strip()
@@ -3447,6 +3467,8 @@ class SystemConfigService:
).strip()
]
if is_reserved_hermes_name(name):
if normalize_llm_channel_api_surface(api_surface) == "responses":
continue
result = parse_hermes_channel(
enabled=True,
protocol=protocol or HERMES_DEFAULT_PROTOCOL,
@@ -3470,6 +3492,13 @@ class SystemConfigService:
)
if not raw_models or not resolved_protocol:
continue
if find_incompatible_llm_channel_models(
raw_models,
resolved_protocol,
api_surface,
base_url,
):
continue
if not api_key and not channel_allows_empty_api_key(resolved_protocol, base_url):
continue
@@ -3963,6 +3992,7 @@ class SystemConfigService:
retryable: Optional[bool],
details: Optional[Dict[str, Any]] = None,
resolved_protocol: Optional[str] = None,
resolved_api_surface: Optional[str] = None,
resolved_model: Optional[str] = None,
models: Optional[List[str]] = None,
latency_ms: Optional[int] = None,
@@ -3981,6 +4011,10 @@ class SystemConfigService:
resolved_protocol,
redaction_values=redaction_values,
) if resolved_protocol is not None else None,
"resolved_api_surface": cls._sanitize_llm_error_text(
resolved_api_surface,
redaction_values=redaction_values,
) if resolved_api_surface is not None else None,
"latency_ms": latency_ms,
}
if resolved_model is not None or models is None:
@@ -4621,9 +4655,11 @@ class SystemConfigService:
seen_names.add(normalized_upper)
normalized_names.append(name)
validated_channels: List[Dict[str, Any]] = []
for name in normalized_names:
prefix = f"LLM_{name.upper()}"
protocol_value = (effective_map.get(f"{prefix}_PROTOCOL") or "").strip()
api_surface_value = (effective_map.get(f"{prefix}_API_SURFACE") or "").strip()
if name.lower() == "anspire" and not protocol_value:
protocol_value = "openai"
base_url_value = (effective_map.get(f"{prefix}_BASE_URL") or "").strip()
@@ -4654,7 +4690,36 @@ class SystemConfigService:
if name.lower() == "anspire" and not (enabled_raw or "").strip():
enabled_raw = effective_map.get("ANSPIRE_LLM_ENABLED")
enabled = parse_env_bool(enabled_raw, default=True)
if not enabled:
continue
if is_reserved_hermes_name(name):
if not is_supported_llm_channel_api_surface_value(api_surface_value):
issues.append(
{
"key": f"{prefix}_API_SURFACE",
"code": "invalid_api_surface",
"message": (
f"Unsupported LLM API surface '{api_surface_value}'. "
f"Supported: {', '.join(SUPPORTED_LLM_CHANNEL_API_SURFACES)}"
),
"severity": "error",
"expected": ",".join(SUPPORTED_LLM_CHANNEL_API_SURFACES),
"actual": api_surface_value,
}
)
continue
if normalize_llm_channel_api_surface(api_surface_value) == "responses":
issues.append(
{
"key": f"{prefix}_API_SURFACE",
"code": "hermes_responses_unsupported",
"message": "The reserved Hermes channel does not support the Responses API surface",
"severity": "error",
"expected": "chat_completions",
"actual": "responses",
}
)
continue
result = parse_hermes_channel(
enabled=enabled,
protocol=protocol_value or HERMES_DEFAULT_PROTOCOL,
@@ -4675,11 +4740,13 @@ class SystemConfigService:
"actual": "",
}
)
if result.channel is not None and not result.issues:
validated_channels.append(result.channel)
continue
issues.extend(
SystemConfigService._validate_llm_channel_definition(
channel_issues = SystemConfigService._validate_llm_channel_definition(
channel_name=name,
protocol_value=protocol_value,
api_surface_value=api_surface_value,
base_url_value=base_url_value,
api_key_value=api_key_value,
model_values=models_value,
@@ -4687,6 +4754,38 @@ class SystemConfigService:
field_prefix=prefix,
require_complete=enabled,
)
issues.extend(channel_issues)
if not any(issue.get("severity") == "error" for issue in channel_issues):
resolved_protocol = resolve_llm_channel_protocol(
protocol_value,
base_url=base_url_value,
models=models_value,
channel_name=name,
)
validated_channels.append(
{
"name": name.lower(),
"protocol": resolved_protocol,
"api_surface": normalize_llm_channel_api_surface(api_surface_value),
"base_url": base_url_value,
"models": models_value,
"enabled": True,
}
)
for model, surfaces in find_llm_channel_surface_conflicts(validated_channels).items():
issues.append(
{
"key": "LLM_CHANNELS",
"code": "mixed_api_surfaces_for_route",
"message": (
f"LLM route alias '{model}' is declared with multiple API surfaces: "
f"{', '.join(surfaces)}"
),
"severity": "error",
"expected": "one API surface per normalized route alias",
"actual": ",".join(surfaces),
}
)
return issues
@@ -4722,6 +4821,7 @@ class SystemConfigService:
protocol_value = (effective_map.get(f"{prefix}_PROTOCOL") or "").strip()
if name.lower() == "anspire" and not protocol_value:
protocol_value = "openai"
api_surface_value = (effective_map.get(f"{prefix}_API_SURFACE") or "").strip()
raw_models = [
model.strip()
for model in (effective_map.get(f"{prefix}_MODELS") or "").split(",")
@@ -4735,6 +4835,11 @@ class SystemConfigService:
).strip()
]
if is_reserved_hermes_name(name):
if (
not is_supported_llm_channel_api_surface_value(api_surface_value)
or normalize_llm_channel_api_surface(api_surface_value) == "responses"
):
continue
result = parse_hermes_channel(
enabled=True,
protocol=protocol_value or HERMES_DEFAULT_PROTOCOL,
@@ -4751,6 +4856,16 @@ class SystemConfigService:
models.append(model)
continue
resolved_protocol = resolve_llm_channel_protocol(protocol_value, base_url=base_url_value, models=raw_models, channel_name=name)
if (
not is_supported_llm_channel_api_surface_value(api_surface_value)
or find_incompatible_llm_channel_models(
raw_models,
resolved_protocol,
api_surface_value,
base_url_value,
)
):
continue
for model in raw_models:
normalized_model = normalize_llm_channel_model(model, resolved_protocol, base_url_value)
if not normalized_model or normalized_model in seen:
@@ -4779,6 +4894,12 @@ class SystemConfigService:
if not enabled:
continue
api_surface_value = (effective_map.get(f"{prefix}_API_SURFACE") or "").strip()
if (
not is_supported_llm_channel_api_surface_value(api_surface_value)
or normalize_llm_channel_api_surface(api_surface_value) == "responses"
):
continue
raw_models = SystemConfigService._split_csv(effective_map.get(f"{prefix}_MODELS") or "")
result = parse_hermes_channel(
enabled=True,
@@ -4823,6 +4944,9 @@ class SystemConfigService:
protocol_value = (effective_map.get(f"{prefix}_PROTOCOL") or "").strip()
if name.lower() == "anspire" and not protocol_value:
protocol_value = "openai"
api_surface_value = (effective_map.get(f"{prefix}_API_SURFACE") or "").strip()
if not is_supported_llm_channel_api_surface_value(api_surface_value):
continue
raw_models = SystemConfigService._split_csv(effective_map.get(f"{prefix}_MODELS") or "")
if name.lower() == "anspire" and not raw_models:
raw_models = [
@@ -4837,6 +4961,13 @@ class SystemConfigService:
models=raw_models,
channel_name=name,
)
if find_incompatible_llm_channel_models(
raw_models,
resolved_protocol,
api_surface_value,
base_url_value,
):
continue
for raw_model in raw_models:
model = normalize_llm_channel_model(raw_model, resolved_protocol, base_url_value)
if model and model not in seen:
@@ -5216,6 +5347,7 @@ class SystemConfigService:
*,
channel_name: str,
protocol_value: str,
api_surface_value: str,
base_url_value: str,
api_key_value: str,
model_values: Sequence[str],
@@ -5237,6 +5369,73 @@ class SystemConfigService:
require_base_url=False,
)
models_key = f"{field_prefix}_MODELS" if field_prefix != "test_channel" else "models"
api_surface_key = (
f"{field_prefix}_API_SURFACE"
if field_prefix != "test_channel"
else "api_surface"
)
canonical_api_surface = canonicalize_llm_channel_api_surface(api_surface_value)
resolved_api_surface = normalize_llm_channel_api_surface(api_surface_value)
if (
canonical_api_surface
and canonical_api_surface not in SUPPORTED_LLM_CHANNEL_API_SURFACES
):
issues.append(
{
"key": api_surface_key,
"code": "invalid_api_surface",
"message": (
f"Unsupported LLM API surface '{api_surface_value}'. "
f"Supported: {', '.join(SUPPORTED_LLM_CHANNEL_API_SURFACES)}"
),
"severity": "error",
"expected": ",".join(SUPPORTED_LLM_CHANNEL_API_SURFACES),
"actual": api_surface_value,
}
)
elif resolved_api_surface == "responses" and resolved_protocol != "openai":
issues.append(
{
"key": api_surface_key,
"code": "responses_requires_openai_protocol",
"message": "Responses API surface currently requires the openai protocol",
"severity": "error",
"expected": "openai",
"actual": resolved_protocol or protocol_value,
}
)
elif resolved_api_surface == "responses" and is_reserved_hermes_name(channel_name):
issues.append(
{
"key": api_surface_key,
"code": "hermes_responses_unsupported",
"message": "The reserved Hermes channel does not support the Responses API surface",
"severity": "error",
"expected": "chat_completions",
"actual": resolved_api_surface,
}
)
elif resolved_api_surface == "responses":
incompatible_models = find_incompatible_llm_channel_models(
list(model_values),
resolved_protocol,
resolved_api_surface,
base_url_value,
)
if incompatible_models:
issues.append(
{
"key": models_key,
"code": "responses_requires_openai_model_provider",
"message": (
"Responses API surface requires every model to use the OpenAI "
f"provider route; incompatible: {', '.join(incompatible_models[:3])}"
),
"severity": "error",
"expected": "openai/<model> or an unprefixed OpenAI-compatible model ID",
"actual": ", ".join(incompatible_models[:3]),
}
)
if not model_values:
issues.append(

View File

@@ -66,6 +66,7 @@ def test_daily_analysis_maps_all_provider_template_channels() -> None:
prefix = f"LLM_{channel.upper()}_"
for suffix in (
"PROTOCOL",
"API_SURFACE",
"BASE_URL",
"API_KEY",
"API_KEYS",
@@ -88,7 +89,7 @@ def test_daily_analysis_keeps_channel_secrets_in_secrets_context() -> None:
key = f"LLM_{upper}_{suffix}"
assert env[key] == f"${{{{ secrets.{key} }}}}"
for suffix in ("PROTOCOL", "BASE_URL", "MODELS", "ENABLED", "EXTRA_HEADERS"):
for suffix in ("PROTOCOL", "API_SURFACE", "BASE_URL", "MODELS", "ENABLED", "EXTRA_HEADERS"):
key = f"LLM_{upper}_{suffix}"
assert f"vars.{key}" in env[key]
assert f"secrets.{key}" in env[key]

View File

@@ -388,6 +388,142 @@ def test_litellm_channel_route_is_used_for_status_and_smoke_config() -> None:
assert config.llm_model_list[0]["litellm_params"]["api_base"] == "https://api.example.com/v1"
def test_litellm_channel_route_preserves_responses_surface_for_smoke_config() -> None:
_CapturingAnalyzer.configs = []
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "responses",
"LLM_DRAFT_BASE_URL": "https://api.example.com/v1",
"LLM_DRAFT_API_KEY": "sk-draft",
"LLM_DRAFT_MODELS": "gpt-5.6-sol",
},
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-5.6-sol"
assert config.llm_model_list[0]["litellm_params"]["model"] == "openai/responses/gpt-5.6-sol"
assert config.llm_model_list[0]["model_info"]["dsa_api_surface"] == "responses"
def test_litellm_status_skips_unknown_channel_api_surface() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "respones",
"LLM_DRAFT_BASE_URL": "https://api.example.com/v1",
"LLM_DRAFT_API_KEY": "sk-draft",
"LLM_DRAFT_MODELS": "gpt-5.6-sol",
}
)
status = service.get_status()
assert status["primary"]["available"] is False
assert status["primary"]["last_error_code"] == "backend_not_configured"
def test_litellm_status_skips_responses_surface_for_non_openai_protocol() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "anthropic",
"LLM_DRAFT_API_SURFACE": "responses",
"LLM_DRAFT_API_KEY": "sk-draft",
"LLM_DRAFT_MODELS": "claude-sonnet-4-6",
}
)
status = service.get_status()
assert status["primary"]["available"] is False
assert status["primary"]["last_error_code"] == "backend_not_configured"
def test_litellm_status_skips_openai_responses_channel_with_non_openai_model_provider() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "responses",
"LLM_DRAFT_API_KEY": "sk-draft",
"LLM_DRAFT_MODELS": "anthropic/claude-sonnet-4-6",
}
)
status = service.get_status()
assert status["primary"]["available"] is False
assert status["primary"]["last_error_code"] == "backend_not_configured"
def test_litellm_status_skips_openai_responses_channel_with_direct_provider_prefix() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "responses",
"LLM_DRAFT_API_KEY": "sk-draft",
"LLM_DRAFT_MODELS": "xai/grok-beta",
}
)
status = service.get_status()
assert status["primary"]["available"] is False
assert status["primary"]["last_error_code"] == "backend_not_configured"
def test_litellm_status_skips_duplicate_route_alias_with_mixed_surfaces() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LLM_CHANNELS": "chat,responses",
"LLM_CHAT_PROTOCOL": "openai",
"LLM_CHAT_API_KEY": "sk-chat",
"LLM_CHAT_MODELS": "gpt-5.6-sol",
"LLM_RESPONSES_PROTOCOL": "openai",
"LLM_RESPONSES_API_SURFACE": "responses",
"LLM_RESPONSES_API_KEY": "sk-responses",
"LLM_RESPONSES_MODELS": "gpt-5.6-sol",
}
)
status = service.get_status()
assert status["primary"]["available"] is False
assert status["primary"]["last_error_code"] == "backend_not_configured"
def test_litellm_status_skips_unsupported_hermes_responses_surface() -> None:
service = GenerationBackendStatusService(
effective_map={
"GENERATION_BACKEND": "litellm",
"LLM_CHANNELS": "hermes",
"LLM_HERMES_API_SURFACE": "responses",
"LLM_HERMES_API_KEY": "sk-hermes",
}
)
status = service.get_status()
assert status["primary"]["available"] is False
assert status["primary"]["last_error_code"] == "backend_not_configured"
def test_public_effective_config_builder_preserves_smoke_overrides() -> None:
service = GenerationBackendStatusService(
effective_map={

View File

@@ -190,6 +190,75 @@ class TestCallLitellmVision:
assert kwargs["api_base"] == "https://aihubmix.com/v1"
assert kwargs["extra_headers"]["APP-Code"] == "GPIJ3886"
def test_responses_vision_route_uses_deployment_wire_model_and_credentials(self):
cfg = _cfg(
vision_model="openai/gpt-5.6-sol",
openai_api_keys=[_OPENAI_KEY],
openai_base_url="https://legacy.example/v1",
llm_model_list=[{
"model_name": "openai/gpt-5.6-sol",
"litellm_params": {
"model": "openai/responses/gpt-5.6-sol",
"api_key": "sk-channel-test-value",
"api_base": "https://responses.example/v1",
"extra_headers": {"X-Channel": "responses"},
},
"model_info": {"dsa_api_surface": "responses"},
}],
)
with patch("src.services.image_stock_extractor.get_config", return_value=cfg), \
patch("src.services.image_stock_extractor.litellm.completion",
return_value=self._good_response()) as mock_comp:
_call_litellm_vision("b64", "image/jpeg")
kwargs = mock_comp.call_args.kwargs
assert kwargs["model"] == "openai/responses/gpt-5.6-sol"
assert kwargs["api_key"] == "sk-channel-test-value"
assert kwargs["api_base"] == "https://responses.example/v1"
assert kwargs["extra_headers"] == {"X-Channel": "responses"}
def test_responses_vision_route_allows_keyless_loopback_deployment(self):
cfg = _cfg(
vision_model="openai/gpt-5.6-sol",
openai_api_keys=[],
llm_model_list=[{
"model_name": "openai/gpt-5.6-sol",
"litellm_params": {
"model": "openai/responses/gpt-5.6-sol",
"api_base": "http://127.0.0.1:8642/v1",
},
"model_info": {"dsa_api_surface": "responses"},
}],
)
with patch("src.services.image_stock_extractor.get_config", return_value=cfg), \
patch("src.services.image_stock_extractor.litellm.completion",
return_value=self._good_response()) as mock_comp:
_call_litellm_vision("b64", "image/jpeg")
kwargs = mock_comp.call_args.kwargs
assert kwargs["model"] == "openai/responses/gpt-5.6-sol"
assert kwargs["api_base"] == "http://127.0.0.1:8642/v1"
assert "api_key" not in kwargs
def test_responses_vision_route_rejects_keyless_remote_deployment(self):
cfg = _cfg(
vision_model="openai/gpt-5.6-sol",
openai_api_keys=[],
llm_model_list=[{
"model_name": "openai/gpt-5.6-sol",
"litellm_params": {
"model": "openai/responses/gpt-5.6-sol",
"api_base": "https://responses.example/v1",
},
"model_info": {"dsa_api_surface": "responses"},
}],
)
with patch("src.services.image_stock_extractor.get_config", return_value=cfg), \
patch("src.services.image_stock_extractor.litellm.completion") as mock_comp:
with pytest.raises(ValueError, match="No API key found"):
_call_litellm_vision("b64", "image/jpeg")
mock_comp.assert_not_called()
def test_raises_when_model_not_configured(self):
cfg = _cfg(openai_vision_model=None, litellm_model="", gemini_api_keys=[], anthropic_api_keys=[], openai_api_keys=[])
with patch("src.services.image_stock_extractor.get_config", return_value=cfg):

View File

@@ -13,6 +13,7 @@ from src.config import (
ANSPIRE_LLM_BASE_URL_DEFAULT,
ANSPIRE_LLM_MODEL_DEFAULT,
Config,
apply_litellm_api_surface,
get_configured_llm_models,
get_effective_agent_models_to_try,
get_effective_agent_primary_model,
@@ -82,6 +83,333 @@ class LLMChannelConfigTestCase(unittest.TestCase):
params = config.llm_model_list[0]["litellm_params"]
self.assertEqual(params["api_base"], ANSPIRE_LLM_BASE_URL_DEFAULT)
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_anspire_responses_channel_supports_multiple_models_without_changing_aliases(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "anspire",
"LLM_ANSPIRE_API_SURFACE": "responses",
"LLM_ANSPIRE_MODELS": "gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna",
"ANSPIRE_API_KEYS": "sk-anspire-test-value",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertEqual(config.llm_channels[0]["api_surface"], "responses")
deployments = {
entry["model_name"]: entry
for entry in config.llm_model_list
}
for model in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
alias = f"openai/{model}"
self.assertEqual(
deployments[alias]["litellm_params"]["model"],
f"openai/responses/{model}",
)
self.assertEqual(
deployments[alias]["model_info"]["dsa_api_surface"],
"responses",
)
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_non_openai_channel_rejects_responses_surface_from_env(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "anthropic",
"LLM_ANTHROPIC_PROTOCOL": "anthropic",
"LLM_ANTHROPIC_API_SURFACE": "responses",
"LLM_ANTHROPIC_API_KEY": "sk-anthropic-test-value",
"LLM_ANTHROPIC_MODELS": "claude-sonnet-4-6",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertEqual(config.llm_channels, [])
self.assertEqual(config.llm_model_list, [])
self.assertEqual(len(config.llm_channel_config_issues), 1)
issue = config.llm_channel_config_issues[0]
self.assertEqual(issue["field"], "LLM_ANTHROPIC_API_SURFACE")
self.assertEqual(issue["code"], "responses_requires_openai_protocol")
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_openai_responses_channel_rejects_explicit_non_openai_model_provider(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "responses",
"LLM_DRAFT_API_KEY": "sk-draft-test-value",
"LLM_DRAFT_MODELS": "anthropic/claude-sonnet-4-6",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertEqual(config.llm_channels, [])
self.assertEqual(config.llm_model_list, [])
self.assertEqual(
[issue["code"] for issue in config.llm_channel_config_issues],
["responses_requires_openai_model_provider"],
)
self.assertEqual(config.llm_channel_config_issues[0]["field"], "LLM_DRAFT_MODELS")
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_openai_responses_channel_rejects_litellm_direct_provider_prefix(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "responses",
"LLM_DRAFT_API_KEY": "sk-draft-test-value",
"LLM_DRAFT_MODELS": "xai/grok-beta",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertEqual(config.llm_channels, [])
self.assertEqual(
[issue["code"] for issue in config.llm_channel_config_issues],
["responses_requires_openai_model_provider"],
)
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_openai_responses_channel_prefixes_gateway_owned_slash_model_id(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "responses",
"LLM_DRAFT_API_KEY": "sk-draft-test-value",
"LLM_DRAFT_MODELS": "deepseek-ai/DeepSeek-V3",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertEqual(config.llm_channels[0]["models"], ["openai/deepseek-ai/DeepSeek-V3"])
self.assertEqual(
config.llm_model_list[0]["litellm_params"]["model"],
"openai/responses/deepseek-ai/DeepSeek-V3",
)
def test_responses_wire_model_builder_rejects_non_openai_provider(self) -> None:
with self.assertRaisesRegex(ValueError, "normalized openai"):
apply_litellm_api_surface("anthropic/claude-sonnet-4-6", "responses")
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_duplicate_route_alias_rejects_mixed_api_surfaces(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "chat,responses",
"LLM_CHAT_PROTOCOL": "openai",
"LLM_CHAT_API_KEY": "sk-chat-test-value",
"LLM_CHAT_MODELS": "gpt-5.6-sol",
"LLM_RESPONSES_PROTOCOL": "openai",
"LLM_RESPONSES_API_SURFACE": "responses",
"LLM_RESPONSES_API_KEY": "sk-responses-test-value",
"LLM_RESPONSES_MODELS": "gpt-5.6-sol",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertEqual(config.llm_channels, [])
self.assertEqual(config.llm_model_list, [])
self.assertTrue(
any(
issue["code"] == "mixed_api_surfaces_for_route"
and "openai/gpt-5.6-sol" in issue["message"]
for issue in config.llm_channel_config_issues
)
)
def test_model_list_builder_defensively_rejects_mixed_surface_alias(self) -> None:
channels = [
{
"name": "chat",
"protocol": "openai",
"api_surface": "chat_completions",
"base_url": "https://chat.example.com/v1",
"api_keys": ["sk-chat"],
"models": ["openai/gpt-5.6-sol"],
},
{
"name": "responses",
"protocol": "openai",
"api_surface": "responses",
"base_url": "https://responses.example.com/v1",
"api_keys": ["sk-responses"],
"models": ["openai/gpt-5.6-sol"],
},
]
with self.assertRaisesRegex(ValueError, "cannot mix API surfaces"):
Config._channels_to_model_list(channels)
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_duplicate_route_alias_allows_same_surface_deployments(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "primary,backup",
"LLM_PRIMARY_PROTOCOL": "openai",
"LLM_PRIMARY_API_KEY": "sk-primary-test-value",
"LLM_PRIMARY_MODELS": "gpt-4o-mini",
"LLM_BACKUP_PROTOCOL": "openai",
"LLM_BACKUP_API_KEY": "sk-backup-test-value",
"LLM_BACKUP_MODELS": "gpt-4o-mini",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertEqual(len(config.llm_channels), 2)
self.assertEqual(len(config.llm_model_list), 2)
self.assertFalse(
any(
issue["code"] == "mixed_api_surfaces_for_route"
for issue in config.llm_channel_config_issues
)
)
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_unknown_api_surface_from_env_is_rejected_instead_of_falling_back(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "respones",
"LLM_DRAFT_API_KEY": "sk-draft-test-value",
"LLM_DRAFT_MODELS": "gpt-5.6-sol",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertEqual(config.llm_channels, [])
self.assertEqual(config.llm_model_list, [])
self.assertEqual(len(config.llm_channel_config_issues), 1)
issue = config.llm_channel_config_issues[0]
self.assertEqual(issue["field"], "LLM_DRAFT_API_SURFACE")
self.assertEqual(issue["code"], "invalid_api_surface")
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_invalid_anspire_surface_does_not_promote_shared_key_to_legacy(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "anspire",
"LLM_ANSPIRE_API_SURFACE": "respones",
"ANSPIRE_API_KEYS": "sk-anspire-test-value",
"GEMINI_API_KEY": "sk-gemini-test-value",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertFalse(config.llm_blocks_legacy_fallback)
self.assertEqual(config.openai_api_keys, [])
self.assertEqual(config.llm_channels, [])
self.assertEqual(config.llm_models_source, "legacy_env")
self.assertEqual(config.litellm_model, "gemini/gemini-3.1-pro-preview")
self.assertTrue(config.llm_model_list)
self.assertEqual(
[issue["code"] for issue in config.llm_channel_config_issues],
["invalid_api_surface"],
)
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_anspire_responses_protocol_mismatch_does_not_fall_back_to_chat(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "anspire",
"LLM_ANSPIRE_API_SURFACE": "responses",
"LLM_ANSPIRE_PROTOCOL": "anthropic",
"ANSPIRE_API_KEYS": "sk-anspire-test-value",
"GEMINI_API_KEY": "sk-gemini-test-value",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertFalse(config.llm_blocks_legacy_fallback)
self.assertEqual(config.openai_api_keys, [])
self.assertEqual(config.llm_channels, [])
self.assertEqual(config.llm_models_source, "legacy_env")
self.assertEqual(config.litellm_model, "gemini/gemini-3.1-pro-preview")
self.assertTrue(config.llm_model_list)
self.assertEqual(
[issue["code"] for issue in config.llm_channel_config_issues],
["responses_requires_openai_protocol"],
)
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_hermes_responses_surface_is_rejected(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"LLM_CHANNELS": "hermes",
"LLM_HERMES_API_SURFACE": "responses",
"LLM_HERMES_API_KEY": "sk-hermes-test-value",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertTrue(config.llm_blocks_legacy_fallback)
self.assertEqual(config.llm_channels, [])
self.assertEqual(config.llm_model_list, [])
self.assertIn("hermes-agent", config.llm_blocked_hermes_routes)
self.assertIn("openai/hermes-agent", config.llm_blocked_hermes_routes)
self.assertEqual(len(config.llm_channel_config_issues), 1)
issue = config.llm_channel_config_issues[0]
self.assertEqual(issue["field"], "LLM_HERMES_API_SURFACE")
self.assertEqual(issue["code"], "hermes_responses_unsupported")
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_blank_anspire_channel_enabled_uses_shared_disable_flag(
@@ -642,6 +970,7 @@ class LLMChannelConfigTestCase(unittest.TestCase):
env = {
"LLM_CHANNELS": "hermes",
"LLM_HERMES_ENABLED": "false",
"LLM_HERMES_API_SURFACE": "responses",
"OPENAI_API_KEY": "sk-openai-test-value",
}
@@ -649,6 +978,7 @@ class LLMChannelConfigTestCase(unittest.TestCase):
config = Config._load_from_env()
self.assertFalse(config.llm_blocks_legacy_fallback)
self.assertEqual(config.llm_channel_config_issues, [])
self.assertEqual(config.llm_models_source, "legacy_env")
self.assertEqual(config.litellm_model, "openai/gpt-5.5")
self.assertTrue(config.llm_model_list)

View File

@@ -91,6 +91,43 @@ def test_registry_does_not_match_qwen_openai_compatible_route_to_dashscope_nativ
assert caps.provider == "unknown"
def test_route_context_uses_explicit_responses_surface_from_model_list():
route_context = build_provider_cache_route_context(
model="openai/gpt-5.6-sol",
provider="openai",
model_list=[
{
"model_name": "openai/gpt-5.6-sol",
"litellm_params": {"model": "openai/responses/gpt-5.6-sol"},
"model_info": {"dsa_api_surface": "responses"},
}
],
)
assert route_context.api_surface == "responses"
def test_route_context_marks_mixed_surface_alias_unknown():
route_context = build_provider_cache_route_context(
model="openai/shared-model",
provider="openai",
model_list=[
{
"model_name": "openai/shared-model",
"litellm_params": {"model": "openai/responses/shared-model"},
"model_info": {"dsa_api_surface": "responses"},
},
{
"model_name": "openai/shared-model",
"litellm_params": {"model": "openai/shared-model"},
},
],
)
assert route_context.api_surface == "unknown"
assert resolve_provider_cache_caps(route_context).provider == "unknown"
def test_registry_matches_dashscope_native_surface_only_for_native_route():
caps = resolve_provider_cache_caps(
ProviderCacheRouteContext(

View File

@@ -3609,6 +3609,141 @@ class ScreeningOpportunitiesApiTestCase(unittest.TestCase):
self.assertEqual(env["SNAPSHOT_SOURCE_PRIORITY"], "tushare,sina,efinance,akshare_em,em_datacenter")
def test_screening_runtime_env_preserves_responses_api_surface(self) -> None:
config = Config(
screening_enabled=True,
litellm_model="openai/gpt-5.6-sol",
llm_channels=[
{
"name": "draft",
"protocol": "openai",
"api_surface": "responses",
"enabled": True,
"base_url": "https://api.example.com/v1",
"api_keys": ["sk-draft"],
"models": ["openai/gpt-5.6-sol"],
}
],
)
env = screening_service._build_screening_runtime_env(config)
self.assertEqual(env["LLM_DRAFT_API_SURFACE"], "responses")
with patch.dict(os.environ, env, clear=True):
runtime_config = ScreeningPipelineConfig.from_env()
self.assertEqual(runtime_config.llm_channels[0]["api_surface"], "responses")
def test_screening_runtime_env_skips_unknown_api_surface(self) -> None:
with patch.dict(
os.environ,
{
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "respones",
"LLM_DRAFT_API_KEY": "sk-draft",
"LLM_DRAFT_MODELS": "gpt-5.6-sol",
},
clear=True,
):
runtime_config = ScreeningPipelineConfig.from_env()
self.assertEqual(runtime_config.llm_channels, [])
def test_screening_runtime_env_uses_provider_protocol_before_validating_surface(self) -> None:
with patch.dict(
os.environ,
{
"LLM_CHANNELS": "gemini",
"LLM_GEMINI_API_SURFACE": "responses",
"LLM_GEMINI_API_KEY": "gemini-key",
"LLM_GEMINI_MODELS": "gemini-2.5-flash",
},
clear=True,
):
runtime_config = ScreeningPipelineConfig.from_env()
self.assertEqual(runtime_config.llm_channels, [])
def test_screening_runtime_env_skips_openai_responses_channel_with_non_openai_model(self) -> None:
with patch.dict(
os.environ,
{
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "responses",
"LLM_DRAFT_API_KEY": "sk-draft",
"LLM_DRAFT_MODELS": "anthropic/claude-sonnet-4-6",
},
clear=True,
):
runtime_config = ScreeningPipelineConfig.from_env()
self.assertEqual(runtime_config.llm_channels, [])
def test_screening_runtime_env_skips_openai_responses_channel_with_direct_provider_model(self) -> None:
with patch.dict(
os.environ,
{
"LLM_CHANNELS": "draft",
"LLM_DRAFT_PROTOCOL": "openai",
"LLM_DRAFT_API_SURFACE": "responses",
"LLM_DRAFT_API_KEY": "sk-draft",
"LLM_DRAFT_MODELS": "xai/grok-beta",
},
clear=True,
):
runtime_config = ScreeningPipelineConfig.from_env()
self.assertEqual(runtime_config.llm_channels, [])
def test_screening_runtime_env_skips_duplicate_route_alias_with_mixed_surfaces(self) -> None:
with patch.dict(
os.environ,
{
"LLM_CHANNELS": "chat,responses",
"LLM_CHAT_PROTOCOL": "openai",
"LLM_CHAT_API_KEY": "sk-chat",
"LLM_CHAT_MODELS": "gpt-5.6-sol",
"LLM_RESPONSES_PROTOCOL": "openai",
"LLM_RESPONSES_API_SURFACE": "responses",
"LLM_RESPONSES_API_KEY": "sk-responses",
"LLM_RESPONSES_MODELS": "gpt-5.6-sol",
},
clear=True,
):
runtime_config = ScreeningPipelineConfig.from_env()
self.assertEqual(runtime_config.llm_channels, [])
def test_screening_runtime_env_keeps_generic_channel_openai_default(self) -> None:
with patch.dict(
os.environ,
{
"LLM_CHANNELS": "draft",
"LLM_DRAFT_API_KEY": "sk-draft",
"LLM_DRAFT_MODELS": "gpt-5.6-sol",
},
clear=True,
):
runtime_config = ScreeningPipelineConfig.from_env()
self.assertEqual(runtime_config.llm_channels[0]["protocol"], "openai")
def test_screening_runtime_env_skips_unsupported_hermes_responses_surface(self) -> None:
with patch.dict(
os.environ,
{
"LLM_CHANNELS": "hermes",
"LLM_HERMES_API_SURFACE": "responses",
"LLM_HERMES_API_KEY": "sk-hermes",
"LLM_HERMES_MODELS": "hermes-agent",
},
clear=True,
):
runtime_config = ScreeningPipelineConfig.from_env()
self.assertEqual(runtime_config.llm_channels, [])
def test_screen_preserves_explicit_candidate_context_provider_override(self) -> None:
config = self._config(enabled=True)
captured: dict[str, object] = {}

View File

@@ -58,6 +58,114 @@ def test_screening_ranker_direct_call_omits_temperature_for_gpt5() -> None:
assert "temperature" not in completion_calls[0]
def test_screening_ranker_direct_call_uses_responses_wire_model_for_matching_channel() -> None:
completion_calls: list[dict[str, object]] = []
def completion(**kwargs):
completion_calls.append(dict(kwargs))
return _response()
fake_litellm = SimpleNamespace(completion=completion)
with patch.dict(sys.modules, {"litellm": fake_litellm}, clear=False):
result = _call_llm(
"rank candidates",
api_key="test-key",
model="openai/gpt-5.6-sol",
base_url="",
json_mode=False,
channels=[
{
"name": "draft",
"protocol": "openai",
"api_surface": "responses",
"api_keys": ["sk-draft"],
"base_url": "https://api.example.com/v1",
"models": ["openai/gpt-5.6-sol"],
}
],
)
assert result == "ok"
assert len(completion_calls) == 1
assert completion_calls[0]["model"] == "openai/responses/gpt-5.6-sol"
assert completion_calls[0]["api_key"] == "sk-draft"
assert completion_calls[0]["api_base"] == "https://api.example.com/v1"
def test_screening_ranker_does_not_retry_public_alias_after_responses_attempt_failure() -> None:
completion_calls: list[dict[str, object]] = []
def completion(**kwargs):
completion_calls.append(dict(kwargs))
raise RuntimeError("responses endpoint rejected request")
fake_litellm = SimpleNamespace(completion=completion)
with patch.dict(sys.modules, {"litellm": fake_litellm}, clear=False):
try:
_call_llm(
"rank candidates",
api_key="test-key",
model="openai/gpt-5.6-sol",
base_url="https://fallback.example.com/v1",
json_mode=False,
channels=[
{
"name": "draft",
"protocol": "openai",
"api_surface": "responses",
"api_keys": ["sk-draft"],
"base_url": "https://api.example.com/v1",
"models": ["openai/gpt-5.6-sol"],
}
],
)
except RuntimeError as exc:
assert "responses endpoint rejected request" in str(exc)
else:
raise AssertionError("expected _call_llm to raise")
assert len(completion_calls) == 1
assert completion_calls[0]["model"] == "openai/responses/gpt-5.6-sol"
assert completion_calls[0]["api_base"] == "https://api.example.com/v1"
def test_screening_ranker_rejects_invalid_responses_wire_route_before_call() -> None:
completion_calls: list[dict[str, object]] = []
def completion(**kwargs):
completion_calls.append(dict(kwargs))
return _response()
fake_litellm = SimpleNamespace(completion=completion)
with patch.dict(sys.modules, {"litellm": fake_litellm}, clear=False):
try:
_call_llm(
"rank candidates",
api_key="test-key",
model="anthropic/claude-sonnet-4-6",
base_url="",
json_mode=False,
channels=[
{
"name": "draft",
"protocol": "openai",
"api_surface": "responses",
"api_keys": ["sk-draft"],
"models": ["anthropic/claude-sonnet-4-6"],
}
],
)
except ValueError as exc:
assert "normalized openai" in str(exc)
else:
raise AssertionError("expected invalid Responses route to raise")
assert completion_calls == []
def test_screening_ranker_direct_call_retries_temperature_with_param_recovery() -> None:
clear_litellm_generation_param_recovery_cache()
completion_calls: list[dict[str, object]] = []

View File

@@ -111,6 +111,8 @@ class SystemConfigApiTestCase(unittest.TestCase):
def test_get_config_keeps_regular_secret_value_unmasked(self) -> None:
payload = system_config.get_system_config(include_schema=True, service=self.service).model_dump(by_alias=True)
item_map = {item["key"]: item for item in payload["items"]}
self.assertIn("openai", payload["llm_model_providers"])
self.assertIn("xai", payload["llm_model_providers"])
self.assertEqual(item_map["GEMINI_API_KEY"]["value"], "secret-key-value")
self.assertFalse(item_map["GEMINI_API_KEY"]["is_masked"])
@@ -842,6 +844,7 @@ class SystemConfigApiTestCase(unittest.TestCase):
"retryable": False,
"details": {},
"resolved_protocol": "openai",
"resolved_api_surface": "responses",
"resolved_model": "openai/gpt-4o-mini",
"latency_ms": 123,
},
@@ -850,6 +853,7 @@ class SystemConfigApiTestCase(unittest.TestCase):
request=TestLLMChannelRequest(
name="primary",
protocol="openai",
api_surface="responses",
base_url="https://api.example.com/v1",
api_key="sk-test",
models=["gpt-4o-mini"],
@@ -860,10 +864,12 @@ class SystemConfigApiTestCase(unittest.TestCase):
self.assertTrue(payload["success"])
self.assertEqual(payload["resolved_model"], "openai/gpt-4o-mini")
self.assertEqual(payload["resolved_api_surface"], "responses")
self.assertEqual(payload["stage"], "chat_completion")
self.assertEqual(payload["capability_results"], {})
mock_test.assert_called_once()
self.assertEqual(mock_test.call_args.kwargs["capability_checks"], ["json", "stream"])
self.assertEqual(mock_test.call_args.kwargs["api_surface"], "responses")
def test_test_notification_channel_endpoint_returns_service_payload(self) -> None:
with patch.object(

View File

@@ -66,6 +66,8 @@ class SystemConfigServiceTestCase(unittest.TestCase):
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertIn("openai", payload["llm_model_providers"])
self.assertIn("xai", payload["llm_model_providers"])
self.assertIn("GEMINI_API_KEY", items)
self.assertEqual(items["GEMINI_API_KEY"]["value"], "secret-key-value")
self.assertFalse(items["GEMINI_API_KEY"]["is_masked"])
@@ -2035,6 +2037,146 @@ class SystemConfigServiceTestCase(unittest.TestCase):
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "missing_api_key" for issue in validation["issues"]))
def test_validate_rejects_unknown_llm_api_surface(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_SURFACE", "value": "automatic"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_api_surface" for issue in validation["issues"]))
def test_validate_rejects_unknown_anspire_llm_api_surface(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "anspire"},
{"key": "LLM_ANSPIRE_API_SURFACE", "value": "respones"},
{"key": "ANSPIRE_API_KEYS", "value": "sk-anspire-test-value"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LLM_ANSPIRE_API_SURFACE"
and issue["code"] == "invalid_api_surface"
for issue in validation["issues"]
)
)
def test_validate_requires_openai_protocol_for_responses_surface(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "deepseek"},
{"key": "LLM_PRIMARY_API_SURFACE", "value": "responses"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "deepseek-v4-flash"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(issue["code"] == "responses_requires_openai_protocol" for issue in validation["issues"])
)
def test_validate_rejects_non_openai_model_provider_for_responses_surface(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_SURFACE", "value": "responses"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "anthropic/claude-sonnet-4-6"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LLM_PRIMARY_MODELS"
and issue["code"] == "responses_requires_openai_model_provider"
for issue in validation["issues"]
)
)
def test_validate_rejects_litellm_direct_provider_for_responses_surface(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_SURFACE", "value": "responses"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "xai/grok-beta"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LLM_PRIMARY_MODELS"
and issue["code"] == "responses_requires_openai_model_provider"
for issue in validation["issues"]
)
)
def test_validate_rejects_duplicate_route_alias_with_mixed_surfaces(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "chat,responses"},
{"key": "LLM_CHAT_PROTOCOL", "value": "openai"},
{"key": "LLM_CHAT_API_KEY", "value": "sk-chat"},
{"key": "LLM_CHAT_MODELS", "value": "gpt-5.6-sol"},
{"key": "LLM_RESPONSES_PROTOCOL", "value": "openai"},
{"key": "LLM_RESPONSES_API_SURFACE", "value": "responses"},
{"key": "LLM_RESPONSES_API_KEY", "value": "sk-responses"},
{"key": "LLM_RESPONSES_MODELS", "value": "gpt-5.6-sol"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LLM_CHANNELS"
and issue["code"] == "mixed_api_surfaces_for_route"
and "openai/gpt-5.6-sol" in issue["message"]
for issue in validation["issues"]
)
)
def test_validate_rejects_responses_surface_for_hermes_channel(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "hermes"},
{"key": "LLM_HERMES_API_SURFACE", "value": "responses"},
{"key": "LLM_HERMES_API_KEY", "value": "sk-test-value"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(issue["code"] == "hermes_responses_unsupported" for issue in validation["issues"])
)
def test_validate_skips_stale_responses_surface_for_disabled_hermes_channel(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "hermes"},
{"key": "LLM_HERMES_ENABLED", "value": "false"},
{"key": "LLM_HERMES_API_SURFACE", "value": "responses"},
]
)
self.assertTrue(validation["valid"], validation["issues"])
self.assertFalse(
any(issue["key"] == "LLM_HERMES_API_SURFACE" for issue in validation["issues"])
)
def test_validate_preserves_model_based_protocol_inference_for_ollama_channel(self) -> None:
validation = self.service.validate(
items=[
@@ -3423,6 +3565,50 @@ class SystemConfigServiceTestCase(unittest.TestCase):
self.assertEqual(mock_completion.call_args_list[0].kwargs["temperature"], 0.42)
self.assertNotIn("temperature", mock_completion.call_args_list[1].kwargs)
@patch("litellm.completion")
def test_test_llm_channel_routes_responses_surface_through_litellm_bridge(
self,
mock_completion,
) -> None:
mock_completion.return_value = self._mock_completion_response("OK")
payload = self.service.test_llm_channel(
name="anspire",
protocol="openai",
api_surface="responses",
base_url="https://open-gateway.anspire.cn/v6",
api_key="sk-test-value",
models=["gpt-5.6-sol"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["stage"], "responses")
self.assertEqual(payload["resolved_api_surface"], "responses")
self.assertEqual(payload["resolved_model"], "openai/gpt-5.6-sol")
self.assertEqual(
mock_completion.call_args.kwargs["model"],
"openai/responses/gpt-5.6-sol",
)
@patch("litellm.completion")
def test_test_llm_channel_rejects_non_openai_model_before_network_call(
self,
mock_completion,
) -> None:
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
api_surface="responses",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["anthropic/claude-sonnet-4-6"],
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], "invalid_config")
self.assertEqual(payload["details"]["issue_code"], "responses_requires_openai_model_provider")
mock_completion.assert_not_called()
@patch("litellm.completion")
@patch("src.services.system_config_service.Config._load_from_env")
def test_test_llm_channel_uses_runtime_temperature_for_non_kimi_models(