Skip to content

naqsha.models

NAP V2 and Thin Model Adapters

naqsha.models

Model Client ports and adapters.

AnthropicMessagesModelClient

Bases: ModelClient

POST {base_url}/v1/messages.

Source code in src/naqsha/models/anthropic.py
class AnthropicMessagesModelClient(ModelClient):
    """POST ``{base_url}/v1/messages``."""

    def __init__(
        self,
        *,
        base_url: str,
        model: str,
        api_key_env: str = "ANTHROPIC_API_KEY",
        timeout_seconds: float = 120.0,
        max_tokens: int = 4096,
        anthropic_version: str = "2023-06-01",
        post_fn: _PostFn | None = None,
    ) -> None:
        self._base_url = base_url.rstrip("/")
        self._model = model
        self._api_key_env = api_key_env
        self._timeout_seconds = timeout_seconds
        self._max_tokens = max_tokens
        self._anthropic_version = anthropic_version
        self._post_fn = post_fn

    def next_message(
        self,
        *,
        query: str,
        trace: list[TraceEvent],
        tools: list[ToolSpec],
        memory: list[MemoryRecord],
        span_context: SpanContext | None = None,
        instructions: str = "",
    ) -> NapMessage:
        api_key = os.environ.get(self._api_key_env, "").strip()
        if not api_key:
            raise ModelInvocationError(
                f"Environment variable {self._api_key_env!r} is not set or empty."
            )

        transcript = trace_to_transcript(
            query=query, trace=trace, memory=memory, instructions=instructions
        )
        system_text, anthropic_messages = transcript_to_anthropic_messages(transcript)

        url = f"{self._base_url}/v1/messages"
        payload: dict[str, Any] = {
            "model": self._model,
            "max_tokens": self._max_tokens,
            "system": system_text,
            "messages": anthropic_messages,
            "tools": _anthropic_tools(tools),
            "temperature": 0,
        }

        wrapper = self._post_fn if self._post_fn is not None else default_post

        def _post(
            url_: str,
            headers: dict[str, str],
            body: bytes,
            timeout: float,
        ) -> tuple[int, bytes]:
            return wrapper(url_, headers, body, timeout)

        parsed = post_json(
            url,
            headers={
                "x-api-key": api_key,
                "anthropic-version": self._anthropic_version,
            },
            payload=payload,
            timeout_seconds=self._timeout_seconds,
            post_fn=_post,
            error_label="Anthropic Messages API",
        )

        msg_content = parsed.get("content")
        return attach_span_context(_content_blocks_to_nap(msg_content), span_context)

next_message

next_message(
    *,
    query: str,
    trace: list[TraceEvent],
    tools: list[ToolSpec],
    memory: list[MemoryRecord],
    span_context: SpanContext | None = None,
    instructions: str = "",
) -> NapMessage
Source code in src/naqsha/models/anthropic.py
def next_message(
    self,
    *,
    query: str,
    trace: list[TraceEvent],
    tools: list[ToolSpec],
    memory: list[MemoryRecord],
    span_context: SpanContext | None = None,
    instructions: str = "",
) -> NapMessage:
    api_key = os.environ.get(self._api_key_env, "").strip()
    if not api_key:
        raise ModelInvocationError(
            f"Environment variable {self._api_key_env!r} is not set or empty."
        )

    transcript = trace_to_transcript(
        query=query, trace=trace, memory=memory, instructions=instructions
    )
    system_text, anthropic_messages = transcript_to_anthropic_messages(transcript)

    url = f"{self._base_url}/v1/messages"
    payload: dict[str, Any] = {
        "model": self._model,
        "max_tokens": self._max_tokens,
        "system": system_text,
        "messages": anthropic_messages,
        "tools": _anthropic_tools(tools),
        "temperature": 0,
    }

    wrapper = self._post_fn if self._post_fn is not None else default_post

    def _post(
        url_: str,
        headers: dict[str, str],
        body: bytes,
        timeout: float,
    ) -> tuple[int, bytes]:
        return wrapper(url_, headers, body, timeout)

    parsed = post_json(
        url,
        headers={
            "x-api-key": api_key,
            "anthropic-version": self._anthropic_version,
        },
        payload=payload,
        timeout_seconds=self._timeout_seconds,
        post_fn=_post,
        error_label="Anthropic Messages API",
    )

    msg_content = parsed.get("content")
    return attach_span_context(_content_blocks_to_nap(msg_content), span_context)

ModelInvocationError

Bases: RuntimeError

Raised when transport or provider payloads cannot yield a valid NAP message.

Source code in src/naqsha/models/errors.py
class ModelInvocationError(RuntimeError):
    """Raised when transport or provider payloads cannot yield a valid NAP message."""

