Skip to content

Commit 15d39b7

Browse files
committed
Add Wardwright provider adapter docs
1 parent 80e3f7b commit 15d39b7

18 files changed

Lines changed: 317 additions & 40 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: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,27 @@ 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.
67+
///
68+
/// Kept for compatibility. New synthetic-model composition should live in
69+
/// Wardwright or another OpenAI-compatible provider adapter.
6770
/// Use `!model <alloy-id>` to activate an alloy for an identity.
6871
#[serde(default)]
6972
pub alloys: Vec<AlloyConfig>,
7073

71-
/// `[[cascades]]` — explicit ordered model fallback chains.
74+
/// `[[cascades]]` — legacy explicit ordered model fallback chains.
75+
///
76+
/// Kept for compatibility. New fallback graphs should live in Wardwright
77+
/// or another OpenAI-compatible provider adapter.
7278
/// The proxy tries the first model whose declared context window can hold
7379
/// the request, then falls through to later eligible models on failure.
7480
#[serde(default)]
7581
pub cascades: Vec<CascadeConfig>,
7682

77-
/// `[[dispatchers]]` — request-size aware model selectors.
83+
/// `[[dispatchers]]` — legacy request-size aware model selectors.
84+
///
85+
/// Kept for compatibility. New request-shape routing should live in
86+
/// Wardwright or another OpenAI-compatible provider adapter.
7887
/// The proxy picks the smallest configured model that can hold the request,
7988
/// then uses larger eligible models as fallbacks.
8089
#[serde(default)]
@@ -149,7 +158,10 @@ impl CalciforgeConfig {
149158
}
150159
}
151160

