옵시디언 AI 플러그인 자동 요약, KeyError와 model not found 해결
플러그인 표시 이름을 외워 두었다가 설정에 넣거나, 다른 컴퓨터에서 쓰던 모델 이름을 그대로 옮겨 오면 옵시디언 노트 요약은 연결 단계에서부터 막힐 수 있습니다. 목록의 id와 이 서버에 설치된 모델 이름을 따로 확인하는 데서 테스트를 시작한 이유입니다. 업무 흐름에 넣을 수 있는지는 Debian GNU/Linux 13에서 공식 플러그인 목록 조회와 로컬 Ollama 호출이 실제로 이어지는지로 판단했고, 그 과정을 세 스크립트로 실행해 기록했습니다.
기억하던 철자로 바로 호출하자 목록 조회에서는 KeyError가, 생성 요청에서는 HTTP 404가 돌아왔습니다. 둘 다 이름 문제이긴 한데 들여다볼 곳이 다릅니다. 플러그인은 community-plugins.json의 id를 찾아야 하고, 모델은 그 컴퓨터의 /api/tags 응답에서 찾아야 합니다.
유료 API 키 없이 공식 목록 파일과 로컬 HTTP API만 씁니다. 실패를 먼저 재현하고, 실제 목록과 모델 태그를 읽어 샘플 노트 끝에 생성 응답을 붙입니다. 그다음 stream true 응답을 한 객체로 읽을 때 나는 오류와 /api/chat 결과를 견줘 보고, 파일 저장 자동화에서 어떤 응답을 성공으로 칠지 기준을 세웁니다.
text-generator 키도, 요청한 모델도 없을 때 나는 두 오류
실행 환경은 해결 스크립트 앞부분이 찍은 값입니다. OS, 파이썬, Ollama, 실행일은 아래 표와 같고, 같은 문자열이 해결 절차 출력의 OS, Python, ollama version, DATE 줄에 남아 있습니다.
| 항목 | 값 | 출력 줄 |
|---|---|---|
| OS | Debian GNU/Linux 13 (trixie) | OS= |
| Python | Python 3.13.5 | python3 –version |
| Ollama | ollama version is 0.40.3 | ollama –version |
| 실행일 | 2026-10-14 KST | DATE= |
재현은 두 단계로 나뉩니다. 공식 목록을 받아 id가 text-generator인 행의 repo를 읽고, 이어서 본문이 llama3인 요청을 로컬 /api/generate로 보냅니다. 목록 파일은 community-plugins.json이고, 플러그인 도움말 페이지는 Community plugins입니다. 두 주소 모두 트러블슈팅 스크립트의 HEAD 결과에서 200이었습니다.
코드는 목록 다운로드, 플러그인 조회, 모델 호출을 차례로 돌려 어디서 실패하는지 갈라 봅니다. text-generator는 목록 딕셔너리의 키로, llama3는 생성 요청의 model 값으로 들어갑니다. curl 출력에 HTTP 상태 코드를 붙여 두면 셸 명령이 끝난 것과 서버가 요청을 처리한 것이 따로 보입니다.
curl -fsSL --max-time 30 -o work/community-plugins.json https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json
cat > work/a_lookup.py <<'PY'
import json
d = json.load(open("work/community-plugins.json", encoding="utf-8"))
by = {x["id"]: x for x in d}
print("전체 플러그인", len(d))
print(by["text-generator"]["repo"])
PY
python3 work/a_lookup.py
cat > work/bad-model.json <<'EOF'
{"model":"llama3","prompt":"한 줄 요약","stream":false}
EOF
curl -sS --max-time 20 -H "Content-Type: application/json" --data @work/bad-model.json -w " http:%{http_code}\n" http://127.0.0.1:11434/api/generate
실제 출력을 보면 전체 플러그인 8886을 읽은 뒤 text-generator 조회가 KeyError로 멈춥니다. 뒤의 모델 요청은 model not found와 HTTP 404를 돌려주는데 curl의 exit는 0입니다. 자동화의 성공 조건을 종료 코드에만 걸어 두면 이 생성 실패가 그대로 통과하니, 본문과 HTTP 상태를 같이 봐야 합니다.
$ curl -fsSL --max-time 30 -o work/community-plugins.json https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json
exit:0
$ cat > work/a_lookup.py <<'PY'
$ python3 work/a_lookup.py
전체 플러그인 8886
Traceback (most recent call last):
File "/home/user/obsidian/run/work/a_lookup.py", line 6, in <module>
print(by["text-generator"]["repo"])
~~^^^^^^^^^^^^^^^^^^
KeyError: 'text-generator'
exit:1
$ curl -sS --max-time 20 -H "Content-Type: application/json" --data @work/bad-model.json -w " http:%{http_code}\n" http://127.0.0.1:11434/api/generate
{"error":"model 'llama3' not found"} http:404
exit:0

