Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Typed clients for narrow Inference Gateway provider proxy calls."""

from __future__ import annotations

import json

import httpx
from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient
from nemo_platform_plugin.client.method import method
from nemo_platform_plugin.inference_gateway import endpoints


def _decode_provider_proxy_body(content: bytes) -> object:
"""Decode a provider proxy body.

Provider proxy responses are intentionally dynamic because the gateway
forwards upstream provider payloads. Decode JSON when possible and return
text otherwise so callers can validate the small shape they need.
"""
text = content.decode("utf-8", errors="replace")
try:
return json.loads(text)
except json.JSONDecodeError:
return text


class _InferenceGatewayProviderMethods:
get_provider_models_raw = method(endpoints.get_provider_models_raw)


class InferenceGatewayProviderClient(_InferenceGatewayProviderMethods, NemoClient):
"""Sync client for Inference Gateway provider proxy reads."""

def get_provider_models(
self,
*,
workspace: str | None = None,
name: str,
timeout: float | httpx.Timeout | None = None,
) -> object:
"""Return the decoded ``GET /v1/models`` payload for a provider."""
client = self.with_options(timeout=timeout) if timeout is not None else self
response = client.get_provider_models_raw(workspace=workspace, name=name)
return _decode_provider_proxy_body(response.read())


class AsyncInferenceGatewayProviderClient(_InferenceGatewayProviderMethods, AsyncNemoClient):
"""Async client for Inference Gateway provider proxy reads."""

async def get_provider_models(
self,
*,
workspace: str | None = None,
name: str,
timeout: float | httpx.Timeout | None = None,
) -> object:
"""Return the decoded ``GET /v1/models`` payload for a provider."""
client = self.with_options(timeout=timeout) if timeout is not None else self
response = await client.get_provider_models_raw(workspace=workspace, name=name)
return _decode_provider_proxy_body(await response.read())
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Typed endpoint definitions for narrow Inference Gateway provider calls."""

from __future__ import annotations

from abc import abstractmethod

from nemo_platform_plugin.client.endpoint import get
from nemo_platform_plugin.client.types import BinaryContent

_PROVIDER = "/apis/inference-gateway/v2/workspaces/{workspace}/provider/{name}/-"


@get(_PROVIDER + "/v1/models")
@abstractmethod
def get_provider_models_raw(*, workspace: str | None = None, name: str) -> BinaryContent: ...
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,18 @@
from datetime import datetime
from typing import Protocol

