Skip to main content

tokn_headers/schemas/overlays/
copilot.rs

1//! GitHub Copilot transport overlay.
2//!
3//! Headers required by the Copilot proxy regardless of which CLI persona
4//! originated the request.
5//!
6//! SCOPE: this overlay models **outbound** headers the router injects when
7//! forwarding to `api.githubcopilot.com`. The mined inbound matrix never
8//! shows `Editor-Version`, `Editor-Plugin-Version`, `Copilot-Integration-Id`,
9//! or `Copilot-Vision-Request` because those are added downstream of the
10//! gateway. Inbound-only Copilot signals (e.g. `X-Initiator`,
11//! `OpenAI-Intent`) are observed from CLI clients targeting the gateway.
12
13use crate::error::Error;
14use crate::keys;
15use crate::map::HeaderMap;
16use crate::name::HeaderName;
17use crate::schema::{from_inbound_or, opt_from_inbound, optional, put, put_opt, required, HeaderSchema};
18use crate::vars::TemplateVars;
19use serde::{Deserialize, Serialize};
20use smol_str::SmolStr;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct CopilotOverlay {
24  #[serde(rename = "Editor-Version")]
25  pub editor_version: SmolStr,
26  #[serde(rename = "Editor-Plugin-Version")]
27  pub editor_plugin_version: SmolStr,
28  #[serde(rename = "Copilot-Integration-Id")]
29  pub integration_id: SmolStr,
30  #[serde(rename = "Copilot-Vision-Request")]
31  pub vision_request: Option<SmolStr>,
32  #[serde(rename = "X-Initiator")]
33  pub initiator: Option<SmolStr>,
34}
35
36impl HeaderSchema for CopilotOverlay {
37  fn parse(map: &HeaderMap) -> Result<Self, Error> {
38    Ok(Self {
39      editor_version: required(map, &keys::EDITOR_VERSION)?,
40      editor_plugin_version: required(map, &keys::EDITOR_PLUGIN_VERSION)?,
41      integration_id: required(map, &keys::COPILOT_INTEGRATION_ID)?,
42      vision_request: optional(map, &keys::COPILOT_VISION_REQUEST),
43      initiator: optional(map, &keys::X_INITIATOR),
44    })
45  }
46  fn dump(&self) -> HeaderMap {
47    let mut m = HeaderMap::new();
48    put(&mut m, &keys::EDITOR_VERSION, &self.editor_version);
49    put(&mut m, &keys::EDITOR_PLUGIN_VERSION, &self.editor_plugin_version);
50    put(&mut m, &keys::COPILOT_INTEGRATION_ID, &self.integration_id);
51    put_opt(&mut m, &keys::COPILOT_VISION_REQUEST, &self.vision_request);
52    put_opt(&mut m, &keys::X_INITIATOR, &self.initiator);
53    m
54  }
55  fn known_names() -> &'static [&'static HeaderName] {
56    static NAMES: [&HeaderName; 5] = [
57      &keys::EDITOR_VERSION,
58      &keys::EDITOR_PLUGIN_VERSION,
59      &keys::COPILOT_INTEGRATION_ID,
60      &keys::COPILOT_VISION_REQUEST,
61      &keys::X_INITIATOR,
62    ];
63    &NAMES
64  }
65}
66
67impl CopilotOverlay {
68  /// Build a [`CopilotOverlay`] from inbound transport headers and
69  /// correlation [`TemplateVars`]. Required overlay fields fall back to
70  /// canonical Copilot gateway defaults when absent from the inbound map.
71  pub fn build(_vars: &TemplateVars, inbound: &HeaderMap) -> Self {
72    Self {
73      editor_version: from_inbound_or(inbound, &keys::EDITOR_VERSION, || "vscode/1.95.0".into()),
74      editor_plugin_version: from_inbound_or(inbound, &keys::EDITOR_PLUGIN_VERSION, || "copilot-chat/0.23.0".into()),
75      integration_id: from_inbound_or(inbound, &keys::COPILOT_INTEGRATION_ID, || "vscode-chat".into()),
76      vision_request: opt_from_inbound(inbound, &keys::COPILOT_VISION_REQUEST),
77      initiator: opt_from_inbound(inbound, &keys::X_INITIATOR),
78    }
79  }
80
81  /// Apply this overlay onto an outbound [`HeaderMap`]. Gateway-identifying
82  /// headers (`Editor-Version`, `Editor-Plugin-Version`,
83  /// `Copilot-Integration-Id`) override any existing values; optional fields
84  /// (`Copilot-Vision-Request`, `X-Initiator`) are filled in only when the
85  /// overlay has a value AND the header is not already present on the map.
86  pub fn apply_to(&self, map: &mut HeaderMap, _vars: &TemplateVars) {
87    map.insert(&keys::EDITOR_VERSION, self.editor_version.to_string());
88    map.insert(&keys::EDITOR_PLUGIN_VERSION, self.editor_plugin_version.to_string());
89    map.insert(&keys::COPILOT_INTEGRATION_ID, self.integration_id.to_string());
90    if let Some(v) = &self.vision_request {
91      if !map.contains_key(&keys::COPILOT_VISION_REQUEST) {
92        map.insert(&keys::COPILOT_VISION_REQUEST, v.to_string());
93      }
94    }
95    if let Some(v) = &self.initiator {
96      if !map.contains_key(&keys::X_INITIATOR) {
97        map.insert(&keys::X_INITIATOR, v.to_string());
98      }
99    }
100  }
101}
102
103#[cfg(test)]
104mod tests {
105  use super::*;
106
107  #[test]
108  fn round_trip() {
109    let h = CopilotOverlay {
110      editor_version: "vscode/1.95.0".into(),
111      editor_plugin_version: "copilot-chat/0.23.0".into(),
112      integration_id: "vscode-chat".into(),
113      vision_request: Some("true".into()),
114      initiator: Some("agent".into()),
115    };
116    assert_eq!(CopilotOverlay::parse(&h.dump()).unwrap(), h);
117  }
118
119  #[test]
120  fn missing_required_errors() {
121    let m = HeaderMap::new();
122    assert!(matches!(CopilotOverlay::parse(&m), Err(Error::MissingHeader { .. })));
123  }
124
125  #[test]
126  fn build_with_empty_inbound_uses_defaults() {
127    let h = CopilotOverlay::build(&TemplateVars::default(), &HeaderMap::new());
128    assert_eq!(h.editor_version.as_str(), "vscode/1.95.0");
129    assert_eq!(h.editor_plugin_version.as_str(), "copilot-chat/0.23.0");
130    assert_eq!(h.integration_id.as_str(), "vscode-chat");
131    assert!(h.vision_request.is_none());
132    assert!(h.initiator.is_none());
133  }
134
135  #[test]
136  fn build_passes_through_inbound() {
137    let mut inbound = HeaderMap::new();
138    inbound.insert(&keys::EDITOR_VERSION, "vscode/1.99.0");
139    inbound.insert(&keys::COPILOT_VISION_REQUEST, "true");
140    let h = CopilotOverlay::build(&TemplateVars::default(), &inbound);
141    assert_eq!(h.editor_version.as_str(), "vscode/1.99.0");
142    assert_eq!(h.vision_request.as_deref(), Some("true"));
143  }
144
145  #[test]
146  fn build_uses_vars_for_correlation() {
147    // CopilotOverlay holds no correlation fields itself; vars should not panic
148    // and required fields should still come from defaults.
149    let vars = TemplateVars {
150      session_id: Some("ses_xyz".into()),
151      ..Default::default()
152    };
153    let h = CopilotOverlay::build(&vars, &HeaderMap::new());
154    assert_eq!(h.integration_id.as_str(), "vscode-chat");
155  }
156
157  #[test]
158  fn apply_to_overrides_managed_fields_and_skips_optionals_when_none() {
159    // Start from an outbound map dumped from a CopilotCli persona-ish request.
160    let mut map = HeaderMap::new();
161    map.insert(&keys::EDITOR_VERSION, "stale/0.0.0");
162    map.insert(&keys::COPILOT_INTEGRATION_ID, "old-integration");
163    map.insert(&keys::X_INITIATOR, "preexisting");
164
165    let overlay = CopilotOverlay {
166      editor_version: "vscode/1.95.0".into(),
167      editor_plugin_version: "copilot-chat/0.23.0".into(),
168      integration_id: "vscode-chat".into(),
169      vision_request: None,
170      initiator: Some("agent".into()),
171    };
172    overlay.apply_to(&mut map, &TemplateVars::default());
173
174    // Managed fields overridden.
175    assert_eq!(map.get(&keys::EDITOR_VERSION).unwrap().as_str(), "vscode/1.95.0");
176    assert_eq!(map.get(&keys::COPILOT_INTEGRATION_ID).unwrap().as_str(), "vscode-chat");
177    assert_eq!(
178      map.get(&keys::EDITOR_PLUGIN_VERSION).unwrap().as_str(),
179      "copilot-chat/0.23.0"
180    );
181    // Pre-existing X-Initiator preserved (we only fill if absent).
182    assert_eq!(map.get(&keys::X_INITIATOR).unwrap().as_str(), "preexisting");
183    // None-valued optional not inserted.
184    assert!(!map.contains_key(&keys::COPILOT_VISION_REQUEST));
185  }
186}