diff --git a/code_puppy/config.py b/code_puppy/config.py index 48db055d0..d3dbc6539 100644 --- a/code_puppy/config.py +++ b/code_puppy/config.py @@ -672,7 +672,7 @@ def get_openai_reasoning_summary() -> str: - detailed: fuller reasoning summaries """ allowed_values = {"auto", "concise", "detailed"} - configured = (get_value("openai_reasoning_summary") or "auto").strip().lower() + configured = (get_value("openai_reasoning_summary") or "detailed").strip().lower() if configured not in allowed_values: return "auto" return configured diff --git a/code_puppy/model_factory.py b/code_puppy/model_factory.py index a382d87de..757b1bbbb 100644 --- a/code_puppy/model_factory.py +++ b/code_puppy/model_factory.py @@ -228,6 +228,7 @@ def make_model_settings( uses_responses_api = ( model_type == "chatgpt_oauth" + or model_type == "azure_foundry_openai" or (model_type == "openai" and "codex" in model_name) or (model_type == "custom_openai" and "codex" in model_name) ) diff --git a/code_puppy/plugins/azure_foundry/config.py b/code_puppy/plugins/azure_foundry/config.py index 4b7f38863..5ade17be4 100644 --- a/code_puppy/plugins/azure_foundry/config.py +++ b/code_puppy/plugins/azure_foundry/config.py @@ -26,6 +26,46 @@ "haiku": 200000, # 200K tokens for Haiku models } +# Context lengths for OpenAI models (Azure doesn't expose this in the catalog API). +# Prefixes are matched longest-first against the model name. +OPENAI_CONTEXT_LENGTHS: dict[str, int] = { + "gpt-5.4": 1000000, + "gpt-5.4-mini": 1000000, + "gpt-5.3-codex": 1000000, + "gpt-5.3": 1000000, + "gpt-5.2-codex": 1000000, + "gpt-5.2": 1000000, + "gpt-5.1-codex-max": 1000000, + "gpt-5.1-codex-mini": 1000000, + "gpt-5.1-codex": 1000000, + "gpt-5.1": 1000000, + "gpt-5-codex": 1000000, + "gpt-5": 1000000, + "gpt-4.1": 1000000, + "gpt-4.1-mini": 1000000, + "gpt-4.1-nano": 1000000, + "o4-mini": 200000, + "o3": 200000, + "o3-mini": 200000, + "o1": 200000, + "o1-mini": 128000, + "codex-mini": 200000, +} +DEFAULT_OPENAI_CONTEXT_LENGTH = 128000 + + +def get_openai_context_length(model_name: str) -> int: + """Look up the context length for an OpenAI model by name. + + Matches the longest prefix first so 'gpt-5.4-mini' matches before 'gpt-5.4'. + Falls back to DEFAULT_OPENAI_CONTEXT_LENGTH if no match. + """ + for prefix in sorted(OPENAI_CONTEXT_LENGTHS, key=len, reverse=True): + if model_name.startswith(prefix): + return OPENAI_CONTEXT_LENGTHS[prefix] + return DEFAULT_OPENAI_CONTEXT_LENGTH + + # Default deployment name patterns (can be overridden by user) DEFAULT_DEPLOYMENT_NAMES: dict[str, str] = { "opus": "claude-opus-4-6", diff --git a/code_puppy/plugins/azure_foundry/discovery.py b/code_puppy/plugins/azure_foundry/discovery.py new file mode 100644 index 000000000..ae6aec3ce --- /dev/null +++ b/code_puppy/plugins/azure_foundry/discovery.py @@ -0,0 +1,189 @@ +"""Azure AI Services deployment discovery. + +Queries the Azure Management API to find AI Services accounts and +list their model deployments. Works with any AIServices account +hosting Anthropic, OpenAI, or other model formats. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger(__name__) + +AZURE_MANAGEMENT_SCOPE = "https://management.azure.com/.default" +MANAGEMENT_BASE = "https://management.azure.com" +RESOURCE_API_VERSION = "2021-04-01" +DEPLOYMENT_API_VERSION = "2024-10-01" + + +@dataclass +class AzureAccount: + """Discovered Azure AI Services account.""" + + resource_id: str + name: str + location: str + resource_group: str + subscription_id: str + + +@dataclass +class AzureDeployment: + """Discovered model deployment on an Azure AI Services account.""" + + name: str + model_name: str + model_format: str # "Anthropic", "OpenAI", etc. + model_version: str + provisioning_state: str + sku_name: str + capacity: int + + +def _get_management_token() -> str | None: + """Get a token for the Azure Management API using az login credentials. + + Returns: + Bearer token string, or None if auth fails. + """ + try: + from azure.identity import AzureCliCredential + + credential = AzureCliCredential() + token = credential.get_token(AZURE_MANAGEMENT_SCOPE) + return token.token + except Exception as e: + logger.warning("Failed to get management token: %s", e) + return None + + +def _management_get(token: str, url: str) -> dict[str, Any] | None: + """Make a GET request to the Azure Management API. + + Args: + token: Bearer token for authentication. + url: Full URL to GET. + + Returns: + Parsed JSON response, or None on error. + """ + try: + import httpx + + resp = httpx.get( + url, + headers={"Authorization": f"Bearer {token}"}, + timeout=30, + ) + if resp.status_code == 200: + return resp.json() + logger.warning("Management API %s returned %d", url, resp.status_code) + return None + except Exception as e: + logger.warning("Management API request failed: %s", e) + return None + + +def find_account(resource_name: str) -> AzureAccount | None: + """Find an Azure AI Services account by name across all accessible subscriptions. + + Args: + resource_name: The account name to search for. + + Returns: + AzureAccount if found, None otherwise. + """ + token = _get_management_token() + if not token: + return None + + # List subscriptions + subs_url = f"{MANAGEMENT_BASE}/subscriptions?api-version=2022-12-01" + subs_resp = _management_get(token, subs_url) + if not subs_resp: + return None + + subscriptions = subs_resp.get("value", []) + + for sub in subscriptions: + sub_id = sub.get("subscriptionId", "") + if sub.get("state") != "Enabled": + continue + + # Search for the resource by name and type + filter_str = ( + f"name eq '{resource_name}' and " + f"resourceType eq 'Microsoft.CognitiveServices/accounts'" + ) + resources_url = ( + f"{MANAGEMENT_BASE}/subscriptions/{sub_id}/resources" + f"?$filter={filter_str}&api-version={RESOURCE_API_VERSION}" + ) + resources_resp = _management_get(token, resources_url) + if not resources_resp: + continue + + for resource in resources_resp.get("value", []): + rid = resource.get("id", "") + parts = rid.split("/") + # /subscriptions/{sub}/resourceGroups/{rg}/providers/.../accounts/{name} + rg_idx = next( + (i for i, p in enumerate(parts) if p.lower() == "resourcegroups"), + None, + ) + rg = parts[rg_idx + 1] if rg_idx is not None else "" + + return AzureAccount( + resource_id=rid, + name=resource_name, + location=resource.get("location", ""), + resource_group=rg, + subscription_id=sub_id, + ) + + return None + + +def list_deployments(account: AzureAccount) -> list[AzureDeployment]: + """List all model deployments on an Azure AI Services account. + + Args: + account: The account to query. + + Returns: + List of deployments (all states, caller filters as needed). + """ + token = _get_management_token() + if not token: + return [] + + url = ( + f"{MANAGEMENT_BASE}{account.resource_id}" + f"/deployments?api-version={DEPLOYMENT_API_VERSION}" + ) + resp = _management_get(token, url) + if not resp: + return [] + + deployments = [] + for d in resp.get("value", []): + props = d.get("properties", {}) + model = props.get("model", {}) + sku = d.get("sku", {}) + + deployments.append( + AzureDeployment( + name=d.get("name", ""), + model_name=model.get("name", ""), + model_format=model.get("format", ""), + model_version=model.get("version", ""), + provisioning_state=props.get("provisioningState", ""), + sku_name=sku.get("name", ""), + capacity=sku.get("capacity", 0), + ) + ) + + return deployments diff --git a/code_puppy/plugins/azure_foundry/register_callbacks.py b/code_puppy/plugins/azure_foundry/register_callbacks.py index 708e97168..5705a6dd8 100644 --- a/code_puppy/plugins/azure_foundry/register_callbacks.py +++ b/code_puppy/plugins/azure_foundry/register_callbacks.py @@ -23,8 +23,10 @@ ENV_FOUNDRY_RESOURCE, get_foundry_resource, ) +from .discovery import find_account, list_deployments from .token import get_token_provider from .utils import ( + add_discovered_models_to_config, add_foundry_models_to_config, get_foundry_models_from_config, remove_foundry_models_from_config, @@ -146,26 +148,36 @@ def _print(msg: str = "") -> None: _print() - # Get deployment names - _print("Step 3: Model Deployments") - _print(" Enter deployment names (press Enter to use default)") - _print() - - opus_default = DEFAULT_DEPLOYMENT_NAMES["opus"] - sonnet_default = DEFAULT_DEPLOYMENT_NAMES["sonnet"] - haiku_default = DEFAULT_DEPLOYMENT_NAMES["haiku"] - - sys.stdout.flush() - opus_input = safe_input(f" Opus [{opus_default}]: ").strip() - opus_deployment = opus_input if opus_input else opus_default - - sys.stdout.flush() - sonnet_input = safe_input(f" Sonnet [{sonnet_default}]: ").strip() - sonnet_deployment = sonnet_input if sonnet_input else sonnet_default - - sys.stdout.flush() - haiku_input = safe_input(f" Haiku [{haiku_default}]: ").strip() - haiku_deployment = haiku_input if haiku_input else haiku_default + # Step 3: Try auto-discovery, fall back to manual + _print("Step 3: Discovering deployments...") + account = find_account(resource_name) + + if account: + _print(f" Found: {account.name} ({account.location})") + _print(f" RG: {account.resource_group}") + _print() + + deployments = list_deployments(account) + succeeded = [d for d in deployments if d.provisioning_state == "Succeeded"] + + if succeeded: + _print(f" {len(succeeded)} active deployment(s):") + for d in succeeded: + _print(f" - {d.name} ({d.model_format}: {d.model_name})") + _print() + + sys.stdout.flush() + confirm = safe_input(" Configure these? [Y/n]: ").strip().lower() + if confirm not in ("", "y", "yes"): + _print(" Skipped.") + return + else: + _print(" No active deployments found.") + return + else: + _print(" Discovery failed — falling back to manual entry.") + _print() + succeeded = None except (KeyboardInterrupt, EOFError): _print() @@ -176,27 +188,29 @@ def _print(msg: str = "") -> None: _print() - # Save configuration + # Step 4: Save configuration _print("Step 4: Saving configuration...") - # Set environment variable hint if not get_foundry_resource(): _print( f" Tip: Set {ENV_FOUNDRY_RESOURCE}={resource_name} in your environment" ) - # Add models to config — always pass the resolved deployment names so that - # pressing Enter (which keeps the default) is persisted, not treated as None. - added_models = add_foundry_models_to_config( - resource_name=resource_name, - opus_deployment=opus_deployment, - sonnet_deployment=sonnet_deployment, - haiku_deployment=haiku_deployment, - ) + if succeeded is not None: + # Auto-discovered — configure all succeeded deployments + added_models = add_discovered_models_to_config(resource_name, succeeded) + else: + # Manual fallback — use hardcoded Anthropic defaults + added_models = add_foundry_models_to_config( + resource_name=resource_name, + opus_deployment=DEFAULT_DEPLOYMENT_NAMES["opus"], + sonnet_deployment=DEFAULT_DEPLOYMENT_NAMES["sonnet"], + haiku_deployment=DEFAULT_DEPLOYMENT_NAMES["haiku"], + ) _print() if added_models: - _print(f"OK: Configuration saved! Added {len(added_models)} model(s):") + _print(f"OK: Configured {len(added_models)} model(s):") for model_key in added_models: _print(f" - {model_key}") _print() @@ -393,9 +407,84 @@ def _create_azure_foundry_model( return None +def _create_azure_foundry_openai_model( + model_name: str, model_config: dict, config: dict +) -> Any: + """Create an Azure Foundry OpenAI model instance. + + Handles models with type='azure_foundry_openai' — OpenAI models on + Azure AI Services using Azure AD token auth (no API keys). + """ + try: + from openai import AsyncAzureOpenAI + from pydantic_ai.models.openai import OpenAIChatModel, OpenAIResponsesModel + except ImportError as e: + emit_error(f"Failed to create Azure Foundry OpenAI model '{model_name}': {e}") + return None + + from code_puppy.provider_identity import ( + make_openai_provider, + resolve_provider_identity, + ) + + resource_config = model_config.get("foundry_resource", f"${ENV_FOUNDRY_RESOURCE}") + resource_name = resolve_env_var(resource_config) + + if not resource_name: + emit_warning( + f"Azure Foundry resource not configured for model '{model_name}'. " + f"Set {ENV_FOUNDRY_RESOURCE} or run /foundry-setup." + ) + return None + + deployment_name = model_config.get("name") + if not deployment_name: + emit_warning(f"Deployment name not specified for model '{model_name}'.") + return None + + token_provider = get_token_provider() + is_auth, status_msg, _ = token_provider.check_auth_status() + if not is_auth: + emit_warning(f"Azure AD auth failed for model '{model_name}': {status_msg}") + return None + + try: + api_version = model_config.get("api_version", "2025-04-01-preview") + azure_endpoint = f"https://{resource_name}.openai.azure.com" + + azure_client = AsyncAzureOpenAI( + azure_endpoint=azure_endpoint, + api_version=api_version, + azure_ad_token_provider=token_provider.get_token, + ) + + provider_identity = resolve_provider_identity(model_name, model_config) + provider = make_openai_provider(provider_identity, openai_client=azure_client) + + if deployment_name.startswith("gpt-5"): + model = OpenAIResponsesModel(model_name=deployment_name, provider=provider) + else: + model = OpenAIChatModel(model_name=deployment_name, provider=provider) + logger.info( + "Created Azure Foundry OpenAI model: %s -> %s @ %s", + model_name, + deployment_name, + resource_name, + ) + return model + + except Exception as e: + emit_error(f"Failed to create Azure Foundry OpenAI model '{model_name}': {e}") + logger.exception("Error creating Azure Foundry OpenAI model: %s", e) + return None + + def _register_model_types() -> list[dict[str, Any]]: - """Register the azure_foundry model type handler.""" - return [{"type": "azure_foundry", "handler": _create_azure_foundry_model}] + """Register azure_foundry and azure_foundry_openai model type handlers.""" + return [ + {"type": "azure_foundry", "handler": _create_azure_foundry_model}, + {"type": "azure_foundry_openai", "handler": _create_azure_foundry_openai_model}, + ] # ============================================================================ diff --git a/code_puppy/plugins/azure_foundry/utils.py b/code_puppy/plugins/azure_foundry/utils.py index 0f7764848..9ff13bad7 100644 --- a/code_puppy/plugins/azure_foundry/utils.py +++ b/code_puppy/plugins/azure_foundry/utils.py @@ -16,6 +16,7 @@ DEFAULT_CONTEXT_LENGTHS, ENV_FOUNDRY_RESOURCE, get_extra_models_path, + get_openai_context_length, ) logger = logging.getLogger(__name__) @@ -241,19 +242,88 @@ def add_foundry_models_to_config( return [] -def remove_foundry_models_from_config() -> list[str]: - """Remove all Azure Foundry model configurations from extra_models.json. +FOUNDRY_TYPES = {"azure_foundry", "azure_foundry_openai"} - Returns: - List of model keys that were removed. + +_GPT5_SUPPORTED_SETTINGS = [ + "reasoning_effort", + "summary", + "verbosity", +] + + +def get_foundry_openai_supported_settings(model_name: str) -> list[str]: + """Return supported settings for an Azure Foundry OpenAI model. + + Later GPT-5-family models support Code Puppy's reasoning/summary/verbosity + controls in addition to the baseline temperature setting. + """ + supported_settings = ["temperature"] + if model_name.startswith("gpt-5"): + supported_settings.extend(_GPT5_SUPPORTED_SETTINGS) + return supported_settings + + +def add_discovered_models_to_config( + resource_name: str, + deployments: list, +) -> list[str]: + """Add auto-discovered deployments to extra_models.json. + + Classifies each deployment by model format (Anthropic vs OpenAI) + and creates the appropriate model config. """ + from .discovery import AzureDeployment + + models = load_extra_models() + added: list[str] = [] + + for d in deployments: + if not isinstance(d, AzureDeployment): + continue + + key = f"foundry-{d.name}" + + if d.model_format == "Anthropic": + tier = "haiku" + for t in ("opus", "sonnet"): + if t in d.model_name.lower(): + tier = t + break + models[key] = build_foundry_model_config( + deployment_name=d.name, + model_tier=tier, + foundry_resource=resource_name, + ) + added.append(key) + + elif d.model_format == "OpenAI": + models[key] = { + "type": "azure_foundry_openai", + "provider": "azure_foundry_openai", + "name": d.name, + "foundry_resource": resource_name, + "context_length": get_openai_context_length(d.model_name), + "supported_settings": get_foundry_openai_supported_settings( + d.model_name + ), + } + added.append(key) + + if added and save_extra_models(models): + return added + return [] + + +def remove_foundry_models_from_config() -> list[str]: + """Remove all Azure Foundry model configurations from extra_models.json.""" models = load_extra_models() removed_models: list[str] = [] keys_to_remove = [ key for key, config in models.items() - if isinstance(config, dict) and config.get("type") == "azure_foundry" + if isinstance(config, dict) and config.get("type") in FOUNDRY_TYPES ] for key in keys_to_remove: @@ -269,14 +339,10 @@ def remove_foundry_models_from_config() -> list[str]: def get_foundry_models_from_config() -> dict[str, Any]: - """Get all Azure Foundry model configurations from extra_models.json. - - Returns: - Dictionary of model key -> config for all Foundry models. - """ + """Get all Azure Foundry model configurations from extra_models.json.""" models = load_extra_models() return { key: config for key, config in models.items() - if isinstance(config, dict) and config.get("type") == "azure_foundry" + if isinstance(config, dict) and config.get("type") in FOUNDRY_TYPES } diff --git a/code_puppy/provider_identity.py b/code_puppy/provider_identity.py index 68d09b3ef..0c7f4882e 100644 --- a/code_puppy/provider_identity.py +++ b/code_puppy/provider_identity.py @@ -47,6 +47,7 @@ def name(self) -> str: "gemini": "google", "gemini_oauth": "google", "azure_openai": "azure_openai", + "azure_foundry_openai": "azure_foundry_openai", } _KEY_PREFIX_OVERRIDES = ( diff --git a/tests/plugins/test_azure_foundry.py b/tests/plugins/test_azure_foundry.py index 81ed9c2f8..bea32e6a5 100644 --- a/tests/plugins/test_azure_foundry.py +++ b/tests/plugins/test_azure_foundry.py @@ -537,6 +537,7 @@ def test_get_foundry_models_mixed_types(self, tmp_path): mixed_config = { "foundry-model": {"type": "azure_foundry", "name": "test"}, + "foundry-openai": {"type": "azure_foundry_openai", "name": "gpt-5"}, "openai-model": {"type": "openai", "name": "gpt-4"}, "anthropic-model": {"type": "anthropic", "name": "claude"}, } @@ -549,8 +550,9 @@ def test_get_foundry_models_mixed_types(self, tmp_path): return_value=models_path, ): foundry_models = get_foundry_models_from_config() - assert len(foundry_models) == 1 + assert len(foundry_models) == 2 assert "foundry-model" in foundry_models + assert "foundry-openai" in foundry_models # ============================================================================ @@ -688,15 +690,17 @@ class TestRegisterModelTypes: """Test model type registration.""" def test_register_model_types(self): - """Test that azure_foundry model type is registered.""" + """Test that both model types are registered.""" from code_puppy.plugins.azure_foundry.register_callbacks import ( _register_model_types, ) registrations = _register_model_types() - assert len(registrations) == 1 - assert registrations[0]["type"] == "azure_foundry" - assert callable(registrations[0]["handler"]) + assert len(registrations) == 2 + types = {r["type"] for r in registrations} + assert "azure_foundry" in types + assert "azure_foundry_openai" in types + assert all(callable(r["handler"]) for r in registrations) class TestCreateAzureFoundryModel: @@ -869,3 +873,490 @@ def test_help_includes_foundry_commands(self): assert "foundry-status" in command_names assert "foundry-setup" in command_names assert "foundry-remove" in command_names + + +# ============================================================================ +# DISCOVERY MODULE TESTS +# ============================================================================ + + +class TestDiscovery: + """Test Azure AI Services deployment discovery.""" + + def test_azure_account_dataclass(self): + """Test AzureAccount dataclass creation.""" + from code_puppy.plugins.azure_foundry.discovery import AzureAccount + + account = AzureAccount( + resource_id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.CognitiveServices/accounts/my-ai", + name="my-ai", + location="eastus2", + resource_group="rg1", + subscription_id="sub1", + ) + assert account.name == "my-ai" + assert account.location == "eastus2" + + def test_azure_deployment_dataclass(self): + """Test AzureDeployment dataclass creation.""" + from code_puppy.plugins.azure_foundry.discovery import AzureDeployment + + dep = AzureDeployment( + name="gpt-5-4", + model_name="gpt-5.4", + model_format="OpenAI", + model_version="2026-03-05", + provisioning_state="Succeeded", + sku_name="GlobalStandard", + capacity=10, + ) + assert dep.model_format == "OpenAI" + assert dep.provisioning_state == "Succeeded" + + def test_get_management_token_failure(self): + """Test management token returns None on failure.""" + from code_puppy.plugins.azure_foundry.discovery import _get_management_token + + with patch( + "azure.identity.AzureCliCredential", + side_effect=Exception("not logged in"), + ): + assert _get_management_token() is None + + def test_management_get_success(self): + """Test successful management API GET.""" + from code_puppy.plugins.azure_foundry.discovery import _management_get + + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"value": []} + + with patch("httpx.get", return_value=mock_resp): + result = _management_get("token123", "https://management.azure.com/test") + assert result == {"value": []} + + def test_management_get_failure(self): + """Test management API GET returns None on error.""" + from code_puppy.plugins.azure_foundry.discovery import _management_get + + mock_resp = Mock() + mock_resp.status_code = 403 + + with patch("httpx.get", return_value=mock_resp): + result = _management_get("token123", "https://management.azure.com/test") + assert result is None + + def test_find_account_success(self): + """Test finding an account across subscriptions.""" + from code_puppy.plugins.azure_foundry.discovery import find_account + + mock_token = Mock() + mock_token.token = "mgmt-token" + + def mock_get(url, **kwargs): + resp = Mock() + resp.status_code = 200 + if "subscriptions?" in url: + resp.json.return_value = { + "value": [{"subscriptionId": "sub-123", "state": "Enabled"}] + } + elif "resources?" in url: + resp.json.return_value = { + "value": [ + { + "id": "/subscriptions/sub-123/resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-ai", + "location": "eastus2", + } + ] + } + return resp + + with patch("azure.identity.AzureCliCredential") as mock_cred_cls: + mock_cred_cls.return_value.get_token.return_value = mock_token + with patch("httpx.get", side_effect=mock_get): + account = find_account("my-ai") + + assert account is not None + assert account.name == "my-ai" + assert account.subscription_id == "sub-123" + assert account.resource_group == "my-rg" + assert account.location == "eastus2" + + def test_find_account_not_found(self): + """Test find_account returns None when not found.""" + from code_puppy.plugins.azure_foundry.discovery import find_account + + mock_token = Mock() + mock_token.token = "mgmt-token" + + def mock_get(url, **kwargs): + resp = Mock() + resp.status_code = 200 + if "subscriptions?" in url: + resp.json.return_value = { + "value": [{"subscriptionId": "sub-123", "state": "Enabled"}] + } + else: + resp.json.return_value = {"value": []} + return resp + + with patch("azure.identity.AzureCliCredential") as mock_cred_cls: + mock_cred_cls.return_value.get_token.return_value = mock_token + with patch("httpx.get", side_effect=mock_get): + assert find_account("nonexistent") is None + + def test_list_deployments_success(self): + """Test listing deployments on an account.""" + from code_puppy.plugins.azure_foundry.discovery import ( + AzureAccount, + list_deployments, + ) + + account = AzureAccount( + resource_id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.CognitiveServices/accounts/my-ai", + name="my-ai", + location="eastus2", + resource_group="rg1", + subscription_id="sub1", + ) + + mock_token = Mock() + mock_token.token = "mgmt-token" + + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "value": [ + { + "name": "gpt-5-4", + "properties": { + "model": { + "name": "gpt-5.4", + "format": "OpenAI", + "version": "2026-03-05", + }, + "provisioningState": "Succeeded", + }, + "sku": {"name": "GlobalStandard", "capacity": 10}, + }, + { + "name": "claude-opus", + "properties": { + "model": { + "name": "claude-opus-4-6", + "format": "Anthropic", + "version": "1", + }, + "provisioningState": "Failed", + }, + "sku": {"name": "GlobalStandard", "capacity": 1}, + }, + ] + } + + with patch("azure.identity.AzureCliCredential") as mock_cred_cls: + mock_cred_cls.return_value.get_token.return_value = mock_token + with patch("httpx.get", return_value=mock_resp): + deps = list_deployments(account) + + assert len(deps) == 2 + assert deps[0].name == "gpt-5-4" + assert deps[0].model_format == "OpenAI" + assert deps[0].provisioning_state == "Succeeded" + assert deps[1].name == "claude-opus" + assert deps[1].model_format == "Anthropic" + assert deps[1].provisioning_state == "Failed" + + +# ============================================================================ +# DISCOVERED MODELS CONFIG TESTS +# ============================================================================ + + +class TestAddDiscoveredModels: + """Test adding auto-discovered models to config.""" + + def test_add_discovered_openai_model(self, tmp_path): + """Test adding a discovered OpenAI deployment.""" + from code_puppy.plugins.azure_foundry.discovery import AzureDeployment + from code_puppy.plugins.azure_foundry.utils import ( + add_discovered_models_to_config, + load_extra_models, + ) + + models_path = tmp_path / "models.json" + models_path.write_text("{}") + + deployments = [ + AzureDeployment( + name="gpt-5-4", + model_name="gpt-5.4", + model_format="OpenAI", + model_version="2026-03-05", + provisioning_state="Succeeded", + sku_name="GlobalStandard", + capacity=10, + ), + ] + + with patch( + "code_puppy.plugins.azure_foundry.utils.get_extra_models_path", + return_value=models_path, + ): + added = add_discovered_models_to_config("my-resource", deployments) + assert "foundry-gpt-5-4" in added + + models = load_extra_models() + assert models["foundry-gpt-5-4"]["type"] == "azure_foundry_openai" + assert models["foundry-gpt-5-4"]["name"] == "gpt-5-4" + assert models["foundry-gpt-5-4"]["foundry_resource"] == "my-resource" + assert models["foundry-gpt-5-4"]["supported_settings"] == [ + "temperature", + "reasoning_effort", + "summary", + "verbosity", + ] + + def test_add_discovered_later_non_gpt_openai_model(self, tmp_path): + """Test non-GPT-5 OpenAI deployments keep baseline settings only.""" + from code_puppy.plugins.azure_foundry.discovery import AzureDeployment + from code_puppy.plugins.azure_foundry.utils import ( + add_discovered_models_to_config, + load_extra_models, + ) + + models_path = tmp_path / "models.json" + models_path.write_text("{}") + + deployments = [ + AzureDeployment( + name="o4-mini", + model_name="o4-mini", + model_format="OpenAI", + model_version="2026-03-05", + provisioning_state="Succeeded", + sku_name="GlobalStandard", + capacity=10, + ), + ] + + with patch( + "code_puppy.plugins.azure_foundry.utils.get_extra_models_path", + return_value=models_path, + ): + added = add_discovered_models_to_config("my-resource", deployments) + assert "foundry-o4-mini" in added + + models = load_extra_models() + assert models["foundry-o4-mini"]["supported_settings"] == ["temperature"] + + def test_add_discovered_anthropic_model(self, tmp_path): + """Test adding a discovered Anthropic deployment.""" + from code_puppy.plugins.azure_foundry.discovery import AzureDeployment + from code_puppy.plugins.azure_foundry.utils import ( + add_discovered_models_to_config, + load_extra_models, + ) + + models_path = tmp_path / "models.json" + models_path.write_text("{}") + + deployments = [ + AzureDeployment( + name="claude-opus-4-6", + model_name="claude-opus-4-6", + model_format="Anthropic", + model_version="1", + provisioning_state="Succeeded", + sku_name="GlobalStandard", + capacity=1, + ), + ] + + with patch( + "code_puppy.plugins.azure_foundry.utils.get_extra_models_path", + return_value=models_path, + ): + added = add_discovered_models_to_config("my-resource", deployments) + assert "foundry-claude-opus-4-6" in added + + models = load_extra_models() + assert models["foundry-claude-opus-4-6"]["type"] == "azure_foundry" + + def test_add_discovered_mixed_models(self, tmp_path): + """Test adding both Anthropic and OpenAI deployments.""" + from code_puppy.plugins.azure_foundry.discovery import AzureDeployment + from code_puppy.plugins.azure_foundry.utils import ( + add_discovered_models_to_config, + ) + + models_path = tmp_path / "models.json" + models_path.write_text("{}") + + deployments = [ + AzureDeployment( + "gpt-5-4", "gpt-5.4", "OpenAI", "1", "Succeeded", "GlobalStandard", 10 + ), + AzureDeployment( + "claude-opus", + "claude-opus-4-6", + "Anthropic", + "1", + "Succeeded", + "GlobalStandard", + 1, + ), + AzureDeployment( + "o4-mini", "o4-mini", "OpenAI", "1", "Succeeded", "GlobalStandard", 10 + ), + ] + + with patch( + "code_puppy.plugins.azure_foundry.utils.get_extra_models_path", + return_value=models_path, + ): + added = add_discovered_models_to_config("my-resource", deployments) + assert len(added) == 3 + + def test_remove_both_types(self, tmp_path): + """Test remove cleans up both azure_foundry and azure_foundry_openai.""" + from code_puppy.plugins.azure_foundry.utils import ( + remove_foundry_models_from_config, + ) + + mixed = { + "foundry-claude": {"type": "azure_foundry", "name": "claude"}, + "foundry-gpt": {"type": "azure_foundry_openai", "name": "gpt"}, + "other-model": {"type": "openai", "name": "direct"}, + } + models_path = tmp_path / "models.json" + with open(models_path, "w") as f: + json.dump(mixed, f) + + with patch( + "code_puppy.plugins.azure_foundry.utils.get_extra_models_path", + return_value=models_path, + ): + removed = remove_foundry_models_from_config() + assert "foundry-claude" in removed + assert "foundry-gpt" in removed + assert len(removed) == 2 + + with open(models_path) as f: + remaining = json.load(f) + assert "other-model" in remaining + + +# ============================================================================ +# OPENAI MODEL HANDLER TESTS +# ============================================================================ + + +class TestCreateAzureFoundryOpenAIModel: + """Test Azure Foundry OpenAI model creation.""" + + def test_create_model_no_resource(self): + """Test model creation fails without resource.""" + from code_puppy.plugins.azure_foundry.register_callbacks import ( + _create_azure_foundry_openai_model, + ) + + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("ANTHROPIC_FOUNDRY_RESOURCE", None) + with patch( + "code_puppy.plugins.azure_foundry.register_callbacks.emit_warning" + ): + result = _create_azure_foundry_openai_model( + "foundry-gpt", {"name": "gpt-5-4"}, {} + ) + assert result is None + + def test_create_model_no_deployment_name(self): + """Test model creation fails without deployment name.""" + from code_puppy.plugins.azure_foundry.register_callbacks import ( + _create_azure_foundry_openai_model, + ) + + with patch("code_puppy.plugins.azure_foundry.register_callbacks.emit_warning"): + result = _create_azure_foundry_openai_model( + "foundry-gpt", {"foundry_resource": "my-resource"}, {} + ) + assert result is None + + def test_create_model_auth_failed(self): + """Test model creation fails when not authenticated.""" + from code_puppy.plugins.azure_foundry.register_callbacks import ( + _create_azure_foundry_openai_model, + ) + + mock_provider = Mock() + mock_provider.check_auth_status.return_value = (False, "Not auth", None) + + with patch( + "code_puppy.plugins.azure_foundry.register_callbacks.get_token_provider", + return_value=mock_provider, + ): + with patch( + "code_puppy.plugins.azure_foundry.register_callbacks.emit_warning" + ): + result = _create_azure_foundry_openai_model( + "foundry-gpt", + {"name": "gpt-5-4", "foundry_resource": "my-resource"}, + {}, + ) + assert result is None + + def test_create_model_success(self): + """Test successful OpenAI model creation.""" + from code_puppy.plugins.azure_foundry.register_callbacks import ( + _create_azure_foundry_openai_model, + ) + + mock_provider = Mock() + mock_provider.check_auth_status.return_value = (True, "Valid", "user@test.com") + mock_provider.get_token = Mock(return_value="token123") + + mock_model = Mock() + + with patch( + "code_puppy.plugins.azure_foundry.register_callbacks.get_token_provider", + return_value=mock_provider, + ): + with patch("openai.AsyncAzureOpenAI") as mock_client_cls: + with patch( + "code_puppy.provider_identity.resolve_provider_identity", + return_value="azure_foundry_openai", + ): + with patch( + "code_puppy.provider_identity.make_openai_provider", + return_value=Mock(), + ): + with patch( + "pydantic_ai.models.openai.OpenAIResponsesModel", + return_value=mock_model, + ) as mock_responses_model: + with patch( + "pydantic_ai.models.openai.OpenAIChatModel" + ) as mock_chat_model: + result = _create_azure_foundry_openai_model( + "foundry-gpt", + { + "name": "gpt-5-4", + "foundry_resource": "my-resource", + }, + {}, + ) + + assert result is mock_model + mock_responses_model.assert_called_once() + mock_chat_model.assert_not_called() + mock_client_cls.assert_called_once() + call_kwargs = mock_client_cls.call_args.kwargs + assert ( + call_kwargs["azure_endpoint"] + == "https://my-resource.openai.azure.com" + ) + assert ( + call_kwargs["azure_ad_token_provider"] + == mock_provider.get_token + ) diff --git a/tests/test_model_factory_coverage.py b/tests/test_model_factory_coverage.py index 429d4f513..585997d1f 100644 --- a/tests/test_model_factory_coverage.py +++ b/tests/test_model_factory_coverage.py @@ -96,6 +96,42 @@ def test_make_model_settings_gpt5_codex_no_verbosity(self): # extra_body should NOT be set for codex models assert settings.get("extra_body") is None + def test_make_model_settings_foundry_gpt5_uses_responses_fields(self): + """Test Azure Foundry GPT-5 gets Responses API reasoning summary fields.""" + from code_puppy.model_factory import make_model_settings + + with patch( + "code_puppy.model_factory.ModelFactory.load_config", + return_value={ + "foundry-gpt-5-4": { + "type": "azure_foundry_openai", + "name": "gpt-5-4", + "context_length": 1_000_000, + } + }, + ): + with patch( + "code_puppy.config.get_openai_reasoning_effort", + return_value="medium", + ): + with patch( + "code_puppy.config.get_openai_reasoning_summary", + return_value="auto", + ): + with patch( + "code_puppy.config.get_openai_verbosity", + return_value="medium", + ): + settings = make_model_settings( + "foundry-gpt-5-4", max_tokens=4096 + ) + + assert isinstance(settings, dict) + assert settings["openai_reasoning_effort"] == "medium" + assert settings["openai_reasoning_summary"] == "auto" + assert settings["openai_text_verbosity"] == "medium" + assert settings.get("extra_body") is None + def test_make_model_settings_claude_has_temperature(self): """Test Claude model returns settings with temperature.""" from code_puppy.model_factory import make_model_settings