from models import (
from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient
from nemo_platform_plugin.client.errors import NotFoundError
from nemo_platform_plugin.client.method import method
from nemo_platform_plugin.models import endpoints
from nemo_platform_plugin.models.refs import (
ResolvedModelReference,
first_provider_ref,
model_entity_route_openai_url,
parse_workspace_name_ref,
resolved_model_reference,
warn_provider_host_url_resolution_failure,
)
from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient
from nemo_platform_plugin.client.errors import NotFoundError
from nemo_platform_plugin.client.method import method
from nemo_platform_plugin.models import endpoints
from nemo_platform_plugin.models.types import ModelDeployment, ModelEntity

_INFERENCE_GATEWAY_PREFIX = "/apis/inference-gateway/v2/workspaces"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Pure Models route-reference helpers.

These helpers mirror the convenience functions historically exported from the
Stainless-backed ``models`` package. They live in the plugin client package so
typed clients can build route references without importing generated resources.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass

_logger = logging.getLogger(__name__)


@dataclass(frozen=True, slots=True)
class ResolvedModelReference:
"""Inference route details for a workspace-qualified model reference."""

url: str
name: str
host_url: str | None


def parse_workspace_name_ref(ref: str, *, label: str, expected_format: str = "workspace/name") -> tuple[str, str]:
"""Parse a strict workspace-qualified reference."""
workspace, separator, name = ref.partition("/")
if separator != "/" or not workspace or not name or "/" in name:
raise ValueError(f"{label} must be in format '{expected_format}'")
return workspace, name


def first_provider_ref(model_providers: list[str] | None) -> tuple[str, str, str] | None:
"""Return the first valid ``(ref, workspace, name)`` provider reference, if present."""
if not model_providers:
return None

provider_ref = model_providers[0]
try:
provider_workspace, provider_name = parse_workspace_name_ref(provider_ref, label="Provider reference")
except ValueError:
_logger.warning("Invalid provider reference format", extra={"provider_ref": provider_ref})
return None
return provider_ref, provider_workspace, provider_name


def model_entity_route_openai_url(*, base_url: str, workspace: str, name: str) -> str:
"""OpenAI SDK-compatible URL for a model-entity proxy route."""
return f"{base_url.rstrip('/')}/apis/inference-gateway/v2/workspaces/{workspace}/model/{name}/-/v1"


def resolved_model_reference(
*,
base_url: str,
name: str,
route_workspace: str,
route_model_name: str,
host_url: str | None,
) -> ResolvedModelReference:
"""Build route details for a resolved model entity."""
return ResolvedModelReference(
url=model_entity_route_openai_url(base_url=base_url, workspace=route_workspace, name=route_model_name),
name=name,
host_url=host_url,
)


def warn_provider_host_url_resolution_failure(
provider_ref: str,
exc: Exception,
*,
not_found_error_type: type[Exception],
) -> None:
"""Log a provider host-url lookup failure with the expected severity."""
if isinstance(exc, not_found_error_type):
_logger.warning("Provider not found during host_url resolution", extra={"provider_ref": provider_ref})
return
_logger.warning("Failed to resolve provider host_url", extra={"provider_ref": provider_ref}, exc_info=True)
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
from contextvars import ContextVar
from dataclasses import dataclass

from models import parse_workspace_name_ref
from nemo_platform import AsyncNeMoPlatform
from nemo_platform_ext.config import get_context
from nemo_platform_plugin.client.adapter import client_from_platform
from nemo_platform_plugin.models.client import AsyncModelsClient
from nemo_platform_plugin.models.refs import parse_workspace_name_ref
from nemo_platform_plugin.models.types import ModelEntity, ModelProvider
from nooa.unifiedllm import CompletionClient, UnifiedLLM

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from nemo_platform_plugin.client.types import Paginated
from nemo_platform_plugin.virtual_models.types import (
CreateVirtualModelRequest,
DeleteVirtualModelQueryParams,
ListVirtualModelsQueryParams,
UpdateVirtualModelRequest,
VirtualModel,
Expand Down Expand Up @@ -45,4 +46,6 @@ def update_virtual_model(

@delete(_VIRTUAL_MODELS + "/{name}")
@abstractmethod
def delete_virtual_model(*, workspace: str | None = None, name: str) -> None: ...
def delete_virtual_model(
*, workspace: str | None = None, name: str, query_params: DeleteVirtualModelQueryParams | None = None
) -> None: ...
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

__all__ = [
"CreateVirtualModelRequest",
"DeleteVirtualModelQueryParams",
"ListVirtualModelsQueryParams",
"MiddlewareCall",
"UpdateVirtualModelRequest",
Expand Down Expand Up @@ -117,3 +118,9 @@ class ListVirtualModelsQueryParams(TypedDict, total=False):
sort: NotRequired[str]
filter: NotRequired[str]
exclude_autoprovisioned: NotRequired[bool]


class DeleteVirtualModelQueryParams(TypedDict, total=False):
"""Query parameters accepted by the VirtualModel delete operation."""

expected_db_version: NotRequired[int]
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Inference Gateway provider client tests."""

from __future__ import annotations

import httpx
from nemo_platform_plugin.inference_gateway.client import (
AsyncInferenceGatewayProviderClient,
InferenceGatewayProviderClient,
)

BASE = "http://test:8000"


def test_get_provider_models_decodes_json_and_uses_provider_route() -> None:
seen: list[httpx.Request] = []

def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return httpx.Response(200, request=request, json={"object": "list", "data": [{"id": "model-a"}]})

client = InferenceGatewayProviderClient(
base_url=BASE,
workspace="default",
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)

result = client.get_provider_models(workspace="team-a", name="provider-a")

assert result == {"object": "list", "data": [{"id": "model-a"}]}
assert seen[0].url.path == "/apis/inference-gateway/v2/workspaces/team-a/provider/provider-a/-/v1/models"


async def test_async_get_provider_models_returns_text_for_non_json_body() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, request=request, content=b"not-json")

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
client = AsyncInferenceGatewayProviderClient(
base_url=BASE,
workspace="default",
http_client=http_client,
)

result = await client.get_provider_models(name="provider-a")

assert result == "not-json"
17 changes: 6 additions & 11 deletions services/core/models/src/nmp/core/models/app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,14 @@
from logging import getLogger
from typing import Generic, List, Optional, TypeVar

from nemo_platform.types.inference.model_deployment import ModelDeployment
from nemo_platform.types.inference.model_deployment_config import ModelDeploymentConfig
from nemo_platform.types.inference.model_provider import ModelProvider
from nemo_platform_plugin.k8s_naming import (
DNS_LABEL_MAX_LENGTH,
DNS_SUBDOMAIN_MAX_LENGTH,
HASH_SUFFIX_LENGTH,
k8s_safe_name,
workspace_name_identity,
)
from nemo_platform_plugin.models.types import ModelEntity
from nemo_platform_plugin.models.types import ModelDeployment, ModelDeploymentConfig, ModelEntity, ModelProvider
from nmp.common.api.common import PaginationData
from nmp.common.entities.constants import NAME_PATTERN as ENTITY_NAME_PATTERN
from pydantic import BaseModel
Expand Down Expand Up @@ -108,7 +105,7 @@ def parse_model_name_revision(
parsed_name = name_without_revision

# Parse namespace prefix only if explicit model_namespace was NOT provided
if not model_namespace and "/" in parsed_name:
if not model_namespace and parsed_name is not None and "/" in parsed_name:
# Split on first / to extract namespace
parts = parsed_name.split("/", 1)
parsed_namespace = parts[0]
Expand Down Expand Up @@ -169,12 +166,10 @@ def get_model_weights_type(
if model_entity and model_entity.fileset:
return ModelWeightsType.FILES_SERVICE

# Guard the nested groups: a partial/legacy config may omit executor_config or
# model_spec, and we must not raise AttributeError while resolving weights.
executor_cfg = getattr(model_deployment_config, "executor_config", None)
model_spec_cfg = getattr(model_deployment_config, "model_spec", None)
image_name = getattr(executor_cfg, "image_name", None)
model_name = getattr(model_spec_cfg, "model_name", None)
executor_cfg = model_deployment_config.executor_config if model_deployment_config else None
model_spec_cfg = model_deployment_config.model_spec if model_deployment_config else None
image_name = executor_cfg.image_name if executor_cfg else None
model_name = model_spec_cfg.model_name if model_spec_cfg else None

# If the model is a multi-LLM, we have already ruled out HF weights, so we download from Files service
if is_multi_llm_image(image_name) and model_name:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import Any, Dict

from nemo_platform import AsyncNeMoPlatform
from nemo_platform.types.inference import ModelDeploymentStatus
from nemo_platform_plugin.models.types import ModelDeploymentStatus
from nmp.core.models.controllers.context import ModelContext
from pydantic import BaseModel

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Protocol

from nemo_platform.types.inference.k8s_nim_operator_config import K8sNIMOperatorConfig
from nemo_platform.types.inference.model_deployment import ModelDeployment
from nemo_platform.types.shared.tool_call_config import ToolCallConfig
from nemo_platform_plugin.models.types import K8sNIMOperatorConfig, ModelDeployment, ToolCallConfig

LOG_TAIL_LINES = 80
LOG_MAX_CHARS = 2048
Expand Down
Loading