feat(p4-2): ollama-adapter — kb-llm-local 크레이트 (reqwest::blocking) #22
Reference in New Issue
Block a user
Delete Branch "feat/p4-2-ollama-adapter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
변경 요약
P4-2 ollama-adapter 작업입니다. P4-1의
LanguageModeltrait의 첫 실제 구현을 새 크레이트kb-llm-local으로 추가합니다. Ollama 로컬 HTTP API (POST /api/generate?stream=true) 위에서 동기 streaming을 처리하고, 오류를 actionable hint가 붙은LlmError로 매핑합니다.무엇을 했는가
핵심 동작
model,prompt = system + "\n\n" + user,stream: true,options { temperature, seed, num_ctx, stop }. design §11.2 그대로.OllamaStream이터레이터가BufReader<reqwest::blocking::Response>을 소유하고 NDJSON 라인을read_until(b'\n')으로 점진적으로 읽음. Token 프레임 →TokenChunk::Token, done 프레임 →TokenChunk::Done { finish_reason, usage }. UTF-8: 라인 단위라 cross-HTTP-chunk codepoint split 우려 없음.done_reason매핑:"length"→Length,"abort"→Aborted,"stop"/ 누락 / 미지값 →Stop(forward-compat).prompt_eval_count/eval_count): 0으로 default +tracing::warn. 실패 안 함.Done { Aborted, zeros }을 합성해서 downstream 파이프라인이 hang 없이 종료.오류 매핑 (
LlmError)리뷰의 "non-NDJSON 200 body는
Malformed이 아니라Stream이어야 한다" + "오류 본문 truncate" MUST-FIX 반영:is_connect()→Unreachable+ hint "ensureollama serveis running".is_timeout()→Timeout.Stream(truncate_body(line, 512)). iterator의has_emittedflag로 구분.Stream(truncated body).ModelNotPulled(model_id)+ hint "ollama pull <model_id>". 리뷰의 N3 반영 — Ollama가 메시지를 한국어/일본어 등으로 localize해도 model_id echo로 잡히게 강화.Malformed(line).\"http://x:1234/\"→.../api/generate(단일 슬래시). 전용 테스트로 pin.Tokio transitivity 솔직화 (리뷰 M1)
reqwest 0.12의blockingfeature는 내부적으로 private current-thread tokio runtime을 wrap합니다.cargo tree --edges normal에 tokio가 등장합니다. spec line 35 "no async runtime needed"의 의도는 "crate 자체가 async semantics을 추가하지 않는다"이고 "tokio가 dep graph에 0건이다"가 아닙니다.Cargo.toml+lib.rs코멘트를 다음과 같이 정직하게 다시 썼습니다:default-features = false은default-tls(native-tls)을 끄는 거지 tokio을 끄는 게 아니다.crates/kb-llm-local/src/에async/await/tokio::*0건.wiremock+tokio은[dev-dependencies]에만.ureq로 갈아끼면 tokio 완전 제거 가능하지만 spec의 Allowed deps에 reqwest이 명시되어 있어 보류.
테스트
src/ollama.rs): prompt 빌드, options 빌드, finish_reason 매핑,truncate_body경계 (under_cap / over_cap_marker / multibyte_chars_not_bytes), 404 + model_id heuristic.ModelRef구조,context_tokens통과, lazy-connect (port 1 가리키게 했을 때new()가 성공해야 함을 증명).multibyte_chars_within_a_line_round_trip로 변경 — cross-HTTP-chunk reassembly가 아니라 라인 내 multibyte 처리 검증), Unreachable + hint, 4xx → Stream, 404 → ModelNotPulled, concat = full text, done_reason length / abort, missing eval counts → 0, missing done_reason → Stop, determinism by mock, trailing-slash endpoint, non-NDJSON 200 body → Stream not Malformed (M2 반영), localized 404 + model_id (N3 반영).#[ignore]): 실제 Ollama onlocalhost:11434. opt-in viacargo test -p kb-llm-local -- --ignored(ollama serve+ollama pull필요).워크스페이스 default 288 passed / 25 ignored / 0 failed.
cargo clippy --workspace --all-targets -- -D warningsclean.의존성
Allowed deps 준수:
kb-core,kb-config,kb-llm,reqwest 0.12(default-features=false, features=[blocking, json, rustls-tls]),serde,serde_json,tracing,thiserror+ 강제anyhow(trait 반환 타입).[dev-dependencies]에만wiremock = \"0.6\"+tokio(wiremock의 mock 서버 요구).native-tls / openssl은 그래프에 0건. Forbidden runtime deps (
tokiodirect,async-std,kb-source-fs/parse-md/normalize/chunk/store-*/embed*/search/rag/tui/desktop)도 모두 0건.변경 파일
crates/kb-llm-local/Cargo.toml(신규)crates/kb-llm-local/src/lib.rs(신규 — public surface 재노출)crates/kb-llm-local/src/error.rs(신규 —LlmError)crates/kb-llm-local/src/ollama.rs(신규 —OllamaLanguageModel+OllamaStream+truncate_body)crates/kb-llm-local/tests/{construction,streaming,integration}.rs(신규)Cargo.toml(workspace member +wiremockworkspace dep)Cargo.lockOut of scope
/api/embed)을 사용한 embedder — P+에서kb-embed-local또는 별도 크레이트.후속 작업 후보
kb-core::GenerateRequest::temperaturedoc-comment에 "req가 항상 effective intent" 명시.Request::body캡쳐로 actual sent body의temperature: 0.0+seed을 검증하는 강화. 현재 통합 테스트가 진정한 determinism gate.design §7.2, §6.4, §0 Q5, §10 / report §11.2 참고.
First real LanguageModel implementation. Wraps Ollama's local HTTP API at POST {endpoint}/api/generate with stream:true, parses the NDJSON streaming response into TokenChunk events, and maps Ollama error states to a thiserror-derived LlmError with actionable hints. Synchronous trait surface; reqwest::blocking handles the HTTP I/O. Public surface: - pub struct OllamaLanguageModel - pub fn new(config: &Config) -> Result<Self> — lazy connect; never hits the network. Spec line 96. - pub enum LlmError { Unreachable, ModelNotPulled, Timeout, Stream, Malformed }. Lives in this crate per spec — kb-core / kb-llm stay free of error taxonomy. - impl kb_core::LanguageModel via re-export from kb-llm. Streaming: - POST body shape per spec §11.2: model, prompt = system + "\n\n" + user, stream: true, options { temperature, seed, num_ctx, stop }. - OllamaStream owns BufReader<reqwest::blocking::Response>, reads NDJSON lines via read_until(b'\n'), parses each as {response, done, done_reason?, prompt_eval_count?, eval_count?, total_duration?}. Token frame → TokenChunk::Token; done frame → TokenChunk::Done { finish_reason, usage }. - done_reason mapping: "length" → Length, "abort" → Aborted, "stop" / missing / unknown → Stop (forward-compat with future Ollama tags). - Missing prompt_eval_count / eval_count default to 0 + tracing::warn (do NOT fail). Spec line 135. - EOF without a done line synthesizes Done { Aborted, zeros } so downstream pipelines never deadlock waiting for a terminal frame. - UTF-8: line-delimited framing means each JSON line is a complete UTF-8 sequence — no cross-HTTP-chunk codepoint splits to worry about. read_until accumulates whole lines regardless of how the underlying reqwest body chunks. Error mapping (LlmError): - reqwest::Error::is_connect() → Unreachable { endpoint, source } with hint "ensure `ollama serve` is running and reachable at <endpoint>". - reqwest::Error::is_timeout() → Timeout. - 200 with non-NDJSON first line (e.g., transparent-proxy HTML error page) → Stream(truncated body) — distinguished from Malformed by the iterator's has_emitted flag. - 404 with body containing model_id (case-insensitive) OR English "model" + "not found" → ModelNotPulled(model_id) with hint "ollama pull <model_id>". Tightened beyond spec to survive Ollama localizing the error message (Korean / Japanese / etc.) while keeping the original English-substring fallback. - Other 4xx/5xx → Stream(truncated body). - Mid-stream JSON parse failure (after at least one valid line) → Malformed(line). Truncate all error bodies to 512 chars (chars-based, multibyte safe) so an nginx 500 page can't blow up the diagnostic. - Trailing slash in endpoint stripped before formatting the URL — endpoint = "http://x:1234/" produces .../api/generate, not .../api//generate. Pinned by trailing-slash test. Tokio note: reqwest 0.12's blocking feature internally wraps a private current-thread tokio runtime, so cargo tree --edges normal shows tokio. The auditable invariant is "no top-level tokio dep + no async surface exposed to callers" — verified: src/ has zero async/await/tokio::*. default-features = false drops default-tls (rustls only) but does NOT drop tokio. Documented honestly in Cargo.toml + lib.rs. Switching to ureq would remove tokio entirely; deferred since reqwest is the spec's allowed dep. Tests (24 total: 23 default + 1 ignored): - 7 unit in src/ollama.rs: prompt-build, options-build, finish- reason mapping, truncate_body bounds (under_cap / over_cap_marker / multibyte_chars_not_bytes), 404+model-id heuristic. - 3 in tests/construction.rs: ModelRef shape, context_tokens passthrough, lazy-connect proven via port-1 pointing. - 13 in tests/streaming.rs: streamed tokens then Done, multibyte chars within a line round-trip (renamed from "split across chunks" to honestly reflect what's tested), Unreachable-with- hint, 4xx→Stream, 404→ModelNotPulled, concat-equals-canned, done_reason length / abort, missing eval counts default to zero, missing done_reason defaults to Stop, determinism-by-mock, trailing-slash endpoint, non-NDJSON 200 body → Stream not Malformed. - 1 #[ignore] in tests/integration.rs: real Ollama on localhost:11434 with the configured model. Opt-in via cargo test -p kb-llm-local -- --ignored after `ollama serve` + `ollama pull`. Workspace: 288 passed / 25 ignored / 0 failed. cargo clippy --workspace --all-targets -- -D warnings clean. No native-tls, no openssl in the dep graph. Allowed deps respected: kb-core, kb-config, kb-llm, reqwest 0.12 (default-features=false; blocking, json, rustls-tls), serde, serde_json, tracing, thiserror plus anyhow (forced by trait return type). wiremock + tokio in [dev-dependencies] only. Out of scope: llama.cpp / candle adapters (P+), Ollama embed endpoint (separate adapter inside kb-embed-local if requested), cancellation / abort tokens (P+), connection-pool tuning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>P4-2 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.
spec compliance + code quality 양쪽 리뷰 결과 BLOCKER 0건. 핵심 발견:
3개 MUST-FIX 모두 + 5개 NIT (trailing-slash test, 404 heuristic 강화, OllamaStream timeout doc, UTF-8 test 이름 정직화, Stream/Malformed 의미 분리) 반영했습니다.
핵심 포인트:
OllamaStream이BufReader<Response>을 소유하고 라인 단위로 점진적으로 yield — 진짜 streaming, collect-to-Vec 없음.LlmError::Stream(Malformed 아님).has_emittedflag로 첫 라인 vs 중간 라인 parse 실패 구분.워크스페이스 default 288 passed / 25 ignored / 0 failed. clippy clean. 통합 테스트 1건 (#[ignore])은 실제 Ollama 필요.
inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다. 다음은 P4-3 rag-pipeline.
@@ -0,0 +17,4 @@# `blocking` feature internally wraps a private current-thread tokio# runtime, so `cargo tree -p kb-llm-local --edges normal | grep tokio`# will list tokio. The auditable invariant for this crate is "no# top-level tokio dep + no async surface (`async`/`await`/`tokio::*`)tokio transitivity에 대한 코멘트가 정직하게 다시 작성된 게 좋습니다. "tokio absent from tree" 같은 검증 불가능한 주장 대신 "no top-level tokio dep + no async surface in src"라는 audit 가능한 invariant로 전환.
default-features = false이 default-tls을 끄는 거지 tokio을 끄는 게 아니라는 점도 명시 — 미래 누군가 "왜 tokio이 그래프에 보이지?" 질문하면 코멘트가 답을 줍니다.@@ -0,0 +35,4 @@"ollama model `{0}` is not pulled\n\hint: run `ollama pull {0}`")]ModelNotPulled(String),StreamvsMalformed의 의미 분리가 doc-comment에 명문화.Stream= HTTP-level / server-shape 오류 (truncated to 512 chars),Malformed= mid-stream JSON parse 실패 (이미 valid 라인 받은 후). 각 variant가 어떤 caller action을 유도해야 하는지가 분명해서 caller가 두 variant를 의미 있게 분기 처리할 수 있습니다.@@ -0,0 +257,4 @@line_buf: Vec<u8>,done: bool,/// Tracks whether we have parsed at least one valid NDJSON line. Used/// to discriminate "server never spoke NDJSON" (→ `LlmError::Stream`)OllamaStreamdoc에 "no inherent stop condition; only REQUEST_TIMEOUT (300s) breaks the hang" 명시. iterator의 lifecycle을 미래 reader가 즉시 이해할 수 있고, "왜 cancellation이 없냐?"는 의문을 미리 차단. 더 짧은 cancellation이 필요하면OllamaLanguageModel::new의 client timeout을 줄이라는 가이드까지.@@ -0,0 +277,4 @@// sequences inside a JSON `response` field are therefore// always whole by the time we attempt to decode — the line// boundary IS the safe re-sync point.let read = match self.reader.read_until(b'\n', &mut self.line_buf) {has_emittedflag로 첫 라인 vs 중간 라인 parse 실패를 구분해서 LlmError 타입을 갈라놓은 결정이 정확합니다. 200 + non-NDJSON (transparent proxy HTML 등)은 "server didn't send NDJSON" 시그널이므로Stream, 이미 valid 라인을 받은 후 corruption은Malformed. 두 시나리오의 진단 메시지가 다른 행동을 유도해야 하기에 (proxy 설정 vs server 버그) 단일 variant로 묶지 않은 게 옳습니다.@@ -0,0 +447,4 @@// Ollama returns a generic envelope without echoing the model id.if lower.contains(&model_id.to_ascii_lowercase())|| (lower.contains("model") && lower.contains("not found")){404 + model_id heuristic이 두 axis로 강화되었습니다 — 영어 "model" + "not found" 매치 (기존) OR body가 model_id 자체를 echo (신규). Ollama가 메시지를 한국어/일본어로 localize하는 변종이 들어와도 model_id가 본문에 etcho되는 한 정확히 잡힙니다. ASCII lowercasing은 model_id가 ASCII만 사용하는 점을 고려한 micro-opt — 정당.
@@ -0,0 +517,4 @@// `{"error":"모델 'qwen2.5:7b-instruct' 을(를) 찾을 수 없습니다"}`.// The English "not found" substring is absent, but the model id// is echoed — heuristic should still route to ModelNotPulled.let body = r#"{"error":"모델 'qwen2.5:7b-instruct' 을(를) 찾을 수 없습니다"}"#;truncate_body이 chars-based로 작성된 점이 결정적입니다 — bytes-based 잘림은 multibyte 한글/일문/중문 character를 중간에서 cut해서 invalid UTF-8을 만들 수 있고, 그게 이후String::from_utf8등에서 cascade 실패를 일으킵니다. 단위 테스트multibyte_chars_not_bytes가 이 경계를 명시적으로 pin.@@ -0,0 +141,4 @@#[tokio::test]async fn multibyte_chars_within_a_line_round_trip() {// The "split across HTTP chunks" concern in the spec is about테스트 이름을
utf8_split_across_chunks_reassembles에서multibyte_chars_within_a_line_round_trip으로 바꾼 결정. 원래 이름이 "cross-HTTP-chunk reassembly을 검증한다"고 over-sell했지만 실제로는 라인 내 multibyte 처리만 검증하므로 (BufReader가 라인 단위 framing을 책임지므로 cross-chunk 우려는 moot) 이름이 동작에 정직하게 부합. 미래 누군가가 "왜 이 이름인데 chunk-level split을 안 테스트하지?"의 혼란 차단.