Skip to content

Commit 54666c0

Browse files
AlexMikhalevclaude
andcommitted
feat(config): extend AgentDefinition with provider routing and ProviderTier enum
Add provider, fallback_provider, fallback_model, and provider_tier fields to AgentDefinition for subscription-based model routing (ADR-002, ADR-003). Add ProviderTier enum (Quick/Deep/Implementation/Oracle) with per-tier timeout values. Add opencode CLI support in spawner arg inference. All new fields are Optional with serde(default) for backward compatibility. Fixes #28 Refs #29 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f63f114 commit 54666c0

11 files changed

Lines changed: 630 additions & 0 deletions

crates/terraphim_orchestrator/src/config.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ pub struct AgentDefinition {
4444
pub capabilities: Vec<String>,
4545
/// Maximum memory in bytes (optional resource limit).
4646
pub max_memory_bytes: Option<u64>,
47+
/// Provider prefix for model routing (e.g., "opencode-go", "kimi-for-coding", "claude-code").
48+
#[serde(default)]
49+
pub provider: Option<String>,
50+
/// Fallback provider if primary fails/times out.
51+
#[serde(default)]
52+
pub fallback_provider: Option<String>,
53+
/// Fallback model to use with fallback_provider.
54+
#[serde(default)]
55+
pub fallback_model: Option<String>,
56+
/// Provider tier classification.
57+
#[serde(default)]
58+
pub provider_tier: Option<ProviderTier>,
4759
}
4860

4961
/// Agent layer in the dark factory hierarchy.
@@ -57,6 +69,32 @@ pub enum AgentLayer {
5769
Growth,
5870
}
5971

