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