Skip to content

Commit f86f16d

Browse files
haasonsaasclaude
andcommitted
Add first-class self-hosted LLM support
- Add --base-url, --api-key, --adapter global CLI flags with env var fallbacks - Make API key optional for local servers (localhost, custom domains) - Add explicit adapter routing (openai/anthropic/ollama) via config or CLI - Add `diffscope doctor` command for endpoint diagnostics and model recommendations - Wire prompt optimization for local models with smaller context windows - Consolidate ModelConfig construction via Config::to_model_config() - Add release profile (LTO, strip), Docker dependency caching, Ollama healthcheck - Add self-hosted documentation section and example configs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6d056d6 commit f86f16d

File tree

19 files changed

+483
-80
lines changed

19 files changed

+483
-80
lines changed

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ shell-words = "1.1"
3535
tempfile = "3.8"
3636
mockito = "1.2"
3737

38+
[profile.release]
39+
lto = "thin"
40+
codegen-units = 1
41+
strip = true
42+
3843
[[bin]]
3944
name = "diffscope"
4045
path = "src/main.rs"

Dockerfile

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@ FROM rust:alpine AS builder
44
RUN apk add --no-cache musl-dev
55

66
WORKDIR /app
7+
8+
# Cache dependencies
79
COPY Cargo.toml Cargo.lock ./
8-
COPY src ./src
10+
RUN mkdir src && echo 'fn main() {}' > src/main.rs && \
11+
cargo build --release && \
12+
rm -rf src
913

10-
# Build for the native architecture
11-
RUN cargo build --release
12-
RUN strip target/release/diffscope
14+
# Build actual binary
15+
COPY src ./src
16+
RUN touch src/main.rs && cargo build --release
1317

1418
# Runtime stage
1519
FROM alpine:3.19
1620

17-
RUN apk add --no-cache ca-certificates
21+
RUN apk add --no-cache ca-certificates git
1822

1923
COPY --from=builder /app/target/release/diffscope /usr/local/bin/diffscope
2024