72+
/// Model routing tier based on task complexity and cost.
73+
/// See ADR-003: Four-tier model routing.
74+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75+
pub enum ProviderTier {
76+
/// Routine docs, advisory. Primary: opencode-go/minimax-m2.5. Timeout: 30s.
77+
Quick,
78+
/// Quality gates, compound review, security. Primary: opencode-go/glm-5. Timeout: 60s.
79+
Deep,
80+
/// Code generation, twins, tests. Primary: kimi-for-coding/k2p5. Timeout: 120s.
81+
Implementation,
82+
/// Spec validation, deep reasoning. Primary: claude-code opus-4-6. Timeout: 300s. No fallback.
83+
Oracle,
84+
}
85+
86+
impl ProviderTier {
87+
/// Timeout in seconds for this tier
88+
pub fn timeout_secs(&self) -> u64 {
89+
match self {
90+
Self::Quick => 30,
91+
Self::Deep => 60,
92+
Self::Implementation => 120,
93+
Self::Oracle => 300,
94+
}
95+
}
96+
}
97+
6098
/// Nightwatch drift detection thresholds.
6199
#[derive(Debug, Clone, Serialize, Deserialize)]
62100
pub struct NightwatchConfig {
@@ -351,4 +389,107 @@ task = "t"
351389
assert_eq!(config.agents[2].layer, AgentLayer::Growth);
352390
assert!(config.agents[1].schedule.is_some());
353391
}
392+
393+
#[test]
394+
fn test_config_parse_with_provider_fields() {
395+
let toml_str = r#"
396+
working_dir = "/tmp"
397+
398+
[nightwatch]
399+
400+
[compound_review]
401+
schedule = "0 0 * * *"
402+
repo_path = "/tmp"
403+
404+
[[agents]]
405+
name = "security-sentinel"
406+
layer = "Safety"
407+
cli_tool = "opencode"
408+
provider = "opencode-go"
409+
model = "kimi-k2.5"
410+
fallback_provider = "opencode-go"
411+
fallback_model = "glm-5"
412+
provider_tier = "Deep"
413+
task = "Run security audit"
414+
capabilities = ["security", "vulnerability-scanning"]
415+
"#;
416+
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
417+
assert_eq!(config.agents.len(), 1);
418+
assert_eq!(config.agents[0].name, "security-sentinel");
419+
assert_eq!(config.agents[0].provider, Some("opencode-go".to_string()));
420+
assert_eq!(config.agents[0].model, Some("kimi-k2.5".to_string()));
421+
assert_eq!(
422+
config.agents[0].fallback_provider,
423+
Some("opencode-go".to_string())
424+
);
425+
assert_eq!(config.agents[0].fallback_model, Some("glm-5".to_string()));
426+
assert_eq!(config.agents[0].provider_tier, Some(ProviderTier::Deep));
427+
}
428+
429+
#[test]
430+
fn test_provider_tier_timeout_secs() {
431+
assert_eq!(ProviderTier::Quick.timeout_secs(), 30);
432+
assert_eq!(ProviderTier::Deep.timeout_secs(), 60);
433+
assert_eq!(ProviderTier::Implementation.timeout_secs(), 120);
434+
assert_eq!(ProviderTier::Oracle.timeout_secs(), 300);
435+
}
436+
437+
#[test]
438+
fn test_provider_fields_backward_compatible() {
439+
let toml_str = r#"
440+
working_dir = "/tmp"
441+
442+
[nightwatch]
443+
444+
[compound_review]
445+
schedule = "0 0 * * *"
446+
repo_path = "/tmp"
447+
448+
[[agents]]
449+
name = "legacy-agent"
450+
layer = "Safety"
451+
cli_tool = "codex"
452+
task = "Legacy task without new fields"
453+
"#;
454+
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
455+
assert_eq!(config.agents.len(), 1);
456+
assert_eq!(config.agents[0].name, "legacy-agent");
457+
assert!(config.agents[0].provider.is_none());
458+
assert!(config.agents[0].fallback_provider.is_none());
459+
assert!(config.agents[0].fallback_model.is_none());
460+
assert!(config.agents[0].provider_tier.is_none());
461+
}
462+
463+
#[test]
464+
fn test_all_provider_tier_variants() {
465+
let tiers = vec![
466+
("Quick", ProviderTier::Quick),
467+
("Deep", ProviderTier::Deep),
468+
("Implementation", ProviderTier::Implementation),
469+
("Oracle", ProviderTier::Oracle),
470+
];
471+
for (name, tier) in tiers {
472+
let toml_str = format!(
473+
r#"
474+
working_dir = "/tmp"
475+
476+
[nightwatch]
477+
478+
[compound_review]
479+
schedule = "0 0 * * *"
480+
repo_path = "/tmp"
481+
482+
[[agents]]
483+
name = "test-agent"
484+
layer = "Safety"
485+
cli_tool = "codex"
486+
provider_tier = "{}"
487+
task = "Test"
488+
"#,
489+
name
490+
);
491+
let config = OrchestratorConfig::from_toml(&toml_str).unwrap();
492+
assert_eq!(config.agents[0].provider_tier, Some(tier));
493+
}
494+
}
354495
}

crates/terraphim_orchestrator/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,10 @@ mod tests {
672672
schedule: None,
673673
capabilities: vec!["security".to_string()],
674674
max_memory_bytes: None,
675+
provider: None,
676+
fallback_provider: None,
677+
fallback_model: None,
678+
provider_tier: None,
675679
},
676680
AgentDefinition {
677681
name: "sync".to_string(),
@@ -682,6 +686,10 @@ mod tests {
682686
schedule: Some("0 3 * * *".to_string()),
683687
capabilities: vec!["sync".to_string()],
684688
max_memory_bytes: None,
689+
provider: None,
690+
fallback_provider: None,
691+
fallback_model: None,
692+
provider_tier: None,
685693
},
686694
],
687695
restart_cooldown_secs: 60,
@@ -783,6 +791,10 @@ task = "test"
783791
schedule: None,
784792
capabilities: vec![],
785793
max_memory_bytes: None,
794+
provider: None,
795+
fallback_provider: None,
796+
fallback_model: None,
797+
provider_tier: None,
786798
}],
787799
restart_cooldown_secs: 0, // instant restart for testing
788800
max_restart_count: 3,
@@ -852,6 +864,10 @@ task = "test"
852864
schedule: Some("0 3 * * *".to_string()),
853865
capabilities: vec![],
854866
max_memory_bytes: None,
867+
provider: None,
868+
fallback_provider: None,
869+
fallback_model: None,
870+
provider_tier: None,
855871
}];
856872
let mut orch = AgentOrchestrator::new(config).unwrap();
857873

