Refactor LLM API key handling for Ollama
The LLM client now conditionally adds the `Authorization` header only when an API key is provided. The `llm_configured` check is updated to reflect that an API key is not strictly required for Ollama-style endpoints. A new integration test verifies the functionality against an Ollama-compatible endpoint without an API key.
This commit is contained in:
@@ -74,6 +74,9 @@ struct ResponseMessage {
|
|||||||
|
|
||||||
/// Single-shot chat completion against an OpenAI-compatible endpoint.
|
/// Single-shot chat completion against an OpenAI-compatible endpoint.
|
||||||
/// The `api_key` in `settings` is used as a Bearer token; do not log it.
|
/// The `api_key` in `settings` is used as a Bearer token; do not log it.
|
||||||
|
/// An empty `api_key` is treated as "no auth" — the `Authorization` header
|
||||||
|
/// is omitted entirely, which is the correct mode for Ollama's
|
||||||
|
/// OpenAI-compatible endpoint (some gateways object to a blank Bearer).
|
||||||
pub async fn chat_once(
|
pub async fn chat_once(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
settings: &LlmSettings<'_>,
|
settings: &LlmSettings<'_>,
|
||||||
@@ -94,14 +97,11 @@ pub async fn chat_once(
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = client
|
let mut req = client.post(&url).timeout(timeout).json(&body);
|
||||||
.post(&url)
|
if !settings.api_key.is_empty() {
|
||||||
.bearer_auth(settings.api_key)
|
req = req.bearer_auth(settings.api_key);
|
||||||
.timeout(timeout)
|
}
|
||||||
.json(&body)
|
let response = req.send().await.map_err(LlmError::Http)?;
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(LlmError::Http)?;
|
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
|
|||||||
@@ -105,11 +105,14 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True iff all three required fields for the external analysis LLM are
|
/// True iff the external analysis LLM is configured. `llm_api_key` is
|
||||||
/// non-empty. Used to gate the "Fall abschließen" UI and handler — if
|
/// intentionally **not** required — Ollama's OpenAI-compatible endpoint
|
||||||
/// no LLM is configured, the close action must not be reachable.
|
/// at `/v1/chat/completions` accepts unauthenticated requests and is a
|
||||||
|
/// supported deployment target. For hosted providers (Ionos, OpenAI) the
|
||||||
|
/// key remains necessary in practice; the provider will reject with 401
|
||||||
|
/// if it is missing, which is correct feedback.
|
||||||
pub fn llm_configured(&self) -> bool {
|
pub fn llm_configured(&self) -> bool {
|
||||||
!self.llm_url.is_empty() && !self.llm_api_key.is_empty() && !self.llm_model.is_empty()
|
!self.llm_url.is_empty() && !self.llm_model.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sane defaults for integration tests. Not a `Default` impl on purpose:
|
/// Sane defaults for integration tests. Not a `Default` impl on purpose:
|
||||||
|
|||||||
@@ -797,3 +797,91 @@ async fn bulk_reset_processes_multiple_cases_admin_only() {
|
|||||||
assert!(dir_c.join("document.md").exists());
|
assert!(dir_c.join("document.md").exists());
|
||||||
assert!(dir_c.join("2026-04-15T11-00-00Z.transcript.txt").exists());
|
assert!(dir_c.join("2026-04-15T11-00-00Z.transcript.txt").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Ollama-style (no-auth) provider
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Proves the analysis pipeline works against an OpenAI-compatible endpoint
|
||||||
|
/// that expects **no** Authorization header — i.e. Ollama's `/v1` surface.
|
||||||
|
/// Guard mock (registered first, so wiremock picks it up for any request that
|
||||||
|
/// actually carries the header) must stay at `.expect(0)`; happy mock takes
|
||||||
|
/// all no-auth calls. This catches any future regression where the client
|
||||||
|
/// accidentally sends `Authorization: Bearer `.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
|
||||||
|
let tmp = unique_tmp("ollama");
|
||||||
|
let case_dir = tmp.join("dr_a/22222222-2222-2222-2222-222222222222");
|
||||||
|
std::fs::create_dir_all(&case_dir).unwrap();
|
||||||
|
|
||||||
|
let input = json!({
|
||||||
|
"last_recording_mtime": "2026-04-16T10:00:00Z",
|
||||||
|
"recordings": [
|
||||||
|
{ "recorded_at": "2026-04-16T10:00:00Z", "text": "Test." }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
std::fs::write(
|
||||||
|
case_dir.join("analysis_input.json"),
|
||||||
|
serde_json::to_vec_pretty(&input).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mock = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/v1/chat/completions"))
|
||||||
|
.and(wiremock::matchers::header_exists("authorization"))
|
||||||
|
.respond_with(ResponseTemplate::new(500))
|
||||||
|
.expect(0)
|
||||||
|
.mount(&mock)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/v1/chat/completions"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"choices": [{ "message": { "content": "# Doc via Ollama" } }]
|
||||||
|
})))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&mock)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Ollama-style config: explicitly empty api_key. Inline because
|
||||||
|
// `config_with_llm` hard-codes "test-key" for hosted-provider tests.
|
||||||
|
let users = vec![make_user("dr_a")];
|
||||||
|
let api_keys: HashMap<String, String> = users
|
||||||
|
.iter()
|
||||||
|
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
||||||
|
.collect();
|
||||||
|
let config = Arc::new(Config {
|
||||||
|
data_path: tmp.clone(),
|
||||||
|
users,
|
||||||
|
api_keys,
|
||||||
|
llm_url: mock.uri(),
|
||||||
|
llm_api_key: String::new(),
|
||||||
|
llm_model: "llama3.3:70b".into(),
|
||||||
|
..Config::test_default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let (tx, rx) = analyze::channel();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let handle = tokio::spawn(analyze::worker::run(rx, config, client, busy));
|
||||||
|
|
||||||
|
tx.send(analyze::AnalyzeJob {
|
||||||
|
case_dir: case_dir.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let document_path = case_dir.join("document.md");
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
while !document_path.exists() {
|
||||||
|
if std::time::Instant::now() > deadline {
|
||||||
|
panic!("document.md not written within 5s");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&document_path).unwrap();
|
||||||
|
assert!(content.contains("Ollama"), "unexpected content: {content}");
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
doctate-whisper:
|
||||||
|
image: doctate-whisper:latest
|
||||||
|
container_name: doctate-whisper
|
||||||
|
environment:
|
||||||
|
- WHISPER_MODEL=large-v3
|
||||||
|
- WHISPER_DEVICE=cpu
|
||||||
|
- WHISPER_COMPUTE_TYPE=int8
|
||||||
|
volumes:
|
||||||
|
- /opt/stacks/doctate-whisper/models:/models
|
||||||
|
ports:
|
||||||
|
- "9001:9001"
|
||||||
|
restart: unless-stopped
|
||||||
Reference in New Issue
Block a user