캡처의 a_lookup.py 경로는 실제 작업 폴더를 기준으로 찍혔고, 본문은 같은 트레이스백을 /home/user/obsidian 아래 경로로 바꿔 실었습니다.
목록 조회 오류와 모델 요청 실패가 한 실행 흐름 안에 같이 찍혔습니다. 목록 파일을 내려받는 단계는 성공했고, id를 찾는 단계에서 exit 1이 나왔습니다. 모델 호출의 HTTP 404까지 함께 보면 네트워크 재시도보다 식별자를 먼저 확인할 일이라는 것이 드러납니다.
첫 명령은 목록을 work/community-plugins.json에 저장하고 종료 코드 0으로 끝납니다. a_lookup.py가 id를 키로 딕셔너리를 만든 뒤 text-generator를 읽으면, 출력은 KeyError: ‘text-generator’이고 그 명령의 exit는 1입니다. 목록 길이는 같은 출력의 전체 플러그인 줄에서 볼 수 있습니다.
마지막 curl은 llama3를 stream false로 생성 엔드포인트에 넣습니다. 본문은 {“error”:”model ‘llama3’ not found”}, 같은 줄의 http는 404입니다. 그런데 curl 자체의 exit는 0입니다. curl이 정상 종료했다는 것과 서버가 요청을 받아들였다는 것은 서로 다른 신호입니다.
표시 이름과 id, 기억한 모델 이름과 설치된 태그
커뮤니티 목록은 id, name, author, repo, description을 가진 객체의 배열이고, 해결 스크립트가 이 필드를 읽어 PLUGIN 줄로 찍습니다. 딕셔너리 키는 name이 아니라 id입니다. text-generator라는 키는 없고, 표시 이름이 Text Generator인 항목의 id는 obsidian-textgenerator-plugin입니다. 짧게 줄인 철자를 키로 쓰면 KeyError가 나는 이유입니다.
Text Generator 행의 description은 “Generate text content using GPT-3 (OpenAI).”입니다. 로컬 Ollama를 말하는 설명은 다른 id에 있습니다. ollama 행은 “Enable the usage of Ollama within your notes.”, local-gpt 행은 “Local Ollama and OpenAI-like GPT’s assistance for maximum privacy and offline access.”입니다. 로컬 요약에 참고할 플러그인 id는 ollama와 local-gpt이고, Text Generator는 OpenAI를 설명에 내건 항목이라는 이야기입니다.
Ollama의 생성 API는 model 문자열을 필수로 받습니다. 설명은 POST /api/generate에, 태그 목록은 GET /api/tags에 있습니다. 이 서버의 /api/tags가 돌려준 첫 이름은 qwen2.5-0.5b-local:latest입니다. llama3는 그 응답에 없으니 서버가 404와 model not found를 돌려준 것입니다.
목록의 id와 설치된 모델 태그로 노트 끝에 요약 붙이기
추측한 철자를 키로 쓰지 않으면 됩니다. 목록을 다시 받아 실제로 있는 id 다섯 개의 name, author, repo, description을 찍고, text-generator가 없다는 것도 함께 찍습니다. 모델 이름은 /api/tags의 첫 항목에서 읽고, stream을 false로 둔 /api/generate 응답을 샘플 노트 끝에 붙입니다.
샘플 노트는 스크립트가 work/note.md에 만드는 짧은 메모입니다. 프롬프트에는 한국어 한 문장으로 요약하되 노트에 없는 사실은 넣지 말라고 적었습니다. options에는 temperature 0과 num_predict 80을 넣습니다. 생성 문서는 options를 생성 동작을 조절하는 객체로 소개합니다.
해결 스크립트는 목록의 실제 id를 찍고, /api/tags에서 읽은 모델 이름을 생성 요청에 넘깁니다. 파일에 붙이기 전에 done이 True인지, response가 비어 있지 않은지 검사합니다. stream false를 굳이 적은 것은 본문을 JSON 객체 하나로 읽는 이 저장 흐름에 맞추려는 것입니다. temperature 0과 num_predict 80은 이 요청의 생성 옵션일 뿐이고, 요약 품질은 마지막 파일 내용을 보고 다시 판단합니다.
. /etc/os-release && printf "OS=%s\n" "$PRETTY_NAME"
python3 --version
ollama --version
date "+DATE=%Y-%m-%d %Z"
curl -fsSL --max-time 30 -o work/community-plugins.json https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json
cat > work/b_summary.py <<'PY'
import json
import urllib.request
from pathlib import Path
d = json.load(open("work/community-plugins.json", encoding="utf-8"))
by = {x["id"]: x for x in d}
print("전체 플러그인", len(d))
print("MISSING", "text-generator", "text-generator" not in by)
wanted = [
"obsidian-textgenerator-plugin",
"ollama",
"local-gpt",
"copilot",
"smart-connections",
]
for i in wanted:
x = by[i]
desc = " ".join(x["description"].split())
print("PLUGIN\t%s\t%s\t%s\t%s\t%s" % (x["id"], x["name"], x["author"], x["repo"], desc))
with urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=10) as r:
print("tags_http", r.status)
tags = json.load(r)
model = tags["models"][0]["name"]
print("MODEL", model)
details = tags["models"][0].get("details") or {}
for key in ("family", "parameter_size", "quantization_level", "parent_model"):
if key in details:
print("DETAIL\t%s\t%s" % (key, details[key]))
note = """---
tags: [회의]
---
# 플러그인 목록 점검
- 커뮤니티 목록에서 AI 플러그인 id를 확인한다
- 로컬 Ollama로 이 노트 한 건을 요약해 파일 끝에 붙인다
- 유료 API 키는 쓰지 않는다
"""
path = Path("work/note.md")
path.write_text(note, encoding="utf-8")
prompt = (
"다음 마크다운 노트를 한국어 한 문장으로 요약하세요. "
"노트에 없는 사실은 넣지 마세요.\n\n" + note
)
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0, "num_predict": 80},
}
req = urllib.request.Request(
"http://127.0.0.1:11434/api/generate",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=50) as r:
print("generate_http", r.status)
body = json.load(r)
print("done", body.get("done"))
text = (body.get("response") or "").strip()
print("response_chars", len(text))
if body.get("done") is not True or not text:
raise SystemExit("generate did not return a finished response")
prev = path.read_text(encoding="utf-8")
if not prev.endswith("\n"):
prev += "\n"
path.write_text(prev + "\n## 요약 (로컬 모델)\n\n" + text + "\n", encoding="utf-8")
print("SUMMARY_OK")
print("--- note tail ---")
tail = path.read_text(encoding="utf-8").splitlines()[-8:]
print("\n".join(tail))
PY
python3 work/b_summary.py
출력에서 tags_http와 generate_http는 둘 다 200이고 response_chars는 81입니다. done True와 SUMMARY_OK가 이어지니 응답 검사와 파일 추가 단계까지 통과했습니다. 그런데 note tail에는 한 문장 요약 대신 불릿이 남아 있습니다. API가 요청을 처리한 것과 원하는 요약 형식이 나온 것은 별개로 봐야 합니다.
$ . /etc/os-release && printf "OS=%s\n" "$PRETTY_NAME"
OS=Debian GNU/Linux 13 (trixie)
$ python3 --version
Python 3.13.5
$ ollama --version
ollama version is 0.40.3
$ date "+DATE=%Y-%m-%d %Z"
DATE=2026-10-14 KST
$ curl -fsSL --max-time 30 -o work/community-plugins.json https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json
$ python3 work/b_summary.py
전체 플러그인 8886
MISSING text-generator True
PLUGIN obsidian-textgenerator-plugin Text Generator nhaouari nhaouari/obsidian-textgenerator-plugin Generate text content using GPT-3 (OpenAI).
PLUGIN ollama Ollama hinterdupfinger hinterdupfinger/obsidian-ollama Enable the usage of Ollama within your notes.
PLUGIN local-gpt Local GPT pfrankov pfrankov/obsidian-local-gpt Local Ollama and OpenAI-like GPT's assistance for maximum privacy and offline access.
PLUGIN copilot Copilot Logan Yang logancyang/obsidian-copilot Run AI agents such as Claude Code, Codex, and OpenCode inside your vault. Turn your second brain into a smart assistant that gets knowledge work done.
PLUGIN smart-connections Smart Connections 🌴 Brian brianpetro/obsidian-smart-connections Find related notes and excerpts while writing. Your AI link building copilot displays relevant content in graph + list view. A local embedding model powers semantic search. Zero setup. No API key.
tags_http 200
MODEL qwen2.5-0.5b-local:latest
DETAIL family qwen2
DETAIL parameter_size 630.17M
DETAIL quantization_level Q4_K_M
DETAIL parent_model qwen2.5-0.5b-instruct-q4_k_m.gguf
generate_http 200
done True
response_chars 81
SUMMARY_OK
--- note tail ---
- 로컬 Ollama로 이 노트 한 건을 요약해 파일 끝에 붙인다
- 유료 API 키는 쓰지 않는다
## 요약 (로컬 모델)
- 커뮤니티에서 AI 플러그인 id를 확인한다
- 로컬 Ollama로 이 노트 한 건을 요약해 파일 끝에 붙인다
- 유료 API 키는 쓰지 않는다

