返回 CodeWhale
capabilities.rs
根目录 / crates / config / src / route / capabilities.rs
1 //! Route-scoped capability facts.
2 //!
3 //! Capability state is deliberately three-valued: an absent catalog fact is
4 //! unknown, not unsupported, and must never be promoted to supported by a
5 //! transport/protocol heuristic. These values travel with the exact provider
6 //! offering selected by [`super::resolver::RouteResolver`].
7
8 use serde::{Deserialize, Serialize};
9
10 use crate::ProviderKind;
11
12 /// Whether a resolved provider/model offering supports one capability.
13 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
14 #[serde(rename_all = "snake_case")]
15 pub enum CapabilityState {
16 /// The selected offering explicitly reports support.
17 Supported,
18 /// The selected offering explicitly reports no support.
19 Unsupported,
20 /// The selected offering did not state the fact.
21 #[default]
22 Unknown,
23 }
24
25 impl CapabilityState {
26 /// Preserve a sourced optional boolean as a three-state fact.
27 #[must_use]
28 pub const fn from_optional_bool(value: Option<bool>) -> Self {
29 match value {
30 Some(true) => Self::Supported,
31 Some(false) => Self::Unsupported,
32 None => Self::Unknown,
33 }
34 }
35
36 /// Whether the source explicitly reports support.
37 #[must_use]
38 pub const fn is_supported(self) -> bool {
39 matches!(self, Self::Supported)
40 }
41 }
42
43 /// Return the documented server-side web-search fact for one exact direct
44 /// provider/model offering.
45 ///
46 /// This is intentionally a small sourced table, not a protocol or model-family
47 /// heuristic. Aggregators, custom endpoints, aliases, snapshots, and nearby
48 /// model names remain [`CapabilityState::Unknown`] until a provider-owned fact
49 /// exists for that exact offering.
50 ///
51 /// Sources:
52 /// - OpenAI Responses web search: <https://developers.openai.com/api/docs/guides/tools-web-search>
53 /// - Anthropic web search tool: <https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool>
54 /// - xAI web search tool: <https://docs.x.ai/developers/tools/web-search>
55 /// - Xiaomi MiMo web search: <https://mimo.mi.com/docs/en-US/usage-guide/tool-calling/web-search>
56 /// - Z.AI Web Search API: <https://docs.z.ai/api-reference/tools/web-search>
57 /// - Zhipu Web Search API: <https://docs.bigmodel.cn/api-reference/工具-api/网络搜索>
58 /// - Alibaba Model Studio Token Plan Harness tools: <https://help.aliyun.com/en/model-studio/token-plan-harness-tool>
59 /// - DeepSeek Responses web search: <https://api-docs.deepseek.com/api/create-response/>
60 /// - Kimi built-in and Formula web search: <https://platform.kimi.ai/docs/guide/use-web-search>
61 #[must_use]
62 pub(crate) fn documented_server_side_web_search(
63 provider_id: &str,
64 wire_model_id: &str,
65 ) -> CapabilityState {
66 let provider_id = provider_id.trim().to_ascii_lowercase();
67 let wire_model_id = wire_model_id.trim().to_ascii_lowercase();
68 if crate::catalog::reviewed::bundled_reviewed()
69 .search_models
70 .get(&provider_id)
71 .is_some_and(|models| models.contains(&wire_model_id))
72 {
73 CapabilityState::Supported
74 } else {
75 CapabilityState::Unknown
76 }
77 }
78
79 /// Return the Z.AI/Zhipu search fact only for the two exact general API
80 /// products that expose the structured `/web_search` endpoint.
81 #[must_use]
82 pub(crate) fn documented_zai_web_search_for_route(
83 provider: ProviderKind,
84 wire_model_id: &str,
85 base_url: &str,
86 ) -> CapabilityState {
87 if provider != ProviderKind::Zai {
88 return CapabilityState::Unknown;
89 }
90 let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
91 if !matches!(
92 normalized.as_str(),
93 "https://api.z.ai/api/paas/v4" | "https://open.bigmodel.cn/api/paas/v4"
94 ) {
95 return CapabilityState::Unknown;
96 }
97 documented_server_side_web_search("zai", wire_model_id)
98 }
99
100 /// Return the native-search fact for exact Moonshot direct and Kimi Code
101 /// product routes. Adjacent coding paths and cross-product model ids remain
102 /// unknown even though they share one provider identity.
103 #[must_use]
104 pub(crate) fn documented_moonshot_web_search_for_route(
105 provider: ProviderKind,
106 wire_model_id: &str,
107 base_url: &str,
108 ) -> CapabilityState {
109 if provider != ProviderKind::Moonshot {
110 return CapabilityState::Unknown;
111 }
112 let model = wire_model_id.trim().to_ascii_lowercase();
113 if crate::provider::is_exact_kimi_code_route(provider, base_url)
114 && crate::catalog::reviewed::route_model_set_contains("kimi_membership_search", &model)
115 {
116 return CapabilityState::Supported;
117 }
118 if crate::provider::is_exact_moonshot_platform_route(provider, base_url) {
119 return documented_server_side_web_search("moonshot", &model);
120 }
121 CapabilityState::Unknown
122 }
123
124 /// Return the provider Files API fact for exact DeepSeek direct offerings.
125 ///
126 /// DeepSeek stores one uploaded image per account (`purpose=user_data`) and
127 /// both Codewhale DeepSeek wire dialects can reference the returned
128 /// `file-api-…` id, but only on the exact official hosts: a custom
129 /// DeepSeek-compatible base URL, an aggregator row, or a neighboring model id
130 /// stays [`CapabilityState::Unknown`].
131 ///
132 /// Source: <https://api-docs.deepseek.com/guides/files_api> (verified 2026-09-17)
133 #[must_use]
134 pub(crate) fn documented_deepseek_files_api_for_route(
135 provider: ProviderKind,
136 wire_model_id: &str,
137 base_url: &str,
138 ) -> CapabilityState {
139 if !is_official_deepseek_route(provider, base_url) {
140 return CapabilityState::Unknown;
141 }
142 let model = wire_model_id.trim().to_ascii_lowercase();
143 if crate::catalog::reviewed::route_model_set_contains("deepseek_files", &model) {
144 CapabilityState::Supported
145 } else {
146 CapabilityState::Unknown
147 }
148 }
149
150 /// Return the image-input fact for exact DeepSeek direct Flash routes.
151 ///
152 /// DeepSeek's Vision guide documents image input for `deepseek-flash` over
153 /// Chat Completions, Responses *and* Messages; the legacy `deepseek-v4-flash`
154 /// and `deepseek-v4-flash-vision-exp` ids are served by the same model
155 /// (verified 2026-09-23, #6421). The curated offering rows are scoped to the
156 /// canonical `deepseek` provider and its OpenAI-compatible hosts, so the
157 /// Messages route — `deepseek-anthropic`, or canonical DeepSeek with
158 /// `wire = "anthropic"` (the `/anthropic` base URL) — needs this route-aware
159 /// projection. A custom compatible host or any other model stays `Unknown`.
160 #[must_use]
161 pub(crate) fn documented_deepseek_image_input_for_route(
162 provider: ProviderKind,
163 wire_model_id: &str,
164 base_url: &str,
165 ) -> CapabilityState {
166 if !is_official_deepseek_route(provider, base_url) {
167 return CapabilityState::Unknown;
168 }
169 let model = wire_model_id.trim().to_ascii_lowercase();
170 if crate::catalog::reviewed::route_model_set_contains("deepseek_image", &model) {
171 CapabilityState::Supported
172 } else {
173 CapabilityState::Unknown
174 }
175 }
176
177 /// A DeepSeek provider kind on one of DeepSeek's own hosts, in either wire
178 /// dialect.
179 fn is_official_deepseek_route(provider: ProviderKind, base_url: &str) -> bool {
180 if !matches!(
181 provider,
182 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
183 ) {
184 return false;
185 }
186 let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
187 matches!(
188 normalized.as_str(),
189 "https://api.deepseek.com"
190 | "https://api.deepseek.com/v1"
191 | "https://api.deepseek.com/beta"
192 | "https://api.deepseek.com/anthropic"
193 )
194 }
195
196 /// Capability facts owned by one provider/model route offering.
197 ///
198 /// Fields without a current authoritative catalog source remain `Unknown`.
199 /// They are present now so live/provider-native facts can be added without
200 /// changing the candidate contract or guessing from request protocol.
201 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
202 pub struct RouteCapabilities {
203 #[serde(default)]
204 pub attachments: CapabilityState,
205 /// Whether the exact offering explicitly accepts image input.
206 #[serde(default)]
207 pub image_input: CapabilityState,
208 /// Whether the exact offering supports the provider's Files API (upload
209 /// once, reference the returned id from later turns).
210 #[serde(default)]
211 pub files_api: CapabilityState,
212 #[serde(default)]
213 pub reasoning: CapabilityState,
214 #[serde(default)]
215 pub native_tool_calls: CapabilityState,
216 #[serde(default)]
217 pub structured_output: CapabilityState,
218 #[serde(default)]
219 pub parallel_tool_calls: CapabilityState,
220 #[serde(default)]
221 pub streaming: CapabilityState,
222 #[serde(default)]
223 pub prompt_caching: CapabilityState,
224 #[serde(default)]
225 pub server_side_web_search: CapabilityState,
226 }
227
228 #[cfg(test)]
229 mod tests {
230 use super::*;
231 use crate::DEFAULT_KIMI_CODE_BASE_URL;
232
233 #[test]
234 fn optional_boolean_preserves_unknown_and_false() {
235 assert_eq!(
236 CapabilityState::from_optional_bool(None),
237 CapabilityState::Unknown
238 );
239 assert_eq!(
240 CapabilityState::from_optional_bool(Some(false)),
241 CapabilityState::Unsupported
242 );
243 assert_eq!(
244 CapabilityState::from_optional_bool(Some(true)),
245 CapabilityState::Supported
246 );
247 }
248
249 #[test]
250 fn unsourced_route_capabilities_default_to_unknown() {
251 let capabilities = RouteCapabilities::default();
252 assert_eq!(capabilities.streaming, CapabilityState::Unknown);
253 assert_eq!(
254 capabilities.server_side_web_search,
255 CapabilityState::Unknown
256 );
257 }
258
259 #[test]
260 fn documented_web_search_is_exact_and_provider_owned() {
261 assert_eq!(
262 documented_server_side_web_search("xai", "grok-4.6"),
263 CapabilityState::Supported
264 );
265 assert_eq!(
266 documented_server_side_web_search("xai", "grok-4.5"),
267 CapabilityState::Supported
268 );
269 assert_eq!(
270 documented_server_side_web_search("openai", "gpt-5.6"),
271 CapabilityState::Supported
272 );
273 assert_eq!(
274 documented_server_side_web_search("anthropic", "claude-sonnet-4-6"),
275 CapabilityState::Supported
276 );
277 assert_eq!(
278 documented_server_side_web_search("xiaomi-mimo", "mimo-v2.5-pro"),
279 CapabilityState::Supported
280 );
281 assert_eq!(
282 documented_server_side_web_search("zai", "GLM-5.3"),
283 CapabilityState::Supported
284 );
285 assert_eq!(
286 documented_server_side_web_search("modelstudio-token-plan", "qwen3.8-max"),
287 CapabilityState::Supported
288 );
289 assert_eq!(
290 documented_server_side_web_search("deepseek", "deepseek-v4-flash"),
291 CapabilityState::Supported
292 );
293 assert_eq!(
294 documented_server_side_web_search("moonshot", "kimi-k3"),
295 CapabilityState::Supported
296 );
297
298 for (provider, model) in [
299 ("openrouter", "openai/gpt-5.6"),
300 ("custom", "gpt-5.6"),
301 ("openai", "gpt-5.6-sol"),
302 ("xai", "grok-4.6-fast"),
303 ("xai", "grok-4.6-latest"),
304 ("xai", "grok-4.5-fast"),
305 ("anthropic", "claude-haiku-4-5"),
306 ("xiaomi-mimo", "mimo-v2.5-pro-ultraspeed"),
307 ("zai", "glm-5.3-preview"),
308 ("modelstudio-coding-plan", "qwen3.8-max"),
309 ("modelstudio-token-plan", "qwen3.8-max-preview"),
310 ("deepseek", "deepseek-v4-flash-preview"),
311 ("moonshot", "kimi-k2.7-code"),
312 ] {
313 assert_eq!(
314 documented_server_side_web_search(provider, model),
315 CapabilityState::Unknown,
316 "{provider}/{model} must not inherit a capability by similarity"
317 );
318 }
319 }
320
321 #[test]
322 fn deepseek_files_api_fact_is_exact_to_model_and_host() {
323 for provider in [ProviderKind::Deepseek, ProviderKind::DeepseekAnthropic] {
324 for base_url in [
325 "https://api.deepseek.com",
326 "https://api.deepseek.com/v1",
327 "https://api.deepseek.com/beta",
328 "https://api.deepseek.com/anthropic/",
329 ] {
330 for model in ["deepseek-flash", "deepseek-v4-flash"] {
331 assert_eq!(
332 documented_deepseek_files_api_for_route(provider, model, base_url),
333 CapabilityState::Supported,
334 "{provider:?}/{model}/{base_url}"
335 );
336 }
337 }
338 }
339 for (provider, model, base_url) in [
340 (
341 ProviderKind::Deepseek,
342 "deepseek-v4-pro",
343 "https://api.deepseek.com",
344 ),
345 (
346 ProviderKind::Deepseek,
347 "deepseek-v4-flash-vision-exp",
348 "https://api.deepseek.com",
349 ),
350 (
351 ProviderKind::Deepseek,
352 "deepseek-v5-future",
353 "https://api.deepseek.com",
354 ),
355 (
356 ProviderKind::Deepseek,
357 "deepseek-flash",
358 "https://compatible.example.test/v1",
359 ),
360 (
361 ProviderKind::Deepseek,
362 "deepseek-flash",
363 "https://api.deepseek.com.example.test/v1",
364 ),
365 (
366 ProviderKind::Openrouter,
367 "deepseek/deepseek-v4-flash",
368 "https://openrouter.ai/api/v1",
369 ),
370 ] {
371 assert_eq!(
372 documented_deepseek_files_api_for_route(provider, model, base_url),
373 CapabilityState::Unknown,
374 "{provider:?}/{model}/{base_url} must not inherit the Files API fact"
375 );
376 }
377 }
378
379 #[test]
380 fn zai_route_fact_rejects_coding_and_neighboring_endpoints() {
381 for base_url in [
382 "https://api.z.ai/api/paas/v4",
383 "https://open.bigmodel.cn/api/paas/v4/",
384 ] {
385 assert_eq!(
386 documented_zai_web_search_for_route(ProviderKind::Zai, "GLM-5.3", base_url),
387 CapabilityState::Supported
388 );
389 }
390 for base_url in [
391 "https://api.z.ai/api/coding/paas/v4",
392 "https://open.bigmodel.cn/api/paas/v4/preview",
393 "https://gateway.example.test/v4",
394 ] {
395 assert_eq!(
396 documented_zai_web_search_for_route(ProviderKind::Zai, "GLM-5.3", base_url),
397 CapabilityState::Unknown
398 );
399 }
400 }
401
402 #[test]
403 fn moonshot_route_fact_is_exact_to_product_and_model() {
404 for (model, base_url) in [
405 ("kimi-k3", "https://api.moonshot.ai/v1"),
406 ("kimi-k3", "https://api.moonshot.cn/v1"),
407 ("kimi-k2.6", "https://api.moonshot.ai/v1"),
408 ("k3", DEFAULT_KIMI_CODE_BASE_URL),
409 ("kimi-for-coding", DEFAULT_KIMI_CODE_BASE_URL),
410 ] {
411 assert_eq!(
412 documented_moonshot_web_search_for_route(ProviderKind::Moonshot, model, base_url,),
413 CapabilityState::Supported
414 );
415 }
416 for (model, base_url) in [
417 ("kimi-k3", "https://api.kimi.com/coding/v2"),
418 ("kimi-k2.6", "https://api.kimi.com/coding/v1/preview"),
419 ("k3", "https://api.moonshot.ai/v1"),
420 ] {
421 assert_eq!(
422 documented_moonshot_web_search_for_route(ProviderKind::Moonshot, model, base_url,),
423 CapabilityState::Unknown
424 );
425 }
426 }
427 }
428
428 lines RUST