2125
ENTRYPOINT ["diffscope"]
22-
CMD ["--help"]
26+
CMD ["--help"]

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,63 @@ export OPENAI_BASE_URL=https://api.custom.com/v1
191191
git diff | diffscope review --model custom-model
192192
```
193193

194+
### Self-Hosted / Local Models
195+
196+
Run DiffScope against a local LLM with zero cloud dependencies. No API key required.
197+
198+
#### Ollama (Recommended)
199+
```bash
200+
# Install Ollama and pull a code model
201+
ollama pull codellama
202+
203+
# Review code with local model
204+
git diff | diffscope review --base-url http://localhost:11434 --model ollama:codellama
205+
206+
# Or use a config file (see examples/selfhosted-ollama.yml)
207+
cp examples/selfhosted-ollama.yml .diffscope.yml
208+
git diff | diffscope review
209+
```
210+
211+
#### vLLM / LM Studio / OpenAI-Compatible Servers
212+
```bash
213+
# Point to any OpenAI-compatible endpoint
214+
git diff | diffscope review \
215+
--base-url http://localhost:8000/v1 \
216+
--adapter openai \
217+
--model deepseek-coder-6.7b
218+
219+
# See examples/selfhosted-vllm.yml for a ready-made config
220+
```
221+
222+
#### Docker Compose (Ollama + DiffScope)
223+
```bash
224+
# Start Ollama and DiffScope together
225+
docker compose up diffscope-local
226+
227+
# Pull a model first
228+
docker compose exec ollama ollama pull codellama
229+
```
230+
231+
#### Check Your Setup
232+
```bash
233+
# Verify endpoint reachability, models, and recommendations
234+
diffscope doctor
235+
diffscope doctor --base-url http://localhost:11434
236+
```
237+
238+
#### Environment Variables
239+
| Variable | Description |
240+
|----------|-------------|
241+
| `DIFFSCOPE_BASE_URL` | LLM API base URL (also accepts `OPENAI_BASE_URL`) |
242+
| `DIFFSCOPE_API_KEY` | API key for the LLM endpoint |
243+
244+
#### CLI Flags
245+
| Flag | Description |
246+
|------|-------------|
247+
| `--base-url` | LLM API base URL |
248+
| `--api-key` | API key (optional for local servers) |
249+
| `--adapter` | Force adapter: `openai`, `anthropic`, or `ollama` |
250+
194251
### Supported Models
195252

196253
**OpenAI**: gpt-4o, gpt-4-turbo, gpt-3.5-turbo

docker-compose.yml

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,33 @@ services:
1111
- .:/workspace
1212
working_dir: /workspace
1313
command: review --diff /workspace/example.diff
14-
15-
diffscope-ollama:
14+
15+
diffscope-local:
1616
build: .
1717
image: diffscope:latest
1818
depends_on:
19-
- ollama
19+
ollama:
20+
condition: service_healthy
2021
environment:
21-
- DIFFSCOPE_MODEL=ollama:codellama
22+
- DIFFSCOPE_BASE_URL=http://ollama:11434
23+
- DIFFSCOPE_MODEL=${DIFFSCOPE_MODEL:-ollama:codellama}
2224
volumes:
2325
- .:/workspace
2426
working_dir: /workspace
25-
command: review --diff /workspace/example.diff --model ollama:codellama
26-
27+
command: review --diff /workspace/example.diff --base-url http://ollama:11434 --model ollama:codellama
28+
2729
ollama:
2830
image: ollama/ollama:latest
2931
ports:
3032
- "11434:11434"
3133
volumes:
3234
- ollama_data:/root/.ollama
33-
35+
healthcheck:
36+
test: ["CMD-SHELL", "curl -sf http://localhost:11434/api/tags || exit 1"]
37+
interval: 10s
38+
timeout: 5s
39+
retries: 5
40+
start_period: 30s
41+
3442
volumes:
35-
ollama_data:
43+
ollama_data:

examples/selfhosted-ollama.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# DiffScope config for self-hosted Ollama
2+
# Usage: cp examples/selfhosted-ollama.yml .diffscope.yml
3+
# ollama pull codellama
4+
# git diff | diffscope review
5+
6+
model: ollama:codellama
7+
base_url: http://localhost:11434
8+
context_window: 8192
9+
temperature: 0.1
10+
max_tokens: 4096
11+
strictness: 2
12+
13+
# No API key needed for local Ollama
14+
15+
# Reduce context to fit smaller model windows
16+
max_context_chars: 8000
17+
max_diff_chars: 16000
18+
context_max_chunks: 8
19+
context_budget_chars: 8000

examples/selfhosted-vllm.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# DiffScope config for self-hosted vLLM / LM Studio / any OpenAI-compatible server
2+
# Usage: cp examples/selfhosted-vllm.yml .diffscope.yml
3+
# vllm serve deepseek-ai/deepseek-coder-6.7b-instruct
4+
# git diff | diffscope review
5+
6+
model: deepseek-ai/deepseek-coder-6.7b-instruct
7+
adapter: openai
8+
base_url: http://localhost:8000/v1
9+
context_window: 16384
10+
temperature: 0.1
11+
max_tokens: 4096
12+
strictness: 2
13+
14+
# No API key needed for local servers
15+
16+
# Adjust context for model capacity
17+
max_context_chars: 12000
18+
max_diff_chars: 24000
19+
context_max_chunks: 12
20+
context_budget_chars: 12000

src/adapters/anthropic.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,20 @@ struct AnthropicUsage {
5050

5151
impl AnthropicAdapter {
5252
pub fn new(config: ModelConfig) -> Result<Self> {
53-
let api_key = config.api_key.clone()
54-
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
55-
.context("Anthropic API key not found. Set ANTHROPIC_API_KEY environment variable or provide in config")?;
56-
5753
let base_url = config
5854
.base_url
5955
.clone()
6056
.unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
6157

58+
let is_local = is_local_endpoint(&base_url);
59+
60+
let api_key = config.api_key.clone()
61+
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
62+
.or_else(|| if is_local { Some(String::new()) } else { None })
63+
.context("Anthropic API key not found. Set ANTHROPIC_API_KEY environment variable or provide in config")?;
64+
6265
let client = Client::builder()
63-
.timeout(std::time::Duration::from_secs(60))
66+
.timeout(std::time::Duration::from_secs(if is_local { 300 } else { 60 }))
6467
.build()?;
6568

6669
Ok(Self {
@@ -176,3 +179,9 @@ impl LLMAdapter for AnthropicAdapter {
176179
fn is_retryable_status(status: StatusCode) -> bool {
177180
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
178181
}
182+
183+
fn is_local_endpoint(url: &str) -> bool {
184+
url.contains("localhost") || url.contains("127.0.0.1") || url.contains("0.0.0.0")
185+
|| url.contains("[::1]")
186+
|| (!url.contains("openai.com") && !url.contains("anthropic.com"))
187+
}

src/adapters/llm.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ pub struct ModelConfig {
1010
pub temperature: f32,
1111
pub max_tokens: usize,
1212
pub openai_use_responses: Option<bool>,
13+
#[serde(default)]
14+
pub adapter_override: Option<String>,
1315
}
1416

1517
impl Default for ModelConfig {
@@ -21,6 +23,7 @@ impl Default for ModelConfig {
2123
temperature: 0.2,
2224
max_tokens: 4000,
2325
openai_use_responses: None,
26+
adapter_override: None,
2427
}
2528
}
2629
}
@@ -54,6 +57,16 @@ pub trait LLMAdapter: Send + Sync {
5457
}
5558

5659
pub fn create_adapter(config: &ModelConfig) -> Result<Box<dyn LLMAdapter>> {
60+
// Explicit adapter override takes priority
61+
if let Some(ref adapter) = config.adapter_override {
62+
return match adapter.as_str() {
63+
"anthropic" => Ok(Box::new(crate::adapters::AnthropicAdapter::new(config.clone())?)),
64+
"ollama" => Ok(Box::new(crate::adapters::OllamaAdapter::new(config.clone())?)),
65+
_ => Ok(Box::new(crate::adapters::OpenAIAdapter::new(config.clone())?)),
66+
};
67+
}
68+
69+
// Model-name heuristic
5770
match config.model_name.as_str() {
5871
// Anthropic Claude models (all versions)
5972
name if name.starts_with("claude-") => Ok(Box::new(

src/adapters/openai.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,20 @@ struct OpenAIResponsesUsage {
8787

8888
impl OpenAIAdapter {
8989
pub fn new(config: ModelConfig) -> Result<Self> {
90-
let api_key = config.api_key.clone()
91-
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
92-
.context("OpenAI API key not found. Set OPENAI_API_KEY environment variable or provide in config")?;
93-
9490
let base_url = config
9591
.base_url
9692
.clone()
9793
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
9894

95+
let is_local = is_local_endpoint(&base_url);
96+
97+
let api_key = config.api_key.clone()
98+
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
99+
.or_else(|| if is_local { Some(String::new()) } else { None })
100+
.context("OpenAI API key not found. Set OPENAI_API_KEY environment variable or provide in config")?;
101+
99102
let client = Client::builder()
100-
.timeout(std::time::Duration::from_secs(60))
103+
.timeout(std::time::Duration::from_secs(if is_local { 300 } else { 60 }))
101104
.build()?;
102105

103106
Ok(Self {
@@ -164,6 +167,12 @@ fn is_retryable_status(status: StatusCode) -> bool {
164167
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
165168
}
166169

170+
fn is_local_endpoint(url: &str) -> bool {
171+
url.contains("localhost") || url.contains("127.0.0.1") || url.contains("0.0.0.0")
172+
|| url.contains("[::1]")
173+
|| (!url.contains("openai.com") && !url.contains("anthropic.com"))
174+
}
175+
167176
fn should_use_responses_api(config: &ModelConfig) -> bool {
168177
if let Some(flag) = config.openai_use_responses {
169178
return flag;

0 commit comments

Comments
 (0)