crates/terraphim_orchestrator/src/scheduler.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ mod tests {
141141
schedule: schedule.map(String::from),
142142
capabilities: vec![],
143143
max_memory_bytes: None,
144+
provider: None,
145+
fallback_provider: None,
146+
fallback_model: None,
147+
provider_tier: None,
144148
}
145149
}
146150

crates/terraphim_orchestrator/tests/orchestrator_tests.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ fn test_config() -> OrchestratorConfig {
2626
schedule: None,
2727
capabilities: vec!["security".to_string()],
2828
max_memory_bytes: None,
29+
provider: None,
30+
fallback_provider: None,
31+
fallback_model: None,
32+
provider_tier: None,
2933
},
3034
AgentDefinition {
3135
name: "sync".to_string(),
@@ -36,6 +40,10 @@ fn test_config() -> OrchestratorConfig {
3640
schedule: Some("0 3 * * *".to_string()),
3741
capabilities: vec!["sync".to_string()],
3842
max_memory_bytes: None,
43+
provider: None,
44+
fallback_provider: None,
45+
fallback_model: None,
46+
provider_tier: None,
3947
},
4048
AgentDefinition {
4149
name: "reviewer".to_string(),
@@ -46,6 +54,10 @@ fn test_config() -> OrchestratorConfig {
4654
schedule: None,
4755
capabilities: vec!["code-review".to_string()],
4856
max_memory_bytes: None,
57+
provider: None,
58+
fallback_provider: None,
59+
fallback_model: None,
60+
provider_tier: None,
4961
},
5062
],
5163
restart_cooldown_secs: 60,

crates/terraphim_orchestrator/tests/scheduler_tests.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ fn make_agent(name: &str, layer: AgentLayer, schedule: Option<&str>) -> AgentDef
1010
schedule: schedule.map(String::from),
1111
capabilities: vec![],
1212
max_memory_bytes: None,
13+
provider: None,
14+
fallback_provider: None,
15+
fallback_model: None,
16+
provider_tier: None,
1317
}
1418
}
1519

