v3.4.0: subscription backend (Claude Code / Codex / Grok logins)

The Rust harness can now use models two ways:
- API: provider API key (OpenAI-compatible HTTP) — existing path
- Subscription: drive the locally-installed agentic CLI login directly, no API
  key (anthropic→claude, openai→codex, xai→grok)

- models.rs: ChatClient::chat_cli spawns the CLI (stdin prompt), cli_binary_for
  + installed_cli_backends + binary_in_path PATH detection
- pool.rs: ModelPool::with_auth(subscription); one() routes per model
- types/CLI: RunConfig.subscription + `run --subscription` flag
- web: /api/run honors "subscription"; /api/info reports detected cli_backends;
  SPA gets a "Use subscription" toggle

Verified live: `run --subscription --model anthropic:claude-haiku-4-5` drove the
Claude subscription end-to-end (recon + agent + vote) with no API key set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUPandClaude Opus 4.8 committed 2026-06-22 16:59:35 -03:00
1 parent 56d3f0c723
commit d59f28f36d
8 files changed
+111 -9

No files matched your search

+3 -1
View File
@@ -14,7 +14,9 @@ pub mod report;
pub mod types;
pub use agents::{Agent, Library};
pub use models::{provider_for, providers, ChatClient, ModelRef, Provider};
pub use models::{
cli_binary_for, installed_cli_backends, provider_for, providers, ChatClient, ModelRef, Provider,
};
pub use pipeline::run;
pub use pool::ModelPool;
pub use types::{Finding, RunConfig};
@@ -1,6 +1,9 @@
use anyhow::{anyhow, Result};
use serde::Serialize;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
/// A model provider exposing an OpenAI-compatible `/chat/completions` endpoint.
#[derive(Clone, Debug, Serialize)]
@@ -119,6 +122,66 @@ impl ChatClient {
}
}
impl ChatClient {
/// Complete via a locally-installed **agentic CLI subscription** (Claude
/// Code / Codex / Grok) instead of an API key. This uses the user's logged-in
/// subscription, so no provider key is required.
pub async fn chat_cli(&self, provider: &str, model: &str, system: &str, user: &str) -> Result<String> {
let bin = cli_binary_for(provider)
.ok_or_else(|| anyhow!("no CLI/subscription backend for provider '{}'", provider))?;
let prompt = format!("{system}\n\n{user}");
let mut cmd = Command::new(bin);
match bin {
// Claude Code headless print mode (uses the Claude subscription login).
"claude" => {
cmd.arg("-p").arg("--model").arg(model);
}
// Codex non-interactive exec (uses the ChatGPT/Codex login), prompt on stdin.
"codex" => {
cmd.arg("exec").arg("--model").arg(model).arg("-");
}
// Grok CLI, prompt on stdin (best-effort flags).
"grok" => {
cmd.arg("--model").arg(model);
}
_ => {}
}
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| anyhow!("spawn {} failed: {}", bin, e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(prompt.as_bytes()).await?;
// Drop closes stdin so the CLI processes the prompt and exits.
}
let out = child.wait_with_output().await?;
if !out.status.success() {
return Err(anyhow!("{} subscription CLI failed: {}", bin, truncate(&String::from_utf8_lossy(&out.stderr), 200)));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
}
/// Map a provider to its local agentic CLI binary (subscription backend).
pub fn cli_binary_for(provider: &str) -> Option<&'static str> {
match provider {
"anthropic" => Some("claude"),
"openai" => Some("codex"),
"xai" => Some("grok"),
_ => None,
}
}
/// Is `name` an executable found on PATH?
pub fn binary_in_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).any(|dir| dir.join(name).is_file()))
.unwrap_or(false)
}
/// Which subscription CLI backends are installed locally.
pub fn installed_cli_backends() -> Vec<&'static str> {
["claude", "codex", "grok"].into_iter().filter(|b| binary_in_path(b)).collect()
}
impl Default for ChatClient {
fn default() -> Self {
Self::new()
+20 -3
View File
@@ -1,18 +1,26 @@
use crate::models::{ChatClient, ModelRef};
use crate::models::{cli_binary_for, ChatClient, ModelRef};
use anyhow::{anyhow, Result};
use std::sync::Arc;
use tokio::sync::Semaphore;
/// A pool of candidate models with a global concurrency cap and provider
/// failover. The same panel of models is reused for validator voting.
///
/// `subscription = true` routes each model through its local agentic CLI
/// (Claude Code / Codex / Grok login) instead of an HTTP API key.
pub struct ModelPool {
client: ChatClient,
sem: Arc<Semaphore>,
pub candidates: Vec<ModelRef>,
pub subscription: bool,
}
impl ModelPool {
pub fn new(models: Vec<ModelRef>, concurrency: usize) -> Self {
Self::with_auth(models, concurrency, false)
}
pub fn with_auth(models: Vec<ModelRef>, concurrency: usize, subscription: bool) -> Self {
let concurrency = concurrency.max(1);
ModelPool {
client: ChatClient::new(),
@@ -22,16 +30,25 @@ impl ModelPool {
} else {
models
},
subscription,
}
}
/// One completion for a model, via subscription CLI or HTTP API.
async fn one(&self, m: &ModelRef, system: &str, user: &str) -> Result<String> {
if self.subscription && cli_binary_for(&m.provider).is_some() {
return self.client.chat_cli(&m.provider, &m.model, system, user).await;
}
self.client.chat(m, system, user).await
}
/// Complete a prompt, trying each candidate model until one succeeds.
/// Returns the model that answered and its text.
pub async fn complete(&self, system: &str, user: &str) -> Result<(ModelRef, String)> {
let _permit = self.sem.acquire().await.expect("semaphore closed");
let mut last = anyhow!("no candidate models");
for m in &self.candidates {
match self.client.chat(m, system, user).await {
match self.one(m, system, user).await {
Ok(text) => return Ok((m.clone(), text)),
Err(e) => last = e,
}
@@ -51,7 +68,7 @@ impl ModelPool {
Ok(p) => p,
Err(_) => break,
};
if let Ok(text) = self.client.chat(m, system, user).await {
if let Ok(text) = self.one(m, system, user).await {
total += 1;
let t = text.to_lowercase();
if t.contains("\"verdict\": \"confirmed\"")
@@ -70,6 +70,10 @@ pub struct RunConfig {
/// Offline mode: exercise the full pipeline without calling any model API.
#[serde(default)]
pub offline: bool,
/// Use local agentic CLI subscriptions (Claude Code / Codex / Grok) instead
/// of HTTP API keys.
#[serde(default)]
pub subscription: bool,
}
fn default_vote() -> usize {
@@ -88,6 +92,7 @@ impl RunConfig {
concurrency: 8,
max_agents: 0,
offline: false,
subscription: false,
}
}
}