Skip to main content

rig_core/providers/copilot/
wire.rs

1//! Copilot configuration and completion, embedding, and catalogue wires.
2//! Completion selects the Responses route for Codex model identifiers and
3//! Chat Completions otherwise. Credentials must be exchanged before encoding.
4//!
5//! ```no_run
6//! use rig_core::providers::copilot::{Copilot, GPT_4O};
7//!
8//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
9//! let mut model = Copilot::from_env()?.completion(GPT_4O);
10//! model.wire = model.wire.with_edits_intent();
11//! # Ok(())
12//! # }
13//! ```
14
15use serde::{Deserialize, Serialize};
16
17use crate::client::env::{self, EnvError};
18use crate::completion::CompletionRequest;
19use crate::error::EncodeError;
20use crate::operation::Completion;
21use crate::providers::openai::responses_api::SystemInstructionsPlacement;
22/// Copilot's embeddings wire is the shared one, pointed at Copilot by
23/// [`Copilot::embedding`](crate::providers::copilot::Copilot::embedding); the editor envelope is the dialect's modality
24/// hook.
25pub use crate::providers::openai::wire::Embeddings;
26use crate::providers::openai::wire::{
27    Dialect, DialectHooks, EmbeddingQuirks, OpenAIConfig, OpenAiDecoder, OpenAiReassembler,
28    OpenAiWire, Quirks, ResponsesQuirks, Route,
29};
30use crate::wire::{Body, Descriptor, Encoded, Mode, Secret, Wire};
31
32use super::{CopilotIntent, PROVIDER_NAME};
33
34/// The reply header carrying Copilot's transport request id. Copilot relays
35/// OpenAI's wire on both routes, header included.
36pub(super) const REQUEST_ID_HEADER: Option<&str> = Some("x-request-id");
37
38/// Credential variable named in missing-credential errors.
39const PRIMARY_API_KEY_ENV: &str = "GITHUB_COPILOT_API_KEY";
40
41/// Session-token variables in precedence order.
42const API_KEY_ENV: [&str; 2] = ["GITHUB_COPILOT_API_KEY", "COPILOT_API_KEY"];
43
44/// The base-URL override, in order of precedence.
45const BASE_URL_ENV: &[&str] = &["GITHUB_COPILOT_API_BASE", "COPILOT_BASE_URL"];
46
47/// Copilot dialect with model-based routing and editor-identity headers.
48/// Responses requests place system messages in `input`. Verification uses
49/// token exchange rather than a dedicated endpoint.
50pub const DIALECT: Dialect = Dialect {
51    base_url_env: Some("GITHUB_COPILOT_API_BASE"),
52    request_id_header: REQUEST_ID_HEADER,
53    quirks: Quirks {
54        hooks: Some(&HOOKS),
55        verify_path: "",
56        base_url_env_alias: Some("COPILOT_BASE_URL"),
57        // Copilot keeps no files, so no file id resolves there.
58        accepts_file_ids: false,
59        embedding: EmbeddingQuirks {
60            requires_usage: false,
61            ..EmbeddingQuirks::openai()
62        },
63        responses: ResponsesQuirks {
64            strict_tools_by_default: true,
65            system_instructions: SystemInstructionsPlacement::InputSystemMessages,
66            ..ResponsesQuirks::openai()
67        },
68        ..Quirks::openai()
69    },
70    ..Dialect::gateway(
71        PROVIDER_NAME,
72        super::GITHUB_COPILOT_API_BASE_URL,
73        "GITHUB_COPILOT_API_KEY",
74    )
75};
76
77static HOOKS: DialectHooks = DialectHooks {
78    default_endpoint: Some(super::auth::base_url_from_token),
79    model_route: Some(|model| {
80        if routes_through_responses(model) {
81            Route::Responses
82        } else {
83            Route::Chat
84        }
85    }),
86    completion_envelope: Some(|provider, request, builder| {
87        completion_envelope(provider, request, builder, CopilotIntent::default())
88    }),
89    // Non-conversational modality requests use panel intent and a user initiator.
90    modality_envelope: Some(|provider, request| {
91        stamp(
92            request,
93            provider.api_key.expose(),
94            "user",
95            false,
96            CopilotIntent::Panel,
97        )
98    }),
99};
100
101/// The same envelope calculation serves the dialect hook and the public wrapper.
102fn completion_envelope(
103    provider: &OpenAIConfig,
104    request: &CompletionRequest,
105    mut builder: http::request::Builder,
106    intent: CopilotIntent,
107) -> http::request::Builder {
108    for (name, value) in super::default_headers(
109        provider.api_key.expose(),
110        super::request_initiator(request),
111        super::request_has_vision(request),
112        intent,
113    ) {
114        if let Some(headers) = builder.headers_mut() {
115            headers.remove(name);
116        }
117        builder = builder.header(name, value);
118    }
119    builder
120}
121
122/// Return whether `model` contains `codex`, case-insensitively, selecting `/responses`.
123pub fn routes_through_responses(model: &str) -> bool {
124    model.to_ascii_lowercase().contains("codex")
125}
126
127/// Copilot's configuration: plain data, credential redacted.
128///
129/// The credential is an *exchanged* Copilot session token, not a GitHub
130/// OAuth token: see the module docs and [`Self::from_auth`].
131#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
132pub struct CopilotConfig {
133    /// The exchanged session token. Never serialized (see [`Secret`]).
134    pub api_key: Secret,
135    /// The API root every path resolves against.
136    pub base_url: String,
137}
138
139impl CopilotConfig {
140    /// Configure Copilot with an exchanged session token.
141    /// Derive a permitted endpoint from `proxy-ep=` when present, otherwise use
142    /// the default. Explicit base-URL settings override token-derived routing.
143    pub fn new(api_key: impl Into<Secret>) -> Self {
144        let provider = OpenAIConfig::with_key(&DIALECT, api_key);
145        Self {
146            api_key: provider.api_key,
147            base_url: provider.base_url,
148        }
149    }
150
151    /// Configure Copilot from an exchanged auth context.
152    /// The context's API base takes precedence over token-derived routing.
153    pub fn from_auth(context: &super::auth::AuthContext) -> Self {
154        let mut provider = Self::new(context.api_key.clone());
155        if let Some(api_base) = &context.api_base {
156            provider.base_url = api_base.clone();
157        }
158        provider
159    }
160
161    /// Read an exchanged token from `GITHUB_COPILOT_API_KEY` or `COPILOT_API_KEY`.
162    /// Read the optional base URL from `GITHUB_COPILOT_API_BASE` or `COPILOT_BASE_URL`.
163    /// Earlier nonblank variables take precedence. Return an error for missing
164    /// credentials or invalid environment values; this does not perform token exchange.
165    pub fn from_env() -> Result<Self, EnvError> {
166        let Some(api_key) = first_env(&API_KEY_ENV)? else {
167            return Err(EnvError::Variable {
168                name: PRIMARY_API_KEY_ENV,
169                source: std::env::VarError::NotPresent,
170            });
171        };
172        let mut provider = Self::new(api_key);
173        if let Some(base_url) = first_env(BASE_URL_ENV)? {
174            provider.base_url = base_url;
175        }
176        Ok(provider)
177    }
178
179    /// Override the base URL.
180    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
181        self.base_url = base_url.into();
182        self
183    }
184
185    /// The completion wire for `model`, on whichever route answers it.
186    pub(crate) fn completion(&self, model: impl Into<String>) -> CopilotWire {
187        CopilotWire {
188            wire: self.openai().completion(model),
189            intent: CopilotIntent::default(),
190        }
191    }
192
193    /// Build an embedding wire with Copilot's editor headers and optional usage.
194    /// Use `ndims` when supplied, otherwise the shared wire's model default.
195    pub(crate) fn embedding(&self, model: impl Into<String>, ndims: Option<usize>) -> Embeddings {
196        Embeddings::new(self.openai(), model, ndims)
197    }
198
199    /// Convert to shared configuration with Copilot's dialect and explicit endpoint.
200    fn openai(&self) -> OpenAIConfig {
201        OpenAIConfig::with_key(&DIALECT, self.api_key.clone()).with_base_url(self.base_url.clone())
202    }
203
204    /// Resolve `path` against the base URL.
205    pub(super) fn uri(&self, path: &str) -> String {
206        format!("{}{path}", self.base_url.trim_end_matches('/'))
207    }
208}
209
210/// Read the first nonblank environment variable in `names`, preserving its value.
211/// Return an error if an encountered variable cannot be decoded.
212fn first_env(names: &[&'static str]) -> Result<Option<String>, EnvError> {
213    for name in names {
214        if let Some(value) = env::optional(name)?.filter(|value| !value.trim().is_empty()) {
215            return Ok(Some(value));
216        }
217    }
218    Ok(None)
219}
220
221/// Stamp Copilot's request envelope onto a built request.
222///
223/// Used by the modality hook and the catalogue wire. `insert` replaces the
224/// shared authentication header rather than appending a second credential.
225/// Completion routes use `completion_envelope` during encoding instead.
226pub(super) fn stamp(
227    request: &mut http::Request<Body>,
228    api_key: &str,
229    initiator: &'static str,
230    has_vision: bool,
231    intent: CopilotIntent,
232) -> Result<(), http::Error> {
233    let map = request.headers_mut();
234    for (name, value) in super::default_headers(api_key, initiator, has_vision, intent) {
235        map.insert(
236            http::HeaderName::from_bytes(name.as_bytes())?,
237            http::HeaderValue::from_str(&value)?,
238        );
239    }
240    Ok(())
241}
242
243/// Completion wire with Copilot's conversation intent and editor headers.
244/// Delegates payload handling to `wire`, but overrides its request envelope
245/// even when that field contains another dialect.
246#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
247pub struct CopilotWire {
248    /// The route's wire, pointed at Copilot.
249    pub wire: OpenAiWire,
250    /// The conversation intent this turn declares (`openai-intent`).
251    pub intent: CopilotIntent,
252}
253
254impl CopilotWire {
255    /// The conversation intent this wire declares.
256    pub fn intent(&self) -> CopilotIntent {
257        self.intent
258    }
259
260    /// Declare `intent` in the `openai-intent` header.
261    pub fn with_intent(mut self, intent: CopilotIntent) -> Self {
262        self.intent = intent;
263        self
264    }
265
266    /// Declare the generic chat-panel conversation semantics.
267    pub fn with_panel_intent(self) -> Self {
268        self.with_intent(CopilotIntent::Panel)
269    }
270
271    /// Declare the edit-oriented conversation semantics.
272    pub fn with_edits_intent(self) -> Self {
273        self.with_intent(CopilotIntent::Edits)
274    }
275
276    /// Sanitize tool schemas for strict mode on whichever route answers.
277    ///
278    /// The shared [`Responses::new`](crate::providers::openai::responses_api::wire::Responses::new)
279    /// constructor already enables this for Copilot, so this is the chat route's opt-in.
280    pub fn with_strict_tools(mut self) -> Self {
281        self.wire = self.wire.with_strict_tools();
282        self
283    }
284
285    /// Serialize tool-result content as arrays.
286    ///
287    /// A chat-completions shape: the Responses request has one content
288    /// encoding, so this is a no-op on that route.
289    pub fn with_tool_result_array_content(mut self) -> Self {
290        if let OpenAiWire::Chat(wire) = self.wire {
291            self.wire = OpenAiWire::Chat(wire.with_tool_result_array_content());
292        }
293        self
294    }
295}
296
297impl Wire for CopilotWire {
298    type Op = Completion;
299    type Payload = crate::wire::Encoded;
300    type Frame = crate::wire::WireFrame;
301    type Decoder<'id> = OpenAiDecoder;
302    type Reassembler = OpenAiReassembler;
303
304    fn describe(&self) -> Descriptor<'_> {
305        self.wire.describe()
306    }
307
308    fn encode(&self, request: CompletionRequest, mode: Mode) -> Result<Encoded, EncodeError> {
309        self.wire
310            .encode_with_headers(request, mode, |provider, request, builder| {
311                completion_envelope(provider, request, provider.headers(builder), self.intent)
312            })
313    }
314
315    fn decoder<'id>(&self) -> Self::Decoder<'id> {
316        self.wire.decoder()
317    }
318
319    fn reassembler(&self) -> Self::Reassembler {
320        self.wire.reassembler()
321    }
322}
323
324#[cfg(test)]
325mod tests;