Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,11 @@ Inside interactive mode, switch with `/model`:
/model ollama/llama3.1:8b
/model lm_studio/google/gemma-3-4b
/model llamacpp/llama-3.1-8b-instruct
/model custom-proxy/Azure AI/gpt-5.5
```

Supported local prefixes are `ollama/`, `vllm/`, `lm_studio/`, and
`llamacpp/`.
Supported local/custom prefixes are `ollama/`, `vllm/`, `lm_studio/`,
`llamacpp/`, and `custom-proxy/`.

```bash
LOCAL_LLM_BASE_URL=http://localhost:8000
Expand All @@ -99,7 +100,17 @@ Set `LOCAL_LLM_BASE_URL` and optional `LOCAL_LLM_API_KEY` to use one shared
local endpoint, or override a specific provider with its matching `*_BASE_URL`
/ `*_API_KEY` variable, such as `OLLAMA_BASE_URL` or `VLLM_API_KEY`.
Provider-specific variables take precedence over the shared local variables.
Base URLs may include or omit `/v1`.
Base URLs may include or omit `/v1` for the built-in local providers.

For a custom OpenAI-compatible proxy, set `CUSTOM_PROXY_BASE_URL` to the exact
OpenAI-compatible base URL and optional `CUSTOM_PROXY_API_KEY`, then use
`custom-proxy/<model>`:

```bash
CUSTOM_PROXY_BASE_URL="https://proxy.test/api/v1/"
CUSTOM_PROXY_API_KEY=<optional-proxy-api-key>
ml-intern --model "custom-proxy/Azure AI/gpt-5.5" "your prompt"
```

**CLI tool runtime:**

Expand Down
19 changes: 13 additions & 6 deletions agent/core/llm_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,21 @@ def _resolve_local_model_params(
or os.environ.get(LOCAL_MODEL_BASE_URL_ENV)
or provider["base_url_default"]
)
if not raw_base:
raise ValueError(
f"Set {provider['base_url_env']} or {LOCAL_MODEL_BASE_URL_ENV} "
f"to use {model_name}"
)
api_key = (
os.environ.get(provider["api_key_env"])
or os.environ.get(LOCAL_MODEL_API_KEY_ENV)
or LOCAL_MODEL_API_KEY_DEFAULT
)
return {
"model": f"openai/{local_name}",
"api_base": _local_api_base(raw_base),
"api_base": _local_api_base(raw_base)
if provider.get("base_url_mode") != "exact"
else raw_base.strip().rstrip("/"),
"api_key": api_key,
}

