diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 00685436d..482e3c47a 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -356,6 +356,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { api_key_env_vars=(), base_url_env_var="BEDROCK_BASE_URL", ), + "azure-foundry": ProviderConfig( + id="azure-foundry", + name="Azure Foundry", + auth_type="api_key", + inference_base_url="", # User-provided endpoint + api_key_env_vars=("AZURE_FOUNDRY_API_KEY",), + base_url_env_var="AZURE_FOUNDRY_BASE_URL", + ), } diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a32213c5f..3b5e24a37 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1371,6 +1371,21 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "AZURE_FOUNDRY_API_KEY": { + "description": "Azure Foundry API key for custom Azure endpoints", + "prompt": "Azure Foundry API Key", + "url": "https://ai.azure.com/", + "password": True, + "category": "provider", + }, + "AZURE_FOUNDRY_BASE_URL": { + "description": "Azure Foundry base URL (set via 'hermes model' for endpoint-specific config)", + "prompt": "Azure Foundry base URL", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, # ── Tool API keys ── "EXA_API_KEY": { diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0ea3db27a..8a4557e2b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1719,6 +1719,8 @@ def select_provider_and_model(args=None): _model_flow_stepfun(config, current_model) elif selected_provider == "bedrock": _model_flow_bedrock(config, current_model) + elif selected_provider == "azure-foundry": + _model_flow_azure_foundry(config, current_model) elif selected_provider in ( "gemini", "deepseek", @@ -2930,6 +2932,152 @@ def _save_custom_provider( print(f' 💾 Saved to custom providers as "{name}" (edit in config.yaml)') +def _model_flow_azure_foundry(config, current_model=""): + """Azure Foundry provider: configure endpoint, API mode, API key, and model. + + Azure Foundry supports both OpenAI-style (/v1/chat/completions) and + Anthropic-style (/v1/messages) endpoints. The user must select which + API format their endpoint uses. + """ + from hermes_cli.auth import _save_model_choice, deactivate_provider + from hermes_cli.config import get_env_value, save_env_value, load_config, save_config + import getpass + + # Load current Azure Foundry configuration + model_cfg = config.get("model", {}) + if isinstance(model_cfg, dict): + current_base_url = model_cfg.get("base_url", "") if model_cfg.get("provider") == "azure-foundry" else "" + current_api_mode = model_cfg.get("api_mode", "") if model_cfg.get("provider") == "azure-foundry" else "" + else: + current_base_url = "" + current_api_mode = "" + + current_api_key = get_env_value("AZURE_FOUNDRY_API_KEY") or "" + + print() + print("Azure Foundry Configuration") + print("=" * 50) + print() + print("Azure Foundry can host models with either OpenAI-style or") + print("Anthropic-style API endpoints. Configure your endpoint below.") + print() + + if current_base_url: + print(f" Current endpoint: {current_base_url}") + if current_api_mode: + mode_label = "OpenAI-style" if current_api_mode == "chat_completions" else "Anthropic-style" + print(f" Current API mode: {mode_label}") + if current_api_key: + print(f" Current API key: {current_api_key[:8]}...") + print() + + # Step 1: Get the endpoint URL + try: + base_url = input(f"API endpoint URL [{current_base_url or 'e.g. https://your-model.azure.com/v1'}]: ").strip() + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return + + effective_url = base_url or current_base_url + if not effective_url: + print("No endpoint URL provided. Cancelled.") + return + + # Validate URL format + if not effective_url.startswith(("http://", "https://")): + print(f"Invalid URL: {effective_url} (must start with http:// or https://)") + return + + # Step 2: Select API mode (OpenAI or Anthropic style) + print() + print("Select the API format your Azure Foundry endpoint uses:") + print() + print(" 1. OpenAI-style (POST /v1/chat/completions)") + print(" For: GPT models, Llama, Mistral, and most open models") + print() + print(" 2. Anthropic-style (POST /v1/messages)") + print(" For: Claude models deployed via Anthropic API format") + print() + + try: + default_choice = "1" if current_api_mode != "anthropic_messages" else "2" + mode_choice = input(f"API format [1/2] ({default_choice}): ").strip() or default_choice + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return + + if mode_choice == "2": + api_mode = "anthropic_messages" + print(" → Using Anthropic-style API format") + else: + api_mode = "chat_completions" + print(" → Using OpenAI-style API format") + + # Step 3: Get the API key + print() + try: + api_key = getpass.getpass(f"API key [{current_api_key[:8] + '...' if current_api_key else 'required'}]: ").strip() + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return + + effective_key = api_key or current_api_key + if not effective_key: + print("No API key provided. Cancelled.") + return + + # Step 4: Get the model name + print() + try: + model_name = input(f"Model name [{current_model or 'e.g. gpt-4, claude-3-5-sonnet'}]: ").strip() + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return + + effective_model = model_name or current_model + if not effective_model: + print("No model name provided. Cancelled.") + return + + # Step 5: Save configuration + # Save API key to .env + save_env_value("AZURE_FOUNDRY_API_KEY", effective_key) + + # Update config.yaml + cfg = load_config() + model = cfg.get("model") + if not isinstance(model, dict): + model = {"default": model} if model else {} + cfg["model"] = model + + model["provider"] = "azure-foundry" + model["base_url"] = effective_url.rstrip("/") + model["api_mode"] = api_mode + model["default"] = effective_model + + save_config(cfg) + + # Deactivate any OAuth provider + deactivate_provider() + + # Update caller's config dict + config["model"] = dict(model) + + # Clear any conflicting env vars + if get_env_value("OPENAI_BASE_URL"): + save_env_value("OPENAI_BASE_URL", "") + if get_env_value("OPENAI_API_KEY"): + save_env_value("OPENAI_API_KEY", "") + + mode_label = "OpenAI-style" if api_mode == "chat_completions" else "Anthropic-style" + print() + print(f"✓ Azure Foundry configured:") + print(f" Endpoint: {effective_url}") + print(f" API mode: {mode_label}") + print(f" Model: {effective_model}") + print() + + def _remove_custom_provider(config): """Let the user remove a saved custom provider from config.yaml.""" from hermes_cli.config import load_config, save_config diff --git a/hermes_cli/models.py b/hermes_cli/models.py index f565580c3..23ddc6f3c 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -383,6 +383,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "us.meta.llama4-maverick-17b-instruct-v1:0", "us.meta.llama4-scout-17b-instruct-v1:0", ], + # Azure Foundry: user-provided endpoint and model. + # Empty list because models depend on the endpoint configuration. + "azure-foundry": [], } # Vercel AI Gateway: derive the bare-model-id catalog from the curated @@ -740,6 +743,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"), ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"), ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek — IAM or API key)"), + ProviderEntry("azure-foundry", "Azure Foundry", "Azure Foundry (OpenAI-style or Anthropic-style endpoint — your Azure AI deployment)"), ] # Derived dicts — used throughout the codebase diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index f65ceac7a..2f759c790 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -167,6 +167,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { transport="openai_chat", base_url_env_var="OLLAMA_BASE_URL", ), + # Azure Foundry: supports both OpenAI-style and Anthropic-style endpoints. + # The transport is determined at runtime from config.yaml model.api_mode. + "azure-foundry": HermesOverlay( + transport="openai_chat", # default; overridden by api_mode in config + base_url_env_var="AZURE_FOUNDRY_BASE_URL", + ), } diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index cbfcbdbd6..09895b629 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -221,6 +221,19 @@ def _resolve_runtime_from_pool_entry( elif provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", "")) base_url = base_url or PROVIDER_REGISTRY["copilot"].inference_base_url + elif provider == "azure-foundry": + # Azure Foundry: read api_mode and base_url from config + cfg_provider = str(model_cfg.get("provider") or "").strip().lower() + if cfg_provider == "azure-foundry": + cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") + if cfg_base_url: + base_url = cfg_base_url + configured_mode = _parse_api_mode(model_cfg.get("api_mode")) + if configured_mode: + api_mode = configured_mode + # For Anthropic-style endpoints, strip /v1 suffix + if api_mode == "anthropic_messages": + base_url = re.sub(r"/v1/?$", "", base_url) else: configured_provider = str(model_cfg.get("provider") or "").strip().lower() # Honour model.base_url from config.yaml when the configured provider @@ -678,6 +691,47 @@ def _resolve_explicit_runtime( "requested_provider": requested_provider, } + # Azure Foundry: user-configured endpoint with selectable API mode + if provider == "azure-foundry": + cfg_provider = str(model_cfg.get("provider") or "").strip().lower() + cfg_base_url = "" + cfg_api_mode = "chat_completions" + if cfg_provider == "azure-foundry": + cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") + cfg_api_mode = _parse_api_mode(model_cfg.get("api_mode")) or "chat_completions" + + env_base_url = os.getenv("AZURE_FOUNDRY_BASE_URL", "").strip().rstrip("/") + base_url = explicit_base_url or cfg_base_url or env_base_url + if not base_url: + raise AuthError( + "Azure Foundry requires a base URL. Set it via 'hermes model' or " + "the AZURE_FOUNDRY_BASE_URL environment variable." + ) + + api_key = explicit_api_key + if not api_key: + from hermes_cli.config import get_env_value + api_key = get_env_value("AZURE_FOUNDRY_API_KEY") or os.getenv("AZURE_FOUNDRY_API_KEY", "") + if not api_key: + raise AuthError( + "Azure Foundry requires an API key. Set AZURE_FOUNDRY_API_KEY in " + "~/.hermes/.env or run 'hermes model' to configure." + ) + + # For Anthropic-style endpoints, strip /v1 suffix since the Anthropic SDK + # appends /v1/messages internally + if cfg_api_mode == "anthropic_messages": + base_url = re.sub(r"/v1/?$", "", base_url) + + return { + "provider": "azure-foundry", + "api_mode": cfg_api_mode, + "base_url": base_url, + "api_key": api_key, + "source": "explicit", + "requested_provider": requested_provider, + } + pconfig = PROVIDER_REGISTRY.get(provider) if pconfig and pconfig.auth_type == "api_key": env_url = ""