Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/crates/adapters/ai-adapters/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
Scope: this guide applies to `src/crates/adapters/ai-adapters`.

`bitfun-ai-adapters` owns provider-specific request/response mapping, stream
protocol parsing, and provider/model selection helpers that are independent of
core config IO. Keep provider quirks here, then convert stream chunks into the
provider-neutral contracts owned by `bitfun-agent-stream`.
protocol parsing, CLI credential resolution, and provider/model selection
helpers that are independent of core config IO. Keep provider quirks here, then
convert stream chunks into the provider-neutral contracts owned by
`bitfun-agent-stream`.

## Guardrails

Expand All @@ -19,12 +20,17 @@ provider-neutral contracts owned by `bitfun-agent-stream`.
adapter tests and downstream usage expectations.
- Do not move provider-neutral stream DTOs, replay policy, or tool-call
accumulation ownership back into this crate.
- CLI credential probing may reuse lower-layer service command helpers for
PATH and process-platform behavior; do not introduce host framework calls.
- Keep `cli-credentials` optional so standalone protocol adapters do not pull
service/process dependencies by default.

## Verification

```bash
cargo test -p bitfun-agent-stream
cargo test -p bitfun-ai-adapters
cargo test -p bitfun-ai-adapters --features cli-credentials cli_credentials
```

If stream behavior affects core integration, also run the relevant tests in
Expand Down
7 changes: 7 additions & 0 deletions src/crates/adapters/ai-adapters/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ crate-type = ["rlib"]
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true, optional = true }
bitfun-agent-stream = { path = "../../execution/agent-stream" }
bitfun-core-types = { path = "../../contracts/core-types" }
bitfun-services-core = { path = "../../services/services-core", default-features = false, optional = true }
chrono = { workspace = true }
dirs = { workspace = true, optional = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
log = { workspace = true }
Expand All @@ -25,6 +28,10 @@ tokio = { workspace = true }
tokio-stream = { workspace = true }
tokio-util = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true, optional = true }

[features]
cli-credentials = ["dep:base64", "dep:bitfun-services-core", "dep:dirs", "dep:uuid"]

[dev-dependencies]
axum = { workspace = true }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@ fn parse_codex_cli_version(output: &str) -> Option<String> {
}

async fn resolve_codex_cli_version() -> Option<String> {
let check = crate::service::system::check_command("codex");
let check = bitfun_services_core::system::check_command("codex");
let command = check.path.as_deref()?;
let args = vec!["--version".to_string()];
let output = crate::service::system::run_command(command, &args, None, None)
let output = bitfun_services_core::system::run_command(command, &args, None, None)
.await
.ok()?;
if !output.success {
Expand Down
89 changes: 89 additions & 0 deletions src/crates/adapters/ai-adapters/src/cli_credentials/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! CLI credential discovery and resolution.
//!
//! Lets BitFun reuse already-authenticated Codex CLI / Gemini CLI sessions on
//! the local machine instead of asking the user to paste an API key. Each
//! provider exposes a [`CredentialResolver`] that:
//! * inspects well-known config files in the user's home directory,
//! * refreshes OAuth tokens if they are expired or close to expiry,
//! * returns a [`ResolvedCredential`] that the AI client factory uses to
//! override `api_key` / `base_url` / `request_url` / `format` /
//! `custom_headers` before constructing an `AIClient`.

pub mod codex;
pub mod gemini;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Source kind of a discovered CLI credential. Persisted on `AIModelConfig.auth`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CliCredentialKind {
Codex,
Gemini,
}

/// Concrete sub-mode of a credential, auto-detected from the on-disk file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CliCredentialMode {
/// Codex CLI in `OPENAI_API_KEY` mode, or Gemini CLI in `GEMINI_API_KEY` mode.
ApiKey,
/// Codex CLI in ChatGPT login mode (uses chatgpt.com backend with OAuth tokens).
ChatGpt,
/// Gemini CLI in personal Google OAuth mode (uses Cloud Code Assist endpoint).
OauthPersonal,
}

/// What `discover_cli_credentials` returns to the UI for each detected source.
#[derive(Debug, Clone, Serialize)]
pub struct DiscoveredCredential {
pub kind: CliCredentialKind,
pub mode: CliCredentialMode,
pub display_label: String,
pub account: Option<String>,
pub expires_at: Option<i64>,
pub source_path: String,
/// Suggested provider format (`responses`, `gemini`, `gemini-code-assist`, `openai`).
pub suggested_format: String,
/// Suggested base URL to seed the model entry with.
pub suggested_base_url: String,
/// Suggested model name to seed the entry with.
pub suggested_model: String,
}

/// Final, runtime-resolved credential that overrides fields in `AIConfig`.
#[derive(Debug, Clone)]
pub struct ResolvedCredential {
pub api_key: String,
pub base_url: Option<String>,
pub request_url: Option<String>,
pub format: Option<String>,
pub extra_headers: HashMap<String, String>,
/// Unix seconds when this credential expires; `None` means non-expiring.
pub expires_at: Option<i64>,
}

#[async_trait]
pub trait CredentialResolver: Send + Sync {
async fn resolve(&self) -> anyhow::Result<ResolvedCredential>;
}

/// Discover all CLI credentials on the local machine. Errors per-source are
/// swallowed (logged) so that a broken Codex install doesn't hide a working
/// Gemini install.
pub async fn discover_all() -> Vec<DiscoveredCredential> {
let mut out = Vec::new();
match codex::discover().await {
Ok(Some(item)) => out.push(item),
Ok(None) => {}
Err(err) => log::debug!("codex credential discovery failed: {err:#}"),
}
match gemini::discover().await {
Ok(Some(item)) => out.push(item),
Ok(None) => {}
Err(err) => log::debug!("gemini credential discovery failed: {err:#}"),
}
out
}
2 changes: 2 additions & 0 deletions src/crates/adapters/ai-adapters/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#![doc = include_str!("../README.md")]

#[cfg(feature = "cli-credentials")]
pub mod cli_credentials;
pub mod client;
pub mod diagnostics;
pub mod model_selector;
Expand Down
9 changes: 6 additions & 3 deletions src/crates/assembly/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ dirs = { workspace = true }
dunce = { workspace = true }
filetime = { workspace = true, optional = true }
fs2 = { workspace = true, optional = true }
zip = { workspace = true }
flate2 = { workspace = true, optional = true }
include_dir = { workspace = true, optional = true }

Expand Down Expand Up @@ -97,7 +96,7 @@ bitfun-agent-tools = { path = "../../execution/tool-contracts" }
bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-features = false, optional = true }

