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:
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user