codetether_agent/tool/
morph_backend.rs1use crate::provider::{CompletionRequest, ContentPart, Message, ProviderRegistry, Role};
2use anyhow::{Context, Result, anyhow};
3use serde::Deserialize;
4use serde_json::json;
5use std::sync::Arc;
6use tokio::sync::OnceCell;
7
8#[derive(Debug, Deserialize)]
9struct MorphResponse {
10 choices: Vec<MorphChoice>,
11}
12
13#[derive(Debug, Deserialize)]
14struct MorphChoice {
15 message: MorphMessage,
16}
17
18#[derive(Debug, Deserialize)]
19struct MorphMessage {
20 #[serde(default)]
21 content: Option<String>,
22}
23
24static MORPH_PROVIDER_REGISTRY: OnceCell<Arc<ProviderRegistry>> = OnceCell::const_new();
25
26fn backend_enabled() -> bool {
27 match std::env::var("CODETETHER_MORPH_TOOL_BACKEND") {
28 Ok(v) => matches!(
29 v.trim().to_ascii_lowercase().as_str(),
30 "1" | "true" | "yes" | "on"
31 ),
32 Err(_) => false,
33 }
34}
35
36pub fn should_use_morph_backend() -> bool {
37 backend_enabled()
38}
39
40fn morph_model_ref() -> String {
41 let configured = std::env::var("CODETETHER_MORPH_TOOL_MODEL")
42 .ok()
43 .filter(|v| !v.trim().is_empty())
44 .unwrap_or_else(|| "openrouter/morph/morph-v3-large".to_string());
45 let trimmed = configured.trim();
46 if trimmed.starts_with("openrouter/") {
47 return trimmed.to_string();
48 }
49 if trimmed.starts_with("morph/") {
50 return format!("openrouter/{trimmed}");
51 }
52 trimmed.to_string()
53}
54
55fn direct_openrouter_base_url() -> Option<String> {
56 std::env::var("CODETETHER_OPENROUTER_BASE_URL")
57 .ok()
58 .filter(|v| !v.trim().is_empty())
59 .map(|v| v.trim().trim_end_matches('/').to_string())
60}
61
62async fn get_registry() -> Result<Arc<ProviderRegistry>> {
63 let registry = MORPH_PROVIDER_REGISTRY
64 .get_or_try_init(|| async {
65 let loaded = ProviderRegistry::from_vault().await?;
66 Ok::<_, anyhow::Error>(Arc::new(loaded))
67 })
68 .await?;
69 Ok(registry.clone())
70}
71
72async fn apply_with_provider(prompt: &str) -> Result<String> {
73 let model_ref = morph_model_ref();
74 let registry = get_registry()
75 .await
76 .context("Failed to initialize provider registry for Morph backend")?;
77 let (provider, model) = registry
78 .resolve_model(&model_ref)
79 .with_context(|| format!("Failed to resolve Morph model '{model_ref}'"))?;
80
81 tracing::info!(
82 provider = %provider.name(),
83 model = %model,
84 "Executing Morph-backed edit via provider registry"
85 );
86
87 let request = CompletionRequest {
88 messages: vec![Message {
89 role: Role::User,
90 content: vec![ContentPart::Text {
91 text: prompt.to_string(),
92 }],
93 }],
94 tools: vec![],
95 model,
96 temperature: None,
97 top_p: None,
98 max_tokens: None,
99 stop: vec![],
100 };
101
102 let response = provider
103 .complete(request)
104 .await
105 .context("Morph provider request failed")?;
106
107 let content = response
108 .message
109 .content
110 .into_iter()
111 .filter_map(|part| match part {
112 ContentPart::Text { text } => Some(text),
113 _ => None,
114 })
115 .collect::<Vec<_>>()
116 .join("\n");
117 let content = content.trim().to_string();
118 if content.is_empty() {
119 return Err(anyhow!("Morph backend returned empty content"));
120 }
121 Ok(content)
122}
123
124async fn apply_with_direct_openrouter(prompt: &str, base_url: &str) -> Result<String> {
125 let api_key = std::env::var("OPENROUTER_API_KEY").context(
126 "OPENROUTER_API_KEY is required when CODETETHER_OPENROUTER_BASE_URL is set for direct Morph backend calls",
127 )?;
128 let model_ref = morph_model_ref();
129 let model = if let Some(stripped) = model_ref.strip_prefix("openrouter/") {
130 stripped.to_string()
131 } else {
132 model_ref
133 };
134 let body = json!({
135 "model": model,
136 "messages": [{
137 "role": "user",
138 "content": prompt
139 }]
140 });
141
142 let client = reqwest::Client::new();
143 let resp = client
144 .post(format!("{base_url}/chat/completions"))
145 .header("Authorization", format!("Bearer {api_key}"))
146 .header("Content-Type", "application/json")
147 .header("HTTP-Referer", "https://codetether.run")
148 .header("X-Title", "CodeTether Agent")
149 .json(&body)
150 .send()
151 .await
152 .context("Failed to send Morph request")?;
153
154 let status = resp.status();
155 let text = resp.text().await.context("Failed to read Morph response")?;
156 if !status.is_success() {
157 anyhow::bail!("Morph backend error: {} {}", status, text);
158 }
159
160 let parsed: MorphResponse =
161 serde_json::from_str(&text).context("Failed to parse Morph response JSON")?;
162 let content = parsed
163 .choices
164 .first()
165 .and_then(|c| c.message.content.clone())
166 .filter(|s| !s.trim().is_empty())
167 .ok_or_else(|| anyhow!("Morph backend returned empty content"))?;
168
169 Ok(content)
170}
171
172pub async fn apply_edit_with_morph(code: &str, instruction: &str, update: &str) -> Result<String> {
173 let prompt = format!(
174 "<instruction>{instruction}</instruction>\n<code>{code}</code>\n<update>{update}</update>"
175 );
176 if let Some(base_url) = direct_openrouter_base_url() {
177 tracing::info!(
178 base_url = %base_url,
179 "Using direct OpenRouter HTTP path for Morph backend"
180 );
181 return apply_with_direct_openrouter(&prompt, &base_url).await;
182 }
183 apply_with_provider(&prompt).await
184}