mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 06:32:17 +00:00
fix providers
This commit is contained in:
Generated
+14
-14
@@ -3866,7 +3866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3902,7 +3902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3933,7 +3933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3960,7 +3960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3986,7 +3986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4014,7 +4014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4031,7 +4031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4067,7 +4067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4086,7 +4086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4105,7 +4105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4136,7 +4136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4158,7 +4158,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4177,7 +4177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -8789,7 +8789,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
|
||||
@@ -109,6 +109,7 @@ pub async fn auth(
|
||||
|| path == "/api/integrations/available"
|
||||
|| path == "/api/integrations/health"
|
||||
|| path.starts_with("/api/cron/")
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/")
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
@@ -8703,3 +8703,153 @@ fn validate_webhook_token(headers: &axum::http::HeaderMap, token_env: &str) -> b
|
||||
}
|
||||
provided.as_bytes().ct_eq(expected.as_bytes()).into()
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GitHub Copilot OAuth Device Flow
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// State for an in-progress device flow.
|
||||
struct CopilotFlowState {
|
||||
device_code: String,
|
||||
interval: u64,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// Active device flows, keyed by poll_id. Auto-expire after the flow's TTL.
|
||||
static COPILOT_FLOWS: LazyLock<DashMap<String, CopilotFlowState>> = LazyLock::new(DashMap::new);
|
||||
|
||||
/// POST /api/providers/github-copilot/oauth/start
|
||||
///
|
||||
/// Initiates a GitHub device flow for Copilot authentication.
|
||||
/// Returns a user code and verification URI that the user visits in their browser.
|
||||
pub async fn copilot_oauth_start() -> impl IntoResponse {
|
||||
// Clean up expired flows first
|
||||
COPILOT_FLOWS.retain(|_, state| state.expires_at > Instant::now());
|
||||
|
||||
match openfang_runtime::copilot_oauth::start_device_flow().await {
|
||||
Ok(resp) => {
|
||||
let poll_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
COPILOT_FLOWS.insert(
|
||||
poll_id.clone(),
|
||||
CopilotFlowState {
|
||||
device_code: resp.device_code,
|
||||
interval: resp.interval,
|
||||
expires_at: Instant::now()
|
||||
+ std::time::Duration::from_secs(resp.expires_in),
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"user_code": resp.user_code,
|
||||
"verification_uri": resp.verification_uri,
|
||||
"poll_id": poll_id,
|
||||
"expires_in": resp.expires_in,
|
||||
"interval": resp.interval,
|
||||
})),
|
||||
)
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/providers/github-copilot/oauth/poll/{poll_id}
|
||||
///
|
||||
/// Poll the status of a GitHub device flow.
|
||||
/// Returns `pending`, `complete`, `expired`, `denied`, or `error`.
|
||||
/// On `complete`, saves the token to secrets.env and sets GITHUB_TOKEN.
|
||||
pub async fn copilot_oauth_poll(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(poll_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let flow = match COPILOT_FLOWS.get(&poll_id) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"status": "not_found", "error": "Unknown poll_id"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if flow.expires_at <= Instant::now() {
|
||||
drop(flow);
|
||||
COPILOT_FLOWS.remove(&poll_id);
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "expired"})),
|
||||
);
|
||||
}
|
||||
|
||||
let device_code = flow.device_code.clone();
|
||||
drop(flow);
|
||||
|
||||
match openfang_runtime::copilot_oauth::poll_device_flow(&device_code).await {
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::Pending => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "pending"})),
|
||||
),
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::Complete { access_token } => {
|
||||
// Save to secrets.env
|
||||
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
|
||||
if let Err(e) = write_secret_env(&secrets_path, "GITHUB_TOKEN", &access_token) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")})),
|
||||
);
|
||||
}
|
||||
|
||||
// Set in current process
|
||||
std::env::set_var("GITHUB_TOKEN", access_token.as_str());
|
||||
|
||||
// Refresh auth detection
|
||||
state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.detect_auth();
|
||||
|
||||
// Clean up flow state
|
||||
COPILOT_FLOWS.remove(&poll_id);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "complete"})),
|
||||
)
|
||||
}
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::SlowDown { new_interval } => {
|
||||
// Update interval
|
||||
if let Some(mut f) = COPILOT_FLOWS.get_mut(&poll_id) {
|
||||
f.interval = new_interval;
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "pending", "interval": new_interval})),
|
||||
)
|
||||
}
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::Expired => {
|
||||
COPILOT_FLOWS.remove(&poll_id);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "expired"})),
|
||||
)
|
||||
}
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::AccessDenied => {
|
||||
COPILOT_FLOWS.remove(&poll_id);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "denied"})),
|
||||
)
|
||||
}
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::Error(e) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "error", "error": e})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +452,15 @@ pub async fn build_router(
|
||||
)
|
||||
.route("/api/models/{*id}", axum::routing::get(routes::get_model))
|
||||
.route("/api/providers", axum::routing::get(routes::list_providers))
|
||||
// Copilot OAuth (must be before parametric {name} routes)
|
||||
.route(
|
||||
"/api/providers/github-copilot/oauth/start",
|
||||
axum::routing::post(routes::copilot_oauth_start),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/github-copilot/oauth/poll/{poll_id}",
|
||||
axum::routing::get(routes::copilot_oauth_poll),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/{name}/key",
|
||||
axum::routing::post(routes::set_provider_key).delete(routes::delete_provider_key),
|
||||
|
||||
@@ -2884,6 +2884,21 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<template x-if="p.auth_status !== 'configured' && p.api_key_env">
|
||||
<div class="text-xs text-dim mt-2">Or set <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px" x-text="p.api_key_env"></code> in your environment and restart</div>
|
||||
</template>
|
||||
<!-- Copilot OAuth button -->
|
||||
<template x-if="p.id === 'github-copilot' && p.auth_status !== 'configured'">
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-primary btn-sm" @click="startCopilotOAuth()" :disabled="copilotOAuth.polling" x-show="!copilotOAuth.userCode">Login with GitHub</button>
|
||||
<div x-show="copilotOAuth.userCode" class="mt-2">
|
||||
<div class="text-sm">Visit <a :href="copilotOAuth.verificationUri" target="_blank" x-text="copilotOAuth.verificationUri" style="color:var(--accent-light)"></a> and enter:</div>
|
||||
<div style="font-size:24px;font-weight:bold;letter-spacing:4px;margin:8px 0;color:var(--accent-light)" x-text="copilotOAuth.userCode"></div>
|
||||
<div class="text-xs text-dim"><span class="spinner" style="width:10px;height:10px;border-width:2px;display:inline-block;vertical-align:middle"></span> Waiting for authorization...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Claude Code install hint -->
|
||||
<template x-if="p.id === 'claude-code' && p.auth_status !== 'configured'">
|
||||
<div class="mt-2 text-xs text-dim">Install: <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
|
||||
</template>
|
||||
<!-- Actions for configured providers -->
|
||||
<template x-if="p.auth_status === 'configured'">
|
||||
<div class="flex gap-2 mt-2">
|
||||
|
||||
@@ -19,6 +19,7 @@ function settingsPage() {
|
||||
providerUrlSaving: {},
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -368,6 +369,54 @@ function settingsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
async startCopilotOAuth() {
|
||||
this.copilotOAuth.polling = true;
|
||||
this.copilotOAuth.userCode = '';
|
||||
try {
|
||||
var resp = await OpenFangAPI.post('/api/providers/github-copilot/oauth/start', {});
|
||||
this.copilotOAuth.userCode = resp.user_code;
|
||||
this.copilotOAuth.verificationUri = resp.verification_uri;
|
||||
this.copilotOAuth.pollId = resp.poll_id;
|
||||
this.copilotOAuth.interval = resp.interval || 5;
|
||||
window.open(resp.verification_uri, '_blank');
|
||||
this.pollCopilotOAuth();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to start Copilot login: ' + e.message);
|
||||
this.copilotOAuth.polling = false;
|
||||
}
|
||||
},
|
||||
|
||||
pollCopilotOAuth() {
|
||||
var self = this;
|
||||
setTimeout(async function() {
|
||||
if (!self.copilotOAuth.pollId) return;
|
||||
try {
|
||||
var resp = await OpenFangAPI.get('/api/providers/github-copilot/oauth/poll/' + self.copilotOAuth.pollId);
|
||||
if (resp.status === 'complete') {
|
||||
OpenFangToast.success('GitHub Copilot authenticated successfully!');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
await self.loadProviders();
|
||||
await self.loadModels();
|
||||
} else if (resp.status === 'pending') {
|
||||
if (resp.interval) self.copilotOAuth.interval = resp.interval;
|
||||
self.pollCopilotOAuth();
|
||||
} else if (resp.status === 'expired') {
|
||||
OpenFangToast.error('Device code expired. Please try again.');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
} else if (resp.status === 'denied') {
|
||||
OpenFangToast.error('Access denied by user.');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
} else {
|
||||
OpenFangToast.error('OAuth error: ' + (resp.error || resp.status));
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Poll error: ' + e.message);
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
}
|
||||
}, self.copilotOAuth.interval * 1000);
|
||||
},
|
||||
|
||||
async testProvider(provider) {
|
||||
this.providerTesting[provider.id] = true;
|
||||
this.providerTestResults[provider.id] = null;
|
||||
|
||||
@@ -2252,7 +2252,10 @@ impl OpenFangKernel {
|
||||
.map(|entry| entry.provider.clone())
|
||||
});
|
||||
|
||||
if let Some(provider) = resolved_provider {
|
||||
// If catalog lookup failed, try to infer provider from model name prefix
|
||||
let provider = resolved_provider.or_else(|| infer_provider_from_model(model));
|
||||
|
||||
if let Some(provider) = provider {
|
||||
self.registry
|
||||
.update_model_and_provider(agent_id, model.to_string(), provider.clone())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
@@ -2261,7 +2264,7 @@ impl OpenFangKernel {
|
||||
self.registry
|
||||
.update_model(agent_id, model.to_string())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
info!(agent_id = %agent_id, model = %model, "Agent model updated");
|
||||
info!(agent_id = %agent_id, model = %model, "Agent model updated (provider unchanged)");
|
||||
}
|
||||
|
||||
// Persist the updated entry
|
||||
@@ -3491,43 +3494,50 @@ impl OpenFangKernel {
|
||||
let primary = if agent_provider == default_provider && !has_custom_key && !has_custom_url {
|
||||
Arc::clone(&self.default_driver)
|
||||
} else {
|
||||
// Create a dedicated driver for this agent
|
||||
// Auth profile rotation: if profiles are configured for this provider,
|
||||
// select the highest-priority profile's key env var.
|
||||
let default_key_env = manifest
|
||||
.model
|
||||
.api_key_env
|
||||
.as_deref()
|
||||
.unwrap_or(&self.config.default_model.api_key_env);
|
||||
|
||||
let api_key_env =
|
||||
// Create a dedicated driver for this agent.
|
||||
//
|
||||
// IMPORTANT: When the agent's provider differs from the default,
|
||||
// we must NOT pass the default provider's API key. Instead, pass None
|
||||
// so create_driver() can look up the correct env var for the target provider.
|
||||
let api_key = if has_custom_key {
|
||||
// Agent explicitly set an API key env var — use it
|
||||
manifest
|
||||
.model
|
||||
.api_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
} else if agent_provider == default_provider {
|
||||
// Same provider — use default key
|
||||
std::env::var(&self.config.default_model.api_key_env).ok()
|
||||
} else {
|
||||
// Different provider — check auth profiles first, then let
|
||||
// create_driver() look up the correct env var automatically.
|
||||
if let Some(profiles) = self.config.auth_profiles.get(agent_provider.as_str()) {
|
||||
if !profiles.is_empty() {
|
||||
// Pick highest-priority profile (lowest priority number)
|
||||
let mut sorted: Vec<_> = profiles.iter().collect();
|
||||
sorted.sort_by_key(|p| p.priority);
|
||||
let best = &sorted[0];
|
||||
// Use the profile's env var if the key exists, otherwise fall back
|
||||
if std::env::var(&best.api_key_env).is_ok() {
|
||||
best.api_key_env.clone()
|
||||
} else {
|
||||
default_key_env.to_string()
|
||||
}
|
||||
} else {
|
||||
default_key_env.to_string()
|
||||
}
|
||||
let mut sorted: Vec<_> = profiles.iter().collect();
|
||||
sorted.sort_by_key(|p| p.priority);
|
||||
sorted
|
||||
.first()
|
||||
.and_then(|best| std::env::var(&best.api_key_env).ok())
|
||||
} else {
|
||||
default_key_env.to_string()
|
||||
};
|
||||
// Pass None — create_driver() has per-provider env var lookups
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Don't inherit default provider's base_url when switching providers
|
||||
let base_url = if has_custom_url {
|
||||
manifest.model.base_url.clone()
|
||||
} else if agent_provider == default_provider {
|
||||
self.config.default_model.base_url.clone()
|
||||
} else {
|
||||
// Let create_driver() use the target provider's default base URL
|
||||
None
|
||||
};
|
||||
|
||||
let driver_config = DriverConfig {
|
||||
provider: agent_provider.clone(),
|
||||
api_key: std::env::var(&api_key_env).ok(),
|
||||
base_url: manifest
|
||||
.model
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.config.default_model.base_url.clone()),
|
||||
api_key,
|
||||
base_url,
|
||||
};
|
||||
|
||||
drivers::create_driver(&driver_config).map_err(|e| {
|
||||
@@ -4231,6 +4241,62 @@ fn manifest_to_capabilities(manifest: &AgentManifest) -> Vec<Capability> {
|
||||
caps
|
||||
}
|
||||
|
||||
/// Infer provider from a model name when catalog lookup fails.
|
||||
///
|
||||
/// Uses well-known model name prefixes to map to the correct provider.
|
||||
/// This is a defense-in-depth fallback — models should ideally be in the catalog.
|
||||
fn infer_provider_from_model(model: &str) -> Option<String> {
|
||||
let lower = model.to_lowercase();
|
||||
// Check for explicit provider prefix (e.g., "minimax/MiniMax-M2.5")
|
||||
if let Some(prefix) = lower.split('/').next() {
|
||||
match prefix {
|
||||
"minimax" | "gemini" | "anthropic" | "openai" | "groq" | "deepseek" | "mistral"
|
||||
| "cohere" | "xai" | "ollama" | "together" | "fireworks" | "perplexity"
|
||||
| "cerebras" | "sambanova" | "replicate" | "huggingface" | "ai21" | "codex"
|
||||
| "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "moonshot"
|
||||
| "openrouter" => {
|
||||
if model.contains('/') {
|
||||
return Some(prefix.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Infer from well-known model name patterns
|
||||
if lower.starts_with("minimax") {
|
||||
Some("minimax".to_string())
|
||||
} else if lower.starts_with("gemini") {
|
||||
Some("gemini".to_string())
|
||||
} else if lower.starts_with("claude") {
|
||||
Some("anthropic".to_string())
|
||||
} else if lower.starts_with("gpt") || lower.starts_with("o1") || lower.starts_with("o3") || lower.starts_with("o4") {
|
||||
Some("openai".to_string())
|
||||
} else if lower.starts_with("llama") || lower.starts_with("mixtral") || lower.starts_with("qwen") {
|
||||
// These could be on multiple providers; don't infer
|
||||
None
|
||||
} else if lower.starts_with("grok") {
|
||||
Some("xai".to_string())
|
||||
} else if lower.starts_with("deepseek") {
|
||||
Some("deepseek".to_string())
|
||||
} else if lower.starts_with("mistral") || lower.starts_with("codestral") || lower.starts_with("pixtral") {
|
||||
Some("mistral".to_string())
|
||||
} else if lower.starts_with("command") || lower.starts_with("embed-") {
|
||||
Some("cohere".to_string())
|
||||
} else if lower.starts_with("jamba") {
|
||||
Some("ai21".to_string())
|
||||
} else if lower.starts_with("sonar") {
|
||||
Some("perplexity".to_string())
|
||||
} else if lower.starts_with("glm") {
|
||||
Some("zhipu".to_string())
|
||||
} else if lower.starts_with("ernie") {
|
||||
Some("qianfan".to_string())
|
||||
} else if lower.starts_with("abab") {
|
||||
Some("minimax".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A well-known agent ID used for shared memory operations across agents.
|
||||
/// This is a fixed UUID so all agents read/write to the same namespace.
|
||||
fn shared_memory_agent_id() -> AgentId {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! GitHub Copilot OAuth — device flow for obtaining a GitHub PAT via browser login.
|
||||
//!
|
||||
//! Implements the OAuth 2.0 Device Authorization Grant (RFC 8628) using GitHub's
|
||||
//! device flow endpoint. Users visit a URL, enter a code, and authorize the app.
|
||||
//! Once complete, the resulting access token can be used with the CopilotDriver.
|
||||
|
||||
use serde::Deserialize;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// GitHub device code request URL.
|
||||
const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
|
||||
|
||||
/// GitHub OAuth token URL.
|
||||
const GITHUB_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
|
||||
|
||||
/// Public OAuth client ID — same as VSCode Copilot extension.
|
||||
const COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
|
||||
|
||||
/// Response from the device code initiation request.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
/// Status of a device flow polling attempt.
|
||||
pub enum DeviceFlowStatus {
|
||||
/// Authorization is pending — user hasn't completed the flow yet.
|
||||
Pending,
|
||||
/// Authorization succeeded — contains the access token.
|
||||
Complete { access_token: Zeroizing<String> },
|
||||
/// Server asked to slow down — use the new interval.
|
||||
SlowDown { new_interval: u64 },
|
||||
/// The device code expired — user must restart the flow.
|
||||
Expired,
|
||||
/// User explicitly denied access.
|
||||
AccessDenied,
|
||||
/// An unexpected error occurred.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Start a GitHub device flow for Copilot OAuth.
|
||||
///
|
||||
/// POST https://github.com/login/device/code
|
||||
/// Returns a device code and user code for the user to enter at the verification URI.
|
||||
pub async fn start_device_flow() -> Result<DeviceCodeResponse, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client error: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
.post(GITHUB_DEVICE_CODE_URL)
|
||||
.header("Accept", "application/json")
|
||||
.form(&[("client_id", COPILOT_CLIENT_ID), ("scope", "read:user")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device code request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Device code request returned {status}: {body}"));
|
||||
}
|
||||
|
||||
resp.json::<DeviceCodeResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device code response: {e}"))
|
||||
}
|
||||
|
||||
/// Poll the GitHub token endpoint for the device flow result.
|
||||
///
|
||||
/// POST https://github.com/login/oauth/access_token
|
||||
/// Returns the current status of the authorization flow.
|
||||
pub async fn poll_device_flow(device_code: &str) -> DeviceFlowStatus {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return DeviceFlowStatus::Error(format!("HTTP client error: {e}")),
|
||||
};
|
||||
|
||||
let resp = match client
|
||||
.post(GITHUB_TOKEN_URL)
|
||||
.header("Accept", "application/json")
|
||||
.form(&[
|
||||
("client_id", COPILOT_CLIENT_ID),
|
||||
(
|
||||
"grant_type",
|
||||
"urn:ietf:params:oauth:grant-type:device_code",
|
||||
),
|
||||
("device_code", device_code),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return DeviceFlowStatus::Error(format!("Token poll failed: {e}")),
|
||||
};
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return DeviceFlowStatus::Error(format!("Failed to parse token response: {e}")),
|
||||
};
|
||||
|
||||
// Check for error field first (GitHub returns 200 with error during polling)
|
||||
if let Some(error) = body.get("error").and_then(|v| v.as_str()) {
|
||||
return match error {
|
||||
"authorization_pending" => DeviceFlowStatus::Pending,
|
||||
"slow_down" => {
|
||||
let interval = body
|
||||
.get("interval")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10);
|
||||
DeviceFlowStatus::SlowDown {
|
||||
new_interval: interval,
|
||||
}
|
||||
}
|
||||
"expired_token" => DeviceFlowStatus::Expired,
|
||||
"access_denied" => DeviceFlowStatus::AccessDenied,
|
||||
_ => {
|
||||
let desc = body
|
||||
.get("error_description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(error);
|
||||
DeviceFlowStatus::Error(desc.to_string())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Success — extract access token
|
||||
if let Some(token) = body.get("access_token").and_then(|v| v.as_str()) {
|
||||
DeviceFlowStatus::Complete {
|
||||
access_token: Zeroizing::new(token.to_string()),
|
||||
}
|
||||
} else {
|
||||
DeviceFlowStatus::Error("No access_token in response".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_constants() {
|
||||
assert!(GITHUB_DEVICE_CODE_URL.starts_with("https://"));
|
||||
assert!(GITHUB_TOKEN_URL.starts_with("https://"));
|
||||
assert!(!COPILOT_CLIENT_ID.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Claude Code CLI backend driver.
|
||||
//!
|
||||
//! Spawns the `claude` CLI (Claude Code) as a subprocess in print mode (`-p`),
|
||||
//! which is non-interactive and handles its own authentication.
|
||||
//! This allows users with Claude Code installed to use it as an LLM provider
|
||||
//! without needing a separate API key.
|
||||
|
||||
use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent};
|
||||
use async_trait::async_trait;
|
||||
use openfang_types::message::{ContentBlock, Role, StopReason, TokenUsage};
|
||||
use serde::Deserialize;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// LLM driver that delegates to the Claude Code CLI.
|
||||
pub struct ClaudeCodeDriver {
|
||||
cli_path: String,
|
||||
}
|
||||
|
||||
impl ClaudeCodeDriver {
|
||||
/// Create a new Claude Code driver.
|
||||
///
|
||||
/// `cli_path` overrides the CLI binary path; defaults to `"claude"` on PATH.
|
||||
pub fn new(cli_path: Option<String>) -> Self {
|
||||
Self {
|
||||
cli_path: cli_path
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "claude".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect if the Claude Code CLI is available on PATH.
|
||||
pub fn detect() -> Option<String> {
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a text prompt from the completion request messages.
|
||||
fn build_prompt(request: &CompletionRequest) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(ref sys) = request.system {
|
||||
parts.push(format!("[System]\n{sys}"));
|
||||
}
|
||||
|
||||
for msg in &request.messages {
|
||||
let role_label = match msg.role {
|
||||
Role::User => "User",
|
||||
Role::Assistant => "Assistant",
|
||||
Role::System => "System",
|
||||
};
|
||||
let text = msg.content.text_content();
|
||||
if !text.is_empty() {
|
||||
parts.push(format!("[{role_label}]\n{text}"));
|
||||
}
|
||||
}
|
||||
|
||||
parts.join("\n\n")
|
||||
}
|
||||
|
||||
/// Map a model ID like "claude-code/opus" to CLI --model flag value.
|
||||
fn model_flag(model: &str) -> Option<String> {
|
||||
let stripped = model
|
||||
.strip_prefix("claude-code/")
|
||||
.unwrap_or(model);
|
||||
match stripped {
|
||||
"opus" => Some("opus".to_string()),
|
||||
"sonnet" => Some("sonnet".to_string()),
|
||||
"haiku" => Some("haiku".to_string()),
|
||||
_ => Some(stripped.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON output from `claude -p --output-format json`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClaudeJsonOutput {
|
||||
result: Option<String>,
|
||||
#[serde(default)]
|
||||
usage: Option<ClaudeUsage>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
/// Usage stats from Claude CLI JSON output.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct ClaudeUsage {
|
||||
#[serde(default)]
|
||||
input_tokens: u64,
|
||||
#[serde(default)]
|
||||
output_tokens: u64,
|
||||
}
|
||||
|
||||
/// Stream JSON event from `claude -p --output-format stream-json`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClaudeStreamEvent {
|
||||
#[serde(default)]
|
||||
r#type: String,
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
result: Option<String>,
|
||||
#[serde(default)]
|
||||
usage: Option<ClaudeUsage>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for ClaudeCodeDriver {
|
||||
async fn complete(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
let prompt = Self::build_prompt(&request);
|
||||
let model_flag = Self::model_flag(&request.model);
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&self.cli_path);
|
||||
cmd.arg("-p")
|
||||
.arg(&prompt)
|
||||
.arg("--output-format")
|
||||
.arg("json");
|
||||
|
||||
if let Some(ref model) = model_flag {
|
||||
cmd.arg("--model").arg(model);
|
||||
}
|
||||
|
||||
// SECURITY: Don't inherit all env vars — only safe ones
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
debug!(cli = %self.cli_path, "Spawning Claude Code CLI");
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(format!("Failed to spawn claude CLI: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(LlmError::Api {
|
||||
status: output.status.code().unwrap_or(1) as u16,
|
||||
message: format!("Claude CLI failed: {stderr}"),
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Try JSON parse first
|
||||
if let Ok(parsed) = serde_json::from_str::<ClaudeJsonOutput>(&stdout) {
|
||||
let text = parsed.result.unwrap_or_default();
|
||||
let usage = parsed.usage.unwrap_or_default();
|
||||
return Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text { text: text.clone() }],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: Vec::new(),
|
||||
usage: TokenUsage {
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: treat entire stdout as plain text
|
||||
let text = stdout.trim().to_string();
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text { text }],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: Vec::new(),
|
||||
usage: TokenUsage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
tx: tokio::sync::mpsc::Sender<StreamEvent>,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
let prompt = Self::build_prompt(&request);
|
||||
let model_flag = Self::model_flag(&request.model);
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&self.cli_path);
|
||||
cmd.arg("-p")
|
||||
.arg(&prompt)
|
||||
.arg("--output-format")
|
||||
.arg("stream-json");
|
||||
|
||||
if let Some(ref model) = model_flag {
|
||||
cmd.arg("--model").arg(model);
|
||||
}
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
debug!(cli = %self.cli_path, "Spawning Claude Code CLI (streaming)");
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| LlmError::Http(format!("Failed to spawn claude CLI: {e}")))?;
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| LlmError::Http("No stdout from claude CLI".to_string()))?;
|
||||
|
||||
let reader = tokio::io::BufReader::new(stdout);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
let mut full_text = String::new();
|
||||
let mut final_usage = TokenUsage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
};
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match serde_json::from_str::<ClaudeStreamEvent>(&line) {
|
||||
Ok(event) => {
|
||||
match event.r#type.as_str() {
|
||||
"content" | "text" => {
|
||||
if let Some(ref content) = event.content {
|
||||
full_text.push_str(content);
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta {
|
||||
text: content.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"result" | "done" | "complete" => {
|
||||
if let Some(ref result) = event.result {
|
||||
if full_text.is_empty() {
|
||||
full_text = result.clone();
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta {
|
||||
text: result.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Some(usage) = event.usage {
|
||||
final_usage = TokenUsage {
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unknown event type — try content field as fallback
|
||||
if let Some(ref content) = event.content {
|
||||
full_text.push_str(content);
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta {
|
||||
text: content.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Not valid JSON — treat as raw text
|
||||
warn!(line = %line, error = %e, "Non-JSON line from Claude CLI");
|
||||
full_text.push_str(&line);
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta { text: line })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process to finish
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(format!("Claude CLI wait failed: {e}")))?;
|
||||
|
||||
if !status.success() {
|
||||
warn!(code = ?status.code(), "Claude CLI exited with error");
|
||||
}
|
||||
|
||||
let _ = tx
|
||||
.send(StreamEvent::ContentComplete {
|
||||
stop_reason: StopReason::EndTurn,
|
||||
usage: final_usage,
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text { text: full_text }],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: Vec::new(),
|
||||
usage: final_usage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the Claude Code CLI is available.
|
||||
pub fn claude_code_available() -> bool {
|
||||
ClaudeCodeDriver::detect().is_some()
|
||||
|| claude_credentials_exist()
|
||||
}
|
||||
|
||||
/// Check if Claude credentials file exists (~/.claude/.credentials.json).
|
||||
fn claude_credentials_exist() -> bool {
|
||||
if let Some(home) = home_dir() {
|
||||
home.join(".claude").join(".credentials.json").exists()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-platform home directory.
|
||||
fn home_dir() -> Option<std::path::PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("USERPROFILE").ok().map(std::path::PathBuf::from)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::var("HOME").ok().map(std::path::PathBuf::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_prompt_simple() {
|
||||
use openfang_types::message::{Message, MessageContent};
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: "claude-code/sonnet".to_string(),
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::text("Hello"),
|
||||
}],
|
||||
tools: vec![],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
system: Some("You are helpful.".to_string()),
|
||||
thinking: None,
|
||||
};
|
||||
|
||||
let prompt = ClaudeCodeDriver::build_prompt(&request);
|
||||
assert!(prompt.contains("[System]"));
|
||||
assert!(prompt.contains("You are helpful."));
|
||||
assert!(prompt.contains("[User]"));
|
||||
assert!(prompt.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_flag_mapping() {
|
||||
assert_eq!(
|
||||
ClaudeCodeDriver::model_flag("claude-code/opus"),
|
||||
Some("opus".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ClaudeCodeDriver::model_flag("claude-code/sonnet"),
|
||||
Some("sonnet".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ClaudeCodeDriver::model_flag("claude-code/haiku"),
|
||||
Some("haiku".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ClaudeCodeDriver::model_flag("custom-model"),
|
||||
Some("custom-model".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_defaults_to_claude() {
|
||||
let driver = ClaudeCodeDriver::new(None);
|
||||
assert_eq!(driver.cli_path, "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_with_custom_path() {
|
||||
let driver = ClaudeCodeDriver::new(Some("/usr/local/bin/claude".to_string()));
|
||||
assert_eq!(driver.cli_path, "/usr/local/bin/claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_with_empty_path() {
|
||||
let driver = ClaudeCodeDriver::new(Some(String::new()));
|
||||
assert_eq!(driver.cli_path, "claude");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Mistral, Fireworks, Ollama, vLLM, and any OpenAI-compatible endpoint.
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod claude_code;
|
||||
pub mod copilot;
|
||||
pub mod fallback;
|
||||
pub mod gemini;
|
||||
@@ -132,6 +133,16 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "GITHUB_TOKEN",
|
||||
key_required: true,
|
||||
}),
|
||||
"codex" | "openai-codex" => Some(ProviderDefaults {
|
||||
base_url: OPENAI_BASE_URL,
|
||||
api_key_env: "OPENAI_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"claude-code" => Some(ProviderDefaults {
|
||||
base_url: "",
|
||||
api_key_env: "",
|
||||
key_required: false,
|
||||
}),
|
||||
"moonshot" | "kimi" => Some(ProviderDefaults {
|
||||
base_url: MOONSHOT_BASE_URL,
|
||||
api_key_env: "MOONSHOT_API_KEY",
|
||||
@@ -227,6 +238,31 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
return Ok(Arc::new(gemini::GeminiDriver::new(api_key, base_url)));
|
||||
}
|
||||
|
||||
// Codex — reuses OpenAI driver with credential sync from Codex CLI
|
||||
if provider == "codex" || provider == "openai-codex" {
|
||||
let api_key = config
|
||||
.api_key
|
||||
.clone()
|
||||
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
|
||||
.or_else(crate::model_catalog::read_codex_credential)
|
||||
.ok_or_else(|| {
|
||||
LlmError::MissingApiKey(
|
||||
"Set OPENAI_API_KEY or install Codex CLI".to_string(),
|
||||
)
|
||||
})?;
|
||||
let base_url = config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| OPENAI_BASE_URL.to_string());
|
||||
return Ok(Arc::new(openai::OpenAIDriver::new(api_key, base_url)));
|
||||
}
|
||||
|
||||
// Claude Code CLI — subprocess-based, no API key needed
|
||||
if provider == "claude-code" {
|
||||
let cli_path = config.base_url.clone();
|
||||
return Ok(Arc::new(claude_code::ClaudeCodeDriver::new(cli_path)));
|
||||
}
|
||||
|
||||
// GitHub Copilot — wraps OpenAI-compatible driver with automatic token exchange.
|
||||
// The CopilotDriver exchanges the GitHub PAT for a Copilot API token on demand,
|
||||
// caches it, and refreshes when expired.
|
||||
@@ -287,8 +323,8 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
message: format!(
|
||||
"Unknown provider '{}'. Supported: anthropic, gemini, openai, groq, openrouter, \
|
||||
deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \
|
||||
cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot. \
|
||||
Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \
|
||||
codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
provider
|
||||
),
|
||||
})
|
||||
@@ -324,6 +360,8 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
"zhipu",
|
||||
"zhipu_coding",
|
||||
"qianfan",
|
||||
"codex",
|
||||
"claude-code",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -417,7 +455,9 @@ mod tests {
|
||||
assert!(providers.contains(&"zhipu"));
|
||||
assert!(providers.contains(&"zhipu_coding"));
|
||||
assert!(providers.contains(&"qianfan"));
|
||||
assert_eq!(providers.len(), 27);
|
||||
assert!(providers.contains(&"codex"));
|
||||
assert!(providers.contains(&"claude-code"));
|
||||
assert_eq!(providers.len(), 29);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod auth_cooldown;
|
||||
pub mod browser;
|
||||
pub mod command_lane;
|
||||
pub mod compactor;
|
||||
pub mod copilot_oauth;
|
||||
pub mod context_budget;
|
||||
pub mod context_overflow;
|
||||
pub mod docker_sandbox;
|
||||
|
||||
@@ -48,16 +48,28 @@ impl ModelCatalog {
|
||||
for provider in &mut self.providers {
|
||||
if !provider.key_required {
|
||||
provider.auth_status = AuthStatus::NotRequired;
|
||||
} else if std::env::var(&provider.api_key_env).is_ok() {
|
||||
provider.auth_status = AuthStatus::Configured;
|
||||
} else {
|
||||
// Special case: Gemini also accepts GOOGLE_API_KEY
|
||||
if provider.id == "gemini" && std::env::var("GOOGLE_API_KEY").is_ok() {
|
||||
provider.auth_status = AuthStatus::Configured;
|
||||
} else {
|
||||
provider.auth_status = AuthStatus::Missing;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Primary: check the provider's declared env var
|
||||
let has_key = std::env::var(&provider.api_key_env).is_ok();
|
||||
|
||||
// Secondary: provider-specific fallback auth
|
||||
let has_fallback = match provider.id.as_str() {
|
||||
"gemini" => std::env::var("GOOGLE_API_KEY").is_ok(),
|
||||
"codex" => {
|
||||
std::env::var("OPENAI_API_KEY").is_ok()
|
||||
|| read_codex_credential().is_some()
|
||||
}
|
||||
"claude-code" => crate::drivers::claude_code::claude_code_available(),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
provider.auth_status = if has_key || has_fallback {
|
||||
AuthStatus::Configured
|
||||
} else {
|
||||
AuthStatus::Missing
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +222,53 @@ impl Default for ModelCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an OpenAI API key from the Codex CLI credential file.
|
||||
///
|
||||
/// Checks `$CODEX_HOME/auth.json` or `~/.codex/auth.json`.
|
||||
/// Returns `Some(api_key)` if the file exists and contains a valid, non-expired token.
|
||||
/// Only checks presence — the actual key value is used transiently, never stored.
|
||||
pub fn read_codex_credential() -> Option<String> {
|
||||
let codex_home = std::env::var("CODEX_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("USERPROFILE")
|
||||
.ok()
|
||||
.map(|h| std::path::PathBuf::from(h).join(".codex"))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(|h| std::path::PathBuf::from(h).join(".codex"))
|
||||
}
|
||||
})?;
|
||||
|
||||
let auth_path = codex_home.join("auth.json");
|
||||
let content = std::fs::read_to_string(&auth_path).ok()?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
|
||||
// Check expiry if present
|
||||
if let Some(expires_at) = parsed.get("expires_at").and_then(|v| v.as_i64()) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
if now >= expires_at {
|
||||
return None; // Expired
|
||||
}
|
||||
}
|
||||
|
||||
parsed
|
||||
.get("api_key")
|
||||
.or_else(|| parsed.get("token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builtin data
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -472,6 +531,26 @@ fn builtin_providers() -> Vec<ProviderInfo> {
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── OpenAI Codex ────────────────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "codex".into(),
|
||||
display_name: "OpenAI Codex".into(),
|
||||
api_key_env: "OPENAI_API_KEY".into(),
|
||||
base_url: OPENAI_BASE_URL.into(),
|
||||
key_required: true,
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── Claude Code CLI ─────────────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "claude-code".into(),
|
||||
display_name: "Claude Code".into(),
|
||||
api_key_env: String::new(),
|
||||
base_url: String::new(),
|
||||
key_required: false,
|
||||
auth_status: AuthStatus::NotRequired,
|
||||
model_count: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -526,8 +605,19 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("glm", "glm-4-plus"),
|
||||
("ernie", "ernie-4.5-8k"),
|
||||
("kimi", "moonshot-v1-128k"),
|
||||
("minimax", "minimax-text-01"),
|
||||
("minimax", "MiniMax-M2.5"),
|
||||
("minimax-m2.5", "MiniMax-M2.5"),
|
||||
("minimax-m2.1", "MiniMax-M2.1"),
|
||||
("codegeex", "codegeex-4"),
|
||||
// Codex aliases
|
||||
("codex", "codex/gpt-4.1"),
|
||||
("codex-4.1", "codex/gpt-4.1"),
|
||||
("codex-o4", "codex/o4-mini"),
|
||||
// Claude Code aliases
|
||||
("claude-code", "claude-code/sonnet"),
|
||||
("claude-code-opus", "claude-code/opus"),
|
||||
("claude-code-sonnet", "claude-code/sonnet"),
|
||||
("claude-code-haiku", "claude-code/haiku"),
|
||||
];
|
||||
pairs
|
||||
.into_iter()
|
||||
@@ -2242,7 +2332,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// MiniMax (3)
|
||||
// MiniMax (4)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "minimax-text-01".into(),
|
||||
@@ -2258,6 +2348,20 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec!["minimax".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "MiniMax-M2.5".into(),
|
||||
display_name: "MiniMax M2.5".into(),
|
||||
provider: "minimax".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 1_048_576,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 1.10,
|
||||
output_cost_per_m: 4.40,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["minimax-m2.5".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "MiniMax-M2.1".into(),
|
||||
display_name: "MiniMax M2.1".into(),
|
||||
@@ -2270,7 +2374,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
aliases: vec!["minimax-m2.1".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "abab6.5-chat".into(),
|
||||
@@ -2567,6 +2671,82 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// OpenAI Codex (2) — reuses OpenAI driver
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "codex/gpt-4.1".into(),
|
||||
display_name: "GPT-4.1 (Codex)".into(),
|
||||
provider: "codex".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 1_047_576,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 2.00,
|
||||
output_cost_per_m: 8.00,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["codex".into(), "codex-4.1".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "codex/o4-mini".into(),
|
||||
display_name: "o4-mini (Codex)".into(),
|
||||
provider: "codex".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 100_000,
|
||||
input_cost_per_m: 1.10,
|
||||
output_cost_per_m: 4.40,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["codex-o4".into()],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Claude Code CLI (3) — subprocess-based
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "claude-code/opus".into(),
|
||||
display_name: "Claude Opus (CLI)".into(),
|
||||
provider: "claude-code".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 128_000,
|
||||
input_cost_per_m: 5.0,
|
||||
output_cost_per_m: 25.0,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["claude-code-opus".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "claude-code/sonnet".into(),
|
||||
display_name: "Claude Sonnet (CLI)".into(),
|
||||
provider: "claude-code".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
input_cost_per_m: 3.0,
|
||||
output_cost_per_m: 15.0,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["claude-code".into(), "claude-code-sonnet".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "claude-code/haiku".into(),
|
||||
display_name: "Claude Haiku (CLI)".into(),
|
||||
provider: "claude-code".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.25,
|
||||
output_cost_per_m: 1.25,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["claude-code-haiku".into()],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2583,7 +2763,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_catalog_has_providers() {
|
||||
let catalog = ModelCatalog::new();
|
||||
assert_eq!(catalog.list_providers().len(), 28);
|
||||
assert_eq!(catalog.list_providers().len(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2817,6 +2997,14 @@ mod tests {
|
||||
assert!(catalog.find_model("codegeex").is_some());
|
||||
assert!(catalog.find_model("ernie").is_some());
|
||||
assert!(catalog.find_model("minimax").is_some());
|
||||
// MiniMax M2.5 — by exact ID, alias, and case-insensitive
|
||||
let m25 = catalog.find_model("MiniMax-M2.5").unwrap();
|
||||
assert_eq!(m25.provider, "minimax");
|
||||
assert_eq!(m25.tier, ModelTier::Frontier);
|
||||
assert!(catalog.find_model("minimax-m2.5").is_some());
|
||||
// Default "minimax" alias now points to M2.5
|
||||
let default = catalog.find_model("minimax").unwrap();
|
||||
assert_eq!(default.id, "MiniMax-M2.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2871,4 +3059,54 @@ mod tests {
|
||||
LMSTUDIO_BASE_URL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_provider() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let codex = catalog.get_provider("codex").unwrap();
|
||||
assert_eq!(codex.display_name, "OpenAI Codex");
|
||||
assert_eq!(codex.api_key_env, "OPENAI_API_KEY");
|
||||
assert!(codex.key_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_models() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let models = catalog.models_by_provider("codex");
|
||||
assert_eq!(models.len(), 2);
|
||||
assert!(models.iter().any(|m| m.id == "codex/gpt-4.1"));
|
||||
assert!(models.iter().any(|m| m.id == "codex/o4-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_aliases() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog.find_model("codex").unwrap();
|
||||
assert_eq!(entry.id, "codex/gpt-4.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_code_provider() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let cc = catalog.get_provider("claude-code").unwrap();
|
||||
assert_eq!(cc.display_name, "Claude Code");
|
||||
assert!(!cc.key_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_code_models() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let models = catalog.models_by_provider("claude-code");
|
||||
assert_eq!(models.len(), 3);
|
||||
assert!(models.iter().any(|m| m.id == "claude-code/opus"));
|
||||
assert!(models.iter().any(|m| m.id == "claude-code/sonnet"));
|
||||
assert!(models.iter().any(|m| m.id == "claude-code/haiku"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_code_aliases() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog.find_model("claude-code").unwrap();
|
||||
assert_eq!(entry.id, "claude-code/sonnet");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user