GeminiGenerateContentModelClient

Bases: ModelClient

POST {base}/v1beta/models/{model}:generateContent.

Source code in src/naqsha/models/gemini.py
class GeminiGenerateContentModelClient(ModelClient):
    """POST ``{base}/v1beta/models/{model}:generateContent``."""

    def __init__(
        self,
        *,
        base_url: str,
        model: str,
        api_key_env: str = "GEMINI_API_KEY",
        timeout_seconds: float = 120.0,
        post_fn: _PostFn | None = None,
    ) -> None:
        self._base_url = base_url.rstrip("/")
        self._model = model
        self._api_key_env = api_key_env
        self._timeout_seconds = timeout_seconds
        self._post_fn = post_fn

    def next_message(
        self,
        *,
        query: str,
        trace: list[TraceEvent],
        tools: list[ToolSpec],
        memory: list[MemoryRecord],
        span_context: SpanContext | None = None,
        instructions: str = "",
    ) -> NapMessage:
        api_key = os.environ.get(self._api_key_env, "").strip()
        if not api_key:
            raise ModelInvocationError(
                f"Environment variable {self._api_key_env!r} is not set or empty."
            )

        transcript = trace_to_transcript(
            query=query, trace=trace, memory=memory, instructions=instructions
        )
        system_instruction, contents = transcript_to_gemini_contents(transcript)

        url = f"{self._base_url}/v1beta/models/{self._model}:generateContent"
        payload: dict[str, Any] = {
            "systemInstruction": system_instruction,
            "contents": contents,
            "tools": [{"functionDeclarations": _gemini_declarations(tools)}],
            "toolConfig": {"functionCallingConfig": {"mode": "AUTO"}},
            "generationConfig": {"temperature": 0},
        }

        wrapper = self._post_fn if self._post_fn is not None else default_post

        def _post(
            url_: str,
            headers: dict[str, str],
            body: bytes,
            timeout: float,
        ) -> tuple[int, bytes]:
            return wrapper(url_, headers, body, timeout)

        parsed = post_json(
            url,
            headers={"x-goog-api-key": api_key},
            payload=payload,
            timeout_seconds=self._timeout_seconds,
            post_fn=_post,
            error_label="Gemini generateContent",
        )

        candidates = parsed.get("candidates")
        if not isinstance(candidates, list) or not candidates:
            raise ModelInvocationError("Gemini response missing candidates[0].")
        cand0 = candidates[0]
        if not isinstance(cand0, dict):
            raise ModelInvocationError("candidates[0] must be an object.")
        inner = cand0.get("content")
        if not isinstance(inner, dict):
            raise ModelInvocationError("candidate.content must be an object.")
        parts = inner.get("parts")
        return attach_span_context(_candidate_parts_to_nap(parts), span_context)

next_message

next_message(
    *,
    query: str,
    trace: list[TraceEvent],
    tools: list[ToolSpec],
    memory: list[MemoryRecord],
    span_context: SpanContext | None = None,
    instructions: str = "",
) -> NapMessage
Source code in src/naqsha/models/gemini.py
def next_message(
    self,
    *,
    query: str,
    trace: list[TraceEvent],
    tools: list[ToolSpec],
    memory: list[MemoryRecord],
    span_context: SpanContext | None = None,
    instructions: str = "",
) -> NapMessage:
    api_key = os.environ.get(self._api_key_env, "").strip()
    if not api_key:
        raise ModelInvocationError(
            f"Environment variable {self._api_key_env!r} is not set or empty."
        )

    transcript = trace_to_transcript(
        query=query, trace=trace, memory=memory, instructions=instructions
    )
    system_instruction, contents = transcript_to_gemini_contents(transcript)

    url = f"{self._base_url}/v1beta/models/{self._model}:generateContent"
    payload: dict[str, Any] = {
        "systemInstruction": system_instruction,
        "contents": contents,
        "tools": [{"functionDeclarations": _gemini_declarations(tools)}],
        "toolConfig": {"functionCallingConfig": {"mode": "AUTO"}},
        "generationConfig": {"temperature": 0},
    }

    wrapper = self._post_fn if self._post_fn is not None else default_post

    def _post(
        url_: str,
        headers: dict[str, str],
        body: bytes,
        timeout: float,
    ) -> tuple[int, bytes]:
        return wrapper(url_, headers, body, timeout)

    parsed = post_json(
        url,
        headers={"x-goog-api-key": api_key},
        payload=payload,
        timeout_seconds=self._timeout_seconds,
        post_fn=_post,
        error_label="Gemini generateContent",
    )

    candidates = parsed.get("candidates")
    if not isinstance(candidates, list) or not candidates:
        raise ModelInvocationError("Gemini response missing candidates[0].")
    cand0 = candidates[0]
    if not isinstance(cand0, dict):
        raise ModelInvocationError("candidates[0] must be an object.")
    inner = cand0.get("content")
    if not isinstance(inner, dict):
        raise ModelInvocationError("candidate.content must be an object.")
    parts = inner.get("parts")
    return attach_span_context(_candidate_parts_to_nap(parts), span_context)

