feat(p4-2): ollama-adapter — kb-llm-local 크레이트 (reqwest::blocking) #22

Merged
altair823 merged 1 commits from feat/p4-2-ollama-adapter into main 2026-05-01 14:32:35 +00:00
Owner

변경 요약

P4-2 ollama-adapter 작업입니다. P4-1의 LanguageModel trait의 첫 실제 구현을 새 크레이트 kb-llm-local으로 추가합니다. Ollama 로컬 HTTP API (POST /api/generate?stream=true) 위에서 동기 streaming을 처리하고, 오류를 actionable hint가 붙은 LlmError로 매핑합니다.

무엇을 했는가

핵심 동작

  • HTTP 요청: model, prompt = system + "\n\n" + user, stream: true, options { temperature, seed, num_ctx, stop }. design §11.2 그대로.
  • Streaming 파싱: 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).
  • 누락 token count (prompt_eval_count / eval_count): 0으로 default + tracing::warn. 실패 안 함.
  • EOF without done line: Done { Aborted, zeros }을 합성해서 downstream 파이프라인이 hang 없이 종료.

오류 매핑 (LlmError)

리뷰의 "non-NDJSON 200 body는 Malformed이 아니라 Stream이어야 한다" + "오류 본문 truncate" MUST-FIX 반영:

  • is_connect()Unreachable + hint "ensure ollama serve is running".
  • is_timeout()Timeout.
  • 200 + non-NDJSON 첫 라인 (transparent proxy HTML 등) → Stream(truncate_body(line, 512)). iterator의 has_emitted flag로 구분.
  • 4xx/5xx → Stream(truncated body).
  • 404 + body가 model_id 포함 (case-insensitive) OR 영어 "model" + "not found" → ModelNotPulled(model_id) + hint "ollama pull <model_id>". 리뷰의 N3 반영 — Ollama가 메시지를 한국어/일본어 등으로 localize해도 model_id echo로 잡히게 강화.
  • 중간 stream JSON parse 실패 (이미 valid 라인 emit한 후) → Malformed(line).
  • 모든 오류 본문은 chars-based 512자로 truncate (multibyte safe). nginx 500 페이지가 진단 메시지를 폭발시키지 않게.
  • Endpoint trailing slash 처리: \"http://x:1234/\".../api/generate (단일 슬래시). 전용 테스트로 pin.

Tokio transitivity 솔직화 (리뷰 M1)

reqwest 0.12blocking feature는 내부적으로 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 코멘트를 다음과 같이 정직하게 다시 썼습니다:

  • "no top-level tokio dep + no async surface exposed to callers"가 audit 가능한 invariant.
  • default-features = falsedefault-tls (native-tls)을 끄는 거지 tokio을 끄는 게 아니다.
  • 검증: crates/kb-llm-local/src/async/await/tokio::* 0건. wiremock + tokio[dev-dependencies]에만.

ureq로 갈아끼면 tokio 완전 제거 가능하지만 spec의 Allowed deps에 reqwest이 명시되어 있어 보류.

