Skip to content

Commit 28a133b

Browse files
authored
Add Wardwright provider adapter docs (#207)
1 parent 80e3f7b commit 28a133b

21 files changed

Lines changed: 380 additions & 86 deletions

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ potions than Howl would tolerate.
2828
| 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) |
2929
| Agent runtime contract for command-line guidance, optional MCP, artifacts, and future Calciforge APIs | Working draft | [Agent runtime contract](docs/agent-runtime-contract.md) |
3030
| Telegram, Matrix, WhatsApp, Signal, and text/iMessage routing | Working | [Multi-channel chat](https://calciforge.org/#multi-channel-chat) |
31-
| OpenAI-compatible model gateway, provider routing, model aliases, alloys, cascades, dispatchers, and local model switching | Working | [Model gateway](docs/model-gateway.md) |
31+
| 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) |
3232
| Helicone-backed gateway observability with dashboard-visible doctor checks | Working | [Model gateway](docs/model-gateway.md#external-gateway-engines) |
3333
| Codex CLI and OpenClaw Codex subscription/OAuth integration paths | Working | [Codex integration](docs/codex-openclaw-integration.md) |
3434
| `calciforge doctor` config/state/endpoint diagnostics | Working | [Quick Start](#quick-start) |
@@ -124,8 +124,10 @@ tunnel with `CALCIFORGE_PASTE_PUBLIC_BASE_URL`.
124124

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

crates/calciforge/src/config.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,18 @@ pub struct CalciforgeConfig {
6363
#[serde(default)]
6464
pub model_roles: Vec<ModelRoleConfig>,
6565

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

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

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

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

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

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

10781078
/// Provider adapter kind. Supported OpenAI-compatible engine overlays
10791079
/// include "http", "helicone", "litellm", "portkey", "tensorzero",
1080-
/// "future-agi", and "openrouter". They share the same request core;
1081-
/// the kind chooses engine metadata, dashboard capability, and any
1080+
/// "future-agi", "openrouter", and "wardwright". They share the same
1081+
/// request core; the kind chooses engine metadata, dashboard capability, and any
10821082
/// provider-specific headers. CLI-backed subscriptions are configured as
10831083
/// `[[agents]]`, not gateway providers.
10841084
#[serde(default = "default_proxy_provider_backend")]

crates/calciforge/src/config/validator.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ pub fn validate_config(config: &CalciforgeConfig) -> ValidationResult {
7171
// Validate enabled channels before long-lived tasks start.
7272
validate_channels(config, &mut result);
7373

74+
warn_on_legacy_synthetic_selectors(config, &mut result);
75+
7476
// Validate alloys have valid constituents
7577
validate_alloys(config, &mut result);
7678

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

95+
fn warn_on_legacy_synthetic_selectors(config: &CalciforgeConfig, result: &mut ValidationResult) {
96+
let has_legacy_synthetic_selectors =
97+
!config.alloys.is_empty() || !config.cascades.is_empty() || !config.dispatchers.is_empty();
98+
if has_legacy_synthetic_selectors {
99+
result.add_warning(
100+
"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."
101+
.to_string(),
102+
);
103+
}
104+
}
105+
93106
/// Validate agent adapter kinds and required fields.
94107
fn validate_agents(config: &CalciforgeConfig, result: &mut ValidationResult) {
95108
for agent in &config.agents {
@@ -1044,3 +1057,7 @@ mod validator_tests_2;
10441057
#[cfg(test)]
10451058
#[path = "validator_tests_3.rs"]
10461059
mod validator_tests_3;
1060+
1061+
#[cfg(test)]
1062+
#[path = "validator_wardwright_tests.rs"]
1063+
mod validator_wardwright_tests;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use super::validator_test_support::{MIN_VALID, parse};
2+
use super::*;
3+
4+
#[test]
5+
fn wardwright_backend_type_validates_from_shared_allowlist() {
6+
let fixture = format!(
7+
"{MIN_VALID}\n[proxy]\nenabled = true\nbind = \"127.0.0.1:18083\"\nbackend_type = \"wardwright\"\nbackend_url = \"https://gateway.example.invalid/v1\"\n"
8+
);
9+
let config = parse(&fixture);
10+
let result = validate_config(&config);
11+
assert!(
12+
result.is_valid(),
13+
"wardwright should be accepted as an OpenAI-compatible provider adapter; errors: {:?}",
14+
result.errors
15+
);
16+
}
17+
18+
#[test]
19+
fn legacy_synthetic_selectors_warn_to_prefer_wardwright() {
20+
let fixture = format!(
21+
"{MIN_VALID}\n[[dispatchers]]\nid = \"balanced\"\nname = \"Balanced\"\n\n[[dispatchers.models]]\nmodel = \"local-small\"\ncontext_window = 32000\n"
22+
);
23+
let config = parse(&fixture);
24+
let result = validate_config(&config);
25+
26+
assert!(
27+
result.is_valid(),
28+
"legacy synthetic selectors should remain valid while migration is optional: {:?}",
29+
result.errors
30+
);
31+
assert!(
32+
result.warnings.iter().any(|warning| {
33+
warning.contains("legacy compatibility") && warning.contains("Wardwright")
34+
}),
35+
"synthetic selector configs should point operators toward Wardwright; warnings: {:?}",
36+
result.warnings
37+
);
38+
}

crates/calciforge/src/proxy/backend.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ impl SecretsBackend for MockBackend {
279279
total_tokens: 0,
280280
},
281281
system_fingerprint: None,
282+
extra_body: serde_json::Map::new(),
282283
})
283284
}
284285

crates/calciforge/src/proxy/gateway.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! Calciforge should not assume there is one installed "model gateway". It owns
44
//! a policy/audit/auth boundary, then routes to one or more configured provider
55
//! adapters such as builtin OpenAI-compatible HTTP, Helicone, LiteLLM,
6-
//! OpenRouter, Ollama, or mock test adapters.
6+
//! OpenRouter, Wardwright, Ollama, or mock test adapters.
77
//!
88
//! The older config field names still say `backend_type` for compatibility, but
99
//! runtime code should treat these as adapter kinds.
@@ -136,6 +136,8 @@ pub enum GatewayType {
136136
FutureAgi,
137137
/// OpenRouter OpenAI-compatible provider boundary.
138138
OpenRouter,
139+
/// Wardwright synthetic model gateway.
140+
Wardwright,
139141
/// Mock adapter for tests only.
140142
Mock,
141143
}
@@ -152,6 +154,7 @@ impl std::str::FromStr for GatewayType {
152154
"tensorzero" | "tensor-zero" | "tensor_zero" => Ok(GatewayType::TensorZero),
153155
"future-agi" | "future_agi" | "futureagi" => Ok(GatewayType::FutureAgi),
154156
"openrouter" | "open-router" | "open_router" => Ok(GatewayType::OpenRouter),
157+
"wardwright" | "ward-wright" | "ward_wright" => Ok(GatewayType::Wardwright),
155158
"mock" => Ok(GatewayType::Mock),
156159
_ => Err(format!("Unknown gateway type: {}", s)),
157160
}
@@ -168,6 +171,7 @@ impl std::fmt::Display for GatewayType {
168171
GatewayType::TensorZero => write!(f, "tensorzero"),
169172
GatewayType::FutureAgi => write!(f, "future-agi"),
170173
GatewayType::OpenRouter => write!(f, "openrouter"),
174+
GatewayType::Wardwright => write!(f, "wardwright"),
171175
GatewayType::Mock => write!(f, "mock"),
172176
}
173177
}
@@ -182,6 +186,7 @@ impl GatewayType {
182186
"tensorzero",
183187
"future-agi",
184188
"openrouter",
189+
"wardwright",
185190
"mock",
186191
];
187192

@@ -193,6 +198,7 @@ impl GatewayType {
193198
"tensorzero",
194199
"future-agi",
195200
"openrouter",
201+
"wardwright",
196202
];
197203

198204
pub fn display_name(self) -> &'static str {
@@ -204,6 +210,7 @@ impl GatewayType {
204210
GatewayType::TensorZero => "TensorZero gateway",
205211
GatewayType::FutureAgi => "Future AGI gateway",
206212
GatewayType::OpenRouter => "OpenRouter",
213+
GatewayType::Wardwright => "Wardwright synthetic model gateway",
207214
GatewayType::Mock => "Mock provider adapter",
208215
}
209216
}
@@ -266,6 +273,14 @@ impl GatewayType {
266273
observability: false,
267274
operator_ui: true,
268275
},
276+
GatewayType::Wardwright => GatewayCapabilities {
277+
openai_chat_completions: true,
278+
model_listing: true,
279+
tool_call_transcripts: false,
280+
config_validation: false,
281+
observability: true,
282+
operator_ui: true,
283+
},
269284
GatewayType::Mock => GatewayCapabilities {
270285
openai_chat_completions: true,
271286
model_listing: true,
@@ -337,6 +352,11 @@ impl GatewayType {
337352
true,
338353
),
339354
],
355+
GatewayType::Wardwright => vec![ProviderObservabilityCapability::new(
356+
ProviderObservabilityKind::NativeDashboard,
357+
"Wardwright receipt and route dashboard",
358+
false,
359+
)],
340360
GatewayType::BuiltinHttp | GatewayType::OpenRouter | GatewayType::Mock => Vec::new(),
341361
}
342362
}
@@ -351,6 +371,7 @@ impl GatewayType {
351371
| GatewayType::TensorZero
352372
| GatewayType::FutureAgi
353373
| GatewayType::OpenRouter
374+
| GatewayType::Wardwright
354375
)
355376
}
356377

