Skip to main content

codetether_agent/tool/
morph_backend.rs

1use 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}