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
14 changes: 10 additions & 4 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name: Docker
# cuda — NVIDIA CUDA runtime baked in
# vulkan — vendor-neutral GPU via Vulkan
# rocm — AMD ROCm runtime (linux/amd64 only — upstream has no arm64 build)
# all — bundles cpu+cuda+vulkan+rocm into a single image
# all — bundles every backend available for the target architecture
#
# Every variant also bakes in matching whisper.cpp (bucky) shared libraries
# for audio transcription via /v1/audio/transcriptions. The bucky processor
Expand Down Expand Up @@ -215,7 +215,8 @@ jobs:
[all]=runtime
)
# Whitespace-separated processor lists fed to LLAMA_PROCESSORS in
# the Dockerfile.
# the Dockerfile. The all/arm64 entry drops rocm when the matrix is
# expanded below because upstream publishes no arm64 ROCm bundle.
declare -A PROCESSORS=(
[cpu]="cpu"
[cuda]="cuda"
Expand Down Expand Up @@ -385,12 +386,16 @@ jobs:
fi
for a in $PLATS; do
RUNNER=$([[ "$a" == "arm64" ]] && echo "ubuntu-24.04-arm" || echo "ubuntu-24.04")
PROCESSORS_FOR_ARCH="${PROCESSORS[$v]}"
if [[ "$v" == "all" && "$a" == "arm64" ]]; then
PROCESSORS_FOR_ARCH="cpu cuda vulkan"
fi
ITEM=$(jq -nc \
--arg variant "$v" \
--arg arch "$a" \
--arg platform "linux/$a" \
--arg target "${TARGET[$v]}" \
--arg processors "${PROCESSORS[$v]}" \
--arg processors "$PROCESSORS_FOR_ARCH" \
--arg bucky_processors "${BUCKY_PROCESSORS[$v]}" \
--arg runner "$RUNNER" \
'{variant:$variant, arch:$arch, platform:$platform, target:$target, processors:$processors, bucky_processors:$bucky_processors, runner:$runner}')
Expand Down Expand Up @@ -428,7 +433,8 @@ jobs:
needs: plan
if: needs.plan.outputs.matrix != '{"include":[]}'
runs-on: ${{ matrix.runner }}
# The `all` variant (cpu+cuda+vulkan+rocm) is the long pole; 90 min
# The `all` variant (all backends available for each arch) is the long
# pole; 90 min
# leaves headroom over a cold-cache build while still killing a hung
# runner well before the 6-hour GitHub default.
timeout-minutes: 90
Expand Down
4 changes: 4 additions & 0 deletions .manual/chapter-03-model-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,10 @@ some-provider/some-model:
host tensor operations on the CPU. These options can reduce discrete-GPU VRAM
pressure at a performance cost. They do not reduce total memory requirements.

llama.cpp also supports the process-wide `GGML_OP_OFFLOAD_MIN_BATCH`
environment variable. Set it before Kronk starts; GPU backends read it once
during `kronk.Init`, so it cannot be configured independently per model.

For multimodal models, `proj-on-cpu: true` keeps the media projector on the
CPU without changing placement of the language model itself. To place the
projector on a specific accelerator, set `proj-device` to a name reported by
Expand Down
4 changes: 3 additions & 1 deletion .manual/chapter-10-request-parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,9 @@ For OpenAI-compatible clients, prefer `response_format`:
Supported `response_format.type` values are `text`, `json_object`, and
`json_schema`. Kronk also accepts a schema directly in the top-level
`json_schema` field and accepts a custom GBNF string in `grammar`. Use one
structured-output mechanism per request.
structured-output mechanism per request. JSON Schema `pattern` is not supported;
Kronk rejects schemas containing it rather than interpreting its regular
expression as GBNF.

When a constraint is present and `enable_thinking` is omitted, Kronk disables
thinking automatically so free-form reasoning does not precede the structured
Expand Down
5 changes: 5 additions & 0 deletions .manual/chapter-11-multi-modal-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ behavior and limitations.
- The selected model and projector must support the detected modality.
- Image resolution, media count, and audio duration affect latency and memory.
- Model quality and practical media limits vary by model and projector.
- Kronk does not impose fixed media payload, image-dimension, or media-count
limits. Image decoding allocates memory in proportion to decoded dimensions,
and a request can retain multiple decoded media items at once. Applications
that accept media from untrusted sources must validate those resources before
passing them to Kronk.

---

Expand Down
2 changes: 2 additions & 0 deletions .manual/chapter-12-security-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ binding Kronk to another interface or allowing traffic outside a trusted host:
- restrict `KRONK_WEB_CORS_ALLOWED_ORIGINS` instead of retaining `*`;
- issue separate, short-lived, least-privilege tokens for each application;
- set quotas based on the workload and monitor authentication failures;
- when an application forwards untrusted media, validate request size, media
count, payload size, and decoded image dimensions before forwarding it;
- protect and back up `master.pem` and `master.jwt`; and
- rotate non-master signing keys deliberately, accounting for all tokens that
a deletion will revoke.
Expand Down
82 changes: 23 additions & 59 deletions cmd/kronk/bucky/libs/bundles.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ package libs

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
Expand Down Expand Up @@ -128,15 +126,9 @@ func runInstallWeb(opts installOpts) error {
}

func webListCombinations() error {
body, err := webGet("/v1/bucky/libs/combinations")
if err != nil {
return err
}
defer body.Close()

var resp toolapp.CombinationsResponse
if err := json.NewDecoder(body).Decode(&resp); err != nil {
return fmt.Errorf("bucky libs: decode combinations: %w", err)
if err := webRequest(http.MethodGet, "/v1/bucky/libs/combinations", nil, &resp); err != nil {
return err
}

combos := make([]libs.Combination, len(resp.Combinations))
Expand All @@ -148,15 +140,9 @@ func webListCombinations() error {
}

func webListInstalls() error {
body, err := webGet("/v1/bucky/libs/installs")
if err != nil {
return err
}
defer body.Close()

var resp toolapp.BundleListResponse
if err := json.NewDecoder(body).Decode(&resp); err != nil {
return fmt.Errorf("bucky libs: decode installs: %w", err)
if err := webRequest(http.MethodGet, "/v1/bucky/libs/installs", nil, &resp); err != nil {
return err
}

tags := make([]libs.VersionTag, len(resp.Bundles))
Expand All @@ -178,31 +164,12 @@ func webMutateInstall(method string, path string, opts installOpts) error {
q.Set("os", opts.os)
q.Set("processor", opts.processor)

url, err := client.DefaultURL(path)
if err != nil {
return fmt.Errorf("bucky libs: default url: %w", err)
}
url += "?" + q.Encode()

req, err := http.NewRequest(method, url, nil)
if err != nil {
return fmt.Errorf("bucky libs: build request: %w", err)
}
if tok := os.Getenv("KRONK_TOKEN"); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("bucky libs: request: %w", err)
var resp toolapp.BundleActionResponse
if err := webRequest(method, path, q, &resp); err != nil {
return err
}
defer resp.Body.Close()

body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("bucky libs: server returned %d: %s", resp.StatusCode, string(body))
}
fmt.Println(string(body))
fmt.Printf("%s install arch=%s os=%s processor=%s\n", resp.Status, resp.Arch, resp.OS, resp.Processor)
return nil
}

Expand Down Expand Up @@ -241,30 +208,27 @@ func webPullInstall(opts installOpts) error {
return nil
}

func webGet(path string) (io.ReadCloser, error) {
func webRequest(method string, path string, query neturl.Values, response any) error {
url, err := client.DefaultURL(path)
if err != nil {
return nil, fmt.Errorf("bucky libs: default url: %w", err)
}

req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("bucky libs: build request: %w", err)
return fmt.Errorf("bucky libs: default url: %w", err)
}
if tok := os.Getenv("KRONK_TOKEN"); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
if len(query) > 0 {
url += "?" + query.Encode()
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("bucky libs: request: %w", err)
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("bucky libs: server returned %d: %s", resp.StatusCode, string(body))
cln := client.New(
client.NoopLogger,
client.WithBearer(os.Getenv("KRONK_TOKEN")),
)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

if err := cln.Do(ctx, method, url, nil, response); err != nil {
return fmt.Errorf("bucky libs: request: %w", err)
}
return resp.Body, nil

return nil
}

func printCombinations(combos []libs.Combination) {
Expand Down
21 changes: 18 additions & 3 deletions cmd/kronk/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io"
"net"
"net/http"
"strings"
"time"
)

Expand Down Expand Up @@ -107,6 +108,10 @@ func (cln *Client) Do(ctx context.Context, method string, endpoint string, body
return fmt.Errorf("client: copy error: %w", err)
}

if v == nil {
return nil
}

if err := json.Unmarshal(data, v); err != nil {
return fmt.Errorf("client: response: %s, decoding error: %w ", string(data), err)
}
Expand Down Expand Up @@ -164,15 +169,25 @@ func (cln *SSEClient[T]) DoWithErrors(ctx context.Context, method string, endpoi
}()

scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()

if line == "" || line == "data: [DONE]" {
if line == "" || strings.HasPrefix(line, ":") {
continue
}

field, data, ok := strings.Cut(line, ":")
if !ok || field != "data" {
continue
}
data = strings.TrimPrefix(data, " ")
if data == "[DONE]" {
continue
}

var v T
if err := json.Unmarshal([]byte(line[6:]), &v); err != nil {
if err := json.Unmarshal([]byte(data), &v); err != nil {
errCh <- fmt.Errorf("decoding SSE response: %w", err)
return
}
Expand Down Expand Up @@ -244,7 +259,7 @@ func do(ctx context.Context, cln *Client, method string, endpoint string, body a
}

switch statusCode {
case http.StatusForbidden:
case http.StatusUnauthorized, http.StatusForbidden:
return nil, ErrUnauthorized

default:
Expand Down
36 changes: 35 additions & 1 deletion cmd/kronk/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,35 @@ import (
"testing"
)

func TestClientDoNilResponseTarget(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "response does not need to be decoded")
}))
defer srv.Close()

cln := New(NoopLogger, WithClient(srv.Client()))
if err := cln.Do(t.Context(), http.MethodGet, srv.URL, nil, nil); err != nil {
t.Fatalf("Do: %v", err)
}
}

func TestClientDoMapsUnauthorizedResponses(t *testing.T) {
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
t.Run(http.StatusText(status), func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
}))
defer srv.Close()

cln := New(NoopLogger, WithClient(srv.Client()))
err := cln.Do(t.Context(), http.MethodGet, srv.URL, nil, nil)
if !errors.Is(err, ErrUnauthorized) {
t.Errorf("Do: got %v, want %v", err, ErrUnauthorized)
}
})
}
}