152-
/// Alloy definition (`[[alloys]]`).
161+
/// Legacy alloy definition (`[[alloys]]`).
162+
///
163+
/// Calciforge still executes this for existing configs. Prefer Wardwright for
164+
/// new synthetic-model composition.
153165
#[derive(Debug, Clone, Deserialize, Serialize)]
154166
pub struct AlloyConfig {
155167
/// Alloy identifier used by commands (e.g. "free-alloy-1").
@@ -192,7 +204,10 @@ fn default_alloy_weight() -> u32 {
192204
1
193205
}
194206

195-
/// Cascade definition (`[[cascades]]`).
207+
/// Legacy cascade definition (`[[cascades]]`).
208+
///
209+
/// Calciforge still executes this for existing configs. Prefer Wardwright for
210+
/// new synthetic-model composition.
196211
#[derive(Debug, Clone, Deserialize, Serialize)]
197212
pub struct CascadeConfig {
198213
/// Synthetic model id requested by agents.
@@ -205,7 +220,10 @@ pub struct CascadeConfig {
205220
pub models: Vec<SyntheticModelConfig>,
206221
}
207222

208-
/// Dispatcher definition (`[[dispatchers]]`).
223+
/// Legacy dispatcher definition (`[[dispatchers]]`).
224+
///
225+
/// Calciforge still executes this for existing configs. Prefer Wardwright for
226+
/// new synthetic-model composition.
209227
#[derive(Debug, Clone, Deserialize, Serialize)]
210228
pub struct DispatcherConfig {
211229
/// Synthetic model id requested by agents.
@@ -1077,8 +1095,8 @@ pub struct ProxyProviderConfig {
10771095

10781096
/// Provider adapter kind. Supported OpenAI-compatible engine overlays
10791097
/// 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
1098+
/// "future-agi", "openrouter", and "wardwright". They share the same
1099+
/// request core; the kind chooses engine metadata, dashboard capability, and any
10821100
/// provider-specific headers. CLI-backed subscriptions are configured as
10831101
/// `[[agents]]`, not gateway providers.
10841102
#[serde(default = "default_proxy_provider_backend")]

crates/calciforge/src/config/validator.rs

Lines changed: 13 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 {

crates/calciforge/src/config/validator_tests_3.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ fn named_openai_compatible_backend_types_are_validated_from_shared_allowlist() {
376376
"tensorzero",
377377
"future-agi",
378378
"openrouter",
379+
"wardwright",
379380
] {
380381
let fixture = format!(
381382
"{MIN_VALID}\n[proxy]\nenabled = true\nbind = \"127.0.0.1:18083\"\nbackend_type = \"{backend_type}\"\nbackend_url = \"https://gateway.example.invalid/v1\"\n"
@@ -390,6 +391,30 @@ fn named_openai_compatible_backend_types_are_validated_from_shared_allowlist() {
390391
}
391392
}
392393

394+
#[test]
395+
fn legacy_synthetic_selectors_warn_to_prefer_wardwright() {
396+
let fixture = format!(
397+
"{MIN_VALID}\n[[dispatchers]]\nid = \"balanced\"\nname = \"Balanced\"\n\n[[dispatchers.models]]\nmodel = \"local-small\"\ncontext_window = 32000\n"
398+
);
399+
let config = parse(&fixture);
400+
let result = validate_config(&config);
401+
402+
assert!(
403+
result.is_valid(),
404+
"legacy synthetic selectors should remain valid while migration is optional: {:?}",
405+
result.errors
406+
);
407+
assert!(
408+
result
409+
.warnings
410+
.iter()
411+
.any(|warning| warning.contains("legacy compatibility")
412+
&& warning.contains("Wardwright")),
413+
"synthetic selector configs should point operators toward Wardwright; warnings: {:?}",
414+
result.warnings
415+
);
416+
}
417+
393418
#[test]
394419
fn root_backend_type_aliases_validate_with_runtime_parser() {
395420
for backend_type in ["direct", "builtin_http", "lite_llm", "open_router"] {

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

crates/calciforge/src/proxy/gateway_tests.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ fn test_gateway_type_parsing() {
4949
"open-router".parse::<GatewayType>().unwrap(),
5050
GatewayType::OpenRouter
5151
);
52+
assert_eq!(
53+
"ward-wright".parse::<GatewayType>().unwrap(),
54+
GatewayType::Wardwright
55+
);
5256
assert_eq!("mock".parse::<GatewayType>().unwrap(), GatewayType::Mock);
5357
assert!("unknown".parse::<GatewayType>().is_err());
5458
}
@@ -62,6 +66,7 @@ fn test_gateway_type_display() {
6266
assert_eq!(GatewayType::TensorZero.to_string(), "tensorzero");
6367
assert_eq!(GatewayType::FutureAgi.to_string(), "future-agi");
6468
assert_eq!(GatewayType::OpenRouter.to_string(), "openrouter");
69+
assert_eq!(GatewayType::Wardwright.to_string(), "wardwright");
6570
assert_eq!(GatewayType::Mock.to_string(), "mock");
6671
}
6772

@@ -75,6 +80,7 @@ fn named_gateway_engines_share_openai_compatible_http_core() {
7580
GatewayType::TensorZero,
7681
GatewayType::FutureAgi,
7782
GatewayType::OpenRouter,
83+
GatewayType::Wardwright,
7884
] {
7985
assert!(
8086
gateway_type.uses_openai_compatible_http_core(),
@@ -129,6 +135,36 @@ fn helicone_policy_headers_are_overlay_not_separate_gateway_core() {
129135
);
130136
}
131137

138+
#[test]
139+
fn wardwright_provider_uses_shared_http_core_with_receipt_dashboard_metadata() {
140+
assert!(GatewayType::Wardwright.uses_openai_compatible_http_core());
141+
142+
let config = GatewayConfig {
143+
backend_type: GatewayType::Wardwright,
144+
ui_url: Some("http://127.0.0.1:8791/admin/runtime".to_string()),
145+
..Default::default()
146+
};
147+
let info = config.engine_info(GatewayType::Wardwright);
148+
149+
assert_eq!(info.id, "wardwright");
150+
assert_eq!(info.display_name, "Wardwright synthetic model gateway");
151+
assert_eq!(
152+
info.ui_url.as_deref(),
153+
Some("http://127.0.0.1:8791/admin/runtime")
154+
);
155+
assert!(info.capabilities.openai_chat_completions);
156+
assert!(info.capabilities.model_listing);
157+
assert!(
158+
!info.capabilities.config_validation,
159+
"Calciforge does not call a Wardwright validation API yet"
160+
);
161+
assert!(
162+
info.observability
163+
.iter()
164+
.any(|capability| capability.display_name.contains("receipt"))
165+
);
166+
}
167+
132168
#[test]
133169
fn test_mock_gateway() {
134170
let config = GatewayConfig {
@@ -325,6 +361,7 @@ async fn builtin_http_gateway_forwards_complete_chat_request_options() {
325361
total_tokens: 2,
326362
},
327363
system_fingerprint: None,
364+
extra_body: serde_json::Map::new(),
328365
};
329366
let mock = server
330367
.mock("POST", "/v1/chat/completions")
@@ -413,6 +450,7 @@ async fn configured_authorization_header_cannot_override_backend_api_key() {
413450
total_tokens: 2,
414451
},
415452
system_fingerprint: None,
453+
extra_body: serde_json::Map::new(),
416454
};
417455
let mock = server
418456
.mock("POST", "/v1/chat/completions")
@@ -590,6 +628,7 @@ async fn helicone_engine_uses_shared_http_core_with_engine_headers() {
590628
total_tokens: 2,
591629
},
592630
system_fingerprint: None,
631+
extra_body: serde_json::Map::new(),
593632
};
594633
let mock = server
595634
.mock("POST", "/v1/chat/completions")

crates/calciforge/src/proxy/handlers.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,11 @@ async fn try_provider(
345345
let duration = start.elapsed();
346346

347347
let event = match &result {
348-
Ok(response) => telemetry_attempt.success(duration, response.choices.len()),
348+
Ok(response) => telemetry_attempt.success_with_receipt_id(
349+
duration,
350+
response.choices.len(),
351+
response.wardwright_receipt_id().map(str::to_string),
352+
),
349353
Err(error) => telemetry_attempt.failure(duration, error.failure_kind()),
350354
};
351355
state.telemetry.emit_gateway_attempt(event).await;
@@ -1197,6 +1201,7 @@ mod tests {
11971201
total_tokens: 2,
11981202
},
11991203
system_fingerprint: None,
1204+
extra_body: serde_json::Map::new(),
12001205
})
12011206
}
12021207

0 commit comments

Comments
 (0)