Skip to main content

rig_core/providers/copilot/
mod.rs

1//! GitHub Copilot provider.
2//!
3//! Supports Chat Completions, Responses, and Embeddings against
4//! `https://api.githubcopilot.com`.
5//!
6//! `Client::completion_model(...)` automatically routes Codex-class models
7//! through `/responses` and conversational models through
8//! `/chat/completions`.
9//!
10//! # Example
11//! ```no_run
12//! use rig_core::client::{CompletionClient, ProviderClient};
13//! use rig_core::providers::copilot;
14//!
15//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
16//! let client = copilot::Client::from_env()?;
17//! let model = client.completion_model(copilot::GPT_4O);
18//! # let _ = model;
19//! # Ok(())
20//! # }
21//! ```
22
23mod auth;
24
25use crate::client::{
26    self, ApiKey, DebugExt, ModelLister, Provider, ProviderBuilder, ProviderClient, Transport,
27};
28use crate::completion::NormalizeCompletionResponse;
29use crate::completion::{self, CompletionError};
30use crate::embeddings::{self, EmbeddingError};
31use crate::http_client::{self, HttpClientExt};
32use crate::model::{Model, ModelList, ModelListingError};
33use crate::providers::internal::completion_send::send_completion;
34use crate::providers::internal::envelope::DirectPayload;
35use crate::providers::openai;
36use crate::providers::openai::responses_api::{self, CompletionRequest as ResponsesRequest};
37use crate::streaming::StreamingCompletionResponse;
38use crate::telemetry::{CompletionOperation, CompletionSpanBuilder, SpanCombinator};
39use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
40use futures::StreamExt;
41use http::Request;
42use serde::{Deserialize, Serialize};
43use serde_json::json;
44use std::borrow::Cow;
45use std::fmt::Debug;
46use std::path::{Path, PathBuf};
47use tracing_futures::Instrument as _;
48
49const GITHUB_COPILOT_API_BASE_URL: &str = "https://api.githubcopilot.com";
50pub(crate) const EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.35.0";
51pub(crate) const USER_AGENT: &str = "GitHubCopilotChat/0.35.0";
52pub(crate) const EDITOR_VERSION: &str = "vscode/1.107.0";
53const API_VERSION: &str = "2025-04-01";
54
55/// Copilot conversation intent sent in the `openai-intent` request header.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum CopilotIntent {
58    /// Generic chat panel conversation semantics.
59    #[default]
60    Panel,
61    /// Edit-oriented conversation semantics.
62    Edits,
63}
64
65impl CopilotIntent {
66    fn as_header(self) -> &'static str {
67        match self {
68            Self::Panel => "conversation-panel",
69            Self::Edits => "conversation-edits",
70        }
71    }
72}
73
74/// `gpt-4`
75pub const GPT_4: &str = "gpt-4";
76/// `gpt-4o`
77pub const GPT_4O: &str = "gpt-4o";
78/// `gpt-4o-mini`
79pub const GPT_4O_MINI: &str = "gpt-4o-mini";
80/// `gpt-4.1`
81pub const GPT_4_1: &str = "gpt-4.1";
82/// `gpt-4.1-mini`
83pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
84/// `gpt-4.1-nano`
85pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
86/// `gpt-5.3-codex`
87pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
88/// `gpt-5.1-codex`
89pub const GPT_5_1_CODEX: &str = "gpt-5.1-codex";
90/// `gpt-5.5`
91pub const GPT_5_5: &str = "gpt-5.5";
92/// `gpt-5.4`
93pub const GPT_5_4: &str = "gpt-5.4";
94/// `claude-sonnet-4` completion model (Anthropic, via Copilot)
95pub const CLAUDE_SONNET_4: &str = "claude-sonnet-4";
96/// `claude-sonnet-4.6`
97pub const CLAUDE_SONNET_4_6: &str = "claude-sonnet-4.6";
98/// `claude-opus-4.6`
99pub const CLAUDE_OPUS_4_6: &str = "claude-opus-4.6";
100/// `claude-opus-4.7`
101pub const CLAUDE_OPUS_4_7: &str = "claude-opus-4.7";
102/// `claude-3.5-sonnet` completion model (Anthropic, via Copilot)
103pub const CLAUDE_3_5_SONNET: &str = "claude-3.5-sonnet";
104/// `gemini-3-flash-preview` completion model (Google, via Copilot)
105pub const GEMINI_3_FLASH: &str = "gemini-3-flash-preview";
106/// `gemini-3.1-pro-preview` completion model (Google, via Copilot)
107pub const GEMINI_3_1_PRO_FLASH: &str = "gemini-3.1-pro-preview";
108/// `gemini-2.0-flash-001` completion model (Google, via Copilot)
109pub const GEMINI_2_0_FLASH: &str = "gemini-2.0-flash-001";
110/// `o3-mini` reasoning model (OpenAI, via Copilot)
111pub const O3_MINI: &str = "o3-mini";
112/// `text-embedding-3-small`
113pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
114/// `text-embedding-3-large`
115pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
116/// `text-embedding-ada-002`
117pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
118
119pub use openai::EncodingFormat;
120
121#[derive(Clone)]
122pub enum CopilotAuth {
123    ApiKey(String),
124    GitHubAccessToken(String),
125    OAuth,
126}
127
128impl ApiKey for CopilotAuth {}
129
130impl<S> From<S> for CopilotAuth
131where
132    S: Into<String>,
133{
134    fn from(value: S) -> Self {
135        Self::ApiKey(value.into())
136    }
137}
138
139impl Debug for CopilotAuth {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
143            Self::GitHubAccessToken(_) => f.write_str("GitHubAccessToken(<redacted>)"),
144            Self::OAuth => f.write_str("OAuth"),
145        }
146    }
147}
148
149#[derive(Debug, Clone)]
150pub struct CopilotBuilder {
151    access_token_file: Option<PathBuf>,
152    api_key_file: Option<PathBuf>,
153    device_code_handler: auth::DeviceCodeHandler,
154    allow_device_flow: bool,
155}
156
157#[derive(Clone)]
158pub struct CopilotExt {
159    auth: auth::Authenticator,
160}
161
162impl Debug for CopilotExt {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.debug_struct("CopilotExt")
165            .field("auth", &self.auth)
166            .finish()
167    }
168}
169
170pub type Client<H = reqwest::Client> = client::Client<CopilotExt, H>;
171pub type ClientBuilder<H = crate::markers::Missing> =
172    client::ClientBuilder<CopilotBuilder, CopilotAuth, H>;
173
174impl Default for CopilotBuilder {
175    fn default() -> Self {
176        let token_dir = default_token_dir();
177        Self {
178            access_token_file: token_dir.as_ref().map(|dir| dir.join("access-token")),
179            api_key_file: token_dir.map(|dir| dir.join("api-key.json")),
180            device_code_handler: auth::DeviceCodeHandler::default(),
181            allow_device_flow: true,
182        }
183    }
184}
185
186impl Provider for CopilotExt {
187    type Builder = CopilotBuilder;
188
189    const VERIFY_PATH: &'static str = "";
190}
191
192client::impl_capabilities!(
193    CopilotExt,
194    completion = CompletionModel<H>,
195    embeddings = EmbeddingModel<H>,
196    model_listing = CopilotModelLister<H>,
197);
198
199impl DebugExt for CopilotExt {}
200
201impl ProviderBuilder for CopilotBuilder {
202    type Extension<H>
203        = CopilotExt
204    where
205        H: HttpClientExt;
206    type ApiKey = CopilotAuth;
207
208    const BASE_URL: &'static str = GITHUB_COPILOT_API_BASE_URL;
209
210    fn build<H>(
211        builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
212    ) -> http_client::Result<Self::Extension<H>>
213    where
214        H: HttpClientExt,
215    {
216        let auth = match builder.get_api_key() {
217            CopilotAuth::ApiKey(api_key) => auth::AuthSource::ApiKey(api_key.clone()),
218            CopilotAuth::GitHubAccessToken(access_token) => {
219                auth::AuthSource::GitHubAccessToken(access_token.clone())
220            }
221            CopilotAuth::OAuth => auth::AuthSource::OAuth,
222        };
223
224        let ext = builder.ext();
225        Ok(CopilotExt {
226            auth: auth::Authenticator::new(
227                auth,
228                ext.access_token_file.clone(),
229                ext.api_key_file.clone(),
230                ext.device_code_handler.clone(),
231                ext.allow_device_flow,
232            ),
233        })
234    }
235}
236
237impl ProviderClient for Client {
238    type Input = CopilotAuth;
239    type Error = crate::client::ProviderClientError;
240
241    fn from_env() -> Result<Self, Self::Error> {
242        let mut builder = Self::builder();
243        fn get(name: &str) -> Option<String> {
244            std::env::var(name).ok()
245        }
246
247        if let Some(base_url) = env_base_url(&get) {
248            builder = builder.base_url(base_url);
249        }
250
251        if let Some(api_key) = env_api_key(&get) {
252            builder.api_key(api_key).build().map_err(Into::into)
253        } else if let Some(access_token) = env_github_access_token(&get) {
254            builder
255                .github_access_token(access_token)
256                .build()
257                .map_err(Into::into)
258        } else {
259            builder.oauth().build().map_err(Into::into)
260        }
261    }
262
263    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
264        Self::builder().api_key(input).build().map_err(Into::into)
265    }
266}
267
268impl<H> client::ClientBuilder<CopilotBuilder, crate::markers::Missing, H> {
269    pub fn github_access_token(
270        self,
271        access_token: impl Into<String>,
272    ) -> client::ClientBuilder<CopilotBuilder, CopilotAuth, H> {
273        self.api_key(CopilotAuth::GitHubAccessToken(access_token.into()))
274    }
275
276    pub fn oauth(self) -> client::ClientBuilder<CopilotBuilder, CopilotAuth, H> {
277        self.api_key(CopilotAuth::OAuth)
278    }
279}
280
281impl<H> ClientBuilder<H> {
282    pub fn on_device_code<F>(self, handler: F) -> Self
283    where
284        F: Fn(auth::DeviceCodePrompt) + Send + Sync + 'static,
285    {
286        self.over_ext(|mut ext| {
287            ext.device_code_handler = auth::DeviceCodeHandler::new(handler);
288            ext
289        })
290    }
291
292    /// Control whether OAuth may fall back to an interactive device-code login
293    /// when the cached token is missing or cannot refresh.
294    ///
295    /// Default is `true` for CLI-style interactive use. Services should set it
296    /// to `false` so unattended background work returns a clear auth error
297    /// instead of printing a device code and waiting.
298    pub fn allow_device_flow(self, allow: bool) -> Self {
299        self.over_ext(|mut ext| {
300            ext.allow_device_flow = allow;
301            ext
302        })
303    }
304
305    pub fn token_dir(self, path: impl AsRef<Path>) -> Self {
306        let path = path.as_ref();
307        self.over_ext(|mut ext| {
308            ext.access_token_file = Some(path.join("access-token"));
309            ext.api_key_file = Some(path.join("api-key.json"));
310            ext
311        })
312    }
313
314    pub fn access_token_file(self, path: impl AsRef<Path>) -> Self {
315        let path = path.as_ref().to_path_buf();
316        self.over_ext(|mut ext| {
317            ext.access_token_file = Some(path);
318            ext
319        })
320    }
321
322    pub fn api_key_file(self, path: impl AsRef<Path>) -> Self {
323        let path = path.as_ref().to_path_buf();
324        self.over_ext(|mut ext| {
325            ext.api_key_file = Some(path);
326            ext
327        })
328    }
329}
330
331fn env_value<F>(get: &F, name: &str) -> Option<String>
332where
333    F: Fn(&str) -> Option<String>,
334{
335    get(name).filter(|value| !value.trim().is_empty())
336}
337
338fn first_env_value<F>(get: &F, keys: &[&str]) -> Option<String>
339where
340    F: Fn(&str) -> Option<String>,
341{
342    keys.iter().find_map(|key| env_value(get, key))
343}
344
345fn env_api_key<F>(get: &F) -> Option<String>
346where
347    F: Fn(&str) -> Option<String>,
348{
349    first_env_value(get, &["GITHUB_COPILOT_API_KEY", "COPILOT_API_KEY"])
350}
351
352fn env_github_access_token<F>(get: &F) -> Option<String>
353where
354    F: Fn(&str) -> Option<String>,
355{
356    first_env_value(get, &["COPILOT_GITHUB_ACCESS_TOKEN", "GITHUB_TOKEN"])
357}
358
359fn env_base_url<F>(get: &F) -> Option<String>
360where
361    F: Fn(&str) -> Option<String>,
362{
363    first_env_value(get, &["GITHUB_COPILOT_API_BASE", "COPILOT_BASE_URL"])
364}
365
366impl<H> Client<H>
367where
368    H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
369{
370    pub async fn authorize(&self) -> Result<(), auth::AuthError> {
371        self.ext().auth.auth_context().await.map(|_| ())
372    }
373}
374
375fn default_headers(
376    api_key: &str,
377    initiator: &'static str,
378    has_vision: bool,
379    intent: CopilotIntent,
380) -> Vec<(&'static str, String)> {
381    let mut headers = vec![
382        (
383            http::header::AUTHORIZATION.as_str(),
384            format!("Bearer {api_key}"),
385        ),
386        ("copilot-integration-id", "vscode-chat".to_string()),
387        ("editor-version", EDITOR_VERSION.to_string()),
388        ("editor-plugin-version", EDITOR_PLUGIN_VERSION.to_string()),
389        ("user-agent", USER_AGENT.to_string()),
390        ("openai-intent", intent.as_header().to_string()),
391        ("x-github-api-version", API_VERSION.to_string()),
392        ("x-request-id", crate::id::generate()),
393        (
394            "x-vscode-user-agent-library-version",
395            "electron-fetch".to_string(),
396        ),
397        ("X-Initiator", initiator.to_string()),
398    ];
399
400    if has_vision {
401        headers.push(("copilot-vision-request", "true".to_string()));
402    }
403
404    headers
405}
406
407fn apply_headers(
408    builder: http_client::Builder,
409    headers: &[(&'static str, String)],
410) -> http_client::Builder {
411    headers
412        .iter()
413        .fold(builder, |builder, (key, value)| builder.header(*key, value))
414}
415
416fn runtime_base_url<'a, H>(client: &'a Client<H>, auth: &'a auth::AuthContext) -> Cow<'a, str> {
417    if client.base_url() != GITHUB_COPILOT_API_BASE_URL {
418        return Cow::Borrowed(client.base_url());
419    }
420
421    if let Some(api_base) = auth.api_base.as_deref() {
422        return Cow::Borrowed(api_base);
423    }
424
425    if let Some(base_url) = base_url_from_token(&auth.api_key) {
426        return Cow::Owned(base_url);
427    }
428
429    Cow::Borrowed(client.base_url())
430}
431
432/// Derive the Copilot REST base URL from a chat token's `proxy-ep=` segment.
433///
434/// The endpoint is parsed from a credential string, not from explicit caller
435/// configuration. For that reason, token-derived routing is limited to GitHub
436/// Copilot service hosts and HTTPS. Callers that need a custom non-GitHub host
437/// can still opt in explicitly with [`ClientBuilder::base_url`].
438fn base_url_from_token(token: &str) -> Option<String> {
439    let proxy_ep = token
440        .split(';')
441        .find_map(|part| part.trim().strip_prefix("proxy-ep="))?
442        .trim();
443
444    normalize_copilot_proxy_endpoint(proxy_ep)
445}
446
447fn normalize_copilot_proxy_endpoint(proxy_ep: &str) -> Option<String> {
448    if proxy_ep.is_empty() {
449        return None;
450    }
451
452    let candidate = if proxy_ep.starts_with("http://") || proxy_ep.starts_with("https://") {
453        proxy_ep.to_string()
454    } else {
455        format!("https://{proxy_ep}")
456    };
457
458    let mut url = url::Url::parse(&candidate).ok()?;
459    if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
460        return None;
461    }
462    if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
463        return None;
464    }
465
466    let host = url.host_str()?.to_ascii_lowercase();
467    if !is_allowed_token_derived_copilot_host(&host) {
468        return None;
469    }
470
471    let api_host = host
472        .strip_prefix("proxy.")
473        .map(|suffix| format!("api.{suffix}"))
474        .unwrap_or(host);
475    url.set_host(Some(&api_host)).ok()?;
476
477    Some(url.to_string().trim_end_matches('/').to_string())
478}
479
480fn is_allowed_token_derived_copilot_host(host: &str) -> bool {
481    host == "githubcopilot.com" || host.ends_with(".githubcopilot.com")
482}
483
484fn post_with_auth_base<H>(
485    client: &Client<H>,
486    auth: &auth::AuthContext,
487    path: &str,
488    transport: Transport,
489) -> http_client::Result<http_client::Builder> {
490    let uri = client
491        .ext()
492        .build_uri(runtime_base_url(client, auth).as_ref(), path, transport);
493    let mut req = Request::post(uri);
494
495    if let Some(headers) = req.headers_mut() {
496        headers.extend(client.headers().iter().map(|(k, v)| (k.clone(), v.clone())));
497    }
498
499    client.ext().with_custom(req)
500}
501
502fn get_with_auth_base<H>(
503    client: &Client<H>,
504    auth: &auth::AuthContext,
505    path: &str,
506    transport: Transport,
507) -> http_client::Result<http_client::Builder> {
508    let uri = client
509        .ext()
510        .build_uri(runtime_base_url(client, auth).as_ref(), path, transport);
511    let mut req = Request::get(uri);
512
513    if let Some(headers) = req.headers_mut() {
514        headers.extend(client.headers().iter().map(|(k, v)| (k.clone(), v.clone())));
515    }
516
517    client.ext().with_custom(req)
518}
519
520fn request_initiator(request: &completion::CompletionRequest) -> &'static str {
521    for message in request.chat_history.iter() {
522        match message {
523            crate::completion::Message::Assistant { .. } => return "agent",
524            crate::completion::Message::User { content } => {
525                if content
526                    .iter()
527                    .any(|item| matches!(item, crate::message::UserContent::ToolResult(_)))
528                {
529                    return "agent";
530                }
531            }
532            crate::completion::Message::System { .. } => {}
533        }
534    }
535
536    "user"
537}
538
539fn request_has_vision(request: &completion::CompletionRequest) -> bool {
540    request.chat_history.iter().any(|message| match message {
541        crate::completion::Message::User { content } => content
542            .iter()
543            .any(|item| matches!(item, crate::message::UserContent::Image(_))),
544        _ => false,
545    })
546}
547
548/// Per-request inputs shared by every Copilot route, read off the incoming
549/// request before a route-specific conversion consumes it.
550struct RequestFacts {
551    initiator: &'static str,
552    has_vision: bool,
553    system_instructions: Option<String>,
554    record_telemetry_content: bool,
555}
556
557impl RequestFacts {
558    fn capture(request: &completion::CompletionRequest) -> Self {
559        Self {
560            initiator: request_initiator(request),
561            has_vision: request_has_vision(request),
562            system_instructions: request.preamble.clone(),
563            record_telemetry_content: request.record_telemetry_content,
564        }
565    }
566}
567
568#[derive(Clone, Copy, Debug, PartialEq, Eq)]
569enum CompletionRoute {
570    ChatCompletions,
571    Responses,
572}
573
574fn route_for_model(model: &str) -> CompletionRoute {
575    if model.to_ascii_lowercase().contains("codex") {
576        CompletionRoute::Responses
577    } else {
578        CompletionRoute::ChatCompletions
579    }
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
583#[serde(tag = "api", rename_all = "snake_case")]
584pub enum CopilotCompletionResponse {
585    Chat(Box<openai::completion::CompletionResponse>),
586    Responses(Box<responses_api::CompletionResponse>),
587}
588
589/// The forward direction for the route-tagged raw type, so
590/// [`CompletionModel::raw_completion`] followed by `normalize` is a complete
591/// typed route regardless of which route answered — each variant delegates to
592/// its wire type's own conversion. This is also what
593/// [`completion::CompletionModel::completion`] uses, so the two cannot drift.
594impl NormalizeCompletionResponse for CopilotCompletionResponse {
595    fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
596        match self {
597            Self::Chat(response) => response.normalize(provider),
598            Self::Responses(response) => response.normalize(provider),
599        }
600    }
601}
602
603#[derive(Clone, Serialize, Deserialize)]
604#[serde(tag = "api", rename_all = "snake_case")]
605pub enum CopilotStreamingResponse {
606    Chat(openai::completion::streaming::StreamingCompletionResponse),
607    Responses(responses_api::streaming::StreamingCompletionResponse),
608}
609
610impl From<&CopilotStreamingResponse> for completion::Usage {
611    fn from(response: &CopilotStreamingResponse) -> Self {
612        match response {
613            CopilotStreamingResponse::Chat(response) => (&response.usage).into(),
614            CopilotStreamingResponse::Responses(response) => (&response.usage).into(),
615        }
616    }
617}
618
619impl From<(&str, CopilotStreamingResponse)> for crate::streaming::StreamFinal {
620    fn from((provider, response): (&str, CopilotStreamingResponse)) -> Self {
621        // Both Copilot routes reuse an upstream terminal record, so each maps
622        // through that route's own conversion rather than re-deriving it here.
623        match response {
624            CopilotStreamingResponse::Chat(response) => (provider, response).into(),
625            CopilotStreamingResponse::Responses(response) => (provider, response).into(),
626        }
627    }
628}
629
630/// Stable descriptor name reported on normalized Copilot responses.
631pub const PROVIDER_NAME: &str = "copilot";
632
633#[derive(Debug, Deserialize)]
634pub struct ChatApiErrorResponse {
635    #[serde(default)]
636    pub message: Option<String>,
637    #[serde(default)]
638    pub error: Option<String>,
639}
640
641impl ChatApiErrorResponse {
642    pub fn error_message(&self) -> &str {
643        self.message
644            .as_deref()
645            .or(self.error.as_deref())
646            .unwrap_or("unknown error")
647    }
648}
649
650#[derive(Debug, Deserialize)]
651#[serde(untagged)]
652enum ChatApiResponse<T> {
653    Ok(T),
654    Err(ChatApiErrorResponse),
655}
656
657impl<T> crate::providers::internal::envelope::ProviderEnvelope for ChatApiResponse<T> {
658    type Payload = T;
659
660    fn into_payload(self) -> Result<T, String> {
661        match self {
662            Self::Ok(payload) => Ok(payload),
663            Self::Err(error) => Err(error.error_message().to_owned()),
664        }
665    }
666}
667
668#[derive(Clone)]
669pub struct CompletionModel<H = reqwest::Client> {
670    client: Client<H>,
671    pub model: String,
672    pub strict_tools: bool,
673    pub tool_result_array_content: bool,
674    pub intent: CopilotIntent,
675}
676
677impl<H> CompletionModel<H>
678where
679    Client<H>: HttpClientExt + Clone + Debug + 'static,
680    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
681{
682    pub fn new(client: Client<H>, model: impl Into<String>) -> Self {
683        Self {
684            client,
685            model: model.into(),
686            strict_tools: false,
687            tool_result_array_content: false,
688            intent: CopilotIntent::default(),
689        }
690    }
691
692    pub fn with_strict_tools(mut self) -> Self {
693        self.strict_tools = true;
694        self
695    }
696
697    pub fn with_tool_result_array_content(mut self) -> Self {
698        self.tool_result_array_content = true;
699        self
700    }
701
702    /// Set the Copilot `openai-intent` header for completion and streaming requests.
703    pub fn with_intent(mut self, intent: CopilotIntent) -> Self {
704        self.intent = intent;
705        self
706    }
707
708    /// Use the generic chat panel `openai-intent` header for completion and streaming requests.
709    pub fn with_panel_intent(self) -> Self {
710        self.with_intent(CopilotIntent::Panel)
711    }
712
713    /// Use the edit-oriented `openai-intent` header for completion and streaming requests.
714    pub fn with_edits_intent(self) -> Self {
715        self.with_intent(CopilotIntent::Edits)
716    }
717
718    fn route(&self) -> CompletionRoute {
719        route_for_model(&self.model)
720    }
721
722    async fn auth_context(&self) -> Result<auth::AuthContext, CompletionError> {
723        self.client
724            .ext()
725            .auth
726            .auth_context()
727            .await
728            .map_err(|err| CompletionError::ProviderError(err.to_string()))
729    }
730
731    fn chat_request(
732        &self,
733        completion_request: completion::CompletionRequest,
734    ) -> Result<openai::completion::CompletionRequest, CompletionError> {
735        openai::completion::CompletionRequest::try_from(openai::completion::OpenAIRequestParams {
736            model: self.model.clone(),
737            request: completion_request,
738            strict_tools: self.strict_tools,
739            tool_result_array_content: self.tool_result_array_content,
740            supports_response_format: true,
741            supports_tools: true,
742        })
743    }
744
745    fn responses_request(
746        &self,
747        completion_request: completion::CompletionRequest,
748    ) -> Result<ResponsesRequest, CompletionError> {
749        let mut request = ResponsesRequest::try_from(responses_api::ResponsesRequestParams {
750            model: self.model.clone(),
751            request: completion_request,
752            system_instructions_placement:
753                responses_api::SystemInstructionsPlacement::InputSystemMessages,
754        })?;
755        // Copilot's Responses endpoint expects strict function tool schemas for
756        // reliable tool calls. Preserve that provider-specific behavior while
757        // keeping Chat Completions strict mode opt-in.
758        request.tools = request
759            .tools
760            .into_iter()
761            .map(responses_api::ResponsesToolDefinition::with_strict)
762            .collect();
763        Ok(request)
764    }
765
766    /// Authenticates, signs a POST to `path`, and opens the route's completion
767    /// span.
768    ///
769    /// Call this only *after* the route's request conversion: auth happens
770    /// inside, so calling it earlier would report an auth failure ahead of a
771    /// malformed request and invert the routes' error precedence.
772    async fn signed_request(
773        &self,
774        facts: &RequestFacts,
775        path: &str,
776        transport: Transport,
777        model: &str,
778        operation: CompletionOperation,
779        body: Vec<u8>,
780    ) -> Result<(Request<Vec<u8>>, tracing::Span), CompletionError> {
781        let auth = self.auth_context().await?;
782
783        let headers = default_headers(
784            &auth.api_key,
785            facts.initiator,
786            facts.has_vision,
787            self.intent,
788        );
789        let req = apply_headers(
790            post_with_auth_base(&self.client, &auth, path, transport)?,
791            &headers,
792        )
793        .body(body)
794        .map_err(|err| CompletionError::HttpError(err.into()))?;
795
796        let span = CompletionSpanBuilder::new("copilot", model, operation)
797            .system_instructions(
798                facts.system_instructions.as_deref(),
799                facts.record_telemetry_content,
800            )
801            .build();
802
803        Ok((req, span))
804    }
805
806    /// The chat wire type has no transport-metadata slot, so the captured
807    /// request id rides alongside; `completion()` stamps it onto the
808    /// normalized response.
809    async fn raw_completion_chat(
810        &self,
811        completion_request: completion::CompletionRequest,
812    ) -> Result<(openai::completion::CompletionResponse, Option<String>), CompletionError> {
813        let facts = RequestFacts::capture(&completion_request);
814        let request = self.chat_request(completion_request)?;
815        let (req, span) = self
816            .signed_request(
817                &facts,
818                "/chat/completions",
819                Transport::Http,
820                &request.model,
821                CompletionOperation::Chat,
822                serde_json::to_vec(&request)?,
823            )
824            .await?;
825
826        send_completion::<_, ChatApiResponse<openai::completion::CompletionResponse>, _>(
827            &self.client,
828            req,
829            "Copilot chat completion",
830            // The OpenAI-compatible default; a gateway that omits the header
831            // yields None. Matches the streaming path, which goes through the
832            // shared OpenAI wrapper and captures the same header.
833            Some("x-request-id"),
834            |response| {
835                let span = tracing::Span::current();
836                span.record_response_metadata(response);
837                let usage = response
838                    .usage
839                    .as_ref()
840                    .map(|usage| usage.to_normalized())
841                    .unwrap_or_default();
842                span.record_token_usage(&usage);
843            },
844        )
845        .instrument(span)
846        .await
847    }
848
849    async fn raw_completion_responses(
850        &self,
851        completion_request: completion::CompletionRequest,
852    ) -> Result<responses_api::CompletionResponse, CompletionError> {
853        let facts = RequestFacts::capture(&completion_request);
854        let request = self.responses_request(completion_request)?;
855        let (req, span) = self
856            .signed_request(
857                &facts,
858                "/responses",
859                Transport::Http,
860                &request.model,
861                CompletionOperation::Chat,
862                serde_json::to_vec(&request)?,
863            )
864            .await?;
865
866        send_completion::<_, DirectPayload<responses_api::CompletionResponse>, _>(
867            &self.client,
868            req,
869            "Copilot responses completion",
870            // See the chat path: the OpenAI-compatible default header.
871            Some("x-request-id"),
872            |response| {
873                let span = tracing::Span::current();
874                span.record("gen_ai.response.id", response.id.as_str());
875                span.record("gen_ai.response.model", response.model.as_str());
876                if let Some(usage) = &response.usage {
877                    span.record_token_usage(&usage.into());
878                }
879            },
880        )
881        .instrument(span)
882        .await
883        .map(|(mut payload, provider_request_id)| {
884            payload.provider_request_id = provider_request_id;
885            payload
886        })
887    }
888
889    async fn raw_stream_chat(
890        &self,
891        completion_request: completion::CompletionRequest,
892    ) -> Result<crate::streaming::RawStreamingResult<CopilotStreamingResponse>, CompletionError>
893    {
894        let facts = RequestFacts::capture(&completion_request);
895        let request = self.chat_request(completion_request)?;
896        let mut request_json = serde_json::to_value(&request)?;
897        let request_object = request_json.as_object_mut().ok_or_else(|| {
898            CompletionError::ResponseError("copilot request body must be a JSON object".into())
899        })?;
900        request_object.insert("stream".to_owned(), json!(true));
901        request_object.insert(
902            "stream_options".to_owned(),
903            json!({ "include_usage": true }),
904        );
905
906        let (req, span) = self
907            .signed_request(
908                &facts,
909                "/chat/completions",
910                Transport::Sse,
911                &request.model,
912                CompletionOperation::ChatStreaming,
913                serde_json::to_vec(&request_json)?,
914            )
915            .await?;
916
917        tracing::Instrument::instrument(
918            send_copilot_chat_raw_streaming_request(self.client.clone(), req),
919            span,
920        )
921        .await
922    }
923
924    async fn raw_stream_responses(
925        &self,
926        completion_request: completion::CompletionRequest,
927    ) -> Result<crate::streaming::RawStreamingResult<CopilotStreamingResponse>, CompletionError>
928    {
929        let facts = RequestFacts::capture(&completion_request);
930        let mut request = self.responses_request(completion_request)?;
931        request.stream = Some(true);
932        let (req, span) = self
933            .signed_request(
934                &facts,
935                "/responses",
936                Transport::Sse,
937                &request.model,
938                CompletionOperation::ChatStreaming,
939                serde_json::to_vec(&request)?,
940            )
941            .await?;
942
943        let client = self.client.clone();
944        // The OpenAI-compatible default header, matching the chat route.
945        let (event_source, request_id_slot) =
946            crate::http_client::sse::GenericEventSource::new(client, req)
947                .capture_request_id("x-request-id");
948
949        // Copilot's `/responses` route relays OpenAI's Responses SSE wire
950        // verbatim, so the shared classify + `RawChoiceAccumulator` machinery
951        // is the event interpreter — only the auth/transport above and the
952        // route-carrying terminal wrapper below are Copilot-specific.
953        let raw = responses_api::streaming::raw_stream_from_event_source(event_source, span);
954        let raw = crate::providers::internal::sse_transport::stamp_terminal_request_id(
955            raw,
956            Some(request_id_slot),
957            Some("x-request-id"),
958            |response, id| response.provider_request_id = Some(id),
959        );
960        let stream = raw.map(|item| {
961            item.and_then(|choice| {
962                choice.try_map_final(|response| Ok(CopilotStreamingResponse::Responses(response)))
963            })
964        });
965
966        Ok(Box::pin(stream))
967    }
968
969    /// Execute a completion on whichever route this model is configured for and
970    /// return Copilot's own wire response.
971    ///
972    /// This is the escape hatch for fields rig does not normalize;
973    /// [`completion::CompletionModel::completion`] shares the same request,
974    /// transport, telemetry and error path.
975    ///
976    /// On the chat route the transport request id (`x-request-id`) is not on
977    /// the wire type and is dropped here; use
978    /// [`Self::raw_completion_with_request_id`] when the typed route must
979    /// reproduce everything `completion` returns.
980    pub async fn raw_completion(
981        &self,
982        completion_request: completion::CompletionRequest,
983    ) -> Result<CopilotCompletionResponse, CompletionError> {
984        self.raw_completion_with_request_id(completion_request)
985            .await
986            .map(|(response, _)| response)
987    }
988
989    /// [`Self::raw_completion`] plus the transport request id from the
990    /// `x-request-id` response header.
991    ///
992    /// The pair exists because the chat route's wire type
993    /// ([`openai::completion::CompletionResponse`]) has no slot for a
994    /// transport id — it is the shared OpenAI-compatible shape — while the
995    /// normalized [`completion::CompletionResponse`] carries one. Without this
996    /// method, `raw_completion(..)` followed by
997    /// [`NormalizeCompletionResponse::normalize`] would silently lack the
998    /// `provider_request_id` that [`completion::CompletionModel::completion`]
999    /// reports. Reassemble with
1000    /// [`with_optional_provider_request_id`](completion::CompletionResponse::with_optional_provider_request_id).
1001    /// On the responses route the wire type carries the id itself; the pair's
1002    /// second element is that same value, so reassembly is a no-op there.
1003    pub async fn raw_completion_with_request_id(
1004        &self,
1005        completion_request: completion::CompletionRequest,
1006    ) -> Result<(CopilotCompletionResponse, Option<String>), CompletionError> {
1007        match self.route() {
1008            CompletionRoute::ChatCompletions => self
1009                .raw_completion_chat(completion_request)
1010                .await
1011                .map(|(response, id)| (CopilotCompletionResponse::Chat(Box::new(response)), id)),
1012            CompletionRoute::Responses => self
1013                .raw_completion_responses(completion_request)
1014                .await
1015                .map(|response| {
1016                    let id = response.provider_request_id.clone();
1017                    (CopilotCompletionResponse::Responses(Box::new(response)), id)
1018                }),
1019        }
1020    }
1021
1022    /// Open a stream on whichever route this model is configured for, keeping
1023    /// the terminal record provider-native.
1024    pub async fn raw_stream(
1025        &self,
1026        completion_request: completion::CompletionRequest,
1027    ) -> Result<crate::streaming::RawStreamingResult<CopilotStreamingResponse>, CompletionError>
1028    {
1029        match self.route() {
1030            CompletionRoute::ChatCompletions => self.raw_stream_chat(completion_request).await,
1031            CompletionRoute::Responses => self.raw_stream_responses(completion_request).await,
1032        }
1033    }
1034
1035    /// Open a stream normalized to rig's terminal record. Delegates to
1036    /// [`CompletionModel::raw_stream`] — one request either way.
1037    async fn stream_normalized(
1038        &self,
1039        completion_request: completion::CompletionRequest,
1040    ) -> Result<StreamingCompletionResponse, CompletionError> {
1041        let raw = self.raw_stream(completion_request).await?;
1042
1043        Ok(StreamingCompletionResponse::stream(
1044            PROVIDER_NAME,
1045            crate::streaming::normalize_stream(
1046                raw,
1047                |response| Ok((PROVIDER_NAME, response).into()),
1048            ),
1049        ))
1050    }
1051}
1052
1053impl<H> crate::client::ConstructCompletionModel<Client<H>> for CompletionModel<H>
1054where
1055    Client<H>: HttpClientExt + Clone + Debug + 'static,
1056    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1057{
1058    fn construct(client: &Client<H>, model: String) -> Self {
1059        Self::new(client.clone(), model)
1060    }
1061}
1062
1063impl<H> completion::CompletionModel for CompletionModel<H>
1064where
1065    Client<H>: HttpClientExt + Clone + Debug + 'static,
1066    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1067{
1068    async fn completion(
1069        &self,
1070        completion_request: completion::CompletionRequest,
1071    ) -> Result<completion::CompletionResponse, CompletionError> {
1072        // The captured value is the route-tagged `CopilotCompletionResponse` —
1073        // what `raw_completion` returns — not the inner route type, so it
1074        // round-trips into the same type the typed escape hatch yields.
1075        let (response, provider_request_id) = self
1076            .raw_completion_with_request_id(completion_request)
1077            .await?;
1078        let captured = serde_json::to_value(&response)?;
1079        Ok(response
1080            .normalize(PROVIDER_NAME)?
1081            .with_optional_provider_request_id(provider_request_id)
1082            .with_raw(captured))
1083    }
1084
1085    async fn stream(
1086        &self,
1087        completion_request: completion::CompletionRequest,
1088    ) -> Result<StreamingCompletionResponse, CompletionError> {
1089        self.stream_normalized(completion_request).await
1090    }
1091}
1092
1093#[derive(Clone)]
1094pub struct EmbeddingModel<H = reqwest::Client> {
1095    client: Client<H>,
1096    pub model: String,
1097    pub encoding_format: Option<openai::EncodingFormat>,
1098    pub user: Option<String>,
1099    ndims: usize,
1100}
1101
1102#[derive(Deserialize)]
1103struct CopilotEmbeddingResponse {
1104    data: Vec<CopilotEmbeddingData>,
1105    // Copilot fronts several vendors, so usage is not guaranteed on the wire.
1106    #[serde(default)]
1107    usage: Option<openai::completion::Usage>,
1108}
1109
1110#[derive(Deserialize)]
1111struct CopilotEmbeddingData {
1112    embedding: Vec<serde_json::Number>,
1113}
1114
1115impl<H> EmbeddingModel<H>
1116where
1117    Client<H>: HttpClientExt + Clone + Debug + 'static,
1118    H: Clone + Default + Debug + 'static,
1119{
1120    pub fn new(client: Client<H>, model: impl Into<String>, ndims: usize) -> Self {
1121        Self {
1122            client,
1123            model: model.into(),
1124            encoding_format: None,
1125            user: None,
1126            ndims,
1127        }
1128    }
1129}
1130
1131impl<H> embeddings::EmbeddingModel for EmbeddingModel<H>
1132where
1133    Client<H>: HttpClientExt + Clone + Debug + WasmCompatSend + WasmCompatSync + 'static,
1134    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1135{
1136    const MAX_DOCUMENTS: usize = 1024;
1137    type Client = Client<H>;
1138
1139    fn make(client: &Self::Client, model: impl Into<String>, ndims: Option<usize>) -> Self {
1140        let model = model.into();
1141        let dims = ndims.unwrap_or(match model.as_str() {
1142            TEXT_EMBEDDING_3_LARGE => 3072,
1143            TEXT_EMBEDDING_3_SMALL | TEXT_EMBEDDING_ADA_002 => 1536,
1144            _ => 0,
1145        });
1146        Self::new(client.clone(), model, dims)
1147    }
1148
1149    fn ndims(&self) -> usize {
1150        self.ndims
1151    }
1152
1153    async fn embed_texts(
1154        &self,
1155        documents: impl IntoIterator<Item = String>,
1156    ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
1157        let documents = documents.into_iter().collect::<Vec<_>>();
1158        let response = self.embed_texts_with_usage(documents).await?;
1159        Ok(response.embeddings)
1160    }
1161
1162    async fn embed_texts_with_usage(
1163        &self,
1164        documents: impl IntoIterator<Item = String>,
1165    ) -> Result<embeddings::EmbeddingResponse, EmbeddingError> {
1166        let documents = documents.into_iter().collect::<Vec<_>>();
1167        let auth = self
1168            .client
1169            .ext()
1170            .auth
1171            .auth_context()
1172            .await
1173            .map_err(|err| EmbeddingError::ProviderError(err.to_string()))?;
1174
1175        let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1176        let mut body = json!({
1177            "model": self.model,
1178            "input": documents,
1179        });
1180
1181        let body_object = body.as_object_mut().ok_or_else(|| {
1182            EmbeddingError::ResponseError("embedding request body must be a JSON object".into())
1183        })?;
1184
1185        if self.ndims > 0 && self.model.as_str() != TEXT_EMBEDDING_ADA_002 {
1186            body_object.insert("dimensions".to_owned(), json!(self.ndims));
1187        }
1188        if let Some(encoding_format) = &self.encoding_format {
1189            body_object.insert("encoding_format".to_owned(), json!(encoding_format));
1190        }
1191        if let Some(user) = &self.user {
1192            body_object.insert("user".to_owned(), json!(user));
1193        }
1194
1195        let req = apply_headers(
1196            post_with_auth_base(&self.client, &auth, "/embeddings", Transport::Http)?,
1197            &headers,
1198        )
1199        .body(serde_json::to_vec(&body)?)
1200        .map_err(|err| EmbeddingError::HttpError(err.into()))?;
1201
1202        let response = self.client.send(req).await?;
1203        let status = response.status();
1204        if status.is_success() {
1205            let body: Vec<u8> = response.into_body().await?;
1206            #[derive(Deserialize)]
1207            struct NestedApiError {
1208                error: NestedApiErrorMessage,
1209            }
1210
1211            #[derive(Deserialize)]
1212            struct NestedApiErrorMessage {
1213                message: String,
1214            }
1215
1216            let body: CopilotEmbeddingResponse = match serde_json::from_slice(&body) {
1217                Ok(parsed) => parsed,
1218                Err(parse_error) => {
1219                    if let Ok(err) = serde_json::from_slice::<NestedApiError>(&body) {
1220                        tracing::warn!(message = %err.error.message, "provider returned an error response");
1221                        return Err(EmbeddingError::from_http_response(
1222                            status,
1223                            String::from_utf8_lossy(&body).into_owned(),
1224                        ));
1225                    }
1226
1227                    let preview = String::from_utf8_lossy(&body);
1228                    let preview = if preview.len() > 512 {
1229                        format!("{}...", &preview[..512])
1230                    } else {
1231                        preview.into_owned()
1232                    };
1233
1234                    return Err(EmbeddingError::ProviderError(format!(
1235                        "Failed to parse Copilot embeddings response: {parse_error}; body: {preview}"
1236                    )));
1237                }
1238            };
1239
1240            // Embeddings consume only prompt tokens, so a missing usage
1241            // payload normalizes to the documented zero-usage sentinel.
1242            let usage = body
1243                .usage
1244                .as_ref()
1245                .map(|usage| usage.to_normalized())
1246                .unwrap_or_default();
1247
1248            let embeddings = body
1249                .data
1250                .into_iter()
1251                .zip(documents.into_iter())
1252                .map(|(embedding, document)| embeddings::Embedding {
1253                    document,
1254                    vec: embedding
1255                        .embedding
1256                        .into_iter()
1257                        .filter_map(|n| n.as_f64())
1258                        .collect(),
1259                })
1260                .collect();
1261
1262            Ok(embeddings::EmbeddingResponse { embeddings, usage })
1263        } else {
1264            let text = http_client::text(response).await?;
1265            Err(EmbeddingError::from_http_response(status, text))
1266        }
1267    }
1268}
1269
1270const MODEL_LISTING_PATH: &str = "/models";
1271const MODEL_LISTING_PROVIDER: &str = "Copilot";
1272
1273#[derive(Debug, Deserialize)]
1274struct ListModelsResponse {
1275    data: Vec<ListModelEntry>,
1276}
1277
1278#[derive(Debug, Deserialize)]
1279struct ListModelEntry {
1280    id: String,
1281    #[serde(default)]
1282    name: Option<String>,
1283    #[serde(default)]
1284    vendor: Option<String>,
1285    #[serde(default)]
1286    capabilities: Option<ListModelEntryCapabilities>,
1287}
1288
1289#[derive(Debug, Deserialize)]
1290struct ListModelEntryCapabilities {
1291    #[serde(default, rename = "type")]
1292    r#type: Option<String>,
1293}
1294
1295impl From<ListModelEntry> for Model {
1296    fn from(value: ListModelEntry) -> Self {
1297        let mut model = Model::from_id(value.id);
1298        model.name = value.name;
1299        model.owned_by = value.vendor;
1300        if let Some(caps) = value.capabilities {
1301            model.r#type = caps.r#type;
1302        }
1303        model
1304    }
1305}
1306
1307/// [`ModelLister`] implementation for the GitHub Copilot API (`GET /models`).
1308#[derive(Clone)]
1309pub struct CopilotModelLister<H = reqwest::Client> {
1310    client: Client<H>,
1311}
1312
1313impl<H> ModelLister<H> for CopilotModelLister<H>
1314where
1315    H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
1316{
1317    type Client = Client<H>;
1318
1319    fn new(client: Self::Client) -> Self {
1320        Self { client }
1321    }
1322
1323    async fn list_all(&self) -> Result<ModelList, ModelListingError> {
1324        let auth = self.client.ext().auth.auth_context().await.map_err(|err| {
1325            ModelListingError::AuthError {
1326                message: err.to_string(),
1327            }
1328        })?;
1329
1330        let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1331        let req = apply_headers(
1332            get_with_auth_base(&self.client, &auth, MODEL_LISTING_PATH, Transport::Http)?,
1333            &headers,
1334        )
1335        .body(http_client::NoBody)?;
1336
1337        let response = self.client.send::<_, Vec<u8>>(req).await.map_err(|error| {
1338            crate::providers::internal::model_listing::map_transport_error(
1339                MODEL_LISTING_PROVIDER,
1340                MODEL_LISTING_PATH,
1341                error,
1342            )
1343        })?;
1344
1345        let api_resp: ListModelsResponse =
1346            crate::providers::internal::model_listing::decode_json_response(
1347                response,
1348                MODEL_LISTING_PROVIDER,
1349                MODEL_LISTING_PATH,
1350            )
1351            .await?;
1352        let models = api_resp.data.into_iter().map(Model::from).collect();
1353
1354        Ok(ModelList::new(models))
1355    }
1356}
1357
1358async fn send_copilot_chat_raw_streaming_request<T>(
1359    http_client: T,
1360    req: Request<Vec<u8>>,
1361) -> Result<crate::streaming::RawStreamingResult<CopilotStreamingResponse>, CompletionError>
1362where
1363    T: HttpClientExt + Clone + 'static,
1364{
1365    // Copilot's `/chat/completions` route relays OpenAI's chat-completions
1366    // SSE wire verbatim, so OpenAI's shared streaming profile (tolerant
1367    // deserializers, reasoning handling, finish-reason mapping) is the event
1368    // interpreter — only the auth/transport in the caller and the
1369    // route-carrying terminal wrapper below are Copilot-specific.
1370    let raw =
1371        openai::completion::streaming::send_compatible_raw_streaming_request(http_client, req)
1372            .await?;
1373    let stream = raw.map(|item| {
1374        item.and_then(|choice| {
1375            choice.try_map_final(|response| Ok(CopilotStreamingResponse::Chat(response)))
1376        })
1377    });
1378
1379    Ok(Box::pin(stream))
1380}
1381
1382fn default_token_dir() -> Option<PathBuf> {
1383    config_dir().map(|dir| dir.join("github_copilot"))
1384}
1385
1386use crate::providers::internal::auth::config_dir;
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::{
1391        ChatApiErrorResponse, Client, CompletionRoute, CopilotIntent, TEXT_EMBEDDING_3_SMALL,
1392        base_url_from_token, default_headers, env_api_key, env_base_url, env_github_access_token,
1393        route_for_model,
1394    };
1395    use crate::client::CompletionClient;
1396    use crate::completion::CompletionModel;
1397    use crate::http_client;
1398    use crate::providers::internal::openai_chat_completions_compatible::test_support::{
1399        sse_bytes_from_data_lines, sse_bytes_from_json_events,
1400    };
1401    use crate::providers::openai;
1402    use crate::streaming::StreamedAssistantContent;
1403    use crate::test_utils::MockStreamingClient;
1404    use crate::test_utils::{RecordingHttpClient, SequencedStreamingHttpClient};
1405    use futures::StreamExt;
1406    use std::collections::HashMap;
1407
1408    fn env_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
1409        entries
1410            .iter()
1411            .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
1412            .collect()
1413    }
1414
1415    fn minimal_chat_response() -> &'static str {
1416        r#"{
1417            "id": "chatcmpl-123",
1418            "model": "gpt-4o",
1419            "choices": [{
1420                "index": 0,
1421                "message": {
1422                    "role": "assistant",
1423                    "content": "hello"
1424                },
1425                "finish_reason": "stop"
1426            }],
1427            "usage": {
1428                "prompt_tokens": 4,
1429                "total_tokens": 7
1430            }
1431        }"#
1432    }
1433
1434    fn minimal_responses_response() -> &'static str {
1435        r#"{
1436            "id": "resp_123",
1437            "object": "response",
1438            "created_at": 1700000000,
1439            "status": "completed",
1440            "error": null,
1441            "incomplete_details": null,
1442            "instructions": null,
1443            "max_output_tokens": null,
1444            "model": "gpt-5.3-codex",
1445            "usage": {
1446                "input_tokens": 4,
1447                "input_tokens_details": {
1448                    "cached_tokens": 0
1449                },
1450                "output_tokens": 3,
1451                "output_tokens_details": {
1452                    "reasoning_tokens": 0
1453                },
1454                "total_tokens": 7
1455            },
1456            "output": [{
1457                "type": "message",
1458                "id": "msg_123",
1459                "role": "assistant",
1460                "status": "completed",
1461                "content": [{
1462                    "type": "output_text",
1463                    "text": "hello"
1464                }]
1465            }],
1466            "tools": []
1467        }"#
1468    }
1469
1470    fn minimal_embeddings_response() -> &'static str {
1471        r#"{
1472            "data": [
1473                {
1474                    "embedding": [0.1, 0.2, 0.3]
1475                },
1476                {
1477                    "embedding": [0.4, 0.5, 0.6]
1478                }
1479            ]
1480        }"#
1481    }
1482
1483    #[test]
1484    fn deserialize_standard_openai_response() {
1485        let json = r#"{
1486            "id": "chatcmpl-abc123",
1487            "object": "chat.completion",
1488            "created": 1700000000,
1489            "model": "gpt-4o",
1490            "choices": [{
1491                "index": 0,
1492                "message": {
1493                    "role": "assistant",
1494                    "content": "Hello!"
1495                },
1496                "finish_reason": "stop"
1497            }],
1498            "usage": {
1499                "prompt_tokens": 10,
1500                "completion_tokens": 5,
1501                "total_tokens": 15
1502            }
1503        }"#;
1504
1505        let response: openai::completion::CompletionResponse =
1506            serde_json::from_str(json).expect("standard OpenAI response should deserialize");
1507        assert_eq!(response.id, "chatcmpl-abc123");
1508        assert_eq!(response.object, "chat.completion");
1509        assert_eq!(response.created, 1700000000);
1510        assert_eq!(response.model, "gpt-4o");
1511        assert_eq!(response.choices.len(), 1);
1512        assert_eq!(response.choices[0].finish_reason, "stop");
1513    }
1514
1515    #[test]
1516    fn deserialize_copilot_response_without_object_and_created() {
1517        let response: openai::completion::CompletionResponse =
1518            serde_json::from_str(minimal_chat_response())
1519                .expect("Copilot response should deserialize");
1520
1521        assert_eq!(response.id, "chatcmpl-123");
1522        assert_eq!(response.object, "");
1523        assert_eq!(response.created, 0);
1524        assert_eq!(response.model, "gpt-4o");
1525        assert_eq!(response.choices.len(), 1);
1526    }
1527
1528    #[test]
1529    fn deserialize_copilot_response_without_finish_reason() {
1530        let json = r#"{
1531            "id": "chatcmpl-claude-001",
1532            "model": "claude-3.5-sonnet",
1533            "choices": [{
1534                "message": {
1535                    "role": "assistant",
1536                    "content": "Here is my analysis."
1537                }
1538            }],
1539            "usage": {
1540                "prompt_tokens": 50,
1541                "total_tokens": 80
1542            }
1543        }"#;
1544
1545        let response: openai::completion::CompletionResponse =
1546            serde_json::from_str(json).expect("Claude-via-Copilot response should deserialize");
1547
1548        assert_eq!(response.model, "claude-3.5-sonnet");
1549        assert_eq!(response.choices[0].finish_reason, "");
1550        assert_eq!(response.choices[0].index, 0);
1551    }
1552
1553    #[test]
1554    fn error_response_with_message_field() {
1555        let json = r#"{"message": "rate limit exceeded"}"#;
1556        let err: ChatApiErrorResponse = serde_json::from_str(json).expect("message-shaped error");
1557
1558        assert_eq!(err.error_message(), "rate limit exceeded");
1559    }
1560
1561    #[test]
1562    fn error_response_with_error_field() {
1563        let json = r#"{"error": "model not found"}"#;
1564        let err: ChatApiErrorResponse = serde_json::from_str(json).expect("error-shaped error");
1565
1566        assert_eq!(err.error_message(), "model not found");
1567    }
1568
1569    #[test]
1570    fn routes_codex_models_to_responses() {
1571        assert_eq!(route_for_model("gpt-5.3-codex"), CompletionRoute::Responses);
1572        assert_eq!(
1573            route_for_model("gpt-5.1-CODEX-mini"),
1574            CompletionRoute::Responses
1575        );
1576        assert_eq!(route_for_model("gpt-5.2"), CompletionRoute::ChatCompletions);
1577        assert_eq!(
1578            route_for_model("claude-sonnet-4.5"),
1579            CompletionRoute::ChatCompletions
1580        );
1581    }
1582
1583    #[test]
1584    fn copilot_intent_headers_use_panel_by_default_and_edits_when_requested() {
1585        let panel_headers = default_headers("token", "user", false, CopilotIntent::default());
1586        assert_eq!(
1587            panel_headers
1588                .iter()
1589                .find(|(name, _)| *name == "openai-intent")
1590                .map(|(_, value)| value.as_str()),
1591            Some("conversation-panel")
1592        );
1593
1594        let edits_headers = default_headers("token", "user", false, CopilotIntent::Edits);
1595        assert_eq!(
1596            edits_headers
1597                .iter()
1598                .find(|(name, _)| *name == "openai-intent")
1599                .map(|(_, value)| value.as_str()),
1600            Some("conversation-edits")
1601        );
1602    }
1603
1604    #[test]
1605    fn copilot_completion_model_intent_builders_update_intent() {
1606        let client = Client::builder()
1607            .api_key("copilot-token")
1608            .build()
1609            .expect("build client");
1610
1611        let default_model = client.completion_model("gpt-4o");
1612        assert_eq!(default_model.intent.as_header(), "conversation-panel");
1613
1614        let edits_model = client
1615            .completion_model("gpt-4o")
1616            .with_intent(CopilotIntent::Edits);
1617        assert_eq!(edits_model.intent.as_header(), "conversation-edits");
1618
1619        let panel_model = client
1620            .completion_model("gpt-4o")
1621            .with_edits_intent()
1622            .with_panel_intent();
1623        assert_eq!(panel_model.intent.as_header(), "conversation-panel");
1624    }
1625
1626    #[test]
1627    fn base_url_from_token_derives_api_endpoint() {
1628        assert_eq!(
1629            base_url_from_token("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1630                .as_deref(),
1631            Some("https://api.individual.githubcopilot.com")
1632        );
1633        assert_eq!(
1634            base_url_from_token("tid=1;proxy-ep=https://proxy.individual.githubcopilot.com;exp=2")
1635                .as_deref(),
1636            Some("https://api.individual.githubcopilot.com")
1637        );
1638        assert_eq!(base_url_from_token("tid=1;exp=2"), None);
1639    }
1640
1641    #[test]
1642    fn base_url_from_token_rejects_unsafe_or_non_copilot_endpoints() {
1643        assert_eq!(
1644            base_url_from_token("tid=1;proxy-ep=http://proxy.individual.githubcopilot.com;exp=2"),
1645            None
1646        );
1647        assert_eq!(
1648            base_url_from_token("tid=1;proxy-ep=https://evil.example.com;exp=2"),
1649            None
1650        );
1651        assert_eq!(base_url_from_token("tid=1;proxy-ep=://bad;exp=2"), None);
1652        assert_eq!(base_url_from_token("tid=1;proxy-ep=;exp=2"), None);
1653        assert_eq!(
1654            base_url_from_token(
1655                "tid=1;proxy-ep=https://proxy.individual.githubcopilot.com/base;exp=2"
1656            ),
1657            None
1658        );
1659    }
1660
1661    #[tokio::test]
1662    async fn api_key_with_proxy_endpoint_overrides_base_url() {
1663        let http_client = RecordingHttpClient::new(minimal_chat_response());
1664        let client = Client::builder()
1665            .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1666            .http_client(http_client.clone())
1667            .build()
1668            .expect("build client");
1669        let model = client.completion_model("gpt-4o");
1670        let request = model.completion_request("hello").build();
1671
1672        let _response = model.completion(request).await.expect("chat completion");
1673
1674        let requests = http_client.requests();
1675        assert_eq!(requests.len(), 1);
1676        assert!(
1677            requests[0]
1678                .uri
1679                .starts_with("https://api.individual.githubcopilot.com"),
1680            "expected proxy-derived base URL, got {}",
1681            requests[0].uri
1682        );
1683    }
1684
1685    #[tokio::test]
1686    async fn explicit_base_url_wins_over_token_proxy_endpoint() {
1687        let http_client = RecordingHttpClient::new(minimal_chat_response());
1688        let client = Client::builder()
1689            .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1690            .base_url("https://custom.example.com")
1691            .http_client(http_client.clone())
1692            .build()
1693            .expect("build client");
1694        let model = client.completion_model("gpt-4o");
1695        let request = model.completion_request("hello").build();
1696
1697        let _response = model.completion(request).await.expect("chat completion");
1698
1699        let requests = http_client.requests();
1700        assert_eq!(requests.len(), 1);
1701        assert!(
1702            requests[0].uri.starts_with("https://custom.example.com"),
1703            "expected explicit base URL, got {}",
1704            requests[0].uri
1705        );
1706    }
1707
1708    #[tokio::test]
1709    async fn completion_model_edits_intent_sets_request_header() {
1710        let http_client = RecordingHttpClient::new(minimal_chat_response());
1711        let client = Client::builder()
1712            .api_key("copilot-token")
1713            .http_client(http_client.clone())
1714            .build()
1715            .expect("build client");
1716        let model = client.completion_model("gpt-4o").with_edits_intent();
1717        let request = model.completion_request("hello").build();
1718
1719        let _response = model.completion(request).await.expect("chat completion");
1720
1721        let requests = http_client.requests();
1722        assert_eq!(requests.len(), 1);
1723        assert_eq!(
1724            requests[0]
1725                .headers
1726                .get("openai-intent")
1727                .and_then(|value| value.to_str().ok()),
1728            Some("conversation-edits")
1729        );
1730    }
1731
1732    #[tokio::test]
1733    async fn completion_model_routes_chat_requests_to_chat_completions() {
1734        let http_client = RecordingHttpClient::new(minimal_chat_response());
1735        let client = Client::builder()
1736            .api_key("copilot-token")
1737            .http_client(http_client.clone())
1738            .build()
1739            .expect("build client");
1740        let model = client.completion_model("gpt-4o");
1741        let request = model.completion_request("hello").build();
1742
1743        let _response = model.completion(request).await.expect("chat completion");
1744
1745        let requests = http_client.requests();
1746        assert_eq!(requests.len(), 1);
1747        assert!(requests[0].uri.ends_with("/chat/completions"));
1748        assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-4o\""));
1749    }
1750
1751    #[tokio::test]
1752    async fn completion_model_routes_codex_requests_to_responses() {
1753        let http_client = RecordingHttpClient::new(minimal_responses_response());
1754        let client = Client::builder()
1755            .api_key("copilot-token")
1756            .http_client(http_client.clone())
1757            .build()
1758            .expect("build client");
1759        let model = client.completion_model("gpt-5.3-codex");
1760        let request = model.completion_request("hello").build();
1761
1762        let _response = model
1763            .completion(request)
1764            .await
1765            .expect("responses completion");
1766
1767        let requests = http_client.requests();
1768        assert_eq!(requests.len(), 1);
1769        assert!(requests[0].uri.ends_with("/responses"));
1770        assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-5.3-codex\""));
1771    }
1772
1773    #[tokio::test]
1774    async fn embeddings_accept_minimal_copilot_response_shape() {
1775        use crate::client::EmbeddingsClient;
1776        use crate::embeddings::EmbeddingModel as _;
1777
1778        let http_client = RecordingHttpClient::new(minimal_embeddings_response());
1779        let client = Client::builder()
1780            .api_key("copilot-token")
1781            .http_client(http_client.clone())
1782            .build()
1783            .expect("build client");
1784        let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
1785
1786        let embeddings = model
1787            .embed_texts(["one".to_string(), "two".to_string()])
1788            .await
1789            .expect("embeddings should deserialize");
1790
1791        assert_eq!(embeddings.len(), 2);
1792        assert_eq!(embeddings[0].vec, vec![0.1, 0.2, 0.3]);
1793        assert_eq!(embeddings[1].vec, vec![0.4, 0.5, 0.6]);
1794
1795        let requests = http_client.requests();
1796        assert_eq!(requests.len(), 1);
1797        assert!(requests[0].uri.ends_with("/embeddings"));
1798        assert!(
1799            String::from_utf8_lossy(&requests[0].body)
1800                .contains("\"model\":\"text-embedding-3-small\"")
1801        );
1802    }
1803
1804    #[tokio::test]
1805    async fn responses_stream_terminates_after_terminal_error() {
1806        let tool_call_done = serde_json::json!({
1807            "type": "response.output_item.done",
1808            "output_index": 0,
1809            "sequence_number": 1,
1810            "item": {
1811                "type": "function_call",
1812                "id": "fc_123",
1813                "arguments": "{}",
1814                "call_id": "call_123",
1815                "name": "example_tool",
1816                "status": "completed"
1817            }
1818        });
1819        let failed = serde_json::json!({
1820            "type": "response.failed",
1821            "sequence_number": 2,
1822            "response": {
1823                "id": "resp_123",
1824                "object": "response",
1825                "created_at": 1700000000,
1826                "status": "failed",
1827                "error": {
1828                    "code": "server_error",
1829                    "message": "Copilot response stream failed"
1830                },
1831                "incomplete_details": null,
1832                "instructions": null,
1833                "max_output_tokens": null,
1834                "model": "gpt-5.3-codex",
1835                "usage": null,
1836                "output": [],
1837                "tools": []
1838            }
1839        });
1840        let http_client = MockStreamingClient {
1841            sse_bytes: sse_bytes_from_json_events(&[tool_call_done, failed]),
1842        };
1843        let client = Client::builder()
1844            .api_key("copilot-token")
1845            .http_client(http_client)
1846            .build()
1847            .expect("build client");
1848        let model = client.completion_model("gpt-5.3-codex");
1849        let request = model.completion_request("hello").build();
1850        let mut stream = model.stream(request).await.expect("stream should start");
1851
1852        // The fully-delivered tool call is content, so it is flushed *before*
1853        // the terminal error: consumers that stop at the first `Err` still
1854        // see the completed work.
1855        let tool_call = stream
1856            .next()
1857            .await
1858            .expect("fully-delivered tool call should be flushed before the error")
1859            .expect("flushed tool call should not be an error");
1860        assert!(
1861            matches!(
1862                tool_call,
1863                StreamedAssistantContent::ToolCall { ref tool_call, .. }
1864                    if tool_call.function.name == "example_tool"
1865            ),
1866            "expected the flushed tool call, got {tool_call:?}"
1867        );
1868        let err = match stream.next().await.expect("stream should yield an item") {
1869            Ok(item) => panic!("stream should surface a provider error, got {item:?}"),
1870            Err(err) => err,
1871        };
1872        // The terminal `response.failed` event carries the provider's error
1873        // payload, so the full raw event JSON is preserved for inspection
1874        // (status: None — the error arrived over an already-established stream),
1875        // matching the OpenAI Responses SSE path.
1876        assert!(matches!(
1877            err,
1878            crate::completion::CompletionError::ProviderResponse(_)
1879        ));
1880        assert_eq!(err.provider_response_status(), None);
1881        let json = err
1882            .provider_response_json()
1883            .expect("preserved body should parse as JSON")
1884            .expect("preserved body should not be empty");
1885        let response_error = json
1886            .get("response")
1887            .and_then(|response| response.get("error"))
1888            .expect("preserved body should retain the provider error object");
1889        assert_eq!(
1890            response_error.get("code").and_then(|value| value.as_str()),
1891            Some("server_error")
1892        );
1893        assert_eq!(
1894            response_error
1895                .get("message")
1896                .and_then(|value| value.as_str()),
1897            Some("Copilot response stream failed")
1898        );
1899        assert!(
1900            stream.next().await.is_none(),
1901            "responses stream should end without a terminal record after a terminal error"
1902        );
1903    }
1904
1905    #[tokio::test]
1906    async fn responses_stream_object_less_failed_still_attaches_the_raw_event() {
1907        // #2258 F4 decision: the old Copilot code kept a deliberate two-tier
1908        // shape — `response.failed` WITHOUT an error object surfaced as a
1909        // `ProviderError` with `provider_response_body() == None`. The shared
1910        // Responses interpreter unifies this: the raw event body is ALWAYS
1911        // attached, error object or not, so callers can inspect what the
1912        // provider actually sent. Documented in MIGRATING.
1913        let failed = serde_json::json!({
1914            "type": "response.failed",
1915            "sequence_number": 1,
1916            "response": {
1917                "id": "resp_123",
1918                "object": "response",
1919                "created_at": 1700000000,
1920                "status": "failed",
1921                "error": null,
1922                "incomplete_details": null,
1923                "instructions": null,
1924                "max_output_tokens": null,
1925                "model": "gpt-5.3-codex",
1926                "usage": null,
1927                "output": [],
1928                "tools": []
1929            }
1930        });
1931        let http_client = MockStreamingClient {
1932            sse_bytes: sse_bytes_from_json_events(&[failed]),
1933        };
1934        let client = Client::builder()
1935            .api_key("copilot-token")
1936            .http_client(http_client)
1937            .build()
1938            .expect("build client");
1939        let model = client.completion_model("gpt-5.3-codex");
1940        let request = model.completion_request("hello").build();
1941        let mut stream = model.stream(request).await.expect("stream should start");
1942
1943        let err = match stream.next().await.expect("stream should yield an item") {
1944            Ok(item) => panic!("stream should surface a provider error, got {item:?}"),
1945            Err(err) => err,
1946        };
1947        assert!(matches!(
1948            err,
1949            crate::completion::CompletionError::ProviderResponse(_)
1950        ));
1951        assert_eq!(err.provider_response_status(), None);
1952        assert!(
1953            err.provider_response_body()
1954                .is_some_and(|body| body.contains("response.failed")),
1955            "an object-less response.failed must still carry the raw event body"
1956        );
1957        assert!(
1958            stream.next().await.is_none(),
1959            "responses stream should end after the terminal error"
1960        );
1961    }
1962
1963    #[tokio::test]
1964    async fn responses_stream_incomplete_is_a_terminal_with_partial_content() {
1965        // The content exists only in the delta; the terminal
1966        // `response.incomplete` body has an empty `output`.
1967        let text_delta = serde_json::json!({
1968            "type": "response.output_text.delta",
1969            "content_index": 0,
1970            "delta": "partial",
1971            "item_id": "msg_1",
1972            "logprobs": [],
1973            "output_index": 0,
1974            "sequence_number": 1
1975        });
1976        let incomplete = serde_json::json!({
1977            "type": "response.incomplete",
1978            "sequence_number": 2,
1979            "response": {
1980                "id": "resp_123",
1981                "object": "response",
1982                "created_at": 1700000000,
1983                "status": "incomplete",
1984                "error": null,
1985                "incomplete_details": { "reason": "max_output_tokens" },
1986                "instructions": null,
1987                "max_output_tokens": null,
1988                "model": "gpt-5.3-codex",
1989                "usage": { "input_tokens": 1, "output_tokens": 2, "total_tokens": 3 },
1990                "output": [],
1991                "tools": []
1992            }
1993        });
1994        let http_client = MockStreamingClient {
1995            sse_bytes: sse_bytes_from_json_events(&[text_delta, incomplete]),
1996        };
1997        let client = Client::builder()
1998            .api_key("copilot-token")
1999            .http_client(http_client)
2000            .build()
2001            .expect("build client");
2002        let model = client.completion_model("gpt-5.3-codex");
2003        let request = model.completion_request("hello").build();
2004        let mut stream = model.stream(request).await.expect("stream should start");
2005
2006        let mut text = String::new();
2007        let mut terminal = None;
2008        while let Some(item) = stream.next().await {
2009            match item.expect("incomplete turn should not surface an error") {
2010                StreamedAssistantContent::Text(chunk) => text.push_str(&chunk.text),
2011                StreamedAssistantContent::Final(final_response) => terminal = Some(final_response),
2012                other => panic!("unexpected stream item: {other:?}"),
2013            }
2014        }
2015
2016        assert_eq!(text, "partial");
2017        let terminal = terminal.expect("incomplete turn should emit a terminal record");
2018        assert_eq!(
2019            terminal.finish_reason,
2020            Some(crate::completion::FinishReason::Length)
2021        );
2022        assert_eq!(terminal.usage.input_tokens, 1);
2023        assert_eq!(terminal.usage.output_tokens, 2);
2024        assert_eq!(terminal.usage.total_tokens, 3);
2025    }
2026
2027    #[tokio::test]
2028    async fn chat_stream_surfaces_malformed_frame_and_still_completes() {
2029        let http_client = MockStreamingClient {
2030            sse_bytes: sse_bytes_from_data_lines([
2031                "{\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":null}",
2032                "{not valid json",
2033                "{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":null}",
2034                "[DONE]",
2035            ]),
2036        };
2037        let client = Client::builder()
2038            .api_key("copilot-token")
2039            .http_client(http_client)
2040            .build()
2041            .expect("build client");
2042        let model = client.completion_model("gpt-4o");
2043        let request = model.completion_request("hello").build();
2044        let mut stream = model.stream(request).await.expect("stream should start");
2045
2046        let mut text = String::new();
2047        let mut saw_error = false;
2048        let mut terminal = None;
2049        while let Some(item) = stream.next().await {
2050            match item {
2051                Ok(StreamedAssistantContent::Text(chunk)) => text.push_str(&chunk.text),
2052                Ok(StreamedAssistantContent::Final(final_response)) => {
2053                    terminal = Some(final_response)
2054                }
2055                Ok(other) => panic!("unexpected stream item: {other:?}"),
2056                Err(err) => {
2057                    assert!(
2058                        matches!(err, crate::completion::CompletionError::JsonError(_)),
2059                        "expected a JSON parse error item, got {err:?}"
2060                    );
2061                    saw_error = true;
2062                }
2063            }
2064        }
2065
2066        // The malformed frame is surfaced as an error item, and the content
2067        // and genuine terminal on either side of it both still arrive.
2068        assert_eq!(text, "hello");
2069        assert!(saw_error, "malformed frame should surface an error item");
2070        let terminal = terminal.expect("stream should still emit its terminal record");
2071        assert_eq!(
2072            terminal.finish_reason,
2073            Some(crate::completion::FinishReason::Stop)
2074        );
2075    }
2076
2077    #[tokio::test]
2078    async fn chat_stream_surfaces_recognizable_chunk_with_malformed_field() {
2079        // The frame is recognizably a chat completion chunk (it has
2080        // `choices`), but the payload fails the full parse — a data-level
2081        // defect surfaced as an error item, not a skippable unknown event.
2082        let http_client = MockStreamingClient {
2083            sse_bytes: sse_bytes_from_data_lines([
2084                "{\"object\":\"chat.completion.chunk\",\"choices\":42}",
2085                "{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":null}",
2086                "[DONE]",
2087            ]),
2088        };
2089        let client = Client::builder()
2090            .api_key("copilot-token")
2091            .http_client(http_client)
2092            .build()
2093            .expect("build client");
2094        let model = client.completion_model("gpt-4o");
2095        let request = model.completion_request("hello").build();
2096        let mut stream = model.stream(request).await.expect("stream should start");
2097
2098        let mut saw_error = false;
2099        let mut terminal = None;
2100        while let Some(item) = stream.next().await {
2101            match item {
2102                Ok(StreamedAssistantContent::Final(final_response)) => {
2103                    terminal = Some(final_response)
2104                }
2105                Ok(other) => panic!("unexpected stream item: {other:?}"),
2106                Err(err) => {
2107                    assert!(
2108                        matches!(err, crate::completion::CompletionError::JsonError(_)),
2109                        "expected a JSON parse error item, got {err:?}"
2110                    );
2111                    saw_error = true;
2112                }
2113            }
2114        }
2115
2116        assert!(
2117            saw_error,
2118            "a recognizable chunk with a malformed field should surface an error item"
2119        );
2120        let terminal = terminal.expect("stream should still emit its terminal record");
2121        assert_eq!(
2122            terminal.finish_reason,
2123            Some(crate::completion::FinishReason::Stop)
2124        );
2125    }
2126
2127    #[tokio::test]
2128    async fn chat_stream_skips_unrecognized_event_and_still_completes() {
2129        // Valid JSON that is not recognizably a chat completion chunk (no
2130        // `choices`, no `"object": "chat.completion.chunk"`) is an event this
2131        // client doesn't know yet — skipped semantically for forward
2132        // compatibility, surfaced verbatim on the raw passthrough channel.
2133        let http_client = MockStreamingClient {
2134            sse_bytes: sse_bytes_from_data_lines([
2135                "{\"type\":\"copilot.heartbeat\",\"payload\":{}}",
2136                "{\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":null}",
2137                "{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":null}",
2138                "[DONE]",
2139            ]),
2140        };
2141        let client = Client::builder()
2142            .api_key("copilot-token")
2143            .http_client(http_client)
2144            .build()
2145            .expect("build client");
2146        let model = client.completion_model("gpt-4o");
2147        let request = model.completion_request("hello").build();
2148        let mut stream = model.stream(request).await.expect("stream should start");
2149
2150        let mut text = String::new();
2151        let mut terminal = None;
2152        let mut unknown = None;
2153        while let Some(item) = stream.next().await {
2154            match item.expect("unrecognized events must not surface errors") {
2155                StreamedAssistantContent::Text(chunk) => text.push_str(&chunk.text),
2156                StreamedAssistantContent::Final(final_response) => terminal = Some(final_response),
2157                StreamedAssistantContent::Unknown(value) => unknown = Some(value),
2158                other => panic!("unexpected stream item: {other:?}"),
2159            }
2160        }
2161
2162        assert_eq!(text, "hello");
2163        assert_eq!(
2164            unknown,
2165            Some(serde_json::json!({"type": "copilot.heartbeat", "payload": {}}).into()),
2166            "the unrecognized frame must surface verbatim on the raw channel"
2167        );
2168        let terminal = terminal.expect("stream should still emit its terminal record");
2169        assert_eq!(
2170            terminal.finish_reason,
2171            Some(crate::completion::FinishReason::Stop)
2172        );
2173    }
2174
2175    #[tokio::test]
2176    async fn responses_stream_preserves_reasoning_metadata_on_final_response() {
2177        let metadata = serde_json::json!({
2178            "context": "all_turns",
2179            "effort": "ultra",
2180            "summary": null,
2181            "future_control": true
2182        });
2183        let completed = serde_json::json!({
2184            "type": "response.completed",
2185            "sequence_number": 1,
2186            "response": {
2187                "id": "resp_123",
2188                "object": "response",
2189                "created_at": 1700000000,
2190                "status": "completed",
2191                "error": null,
2192                "incomplete_details": null,
2193                "instructions": null,
2194                "max_output_tokens": null,
2195                "model": "gpt-5.3-codex",
2196                "reasoning": metadata.clone(),
2197                "usage": null,
2198                "output": [],
2199                "tools": []
2200            }
2201        });
2202        let http_client = MockStreamingClient {
2203            sse_bytes: sse_bytes_from_json_events(&[completed]),
2204        };
2205        let client = Client::builder()
2206            .api_key("copilot-token")
2207            .http_client(http_client)
2208            .build()
2209            .expect("build client");
2210        let model = client.completion_model("gpt-5.3-codex");
2211        let request = model.completion_request("hello").build();
2212        // Reasoning metadata is Copilot's own terminal payload, not part of
2213        // the normalized `StreamFinal`, so this reads it through `raw_stream`.
2214        let mut stream = model
2215            .raw_stream(request)
2216            .await
2217            .expect("stream should start");
2218
2219        while let Some(item) = stream.next().await {
2220            if let crate::streaming::RawStreamingChoice::FinalResponse(
2221                super::CopilotStreamingResponse::Responses(response),
2222            ) = item.expect("completed stream should not error")
2223            {
2224                assert_eq!(response.reasoning_context.as_deref(), Some("all_turns"));
2225                assert_eq!(response.reasoning_metadata.as_ref(), metadata.as_object());
2226                return;
2227            }
2228        }
2229
2230        panic!("responses stream should yield a final response");
2231    }
2232
2233    #[tokio::test]
2234    async fn chat_stream_terminates_after_transport_error() {
2235        let chunks = vec![
2236            Ok(sse_bytes_from_data_lines([
2237                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
2238            ])),
2239            Err(http_client::Error::InvalidStatusCode(
2240                http::StatusCode::BAD_GATEWAY,
2241            )),
2242        ];
2243
2244        let http_client = SequencedStreamingHttpClient::new(chunks);
2245        let client = Client::builder()
2246            .api_key("copilot-token")
2247            .http_client(http_client)
2248            .build()
2249            .expect("build client");
2250        let model = client.completion_model("gpt-4o");
2251        let request = model.completion_request("hello").build();
2252        let mut stream = model.stream(request).await.expect("stream should start");
2253
2254        // The fully-delivered tool call is content, so it is flushed *before*
2255        // the terminal error: consumers that stop at the first `Err` still
2256        // see the completed work.
2257        let mut saw_error = false;
2258        let mut saw_tool_call = false;
2259        while let Some(item) = stream.next().await {
2260            match item {
2261                Ok(StreamedAssistantContent::ToolCallDelta { .. }) => {
2262                    assert!(!saw_error, "deltas should precede the terminal error");
2263                }
2264                Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => {
2265                    assert!(
2266                        !saw_error,
2267                        "flushed tool call should precede the terminal error"
2268                    );
2269                    assert_eq!(tool_call.function.name, "ping");
2270                    saw_tool_call = true;
2271                }
2272                Err(err) => {
2273                    assert_eq!(
2274                        err.to_string(),
2275                        "HttpError: Invalid status code: 502 Bad Gateway"
2276                    );
2277                    assert_eq!(
2278                        err.provider_response_status(),
2279                        Some(http::StatusCode::BAD_GATEWAY)
2280                    );
2281                    assert_eq!(err.provider_response_body(), None);
2282                    saw_error = true;
2283                }
2284                Ok(other) => panic!("unexpected stream item: {other:?}"),
2285            }
2286        }
2287
2288        assert!(
2289            saw_tool_call,
2290            "fully-delivered tool call should be flushed before the error"
2291        );
2292        assert!(saw_error, "stream should surface the transport error");
2293        assert!(
2294            stream.next().await.is_none(),
2295            "chat stream should end without a terminal record after a transport error"
2296        );
2297    }
2298
2299    #[test]
2300    fn env_api_key_prefers_github_prefixed_vars() {
2301        let env = env_map(&[
2302            ("COPILOT_API_KEY", "copilot-key"),
2303            ("GITHUB_COPILOT_API_KEY", "github-key"),
2304            ("GITHUB_TOKEN", "bootstrap-token"),
2305        ]);
2306        let get = |name: &str| env.get(name).cloned();
2307
2308        assert_eq!(env_api_key(&get).as_deref(), Some("github-key"));
2309    }
2310
2311    #[test]
2312    fn env_github_access_token_prefers_explicit_bootstrap_var() {
2313        let env = env_map(&[
2314            ("COPILOT_GITHUB_ACCESS_TOKEN", "explicit-bootstrap"),
2315            ("GITHUB_TOKEN", "fallback-bootstrap"),
2316        ]);
2317        let get = |name: &str| env.get(name).cloned();
2318
2319        assert_eq!(
2320            env_github_access_token(&get).as_deref(),
2321            Some("explicit-bootstrap")
2322        );
2323    }
2324
2325    #[test]
2326    fn env_base_url_prefers_github_prefixed_vars() {
2327        let env = env_map(&[
2328            ("COPILOT_BASE_URL", "https://copilot.example"),
2329            ("GITHUB_COPILOT_API_BASE", "https://github.example"),
2330        ]);
2331        let get = |name: &str| env.get(name).cloned();
2332
2333        assert_eq!(
2334            env_base_url(&get).as_deref(),
2335            Some("https://github.example")
2336        );
2337    }
2338
2339    #[test]
2340    fn env_without_api_key_falls_back_to_oauth() {
2341        let env = env_map(&[("COPILOT_BASE_URL", "https://copilot.example")]);
2342        let get = |name: &str| env.get(name).cloned();
2343
2344        assert!(env_api_key(&get).is_none());
2345        assert!(env_github_access_token(&get).is_none());
2346        assert_eq!(
2347            env_base_url(&get).as_deref(),
2348            Some("https://copilot.example")
2349        );
2350    }
2351
2352    #[test]
2353    fn env_github_token_is_not_treated_as_copilot_api_key() {
2354        let env = env_map(&[("GITHUB_TOKEN", "bootstrap-token")]);
2355        let get = |name: &str| env.get(name).cloned();
2356
2357        assert!(env_api_key(&get).is_none());
2358        assert_eq!(
2359            env_github_access_token(&get).as_deref(),
2360            Some("bootstrap-token")
2361        );
2362    }
2363}
2364
2365#[cfg(test)]
2366mod response_identity_tests {
2367    use super::*;
2368
2369    /// Both Copilot routes' streaming terminals carry the transport request id
2370    /// (stamped by the shared SSE capture) into the normalized `StreamFinal`.
2371    /// Deterministic and credential-free: the transport halves — the shared
2372    /// OpenAI chat wrapper's capture and `stamp_terminal_request_id` on the
2373    /// Responses route — are covered by the shared-path tests; this locks the
2374    /// Copilot-specific conversion layer.
2375    #[test]
2376    fn streaming_terminals_carry_request_id_into_stream_final() {
2377        let mut chat_terminal = openai::completion::streaming::StreamingCompletionResponse::<
2378            openai::completion::Usage,
2379        >::new(openai::completion::Usage::default());
2380        chat_terminal.provider_request_id = Some("req-chat".to_string());
2381        let chat_final: crate::streaming::StreamFinal =
2382            (PROVIDER_NAME, CopilotStreamingResponse::Chat(chat_terminal)).into();
2383        assert_eq!(chat_final.provider_request_id.as_deref(), Some("req-chat"));
2384
2385        let mut responses_terminal = responses_api::streaming::StreamingCompletionResponse::new(
2386            serde_json::from_value(
2387                serde_json::json!({"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}),
2388            )
2389            .expect("usage should parse"),
2390        );
2391        responses_terminal.provider_request_id = Some("req-responses".to_string());
2392        let responses_final: crate::streaming::StreamFinal = (
2393            PROVIDER_NAME,
2394            CopilotStreamingResponse::Responses(responses_terminal),
2395        )
2396            .into();
2397        assert_eq!(
2398            responses_final.provider_request_id.as_deref(),
2399            Some("req-responses")
2400        );
2401    }
2402
2403    /// The Responses-route unary wire type carries the stamped id through
2404    /// `normalize` into the core response; the chat route has no wire slot,
2405    /// so `completion()` stamps the normalized response from the returned
2406    /// pair — asserted here at the conversion layer for the responses half.
2407    #[test]
2408    fn responses_unary_wire_id_survives_normalize() {
2409        use crate::completion::NormalizeCompletionResponse;
2410
2411        let payload = serde_json::json!({
2412            "id": "resp_123",
2413            "object": "response",
2414            "created_at": 0,
2415            "status": "completed",
2416            "model": "gpt-test",
2417            "output": [{
2418                "type": "message",
2419                "id": "msg_1",
2420                "role": "assistant",
2421                "status": "completed",
2422                "content": [{"type": "output_text", "text": "hi", "annotations": []}]
2423            }]
2424        });
2425        let mut response: responses_api::CompletionResponse =
2426            serde_json::from_value(payload).expect("wire response should parse");
2427        response.provider_request_id = Some("req-unary".to_string());
2428
2429        let normalized = response
2430            .normalize(PROVIDER_NAME)
2431            .expect("response should normalize");
2432        assert_eq!(normalized.provider_request_id.as_deref(), Some("req-unary"));
2433        assert_eq!(normalized.response_id.as_deref(), Some("resp_123"));
2434        assert_eq!(normalized.provider, PROVIDER_NAME);
2435    }
2436}
2437
2438/// Raw-capture and Part A parity, unit form, for both Copilot routes over the
2439/// recording mock transport. `with_error_response_headers` with `200 OK` is
2440/// the one unary double that carries response headers, which is what lets a
2441/// unit test exercise the `x-request-id` half of the contract: on the chat
2442/// route the id lives only on the header (the shared OpenAI chat wire type has
2443/// no slot), on the responses route the driver stamps it onto the wire type.
2444/// The captured value is the route-tagged [`CopilotCompletionResponse`] — what
2445/// `raw_completion` returns — so it must round-trip through the
2446/// `#[serde(tag = "api")]` enum, including the responses variant whose inner
2447/// type has a hand-written `Serialize`.
2448#[cfg(test)]
2449mod raw_capture_tests {
2450    use super::*;
2451    use crate::client::CompletionClient;
2452    use crate::completion::CompletionModel as _;
2453    use crate::test_utils::RecordingHttpClient;
2454
2455    const REQUEST_ID: &str = "req_unit_copilot_0001";
2456
2457    /// A chat-completions body carrying `system_fingerprint`, which the
2458    /// normalized response provably lacks.
2459    const CHAT_BODY: &str = r#"{
2460        "id": "chatcmpl-copilot-raw",
2461        "object": "chat.completion",
2462        "created": 1700000000,
2463        "model": "gpt-4o-2024-11-20",
2464        "system_fingerprint": "fp_copilot_chat",
2465        "choices": [{
2466            "index": 0,
2467            "message": {"role": "assistant", "content": "hello"},
2468            "logprobs": null,
2469            "finish_reason": "stop"
2470        }],
2471        "usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7}
2472    }"#;
2473
2474    /// A Responses body carrying `service_tier`, which the normalized
2475    /// response provably lacks.
2476    const RESPONSES_BODY: &str = r#"{
2477        "id": "resp_copilot_raw",
2478        "object": "response",
2479        "created_at": 1700000000,
2480        "status": "completed",
2481        "error": null,
2482        "incomplete_details": null,
2483        "instructions": null,
2484        "max_output_tokens": null,
2485        "model": "gpt-5.3-codex",
2486        "service_tier": "default",
2487        "usage": {
2488            "input_tokens": 4,
2489            "input_tokens_details": {"cached_tokens": 0},
2490            "output_tokens": 3,
2491            "output_tokens_details": {"reasoning_tokens": 0},
2492            "total_tokens": 7
2493        },
2494        "output": [{
2495            "type": "message",
2496            "id": "msg_copilot_raw",
2497            "role": "assistant",
2498            "status": "completed",
2499            "content": [{"type": "output_text", "text": "hello", "annotations": []}]
2500        }],
2501        "tools": []
2502    }"#;
2503
2504    fn model(model: &str, body: &'static str) -> CompletionModel<RecordingHttpClient> {
2505        let mut headers = http::HeaderMap::new();
2506        headers.insert("x-request-id", http::HeaderValue::from_static(REQUEST_ID));
2507        let http_client =
2508            RecordingHttpClient::with_error_response_headers(http::StatusCode::OK, body, headers);
2509        let client = Client::builder()
2510            .api_key("copilot-token")
2511            .http_client(http_client)
2512            .build()
2513            .expect("build client");
2514        client.completion_model(model)
2515    }
2516
2517    /// Run one completion for a route and check the shared capture contract:
2518    /// `raw` deserializes into [`CopilotCompletionResponse`] under the
2519    /// expected route tag and re-serializes identically; re-normalizing the
2520    /// capture (with the header id reattached, exactly as `completion()`
2521    /// does) reproduces every normalized field; and the response reports the
2522    /// header's id.
2523    async fn assert_capture_contract(
2524        model: &CompletionModel<RecordingHttpClient>,
2525        expected_api_tag: &str,
2526    ) -> (completion::CompletionResponse, CopilotCompletionResponse) {
2527        let response = model
2528            .completion(model.completion_request("hello").build())
2529            .await
2530            .expect("completion");
2531        let raw = &response.raw;
2532        assert_eq!(raw["api"], expected_api_tag);
2533        let typed: CopilotCompletionResponse =
2534            serde_json::from_value(raw.clone()).expect("raw must deserialize");
2535        assert_eq!(
2536            serde_json::to_value(&typed).expect("re-serialize"),
2537            *raw,
2538            "the capture must be exactly what the route-tagged raw type serializes to"
2539        );
2540
2541        let renormalized = typed
2542            .clone()
2543            .normalize(PROVIDER_NAME)
2544            .expect("re-normalize the capture")
2545            .with_optional_provider_request_id(Some(REQUEST_ID.to_string()));
2546        assert_eq!(response.identity(), renormalized.identity());
2547        assert_eq!(response.finish_reason(), renormalized.finish_reason());
2548        assert_eq!(response.model, renormalized.model);
2549        assert_eq!(response.usage, renormalized.usage);
2550        assert_eq!(response.choice, renormalized.choice);
2551        assert_eq!(response.provider_request_id.as_deref(), Some(REQUEST_ID));
2552        (response, typed)
2553    }
2554
2555    /// Part A parity for one route: `raw_completion_with_request_id` →
2556    /// `normalize` → `with_optional_provider_request_id` reproduces
2557    /// `completion()` on identity, finish reason, model and usage, and the
2558    /// id is the header on both.
2559    async fn assert_parity_contract(model: &CompletionModel<RecordingHttpClient>) {
2560        let (raw, id) = model
2561            .raw_completion_with_request_id(model.completion_request("hello").build())
2562            .await
2563            .expect("typed route");
2564        assert_eq!(id.as_deref(), Some(REQUEST_ID));
2565        let reassembled = raw
2566            .normalize(PROVIDER_NAME)
2567            .expect("normalize")
2568            .with_optional_provider_request_id(id);
2569
2570        let normalized = model
2571            .completion(model.completion_request("hello").build())
2572            .await
2573            .expect("normalized route");
2574
2575        assert_eq!(reassembled.identity(), normalized.identity());
2576        assert_eq!(reassembled.finish_reason(), normalized.finish_reason());
2577        assert_eq!(reassembled.model, normalized.model);
2578        assert_eq!(reassembled.usage, normalized.usage);
2579        assert_eq!(reassembled.provider_request_id.as_deref(), Some(REQUEST_ID));
2580        assert_eq!(normalized.provider_request_id.as_deref(), Some(REQUEST_ID));
2581        assert_eq!(normalized.provider, PROVIDER_NAME);
2582    }
2583
2584    /// Chat route: the capture is tagged `api: chat`, wraps the shared OpenAI
2585    /// chat wire type, and keeps `system_fingerprint`.
2586    #[tokio::test]
2587    async fn chat_route_raw_round_trips_into_the_route_tagged_type() {
2588        let model = model("gpt-4o", CHAT_BODY);
2589
2590        let (response, typed) = assert_capture_contract(&model, "chat").await;
2591
2592        let CopilotCompletionResponse::Chat(chat) = typed else {
2593            panic!("the chat route must capture the chat variant");
2594        };
2595        assert_eq!(chat.system_fingerprint.as_deref(), Some("fp_copilot_chat"));
2596        assert_eq!(
2597            response.finish_reason(),
2598            Some(completion::FinishReason::Stop)
2599        );
2600        assert_eq!(
2601            response.identity().response_id.as_deref(),
2602            Some("chatcmpl-copilot-raw")
2603        );
2604    }
2605
2606    /// Chat route Part A: the wire type has no id slot, so only the pair
2607    /// reproduces `completion()` — this is the case the method exists for.
2608    #[tokio::test]
2609    async fn chat_route_raw_completion_with_request_id_reproduces_completion() {
2610        let model = model("gpt-4o", CHAT_BODY);
2611
2612        assert_parity_contract(&model).await;
2613
2614        // And plain `raw_completion` → `normalize` provably lacks the id:
2615        // the reason the pair is public.
2616        let raw = model
2617            .raw_completion(model.completion_request("hello").build())
2618            .await
2619            .expect("typed route");
2620        let normalized = raw.normalize(PROVIDER_NAME).expect("normalize");
2621        assert_eq!(normalized.provider_request_id, None);
2622    }
2623
2624    /// Responses route: the capture is tagged `api: responses` and wraps the
2625    /// Responses wire type, whose hand-written `Serialize` mirrors the body
2626    /// (`service_tier` kept; the stamped transport id, which is not body,
2627    /// deliberately not emitted — so the deserialized capture reports `None`
2628    /// there while the normalized response beside it carries the header).
2629    #[tokio::test]
2630    async fn responses_route_raw_round_trips_into_the_route_tagged_type() {
2631        let model = model("gpt-5.3-codex", RESPONSES_BODY);
2632
2633        let (response, typed) = assert_capture_contract(&model, "responses").await;
2634
2635        let CopilotCompletionResponse::Responses(responses) = typed else {
2636            panic!("the responses route must capture the responses variant");
2637        };
2638        assert!(matches!(
2639            responses.additional_parameters.service_tier,
2640            Some(responses_api::OpenAIServiceTier::Default)
2641        ));
2642        assert_eq!(responses.provider_request_id, None);
2643        assert_eq!(
2644            response.identity().message_id.as_deref(),
2645            Some("msg_copilot_raw")
2646        );
2647        assert_eq!(
2648            response.identity().response_id.as_deref(),
2649            Some("resp_copilot_raw")
2650        );
2651    }
2652
2653    /// Responses route Part A: the wire type carries the id itself, so the
2654    /// pair's second element equals the raw type's own id and reattaching it
2655    /// is a no-op — the same pair still reproduces `completion()`.
2656    #[tokio::test]
2657    async fn responses_route_raw_completion_with_request_id_reproduces_completion() {
2658        let model = model("gpt-5.3-codex", RESPONSES_BODY);
2659
2660        assert_parity_contract(&model).await;
2661
2662        let (raw, id) = model
2663            .raw_completion_with_request_id(model.completion_request("hello").build())
2664            .await
2665            .expect("typed route");
2666        let CopilotCompletionResponse::Responses(responses) = &raw else {
2667            panic!("codex models route to /responses");
2668        };
2669        assert_eq!(responses.provider_request_id, id);
2670        assert_eq!(id.as_deref(), Some(REQUEST_ID));
2671    }
2672
2673    /// Both variants of the route-tagged unary raw type round-trip through
2674    /// serde, hand-built from parsed wire bodies rather than through the
2675    /// transport: the internally tagged enum has to merge its `api` tag into
2676    /// whatever the inner type serializes as, and the responses variant's
2677    /// inner type serializes through a hand-written `Serialize` (with a
2678    /// flattened tail) rather than a derive.
2679    #[test]
2680    fn copilot_completion_response_round_trips_both_variants() {
2681        let chat: openai::completion::CompletionResponse =
2682            serde_json::from_str(CHAT_BODY).expect("chat body parses");
2683        let responses: responses_api::CompletionResponse =
2684            serde_json::from_str(RESPONSES_BODY).expect("responses body parses");
2685
2686        for (variant, tag) in [
2687            (CopilotCompletionResponse::Chat(Box::new(chat)), "chat"),
2688            (
2689                CopilotCompletionResponse::Responses(Box::new(responses)),
2690                "responses",
2691            ),
2692        ] {
2693            let value = serde_json::to_value(&variant).expect("serialize");
2694            assert_eq!(value["api"], tag);
2695            let back: CopilotCompletionResponse =
2696                serde_json::from_value(value.clone()).expect("deserialize");
2697            assert_eq!(
2698                serde_json::to_value(&back).expect("re-serialize"),
2699                value,
2700                "{tag}: the route-tagged raw type must round-trip"
2701            );
2702            assert_eq!(
2703                back.normalize(PROVIDER_NAME).expect("normalize").provider,
2704                PROVIDER_NAME
2705            );
2706        }
2707    }
2708
2709    /// Both variants of the route-tagged streaming terminal round-trip
2710    /// through serde — this is the value `StreamFinal.raw` carries for a
2711    /// Copilot stream, so a consumer must be able to read it back as
2712    /// [`CopilotStreamingResponse`].
2713    #[test]
2714    fn copilot_streaming_response_round_trips_both_variants() {
2715        let mut chat = openai::completion::streaming::StreamingCompletionResponse::<
2716            openai::completion::Usage,
2717        >::new(openai::completion::Usage::default());
2718        chat.finish_reason = Some(completion::FinishReason::Stop);
2719        chat.response_id = Some("chatcmpl-stream".to_string());
2720        chat.model = Some("gpt-4o".to_string());
2721        chat.provider_request_id = Some("req-chat".to_string());
2722        chat.additional_params = Some(
2723            serde_json::from_value(json!({"service_tier": "default"})).expect("additional params"),
2724        );
2725
2726        let mut responses = responses_api::streaming::StreamingCompletionResponse::new(
2727            serde_json::from_value(
2728                json!({"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}),
2729            )
2730            .expect("usage should parse"),
2731        );
2732        responses.provider_request_id = Some("req-responses".to_string());
2733
2734        for (variant, tag) in [
2735            (CopilotStreamingResponse::Chat(chat), "chat"),
2736            (CopilotStreamingResponse::Responses(responses), "responses"),
2737        ] {
2738            let value = serde_json::to_value(&variant).expect("serialize");
2739            assert_eq!(value["api"], tag);
2740            let back: CopilotStreamingResponse =
2741                serde_json::from_value(value.clone()).expect("deserialize");
2742            assert_eq!(
2743                serde_json::to_value(&back).expect("re-serialize"),
2744                value,
2745                "{tag}: the route-tagged terminal must round-trip"
2746            );
2747            let original: crate::streaming::StreamFinal = (PROVIDER_NAME, variant).into();
2748            let restored: crate::streaming::StreamFinal = (PROVIDER_NAME, back).into();
2749            assert_eq!(
2750                restored, original,
2751                "{tag}: normalization must agree across the round-trip"
2752            );
2753        }
2754    }
2755}