diff --git a/server/src/analyze/llm.rs b/server/src/analyze/llm.rs index 493a561..f040434 100644 --- a/server/src/analyze/llm.rs +++ b/server/src/analyze/llm.rs @@ -74,6 +74,9 @@ struct ResponseMessage { /// Single-shot chat completion against an OpenAI-compatible endpoint. /// 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( client: &reqwest::Client, settings: &LlmSettings<'_>, @@ -94,14 +97,11 @@ pub async fn chat_once( ], }; - let response = client - .post(&url) - .bearer_auth(settings.api_key) - .timeout(timeout) - .json(&body) - .send() - .await - .map_err(LlmError::Http)?; + let mut req = client.post(&url).timeout(timeout).json(&body); + if !settings.api_key.is_empty() { + req = req.bearer_auth(settings.api_key); + } + let response = req.send().await.map_err(LlmError::Http)?; let status = response.status(); if !status.is_success() { diff --git a/server/src/config.rs b/server/src/config.rs index 2c1041e..96844d5 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -105,11 +105,14 @@ impl Config { } } - /// True iff all three required fields for the external analysis LLM are - /// non-empty. Used to gate the "Fall abschließen" UI and handler — if - /// no LLM is configured, the close action must not be reachable. + /// True iff the external analysis LLM is configured. `llm_api_key` is + /// intentionally **not** required — Ollama's OpenAI-compatible endpoint + /// 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 { - !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: diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index 8694265..433089f 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -797,3 +797,91 @@ async fn bulk_reset_processes_multiple_cases_admin_only() { assert!(dir_c.join("document.md").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 = 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; +} diff --git a/whisper/docker-compose.yml.cpu b/whisper/docker-compose.yml.cpu new file mode 100644 index 0000000..6f16839 --- /dev/null +++ b/whisper/docker-compose.yml.cpu @@ -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