diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index 0f4eec6ac..5841d1a4f 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -221,6 +221,20 @@ pub(crate) fn process_alive(pid: u32) -> bool { }; // SAFETY: signal 0 performs liveness/permission checking only. if unsafe { libc::kill(pid, 0) } == 0 { + #[cfg(target_os = "linux")] + { + // A zombie still answers to kill(0), but it has already exited and + // must not be treated as an authenticated leader for escalation. + if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) { + if stat + .rsplit_once(") ") + .and_then(|(_, fields)| fields.split_whitespace().next()) + == Some("Z") + { + return false; + } + } + } return true; } matches!( diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index f006fd8bc..2f11db7f6 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -5172,6 +5172,13 @@ pub async fn get_ai_model_catalog() -> Result bitfun_core_types::ReasoningCatalogProjection { + bitfun_core::project_ai_model_reasoning_catalog(request).await +} + #[tauri::command] pub async fn get_models_dev_catalog_status() -> bitfun_core_types::ModelsDevCatalogStatus { bitfun_core::get_models_dev_catalog_status().await diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 8ed42fb85..75817f61e 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -525,6 +525,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_ai_model_catalog", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ( + "project_ai_model_reasoning_catalog", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "get_models_dev_catalog_status", RemoteWorkspacePolicy::LocalOnly, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index aa24b400d..8cabb43c6 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1604,6 +1604,7 @@ pub async fn run() { subscribe_config_updates, get_model_configs, get_ai_model_catalog, + project_ai_model_reasoning_catalog, get_models_dev_catalog_status, refresh_models_dev_catalog_now, reveal_models_dev_cache_directory, diff --git a/src/crates/adapters/ai-adapters/src/models_dev.rs b/src/crates/adapters/ai-adapters/src/models_dev.rs index a444952b5..bb05ca466 100644 --- a/src/crates/adapters/ai-adapters/src/models_dev.rs +++ b/src/crates/adapters/ai-adapters/src/models_dev.rs @@ -567,6 +567,7 @@ pub fn project_reasoning_catalog_with_limit_and_auto_binding( }; let mut descriptors = BTreeMap::::new(); + let mut unavailable_descriptors = BTreeMap::::new(); let mut has_unmapped_reasoning = false; if let Some((source_provider, source_model)) = source_match { if source_model.reasoning { @@ -606,6 +607,37 @@ pub fn project_reasoning_catalog_with_limit_and_auto_binding( | ModelsDevReasoningOption::Toggle | ModelsDevReasoningOption::BudgetTokens { .. } => { has_unmapped_reasoning = true; + if matches!(binding, ReasoningCatalogBinding::ModelsDev { .. }) { + let unavailable = match option { + ModelsDevReasoningOption::Effort { values } => effort_descriptors( + values, + support.nullable_effort, + ReasoningPresetSource::ModelsDev, + source_provider, + &source_model.id, + ), + ModelsDevReasoningOption::Toggle => toggle_descriptors( + ReasoningPresetSource::ModelsDev, + source_provider, + &source_model.id, + ), + ModelsDevReasoningOption::BudgetTokens { min, max } => { + budget_descriptors( + *min, + *max, + source_model.output_limit, + effective_max_output_tokens, + provider, + ReasoningPresetSource::ModelsDev, + source_provider, + &source_model.id, + ) + } + }; + for descriptor in unavailable { + unavailable_descriptors.insert(descriptor.id.clone(), descriptor); + } + } Vec::new() } }; @@ -673,12 +705,15 @@ pub fn project_reasoning_catalog_with_limit_and_auto_binding( } if preset.disabled { descriptors.remove(preset_id); + unavailable_descriptors.remove(preset_id); continue; } if preset.actions.is_empty() { descriptors.remove(preset_id); + unavailable_descriptors.remove(preset_id); continue; } + unavailable_descriptors.remove(preset_id); descriptors.insert( preset_id.to_string(), ReasoningPresetDescriptor { @@ -704,6 +739,12 @@ pub fn project_reasoning_catalog_with_limit_and_auto_binding( .cmp(&right.order) .then_with(|| left.id.cmp(&right.id)) }); + let mut unavailable_presets = unavailable_descriptors.into_values().collect::>(); + unavailable_presets.sort_by(|left, right| { + left.order + .cmp(&right.order) + .then_with(|| left.id.cmp(&right.id)) + }); let status = if !presets.is_empty() { ReasoningCapabilityStatus::Known } else if matches!(binding, ReasoningCatalogBinding::Disabled) { @@ -731,6 +772,7 @@ pub fn project_reasoning_catalog_with_limit_and_auto_binding( status, default_preset: default_preset.map(ToOwned::to_owned), presets, + unavailable_presets, } } @@ -1193,7 +1235,9 @@ mod tests { }}, "anthropic": {"models": { "claude-sonnet-4-6": {"id":"claude-sonnet-4-6","reasoning":true, - "reasoning_options":[{"type":"effort","values":["low","high"]},{"type":"budget_tokens","min":1024}]} + "reasoning_options":[{"type":"effort","values":["low","high"]},{"type":"budget_tokens","min":1024}]}, + "claude-fable-5": {"id":"claude-fable-5","reasoning":true, + "reasoning_options":{"type":"effort","values":["low","medium","high","xhigh","max"]}} }}, "deepseek": {"models": { "deepseek-v4-flash": {"id":"deepseek-v4-flash","reasoning":true, @@ -1916,6 +1960,35 @@ mod tests { })); } + #[test] + fn explicit_anthropic_binding_reports_efforts_unavailable_to_openai_chat() { + let configured = ReasoningConfig { + catalog: ReasoningCatalogBinding::ModelsDev { + provider: "anthropic".to_string(), + model: "claude-fable-5".to_string(), + }, + ..Default::default() + }; + let projection = project_reasoning_catalog( + "openai", + "dummy-model", + "http://localhost:8000/v1/chat/completions", + Some(&configured), + Some(&catalog()), + ); + + assert_eq!(projection.status, ReasoningCapabilityStatus::Unknown); + assert!(projection.presets.is_empty()); + assert_eq!( + projection + .unavailable_presets + .iter() + .map(|preset| preset.id.as_str()) + .collect::>(), + ["low", "medium", "high", "xhigh", "max"] + ); + } + #[test] fn custom_presets_keep_the_explicit_catalog_identity_for_adapter_compilation() { let configured = ReasoningConfig { diff --git a/src/crates/assembly/core/src/infrastructure/ai/reasoning_catalog.rs b/src/crates/assembly/core/src/infrastructure/ai/reasoning_catalog.rs index 587165e75..3b616bff4 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/reasoning_catalog.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/reasoning_catalog.rs @@ -15,6 +15,8 @@ use bitfun_core_types::{ ReasoningCatalogBinding, ReasoningCatalogProjection, ReasoningPresetDescriptor, }; #[cfg(feature = "model-catalog")] +use bitfun_core_types::ReasoningCatalogProjectionRequest; +#[cfg(feature = "model-catalog")] use bitfun_events::{AIModelCatalogUpdatedEvent, AI_MODEL_CATALOG_UPDATED_EVENT}; #[cfg(feature = "model-catalog")] use bitfun_services_integrations::models_dev::{ @@ -317,6 +319,25 @@ pub(crate) fn project_model_reasoning_catalog( ) } +#[cfg(feature = "model-catalog")] +pub(crate) async fn project_reasoning_catalog_request( + request: ReasoningCatalogProjectionRequest, +) -> ReasoningCatalogProjection { + let models_dev = load_models_dev_reasoning_catalog().await; + project_model_reasoning_catalog( + &AIModelConfig { + provider: request.provider, + model_name: request.model_name, + base_url: request.base_url, + context_window: request.context_window, + max_tokens: request.max_tokens, + reasoning: Some(request.reasoning), + ..Default::default() + }, + models_dev.catalog.as_deref(), + ) +} + pub(crate) fn resolve_reasoning_preset<'a>( projection: &'a ReasoningCatalogProjection, preset_id: &str, @@ -406,8 +427,8 @@ pub(crate) fn apply_selected_reasoning_preset( #[cfg(test)] mod tests { use bitfun_core_types::{ - ReasoningCatalogBinding, ReasoningConfig, ReasoningPreset, ReasoningPresetAction, - ReasoningPresetSource, + ReasoningCatalogBinding, ReasoningCatalogProjectionRequest, ReasoningConfig, + ReasoningPreset, ReasoningPresetAction, ReasoningPresetSource, }; use super::{ @@ -494,6 +515,45 @@ mod tests { assert_eq!(resolve_default_reasoning_preset(&projection), Some(high)); } + #[test] + fn projection_request_shape_projects_explicit_models_dev_presets() { + let request = ReasoningCatalogProjectionRequest { + provider: "responses".to_string(), + model_name: "gateway-alias".to_string(), + base_url: "https://gateway.example.com/v1/responses".to_string(), + context_window: Some(128_000), + max_tokens: Some(8_192), + reasoning: ReasoningConfig { + catalog: ReasoningCatalogBinding::ModelsDev { + provider: "openai".to_string(), + model: "gpt-test".to_string(), + }, + ..Default::default() + }, + }; + let projection = project_model_reasoning_catalog( + &AIModelConfig { + provider: request.provider, + model_name: request.model_name, + base_url: request.base_url, + context_window: request.context_window, + max_tokens: request.max_tokens, + reasoning: Some(request.reasoning), + ..Default::default() + }, + Some(&catalog()), + ); + + assert_eq!( + projection + .presets + .iter() + .map(|preset| preset.id.as_str()) + .collect::>(), + ["low", "high"] + ); + } + #[test] fn openbitfun_models_use_their_exact_upstream_reasoning_catalogs() { for (provider, base_url) in [ diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index ac5bb707c..77ef2c8c4 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -78,6 +78,13 @@ pub async fn get_ai_model_catalog() -> Result { service_agent_runtime::CoreServiceAgentRuntime::load_remote_model_catalog(None).await } +#[cfg(feature = "model-catalog")] +pub async fn project_ai_model_reasoning_catalog( + request: bitfun_core_types::ReasoningCatalogProjectionRequest, +) -> bitfun_core_types::ReasoningCatalogProjection { + infrastructure::ai::reasoning_catalog::project_reasoning_catalog_request(request).await +} + #[cfg(feature = "model-catalog")] pub async fn get_models_dev_catalog_status() -> bitfun_core_types::ModelsDevCatalogStatus { infrastructure::ai::reasoning_catalog::get_models_dev_catalog_status().await diff --git a/src/crates/contracts/core-types/src/ai.rs b/src/crates/contracts/core-types/src/ai.rs index f5234af1d..6406df7e6 100644 --- a/src/crates/contracts/core-types/src/ai.rs +++ b/src/crates/contracts/core-types/src/ai.rs @@ -198,6 +198,26 @@ pub struct ReasoningCatalogProjection { pub default_preset: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub presets: Vec, + /// Presets declared by the selected models.dev model that the active + /// request adapter cannot compile reliably. These are informational only + /// and must not be offered as selectable presets. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unavailable_presets: Vec, +} + +/// Secret-free model facts used to preview the effective reasoning presets +/// while a model configuration is still being edited. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ReasoningCatalogProjectionRequest { + pub provider: String, + pub model_name: String, + pub base_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_window: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + pub reasoning: ReasoningConfig, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index b27f05d7a..fbd506b48 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -21,9 +21,9 @@ pub use ai::{ ProviderCatalogModelLimits, ProviderCatalogModelPricing, ProviderCatalogModelSource, ProviderCatalogProvider, ProviderCatalogSource, ProviderCatalogUpstreamProvider, ProxyConfig, ReasoningCapabilityStatus, ReasoningCatalogBinding, ReasoningCatalogProjection, - ReasoningConfig, ReasoningPreset, ReasoningPresetAction, ReasoningPresetDescriptor, - ReasoningPresetSource, RemoteModelInfo, ToolCall, ToolCallConfirmationDetails, - ToolCallRequestInfo, ToolCallResponseInfo, ToolDefinition, + ReasoningCatalogProjectionRequest, ReasoningConfig, ReasoningPreset, ReasoningPresetAction, + ReasoningPresetDescriptor, ReasoningPresetSource, RemoteModelInfo, ToolCall, + ToolCallConfirmationDetails, ToolCallRequestInfo, ToolCallResponseInfo, ToolDefinition, }; pub use errors::{AiErrorDetail, ErrorCategory}; pub use session::{ diff --git a/src/crates/interfaces/app-server-client/src/lib.rs b/src/crates/interfaces/app-server-client/src/lib.rs index 155e327fe..8b4c59439 100644 --- a/src/crates/interfaces/app-server-client/src/lib.rs +++ b/src/crates/interfaces/app-server-client/src/lib.rs @@ -174,6 +174,13 @@ impl AppServerClient { .await } + pub async fn project_reasoning_catalog( + &self, + request: ProjectReasoningCatalogRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + pub async fn worktree_repository_status( &self, request: WorktreeRepositoryStatusRequest, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/model.rs b/src/crates/interfaces/app-server-protocol/src/schemas/model.rs index 9cf851b37..1164c1297 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/model.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/model.rs @@ -5,7 +5,9 @@ //! are never returned by the server. use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; -use bitfun_core_types::{ProviderCatalog, ReasoningConfig}; +use bitfun_core_types::{ + ProviderCatalog, ReasoningCatalogProjection, ReasoningCatalogProjectionRequest, ReasoningConfig, +}; use serde::{Deserialize, Serialize}; macro_rules! unit_response { @@ -54,6 +56,16 @@ pub struct TuiModelCatalogResponse { pub reasoning_presets_by_model: std::collections::BTreeMap>, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "model/projectReasoningCatalog", response = ProjectReasoningCatalogResponse)] +#[serde(transparent)] +pub struct ProjectReasoningCatalogRequest(pub ReasoningCatalogProjectionRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct ProjectReasoningCatalogResponse { + pub projection: ReasoningCatalogProjection, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ModelSummary { diff --git a/src/crates/interfaces/app-server/src/management.rs b/src/crates/interfaces/app-server/src/management.rs index 79534bc75..b1b4ac8e2 100644 --- a/src/crates/interfaces/app-server/src/management.rs +++ b/src/crates/interfaces/app-server/src/management.rs @@ -93,6 +93,7 @@ impl AppManagementCapabilities { self.models.clone(), &[ "config/getTuiModelCatalog", + "model/projectReasoningCatalog", "model/list", "model/get", "model/add", diff --git a/src/crates/interfaces/app-server/src/management/service.rs b/src/crates/interfaces/app-server/src/management/service.rs index 3a87bd2f5..1513d809a 100644 --- a/src/crates/interfaces/app-server/src/management/service.rs +++ b/src/crates/interfaces/app-server/src/management/service.rs @@ -1352,6 +1352,15 @@ impl AppManagementService { }) } + pub async fn project_reasoning_catalog( + &self, + request: ProjectReasoningCatalogRequest, + ) -> AppManagementResult { + Ok(ProjectReasoningCatalogResponse { + projection: bitfun_core::project_ai_model_reasoning_catalog(request.0).await, + }) + } + pub async fn add_model( &self, request: AddModelRequest, diff --git a/src/crates/interfaces/app-server/src/server/handlers/app.rs b/src/crates/interfaces/app-server/src/server/handlers/app.rs index 4d59cc6bd..84b81cb02 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/app.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/app.rs @@ -166,6 +166,7 @@ fn registered_capabilities( "config/getAgentProfileConfig", "config/getModelConfigs", "config/getTuiModelCatalog", + "model/projectReasoningCatalog", "config/getConfig", "config/getConfigs", "config/setConfig", diff --git a/src/crates/interfaces/app-server/src/server/handlers/model.rs b/src/crates/interfaces/app-server/src/server/handlers/model.rs index d485dc3e9..4229041b3 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/model.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/model.rs @@ -13,6 +13,15 @@ pub(in crate::server) fn builder( AppServer .builder() .name("model handlers") + .on_receive_request( + management_handler!( + management, + MODELS_CAPABILITY, + ProjectReasoningCatalogRequest, + project_reasoning_catalog + ), + agent_client_protocol::on_receive_request!(), + ) .on_receive_request( management_handler!( management, diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 597361e22..63095104b 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -2216,6 +2216,7 @@ fn remote_connect_model_catalog_builder_preserves_config_shape() { execution_provider: None, execution_model: None, }], + unavailable_presets: Vec::new(), }), }], provider_catalog: Default::default(), diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts index b687def46..accc2d3e7 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts @@ -99,6 +99,9 @@ describe('resolveWsMethod', () => { 'config/getAgentProfileConfig' ); expect(resolveWsMethod('get_model_configs')).toBe('config/getModelConfigs'); + expect(resolveWsMethod('project_ai_model_reasoning_catalog')).toBe( + 'model/projectReasoningCatalog', + ); expect(resolveWsMethod('get_config')).toBe('config/getConfig'); expect(resolveWsMethod('get_configs')).toBe('config/getConfigs'); expect(resolveWsMethod('set_agent_profile_config')).toBe( @@ -139,11 +142,11 @@ describe('resolveWsMethod', () => { // covers the schema methods (key count is stable; ordering is not pinned // because the table is a plain object). Track B Batch 1 added config write + // i18n and the P0 Session/Config control plane. Atomic cloud-speech save - // and config validation raise the count to 33. + // config validation, and live reasoning projection raise the count to 34. expect(AGENT_COMMAND_SCHEMA.start_dialog_turn.method).toBe( 'agent/submitDialogTurn' ); - expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(33); + expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(34); // Touch the locals so noUnusedLocals does not flag them under vitest's // transformed build (tsc --noEmit is the real gate; this is belt-and-suspenders). diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts index 98194d740..3d23f6b0d 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts @@ -158,6 +158,7 @@ export const AGENT_COMMAND_SCHEMA = { get_agent_profile_configs: { method: 'config/getAgentProfileConfigs' }, get_agent_profile_config: { method: 'config/getAgentProfileConfig' }, get_model_configs: { method: 'config/getModelConfigs' }, + project_ai_model_reasoning_catalog: { method: 'model/projectReasoningCatalog' }, get_config: { method: 'config/getConfig' }, get_configs: { method: 'config/getConfigs' }, set_agent_profile_config: { @@ -290,6 +291,8 @@ export function decodeResponseBody(action: string, result: any): any { return unwrapArray(result, 'records'); case 'git_get_branches': return unwrapArray(result, 'branches'); + case 'project_ai_model_reasoning_catalog': + return result?.projection ?? result; case 'set_agent_profile_config': return 'Agent profile configuration updated successfully'; case 'reset_agent_profile_config': diff --git a/src/web-ui/src/infrastructure/api/service-api/AIApi.ts b/src/web-ui/src/infrastructure/api/service-api/AIApi.ts index c5d8bd8ed..7a8af8003 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AIApi.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AIApi.ts @@ -7,6 +7,7 @@ import type { ConnectionTestMessageCode } from '@/shared/utils/aiConnectionTestM import type { OpenCodePlan, ReasoningCatalogProjection, + ReasoningConfig, SubscriptionProvider, } from '@/infrastructure/config/types'; export type { @@ -180,6 +181,15 @@ export interface AIModelCatalog { session_model_id?: string; } +export interface ReasoningCatalogProjectionRequest { + provider: string; + modelName: string; + baseUrl: string; + contextWindow?: number; + maxTokens?: number; + reasoning: ReasoningConfig; +} + export type SubscriptionLoginStatus = 'pending' | 'authorized' | 'failed' | 'cancelled'; export interface SubscriptionOfferingModel { @@ -253,6 +263,19 @@ export class AIApi { } } + async projectReasoningCatalog( + request: ReasoningCatalogProjectionRequest, + ): Promise { + try { + return await api.invoke( + 'project_ai_model_reasoning_catalog', + { request }, + ); + } catch (error) { + throw createTauriCommandError('project_ai_model_reasoning_catalog', error); + } + } + async getModelsDevCatalogStatus(): Promise { try { return await api.invoke('get_models_dev_catalog_status', {}); diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index a69305a44..7ef95a0f2 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -3098,6 +3098,15 @@ const AIModelConfig: React.FC = () => { ) ? resolveDraftCatalogEntry(reasoningPanelDraft)?.reasoning : undefined; + const reasoningPanelProjectionRequest = reasoningPanelDraft && editingConfig + ? { + provider: editingConfig.provider || 'openai', + modelName: reasoningPanelDraft.modelName, + baseUrl: editingConfig.base_url || '', + contextWindow: reasoningPanelDraft.contextWindow, + maxTokens: reasoningPanelDraft.maxTokens, + } + : undefined; const modelsDevSourceLabel = modelsDevStatus ? t(`modelsDevCatalog.source.${modelsDevStatus.active_source}`) : t('modelsDevCatalog.loading'); @@ -3737,6 +3746,11 @@ const AIModelConfig: React.FC = () => { value={reasoningPanelDraft.reasoning} generatedProjection={reasoningPanelProjection} modelsDevReasoningCatalog={modelCatalog?.models_dev_reasoning_catalog} + projectionRequest={reasoningPanelProjectionRequest} + requestFormatLabel={reasoningPanelProjectionRequest + ? requestFormatLabelMap[reasoningPanelProjectionRequest.provider] + || reasoningPanelProjectionRequest.provider + : undefined} onCancel={() => setReasoningPanelDraftKey(null)} onApply={(reasoning) => { updateModelDraft(reasoningPanelDraft.modelName, { diff --git a/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx b/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx index 0d59ec46f..353dfd71e 100644 --- a/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx @@ -6,6 +6,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReasoningConfig } from '../types'; import ReasoningConfigPanel from './ReasoningConfigPanel'; +const { projectReasoningCatalog } = vi.hoisted(() => ({ + projectReasoningCatalog: vi.fn(), +})); + +vi.mock('@/infrastructure/api', () => ({ + aiApi: { projectReasoningCatalog }, +})); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); @@ -23,10 +31,12 @@ vi.mock('@/component-library', () => ({ vi.mock('./ReasoningPresetEditor', () => ({ default: ({ value, + generatedProjection, onChange, onValidationChange, }: { value: ReasoningConfig; + generatedProjection?: { presets?: Array<{ id: string }> }; onChange: (value: ReasoningConfig) => void; onValidationChange: (invalid: boolean) => void; }) => ( @@ -41,6 +51,19 @@ vi.mock('./ReasoningPresetEditor', () => ({ > change + + + {generatedProjection?.presets?.map(preset => preset.id).join(',') ?? ''} + + )) : ( +
+ {t('reasoningPresets.catalogSearchEmpty')} +
+ )} + {modelsDevSearchResults.total > modelsDevSearchResults.items.length && ( +
+ {t('reasoningPresets.catalogSearchLimit')} +
+ )} + , + getAppearanceOverlayHost(), + )} + + + + {t('reasoningPresets.catalogSearchHint')} + + +
{t('reasoningPresets.catalogProvider')} updatePreset(presetIndex, { + label: event.target.value || undefined, + })} + /> +
+ ) : ( + + )} + + {formatPresetSummary(preset)} + +
{value.default_preset === preset.id && ( @@ -508,40 +761,6 @@ export const ReasoningPresetEditor: React.FC = ({ data-bf-component="reasoning-preset-editor" data-bf-part="presetEditor" > -
-
- {t('reasoningPresets.id')} - { - const nextId = event.target.value; - updatePreset(presetIndex, { id: nextId }); - if (value.default_preset === preset.id) { - update({ - ...value, - default_preset: nextId, - presets: presets.map((item, index) => index === presetIndex ? { ...item, id: nextId } : item), - }); - } - }} - /> -
-
- {t('reasoningPresets.label')} - updatePreset(presetIndex, { label: event.target.value || undefined })} - /> -
-
-