fix: cloud chat /chat startup failures (422 + Azure api_version) (#1605)

This commit is contained in:
Tong Chen 2026-05-01 22:05:35 +08:00 committed by GitHub
parent 819344fc2f
commit caf4affdfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 110 additions and 32 deletions

View file

@ -24,7 +24,10 @@ from camel.types import ModelPlatformType
from app.agent.listen_chat_agent import ListenChatAgent, logger
from app.model.chat import AgentModelConfig, Chat
from app.model.model_platform import patch_bedrock_cloud_config
from app.model.model_platform import (
patch_azure_cloud_config,
patch_bedrock_cloud_config,
)
from app.service.task import ActionCreateAgentData, Agents, get_task_lock
from app.utils.event_loop_utils import _schedule_async_task
@ -89,6 +92,13 @@ def agent_model(
effective_config["api_url"], extra_params = patch_bedrock_cloud_config(
effective_config["api_url"], extra_params
)
# Cloud mode: default api_version for Azure-backed models so AzureOpenAI
# construction does not blow up when the frontend omits extra_params.
if (
effective_config.get("model_platform") == "azure"
and options.is_cloud()
):
extra_params = patch_azure_cloud_config(extra_params)
init_param_keys = {
"api_version",
"azure_ad_token",

View file

@ -23,7 +23,10 @@ from app.agent.prompt import MCP_SYS_PROMPT
from app.agent.toolkit.mcp_search_toolkit import McpSearchToolkit
from app.agent.tools import get_mcp_tools
from app.model.chat import Chat
from app.model.model_platform import patch_bedrock_cloud_config
from app.model.model_platform import (
patch_azure_cloud_config,
patch_bedrock_cloud_config,
)
from app.service.task import ActionCreateAgentData, Agents, get_task_lock
@ -86,6 +89,8 @@ async def mcp_agent(options: Chat):
api_url, extra_params = patch_bedrock_cloud_config(
api_url, extra_params
)
if options.model_platform == "azure" and options.is_cloud():
extra_params = patch_azure_cloud_config(extra_params)
# Build model_config_dict with prompt caching
model_config_dict = {}

View file

@ -27,6 +27,11 @@ PLATFORM_ALIAS_MAPPING: Final[dict[str, str]] = {
# Bedrock Converse requires a region during model initialization.
BEDROCK_CONVERSE_REGION: Final[str] = "us-west-2"
# Azure OpenAI requires an api_version. The cloud proxy accepts any modern
# version; this default keeps cloud-mode requests working when the frontend
# does not surface api_version in extra_params.
AZURE_DEFAULT_API_VERSION: Final[str] = "2024-10-21"
def patch_bedrock_cloud_config(
api_url: str, extra_params: dict
@ -43,6 +48,20 @@ def patch_bedrock_cloud_config(
return api_url, extra_params
def patch_azure_cloud_config(extra_params: dict) -> dict:
"""Default Azure `api_version` for cloud mode.
The cloud proxy fronts Azure OpenAI but the frontend sends an empty
`extra_params` for cloud, leaving `api_version` unset. Camel's
`AzureOpenAIModel` raises if neither the kwarg nor `AZURE_API_VERSION`
env var is provided inject a sensible default here so cloud-mode
GPT models (gpt-5.4, gpt-5.5, gpt-5-mini, ...) construct cleanly.
"""
extra_params = dict(extra_params)
extra_params.setdefault("api_version", AZURE_DEFAULT_API_VERSION)
return extra_params
def normalize_model_platform(platform: str) -> str:
"""Normalize provider aliases to supported model platform names."""
return PLATFORM_ALIAS_MAPPING.get(platform, platform)

View file

@ -72,18 +72,16 @@ const cloudModelOptions = [
{ id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro Preview' },
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro Preview' },
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash Preview' },
{ id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini' },
{ id: 'gpt-4.1', name: 'GPT-4.1' },
{ id: 'gpt-5', name: 'GPT-5' },
{ id: 'gpt-5.1', name: 'GPT-5.1' },
{ id: 'gpt-5.2', name: 'GPT-5.2' },
{ id: 'gpt-5.4', name: 'GPT-5.4' },
{ id: 'gpt-5.5', name: 'GPT-5.5' },
{ id: 'gpt-5-mini', name: 'GPT-5 Mini' },
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },
{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
{ id: 'minimax_m2_5', name: 'Minimax M2.5' },
{ id: 'claude-opus-4-7', name: 'Claude Opus 4.7' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
{ id: 'minimax_m2_7', name: 'Minimax M2.7' },
] as const;
export interface ChatInputModelDropdownProps {
@ -381,9 +379,9 @@ export function ChatInputModelDropdown({
}
)}
>
<span className="gap-1.5 min-w-0 inline-flex min-h-[1.25rem] items-center overflow-hidden">
<span className="inline-flex min-h-[1.25rem] min-w-0 items-center gap-1.5 overflow-hidden">
<Sparkles className="size-3.5 shrink-0" strokeWidth={2} aria-hidden />
<span className="!text-label-xs min-w-0 font-semibold truncate">
<span className="min-w-0 truncate !text-label-xs font-semibold">
{triggerModelName}
</span>
</span>
@ -407,19 +405,19 @@ export function ChatInputModelDropdown({
className={cn(
modelTriggerShellClass,
'min-w-0 cursor-pointer border-0 text-left',
'font-semibold justify-between transition-colors',
'justify-between font-semibold transition-colors',
'hover:bg-ds-bg-neutral-subtle-hover active:bg-ds-bg-neutral-subtle-default',
'focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-ds-bg-neutral-default-default focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-2 focus-visible:ring-offset-ds-bg-neutral-default-default',
'disabled:pointer-events-none disabled:opacity-50'
)}
>
<span className="gap-1.5 min-w-0 flex flex-1 items-center overflow-hidden">
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<Sparkles
className="size-3.5 shrink-0"
strokeWidth={2}
aria-hidden
/>
<span className="!text-label-xs min-w-0 text-ds-text-neutral-default-default flex-1 truncate text-left">
<span className="min-w-0 flex-1 truncate text-left !text-label-xs text-ds-text-neutral-default-default">
{triggerModelName}
</span>
</span>
@ -446,7 +444,7 @@ export function ChatInputModelDropdown({
}}
>
<DropdownMenuSubTrigger
className="gap-2 min-w-0 [&>svg:first-child]:!h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!min-w-4 flex w-full items-center justify-start"
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
onPointerEnter={(e) => {
activeSubTriggerRef.current = e.currentTarget;
}}
@ -454,10 +452,10 @@ export function ChatInputModelDropdown({
<img
src={folderIcon}
alt=""
className="h-4 w-4 mt-0.5 shrink-0"
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden
/>
<span className="text-body-sm min-w-0 flex-1 text-left">
<span className="min-w-0 flex-1 text-left text-body-sm">
{t('setting.eigent-cloud')}
</span>
</DropdownMenuSubTrigger>
@ -492,16 +490,16 @@ export function ChatInputModelDropdown({
}}
>
<DropdownMenuSubTrigger
className="gap-2 min-w-0 [&>svg:first-child]:!h-5 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!min-w-4 flex w-full items-center justify-start"
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-5 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
onPointerEnter={(e) => {
activeSubTriggerRef.current = e.currentTarget;
}}
>
<Layers
className="text-ds-icon-neutral-default-default shrink-0"
className="shrink-0 text-ds-icon-neutral-default-default"
aria-hidden
/>
<span className="text-body-sm min-w-0 flex-1 text-left">
<span className="min-w-0 flex-1 text-left text-body-sm">
{t('setting.custom-model')}
</span>
</DropdownMenuSubTrigger>
@ -525,7 +523,7 @@ export function ChatInputModelDropdown({
}}
className="flex items-center justify-between"
>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{modelImage ? (
<img
src={modelImage}
@ -546,15 +544,15 @@ export function ChatInputModelDropdown({
{item.name}
</span>
</div>
<div className="gap-1 flex items-center">
<div className="flex items-center gap-1">
{!isConfigured && (
<div className="h-2 w-2 bg-ds-text-neutral-subtle-default rounded-full opacity-10" />
<div className="h-2 w-2 rounded-full bg-ds-text-neutral-subtle-default opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-ds-text-success-default-default" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 bg-ds-text-success-default-default rounded-full" />
<div className="h-2 w-2 rounded-full bg-ds-text-success-default-default" />
)}
</div>
</DropdownMenuItem>
@ -569,16 +567,16 @@ export function ChatInputModelDropdown({
}}
>
<DropdownMenuSubTrigger
className="gap-2 min-w-0 [&>svg:first-child]:!h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!min-w-4 flex w-full items-center justify-start"
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
onPointerEnter={(e) => {
activeSubTriggerRef.current = e.currentTarget;
}}
>
<HardDrive
className="text-ds-icon-neutral-default-default shrink-0"
className="shrink-0 text-ds-icon-neutral-default-default"
aria-hidden
/>
<span className="text-body-sm min-w-0 flex-1 text-left">
<span className="min-w-0 flex-1 text-left text-body-sm">
{t('setting.local-model')}
</span>
</DropdownMenuSubTrigger>
@ -602,7 +600,7 @@ export function ChatInputModelDropdown({
}}
className="flex items-center justify-between"
>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{modelImage ? (
<img
src={modelImage}
@ -623,15 +621,15 @@ export function ChatInputModelDropdown({
{model.name}
</span>
</div>
<div className="gap-1 flex items-center">
<div className="flex items-center gap-1">
{!isConfigured && (
<div className="h-2 w-2 bg-ds-text-neutral-subtle-default rounded-full opacity-10" />
<div className="h-2 w-2 rounded-full bg-ds-text-neutral-subtle-default opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-ds-text-success-default-default" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 bg-ds-text-success-default-default rounded-full" />
<div className="h-2 w-2 rounded-full bg-ds-text-success-default-default" />
)}
</div>
</DropdownMenuItem>

View file

@ -136,6 +136,27 @@ const getRandomDefaultModel = (): CloudModelType => {
return models[Math.floor(Math.random() * models.length)];
};
const SUPPORTED_CLOUD_MODEL_TYPES: ReadonlySet<CloudModelType> =
new Set<CloudModelType>([
'gemini-3.1-pro-preview',
'gemini-3-pro-preview',
'gemini-3-flash-preview',
'claude-haiku-4-5',
'claude-sonnet-4-5',
'claude-sonnet-4-6',
'claude-opus-4-6',
'claude-opus-4-7',
'gpt-5.4',
'gpt-5.5',
'gpt-5-mini',
'deepseek-v4-pro',
'minimax_m2_7',
]);
const isSupportedCloudModelType = (value: unknown): value is CloudModelType =>
typeof value === 'string' &&
SUPPORTED_CLOUD_MODEL_TYPES.has(value as CloudModelType);
// create store
const authStore = create<AuthState>()(
persist(
@ -306,7 +327,7 @@ const authStore = create<AuthState>()(
}),
{
name: 'auth-storage',
version: 6,
version: 7,
migrate: (persistedState, _version) => {
const s = persistedState as
| {
@ -314,10 +335,19 @@ const authStore = create<AuthState>()(
appearanceMode?: AppearanceMode;
customThemeCatalog?: Partial<ThemeCatalog>;
workspaceMainBackground?: string;
cloud_model_type?: unknown;
}
| undefined;
if (!s) return persistedState as typeof persistedState;
// Drop unsupported cloud model ids so stale values like 'gpt-5.2'
// (removed from the chat input dropdown) don't keep submitting an
// empty model_platform and triggering 422 on /chat.
const sanitizedCloudModelType: CloudModelType =
isSupportedCloudModelType(s.cloud_model_type)
? s.cloud_model_type
: getRandomDefaultModel();
const rawWmb = s.workspaceMainBackground;
let workspaceMainBackground: WorkspaceMainBackground = 'empty';
if (
@ -352,6 +382,7 @@ const authStore = create<AuthState>()(
appearanceMode: 'light',
customThemeCatalog: normalizedCustomCatalog,
workspaceMainBackground,
cloud_model_type: sanitizedCloudModelType,
};
}
return {
@ -360,6 +391,7 @@ const authStore = create<AuthState>()(
appearanceMode: normalizedAppearanceMode,
customThemeCatalog: normalizedCustomCatalog,
workspaceMainBackground,
cloud_model_type: sanitizedCloudModelType,
} as typeof persistedState;
},
partialize: (state) => ({

View file

@ -15,9 +15,20 @@
// Usage: npm run dev:web | npm run build:web
import react from '@vitejs/plugin-react';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import { defineConfig, loadEnv } from 'vite';
const proxyHttpsAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30_000,
});
const proxyHttpAgent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 30_000,
});
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
@ -44,6 +55,9 @@ export default defineConfig(({ mode }) => {
'/api': {
target: env.VITE_PROXY_URL,
changeOrigin: true,
agent: env.VITE_PROXY_URL.startsWith('https')
? proxyHttpsAgent
: proxyHttpAgent,
},
}
: undefined,