vtcode_core/copilot/
server_client.rs1use std::path::Path;
2
3use anyhow::{Context, Result, anyhow};
4use serde_json::{Value, json};
5use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
6use tokio::process::{ChildStderr, ChildStdout};
7use tokio::time::timeout;
8use vtcode_config::auth::CopilotAuthConfig;
9
10use super::command::{resolve_copilot_command, spawn_copilot_server_process};
11use super::types::CopilotDiscoveredModel;
12
13pub async fn list_available_models(
14 config: &CopilotAuthConfig,
15 workspace_root: &Path,
16) -> Result<Vec<CopilotDiscoveredModel>> {
17 let resolved = resolve_copilot_command(config)?;
18 let mut child = spawn_copilot_server_process(&resolved, workspace_root)?;
19 let stdin = child
20 .stdin
21 .take()
22 .ok_or_else(|| anyhow!("copilot cli server stdin unavailable"))?;
23 let stdout = child
24 .stdout
25 .take()
26 .ok_or_else(|| anyhow!("copilot cli server stdout unavailable"))?;
27 let stderr = child
28 .stderr
29 .take()
30 .ok_or_else(|| anyhow!("copilot cli server stderr unavailable"))?;
31
32 spawn_server_stderr(stderr);
33
34 let result = timeout(resolved.startup_timeout, async move {
35 let mut writer = stdin;
36 let mut reader = BufReader::new(stdout);
37
38 send_request(
39 &mut writer,
40 1,
41 "ping",
42 Some(json!({ "message": "vtcode model discovery" })),
43 )
44 .await
45 .context("copilot cli ping")?;
46 let ping = read_response(&mut reader, 1)
47 .await
48 .context("copilot cli ping")?;
49 let protocol_version = ping
50 .get("protocolVersion")
51 .and_then(Value::as_i64)
52 .unwrap_or(0);
53 if protocol_version <= 0 {
54 return Err(anyhow!(
55 "copilot cli server did not report a protocol version"
56 ));
57 }
58
59 send_request(&mut writer, 2, "models.list", None)
60 .await
61 .context("copilot cli models.list")?;
62 let payload = read_response(&mut reader, 2)
63 .await
64 .context("copilot cli models.list")?;
65 let models = payload
66 .get("models")
67 .and_then(Value::as_array)
68 .ok_or_else(|| anyhow!("copilot cli models.list response missing models"))?;
69
70 let mut discovered = Vec::new();
71 for model in models {
72 let id = model
73 .get("id")
74 .and_then(Value::as_str)
75 .map(str::trim)
76 .filter(|value| !value.is_empty());
77 let name = model
78 .get("name")
79 .and_then(Value::as_str)
80 .map(str::trim)
81 .filter(|value| !value.is_empty());
82 let policy_enabled = model
83 .get("policy")
84 .and_then(Value::as_object)
85 .and_then(|policy| policy.get("state"))
86 .and_then(Value::as_str)
87 .map(|state| state.eq_ignore_ascii_case("enabled"))
88 .unwrap_or(true);
89 if !policy_enabled {
90 continue;
91 }
92 let Some(id) = id else {
93 continue;
94 };
95 discovered.push(CopilotDiscoveredModel {
96 id: id.to_string(),
97 name: name.unwrap_or(id).to_string(),
98 });
99 }
100
101 discovered.sort_by(|left, right| left.id.cmp(&right.id));
102 discovered.dedup_by(|left, right| left.id.eq_ignore_ascii_case(&right.id));
103 Ok::<Vec<CopilotDiscoveredModel>, anyhow::Error>(discovered)
104 })
105 .await
106 .context("copilot cli model discovery timeout")??;
107
108 let _ = child.start_kill();
109 Ok(result)
110}
111
112fn spawn_server_stderr(stderr: ChildStderr) {
113 tokio::spawn(async move {
114 let mut lines = BufReader::new(stderr).lines();
115 while let Ok(Some(line)) = lines.next_line().await {
116 let trimmed = line.trim();
117 if !trimmed.is_empty() {
118 tracing::debug!(target: "copilot.server.stderr", "{}", trimmed);
119 }
120 }
121 });
122}
123
124async fn send_request<W>(writer: &mut W, id: i64, method: &str, params: Option<Value>) -> Result<()>
125where
126 W: AsyncWrite + Unpin,
127{
128 let message = if let Some(params) = params {
129 json!({
130 "jsonrpc": "2.0",
131 "id": id,
132 "method": method,
133 "params": params,
134 })
135 } else {
136 json!({
137 "jsonrpc": "2.0",
138 "id": id,
139 "method": method,
140 })
141 };
142 let payload = serde_json::to_vec(&message).context("copilot cli json serialization failed")?;
143 writer
144 .write_all(format!("Content-Length: {}\r\n\r\n", payload.len()).as_bytes())
145 .await
146 .context("copilot cli write header failed")?;
147 writer
148 .write_all(&payload)
149 .await
150 .context("copilot cli write payload failed")?;
151 writer.flush().await.context("copilot cli flush failed")?;
152 Ok(())
153}
154
155async fn read_response(reader: &mut BufReader<ChildStdout>, expected_id: i64) -> Result<Value> {
156 loop {
157 let message = read_message(reader).await?;
158 let Some(object) = message.as_object() else {
159 continue;
160 };
161
162 if object.get("method").is_some() {
163 continue;
164 }
165
166 if let Some(error) = object.get("error") {
167 let code = error
168 .get("code")
169 .and_then(Value::as_i64)
170 .unwrap_or_default();
171 let detail = error
172 .get("message")
173 .and_then(Value::as_str)
174 .unwrap_or("unknown error");
175 return Err(anyhow!("copilot cli rpc error {code}: {detail}"));
176 }
177
178 if object.get("id").and_then(Value::as_i64) != Some(expected_id) {
179 continue;
180 }
181
182 return object
183 .get("result")
184 .cloned()
185 .ok_or_else(|| anyhow!("copilot cli rpc response missing result"));
186 }
187}
188
189async fn read_message(reader: &mut BufReader<ChildStdout>) -> Result<Value> {
190 let mut content_length = None;
191 loop {
192 let mut line = String::new();
193 let read = reader
194 .read_line(&mut line)
195 .await
196 .context("copilot cli header read failed")?;
197 if read == 0 {
198 return Err(anyhow!("copilot cli server closed the stdio stream"));
199 }
200
201 let trimmed = line.trim_end_matches(['\r', '\n']);
202 if trimmed.is_empty() {
203 break;
204 }
205
206 if let Some(value) = trimmed.strip_prefix("Content-Length:") {
207 content_length = Some(
208 value
209 .trim()
210 .parse::<usize>()
211 .context("invalid copilot cli content length header")?,
212 );
213 }
214 }
215
216 let content_length =
217 content_length.ok_or_else(|| anyhow!("copilot cli response missing Content-Length"))?;
218 let payload = crate::utils::async_utils::read_exact_uninit(reader, content_length)
219 .await
220 .context("copilot cli payload read failed")?;
221 serde_json::from_slice(&payload).context("copilot cli json decode failed")
222}