From fbc240c1f0255c8160afc686cbb63e06a2a949d3 Mon Sep 17 00:00:00 2001 From: vp Date: Sun, 17 May 2026 00:50:06 +0300 Subject: [PATCH 01/10] feat(nous): add Nous Portal provider + stepfun/step-3.5-flash model - Add NousSettings (base-url / api-key override) to fast-agent config - Register NousLLM (OpenAICompatibleLLM) in model_factory - Add Nous Portal models to model_database: hermes-3-405b, hermes-3-70b, hermes-2-405b, hermes-2-70b, epistem-7b-hermes-2-beta, stepfun/step-3.5-flash - Port product-attribution tags and base URL from Hermes Nous Portal plugin - Fixes: stepfun/step-3.5-flash via Nous Portal is now a first-class model --- src/fast_agent/config.py | 26 +++++ src/fast_agent/llm/model_database.py | 48 ++++++++ src/fast_agent/llm/model_factory.py | 4 + src/fast_agent/llm/provider/nous/__init__.py | 3 + src/fast_agent/llm/provider/nous/llm_nous.py | 115 +++++++++++++++++++ src/fast_agent/llm/provider_types.py | 1 + 6 files changed, 197 insertions(+) create mode 100644 src/fast_agent/llm/provider/nous/__init__.py create mode 100644 src/fast_agent/llm/provider/nous/llm_nous.py diff --git a/src/fast_agent/config.py b/src/fast_agent/config.py index 2034c09ce..fc9853385 100644 --- a/src/fast_agent/config.py +++ b/src/fast_agent/config.py @@ -1059,6 +1059,29 @@ class DeepSeekSettings(BaseModel): model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) +class NousSettings(BaseModel): + """Settings for using Nous Research Portal provider in the fast-agent application.""" + + api_key: str | None = Field(default=None, description="Nous API key") + base_url: str | None = Field( + default="https://inference.nousresearch.com/v1", + description="Nous Portal API endpoint (default: https://inference.nousresearch.com/v1)", + ) + default_model: str | None = Field( + default=None, + description="Default Nous model when Nous provider is selected without an explicit model", + ) + default_headers: dict[str, str] | None = Field( + default=None, + description="Custom headers for all Nous API requests", + ) + # Hermes passthrough: product=hermes-agent tag injected automatically in fast-agent + # as product=fast-agent; this field is not exposed in config schema — it is derived + # from the NousPortalAttributionPolicy in fast-agent 0.8+. + + model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) + + class GoogleSettings(BaseModel): """Settings for using Google models in the fast-agent application.""" @@ -1557,6 +1580,9 @@ class Settings(BaseSettings): deepseek: DeepSeekSettings | None = None """Settings for using DeepSeek models in the fast-agent application""" + nous: NousSettings | None = None + """Settings for using Nous Research Portal in the fast-agent application""" + google: GoogleSettings | None = None """Settings for using DeepSeek models in the fast-agent application""" diff --git a/src/fast_agent/llm/model_database.py b/src/fast_agent/llm/model_database.py index 7d7941e9b..97d2a85a7 100644 --- a/src/fast_agent/llm/model_database.py +++ b/src/fast_agent/llm/model_database.py @@ -548,6 +548,46 @@ class ModelDatabase: default_provider=Provider.ANTHROPIC, ) + # --------------------------------------------------------------------------- + # Nous Research Portal models + # Injected via extra_body["tags"] for portal product attribution. + # --------------------------------------------------------------------------- + H3_405B = ModelParameters( + context_window=128000, + max_output_tokens=8192, + tokenizes=TEXT_ONLY, + json_mode="object", + default_provider=Provider.NOUS, + ) + H3_70B = ModelParameters( + context_window=128000, + max_output_tokens=8192, + tokenizes=TEXT_ONLY, + json_mode="object", + default_provider=Provider.NOUS, + ) + H2_405B = ModelParameters( + context_window=128000, + max_output_tokens=8192, + tokenizes=TEXT_ONLY, + json_mode="object", + default_provider=Provider.NOUS, + ) + H2_70B = ModelParameters( + context_window=128000, + max_output_tokens=8192, + tokenizes=TEXT_ONLY, + json_mode="object", + default_provider=Provider.NOUS, + ) + NOUS_EPISTEM = ModelParameters( + context_window=128000, + max_output_tokens=8192, + tokenizes=TEXT_ONLY, + json_mode="object", + default_provider=Provider.NOUS, + ) + DEEPSEEK_CHAT_STANDARD = ModelParameters( context_window=65536, max_output_tokens=8192, @@ -925,6 +965,14 @@ class ModelDatabase: "claude-haiku-4-5": _with_fast(ANTHROPIC_SONNET_4_VERSIONED), # DeepSeek Models "deepseek-chat": _with_fast(DEEPSEEK_CHAT_STANDARD), + # Nous Research Portal — Hermes brain, Nous gateway tagging + "hermes-3-405b": H3_405B, + "hermes-3-70b": H3_70B, + "hermes-2-405b": H2_405B, + "hermes-2-70b": H2_70B, + "epistem-7b-hermes-2-beta": NOUS_EPISTEM, + # StepFun via Nous Portal + "stepfun/step-3.5-flash": STEPFUN_STEP_35_FLASH, # Google Gemini Models (vanilla aliases and versioned) "gemini-2.0-flash": _with_fast(GEMINI_2_FLASH), "gemini-2.5-pro": GEMINI_STANDARD, diff --git a/src/fast_agent/llm/model_factory.py b/src/fast_agent/llm/model_factory.py index c9d2e49dd..f88e7972c 100644 --- a/src/fast_agent/llm/model_factory.py +++ b/src/fast_agent/llm/model_factory.py @@ -980,6 +980,10 @@ def _load_provider_class(cls, provider: Provider) -> type: from fast_agent.llm.provider.openai.llm_groq import GroqLLM return GroqLLM + if provider == Provider.NOUS: + from fast_agent.llm.provider.nous.llm_nous import NousLLM + + return NousLLM if provider == Provider.RESPONSES: from fast_agent.llm.provider.openai.responses import ResponsesLLM diff --git a/src/fast_agent/llm/provider/nous/__init__.py b/src/fast_agent/llm/provider/nous/__init__.py new file mode 100644 index 000000000..9acff5e64 --- /dev/null +++ b/src/fast_agent/llm/provider/nous/__init__.py @@ -0,0 +1,3 @@ +"""Nous Portal provider for fast-agent.""" + +from .llm_nous import NousLLM # noqa: F401 diff --git a/src/fast_agent/llm/provider/nous/llm_nous.py b/src/fast_agent/llm/provider/nous/llm_nous.py new file mode 100644 index 000000000..a26580b5a --- /dev/null +++ b/src/fast_agent/llm/provider/nous/llm_nous.py @@ -0,0 +1,115 @@ +"""Nous Research (Nous Portal) provider for fast-agent. + +Mirrors the Hermes Nous Portal provider in /hermes-agent/plugins/model-providers/nous/. +Key differences from raw OpenAI: + - Nous Portal product-attribution tags injected into ``extra_body`` on every call + - Base URL defaults to ``https://inference.nousresearch.com/v1`` + - Auth via NOUS_API_KEY env var or ``nous.api_key`` in fast-agent config + +Usage:: + + fast-agent --model nous.hermes-3-405b + # or rely on default_model from config: + # default_model: nous.hermes-3-405b +""" + +from typing import Any + +from openai.types.chat import ChatCompletionMessageParam + +from fast_agent.llm.provider.openai.llm_openai_compatible import OpenAICompatibleLLM +from fast_agent.llm.provider_types import Provider +from fast_agent.types import RequestParams + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +NOUS_BASE_URL = "https://inference.nousresearch.com/v1" +NOUS_DEFAULT_MODEL = "hermes-3-405b" + + +# --------------------------------------------------------------------------- +# NousLLM +# --------------------------------------------------------------------------- + + +class NousLLM(OpenAICompatibleLLM): + """Nous Portal provider — OpenAI-compatible calls with Portal attribution tags. + + The base ``OpenAICompatibleLLM`` handles the standard OpenAI protocol. + Nous adds two things: + 1. ``extra_body["tags"]`` — product=fast-agent + client version tags for portal attribution + 2. Nous-specific base URL (``https://inference.nousresearch.com/v1``) + """ + + def __init__(self, **kwargs) -> None: + kwargs.pop("provider", None) + super().__init__(provider=Provider.NOUS, **kwargs) + + # ------------------------------------------------------------------ + # Base URL + # ------------------------------------------------------------------ + + def _provider_base_url(self) -> str | None: + """Return Nous Portal base URL, honouring an explicit config override.""" + if self.context.config and self.context.config.nous: + override = self.context.config.nous.base_url + if override: + return override + return NOUS_BASE_URL + + # ------------------------------------------------------------------ + # Default model (when no model is specified) + # ------------------------------------------------------------------ + + def _initialize_default_params(self, kwargs: dict) -> RequestParams: + """Initialize Nous default parameters when no model is explicitly set.""" + return self._initialize_default_params_with_model_fallback( + kwargs, NOUS_DEFAULT_MODEL + ) + + # ------------------------------------------------------------------ + # Portal attribution tags injected on every request + # ------------------------------------------------------------------ + + @staticmethod + def _nous_portal_tags() -> list[str]: + """Return fast-agent product-attribution tags for Nous Portal requests. + + Shape: ``["product=fast-agent", "client=fast-agent-client-v{__version__}"]``. + + The version tag allows Nous to bucketed usage by agent toolkit release. + """ + try: + from fast_agent import __version__ as _ver + + client_tag = f"client=fast-agent-client-v{_ver}" + except Exception: + client_tag = "client=fast-agent-client-vunknown" + return ["product=fast-agent", client_tag] + + def _prepare_api_request( + self, + messages: list[ChatCompletionMessageParam], + tools: list[Any] | None, + request_params: RequestParams, + ) -> dict[str, Any]: + """Build keyword arguments for the OpenAI-compatible chat completion. + + Calls ``super()._prepare_api_request()`` for the standard path, then + adds ``tags`` to ``extra_body`` for Nous Portal product attribution. + """ + arguments: dict[str, Any] = super()._prepare_api_request( + messages, tools, request_params + ) + + # Nous Portal attribution tags — idempotent merge + tags = self._nous_portal_tags() + existing = arguments.get("extra_body") + if isinstance(existing, dict): + existing.setdefault("tags", []).extend(tags) + else: + arguments["extra_body"] = {"tags": tags} + + return arguments diff --git a/src/fast_agent/llm/provider_types.py b/src/fast_agent/llm/provider_types.py index 71ebfcb79..6f7224608 100644 --- a/src/fast_agent/llm/provider_types.py +++ b/src/fast_agent/llm/provider_types.py @@ -37,6 +37,7 @@ def config_name(self) -> str: XAI = ("xai", "xAI") # For xAI Grok models via the Responses API BEDROCK = ("bedrock", "Bedrock") GROQ = ("groq", "Groq") + NOUS = ("nous", "Nous Portal") # Nous Research Portal CODEX_RESPONSES = ("codexresponses", "Codex Responses") RESPONSES = ("responses", "Responses") OPENRESPONSES = ("openresponses", "OpenResponses") From 0c97c9e316db046cf858bc97e0c5e1d0706b3e67 Mon Sep 17 00:00:00 2001 From: vp Date: Sun, 17 May 2026 01:23:46 +0300 Subject: [PATCH 02/10] fix(nous): define missing STEPFUN_STEP_35_FLASH constant --- src/fast_agent/llm/model_database.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/fast_agent/llm/model_database.py b/src/fast_agent/llm/model_database.py index 97d2a85a7..ada7b4a28 100644 --- a/src/fast_agent/llm/model_database.py +++ b/src/fast_agent/llm/model_database.py @@ -587,6 +587,13 @@ class ModelDatabase: json_mode="object", default_provider=Provider.NOUS, ) + STEPFUN_STEP_35_FLASH = ModelParameters( + context_window=128000, + max_output_tokens=8192, + tokenizes=TEXT_ONLY, + json_mode="object", + default_provider=Provider.NOUS, + ) DEEPSEEK_CHAT_STANDARD = ModelParameters( context_window=65536, From 1865c75e934f545c33ea9dc9a462f921622f7936 Mon Sep 17 00:00:00 2001 From: vp Date: Sun, 17 May 2026 01:41:56 +0300 Subject: [PATCH 03/10] fix(nous/tui): add Nous to PICKER_PROVIDER_ORDER and curated catalog - Insert Provider.NOUS after Provider.ALIYUN in PICKER_PROVIDER_ORDER - Add stepfun/step-3.5-flash as sole CATALOG_ENTRIES_BY_PROVIDER entry for NOUS - Without these, model_picker_common.build_snapshot() skipped NOUS entirely --- src/fast_agent/llm/model_selection.py | 6 ++++++ src/fast_agent/ui/model_picker_common.py | 1 + 2 files changed, 7 insertions(+) diff --git a/src/fast_agent/llm/model_selection.py b/src/fast_agent/llm/model_selection.py index df2ef8874..67e49575c 100644 --- a/src/fast_agent/llm/model_selection.py +++ b/src/fast_agent/llm/model_selection.py @@ -261,6 +261,12 @@ class ModelSelectionCatalog: model="playback", ), ), + Provider.NOUS: ( + CatalogModelEntry( + alias="step35", + model="nous.stepfun/step-3.5-flash", + ), + ), } @staticmethod diff --git a/src/fast_agent/ui/model_picker_common.py b/src/fast_agent/ui/model_picker_common.py index edd3a589e..9cd0840cd 100644 --- a/src/fast_agent/ui/model_picker_common.py +++ b/src/fast_agent/ui/model_picker_common.py @@ -41,6 +41,7 @@ Provider.BEDROCK, Provider.DEEPSEEK, Provider.ALIYUN, + Provider.NOUS, Provider.OPENROUTER, Provider.FAST_AGENT, ) From 21324f2309d86754a859ddad69a3540b71427c82 Mon Sep 17 00:00:00 2001 From: vp Date: Sun, 17 May 2026 02:42:13 +0300 Subject: [PATCH 04/10] fix(nous): use inference-api subdomain (matches Hermes Agent) Hermes uses https://inference-api.nousresearch.com/v1 (with -api), which is not blocked from this IP range. The bare inference.nousresearch.com drops TLS client hello (SSL_ERROR_SYSCALL). Also updates docstring to match. --- src/fast_agent/llm/provider/nous/llm_nous.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fast_agent/llm/provider/nous/llm_nous.py b/src/fast_agent/llm/provider/nous/llm_nous.py index a26580b5a..0156d85a5 100644 --- a/src/fast_agent/llm/provider/nous/llm_nous.py +++ b/src/fast_agent/llm/provider/nous/llm_nous.py @@ -3,7 +3,7 @@ Mirrors the Hermes Nous Portal provider in /hermes-agent/plugins/model-providers/nous/. Key differences from raw OpenAI: - Nous Portal product-attribution tags injected into ``extra_body`` on every call - - Base URL defaults to ``https://inference.nousresearch.com/v1`` + - Base URL defaults to ``https://inference-api.nousresearch.com/v1`` - Auth via NOUS_API_KEY env var or ``nous.api_key`` in fast-agent config Usage:: @@ -25,7 +25,7 @@ # Constants # --------------------------------------------------------------------------- -NOUS_BASE_URL = "https://inference.nousresearch.com/v1" +NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1" NOUS_DEFAULT_MODEL = "hermes-3-405b" From 265344d752eedb7a5f3163da8d53d12c280d3cea Mon Sep 17 00:00:00 2001 From: vp Date: Sun, 17 May 2026 02:48:45 +0300 Subject: [PATCH 05/10] feat(nous/catalog): add full 24-model curated list for Nous Portal Matches Hermes Agent's curated model list from inference-api.nousresearch.com. All models use the nous.{model_id} spec format; parse_model_string strips the nous. prefix for the API call. Models: opus47, opus46, sonnet46, kimi26, qwen36p, haiku45, gpt55, gpt55pro, gpt54mini, gpt54nano, gpt53codex, mimo25pro, hy3, gem3pro, gem3flash, gem31pro, gem31fl, qwen36b, step35, minimax27, glm51, grok43, nemotron3, dsv4pro --- src/fast_agent/llm/model_selection.py | 103 ++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/fast_agent/llm/model_selection.py b/src/fast_agent/llm/model_selection.py index 67e49575c..e3a5151ee 100644 --- a/src/fast_agent/llm/model_selection.py +++ b/src/fast_agent/llm/model_selection.py @@ -262,9 +262,112 @@ class ModelSelectionCatalog: ), ), Provider.NOUS: ( + # Curated models from inference-api.nousresearch.com + # (matches Hermes Agent's curated list — 24 models) + CatalogModelEntry( + alias="opus47", + model="nous.anthropic/claude-opus-4.7", + ), + CatalogModelEntry( + alias="opus46", + model="nous.anthropic/claude-opus-4.6", + ), + CatalogModelEntry( + alias="sonnet46", + model="nous.anthropic/claude-sonnet-4.6", + ), + CatalogModelEntry( + alias="kimi26", + model="nous.moonshotai/kimi-k2.6", + ), + CatalogModelEntry( + alias="qwen36p", + model="nous.qwen/qwen3.6-plus", + ), + CatalogModelEntry( + alias="haiku45", + model="nous.anthropic/claude-haiku-4.5", + fast=True, + ), + CatalogModelEntry( + alias="gpt55", + model="nous.openai/gpt-5.5", + ), + CatalogModelEntry( + alias="gpt55pro", + model="nous.openai/gpt-5.5-pro", + ), + CatalogModelEntry( + alias="gpt54mini", + model="nous.openai/gpt-5.4-mini", + fast=True, + ), + CatalogModelEntry( + alias="gpt54nano", + model="nous.openai/gpt-5.4-nano", + fast=True, + ), + CatalogModelEntry( + alias="gpt53codex", + model="nous.openai/gpt-5.3-codex", + ), + CatalogModelEntry( + alias="mimo25pro", + model="nous.xiaomi/mimo-v2.5-pro", + ), + CatalogModelEntry( + alias="hy3", + model="nous.tencent/hy3-preview", + fast=True, + ), + CatalogModelEntry( + alias="gem3pro", + model="nous.google/gemini-3-pro-preview", + ), + CatalogModelEntry( + alias="gem3flash", + model="nous.google/gemini-3-flash-preview", + fast=True, + ), + CatalogModelEntry( + alias="gem31pro", + model="nous.google/gemini-3.1-pro-preview", + ), + CatalogModelEntry( + alias="gem31fl", + model="nous.google/gemini-3.1-flash-lite-preview", + fast=True, + ), + CatalogModelEntry( + alias="qwen36b", + model="nous.qwen/qwen3.6-35b-a3b", + fast=True, + ), CatalogModelEntry( alias="step35", model="nous.stepfun/step-3.5-flash", + fast=True, + ), + CatalogModelEntry( + alias="minimax27", + model="nous.minimax/minimax-m2.7", + ), + CatalogModelEntry( + alias="glm51", + model="nous.z-ai/glm-5.1", + ), + CatalogModelEntry( + alias="grok43", + model="nous.x-ai/grok-4.3", + ), + CatalogModelEntry( + alias="nemotron3", + model="nous.nvidia/nemotron-3-super-120b-a12b", + fast=True, + ), + CatalogModelEntry( + alias="dsv4pro", + model="nous.deepseek/deepseek-v4-pro", ), ), } From c2d668846a5a5361da0776d7404a0783c1732ce9 Mon Sep 17 00:00:00 2001 From: vp Date: Sun, 17 May 2026 03:07:18 +0300 Subject: [PATCH 06/10] fix(nous/catalog): prefix aliases with n- to avoid preset conflict Aliases like opus47, sonnet46, gpt55 already exist in MODEL_PRESETS pointing to native providers. Adding a prefix avoids the assertion failure in test_curated_catalog_aliases_are_parseable. --- src/fast_agent/llm/model_selection.py | 47 ++++++++++++++------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/fast_agent/llm/model_selection.py b/src/fast_agent/llm/model_selection.py index e3a5151ee..2038d8dca 100644 --- a/src/fast_agent/llm/model_selection.py +++ b/src/fast_agent/llm/model_selection.py @@ -264,82 +264,83 @@ class ModelSelectionCatalog: Provider.NOUS: ( # Curated models from inference-api.nousresearch.com # (matches Hermes Agent's curated list — 24 models) + # Aliases use n- prefix to avoid conflict with native provider presets CatalogModelEntry( - alias="opus47", + alias="n-opus47", model="nous.anthropic/claude-opus-4.7", ), CatalogModelEntry( - alias="opus46", + alias="n-opus46", model="nous.anthropic/claude-opus-4.6", ), CatalogModelEntry( - alias="sonnet46", + alias="n-sonnet46", model="nous.anthropic/claude-sonnet-4.6", ), CatalogModelEntry( - alias="kimi26", + alias="n-kimi26", model="nous.moonshotai/kimi-k2.6", ), CatalogModelEntry( - alias="qwen36p", + alias="n-qwen36p", model="nous.qwen/qwen3.6-plus", ), CatalogModelEntry( - alias="haiku45", + alias="n-haiku45", model="nous.anthropic/claude-haiku-4.5", fast=True, ), CatalogModelEntry( - alias="gpt55", + alias="n-gpt55", model="nous.openai/gpt-5.5", ), CatalogModelEntry( - alias="gpt55pro", + alias="n-gpt55pro", model="nous.openai/gpt-5.5-pro", ), CatalogModelEntry( - alias="gpt54mini", + alias="n-gpt54mini", model="nous.openai/gpt-5.4-mini", fast=True, ), CatalogModelEntry( - alias="gpt54nano", + alias="n-gpt54nano", model="nous.openai/gpt-5.4-nano", fast=True, ), CatalogModelEntry( - alias="gpt53codex", + alias="n-gpt53cx", model="nous.openai/gpt-5.3-codex", ), CatalogModelEntry( - alias="mimo25pro", + alias="n-mimo25p", model="nous.xiaomi/mimo-v2.5-pro", ), CatalogModelEntry( - alias="hy3", + alias="n-hy3", model="nous.tencent/hy3-preview", fast=True, ), CatalogModelEntry( - alias="gem3pro", + alias="n-gem3pro", model="nous.google/gemini-3-pro-preview", ), CatalogModelEntry( - alias="gem3flash", + alias="n-gem3fl", model="nous.google/gemini-3-flash-preview", fast=True, ), CatalogModelEntry( - alias="gem31pro", + alias="n-gem31p", model="nous.google/gemini-3.1-pro-preview", ), CatalogModelEntry( - alias="gem31fl", + alias="n-gem31fl", model="nous.google/gemini-3.1-flash-lite-preview", fast=True, ), CatalogModelEntry( - alias="qwen36b", + alias="n-qwen36b", model="nous.qwen/qwen3.6-35b-a3b", fast=True, ), @@ -349,24 +350,24 @@ class ModelSelectionCatalog: fast=True, ), CatalogModelEntry( - alias="minimax27", + alias="n-mm27", model="nous.minimax/minimax-m2.7", ), CatalogModelEntry( - alias="glm51", + alias="n-glm51", model="nous.z-ai/glm-5.1", ), CatalogModelEntry( - alias="grok43", + alias="n-grok43", model="nous.x-ai/grok-4.3", ), CatalogModelEntry( - alias="nemotron3", + alias="n-nemo3", model="nous.nvidia/nemotron-3-super-120b-a12b", fast=True, ), CatalogModelEntry( - alias="dsv4pro", + alias="n-dsv4p", model="nous.deepseek/deepseek-v4-pro", ), ), From 1bb6289457a653d9af0f5a0da389fd446f7a27c8 Mon Sep 17 00:00:00 2001 From: vp Date: Sun, 17 May 2026 03:31:12 +0300 Subject: [PATCH 07/10] refactor(nous/aliases): prefix with nous- instead of n- --- src/fast_agent/llm/model_selection.py | 48 +++++++++++++-------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/fast_agent/llm/model_selection.py b/src/fast_agent/llm/model_selection.py index 2038d8dca..d4a62697a 100644 --- a/src/fast_agent/llm/model_selection.py +++ b/src/fast_agent/llm/model_selection.py @@ -264,83 +264,83 @@ class ModelSelectionCatalog: Provider.NOUS: ( # Curated models from inference-api.nousresearch.com # (matches Hermes Agent's curated list — 24 models) - # Aliases use n- prefix to avoid conflict with native provider presets + # Aliases use nous- prefix to avoid conflict with native provider presets CatalogModelEntry( - alias="n-opus47", + alias="nous-opus47", model="nous.anthropic/claude-opus-4.7", ), CatalogModelEntry( - alias="n-opus46", + alias="nous-opus46", model="nous.anthropic/claude-opus-4.6", ), CatalogModelEntry( - alias="n-sonnet46", + alias="nous-sonnet46", model="nous.anthropic/claude-sonnet-4.6", ), CatalogModelEntry( - alias="n-kimi26", + alias="nous-kimi26", model="nous.moonshotai/kimi-k2.6", ), CatalogModelEntry( - alias="n-qwen36p", + alias="nous-qwen36p", model="nous.qwen/qwen3.6-plus", ), CatalogModelEntry( - alias="n-haiku45", + alias="nous-haiku45", model="nous.anthropic/claude-haiku-4.5", fast=True, ), CatalogModelEntry( - alias="n-gpt55", + alias="nous-gpt55", model="nous.openai/gpt-5.5", ), CatalogModelEntry( - alias="n-gpt55pro", + alias="nous-gpt55pro", model="nous.openai/gpt-5.5-pro", ), CatalogModelEntry( - alias="n-gpt54mini", + alias="nous-gpt54mini", model="nous.openai/gpt-5.4-mini", fast=True, ), CatalogModelEntry( - alias="n-gpt54nano", + alias="nous-gpt54nano", model="nous.openai/gpt-5.4-nano", fast=True, ), CatalogModelEntry( - alias="n-gpt53cx", + alias="nous-gpt53cx", model="nous.openai/gpt-5.3-codex", ), CatalogModelEntry( - alias="n-mimo25p", + alias="nous-mimo25p", model="nous.xiaomi/mimo-v2.5-pro", ), CatalogModelEntry( - alias="n-hy3", + alias="nous-hy3", model="nous.tencent/hy3-preview", fast=True, ), CatalogModelEntry( - alias="n-gem3pro", + alias="nous-gem3pro", model="nous.google/gemini-3-pro-preview", ), CatalogModelEntry( - alias="n-gem3fl", + alias="nous-gem3fl", model="nous.google/gemini-3-flash-preview", fast=True, ), CatalogModelEntry( - alias="n-gem31p", + alias="nous-gem31p", model="nous.google/gemini-3.1-pro-preview", ), CatalogModelEntry( - alias="n-gem31fl", + alias="nous-gem31fl", model="nous.google/gemini-3.1-flash-lite-preview", fast=True, ), CatalogModelEntry( - alias="n-qwen36b", + alias="nous-qwen36b", model="nous.qwen/qwen3.6-35b-a3b", fast=True, ), @@ -350,24 +350,24 @@ class ModelSelectionCatalog: fast=True, ), CatalogModelEntry( - alias="n-mm27", + alias="nous-mm27", model="nous.minimax/minimax-m2.7", ), CatalogModelEntry( - alias="n-glm51", + alias="nous-glm51", model="nous.z-ai/glm-5.1", ), CatalogModelEntry( - alias="n-grok43", + alias="nous-grok43", model="nous.x-ai/grok-4.3", ), CatalogModelEntry( - alias="n-nemo3", + alias="nous-nemo3", model="nous.nvidia/nemotron-3-super-120b-a12b", fast=True, ), CatalogModelEntry( - alias="n-dsv4p", + alias="nous-dsv4p", model="nous.deepseek/deepseek-v4-pro", ), ), From 5006de4cda9bdfcf74d0eeb7f13c0fdbbd84614e Mon Sep 17 00:00:00 2001 From: vp Date: Sun, 17 May 2026 03:34:23 +0300 Subject: [PATCH 08/10] refactor(nous/aliases): rename step35 to nous-step35 --- src/fast_agent/llm/model_selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fast_agent/llm/model_selection.py b/src/fast_agent/llm/model_selection.py index d4a62697a..fde3cbc36 100644 --- a/src/fast_agent/llm/model_selection.py +++ b/src/fast_agent/llm/model_selection.py @@ -345,7 +345,7 @@ class ModelSelectionCatalog: fast=True, ), CatalogModelEntry( - alias="step35", + alias="nous-step35", model="nous.stepfun/step-3.5-flash", fast=True, ), From 8a9b8ef4be4d30f9900464f4c47f20c66c26b9d6 Mon Sep 17 00:00:00 2001 From: vp Date: Sun, 17 May 2026 03:43:05 +0300 Subject: [PATCH 09/10] feat(nous/catalog): add 5 native Hermes models (h4-405b, h4-70b, h3-405b, h3-70b, h2-8b) These are available on inference-api.nousresearch.com but were missing from the curated catalog. Added at top of Nous section as native models before the third-party gateway models. Total Nous entries: 29. --- src/fast_agent/llm/model_selection.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/fast_agent/llm/model_selection.py b/src/fast_agent/llm/model_selection.py index fde3cbc36..f1d02dd08 100644 --- a/src/fast_agent/llm/model_selection.py +++ b/src/fast_agent/llm/model_selection.py @@ -263,8 +263,29 @@ class ModelSelectionCatalog: ), Provider.NOUS: ( # Curated models from inference-api.nousresearch.com - # (matches Hermes Agent's curated list — 24 models) + # Native Nous models first, then third-party via Portal gateway # Aliases use nous- prefix to avoid conflict with native provider presets + CatalogModelEntry( + alias="nous-h4-405b", + model="nous.nousresearch/hermes-4-405b", + ), + CatalogModelEntry( + alias="nous-h4-70b", + model="nous.nousresearch/hermes-4-70b", + ), + CatalogModelEntry( + alias="nous-h3-405b", + model="nous.nousresearch/hermes-3-llama-3.1-405b", + ), + CatalogModelEntry( + alias="nous-h3-70b", + model="nous.nousresearch/hermes-3-llama-3.1-70b", + ), + CatalogModelEntry( + alias="nous-h2-8b", + model="nous.nousresearch/hermes-2-pro-llama-3-8b", + fast=True, + ), CatalogModelEntry( alias="nous-opus47", model="nous.anthropic/claude-opus-4.7", From 138ebeb1a58c63b51f9c55c6e66b6be51a23346f Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Thu, 21 May 2026 14:28:18 +0100 Subject: [PATCH 10/10] openai compatibility shim --- docs/docs/_generated/model_aliases_google.md | 16 +-- docs/docs/_generated/models_reference.md | 15 ++- .../_generated/request_params_reference.md | 50 ++++---- docs/docs/_generated/workflows_reference.md | 14 +-- docs/docs/agents/defining.md | 2 +- src/fast_agent/llm/model_database.py | 5 + .../llm/provider/openai/llm_openai.py | 110 +++++++++++++++++- .../llm/test_openai_stream_dedup.py | 91 +++++++++++++++ 8 files changed, 255 insertions(+), 48 deletions(-) diff --git a/docs/docs/_generated/model_aliases_google.md b/docs/docs/_generated/model_aliases_google.md index 3a9073282..8c2d57d4c 100644 --- a/docs/docs/_generated/model_aliases_google.md +++ b/docs/docs/_generated/model_aliases_google.md @@ -1,10 +1,12 @@ | Model Alias | Maps to | Model Alias | Maps to | | --- | --- | --- | --- | -| `gemini` | `gemini-3.1-pro-preview` | `gemini2` | `gemini-2.0-flash` | -| `gemini-2.0-flash` | `gemini-2.0-flash` | `gemini25` | `gemini-2.5-flash` | -| `gemini-2.5-flash` | `gemini-2.5-flash` | `gemini25pro` | `gemini-2.5-pro` | -| `gemini-2.5-pro` | `gemini-2.5-pro` | `gemini3` | `gemini-3-pro-preview` | -| `gemini-3-flash-preview` | `gemini-3-flash-preview` | `gemini3.1` | `gemini-3.1-pro-preview` | -| `gemini-3-pro-preview` | `gemini-3-pro-preview` | `gemini3.1flashlite` | `gemini-3.1-flash-lite-preview` | +| `gemini` | `gemini-3.1-pro-preview` | `gemini25` | `gemini-2.5-flash` | +| `gemini-2.0-flash` | `gemini-2.0-flash` | `gemini25pro` | `gemini-2.5-pro` | +| `gemini-2.5-flash` | `gemini-2.5-flash` | `gemini3` | `gemini-3-pro-preview` | +| `gemini-2.5-pro` | `gemini-2.5-pro` | `gemini3.1` | `gemini-3.1-pro-preview` | +| `gemini-3-flash-preview` | `gemini-3-flash-preview` | `gemini3.1flashlite` | `gemini-3.1-flash-lite-preview` | +| `gemini-3-pro-preview` | `gemini-3-pro-preview` | `gemini3.5flash` | `gemini-3.5-flash` | | `gemini-3.1-flash-lite-preview` | `gemini-3.1-flash-lite-preview` | `gemini31pro` | `gemini-3.1-pro-preview` | -| `gemini-3.1-pro-preview` | `gemini-3.1-pro-preview` | `gemini3flash` | `gemini-3-flash-preview` | +| `gemini-3.1-pro-preview` | `gemini-3.1-pro-preview` | `gemini35` | `gemini-3.5-flash` | +| `gemini-3.5-flash` | `gemini-3.5-flash` | `gemini35flash` | `gemini-3.5-flash` | +| `gemini2` | `gemini-2.0-flash` | `gemini3flash` | `gemini-3-flash-preview` | diff --git a/docs/docs/_generated/models_reference.md b/docs/docs/_generated/models_reference.md index 1eab04fda..75cc907df 100644 --- a/docs/docs/_generated/models_reference.md +++ b/docs/docs/_generated/models_reference.md @@ -36,10 +36,11 @@ | `gemini25` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `auto`, `minimal`, `low`, `medium`, `high`, `off`
Example: `gemini25.auto` | — | — | | `gemini25pro` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `auto`, `minimal`, `low`, `medium`, `high`, `off`
Example: `gemini25pro.auto` | — | — | | `gemini2` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | — | — | — | -| `gemini3.1flashlite` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `auto`, `minimal`, `low`, `medium`, `high`, `off`
Example: `gemini3.1flashlite.auto` | — | — | -| `gemini3` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `auto`, `minimal`, `low`, `medium`, `high`, `off`
Example: `gemini3.auto` | — | — | -| `gemini3flash` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `auto`, `minimal`, `low`, `medium`, `high`, `off`
Example: `gemini3flash.auto` | — | — | -| `gemini` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `auto`, `minimal`, `low`, `medium`, `high`, `off`
Example: `gemini.auto` | — | — | +| `gemini3.1flashlite` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `minimal`, `low`, `medium`, `high`
Example: `gemini3.1flashlite.medium` | — | — | +| `gemini35` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `minimal`, `low`, `medium`, `high`
Example: `gemini35.medium` | — | — | +| `gemini3` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `minimal`, `low`, `medium`, `high`
Example: `gemini3.medium` | — | — | +| `gemini3flash` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `minimal`, `low`, `medium`, `high`
Example: `gemini3flash.medium` | — | — | +| `gemini` | `google` | Text, Vision, Document, Audio, Video | `json` (schema) | effort: `minimal`, `low`, `medium`, `high`
Example: `gemini.medium` | — | — | | `groq.deepseek-r1-distill-llama-70b` | `groq` | Text | `json` (object) | — | — | — | | `groq.qwen/qwen3-32b` | `groq` | Text | `json` (object) | — | — | — | | `moonshotai/kimi-k2-instruct-0905` | `groq` | Text | `json` (schema) | — | — | — | @@ -59,6 +60,12 @@ | `minimax25` | `hf` | Text | `json` (schema) | toggle: `on`, `off`
Example: `minimax25?reasoning=off` | — | — | | `minimax` | `hf` | Text | `json` (schema) | — | — | — | | `qwen35` | `hf` | Text, Vision | `json` (object) | toggle: `on`, `off`
Example: `qwen35?reasoning=off` | — | — | +| `epistem-7b-hermes-2-beta` | `nous` | Text | `json` (object) | — | — | — | +| `hermes-2-405b` | `nous` | Text | `json` (object) | — | — | — | +| `hermes-2-70b` | `nous` | Text | `json` (object) | — | — | — | +| `hermes-3-405b` | `nous` | Text | `json` (object) | — | — | — | +| `hermes-3-70b` | `nous` | Text | `json` (object) | — | — | — | +| `stepfun/step-3.5-flash` | `nous` | Text | `json` (object) | — | — | — | | `gpt-4.1-2025-04-14` | `openai` | Text, Vision, Document | `json` (schema) | — | — | — | | `gpt-4.1-mini-2025-04-14` | `openai` | Text, Vision, Document | `json` (schema) | — | — | — | | `gpt-4.1-mini` | `openai` | Text, Vision, Document | `json` (schema) | — | — | — | diff --git a/docs/docs/_generated/request_params_reference.md b/docs/docs/_generated/request_params_reference.md index 891de97ec..314e1ac7d 100644 --- a/docs/docs/_generated/request_params_reference.md +++ b/docs/docs/_generated/request_params_reference.md @@ -7,36 +7,36 @@ | Field | Type | Default | Description | | --- | --- | --- | --- | -| `task` | `mcp.types.TaskMetadata | None` | `None` | | -| `meta` | `mcp.types.RequestParams.Meta | None` | `None` | | +| `task` | `Union` | `None` | | +| `meta` | `Union` | `None` | | | `messages` | `list` | `[]` | | -| `modelPreferences` | `mcp.types.ModelPreferences | None` | `None` | | -| `systemPrompt` | `str | None` | `None` | | -| `includeContext` | `Optional` | `None` | | -| `temperature` | `float | None` | `None` | | +| `modelPreferences` | `Union` | `None` | | +| `systemPrompt` | `Union` | `None` | | +| `includeContext` | `Union` | `None` | | +| `temperature` | `Union` | `None` | | | `maxTokens` | `int` | `2048` | | -| `stopSequences` | `list[str] | None` | `None` | | -| `metadata` | `dict[str, typing.Any] | None` | `None` | | -| `tools` | `list[mcp.types.Tool] | None` | `None` | | -| `toolChoice` | `mcp.types.ToolChoice | None` | `None` | | -| `model` | `str | None` | `None` | | +| `stopSequences` | `Union` | `None` | | +| `metadata` | `Union` | `None` | | +| `tools` | `Union` | `None` | | +| `toolChoice` | `Union` | `None` | | +| `model` | `Union` | `None` | | | `use_history` | `bool` | `True` | | -| `max_iterations` | `int` | `99` | | +| `max_iterations` | `int` | `199` | | | `parallel_tool_calls` | `bool` | `True` | | -| `response_format` | `typing.Any | None` | `None` | | -| `structured_schema` | `dict[str, typing.Any] | None` | `None` | | +| `response_format` | `Union` | `None` | | +| `structured_schema` | `Union` | `None` | | | `structured_tool_policy` | `Literal` | `'auto'` | | | `template_vars` | `dict` | `PydanticUndefined` | | -| `mcp_metadata` | `dict[str, typing.Any] | None` | `None` | | -| `tool_execution_handler` | `typing.Any | None` | `None` | | +| `mcp_metadata` | `Union` | `None` | | +| `tool_execution_handler` | `Union` | `None` | | | `emit_loop_progress` | `bool` | `False` | | | `tool_result_mode` | `Literal` | `'postprocess'` | | -| `batch_context` | `fast_agent.llm.request_params.BatchRequestContext | None` | `None` | | -| `streaming_timeout` | `float | None` | `300.0` | | -| `top_p` | `float | None` | `None` | | -| `top_k` | `int | None` | `None` | | -| `min_p` | `float | None` | `None` | | -| `presence_penalty` | `float | None` | `None` | | -| `frequency_penalty` | `float | None` | `None` | | -| `repetition_penalty` | `float | None` | `None` | | -| `service_tier` | `Optional` | `None` | | +| `batch_context` | `Union` | `None` | | +| `streaming_timeout` | `Union` | `300.0` | | +| `top_p` | `Union` | `None` | | +| `top_k` | `Union` | `None` | | +| `min_p` | `Union` | `None` | | +| `presence_penalty` | `Union` | `None` | | +| `frequency_penalty` | `Union` | `None` | | +| `repetition_penalty` | `Union` | `None` | | +| `service_tier` | `Union` | `None` | | diff --git a/docs/docs/_generated/workflows_reference.md b/docs/docs/_generated/workflows_reference.md index 1f4da0770..7e11317c3 100644 --- a/docs/docs/_generated/workflows_reference.md +++ b/docs/docs/_generated/workflows_reference.md @@ -10,35 +10,35 @@ These signatures are generated from the installed `fast_agent` package to preven ### `chain` ```python -fast.chain(name: str, *, sequence: list[str], instruction: str | pathlib._local.Path | pydantic.networks.AnyUrl | None = None, cumulative: bool = False, default: bool = False) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] +fast.chain(name: str, *, sequence: list[str], instruction: str | pathlib.Path | pydantic.networks.AnyUrl | None = None, cumulative: bool = False, default: bool = False) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] ``` ### `parallel` ```python -fast.parallel(name: str, *, fan_out: list[str], fan_in: str | None = None, instruction: str | pathlib._local.Path | pydantic.networks.AnyUrl | None = None, include_request: bool = True, default: bool = False) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] +fast.parallel(name: str, *, fan_out: list[str], fan_in: str | None = None, instruction: str | pathlib.Path | pydantic.networks.AnyUrl | None = None, include_request: bool = True, default: bool = False) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] ``` ### `evaluator_optimizer` ```python -fast.evaluator_optimizer(name: str, *, generator: str, evaluator: str, instruction: str | pathlib._local.Path | pydantic.networks.AnyUrl | None = None, min_rating: str = 'GOOD', max_refinements: int = 3, refinement_instruction: str | None = None, default: bool = False) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] +fast.evaluator_optimizer(name: str, *, generator: str, evaluator: str, instruction: str | pathlib.Path | pydantic.networks.AnyUrl | None = None, min_rating: str = 'GOOD', max_refinements: int = 3, refinement_instruction: str | None = None, default: bool = False) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] ``` ### `router` ```python -fast.router(name: str, *, agents: list[str], instruction: str | pathlib._local.Path | pydantic.networks.AnyUrl | None = None, servers: list[str] = [], tools: dict[str, list[str]] | None = None, resources: dict[str, list[str]] | None = None, prompts: dict[str, list[str]] | None = None, model: str | None = None, use_history: bool = False, request_params: fast_agent.llm.request_params.RequestParams | None = None, human_input: bool = False, default: bool = False, elicitation_handler: mcp.client.session.ElicitationFnT | None = None, api_key: str | None = None) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] +fast.router(name: str, *, agents: list[str], instruction: str | pathlib.Path | pydantic.networks.AnyUrl | None = None, servers: list[str] = [], tools: dict[str, list[str]] | None = None, resources: dict[str, list[str]] | None = None, prompts: dict[str, list[str]] | None = None, model: str | None = None, use_history: bool = False, request_params: fast_agent.llm.request_params.RequestParams | None = None, human_input: bool = False, default: bool = False, elicitation_handler: mcp.client.session.ElicitationFnT | None = None, api_key: str | None = None) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] ``` ### `orchestrator` ```python -fast.orchestrator(name: str, *, agents: list[str], instruction: str | pathlib._local.Path | pydantic.networks.AnyUrl = '\n You are an expert planner. Given an objective task and a list of Agents\n (which are collections of capabilities), your job is to break down the objective\n into a series of steps, which can be performed by these agents.\n ', model: str | None = None, request_params: fast_agent.llm.request_params.RequestParams | None = None, use_history: bool = False, human_input: bool = False, plan_type: Literal['full', 'iterative'] = 'full', plan_iterations: int = 5, default: bool = False, api_key: str | None = None) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] +fast.orchestrator(name: str, *, agents: list[str], instruction: str | pathlib.Path | pydantic.networks.AnyUrl = '\n You are an expert planner. Given an objective task and a list of Agents\n (which are collections of capabilities), your job is to break down the objective\n into a series of steps, which can be performed by these agents.\n ', model: str | None = None, request_params: fast_agent.llm.request_params.RequestParams | None = None, use_history: bool = False, human_input: bool = False, plan_type: Literal['full', 'iterative'] = 'full', plan_iterations: int = 5, default: bool = False, api_key: str | None = None) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] ``` ### `iterative_planner` ```python -fast.iterative_planner(name: str, *, agents: list[str], instruction: str | pathlib._local.Path | pydantic.networks.AnyUrl = "\nYou are an expert planner, able to Orchestrate complex tasks by breaking them down in to\nmanageable steps, and delegating tasks to Agents.\n\nYou work iteratively - given an Objective, you consider the current state of the plan,\ndecide the next step towards the goal. You document those steps and create clear instructions\nfor execution by the Agents, being specific about what you need to know to assess task completion. \n\nNOTE: A 'Planning Step' has a description, and a list of tasks that can be delegated \nand executed in parallel.\n\nAgents have a 'description' describing their primary function, and a set of 'skills' that\nrepresent Tools they can use in completing their function.\n\nThe following Agents are available to you:\n\n{{agents}}\n\nYou must specify the Agent name precisely when generating a Planning Step. \n\n", model: str | None = None, request_params: fast_agent.llm.request_params.RequestParams | None = None, plan_iterations: int = -1, default: bool = False, api_key: str | None = None) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] +fast.iterative_planner(name: str, *, agents: list[str], instruction: str | pathlib.Path | pydantic.networks.AnyUrl = "\nYou are an expert planner, able to Orchestrate complex tasks by breaking them down in to\nmanageable steps, and delegating tasks to Agents.\n\nYou work iteratively - given an Objective, you consider the current state of the plan,\ndecide the next step towards the goal. You document those steps and create clear instructions\nfor execution by the Agents, being specific about what you need to know to assess task completion. \n\nNOTE: A 'Planning Step' has a description, and a list of tasks that can be delegated \nand executed in parallel.\n\nAgents have a 'description' describing their primary function, and a set of 'skills' that\nrepresent Tools they can use in completing their function.\n\nThe following Agents are available to you:\n\n{{agents}}\n\nYou must specify the Agent name precisely when generating a Planning Step. \n\n", model: str | None = None, request_params: fast_agent.llm.request_params.RequestParams | None = None, plan_iterations: int = -1, default: bool = False, api_key: str | None = None) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] ``` ### `maker` ```python -fast.maker(name: str, *, worker: str, k: int = 3, max_samples: int = 50, match_strategy: str = 'exact', red_flag_max_length: int | None = None, instruction: str | pathlib._local.Path | pydantic.networks.AnyUrl | None = None, default: bool = False) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] +fast.maker(name: str, *, worker: str, k: int = 3, max_samples: int = 50, match_strategy: str = 'exact', red_flag_max_length: int | None = None, instruction: str | pathlib.Path | pydantic.networks.AnyUrl | None = None, default: bool = False) -> Callable[[Callable[~P, collections.abc.Coroutine[Any, Any, +R]]], Callable[~P, collections.abc.Coroutine[Any, Any, +R]]] ``` diff --git a/docs/docs/agents/defining.md b/docs/docs/agents/defining.md index fe6b954c1..b153beaea 100644 --- a/docs/docs/agents/defining.md +++ b/docs/docs/agents/defining.md @@ -135,7 +135,7 @@ from fast_agent.types import RequestParams | `maxTokens` | `int` | `2048` | The maximum number of tokens to sample, as requested by the server | | `model` | `string` | `None` | The model to use for the LLM generation. Can only be set at Agent creation time | | `use_history` | `bool` | `True` | Agent/LLM maintains conversation history. Does not include applied Prompts | -| `max_iterations` | `int` | `99` | The maximum number of tool calls allowed in a conversation turn | +| `max_iterations` | `int` | `199` | The maximum number of tool calls allowed in a conversation turn | | `parallel_tool_calls` | `bool` | `True` | Whether to allow simultaneous tool calls | | `response_format` | `Any` | `None` | Response format for structured calls (advanced use). Prefer to use `structured` with a Pydantic model instead | | `template_vars` | `Dict[str,Any]` | `{}` | Dictionary of template values for dynamic templates. Currently only supported for TensorZero provider | diff --git a/src/fast_agent/llm/model_database.py b/src/fast_agent/llm/model_database.py index 822d0f74f..81c26efa3 100644 --- a/src/fast_agent/llm/model_database.py +++ b/src/fast_agent/llm/model_database.py @@ -19,6 +19,7 @@ from fast_agent.mcp.mime_utils import DOCUMENT_MIME_TYPES ResourceSource = Literal["embedded", "link"] +ChatCompletionReasoningAdapter = Literal["openai_responses_v1"] class ModelParameters(BaseModel): @@ -45,6 +46,9 @@ class ModelParameters(BaseModel): reasoning_effort_spec: ReasoningEffortSpec | None = None """Reasoning effort input configuration supported by the model, if any.""" + chat_completion_reasoning_adapter: ChatCompletionReasoningAdapter | None = None + """Adapter for provider-specific reasoning payloads on Chat Completions streams.""" + text_verbosity_spec: TextVerbositySpec | None = None """Text verbosity configuration supported by the model, if any.""" @@ -938,6 +942,7 @@ class ModelDatabase: "gpt-5.5": OPENAI_GPT_CODEX.model_copy( update={ "reasoning_effort_spec": OPENAI_GPT_51_CLASS_REASONING, + "chat_completion_reasoning_adapter": "openai_responses_v1", "model_specific": GPT_53_PLUS_MODEL_SPECIFIC, } ), diff --git a/src/fast_agent/llm/provider/openai/llm_openai.py b/src/fast_agent/llm/provider/openai/llm_openai.py index 133abe1b2..92338bf8b 100644 --- a/src/fast_agent/llm/provider/openai/llm_openai.py +++ b/src/fast_agent/llm/provider/openai/llm_openai.py @@ -1,4 +1,5 @@ import asyncio +import json from pathlib import Path from typing import Any, cast @@ -24,7 +25,7 @@ from openai.types.chat.chat_completion_message_tool_call import Function from pydantic_core import from_json -from fast_agent.constants import REASONING +from fast_agent.constants import OPENAI_REASONING_ENCRYPTED, REASONING from fast_agent.core.exceptions import ProviderKeyError from fast_agent.core.logging.logger import get_logger from fast_agent.core.prompt import Prompt @@ -101,6 +102,7 @@ def __init__(self, provider: Provider = Provider.OPENAI, **kwargs) -> None: # Initialize logger with name if available self.logger = get_logger(f"{__name__}.{self.name}" if self.name else __name__) self._file_id_cache: dict[str, str] = {} + self._last_chat_reasoning_details: list[dict[str, Any]] = [] # Set up reasoning-related attributes raw_setting = kwargs.get("reasoning_effort", None) @@ -352,6 +354,26 @@ def _should_emit_reasoning_stream(self, reasoning_mode: str | None) -> bool: # """Allow subclasses to suppress streamed reasoning display.""" return True + def _chat_completion_reasoning_adapter(self, model: str | None) -> str | None: + params = self._get_model_params(model) + return params.chat_completion_reasoning_adapter if params is not None else None + + def _stream_reasoning_mode(self, model: str, reasoning_mode: str | None) -> str | None: + if self._chat_completion_reasoning_adapter(model) == "openai_responses_v1": + return "stream" + return reasoning_mode + + def _record_chat_reasoning_details(self, delta: Any, model: str) -> None: + if self._chat_completion_reasoning_adapter(model) != "openai_responses_v1": + return + reasoning_details = getattr(delta, "reasoning_details", None) + if not reasoning_details: + return + for detail in reasoning_details: + payload = detail if isinstance(detail, dict) else getattr(detail, "model_dump", lambda: None)() + if isinstance(payload, dict) and payload.get("type") == "reasoning.encrypted": + self._last_chat_reasoning_details.append(dict(payload)) + def _handle_tool_delta( self, *, @@ -463,6 +485,7 @@ def _process_stream_chunk_common( if chunk.choices: choice = chunk.choices[0] delta = choice.delta + self._record_chat_reasoning_details(delta, model) reasoning_text = self._extract_reasoning_text( reasoning=getattr(delta, "reasoning", None), reasoning_content=getattr(delta, "reasoning_content", None), @@ -608,7 +631,7 @@ async def _process_stream( estimated_tokens = 0 reasoning_active = False reasoning_segments = ReasoningTextAccumulator(normalizer=normalize_reasoning_delta) - reasoning_mode = self._get_model_reasoning(model) + reasoning_mode = self._stream_reasoning_mode(model, self._get_model_reasoning(model)) # For providers/models that emit non-OpenAI deltas, fall back to manual accumulation stream_mode = self._get_model_stream_mode(model) @@ -759,7 +782,7 @@ async def _process_stream_manual( estimated_tokens = 0 reasoning_active = False reasoning_segments = ReasoningTextAccumulator(normalizer=normalize_reasoning_delta) - reasoning_mode = self._get_model_reasoning(model) + reasoning_mode = self._stream_reasoning_mode(model, self._get_model_reasoning(model)) # Manual accumulation of response data accumulated_content = "" @@ -998,6 +1021,7 @@ async def _openai_completion( capture_filename = _stream_capture_filename(self.chat_turn()) _save_stream_request(capture_filename, arguments) + self._last_chat_reasoning_details = [] stream = await client.chat.completions.create(**arguments) timeout = request_params.streaming_timeout timed_stream = with_stream_idle_timeout( @@ -1136,11 +1160,26 @@ async def _openai_completion( if streamed_reasoning: reasoning_blocks = [TextContent(type="text", text="".join(streamed_reasoning))] + encrypted_reasoning_blocks: list[ContentBlock] | None = None + if self._last_chat_reasoning_details: + encrypted_reasoning_blocks = [ + TextContent(type="text", text=json.dumps(detail, separators=(",", ":"))) + for detail in self._last_chat_reasoning_details + ] + + channels: dict[str, list[ContentBlock]] | None = None + if reasoning_blocks: + channels = {REASONING: reasoning_blocks} + if encrypted_reasoning_blocks: + if channels is None: + channels = {} + channels[OPENAI_REASONING_ENCRYPTED] = encrypted_reasoning_blocks + return PromptMessageExtended( role="assistant", content=response_content_blocks, tool_calls=requested_tool_calls, - channels={REASONING: reasoning_blocks} if reasoning_blocks else None, + channels=channels, stop_reason=stop_reason, ) @@ -1269,6 +1308,68 @@ def _coerce_text(value: Any) -> str: return "" + def _chat_reasoning_summary_text(self, msg: PromptMessageExtended) -> str: + if not msg.channels: + return "" + reasoning_blocks = msg.channels.get(REASONING) or [] + texts = [get_text(block) for block in reasoning_blocks] + return "\n\n".join(text for text in texts if text).strip() + + def _chat_reasoning_encrypted_details( + self, msg: PromptMessageExtended + ) -> list[dict[str, Any]]: + if not msg.channels: + return [] + encrypted_blocks = msg.channels.get(OPENAI_REASONING_ENCRYPTED) or [] + details: list[dict[str, Any]] = [] + for block in encrypted_blocks: + text = get_text(block) + if not text: + continue + try: + payload = json.loads(text) + except json.JSONDecodeError: + self.logger.debug("Skipping malformed chat reasoning detail block") + continue + if isinstance(payload, dict) and payload.get("type") == "reasoning.encrypted": + details.append(payload) + return details + + def _apply_chat_reasoning_adapter( + self, + msg: PromptMessageExtended, + openai_msgs: list[ChatCompletionMessageParam], + model: str | None, + ) -> None: + if self._chat_completion_reasoning_adapter(model) != "openai_responses_v1": + return + if msg.role != "assistant" or not msg.channels: + return + + summary_text = self._chat_reasoning_summary_text(msg) + encrypted_details = self._chat_reasoning_encrypted_details(msg) + if not summary_text and not encrypted_details: + return + + details: list[dict[str, Any]] = [] + if summary_text: + details.append( + { + "type": "reasoning.summary", + "summary": summary_text, + "format": "openai-responses-v1", + "index": 0, + } + ) + details.extend(encrypted_details) + + for oai_msg in openai_msgs: + oai_dict = cast("dict[str, Any]", oai_msg) + if summary_text: + oai_dict["reasoning"] = summary_text + if details: + oai_dict["reasoning_details"] = details + def _convert_extended_messages_to_provider( self, messages: list[PromptMessageExtended] ) -> list[ChatCompletionMessageParam]: @@ -1289,6 +1390,7 @@ def _convert_extended_messages_to_provider( for msg in messages: # convert_to_openai returns a list of messages openai_msgs = OpenAIConverter.convert_to_openai(msg) + self._apply_chat_reasoning_adapter(msg, openai_msgs, model) if reasoning_mode == "reasoning_content" and msg.channels: reasoning_blocks = msg.channels.get(REASONING) if msg.channels else None diff --git a/tests/unit/fast_agent/llm/test_openai_stream_dedup.py b/tests/unit/fast_agent/llm/test_openai_stream_dedup.py index 90c1f2a18..a34b337c6 100644 --- a/tests/unit/fast_agent/llm/test_openai_stream_dedup.py +++ b/tests/unit/fast_agent/llm/test_openai_stream_dedup.py @@ -1,9 +1,13 @@ from dataclasses import dataclass +from typing import Any, cast import pytest +from mcp.types import TextContent +from fast_agent.constants import OPENAI_REASONING_ENCRYPTED, REASONING from fast_agent.context import Context from fast_agent.llm.provider.openai.llm_openai import OpenAILLM +from fast_agent.mcp.prompt_message_extended import PromptMessageExtended def test_extract_incremental_delta_with_cumulative_content() -> None: @@ -43,6 +47,8 @@ class StubToolCallDelta: @dataclass class StubDelta: content: str | None = None + reasoning: str | None = None + reasoning_details: list[dict[str, object]] | None = None tool_calls: list[StubToolCallDelta] | None = None role: str | None = None function_call: object | None = None @@ -130,3 +136,88 @@ async def test_tool_streaming_survives_cumulative_content() -> None: assert "delta" in event_types assert "stop" in event_types assert event_types.index("start") < event_types.index("stop") + + +@pytest.mark.asyncio +async def test_openai_responses_chat_reasoning_adapter_streams_and_records_details() -> None: + context = Context() + llm = OpenAILLM(context=context, model="gpt-5.5") + stream_chunks: list[tuple[str, bool]] = [] + llm.add_stream_listener(lambda chunk: stream_chunks.append((chunk.text, chunk.is_reasoning))) + llm._last_chat_reasoning_details = [] + + encrypted_detail: dict[str, object] = { + "type": "reasoning.encrypted", + "data": "enc", + "format": "openai-responses-v1", + "index": 0, + } + chunks = [ + StubChunk( + [ + StubChoice( + StubDelta( + reasoning="summary", + reasoning_details=[ + { + "type": "reasoning.summary", + "summary": "summary", + "format": "openai-responses-v1", + "index": 0, + } + ], + ) + ) + ] + ), + StubChunk([StubChoice(StubDelta(reasoning_details=[encrypted_detail]))]), + StubChunk([StubChoice(StubDelta(content="done"), finish_reason="stop")]), + ] + + _completion, reasoning = await llm._process_stream_manual(_stream_chunks(chunks), "gpt-5.5") + + assert reasoning == ["summary"] + assert ("summary", True) in stream_chunks + assert llm._last_chat_reasoning_details == [encrypted_detail] + + +def test_openai_responses_chat_reasoning_adapter_replays_details() -> None: + context = Context() + llm = OpenAILLM(context=context, model="gpt-5.5") + encrypted_detail: dict[str, object] = { + "type": "reasoning.encrypted", + "data": "enc", + "format": "openai-responses-v1", + "index": 0, + } + msg = PromptMessageExtended( + role="assistant", + content=[TextContent(type="text", text="done")], + channels={ + REASONING: [TextContent(type="text", text="summary")], + OPENAI_REASONING_ENCRYPTED: [ + TextContent( + type="text", + text=( + '{"type":"reasoning.encrypted","data":"enc",' + '"format":"openai-responses-v1","index":0}' + ), + ) + ], + }, + ) + + converted = llm._convert_extended_messages_to_provider([msg]) + + assert len(converted) == 1 + outgoing = cast("dict[str, Any]", converted[0]) + assert outgoing["reasoning"] == "summary" + assert outgoing["reasoning_details"] == [ + { + "type": "reasoning.summary", + "summary": "summary", + "format": "openai-responses-v1", + "index": 0, + }, + encrypted_detail, + ]