# Core service owner crate
bitfun-services-core = { path = "../../services/services-core" }
bitfun-services-core = { path = "../../services/services-core", default-features = false, features = ["lsp"] }

# Integration service owner crate
bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, features = ["remote-ssh"] }
Expand Down Expand Up @@ -183,7 +182,11 @@ product-full = [
"service-integrations",
"tool-packs",
]
ai-adapter-runtime = ["dep:bitfun-ai-adapters", "dep:reqwest"]
ai-adapter-runtime = [
"dep:bitfun-ai-adapters",
"bitfun-ai-adapters/cli-credentials",
"dep:reqwest",
]
product-capabilities = ["dep:bitfun-product-capabilities"]
product-domains = [
"ai-adapter-runtime",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ use crate::agentic::agents::{
};
use crate::service::config::global::GlobalConfigManager;
use crate::service::config::types::{DebugModeConfig, LanguageDebugTemplate};
use crate::service::lsp::project_detector::{ProjectDetector, ProjectInfo};
use crate::util::errors::BitFunResult;
use async_trait::async_trait;
use bitfun_services_core::lsp::project_detector::{ProjectDetector, ProjectInfo};
use log::debug;
use std::path::Path;

Expand Down
90 changes: 3 additions & 87 deletions src/crates/assembly/core/src/infrastructure/cli_credentials/mod.rs
Original file line number Diff line number Diff line change
@@ -1,89 +1,5 @@
//! CLI credential discovery and resolution.
//! Compatibility re-exports for CLI credential discovery and resolution.
//!
//! Lets BitFun reuse already-authenticated Codex CLI / Gemini CLI sessions on
//! the local machine instead of asking the user to paste an API key. Each
//! provider exposes a [`CredentialResolver`] that:
//! * inspects well-known config files in the user's home directory,
//! * refreshes OAuth tokens if they are expired or close to expiry,
//! * returns a [`ResolvedCredential`] that the AI client factory uses to
//! override `api_key` / `base_url` / `request_url` / `format` /
//! `custom_headers` before constructing an `AIClient`.
//! The provider-specific implementation lives in `bitfun-ai-adapters`.

pub mod codex;
pub mod gemini;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Source kind of a discovered CLI credential. Persisted on `AIModelConfig.auth`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CliCredentialKind {
Codex,
Gemini,
}

/// Concrete sub-mode of a credential, auto-detected from the on-disk file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CliCredentialMode {
/// Codex CLI in `OPENAI_API_KEY` mode, or Gemini CLI in `GEMINI_API_KEY` mode.
ApiKey,
/// Codex CLI in ChatGPT login mode (uses chatgpt.com backend with OAuth tokens).
ChatGpt,
/// Gemini CLI in personal Google OAuth mode (uses Cloud Code Assist endpoint).
OauthPersonal,
}

/// What `discover_cli_credentials` returns to the UI for each detected source.
#[derive(Debug, Clone, Serialize)]
pub struct DiscoveredCredential {
pub kind: CliCredentialKind,
pub mode: CliCredentialMode,
pub display_label: String,
pub account: Option<String>,
pub expires_at: Option<i64>,
pub source_path: String,
/// Suggested provider format (`responses`, `gemini`, `gemini-code-assist`, `openai`).
pub suggested_format: String,
/// Suggested base URL to seed the model entry with.
pub suggested_base_url: String,
/// Suggested model name to seed the entry with.
pub suggested_model: String,
}

/// Final, runtime-resolved credential that overrides fields in `AIConfig`.
#[derive(Debug, Clone)]
pub struct ResolvedCredential {
pub api_key: String,
pub base_url: Option<String>,
pub request_url: Option<String>,
pub format: Option<String>,
pub extra_headers: HashMap<String, String>,
/// Unix seconds when this credential expires; `None` means non-expiring.
pub expires_at: Option<i64>,
}

#[async_trait]
pub trait CredentialResolver: Send + Sync {
async fn resolve(&self) -> anyhow::Result<ResolvedCredential>;
}

/// Discover all CLI credentials on the local machine. Errors per-source are
/// swallowed (logged) so that a broken Codex install doesn't hide a working
/// Gemini install.
pub async fn discover_all() -> Vec<DiscoveredCredential> {
let mut out = Vec::new();
match codex::discover().await {
Ok(Some(item)) => out.push(item),
Ok(None) => {}
Err(err) => log::debug!("codex credential discovery failed: {err:#}"),
}
match gemini::discover().await {
Ok(Some(item)) => out.push(item),
Ok(None) => {}
Err(err) => log::debug!("gemini credential discovery failed: {err:#}"),
}
out
}
pub use bitfun_ai_adapters::cli_credentials::*;
Loading
Loading