Feature chore bug 005 #19
@@ -46,6 +46,12 @@ pub trait LlmProvider: Send + Sync {
|
||||
/// Non-streaming — the reply is short enough that a single round trip is
|
||||
/// simpler than wiring up another token-stream event for it.
|
||||
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError>;
|
||||
/// One-shot, non-streaming completion (T10.6, ADR-0011): a caller-supplied
|
||||
/// system prompt + user message, one full reply back — no output-format
|
||||
/// contract of its own (unlike `summarize`'s three fixed sections), since
|
||||
/// callers like `briefs::FeatureBriefBuilder` define their own. Mirrors
|
||||
/// `suggest_tags`'s single-round-trip shape.
|
||||
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError>;
|
||||
/// True if the endpoint resolves to loopback/local (FR-LLM-6, FR-SEC-1).
|
||||
fn is_local(&self) -> bool;
|
||||
}
|
||||
@@ -154,8 +160,10 @@ fn parse_summary(text: &str) -> Summary {
|
||||
}
|
||||
|
||||
/// `- text` / `* text` -> `Some("text")`; skips empty bullets and the
|
||||
/// placeholder "- None" the prompt asks for when a section is empty.
|
||||
fn bullet_text(line: &str) -> Option<String> {
|
||||
/// placeholder "- None" the prompt asks for when a section is empty. Shared
|
||||
/// with `briefs::parse_brief` — its "Acceptance Criteria" section is the same
|
||||
/// bullet-list shape as `summarize`'s Decisions/Action Items.
|
||||
pub(crate) fn bullet_text(line: &str) -> Option<String> {
|
||||
let trimmed = line.trim();
|
||||
let stripped = trimmed
|
||||
.strip_prefix("- ")
|
||||
@@ -438,6 +446,34 @@ impl LlmProvider for OllamaProvider {
|
||||
))
|
||||
}
|
||||
|
||||
/// One-shot completion (T10.6) — same non-streaming `/api/chat` shape as
|
||||
/// `suggest_tags`, just with a caller-supplied system/user pair instead of
|
||||
/// the fixed tag-list prompt.
|
||||
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError> {
|
||||
let body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": system },
|
||||
{ "role": "user", "content": user },
|
||||
],
|
||||
"stream": false,
|
||||
});
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/api/chat", self.base()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
let chunk: OllamaChatChunk = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Request(e.to_string()))?;
|
||||
Ok(chunk.message.map(|m| m.content).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn is_local(&self) -> bool {
|
||||
is_local_endpoint(&self.endpoint)
|
||||
}
|
||||
@@ -627,6 +663,55 @@ impl LlmProvider for OpenAiCompatProvider {
|
||||
Ok(parse_tags(&full_text))
|
||||
}
|
||||
|
||||
/// One-shot completion (T10.6) — reuses `suggest_tags`'s SSE-accumulate
|
||||
/// path (this API has no simpler non-streaming reply shape) with a
|
||||
/// caller-supplied system/user pair instead of the fixed tag prompt.
|
||||
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError> {
|
||||
let body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": system },
|
||||
{ "role": "user", "content": user },
|
||||
],
|
||||
"stream": true,
|
||||
});
|
||||
let req = self.auth(
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/v1/chat/completions", self.base()))
|
||||
.json(&body),
|
||||
);
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
|
||||
let mut full_text = String::new();
|
||||
stream_lines(resp, |line| {
|
||||
let Some(payload) = line.strip_prefix("data:") else {
|
||||
return true;
|
||||
};
|
||||
let payload = payload.trim();
|
||||
if payload == "[DONE]" {
|
||||
return false;
|
||||
}
|
||||
let Ok(chunk) = serde_json::from_str::<OpenAiChatChunk>(payload) else {
|
||||
return true;
|
||||
};
|
||||
for choice in chunk.choices {
|
||||
if let Some(content) = choice.delta.content {
|
||||
full_text.push_str(&content);
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(full_text)
|
||||
}
|
||||
|
||||
fn is_local(&self) -> bool {
|
||||
self.credential_ref.is_none() && is_local_endpoint(&self.endpoint)
|
||||
}
|
||||
@@ -755,6 +840,14 @@ impl LlmProvider for AnthropicProvider {
|
||||
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
|
||||
))
|
||||
}
|
||||
async fn complete(&self, _system: &str, _user: &str) -> Result<String, LlmError> {
|
||||
// Not built yet (Phase 10a); `briefs::LlmFeatureBriefBuilder` (M1) only
|
||||
// ever gets an Ollama/OpenAI-compatible provider from
|
||||
// `llm_provider_from_settings` today, so this path isn't reachable yet.
|
||||
Err(LlmError::Request(
|
||||
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
|
||||
))
|
||||
}
|
||||
fn is_local(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user