Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md).

## [Unreleased]

### Added
- **Native Ollama provider.** Run local models through RiskKernel — set
`RISKKERNEL_DEFAULT_PROVIDER=ollama` (key-free) and budgets, the proxy, the audit
trail, and crash-resume all work the same as for a hosted provider. Talks to
Ollama's `/api/chat`; token usage comes from `prompt_eval_count` / `eval_count`.
Point it at a remote server with `RISKKERNEL_OLLAMA_BASE_URL` (default
`http://localhost:11434`).

## [0.6.0] - 2026-06-13

Governance and compliance. Approvals can route to **Slack**, policy is now
**code** — reusable bundles via `POST /v1/policies` or a reviewed `riskkernel.yaml`,
with a dry-run against recorded runs — and a new **compliance evidence export** maps
RiskKernel's recorded controls to OWASP / EU AI Act references with a tamper-evident
event log. Plus the published enforcement-overhead number (~150 ns, zero allocations)
and a public roadmap. No breaking API changes; forward-compatible with v0.5.x state.

### Added
- **Published enforcement overhead + a public roadmap.** The deterministic
enforcement decision is measured at ~150 ns and **zero heap allocations** per
Expand Down Expand Up @@ -332,7 +349,8 @@ and a memory you own, in one self-hosted binary. Three integration surfaces
(keyless) on each `v*` tag; GoReleaser binaries + checksums + GitHub release;
`govulncheck` + CodeQL in CI. One-line `docker run` quickstart.

[Unreleased]: https://github.com/prashar32/riskkernel/compare/v0.5.0...HEAD
[Unreleased]: https://github.com/prashar32/riskkernel/compare/v0.6.0...HEAD
[0.6.0]: https://github.com/prashar32/riskkernel/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/prashar32/riskkernel/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/prashar32/riskkernel/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/prashar32/riskkernel/compare/v0.2.0...v0.3.0
Expand Down
2 changes: 1 addition & 1 deletion docs/VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ orchestration inside the runtime, and a required heavyweight datastore.

## Status & roadmap

v0.5.0 (released). The runtime is the entire current focus. See
v0.6.0 (released). The runtime is the entire current focus. See
[`CHANGELOG.md`](../CHANGELOG.md) for what has landed and the public contract in
[`api/v1/`](../api/v1) for the stable surface SDKs build on.

Expand Down
12 changes: 6 additions & 6 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,19 +178,19 @@ func OpenStore(cfg *config.Config, log *slog.Logger) (storage.Store, error) {
return store, nil
}

