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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ potions than Howl would tolerate.
| Command-line and optional MCP tools for agent-facing secret-name discovery, with no value readback | Working | [Agent-facing tools](https://calciforge.org/#agent-facing-tools-mcp-and-cli) |
| Agent runtime contract for command-line guidance, optional MCP, artifacts, and future Calciforge APIs | Working draft | [Agent runtime contract](docs/agent-runtime-contract.md) |
| Telegram, Matrix, WhatsApp, Signal, and text/iMessage routing | Working | [Multi-channel chat](https://calciforge.org/#multi-channel-chat) |
| OpenAI-compatible model gateway, provider routing, model aliases, alloys, cascades, dispatchers, and local model switching | Working | [Model gateway](docs/model-gateway.md) |
| OpenAI-compatible model gateway, provider routing, model aliases, Wardwright adapter support, legacy alloys/cascades/dispatchers, and local model switching | Working | [Model gateway](docs/model-gateway.md) |
| Helicone-backed gateway observability with dashboard-visible doctor checks | Working | [Model gateway](docs/model-gateway.md#external-gateway-engines) |
| Codex CLI and OpenClaw Codex subscription/OAuth integration paths | Working | [Codex integration](docs/codex-openclaw-integration.md) |
| `calciforge doctor` config/state/endpoint diagnostics | Working | [Quick Start](#quick-start) |
Expand Down Expand Up @@ -124,8 +124,10 @@ tunnel with `CALCIFORGE_PASTE_PUBLIC_BASE_URL`.

Keep Calciforge's own service traffic separate from agent traffic. Point
agents at Calciforge's OpenAI-compatible model gateway for model calls;
that path provides model aliases, alloys, cascades, dispatchers, provider
routing, and observability. Route agent tool/web traffic through
that path provides model aliases, provider routing, observability, and legacy
in-process synthetic selectors. For new alloys, cascades, and dispatchers, use
[Wardwright](https://wardwright.dev/) as an OpenAI-compatible provider adapter
and let it own the route graph and receipts. Route agent tool/web traffic through
`security-proxy` or a Calciforge fetch/tool integration when returned
content needs scanning or `{{secret:NAME}}` substitution.

Expand Down
16 changes: 8 additions & 8 deletions crates/calciforge/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,18 @@ pub struct CalciforgeConfig {
#[serde(default)]
pub model_roles: Vec<ModelRoleConfig>,

/// `[[alloys]]` — model blending/mixing groups.
/// `[[alloys]]` — legacy in-process model blending/mixing groups.
/// Use `!model <alloy-id>` to activate an alloy for an identity.
#[serde(default)]
pub alloys: Vec<AlloyConfig>,

/// `[[cascades]]` — explicit ordered model fallback chains.
/// `[[cascades]]` — legacy explicit ordered model fallback chains.
/// The proxy tries the first model whose declared context window can hold
/// the request, then falls through to later eligible models on failure.
#[serde(default)]
pub cascades: Vec<CascadeConfig>,

/// `[[dispatchers]]` — request-size aware model selectors.
/// `[[dispatchers]]` — legacy request-size aware model selectors.
/// The proxy picks the smallest configured model that can hold the request,
/// then uses larger eligible models as fallbacks.
#[serde(default)]
Expand Down Expand Up @@ -149,7 +149,7 @@ impl CalciforgeConfig {
}
}

/// Alloy definition (`[[alloys]]`).
/// Legacy alloy definition (`[[alloys]]`).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AlloyConfig {
/// Alloy identifier used by commands (e.g. "free-alloy-1").
Expand Down Expand Up @@ -192,7 +192,7 @@ fn default_alloy_weight() -> u32 {
1
}

/// Cascade definition (`[[cascades]]`).
/// Legacy cascade definition (`[[cascades]]`).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CascadeConfig {
/// Synthetic model id requested by agents.
Expand All @@ -205,7 +205,7 @@ pub struct CascadeConfig {
pub models: Vec<SyntheticModelConfig>,
}

/// Dispatcher definition (`[[dispatchers]]`).
/// Legacy dispatcher definition (`[[dispatchers]]`).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DispatcherConfig {
/// Synthetic model id requested by agents.
Expand Down Expand Up @@ -1077,8 +1077,8 @@ pub struct ProxyProviderConfig {

/// Provider adapter kind. Supported OpenAI-compatible engine overlays
/// include "http", "helicone", "litellm", "portkey", "tensorzero",
/// "future-agi", and "openrouter". They share the same request core;
/// the kind chooses engine metadata, dashboard capability, and any
/// "future-agi", "openrouter", and "wardwright". They share the same
/// request core; the kind chooses engine metadata, dashboard capability, and any
/// provider-specific headers. CLI-backed subscriptions are configured as
/// `[[agents]]`, not gateway providers.
#[serde(default = "default_proxy_provider_backend")]
Expand Down
17 changes: 17 additions & 0 deletions crates/calciforge/src/config/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ pub fn validate_config(config: &CalciforgeConfig) -> ValidationResult {
// Validate enabled channels before long-lived tasks start.
validate_channels(config, &mut result);

warn_on_legacy_synthetic_selectors(config, &mut result);

// Validate alloys have valid constituents
validate_alloys(config, &mut result);

Expand All @@ -90,6 +92,17 @@ pub fn validate_config(config: &CalciforgeConfig) -> ValidationResult {
result
}

fn warn_on_legacy_synthetic_selectors(config: &CalciforgeConfig, result: &mut ValidationResult) {
let has_legacy_synthetic_selectors =
!config.alloys.is_empty() || !config.cascades.is_empty() || !config.dispatchers.is_empty();
if has_legacy_synthetic_selectors {
result.add_warning(
"Calciforge in-process synthetic selectors ([[alloys]], [[cascades]], [[dispatchers]]) are legacy compatibility features. Prefer Wardwright or another OpenAI-compatible provider adapter for new synthetic-model composition."
.to_string(),
);
}
}

/// Validate agent adapter kinds and required fields.
fn validate_agents(config: &CalciforgeConfig, result: &mut ValidationResult) {
for agent in &config.agents {
Expand Down Expand Up @@ -1044,3 +1057,7 @@ mod validator_tests_2;
#[cfg(test)]
#[path = "validator_tests_3.rs"]
mod validator_tests_3;

#[cfg(test)]
#[path = "validator_wardwright_tests.rs"]
mod validator_wardwright_tests;
38 changes: 38 additions & 0 deletions crates/calciforge/src/config/validator_wardwright_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use super::validator_test_support::{MIN_VALID, parse};
use super::*;

#[test]
fn wardwright_backend_type_validates_from_shared_allowlist() {
let fixture = format!(
"{MIN_VALID}\n[proxy]\nenabled = true\nbind = \"127.0.0.1:18083\"\nbackend_type = \"wardwright\"\nbackend_url = \"https://gateway.example.invalid/v1\"\n"
);
let config = parse(&fixture);
let result = validate_config(&config);
assert!(
result.is_valid(),
"wardwright should be accepted as an OpenAI-compatible provider adapter; errors: {:?}",
result.errors
);
}

#[test]
fn legacy_synthetic_selectors_warn_to_prefer_wardwright() {
let fixture = format!(
"{MIN_VALID}\n[[dispatchers]]\nid = \"balanced\"\nname = \"Balanced\"\n\n[[dispatchers.models]]\nmodel = \"local-small\"\ncontext_window = 32000\n"
);
let config = parse(&fixture);
let result = validate_config(&config);

assert!(
result.is_valid(),
"legacy synthetic selectors should remain valid while migration is optional: {:?}",
result.errors
);
assert!(
result.warnings.iter().any(|warning| {
warning.contains("legacy compatibility") && warning.contains("Wardwright")
}),
"synthetic selector configs should point operators toward Wardwright; warnings: {:?}",
result.warnings
);
}
1 change: 1 addition & 0 deletions crates/calciforge/src/proxy/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ impl SecretsBackend for MockBackend {
total_tokens: 0,
},
system_fingerprint: None,
extra_body: serde_json::Map::new(),
})
}

Expand Down
27 changes: 25 additions & 2 deletions crates/calciforge/src/proxy/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//! Calciforge should not assume there is one installed "model gateway". It owns
//! a policy/audit/auth boundary, then routes to one or more configured provider
//! adapters such as builtin OpenAI-compatible HTTP, Helicone, LiteLLM,
//! OpenRouter, Ollama, or mock test adapters.
//! OpenRouter, Wardwright, Ollama, or mock test adapters.
//!
//! The older config field names still say `backend_type` for compatibility, but
//! runtime code should treat these as adapter kinds.
Expand Down Expand Up @@ -136,6 +136,8 @@ pub enum GatewayType {
FutureAgi,
/// OpenRouter OpenAI-compatible provider boundary.
OpenRouter,
/// Wardwright synthetic model gateway.
Wardwright,
/// Mock adapter for tests only.
Mock,
}
Expand All @@ -152,6 +154,7 @@ impl std::str::FromStr for GatewayType {
"tensorzero" | "tensor-zero" | "tensor_zero" => Ok(GatewayType::TensorZero),
"future-agi" | "future_agi" | "futureagi" => Ok(GatewayType::FutureAgi),
"openrouter" | "open-router" | "open_router" => Ok(GatewayType::OpenRouter),
"wardwright" | "ward-wright" | "ward_wright" => Ok(GatewayType::Wardwright),
"mock" => Ok(GatewayType::Mock),
_ => Err(format!("Unknown gateway type: {}", s)),
}
Expand All @@ -168,6 +171,7 @@ impl std::fmt::Display for GatewayType {
GatewayType::TensorZero => write!(f, "tensorzero"),
GatewayType::FutureAgi => write!(f, "future-agi"),
GatewayType::OpenRouter => write!(f, "openrouter"),
GatewayType::Wardwright => write!(f, "wardwright"),
GatewayType::Mock => write!(f, "mock"),
}
}
Expand All @@ -182,6 +186,7 @@ impl GatewayType {
"tensorzero",
"future-agi",
"openrouter",
"wardwright",
"mock",
];

Expand All @@ -193,6 +198,7 @@ impl GatewayType {
"tensorzero",
"future-agi",
"openrouter",
"wardwright",
];

pub fn display_name(self) -> &'static str {
Expand All @@ -204,6 +210,7 @@ impl GatewayType {
GatewayType::TensorZero => "TensorZero gateway",
GatewayType::FutureAgi => "Future AGI gateway",
GatewayType::OpenRouter => "OpenRouter",
GatewayType::Wardwright => "Wardwright synthetic model gateway",
GatewayType::Mock => "Mock provider adapter",
}
}
Expand Down Expand Up @@ -266,6 +273,14 @@ impl GatewayType {
observability: false,
operator_ui: true,
},
GatewayType::Wardwright => GatewayCapabilities {
openai_chat_completions: true,
model_listing: true,
tool_call_transcripts: false,
config_validation: false,
observability: true,
operator_ui: true,
},
GatewayType::Mock => GatewayCapabilities {
openai_chat_completions: true,
model_listing: true,
Expand Down Expand Up @@ -337,6 +352,11 @@ impl GatewayType {
true,
),
],
GatewayType::Wardwright => vec![ProviderObservabilityCapability::new(
ProviderObservabilityKind::NativeDashboard,
"Wardwright receipt and route dashboard",
false,
)],
GatewayType::BuiltinHttp | GatewayType::OpenRouter | GatewayType::Mock => Vec::new(),
}
}
Expand All @@ -351,6 +371,7 @@ impl GatewayType {
| GatewayType::TensorZero
| GatewayType::FutureAgi
| GatewayType::OpenRouter
| GatewayType::Wardwright
)
}

Expand Down Expand Up @@ -464,7 +485,8 @@ pub fn create_gateway(
| GatewayType::Portkey
| GatewayType::TensorZero
| GatewayType::FutureAgi
| GatewayType::OpenRouter => {
| GatewayType::OpenRouter
| GatewayType::Wardwright => {
// Builtin HTTP upstream calls
// This requires a backend to be passed in
let backend = backend.ok_or_else(|| {
Expand Down Expand Up @@ -785,6 +807,7 @@ impl ProviderAdapter for MockGateway {
total_tokens: 0,
},
system_fingerprint: None,
extra_body: serde_json::Map::new(),
})
}

Expand Down
47 changes: 47 additions & 0 deletions crates/calciforge/src/proxy/gateway_engine_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use super::gateway::{GatewayType, openai_compatible_headers};
use crate::config::GatewayRetryConfig;

#[test]
fn helicone_policy_headers_are_overlay_not_separate_gateway_core() {
let retry = GatewayRetryConfig {
enabled: true,
max_retries: 4,
min_timeout_ms: 250,
max_timeout_ms: 3_000,
factor: 3,
retry_on: vec![],
};

let headers = openai_compatible_headers(GatewayType::Helicone, Some("test-key"), &retry, None)
.expect("helicone overlay should add headers");

assert_eq!(
headers.get("helicone-auth"),
Some(&"Bearer test-key".to_string())
);
assert_eq!(
headers.get("helicone-retry-enabled"),
Some(&"true".to_string())
);
assert_eq!(headers.get("helicone-retry-num"), Some(&"4".to_string()));
assert_eq!(
headers.get("helicone-retry-min-timeout"),
Some(&"250".to_string())
);
assert_eq!(
headers.get("helicone-retry-max-timeout"),
Some(&"3000".to_string())
);
assert_eq!(headers.get("helicone-retry-factor"), Some(&"3".to_string()));

assert!(
openai_compatible_headers(
GatewayType::LiteLlm,
Some("test-key"),
&GatewayRetryConfig::default(),
None
)
.is_none(),
"LiteLLM should not inherit Helicone-specific headers"
);
}
48 changes: 3 additions & 45 deletions crates/calciforge/src/proxy/gateway_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,51 +84,6 @@ fn named_gateway_engines_share_openai_compatible_http_core() {
assert!(!GatewayType::Mock.uses_openai_compatible_http_core());
}

#[test]
fn helicone_policy_headers_are_overlay_not_separate_gateway_core() {
let retry = GatewayRetryConfig {
enabled: true,
max_retries: 4,
min_timeout_ms: 250,
max_timeout_ms: 3_000,
factor: 3,
retry_on: vec![],
};

let headers = openai_compatible_headers(GatewayType::Helicone, Some("test-key"), &retry, None)
.expect("helicone overlay should add headers");

assert_eq!(
headers.get("helicone-auth"),
Some(&"Bearer test-key".to_string())
);
assert_eq!(
headers.get("helicone-retry-enabled"),
Some(&"true".to_string())
);
assert_eq!(headers.get("helicone-retry-num"), Some(&"4".to_string()));
assert_eq!(
headers.get("helicone-retry-min-timeout"),
Some(&"250".to_string())
);
assert_eq!(
headers.get("helicone-retry-max-timeout"),
Some(&"3000".to_string())
);
assert_eq!(headers.get("helicone-retry-factor"), Some(&"3".to_string()));

assert!(
openai_compatible_headers(
GatewayType::LiteLlm,
Some("test-key"),
&GatewayRetryConfig::default(),
None
)
.is_none(),
"LiteLLM should not inherit Helicone-specific headers"
);
}

#[test]
fn test_mock_gateway() {
let config = GatewayConfig {
Expand Down Expand Up @@ -325,6 +280,7 @@ async fn builtin_http_gateway_forwards_complete_chat_request_options() {
total_tokens: 2,
},
system_fingerprint: None,
extra_body: serde_json::Map::new(),
};
let mock = server
.mock("POST", "/v1/chat/completions")
Expand Down Expand Up @@ -413,6 +369,7 @@ async fn configured_authorization_header_cannot_override_backend_api_key() {
total_tokens: 2,
},
system_fingerprint: None,
extra_body: serde_json::Map::new(),
};
let mock = server
.mock("POST", "/v1/chat/completions")
Expand Down Expand Up @@ -590,6 +547,7 @@ async fn helicone_engine_uses_shared_http_core_with_engine_headers() {
total_tokens: 2,
},
system_fingerprint: None,
extra_body: serde_json::Map::new(),
};
let mock = server
.mock("POST", "/v1/chat/completions")
Expand Down
Loading
Loading