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, Capabilities, Capable, DebugExt, ModelLister, Nothing, Provider, ProviderBuilder,
27    ProviderClient, Transport,
28};
29use crate::completion::{self, CompletionError, GetTokenUsage};
30use crate::embeddings::{self, EmbeddingError};
31use crate::http_client::{self, HttpClientExt};
32use crate::model::{Model, ModelList, ModelListingError};
33use crate::providers::internal::openai_chat_completions_compatible::{
34    self, CompatibleChoiceData, CompatibleChunk, CompatibleFinishReason, CompatibleStreamProfile,
35    CompatibleToolCallChunk,
36};
37use crate::providers::openai;
38use crate::providers::openai::responses_api::{self, CompletionRequest as ResponsesRequest};
39use crate::streaming::{self, RawStreamingChoice, StreamingCompletionResponse};
40use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
41use async_stream::stream;
42use futures::StreamExt;
43use http::Request;
44use serde::{Deserialize, Serialize};
45use serde_json::json;
46use std::borrow::Cow;
47use std::collections::HashMap;
48use std::fmt::Debug;
49use std::path::{Path, PathBuf};
50use tracing::info_span;
51use tracing_futures::Instrument as _;
52
53const GITHUB_COPILOT_API_BASE_URL: &str = "https://api.githubcopilot.com";
54pub(crate) const EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.35.0";
55pub(crate) const USER_AGENT: &str = "GitHubCopilotChat/0.35.0";
56pub(crate) const EDITOR_VERSION: &str = "vscode/1.107.0";
57const API_VERSION: &str = "2025-04-01";
58
59/// Copilot conversation intent sent in the `openai-intent` request header.
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
61pub enum CopilotIntent {
62    /// Generic chat panel conversation semantics.
63    #[default]
64    Panel,
65    /// Edit-oriented conversation semantics.
66    Edits,
67}
68
69impl CopilotIntent {
70    fn as_header(self) -> &'static str {
71        match self {
72            Self::Panel => "conversation-panel",
73            Self::Edits => "conversation-edits",
74        }
75    }
76}
77
78/// `gpt-4`
79pub const GPT_4: &str = "gpt-4";
80/// `gpt-4o`
81pub const GPT_4O: &str = "gpt-4o";
82/// `gpt-4o-mini`
83pub const GPT_4O_MINI: &str = "gpt-4o-mini";
84/// `gpt-4.1`
85pub const GPT_4_1: &str = "gpt-4.1";
86/// `gpt-4.1-mini`
87pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
88/// `gpt-4.1-nano`
89pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
90/// `gpt-5.3-codex`
91pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
92/// `gpt-5.1-codex`
93pub const GPT_5_1_CODEX: &str = "gpt-5.1-codex";
94/// `gpt-5.5`
95pub const GPT_5_5: &str = "gpt-5.5";
96/// `gpt-5.4`
97pub const GPT_5_4: &str = "gpt-5.4";
98/// `claude-sonnet-4` completion model (Anthropic, via Copilot)
99pub const CLAUDE_SONNET_4: &str = "claude-sonnet-4";
100/// `claude-sonnet-4.6`
101pub const CLAUDE_SONNET_4_6: &str = "claude-sonnet-4.6";
102/// `claude-opus-4.6`
103pub const CLAUDE_OPUS_4_6: &str = "claude-opus-4.6";
104/// `claude-opus-4.7`
105pub const CLAUDE_OPUS_4_7: &str = "claude-opus-4.7";
106/// `claude-3.5-sonnet` completion model (Anthropic, via Copilot)
107pub const CLAUDE_3_5_SONNET: &str = "claude-3.5-sonnet";
108/// `gemini-3-flash-preview` completion model (Google, via Copilot)
109pub const GEMINI_3_FLASH: &str = "gemini-3-flash-preview";
110/// `gemini-3.1-pro-preview` completion model (Google, via Copilot)
111pub const GEMINI_3_1_PRO_FLASH: &str = "gemini-3.1-pro-preview";
112/// `gemini-2.0-flash-001` completion model (Google, via Copilot)
113pub const GEMINI_2_0_FLASH: &str = "gemini-2.0-flash-001";
114/// `o3-mini` reasoning model (OpenAI, via Copilot)
115pub const O3_MINI: &str = "o3-mini";
116/// `text-embedding-3-small`
117pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
118/// `text-embedding-3-large`
119pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
120/// `text-embedding-ada-002`
121pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
122
123pub use openai::EncodingFormat;
124
125#[derive(Clone)]
126pub enum CopilotAuth {
127    ApiKey(String),
128    GitHubAccessToken(String),
129    OAuth,
130}
131
132impl ApiKey for CopilotAuth {}
133
134impl<S> From<S> for CopilotAuth
135where
136    S: Into<String>,
137{
138    fn from(value: S) -> Self {
139        Self::ApiKey(value.into())
140    }
141}
142
143impl Debug for CopilotAuth {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        match self {
146            Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
147            Self::GitHubAccessToken(_) => f.write_str("GitHubAccessToken(<redacted>)"),
148            Self::OAuth => f.write_str("OAuth"),
149        }
150    }
151}
152
153#[derive(Debug, Clone)]
154pub struct CopilotBuilder {
155    access_token_file: Option<PathBuf>,
156    api_key_file: Option<PathBuf>,
157    device_code_handler: auth::DeviceCodeHandler,
158}
159
160#[derive(Clone)]
161pub struct CopilotExt {
162    auth: auth::Authenticator,
163}
164
165impl Debug for CopilotExt {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("CopilotExt")
168            .field("auth", &self.auth)
169            .finish()
170    }
171}
172
173pub type Client<H = reqwest::Client> = client::Client<CopilotExt, H>;
174pub type ClientBuilder<H = crate::markers::Missing> =
175    client::ClientBuilder<CopilotBuilder, CopilotAuth, H>;
176
177impl Default for CopilotBuilder {
178    fn default() -> Self {
179        let token_dir = default_token_dir();
180        Self {
181            access_token_file: token_dir.as_ref().map(|dir| dir.join("access-token")),
182            api_key_file: token_dir.map(|dir| dir.join("api-key.json")),
183            device_code_handler: auth::DeviceCodeHandler::default(),
184        }
185    }
186}
187
188impl Provider for CopilotExt {
189    type Builder = CopilotBuilder;
190
191    const VERIFY_PATH: &'static str = "";
192}
193
194impl<H> Capabilities<H> for CopilotExt {
195    type Completion = Capable<CompletionModel<H>>;
196    type Embeddings = Capable<EmbeddingModel<H>>;
197    type Transcription = Nothing;
198    type ModelListing = Capable<CopilotModelLister<H>>;
199    #[cfg(feature = "image")]
200    type ImageGeneration = Nothing;
201    #[cfg(feature = "audio")]
202    type AudioGeneration = Nothing;
203    type Rerank = Nothing;
204}
205
206impl DebugExt for CopilotExt {}
207
208impl ProviderBuilder for CopilotBuilder {
209    type Extension<H>
210        = CopilotExt
211    where
212        H: HttpClientExt;
213    type ApiKey = CopilotAuth;
214
215    const BASE_URL: &'static str = GITHUB_COPILOT_API_BASE_URL;
216
217    fn build<H>(
218        builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
219    ) -> http_client::Result<Self::Extension<H>>
220    where
221        H: HttpClientExt,
222    {
223        let auth = match builder.get_api_key() {
224            CopilotAuth::ApiKey(api_key) => auth::AuthSource::ApiKey(api_key.clone()),
225            CopilotAuth::GitHubAccessToken(access_token) => {
226                auth::AuthSource::GitHubAccessToken(access_token.clone())
227            }
228            CopilotAuth::OAuth => auth::AuthSource::OAuth,
229        };
230
231        let ext = builder.ext();
232        Ok(CopilotExt {
233            auth: auth::Authenticator::new(
234                auth,
235                ext.access_token_file.clone(),
236                ext.api_key_file.clone(),
237                ext.device_code_handler.clone(),
238            ),
239        })
240    }
241}
242
243impl ProviderClient for Client {
244    type Input = CopilotAuth;
245    type Error = crate::client::ProviderClientError;
246
247    fn from_env() -> Result<Self, Self::Error> {
248        let mut builder = Self::builder();
249        fn get(name: &str) -> Option<String> {
250            std::env::var(name).ok()
251        }
252
253        if let Some(base_url) = env_base_url(&get) {
254            builder = builder.base_url(base_url);
255        }
256
257        if let Some(api_key) = env_api_key(&get) {
258            builder.api_key(api_key).build().map_err(Into::into)
259        } else if let Some(access_token) = env_github_access_token(&get) {
260            builder
261                .github_access_token(access_token)
262                .build()
263                .map_err(Into::into)
264        } else {
265            builder.oauth().build().map_err(Into::into)
266        }
267    }
268
269    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
270        Self::builder().api_key(input).build().map_err(Into::into)
271    }
272}
273
274impl<H> client::ClientBuilder<CopilotBuilder, crate::markers::Missing, H> {
275    pub fn github_access_token(
276        self,
277        access_token: impl Into<String>,
278    ) -> client::ClientBuilder<CopilotBuilder, CopilotAuth, H> {
279        self.api_key(CopilotAuth::GitHubAccessToken(access_token.into()))
280    }
281
282    pub fn oauth(self) -> client::ClientBuilder<CopilotBuilder, CopilotAuth, H> {
283        self.api_key(CopilotAuth::OAuth)
284    }
285}
286
287impl<H> ClientBuilder<H> {
288    pub fn on_device_code<F>(self, handler: F) -> Self
289    where
290        F: Fn(auth::DeviceCodePrompt) + Send + Sync + 'static,
291    {
292        self.over_ext(|mut ext| {
293            ext.device_code_handler = auth::DeviceCodeHandler::new(handler);
294            ext
295        })
296    }
297
298    pub fn token_dir(self, path: impl AsRef<Path>) -> Self {
299        let path = path.as_ref();
300        self.over_ext(|mut ext| {
301            ext.access_token_file = Some(path.join("access-token"));
302            ext.api_key_file = Some(path.join("api-key.json"));
303            ext
304        })
305    }
306
307    pub fn access_token_file(self, path: impl AsRef<Path>) -> Self {
308        let path = path.as_ref().to_path_buf();
309        self.over_ext(|mut ext| {
310            ext.access_token_file = Some(path);
311            ext
312        })
313    }
314
315    pub fn api_key_file(self, path: impl AsRef<Path>) -> Self {
316        let path = path.as_ref().to_path_buf();
317        self.over_ext(|mut ext| {
318            ext.api_key_file = Some(path);
319            ext
320        })
321    }
322}
323
324fn env_value<F>(get: &F, name: &str) -> Option<String>
325where
326    F: Fn(&str) -> Option<String>,
327{
328    get(name).filter(|value| !value.trim().is_empty())
329}
330
331fn first_env_value<F>(get: &F, keys: &[&str]) -> Option<String>
332where
333    F: Fn(&str) -> Option<String>,
334{
335    keys.iter().find_map(|key| env_value(get, key))
336}
337
338fn env_api_key<F>(get: &F) -> Option<String>
339where
340    F: Fn(&str) -> Option<String>,
341{
342    first_env_value(get, &["GITHUB_COPILOT_API_KEY", "COPILOT_API_KEY"])
343}
344
345fn env_github_access_token<F>(get: &F) -> Option<String>
346where
347    F: Fn(&str) -> Option<String>,
348{
349    first_env_value(get, &["COPILOT_GITHUB_ACCESS_TOKEN", "GITHUB_TOKEN"])
350}
351
352fn env_base_url<F>(get: &F) -> Option<String>
353where
354    F: Fn(&str) -> Option<String>,
355{
356    first_env_value(get, &["GITHUB_COPILOT_API_BASE", "COPILOT_BASE_URL"])
357}
358
359impl<H> Client<H>
360where
361    H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
362{
363    pub async fn authorize(&self) -> Result<(), auth::AuthError> {
364        self.ext().auth.auth_context().await.map(|_| ())
365    }
366}
367
368fn default_headers(
369    api_key: &str,
370    initiator: &'static str,
371    has_vision: bool,
372    intent: CopilotIntent,
373) -> Vec<(&'static str, String)> {
374    let mut headers = vec![
375        (
376            http::header::AUTHORIZATION.as_str(),
377            format!("Bearer {api_key}"),
378        ),
379        ("copilot-integration-id", "vscode-chat".to_string()),
380        ("editor-version", EDITOR_VERSION.to_string()),
381        ("editor-plugin-version", EDITOR_PLUGIN_VERSION.to_string()),
382        ("user-agent", USER_AGENT.to_string()),
383        ("openai-intent", intent.as_header().to_string()),
384        ("x-github-api-version", API_VERSION.to_string()),
385        ("x-request-id", nanoid::nanoid!()),
386        (
387            "x-vscode-user-agent-library-version",
388            "electron-fetch".to_string(),
389        ),
390        ("X-Initiator", initiator.to_string()),
391    ];
392
393    if has_vision {
394        headers.push(("copilot-vision-request", "true".to_string()));
395    }
396
397    headers
398}
399
400fn apply_headers(
401    builder: http_client::Builder,
402    headers: &[(&'static str, String)],
403) -> http_client::Builder {
404    headers
405        .iter()
406        .fold(builder, |builder, (key, value)| builder.header(*key, value))
407}
408
409fn runtime_base_url<'a, H>(client: &'a Client<H>, auth: &'a auth::AuthContext) -> Cow<'a, str> {
410    if client.base_url() != GITHUB_COPILOT_API_BASE_URL {
411        return Cow::Borrowed(client.base_url());
412    }
413
414    if let Some(api_base) = auth.api_base.as_deref() {
415        return Cow::Borrowed(api_base);
416    }
417
418    if let Some(base_url) = base_url_from_token(&auth.api_key) {
419        return Cow::Owned(base_url);
420    }
421
422    Cow::Borrowed(client.base_url())
423}
424
425/// Derive the Copilot REST base URL from a chat token's `proxy-ep=` segment.
426///
427/// The endpoint is parsed from a credential string, not from explicit caller
428/// configuration. For that reason, token-derived routing is limited to GitHub
429/// Copilot service hosts and HTTPS. Callers that need a custom non-GitHub host
430/// can still opt in explicitly with [`ClientBuilder::base_url`].
431fn base_url_from_token(token: &str) -> Option<String> {
432    let proxy_ep = token
433        .split(';')
434        .find_map(|part| part.trim().strip_prefix("proxy-ep="))?
435        .trim();
436
437    normalize_copilot_proxy_endpoint(proxy_ep)
438}
439
440fn normalize_copilot_proxy_endpoint(proxy_ep: &str) -> Option<String> {
441    if proxy_ep.is_empty() {
442        return None;
443    }
444
445    let candidate = if proxy_ep.starts_with("http://") || proxy_ep.starts_with("https://") {
446        proxy_ep.to_string()
447    } else {
448        format!("https://{proxy_ep}")
449    };
450
451    let mut url = url::Url::parse(&candidate).ok()?;
452    if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
453        return None;
454    }
455    if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
456        return None;
457    }
458
459    let host = url.host_str()?.to_ascii_lowercase();
460    if !is_allowed_token_derived_copilot_host(&host) {
461        return None;
462    }
463
464    let api_host = host
465        .strip_prefix("proxy.")
466        .map(|suffix| format!("api.{suffix}"))
467        .unwrap_or(host);
468    url.set_host(Some(&api_host)).ok()?;
469
470    Some(url.to_string().trim_end_matches('/').to_string())
471}
472
473fn is_allowed_token_derived_copilot_host(host: &str) -> bool {
474    host == "githubcopilot.com" || host.ends_with(".githubcopilot.com")
475}
476
477fn post_with_auth_base<H>(
478    client: &Client<H>,
479    auth: &auth::AuthContext,
480    path: &str,
481    transport: Transport,
482) -> http_client::Result<http_client::Builder> {
483    let uri = client
484        .ext()
485        .build_uri(runtime_base_url(client, auth).as_ref(), path, transport);
486    let mut req = Request::post(uri);
487
488    if let Some(headers) = req.headers_mut() {
489        headers.extend(client.headers().iter().map(|(k, v)| (k.clone(), v.clone())));
490    }
491
492    client.ext().with_custom(req)
493}
494
495fn get_with_auth_base<H>(
496    client: &Client<H>,
497    auth: &auth::AuthContext,
498    path: &str,
499    transport: Transport,
500) -> http_client::Result<http_client::Builder> {
501    let uri = client
502        .ext()
503        .build_uri(runtime_base_url(client, auth).as_ref(), path, transport);
504    let mut req = Request::get(uri);
505
506    if let Some(headers) = req.headers_mut() {
507        headers.extend(client.headers().iter().map(|(k, v)| (k.clone(), v.clone())));
508    }
509
510    client.ext().with_custom(req)
511}
512
513fn request_initiator(request: &completion::CompletionRequest) -> &'static str {
514    for message in request.chat_history.iter() {
515        match message {
516            crate::completion::Message::Assistant { .. } => return "agent",
517            crate::completion::Message::User { content } => {
518                if content
519                    .iter()
520                    .any(|item| matches!(item, crate::message::UserContent::ToolResult(_)))
521                {
522                    return "agent";
523                }
524            }
525            crate::completion::Message::System { .. } => {}
526        }
527    }
528
529    "user"
530}
531
532fn request_has_vision(request: &completion::CompletionRequest) -> bool {
533    request.chat_history.iter().any(|message| match message {
534        crate::completion::Message::User { content } => content
535            .iter()
536            .any(|item| matches!(item, crate::message::UserContent::Image(_))),
537        _ => false,
538    })
539}
540
541#[derive(Clone, Copy, Debug, PartialEq, Eq)]
542enum CompletionRoute {
543    ChatCompletions,
544    Responses,
545}
546
547fn route_for_model(model: &str) -> CompletionRoute {
548    if model.to_ascii_lowercase().contains("codex") {
549        CompletionRoute::Responses
550    } else {
551        CompletionRoute::ChatCompletions
552    }
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
556#[serde(tag = "api", rename_all = "snake_case")]
557pub enum CopilotCompletionResponse {
558    Chat(ChatCompletionResponse),
559    Responses(Box<responses_api::CompletionResponse>),
560}
561
562#[derive(Clone, Serialize, Deserialize)]
563#[serde(tag = "api", rename_all = "snake_case")]
564pub enum CopilotStreamingResponse {
565    Chat(openai::completion::streaming::StreamingCompletionResponse),
566    Responses(responses_api::streaming::StreamingCompletionResponse),
567}
568
569impl GetTokenUsage for CopilotStreamingResponse {
570    fn token_usage(&self) -> completion::Usage {
571        match self {
572            Self::Chat(response) => response.token_usage(),
573            Self::Responses(response) => response.token_usage(),
574        }
575    }
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct ChatCompletionResponse {
580    pub id: String,
581    #[serde(default)]
582    pub object: Option<String>,
583    #[serde(default)]
584    pub created: Option<u64>,
585    pub model: String,
586    pub system_fingerprint: Option<String>,
587    pub choices: Vec<ChatChoice>,
588    pub usage: Option<openai::completion::Usage>,
589}
590
591#[derive(Clone, Debug, Serialize, Deserialize)]
592pub struct ChatChoice {
593    #[serde(default)]
594    pub index: usize,
595    pub message: openai::completion::Message,
596    pub logprobs: Option<serde_json::Value>,
597    #[serde(default)]
598    pub finish_reason: Option<String>,
599}
600
601impl TryFrom<ChatCompletionResponse> for completion::CompletionResponse<ChatCompletionResponse> {
602    type Error = CompletionError;
603
604    fn try_from(response: ChatCompletionResponse) -> Result<Self, Self::Error> {
605        let choice = response.choices.first().ok_or_else(|| {
606            CompletionError::ResponseError("Response contained no choices".to_owned())
607        })?;
608
609        let content = match &choice.message {
610            openai::completion::Message::Assistant {
611                content,
612                tool_calls,
613                ..
614            } => {
615                let mut content = content
616                    .iter()
617                    .filter_map(|c| {
618                        let s = match c {
619                            openai::completion::AssistantContent::Text { text } => text,
620                            openai::completion::AssistantContent::Refusal { refusal } => refusal,
621                        };
622                        if s.is_empty() {
623                            None
624                        } else {
625                            Some(completion::AssistantContent::text(s))
626                        }
627                    })
628                    .collect::<Vec<_>>();
629
630                content.extend(
631                    tool_calls
632                        .iter()
633                        .map(|call| {
634                            completion::AssistantContent::tool_call(
635                                &call.id,
636                                &call.function.name,
637                                call.function.arguments.clone(),
638                            )
639                        })
640                        .collect::<Vec<_>>(),
641                );
642                Ok(content)
643            }
644            _ => Err(CompletionError::ResponseError(
645                "Response did not contain a valid message or tool call".into(),
646            )),
647        }?;
648
649        let choice = crate::OneOrMany::many(content).map_err(|_| {
650            CompletionError::ResponseError(
651                "Response contained no message or tool call (empty)".to_owned(),
652            )
653        })?;
654
655        let usage = response
656            .usage
657            .as_ref()
658            .map(|usage| completion::Usage {
659                input_tokens: usage.prompt_tokens as u64,
660                output_tokens: (usage.total_tokens - usage.prompt_tokens) as u64,
661                total_tokens: usage.total_tokens as u64,
662                cached_input_tokens: usage
663                    .prompt_tokens_details
664                    .as_ref()
665                    .map(|d| d.cached_tokens as u64)
666                    .unwrap_or(0),
667                cache_creation_input_tokens: 0,
668                tool_use_prompt_tokens: 0,
669                reasoning_tokens: 0,
670            })
671            .unwrap_or_default();
672
673        Ok(completion::CompletionResponse {
674            choice,
675            usage,
676            raw_response: response,
677            message_id: None,
678        })
679    }
680}
681
682#[derive(Debug, Deserialize)]
683pub struct ChatApiErrorResponse {
684    #[serde(default)]
685    pub message: Option<String>,
686    #[serde(default)]
687    pub error: Option<String>,
688}
689
690impl ChatApiErrorResponse {
691    pub fn error_message(&self) -> &str {
692        self.message
693            .as_deref()
694            .or(self.error.as_deref())
695            .unwrap_or("unknown error")
696    }
697}
698
699#[derive(Debug, Deserialize)]
700#[serde(untagged)]
701enum ChatApiResponse<T> {
702    Ok(T),
703    Err(ChatApiErrorResponse),
704}
705
706#[derive(Clone)]
707pub struct CompletionModel<H = reqwest::Client> {
708    client: Client<H>,
709    pub model: String,
710    pub strict_tools: bool,
711    pub tool_result_array_content: bool,
712    pub intent: CopilotIntent,
713}
714
715impl<H> CompletionModel<H>
716where
717    Client<H>: HttpClientExt + Clone + Debug + 'static,
718    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
719{
720    pub fn new(client: Client<H>, model: impl Into<String>) -> Self {
721        Self {
722            client,
723            model: model.into(),
724            strict_tools: false,
725            tool_result_array_content: false,
726            intent: CopilotIntent::default(),
727        }
728    }
729
730    pub fn with_strict_tools(mut self) -> Self {
731        self.strict_tools = true;
732        self
733    }
734
735    pub fn with_tool_result_array_content(mut self) -> Self {
736        self.tool_result_array_content = true;
737        self
738    }
739
740    /// Set the Copilot `openai-intent` header for completion and streaming requests.
741    pub fn with_intent(mut self, intent: CopilotIntent) -> Self {
742        self.intent = intent;
743        self
744    }
745
746    /// Use the generic chat panel `openai-intent` header for completion and streaming requests.
747    pub fn with_panel_intent(self) -> Self {
748        self.with_intent(CopilotIntent::Panel)
749    }
750
751    /// Use the edit-oriented `openai-intent` header for completion and streaming requests.
752    pub fn with_edits_intent(self) -> Self {
753        self.with_intent(CopilotIntent::Edits)
754    }
755
756    fn route(&self) -> CompletionRoute {
757        route_for_model(&self.model)
758    }
759
760    async fn auth_context(&self) -> Result<auth::AuthContext, CompletionError> {
761        self.client
762            .ext()
763            .auth
764            .auth_context()
765            .await
766            .map_err(|err| CompletionError::ProviderError(err.to_string()))
767    }
768
769    fn chat_request(
770        &self,
771        completion_request: completion::CompletionRequest,
772    ) -> Result<openai::completion::CompletionRequest, CompletionError> {
773        openai::completion::CompletionRequest::try_from(openai::completion::OpenAIRequestParams {
774            model: self.model.clone(),
775            request: completion_request,
776            strict_tools: self.strict_tools,
777            tool_result_array_content: self.tool_result_array_content,
778        })
779    }
780
781    fn responses_request(
782        &self,
783        completion_request: completion::CompletionRequest,
784    ) -> Result<ResponsesRequest, CompletionError> {
785        ResponsesRequest::try_from((self.model.clone(), completion_request))
786    }
787
788    async fn completion_chat(
789        &self,
790        completion_request: completion::CompletionRequest,
791    ) -> Result<completion::CompletionResponse<CopilotCompletionResponse>, CompletionError> {
792        let initiator = request_initiator(&completion_request);
793        let has_vision = request_has_vision(&completion_request);
794        let request = self.chat_request(completion_request)?;
795        let body = serde_json::to_vec(&request)?;
796        let auth = self.auth_context().await?;
797
798        let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
799        let req = apply_headers(
800            post_with_auth_base(&self.client, &auth, "/chat/completions", Transport::Http)?,
801            &headers,
802        )
803        .body(body)
804        .map_err(|err| CompletionError::HttpError(err.into()))?;
805
806        let span = if tracing::Span::current().is_disabled() {
807            info_span!(
808                target: "rig::completions",
809                "chat",
810                gen_ai.operation.name = "chat",
811                gen_ai.provider.name = "copilot",
812                gen_ai.request.model = self.model,
813                gen_ai.response.id = tracing::field::Empty,
814                gen_ai.response.model = tracing::field::Empty,
815                gen_ai.usage.output_tokens = tracing::field::Empty,
816                gen_ai.usage.input_tokens = tracing::field::Empty,
817                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
818            )
819        } else {
820            tracing::Span::current()
821        };
822
823        async move {
824            let response = self.client.send(req).await?;
825
826            if response.status().is_success() {
827                let body = http_client::text(response).await?;
828                match serde_json::from_str::<ChatApiResponse<ChatCompletionResponse>>(&body)? {
829                    ChatApiResponse::Ok(response) => {
830                        let core = completion::CompletionResponse::try_from(response.clone())?;
831                        let span = tracing::Span::current();
832                        span.record("gen_ai.response.id", response.id.as_str());
833                        span.record("gen_ai.response.model", response.model.as_str());
834                        if let Some(usage) = &response.usage {
835                            span.record("gen_ai.usage.input_tokens", usage.prompt_tokens);
836                            span.record(
837                                "gen_ai.usage.output_tokens",
838                                usage.total_tokens - usage.prompt_tokens,
839                            );
840                            span.record(
841                                "gen_ai.usage.cache_read.input_tokens",
842                                usage
843                                    .prompt_tokens_details
844                                    .as_ref()
845                                    .map(|details| details.cached_tokens)
846                                    .unwrap_or(0),
847                            );
848                        }
849
850                        Ok(completion::CompletionResponse {
851                            choice: core.choice,
852                            usage: core.usage,
853                            raw_response: CopilotCompletionResponse::Chat(response),
854                            message_id: core.message_id,
855                        })
856                    }
857                    ChatApiResponse::Err(err) => Err(CompletionError::ProviderError(
858                        err.error_message().to_string(),
859                    )),
860                }
861            } else {
862                let body = http_client::text(response).await?;
863                Err(CompletionError::ProviderError(body))
864            }
865        }
866        .instrument(span)
867        .await
868    }
869
870    async fn completion_responses(
871        &self,
872        completion_request: completion::CompletionRequest,
873    ) -> Result<completion::CompletionResponse<CopilotCompletionResponse>, CompletionError> {
874        let initiator = request_initiator(&completion_request);
875        let has_vision = request_has_vision(&completion_request);
876        let request = self.responses_request(completion_request)?;
877        let auth = self.auth_context().await?;
878
879        let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
880        let req = apply_headers(
881            post_with_auth_base(&self.client, &auth, "/responses", Transport::Http)?,
882            &headers,
883        )
884        .body(serde_json::to_vec(&request)?)
885        .map_err(|err| CompletionError::HttpError(err.into()))?;
886
887        let span = if tracing::Span::current().is_disabled() {
888            info_span!(
889                target: "rig::completions",
890                "chat",
891                gen_ai.operation.name = "chat",
892                gen_ai.provider.name = "copilot",
893                gen_ai.request.model = self.model,
894                gen_ai.response.id = tracing::field::Empty,
895                gen_ai.response.model = tracing::field::Empty,
896                gen_ai.usage.output_tokens = tracing::field::Empty,
897                gen_ai.usage.input_tokens = tracing::field::Empty,
898                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
899            )
900        } else {
901            tracing::Span::current()
902        };
903
904        async move {
905            let response = self.client.send(req).await?;
906            if response.status().is_success() {
907                let body = http_client::text(response).await?;
908                let response = serde_json::from_str::<responses_api::CompletionResponse>(&body)?;
909                let core = completion::CompletionResponse::try_from(response.clone())?;
910
911                let span = tracing::Span::current();
912                span.record("gen_ai.response.id", response.id.as_str());
913                span.record("gen_ai.response.model", response.model.as_str());
914                if let Some(usage) = &response.usage {
915                    span.record("gen_ai.usage.input_tokens", usage.input_tokens);
916                    span.record("gen_ai.usage.output_tokens", usage.output_tokens);
917                    span.record(
918                        "gen_ai.usage.cache_read.input_tokens",
919                        usage
920                            .input_tokens_details
921                            .as_ref()
922                            .map(|details| details.cached_tokens)
923                            .unwrap_or(0),
924                    );
925                }
926
927                Ok(completion::CompletionResponse {
928                    choice: core.choice,
929                    usage: core.usage,
930                    raw_response: CopilotCompletionResponse::Responses(Box::new(response)),
931                    message_id: core.message_id,
932                })
933            } else {
934                let body = http_client::text(response).await?;
935                Err(CompletionError::ProviderError(body))
936            }
937        }
938        .instrument(span)
939        .await
940    }
941
942    async fn stream_chat(
943        &self,
944        completion_request: completion::CompletionRequest,
945    ) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError> {
946        let initiator = request_initiator(&completion_request);
947        let has_vision = request_has_vision(&completion_request);
948        let request = self.chat_request(completion_request)?;
949        let auth = self.auth_context().await?;
950        let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
951        let mut request_json = serde_json::to_value(&request)?;
952        let request_object = request_json.as_object_mut().ok_or_else(|| {
953            CompletionError::ResponseError("copilot request body must be a JSON object".into())
954        })?;
955        request_object.insert("stream".to_owned(), json!(true));
956        request_object.insert(
957            "stream_options".to_owned(),
958            json!({ "include_usage": true }),
959        );
960
961        let req = apply_headers(
962            post_with_auth_base(&self.client, &auth, "/chat/completions", Transport::Sse)?,
963            &headers,
964        )
965        .body(serde_json::to_vec(&request_json)?)
966        .map_err(|err| CompletionError::HttpError(err.into()))?;
967
968        let span = if tracing::Span::current().is_disabled() {
969            info_span!(
970                target: "rig::completions",
971                "chat_streaming",
972                gen_ai.operation.name = "chat_streaming",
973                gen_ai.provider.name = "copilot",
974                gen_ai.request.model = self.model,
975                gen_ai.response.id = tracing::field::Empty,
976                gen_ai.response.model = tracing::field::Empty,
977                gen_ai.usage.output_tokens = tracing::field::Empty,
978                gen_ai.usage.input_tokens = tracing::field::Empty,
979                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
980            )
981        } else {
982            tracing::Span::current()
983        };
984
985        tracing::Instrument::instrument(
986            send_copilot_chat_streaming_request(self.client.clone(), req),
987            span,
988        )
989        .await
990    }
991
992    async fn stream_responses(
993        &self,
994        completion_request: completion::CompletionRequest,
995    ) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError> {
996        let initiator = request_initiator(&completion_request);
997        let has_vision = request_has_vision(&completion_request);
998        let mut request = self.responses_request(completion_request)?;
999        request.stream = Some(true);
1000        let auth = self.auth_context().await?;
1001
1002        let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
1003        let req = apply_headers(
1004            post_with_auth_base(&self.client, &auth, "/responses", Transport::Sse)?,
1005            &headers,
1006        )
1007        .body(serde_json::to_vec(&request)?)
1008        .map_err(|err| CompletionError::HttpError(err.into()))?;
1009
1010        let span = if tracing::Span::current().is_disabled() {
1011            info_span!(
1012                target: "rig::completions",
1013                "chat_streaming",
1014                gen_ai.operation.name = "chat_streaming",
1015                gen_ai.provider.name = "copilot",
1016                gen_ai.request.model = self.model,
1017                gen_ai.response.id = tracing::field::Empty,
1018                gen_ai.response.model = tracing::field::Empty,
1019                gen_ai.usage.output_tokens = tracing::field::Empty,
1020                gen_ai.usage.input_tokens = tracing::field::Empty,
1021                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
1022            )
1023        } else {
1024            tracing::Span::current()
1025        };
1026
1027        let client = self.client.clone();
1028        let mut event_source = crate::http_client::sse::GenericEventSource::new(client, req);
1029
1030        let stream = tracing_futures::Instrument::instrument(
1031            stream! {
1032                let mut final_usage = responses_api::ResponsesUsage::new();
1033                let mut tool_calls: Vec<streaming::RawStreamingChoice<CopilotStreamingResponse>> = Vec::new();
1034                let mut tool_call_internal_ids: HashMap<String, String> = HashMap::new();
1035                let span = tracing::Span::current();
1036
1037                let mut terminated_with_error = false;
1038
1039                while let Some(event_result) = event_source.next().await {
1040                    match event_result {
1041                        Ok(crate::http_client::sse::Event::Open) => continue,
1042                        Ok(crate::http_client::sse::Event::Message(evt)) => {
1043                            if evt.data.trim().is_empty() {
1044                                continue;
1045                            }
1046
1047                            let Ok(data) = serde_json::from_str::<responses_api::streaming::StreamingCompletionChunk>(&evt.data) else {
1048                                continue;
1049                            };
1050
1051                            if let responses_api::streaming::StreamingCompletionChunk::Delta(chunk) = &data {
1052                                use responses_api::streaming::{ItemChunkKind, StreamingItemDoneOutput};
1053
1054                                match &chunk.data {
1055                                    ItemChunkKind::OutputItemAdded(message) => {
1056                                        if let StreamingItemDoneOutput { item: responses_api::Output::FunctionCall(func), .. } = message {
1057                                            let internal_call_id = tool_call_internal_ids
1058                                                .entry(func.id.clone())
1059                                                .or_insert_with(|| nanoid::nanoid!())
1060                                                .clone();
1061                                            yield Ok(RawStreamingChoice::ToolCallDelta {
1062                                                id: func.id.clone(),
1063                                                internal_call_id,
1064                                                content: streaming::ToolCallDeltaContent::Name(func.name.clone()),
1065                                            });
1066                                        }
1067                                    }
1068                                    ItemChunkKind::OutputItemDone(message) => match message {
1069                                        StreamingItemDoneOutput { item: responses_api::Output::FunctionCall(func), .. } => {
1070                                            let internal_id = tool_call_internal_ids
1071                                                .entry(func.id.clone())
1072                                                .or_insert_with(|| nanoid::nanoid!())
1073                                                .clone();
1074                                            let raw_tool_call = streaming::RawStreamingToolCall::new(
1075                                                func.id.clone(),
1076                                                func.name.clone(),
1077                                                func.arguments.clone(),
1078                                            )
1079                                            .with_internal_call_id(internal_id)
1080                                            .with_call_id(func.call_id.clone());
1081                                            tool_calls.push(RawStreamingChoice::ToolCall(raw_tool_call));
1082                                        }
1083                                        StreamingItemDoneOutput { item: responses_api::Output::Reasoning { summary, id, encrypted_content, .. }, .. } => {
1084                                            for reasoning_choice in responses_api::streaming::reasoning_choices_from_done_item(
1085                                                id,
1086                                                summary,
1087                                                encrypted_content.as_deref(),
1088                                            ) {
1089                                                match reasoning_choice {
1090                                                    RawStreamingChoice::Reasoning { id, content } => {
1091                                                        yield Ok(RawStreamingChoice::Reasoning { id, content });
1092                                                    }
1093                                                    RawStreamingChoice::ReasoningDelta { id, reasoning } => {
1094                                                        yield Ok(RawStreamingChoice::ReasoningDelta { id, reasoning });
1095                                                    }
1096                                                    _ => {}
1097                                                }
1098                                            }
1099                                        }
1100                                        StreamingItemDoneOutput { item: responses_api::Output::Message(msg), .. } => {
1101                                            yield Ok(RawStreamingChoice::MessageId(msg.id.clone()));
1102                                        }
1103                                        StreamingItemDoneOutput { item: responses_api::Output::Unknown, .. } => {}
1104                                    },
1105                                    ItemChunkKind::OutputTextDelta(delta) => {
1106                                        yield Ok(RawStreamingChoice::Message(delta.delta.clone()))
1107                                    }
1108                                    ItemChunkKind::ReasoningSummaryTextDelta(delta) => {
1109                                        yield Ok(RawStreamingChoice::ReasoningDelta { id: None, reasoning: delta.delta.clone() })
1110                                    }
1111                                    ItemChunkKind::RefusalDelta(delta) => {
1112                                        yield Ok(RawStreamingChoice::Message(delta.delta.clone()))
1113                                    }
1114                                    ItemChunkKind::FunctionCallArgsDelta(delta) => {
1115                                        if let Some(item_id) = chunk.item_id.as_ref() {
1116                                            let internal_call_id = tool_call_internal_ids
1117                                                .entry(item_id.clone())
1118                                                .or_insert_with(|| nanoid::nanoid!())
1119                                                .clone();
1120                                            yield Ok(RawStreamingChoice::ToolCallDelta {
1121                                                id: item_id.clone(),
1122                                                internal_call_id,
1123                                                content: streaming::ToolCallDeltaContent::Delta(delta.delta.clone())
1124                                            })
1125                                        }
1126                                    }
1127                                    _ => continue,
1128                                }
1129                            }
1130
1131                            if let responses_api::streaming::StreamingCompletionChunk::Response(chunk) = data {
1132                                let responses_api::streaming::ResponseChunk { kind, response, .. } = *chunk;
1133                                match kind {
1134                                    responses_api::streaming::ResponseChunkKind::ResponseCompleted => {
1135                                        span.record("gen_ai.response.id", response.id.as_str());
1136                                        span.record("gen_ai.response.model", response.model.as_str());
1137                                        if let Some(usage) = response.usage {
1138                                            final_usage = usage;
1139                                        }
1140                                    }
1141                                    responses_api::streaming::ResponseChunkKind::ResponseFailed
1142                                    | responses_api::streaming::ResponseChunkKind::ResponseIncomplete => {
1143                                        let error = response
1144                                            .error
1145                                            .as_ref()
1146                                            .map(|err| err.message.clone())
1147                                            .unwrap_or_else(|| "Copilot response stream failed".into());
1148                                        terminated_with_error = true;
1149                                        yield Err(CompletionError::ProviderError(error));
1150                                        break;
1151                                    }
1152                                    _ => continue,
1153                                }
1154                            }
1155                        }
1156                        Err(crate::http_client::Error::StreamEnded) => {
1157                            break;
1158                        }
1159                        Err(error) => {
1160                            terminated_with_error = true;
1161                            yield Err(CompletionError::ProviderError(error.to_string()));
1162                            break;
1163                        }
1164                    }
1165                }
1166
1167                event_source.close();
1168
1169                if terminated_with_error {
1170                    return;
1171                }
1172
1173                for tool_call in &tool_calls {
1174                    yield Ok(tool_call.to_owned())
1175                }
1176
1177                span.record("gen_ai.usage.input_tokens", final_usage.input_tokens);
1178                span.record("gen_ai.usage.output_tokens", final_usage.output_tokens);
1179                span.record(
1180                    "gen_ai.usage.cache_read.input_tokens",
1181                    final_usage
1182                        .input_tokens_details
1183                        .as_ref()
1184                        .map(|details| details.cached_tokens)
1185                        .unwrap_or(0),
1186                );
1187
1188                yield Ok(RawStreamingChoice::FinalResponse(
1189                    CopilotStreamingResponse::Responses(
1190                        responses_api::streaming::StreamingCompletionResponse { usage: final_usage }
1191                    )
1192                ));
1193            },
1194            span,
1195        );
1196
1197        Ok(StreamingCompletionResponse::stream(Box::pin(stream)))
1198    }
1199}
1200
1201impl<H> completion::CompletionModel for CompletionModel<H>
1202where
1203    Client<H>: HttpClientExt + Clone + Debug + 'static,
1204    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1205{
1206    type Response = CopilotCompletionResponse;
1207    type StreamingResponse = CopilotStreamingResponse;
1208    type Client = Client<H>;
1209
1210    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1211        Self::new(client.clone(), model)
1212    }
1213
1214    async fn completion(
1215        &self,
1216        completion_request: completion::CompletionRequest,
1217    ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
1218        match self.route() {
1219            CompletionRoute::ChatCompletions => self.completion_chat(completion_request).await,
1220            CompletionRoute::Responses => self.completion_responses(completion_request).await,
1221        }
1222    }
1223
1224    async fn stream(
1225        &self,
1226        completion_request: completion::CompletionRequest,
1227    ) -> Result<StreamingCompletionResponse<Self::StreamingResponse>, CompletionError> {
1228        match self.route() {
1229            CompletionRoute::ChatCompletions => self.stream_chat(completion_request).await,
1230            CompletionRoute::Responses => self.stream_responses(completion_request).await,
1231        }
1232    }
1233}
1234
1235#[derive(Clone)]
1236pub struct EmbeddingModel<H = reqwest::Client> {
1237    client: Client<H>,
1238    pub model: String,
1239    pub encoding_format: Option<openai::EncodingFormat>,
1240    pub user: Option<String>,
1241    ndims: usize,
1242}
1243
1244#[derive(Deserialize)]
1245struct CopilotEmbeddingResponse {
1246    data: Vec<CopilotEmbeddingData>,
1247}
1248
1249#[derive(Deserialize)]
1250struct CopilotEmbeddingData {
1251    embedding: Vec<serde_json::Number>,
1252}
1253
1254impl<H> EmbeddingModel<H>
1255where
1256    Client<H>: HttpClientExt + Clone + Debug + 'static,
1257    H: Clone + Default + Debug + 'static,
1258{
1259    pub fn new(client: Client<H>, model: impl Into<String>, ndims: usize) -> Self {
1260        Self {
1261            client,
1262            model: model.into(),
1263            encoding_format: None,
1264            user: None,
1265            ndims,
1266        }
1267    }
1268}
1269
1270impl<H> embeddings::EmbeddingModel for EmbeddingModel<H>
1271where
1272    Client<H>: HttpClientExt + Clone + Debug + WasmCompatSend + WasmCompatSync + 'static,
1273    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1274{
1275    const MAX_DOCUMENTS: usize = 1024;
1276    type Client = Client<H>;
1277
1278    fn make(client: &Self::Client, model: impl Into<String>, ndims: Option<usize>) -> Self {
1279        let model = model.into();
1280        let dims = ndims.unwrap_or(match model.as_str() {
1281            TEXT_EMBEDDING_3_LARGE => 3072,
1282            TEXT_EMBEDDING_3_SMALL | TEXT_EMBEDDING_ADA_002 => 1536,
1283            _ => 0,
1284        });
1285        Self::new(client.clone(), model, dims)
1286    }
1287
1288    fn ndims(&self) -> usize {
1289        self.ndims
1290    }
1291
1292    async fn embed_texts(
1293        &self,
1294        documents: impl IntoIterator<Item = String>,
1295    ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
1296        let documents = documents.into_iter().collect::<Vec<_>>();
1297        let auth = self
1298            .client
1299            .ext()
1300            .auth
1301            .auth_context()
1302            .await
1303            .map_err(|err| EmbeddingError::ProviderError(err.to_string()))?;
1304
1305        let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1306        let mut body = json!({
1307            "model": self.model,
1308            "input": documents,
1309        });
1310
1311        let body_object = body.as_object_mut().ok_or_else(|| {
1312            EmbeddingError::ResponseError("embedding request body must be a JSON object".into())
1313        })?;
1314
1315        if self.ndims > 0 && self.model.as_str() != TEXT_EMBEDDING_ADA_002 {
1316            body_object.insert("dimensions".to_owned(), json!(self.ndims));
1317        }
1318        if let Some(encoding_format) = &self.encoding_format {
1319            body_object.insert("encoding_format".to_owned(), json!(encoding_format));
1320        }
1321        if let Some(user) = &self.user {
1322            body_object.insert("user".to_owned(), json!(user));
1323        }
1324
1325        let req = apply_headers(
1326            post_with_auth_base(&self.client, &auth, "/embeddings", Transport::Http)?,
1327            &headers,
1328        )
1329        .body(serde_json::to_vec(&body)?)
1330        .map_err(|err| EmbeddingError::HttpError(err.into()))?;
1331
1332        let response = self.client.send(req).await?;
1333        if response.status().is_success() {
1334            let body: Vec<u8> = response.into_body().await?;
1335            #[derive(Deserialize)]
1336            struct NestedApiError {
1337                error: NestedApiErrorMessage,
1338            }
1339
1340            #[derive(Deserialize)]
1341            struct NestedApiErrorMessage {
1342                message: String,
1343            }
1344
1345            let body: CopilotEmbeddingResponse = match serde_json::from_slice(&body) {
1346                Ok(parsed) => parsed,
1347                Err(parse_error) => {
1348                    if let Ok(err) = serde_json::from_slice::<NestedApiError>(&body) {
1349                        return Err(EmbeddingError::ProviderError(err.error.message));
1350                    }
1351
1352                    let preview = String::from_utf8_lossy(&body);
1353                    let preview = if preview.len() > 512 {
1354                        format!("{}...", &preview[..512])
1355                    } else {
1356                        preview.into_owned()
1357                    };
1358
1359                    return Err(EmbeddingError::ProviderError(format!(
1360                        "Failed to parse Copilot embeddings response: {parse_error}; body: {preview}"
1361                    )));
1362                }
1363            };
1364
1365            Ok(body
1366                .data
1367                .into_iter()
1368                .zip(documents.into_iter())
1369                .map(|(embedding, document)| embeddings::Embedding {
1370                    document,
1371                    vec: embedding
1372                        .embedding
1373                        .into_iter()
1374                        .filter_map(|n| n.as_f64())
1375                        .collect(),
1376                })
1377                .collect())
1378        } else {
1379            let text = http_client::text(response).await?;
1380            Err(EmbeddingError::ProviderError(text))
1381        }
1382    }
1383}
1384
1385const MODEL_LISTING_PATH: &str = "/models";
1386const MODEL_LISTING_PROVIDER: &str = "Copilot";
1387
1388#[derive(Debug, Deserialize)]
1389struct ListModelsResponse {
1390    data: Vec<ListModelEntry>,
1391}
1392
1393#[derive(Debug, Deserialize)]
1394struct ListModelEntry {
1395    id: String,
1396    #[serde(default)]
1397    name: Option<String>,
1398    #[serde(default)]
1399    vendor: Option<String>,
1400    #[serde(default)]
1401    capabilities: Option<ListModelEntryCapabilities>,
1402}
1403
1404#[derive(Debug, Deserialize)]
1405struct ListModelEntryCapabilities {
1406    #[serde(default, rename = "type")]
1407    r#type: Option<String>,
1408}
1409
1410impl From<ListModelEntry> for Model {
1411    fn from(value: ListModelEntry) -> Self {
1412        let mut model = Model::from_id(value.id);
1413        model.name = value.name;
1414        model.owned_by = value.vendor;
1415        if let Some(caps) = value.capabilities {
1416            model.r#type = caps.r#type;
1417        }
1418        model
1419    }
1420}
1421
1422/// [`ModelLister`] implementation for the GitHub Copilot API (`GET /models`).
1423#[derive(Clone)]
1424pub struct CopilotModelLister<H = reqwest::Client> {
1425    client: Client<H>,
1426}
1427
1428impl<H> ModelLister<H> for CopilotModelLister<H>
1429where
1430    H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
1431{
1432    type Client = Client<H>;
1433
1434    fn new(client: Self::Client) -> Self {
1435        Self { client }
1436    }
1437
1438    async fn list_all(&self) -> Result<ModelList, ModelListingError> {
1439        let auth = self.client.ext().auth.auth_context().await.map_err(|err| {
1440            ModelListingError::AuthError {
1441                message: err.to_string(),
1442            }
1443        })?;
1444
1445        let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1446        let req = apply_headers(
1447            get_with_auth_base(&self.client, &auth, MODEL_LISTING_PATH, Transport::Http)?,
1448            &headers,
1449        )
1450        .body(http_client::NoBody)?;
1451
1452        let response = self.client.send::<_, Vec<u8>>(req).await?;
1453
1454        if !response.status().is_success() {
1455            let status_code = response.status().as_u16();
1456            let body = response.into_body().await?;
1457            return Err(ModelListingError::api_error_with_context(
1458                MODEL_LISTING_PROVIDER,
1459                MODEL_LISTING_PATH,
1460                status_code,
1461                &body,
1462            ));
1463        }
1464
1465        let body = response.into_body().await?;
1466        let api_resp: ListModelsResponse = serde_json::from_slice(&body).map_err(|error| {
1467            ModelListingError::parse_error_with_context(
1468                MODEL_LISTING_PROVIDER,
1469                MODEL_LISTING_PATH,
1470                &error,
1471                &body,
1472            )
1473        })?;
1474        let models = api_resp.data.into_iter().map(Model::from).collect();
1475
1476        Ok(ModelList::new(models))
1477    }
1478}
1479
1480#[derive(Deserialize, Debug)]
1481struct ChatStreamingFunction {
1482    name: Option<String>,
1483    arguments: Option<String>,
1484}
1485
1486#[derive(Deserialize, Debug)]
1487struct ChatStreamingToolCall {
1488    index: usize,
1489    id: Option<String>,
1490    function: ChatStreamingFunction,
1491}
1492
1493impl From<&ChatStreamingToolCall> for CompatibleToolCallChunk {
1494    fn from(value: &ChatStreamingToolCall) -> Self {
1495        Self {
1496            index: value.index,
1497            id: value.id.clone(),
1498            name: value.function.name.clone(),
1499            arguments: value.function.arguments.clone(),
1500        }
1501    }
1502}
1503
1504#[derive(Deserialize, Debug, Default)]
1505struct ChatStreamingDelta {
1506    #[serde(default)]
1507    content: Option<String>,
1508    #[serde(default)]
1509    reasoning_content: Option<String>,
1510    #[serde(default, deserialize_with = "crate::json_utils::null_or_vec")]
1511    tool_calls: Vec<ChatStreamingToolCall>,
1512}
1513
1514#[derive(Deserialize, Debug, PartialEq)]
1515#[serde(rename_all = "snake_case")]
1516enum ChatFinishReason {
1517    ToolCalls,
1518    Stop,
1519    ContentFilter,
1520    Length,
1521    #[serde(untagged)]
1522    Other(String),
1523}
1524
1525#[derive(Deserialize, Debug)]
1526struct ChatStreamingChoice {
1527    delta: ChatStreamingDelta,
1528    finish_reason: Option<ChatFinishReason>,
1529}
1530
1531#[derive(Deserialize, Debug)]
1532struct ChatStreamingChunk {
1533    id: Option<String>,
1534    model: Option<String>,
1535    choices: Vec<ChatStreamingChoice>,
1536    usage: Option<openai::completion::Usage>,
1537}
1538
1539#[derive(Clone, Copy)]
1540struct CopilotChatCompatibleProfile;
1541
1542impl CompatibleStreamProfile for CopilotChatCompatibleProfile {
1543    type Usage = openai::completion::Usage;
1544    type Detail = ();
1545    type FinalResponse = CopilotStreamingResponse;
1546
1547    fn normalize_chunk(
1548        &self,
1549        data: &str,
1550    ) -> Result<Option<CompatibleChunk<Self::Usage, Self::Detail>>, CompletionError> {
1551        let data = match serde_json::from_str::<ChatStreamingChunk>(data) {
1552            Ok(data) => data,
1553            Err(error) => {
1554                tracing::debug!(?error, "Couldn't parse Copilot chat SSE payload");
1555                return Ok(None);
1556            }
1557        };
1558
1559        Ok(Some(
1560            openai_chat_completions_compatible::normalize_first_choice_chunk(
1561                data.id,
1562                data.model,
1563                data.usage,
1564                &data.choices,
1565                |choice| CompatibleChoiceData {
1566                    finish_reason: if choice.finish_reason == Some(ChatFinishReason::ToolCalls) {
1567                        CompatibleFinishReason::ToolCalls
1568                    } else {
1569                        CompatibleFinishReason::Other
1570                    },
1571                    text: choice.delta.content.clone(),
1572                    reasoning: choice.delta.reasoning_content.clone(),
1573                    tool_calls: openai_chat_completions_compatible::tool_call_chunks(
1574                        &choice.delta.tool_calls,
1575                    ),
1576                    details: Vec::new(),
1577                },
1578            ),
1579        ))
1580    }
1581
1582    fn build_final_response(&self, usage: Self::Usage) -> Self::FinalResponse {
1583        CopilotStreamingResponse::Chat(openai::completion::streaming::StreamingCompletionResponse {
1584            usage,
1585        })
1586    }
1587
1588    fn uses_distinct_tool_call_eviction(&self) -> bool {
1589        true
1590    }
1591}
1592
1593async fn send_copilot_chat_streaming_request<T>(
1594    http_client: T,
1595    req: Request<Vec<u8>>,
1596) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError>
1597where
1598    T: HttpClientExt + Clone + 'static,
1599{
1600    openai_chat_completions_compatible::send_compatible_streaming_request(
1601        http_client,
1602        req,
1603        CopilotChatCompatibleProfile,
1604    )
1605    .await
1606}
1607
1608fn default_token_dir() -> Option<PathBuf> {
1609    config_dir().map(|dir| dir.join("github_copilot"))
1610}
1611
1612fn config_dir() -> Option<PathBuf> {
1613    #[cfg(target_os = "windows")]
1614    {
1615        std::env::var_os("APPDATA").map(PathBuf::from)
1616    }
1617
1618    #[cfg(not(target_os = "windows"))]
1619    {
1620        std::env::var_os("XDG_CONFIG_HOME")
1621            .map(PathBuf::from)
1622            .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
1623    }
1624}
1625
1626#[cfg(test)]
1627mod tests {
1628    use super::{
1629        ChatApiErrorResponse, ChatCompletionResponse, Client, CompletionRoute, CopilotIntent,
1630        TEXT_EMBEDDING_3_SMALL, base_url_from_token, default_headers, env_api_key, env_base_url,
1631        env_github_access_token, route_for_model,
1632    };
1633    use crate::client::CompletionClient;
1634    use crate::completion::CompletionModel;
1635    use crate::http_client;
1636    use crate::providers::internal::openai_chat_completions_compatible::test_support::{
1637        sse_bytes_from_data_lines, sse_bytes_from_json_events,
1638    };
1639    use crate::streaming::StreamedAssistantContent;
1640    use crate::test_utils::MockStreamingClient;
1641    use crate::test_utils::{RecordingHttpClient, SequencedStreamingHttpClient};
1642    use futures::StreamExt;
1643    use std::collections::HashMap;
1644
1645    fn env_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
1646        entries
1647            .iter()
1648            .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
1649            .collect()
1650    }
1651
1652    fn minimal_chat_response() -> &'static str {
1653        r#"{
1654            "id": "chatcmpl-123",
1655            "model": "gpt-4o",
1656            "choices": [{
1657                "index": 0,
1658                "message": {
1659                    "role": "assistant",
1660                    "content": "hello"
1661                },
1662                "finish_reason": "stop"
1663            }],
1664            "usage": {
1665                "prompt_tokens": 4,
1666                "total_tokens": 7
1667            }
1668        }"#
1669    }
1670
1671    fn minimal_responses_response() -> &'static str {
1672        r#"{
1673            "id": "resp_123",
1674            "object": "response",
1675            "created_at": 1700000000,
1676            "status": "completed",
1677            "error": null,
1678            "incomplete_details": null,
1679            "instructions": null,
1680            "max_output_tokens": null,
1681            "model": "gpt-5.3-codex",
1682            "usage": {
1683                "input_tokens": 4,
1684                "input_tokens_details": {
1685                    "cached_tokens": 0
1686                },
1687                "output_tokens": 3,
1688                "output_tokens_details": {
1689                    "reasoning_tokens": 0
1690                },
1691                "total_tokens": 7
1692            },
1693            "output": [{
1694                "type": "message",
1695                "id": "msg_123",
1696                "role": "assistant",
1697                "status": "completed",
1698                "content": [{
1699                    "type": "output_text",
1700                    "text": "hello"
1701                }]
1702            }],
1703            "tools": []
1704        }"#
1705    }
1706
1707    fn minimal_embeddings_response() -> &'static str {
1708        r#"{
1709            "data": [
1710                {
1711                    "embedding": [0.1, 0.2, 0.3]
1712                },
1713                {
1714                    "embedding": [0.4, 0.5, 0.6]
1715                }
1716            ]
1717        }"#
1718    }
1719
1720    #[test]
1721    fn deserialize_standard_openai_response() {
1722        let json = r#"{
1723            "id": "chatcmpl-abc123",
1724            "object": "chat.completion",
1725            "created": 1700000000,
1726            "model": "gpt-4o",
1727            "choices": [{
1728                "index": 0,
1729                "message": {
1730                    "role": "assistant",
1731                    "content": "Hello!"
1732                },
1733                "finish_reason": "stop"
1734            }],
1735            "usage": {
1736                "prompt_tokens": 10,
1737                "completion_tokens": 5,
1738                "total_tokens": 15
1739            }
1740        }"#;
1741
1742        let response: ChatCompletionResponse =
1743            serde_json::from_str(json).expect("standard OpenAI response should deserialize");
1744        assert_eq!(response.id, "chatcmpl-abc123");
1745        assert_eq!(response.object.as_deref(), Some("chat.completion"));
1746        assert_eq!(response.created, Some(1700000000));
1747        assert_eq!(response.model, "gpt-4o");
1748        assert_eq!(response.choices.len(), 1);
1749        assert_eq!(response.choices[0].finish_reason.as_deref(), Some("stop"));
1750    }
1751
1752    #[test]
1753    fn deserialize_copilot_response_without_object_and_created() {
1754        let response: ChatCompletionResponse = serde_json::from_str(minimal_chat_response())
1755            .expect("Copilot response should deserialize");
1756
1757        assert_eq!(response.id, "chatcmpl-123");
1758        assert_eq!(response.object, None);
1759        assert_eq!(response.created, None);
1760        assert_eq!(response.model, "gpt-4o");
1761        assert_eq!(response.choices.len(), 1);
1762    }
1763
1764    #[test]
1765    fn deserialize_copilot_response_without_finish_reason() {
1766        let json = r#"{
1767            "id": "chatcmpl-claude-001",
1768            "model": "claude-3.5-sonnet",
1769            "choices": [{
1770                "message": {
1771                    "role": "assistant",
1772                    "content": "Here is my analysis."
1773                }
1774            }],
1775            "usage": {
1776                "prompt_tokens": 50,
1777                "total_tokens": 80
1778            }
1779        }"#;
1780
1781        let response: ChatCompletionResponse =
1782            serde_json::from_str(json).expect("Claude-via-Copilot response should deserialize");
1783
1784        assert_eq!(response.model, "claude-3.5-sonnet");
1785        assert_eq!(response.choices[0].finish_reason, None);
1786        assert_eq!(response.choices[0].index, 0);
1787    }
1788
1789    #[test]
1790    fn error_response_with_message_field() {
1791        let json = r#"{"message": "rate limit exceeded"}"#;
1792        let err: ChatApiErrorResponse = serde_json::from_str(json).expect("message-shaped error");
1793
1794        assert_eq!(err.error_message(), "rate limit exceeded");
1795    }
1796
1797    #[test]
1798    fn error_response_with_error_field() {
1799        let json = r#"{"error": "model not found"}"#;
1800        let err: ChatApiErrorResponse = serde_json::from_str(json).expect("error-shaped error");
1801
1802        assert_eq!(err.error_message(), "model not found");
1803    }
1804
1805    #[test]
1806    fn routes_codex_models_to_responses() {
1807        assert_eq!(route_for_model("gpt-5.3-codex"), CompletionRoute::Responses);
1808        assert_eq!(
1809            route_for_model("gpt-5.1-CODEX-mini"),
1810            CompletionRoute::Responses
1811        );
1812        assert_eq!(route_for_model("gpt-5.2"), CompletionRoute::ChatCompletions);
1813        assert_eq!(
1814            route_for_model("claude-sonnet-4.5"),
1815            CompletionRoute::ChatCompletions
1816        );
1817    }
1818
1819    #[test]
1820    fn copilot_intent_headers_use_panel_by_default_and_edits_when_requested() {
1821        let panel_headers = default_headers("token", "user", false, CopilotIntent::default());
1822        assert_eq!(
1823            panel_headers
1824                .iter()
1825                .find(|(name, _)| *name == "openai-intent")
1826                .map(|(_, value)| value.as_str()),
1827            Some("conversation-panel")
1828        );
1829
1830        let edits_headers = default_headers("token", "user", false, CopilotIntent::Edits);
1831        assert_eq!(
1832            edits_headers
1833                .iter()
1834                .find(|(name, _)| *name == "openai-intent")
1835                .map(|(_, value)| value.as_str()),
1836            Some("conversation-edits")
1837        );
1838    }
1839
1840    #[test]
1841    fn copilot_completion_model_intent_builders_update_intent() {
1842        let client = Client::builder()
1843            .api_key("copilot-token")
1844            .build()
1845            .expect("build client");
1846
1847        let default_model = client.completion_model("gpt-4o");
1848        assert_eq!(default_model.intent.as_header(), "conversation-panel");
1849
1850        let edits_model = client
1851            .completion_model("gpt-4o")
1852            .with_intent(CopilotIntent::Edits);
1853        assert_eq!(edits_model.intent.as_header(), "conversation-edits");
1854
1855        let panel_model = client
1856            .completion_model("gpt-4o")
1857            .with_edits_intent()
1858            .with_panel_intent();
1859        assert_eq!(panel_model.intent.as_header(), "conversation-panel");
1860    }
1861
1862    #[test]
1863    fn base_url_from_token_derives_api_endpoint() {
1864        assert_eq!(
1865            base_url_from_token("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1866                .as_deref(),
1867            Some("https://api.individual.githubcopilot.com")
1868        );
1869        assert_eq!(
1870            base_url_from_token("tid=1;proxy-ep=https://proxy.individual.githubcopilot.com;exp=2")
1871                .as_deref(),
1872            Some("https://api.individual.githubcopilot.com")
1873        );
1874        assert_eq!(base_url_from_token("tid=1;exp=2"), None);
1875    }
1876
1877    #[test]
1878    fn base_url_from_token_rejects_unsafe_or_non_copilot_endpoints() {
1879        assert_eq!(
1880            base_url_from_token("tid=1;proxy-ep=http://proxy.individual.githubcopilot.com;exp=2"),
1881            None
1882        );
1883        assert_eq!(
1884            base_url_from_token("tid=1;proxy-ep=https://evil.example.com;exp=2"),
1885            None
1886        );
1887        assert_eq!(base_url_from_token("tid=1;proxy-ep=://bad;exp=2"), None);
1888        assert_eq!(base_url_from_token("tid=1;proxy-ep=;exp=2"), None);
1889        assert_eq!(
1890            base_url_from_token(
1891                "tid=1;proxy-ep=https://proxy.individual.githubcopilot.com/base;exp=2"
1892            ),
1893            None
1894        );
1895    }
1896
1897    #[tokio::test]
1898    async fn api_key_with_proxy_endpoint_overrides_base_url() {
1899        let http_client = RecordingHttpClient::new(minimal_chat_response());
1900        let client = Client::builder()
1901            .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1902            .http_client(http_client.clone())
1903            .build()
1904            .expect("build client");
1905        let model = client.completion_model("gpt-4o");
1906        let request = model.completion_request("hello").build();
1907
1908        let _response = model.completion(request).await.expect("chat completion");
1909
1910        let requests = http_client.requests();
1911        assert_eq!(requests.len(), 1);
1912        assert!(
1913            requests[0]
1914                .uri
1915                .starts_with("https://api.individual.githubcopilot.com"),
1916            "expected proxy-derived base URL, got {}",
1917            requests[0].uri
1918        );
1919    }
1920
1921    #[tokio::test]
1922    async fn explicit_base_url_wins_over_token_proxy_endpoint() {
1923        let http_client = RecordingHttpClient::new(minimal_chat_response());
1924        let client = Client::builder()
1925            .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1926            .base_url("https://custom.example.com")
1927            .http_client(http_client.clone())
1928            .build()
1929            .expect("build client");
1930        let model = client.completion_model("gpt-4o");
1931        let request = model.completion_request("hello").build();
1932
1933        let _response = model.completion(request).await.expect("chat completion");
1934
1935        let requests = http_client.requests();
1936        assert_eq!(requests.len(), 1);
1937        assert!(
1938            requests[0].uri.starts_with("https://custom.example.com"),
1939            "expected explicit base URL, got {}",
1940            requests[0].uri
1941        );
1942    }
1943
1944    #[tokio::test]
1945    async fn completion_model_edits_intent_sets_request_header() {
1946        let http_client = RecordingHttpClient::new(minimal_chat_response());
1947        let client = Client::builder()
1948            .api_key("copilot-token")
1949            .http_client(http_client.clone())
1950            .build()
1951            .expect("build client");
1952        let model = client.completion_model("gpt-4o").with_edits_intent();
1953        let request = model.completion_request("hello").build();
1954
1955        let _response = model.completion(request).await.expect("chat completion");
1956
1957        let requests = http_client.requests();
1958        assert_eq!(requests.len(), 1);
1959        assert_eq!(
1960            requests[0]
1961                .headers
1962                .get("openai-intent")
1963                .and_then(|value| value.to_str().ok()),
1964            Some("conversation-edits")
1965        );
1966    }
1967
1968    #[tokio::test]
1969    async fn completion_model_routes_chat_requests_to_chat_completions() {
1970        let http_client = RecordingHttpClient::new(minimal_chat_response());
1971        let client = Client::builder()
1972            .api_key("copilot-token")
1973            .http_client(http_client.clone())
1974            .build()
1975            .expect("build client");
1976        let model = client.completion_model("gpt-4o");
1977        let request = model.completion_request("hello").build();
1978
1979        let _response = model.completion(request).await.expect("chat completion");
1980
1981        let requests = http_client.requests();
1982        assert_eq!(requests.len(), 1);
1983        assert!(requests[0].uri.ends_with("/chat/completions"));
1984        assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-4o\""));
1985    }
1986
1987    #[tokio::test]
1988    async fn completion_model_routes_codex_requests_to_responses() {
1989        let http_client = RecordingHttpClient::new(minimal_responses_response());
1990        let client = Client::builder()
1991            .api_key("copilot-token")
1992            .http_client(http_client.clone())
1993            .build()
1994            .expect("build client");
1995        let model = client.completion_model("gpt-5.3-codex");
1996        let request = model.completion_request("hello").build();
1997
1998        let _response = model
1999            .completion(request)
2000            .await
2001            .expect("responses completion");
2002
2003        let requests = http_client.requests();
2004        assert_eq!(requests.len(), 1);
2005        assert!(requests[0].uri.ends_with("/responses"));
2006        assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-5.3-codex\""));
2007    }
2008
2009    #[tokio::test]
2010    async fn embeddings_accept_minimal_copilot_response_shape() {
2011        use crate::client::EmbeddingsClient;
2012        use crate::embeddings::EmbeddingModel as _;
2013
2014        let http_client = RecordingHttpClient::new(minimal_embeddings_response());
2015        let client = Client::builder()
2016            .api_key("copilot-token")
2017            .http_client(http_client.clone())
2018            .build()
2019            .expect("build client");
2020        let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
2021
2022        let embeddings = model
2023            .embed_texts(["one".to_string(), "two".to_string()])
2024            .await
2025            .expect("embeddings should deserialize");
2026
2027        assert_eq!(embeddings.len(), 2);
2028        assert_eq!(embeddings[0].vec, vec![0.1, 0.2, 0.3]);
2029        assert_eq!(embeddings[1].vec, vec![0.4, 0.5, 0.6]);
2030
2031        let requests = http_client.requests();
2032        assert_eq!(requests.len(), 1);
2033        assert!(requests[0].uri.ends_with("/embeddings"));
2034        assert!(
2035            String::from_utf8_lossy(&requests[0].body)
2036                .contains("\"model\":\"text-embedding-3-small\"")
2037        );
2038    }
2039
2040    #[tokio::test]
2041    async fn responses_stream_terminates_after_terminal_error() {
2042        let tool_call_done = serde_json::json!({
2043            "type": "response.output_item.done",
2044            "sequence_number": 1,
2045            "item": {
2046                "type": "function_call",
2047                "id": "fc_123",
2048                "arguments": "{}",
2049                "call_id": "call_123",
2050                "name": "example_tool",
2051                "status": "completed"
2052            }
2053        });
2054        let failed = serde_json::json!({
2055            "type": "response.failed",
2056            "sequence_number": 2,
2057            "response": {
2058                "id": "resp_123",
2059                "object": "response",
2060                "created_at": 1700000000,
2061                "status": "failed",
2062                "error": {
2063                    "code": "server_error",
2064                    "message": "Copilot response stream failed"
2065                },
2066                "incomplete_details": null,
2067                "instructions": null,
2068                "max_output_tokens": null,
2069                "model": "gpt-5.3-codex",
2070                "usage": null,
2071                "output": [],
2072                "tools": []
2073            }
2074        });
2075        let http_client = MockStreamingClient {
2076            sse_bytes: sse_bytes_from_json_events(&[tool_call_done, failed]),
2077        };
2078        let client = Client::builder()
2079            .api_key("copilot-token")
2080            .http_client(http_client)
2081            .build()
2082            .expect("build client");
2083        let model = client.completion_model("gpt-5.3-codex");
2084        let request = model.completion_request("hello").build();
2085        let mut stream = model.stream(request).await.expect("stream should start");
2086
2087        let err = match stream.next().await.expect("stream should yield an item") {
2088            Ok(_) => panic!("stream should surface a provider error"),
2089            Err(err) => err,
2090        };
2091        assert_eq!(
2092            err.to_string(),
2093            "ProviderError: Copilot response stream failed"
2094        );
2095        assert!(
2096            stream.next().await.is_none(),
2097            "responses stream should terminate immediately after a terminal error"
2098        );
2099    }
2100
2101    #[tokio::test]
2102    async fn chat_stream_terminates_after_transport_error() {
2103        let chunks = vec![
2104            Ok(sse_bytes_from_data_lines([
2105                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
2106            ])),
2107            Err(http_client::Error::InvalidStatusCode(
2108                http::StatusCode::BAD_GATEWAY,
2109            )),
2110        ];
2111
2112        let http_client = SequencedStreamingHttpClient::new(chunks);
2113        let client = Client::builder()
2114            .api_key("copilot-token")
2115            .http_client(http_client)
2116            .build()
2117            .expect("build client");
2118        let model = client.completion_model("gpt-4o");
2119        let request = model.completion_request("hello").build();
2120        let mut stream = model.stream(request).await.expect("stream should start");
2121
2122        let mut saw_error = false;
2123        while let Some(item) = stream.next().await {
2124            match item {
2125                Ok(StreamedAssistantContent::ToolCallDelta { .. }) => {}
2126                Err(err) => {
2127                    assert_eq!(
2128                        err.to_string(),
2129                        "ProviderError: Invalid status code: 502 Bad Gateway"
2130                    );
2131                    saw_error = true;
2132                    break;
2133                }
2134                Ok(_) => panic!("unexpected non-error stream item before transport failure"),
2135            }
2136        }
2137
2138        assert!(saw_error, "stream should surface the transport error");
2139        assert!(
2140            stream.next().await.is_none(),
2141            "chat stream should terminate immediately after a transport error"
2142        );
2143    }
2144
2145    #[test]
2146    fn env_api_key_prefers_github_prefixed_vars() {
2147        let env = env_map(&[
2148            ("COPILOT_API_KEY", "copilot-key"),
2149            ("GITHUB_COPILOT_API_KEY", "github-key"),
2150            ("GITHUB_TOKEN", "bootstrap-token"),
2151        ]);
2152        let get = |name: &str| env.get(name).cloned();
2153
2154        assert_eq!(env_api_key(&get).as_deref(), Some("github-key"));
2155    }
2156
2157    #[test]
2158    fn env_github_access_token_prefers_explicit_bootstrap_var() {
2159        let env = env_map(&[
2160            ("COPILOT_GITHUB_ACCESS_TOKEN", "explicit-bootstrap"),
2161            ("GITHUB_TOKEN", "fallback-bootstrap"),
2162        ]);
2163        let get = |name: &str| env.get(name).cloned();
2164
2165        assert_eq!(
2166            env_github_access_token(&get).as_deref(),
2167            Some("explicit-bootstrap")
2168        );
2169    }
2170
2171    #[test]
2172    fn env_base_url_prefers_github_prefixed_vars() {
2173        let env = env_map(&[
2174            ("COPILOT_BASE_URL", "https://copilot.example"),
2175            ("GITHUB_COPILOT_API_BASE", "https://github.example"),
2176        ]);
2177        let get = |name: &str| env.get(name).cloned();
2178
2179        assert_eq!(
2180            env_base_url(&get).as_deref(),
2181            Some("https://github.example")
2182        );
2183    }
2184
2185    #[test]
2186    fn env_without_api_key_falls_back_to_oauth() {
2187        let env = env_map(&[("COPILOT_BASE_URL", "https://copilot.example")]);
2188        let get = |name: &str| env.get(name).cloned();
2189
2190        assert!(env_api_key(&get).is_none());
2191        assert!(env_github_access_token(&get).is_none());
2192        assert_eq!(
2193            env_base_url(&get).as_deref(),
2194            Some("https://copilot.example")
2195        );
2196    }
2197
2198    #[test]
2199    fn env_github_token_is_not_treated_as_copilot_api_key() {
2200        let env = env_map(&[("GITHUB_TOKEN", "bootstrap-token")]);
2201        let get = |name: &str| env.get(name).cloned();
2202
2203        assert!(env_api_key(&get).is_none());
2204        assert_eq!(
2205            env_github_access_token(&get).as_deref(),
2206            Some("bootstrap-token")
2207        );
2208    }
2209}