Expand All @@ -96,11 +103,11 @@ def _resolve_llm_params(
"""
Build LiteLLM kwargs for a given model id.

• ``ollama/<model>``, ``vllm/<model>``, ``lm_studio/<model>``, and
``llamacpp/<model>`` — local OpenAI-compatible endpoints. The id prefix
selects a configurable localhost base URL, and the model suffix is sent
to LiteLLM as ``openai/<model>``. These endpoints don't receive
``reasoning_effort``.
• ``ollama/<model>``, ``vllm/<model>``, ``lm_studio/<model>``,
``llamacpp/<model>``, and ``custom-proxy/<model>`` — local or custom
OpenAI-compatible endpoints. The id prefix selects a configurable base
URL, and the model suffix is sent to LiteLLM as ``openai/<model>``.
These endpoints don't receive ``reasoning_effort``.

• Anything else is treated as an HF Router id. We hit the auto-routing
OpenAI-compatible endpoint at ``https://router.huggingface.co/v1``.
Expand Down
17 changes: 14 additions & 3 deletions agent/core/local_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
"base_url_default": "http://localhost:8080",
"api_key_env": "LLAMACPP_API_KEY",
},
"custom-proxy/": {
"base_url_env": "CUSTOM_PROXY_BASE_URL",
"base_url_default": "",
"api_key_env": "CUSTOM_PROXY_API_KEY",
"base_url_mode": "exact",
},
}

LOCAL_MODEL_PREFIXES = tuple(LOCAL_MODEL_PROVIDERS)
Expand Down Expand Up @@ -48,10 +54,15 @@ def local_model_name(model_id: str) -> str | None:


def is_local_model_id(model_id: str) -> bool:
"""Return True for non-empty, whitespace-free local model ids."""
if not model_id or any(char.isspace() for char in model_id):
"""Return True for valid local/custom model ids."""
if not model_id:
return False
name = local_model_name(model_id)
if name is None:
return False
return local_model_name(model_id) is not None
if model_id.startswith("custom-proxy/"):
return bool(name.strip())
return not any(char.isspace() for char in model_id)


def is_reserved_local_model_id(model_id: str) -> bool:
Expand Down
11 changes: 7 additions & 4 deletions agent/core/model_switcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ def is_valid_model_id(model_id: str) -> bool:
"""Loose format check — lets users pick any model id.

Accepts:
• ollama/<model>, vllm/<model>, lm_studio/<model>, llamacpp/<model>
• ollama/<model>, vllm/<model>, lm_studio/<model>, llamacpp/<model>,
custom-proxy/<model>
• <org>/<model>[:<tag>] (HF router; tag = provider or policy)
• huggingface/<org>/<model>[:<tag>] (same, optional LiteLLM prefix)

Expand Down Expand Up @@ -163,8 +164,9 @@ def print_model_listing(config, console) -> None:
console.print(
"\n[dim]Paste any HF model id (e.g. 'MiniMaxAI/MiniMax-M2.7').\n"
"Add ':fastest', ':cheapest', ':preferred', or ':<provider>' to override routing.\n"
"Use 'ollama/<model>', 'vllm/<model>', 'lm_studio/<model>', or "
"'llamacpp/<model>' for local OpenAI-compatible endpoints.[/dim]"
"Use 'ollama/<model>', 'vllm/<model>', 'lm_studio/<model>', "
"'llamacpp/<model>', or 'custom-proxy/<model>' for local/custom "
"OpenAI-compatible endpoints.[/dim]"
)


Expand All @@ -173,7 +175,8 @@ def print_invalid_id(arg: str, console) -> None:
console.print(
"[dim]Expected:\n"
" • <org>/<model>[:tag] (HF router — paste from huggingface.co)\n"
" • ollama/<model> | vllm/<model> | lm_studio/<model> | llamacpp/<model>[/dim]"
" • ollama/<model> | vllm/<model> | lm_studio/<model> | "
"llamacpp/<model> | custom-proxy/<model>[/dim]"
)


Expand Down
3 changes: 3 additions & 0 deletions tests/unit/test_cli_local_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,23 @@ def test_local_model_helper_accepts_supported_prefixes():
assert is_local_model_id("vllm/meta-llama/Llama-3.1-8B-Instruct")
assert is_local_model_id("lm_studio/google/gemma-3-4b")
assert is_local_model_id("llamacpp/unsloth/Qwen3.5-2B")
assert is_local_model_id("custom-proxy/Azure AI/gpt-5.5")


def test_model_switcher_accepts_supported_local_prefixes():
assert model_switcher.is_valid_model_id("ollama/llama3.1:8b")
assert model_switcher.is_valid_model_id("vllm/meta-llama/Llama-3.1-8B")
assert model_switcher.is_valid_model_id("lm_studio/google/gemma-3-4b")
assert model_switcher.is_valid_model_id("llamacpp/llama-3.1-8b")
assert model_switcher.is_valid_model_id("custom-proxy/Azure AI/gpt-5.5")


def test_model_switcher_rejects_empty_or_whitespace_local_ids():
assert not model_switcher.is_valid_model_id("ollama/")
assert not model_switcher.is_valid_model_id("vllm/")
assert not model_switcher.is_valid_model_id("lm_studio/")
assert not model_switcher.is_valid_model_id("llamacpp/")
assert not model_switcher.is_valid_model_id("custom-proxy/")
assert not model_switcher.is_valid_model_id("ollama/llama 3.1")


Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_llm_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,28 @@ def test_resolve_llamacpp_params_strips_provider_prefix(monkeypatch):
assert params["api_base"] == "http://localhost:8080/v1"


def test_resolve_custom_proxy_params_uses_explicit_endpoint(monkeypatch):
monkeypatch.setenv(
"CUSTOM_PROXY_BASE_URL",
"https://proxy.test/api/v1/",
)
monkeypatch.setenv("CUSTOM_PROXY_API_KEY", "proxy-secret")

params = _resolve_llm_params("custom-proxy/Azure AI/gpt-5.5")

assert params["model"] == "openai/Azure AI/gpt-5.5"
assert params["api_base"] == "https://proxy.test/api/v1"
assert params["api_key"] == "proxy-secret"


def test_resolve_custom_proxy_requires_base_url(monkeypatch):
monkeypatch.delenv("CUSTOM_PROXY_BASE_URL", raising=False)
monkeypatch.delenv("LOCAL_LLM_BASE_URL", raising=False)

with pytest.raises(ValueError, match="CUSTOM_PROXY_BASE_URL"):
_resolve_llm_params("custom-proxy/Azure AI/gpt-5.5")


def test_local_params_reject_reasoning_effort_in_strict_mode():
with pytest.raises(UnsupportedEffortError, match="reasoning_effort"):
_resolve_llm_params("ollama/llama3.1", reasoning_effort="high", strict=True)
Expand Down
Loading