Skip to content

Commit 9abe71e

Browse files
avivlclaude
andauthored
feat(llm): add Ollama provider (local/self-hosted) (#22)
internal/llm/ollama: OllamaProvider implements llm.Provider via github.com/ollama/ollama/api, with JSON-schema structured output (chat Format) matching the other adapters. Local/self-hosted, so EXEMPT from the allow_external BAA gate (no third-party disclosure) — the strongest data-control option; the pipeline sanitizer gate still applies. No API key; host defaults to http://localhost:11434. Selectable as primary or router fallback. Review fixes: surface truncated (done_reason=length) and empty structured-output responses as terminal errors so the fallback router advances (parity with openai/anthropic). config.yaml documents that the ollama host MUST stay within the HIPAA control boundary (a third-party 'Ollama-compatible' endpoint would reintroduce external disclosure without the gate). Verified with ADC off (hermetic httptest): go build/vet clean, golangci-lint 0 issues, go test -race green, internal coverage 90.0%, linux static cross-compile OK. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e09093d commit 9abe71e

8 files changed

Lines changed: 679 additions & 3 deletions

File tree

cmd/sre-agent/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/avivl/cloud-sre-agent/internal/llm"
2828
"github.com/avivl/cloud-sre-agent/internal/llm/anthropic"
2929
"github.com/avivl/cloud-sre-agent/internal/llm/gemini"
30+
"github.com/avivl/cloud-sre-agent/internal/llm/ollama"
3031
"github.com/avivl/cloud-sre-agent/internal/llm/openai"
3132
"github.com/avivl/cloud-sre-agent/internal/llm/router"
3233
"github.com/avivl/cloud-sre-agent/internal/obs"
@@ -228,6 +229,13 @@ func buildOneProvider(ctx context.Context, e config.ProviderConfig) (llm.Provide
228229
APIKey: apiKey,
229230
BaseURL: e.BaseURL,
230231
})
232+
case config.KindOllama:
233+
// Ollama is local/self-hosted: no API key, host defaults inside the
234+
// adapter when empty.
235+
return ollama.New(ollama.Config{
236+
Model: e.Model,
237+
Host: e.Host,
238+
})
231239
default:
232240
return nil, fmt.Errorf("unsupported llm provider kind %q", e.Kind)
233241
}

config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ sources:
2424
# consumer Developer API (gated, see allow_non_baa).
2525
# openai — OpenAI (external third party; gated, see allow_external).
2626
# anthropic — Anthropic (external third party; gated, see allow_external).
27+
# ollama — local/self-hosted model server. NOT an external third party:
28+
# prompt content stays on infrastructure you control, so it is
29+
# EXEMPT from the allow_external gate and needs no API key. Set a
30+
# `host` (defaults to http://localhost:11434).
31+
# IMPORTANT: the `host` MUST resolve to infrastructure within your
32+
# HIPAA control boundary. Pointing it at a third-party
33+
# "Ollama-compatible" endpoint reintroduces external disclosure
34+
# WITHOUT the allow_external gate — the code cannot detect this.
2735
#
2836
# HIPAA gate: openai and anthropic are EXTERNAL third parties NOT covered by
2937
# Google's BAA. Selecting either (as primary or fallback) is refused at startup
@@ -74,6 +82,9 @@ llm:
7482
# # base_url: https://my-compatible-gateway.example.com # optional host override
7583
# - kind: anthropic
7684
# model: claude-opus-4-8
85+
# - kind: ollama # local/self-hosted; no allow_external, no API key
86+
# model: llama3.1
87+
# host: http://localhost:11434 # optional; this is the default
7788

