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/config.py b/src/fast_agent/config.py index 722443297..78699865c 100644 --- a/src/fast_agent/config.py +++ b/src/fast_agent/config.py @@ -1071,6 +1071,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.""" @@ -1655,6 +1678,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 fd918e268..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.""" @@ -557,6 +561,53 @@ 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, + ) + 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, max_output_tokens=8192, @@ -891,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, } ), @@ -941,6 +993,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_25_STANDARD, diff --git a/src/fast_agent/llm/model_factory.py b/src/fast_agent/llm/model_factory.py index 654c36ca4..ff13f0d77 100644 --- a/src/fast_agent/llm/model_factory.py +++ b/src/fast_agent/llm/model_factory.py @@ -983,6 +983,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/model_selection.py b/src/fast_agent/llm/model_selection.py index 8e3dc0ccc..7d627b3dc 100644 --- a/src/fast_agent/llm/model_selection.py +++ b/src/fast_agent/llm/model_selection.py @@ -266,6 +266,137 @@ class ModelSelectionCatalog: model="playback", ), ), + Provider.NOUS: ( + # Curated models from inference-api.nousresearch.com + # 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", + ), + CatalogModelEntry( + alias="nous-opus46", + model="nous.anthropic/claude-opus-4.6", + ), + CatalogModelEntry( + alias="nous-sonnet46", + model="nous.anthropic/claude-sonnet-4.6", + ), + CatalogModelEntry( + alias="nous-kimi26", + model="nous.moonshotai/kimi-k2.6", + ), + CatalogModelEntry( + alias="nous-qwen36p", + model="nous.qwen/qwen3.6-plus", + ), + CatalogModelEntry( + alias="nous-haiku45", + model="nous.anthropic/claude-haiku-4.5", + fast=True, + ), + CatalogModelEntry( + alias="nous-gpt55", + model="nous.openai/gpt-5.5", + ), + CatalogModelEntry( + alias="nous-gpt55pro", + model="nous.openai/gpt-5.5-pro", + ), + CatalogModelEntry( + alias="nous-gpt54mini", + model="nous.openai/gpt-5.4-mini", + fast=True, + ), + CatalogModelEntry( + alias="nous-gpt54nano", + model="nous.openai/gpt-5.4-nano", + fast=True, + ), + CatalogModelEntry( + alias="nous-gpt53cx", + model="nous.openai/gpt-5.3-codex", + ), + CatalogModelEntry( + alias="nous-mimo25p", + model="nous.xiaomi/mimo-v2.5-pro", + ), + CatalogModelEntry( + alias="nous-hy3", + model="nous.tencent/hy3-preview", + fast=True, + ), + CatalogModelEntry( + alias="nous-gem3pro", + model="nous.google/gemini-3-pro-preview", + ), + CatalogModelEntry( + alias="nous-gem3fl", + model="nous.google/gemini-3-flash-preview", + fast=True, + ), + CatalogModelEntry( + alias="nous-gem31p", + model="nous.google/gemini-3.1-pro-preview", + ), + CatalogModelEntry( + alias="nous-gem31fl", + model="nous.google/gemini-3.1-flash-lite-preview", + fast=True, + ), + CatalogModelEntry( + alias="nous-qwen36b", + model="nous.qwen/qwen3.6-35b-a3b", + fast=True, + ), + CatalogModelEntry( + alias="nous-step35", + model="nous.stepfun/step-3.5-flash", + fast=True, + ), + CatalogModelEntry( + alias="nous-mm27", + model="nous.minimax/minimax-m2.7", + ), + CatalogModelEntry( + alias="nous-glm51", + model="nous.z-ai/glm-5.1", + ), + CatalogModelEntry( + alias="nous-grok43", + model="nous.x-ai/grok-4.3", + ), + CatalogModelEntry( + alias="nous-nemo3", + model="nous.nvidia/nemotron-3-super-120b-a12b", + fast=True, + ), + CatalogModelEntry( + alias="nous-dsv4p", + model="nous.deepseek/deepseek-v4-pro", + ), + ), } @staticmethod 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..0156d85a5 --- /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-api.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-api.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/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/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") 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, ) 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, + ]