liter_llm 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ext/liter_llm_rb/native/Cargo.toml +1 -1
- data/vendor/Cargo.toml +3 -3
- data/vendor/liter-llm/Cargo.toml +5 -2
- data/vendor/liter-llm/schemas/providers.json +80 -11
- data/vendor/liter-llm/src/auth/github_copilot.rs +615 -0
- data/vendor/liter-llm/src/auth/mod.rs +2 -0
- data/vendor/liter-llm/src/client/mod.rs +432 -0
- data/vendor/liter-llm/src/lib.rs +1 -1
- data/vendor/liter-llm/src/provider/anthropic.rs +6 -12
- data/vendor/liter-llm/src/provider/bedrock.rs +9 -0
- data/vendor/liter-llm/src/provider/github_copilot.rs +519 -0
- data/vendor/liter-llm/src/provider/mod.rs +6 -0
- data/vendor/liter-llm/src/types/mod.rs +2 -0
- data/vendor/liter-llm/src/types/raw.rs +29 -0
- data/vendor/liter-llm-ffi/Cargo.toml +3 -3
- data/vendor/liter-llm-ffi/liter_llm.h +2 -2
- metadata +5 -2
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
use std::borrow::Cow;
|
|
2
|
+
use std::collections::hash_map::DefaultHasher;
|
|
3
|
+
use std::hash::{Hash, Hasher};
|
|
4
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
5
|
+
|
|
6
|
+
use crate::provider::Provider;
|
|
7
|
+
|
|
8
|
+
/// Default base URL for the GitHub Copilot API.
|
|
9
|
+
pub const DEFAULT_API_BASE: &str = "https://api.githubcopilot.com";
|
|
10
|
+
|
|
11
|
+
/// Environment variable used to override the Copilot API base URL.
|
|
12
|
+
const API_BASE_ENV_VAR: &str = "GITHUB_COPILOT_API_BASE";
|
|
13
|
+
|
|
14
|
+
/// Model name prefix used for routing.
|
|
15
|
+
const MODEL_PREFIX: &str = "github_copilot/";
|
|
16
|
+
|
|
17
|
+
// ── Version constants ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const COPILOT_VERSION: &str = "0.26.7";
|
|
20
|
+
const EDITOR_VERSION: &str = "vscode/1.95.0";
|
|
21
|
+
const API_VERSION: &str = "2025-04-01";
|
|
22
|
+
|
|
23
|
+
// ── Static headers ───────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/// Static headers required by the GitHub Copilot API on every request.
|
|
26
|
+
///
|
|
27
|
+
/// These identify the client as VS Code Chat to Copilot's backend and must
|
|
28
|
+
/// appear on all requests regardless of the model or payload.
|
|
29
|
+
static COPILOT_EXTRA_HEADERS: &[(&str, &str)] = &[
|
|
30
|
+
("copilot-integration-id", "vscode-chat"),
|
|
31
|
+
("editor-version", EDITOR_VERSION),
|
|
32
|
+
("editor-plugin-version", "copilot-chat/0.26.7"),
|
|
33
|
+
("user-agent", "GitHubCopilotChat/0.26.7"),
|
|
34
|
+
("openai-intent", "conversation-panel"),
|
|
35
|
+
("x-github-api-version", API_VERSION),
|
|
36
|
+
("x-vscode-user-agent-library-version", "electron-fetch"),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Suppress the unused-constant warning: the values are embedded via string
|
|
40
|
+
// interpolation in the static slice above; Rust does not track that reference.
|
|
41
|
+
const _: () = {
|
|
42
|
+
let _ = COPILOT_VERSION;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ── UUID generation (no external deps) ──────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/// Generate a pseudo-random UUID v4 string without requiring the `uuid` crate.
|
|
48
|
+
///
|
|
49
|
+
/// Uses the current nanosecond timestamp hashed with the thread ID to produce
|
|
50
|
+
/// 128 bits of pseudo-entropy, then formats them in standard UUID v4 layout
|
|
51
|
+
/// with the version (`4xxx`) and variant (`8xxx`–`Bxxx`) bits set correctly.
|
|
52
|
+
///
|
|
53
|
+
/// This is not cryptographically random, but it is sufficiently unique for
|
|
54
|
+
/// per-request tracing headers where only collision avoidance matters.
|
|
55
|
+
fn generate_request_id() -> String {
|
|
56
|
+
let nanos = SystemTime::now()
|
|
57
|
+
.duration_since(UNIX_EPOCH)
|
|
58
|
+
.map(|d| d.as_nanos())
|
|
59
|
+
.unwrap_or(0);
|
|
60
|
+
|
|
61
|
+
let thread_id = std::thread::current().id();
|
|
62
|
+
|
|
63
|
+
let mut hasher = DefaultHasher::new();
|
|
64
|
+
nanos.hash(&mut hasher);
|
|
65
|
+
thread_id.hash(&mut hasher);
|
|
66
|
+
let h1 = hasher.finish();
|
|
67
|
+
|
|
68
|
+
// A second hash pass adds entropy independent of the first 64-bit word.
|
|
69
|
+
h1.hash(&mut hasher);
|
|
70
|
+
let h2 = hasher.finish();
|
|
71
|
+
|
|
72
|
+
let b1 = h1.to_le_bytes();
|
|
73
|
+
let b2 = h2.to_le_bytes();
|
|
74
|
+
|
|
75
|
+
// UUID v4 layout:
|
|
76
|
+
// time_low (32 bits) : b1[0..4]
|
|
77
|
+
// time_mid (16 bits) : b1[4..6]
|
|
78
|
+
// version + hi (16 bits) : "4" + b1[6..8] masked to 12 bits
|
|
79
|
+
// variant + clock (16 bits) : 0x8000 | (b2[0..2] & 0x3FFF)
|
|
80
|
+
// node (48 bits) : b2[2..8]
|
|
81
|
+
let time_low = u32::from_le_bytes([b1[0], b1[1], b1[2], b1[3]]);
|
|
82
|
+
let time_mid = u16::from_le_bytes([b1[4], b1[5]]);
|
|
83
|
+
let version_hi = u16::from_le_bytes([b1[6], b1[7]]) & 0x0FFF;
|
|
84
|
+
let variant_clock = (u16::from_le_bytes([b2[0], b2[1]]) & 0x3FFF) | 0x8000;
|
|
85
|
+
let node = u64::from_le_bytes([b2[2], b2[3], b2[4], b2[5], b2[6], b2[7], 0, 0]) & 0x0000_FFFF_FFFF_FFFF;
|
|
86
|
+
|
|
87
|
+
format!("{time_low:08x}-{time_mid:04x}-4{version_hi:03x}-{variant_clock:04x}-{node:012x}")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Provider ─────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/// GitHub Copilot provider.
|
|
93
|
+
///
|
|
94
|
+
/// GitHub Copilot exposes an OpenAI-compatible chat completions API at
|
|
95
|
+
/// `https://api.githubcopilot.com` (or a dynamic URL returned during the
|
|
96
|
+
/// OAuth token exchange). The request/response format is identical to
|
|
97
|
+
/// OpenAI's, so no `transform_request` or `transform_response` overrides are
|
|
98
|
+
/// needed.
|
|
99
|
+
///
|
|
100
|
+
/// Key differences from a vanilla OpenAI provider:
|
|
101
|
+
/// - Several static headers identify the caller as VS Code Chat.
|
|
102
|
+
/// - Each request carries a unique `x-request-id` UUID.
|
|
103
|
+
/// - An `X-Initiator` header signals whether the turn was started by the user
|
|
104
|
+
/// or by an agent (tool/assistant message in the context).
|
|
105
|
+
/// - The base URL can be overridden at construction time (Copilot's OAuth flow
|
|
106
|
+
/// returns a dynamic endpoint URL in the token response).
|
|
107
|
+
///
|
|
108
|
+
/// # Construction
|
|
109
|
+
///
|
|
110
|
+
/// ```rust,ignore
|
|
111
|
+
/// // Use the default base URL.
|
|
112
|
+
/// let provider = GithubCopilotProvider::new();
|
|
113
|
+
///
|
|
114
|
+
/// // Override the base URL (e.g. from the OAuth token exchange).
|
|
115
|
+
/// let provider = GithubCopilotProvider::with_api_base("https://proxy.example.com".to_owned());
|
|
116
|
+
///
|
|
117
|
+
/// // Read the base URL from the GITHUB_COPILOT_API_BASE environment variable.
|
|
118
|
+
/// let provider = GithubCopilotProvider::from_env();
|
|
119
|
+
/// ```
|
|
120
|
+
pub struct GithubCopilotProvider {
|
|
121
|
+
/// Base URL for the Copilot API. Defaults to [`DEFAULT_API_BASE`].
|
|
122
|
+
api_base: String,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
impl GithubCopilotProvider {
|
|
126
|
+
/// Create a provider using the default Copilot API base URL.
|
|
127
|
+
#[must_use]
|
|
128
|
+
pub fn new() -> Self {
|
|
129
|
+
Self {
|
|
130
|
+
api_base: DEFAULT_API_BASE.to_owned(),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Create a provider with a custom base URL.
|
|
135
|
+
///
|
|
136
|
+
/// Use this when the Copilot OAuth token exchange returns a dynamic
|
|
137
|
+
/// endpoint URL that differs from the default.
|
|
138
|
+
#[must_use]
|
|
139
|
+
#[allow(dead_code)] // public API — used by callers that obtain a dynamic URL from the OAuth flow
|
|
140
|
+
pub fn with_api_base(base: String) -> Self {
|
|
141
|
+
Self { api_base: base }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// Create a provider by reading the base URL from the
|
|
145
|
+
/// `GITHUB_COPILOT_API_BASE` environment variable.
|
|
146
|
+
///
|
|
147
|
+
/// Falls back to [`DEFAULT_API_BASE`] when the variable is unset or empty.
|
|
148
|
+
#[must_use]
|
|
149
|
+
pub fn from_env() -> Self {
|
|
150
|
+
let api_base = std::env::var(API_BASE_ENV_VAR)
|
|
151
|
+
.ok()
|
|
152
|
+
.filter(|s| !s.is_empty())
|
|
153
|
+
.unwrap_or_else(|| DEFAULT_API_BASE.to_owned());
|
|
154
|
+
|
|
155
|
+
Self { api_base }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
impl Default for GithubCopilotProvider {
|
|
160
|
+
fn default() -> Self {
|
|
161
|
+
Self::new()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
impl Provider for GithubCopilotProvider {
|
|
166
|
+
fn name(&self) -> &str {
|
|
167
|
+
"github_copilot"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fn base_url(&self) -> &str {
|
|
171
|
+
&self.api_base
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
|
|
175
|
+
Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Static headers required by the GitHub Copilot API on every request.
|
|
179
|
+
///
|
|
180
|
+
/// These identify the VS Code Chat client to Copilot's backend.
|
|
181
|
+
fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
|
|
182
|
+
COPILOT_EXTRA_HEADERS
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Compute per-request dynamic headers.
|
|
186
|
+
///
|
|
187
|
+
/// Two headers are generated here:
|
|
188
|
+
///
|
|
189
|
+
/// - `x-request-id`: A fresh pseudo-random UUID v4 generated for every
|
|
190
|
+
/// call, used by Copilot's backend for distributed tracing.
|
|
191
|
+
///
|
|
192
|
+
/// - `X-Initiator`: Either `"agent"` (when the conversation context
|
|
193
|
+
/// contains any message with role `"tool"` or `"assistant"`, indicating
|
|
194
|
+
/// an ongoing agentic turn) or `"user"` (for a fresh human-initiated
|
|
195
|
+
/// turn). Copilot uses this to apply different rate-limiting and
|
|
196
|
+
/// routing policies.
|
|
197
|
+
fn dynamic_headers(&self, body: &serde_json::Value) -> Vec<(String, String)> {
|
|
198
|
+
let request_id = generate_request_id();
|
|
199
|
+
|
|
200
|
+
let initiator = determine_initiator(body);
|
|
201
|
+
|
|
202
|
+
vec![
|
|
203
|
+
("x-request-id".to_owned(), request_id),
|
|
204
|
+
("X-Initiator".to_owned(), initiator.to_owned()),
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fn matches_model(&self, model: &str) -> bool {
|
|
209
|
+
model.starts_with(MODEL_PREFIX)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
|
|
213
|
+
model.strip_prefix(MODEL_PREFIX).unwrap_or(model)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// All endpoint paths use OpenAI-compatible defaults; no overrides needed.
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// Determine the `X-Initiator` value from the request body.
|
|
220
|
+
///
|
|
221
|
+
/// Returns `"agent"` when any message in `body["messages"]` has a role of
|
|
222
|
+
/// `"tool"` or `"assistant"`, which indicates that the turn is part of an
|
|
223
|
+
/// ongoing multi-step agentic interaction. Returns `"user"` otherwise.
|
|
224
|
+
fn determine_initiator(body: &serde_json::Value) -> &'static str {
|
|
225
|
+
let messages = match body.get("messages").and_then(|m| m.as_array()) {
|
|
226
|
+
Some(msgs) => msgs,
|
|
227
|
+
None => return "user",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
for message in messages {
|
|
231
|
+
let role = message.get("role").and_then(|r| r.as_str()).unwrap_or("");
|
|
232
|
+
if role == "tool" || role == "assistant" {
|
|
233
|
+
return "agent";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
"user"
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
#[cfg(test)]
|
|
243
|
+
mod tests {
|
|
244
|
+
use serde_json::json;
|
|
245
|
+
use serial_test::serial;
|
|
246
|
+
|
|
247
|
+
use super::*;
|
|
248
|
+
|
|
249
|
+
// ── Basic provider metadata ──────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
#[test]
|
|
252
|
+
fn test_name() {
|
|
253
|
+
let provider = GithubCopilotProvider::new();
|
|
254
|
+
assert_eq!(provider.name(), "github_copilot");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#[test]
|
|
258
|
+
fn test_base_url_default() {
|
|
259
|
+
let provider = GithubCopilotProvider::new();
|
|
260
|
+
assert_eq!(provider.base_url(), "https://api.githubcopilot.com");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#[test]
|
|
264
|
+
fn test_base_url_custom() {
|
|
265
|
+
let provider = GithubCopilotProvider::with_api_base("https://proxy.example.com".to_owned());
|
|
266
|
+
assert_eq!(provider.base_url(), "https://proxy.example.com");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── from_env ─────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
#[test]
|
|
272
|
+
#[serial]
|
|
273
|
+
fn test_from_env_uses_default_when_unset() {
|
|
274
|
+
// Ensure the variable is not set for this test.
|
|
275
|
+
// SAFETY: tests in this group are serialised via #[serial]; no concurrent env access.
|
|
276
|
+
unsafe { std::env::remove_var("GITHUB_COPILOT_API_BASE") };
|
|
277
|
+
let provider = GithubCopilotProvider::from_env();
|
|
278
|
+
assert_eq!(provider.base_url(), DEFAULT_API_BASE);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[test]
|
|
282
|
+
#[serial]
|
|
283
|
+
fn test_from_env_reads_custom_base_url() {
|
|
284
|
+
// SAFETY: tests in this group are serialised via #[serial]; no concurrent env access.
|
|
285
|
+
unsafe { std::env::set_var("GITHUB_COPILOT_API_BASE", "https://custom.copilot.test") };
|
|
286
|
+
let provider = GithubCopilotProvider::from_env();
|
|
287
|
+
// SAFETY: tests in this group are serialised via #[serial]; no concurrent env access.
|
|
288
|
+
unsafe { std::env::remove_var("GITHUB_COPILOT_API_BASE") };
|
|
289
|
+
assert_eq!(provider.base_url(), "https://custom.copilot.test");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#[test]
|
|
293
|
+
#[serial]
|
|
294
|
+
fn test_from_env_falls_back_on_empty_value() {
|
|
295
|
+
// SAFETY: tests in this group are serialised via #[serial]; no concurrent env access.
|
|
296
|
+
unsafe { std::env::set_var("GITHUB_COPILOT_API_BASE", "") };
|
|
297
|
+
let provider = GithubCopilotProvider::from_env();
|
|
298
|
+
// SAFETY: tests in this group are serialised via #[serial]; no concurrent env access.
|
|
299
|
+
unsafe { std::env::remove_var("GITHUB_COPILOT_API_BASE") };
|
|
300
|
+
assert_eq!(provider.base_url(), DEFAULT_API_BASE);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Model routing ────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
#[test]
|
|
306
|
+
fn test_matches_model() {
|
|
307
|
+
let provider = GithubCopilotProvider::new();
|
|
308
|
+
assert!(provider.matches_model("github_copilot/gpt-4o"));
|
|
309
|
+
assert!(provider.matches_model("github_copilot/claude-3.5-sonnet"));
|
|
310
|
+
assert!(provider.matches_model("github_copilot/o3-mini"));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#[test]
|
|
314
|
+
fn test_does_not_match_other_providers() {
|
|
315
|
+
let provider = GithubCopilotProvider::new();
|
|
316
|
+
assert!(!provider.matches_model("openai/gpt-4o"));
|
|
317
|
+
assert!(!provider.matches_model("gpt-4o"));
|
|
318
|
+
assert!(!provider.matches_model("claude-3.5-sonnet"));
|
|
319
|
+
assert!(!provider.matches_model("anthropic/claude-3.5-sonnet"));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[test]
|
|
323
|
+
fn test_strip_model_prefix() {
|
|
324
|
+
let provider = GithubCopilotProvider::new();
|
|
325
|
+
assert_eq!(provider.strip_model_prefix("github_copilot/gpt-4o"), "gpt-4o");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#[test]
|
|
329
|
+
fn test_strip_model_prefix_no_prefix() {
|
|
330
|
+
let provider = GithubCopilotProvider::new();
|
|
331
|
+
assert_eq!(provider.strip_model_prefix("gpt-4o"), "gpt-4o");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Auth header ──────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
#[test]
|
|
337
|
+
fn test_auth_header() {
|
|
338
|
+
let provider = GithubCopilotProvider::new();
|
|
339
|
+
let (name, value) = provider
|
|
340
|
+
.auth_header("ghs_test_token_123")
|
|
341
|
+
.expect("should return an auth header");
|
|
342
|
+
assert_eq!(name, "Authorization");
|
|
343
|
+
assert_eq!(value, "Bearer ghs_test_token_123");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Static extra headers ─────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
#[test]
|
|
349
|
+
fn test_extra_headers() {
|
|
350
|
+
let provider = GithubCopilotProvider::new();
|
|
351
|
+
let headers = provider.extra_headers();
|
|
352
|
+
|
|
353
|
+
// All 7 required static headers must be present.
|
|
354
|
+
let find = |key: &str| headers.iter().find(|(k, _)| *k == key).map(|(_, v)| *v);
|
|
355
|
+
|
|
356
|
+
assert_eq!(find("copilot-integration-id"), Some("vscode-chat"));
|
|
357
|
+
assert_eq!(find("editor-version"), Some("vscode/1.95.0"));
|
|
358
|
+
assert_eq!(find("editor-plugin-version"), Some("copilot-chat/0.26.7"));
|
|
359
|
+
assert_eq!(find("user-agent"), Some("GitHubCopilotChat/0.26.7"));
|
|
360
|
+
assert_eq!(find("openai-intent"), Some("conversation-panel"));
|
|
361
|
+
assert_eq!(find("x-github-api-version"), Some("2025-04-01"));
|
|
362
|
+
assert_eq!(find("x-vscode-user-agent-library-version"), Some("electron-fetch"));
|
|
363
|
+
|
|
364
|
+
assert_eq!(headers.len(), 7, "expected exactly 7 static headers");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── Dynamic headers ──────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
#[test]
|
|
370
|
+
fn test_dynamic_headers_user() {
|
|
371
|
+
let provider = GithubCopilotProvider::new();
|
|
372
|
+
let body = json!({
|
|
373
|
+
"model": "github_copilot/gpt-4o",
|
|
374
|
+
"messages": [
|
|
375
|
+
{"role": "user", "content": "Hello!"}
|
|
376
|
+
]
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
let headers = provider.dynamic_headers(&body);
|
|
380
|
+
let initiator = headers
|
|
381
|
+
.iter()
|
|
382
|
+
.find(|(k, _)| k == "X-Initiator")
|
|
383
|
+
.map(|(_, v)| v.as_str());
|
|
384
|
+
|
|
385
|
+
assert_eq!(initiator, Some("user"));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#[test]
|
|
389
|
+
fn test_dynamic_headers_agent_with_tool_role() {
|
|
390
|
+
let provider = GithubCopilotProvider::new();
|
|
391
|
+
let body = json!({
|
|
392
|
+
"model": "github_copilot/gpt-4o",
|
|
393
|
+
"messages": [
|
|
394
|
+
{"role": "user", "content": "Run the tool."},
|
|
395
|
+
{"role": "assistant", "content": null, "tool_calls": []},
|
|
396
|
+
{"role": "tool", "content": "tool result", "tool_call_id": "abc"}
|
|
397
|
+
]
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
let headers = provider.dynamic_headers(&body);
|
|
401
|
+
let initiator = headers
|
|
402
|
+
.iter()
|
|
403
|
+
.find(|(k, _)| k == "X-Initiator")
|
|
404
|
+
.map(|(_, v)| v.as_str());
|
|
405
|
+
|
|
406
|
+
assert_eq!(initiator, Some("agent"));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#[test]
|
|
410
|
+
fn test_dynamic_headers_agent_with_assistant_role() {
|
|
411
|
+
let provider = GithubCopilotProvider::new();
|
|
412
|
+
let body = json!({
|
|
413
|
+
"messages": [
|
|
414
|
+
{"role": "user", "content": "Hi"},
|
|
415
|
+
{"role": "assistant", "content": "Hello"}
|
|
416
|
+
]
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
let headers = provider.dynamic_headers(&body);
|
|
420
|
+
let initiator = headers
|
|
421
|
+
.iter()
|
|
422
|
+
.find(|(k, _)| k == "X-Initiator")
|
|
423
|
+
.map(|(_, v)| v.as_str());
|
|
424
|
+
|
|
425
|
+
assert_eq!(initiator, Some("agent"));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
#[test]
|
|
429
|
+
fn test_dynamic_headers_user_when_no_messages() {
|
|
430
|
+
let provider = GithubCopilotProvider::new();
|
|
431
|
+
let body = json!({ "model": "github_copilot/gpt-4o" });
|
|
432
|
+
|
|
433
|
+
let headers = provider.dynamic_headers(&body);
|
|
434
|
+
let initiator = headers
|
|
435
|
+
.iter()
|
|
436
|
+
.find(|(k, _)| k == "X-Initiator")
|
|
437
|
+
.map(|(_, v)| v.as_str());
|
|
438
|
+
|
|
439
|
+
assert_eq!(initiator, Some("user"));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
#[test]
|
|
443
|
+
fn test_dynamic_headers_request_id_present_and_valid_uuid() {
|
|
444
|
+
let provider = GithubCopilotProvider::new();
|
|
445
|
+
let body = json!({ "messages": [{"role": "user", "content": "hi"}] });
|
|
446
|
+
|
|
447
|
+
let headers = provider.dynamic_headers(&body);
|
|
448
|
+
let request_id = headers
|
|
449
|
+
.iter()
|
|
450
|
+
.find(|(k, _)| k == "x-request-id")
|
|
451
|
+
.map(|(_, v)| v.as_str())
|
|
452
|
+
.expect("x-request-id header must be present");
|
|
453
|
+
|
|
454
|
+
// UUID v4 canonical form: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx
|
|
455
|
+
// Length check: 8-4-4-4-12 = 32 hex + 4 dashes = 36 chars.
|
|
456
|
+
assert_eq!(request_id.len(), 36, "request id must be 36 characters");
|
|
457
|
+
|
|
458
|
+
let parts: Vec<&str> = request_id.split('-').collect();
|
|
459
|
+
assert_eq!(parts.len(), 5, "UUID must have 5 dash-separated groups");
|
|
460
|
+
assert_eq!(parts[0].len(), 8);
|
|
461
|
+
assert_eq!(parts[1].len(), 4);
|
|
462
|
+
assert_eq!(parts[2].len(), 4);
|
|
463
|
+
assert_eq!(parts[3].len(), 4);
|
|
464
|
+
assert_eq!(parts[4].len(), 12);
|
|
465
|
+
|
|
466
|
+
// Version nibble must be '4'.
|
|
467
|
+
assert_eq!(&parts[2][0..1], "4", "third group must start with '4' (UUID version 4)");
|
|
468
|
+
|
|
469
|
+
// Variant bits: first nibble of fourth group must be 8, 9, a, or b.
|
|
470
|
+
let variant_nibble = parts[3].chars().next().expect("fourth group is non-empty");
|
|
471
|
+
assert!(
|
|
472
|
+
matches!(variant_nibble, '8' | '9' | 'a' | 'b'),
|
|
473
|
+
"fourth group must start with 8, 9, a or b (RFC 4122 variant); got '{variant_nibble}'"
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// All characters must be valid lowercase hex or dashes.
|
|
477
|
+
for ch in request_id.chars() {
|
|
478
|
+
assert!(
|
|
479
|
+
ch.is_ascii_hexdigit() || ch == '-',
|
|
480
|
+
"unexpected character '{ch}' in request id"
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#[test]
|
|
486
|
+
fn test_dynamic_headers_request_id_unique_per_call() {
|
|
487
|
+
let provider = GithubCopilotProvider::new();
|
|
488
|
+
let body = json!({ "messages": [{"role": "user", "content": "hi"}] });
|
|
489
|
+
|
|
490
|
+
let id1 = provider
|
|
491
|
+
.dynamic_headers(&body)
|
|
492
|
+
.into_iter()
|
|
493
|
+
.find(|(k, _)| k == "x-request-id")
|
|
494
|
+
.map(|(_, v)| v)
|
|
495
|
+
.expect("x-request-id must be present");
|
|
496
|
+
|
|
497
|
+
// Inject a small delay so the nanosecond timestamp differs.
|
|
498
|
+
std::thread::sleep(std::time::Duration::from_nanos(100));
|
|
499
|
+
|
|
500
|
+
let id2 = provider
|
|
501
|
+
.dynamic_headers(&body)
|
|
502
|
+
.into_iter()
|
|
503
|
+
.find(|(k, _)| k == "x-request-id")
|
|
504
|
+
.map(|(_, v)| v)
|
|
505
|
+
.expect("x-request-id must be present");
|
|
506
|
+
|
|
507
|
+
assert_ne!(id1, id2, "consecutive request IDs must differ");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ── Endpoint paths (OpenAI defaults) ─────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
#[test]
|
|
513
|
+
fn test_endpoint_paths_are_openai_compatible() {
|
|
514
|
+
let provider = GithubCopilotProvider::new();
|
|
515
|
+
assert_eq!(provider.chat_completions_path(), "/chat/completions");
|
|
516
|
+
assert_eq!(provider.embeddings_path(), "/embeddings");
|
|
517
|
+
assert_eq!(provider.models_path(), "/models");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
@@ -336,6 +336,7 @@ pub mod azure;
|
|
|
336
336
|
pub mod bedrock;
|
|
337
337
|
pub mod cohere;
|
|
338
338
|
pub mod custom;
|
|
339
|
+
pub mod github_copilot;
|
|
339
340
|
pub mod google_ai;
|
|
340
341
|
pub mod mistral;
|
|
341
342
|
pub mod vertex;
|
|
@@ -561,6 +562,11 @@ pub fn detect_provider(model: &str) -> Option<Box<dyn Provider>> {
|
|
|
561
562
|
return Some(Box::new(mistral::MistralProvider));
|
|
562
563
|
}
|
|
563
564
|
|
|
565
|
+
// 9. GitHub Copilot: "github_copilot/" prefix.
|
|
566
|
+
if model.starts_with("github_copilot/") {
|
|
567
|
+
return Some(Box::new(github_copilot::GithubCopilotProvider::from_env()));
|
|
568
|
+
}
|
|
569
|
+
|
|
564
570
|
// Grab the registry; if it failed to parse we cannot route.
|
|
565
571
|
let reg = match REGISTRY.as_ref() {
|
|
566
572
|
Ok(r) => r,
|
|
@@ -8,6 +8,7 @@ pub mod image;
|
|
|
8
8
|
pub mod models;
|
|
9
9
|
pub mod moderation;
|
|
10
10
|
pub mod ocr;
|
|
11
|
+
pub mod raw;
|
|
11
12
|
pub mod rerank;
|
|
12
13
|
pub mod responses;
|
|
13
14
|
pub mod search;
|
|
@@ -22,6 +23,7 @@ pub use image::*;
|
|
|
22
23
|
pub use models::*;
|
|
23
24
|
pub use moderation::*;
|
|
24
25
|
pub use ocr::*;
|
|
26
|
+
pub use raw::*;
|
|
25
27
|
pub use rerank::*;
|
|
26
28
|
pub use responses::*;
|
|
27
29
|
pub use search::*;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/// The raw request and response JSON exchanged with the provider,
|
|
2
|
+
/// paired with the typed (normalized) response.
|
|
3
|
+
///
|
|
4
|
+
/// Returned by every `_raw` method on [`crate::LlmClientRaw`]. Useful for
|
|
5
|
+
/// debugging provider-specific transformations or implementing custom parsing.
|
|
6
|
+
#[derive(Debug, Clone)]
|
|
7
|
+
pub struct RawExchange<T> {
|
|
8
|
+
/// The typed, normalized response.
|
|
9
|
+
pub data: T,
|
|
10
|
+
/// The final request body sent to the provider (after `transform_request`).
|
|
11
|
+
pub raw_request: serde_json::Value,
|
|
12
|
+
/// The raw response body from the provider, before `transform_response`.
|
|
13
|
+
/// `None` for binary endpoints (speech) or when not applicable.
|
|
14
|
+
pub raw_response: Option<serde_json::Value>,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// Raw exchange data for streaming responses.
|
|
18
|
+
///
|
|
19
|
+
/// Returned by [`crate::LlmClientRaw::chat_stream_raw`]. The stream itself is
|
|
20
|
+
/// not captured in its entirety — only the request body is available upfront.
|
|
21
|
+
/// `RawStreamExchange` intentionally does not implement `Clone` because streams
|
|
22
|
+
/// cannot be duplicated.
|
|
23
|
+
#[derive(Debug)]
|
|
24
|
+
pub struct RawStreamExchange<S> {
|
|
25
|
+
/// The chunk stream, unchanged.
|
|
26
|
+
pub stream: S,
|
|
27
|
+
/// The final request body sent to the provider.
|
|
28
|
+
pub raw_request: serde_json::Value,
|
|
29
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "liter-llm-ffi"
|
|
3
|
-
version = "1.2.
|
|
3
|
+
version = "1.2.2"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
license = "MIT"
|
|
6
6
|
repository.workspace = true
|
|
@@ -20,8 +20,8 @@ default = []
|
|
|
20
20
|
base64.workspace = true
|
|
21
21
|
bytes.workspace = true
|
|
22
22
|
futures-core.workspace = true
|
|
23
|
-
liter-llm = { path = "../liter-llm", version = "1.2.
|
|
24
|
-
liter-llm-bindings-core = { path = "../liter-llm-bindings-core", version = "1.2.
|
|
23
|
+
liter-llm = { path = "../liter-llm", version = "1.2.2", features = ["full"] }
|
|
24
|
+
liter-llm-bindings-core = { path = "../liter-llm-bindings-core", version = "1.2.2" }
|
|
25
25
|
serde.workspace = true
|
|
26
26
|
serde_json.workspace = true
|
|
27
27
|
tokio.workspace = true
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: liter_llm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Na'aman Hirschfeld
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rb_sys
|
|
@@ -196,6 +196,7 @@ files:
|
|
|
196
196
|
- vendor/liter-llm/schemas/providers.json
|
|
197
197
|
- vendor/liter-llm/src/auth/azure_ad.rs
|
|
198
198
|
- vendor/liter-llm/src/auth/bedrock_sts.rs
|
|
199
|
+
- vendor/liter-llm/src/auth/github_copilot.rs
|
|
199
200
|
- vendor/liter-llm/src/auth/mod.rs
|
|
200
201
|
- vendor/liter-llm/src/auth/vertex_oauth.rs
|
|
201
202
|
- vendor/liter-llm/src/client/config.rs
|
|
@@ -215,6 +216,7 @@ files:
|
|
|
215
216
|
- vendor/liter-llm/src/provider/bedrock.rs
|
|
216
217
|
- vendor/liter-llm/src/provider/cohere.rs
|
|
217
218
|
- vendor/liter-llm/src/provider/custom.rs
|
|
219
|
+
- vendor/liter-llm/src/provider/github_copilot.rs
|
|
218
220
|
- vendor/liter-llm/src/provider/google_ai.rs
|
|
219
221
|
- vendor/liter-llm/src/provider/mistral.rs
|
|
220
222
|
- vendor/liter-llm/src/provider/mod.rs
|
|
@@ -248,6 +250,7 @@ files:
|
|
|
248
250
|
- vendor/liter-llm/src/types/models.rs
|
|
249
251
|
- vendor/liter-llm/src/types/moderation.rs
|
|
250
252
|
- vendor/liter-llm/src/types/ocr.rs
|
|
253
|
+
- vendor/liter-llm/src/types/raw.rs
|
|
251
254
|
- vendor/liter-llm/src/types/rerank.rs
|
|
252
255
|
- vendor/liter-llm/src/types/responses.rs
|
|
253
256
|
- vendor/liter-llm/src/types/search.rs
|