diff --git a/CLAUDE.md b/CLAUDE.md index 154102d..3396df2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ devkit is a Claude Code plugin: deterministic YAML workflow engine, thin-dispatc | `src/cmd/guard.go` | Hook gatekeeper | What Bash/Edit/etc. is allowed per step type + enforce level | | `src/cmd/workflow.go` | `devkit workflow` CLI command | Workflow invocation entry point | | `src/cmd/mcp.go` | `devkit mcp` CLI command | MCP server bootstrap | +| `src/cmd/probe_local.go` | `devkit probe-local` CLI command | Local-inference endpoint health probe (human + `--json`) | | `src/mcp/tools.go` | MCP tool schemas | `devkit_start`, `devkit_advance`, `devkit_list`, `devkit_status` contracts | | `src/mcp/server.go` | MCP server implementation | Tool dispatching | | `src/mcp/principles.go` | Principle injection | How `_principles.yml` reaches workflows | diff --git a/README.md b/README.md index 4696c8c..6b0e4a1 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,67 @@ This shows which CLIs are installed, which agents are available, and which comma --- +## Local models (optional) + +devkit can dispatch fast-tier work (doc generation, changelogs, summarization, test stubs) to any OpenAI-compatible local inference server. Cloud tiers — Claude, Codex, Gemini — still handle tri-review, architecture planning, and security review. + +### Environment variables + +| Var | Default | Purpose | +|---|---|---| +| `DEVKIT_LOCAL_ENABLED` | unset | Set to `1` to enable | +| `DEVKIT_LOCAL_ENDPOINT` | `http://localhost:11434/v1` | Base URL — must end in `/v1` | +| `DEVKIT_LOCAL_MODEL` | `qwen3:32b` | Model name sent in the request payload | +| `DEVKIT_LOCAL_API_KEY` | unset | Bearer token, if endpoint requires auth | +| `DEVKIT_LOCAL_TIMEOUT` | `600` | Per-request timeout (seconds) | +| `DEVKIT_LOCAL_DEBUG` | unset | Set to `1` to log probe failures to stderr | + +### Default ports by stack + +| Stack | Default port | Model-name source | +|---|---|---| +| llama-server (llama.cpp) | 8080 | loaded via `-m` at launch | +| llama-swap | 8080 | names in llama-swap YAML | +| Ollama | 11434 | `ollama list` | +| LM Studio | 1234 | GUI-loaded model | +| vLLM | 8000 | `--model` flag at launch | +| SGLang | 30000 | `--model-path` at launch | +| LocalAI | 8080 | models directory | + +Port 8080 is the llama.cpp convention; llama-swap reuses it since it fronts llama-server processes. + +### Examples + +Single model, no router (llama-server, LM Studio, LocalAI, vLLM, SGLang): + +```bash +export DEVKIT_LOCAL_ENABLED=1 +export DEVKIT_LOCAL_ENDPOINT=http://:/v1 +export DEVKIT_LOCAL_MODEL= +devkit-engine probe-local +``` + +Multi-model via router (Ollama, llama-swap, LocalAI): + +```bash +export DEVKIT_LOCAL_ENABLED=1 +export DEVKIT_LOCAL_ENDPOINT=http://:/v1 +export DEVKIT_LOCAL_MODEL= +devkit-engine probe-local +``` + +### Verifying the setup + +`devkit-engine probe-local` calls `$DEVKIT_LOCAL_ENDPOINT/models`, reports reachability and latency, and checks whether your configured model is present in the server's model list. Exit 0 on healthy, 1 otherwise. Add `--json` for structured output. + +### Limits + +- Fast tier only — local models have higher tool-call error rates than cloud tiers, so tri-review / architecture / security still go to the cloud. +- No function-calling (plain `/v1/chat/completions` only). +- No streaming. + +--- + ## How It Works Devkit runs as an **MCP server** inside Claude Code. When a workflow starts, the engine takes control: diff --git a/ROADMAP.md b/ROADMAP.md index d17e0b9..6e31289 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,7 +27,7 @@ - **Cross-domain dirty-bit enforcement** — Blocks completion without test evidence per domain - **Language-universal hooks** — Consolidated language-specific hooks into `lang-review.sh` with Go, TypeScript, Rust, Python, and Shell support - **Hook consolidation** — Merged 14 hooks into 10, reduced per-edit shell processes from 7 to 4 -- **Local runner** — OpenAI-compatible HTTP runner for Ollama/llama-server/vLLM alongside Claude/Codex/Gemini CLI runners. Opt-in via `DEVKIT_LOCAL_ENABLED=1`; defaults to `qwen3:32b` at `localhost:11434/v1`. Intended for `fast`-tier dispatch; cloud runners stay default for `smart`/`general` given local tool-call reliability gap. +- **Local runner** — OpenAI-compatible HTTP runner for Ollama/llama-server/llama-swap/vLLM/SGLang/LM Studio/LocalAI alongside Claude/Codex/Gemini CLI runners. Opt-in via `DEVKIT_LOCAL_ENABLED=1`; defaults to `qwen3:32b` at `localhost:11434/v1`. Intended for `fast`-tier dispatch; cloud runners stay default for `smart`/`general` given local tool-call reliability gap. `devkit-engine probe-local` verifies endpoint reachability, latency, and configured-model presence with human or `--json` output (wired into `/devkit:health`). - **Stealth scraping backends** — `scrape` skill adds Camoufox (patched Firefox with leak-fixed JA3/TLS fingerprints) between Playwright and Firecrawl with auto-fallback on Cloudflare/DataDome/Incapsula challenge-page signatures, plus Scweet as host-routed backend for `x.com`/`twitter.com` URLs when `SCWEET_AUTH_TOKEN` is set. ## Retired diff --git a/skills/health/SKILL.md b/skills/health/SKILL.md index f732ac4..f200995 100644 --- a/skills/health/SKILL.md +++ b/skills/health/SKILL.md @@ -43,6 +43,21 @@ else fi ``` +### Local Runner + +```bash +echo "=== Local Runner ===" +if [ "$DEVKIT_LOCAL_ENABLED" = "1" ]; then + if command -v devkit-engine >/dev/null 2>&1; then + devkit-engine probe-local 2>&1 || echo "probe failed — see output above" + else + echo "devkit-engine not in PATH — build with 'make build' from src/" + fi +else + echo "disabled (DEVKIT_LOCAL_ENABLED unset) — set to 1 to enable local inference" +fi +``` + ### Agent Availability List all agents from `agents/` directory with their model and isolation settings. diff --git a/src/cmd/probe_local.go b/src/cmd/probe_local.go new file mode 100644 index 0000000..a631a08 --- /dev/null +++ b/src/cmd/probe_local.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/5uck1ess/devkit/runners" + "github.com/spf13/cobra" +) + +var errProbeFailed = errors.New("probe failed") + +type ProbeConfig struct { + Enabled bool + Endpoint string + Model string + APIKey string + Timeout time.Duration +} + +type ProbeResult struct { + Endpoint string `json:"endpoint"` + Model string `json:"model"` + Enabled bool `json:"enabled"` + Reachable bool `json:"reachable"` + HTTPStatus int `json:"http_status"` + LatencyMS int64 `json:"latency_ms"` + ModelsSeen []string `json:"models_seen"` + ModelMatch bool `json:"model_match"` + ErrorMsg string `json:"error,omitempty"` + Hint string `json:"hint,omitempty"` +} + +func runProbe(ctx context.Context, cfg ProbeConfig) ProbeResult { + r := ProbeResult{ + Endpoint: cfg.Endpoint, + Model: cfg.Model, + Enabled: cfg.Enabled, + } + if !cfg.Enabled { + return r + } + + reqCtx, cancel := context.WithTimeout(ctx, cfg.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, cfg.Endpoint+"/models", nil) + if err != nil { + r.ErrorMsg = fmt.Sprintf("building request: %v", err) + r.Hint = "check DEVKIT_LOCAL_ENDPOINT format — must be a valid URL ending in /v1" + return r + } + if cfg.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + } + + client := &http.Client{Timeout: cfg.Timeout} + start := time.Now() + resp, err := client.Do(req) + r.LatencyMS = time.Since(start).Milliseconds() + if err != nil { + r.ErrorMsg = err.Error() + r.Hint = "endpoint unreachable — check it is running and DEVKIT_LOCAL_ENDPOINT is correct" + return r + } + defer resp.Body.Close() + r.HTTPStatus = resp.StatusCode + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + r.ErrorMsg = strings.TrimSpace(string(body)) + switch resp.StatusCode { + case 401, 403: + r.Hint = "check DEVKIT_LOCAL_API_KEY — endpoint requires auth" + case 404: + r.Hint = "check DEVKIT_LOCAL_ENDPOINT — must end in /v1 (some stacks need the suffix)" + default: + r.Hint = "endpoint returned an error — check server logs" + } + return r + } + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + r.ErrorMsg = fmt.Sprintf("reading response: %v", err) + return r + } + + var parsed struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + r.ErrorMsg = fmt.Sprintf("parsing /models response: %v", err) + r.Hint = "endpoint did not return OpenAI /models JSON — confirm it speaks the OpenAI spec" + return r + } + + r.Reachable = true + for _, m := range parsed.Data { + r.ModelsSeen = append(r.ModelsSeen, m.ID) + if m.ID == cfg.Model { + r.ModelMatch = true + } + } + if !r.ModelMatch { + r.Hint = fmt.Sprintf("configured model %q not in /models — check DEVKIT_LOCAL_MODEL matches a name the server exposes", cfg.Model) + } + return r +} + +func formatHuman(r ProbeResult) string { + var b strings.Builder + fmt.Fprintf(&b, "endpoint: %s\n", r.Endpoint) + fmt.Fprintf(&b, "model: %s\n", r.Model) + if !r.Enabled { + fmt.Fprintln(&b, "enabled: no — disabled (set DEVKIT_LOCAL_ENABLED=1 to enable)") + return b.String() + } + fmt.Fprintln(&b, "enabled: yes") + if r.Reachable { + fmt.Fprintf(&b, "reachable: yes (HTTP %d, %dms)\n", r.HTTPStatus, r.LatencyMS) + if len(r.ModelsSeen) > 0 { + fmt.Fprintf(&b, "models seen: %s\n", strings.Join(r.ModelsSeen, ", ")) + } else { + fmt.Fprintln(&b, "models seen: (none returned)") + } + if r.ModelMatch { + fmt.Fprintln(&b, "model match: OK (configured model present in /models)") + } else { + fmt.Fprintln(&b, "model match: MISSING") + if r.Hint != "" { + fmt.Fprintf(&b, "hint: %s\n", r.Hint) + } + } + return b.String() + } + if r.HTTPStatus > 0 { + fmt.Fprintf(&b, "reachable: NO (HTTP %d in %dms)\n", r.HTTPStatus, r.LatencyMS) + } else { + fmt.Fprintf(&b, "reachable: NO (%dms)\n", r.LatencyMS) + } + if r.Hint != "" { + fmt.Fprintf(&b, "hint: %s\n", r.Hint) + } + if r.ErrorMsg != "" { + fmt.Fprintf(&b, "body: %s\n", r.ErrorMsg) + } + return b.String() +} + +func formatJSON(r ProbeResult) ([]byte, error) { + return json.MarshalIndent(r, "", " ") +} + +var probeLocalJSON bool + +var probeLocalCmd = &cobra.Command{ + Use: "probe-local", + Short: "Probe the configured local inference endpoint", + Long: `Probe the OpenAI-compatible endpoint configured via DEVKIT_LOCAL_* env vars. + +Reports endpoint, model, reachability, and whether the configured model is +present in the server's /v1/models response. Exit 0 on healthy, 1 otherwise.`, + // Override root's PersistentPreRunE — this probe doesn't need a git repo or DB. + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return nil }, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := ProbeConfig{ + Enabled: runners.LocalEnabled(), + Endpoint: runners.LocalEndpoint(), + Model: runners.LocalModel(), + APIKey: runners.LocalAPIKey(), + Timeout: 3 * time.Second, + } + result := runProbe(cmd.Context(), cfg) + + if probeLocalJSON { + out, err := formatJSON(result) + if err != nil { + return fmt.Errorf("formatting JSON: %w", err) + } + fmt.Println(string(out)) + } else { + fmt.Print(formatHuman(result)) + } + + if !cfg.Enabled { + return nil + } + if !result.Reachable || !result.ModelMatch { + return errProbeFailed + } + return nil + }, +} + +func init() { + probeLocalCmd.Flags().BoolVar(&probeLocalJSON, "json", false, "emit structured JSON instead of human-readable text") + rootCmd.AddCommand(probeLocalCmd) +} diff --git a/src/cmd/probe_local_test.go b/src/cmd/probe_local_test.go new file mode 100644 index 0000000..b7878d5 --- /dev/null +++ b/src/cmd/probe_local_test.go @@ -0,0 +1,272 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestRunProbe_Disabled(t *testing.T) { + cfg := ProbeConfig{ + Enabled: false, + Endpoint: "http://localhost:8080/v1", + Model: "test-model", + Timeout: 1 * time.Second, + } + got := runProbe(context.Background(), cfg) + + if got.Enabled { + t.Errorf("Enabled: got true, want false") + } + if got.Reachable { + t.Errorf("Reachable: got true, want false (disabled should not probe)") + } + if got.HTTPStatus != 0 { + t.Errorf("HTTPStatus: got %d, want 0 (no request should fire)", got.HTTPStatus) + } +} + +func TestRunProbe_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[{"id":"gemma-4-26b-a4b"},{"id":"mistral-small-4"}]}`) + })) + defer srv.Close() + + cfg := ProbeConfig{ + Enabled: true, + Endpoint: srv.URL + "/v1", + Model: "gemma-4-26b-a4b", + Timeout: 2 * time.Second, + } + got := runProbe(context.Background(), cfg) + + if !got.Reachable { + t.Fatalf("Reachable: got false, want true (err=%q)", got.ErrorMsg) + } + if got.HTTPStatus != 200 { + t.Errorf("HTTPStatus: got %d, want 200", got.HTTPStatus) + } + if !got.ModelMatch { + t.Errorf("ModelMatch: got false, want true (models=%v)", got.ModelsSeen) + } + wantModels := []string{"gemma-4-26b-a4b", "mistral-small-4"} + if len(got.ModelsSeen) != len(wantModels) { + t.Fatalf("ModelsSeen len: got %d, want %d", len(got.ModelsSeen), len(wantModels)) + } + for i, m := range wantModels { + if got.ModelsSeen[i] != m { + t.Errorf("ModelsSeen[%d]: got %q, want %q", i, got.ModelsSeen[i], m) + } + } +} + +func TestRunProbe_ModelMissing(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":[{"id":"something-else"}]}`) + })) + defer srv.Close() + + got := runProbe(context.Background(), ProbeConfig{ + Enabled: true, Endpoint: srv.URL + "/v1", Model: "not-there", Timeout: 2 * time.Second, + }) + + if !got.Reachable { + t.Fatalf("Reachable: got false, want true") + } + if got.ModelMatch { + t.Errorf("ModelMatch: got true, want false") + } + if got.Hint == "" { + t.Errorf("Hint: got empty, want actionable text about DEVKIT_LOCAL_MODEL") + } +} + +func TestRunProbe_Unauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"unauthorized"}`) + })) + defer srv.Close() + + got := runProbe(context.Background(), ProbeConfig{ + Enabled: true, Endpoint: srv.URL + "/v1", Model: "m", Timeout: 2 * time.Second, + }) + + if got.Reachable { + t.Errorf("Reachable: got true, want false on 401") + } + if got.HTTPStatus != 401 { + t.Errorf("HTTPStatus: got %d, want 401", got.HTTPStatus) + } + if !strings.Contains(got.Hint, "DEVKIT_LOCAL_API_KEY") { + t.Errorf("Hint: got %q, want mention of DEVKIT_LOCAL_API_KEY", got.Hint) + } +} + +func TestRunProbe_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + got := runProbe(context.Background(), ProbeConfig{ + Enabled: true, Endpoint: srv.URL + "/wrong", Model: "m", Timeout: 2 * time.Second, + }) + + if got.Reachable { + t.Errorf("Reachable: got true, want false on 404") + } + if got.HTTPStatus != 404 { + t.Errorf("HTTPStatus: got %d, want 404", got.HTTPStatus) + } + if !strings.Contains(got.Hint, "/v1") { + t.Errorf("Hint: got %q, want mention of /v1 suffix", got.Hint) + } +} + +func TestRunProbe_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"error":"boom"}`) + })) + defer srv.Close() + + got := runProbe(context.Background(), ProbeConfig{ + Enabled: true, Endpoint: srv.URL + "/v1", Model: "m", Timeout: 2 * time.Second, + }) + + if got.Reachable { + t.Errorf("Reachable: got true, want false on 500") + } + if got.HTTPStatus != 500 { + t.Errorf("HTTPStatus: got %d, want 500", got.HTTPStatus) + } + if !strings.Contains(got.Hint, "server logs") { + t.Errorf("Hint: got %q, want mention of server logs (default 5xx branch)", got.Hint) + } + if got.ErrorMsg == "" { + t.Errorf("ErrorMsg: got empty, want response body snippet") + } +} + +func TestRunProbe_Timeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(300 * time.Millisecond) + fmt.Fprint(w, `{"data":[]}`) + })) + defer srv.Close() + + got := runProbe(context.Background(), ProbeConfig{ + Enabled: true, Endpoint: srv.URL + "/v1", Model: "m", Timeout: 50 * time.Millisecond, + }) + + if got.Reachable { + t.Errorf("Reachable: got true, want false on timeout") + } + if got.ErrorMsg == "" { + t.Errorf("ErrorMsg: got empty, want timeout error text") + } +} + +func TestRunProbe_BadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `not json at all`) + })) + defer srv.Close() + + got := runProbe(context.Background(), ProbeConfig{ + Enabled: true, Endpoint: srv.URL + "/v1", Model: "m", Timeout: 2 * time.Second, + }) + + if got.Reachable { + t.Errorf("Reachable: got true, want false on invalid JSON") + } + if !strings.Contains(got.ErrorMsg, "parsing") { + t.Errorf("ErrorMsg: got %q, want mention of parse error", got.ErrorMsg) + } +} + +func TestFormatHuman_HappyPath(t *testing.T) { + r := ProbeResult{ + Endpoint: "http://host:8080/v1", + Model: "gemma", + Enabled: true, + Reachable: true, + HTTPStatus: 200, + LatencyMS: 123, + ModelsSeen: []string{"gemma", "mistral"}, + ModelMatch: true, + } + out := formatHuman(r) + + for _, want := range []string{ + "endpoint: http://host:8080/v1", + "model: gemma", + "enabled: yes", + "reachable: yes (HTTP 200, 123ms)", + "models seen: gemma, mistral", + "model match: OK", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q. Got:\n%s", want, out) + } + } +} + +func TestFormatHuman_Disabled(t *testing.T) { + out := formatHuman(ProbeResult{Endpoint: "http://x/v1", Model: "y", Enabled: false}) + if !strings.Contains(out, "disabled") { + t.Errorf("disabled output missing 'disabled' marker. Got:\n%s", out) + } +} + +func TestFormatHuman_Unreachable(t *testing.T) { + r := ProbeResult{ + Endpoint: "http://x/v1", Model: "y", Enabled: true, + HTTPStatus: 401, ErrorMsg: `{"error":"nope"}`, + Hint: "check DEVKIT_LOCAL_API_KEY — endpoint requires auth", + } + out := formatHuman(r) + + for _, want := range []string{"reachable: NO", "HTTP 401", "hint:", "DEVKIT_LOCAL_API_KEY", "body:"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q. Got:\n%s", want, out) + } + } +} + +func TestFormatJSON(t *testing.T) { + r := ProbeResult{ + Endpoint: "http://x/v1", Model: "m", Enabled: true, + Reachable: true, HTTPStatus: 200, LatencyMS: 42, + ModelsSeen: []string{"m", "other"}, ModelMatch: true, + } + out, err := formatJSON(r) + if err != nil { + t.Fatalf("formatJSON err: %v", err) + } + + var parsed map[string]any + if err := json.Unmarshal(out, &parsed); err != nil { + t.Fatalf("output not valid JSON: %v\n%s", err, out) + } + if parsed["endpoint"] != "http://x/v1" { + t.Errorf("endpoint: got %v, want http://x/v1", parsed["endpoint"]) + } + if parsed["model_match"] != true { + t.Errorf("model_match: got %v, want true", parsed["model_match"]) + } + if parsed["http_status"].(float64) != 200 { + t.Errorf("http_status: got %v, want 200", parsed["http_status"]) + } +} diff --git a/src/runners/local.go b/src/runners/local.go index b0ccd61..8cd5719 100644 --- a/src/runners/local.go +++ b/src/runners/local.go @@ -61,16 +61,16 @@ type localChatResponse struct { func (r *LocalRunner) Name() string { return "local" } func (r *LocalRunner) Available() bool { - if os.Getenv("DEVKIT_LOCAL_ENABLED") != "1" { + if !LocalEnabled() { return false } - endpoint := localEndpoint() + endpoint := LocalEndpoint() req, err := http.NewRequest(http.MethodGet, endpoint+"/models", nil) if err != nil { localDebugf("local runner probe: building request for %s: %v", endpoint, err) return false } - if key := os.Getenv("DEVKIT_LOCAL_API_KEY"); key != "" { + if key := LocalAPIKey(); key != "" { req.Header.Set("Authorization", "Bearer "+key) } client := &http.Client{Timeout: 3 * time.Second} @@ -106,23 +106,23 @@ func (r *LocalRunner) Run(ctx context.Context, prompt string, opts RunOpts) (Run } body, err := json.Marshal(localChatRequest{ - Model: localModel(), + Model: LocalModel(), Messages: messages, }) if err != nil { return RunResult{ExitCode: 1}, fmt.Errorf("marshaling local request: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, localEndpoint()+"/chat/completions", bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, LocalEndpoint()+"/chat/completions", bytes.NewReader(body)) if err != nil { return RunResult{ExitCode: 1}, fmt.Errorf("building local request: %w", err) } req.Header.Set("Content-Type", "application/json") - if key := os.Getenv("DEVKIT_LOCAL_API_KEY"); key != "" { + if key := LocalAPIKey(); key != "" { req.Header.Set("Authorization", "Bearer "+key) } - client := &http.Client{Timeout: localTimeout()} + client := &http.Client{Timeout: LocalTimeout()} resp, err := client.Do(req) if err != nil { return RunResult{ExitCode: 1}, fmt.Errorf("local endpoint request failed: %w", err) @@ -167,10 +167,12 @@ func envDefault(key, fallback string) string { return fallback } -func localEndpoint() string { return envDefault("DEVKIT_LOCAL_ENDPOINT", "http://localhost:11434/v1") } -func localModel() string { return envDefault("DEVKIT_LOCAL_MODEL", "qwen3:32b") } +func LocalEnabled() bool { return os.Getenv("DEVKIT_LOCAL_ENABLED") == "1" } +func LocalAPIKey() string { return os.Getenv("DEVKIT_LOCAL_API_KEY") } +func LocalEndpoint() string { return envDefault("DEVKIT_LOCAL_ENDPOINT", "http://localhost:11434/v1") } +func LocalModel() string { return envDefault("DEVKIT_LOCAL_MODEL", "qwen3:32b") } -func localTimeout() time.Duration { +func LocalTimeout() time.Duration { const fallback = 10 * time.Minute raw := os.Getenv("DEVKIT_LOCAL_TIMEOUT") if raw == "" { diff --git a/src/runners/local_test.go b/src/runners/local_test.go index d7ef53f..61e1050 100644 --- a/src/runners/local_test.go +++ b/src/runners/local_test.go @@ -302,11 +302,11 @@ func TestLocalRunner_EndpointAndModelDefaults(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Setenv("DEVKIT_LOCAL_ENDPOINT", tt.endpointEnv) t.Setenv("DEVKIT_LOCAL_MODEL", tt.modelEnv) - if got := localEndpoint(); got != tt.wantEndpoint { - t.Errorf("localEndpoint() = %q, want %q", got, tt.wantEndpoint) + if got := LocalEndpoint(); got != tt.wantEndpoint { + t.Errorf("LocalEndpoint() = %q, want %q", got, tt.wantEndpoint) } - if got := localModel(); got != tt.wantModel { - t.Errorf("localModel() = %q, want %q", got, tt.wantModel) + if got := LocalModel(); got != tt.wantModel { + t.Errorf("LocalModel() = %q, want %q", got, tt.wantModel) } }) } @@ -327,8 +327,8 @@ func TestLocalRunner_Timeout(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("DEVKIT_LOCAL_TIMEOUT", tt.env) - if got := localTimeout(); got != tt.want { - t.Errorf("localTimeout() = %v, want %v", got, tt.want) + if got := LocalTimeout(); got != tt.want { + t.Errorf("LocalTimeout() = %v, want %v", got, tt.want) } }) }