설치된 모델을 읽어 생성 요청을 끝내고 노트에 응답을 붙인 결과가 이 화면입니다. generate_http 200 뒤의 SUMMARY_OK는 파일 저장까지 이어졌다는 표시입니다. 운영 자동화라면 이 표식만 남기고 끝낼 것이 아니라, note tail처럼 저장된 내용을 눈으로 확인할 수 있는 흔적까지 남겨 두는 게 낫습니다.
출력의 MISSING text-generator는 True입니다. PLUGIN 줄은 탭으로 나뉜 id, name, author, repo, description이고, 이번에 찍힌 다섯 항목은 아래 표와 같습니다.
| id | name | author | repo |
|---|---|---|---|
| obsidian-textgenerator-plugin | Text Generator | nhaouari | nhaouari/obsidian-textgenerator-plugin |
| ollama | Ollama | hinterdupfinger | hinterdupfinger/obsidian-ollama |
| local-gpt | Local GPT | pfrankov | pfrankov/obsidian-local-gpt |
| copilot | Copilot | Logan Yang | logancyang/obsidian-copilot |
| smart-connections | Smart Connections | 🌴 Brian | brianpetro/obsidian-smart-connections |
MODEL 줄의 이름은 qwen2.5-0.5b-local:latest입니다. DETAIL 줄의 family는 qwen2, parameter_size는 630.17M, quantization_level은 Q4_K_M, parent_model은 qwen2.5-0.5b-instruct-q4_k_m.gguf입니다. 같은 계열 GGUF의 공개 카드는 Qwen2.5-0.5B-Instruct-GGUF입니다. 호출에 쓴 이름은 이미 서버에 있던 태그입니다.
tags_http와 generate_http는 200이고 done은 True입니다. SUMMARY_OK 다음 note tail에 제목 “요약 (로컬 모델)”과 그 아래 응답이 붙었습니다. 다만 이번 generate의 tail에 붙은 것은 한 문장이 아니라 불릿 목록입니다.
스크립트는 models의 첫 항목만 사용합니다. 서버에 모델이 여러 개이면 목록의 첫 이름이 선택됩니다. 이번 출력에는 MODEL이 한 줄 있습니다.
줄인 철자 대신 목록과 태그를 읽게 한 효과
공식 목록의 id와 repo를 그대로 찍으니 text-generator처럼 줄인 철자를 키로 쓸 일이 없고, 모델 이름도 /api/tags에서 읽으니 설치되지 않은 llama3가 본문에 들어갈 일이 없습니다. stream을 false로 두면 응답이 JSON 객체 하나로 와서 그 안의 response를 파일 끝에 바로 붙일 수 있습니다. 대신 목록 raw 주소와 로컬 11434 포트가 둘 다 응답해야 돌아가는 구조입니다. 작은 로컬 모델이 쓴 문장은 노트 표현과 느슨하게 달라질 수 있으니, 붙인 내용은 출력의 note tail로 한 번 더 읽어야 합니다.
stream true, 짧은 이름, chat으로 바꿔 보면 갈리는 결과
생성 호출은 200인데 파이썬 json.loads에서 오류가 나는 경우가 있습니다. Generate API는 stream의 기본값을 true로 적고, true이면 부분 응답의 스트림을 돌려준다고 설명합니다. ollama 저장소의 API 문서에도 stream이 false일 때 단일 응답 객체가 된다고 나옵니다. 저장소 루트는 ollama/ollama, API 입구는 Introduction입니다.
트러블슈팅 스크립트는 같은 서버에서 이어서 돌립니다. stream true로 받은 본문 전체를 json.loads에 넣어 보고, 콜론 앞의 짧은 이름으로 stream false 호출을 한 뒤, POST /api/chat에 같은 노트를 넘깁니다. 마지막에 Origin 헤더와 출처 주소의 HEAD 상태를 찍습니다. 응답을 한 객체로 받고 싶다면 답은 stream false입니다.
아래 코드는 응답을 읽는 방식과 요청 형식을 바꿔 가며 같은 서버를 찔러 봅니다. stream true 본문을 통째로 json.loads에 넣는 호출과 stream false로 처리하는 호출을 나란히 두고, 짧은 모델 이름, messages를 쓰는 /api/chat, Origin 헤더 유무도 각각 찍습니다. 원인이 다른 문제들이 하나의 연결 오류로 뭉뚱그려지지 않게 하려는 배치입니다.
cat > work/c_stream.py <<'PY'
import json
import urllib.request
with urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=10) as r:
tags = json.load(r)
model = tags["models"][0]["name"]
print("MODEL", model)
payload = {
"model": model,
"prompt": "한 단어만 답하세요: 확인",
"stream": True,
"options": {"num_predict": 8, "temperature": 0},
}
req = urllib.request.Request(
"http://127.0.0.1:11434/api/generate",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=40) as r:
raw = r.read().decode()
print("stream_lines", len(raw.splitlines()))
json.loads(raw)
PY
python3 work/c_stream.py
cat > work/c_chat.py <<'PY'
import json
import urllib.error
import urllib.request
from pathlib import Path
with urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=10) as r:
tags = json.load(r)
model = tags["models"][0]["name"]
print("MODEL", model)
short = model.split(":", 1)[0]
print("SHORT_NAME", short)
def generate(name, predict):
payload = {
"model": name,
"prompt": "한 단어만 답하세요: 확인",
"stream": False,
"options": {"temperature": 0, "num_predict": predict},
}
req = urllib.request.Request(
"http://127.0.0.1:11434/api/generate",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=40) as r:
body = json.load(r)
print("short_http", r.status)
print("short_done", body.get("done"))
print("short_response", (body.get("response") or "").strip().splitlines()[:1])
except urllib.error.HTTPError as e:
err = e.read().decode()
print("short_http", e.code)
print("short_error", err.strip())
generate(short, 1)
note = """---
tags: [회의]
---
# 플러그인 목록 점검
- 커뮤니티 목록에서 AI 플러그인 id를 확인한다
- 로컬 Ollama로 이 노트 한 건을 요약해 파일 끝에 붙인다
- 유료 API 키는 쓰지 않는다
"""
path = Path("work/note-chat.md")
path.write_text(note, encoding="utf-8")
prompt = (
"다음 마크다운 노트를 한국어 한 문장으로 요약하세요. "
"노트에 없는 사실은 넣지 마세요.\n\n" + note
)
payload = {
"model": model,
"stream": False,
"messages": [{"role": "user", "content": prompt}],
"options": {"temperature": 0, "num_predict": 60},
}
req = urllib.request.Request(
"http://127.0.0.1:11434/api/chat",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=50) as r:
print("chat_http", r.status)
body = json.load(r)
msg = body.get("message") or {}
text = (msg.get("content") or "").strip()
print("chat_role", msg.get("role"))
print("chat_done", body.get("done"))
print("response_chars", len(text))
if body.get("done") is not True or not text:
raise SystemExit("chat did not return a finished response")
prev = path.read_text(encoding="utf-8")
if not prev.endswith("\n"):
prev += "\n"
path.write_text(prev + "\n## 요약 (로컬 모델, chat)\n\n" + text + "\n", encoding="utf-8")
print("CHAT_OK")
print("--- note tail ---")
print("\n".join(path.read_text(encoding="utf-8").splitlines()[-6:]))
PY
python3 work/c_chat.py
curl -sS --max-time 10 -o /dev/null -w "origin_none %{http_code}\n" http://127.0.0.1:11434/api/tags
curl -sS --max-time 10 -o /dev/null -w "origin_obsidian %{http_code}\n" -H "Origin: app://obsidian.md" http://127.0.0.1:11434/api/tags
출력의 stream_lines는 2이고, 본문 전체를 읽은 파서는 JSONDecodeError: Extra data로 끝납니다. 반면 짧은 모델 이름은 short_http 200을 돌려주고, /api/chat도 chat_http 200과 CHAT_OK까지 갑니다. Origin 비교는 양쪽 다 200이고, 뒤의 HEAD 결과도 문서 주소에 대한 200 응답입니다. 이 로그로 보면 파싱 실패는 서버 접속 문제가 아니라 응답 형식 문제입니다.
$ python3 work/c_stream.py
MODEL qwen2.5-0.5b-local:latest
stream_lines 2
Traceback (most recent call last):
File "/home/user/obsidian/run/work/c_stream.py", line 22, in <module>
json.loads(raw)
~~~~~~~~~~^^^^^
File "/usr/lib/python3.13/json/__init__.py", line 346, in loads
return _default_decoder.decode(s)
~~~~~~~~~~~~~~~~~~~~~~~^^^
File "/usr/lib/python3.13/json/decoder.py", line 348, in decode
raise JSONDecodeError("Extra data", s, end)
json.decoder.JSONDecodeError: Extra data: line 2 column 1 (char 112)
exit:1
$ python3 work/c_chat.py
MODEL qwen2.5-0.5b-local:latest
SHORT_NAME qwen2.5-0.5b-local
short_http 200
short_done True
short_response ['확인']
chat_http 200
chat_role assistant
chat_done True
response_chars 68
CHAT_OK
--- note tail ---
- 유료 API 키는 쓰지 않는다
## 요약 (로컬 모델, chat)
- 플러그인 목록을 확인하고, 필요한 플러그인을 선택한다
- 로컬 Ollama로 이 노트 한 건을 요약해 파일 끝에 붙인다
$ curl -sS --max-time 10 -o /dev/null -w "origin_none %{http_code}\n" http://127.0.0.1:11434/api/tags
origin_none 200
exit:0
$ curl -sS --max-time 10 -o /dev/null -w "origin_obsidian %{http_code}\n" -H "Origin: app://obsidian.md" http://127.0.0.1:11434/api/tags
origin_obsidian 200
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json\n' 'https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json'
200 https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://obsidian.md/help/community-plugins\n' 'https://obsidian.md/help/community-plugins'
200 https://obsidian.md/help/community-plugins
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://docs.ollama.com/api/introduction\n' 'https://docs.ollama.com/api/introduction'
200 https://docs.ollama.com/api/introduction
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://docs.ollama.com/api/generate\n' 'https://docs.ollama.com/api/generate'
200 https://docs.ollama.com/api/generate
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://docs.ollama.com/api/chat\n' 'https://docs.ollama.com/api/chat'
200 https://docs.ollama.com/api/chat
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://docs.ollama.com/api/tags\n' 'https://docs.ollama.com/api/tags'
200 https://docs.ollama.com/api/tags
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://github.com/ollama/ollama\n' 'https://github.com/ollama/ollama'
200 https://github.com/ollama/ollama
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://github.com/ollama/ollama/blob/main/docs/api.md\n' 'https://github.com/ollama/ollama/blob/main/docs/api.md'
200 https://github.com/ollama/ollama/blob/main/docs/api.md
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://github.com/nhaouari/obsidian-textgenerator-plugin\n' 'https://github.com/nhaouari/obsidian-textgenerator-plugin'
200 https://github.com/nhaouari/obsidian-textgenerator-plugin
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://github.com/hinterdupfinger/obsidian-ollama\n' 'https://github.com/hinterdupfinger/obsidian-ollama'
200 https://github.com/hinterdupfinger/obsidian-ollama
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://github.com/pfrankov/obsidian-local-gpt\n' 'https://github.com/pfrankov/obsidian-local-gpt'
200 https://github.com/pfrankov/obsidian-local-gpt
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://github.com/logancyang/obsidian-copilot\n' 'https://github.com/logancyang/obsidian-copilot'
200 https://github.com/logancyang/obsidian-copilot
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://github.com/brianpetro/obsidian-smart-connections\n' 'https://github.com/brianpetro/obsidian-smart-connections'
200 https://github.com/brianpetro/obsidian-smart-connections
exit:0
$ curl -sI --max-time 15 -o /dev/null -w '%{http_code} https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF\n' 'https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF'
200 https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF
exit:0