// BuildRegistry constructs the provider registry from config. Anthropic and
// OpenAI are implemented natively; Bedrock/Ollama are stubs config can reference.
// BuildRegistry constructs the provider registry from config. Anthropic, OpenAI,
// and Ollama are implemented natively; Bedrock is a stub config can reference.
// The default provider must be usable.
func BuildRegistry(cfg *config.Config) (*provider.Registry, error) {
// Anthropic is always registered (native provider). When the key is absent the
// daemon still boots — health and routing work — and only an actual Chat call
// returns a clear "missing API key" error. OpenAI is registered (native) when a
// key is present; Bedrock/Ollama are stubs config can name before they're built
// out.
// returns a clear "missing API key" error. Ollama is native and key-free
// (local models); OpenAI is registered (native) when a key is present; Bedrock
// is a stub config can name before it's built out.
ps := []provider.Provider{
provider.NewAnthropic(cfg.AnthropicAPIKey).WithBaseURL(cfg.AnthropicBaseURL),
provider.NewBedrock(),
provider.NewOllama("http://localhost:11434"),
provider.NewOllama(cfg.OllamaBaseURL), // empty → local default
}
if cfg.OpenAIAPIKey != "" {
ps = append(ps, provider.NewOpenAI(cfg.OpenAIAPIKey).WithBaseURL(cfg.OpenAIBaseURL))
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ type Config struct {
// itself in a shared shell). Empty uses the provider's default endpoint.
AnthropicBaseURL string // RISKKERNEL_ANTHROPIC_BASE_URL
OpenAIBaseURL string // RISKKERNEL_OPENAI_BASE_URL
// OllamaBaseURL points the native Ollama provider at a server. Empty uses the
// local default (http://localhost:11434). Read from RISKKERNEL_OLLAMA_BASE_URL.
OllamaBaseURL string

// DefaultBudget is applied to runs created without an explicit budget — e.g.
// proxy calls that supply only a run-id. Any zero field is unlimited. When no
Expand Down Expand Up @@ -209,6 +212,7 @@ func Load() (*Config, error) {
OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"),
AnthropicBaseURL: os.Getenv("RISKKERNEL_ANTHROPIC_BASE_URL"),
OpenAIBaseURL: os.Getenv("RISKKERNEL_OPENAI_BASE_URL"),
OllamaBaseURL: os.Getenv("RISKKERNEL_OLLAMA_BASE_URL"),
DefaultBudget: budget,
PricingFile: os.Getenv("RISKKERNEL_PRICING_FILE"),
PolicyFile: os.Getenv("RISKKERNEL_POLICY_FILE"),
Expand Down
148 changes: 148 additions & 0 deletions internal/provider/ollama.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package provider

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)

// defaultOllamaBaseURL is the local Ollama server. Overridable for a remote
// Ollama or a test mock.
const defaultOllamaBaseURL = "http://localhost:11434"

// Ollama implements Provider against a local (or self-hosted) Ollama server's
// /api/chat endpoint — local models, no API key, your machine. Token usage comes
// from Ollama's prompt_eval_count / eval_count so the governor and cost ledger
// meter local runs the same way as hosted ones (a local model is typically priced
// at $0, which the pricing table handles).
type Ollama struct {
baseURL string
http *http.Client
}

// NewOllama constructs an Ollama provider. An empty baseURL uses the local default.
func NewOllama(baseURL string) *Ollama {
if baseURL == "" {
baseURL = defaultOllamaBaseURL
}
return &Ollama{
baseURL: strings.TrimRight(baseURL, "/"),
// Local generation can be slow on first load (model pull/warm); allow more
// headroom than the hosted providers. The governor's time budget still
// interrupts via ctx.
http: &http.Client{Timeout: 300 * time.Second},
}
}

// WithBaseURL overrides the server URL (empty keeps the current). Returns the
// provider for chaining, matching the other providers.
func (o *Ollama) WithBaseURL(url string) *Ollama {
if url != "" {
o.baseURL = strings.TrimRight(url, "/")
}
return o
}

// Name returns the stable provider identifier.
func (o *Ollama) Name() string { return "ollama" }

// --- wire types (Ollama /api/chat) ---

type ollamaReq struct {
Model string `json:"model"`
Messages []ollamaMessage `json:"messages"`
Stream bool `json:"stream"`
Options *ollamaOptions `json:"options,omitempty"`
}

type ollamaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}

type ollamaOptions struct {
NumPredict int `json:"num_predict,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
}

type ollamaResp struct {
Model string `json:"model"`
Message ollamaMessage `json:"message"`
DoneReason string `json:"done_reason"`
// Ollama reports token counts as eval counts.
PromptEvalCount int64 `json:"prompt_eval_count"`
EvalCount int64 `json:"eval_count"`
Error string `json:"error"`
}

// Chat performs one chat completion against Ollama's /api/chat (non-streaming).
func (o *Ollama) Chat(ctx context.Context, req Request) (*Response, error) {
// Ollama takes the system prompt as a leading system message (like OpenAI).
msgs := make([]ollamaMessage, 0, len(req.Messages)+1)
if req.System != "" {
msgs = append(msgs, ollamaMessage{Role: string(RoleSystem), Content: req.System})
}
for _, m := range req.Messages {
msgs = append(msgs, ollamaMessage{Role: string(m.Role), Content: m.Content})
}

wire := ollamaReq{Model: req.Model, Messages: msgs, Stream: false}
if req.MaxTokens > 0 || req.Temperature != nil {
wire.Options = &ollamaOptions{NumPredict: req.MaxTokens, Temperature: req.Temperature}
}
body, err := json.Marshal(wire)
if err != nil {
return nil, fmt.Errorf("ollama: marshaling request: %w", err)
}

httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, o.baseURL+"/api/chat", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("ollama: building request: %w", err)
}
httpReq.Header.Set("content-type", "application/json")

resp, err := o.http.Do(httpReq)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, fmt.Errorf("ollama: request failed (is ollama running at %s?): %w", o.baseURL, err)
}
defer resp.Body.Close()

raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("ollama: reading response: %w", err)
}

if resp.StatusCode != http.StatusOK {
var apiErr ollamaResp
if json.Unmarshal(raw, &apiErr) == nil && apiErr.Error != "" {
return nil, fmt.Errorf("ollama: %s (http %d)", apiErr.Error, resp.StatusCode)
}
return nil, fmt.Errorf("ollama: http %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}

var out ollamaResp
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("ollama: decoding response: %w", err)
}
if out.Error != "" {
return nil, fmt.Errorf("ollama: %s", out.Error)
}

return &Response{
Model: out.Model,
Content: out.Message.Content,
FinishReason: out.DoneReason,
Usage: Usage{
PromptTokens: out.PromptEvalCount,
CompletionTokens: out.EvalCount,
},
}, nil
}
83 changes: 83 additions & 0 deletions internal/provider/ollama_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package provider

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestOllamaChat_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
t.Errorf("path = %q, want /api/chat", r.URL.Path)
}
var got ollamaReq
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatalf("decode request: %v", err)
}
if got.Stream {
t.Error("stream should be false")
}
// System prompt should be the leading system message.
if len(got.Messages) != 2 || got.Messages[0].Role != "system" || got.Messages[0].Content != "be terse" {
t.Errorf("system not prepended: %+v", got.Messages)
}
if got.Options == nil || got.Options.NumPredict != 64 {
t.Errorf("options not mapped: %+v", got.Options)
}
w.Header().Set("content-type", "application/json")
_, _ = w.Write([]byte(`{
"model":"qwen2.5-coder",
"message":{"role":"assistant","content":"hello from local"},
"done":true,"done_reason":"stop",
"prompt_eval_count":18,"eval_count":5
}`))
}))
defer srv.Close()

o := NewOllama(srv.URL)
resp, err := o.Chat(context.Background(), Request{
Model: "qwen2.5-coder",
System: "be terse",
Messages: []Message{{Role: RoleUser, Content: "hi"}},
MaxTokens: 64,
})
if err != nil {
t.Fatalf("Chat: %v", err)
}
if resp.Content != "hello from local" || resp.FinishReason != "stop" {
t.Errorf("resp = %+v", resp)
}
if resp.Usage.PromptTokens != 18 || resp.Usage.CompletionTokens != 5 || resp.Usage.Total() != 23 {
t.Errorf("usage = %+v", resp.Usage)
}
if resp.Model != "qwen2.5-coder" {
t.Errorf("model = %q", resp.Model)
}
}

func TestOllamaChat_Error(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"model \"ghost\" not found, try pulling it first"}`))
}))
defer srv.Close()