@@ -464,7 +485,8 @@ pub fn create_gateway(
464485
| GatewayType::Portkey
465486
| GatewayType::TensorZero
466487
| GatewayType::FutureAgi
467-
| GatewayType::OpenRouter => {
488+
| GatewayType::OpenRouter
489+
| GatewayType::Wardwright => {
468490
// Builtin HTTP upstream calls
469491
// This requires a backend to be passed in
470492
let backend = backend.ok_or_else(|| {
@@ -785,6 +807,7 @@ impl ProviderAdapter for MockGateway {
785807
total_tokens: 0,
786808
},
787809
system_fingerprint: None,
810+
extra_body: serde_json::Map::new(),
788811
})
789812
}
790813

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use super::gateway::{GatewayType, openai_compatible_headers};
2+
use crate::config::GatewayRetryConfig;
3+
4+
#[test]
5+
fn helicone_policy_headers_are_overlay_not_separate_gateway_core() {
6+
let retry = GatewayRetryConfig {
7+
enabled: true,
8+
max_retries: 4,
9+
min_timeout_ms: 250,
10+
max_timeout_ms: 3_000,
11+
factor: 3,
12+
retry_on: vec![],
13+
};
14+
15+
let headers = openai_compatible_headers(GatewayType::Helicone, Some("test-key"), &retry, None)
16+
.expect("helicone overlay should add headers");
17+
18+
assert_eq!(
19+
headers.get("helicone-auth"),
20+
Some(&"Bearer test-key".to_string())
21+
);
22+
assert_eq!(
23+
headers.get("helicone-retry-enabled"),
24+
Some(&"true".to_string())
25+
);
26+
assert_eq!(headers.get("helicone-retry-num"), Some(&"4".to_string()));
27+
assert_eq!(
28+
headers.get("helicone-retry-min-timeout"),
29+
Some(&"250".to_string())
30+
);
31+
assert_eq!(
32+
headers.get("helicone-retry-max-timeout"),
33+
Some(&"3000".to_string())
34+
);
35+
assert_eq!(headers.get("helicone-retry-factor"), Some(&"3".to_string()));
36+
37+
assert!(
38+
openai_compatible_headers(
39+
GatewayType::LiteLlm,
40+
Some("test-key"),
41+
&GatewayRetryConfig::default(),
42+
None
43+
)
44+
.is_none(),
45+
"LiteLLM should not inherit Helicone-specific headers"
46+
);
47+
}

crates/calciforge/src/proxy/gateway_tests.rs

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -84,51 +84,6 @@ fn named_gateway_engines_share_openai_compatible_http_core() {
8484
assert!(!GatewayType::Mock.uses_openai_compatible_http_core());
8585
}
8686

87-
#[test]
88-
fn helicone_policy_headers_are_overlay_not_separate_gateway_core() {
89-
let retry = GatewayRetryConfig {
90-
enabled: true,
91-
max_retries: 4,
92-
min_timeout_ms: 250,
93-
max_timeout_ms: 3_000,
94-
factor: 3,
95-
retry_on: vec![],
96-
};
97-
98-
let headers = openai_compatible_headers(GatewayType::Helicone, Some("test-key"), &retry, None)
99-
.expect("helicone overlay should add headers");
100-
101-
assert_eq!(
102-
headers.get("helicone-auth"),
103-
Some(&"Bearer test-key".to_string())
104-
);
105-
assert_eq!(
106-
headers.get("helicone-retry-enabled"),
107-
Some(&"true".to_string())
108-
);
109-
assert_eq!(headers.get("helicone-retry-num"), Some(&"4".to_string()));
110-
assert_eq!(
111-
headers.get("helicone-retry-min-timeout"),
112-
Some(&"250".to_string())
113-
);
114-
assert_eq!(
115-
headers.get("helicone-retry-max-timeout"),
116-
Some(&"3000".to_string())
117-
);
118-
assert_eq!(headers.get("helicone-retry-factor"), Some(&"3".to_string()));
119-
120-
assert!(
121-
openai_compatible_headers(
122-
GatewayType::LiteLlm,
123-
Some("test-key"),
124-
&GatewayRetryConfig::default(),
125-
None
126-
)
127-
.is_none(),
128-
"LiteLLM should not inherit Helicone-specific headers"
129-
);
130-
}
131-
13287
#[test]
13388
fn test_mock_gateway() {
13489
let config = GatewayConfig {
@@ -325,6 +280,7 @@ async fn builtin_http_gateway_forwards_complete_chat_request_options() {
325280
total_tokens: 2,
326281
},
327282
system_fingerprint: None,
283+
extra_body: serde_json::Map::new(),
328284
};
329285
let mock = server
330286
.mock("POST", "/v1/chat/completions")
@@ -413,6 +369,7 @@ async fn configured_authorization_header_cannot_override_backend_api_key() {
413369
total_tokens: 2,
414370
},
415371
system_fingerprint: None,
372+
extra_body: serde_json::Map::new(),
416373
};
417374
let mock = server
418375
.mock("POST", "/v1/chat/completions")
@@ -590,6 +547,7 @@ async fn helicone_engine_uses_shared_http_core_with_engine_headers() {
590547
total_tokens: 2,
591548
},
592549
system_fingerprint: None,
550+
extra_body: serde_json::Map::new(),
593551
};
594552
let mock = server
595553
.mock("POST", "/v1/chat/completions")

0 commit comments

Comments
 (0)