返回 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 let supported = match provider_id.as_str() {
69 "openai" => matches!(
70 wire_model_id.as_str(),
71 "gpt-5.6" | "gpt-5.5" | "gpt-5.4" | "gpt-4.1" | "gpt-4.1-mini" | "o4-mini"
72 ),
73 "anthropic" => matches!(
74 wire_model_id.as_str(),
75 "claude-fable-5"
76 | "claude-opus-4-8"
77 | "claude-mythos-5"
78 | "claude-mythos-preview"
79 | "claude-opus-4-7"
80 | "claude-opus-4-6"
81 | "claude-sonnet-5"
82 | "claude-sonnet-4-6"
83 ),
84 "xai" => matches!(wire_model_id.as_str(), "grok-4.6" | "grok-4.5"),
85 "xiaomi-mimo" => matches!(wire_model_id.as_str(), "mimo-v2.5-pro" | "mimo-v2.5"),
86 "zai" => matches!(
87 wire_model_id.as_str(),
88 "glm-5.3" | "glm-5.3-flash" | "glm-5.2" | "glm-5.1" | "glm-5-turbo"
89 ),
90 "modelstudio-token-plan" => matches!(
91 wire_model_id.as_str(),
92 "qwen3.8-max" | "qwen3.7-plus" | "qwen3.7-max"
93 ),
94 "deepseek" => matches!(
95 wire_model_id.as_str(),
96 "deepseek-flash"
97 | "deepseek-v4-flash"
98 | "deepseek-v4-pro"
99 | "deepseek-v4-flash-vision-exp"
100 ),
101 "moonshot" => matches!(wire_model_id.as_str(), "kimi-k3" | "kimi-k2.6"),
102 _ => false,
103 };
104 if supported {
105 CapabilityState::Supported
106 } else {
107 CapabilityState::Unknown
108 }
109 }
110
111 /// Return the Z.AI/Zhipu search fact only for the two exact general API
112 /// products that expose the structured `/web_search` endpoint.
113 #[must_use]
114 pub(crate) fn documented_zai_web_search_for_route(
115 provider: ProviderKind,
116 wire_model_id: &str,
117 base_url: &str,
118 ) -> CapabilityState {
119 if provider != ProviderKind::Zai {
120 return CapabilityState::Unknown;
121 }
122 let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
123 if !matches!(
124 normalized.as_str(),
125 "https://api.z.ai/api/paas/v4" | "https://open.bigmodel.cn/api/paas/v4"
126 ) {
127 return CapabilityState::Unknown;
128 }
129 documented_server_side_web_search("zai", wire_model_id)
130 }
131
132 /// Return the native-search fact for exact Moonshot direct and Kimi Code
133 /// product routes. Adjacent coding paths and cross-product model ids remain
134 /// unknown even though they share one provider identity.
135 #[must_use]
136 pub(crate) fn documented_moonshot_web_search_for_route(
137 provider: ProviderKind,
138 wire_model_id: &str,
139 base_url: &str,
140 ) -> CapabilityState {
141 if provider != ProviderKind::Moonshot {
142 return CapabilityState::Unknown;
143 }
144 let model = wire_model_id.trim().to_ascii_lowercase();
145 if crate::provider::is_exact_kimi_code_route(provider, base_url)
146 && matches!(
147 model.as_str(),
148 "k3" | "k3-256k" | "kimi-for-coding" | "kimi-for-coding-highspeed"
149 )
150 {
151 return CapabilityState::Supported;
152 }
153 if crate::provider::is_exact_moonshot_platform_route(provider, base_url) {
154 return documented_server_side_web_search("moonshot", &model);
155 }
156 CapabilityState::Unknown
157 }
158
159 /// Return the provider Files API fact for exact DeepSeek direct offerings.
160 ///
161 /// DeepSeek stores one uploaded image per account (`purpose=user_data`) and
162 /// both Codewhale DeepSeek wire dialects can reference the returned
163 /// `file-api-…` id, but only on the exact official hosts: a custom
164 /// DeepSeek-compatible base URL, an aggregator row, or a neighboring model id
165 /// stays [`CapabilityState::Unknown`].
166 ///
167 /// Source: <https://api-docs.deepseek.com/guides/files_api> (verified 2026-09-17)
168 #[must_use]
169 pub(crate) fn documented_deepseek_files_api_for_route(
170 provider: ProviderKind,
171 wire_model_id: &str,
172 base_url: &str,
173 ) -> CapabilityState {
174 if !matches!(
175 provider,
176 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
177 ) {
178 return CapabilityState::Unknown;
179 }
180 let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
181 if !matches!(
182 normalized.as_str(),
183 "https://api.deepseek.com"
184 | "https://api.deepseek.com/v1"
185 | "https://api.deepseek.com/beta"
186 | "https://api.deepseek.com/anthropic"
187 ) {
188 return CapabilityState::Unknown;
189 }
190 let model = wire_model_id.trim().to_ascii_lowercase();
191 if matches!(model.as_str(), "deepseek-flash" | "deepseek-v4-flash") {
192 CapabilityState::Supported
193 } else {
194 CapabilityState::Unknown
195 }
196 }
197
198 /// Capability facts owned by one provider/model route offering.
199 ///
200 /// Fields without a current authoritative catalog source remain `Unknown`.
201 /// They are present now so live/provider-native facts can be added without
202 /// changing the candidate contract or guessing from request protocol.
203 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
204 pub struct RouteCapabilities {
205 #[serde(default)]
206 pub attachments: CapabilityState,
207 /// Whether the exact offering explicitly accepts image input.
208 #[serde(default)]
209 pub image_input: CapabilityState,
210 /// Whether the exact offering supports the provider's Files API (upload
211 /// once, reference the returned id from later turns).
212 #[serde(default)]
213 pub files_api: CapabilityState,
214 #[serde(default)]
215 pub reasoning: CapabilityState,
216 #[serde(default)]
217 pub native_tool_calls: CapabilityState,
218 #[serde(default)]
219 pub structured_output: CapabilityState,
220 #[serde(default)]
221 pub parallel_tool_calls: CapabilityState,
222 #[serde(default)]
223 pub streaming: CapabilityState,
224 #[serde(default)]
225 pub prompt_caching: CapabilityState,
226 #[serde(default)]
227 pub server_side_web_search: CapabilityState,
228 }
229
230 #[cfg(test)]
231 mod tests {
232 use super::*;
233 use crate::DEFAULT_KIMI_CODE_BASE_URL;
234
235 #[test]
236 fn optional_boolean_preserves_unknown_and_false() {
237 assert_eq!(
238 CapabilityState::from_optional_bool(None),
239 CapabilityState::Unknown
240 );
241 assert_eq!(
242 CapabilityState::from_optional_bool(Some(false)),
243 CapabilityState::Unsupported
244 );
245 assert_eq!(
246 CapabilityState::from_optional_bool(Some(true)),
247 CapabilityState::Supported
248 );
249 }
250
251 #[test]
252 fn unsourced_route_capabilities_default_to_unknown() {
253 let capabilities = RouteCapabilities::default();
254 assert_eq!(capabilities.streaming, CapabilityState::Unknown);
255 assert_eq!(
256 capabilities.server_side_web_search,
257 CapabilityState::Unknown
258 );
259 }
260
261 #[test]
262 fn documented_web_search_is_exact_and_provider_owned() {
263 assert_eq!(
264 documented_server_side_web_search("xai", "grok-4.6"),
265 CapabilityState::Supported
266 );
267 assert_eq!(
268 documented_server_side_web_search("xai", "grok-4.5"),
269 CapabilityState::Supported
270 );
271 assert_eq!(
272 documented_server_side_web_search("openai", "gpt-5.6"),
273 CapabilityState::Supported
274 );
275 assert_eq!(
276 documented_server_side_web_search("anthropic", "claude-sonnet-4-6"),
277 CapabilityState::Supported
278 );
279 assert_eq!(
280 documented_server_side_web_search("xiaomi-mimo", "mimo-v2.5-pro"),
281 CapabilityState::Supported
282 );
283 assert_eq!(
284 documented_server_side_web_search("zai", "GLM-5.3"),
285 CapabilityState::Supported
286 );
287 assert_eq!(
288 documented_server_side_web_search("modelstudio-token-plan", "qwen3.8-max"),
289 CapabilityState::Supported
290 );
291 assert_eq!(
292 documented_server_side_web_search("deepseek", "deepseek-v4-flash"),
293 CapabilityState::Supported
294 );
295 assert_eq!(
296 documented_server_side_web_search("moonshot", "kimi-k3"),
297 CapabilityState::Supported
298 );
299
300 for (provider, model) in [
301 ("openrouter", "openai/gpt-5.6"),
302 ("custom", "gpt-5.6"),
303 ("openai", "gpt-5.6-sol"),
304 ("xai", "grok-4.6-fast"),
305 ("xai", "grok-4.6-latest"),
306 ("xai", "grok-4.5-fast"),
307 ("anthropic", "claude-haiku-4-5"),
308 ("xiaomi-mimo", "mimo-v2.5-pro-ultraspeed"),
309 ("zai", "glm-5.3-preview"),
310 ("modelstudio-coding-plan", "qwen3.8-max"),
311 ("modelstudio-token-plan", "qwen3.8-max-preview"),
312 ("deepseek", "deepseek-v4-flash-preview"),
313 ("moonshot", "kimi-k2.7-code"),
314 ] {
315 assert_eq!(
316 documented_server_side_web_search(provider, model),
317 CapabilityState::Unknown,
318 "{provider}/{model} must not inherit a capability by similarity"
319 );
320 }
321 }
322
323 #[test]
324 fn deepseek_files_api_fact_is_exact_to_model_and_host() {
325 for provider in [ProviderKind::Deepseek, ProviderKind::DeepseekAnthropic] {
326 for base_url in [
327 "https://api.deepseek.com",
328 "https://api.deepseek.com/v1",
329 "https://api.deepseek.com/beta",
330 "https://api.deepseek.com/anthropic/",
331 ] {
332 for model in ["deepseek-flash", "deepseek-v4-flash"] {
333 assert_eq!(
334 documented_deepseek_files_api_for_route(provider, model, base_url),
335 CapabilityState::Supported,
336 "{provider:?}/{model}/{base_url}"
337 );
338 }
339 }
340 }
341 for (provider, model, base_url) in [
342 (
343 ProviderKind::Deepseek,
344 "deepseek-v4-pro",
345 "https://api.deepseek.com",
346 ),
347 (
348 ProviderKind::Deepseek,
349 "deepseek-v4-flash-vision-exp",
350 "https://api.deepseek.com",
351 ),
352 (
353 ProviderKind::Deepseek,
354 "deepseek-v5-future",
355 "https://api.deepseek.com",
356 ),
357 (
358 ProviderKind::Deepseek,
359 "deepseek-flash",
360 "https://compatible.example.test/v1",
361 ),
362 (
363 ProviderKind::Deepseek,
364 "deepseek-flash",
365 "https://api.deepseek.com.example.test/v1",
366 ),
367 (
368 ProviderKind::Openrouter,
369 "deepseek/deepseek-v4-flash",
370 "https://openrouter.ai/api/v1",
371 ),
372 ] {
373 assert_eq!(
374 documented_deepseek_files_api_for_route(provider, model, base_url),
375 CapabilityState::Unknown,
376 "{provider:?}/{model}/{base_url} must not inherit the Files API fact"
377 );
378 }
379 }
380
381 #[test]
382 fn zai_route_fact_rejects_coding_and_neighboring_endpoints() {
383 for base_url in [
384 "https://api.z.ai/api/paas/v4",
385 "https://open.bigmodel.cn/api/paas/v4/",
386 ] {
387 assert_eq!(
388 documented_zai_web_search_for_route(ProviderKind::Zai, "GLM-5.3", base_url),
389 CapabilityState::Supported
390 );
391 }
392 for base_url in [
393 "https://api.z.ai/api/coding/paas/v4",
394 "https://open.bigmodel.cn/api/paas/v4/preview",
395 "https://gateway.example.test/v4",
396 ] {
397 assert_eq!(
398 documented_zai_web_search_for_route(ProviderKind::Zai, "GLM-5.3", base_url),
399 CapabilityState::Unknown
400 );
401 }
402 }
403
404 #[test]
405 fn moonshot_route_fact_is_exact_to_product_and_model() {
406 for (model, base_url) in [
407 ("kimi-k3", "https://api.moonshot.ai/v1"),
408 ("kimi-k3", "https://api.moonshot.cn/v1"),
409 ("kimi-k2.6", "https://api.moonshot.ai/v1"),
410 ("k3", DEFAULT_KIMI_CODE_BASE_URL),
411 ("kimi-for-coding", DEFAULT_KIMI_CODE_BASE_URL),
412 ] {
413 assert_eq!(
414 documented_moonshot_web_search_for_route(ProviderKind::Moonshot, model, base_url,),
415 CapabilityState::Supported
416 );
417 }
418 for (model, base_url) in [
419 ("kimi-k3", "https://api.kimi.com/coding/v2"),
420 ("kimi-k2.6", "https://api.kimi.com/coding/v1/preview"),
421 ("k3", "https://api.moonshot.ai/v1"),
422 ] {
423 assert_eq!(
424 documented_moonshot_web_search_for_route(ProviderKind::Moonshot, model, base_url,),
425 CapabilityState::Unknown
426 );
427 }
428 }
429 }
430
430 lines RUST