OllamaChatModelClient

Bases: ModelClient

POST {base_url}/api/chat with tool definitions compatible with Ollama.

Source code in src/naqsha/models/ollama.py
class OllamaChatModelClient(ModelClient):
    """POST ``{base_url}/api/chat`` with tool definitions compatible with Ollama."""

    def __init__(
        self,
        *,
        base_url: str,
        model: str,
        api_key_env: str | None = None,
        timeout_seconds: float = 120.0,
        post_fn: _PostFn | None = None,
    ) -> None:
        self._base_url = base_url.rstrip("/")
        self._model = model
        self._api_key_env = api_key_env
        self._timeout_seconds = timeout_seconds
        self._post_fn = post_fn

    def next_message(
        self,
        *,
        query: str,
        trace: list[TraceEvent],
        tools: list[ToolSpec],
        memory: list[MemoryRecord],
        span_context: SpanContext | None = None,
        instructions: str = "",
    ) -> NapMessage:
        headers: dict[str, str] = {}
        if self._api_key_env:
            api_key = os.environ.get(self._api_key_env, "").strip()
            if not api_key:
                raise ModelInvocationError(
                    f"Environment variable {self._api_key_env!r} is not set or empty."
                )
            headers["Authorization"] = f"Bearer {api_key}"

        messages = trace_to_chat_messages(
            query=query, trace=trace, memory=memory, instructions=instructions
        )
        url = f"{self._base_url}/api/chat"
        payload: dict[str, Any] = {
            "model": self._model,
            "messages": messages,
            "tools": _tools_payload(tools),
            "stream": False,
            "options": {"temperature": 0},
        }
        wrapper = self._post_fn if self._post_fn is not None else default_post

        def _post(
            url_: str,
            hdrs: dict[str, str],
            body: bytes,
            timeout: float,
        ) -> tuple[int, bytes]:
            return wrapper(url_, hdrs, body, timeout)

        parsed = post_json(
            url,
            headers=headers,
            payload=payload,
            timeout_seconds=self._timeout_seconds,
            post_fn=_post,
            error_label="Ollama chat",
        )

        message = parsed.get("message")
        if not isinstance(message, dict):
            raise ModelInvocationError("Ollama response missing message object.")

        return attach_span_context(_openai_message_to_nap(message), span_context)

next_message

next_message(
    *,
    query: str,
    trace: list[TraceEvent],
    tools: list[ToolSpec],
    memory: list[MemoryRecord],
    span_context: SpanContext | None = None,
    instructions: str = "",
) -> NapMessage
Source code in src/naqsha/models/ollama.py
def next_message(
    self,
    *,
    query: str,
    trace: list[TraceEvent],
    tools: list[ToolSpec],
    memory: list[MemoryRecord],
    span_context: SpanContext | None = None,
    instructions: str = "",
) -> NapMessage:
    headers: dict[str, str] = {}
    if self._api_key_env:
        api_key = os.environ.get(self._api_key_env, "").strip()
        if not api_key:
            raise ModelInvocationError(
                f"Environment variable {self._api_key_env!r} is not set or empty."
            )
        headers["Authorization"] = f"Bearer {api_key}"

    messages = trace_to_chat_messages(
        query=query, trace=trace, memory=memory, instructions=instructions
    )
    url = f"{self._base_url}/api/chat"
    payload: dict[str, Any] = {
        "model": self._model,
        "messages": messages,
        "tools": _tools_payload(tools),
        "stream": False,
        "options": {"temperature": 0},
    }
    wrapper = self._post_fn if self._post_fn is not None else default_post

    def _post(
        url_: str,
        hdrs: dict[str, str],
        body: bytes,
        timeout: float,
    ) -> tuple[int, bytes]:
        return wrapper(url_, hdrs, body, timeout)

    parsed = post_json(
        url,
        headers=headers,
        payload=payload,
        timeout_seconds=self._timeout_seconds,
        post_fn=_post,
        error_label="Ollama chat",
    )

    message = parsed.get("message")
    if not isinstance(message, dict):
        raise ModelInvocationError("Ollama response missing message object.")

    return attach_span_context(_openai_message_to_nap(message), span_context)