func TestSSEClientDoWithErrors(t *testing.T) {
tests := []struct {
name string
Expand All @@ -19,7 +48,12 @@ func TestSSEClientDoWithErrors(t *testing.T) {
}{
{
name: "complete stream",
stream: "data: {\"status\":\"downloaded\"}\n",
stream: ": keep-alive\nevent: progress\nid: 1\ndata: {\"status\":\"downloaded\"}\ndata: [DONE]\n",
want: "downloaded",
},
{
name: "short and unknown fields are ignored",
stream: "x\nevent:\nretry: 1000\ndata:{\"status\":\"downloaded\"}\n",
want: "downloaded",
},
{
Expand Down
16 changes: 4 additions & 12 deletions cmd/kronk/kronk/catalog/list/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,14 @@ func main(cmd *cobra.Command, args []string) {

func run(cmd *cobra.Command) error {
local, _ := cmd.Flags().GetBool("local")
if !local {
return runWeb()
}

mdls, err := models.NewWithPaths(client.GetBasePath(cmd))
if err != nil {
return fmt.Errorf("unable to create models system: %w", err)
}

switch local {
case true:
err = runLocal(mdls)
default:
err = runWeb()
}

if err != nil {
return err
}

return nil
return runLocal(mdls)
}
16 changes: 4 additions & 12 deletions cmd/kronk/kronk/catalog/remove/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,14 @@ func main(cmd *cobra.Command, args []string) {

func run(cmd *cobra.Command, args []string) error {
local, _ := cmd.Flags().GetBool("local")
if !local {
return runWeb(args)
}

mdls, err := models.NewWithPaths(client.GetBasePath(cmd))
if err != nil {
return fmt.Errorf("unable to create models system: %w", err)
}

switch local {
case true:
err = runLocal(mdls, args)
default:
err = runWeb(args)
}

if err != nil {
return err
}

return nil
return runLocal(mdls, args)
}
Loading
Loading