返回 CodeWhale
headless_catalog.rs
根目录 / crates / tui / src / runtime_api / tests / headless_catalog.rs
1 use super::*;
2 use codewhale_config::catalog::{
3 CatalogOffering, CatalogSource, CatalogStatus, ProviderCatalogDelta, base_url_fingerprint,
4 now_unix,
5 };
6 use codewhale_config::models_dev::ModelsDevModalities;
7 use codewhale_config::route::CapabilityState;
8
9 const MODEL: &str = "fixture/vision";
10 const FIRST_ENDPOINT: &str = "http://127.0.0.1:9/first/v1";
11 const SECOND_ENDPOINT: &str = "http://127.0.0.1:9/second/v1";
12
13 struct CatalogReset;
14
15 impl Drop for CatalogReset {
16 fn drop(&mut self) {
17 cold_process();
18 crate::tools::large_output_router::WorkshopConfig::install_active(None);
19 }
20 }
21
22 fn cold_process() {
23 crate::provider_catalog_live::reset_cache_for_test();
24 crate::provider_lake::clear_live_snapshot();
25 }
26
27 fn persist_catalog(kind: ProviderKind, identity: &str, endpoint: &str, images: bool) {
28 let fingerprint = base_url_fingerprint(endpoint);
29 let fetched_at = now_unix();
30 let ticket = crate::provider_catalog_live::begin_refresh_for_identity(kind, identity, endpoint);
31 assert_eq!(
32 crate::provider_catalog_live::record_success_if_current(
33 &ticket,
34 ProviderCatalogDelta {
35 provider: identity.into(),
36 base_url_fingerprint: fingerprint.clone(),
37 fetched_at,
38 offerings: vec![CatalogOffering {
39 provider: identity.into(),
40 wire_model_id: MODEL.into(),
41 endpoint_key: "chat".into(),
42 modalities: Some(ModelsDevModalities {
43 input: if images {
44 vec!["text".into(), "image".into()]
45 } else {
46 vec!["text".into()]
47 },
48 output: vec!["text".into()],
49 }),
50 source: CatalogSource::Live {
51 base_url_fingerprint: fingerprint,
52 fetched_at,
53 },
54 ..Default::default()
55 }],
56 },
57 ),
58 Some(CatalogStatus::Fresh)
59 );
60 }
61
62 fn config_for(identity: &str, endpoint: &str) -> Config {
63 let mut config = Config {
64 provider: Some(identity.into()),
65 ..Default::default()
66 };
67 let route = if identity == "openrouter" {
68 &mut config
69 .providers
70 .get_or_insert_with(Default::default)
71 .openrouter
72 } else {
73 let route = config
74 .providers
75 .get_or_insert_with(Default::default)
76 .custom
77 .entry(identity.into())
78 .or_default();
79 route.kind = Some("openai-compatible".into());
80 route
81 };
82 route.base_url = Some(endpoint.into());
83 route.api_key = Some("synthetic-headless-catalog-key".into());
84 route.model = Some(MODEL.into());
85 config
86 }
87
88 fn image_capability(config: &Config) -> CapabilityState {
89 provider_model_image_input_for_api(config, &config.active_provider_identity().unwrap(), MODEL)
90 }
91
92 fn open_server_manager(config: &Config, root: &Path) -> Result<SharedRuntimeThreadManager> {
93 let workspace = root.join("workspace");
94 fs::create_dir_all(&workspace)?;
95 let (manager, _) = open_runtime_threads_for_server(
96 config,
97 workspace.clone(),
98 RuntimeThreadManagerConfig {
99 data_dir: root.join("runtime"),
100 task_data_dir: root.join("tasks"),
101 sessions_dir: None,
102 max_active_threads: 2,
103 },
104 Arc::new(crate::plugins::PluginRegistry::empty(&workspace)),
105 )?;
106 Ok(manager)
107 }
108
109 #[test]
110 fn headless_startup_publishes_cold_endpoint_catalog_capabilities() -> Result<()> {
111 // `active_workshop_test_guard()` takes the env barrier before the workshop
112 // gate and holds both (#6306), so it is the whole lock acquisition. Taking
113 // `lock_test_env()` again here parks this thread on a non-reentrant mutex
114 // it already owns; the ordering that #6049 hand-rolled now lives in the guard.
115 let _workshop = crate::tools::large_output_router::active_workshop_test_guard();
116 let _offline = EnvVarGuard::set("CODEWHALE_DISABLE_CLOUD_FACTS", "1");
117 let _live = crate::provider_lake::lock_live_snapshot();
118 let home = tempfile::tempdir()?;
119 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
120 let _reset = CatalogReset;
121 cold_process();
122 persist_catalog(ProviderKind::Openrouter, "openrouter", FIRST_ENDPOINT, true);
123 cold_process();
124
125 let config = config_for("openrouter", FIRST_ENDPOINT);
126 assert!(
127 provider_models_for_api(
128 &config,
129 &(config).test_identity_for_kind(ProviderKind::Openrouter)
130 )
131 .contains(&MODEL.to_string())
132 );
133 assert_eq!(image_capability(&config), CapabilityState::Unknown);
134 let _manager = open_server_manager(&config, home.path())?;
135 assert_eq!(image_capability(&config), CapabilityState::Supported);
136 assert_eq!(
137 image_capability(&config_for("openrouter", SECOND_ENDPOINT)),
138 CapabilityState::Unknown,
139 "another endpoint must not inherit the saved capabilities"
140 );
141 Ok(())
142 }
143
144 #[tokio::test(flavor = "current_thread")]
145 async fn headless_reload_publishes_only_the_accepted_identity_and_endpoint() -> Result<()> {
146 // The guard owns the env barrier and the workshop gate — see the
147 // headless_startup twin.
148 let _workshop = crate::tools::large_output_router::active_workshop_test_guard();
149 let _offline = EnvVarGuard::set("CODEWHALE_DISABLE_CLOUD_FACTS", "1");
150 let _live = crate::provider_lake::lock_live_snapshot();
151 let home = tempfile::tempdir()?;
152 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
153 let _reset = CatalogReset;
154 cold_process();
155 persist_catalog(ProviderKind::Custom, "vision-one", FIRST_ENDPOINT, true);
156 persist_catalog(ProviderKind::Custom, "vision-two", FIRST_ENDPOINT, false);
157 persist_catalog(ProviderKind::Custom, "vision-one", SECOND_ENDPOINT, false);
158 cold_process();
159
160 let first = config_for("vision-one", FIRST_ENDPOINT);
161 let second = config_for("vision-two", FIRST_ENDPOINT);
162 let manager = open_server_manager(&first, home.path())?;
163 assert_eq!(image_capability(&first), CapabilityState::Supported);
164 assert_eq!(image_capability(&second), CapabilityState::Unknown);
165
166 manager.reload_config(second).await?;
167 assert_eq!(
168 image_capability(&manager.read_config()),
169 CapabilityState::Unsupported
170 );
171 manager
172 .reload_config(config_for("vision-one", "http://127.0.0.1:9/uncached/v1"))
173 .await?;
174 assert_eq!(
175 image_capability(&manager.read_config()),
176 CapabilityState::Unknown
177 );
178 manager
179 .reload_config(config_for("vision-one", SECOND_ENDPOINT))
180 .await?;
181 assert_eq!(
182 image_capability(&manager.read_config()),
183 CapabilityState::Unsupported
184 );
185 manager.reload_config(first).await?;
186 assert_eq!(
187 image_capability(&manager.read_config()),
188 CapabilityState::Supported
189 );
190 Ok(())
191 }
192
192 lines RUST