Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
9 changes: 8 additions & 1 deletion docs/model-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,14 @@ configured for those formats can send them again. See the
[media guide](media.md#text-only-requests-and-replay).

To let a model receive it, add the kind to its entry, for example
`media_inputs: [video]`, and keep any kinds already listed.
`media_inputs: [video]`, and keep any kinds already listed. Only add kinds the
request interface implements: native Anthropic Messages and Responses send
image and PDF inputs, not audio or video. Chat sends `input_audio` and
`video_url` extensions, but the selected backend may still reject them.
Clients make text-only calls as before, but an enabled audio or video
attachment on Anthropic Messages or Responses is rejected locally before any
HTTP request; disabled attachments are filtered out first. Original history is
not rewritten.

Connect can test media for you with generated samples. See
[Connect media setup](model-connect.md#choose-media-inputs-during-setup).
2 changes: 2 additions & 0 deletions src/nooa/media_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def validate_media_inputs(value: object) -> tuple[MediaInput, ...]:
"input_image": "image",
"image": "image",
"input_audio": "audio",
"audio_url": "audio",
"audio": "audio",
"output_audio": "audio",
"video_url": "video",
Expand All @@ -60,6 +61,7 @@ def _mime_hints(block: Mapping[str, Any]) -> set[str]:
"video_url": ("video_url",),
"video": ("source",),
"input_audio": ("input_audio",),
"audio_url": ("audio_url",),
"audio": ("source",),
"file": ("file",),
"document": ("source",),
Expand Down
156 changes: 156 additions & 0 deletions src/nooa/media_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Which media kinds each request interface can carry.

NOOA sends requests through one of four interfaces:

- ``chat``: OpenAI-compatible Chat Completions. Every ``CompletionClient`` route
that is not native Anthropic uses this format; LiteLLM translates it for other
providers. It can carry images, audio, video and PDFs.
- ``anthropic``: native Anthropic Messages, used when LiteLLM dispatches a
``CompletionClient`` request to the ``anthropic`` provider. Images and PDFs only.
- ``responses``: the OpenAI Responses API, used by ``ResponsesClient``. Images and
PDFs only.
- ``decision``: native decision endpoints. Images only.

These limits describe what NOOA's request code can send. They are not a claim
about what a particular model accepts: a Chat backend may still reject audio or
video. The interface is resolved from the actual LiteLLM dispatch (model, endpoint
and ``custom_llm_provider``), never from the model name or ``api_style``.
"""

from collections.abc import Mapping
from typing import Any

from nooa.media_inputs import MediaInput, attachment_kind, validate_media_inputs

ALL_INPUT_KINDS: tuple[MediaInput, ...] = ("image", "audio", "video", "pdf")


def interface_for_provider(provider: str | None) -> str:
"""Return the wire format a ``CompletionClient`` request uses for ``provider``.

``provider`` is the LiteLLM provider name for the request. LiteLLM sends
``anthropic`` requests as native Anthropic Messages; every other provider is
given an OpenAI-compatible Chat request. ``ResponsesClient`` does not use this
function: its requests always use the ``responses`` interface.
"""
return "anthropic" if provider == "anthropic" else "chat"


def implemented_input_kinds(
interface: str = "chat", provider: str | None = None
) -> tuple[MediaInput, ...]:
"""Return the media kinds NOOA can send on ``interface``.

``provider`` lets a caller pass the LiteLLM provider instead of an interface
name; ``anthropic`` gives the native Anthropic limits. The result is an upper
bound for the request code, not proof that a model accepts these kinds.
"""
if interface == "decision":
return ("image",)
if interface == "responses" or interface == "anthropic" or provider == "anthropic":
return ("image", "pdf")
return ALL_INPUT_KINDS


def resolved_input_interface(
model: str, config: Mapping[str, Any], *, client_type: str = "completion"
) -> str:
"""Return the interface a client built from ``model`` and ``config`` would use.

``client_type`` ``responses`` and ``decision`` fix the interface directly. For
completion clients, LiteLLM resolves the provider from the model, the endpoint
(``api_base`` or ``base_url``) and ``custom_llm_provider``, exactly as it does
when sending. No network call, key lookup or metadata is used. If the provider
cannot be resolved (for example an incomplete profile), the result is ``chat``,
which does not restrict any media kind.
"""
if client_type in {"responses", "decision"}:
return client_type
if not model:
return "chat"
import litellm

try:
_, provider, _, _ = litellm.get_llm_provider(
model=model,
api_base=config.get("api_base") or config.get("base_url"),
custom_llm_provider=config.get("custom_llm_provider"),
)
except Exception:
# LiteLLM raises for model strings it cannot route; treat them as Chat,
# which is also how CompletionClient would try to send them.
return "chat"
return interface_for_provider(provider)


def entry_input_interface(entry: Mapping[str, Any]) -> str:
"""Return the interface for a registry entry (``model_name`` plus settings)."""
return resolved_input_interface(
entry.get("model_name") or "",
entry,
client_type=entry.get("client_type") or "completion",
)


def unsupported_input_reason(interface: str, kinds) -> str:
"""Return the error message for media ``kinds`` that ``interface`` cannot send."""
name = {
"anthropic": "native Anthropic Messages",
"responses": "Responses",
"decision": "Decisions",
}.get(interface, interface)
available = ", ".join(implemented_input_kinds(interface))
return (
f"Unsupported media input for {name}: {', '.join(kinds)}. "
f"This request interface implements only {available}. "
"Explicitly repair media_inputs by choosing supported formats or text-only; "
"enabling without testing cannot support these inputs."
)


def validate_implemented_media_inputs(value: object, interface: str) -> tuple[MediaInput, ...]:
"""Validate a ``media_inputs`` setting and check every kind fits ``interface``.

Returns the normalized tuple. Raises ``ValueError`` if ``value`` is not a valid
``media_inputs`` setting or lists a kind ``interface`` cannot send.
"""
enabled = validate_media_inputs(value)
unsupported = [kind for kind in enabled if kind not in implemented_input_kinds(interface)]
if unsupported:
raise ValueError(unsupported_input_reason(interface, unsupported))
return enabled


def reject_unsupported_media(messages, interface: str) -> None:
"""Raise ``ValueError`` if ``messages`` attach a kind ``interface`` cannot send.

Call this after disabled media has been filtered out, so only media the
caller allowed is checked. The check looks at the places a request carries
attachments: a message that is itself an attachment item, an assistant
``audio`` field, and the blocks of ``content`` or ``output`` lists. It does not
inspect tool arguments, reasoning or other nested JSON.
"""
allowed = implemented_input_kinds(interface)
if allowed == ALL_INPUT_KINDS:
return
kinds = []
for message in messages:
if not isinstance(message, Mapping):
continue
if message.get("audio") is not None:
kinds.append("audio")
# Responses input can contain attachment items at the top level, beside
# role/content messages.
if (kind := attachment_kind(message)) is not None:
kinds.append(kind)
for slot in ("content", "output"):
blocks = message.get(slot)
if isinstance(blocks, (list, tuple)):
kinds.extend(
attachment_kind(block) for block in blocks if isinstance(block, Mapping)
)
unsupported = [kind for kind in ALL_INPUT_KINDS if kind in kinds and kind not in allowed]
if unsupported:
raise ValueError(unsupported_input_reason(interface, unsupported))
6 changes: 5 additions & 1 deletion src/nooa/unifiedllm/chat_parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from nooa._immutable_json import json_containers
from nooa.llm_types import AssistantPart, AssistantReasoning, AssistantText, LLMResponse, ToolCall
from nooa.media_policy import interface_for_provider
from nooa.unifiedllm.cache_policy import wrap_anthropic_text

from .media_inputs import guard_native_media
Expand Down Expand Up @@ -214,7 +215,10 @@ def project_chat_turn(
for part in turn.parts:
native = json_containers(part.native) if compatible and part.native else {}
if isinstance(part, AssistantText) and native:
guard_native_media(native, media_inputs)
# This projection serves CompletionClient only, which sends either
# native Anthropic Messages or OpenAI-compatible Chat. ResponsesClient
# turns are projected in response_parts.py with interface="responses".
guard_native_media(native, media_inputs, interface=interface_for_provider(provider))
if isinstance(part, ToolCall):
call_id = part.id
if signature := native.pop("inline_thought_signature", None):
Expand Down
63 changes: 47 additions & 16 deletions src/nooa/unifiedllm/media_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,30 @@ def removed_media_notice(kind: str) -> str:


def filter_media_messages(messages, enabled: tuple[MediaInput, ...]):
"""Filter public attachment slots on a new request view, keeping turn objects.
"""Return a copy of ``messages`` with media that is not ``enabled`` replaced.

Does not visit tool arguments, reasoning, vendor extensions or arbitrary
nested dictionaries. Projection still owns native LLMResponse containers.
Each disabled attachment is replaced by a short text notice (see
``removed_media_notice``) so the model knows something was removed. Three
places are checked: a message that is itself an attachment item (Responses
input allows these at the top level), an assistant ``audio`` field, and the
blocks of a ``content`` list (``output`` for ``function_call_output`` items).
Tool arguments, reasoning and vendor extensions are not inspected.
``LLMResponse`` and ``CacheBoundary`` objects are passed through unchanged;
their native content is checked later by ``guard_native_media``. The input
list and its messages are not modified.
"""
result = []
for message in messages:
if isinstance(message, (LLMResponse, CacheBoundary)) or not isinstance(message, Mapping):
result.append(message)
continue
item = dict(message)
kind = attachment_kind(item)
if kind is not None and kind not in enabled:
# The whole message is an attachment item, so there is no content
# list to filter: replace the item with a user notice.
result.append({"role": "user", "content": removed_media_notice(kind)})
continue
slot = "output" if item.get("type") == "function_call_output" else "content"
if isinstance(item.get(slot), tuple):
# A tuple of blocks is sent like a list, so it must be filtered like one.
Expand Down Expand Up @@ -75,16 +88,34 @@ def filter_media_messages(messages, enabled: tuple[MediaInput, ...]):
return result


def guard_native_media(native: Mapping[str, Any], enabled: tuple[MediaInput, ...] | None) -> None:
"""Adapter-owned compatible envelopes are indivisible; never recurse."""
if enabled is None:
return # Direct projection helpers retain their existing contract.
content = native.get("content")
kinds = ["audio"] if native.get("audio") is not None else []
if isinstance(content, (list, tuple)):
kinds.extend(attachment_kind(block) for block in content if isinstance(block, Mapping))
if any(kind is not None and kind not in enabled for kind in kinds):
raise ReasoningReplayError(
"Cannot remove disabled media from opaque assistant state; "
"supply a portable assistant turn instead."
)
def guard_native_media(
native: Mapping[str, Any], enabled: tuple[MediaInput, ...] | None, *, interface: str = "chat"
) -> None:
"""Check the native content of a replayed assistant turn before it is sent.

``native`` is the provider's own message for an earlier assistant turn. It is
replayed as one unit and cannot be partly edited, so media cannot be removed
from it the way ``filter_media_messages`` removes it from ordinary messages.

Two checks run in order:

1. If ``enabled`` is not None and ``native`` contains a media kind that is not
enabled, raise ``ReasoningReplayError``. The caller must replay a portable
version of the turn instead. ``enabled=None`` skips this check; direct
projection helpers use it when they do not apply media permissions.
2. Raise ``ValueError`` if ``native`` contains media that ``interface`` cannot
send (see ``nooa.media_policy``).
"""

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring should say what the method does and why in clear technical English.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten in 9f06160. The docstring now says what guard_native_media does and why:

  • What it checks: the native content of a replayed assistant turn, before the turn is sent again.
  • Why it can only raise: that content is the provider's own message. It is replayed as one unit and can't be partly edited, so disabled media can't be filtered out of it the way filter_media_messages filters ordinary messages.
  • The two checks, in order:
    1. Disabled media raises ReasoningReplayError; the caller must replay a portable version of the turn instead. enabled=None skips this check.
    2. Media the interface can't send raises ValueError (see nooa.media_policy). 🤖🤖🤖

if enabled is not None:
content = native.get("content")
kinds = ["audio"] if native.get("audio") is not None else []
if isinstance(content, (list, tuple)):
kinds.extend(attachment_kind(block) for block in content if isinstance(block, Mapping))
if any(kind is not None and kind not in enabled for kind in kinds):
raise ReasoningReplayError(
"Cannot remove disabled media from opaque assistant state; "
"supply a portable assistant turn instead."
)
from nooa.media_policy import reject_unsupported_media

reject_unsupported_media([native], interface)
60 changes: 58 additions & 2 deletions src/nooa/unifiedllm/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,20 @@ def client_from_config(
"drop_params": config.get("drop_params", True),
}

if "api_base" not in overrides and (api_base := config.get("api_base")):
# api_base and base_url are two spellings of the same endpoint. An override
# of None counts as no override: clearing the configured endpoint would send
# requests and credentials to the provider's default endpoint. A non-None
# override of either spelling replaces every spelling the entry configures
# (set below), so the owned SDK client never binds the old endpoint.
overrides = {
key: value
for key, value in overrides.items()
if not (key in ("api_base", "base_url") and value is None)
}
endpoint_override = next(
(overrides[key] for key in ("api_base", "base_url") if key in overrides), None
)
if endpoint_override is None and (api_base := config.get("api_base")):
params["api_base"] = api_base

# Only resolve the registry's api_key_env when the caller hasn't
Expand All @@ -486,6 +499,9 @@ def client_from_config(
for key in (
"temperature",
"top_p",
"timeout",
"base_url",
"custom_llm_provider",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"max_tokens",
"reasoning",
"reasoning_effort",
Expand All @@ -500,8 +516,14 @@ def client_from_config(
"cache_breakpoint",
"media_inputs",
):
if key == "base_url" and endpoint_override is not None:
continue
if key in config and key not in overrides:
params[key] = config[key]
if endpoint_override is not None:
for alias in ("api_base", "base_url"):
if alias in config:
params[alias] = endpoint_override

# Registry aliases can centrally tune or disable the clients' default endpoint
# retry behavior. ``retry_config: false`` means a single attempt for every
Expand Down Expand Up @@ -541,6 +563,37 @@ def client_from_config(
name,
)

# A context_window belongs to one route. If an override changes the model,
# endpoint, client type, provider, client object or credentials, drop the
# entry's window: it may not hold for the new route. Passing api_key where
# the entry used api_key_env counts as a credential change even if the
# values match. A context_window passed in the overrides is always kept.
from nooa.unifiedllm.route_identity import same_context_route

# Compare only settings that reach the client. A completion or responses
# entry may carry an ``endpoint`` key (the decision-route spelling) that
# client_from_config does not forward; including it would make every such
# entry look like a changed route and drop its context_window.
original_route = {
**{key: value for key, value in config.items() if key != "endpoint"},
"api_key": params.get("api_key"),
}
effective_route = {
**params,
**overrides,
"api_key_env": config.get("api_key_env") if "api_key" not in overrides else None,
}
Comment thread
furgalep marked this conversation as resolved.
same_route = same_context_route(
model,
original_route,
config.get("client_type", "completion"),
effective_route.get("model", model),
effective_route,
selected_client_type,
)
if not same_route:
config.pop("context_window", None)

params.update(overrides)
if config and "media_inputs" not in params and name not in _warned_text_only:
_warned_text_only.add(name)
Expand All @@ -557,5 +610,8 @@ def client_from_config(
if selected_client_type == "responses"
else CompletionClient(**params)
)
client._registry_config = config # For context_window lookup
# Keep the entry's settings on the client so later context-window lookups
# can check they still describe the client's actual route.
client._registry_config = config
client._registry_context_route = (client.model, dict(client.config), selected_client_type)
return client
2 changes: 1 addition & 1 deletion src/nooa/unifiedllm/response_parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ def project_turn(
if native:
from .media_inputs import guard_native_media

guard_native_media(native, media_inputs)
guard_native_media(native, media_inputs, interface="responses")
_restore_text(native["content"], part.text, "")
item = native
elif part.text:
Expand Down
Loading
Loading