1use super::{
19 CatalogModel, CatalogProviderKind, CatalogSource, Modality, PricingSummary, ReasoningSupport,
20};
21use serde::Deserialize;
22
23pub const PROVIDER_KEY: &str = "github-copilot";
25pub const PROVIDER_NAME: &str = "GitHub Copilot";
27
28pub const MODELS_BASE_URL: &str = "https://api.githubcopilot.com";
31pub const MODELS_PATH: &str = "/models";
33pub const MODELS_URL: &str = "https://api.githubcopilot.com/models";
35
36pub const COPILOT_API_VERSION: &str = "2025-10-01";
38
39pub const MAX_MODELS_BODY_BYTES: usize = 256 * 1024;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum CopilotEndpoint {
47 Responses,
49 ChatCompletions,
51 V1Messages,
53 WsResponses,
55 Other(String),
57}
58
59impl CopilotEndpoint {
60 pub fn parse(s: &str) -> Self {
61 match s {
62 "/responses" => Self::Responses,
63 "/chat/completions" => Self::ChatCompletions,
64 "/v1/messages" => Self::V1Messages,
65 "ws:/responses" => Self::WsResponses,
66 other => Self::Other(other.to_string()),
67 }
68 }
69
70 pub fn is_reviewed(&self) -> bool {
72 matches!(self, Self::Responses | Self::ChatCompletions)
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum CopilotPolicyState {
79 Enabled,
81 Disabled,
83 Unspecified,
85 Other(String),
87}
88
89impl CopilotPolicyState {
90 fn from_opt(s: Option<&str>) -> Self {
91 match s {
92 None => Self::Unspecified,
93 Some("enabled") => Self::Enabled,
94 Some("disabled") => Self::Disabled,
95 Some(other) => Self::Other(other.to_string()),
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum CopilotWire {
105 Responses,
106 ChatCompletions,
107}
108
109impl CopilotWire {
110 pub fn to_wire_protocol(self) -> crate::runtime::openai::WireProtocol {
111 match self {
112 Self::Responses => crate::runtime::openai::WireProtocol::OpenAiResponses,
113 Self::ChatCompletions => crate::runtime::openai::WireProtocol::OpenAiChatCompletions,
114 }
115 }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct CopilotModelDescriptor {
121 pub id: &'static str,
122 pub label: &'static str,
123 pub vendor: &'static str,
125 pub selected_wire: CopilotWire,
127}
128
129pub const COPILOT_FALLBACK_MODELS: &[CopilotModelDescriptor] = &[
134 CopilotModelDescriptor {
135 id: "gpt-5.3-codex",
136 label: "GPT-5.3-Codex",
137 vendor: "OpenAI",
138 selected_wire: CopilotWire::Responses,
139 },
140 CopilotModelDescriptor {
141 id: "gpt-5.4",
142 label: "GPT-5.4",
143 vendor: "OpenAI",
144 selected_wire: CopilotWire::Responses,
145 },
146 CopilotModelDescriptor {
147 id: "gpt-5.4-mini",
148 label: "GPT-5.4 mini",
149 vendor: "OpenAI",
150 selected_wire: CopilotWire::Responses,
151 },
152 CopilotModelDescriptor {
153 id: "gpt-5.6-luna",
154 label: "GPT-5.6 Luna",
155 vendor: "OpenAI",
156 selected_wire: CopilotWire::Responses,
157 },
158 CopilotModelDescriptor {
159 id: "gpt-5.6-terra",
160 label: "GPT-5.6 Terra",
161 vendor: "OpenAI",
162 selected_wire: CopilotWire::Responses,
163 },
164 CopilotModelDescriptor {
165 id: "gpt-5-mini",
166 label: "GPT-5 mini",
167 vendor: "Azure OpenAI",
168 selected_wire: CopilotWire::Responses,
169 },
170 CopilotModelDescriptor {
171 id: "gpt-5.5",
172 label: "GPT-5.5",
173 vendor: "OpenAI",
174 selected_wire: CopilotWire::Responses,
175 },
176 CopilotModelDescriptor {
177 id: "claude-fable-5",
178 label: "Claude Fable 5",
179 vendor: "Anthropic",
180 selected_wire: CopilotWire::ChatCompletions,
181 },
182 CopilotModelDescriptor {
183 id: "claude-opus-4.7",
184 label: "Claude Opus 4.7",
185 vendor: "Anthropic",
186 selected_wire: CopilotWire::ChatCompletions,
187 },
188 CopilotModelDescriptor {
189 id: "claude-opus-4.8",
190 label: "Claude Opus 4.8",
191 vendor: "Anthropic",
192 selected_wire: CopilotWire::ChatCompletions,
193 },
194 CopilotModelDescriptor {
195 id: "claude-opus-4.8-fast",
196 label: "Claude Opus 4.8 (fast mode)",
197 vendor: "Anthropic",
198 selected_wire: CopilotWire::ChatCompletions,
199 },
200 CopilotModelDescriptor {
201 id: "claude-sonnet-4.6",
202 label: "Claude Sonnet 4.6",
203 vendor: "Anthropic",
204 selected_wire: CopilotWire::ChatCompletions,
205 },
206 CopilotModelDescriptor {
207 id: "claude-sonnet-5",
208 label: "Claude Sonnet 5",
209 vendor: "Anthropic",
210 selected_wire: CopilotWire::ChatCompletions,
211 },
212 CopilotModelDescriptor {
213 id: "claude-haiku-4.5",
214 label: "Claude Haiku 4.5",
215 vendor: "Anthropic",
216 selected_wire: CopilotWire::ChatCompletions,
217 },
218 CopilotModelDescriptor {
219 id: "gemini-3.1-pro-preview",
220 label: "Gemini 3.1 Pro",
221 vendor: "Google",
222 selected_wire: CopilotWire::ChatCompletions,
223 },
224 CopilotModelDescriptor {
225 id: "gemini-3.5-flash",
226 label: "Gemini 3.5 Flash",
227 vendor: "Google",
228 selected_wire: CopilotWire::ChatCompletions,
229 },
230];
231
232pub fn models_request_headers() -> &'static [(&'static str, &'static str)] {
235 &[
236 ("User-Agent", "SynapsCLI/0.6.0"),
237 ("Accept", "application/json"),
238 ("Editor-Version", "vscode/1.107.0"),
239 ("Editor-Plugin-Version", "copilot-chat/0.35.0"),
240 ("Copilot-Integration-Id", "vscode-chat"),
241 ("X-Github-Api-Version", COPILOT_API_VERSION),
242 ]
243}
244
245pub fn validate_models_endpoint(url: &str) -> Result<(), String> {
247 if url != MODELS_URL {
248 return Err("github-copilot models endpoint is not the pinned URL".into());
249 }
250 Ok(())
251}
252
253pub fn copilot_model(id: &str) -> Option<&'static CopilotModelDescriptor> {
255 COPILOT_FALLBACK_MODELS.iter().find(|m| m.id == id)
256}
257
258pub fn runtime_wire_protocol(id: &str) -> Option<crate::runtime::openai::WireProtocol> {
262 copilot_model(id).map(|d| d.selected_wire.to_wire_protocol())
263}
264
265pub fn preferred_wire_protocol_from_endpoints(
275 vendor: Option<&str>,
276 endpoints: &[CopilotEndpoint],
277) -> Option<crate::runtime::openai::WireProtocol> {
278 use crate::runtime::openai::WireProtocol;
279 let has_responses = endpoints
280 .iter()
281 .any(|e| matches!(e, CopilotEndpoint::Responses));
282 let has_chat = endpoints
283 .iter()
284 .any(|e| matches!(e, CopilotEndpoint::ChatCompletions));
285 let prefer_responses = matches!(vendor, Some("OpenAI") | Some("Azure OpenAI"));
286 if prefer_responses && has_responses {
287 return Some(WireProtocol::OpenAiResponses);
288 }
289 if has_chat {
290 return Some(WireProtocol::OpenAiChatCompletions);
291 }
292 if has_responses {
293 return Some(WireProtocol::OpenAiResponses);
294 }
295 None
296}
297
298pub fn copilot_static_catalog_models() -> Vec<CatalogModel> {
301 COPILOT_FALLBACK_MODELS
302 .iter()
303 .map(|descriptor| CatalogModel {
304 provider_key: PROVIDER_KEY.into(),
305 provider_name: PROVIDER_NAME.into(),
306 provider_kind: CatalogProviderKind::Generic {
307 key: PROVIDER_KEY.into(),
308 },
309 id: descriptor.id.into(),
310 label: Some(descriptor.label.into()),
311 context_tokens: None,
312 max_output_tokens: None,
313 input_modalities: vec![Modality::Text],
314 pricing: PricingSummary::default(),
315 reasoning: ReasoningSupport::Unknown,
316 source: CatalogSource::StaticFallback,
317 })
318 .collect()
319}
320
321#[derive(Debug, Deserialize)]
324struct CopilotModelsResponse {
325 data: Vec<CopilotModelItem>,
326}
327
328#[derive(Debug, Deserialize)]
329struct CopilotModelItem {
330 id: String,
331 #[serde(default)]
332 name: Option<String>,
333 #[serde(default)]
334 vendor: Option<String>,
335 #[serde(default)]
336 capabilities: Option<CopilotCapabilities>,
337 #[serde(default)]
338 preview: Option<bool>,
339 #[serde(default)]
340 model_picker_enabled: Option<bool>,
341 #[serde(default)]
342 is_chat_default: Option<bool>,
343 #[serde(default)]
344 is_chat_fallback: Option<bool>,
345 #[serde(default)]
346 policy: Option<CopilotPolicy>,
347 #[serde(default)]
348 supported_endpoints: Option<Vec<String>>,
349}
350
351#[derive(Debug, Deserialize)]
352struct CopilotPolicy {
353 #[serde(default)]
354 state: Option<String>,
355}
356
357#[derive(Debug, Deserialize)]
358struct CopilotCapabilities {
359 #[serde(default)]
360 #[serde(rename = "type")]
361 kind: Option<String>,
362 #[serde(default)]
363 limits: Option<CopilotLimits>,
364 #[serde(default)]
365 supports: Option<CopilotSupports>,
366}
367
368#[derive(Debug, Deserialize)]
369struct CopilotLimits {
370 #[serde(default)]
371 max_context_window_tokens: Option<u64>,
372 #[serde(default)]
373 max_output_tokens: Option<u64>,
374 #[serde(default)]
375 max_prompt_tokens: Option<u64>,
376}
377
378#[derive(Debug, Deserialize)]
379struct CopilotSupports {
380 #[serde(default)]
381 vision: Option<bool>,
382 #[serde(default)]
383 reasoning_effort: Option<serde_json::Value>,
384 #[serde(default)]
385 adaptive_thinking: Option<bool>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct CopilotCatalogEntry {
396 pub id: String,
397 pub label: String,
398 pub vendor: Option<String>,
399 pub kind: String,
401 pub model_picker_enabled: bool,
402 pub policy_state: CopilotPolicyState,
403 pub preview: bool,
404 pub is_chat_default: bool,
405 pub is_chat_fallback: bool,
406 pub supported_endpoints: Vec<CopilotEndpoint>,
407 pub context_tokens: Option<u64>,
408 pub max_output_tokens: Option<u64>,
409 pub vision: bool,
410 pub thinking: bool,
411}
412
413impl CopilotCatalogEntry {
414 pub fn preferred_wire_protocol(&self) -> Option<crate::runtime::openai::WireProtocol> {
416 preferred_wire_protocol_from_endpoints(self.vendor.as_deref(), &self.supported_endpoints)
417 }
418
419 pub fn is_selectable_for_picker(&self) -> bool {
421 self.kind.eq_ignore_ascii_case("chat")
422 && (self.model_picker_enabled
423 || matches!(self.policy_state, CopilotPolicyState::Disabled))
424 && self.preferred_wire_protocol().is_some()
425 }
426}
427
428fn body_size_guard(body: &str) -> Result<(), String> {
429 if body.len() > MAX_MODELS_BODY_BYTES {
430 return Err(format!(
431 "github-copilot models body exceeded the {MAX_MODELS_BODY_BYTES}-byte cap"
432 ));
433 }
434 Ok(())
435}
436
437fn parse_response(body: &str) -> Result<CopilotModelsResponse, String> {
438 body_size_guard(body)?;
439 serde_json::from_str(body).map_err(|e| format!("github-copilot models parse failed: {e}"))
440}
441
442fn to_entry(item: CopilotModelItem) -> Option<CopilotCatalogEntry> {
443 let id = item.id.trim().to_string();
444 if id.is_empty() {
445 return None;
446 }
447 let caps = item.capabilities.as_ref();
448 let kind = caps
449 .and_then(|c| c.kind.as_deref())
450 .unwrap_or("chat")
451 .to_string();
452 let (context_tokens, max_output_tokens, vision, thinking) = caps
453 .map(|c| {
454 let (ctx, out) = c
455 .limits
456 .as_ref()
457 .map(|l| {
458 (
459 l.max_context_window_tokens.or(l.max_prompt_tokens),
460 l.max_output_tokens,
461 )
462 })
463 .unwrap_or((None, None));
464 let (vision, thinking) = c
465 .supports
466 .as_ref()
467 .map(|s| {
468 let thinking = s.adaptive_thinking == Some(true)
469 || s.reasoning_effort.as_ref().is_some_and(|v| !v.is_null());
470 (s.vision.unwrap_or(false), thinking)
471 })
472 .unwrap_or((false, false));
473 (ctx, out, vision, thinking)
474 })
475 .unwrap_or((None, None, false, false));
476 let supported_endpoints = item
477 .supported_endpoints
478 .unwrap_or_default()
479 .into_iter()
480 .map(|s| CopilotEndpoint::parse(&s))
481 .collect();
482 let label = item
483 .name
484 .filter(|n| !n.trim().is_empty())
485 .unwrap_or_else(|| id.clone());
486 Some(CopilotCatalogEntry {
487 id,
488 label,
489 vendor: item.vendor.filter(|v| !v.trim().is_empty()),
490 kind,
491 model_picker_enabled: item.model_picker_enabled.unwrap_or(false),
492 policy_state: CopilotPolicyState::from_opt(item.policy.and_then(|p| p.state).as_deref()),
493 preview: item.preview.unwrap_or(false),
494 is_chat_default: item.is_chat_default.unwrap_or(false),
495 is_chat_fallback: item.is_chat_fallback.unwrap_or(false),
496 supported_endpoints,
497 context_tokens,
498 max_output_tokens,
499 vision,
500 thinking,
501 })
502}
503
504pub fn parse_copilot_catalog_entries(body: &str) -> Result<Vec<CopilotCatalogEntry>, String> {
510 let resp = parse_response(body)?;
511 Ok(resp.data.into_iter().filter_map(to_entry).collect())
512}
513
514pub fn parse_copilot_catalog_models(body: &str) -> Result<Vec<CatalogModel>, String> {
521 let entries = parse_copilot_catalog_entries(body)?;
522 Ok(entries
523 .into_iter()
524 .filter(CopilotCatalogEntry::is_selectable_for_picker)
525 .filter_map(|e| catalog_model_from_entry(&e))
526 .collect())
527}
528
529pub fn selectable_copilot_entries(body: &str) -> Result<Vec<CopilotCatalogEntry>, String> {
531 Ok(parse_copilot_catalog_entries(body)?
532 .into_iter()
533 .filter(CopilotCatalogEntry::is_selectable_for_picker)
534 .collect())
535}
536
537fn catalog_model_from_entry(e: &CopilotCatalogEntry) -> Option<CatalogModel> {
538 let mut m = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, &e.id)?;
539 m.provider_kind = CatalogProviderKind::Generic {
540 key: PROVIDER_KEY.into(),
541 };
542 m.label = Some(e.label.clone());
543 m.context_tokens = e.context_tokens;
544 m.max_output_tokens = e.max_output_tokens;
545 let mut modalities = vec![Modality::Text];
546 if e.vision {
547 modalities.push(Modality::Image);
548 }
549 m.input_modalities = modalities;
550 m.reasoning = if e.thinking {
551 ReasoningSupport::GenericOpenAi
552 } else {
553 ReasoningSupport::Unknown
554 };
555 m.source = CatalogSource::Live;
556 Some(m)
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 const LIVE_FIXTURE: &str = include_str!("fixtures/github_copilot_models.json");
564
565 #[test]
566 fn fallback_wire_ids_are_fixture_selectable_set() {
567 let entries = parse_copilot_catalog_entries(LIVE_FIXTURE).expect("fixture parse");
568 let selectable: std::collections::HashSet<_> = entries
569 .iter()
570 .filter(|e| e.is_selectable_for_picker())
571 .map(|e| e.id.as_str())
572 .collect();
573 let fallback: std::collections::HashSet<_> =
574 COPILOT_FALLBACK_MODELS.iter().map(|d| d.id).collect();
575 assert_eq!(
576 fallback, selectable,
577 "curated fallback must match the fixture's selectable set exactly"
578 );
579 assert!(copilot_model("gpt-5.6-sol").is_none());
581 assert!(copilot_model("auto").is_none());
582 assert!(copilot_model("gpt-4.1").is_none());
583 assert!(copilot_model("claude-sonnet-4").is_none());
584 assert!(copilot_model("gemini-3-pro").is_none());
585 assert!(copilot_model("gemini-3-flash-preview").is_none());
587 }
588
589 #[test]
590 fn static_catalog_uses_fallback_source_and_prefixed_runtime_ids() {
591 let models = copilot_static_catalog_models();
592 assert_eq!(models.len(), COPILOT_FALLBACK_MODELS.len());
593 assert!(models
594 .iter()
595 .all(|m| m.source == CatalogSource::StaticFallback));
596 assert!(models
597 .iter()
598 .all(|m| m.runtime_id().starts_with("github-copilot/")));
599 assert_eq!(models[0].id, "gpt-5.3-codex");
600 assert_eq!(models[0].label.as_deref(), Some("GPT-5.3-Codex"));
601 }
602
603 #[test]
604 fn models_endpoint_is_pinned() {
605 validate_models_endpoint(MODELS_URL).unwrap();
606 assert!(
607 validate_models_endpoint("https://api.individual.githubcopilot.com/models").is_err()
608 );
609 assert!(validate_models_endpoint("https://api.githubcopilot.com/models/").is_err());
610 assert!(validate_models_endpoint("https://evil.example/models").is_err());
611 assert!(validate_models_endpoint("http://api.githubcopilot.com/models").is_err());
612 }
613
614 #[test]
615 fn models_headers_include_integration_and_api_version() {
616 let map: std::collections::HashMap<_, _> =
617 models_request_headers().iter().copied().collect();
618 assert_eq!(map.get("User-Agent"), Some(&"SynapsCLI/0.6.0"));
619 assert_eq!(map.get("Copilot-Integration-Id"), Some(&"vscode-chat"));
620 assert_eq!(map.get("X-Github-Api-Version"), Some(&COPILOT_API_VERSION));
621 assert_eq!(map.get("Editor-Version"), Some(&"vscode/1.107.0"));
622 }
623
624 #[test]
625 fn parse_rejects_malformed_and_missing_data() {
626 assert!(parse_copilot_catalog_models("{not json}").is_err());
627 assert!(parse_copilot_catalog_models(r#"{"models":[]}"#).is_err());
628 assert!(parse_copilot_catalog_models("[]").is_err());
629 }
630
631 #[test]
632 fn parse_rejects_oversized_body() {
633 let huge = format!(
634 "{{\"data\":[{{\"id\":\"x\",\"capabilities\":{{\"type\":\"chat\"}}}}{}]}}",
635 " ".repeat(MAX_MODELS_BODY_BYTES)
636 );
637 let err = parse_copilot_catalog_models(&huge).unwrap_err();
638 assert!(err.contains("exceeded"), "{err}");
639 }
640
641 #[test]
642 fn parse_filters_non_chat_and_endpointless_but_keeps_opt_in_rows() {
643 let body = r#"{
644 "object":"list",
645 "data":[
646 {"id":"","capabilities":{"type":"chat"}},
647 {"id":"embed-1","name":"Embed","capabilities":{"type":"embeddings"},
648 "model_picker_enabled":true,"policy":{"state":"enabled"},
649 "supported_endpoints":["/chat/completions"]},
650 {"id":"complete-1","name":"Complete","capabilities":{"type":"completion"},
651 "model_picker_enabled":true,"policy":{"state":"enabled"},
652 "supported_endpoints":["/chat/completions"]},
653 {"id":"disabled-chat","name":"Disabled","capabilities":{"type":"chat"},
654 "model_picker_enabled":true,"policy":{"state":"disabled"},
655 "supported_endpoints":["/chat/completions"]},
656 {"id":"unpicked-chat","name":"Hidden","capabilities":{"type":"chat"},
657 "model_picker_enabled":false,"policy":{"state":"enabled"},
658 "supported_endpoints":["/chat/completions"]},
659 {"id":"endpointless","name":"Endpointless","capabilities":{"type":"chat"},
660 "model_picker_enabled":true,"policy":{"state":"enabled"}},
661 {"id":"only-v1-messages","name":"OnlyMessages","vendor":"Anthropic",
662 "capabilities":{"type":"chat"},"model_picker_enabled":true,
663 "policy":{"state":"enabled"},"supported_endpoints":["/v1/messages"]},
664 {"id":"only-ws","name":"OnlyWs","vendor":"OpenAI",
665 "capabilities":{"type":"chat"},"model_picker_enabled":true,
666 "policy":{"state":"enabled"},"supported_endpoints":["ws:/responses"]},
667 {"id":"gpt-x","name":"GPT-X","vendor":"OpenAI","capabilities":{
668 "type":"chat",
669 "limits":{"max_context_window_tokens":128000,"max_output_tokens":16384},
670 "supports":{"vision":true,"reasoning_effort":["low","high"]}
671 },"model_picker_enabled":true,"policy":{"state":"enabled"},
672 "supported_endpoints":["/responses","/chat/completions"]}
673 ]
674 }"#;
675 let models = parse_copilot_catalog_models(body).expect("parse");
676 let ids: Vec<_> = models.iter().map(|m| m.id.as_str()).collect();
677 assert_eq!(ids, vec!["disabled-chat", "gpt-x"]);
678 let gpt = &models[1];
679 assert_eq!(gpt.context_tokens, Some(128000));
680 assert_eq!(gpt.max_output_tokens, Some(16384));
681 assert!(gpt.input_modalities.contains(&Modality::Image));
682 assert_eq!(gpt.reasoning, ReasoningSupport::GenericOpenAi);
683 assert_eq!(gpt.source, CatalogSource::Live);
684 assert_eq!(gpt.runtime_id(), "github-copilot/gpt-x");
685 }
686
687 #[test]
688 fn parse_live_fixture_keeps_only_selectable_rows() {
689 let models = parse_copilot_catalog_models(LIVE_FIXTURE).expect("fixture parse");
690 let ids: std::collections::HashSet<_> = models.iter().map(|m| m.id.as_str()).collect();
691 for expected in [
693 "gpt-5.3-codex",
694 "gpt-5.4",
695 "gpt-5.4-mini",
696 "gpt-5.6-luna",
697 "gpt-5.6-terra",
698 "gpt-5-mini",
699 "claude-sonnet-4.6",
700 "claude-sonnet-5",
701 "claude-haiku-4.5",
702 "gemini-3.1-pro-preview",
703 "gemini-3.5-flash",
704 "claude-fable-5",
705 "claude-opus-4.7",
706 "claude-opus-4.8",
707 "claude-opus-4.8-fast",
708 "gpt-5.5",
709 ] {
710 assert!(ids.contains(expected), "missing {expected}");
711 }
712 for banned in [
714 "gemini-3-flash-preview",
715 "text-embedding-3-small",
716 "gpt-41-copilot",
717 "trajectory-compaction",
718 ] {
719 assert!(!ids.contains(banned), "must filter {banned}");
720 }
721 assert!(models.iter().all(|m| m.provider_key == PROVIDER_KEY));
722 assert!(models
723 .iter()
724 .all(|m| m.runtime_id().starts_with("github-copilot/")));
725 }
726
727 #[test]
728 fn every_fallback_id_selectable_in_live_fixture() {
729 let selectable = selectable_copilot_entries(LIVE_FIXTURE).expect("fixture");
730 let ids: std::collections::HashSet<_> = selectable.iter().map(|e| e.id.as_str()).collect();
731 for d in COPILOT_FALLBACK_MODELS {
732 assert!(
733 ids.contains(d.id),
734 "fallback id {} is not selectable in live fixture",
735 d.id
736 );
737 let entry = selectable.iter().find(|e| e.id == d.id).unwrap();
740 assert_eq!(
741 Some(d.selected_wire.to_wire_protocol()),
742 entry.preferred_wire_protocol(),
743 "descriptor `{}` disagrees with fixture endpoint pick",
744 d.id
745 );
746 }
747 }
748
749 #[test]
750 fn endpoint_preference_openai_prefers_responses_others_prefer_chat() {
751 use crate::runtime::openai::WireProtocol;
752 assert_eq!(
754 preferred_wire_protocol_from_endpoints(
755 Some("OpenAI"),
756 &[CopilotEndpoint::Responses, CopilotEndpoint::ChatCompletions]
757 ),
758 Some(WireProtocol::OpenAiResponses)
759 );
760 assert_eq!(
762 preferred_wire_protocol_from_endpoints(
763 Some("OpenAI"),
764 &[CopilotEndpoint::ChatCompletions]
765 ),
766 Some(WireProtocol::OpenAiChatCompletions)
767 );
768 assert_eq!(
770 preferred_wire_protocol_from_endpoints(
771 Some("Azure OpenAI"),
772 &[CopilotEndpoint::ChatCompletions, CopilotEndpoint::Responses]
773 ),
774 Some(WireProtocol::OpenAiResponses)
775 );
776 assert_eq!(
778 preferred_wire_protocol_from_endpoints(
779 Some("Anthropic"),
780 &[
781 CopilotEndpoint::V1Messages,
782 CopilotEndpoint::ChatCompletions
783 ]
784 ),
785 Some(WireProtocol::OpenAiChatCompletions)
786 );
787 assert_eq!(
789 preferred_wire_protocol_from_endpoints(
790 Some("Anthropic"),
791 &[CopilotEndpoint::V1Messages]
792 ),
793 None
794 );
795 assert_eq!(
797 preferred_wire_protocol_from_endpoints(
798 Some("Google"),
799 &[CopilotEndpoint::ChatCompletions]
800 ),
801 Some(WireProtocol::OpenAiChatCompletions)
802 );
803 assert_eq!(
805 preferred_wire_protocol_from_endpoints(Some("OpenAI"), &[CopilotEndpoint::WsResponses]),
806 None
807 );
808 assert_eq!(
810 preferred_wire_protocol_from_endpoints(Some("OpenAI"), &[]),
811 None
812 );
813 }
814}