crates/terraphim_spawner/src/config.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ impl AgentConfig {
8080
/// Each CLI tool has its own subcommand/flag for non-interactive mode:
8181
/// - codex: `exec <prompt>` runs a single task and exits
8282
/// - claude: `-p <prompt>` prints output without interactive UI
83+
/// - opencode: `run --format json` runs a single task and outputs JSON
8384
fn infer_args(cli_command: &str) -> Vec<String> {
8485
match Self::cli_name(cli_command) {
8586
"codex" => vec!["exec".to_string(), "--full-auto".to_string()],
@@ -88,6 +89,7 @@ impl AgentConfig {
8889
"--allowedTools".to_string(),
8990
"Bash,Read,Write,Edit,Glob,Grep".to_string(),
9091
],
92+
"opencode" => vec!["run".to_string(), "--format".to_string(), "json".to_string()],
9193
_ => Vec::new(),
9294
}
9395
}
@@ -97,6 +99,7 @@ impl AgentConfig {
9799
match Self::cli_name(cli_command) {
98100
"codex" => vec!["-m".to_string(), model.to_string()],
99101
"claude" | "claude-code" => vec!["--model".to_string(), model.to_string()],
102+
"opencode" => vec!["-m".to_string(), model.to_string()],
100103
_ => vec![],
101104
}
102105
}
@@ -226,4 +229,27 @@ mod tests {
226229
let keys = AgentConfig::infer_api_keys("unknown");
227230
assert!(keys.is_empty());
228231
}
232+
233+
#[test]
234+
fn test_infer_args_opencode() {
235+
let args = AgentConfig::infer_args("opencode");
236+
assert_eq!(args, vec!["run".to_string(), "--format".to_string(), "json".to_string()]);
237+
}
238+
239+
#[test]
240+
fn test_model_args_opencode() {
241+
let args = AgentConfig::model_args("opencode", "opencode-go/kimi-k2.5");
242+
assert_eq!(args, vec!["-m".to_string(), "opencode-go/kimi-k2.5".to_string()]);
243+
}
244+
245+
#[test]
246+
fn test_model_args_with_provider_prefix() {
247+
// Test that opencode accepts provider-prefixed model strings
248+
let args = AgentConfig::model_args("opencode", "kimi-for-coding/k2p5");
249+
assert_eq!(args, vec!["-m".to_string(), "kimi-for-coding/k2p5".to_string()]);
250+
251+
// Test with opencode-go prefix
252+
let args = AgentConfig::model_args("opencode", "opencode-go/glm-5");
253+
assert_eq!(args, vec!["-m".to_string(), "opencode-go/glm-5".to_string()]);
254+
}
229255
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# ADR-002: Subscription-Only Model Providers for ADF Agent Fleet
2+
3+
**Date**: 2026-03-20
4+
**Status**: Accepted
5+
**Deciders**: Alex (CTO)
6+
**Tags**: architecture, cost-optimisation, model-routing
7+
8+
---
9+
10+
## Context and Problem Statement
11+
12+
In the context of the ADF agent fleet dispatching tasks to LLM providers via the opencode CLI, facing the discovery that the `opencode/` (Zen) provider prefix routes through a pay-per-use proxy with significant markup, we decided to ban the `opencode/` prefix entirely and route all agent dispatch through subscription-based providers, accepting that we must maintain multiple provider subscriptions.
13+
14+
## Decision Drivers
15+
16+
* `opencode/kimi-k2.5` via Zen costs significantly more than `opencode-go/kimi-k2.5` via Go subscription ($10/mo flat)
17+
* The ADF fleet dispatches hundreds of requests daily -- per-request markup compounds rapidly
18+
* All required models are available through subscription providers at predictable monthly costs
19+
* Subscription providers already connected and verified in local `auth.json`
20+
21+
## Considered Options
22+
23+
* **Option A**: Continue using `opencode/` (Zen) prefix for convenience
24+
* **Option B**: Ban `opencode/` prefix, use subscription providers only
25+
* **Option C**: Run local inference to avoid all provider costs
26+
27+
## Decision Outcome
28+
29+
**Chosen option**: Option B -- Ban `opencode/` prefix, subscription providers only
30+
31+
**Reasoning**: All required models (kimi-k2.5, glm-5, minimax-m2.5, k2p5) are available through subscription providers at predictable flat-rate costs. The Go subscription alone ($10/mo) covers 4 models with ~100K requests/mo for minimax. Adding a runtime guard in `terraphim_spawner` prevents accidental use of the expensive Zen proxy.
32+
33+
### Positive Consequences
34+
35+
* Predictable monthly costs across all providers
36+
* No risk of unexpected per-request charges
37+
* Runtime guard catches configuration errors before they incur cost
38+
39+
### Negative Consequences
40+
41+
* Must maintain 5+ provider subscriptions (opencode-go, kimi-for-coding, zai-coding-plan, minimax-coding-plan, github-copilot)
42+
* Provider auth tokens must be renewed/refreshed across all subscriptions
43+
* Some models only available via Zen (e.g., `opencode/big-pickle`) become inaccessible
44+
45+
## Approved Providers
46+
47+
| Provider | Prefix | Pricing |
48+
|---|---|---|
49+
| opencode Go | `opencode-go/` | $10/mo flat |
50+
| Kimi for Coding | `kimi-for-coding/` | Subscription |
51+
| z.ai Coding Plan | `zai-coding-plan/` | Subscription |
52+
| MiniMax Coding Plan | `minimax-coding-plan/` | Subscription |
53+
| GitHub Copilot | `github-copilot/` | Free OSS quota |
54+
| Anthropic | `claude-code` CLI | Subscription |
55+
| **BANNED** | ~~`opencode/`~~ | ~~Pay-per-use~~ |
56+
57+
## Links
58+
59+
* Related to ADR-003 (Four-tier model routing)
60+
* Implements Section 4.1 of `plans/autonomous-org-configuration.md`
61+
* Gitea: terraphim/terraphim-ai #31 (Subscription guard implementation)

0 commit comments

Comments
 (0)