7889
output:
7990
dir: ./out

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/knadh/koanf/providers/env v1.1.0
1515
github.com/knadh/koanf/providers/file v1.2.1
1616
github.com/knadh/koanf/v2 v2.3.5
17+
github.com/ollama/ollama v0.30.10
1718
github.com/openai/openai-go/v3 v3.41.0
1819
github.com/spf13/cobra v1.10.2
1920
github.com/stretchr/testify v1.11.1
@@ -57,6 +58,7 @@ require (
5758
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
5859
github.com/inconshreveable/mousetrap v1.1.0 // indirect
5960
github.com/knadh/koanf/maps v0.1.2 // indirect
61+
github.com/mailru/easyjson v0.7.7 // indirect
6062
github.com/mitchellh/copystructure v1.2.0 // indirect
6163
github.com/mitchellh/reflectwalk v1.0.2 // indirect
6264
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
@@ -67,6 +69,7 @@ require (
6769
github.com/tidwall/match v1.1.1 // indirect
6870
github.com/tidwall/pretty v1.2.1 // indirect
6971
github.com/tidwall/sjson v1.2.5 // indirect
72+
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
7073
go.einride.tech/aip v0.83.0 // indirect
7174
go.opencensus.io v0.24.0 // indirect
7275
go.opentelemetry.io/auto/sdk v1.2.1 // indirect

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/Z
123123
github.com/influxdata/tdigest v0.0.1/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y=
124124
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
125125
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
126+
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
126127
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
127128
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
128129
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
@@ -139,6 +140,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
139140
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
140141
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
141142
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
143+
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
144+
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
142145
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
143146
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
144147
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -147,6 +150,8 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1
147150
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
148151
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
149152
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
153+
github.com/ollama/ollama v0.30.10 h1:xeW6KBUqB2g88jSHBoxFiaP1gsLbRIB7LcjTreAFHn8=
154+
github.com/ollama/ollama v0.30.10/go.mod h1:TjwyryJftKpcf7ByoIuZWso/Wx2Jr2AcGubxadv13dY=
150155
github.com/openai/openai-go/v3 v3.41.0 h1:9GkxcN02U5NG0WGdQjZ0cTSu/pMXEyzL2LfF0ruZCck=
151156
github.com/openai/openai-go/v3 v3.41.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
152157
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
@@ -183,6 +188,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
183188
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
184189
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
185190
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
191+
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
192+
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
186193
gitlab.com/gitlab-org/api/client-go/v2 v2.43.0 h1:CxvWrDmW6/NAmnFeC4if5SGSP/0X54RIYox4sMo7pH0=
187194
gitlab.com/gitlab-org/api/client-go/v2 v2.43.0/go.mod h1:pTbeBowtVA+0/ZExWEZYUGOrpu5qlRN5ZyOUf27BnVY=
188195
go.einride.tech/aip v0.83.0 h1:TI21IdeOnLTwZEJ3BxtImIZk6bsN2Q+sd0x99SLiQ+M=
@@ -243,6 +250,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
243250
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
244251
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
245252
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
253+
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
254+
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
246255
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
247256
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
248257
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=

internal/config/config.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,19 @@ type TracingConfig struct {
6565
}
6666

6767
// LLM provider kinds. "gemini" is Google's Gemini (via Vertex AI or the
68-
// Developer API); "openai" and "anthropic" are external third-party services.
68+
// Developer API); "openai" and "anthropic" are external third-party services;
69+
// "ollama" is a local/self-hosted model server (no external disclosure).
6970
const (
7071
KindGemini = "gemini"
7172
KindOpenAI = "openai"
7273
KindAnthropic = "anthropic"
74+
KindOllama = "ollama"
7375
)
7476

77+
// DefaultOllamaHost is the Ollama daemon address used when an ollama provider's
78+
// host is left empty.
79+
const DefaultOllamaHost = "http://localhost:11434"
80+
7581
// LLM backend identifiers (gemini kind only). Vertex AI is the only BAA-eligible
7682
// Google backend and is the default; the consumer Gemini Developer API is not
7783
// covered by Google's BAA and is gated behind an explicit opt-in.
@@ -96,7 +102,7 @@ const AllowExternalLLMEnv = "SRE_ALLOW_EXTERNAL_LLM"
96102
// the gemini kind, BaseURL applies to openai/anthropic. API keys are never
97103
// stored here — they are read from the environment at wire time.
98104
type ProviderConfig struct {
99-
// Kind selects the adapter: "gemini", "openai", or "anthropic".
105+
// Kind selects the adapter: "gemini", "openai", "anthropic", or "ollama".
100106
Kind string `koanf:"kind"`
101107
// Model is the model name (e.g. "gemini-2.5-flash", "gpt-4o-mini",
102108
// "claude-opus-4-8"). Required.
@@ -124,6 +130,13 @@ type ProviderConfig struct {
124130
// BaseURL optionally overrides the provider API host (e.g. a compatible
125131
// gateway). Empty uses the SDK default. Applies to openai/anthropic.
126132
BaseURL string `koanf:"base_url"`
133+
134+
// --- ollama fields ---
135+
136+
// Host is the Ollama daemon base URL (e.g. "http://localhost:11434"). Empty
137+
// defaults to DefaultOllamaHost. Applies to the ollama kind only; Ollama is
138+
// local/self-hosted and uses no API key.
139+
Host string `koanf:"host"`
127140
}
128141

129142
// LLMConfig selects the primary LLM provider plus an ordered list of fallbacks.
@@ -154,6 +167,9 @@ type LLMConfig struct {
154167
// BaseURL optionally overrides the primary provider API host when Provider is
155168
// openai/anthropic. Ignored for gemini.
156169
BaseURL string `koanf:"base_url"`
170+
// Host is the Ollama daemon base URL when Provider is ollama. Empty defaults
171+
// to DefaultOllamaHost. Ignored for other kinds.
172+
Host string `koanf:"host"`
157173

158174
// Fallbacks is the ordered list of fallback providers, tried in turn when the
159175
// primary (and preceding fallbacks) fail.
@@ -178,6 +194,7 @@ func (l LLMConfig) Primary() ProviderConfig {
178194
APIKeyEnv: l.APIKeyEnv,
179195
AllowNonBAA: l.AllowNonBAA,
180196
BaseURL: l.BaseURL,
197+
Host: l.Host,
181198
}
182199
}
183200

@@ -471,6 +488,12 @@ func (l LLMConfig) validateProvider(where string, p ProviderConfig) error {
471488
switch p.Kind {
472489
case KindGemini:
473490
return l.validateGeminiBackend(where, p)
491+
case KindOllama:
492+
// Ollama is local/self-hosted: prompt content stays on infrastructure
493+
// the operator controls, so it is EXEMPT from the external-disclosure
494+
// (BAA) gate. The model is already validated above; host defaults to
495+
// DefaultOllamaHost when empty, so there is nothing further to check.
496+
return nil
474497
case KindOpenAI, KindAnthropic:
475498
if !l.AllowsExternal() {
476499
return fmt.Errorf("config: %s: provider %q is an external third-party service; prompt content "+
@@ -482,7 +505,7 @@ func (l LLMConfig) validateProvider(where string, p ProviderConfig) error {
482505
}
483506
return nil
484507
default:
485-
return fmt.Errorf("config: %s: provider kind %q must be %q, %q, or %q", where, p.Kind, KindGemini, KindOpenAI, KindAnthropic)
508+
return fmt.Errorf("config: %s: provider kind %q must be %q, %q, %q, or %q", where, p.Kind, KindGemini, KindOpenAI, KindAnthropic, KindOllama)
486509
}
487510
}
488511

internal/config/config_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,81 @@ func TestValidate_ExternalProviders(t *testing.T) {
228228
})
229229
}
230230

231+
// TestValidate_Ollama covers the local/self-hosted ollama provider, which is
232+
// EXEMPT from the external-disclosure (BAA) gate: it must be selectable as
233+
// primary and as a fallback WITHOUT allow_external.
234+
func TestValidate_Ollama(t *testing.T) {
235+
base := func() Config {
236+
return Config{
237+
Sources: []SourceConfig{{Type: "file", Path: "x.log"}},
238+
Output: OutputConfig{Dir: "./out"},
239+
Target: TargetLocal,
240+
Validator: ValidatorNone,
241+
Log: LogConfig{Format: "json"},
242+
}
243+
}
244+
245+
t.Run("ollama as primary without opt-in is allowed", func(t *testing.T) {
246+
c := base()
247+
c.LLM = LLMConfig{Provider: KindOllama, Model: "llama3.1"}
248+
require.NoError(t, c.Validate())
249+
})
250+
251+
t.Run("ollama as primary with explicit host is allowed", func(t *testing.T) {
252+
c := base()
253+
c.LLM = LLMConfig{Provider: KindOllama, Model: "llama3.1", Host: "http://ollama.internal:11434"}
254+
require.NoError(t, c.Validate())
255+
})
256+
257+
t.Run("ollama as fallback without opt-in is allowed", func(t *testing.T) {
258+
c := base()
259+
c.LLM = LLMConfig{
260+
Provider: KindGemini, Model: "gemini-2.5-flash",
261+
Backend: BackendVertex, Project: "p", Location: "us-central1",
262+
Fallbacks: []ProviderConfig{{Kind: KindOllama, Model: "llama3.1"}},
263+
}
264+
// No AllowExternal set: ollama is exempt from the external gate.
265+
require.False(t, c.LLM.AllowsExternal())
266+
require.NoError(t, c.Validate())
267+
})
268+
269+
t.Run("ollama missing model fails", func(t *testing.T) {
270+
c := base()
271+
c.LLM = LLMConfig{Provider: KindOllama, Model: ""}
272+
err := c.Validate()
273+
require.Error(t, err)
274+
require.Contains(t, err.Error(), "model is required")
275+
})
276+
}
277+
278+
// TestLoad_OllamaProvider loads an ollama config from YAML to confirm the host
279+
// field round-trips and the provider is accepted without allow_external.
280+
func TestLoad_OllamaProvider(t *testing.T) {
281+
p := writeConfig(t, `
282+
sources:
283+
- type: file
284+
path: ./x.log
285+
llm:
286+
provider: ollama
287+
model: llama3.1
288+
host: http://localhost:11434
289+
fallbacks:
290+
- kind: ollama
291+
model: llama3.2
292+
host: http://ollama.internal:11434
293+
`)
294+
cfg, err := Load(p)
295+
require.NoError(t, err)
296+
assert.Equal(t, KindOllama, cfg.LLM.Provider)
297+
assert.Equal(t, "http://localhost:11434", cfg.LLM.Host)
298+
require.Len(t, cfg.LLM.Fallbacks, 1)
299+
assert.Equal(t, KindOllama, cfg.LLM.Fallbacks[0].Kind)
300+
assert.Equal(t, "http://ollama.internal:11434", cfg.LLM.Fallbacks[0].Host)
301+
302+
// Primary projection carries the host through.
303+
assert.Equal(t, "http://localhost:11434", cfg.LLM.Primary().Host)
304+
}
305+
231306
func TestLoad_PrimaryAndFallbacks(t *testing.T) {
232307
p := writeConfig(t, `
233308
sources:

0 commit comments

Comments
 (0)