테스트

  • 단위 (7건, src/ollama.rs): prompt 빌드, options 빌드, finish_reason 매핑, truncate_body 경계 (under_cap / over_cap_marker / multibyte_chars_not_bytes), 404 + model_id heuristic.
  • construction (3건): ModelRef 구조, context_tokens 통과, lazy-connect (port 1 가리키게 했을 때 new()가 성공해야 함을 증명).
  • streaming (13건, wiremock 기반): 2 chunk + Done, multibyte chars within a line round-trip (이름을 정직하게 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 반영).
  • integration (1건 #[ignore]): 실제 Ollama on localhost:11434. opt-in via cargo test -p kb-llm-local -- --ignored (ollama serve + ollama pull 필요).

워크스페이스 default 288 passed / 25 ignored / 0 failed. cargo clippy --workspace --all-targets -- -D warnings clean.

의존성

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 (tokio direct, 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 + wiremock workspace dep)
  • Cargo.lock

Out of scope

  • llama.cpp / candle adapter (P+).
  • Ollama embed endpoint (/api/embed)을 사용한 embedder — P+에서 kb-embed-local 또는 별도 크레이트.
  • Cancellation / abort tokens (P+).
  • Connection pool tuning — single-user CLI에는 reqwest::blocking 기본값 충분.

후속 작업 후보

  • 리뷰의 N1 (temperature 시맨틱 명문화) — kb-core::GenerateRequest::temperature doc-comment에 "req가 항상 effective intent" 명시.
  • 리뷰의 N6 (determinism 테스트가 wiremock-driven이라 tautology) — wiremock의 Request::body 캡쳐로 actual sent body의 temperature: 0.0 + seed을 검증하는 강화. 현재 통합 테스트가 진정한 determinism gate.
  • ureq 전환 검토 — tokio dep 그래프에서 완전히 빼고 싶으면.

design §7.2, §6.4, §0 Q5, §10 / report §11.2 참고.

## 변경 요약 P4-2 ollama-adapter 작업입니다. P4-1의 `LanguageModel` trait의 첫 실제 구현을 새 크레이트 `kb-llm-local`으로 추가합니다. Ollama 로컬 HTTP API (`POST /api/generate?stream=true`) 위에서 동기 streaming을 처리하고, 오류를 actionable hint가 붙은 `LlmError`로 매핑합니다. ## 무엇을 했는가 ### 핵심 동작 - **HTTP 요청**: `model`, `prompt = system + "\n\n" + user`, `stream: true`, `options { temperature, seed, num_ctx, stop }`. design §11.2 그대로. - **Streaming 파싱**: `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). - **누락 token count** (`prompt_eval_count` / `eval_count`): 0으로 default + `tracing::warn`. 실패 안 함. - **EOF without done line**: `Done { Aborted, zeros }`을 합성해서 downstream 파이프라인이 hang 없이 종료. ### 오류 매핑 (`LlmError`) 리뷰의 \"non-NDJSON 200 body는 `Malformed`이 아니라 `Stream`이어야 한다\" + \"오류 본문 truncate\" MUST-FIX 반영: - `is_connect()` → `Unreachable` + hint \"ensure `ollama serve` is running\". - `is_timeout()` → `Timeout`. - 200 + non-NDJSON 첫 라인 (transparent proxy HTML 등) → `Stream(truncate_body(line, 512))`. iterator의 `has_emitted` flag로 구분. - 4xx/5xx → `Stream(truncated body)`. - 404 + body가 model_id 포함 (case-insensitive) **OR** 영어 \"model\" + \"not found\" → `ModelNotPulled(model_id)` + hint \"ollama pull <model_id>\". 리뷰의 N3 반영 — Ollama가 메시지를 한국어/일본어 등으로 localize해도 model_id echo로 잡히게 강화. - 중간 stream JSON parse 실패 (이미 valid 라인 emit한 후) → `Malformed(line)`. - 모든 오류 본문은 chars-based 512자로 truncate (multibyte safe). nginx 500 페이지가 진단 메시지를 폭발시키지 않게. - Endpoint trailing slash 처리: `\"http://x:1234/\"` → `.../api/generate` (단일 슬래시). 전용 테스트로 pin. ### Tokio transitivity 솔직화 (리뷰 M1) `reqwest 0.12`의 `blocking` feature는 내부적으로 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` 코멘트를 다음과 같이 정직하게 다시 썼습니다: - \"no top-level tokio dep + no async surface exposed to callers\"가 audit 가능한 invariant. - `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이 명시되어 있어 보류. ## 테스트 - **단위 (7건, `src/ollama.rs`)**: prompt 빌드, options 빌드, finish_reason 매핑, `truncate_body` 경계 (under_cap / over_cap_marker / multibyte_chars_not_bytes), 404 + model_id heuristic. - **construction (3건)**: `ModelRef` 구조, `context_tokens` 통과, lazy-connect (port 1 가리키게 했을 때 `new()`가 성공해야 함을 증명). - **streaming (13건, wiremock 기반)**: 2 chunk + Done, multibyte chars within a line round-trip (이름을 정직하게 `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 반영). - **integration (1건 `#[ignore]`)**: 실제 Ollama on `localhost:11434`. opt-in via `cargo test -p kb-llm-local -- --ignored` (`ollama serve` + `ollama pull` 필요). 워크스페이스 default 288 passed / 25 ignored / 0 failed. `cargo clippy --workspace --all-targets -- -D warnings` clean. ## 의존성 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 (`tokio` direct, `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 + `wiremock` workspace dep) - `Cargo.lock` ## Out of scope - llama.cpp / candle adapter (P+). - Ollama embed endpoint (`/api/embed`)을 사용한 embedder — P+에서 `kb-embed-local` 또는 별도 크레이트. - Cancellation / abort tokens (P+). - Connection pool tuning — single-user CLI에는 reqwest::blocking 기본값 충분. ## 후속 작업 후보 - 리뷰의 N1 (temperature 시맨틱 명문화) — `kb-core::GenerateRequest::temperature` doc-comment에 \"req가 항상 effective intent\" 명시. - 리뷰의 N6 (determinism 테스트가 wiremock-driven이라 tautology) — wiremock의 `Request::body` 캡쳐로 actual sent body의 `temperature: 0.0` + `seed`을 검증하는 강화. 현재 통합 테스트가 진정한 determinism gate. - ureq 전환 검토 — tokio dep 그래프에서 완전히 빼고 싶으면. design §7.2, §6.4, §0 Q5, §10 / report §11.2 참고.
altair823 added 1 commit 2026-05-01 14:29:36 +00:00
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>
claude-reviewer-01 reviewed 2026-05-01 14:30:24 +00:00
claude-reviewer-01 left a comment
Member

P4-2 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.

spec compliance + code quality 양쪽 리뷰 결과 BLOCKER 0건. 핵심 발견:

  • spec compliance: PASS, AMBIGUOUS 2건 (tokio transitivity, UTF-8 chunk wording).
  • code quality: 3 MUST-FIX (tokio comment overreach, non-NDJSON 200 body 매핑, error body truncate) + 7 NIT.

3개 MUST-FIX 모두 + 5개 NIT (trailing-slash test, 404 heuristic 강화, OllamaStream timeout doc, UTF-8 test 이름 정직화, Stream/Malformed 의미 분리) 반영했습니다.

핵심 포인트:

  • OllamaStreamBufReader<Response>을 소유하고 라인 단위로 점진적으로 yield — 진짜 streaming, collect-to-Vec 없음.
  • 200 + non-NDJSON 첫 라인 (transparent proxy HTML 등) → LlmError::Stream (Malformed 아님). has_emitted flag로 첫 라인 vs 중간 라인 parse 실패 구분.
  • 모든 오류 본문 chars-based 512자 truncate — multibyte safe.
  • 404 heuristic이 (영어 "model" + "not found") OR (body가 model_id echo) 두 axis. Ollama 메시지 localization에 강건.
  • tokio transitivity를 정직하게 인정 — "reqwest 0.12 blocking이 private current-thread 런타임을 wrap" 명시. audit 가능한 invariant은 "no top-level tokio dep + no async surface in src"로 재정의.

워크스페이스 default 288 passed / 25 ignored / 0 failed. clippy clean. 통합 테스트 1건 (#[ignore])은 실제 Ollama 필요.

inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다. 다음은 P4-3 rag-pipeline.

P4-2 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only. spec compliance + code quality 양쪽 리뷰 결과 BLOCKER 0건. 핵심 발견: - spec compliance: PASS, AMBIGUOUS 2건 (tokio transitivity, UTF-8 chunk wording). - code quality: 3 MUST-FIX (tokio comment overreach, non-NDJSON 200 body 매핑, error body truncate) + 7 NIT. 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 없음. - 200 + non-NDJSON 첫 라인 (transparent proxy HTML 등) → `LlmError::Stream` (Malformed 아님). `has_emitted` flag로 첫 라인 vs 중간 라인 parse 실패 구분. - 모든 오류 본문 chars-based 512자 truncate — multibyte safe. - 404 heuristic이 (영어 \"model\" + \"not found\") **OR** (body가 model_id echo) 두 axis. Ollama 메시지 localization에 강건. - tokio transitivity를 정직하게 인정 — \"reqwest 0.12 blocking이 private current-thread 런타임을 wrap\" 명시. audit 가능한 invariant은 \"no top-level tokio dep + no async surface in src\"로 재정의. 워크스페이스 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이 그래프에 보이지?" 질문하면 코멘트가 답을 줍니다.

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),

Stream vs Malformed의 의미 분리가 doc-comment에 명문화. Stream = HTTP-level / server-shape 오류 (truncated to 512 chars), Malformed = mid-stream JSON parse 실패 (이미 valid 라인 받은 후). 각 variant가 어떤 caller action을 유도해야 하는지가 분명해서 caller가 두 variant를 의미 있게 분기 처리할 수 있습니다.

`Stream` vs `Malformed`의 의미 분리가 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`)

OllamaStream doc에 "no inherent stop condition; only REQUEST_TIMEOUT (300s) breaks the hang" 명시. iterator의 lifecycle을 미래 reader가 즉시 이해할 수 있고, "왜 cancellation이 없냐?"는 의문을 미리 차단. 더 짧은 cancellation이 필요하면 OllamaLanguageModel::new의 client timeout을 줄이라는 가이드까지.

`OllamaStream` doc에 "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_emitted flag로 첫 라인 vs 중간 라인 parse 실패를 구분해서 LlmError 타입을 갈라놓은 결정이 정확합니다. 200 + non-NDJSON (transparent proxy HTML 등)은 "server didn't send NDJSON" 시그널이므로 Stream, 이미 valid 라인을 받은 후 corruption은 Malformed. 두 시나리오의 진단 메시지가 다른 행동을 유도해야 하기에 (proxy 설정 vs server 버그) 단일 variant로 묶지 않은 게 옳습니다.

`has_emitted` flag로 첫 라인 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 — 정당.

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.

`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을 안 테스트하지?"의 혼란 차단.

테스트 이름을 `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을 안 테스트하지?"의 혼란 차단.
altair823 merged commit 5db4aa6e1e into main 2026-05-01 14:32:35 +00:00
altair823 deleted branch feat/p4-2-ollama-adapter 2026-05-01 14:32:36 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#22