o := NewOllama(srv.URL)
_, err := o.Chat(context.Background(), Request{Model: "ghost", Messages: []Message{{Role: RoleUser, Content: "hi"}}})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected a not-found error surfaced, got %v", err)
}
}

func TestOllamaDefaultBaseURL(t *testing.T) {
if o := NewOllama(""); o.baseURL != defaultOllamaBaseURL {
t.Errorf("empty baseURL = %q, want default %q", o.baseURL, defaultOllamaBaseURL)
}
if o := NewOllama("http://remote:11434/"); o.baseURL != "http://remote:11434" {
t.Errorf("trailing slash not trimmed: %q", o.baseURL)
}
}
21 changes: 6 additions & 15 deletions internal/provider/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,17 @@ package provider

import "context"

// The providers below are stubs for v0.1. They satisfy the Provider interface so
// routing and config can reference them, but Chat returns ErrNotImplemented until
// each is built out. For the long tail of providers, the documented path is to
// front RiskKernel with LiteLLM rather than reimplement 100+ vendors here.
// (Anthropic and OpenAI are implemented natively — see anthropic.go, openai.go.)
// The provider below is a stub. It satisfies the Provider interface so routing and
// config can reference it, but Chat returns ErrNotImplemented until it is built
// out. For the long tail of providers, the documented path is to front RiskKernel
// with LiteLLM rather than reimplement 100+ vendors here. (Anthropic, OpenAI, and
// Ollama are implemented natively — see anthropic.go, openai.go, ollama.go.)

// Bedrock is a stub. Native implementation is planned post-v0.1.
// Bedrock is a stub. Native implementation is planned.
type Bedrock struct{}

func NewBedrock() *Bedrock { return &Bedrock{} }
func (b *Bedrock) Name() string { return "bedrock" }
func (b *Bedrock) Chat(context.Context, Request) (*Response, error) {
return nil, ErrNotImplemented
}

// Ollama is a stub for local models. Native implementation is planned post-v0.1.
type Ollama struct{ baseURL string }

func NewOllama(baseURL string) *Ollama { return &Ollama{baseURL: baseURL} }
func (o *Ollama) Name() string { return "ollama" }
func (o *Ollama) Chat(context.Context, Request) (*Response, error) {
return nil, ErrNotImplemented
}
2 changes: 1 addition & 1 deletion internal/version/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ package version
// These are set via -ldflags "-X github.com/prashar32/riskkernel/internal/version.Version=..."
var (
// Version is the semantic version of this build.
Version = "0.5.1-dev"
Version = "0.6.1-dev"
// Commit is the git commit this binary was built from.
Commit = "unknown"
// Date is the build date (RFC3339), stamped at release.
Expand Down
2 changes: 1 addition & 1 deletion sdks/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "riskkernel"
version = "0.5.0"
version = "0.6.0"
description = "Thin Python client for the RiskKernel reliability runtime (Surface 2)."
readme = "README.md"
requires-python = ">=3.9"
Expand Down
2 changes: 1 addition & 1 deletion sdks/python/riskkernel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
governed_run,
)

__version__ = "0.5.0"
__version__ = "0.6.0"

__all__ = [
"RiskKernel",
Expand Down
4 changes: 2 additions & 2 deletions sdks/typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading