diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd1704..5f40548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/docs/VISION.md b/docs/VISION.md index c5f7934..b8eeb71 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -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. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 8387583..4e92dad 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -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)) diff --git a/internal/config/config.go b/internal/config/config.go index 0b86a6f..ec2c911 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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"), diff --git a/internal/provider/ollama.go b/internal/provider/ollama.go new file mode 100644 index 0000000..0bddfa3 --- /dev/null +++ b/internal/provider/ollama.go @@ -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 +} diff --git a/internal/provider/ollama_test.go b/internal/provider/ollama_test.go new file mode 100644 index 0000000..a59f3f2 --- /dev/null +++ b/internal/provider/ollama_test.go @@ -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) + } +} diff --git a/internal/provider/stubs.go b/internal/provider/stubs.go index 619cd48..e36a15e 100644 --- a/internal/provider/stubs.go +++ b/internal/provider/stubs.go @@ -2,13 +2,13 @@ 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{} } @@ -16,12 +16,3 @@ 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 -} diff --git a/internal/version/version.go b/internal/version/version.go index c72ec61..73734a7 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -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. diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 5078db3..b843d7b 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -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" diff --git a/sdks/python/riskkernel/__init__.py b/sdks/python/riskkernel/__init__.py index 6722e82..8d44b17 100644 --- a/sdks/python/riskkernel/__init__.py +++ b/sdks/python/riskkernel/__init__.py @@ -37,7 +37,7 @@ governed_run, ) -__version__ = "0.5.0" +__version__ = "0.6.0" __all__ = [ "RiskKernel", diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index cf1ccba..06b69c5 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@riskkernel/sdk", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@riskkernel/sdk", - "version": "0.5.0", + "version": "0.6.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/provider": "^2.0.3", diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 2b93443..41b5f00 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@riskkernel/sdk", - "version": "0.5.0", + "version": "0.6.0", "description": "Thin TypeScript client for the RiskKernel reliability runtime (Surface 2).", "license": "Apache-2.0", "author": "Adarsh Prashar",