OpenAiCompatModelClient

Bases: ModelClient

POST {base_url}/chat/completions on an OpenAI-compatible server.

Source code in src/naqsha/models/openai_compat.py
class OpenAiCompatModelClient(ModelClient):
    """POST ``{base_url}/chat/completions`` on an OpenAI-compatible server."""

    def __init__(
        self,
        *,
        base_url: str,
        model: str,
        api_key_env: str = "OPENAI_API_KEY",
        timeout_seconds: float = 120.0,
        post_fn: _PostFn | None = None,
    ) -> None:
        self._base_url = base_url.rstrip("/")
        self._model = model
        self._api_key_env = api_key_env
        self._timeout_seconds = timeout_seconds
        self._post_fn = post_fn

    def next_message(
        self,
        *,
        query: str,
        trace: list[TraceEvent],
        tools: list[ToolSpec],
        memory: list[MemoryRecord],
        span_context: SpanContext | None = None,
        instructions: str = "",
    ) -> NapMessage:
        api_key = os.environ.get(self._api_key_env, "").strip()
        if not api_key:
            raise ModelInvocationError(
                f"Environment variable {self._api_key_env!r} is not set or empty."
            )

        transcript = trace_to_transcript(
            query=query, trace=trace, memory=memory, instructions=instructions
        )
        messages = transcript_to_openai_chat_messages(transcript)
        url = f"{self._base_url}/chat/completions"
        payload = {
            "model": self._model,
            "messages": messages,
            "tools": _tools_payload(tools),
            "tool_choice": "auto",
            "temperature": 0,
        }
        wrapper = self._post_fn if self._post_fn is not None else default_post

        def _post(
            url_: str,
            headers: dict[str, str],
            body: bytes,
            timeout: float,
        ) -> tuple[int, bytes]:
            return wrapper(url_, headers, body, timeout)

        parsed = post_json(
            url,
            headers={"Authorization": f"Bearer {api_key}"},
            payload=payload,
            timeout_seconds=self._timeout_seconds,
            post_fn=_post,
            error_label="OpenAI-compatible chat completion",
        )

        choices = parsed.get("choices")
        if not isinstance(choices, list) or not choices:
            raise ModelInvocationError("Provider response missing choices[0].")
        first = choices[0]
        if not isinstance(first, dict):
            raise ModelInvocationError("choices[0] must be an object.")
        message = first.get("message")
        if not isinstance(message, dict):
            raise ModelInvocationError("choices[0].message must be an object.")

        return attach_span_context(_openai_message_to_nap(message), span_context)

next_message

next_message(
    *,
    query: str,
    trace: list[TraceEvent],
    tools: list[ToolSpec],
    memory: list[MemoryRecord],
    span_context: SpanContext | None = None,
    instructions: str = "",
) -> NapMessage
Source code in src/naqsha/models/openai_compat.py
def next_message(
    self,
    *,
    query: str,
    trace: list[TraceEvent],
    tools: list[ToolSpec],
    memory: list[MemoryRecord],
    span_context: SpanContext | None = None,
    instructions: str = "",
) -> NapMessage:
    api_key = os.environ.get(self._api_key_env, "").strip()
    if not api_key:
        raise ModelInvocationError(
            f"Environment variable {self._api_key_env!r} is not set or empty."
        )

    transcript = trace_to_transcript(
        query=query, trace=trace, memory=memory, instructions=instructions
    )
    messages = transcript_to_openai_chat_messages(transcript)
    url = f"{self._base_url}/chat/completions"
    payload = {
        "model": self._model,
        "messages": messages,
        "tools": _tools_payload(tools),
        "tool_choice": "auto",
        "temperature": 0,
    }
    wrapper = self._post_fn if self._post_fn is not None else default_post

    def _post(
        url_: str,
        headers: dict[str, str],
        body: bytes,
        timeout: float,
    ) -> tuple[int, bytes]:
        return wrapper(url_, headers, body, timeout)

    parsed = post_json(
        url,
        headers={"Authorization": f"Bearer {api_key}"},
        payload=payload,
        timeout_seconds=self._timeout_seconds,
        post_fn=_post,
        error_label="OpenAI-compatible chat completion",
    )

    choices = parsed.get("choices")
    if not isinstance(choices, list) or not choices:
        raise ModelInvocationError("Provider response missing choices[0].")
    first = choices[0]
    if not isinstance(first, dict):
        raise ModelInvocationError("choices[0] must be an object.")
    message = first.get("message")
    if not isinstance(message, dict):
        raise ModelInvocationError("choices[0].message must be an object.")

    return attach_span_context(_openai_message_to_nap(message), span_context)