feat(provider): local-first routing — native engine :36900 when up, cloud fallback

g:hanzo_route=auto probes the local Hanzo engine /health and routes :AI to its
OpenAI-compatible API directly (coherent, no auth) when running, else falls back
to the cloud gateway/account creds. Adds g:hanzo_local_url/g:hanzo_local_model;
:AIStatus shows the active route. Does not route through `dev` (agent harness
garbles small local models).
This commit is contained in:
hanzo-dev
2026-06-27 21:32:14 +00:00
parent 010133988b
commit 334a0941ce
8 changed files with 633 additions and 62 deletions
+26 -3
View File
@@ -36,10 +36,33 @@ hanzo.vim/
- `README.md` -- Project documentation
- `pyproject.toml` -- Python project config
- `Dockerfile` -- Container build
- `plugin/hanzo.vim` -- commands + config defaults (incl. `g:ai_cli`)
- `autoload/hanzo.vim` -- command impls (incl. `:AILogin` family)
- `plugin/hanzo.vim` -- commands + config defaults (incl. `g:ai_cli`,
`g:hanzo_route`); registers the Hanzo provider as Neural's default so `:AI`
is local-first out of the box
- `autoload/hanzo.vim` -- command impls (incl. `:AILogin` family,
`hanzo#NeuralProvider`, `hanzo#ResolveRoute`, `:AIStatus` route line)
- `src/neural/provider/hanzo.py` -- AI provider + shared credential resolver
- `doc/hanzo.txt` -- `:help hanzo-ailogin`
+ `resolve_endpoint()` local-first routing
- `doc/hanzo.txt` -- `:help hanzo-ailogin`, `:help hanzo-routing`
## Local-first routing (native engine vs cloud account)
`:AI`/`:Hanzo` -> Neural -> `src/neural/provider/hanzo.py`. `resolve_endpoint()`
is the ONE place that picks the target: native local engine when up, else cloud.
- `g:hanzo_route` = `auto` (default) | `local` | `cloud`.
- `auto`: `GET {g:hanzo_local_url}/health` (cached ~5s); 2xx -> local (no auth),
else cloud. An explicitly chosen cloud vendor (`g:hanzo_provider`
anthropic/openai, tracked via `g:hanzo_provider_explicit`) with a resolved
cred wins first.
- local engine: `g:hanzo_local_url` (`http://127.0.0.1:36900`), model
`g:hanzo_local_model` (`default`), no auth -- it is spawned/kept alive by the
Hanzo desktop app, NOT by this plugin. Do NOT route through `dev` (the agent
harness garbles small local models); talk to its OpenAI HTTP API directly.
- cloud account: `g:hanzo_cloud_url` (`https://api.hanzo.ai`), creds via
`resolve_shared_credential`/`build_auth_headers`. `g:hanzo_llm_gateway`
(default "") optionally overrides the cloud base (e.g. a local `:4000`).
- Both endpoints are OpenAI-compatible `POST {base}/v1/chat/completions`.
- `python3 src/neural/provider/hanzo.py --resolve` prints the resolved route as
JSON (stdin `{"config":{...}}`); `:AIStatus` uses it via `hanzo#ResolveRoute`.
## AI Login (multi-vendor, not vendor-locked)
`:AILogin` / `:AILogout` / `:AIStatus` delegate OAuth to the installed `dev`
+39 -5
View File
@@ -17,8 +17,11 @@ A multi-provider AI coding agent plugin for Vim/Neovim supporting Claude, GPT-4,
* Only dependency is Python 3.10+ (required for security and libraries)
### Hanzo Extensions
* **Local-first routing**: `:AI` uses the native local Hanzo engine when it's
running and falls back to your cloud account otherwise (see below)
* **Multi-Provider**: Claude, GPT-4, Gemini, Ollama, any OpenAI-compatible API
* **LLM Gateway**: Unified proxy for 100+ providers via `http://localhost:4000`
* **Cloud account**: the Hanzo gateway at `https://api.hanzo.ai` proxies 100+
providers with your account credentials
* **MCP/ZAP Bridge**: WebSocket bridge for AI agent control (hanzo-mcp compatible)
* **REPL Integration**: Jupyter kernel support for interactive code evaluation
* **Extended Commands**: Complete, Explain, Refactor, Fix, Tests, Docs, Review
@@ -121,6 +124,40 @@ Try typing `:Neural say hello`, and if all goes well the machine learning
tool will say "hello" to you in the current buffer. Type `:help neural` to
see the full documentation.
### Local-first routing (local engine vs cloud)
`:AI` (and `:Hanzo`) talk to the **native local Hanzo engine** when it is
running, and fall back to your **cloud account** otherwise — *native node if
running, else cloud account*. The local engine is the OpenAI-compatible server
the **Hanzo desktop app** spawns and keeps alive (its node manager); hanzo.vim
does not start or stop it. When the engine is up, requests go straight to it
with **no auth** and stay on your machine.
Routing is controlled by `g:hanzo_route`:
| `g:hanzo_route` | Behaviour |
|-----------------|-----------|
| `auto` (default) | Probe the local engine's `/health`; use it if up, else cloud. An explicitly chosen cloud vendor (`g:hanzo_provider` = `anthropic`/`openai`) with a resolved credential takes precedence. |
| `local` | Always the native local engine (no auth). |
| `cloud` | Always the cloud account (resolved credentials). |
```vim
" Local-first routing
let g:hanzo_route = 'auto' " auto | local | cloud
let g:hanzo_local_url = 'http://127.0.0.1:36900' " native engine
let g:hanzo_local_model = 'default' " model the engine serves
let g:hanzo_cloud_url = 'https://api.hanzo.ai' " cloud account gateway
" Optional: point the cloud base at a local gateway instead of api.hanzo.ai
" let g:hanzo_llm_gateway = 'http://localhost:4000'
```
`:AIStatus` shows which route is active right now, e.g.
`route=local engine http://127.0.0.1:36900 (default) [UP]` or
`route=cloud provider=openai (cloud https://api.hanzo.ai)`.
The health probe is cached for a few seconds, so typing does not re-probe the
engine on every keystroke.
### Hanzo Configuration
```vim
@@ -131,9 +168,6 @@ let g:hanzo_provider = 'anthropic' " anthropic, openai, google, ollama
" Mode selection
let g:hanzo_mode = 'api' " api, mcp, or ollama
" LLM Gateway (optional)
let g:hanzo_llm_gateway = 'http://localhost:4000'
" Enable default keybinds
let g:hanzo_set_default_keybinds = 1
```
@@ -252,7 +286,7 @@ value. You can set a keybind to stop Neural by mapping to `<Plug>(neural_stop)`.
| `:HanzoMode <mode>` | Set mode (api/mcp/ollama) |
| `:AILogin [vendor]` | Log in: Claude / ChatGPT / Hanzo / API key |
| `:AILogout` | Clear shared credentials (`dev logout`) |
| `:AIStatus` (`:AIWhoami`) | Show login status + active provider |
| `:AIStatus` (`:AIWhoami`) | Show active route (local/cloud) + login status + provider |
| `:HanzoLogin` | Alias for `:AILogin hanzo` |
### Hanzo Keybindings
+72 -12
View File
@@ -22,8 +22,33 @@ function! hanzo#GetConfig() abort
\ 'debug': get(g:, 'hanzo_debug', 0),
\ 'model': get(g:, 'hanzo_model', 'claude-sonnet-4-20250514'),
\ 'provider': get(g:, 'hanzo_provider', 'anthropic'),
\ 'provider_explicit': get(g:, 'hanzo_provider_explicit', 0),
\ 'mode': get(g:, 'hanzo_mode', 'api'),
\ 'llm_gateway': get(g:, 'hanzo_llm_gateway', 'http://localhost:4000'),
\ 'route': get(g:, 'hanzo_route', 'auto'),
\ 'local_url': get(g:, 'hanzo_local_url', 'http://127.0.0.1:36900'),
\ 'local_model': get(g:, 'hanzo_local_model', 'default'),
\ 'cloud_url': get(g:, 'hanzo_cloud_url', 'https://api.hanzo.ai'),
\ 'llm_gateway': get(g:, 'hanzo_llm_gateway', ''),
\}
endfunction
" Build the Neural provider entry for the Hanzo provider, carrying the routing
" config the Python provider needs (g:hanzo_route + the endpoints). This is the
" single source for both :AI (default registration) and :Hanzo (hanzo#Chat).
function! hanzo#NeuralProvider() abort
let l:config = hanzo#GetConfig()
return {
\ 'type': 'hanzo',
\ 'mode': l:config.mode,
\ 'provider': l:config.provider,
\ 'provider_explicit': l:config.provider_explicit,
\ 'model': l:config.model,
\ 'route': l:config.route,
\ 'local_url': l:config.local_url,
\ 'local_model': l:config.local_model,
\ 'cloud_url': l:config.cloud_url,
\ 'llm_gateway': l:config.llm_gateway,
\}
endfunction
@@ -334,20 +359,12 @@ endfunction
" ============================================================================
function! hanzo#Chat(prompt) abort
" Configure Neural to use Hanzo provider
let l:config = hanzo#GetConfig()
" Set up Neural config
" Route :Hanzo/:H through the Hanzo provider with local-first routing.
if !exists('g:neural')
let g:neural = {}
endif
let g:neural.providers = [{
\ 'type': 'hanzo',
\ 'model': l:config.model,
\ 'mode': l:config.mode,
\ 'url': l:config.llm_gateway,
\}]
let g:neural.providers = [hanzo#NeuralProvider()]
" Forward to Neural
call neural#Prompt(a:prompt)
@@ -671,6 +688,8 @@ function! s:LoginApiKey(provider) abort
endif
let g:hanzo_provider = l:provider
" Logging in is a deliberate provider choice: let auto-routing honor it.
let g:hanzo_provider_explicit = 1
if s:HasDev()
" Pipe the key via stdin: never on the command line or in history.
@@ -695,6 +714,8 @@ function! s:LoginOAuth(vendor) abort
let g:hanzo_provider = s:NormalizeVendor(a:vendor) ==# 'hanzo'
\ ? 'hanzo'
\ : 'openai'
" Logging in is a deliberate provider choice: let auto-routing honor it.
let g:hanzo_provider_explicit = 1
echo 'Starting ' . a:vendor . ' login via '
\ . s:DevCli() . ' (device-code)...'
call s:RunTerminal(hanzo#LoginArgv(a:vendor))
@@ -752,8 +773,47 @@ function! hanzo#Logout() abort
endif
endfunction
" :AIStatus / :AIWhoami -- show login state and the active provider.
" Ask the Python provider which endpoint a request would use right now (it
" probes the local engine /health). Returns {} when it cannot be determined.
function! hanzo#ResolveRoute() abort
let l:script = s:script_dir . '/src/neural/provider/hanzo.py'
if !filereadable(l:script)
return {}
endif
let l:input = json_encode({'config': hanzo#NeuralProvider()})
let l:out = system('python3 ' . shellescape(l:script) . ' --resolve', l:input)
if v:shell_error != 0 || empty(l:out)
return {}
endif
try
let l:parsed = json_decode(l:out)
catch
return {}
endtry
return type(l:parsed) == v:t_dict ? l:parsed : {}
endfunction
" :AIStatus / :AIWhoami -- show the active route, login state, and provider.
function! hanzo#Status() abort
let l:route = hanzo#ResolveRoute()
if empty(l:route)
echo 'route: unknown (could not probe local engine)'
elseif get(l:route, 'route', '') ==# 'local'
echo printf('route=local engine %s (%s) [UP]',
\ get(l:route, 'base_url', ''), get(l:route, 'model', ''))
else
let l:note = get(l:route, 'authenticated', v:false)
\ ? '' : ' [no credential]'
echo printf('route=cloud provider=%s (cloud %s)%s',
\ get(l:route, 'provider', ''), get(l:route, 'base_url', ''), l:note)
endif
if s:HasDev()
" `dev login status` reports state only; it never prints the secret.
echo system(s:DevCli() . ' login status')
+61 -1
View File
@@ -9,6 +9,9 @@ CONTENTS *hanzo-contents*
1.3 Shared credentials ...................... |hanzo-ailogin-credentials|
1.4 Headless / SSH .......................... |hanzo-ailogin-headless|
1.5 Configuration ........................... |hanzo-ailogin-config|
2. Local-first routing ........................ |hanzo-routing|
2.1 g:hanzo_route ........................... |g:hanzo_route|
2.2 Endpoints ............................... |hanzo-routing-endpoints|
===============================================================================
1. AI Login *hanzo-ailogin*
@@ -40,7 +43,11 @@ authenticated.
`:AIStatus` *:AIStatus*
`:AIWhoami` *:AIWhoami*
Shows `dev login status` and the active provider (|g:hanzo_provider|).
Shows the active route (local vs cloud, see |hanzo-routing|), then
`dev login status` and the active provider (|g:hanzo_provider|). Example: >
route=local engine http://127.0.0.1:36900 (default) [UP]
route=cloud provider=openai (cloud https://api.hanzo.ai)
<
`:HanzoLogin` *:HanzoLogin*
@@ -103,5 +110,58 @@ If `g:ai_cli` is not on $PATH, `:AILogin` falls back to prompting for an API
key, stores it where the provider reads it, and notes that the `dev` CLI is
needed for OAuth logins.
===============================================================================
2. Local-first routing *hanzo-routing*
`:AI` and `:Hanzo` talk to the native local Hanzo engine when it is running and
fall back to your cloud account otherwise -- "native node if running, else
cloud account". The local engine is the OpenAI-compatible server the Hanzo
desktop app spawns and keeps alive (its node manager); hanzo.vim never starts
or stops it. When the engine is up, requests go to it directly with NO auth and
stay on your machine. See the active choice at any time with |:AIStatus|.
By default (no `g:neural` providers configured) hanzo.vim registers the Hanzo
provider as Neural's provider, so `:AI` is local-first out of the box.
-------------------------------------------------------------------------------
2.1 g:hanzo_route *g:hanzo_route*
`g:hanzo_route` (default: "auto")
auto Probe the local engine's /health; use it if up, else the cloud
account. An explicitly chosen cloud vendor (|g:hanzo_provider| set to
"anthropic"/"openai") with a resolved credential takes precedence, so
a deliberate cloud choice still wins.
local Always the native local engine (no auth).
cloud Always the cloud account (resolved credentials). >
let g:hanzo_route = 'auto'
<
The /health probe is cached for a few seconds so typing does not re-probe the
engine on every keystroke.
-------------------------------------------------------------------------------
2.2 Endpoints *hanzo-routing-endpoints*
*g:hanzo_local_url*
`g:hanzo_local_url` (default: "http://127.0.0.1:36900")
The native local engine's OpenAI-compatible base URL.
*g:hanzo_local_model*
`g:hanzo_local_model` (default: "default")
The model id the local engine serves.
*g:hanzo_cloud_url*
`g:hanzo_cloud_url` (default: "https://api.hanzo.ai")
The cloud account gateway used on the cloud route. Authenticated with the
resolved account credentials (see |hanzo-ailogin-credentials|).
*g:hanzo_llm_gateway*
`g:hanzo_llm_gateway` (default: "")
Optional override of the cloud base, e.g. a local gateway on
"http://localhost:4000". Empty means use |g:hanzo_cloud_url|.
All endpoints are OpenAI-compatible: `POST {base}/v1/chat/completions`.
===============================================================================
vim:tw=78:ts=8:ft=help:norl:
+9
View File
@@ -15,6 +15,11 @@ M.config = {
-- Model settings
model = "claude-sonnet-4-20250514",
mode = "api",
-- Local-first routing (native engine vs cloud account)
route = "auto", -- auto | local | cloud
local_url = "http://127.0.0.1:36900",
local_model = "default",
cloud_url = "https://api.hanzo.ai",
-- Keybinds
set_keymaps = false,
}
@@ -26,6 +31,10 @@ function M.setup(opts)
-- Sync with Vim globals
vim.g.hanzo_model = M.config.model
vim.g.hanzo_mode = M.config.mode
vim.g.hanzo_route = M.config.route
vim.g.hanzo_local_url = M.config.local_url
vim.g.hanzo_local_model = M.config.local_model
vim.g.hanzo_cloud_url = M.config.cloud_url
if M.config.set_keymaps then
M.setup_keymaps()
+24 -1
View File
@@ -38,9 +38,23 @@ let g:hanzo_debug = get(g:, 'hanzo_debug', 0)
" Model settings
let g:hanzo_model = get(g:, 'hanzo_model', 'claude-sonnet-4-20250514')
" Capture whether the user chose a provider before we apply the default, so
" auto-routing can tell a deliberate cloud choice from the anthropic default.
let g:hanzo_provider_explicit = get(g:, 'hanzo_provider_explicit', exists('g:hanzo_provider'))
let g:hanzo_provider = get(g:, 'hanzo_provider', 'anthropic')
let g:hanzo_mode = get(g:, 'hanzo_mode', 'api')
let g:hanzo_llm_gateway = get(g:, 'hanzo_llm_gateway', 'http://localhost:4000')
" Local-first routing: native engine when up, else cloud account.
" auto -> probe the local engine /health; local if up, else cloud
" local -> always the native local engine (no auth)
" cloud -> always the cloud account (resolved creds)
let g:hanzo_route = get(g:, 'hanzo_route', 'auto')
let g:hanzo_local_url = get(g:, 'hanzo_local_url', 'http://127.0.0.1:36900')
let g:hanzo_local_model = get(g:, 'hanzo_local_model', 'default')
let g:hanzo_cloud_url = get(g:, 'hanzo_cloud_url', 'https://api.hanzo.ai')
" Optional override of the cloud base (e.g. a local gateway on :4000). Empty
" means use g:hanzo_cloud_url.
let g:hanzo_llm_gateway = get(g:, 'hanzo_llm_gateway', '')
" AI login / CLI delegation (the binary :AILogin reuses for OAuth flows)
let g:ai_cli = get(g:, 'ai_cli', 'dev')
@@ -48,6 +62,15 @@ let g:ai_cli = get(g:, 'ai_cli', 'dev')
" Keybind settings
let g:hanzo_set_default_keybinds = get(g:, 'hanzo_set_default_keybinds', 0)
" Local-first by default: route :AI (Neural) through the Hanzo provider unless
" the user configured their own providers. An existing g:neural is untouched.
if !exists('g:neural')
let g:neural = {}
endif
if type(g:neural) == v:t_dict && !has_key(g:neural, 'providers')
let g:neural.providers = [hanzo#NeuralProvider()]
endif
" ============================================================================
" Bridge Commands
" ============================================================================
+246 -40
View File
@@ -15,7 +15,10 @@ import platform
import ssl
import subprocess
import sys
from typing import Any, cast
import time
import urllib.error
import urllib.request
from typing import Any, NamedTuple, cast
# Try to import websockets for MCP/ZAP bridge
try:
@@ -25,12 +28,28 @@ except ImportError:
HAS_WEBSOCKETS = False
# Constants
HANZO_LLM_GATEWAY = "http://localhost:4000" # Default LLM Gateway
#
# Local-first routing endpoints. The native Hanzo engine (kept alive by the
# Hanzo desktop app's node manager) speaks the OpenAI-compatible API on :36900
# and needs no auth. The cloud account is the Hanzo gateway, reached with the
# resolved account credentials.
HANZO_LOCAL_URL = "http://127.0.0.1:36900" # native local engine
HANZO_LOCAL_MODEL = "default" # model id the local engine serves
HANZO_CLOUD_URL = "https://api.hanzo.ai" # cloud account gateway
HANZO_MCP_BRIDGE = "ws://localhost:9228" # Vim bridge port
DATA_HEADER = "data: "
DONE_MARKER = "[DONE]"
ANTHROPIC_VERSION = "2023-06-01"
# A /health probe result is trusted for this long (per URL), so a burst of
# requests/keystrokes does not re-probe the local engine each time.
_HEALTH_TTL_SECONDS = 5.0
_health_cache: "dict[str, tuple[float, bool]]" = {}
# Cloud vendors whose explicit selection (with a resolved credential) wins
# over the local engine even in auto mode.
_CLOUD_VENDORS = ("anthropic", "openai")
# Keys a `dev`-written auth.json may carry, in the order each store prefers.
_CODEX_KEYS = ("OPENAI_API_KEY", "openai_api_key")
_HANZO_KEYS = ("openai_api_key", "OPENAI_API_KEY")
@@ -214,6 +233,14 @@ class HanzoConfig:
# Model settings
model: str = "claude-sonnet-4-20250514",
provider: str = "anthropic", # anthropic, openai, google, ollama
provider_explicit: bool = False,
# Local-first routing
route: str = "auto", # auto | local | cloud
local_url: str = HANZO_LOCAL_URL,
local_model: str = HANZO_LOCAL_MODEL,
cloud_url: str = HANZO_CLOUD_URL,
gateway: str = "", # optional cloud-base override (e.g. :4000)
# Generation settings
temperature: float = 0.2,
@@ -231,6 +258,12 @@ class HanzoConfig:
self.api_key = api_key
self.model = model
self.provider = provider
self.provider_explicit = provider_explicit
self.route = route
self.local_url = local_url
self.local_model = local_model
self.cloud_url = cloud_url
self.gateway = gateway
self.temperature = temperature
self.top_p = top_p
self.max_tokens = max_tokens
@@ -248,16 +281,12 @@ def load_config(raw_config: dict[str, Any]) -> HanzoConfig:
if mode not in ("api", "mcp", "ollama"):
mode = "api"
# URL defaults
# URL defaults. In api mode the request target (local engine vs cloud
# account) is chosen by resolve_endpoint(), so url stays empty here; only
# ollama uses this field directly.
url = raw_config.get("url", "")
if not url:
if mode == "ollama":
url = "http://localhost:11434"
elif mode == "mcp":
url = "" # Uses WebSocket
else:
# Check for LLM Gateway first, then direct API
url = os.environ.get("HANZO_LLM_GATEWAY", HANZO_LLM_GATEWAY)
if not url and mode == "ollama":
url = "http://localhost:11434"
# Model selection
model = raw_config.get("model", "claude-sonnet-4-20250514")
@@ -295,6 +324,25 @@ def load_config(raw_config: dict[str, Any]) -> HanzoConfig:
if api_key:
break
# Explicit provider intent: set by the user via g:hanzo_provider /
# :AILogin. Distinguishes a deliberate cloud choice from the default,
# so auto-routing does not silently favour cloud just because a key
# happens to be in the environment.
provider_explicit = bool(raw_config.get("provider_explicit", False))
# Local-first routing config (mirrors the g:hanzo_* vim defaults).
route = raw_config.get("route", "auto")
if route not in ("auto", "local", "cloud"):
route = "auto"
local_url = raw_config.get("local_url", "") or HANZO_LOCAL_URL
local_model = raw_config.get("local_model", "") or HANZO_LOCAL_MODEL
cloud_url = raw_config.get("cloud_url", "") or HANZO_CLOUD_URL
gateway = (
raw_config.get("llm_gateway", "")
or os.environ.get("HANZO_LLM_GATEWAY", "")
)
# Generation settings
temperature = float(raw_config.get("temperature", 0.2))
top_p = float(raw_config.get("top_p", 1.0))
@@ -306,10 +354,13 @@ def load_config(raw_config: dict[str, Any]) -> HanzoConfig:
# System prompt
system_prompt = raw_config.get("system_prompt", "")
if not system_prompt:
system_prompt = """You are an expert programmer assistant integrated into Vim/Neovim.
Provide concise, accurate code and explanations.
When writing code, match the existing style in the file.
Focus on the specific task requested."""
system_prompt = (
"You are an expert programmer assistant integrated into "
"Vim/Neovim.\n"
"Provide concise, accurate code and explanations.\n"
"When writing code, match the existing style in the file.\n"
"Focus on the specific task requested."
)
return HanzoConfig(
mode=mode,
@@ -317,6 +368,12 @@ Focus on the specific task requested."""
api_key=api_key,
model=model,
provider=provider,
provider_explicit=provider_explicit,
route=route,
local_url=local_url,
local_model=local_model,
cloud_url=cloud_url,
gateway=gateway,
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
@@ -325,10 +382,120 @@ Focus on the specific task requested."""
)
# ---------------------------------------------------------------------------
# Local-first routing
#
# resolve_endpoint() is the single place that decides where a request goes:
# the native local engine (no auth) when it is up, else the cloud account.
# Both speak the OpenAI-compatible POST {base}/v1/chat/completions, so the
# request/stream code below is shared.
# ---------------------------------------------------------------------------
class Endpoint(NamedTuple):
"""A resolved request target: where to send and how to authenticate."""
route: str # "local" or "cloud"
base_url: str
model: str
headers: dict[str, str]
def _probe_health(url: str, timeout: float = 0.7) -> bool:
"""Return True if ``GET {url}/health`` answers 2xx.
The result is cached per URL for a few seconds so a burst of requests
does not re-probe the engine on every keystroke.
"""
now = time.monotonic()
cached = _health_cache.get(url)
if cached is not None and now - cached[0] < _HEALTH_TTL_SECONDS:
return cached[1]
ok = False
try:
req = urllib.request.Request(f"{url}/health", method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
status = getattr(resp, "status", 0)
ok = isinstance(status, int) and 200 <= status < 300
except (urllib.error.URLError, OSError, ValueError):
ok = False
_health_cache[url] = (now, ok)
return ok
def _local_endpoint(config: HanzoConfig) -> Endpoint:
"""Native local engine endpoint. It needs no auth header."""
return Endpoint(
route="local",
base_url=config.local_url,
model=config.local_model,
headers={"Content-Type": "application/json"},
)
def _cloud_endpoint(config: HanzoConfig) -> Endpoint:
"""Cloud account endpoint: the Hanzo gateway (or a gateway override).
An explicitly chosen cloud vendor uses its own credential; otherwise the
Hanzo account token (Bearer) authenticates against the gateway.
"""
base = config.gateway or config.cloud_url
if config.provider_explicit and config.provider in _CLOUD_VENDORS:
provider, api_key = config.provider, config.api_key
else:
provider = "hanzo"
api_key = resolve_shared_credential("hanzo") or config.api_key
return Endpoint(
route="cloud",
base_url=base,
model=config.model,
headers=build_auth_headers(provider, api_key),
)
def resolve_endpoint(config: HanzoConfig) -> Endpoint:
"""Pick the local engine or the cloud account for this request.
- ``local``: always the native engine (no auth).
- ``cloud``: always the cloud account (resolved account creds).
- ``auto`` (default): an explicitly chosen cloud vendor with a resolved
credential wins; else the local engine when its /health is up; else
the cloud account.
"""
if config.route == "local":
return _local_endpoint(config)
if config.route == "cloud":
return _cloud_endpoint(config)
explicit_cloud = (
config.provider_explicit
and config.provider in _CLOUD_VENDORS
and bool(config.api_key)
)
if explicit_cloud:
return _cloud_endpoint(config)
if _probe_health(config.local_url):
return _local_endpoint(config)
return _cloud_endpoint(config)
async def call_via_mcp(config: HanzoConfig, prompt: str) -> None:
"""Call LLM via MCP/ZAP WebSocket bridge."""
if not HAS_WEBSOCKETS:
raise RuntimeError("websockets not installed. Run: pip install websockets")
raise RuntimeError(
"websockets not installed. Run: pip install websockets",
)
uri = f"ws://localhost:{config.mcp_bridge_port + 1}"
@@ -345,7 +512,7 @@ async def call_via_mcp(config: HanzoConfig, prompt: str) -> None:
"max_tokens": config.max_tokens,
"stream": True,
"system_prompt": config.system_prompt,
}
},
}
await ws.send(json.dumps(request))
@@ -360,25 +527,31 @@ async def call_via_mcp(config: HanzoConfig, prompt: str) -> None:
break
print()
except ConnectionRefusedError:
raise RuntimeError(f"Cannot connect to MCP bridge on port {config.mcp_bridge_port + 1}. Start Vim with :HanzoStart")
except ConnectionRefusedError as error:
port = config.mcp_bridge_port + 1
raise RuntimeError(
f"Cannot connect to MCP bridge on port {port}. "
"Start Vim with :HanzoStart",
) from error
def call_openai_compatible(config: HanzoConfig, prompt: str) -> None:
"""Call OpenAI-compatible API (works with LLM Gateway, OpenAI, Anthropic via proxy)."""
import urllib.request
import urllib.error
"""Stream a completion from the resolved OpenAI-compatible endpoint.
headers = build_auth_headers(config.provider, config.api_key)
The target (local engine vs cloud account) is chosen by
``resolve_endpoint``; both speak ``POST {base}/v1/chat/completions``.
"""
target = resolve_endpoint(config)
headers = dict(target.headers)
# Build messages
messages = []
messages: list[dict[str, str]] = []
if config.system_prompt:
messages.append({"role": "system", "content": config.system_prompt})
messages.append({"role": "user", "content": prompt})
data: dict[str, Any] = {
"model": config.model,
"model": target.model,
"messages": messages,
"temperature": config.temperature,
"max_tokens": config.max_tokens,
@@ -386,14 +559,20 @@ def call_openai_compatible(config: HanzoConfig, prompt: str) -> None:
"stream": True,
}
# Determine endpoint
if config.provider == "anthropic" and "api.anthropic.com" in config.url:
endpoint = f"{config.url}/v1/messages"
else:
endpoint = f"{config.url}/v1/chat/completions"
# Anthropic's native API uses /v1/messages; the local engine and the
# Hanzo gateway are OpenAI-compatible.
is_anthropic_native = (
config.provider == "anthropic"
and "api.anthropic.com" in target.base_url
)
request_url = (
f"{target.base_url}/v1/messages"
if is_anthropic_native
else f"{target.base_url}/v1/chat/completions"
)
req = urllib.request.Request(
endpoint,
request_url,
data=json.dumps(data).encode("utf-8"),
headers=headers,
method="POST",
@@ -452,29 +631,28 @@ def call_openai_compatible(config: HanzoConfig, prompt: str) -> None:
err_data = json.loads(message)
if "error" in err_data:
message = err_data["error"].get("message", message)
except:
except ValueError:
pass
raise RuntimeError(f"API error ({error.code}): {message}")
raise RuntimeError(
f"API error ({error.code}): {message}",
) from error
def call_ollama(config: HanzoConfig, prompt: str) -> None:
"""Call local Ollama instance."""
import urllib.request
import urllib.error
messages = []
messages: list[dict[str, str]] = []
if config.system_prompt:
messages.append({"role": "system", "content": config.system_prompt})
messages.append({"role": "user", "content": prompt})
data = {
data: dict[str, Any] = {
"model": config.model,
"messages": messages,
"stream": True,
"options": {
"temperature": config.temperature,
"num_predict": config.max_tokens,
}
},
}
req = urllib.request.Request(
@@ -505,11 +683,39 @@ def call_ollama(config: HanzoConfig, prompt: str) -> None:
print()
except urllib.error.HTTPError as error:
raise RuntimeError(f"Ollama error ({error.code}): {error.read().decode()}")
body = error.read().decode()
raise RuntimeError(f"Ollama error ({error.code}): {body}") from error
def _print_resolution() -> None:
"""Print the resolved route as JSON for :AIStatus. Reads config on stdin.
Input: {"config": {...}} on a single line.
Output: {"route", "base_url", "model", "provider", "authenticated"}.
"""
payload = json.loads(sys.stdin.readline() or "{}")
config = load_config(payload.get("config", {}))
target = resolve_endpoint(config)
authenticated = bool(
target.headers.get("Authorization")
or target.headers.get("x-api-key"),
)
print(json.dumps({
"route": target.route,
"base_url": target.base_url,
"model": target.model,
"provider": config.provider,
"authenticated": authenticated,
}))
def main() -> None:
"""Main entry point."""
if len(sys.argv) > 1 and sys.argv[1] == "--resolve":
_print_resolution()
return
input_data = json.loads(sys.stdin.readline())
try:
+156
View File
@@ -1,4 +1,5 @@
import json
import socket
from pathlib import Path
import pytest
@@ -11,6 +12,34 @@ def _write(path: Path, data: dict[str, object]) -> None:
path.write_text(json.dumps(data), encoding="utf-8")
def _clear_cloud_env(monkeypatch: pytest.MonkeyPatch) -> None:
for var in (
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"HANZO_API_KEY",
"HANZO_LLM_GATEWAY",
):
monkeypatch.delenv(var, raising=False)
def _closed_port() -> int:
"""Return a localhost port that is bound then released, so closed."""
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = int(sock.getsockname()[1])
sock.close()
return port
def _always_up(_url: str, timeout: float = 0.7) -> bool:
return True
def _always_down(_url: str, timeout: float = 0.7) -> bool:
return False
def test_anthropic_prefers_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-xxx")
@@ -155,3 +184,130 @@ def test_load_config_resolves_from_store(
assert config.api_key == "gateway-token"
assert headers["Authorization"] == "Bearer gateway-token"
# ---------------------------------------------------------------------------
# Local-first routing (resolve_endpoint)
# ---------------------------------------------------------------------------
def test_route_local_uses_engine_no_auth(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_cloud_env(monkeypatch)
endpoint = hanzo.resolve_endpoint(hanzo.load_config({"route": "local"}))
assert endpoint.route == "local"
assert "36900" in endpoint.base_url
assert endpoint.model == "default"
assert "Authorization" not in endpoint.headers
assert "x-api-key" not in endpoint.headers
def test_route_cloud_uses_gateway_with_creds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_cloud_env(monkeypatch)
monkeypatch.setenv("HANZO_API_KEY", "tok")
endpoint = hanzo.resolve_endpoint(hanzo.load_config({"route": "cloud"}))
assert endpoint.route == "cloud"
assert endpoint.base_url == "https://api.hanzo.ai"
assert endpoint.headers["Authorization"] == "Bearer tok"
def test_auto_picks_local_when_engine_up(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_cloud_env(monkeypatch)
monkeypatch.setattr(hanzo, "_probe_health", _always_up)
endpoint = hanzo.resolve_endpoint(hanzo.load_config({"route": "auto"}))
assert endpoint.route == "local"
assert "36900" in endpoint.base_url
assert endpoint.model == "default"
assert "Authorization" not in endpoint.headers
def test_auto_falls_back_to_cloud_when_engine_down(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_cloud_env(monkeypatch)
monkeypatch.setattr(hanzo, "_probe_health", _always_down)
endpoint = hanzo.resolve_endpoint(hanzo.load_config({"route": "auto"}))
assert endpoint.route == "cloud"
assert endpoint.base_url == "https://api.hanzo.ai"
def test_auto_explicit_cloud_vendor_wins_over_local(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_cloud_env(monkeypatch)
# Engine is up, but the user explicitly chose a cloud vendor with a key.
monkeypatch.setattr(hanzo, "_probe_health", _always_up)
endpoint = hanzo.resolve_endpoint(hanzo.load_config({
"route": "auto",
"provider": "openai",
"provider_explicit": True,
"api_key": "sk-oai",
}))
assert endpoint.route == "cloud"
assert endpoint.headers["Authorization"] == "Bearer sk-oai"
def test_auto_default_provider_stays_local_even_with_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_cloud_env(monkeypatch)
# A key is present, but the anthropic default was not explicitly chosen,
# so local-first must still win when the engine is up.
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
monkeypatch.setattr(hanzo, "_probe_health", _always_up)
endpoint = hanzo.resolve_endpoint(hanzo.load_config({
"route": "auto",
"provider": "anthropic",
}))
assert endpoint.route == "local"
def test_gateway_override_replaces_cloud_base(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_cloud_env(monkeypatch)
endpoint = hanzo.resolve_endpoint(hanzo.load_config({
"route": "cloud",
"llm_gateway": "http://localhost:4000",
}))
assert endpoint.base_url == "http://localhost:4000"
def test_auto_dead_port_falls_back_to_cloud_no_crash(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Real probe (not monkeypatched) against a closed port: must refuse fast,
# report unhealthy, and fall back to cloud without raising.
_clear_cloud_env(monkeypatch)
dead = f"http://127.0.0.1:{_closed_port()}"
endpoint = hanzo.resolve_endpoint(hanzo.load_config({
"route": "auto",
"local_url": dead,
}))
assert endpoint.route == "cloud"
assert endpoint.base_url == "https://api.hanzo.ai"
def test_invalid_route_defaults_to_auto() -> None:
assert hanzo.load_config({"route": "nonsense"}).route == "auto"