스트리밍 파싱 실패, 대체 호출 결과, 참조 주소의 상태 확인이 한 화면에 묶여 있습니다. 문서 URL의 200은 주소에 닿을 수 있다는 뜻일 뿐이고, 생성 결과가 어떤지는 chat_done과 note tail에서 읽어야 합니다. HTTP 상태만 모으는 점검과 실제 요약 결과를 보는 검사를 나눠 두면 장애가 어느 쪽인지 금방 갈립니다.
c_stream.py는 /api/tags의 첫 모델로 stream true 요청을 보내고 받은 문자열을 json.loads합니다. 출력은 json.decoder.JSONDecodeError, 메시지는 Extra data, 명령 exit는 1입니다. 부분 JSON이 여러 개 이어져 있어서 한 객체가 아니기 때문입니다.
짧은 이름은 MODEL 값에서 콜론 뒤를 뗀 qwen2.5-0.5b-local입니다. 이 이름으로 num_predict 1인 stream false 호출을 하면 short_http는 200, short_done은 True입니다. 그러니 앞의 404는 태그가 빠져서 난 것이 아니라 llama3라는 이름이 서버에 없어서 난 것입니다. 물론 이 결과는 이번 서버, 이 모델에서 본 것입니다.
/api/chat은 messages에 user 한 건을 넣고 stream을 false로 둡니다. chat_http는 200, chat_role은 assistant, chat_done은 True이고 마지막에 CHAT_OK가 찍힙니다. 다만 note-chat.md의 tail은 한 문장 요약이 아니라 샘플 노트의 불릿을 다시 옮겨 적은 것이었습니다. 어느 경로든 문장 내용은 그 출력의 note tail을 기준으로 봅니다.
chat과 stream 옵션을 고를 때 걸리는 점
stream을 false로 두면 json.loads 한 번으로 응답을 다룰 수 있습니다. stream true는 이번 실행에서 본문 전체를 json.loads하면 Extra data로 끝났습니다. /api/chat은 messages 배열로 요청을 보낼 수 있다는 점이 편하지만, 이번 실행에서는 불릿을 그대로 다시 적어서 한 문장 요약으로 쓰기는 어려웠습니다.
Origin은 옵시디언 앱을 열지 않고 curl로만 비교했습니다. 헤더가 없을 때 origin_none은 200이고, Origin을 app://obsidian.md로 둔 origin_obsidian도 200입니다. 이 두 줄은 로컬 /api/tags가 그 헤더를 거부하지 않았다는 뜻입니다. 플러그인 화면의 버튼 동작까지 보여 주지는 않습니다.
본문에 넣은 출처 주소는 트러블슈팅 스크립트가 curl -sI로 상태 코드를 찍은 것이고, 그 출력은 모두 200입니다.
버전보다 먼저 확인할 stream 설정
환경 출력의 Ollama 버전은 0.40.3입니다. 하지만 이 기록만으로 버전 사이의 API 변경이나 폐기를 단정할 수는 없습니다. 실제로 드러난 함정은 같은 생성 경로라도 stream 설정에 따라 파서가 받아야 할 본문 형식이 달라진다는 점입니다. 단일 객체를 읽는 저장 작업이라면 요청 설정에 stream false를 적어 두고, 스트리밍으로 가겠다면 부분 응답을 나눠 읽는 처리로 같이 바꿔야 합니다.
API 경로나 플러그인 설정을 손볼 때는 로컬 주소와 모델 이름을 별도 항목으로 관리하기를 권합니다. 헬스체크는 /api/tags 응답과 쓸 모델이 있는지를 보고, 생성 기능 점검은 done과 실제 텍스트를 보도록 나눕니다. k8s probe에 걸 때도 목록 조회가 성공했다는 신호만으로 요약 기능 전체가 준비됐다고 보지 않도록 점검 범위를 정해 둬야 합니다. 이번 출력처럼 HTTP 200 뒤에도 기대와 다른 형식의 텍스트가 나올 수 있으니, 내용 검수는 파일에 반영하는 단계에 남겨 둡니다.
공식 목록의 id와 /api/tags 모델 이름부터 기록하기
표에는 이번 세 스크립트에서 실제로 끝까지 돌아간 선택만 모았습니다. 상태 코드와 성공 표식은 각 출력 블록의 줄 그대로입니다.
| 상황 | 선택 | 실행 결과 |
|---|---|---|
| 플러그인 id text-generator | id를 키로 조회 | KeyError, exit 1 |
| 모델 llama3 | POST /api/generate, stream false | model ‘llama3’ not found, http 404 |
| /api/tags의 첫 모델 | POST /api/generate, stream false | generate_http 200, SUMMARY_OK |
| stream true 본문을 json.loads | POST /api/generate | JSONDecodeError Extra data, exit 1 |
| 태그를 뺀 모델 이름 | POST /api/generate, num_predict 1 | short_http 200 |
| messages 한 건 | POST /api/chat, stream false | chat_http 200, CHAT_OK |
| Origin 없음 | GET /api/tags | origin_none 200 |
| Origin app://obsidian.md | GET /api/tags | origin_obsidian 200 |
개인 노트의 보조 자동화로 시작한다면, 공식 목록에서 확인한 id와 로컬 /api/tags의 모델 이름을 함께 기록해 두는 구성을 권합니다. 이번 서버의 qwen2.5-0.5b-local:latest는 parameter_size 630.17M, quantization_level Q4_K_M으로 나왔고, 파일에 응답을 붙이는 단계까지 동작했습니다. 플러그인을 고를 때도 이름에서 받는 인상보다 description을 먼저 읽고, 로컬 Ollama를 언급한 ollama와 local-gpt부터 용도를 맞춰 보는 쪽이 낫습니다.
노트 파일에 붙이는 경로는 stream false를 명시한 /api/generate 하나로 단순하게 두겠습니다. /api/chat도 성공은 했지만, 이번에는 두 경로 모두 한 문장으로 쓰라는 지시와 달리 불릿을 돌려줬습니다. note tail 출력을 보면 저장 파이프라인이 이어지는지 검증하는 데는 쓸모가 있었고, 응답을 곧바로 완성된 요약으로 취급하기에는 아직 내용 검토가 필요합니다.
운영에서는 목록의 첫 모델을 그대로 집는 부분을 사용 목적에 맞게 모델을 고르는 방식으로 바꾸고, 원본 노트와 생성 텍스트를 구분해서 검토하겠습니다. HTTP 상태, done, 응답이 비었는지를 통과 조건으로 삼고, 형식과 의미는 그와 별개로 확인합니다. 다시 돌릴 때 이미 붙인 요약이 한 번 더 추가되지 않도록 저장 절차도 손봐야 합니다.
실시간 서비스로 넓힌다면 생성 처리를 노트 저장 흐름에서 떼어 내고, 응답을 기다리는 동안 작업 상태를 보여 주는 방식이 맞습니다. 모델을 고르는 기준도 호출 성공이 아니라 실제 노트에서 요구한 형식과 내용이 나오느냐로 잡아야 합니다. 저는 이번에 동작한 로컬 구성을 검토를 거치는 보조 도구로 쓰고, 서비스용 자원 구성은 요청량과 응답 시간을 재 본 뒤에 정하겠습니다.
Copilot의 description에는 볼트 안에서 Claude Code, Codex, OpenCode 같은 에이전트를 실행한다는 문장이 있습니다. Smart Connections의 description에는 “A local embedding model powers semantic search.”라는 문장이 있습니다. 이번 실행에서 확인한 것은 그 id와 description입니다.
ainewsnote에서 작성한 글입니다. 작성과 검증 절차는 콘텐츠 작성 및 검증 원칙에 공개해 두었고, 틀린 내용을 보셨다면 글 주소와 함께 연락처로 보내 주세요. 제휴 링크는 없습니다.






