Skip to content

Commit 23c0ed0

Browse files
committed
Enhance support for Ollama models and update environment configuration
- Updated `.env.example`, `CONTRIBUTING.md`, and `README.md` to include instructions for using Ollama models with optional local API base configuration. - Modified CLI error handling to provide specific messages based on the selected model provider. - Enhanced `has_supported_provider_key` function to accommodate Ollama, allowing it to run without cloud API keys. - Added new test cases to verify the correct behavior of provider key requirements and model-specific configurations.
1 parent 8542fd1 commit 23c0ed0

8 files changed

Lines changed: 150 additions & 11 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ SECNODE_LLM=openai/gpt-4o
44
# Provider API keys
55
OPENAI_API_KEY=
66
ANTHROPIC_API_KEY=
7+
8+
# Optional local endpoint when using ollama/* models
9+
OLLAMA_API_BASE=http://localhost:11434

CONTRIBUTING.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,14 @@ Thank you for your interest in contributing to SecNode API! This guide will help
4141
# Or Anthropic
4242
export SECNODE_LLM="anthropic/claude-3-5-sonnet-20241022"
4343
export ANTHROPIC_API_KEY="your-anthropic-key"
44+
45+
# Or Ollama
46+
export SECNODE_LLM="ollama/llama3.1"
47+
export OLLAMA_API_BASE="http://localhost:11434" # optional
4448
```
45-
Provider keys are model-specific. See [LiteLLM provider docs](https://docs.litellm.ai/docs/providers) for other providers.
49+
Provider credentials are model-specific. `openai/*` requires `OPENAI_API_KEY`,
50+
`anthropic/*` requires `ANTHROPIC_API_KEY`, and `ollama/*` can run locally without
51+
cloud API keys. See [LiteLLM provider docs](https://docs.litellm.ai/docs/providers) for other providers.
4652

4753
5. **Run SecNode in development mode**
4854
```bash

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,16 @@ export SECNODE_LLM="anthropic/claude-3-5-sonnet-20241022"
7777
export ANTHROPIC_API_KEY="your-anthropic-key"
7878
```
7979

80-
Provider keys are model-specific. See [LiteLLM providers](https://docs.litellm.ai/docs/providers).
80+
Or with Ollama:
81+
82+
```bash
83+
export SECNODE_LLM="ollama/llama3.1"
84+
export OLLAMA_API_BASE="http://localhost:11434" # optional, defaults to localhost if omitted
85+
```
86+
87+
Provider credentials are model-specific. `openai/*` requires `OPENAI_API_KEY`, `anthropic/*`
88+
requires `ANTHROPIC_API_KEY`, and `ollama/*` can run locally without cloud API keys.
89+
See [LiteLLM providers](https://docs.litellm.ai/docs/providers).
8190

8291
## Quick Start
8392

src/secnodeapi/ai/llm_client.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,30 @@
1616
async def call_llm(system_prompt: str, user_prompt: str, temperature: float = 0.2) -> str:
1717
"""Call LiteLLM with retries and basic rate-limit backoff."""
1818
model = os.getenv("SECNODE_LLM", DEFAULT_MODEL)
19+
provider = model.split("/", 1)[0].lower() if "/" in model else model.lower()
1920
max_retries = 5
2021
base_delay = 2.0
2122

2223
for attempt in range(max_retries):
2324
try:
24-
response = await acompletion(
25-
model=model,
26-
messages=[
25+
completion_kwargs = {
26+
"model": model,
27+
"messages": [
2728
{"role": "system", "content": system_prompt},
2829
{"role": "user", "content": user_prompt},
2930
],
30-
temperature=temperature,
31-
response_format={"type": "json_object"},
31+
"temperature": temperature,
32+
}
33+
if provider != "ollama":
34+
completion_kwargs["response_format"] = {"type": "json_object"}
35+
36+
if provider == "ollama":
37+
api_base = os.getenv("OLLAMA_API_BASE", "").strip()
38+
if api_base:
39+
completion_kwargs["api_base"] = api_base
40+
41+
response = await acompletion(
42+
**completion_kwargs
3243
)
3344
return response.choices[0].message.content
3445
except Exception as e:

src/secnodeapi/cli.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import argparse
44
import asyncio
55
import json
6+
import os
67
import warnings
78
from pathlib import Path
89
from typing import List
@@ -153,9 +154,27 @@ def _write_dry_run_output(tests: list, dry_run_output: str) -> None:
153154
def _require_provider_key(schema_only: bool) -> None:
154155
if schema_only or has_supported_provider_key():
155156
return
157+
model = os.getenv("SECNODE_LLM", "openai/gpt-4o").strip().lower()
158+
provider = model.split("/", 1)[0] if "/" in model else model
159+
160+
if provider == "openai":
161+
message = "Provider API key required. Set OPENAI_API_KEY for SECNODE_LLM openai/* models."
162+
elif provider == "anthropic":
163+
message = (
164+
"Provider API key required. Set ANTHROPIC_API_KEY for SECNODE_LLM anthropic/* models."
165+
)
166+
elif provider == "ollama":
167+
message = (
168+
"OLLAMA provider selected but unavailable configuration detected. "
169+
"Set SECNODE_LLM=ollama/<model> and optionally OLLAMA_API_BASE."
170+
)
171+
else:
172+
message = (
173+
"Provider API key required. Set provider-specific credentials to match SECNODE_LLM."
174+
)
175+
156176
logger.error(
157-
"Provider API key required. Set OPENAI_API_KEY or ANTHROPIC_API_KEY "
158-
"to match SECNODE_LLM."
177+
message
159178
)
160179
raise SystemExit(1)
161180

src/secnodeapi/config.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""
22
Runtime configuration for SecNode CLI execution.
33
"""
4-
from dataclasses import dataclass
54
import os
5+
from dataclasses import dataclass
66

77

88
@dataclass(frozen=True)
@@ -17,5 +17,15 @@ def verify_ssl(self) -> bool:
1717

1818

1919
def has_supported_provider_key() -> bool:
20-
"""Return True when a supported provider API key is configured."""
20+
"""Return True when provider requirements are met for the selected model."""
21+
model = os.getenv("SECNODE_LLM", "openai/gpt-4o").strip().lower()
22+
provider = model.split("/", 1)[0] if "/" in model else model
23+
24+
if provider == "openai":
25+
return bool(os.getenv("OPENAI_API_KEY"))
26+
if provider == "anthropic":
27+
return bool(os.getenv("ANTHROPIC_API_KEY"))
28+
if provider == "ollama":
29+
return True
30+
2131
return bool(os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY"))

tests/test_cli_and_config.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,36 @@ def test_runtime_config_verify_ssl_defaults() -> None:
1515
def test_has_supported_provider_key(monkeypatch: pytest.MonkeyPatch) -> None:
1616
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
1717
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
18+
monkeypatch.delenv("SECNODE_LLM", raising=False)
1819
assert has_supported_provider_key() is False
1920

2021
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
2122
assert has_supported_provider_key() is True
2223

2324

25+
def test_has_supported_provider_key_respects_selected_provider(
26+
monkeypatch: pytest.MonkeyPatch,
27+
) -> None:
28+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
29+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
30+
31+
monkeypatch.setenv("SECNODE_LLM", "anthropic/claude-3-5-sonnet-20241022")
32+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
33+
assert has_supported_provider_key() is False
34+
35+
monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-test")
36+
assert has_supported_provider_key() is True
37+
38+
39+
def test_has_supported_provider_key_allows_ollama_without_cloud_keys(
40+
monkeypatch: pytest.MonkeyPatch,
41+
) -> None:
42+
monkeypatch.setenv("SECNODE_LLM", "ollama/llama3.1")
43+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
44+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
45+
assert has_supported_provider_key() is True
46+
47+
2448
def test_parse_auth_header_only() -> None:
2549
headers = cli.parse_auth("Authorization: Bearer token", None)
2650
assert headers == {"Authorization": "Bearer token"}
@@ -55,6 +79,7 @@ def test_parse_identities_file(tmp_path: Path) -> None:
5579
def test_require_provider_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
5680
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
5781
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
82+
monkeypatch.setenv("SECNODE_LLM", "openai/gpt-4o")
5883

5984
with pytest.raises(SystemExit):
6085
cli._require_provider_key(schema_only=False)
@@ -68,6 +93,15 @@ def test_require_provider_key_allows_schema_only(
6893
cli._require_provider_key(schema_only=True)
6994

7095

96+
def test_require_provider_key_allows_ollama(
97+
monkeypatch: pytest.MonkeyPatch,
98+
) -> None:
99+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
100+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
101+
monkeypatch.setenv("SECNODE_LLM", "ollama/llama3.1")
102+
cli._require_provider_key(schema_only=False)
103+
104+
71105
def test_parse_args_with_dry_run_output(monkeypatch: pytest.MonkeyPatch) -> None:
72106
monkeypatch.setattr(
73107
"sys.argv",

tests/test_cli_main_and_llm.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,60 @@ async def test_call_llm_success(monkeypatch) -> None:
8888
message = types.SimpleNamespace(content='{"ok":true}')
8989
choice = types.SimpleNamespace(message=message)
9090
response = types.SimpleNamespace(choices=[choice])
91+
captured = {}
9192

9293
async def fake_completion(**kwargs):
94+
captured.update(kwargs)
9395
return response
9496

97+
monkeypatch.delenv("SECNODE_LLM", raising=False)
98+
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
9599
monkeypatch.setattr("secnodeapi.ai.llm_client.acompletion", fake_completion)
96100
out = await llm_client.call_llm("sys", "user")
97101
assert out == '{"ok":true}'
102+
assert captured["model"] == "openai/gpt-4o"
103+
assert captured["response_format"] == {"type": "json_object"}
104+
105+
106+
@pytest.mark.asyncio
107+
async def test_call_llm_ollama_uses_optional_api_base(monkeypatch) -> None:
108+
message = types.SimpleNamespace(content='{"ok":true}')
109+
choice = types.SimpleNamespace(message=message)
110+
response = types.SimpleNamespace(choices=[choice])
111+
captured = {}
112+
113+
async def fake_completion(**kwargs):
114+
captured.update(kwargs)
115+
return response
116+
117+
monkeypatch.setenv("SECNODE_LLM", "ollama/llama3.1")
118+
monkeypatch.setenv("OLLAMA_API_BASE", "http://localhost:11434")
119+
monkeypatch.setattr("secnodeapi.ai.llm_client.acompletion", fake_completion)
120+
out = await llm_client.call_llm("sys", "user")
121+
assert out == '{"ok":true}'
122+
assert captured["model"] == "ollama/llama3.1"
123+
assert captured["api_base"] == "http://localhost:11434"
124+
assert "response_format" not in captured
125+
126+
127+
@pytest.mark.asyncio
128+
async def test_call_llm_ollama_without_api_base(monkeypatch) -> None:
129+
message = types.SimpleNamespace(content='{"ok":true}')
130+
choice = types.SimpleNamespace(message=message)
131+
response = types.SimpleNamespace(choices=[choice])
132+
captured = {}
133+
134+
async def fake_completion(**kwargs):
135+
captured.update(kwargs)
136+
return response
137+
138+
monkeypatch.setenv("SECNODE_LLM", "ollama/llama3.1")
139+
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
140+
monkeypatch.setattr("secnodeapi.ai.llm_client.acompletion", fake_completion)
141+
out = await llm_client.call_llm("sys", "user")
142+
assert out == '{"ok":true}'
143+
assert "api_base" not in captured
144+
assert "response_format" not in captured
98145

99146

100147
@pytest.mark.asyncio

0 commit comments

Comments
 (0)