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::telemetry::{CompletionOperation, CompletionSpanBuilder};
41use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
42use async_stream::stream;
43use futures::StreamExt;
44use http::Request;
45use serde::{Deserialize, Serialize};
46use serde_json::json;
47use std::borrow::Cow;
48use std::collections::HashMap;
49use std::fmt::Debug;
50use std::path::{Path, PathBuf};
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 system_instructions = completion_request.preamble.clone();
827        let record_telemetry_content = completion_request.record_telemetry_content;
828        let request = self.chat_request(completion_request)?;
829        let body = serde_json::to_vec(&request)?;
830        let auth = self.auth_context().await?;
831
832        let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
833        let req = apply_headers(
834            post_with_auth_base(&self.client, &auth, "/chat/completions", Transport::Http)?,
835            &headers,
836        )
837        .body(body)
838        .map_err(|err| CompletionError::HttpError(err.into()))?;
839
840        let span = CompletionSpanBuilder::new("copilot", &request.model, CompletionOperation::Chat)
841            .system_instructions(system_instructions.as_deref(), record_telemetry_content)
842            .build();
843
844        async move {
845            let response = self.client.send(req).await?;
846
847            let status = response.status();
848            if status.is_success() {
849                let body = http_client::text(response).await?;
850                match serde_json::from_str::<ChatApiResponse<ChatCompletionResponse>>(&body)? {
851                    ChatApiResponse::Ok(response) => {
852                        let core = completion::CompletionResponse::try_from(response.clone())?;
853                        let span = tracing::Span::current();
854                        span.record("gen_ai.response.id", response.id.as_str());
855                        span.record("gen_ai.response.model", response.model.as_str());
856                        if let Some(usage) = &response.usage {
857                            span.record("gen_ai.usage.input_tokens", usage.prompt_tokens);
858                            span.record(
859                                "gen_ai.usage.output_tokens",
860                                usage.total_tokens - usage.prompt_tokens,
861                            );
862                            span.record(
863                                "gen_ai.usage.cache_read.input_tokens",
864                                usage
865                                    .prompt_tokens_details
866                                    .as_ref()
867                                    .map(|details| details.cached_tokens)
868                                    .unwrap_or(0),
869                            );
870                        }
871
872                        Ok(completion::CompletionResponse {
873                            choice: core.choice,
874                            usage: core.usage,
875                            raw_response: CopilotCompletionResponse::Chat(Box::new(response)),
876                            message_id: core.message_id,
877                        })
878                    }
879                    ChatApiResponse::Err(err) => {
880                        tracing::warn!(
881                            message = %err.error_message(),
882                            "provider returned an error response"
883                        );
884                        Err(CompletionError::from_http_response(status, body))
885                    }
886                }
887            } else {
888                let body = http_client::text(response).await?;
889                Err(CompletionError::from_http_response(status, body))
890            }
891        }
892        .instrument(span)
893        .await
894    }
895
896    async fn completion_responses(
897        &self,
898        completion_request: completion::CompletionRequest,
899    ) -> Result<completion::CompletionResponse<CopilotCompletionResponse>, CompletionError> {
900        let initiator = request_initiator(&completion_request);
901        let has_vision = request_has_vision(&completion_request);
902        let system_instructions = completion_request.preamble.clone();
903        let record_telemetry_content = completion_request.record_telemetry_content;
904        let request = self.responses_request(completion_request)?;
905        let auth = self.auth_context().await?;
906
907        let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
908        let req = apply_headers(
909            post_with_auth_base(&self.client, &auth, "/responses", Transport::Http)?,
910            &headers,
911        )
912        .body(serde_json::to_vec(&request)?)
913        .map_err(|err| CompletionError::HttpError(err.into()))?;
914
915        let span = CompletionSpanBuilder::new("copilot", &request.model, CompletionOperation::Chat)
916            .system_instructions(system_instructions.as_deref(), record_telemetry_content)
917            .build();
918
919        async move {
920            let response = self.client.send(req).await?;
921            let status = response.status();
922            if status.is_success() {
923                let body = http_client::text(response).await?;
924                let response = serde_json::from_str::<responses_api::CompletionResponse>(&body)?;
925                let core = completion::CompletionResponse::try_from(response.clone())?;
926
927                let span = tracing::Span::current();
928                span.record("gen_ai.response.id", response.id.as_str());
929                span.record("gen_ai.response.model", response.model.as_str());
930                if let Some(usage) = &response.usage {
931                    span.record("gen_ai.usage.input_tokens", usage.input_tokens);
932                    span.record("gen_ai.usage.output_tokens", usage.output_tokens);
933                    span.record(
934                        "gen_ai.usage.cache_read.input_tokens",
935                        usage
936                            .input_tokens_details
937                            .as_ref()
938                            .map(|details| details.cached_tokens)
939                            .unwrap_or(0),
940                    );
941                }
942
943                Ok(completion::CompletionResponse {
944                    choice: core.choice,
945                    usage: core.usage,
946                    raw_response: CopilotCompletionResponse::Responses(Box::new(response)),
947                    message_id: core.message_id,
948                })
949            } else {
950                let body = http_client::text(response).await?;
951                Err(CompletionError::from_http_response(status, body))
952            }
953        }
954        .instrument(span)
955        .await
956    }
957
958    async fn stream_chat(
959        &self,
960        completion_request: completion::CompletionRequest,
961    ) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError> {
962        let initiator = request_initiator(&completion_request);
963        let has_vision = request_has_vision(&completion_request);
964        let system_instructions = completion_request.preamble.clone();
965        let record_telemetry_content = completion_request.record_telemetry_content;
966        let request = self.chat_request(completion_request)?;
967        let auth = self.auth_context().await?;
968        let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
969        let mut request_json = serde_json::to_value(&request)?;
970        let request_object = request_json.as_object_mut().ok_or_else(|| {
971            CompletionError::ResponseError("copilot request body must be a JSON object".into())
972        })?;
973        request_object.insert("stream".to_owned(), json!(true));
974        request_object.insert(
975            "stream_options".to_owned(),
976            json!({ "include_usage": true }),
977        );
978
979        let req = apply_headers(
980            post_with_auth_base(&self.client, &auth, "/chat/completions", Transport::Sse)?,
981            &headers,
982        )
983        .body(serde_json::to_vec(&request_json)?)
984        .map_err(|err| CompletionError::HttpError(err.into()))?;
985
986        let span = CompletionSpanBuilder::new(
987            "copilot",
988            &request.model,
989            CompletionOperation::ChatStreaming,
990        )
991        .system_instructions(system_instructions.as_deref(), record_telemetry_content)
992        .build();
993
994        tracing::Instrument::instrument(
995            send_copilot_chat_streaming_request(self.client.clone(), req),
996            span,
997        )
998        .await
999    }
1000
1001    async fn stream_responses(
1002        &self,
1003        completion_request: completion::CompletionRequest,
1004    ) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError> {
1005        let initiator = request_initiator(&completion_request);
1006        let has_vision = request_has_vision(&completion_request);
1007        let system_instructions = completion_request.preamble.clone();
1008        let record_telemetry_content = completion_request.record_telemetry_content;
1009        let mut request = self.responses_request(completion_request)?;
1010        request.stream = Some(true);
1011        let auth = self.auth_context().await?;
1012
1013        let headers = default_headers(&auth.api_key, initiator, has_vision, self.intent);
1014        let req = apply_headers(
1015            post_with_auth_base(&self.client, &auth, "/responses", Transport::Sse)?,
1016            &headers,
1017        )
1018        .body(serde_json::to_vec(&request)?)
1019        .map_err(|err| CompletionError::HttpError(err.into()))?;
1020
1021        let span = CompletionSpanBuilder::new(
1022            "copilot",
1023            &request.model,
1024            CompletionOperation::ChatStreaming,
1025        )
1026        .system_instructions(system_instructions.as_deref(), record_telemetry_content)
1027        .build();
1028
1029        let client = self.client.clone();
1030        let mut event_source = crate::http_client::sse::GenericEventSource::new(client, req);
1031
1032        let stream = tracing_futures::Instrument::instrument(
1033            stream! {
1034                let mut final_usage = responses_api::ResponsesUsage::new();
1035                let mut reasoning_metadata = None;
1036                let mut reasoning_context = None;
1037                let mut tool_calls: Vec<streaming::RawStreamingChoice<CopilotStreamingResponse>> = Vec::new();
1038                let mut tool_call_internal_ids: HashMap<String, String> = HashMap::new();
1039                let span = tracing::Span::current();
1040
1041                let mut terminated_with_error = false;
1042
1043                while let Some(event_result) = event_source.next().await {
1044                    match event_result {
1045                        Ok(crate::http_client::sse::Event::Open) => continue,
1046                        Ok(crate::http_client::sse::Event::Message(evt)) => {
1047                            if evt.data.trim().is_empty() {
1048                                continue;
1049                            }
1050
1051                            let Ok(data) = serde_json::from_str::<responses_api::streaming::StreamingCompletionChunk>(&evt.data) else {
1052                                continue;
1053                            };
1054
1055                            if let responses_api::streaming::StreamingCompletionChunk::Delta(chunk) = &data {
1056                                use responses_api::streaming::{ItemChunkKind, StreamingItemDoneOutput};
1057
1058                                match &chunk.data {
1059                                    ItemChunkKind::OutputItemAdded(message) => {
1060                                        if let StreamingItemDoneOutput { item: responses_api::Output::FunctionCall(func), .. } = message {
1061                                            let internal_call_id = tool_call_internal_ids
1062                                                .entry(func.id.clone())
1063                                                .or_insert_with(crate::id::generate)
1064                                                .clone();
1065                                            yield Ok(RawStreamingChoice::ToolCallDelta {
1066                                                id: func.id.clone(),
1067                                                internal_call_id,
1068                                                content: streaming::ToolCallDeltaContent::Name(func.name.clone()),
1069                                            });
1070                                        }
1071                                    }
1072                                    ItemChunkKind::OutputItemDone(message) => match message {
1073                                        StreamingItemDoneOutput { item: responses_api::Output::FunctionCall(func), .. } => {
1074                                            let internal_id = tool_call_internal_ids
1075                                                .entry(func.id.clone())
1076                                                .or_insert_with(crate::id::generate)
1077                                                .clone();
1078                                            let raw_tool_call = streaming::RawStreamingToolCall::new(
1079                                                func.id.clone(),
1080                                                func.name.clone(),
1081                                                func.arguments.clone(),
1082                                            )
1083                                            .with_internal_call_id(internal_id)
1084                                            .with_call_id(func.call_id.clone());
1085                                            tool_calls.push(RawStreamingChoice::ToolCall(raw_tool_call));
1086                                        }
1087                                        StreamingItemDoneOutput { item: responses_api::Output::Reasoning { summary, id, content, encrypted_content, .. }, .. } => {
1088                                            for reasoning_choice in responses_api::streaming::reasoning_choices_from_done_item(
1089                                                id,
1090                                                summary,
1091                                                content,
1092                                                encrypted_content.as_deref(),
1093                                            ) {
1094                                                match reasoning_choice {
1095                                                    RawStreamingChoice::Reasoning { id, content } => {
1096                                                        yield Ok(RawStreamingChoice::Reasoning { id, content });
1097                                                    }
1098                                                    RawStreamingChoice::ReasoningDelta { id, reasoning } => {
1099                                                        yield Ok(RawStreamingChoice::ReasoningDelta { id, reasoning });
1100                                                    }
1101                                                    _ => {}
1102                                                }
1103                                            }
1104                                        }
1105                                        StreamingItemDoneOutput { item: responses_api::Output::Message(msg), .. } => {
1106                                            yield Ok(RawStreamingChoice::MessageId(msg.id.clone()));
1107                                        }
1108                                        // Surface an unmodeled output item (e.g. a hosted-tool result) to the consumer verbatim.
1109                                        StreamingItemDoneOutput { item: responses_api::Output::Unknown(value), .. } => {
1110                                            yield Ok(RawStreamingChoice::Unknown(value.clone()));
1111                                        }
1112                                    },
1113                                    ItemChunkKind::OutputTextDelta(delta) => {
1114                                        yield Ok(RawStreamingChoice::Message(delta.delta.clone()))
1115                                    }
1116                                    ItemChunkKind::ReasoningSummaryTextDelta(delta) => {
1117                                        yield Ok(RawStreamingChoice::ReasoningDelta { id: None, reasoning: delta.delta.clone() })
1118                                    }
1119                                    ItemChunkKind::RefusalDelta(delta) => {
1120                                        yield Ok(RawStreamingChoice::Message(delta.delta.clone()))
1121                                    }
1122                                    ItemChunkKind::FunctionCallArgsDelta(delta) => {
1123                                        if let Some(item_id) = chunk.item_id.as_ref() {
1124                                            let internal_call_id = tool_call_internal_ids
1125                                                .entry(item_id.clone())
1126                                                .or_insert_with(crate::id::generate)
1127                                                .clone();
1128                                            yield Ok(RawStreamingChoice::ToolCallDelta {
1129                                                id: item_id.clone(),
1130                                                internal_call_id,
1131                                                content: streaming::ToolCallDeltaContent::Delta(delta.delta.clone())
1132                                            })
1133                                        }
1134                                    }
1135                                    _ => continue,
1136                                }
1137                            }
1138
1139                            if let responses_api::streaming::StreamingCompletionChunk::Response(chunk) = data {
1140                                let responses_api::streaming::ResponseChunk { kind, response, .. } = *chunk;
1141                                match kind {
1142                                    responses_api::streaming::ResponseChunkKind::ResponseCompleted => {
1143                                        span.record("gen_ai.response.id", response.id.as_str());
1144                                        span.record("gen_ai.response.model", response.model.as_str());
1145                                        if let Some(usage) = response.usage {
1146                                            final_usage = usage;
1147                                        }
1148                                        if response.reasoning_metadata.is_some() {
1149                                            reasoning_metadata = response.reasoning_metadata;
1150                                        }
1151                                        if response.reasoning_context.is_some() {
1152                                            reasoning_context = response.reasoning_context;
1153                                        }
1154                                    }
1155                                    responses_api::streaming::ResponseChunkKind::ResponseFailed
1156                                    | responses_api::streaming::ResponseChunkKind::ResponseIncomplete => {
1157                                        terminated_with_error = true;
1158                                        // Deliberate two-tier behaviour matching the OpenAI Responses
1159                                        // SSE path: when the provider supplies an error object we
1160                                        // preserve the raw event JSON via `completion_error_from_body`
1161                                        // so the `provider_response_*` helpers surface the full payload
1162                                        // (code + message). The error arrives over an established 2xx
1163                                        // stream, so there is no HTTP status to attach (status: None).
1164                                        // When the object is absent we emit a Rig-authored
1165                                        // `ProviderError` diagnostic (provider_response_body() is None).
1166                                        match response.error.as_ref() {
1167                                            Some(_) => yield Err(
1168                                                crate::provider_response::completion_error_from_body(
1169                                                    evt.data.clone(),
1170                                                ),
1171                                            ),
1172                                            None => yield Err(CompletionError::ProviderError(
1173                                                "Copilot response stream failed".into(),
1174                                            )),
1175                                        }
1176                                        break;
1177                                    }
1178                                    _ => continue,
1179                                }
1180                            }
1181                        }
1182                        Err(crate::http_client::Error::StreamEnded) => {
1183                            break;
1184                        }
1185                        Err(error) => {
1186                            terminated_with_error = true;
1187                            yield Err(CompletionError::from_stream_transport(error));
1188                            break;
1189                        }
1190                    }
1191                }
1192
1193                event_source.close();
1194
1195                if terminated_with_error {
1196                    return;
1197                }
1198
1199                for tool_call in &tool_calls {
1200                    yield Ok(tool_call.to_owned())
1201                }
1202
1203                span.record("gen_ai.usage.input_tokens", final_usage.input_tokens);
1204                span.record("gen_ai.usage.output_tokens", final_usage.output_tokens);
1205                span.record(
1206                    "gen_ai.usage.cache_read.input_tokens",
1207                    final_usage
1208                        .input_tokens_details
1209                        .as_ref()
1210                        .map(|details| details.cached_tokens)
1211                        .unwrap_or(0),
1212                );
1213
1214                yield Ok(RawStreamingChoice::FinalResponse(
1215                    CopilotStreamingResponse::Responses(
1216                        responses_api::streaming::StreamingCompletionResponse {
1217                            usage: final_usage,
1218                            reasoning_metadata,
1219                            reasoning_context,
1220                        }
1221                    )
1222                ));
1223            },
1224            span,
1225        );
1226
1227        Ok(StreamingCompletionResponse::stream(Box::pin(stream)))
1228    }
1229}
1230
1231impl<H> completion::CompletionModel for CompletionModel<H>
1232where
1233    Client<H>: HttpClientExt + Clone + Debug + 'static,
1234    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1235{
1236    type Response = CopilotCompletionResponse;
1237    type StreamingResponse = CopilotStreamingResponse;
1238    type Client = Client<H>;
1239
1240    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1241        Self::new(client.clone(), model)
1242    }
1243
1244    async fn completion(
1245        &self,
1246        completion_request: completion::CompletionRequest,
1247    ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
1248        match self.route() {
1249            CompletionRoute::ChatCompletions => self.completion_chat(completion_request).await,
1250            CompletionRoute::Responses => self.completion_responses(completion_request).await,
1251        }
1252    }
1253
1254    async fn stream(
1255        &self,
1256        completion_request: completion::CompletionRequest,
1257    ) -> Result<StreamingCompletionResponse<Self::StreamingResponse>, CompletionError> {
1258        match self.route() {
1259            CompletionRoute::ChatCompletions => self.stream_chat(completion_request).await,
1260            CompletionRoute::Responses => self.stream_responses(completion_request).await,
1261        }
1262    }
1263}
1264
1265#[derive(Clone)]
1266pub struct EmbeddingModel<H = reqwest::Client> {
1267    client: Client<H>,
1268    pub model: String,
1269    pub encoding_format: Option<openai::EncodingFormat>,
1270    pub user: Option<String>,
1271    ndims: usize,
1272}
1273
1274#[derive(Deserialize)]
1275struct CopilotEmbeddingResponse {
1276    data: Vec<CopilotEmbeddingData>,
1277}
1278
1279#[derive(Deserialize)]
1280struct CopilotEmbeddingData {
1281    embedding: Vec<serde_json::Number>,
1282}
1283
1284impl<H> EmbeddingModel<H>
1285where
1286    Client<H>: HttpClientExt + Clone + Debug + 'static,
1287    H: Clone + Default + Debug + 'static,
1288{
1289    pub fn new(client: Client<H>, model: impl Into<String>, ndims: usize) -> Self {
1290        Self {
1291            client,
1292            model: model.into(),
1293            encoding_format: None,
1294            user: None,
1295            ndims,
1296        }
1297    }
1298}
1299
1300impl<H> embeddings::EmbeddingModel for EmbeddingModel<H>
1301where
1302    Client<H>: HttpClientExt + Clone + Debug + WasmCompatSend + WasmCompatSync + 'static,
1303    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
1304{
1305    const MAX_DOCUMENTS: usize = 1024;
1306    type Client = Client<H>;
1307
1308    fn make(client: &Self::Client, model: impl Into<String>, ndims: Option<usize>) -> Self {
1309        let model = model.into();
1310        let dims = ndims.unwrap_or(match model.as_str() {
1311            TEXT_EMBEDDING_3_LARGE => 3072,
1312            TEXT_EMBEDDING_3_SMALL | TEXT_EMBEDDING_ADA_002 => 1536,
1313            _ => 0,
1314        });
1315        Self::new(client.clone(), model, dims)
1316    }
1317
1318    fn ndims(&self) -> usize {
1319        self.ndims
1320    }
1321
1322    async fn embed_texts(
1323        &self,
1324        documents: impl IntoIterator<Item = String>,
1325    ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
1326        let documents = documents.into_iter().collect::<Vec<_>>();
1327        let auth = self
1328            .client
1329            .ext()
1330            .auth
1331            .auth_context()
1332            .await
1333            .map_err(|err| EmbeddingError::ProviderError(err.to_string()))?;
1334
1335        let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1336        let mut body = json!({
1337            "model": self.model,
1338            "input": documents,
1339        });
1340
1341        let body_object = body.as_object_mut().ok_or_else(|| {
1342            EmbeddingError::ResponseError("embedding request body must be a JSON object".into())
1343        })?;
1344
1345        if self.ndims > 0 && self.model.as_str() != TEXT_EMBEDDING_ADA_002 {
1346            body_object.insert("dimensions".to_owned(), json!(self.ndims));
1347        }
1348        if let Some(encoding_format) = &self.encoding_format {
1349            body_object.insert("encoding_format".to_owned(), json!(encoding_format));
1350        }
1351        if let Some(user) = &self.user {
1352            body_object.insert("user".to_owned(), json!(user));
1353        }
1354
1355        let req = apply_headers(
1356            post_with_auth_base(&self.client, &auth, "/embeddings", Transport::Http)?,
1357            &headers,
1358        )
1359        .body(serde_json::to_vec(&body)?)
1360        .map_err(|err| EmbeddingError::HttpError(err.into()))?;
1361
1362        let response = self.client.send(req).await?;
1363        let status = response.status();
1364        if status.is_success() {
1365            let body: Vec<u8> = response.into_body().await?;
1366            #[derive(Deserialize)]
1367            struct NestedApiError {
1368                error: NestedApiErrorMessage,
1369            }
1370
1371            #[derive(Deserialize)]
1372            struct NestedApiErrorMessage {
1373                message: String,
1374            }
1375
1376            let body: CopilotEmbeddingResponse = match serde_json::from_slice(&body) {
1377                Ok(parsed) => parsed,
1378                Err(parse_error) => {
1379                    if let Ok(err) = serde_json::from_slice::<NestedApiError>(&body) {
1380                        tracing::warn!(message = %err.error.message, "provider returned an error response");
1381                        return Err(EmbeddingError::from_http_response(
1382                            status,
1383                            String::from_utf8_lossy(&body).into_owned(),
1384                        ));
1385                    }
1386
1387                    let preview = String::from_utf8_lossy(&body);
1388                    let preview = if preview.len() > 512 {
1389                        format!("{}...", &preview[..512])
1390                    } else {
1391                        preview.into_owned()
1392                    };
1393
1394                    return Err(EmbeddingError::ProviderError(format!(
1395                        "Failed to parse Copilot embeddings response: {parse_error}; body: {preview}"
1396                    )));
1397                }
1398            };
1399
1400            Ok(body
1401                .data
1402                .into_iter()
1403                .zip(documents.into_iter())
1404                .map(|(embedding, document)| embeddings::Embedding {
1405                    document,
1406                    vec: embedding
1407                        .embedding
1408                        .into_iter()
1409                        .filter_map(|n| n.as_f64())
1410                        .collect(),
1411                })
1412                .collect())
1413        } else {
1414            let text = http_client::text(response).await?;
1415            Err(EmbeddingError::from_http_response(status, text))
1416        }
1417    }
1418}
1419
1420const MODEL_LISTING_PATH: &str = "/models";
1421const MODEL_LISTING_PROVIDER: &str = "Copilot";
1422
1423#[derive(Debug, Deserialize)]
1424struct ListModelsResponse {
1425    data: Vec<ListModelEntry>,
1426}
1427
1428#[derive(Debug, Deserialize)]
1429struct ListModelEntry {
1430    id: String,
1431    #[serde(default)]
1432    name: Option<String>,
1433    #[serde(default)]
1434    vendor: Option<String>,
1435    #[serde(default)]
1436    capabilities: Option<ListModelEntryCapabilities>,
1437}
1438
1439#[derive(Debug, Deserialize)]
1440struct ListModelEntryCapabilities {
1441    #[serde(default, rename = "type")]
1442    r#type: Option<String>,
1443}
1444
1445impl From<ListModelEntry> for Model {
1446    fn from(value: ListModelEntry) -> Self {
1447        let mut model = Model::from_id(value.id);
1448        model.name = value.name;
1449        model.owned_by = value.vendor;
1450        if let Some(caps) = value.capabilities {
1451            model.r#type = caps.r#type;
1452        }
1453        model
1454    }
1455}
1456
1457/// [`ModelLister`] implementation for the GitHub Copilot API (`GET /models`).
1458#[derive(Clone)]
1459pub struct CopilotModelLister<H = reqwest::Client> {
1460    client: Client<H>,
1461}
1462
1463impl<H> ModelLister<H> for CopilotModelLister<H>
1464where
1465    H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
1466{
1467    type Client = Client<H>;
1468
1469    fn new(client: Self::Client) -> Self {
1470        Self { client }
1471    }
1472
1473    async fn list_all(&self) -> Result<ModelList, ModelListingError> {
1474        let auth = self.client.ext().auth.auth_context().await.map_err(|err| {
1475            ModelListingError::AuthError {
1476                message: err.to_string(),
1477            }
1478        })?;
1479
1480        let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel);
1481        let req = apply_headers(
1482            get_with_auth_base(&self.client, &auth, MODEL_LISTING_PATH, Transport::Http)?,
1483            &headers,
1484        )
1485        .body(http_client::NoBody)?;
1486
1487        let response = self.client.send::<_, Vec<u8>>(req).await?;
1488
1489        if !response.status().is_success() {
1490            let status_code = response.status().as_u16();
1491            let body = response.into_body().await?;
1492            return Err(ModelListingError::api_error_with_context(
1493                MODEL_LISTING_PROVIDER,
1494                MODEL_LISTING_PATH,
1495                status_code,
1496                &body,
1497            ));
1498        }
1499
1500        let body = response.into_body().await?;
1501        let api_resp: ListModelsResponse = serde_json::from_slice(&body).map_err(|error| {
1502            ModelListingError::parse_error_with_context(
1503                MODEL_LISTING_PROVIDER,
1504                MODEL_LISTING_PATH,
1505                &error,
1506                &body,
1507            )
1508        })?;
1509        let models = api_resp.data.into_iter().map(Model::from).collect();
1510
1511        Ok(ModelList::new(models))
1512    }
1513}
1514
1515#[derive(Deserialize, Debug)]
1516struct ChatStreamingFunction {
1517    name: Option<String>,
1518    arguments: Option<String>,
1519}
1520
1521#[derive(Deserialize, Debug)]
1522struct ChatStreamingToolCall {
1523    index: usize,
1524    id: Option<String>,
1525    function: ChatStreamingFunction,
1526}
1527
1528impl From<&ChatStreamingToolCall> for CompatibleToolCallChunk {
1529    fn from(value: &ChatStreamingToolCall) -> Self {
1530        Self {
1531            index: value.index,
1532            id: value.id.clone(),
1533            name: value.function.name.clone(),
1534            arguments: value.function.arguments.clone(),
1535        }
1536    }
1537}
1538
1539#[derive(Deserialize, Debug, Default)]
1540struct ChatStreamingDelta {
1541    #[serde(default)]
1542    content: Option<String>,
1543    #[serde(default)]
1544    reasoning_content: Option<String>,
1545    #[serde(default, deserialize_with = "crate::json_utils::null_or_vec")]
1546    tool_calls: Vec<ChatStreamingToolCall>,
1547}
1548
1549#[derive(Deserialize, Debug, PartialEq)]
1550#[serde(rename_all = "snake_case")]
1551enum ChatFinishReason {
1552    ToolCalls,
1553    Stop,
1554    ContentFilter,
1555    Length,
1556    #[serde(untagged)]
1557    Other(String),
1558}
1559
1560#[derive(Deserialize, Debug)]
1561struct ChatStreamingChoice {
1562    delta: ChatStreamingDelta,
1563    finish_reason: Option<ChatFinishReason>,
1564}
1565
1566#[derive(Deserialize, Debug)]
1567struct ChatStreamingChunk {
1568    id: Option<String>,
1569    model: Option<String>,
1570    choices: Vec<ChatStreamingChoice>,
1571    usage: Option<openai::completion::Usage>,
1572}
1573
1574#[derive(Clone, Copy)]
1575struct CopilotChatCompatibleProfile;
1576
1577impl CompatibleStreamProfile for CopilotChatCompatibleProfile {
1578    type Usage = openai::completion::Usage;
1579    type Detail = ();
1580    type FinalResponse = CopilotStreamingResponse;
1581
1582    fn normalize_chunk(
1583        &self,
1584        data: &str,
1585    ) -> Result<Option<CompatibleChunk<Self::Usage, Self::Detail>>, CompletionError> {
1586        let data = match serde_json::from_str::<ChatStreamingChunk>(data) {
1587            Ok(data) => data,
1588            Err(error) => {
1589                tracing::debug!(?error, "Couldn't parse Copilot chat SSE payload");
1590                return Ok(None);
1591            }
1592        };
1593
1594        Ok(Some(
1595            openai_chat_completions_compatible::normalize_first_choice_chunk(
1596                data.id,
1597                data.model,
1598                data.usage,
1599                &data.choices,
1600                |choice| CompatibleChoiceData {
1601                    finish_reason: if choice.finish_reason == Some(ChatFinishReason::ToolCalls) {
1602                        CompatibleFinishReason::ToolCalls
1603                    } else {
1604                        CompatibleFinishReason::Other
1605                    },
1606                    text: choice.delta.content.clone(),
1607                    reasoning: choice.delta.reasoning_content.clone(),
1608                    tool_calls: openai_chat_completions_compatible::tool_call_chunks(
1609                        &choice.delta.tool_calls,
1610                    ),
1611                    details: Vec::new(),
1612                },
1613            ),
1614        ))
1615    }
1616
1617    fn build_final_response(&self, usage: Self::Usage) -> Self::FinalResponse {
1618        CopilotStreamingResponse::Chat(openai::completion::streaming::StreamingCompletionResponse {
1619            usage,
1620        })
1621    }
1622
1623    fn uses_distinct_tool_call_eviction(&self) -> bool {
1624        true
1625    }
1626}
1627
1628async fn send_copilot_chat_streaming_request<T>(
1629    http_client: T,
1630    req: Request<Vec<u8>>,
1631) -> Result<StreamingCompletionResponse<CopilotStreamingResponse>, CompletionError>
1632where
1633    T: HttpClientExt + Clone + 'static,
1634{
1635    openai_chat_completions_compatible::send_compatible_streaming_request(
1636        http_client,
1637        req,
1638        CopilotChatCompatibleProfile,
1639    )
1640    .await
1641}
1642
1643fn default_token_dir() -> Option<PathBuf> {
1644    config_dir().map(|dir| dir.join("github_copilot"))
1645}
1646
1647fn config_dir() -> Option<PathBuf> {
1648    #[cfg(target_os = "windows")]
1649    {
1650        std::env::var_os("APPDATA").map(PathBuf::from)
1651    }
1652
1653    #[cfg(not(target_os = "windows"))]
1654    {
1655        std::env::var_os("XDG_CONFIG_HOME")
1656            .map(PathBuf::from)
1657            .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
1658    }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663    use super::{
1664        ChatApiErrorResponse, ChatCompletionResponse, Client, CompletionRoute, CopilotIntent,
1665        TEXT_EMBEDDING_3_SMALL, base_url_from_token, default_headers, env_api_key, env_base_url,
1666        env_github_access_token, route_for_model,
1667    };
1668    use crate::client::CompletionClient;
1669    use crate::completion::CompletionModel;
1670    use crate::http_client;
1671    use crate::providers::internal::openai_chat_completions_compatible::test_support::{
1672        sse_bytes_from_data_lines, sse_bytes_from_json_events,
1673    };
1674    use crate::streaming::StreamedAssistantContent;
1675    use crate::test_utils::MockStreamingClient;
1676    use crate::test_utils::{RecordingHttpClient, SequencedStreamingHttpClient};
1677    use futures::StreamExt;
1678    use std::collections::HashMap;
1679
1680    fn env_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
1681        entries
1682            .iter()
1683            .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
1684            .collect()
1685    }
1686
1687    fn minimal_chat_response() -> &'static str {
1688        r#"{
1689            "id": "chatcmpl-123",
1690            "model": "gpt-4o",
1691            "choices": [{
1692                "index": 0,
1693                "message": {
1694                    "role": "assistant",
1695                    "content": "hello"
1696                },
1697                "finish_reason": "stop"
1698            }],
1699            "usage": {
1700                "prompt_tokens": 4,
1701                "total_tokens": 7
1702            }
1703        }"#
1704    }
1705
1706    fn minimal_responses_response() -> &'static str {
1707        r#"{
1708            "id": "resp_123",
1709            "object": "response",
1710            "created_at": 1700000000,
1711            "status": "completed",
1712            "error": null,
1713            "incomplete_details": null,
1714            "instructions": null,
1715            "max_output_tokens": null,
1716            "model": "gpt-5.3-codex",
1717            "usage": {
1718                "input_tokens": 4,
1719                "input_tokens_details": {
1720                    "cached_tokens": 0
1721                },
1722                "output_tokens": 3,
1723                "output_tokens_details": {
1724                    "reasoning_tokens": 0
1725                },
1726                "total_tokens": 7
1727            },
1728            "output": [{
1729                "type": "message",
1730                "id": "msg_123",
1731                "role": "assistant",
1732                "status": "completed",
1733                "content": [{
1734                    "type": "output_text",
1735                    "text": "hello"
1736                }]
1737            }],
1738            "tools": []
1739        }"#
1740    }
1741
1742    fn minimal_embeddings_response() -> &'static str {
1743        r#"{
1744            "data": [
1745                {
1746                    "embedding": [0.1, 0.2, 0.3]
1747                },
1748                {
1749                    "embedding": [0.4, 0.5, 0.6]
1750                }
1751            ]
1752        }"#
1753    }
1754
1755    #[test]
1756    fn deserialize_standard_openai_response() {
1757        let json = r#"{
1758            "id": "chatcmpl-abc123",
1759            "object": "chat.completion",
1760            "created": 1700000000,
1761            "model": "gpt-4o",
1762            "choices": [{
1763                "index": 0,
1764                "message": {
1765                    "role": "assistant",
1766                    "content": "Hello!"
1767                },
1768                "finish_reason": "stop"
1769            }],
1770            "usage": {
1771                "prompt_tokens": 10,
1772                "completion_tokens": 5,
1773                "total_tokens": 15
1774            }
1775        }"#;
1776
1777        let response: ChatCompletionResponse =
1778            serde_json::from_str(json).expect("standard OpenAI response should deserialize");
1779        assert_eq!(response.id, "chatcmpl-abc123");
1780        assert_eq!(response.object.as_deref(), Some("chat.completion"));
1781        assert_eq!(response.created, Some(1700000000));
1782        assert_eq!(response.model, "gpt-4o");
1783        assert_eq!(response.choices.len(), 1);
1784        assert_eq!(response.choices[0].finish_reason.as_deref(), Some("stop"));
1785    }
1786
1787    #[test]
1788    fn deserialize_copilot_response_without_object_and_created() {
1789        let response: ChatCompletionResponse = serde_json::from_str(minimal_chat_response())
1790            .expect("Copilot response should deserialize");
1791
1792        assert_eq!(response.id, "chatcmpl-123");
1793        assert_eq!(response.object, None);
1794        assert_eq!(response.created, None);
1795        assert_eq!(response.model, "gpt-4o");
1796        assert_eq!(response.choices.len(), 1);
1797    }
1798
1799    #[test]
1800    fn deserialize_copilot_response_without_finish_reason() {
1801        let json = r#"{
1802            "id": "chatcmpl-claude-001",
1803            "model": "claude-3.5-sonnet",
1804            "choices": [{
1805                "message": {
1806                    "role": "assistant",
1807                    "content": "Here is my analysis."
1808                }
1809            }],
1810            "usage": {
1811                "prompt_tokens": 50,
1812                "total_tokens": 80
1813            }
1814        }"#;
1815
1816        let response: ChatCompletionResponse =
1817            serde_json::from_str(json).expect("Claude-via-Copilot response should deserialize");
1818
1819        assert_eq!(response.model, "claude-3.5-sonnet");
1820        assert_eq!(response.choices[0].finish_reason, None);
1821        assert_eq!(response.choices[0].index, 0);
1822    }
1823
1824    #[test]
1825    fn error_response_with_message_field() {
1826        let json = r#"{"message": "rate limit exceeded"}"#;
1827        let err: ChatApiErrorResponse = serde_json::from_str(json).expect("message-shaped error");
1828
1829        assert_eq!(err.error_message(), "rate limit exceeded");
1830    }
1831
1832    #[test]
1833    fn error_response_with_error_field() {
1834        let json = r#"{"error": "model not found"}"#;
1835        let err: ChatApiErrorResponse = serde_json::from_str(json).expect("error-shaped error");
1836
1837        assert_eq!(err.error_message(), "model not found");
1838    }
1839
1840    #[test]
1841    fn routes_codex_models_to_responses() {
1842        assert_eq!(route_for_model("gpt-5.3-codex"), CompletionRoute::Responses);
1843        assert_eq!(
1844            route_for_model("gpt-5.1-CODEX-mini"),
1845            CompletionRoute::Responses
1846        );
1847        assert_eq!(route_for_model("gpt-5.2"), CompletionRoute::ChatCompletions);
1848        assert_eq!(
1849            route_for_model("claude-sonnet-4.5"),
1850            CompletionRoute::ChatCompletions
1851        );
1852    }
1853
1854    #[test]
1855    fn copilot_intent_headers_use_panel_by_default_and_edits_when_requested() {
1856        let panel_headers = default_headers("token", "user", false, CopilotIntent::default());
1857        assert_eq!(
1858            panel_headers
1859                .iter()
1860                .find(|(name, _)| *name == "openai-intent")
1861                .map(|(_, value)| value.as_str()),
1862            Some("conversation-panel")
1863        );
1864
1865        let edits_headers = default_headers("token", "user", false, CopilotIntent::Edits);
1866        assert_eq!(
1867            edits_headers
1868                .iter()
1869                .find(|(name, _)| *name == "openai-intent")
1870                .map(|(_, value)| value.as_str()),
1871            Some("conversation-edits")
1872        );
1873    }
1874
1875    #[test]
1876    fn copilot_completion_model_intent_builders_update_intent() {
1877        let client = Client::builder()
1878            .api_key("copilot-token")
1879            .build()
1880            .expect("build client");
1881
1882        let default_model = client.completion_model("gpt-4o");
1883        assert_eq!(default_model.intent.as_header(), "conversation-panel");
1884
1885        let edits_model = client
1886            .completion_model("gpt-4o")
1887            .with_intent(CopilotIntent::Edits);
1888        assert_eq!(edits_model.intent.as_header(), "conversation-edits");
1889
1890        let panel_model = client
1891            .completion_model("gpt-4o")
1892            .with_edits_intent()
1893            .with_panel_intent();
1894        assert_eq!(panel_model.intent.as_header(), "conversation-panel");
1895    }
1896
1897    #[test]
1898    fn base_url_from_token_derives_api_endpoint() {
1899        assert_eq!(
1900            base_url_from_token("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1901                .as_deref(),
1902            Some("https://api.individual.githubcopilot.com")
1903        );
1904        assert_eq!(
1905            base_url_from_token("tid=1;proxy-ep=https://proxy.individual.githubcopilot.com;exp=2")
1906                .as_deref(),
1907            Some("https://api.individual.githubcopilot.com")
1908        );
1909        assert_eq!(base_url_from_token("tid=1;exp=2"), None);
1910    }
1911
1912    #[test]
1913    fn base_url_from_token_rejects_unsafe_or_non_copilot_endpoints() {
1914        assert_eq!(
1915            base_url_from_token("tid=1;proxy-ep=http://proxy.individual.githubcopilot.com;exp=2"),
1916            None
1917        );
1918        assert_eq!(
1919            base_url_from_token("tid=1;proxy-ep=https://evil.example.com;exp=2"),
1920            None
1921        );
1922        assert_eq!(base_url_from_token("tid=1;proxy-ep=://bad;exp=2"), None);
1923        assert_eq!(base_url_from_token("tid=1;proxy-ep=;exp=2"), None);
1924        assert_eq!(
1925            base_url_from_token(
1926                "tid=1;proxy-ep=https://proxy.individual.githubcopilot.com/base;exp=2"
1927            ),
1928            None
1929        );
1930    }
1931
1932    #[tokio::test]
1933    async fn api_key_with_proxy_endpoint_overrides_base_url() {
1934        let http_client = RecordingHttpClient::new(minimal_chat_response());
1935        let client = Client::builder()
1936            .api_key("tid=1;proxy-ep=proxy.individual.githubcopilot.com;exp=2")
1937            .http_client(http_client.clone())
1938            .build()
1939            .expect("build client");
1940        let model = client.completion_model("gpt-4o");
1941        let request = model.completion_request("hello").build();
1942
1943        let _response = model.completion(request).await.expect("chat completion");
1944
1945        let requests = http_client.requests();
1946        assert_eq!(requests.len(), 1);
1947        assert!(
1948            requests[0]
1949                .uri
1950                .starts_with("https://api.individual.githubcopilot.com"),
1951            "expected proxy-derived base URL, got {}",
1952            requests[0].uri
1953        );
1954    }
1955
1956    #[tokio::test]
1957    async fn explicit_base_url_wins_over_token_proxy_endpoint() {
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            .base_url("https://custom.example.com")
1962            .http_client(http_client.clone())
1963            .build()
1964            .expect("build client");
1965        let model = client.completion_model("gpt-4o");
1966        let request = model.completion_request("hello").build();
1967
1968        let _response = model.completion(request).await.expect("chat completion");
1969
1970        let requests = http_client.requests();
1971        assert_eq!(requests.len(), 1);
1972        assert!(
1973            requests[0].uri.starts_with("https://custom.example.com"),
1974            "expected explicit base URL, got {}",
1975            requests[0].uri
1976        );
1977    }
1978
1979    #[tokio::test]
1980    async fn completion_model_edits_intent_sets_request_header() {
1981        let http_client = RecordingHttpClient::new(minimal_chat_response());
1982        let client = Client::builder()
1983            .api_key("copilot-token")
1984            .http_client(http_client.clone())
1985            .build()
1986            .expect("build client");
1987        let model = client.completion_model("gpt-4o").with_edits_intent();
1988        let request = model.completion_request("hello").build();
1989
1990        let _response = model.completion(request).await.expect("chat completion");
1991
1992        let requests = http_client.requests();
1993        assert_eq!(requests.len(), 1);
1994        assert_eq!(
1995            requests[0]
1996                .headers
1997                .get("openai-intent")
1998                .and_then(|value| value.to_str().ok()),
1999            Some("conversation-edits")
2000        );
2001    }
2002
2003    #[tokio::test]
2004    async fn completion_model_routes_chat_requests_to_chat_completions() {
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");
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!(requests[0].uri.ends_with("/chat/completions"));
2019        assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-4o\""));
2020    }
2021
2022    #[tokio::test]
2023    async fn completion_model_routes_codex_requests_to_responses() {
2024        let http_client = RecordingHttpClient::new(minimal_responses_response());
2025        let client = Client::builder()
2026            .api_key("copilot-token")
2027            .http_client(http_client.clone())
2028            .build()
2029            .expect("build client");
2030        let model = client.completion_model("gpt-5.3-codex");
2031        let request = model.completion_request("hello").build();
2032
2033        let _response = model
2034            .completion(request)
2035            .await
2036            .expect("responses completion");
2037
2038        let requests = http_client.requests();
2039        assert_eq!(requests.len(), 1);
2040        assert!(requests[0].uri.ends_with("/responses"));
2041        assert!(String::from_utf8_lossy(&requests[0].body).contains("\"model\":\"gpt-5.3-codex\""));
2042    }
2043
2044    #[tokio::test]
2045    async fn embeddings_accept_minimal_copilot_response_shape() {
2046        use crate::client::EmbeddingsClient;
2047        use crate::embeddings::EmbeddingModel as _;
2048
2049        let http_client = RecordingHttpClient::new(minimal_embeddings_response());
2050        let client = Client::builder()
2051            .api_key("copilot-token")
2052            .http_client(http_client.clone())
2053            .build()
2054            .expect("build client");
2055        let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
2056
2057        let embeddings = model
2058            .embed_texts(["one".to_string(), "two".to_string()])
2059            .await
2060            .expect("embeddings should deserialize");
2061
2062        assert_eq!(embeddings.len(), 2);
2063        assert_eq!(embeddings[0].vec, vec![0.1, 0.2, 0.3]);
2064        assert_eq!(embeddings[1].vec, vec![0.4, 0.5, 0.6]);
2065
2066        let requests = http_client.requests();
2067        assert_eq!(requests.len(), 1);
2068        assert!(requests[0].uri.ends_with("/embeddings"));
2069        assert!(
2070            String::from_utf8_lossy(&requests[0].body)
2071                .contains("\"model\":\"text-embedding-3-small\"")
2072        );
2073    }
2074
2075    #[tokio::test]
2076    async fn responses_stream_terminates_after_terminal_error() {
2077        let tool_call_done = serde_json::json!({
2078            "type": "response.output_item.done",
2079            "sequence_number": 1,
2080            "item": {
2081                "type": "function_call",
2082                "id": "fc_123",
2083                "arguments": "{}",
2084                "call_id": "call_123",
2085                "name": "example_tool",
2086                "status": "completed"
2087            }
2088        });
2089        let failed = serde_json::json!({
2090            "type": "response.failed",
2091            "sequence_number": 2,
2092            "response": {
2093                "id": "resp_123",
2094                "object": "response",
2095                "created_at": 1700000000,
2096                "status": "failed",
2097                "error": {
2098                    "code": "server_error",
2099                    "message": "Copilot response stream failed"
2100                },
2101                "incomplete_details": null,
2102                "instructions": null,
2103                "max_output_tokens": null,
2104                "model": "gpt-5.3-codex",
2105                "usage": null,
2106                "output": [],
2107                "tools": []
2108            }
2109        });
2110        let http_client = MockStreamingClient {
2111            sse_bytes: sse_bytes_from_json_events(&[tool_call_done, failed]),
2112        };
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-5.3-codex");
2119        let request = model.completion_request("hello").build();
2120        let mut stream = model.stream(request).await.expect("stream should start");
2121
2122        let err = match stream.next().await.expect("stream should yield an item") {
2123            Ok(_) => panic!("stream should surface a provider error"),
2124            Err(err) => err,
2125        };
2126        // The terminal `response.failed` event carries the provider's error
2127        // payload, so the full raw event JSON is preserved for inspection
2128        // (status: None — the error arrived over an already-established stream),
2129        // matching the OpenAI Responses SSE path.
2130        assert!(matches!(
2131            err,
2132            crate::completion::CompletionError::ProviderResponse(_)
2133        ));
2134        assert_eq!(err.provider_response_status(), None);
2135        let json = err
2136            .provider_response_json()
2137            .expect("preserved body should parse as JSON")
2138            .expect("preserved body should not be empty");
2139        let response_error = json
2140            .get("response")
2141            .and_then(|response| response.get("error"))
2142            .expect("preserved body should retain the provider error object");
2143        assert_eq!(
2144            response_error.get("code").and_then(|value| value.as_str()),
2145            Some("server_error")
2146        );
2147        assert_eq!(
2148            response_error
2149                .get("message")
2150                .and_then(|value| value.as_str()),
2151            Some("Copilot response stream failed")
2152        );
2153        assert!(
2154            stream.next().await.is_none(),
2155            "responses stream should terminate immediately after a terminal error"
2156        );
2157    }
2158
2159    #[tokio::test]
2160    async fn responses_stream_preserves_reasoning_metadata_on_final_response() {
2161        let metadata = serde_json::json!({
2162            "context": "all_turns",
2163            "effort": "ultra",
2164            "summary": null,
2165            "future_control": true
2166        });
2167        let completed = serde_json::json!({
2168            "type": "response.completed",
2169            "sequence_number": 1,
2170            "response": {
2171                "id": "resp_123",
2172                "object": "response",
2173                "created_at": 1700000000,
2174                "status": "completed",
2175                "error": null,
2176                "incomplete_details": null,
2177                "instructions": null,
2178                "max_output_tokens": null,
2179                "model": "gpt-5.3-codex",
2180                "reasoning": metadata.clone(),
2181                "usage": null,
2182                "output": [],
2183                "tools": []
2184            }
2185        });
2186        let http_client = MockStreamingClient {
2187            sse_bytes: sse_bytes_from_json_events(&[completed]),
2188        };
2189        let client = Client::builder()
2190            .api_key("copilot-token")
2191            .http_client(http_client)
2192            .build()
2193            .expect("build client");
2194        let model = client.completion_model("gpt-5.3-codex");
2195        let request = model.completion_request("hello").build();
2196        let mut stream = model.stream(request).await.expect("stream should start");
2197
2198        while let Some(item) = stream.next().await {
2199            if let StreamedAssistantContent::Final(super::CopilotStreamingResponse::Responses(
2200                response,
2201            )) = item.expect("completed stream should not error")
2202            {
2203                assert_eq!(response.reasoning_context.as_deref(), Some("all_turns"));
2204                assert_eq!(response.reasoning_metadata.as_ref(), metadata.as_object());
2205                return;
2206            }
2207        }
2208
2209        panic!("responses stream should yield a final response");
2210    }
2211
2212    #[tokio::test]
2213    async fn chat_stream_terminates_after_transport_error() {
2214        let chunks = vec![
2215            Ok(sse_bytes_from_data_lines([
2216                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
2217            ])),
2218            Err(http_client::Error::InvalidStatusCode(
2219                http::StatusCode::BAD_GATEWAY,
2220            )),
2221        ];
2222
2223        let http_client = SequencedStreamingHttpClient::new(chunks);
2224        let client = Client::builder()
2225            .api_key("copilot-token")
2226            .http_client(http_client)
2227            .build()
2228            .expect("build client");
2229        let model = client.completion_model("gpt-4o");
2230        let request = model.completion_request("hello").build();
2231        let mut stream = model.stream(request).await.expect("stream should start");
2232
2233        let mut saw_error = false;
2234        while let Some(item) = stream.next().await {
2235            match item {
2236                Ok(StreamedAssistantContent::ToolCallDelta { .. }) => {}
2237                Err(err) => {
2238                    assert_eq!(
2239                        err.to_string(),
2240                        "HttpError: Invalid status code: 502 Bad Gateway"
2241                    );
2242                    assert_eq!(
2243                        err.provider_response_status(),
2244                        Some(http::StatusCode::BAD_GATEWAY)
2245                    );
2246                    assert_eq!(err.provider_response_body(), None);
2247                    saw_error = true;
2248                    break;
2249                }
2250                Ok(_) => panic!("unexpected non-error stream item before transport failure"),
2251            }
2252        }
2253
2254        assert!(saw_error, "stream should surface the transport error");
2255        assert!(
2256            stream.next().await.is_none(),
2257            "chat stream should terminate immediately after a transport error"
2258        );
2259    }
2260
2261    #[test]
2262    fn env_api_key_prefers_github_prefixed_vars() {
2263        let env = env_map(&[
2264            ("COPILOT_API_KEY", "copilot-key"),
2265            ("GITHUB_COPILOT_API_KEY", "github-key"),
2266            ("GITHUB_TOKEN", "bootstrap-token"),
2267        ]);
2268        let get = |name: &str| env.get(name).cloned();
2269
2270        assert_eq!(env_api_key(&get).as_deref(), Some("github-key"));
2271    }
2272
2273    #[test]
2274    fn env_github_access_token_prefers_explicit_bootstrap_var() {
2275        let env = env_map(&[
2276            ("COPILOT_GITHUB_ACCESS_TOKEN", "explicit-bootstrap"),
2277            ("GITHUB_TOKEN", "fallback-bootstrap"),
2278        ]);
2279        let get = |name: &str| env.get(name).cloned();
2280
2281        assert_eq!(
2282            env_github_access_token(&get).as_deref(),
2283            Some("explicit-bootstrap")
2284        );
2285    }
2286
2287    #[test]
2288    fn env_base_url_prefers_github_prefixed_vars() {
2289        let env = env_map(&[
2290            ("COPILOT_BASE_URL", "https://copilot.example"),
2291            ("GITHUB_COPILOT_API_BASE", "https://github.example"),
2292        ]);
2293        let get = |name: &str| env.get(name).cloned();
2294
2295        assert_eq!(
2296            env_base_url(&get).as_deref(),
2297            Some("https://github.example")
2298        );
2299    }
2300
2301    #[test]
2302    fn env_without_api_key_falls_back_to_oauth() {
2303        let env = env_map(&[("COPILOT_BASE_URL", "https://copilot.example")]);
2304        let get = |name: &str| env.get(name).cloned();
2305
2306        assert!(env_api_key(&get).is_none());
2307        assert!(env_github_access_token(&get).is_none());
2308        assert_eq!(
2309            env_base_url(&get).as_deref(),
2310            Some("https://copilot.example")
2311        );
2312    }
2313
2314    #[test]
2315    fn env_github_token_is_not_treated_as_copilot_api_key() {
2316        let env = env_map(&[("GITHUB_TOKEN", "bootstrap-token")]);
2317        let get = |name: &str| env.get(name).cloned();
2318
2319        assert!(env_api_key(&get).is_none());
2320        assert_eq!(
2321            env_github_access_token(&get).as_deref(),
2322            Some("bootstrap-token")
2323        );
2324    }
2325}