From eed8223ea849305a51f8fd556b4950cc9832c2f2 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 16:07:27 +0530 Subject: [PATCH 01/11] UN-3503 [FEAT] Add NVIDIA Build and OpenRouter OpenAI-compatible LLM adapters Both reuse the existing OpenAI Compatible adapter's validation logic with their API endpoints hard-coded, so users only pick a model and supply an API key: - NVIDIA Build -> https://integrate.api.nvidia.com/v1 - OpenRouter -> https://openrouter.ai/api/v1 Adds branded parameter subclasses (sharing one validate helper), thin adapter classes, branded JSON schemas (api_base hidden), and logos. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../icons/adapter-icons/NvidiaBuild.png | Bin 0 -> 24669 bytes .../public/icons/adapter-icons/OpenRouter.png | Bin 0 -> 29953 bytes .../sdk1/src/unstract/sdk1/adapters/base1.py | 39 +++++++ .../unstract/sdk1/adapters/llm1/__init__.py | 4 + .../sdk1/adapters/llm1/nvidia_build.py | 45 ++++++++ .../unstract/sdk1/adapters/llm1/openrouter.py | 45 ++++++++ .../adapters/llm1/static/nvidia_build.json | 105 ++++++++++++++++++ .../sdk1/adapters/llm1/static/openrouter.json | 105 ++++++++++++++++++ 8 files changed, 343 insertions(+) create mode 100644 frontend/public/icons/adapter-icons/NvidiaBuild.png create mode 100644 frontend/public/icons/adapter-icons/OpenRouter.png create mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/llm1/nvidia_build.py create mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/llm1/openrouter.py create mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json create mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json diff --git a/frontend/public/icons/adapter-icons/NvidiaBuild.png b/frontend/public/icons/adapter-icons/NvidiaBuild.png new file mode 100644 index 0000000000000000000000000000000000000000..de449c13a4280b816d330bcdf0c962ff778d4cb7 GIT binary patch literal 24669 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 E0Eo(vCIA2c literal 0 HcmV?d00001 diff --git a/frontend/public/icons/adapter-icons/OpenRouter.png b/frontend/public/icons/adapter-icons/OpenRouter.png new file mode 100644 index 0000000000000000000000000000000000000000..acaabd3da98fb395b9bd89393344ac7045009f14 GIT binary patch literal 29953 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 str: return f"{_CUSTOM_OPENAI_PROVIDER_PREFIX}{model}" +# Branded OpenAI-compatible providers. Wire protocol is identical to the +# generic OpenAI Compatible adapter; the only difference is a hard-coded +# endpoint so users pick a model + API key without typing a URL. Cost stays +# unresolved (same as any non-OpenAI gateway) since requests route through +# LiteLLM's generic custom_openai provider. +def _validate_branded_openai_compatible( + adapter_metadata: dict[str, "Any"], api_base: str +) -> dict[str, "Any"]: + adapter_metadata = {**adapter_metadata, "api_base": api_base} + return OpenAICompatibleLLMParameters.validate(adapter_metadata) + + +_NVIDIA_BUILD_API_BASE = "https://integrate.api.nvidia.com/v1" +_OPENROUTER_API_BASE = "https://openrouter.ai/api/v1" + + +class NvidiaBuildLLMParameters(OpenAICompatibleLLMParameters): + """OpenAI-compatible adapter for NVIDIA's hosted models (build.nvidia.com).""" + + # Endpoint is hard-coded; users never supply api_base. + api_base: str | None = None + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + return _validate_branded_openai_compatible( + adapter_metadata, _NVIDIA_BUILD_API_BASE + ) + + +class OpenRouterLLMParameters(OpenAICompatibleLLMParameters): + """OpenAI-compatible adapter for OpenRouter (openrouter.ai).""" + + api_base: str | None = None + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + return _validate_branded_openai_compatible(adapter_metadata, _OPENROUTER_API_BASE) + + class AzureOpenAILLMParameters(BaseChatCompletionParameters): """See https://docs.litellm.ai/docs/providers/azure/#completion---using-azure_ad_token-api_base-api_version.""" diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py index 1da3590f51..2d935e218f 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py @@ -6,9 +6,11 @@ from unstract.sdk1.adapters.llm1.anyscale import AnyscaleLLMAdapter from unstract.sdk1.adapters.llm1.azure_openai import AzureOpenAILLMAdapter from unstract.sdk1.adapters.llm1.bedrock import AWSBedrockLLMAdapter +from unstract.sdk1.adapters.llm1.nvidia_build import NvidiaBuildLLMAdapter from unstract.sdk1.adapters.llm1.ollama import OllamaLLMAdapter from unstract.sdk1.adapters.llm1.openai import OpenAILLMAdapter from unstract.sdk1.adapters.llm1.openai_compatible import OpenAICompatibleLLMAdapter +from unstract.sdk1.adapters.llm1.openrouter import OpenRouterLLMAdapter from unstract.sdk1.adapters.llm1.vertexai import VertexAILLMAdapter adapters: dict[str, dict[str, Any]] = {} @@ -21,8 +23,10 @@ "AnyscaleLLMAdapter", "AWSBedrockLLMAdapter", "AzureOpenAILLMAdapter", + "NvidiaBuildLLMAdapter", "OllamaLLMAdapter", "OpenAILLMAdapter", "OpenAICompatibleLLMAdapter", + "OpenRouterLLMAdapter", "VertexAILLMAdapter", ] diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/nvidia_build.py b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/nvidia_build.py new file mode 100644 index 0000000000..f601a9d532 --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/nvidia_build.py @@ -0,0 +1,45 @@ +from typing import Any + +from unstract.sdk1.adapters.base1 import BaseAdapter, NvidiaBuildLLMParameters +from unstract.sdk1.adapters.enums import AdapterTypes + +DESCRIPTION = ( + "Adapter for NVIDIA's OpenAI-compatible hosted models (build.nvidia.com). " + "Supply a model name and your NVIDIA API key; the endpoint is preconfigured." +) + + +class NvidiaBuildLLMAdapter(NvidiaBuildLLMParameters, BaseAdapter): + @staticmethod + def get_id() -> str: + return "nvidiabuild|240d142d-68dd-4b6f-9716-80afd5c661cc" + + @staticmethod + def get_metadata() -> dict[str, Any]: + return { + "name": "NVIDIA Build", + "version": "1.0.0", + "adapter": NvidiaBuildLLMAdapter, + "description": DESCRIPTION, + "is_active": True, + } + + @staticmethod + def get_name() -> str: + return "NVIDIA Build" + + @staticmethod + def get_description() -> str: + return DESCRIPTION + + @staticmethod + def get_provider() -> str: + return "nvidia_build" + + @staticmethod + def get_icon() -> str: + return "/icons/adapter-icons/NvidiaBuild.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + return AdapterTypes.LLM diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/openrouter.py b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/openrouter.py new file mode 100644 index 0000000000..9ed260ed86 --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/openrouter.py @@ -0,0 +1,45 @@ +from typing import Any + +from unstract.sdk1.adapters.base1 import BaseAdapter, OpenRouterLLMParameters +from unstract.sdk1.adapters.enums import AdapterTypes + +DESCRIPTION = ( + "Adapter for OpenRouter's OpenAI-compatible API (openrouter.ai). " + "Supply a model name and your OpenRouter API key; the endpoint is preconfigured." +) + + +class OpenRouterLLMAdapter(OpenRouterLLMParameters, BaseAdapter): + @staticmethod + def get_id() -> str: + return "openrouter|17756452-5dca-4e10-9cbf-d9bc16505458" + + @staticmethod + def get_metadata() -> dict[str, Any]: + return { + "name": "OpenRouter", + "version": "1.0.0", + "adapter": OpenRouterLLMAdapter, + "description": DESCRIPTION, + "is_active": True, + } + + @staticmethod + def get_name() -> str: + return "OpenRouter" + + @staticmethod + def get_description() -> str: + return DESCRIPTION + + @staticmethod + def get_provider() -> str: + return "openrouter" + + @staticmethod + def get_icon() -> str: + return "/icons/adapter-icons/OpenRouter.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + return AdapterTypes.LLM diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json new file mode 100644 index 0000000000..8de1e3c12a --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json @@ -0,0 +1,105 @@ +{ + "title": "NVIDIA Build", + "type": "object", + "required": [ + "adapter_name", + "api_key", + "model" + ], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: nvidia-build-1" + }, + "api_key": { + "type": "string", + "title": "API Key", + "format": "password", + "description": "Your NVIDIA API key from build.nvidia.com." + }, + "model": { + "type": "string", + "title": "Model", + "description": "The model name as listed on build.nvidia.com. Examples: nvidia/nemotron-mini-4b-instruct, meta/llama-3.1-70b-instruct" + }, + "max_tokens": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Maximum Output Tokens", + "default": 4096, + "description": "Maximum number of output tokens to limit LLM replies. Leave it empty to use the provider default. Sent as `max_completion_tokens` when Enable Reasoning is on." + }, + "max_retries": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Max Retries", + "default": 5, + "description": "The maximum number of times to retry a request if it fails." + }, + "timeout": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Timeout", + "default": 900, + "description": "Timeout in seconds." + }, + "enable_reasoning": { + "type": "boolean", + "title": "Enable Reasoning", + "default": false, + "description": "Toggle on for reasoning models to drop `temperature` and send `max_completion_tokens` and `reasoning_effort` via `extra_body` so the upstream API accepts them." + } + }, + "allOf": [ + { + "if": { + "properties": { + "enable_reasoning": { + "const": true + } + }, + "required": [ + "enable_reasoning" + ] + }, + "then": { + "properties": { + "reasoning_effort": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "default": "medium", + "title": "Reasoning Effort", + "description": "Sets the reasoning strength when Enable Reasoning is on." + } + }, + "required": [ + "reasoning_effort" + ] + } + }, + { + "if": { + "properties": { + "enable_reasoning": { + "const": false + } + }, + "required": [ + "enable_reasoning" + ] + }, + "then": { + "properties": {} + } + } + ] +} diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json new file mode 100644 index 0000000000..79f409c3da --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json @@ -0,0 +1,105 @@ +{ + "title": "OpenRouter", + "type": "object", + "required": [ + "adapter_name", + "api_key", + "model" + ], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: openrouter-1" + }, + "api_key": { + "type": "string", + "title": "API Key", + "format": "password", + "description": "Your OpenRouter API key from openrouter.ai/keys." + }, + "model": { + "type": "string", + "title": "Model", + "description": "The model slug as listed on openrouter.ai/models. Examples: openai/gpt-4o, anthropic/claude-3.5-sonnet, meta-llama/llama-3.1-70b-instruct" + }, + "max_tokens": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Maximum Output Tokens", + "default": 4096, + "description": "Maximum number of output tokens to limit LLM replies. Leave it empty to use the provider default. Sent as `max_completion_tokens` when Enable Reasoning is on." + }, + "max_retries": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Max Retries", + "default": 5, + "description": "The maximum number of times to retry a request if it fails." + }, + "timeout": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Timeout", + "default": 900, + "description": "Timeout in seconds." + }, + "enable_reasoning": { + "type": "boolean", + "title": "Enable Reasoning", + "default": false, + "description": "Toggle on for reasoning models to drop `temperature` and send `max_completion_tokens` and `reasoning_effort` via `extra_body` so the upstream API accepts them." + } + }, + "allOf": [ + { + "if": { + "properties": { + "enable_reasoning": { + "const": true + } + }, + "required": [ + "enable_reasoning" + ] + }, + "then": { + "properties": { + "reasoning_effort": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "default": "medium", + "title": "Reasoning Effort", + "description": "Sets the reasoning strength when Enable Reasoning is on." + } + }, + "required": [ + "reasoning_effort" + ] + } + }, + { + "if": { + "properties": { + "enable_reasoning": { + "const": false + } + }, + "required": [ + "enable_reasoning" + ] + }, + "then": { + "properties": {} + } + } + ] +} From e9cb517590e1c5bad247b2a872225e57bf4ab8c3 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 16:39:36 +0530 Subject: [PATCH 02/11] UN-3503 [FEAT] Add NVIDIA Build embedding adapter Routes through LiteLLM's native nvidia_nim provider (custom_openai has no embedding support), which reuses the OpenAI embedding handler and resolves the integrate.api.nvidia.com endpoint automatically. Reuses OpenAIEmbeddingParameters with the model prefixed nvidia_nim/ and the endpoint pinned. Scaffolded via the adapter-ops skill. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 32 ++++++++++++ .../sdk1/adapters/embedding1/__init__.py | 2 + .../sdk1/adapters/embedding1/nvidia_build.py | 45 ++++++++++++++++ .../embedding1/static/nvidia_build.json | 52 +++++++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/embedding1/nvidia_build.py create mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 170ca84d4d..999cfb53e2 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -1350,6 +1350,38 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: return model +# Branded NVIDIA Build embeddings. LiteLLM's generic `custom_openai` provider +# (used by the branded LLM adapters) has no embedding support, so embeddings +# route through LiteLLM's native `nvidia_nim` provider instead — it reuses the +# OpenAI embedding handler and resolves the integrate.api.nvidia.com endpoint +# automatically. api_base is still pinned for clarity. +_NVIDIA_NIM_PROVIDER_PREFIX = "nvidia_nim/" + + +class NvidiaBuildEmbeddingParameters(OpenAIEmbeddingParameters): + """OpenAI-compatible embeddings via NVIDIA's hosted endpoint (build.nvidia.com).""" + + # Endpoint is hard-coded; users never supply api_base. + api_base: str | None = _NVIDIA_BUILD_API_BASE + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata = {**adapter_metadata, "api_base": _NVIDIA_BUILD_API_BASE} + adapter_metadata["model"] = NvidiaBuildEmbeddingParameters.validate_model( + adapter_metadata + ) + return OpenAIEmbeddingParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + model = str(adapter_metadata.get("model", "")).strip() + if not model: + raise ValueError("model is required for the NVIDIA Build embedding adapter.") + if model.startswith(_NVIDIA_NIM_PROVIDER_PREFIX): + return model + return f"{_NVIDIA_NIM_PROVIDER_PREFIX}{model}" + + class AzureOpenAIEmbeddingParameters(BaseEmbeddingParameters): """See https://docs.litellm.ai/docs/providers/azure.""" diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/__init__.py b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/__init__.py index 3f7de6e916..ef99e44122 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/__init__.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/__init__.py @@ -4,6 +4,7 @@ from unstract.sdk1.adapters.embedding1.azure_openai import AzureOpenAIEmbeddingAdapter from unstract.sdk1.adapters.embedding1.bedrock import AWSBedrockEmbeddingAdapter from unstract.sdk1.adapters.embedding1.gemini import GeminiEmbeddingAdapter +from unstract.sdk1.adapters.embedding1.nvidia_build import NvidiaBuildEmbeddingAdapter from unstract.sdk1.adapters.embedding1.ollama import OllamaEmbeddingAdapter from unstract.sdk1.adapters.embedding1.openai import OpenAIEmbeddingAdapter from unstract.sdk1.adapters.embedding1.vertexai import VertexAIEmbeddingAdapter @@ -18,6 +19,7 @@ "AzureOpenAIEmbeddingAdapter", "AWSBedrockEmbeddingAdapter", "GeminiEmbeddingAdapter", + "NvidiaBuildEmbeddingAdapter", "OpenAIEmbeddingAdapter", "VertexAIEmbeddingAdapter", "OllamaEmbeddingAdapter", diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/nvidia_build.py b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/nvidia_build.py new file mode 100644 index 0000000000..d65d57ac51 --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/nvidia_build.py @@ -0,0 +1,45 @@ +from typing import Any + +from unstract.sdk1.adapters.base1 import BaseAdapter, NvidiaBuildEmbeddingParameters +from unstract.sdk1.adapters.enums import AdapterTypes + +DESCRIPTION = ( + "Adapter for NVIDIA's OpenAI-compatible embedding models (build.nvidia.com). " + "Supply a model name and your NVIDIA API key; the endpoint is preconfigured." +) + + +class NvidiaBuildEmbeddingAdapter(NvidiaBuildEmbeddingParameters, BaseAdapter): + @staticmethod + def get_id() -> str: + return "nvidiabuild|2afcdf59-5323-4086-97fb-d9a0432e7795" + + @staticmethod + def get_metadata() -> dict[str, Any]: + return { + "name": "NVIDIA Build", + "version": "1.0.0", + "adapter": NvidiaBuildEmbeddingAdapter, + "description": DESCRIPTION, + "is_active": True, + } + + @staticmethod + def get_name() -> str: + return "NVIDIA Build" + + @staticmethod + def get_description() -> str: + return DESCRIPTION + + @staticmethod + def get_provider() -> str: + return "nvidia_build" + + @staticmethod + def get_icon() -> str: + return "/icons/adapter-icons/NvidiaBuild.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + return AdapterTypes.EMBEDDING diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json new file mode 100644 index 0000000000..b8f276aedf --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json @@ -0,0 +1,52 @@ +{ + "title": "NVIDIA Build Embedding", + "type": "object", + "required": [ + "adapter_name", + "api_key", + "model" + ], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: nvidia-build-emb-1" + }, + "model": { + "type": "string", + "title": "Model", + "description": "The embedding model name as listed on build.nvidia.com. Examples: nvidia/nv-embedqa-e5-v5, nvidia/llama-3.2-nv-embedqa-1b-v2" + }, + "api_key": { + "type": "string", + "title": "API Key", + "format": "password", + "description": "Your NVIDIA API key from build.nvidia.com." + }, + "embed_batch_size": { + "type": "number", + "minimum": 1, + "multipleOf": 1, + "title": "Embed Batch Size", + "default": 10, + "description": "Number of texts to embed in each batch." + }, + "max_retries": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Max Retries", + "default": 3, + "description": "The maximum number of times to retry a request if it fails." + }, + "timeout": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Timeout", + "default": 240, + "description": "Timeout in seconds" + } + } +} From b655ffccf1361a17f3c2f357efb9a41835c881d2 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 16:51:27 +0530 Subject: [PATCH 03/11] UN-3503 [FEAT] Expose API base override, add OpenAI Compatible embedding adapter, tests - Expose api_base (pre-filled with the default) in the NVIDIA Build and OpenRouter LLM adapters and the NVIDIA Build embedding adapter, so users can repoint if a provider moves its base URL. Validation honours an override and falls back to the default when blank. - Add a generic OpenAI Compatible embedding adapter. LiteLLM has no custom_openai embedding handler, so it routes via the openai/ provider with a user-supplied api_base (api_key optional, api_base required). - Add unit tests covering registration, model prefixing, api_base default/override, and routing for all new adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 56 ++++++- .../sdk1/adapters/embedding1/__init__.py | 4 + .../adapters/embedding1/openai_compatible.py | 49 ++++++ .../embedding1/static/custom_openai.json | 61 +++++++ .../embedding1/static/nvidia_build.json | 7 + .../adapters/llm1/static/nvidia_build.json | 7 + .../sdk1/adapters/llm1/static/openrouter.json | 7 + .../tests/test_branded_openai_adapters.py | 152 ++++++++++++++++++ 8 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/embedding1/openai_compatible.py create mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/custom_openai.json create mode 100644 unstract/sdk1/tests/test_branded_openai_adapters.py diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 999cfb53e2..d5103b83c8 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -500,8 +500,15 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: # unresolved (same as any non-OpenAI gateway) since requests route through # LiteLLM's generic custom_openai provider. def _validate_branded_openai_compatible( - adapter_metadata: dict[str, "Any"], api_base: str + adapter_metadata: dict[str, "Any"], default_api_base: str ) -> dict[str, "Any"]: + # Endpoint is exposed in the schema with a sane default; honour a user + # override but fall back to the default when blank/absent (e.g. if the + # provider ever changes its base URL, users can point at the new one + # without an adapter release). + api_base = adapter_metadata.get("api_base") + if not (isinstance(api_base, str) and api_base.strip()): + api_base = default_api_base adapter_metadata = {**adapter_metadata, "api_base": api_base} return OpenAICompatibleLLMParameters.validate(adapter_metadata) @@ -1354,19 +1361,24 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: # (used by the branded LLM adapters) has no embedding support, so embeddings # route through LiteLLM's native `nvidia_nim` provider instead — it reuses the # OpenAI embedding handler and resolves the integrate.api.nvidia.com endpoint -# automatically. api_base is still pinned for clarity. +# automatically. The endpoint is exposed with a sane default for overrides. _NVIDIA_NIM_PROVIDER_PREFIX = "nvidia_nim/" class NvidiaBuildEmbeddingParameters(OpenAIEmbeddingParameters): """OpenAI-compatible embeddings via NVIDIA's hosted endpoint (build.nvidia.com).""" - # Endpoint is hard-coded; users never supply api_base. + # Endpoint defaults to NVIDIA Build; users may override it in the schema. api_base: str | None = _NVIDIA_BUILD_API_BASE @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: - adapter_metadata = {**adapter_metadata, "api_base": _NVIDIA_BUILD_API_BASE} + adapter_metadata = dict(adapter_metadata) + # Endpoint is exposed in the schema with a sane default; honour an + # override but fall back to the default when blank/absent. + api_base = adapter_metadata.get("api_base") + if not (isinstance(api_base, str) and api_base.strip()): + adapter_metadata["api_base"] = _NVIDIA_BUILD_API_BASE adapter_metadata["model"] = NvidiaBuildEmbeddingParameters.validate_model( adapter_metadata ) @@ -1382,6 +1394,42 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: return f"{_NVIDIA_NIM_PROVIDER_PREFIX}{model}" +class OpenAICompatibleEmbeddingParameters(OpenAIEmbeddingParameters): + """Embeddings for any server implementing the OpenAI embeddings API. + + LiteLLM has no generic `custom_openai` embedding handler, so requests route + through the `openai/` provider with a user-supplied `api_base` (vLLM, + self-hosted gateways, third-party providers). Cost stays unresolved since + the endpoint is arbitrary. + """ + + # api_key is optional (some gateways are keyless); api_base is required. + api_key: str | None = None + api_base: str + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata = dict(adapter_metadata) + api_key = adapter_metadata.get("api_key") + if isinstance(api_key, str) and not api_key.strip(): + adapter_metadata["api_key"] = None + adapter_metadata["model"] = OpenAICompatibleEmbeddingParameters.validate_model( + adapter_metadata + ) + return OpenAICompatibleEmbeddingParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + model = str(adapter_metadata.get("model", "")).strip() + if not model: + raise ValueError( + "model is required for the OpenAI Compatible embedding adapter." + ) + if model.startswith(_OPENAI_PROVIDER_PREFIX): + return model + return f"{_OPENAI_PROVIDER_PREFIX}{model}" + + class AzureOpenAIEmbeddingParameters(BaseEmbeddingParameters): """See https://docs.litellm.ai/docs/providers/azure.""" diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/__init__.py b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/__init__.py index ef99e44122..18f240f04e 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/__init__.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/__init__.py @@ -7,6 +7,9 @@ from unstract.sdk1.adapters.embedding1.nvidia_build import NvidiaBuildEmbeddingAdapter from unstract.sdk1.adapters.embedding1.ollama import OllamaEmbeddingAdapter from unstract.sdk1.adapters.embedding1.openai import OpenAIEmbeddingAdapter +from unstract.sdk1.adapters.embedding1.openai_compatible import ( + OpenAICompatibleEmbeddingAdapter, +) from unstract.sdk1.adapters.embedding1.vertexai import VertexAIEmbeddingAdapter from unstract.sdk1.adapters.enums import AdapterTypes @@ -21,6 +24,7 @@ "GeminiEmbeddingAdapter", "NvidiaBuildEmbeddingAdapter", "OpenAIEmbeddingAdapter", + "OpenAICompatibleEmbeddingAdapter", "VertexAIEmbeddingAdapter", "OllamaEmbeddingAdapter", ] diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/openai_compatible.py b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/openai_compatible.py new file mode 100644 index 0000000000..12fb1f2f6f --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/openai_compatible.py @@ -0,0 +1,49 @@ +from typing import Any + +from unstract.sdk1.adapters.base1 import ( + BaseAdapter, + OpenAICompatibleEmbeddingParameters, +) +from unstract.sdk1.adapters.enums import AdapterTypes + +DESCRIPTION = ( + "Embedding adapter for servers that implement the OpenAI Embeddings API " + "(vLLM, self-hosted gateways, and third-party providers). " + "Use OpenAI for the official OpenAI service." +) + + +class OpenAICompatibleEmbeddingAdapter(OpenAICompatibleEmbeddingParameters, BaseAdapter): + @staticmethod + def get_id() -> str: + return "openaicompatible|65573de7-2ea5-4631-bb49-492717972455" + + @staticmethod + def get_metadata() -> dict[str, Any]: + return { + "name": "OpenAI Compatible", + "version": "1.0.0", + "adapter": OpenAICompatibleEmbeddingAdapter, + "description": DESCRIPTION, + "is_active": True, + } + + @staticmethod + def get_name() -> str: + return "OpenAI Compatible" + + @staticmethod + def get_description() -> str: + return DESCRIPTION + + @staticmethod + def get_provider() -> str: + return "custom_openai" + + @staticmethod + def get_icon() -> str: + return "/icons/adapter-icons/OpenAICompatible.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + return AdapterTypes.EMBEDDING diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/custom_openai.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/custom_openai.json new file mode 100644 index 0000000000..d9d080cfbd --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/custom_openai.json @@ -0,0 +1,61 @@ +{ + "title": "OpenAI Compatible Embedding", + "type": "object", + "required": [ + "adapter_name", + "model", + "api_base" + ], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: compatible-emb-1" + }, + "model": { + "type": "string", + "title": "Model", + "description": "The embedding model name expected by your OpenAI-compatible endpoint. Examples: text-embedding-3-small, BAAI/bge-m3, nomic-embed-text" + }, + "api_key": { + "type": [ + "string", + "null" + ], + "title": "API Key", + "format": "password", + "description": "API key for your OpenAI-compatible endpoint. Leave empty if the endpoint does not require one." + }, + "api_base": { + "type": "string", + "format": "uri", + "title": "API Base", + "description": "Base URL for the OpenAI-compatible embeddings endpoint. Examples: https://gateway.example.com/v1, https://llm.example.net/openai/v1" + }, + "embed_batch_size": { + "type": "number", + "minimum": 1, + "multipleOf": 1, + "title": "Embed Batch Size", + "default": 10, + "description": "Number of texts to embed in each batch." + }, + "max_retries": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Max Retries", + "default": 3, + "description": "The maximum number of times to retry a request if it fails." + }, + "timeout": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Timeout", + "default": 240, + "description": "Timeout in seconds" + } + } +} diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json index b8f276aedf..4def1d79c6 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json @@ -24,6 +24,13 @@ "format": "password", "description": "Your NVIDIA API key from build.nvidia.com." }, + "api_base": { + "type": "string", + "format": "uri", + "title": "API Base", + "default": "https://integrate.api.nvidia.com/v1", + "description": "NVIDIA Build endpoint. Pre-filled with the default; change only if NVIDIA moves the base URL." + }, "embed_batch_size": { "type": "number", "minimum": 1, diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json index 8de1e3c12a..691609e0a8 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json @@ -24,6 +24,13 @@ "title": "Model", "description": "The model name as listed on build.nvidia.com. Examples: nvidia/nemotron-mini-4b-instruct, meta/llama-3.1-70b-instruct" }, + "api_base": { + "type": "string", + "format": "url", + "title": "API Base", + "default": "https://integrate.api.nvidia.com/v1", + "description": "NVIDIA Build endpoint. Pre-filled with the default; change only if NVIDIA moves the base URL." + }, "max_tokens": { "type": "number", "minimum": 0, diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json index 79f409c3da..99059b4298 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json @@ -24,6 +24,13 @@ "title": "Model", "description": "The model slug as listed on openrouter.ai/models. Examples: openai/gpt-4o, anthropic/claude-3.5-sonnet, meta-llama/llama-3.1-70b-instruct" }, + "api_base": { + "type": "string", + "format": "url", + "title": "API Base", + "default": "https://openrouter.ai/api/v1", + "description": "OpenRouter endpoint. Pre-filled with the default; change only if OpenRouter moves the base URL." + }, "max_tokens": { "type": "number", "minimum": 0, diff --git a/unstract/sdk1/tests/test_branded_openai_adapters.py b/unstract/sdk1/tests/test_branded_openai_adapters.py new file mode 100644 index 0000000000..8e658d8ad5 --- /dev/null +++ b/unstract/sdk1/tests/test_branded_openai_adapters.py @@ -0,0 +1,152 @@ +import json + +import pytest +from unstract.sdk1.adapters.base1 import ( + NvidiaBuildEmbeddingParameters, + NvidiaBuildLLMParameters, + OpenAICompatibleEmbeddingParameters, + OpenRouterLLMParameters, +) +from unstract.sdk1.adapters.constants import Common +from unstract.sdk1.adapters.embedding1 import adapters as embedding_adapters +from unstract.sdk1.adapters.embedding1.nvidia_build import NvidiaBuildEmbeddingAdapter +from unstract.sdk1.adapters.embedding1.openai_compatible import ( + OpenAICompatibleEmbeddingAdapter, +) +from unstract.sdk1.adapters.llm1 import adapters as llm_adapters +from unstract.sdk1.adapters.llm1.nvidia_build import NvidiaBuildLLMAdapter +from unstract.sdk1.adapters.llm1.openrouter import OpenRouterLLMAdapter + +_NVIDIA_BUILD_API_BASE = "https://integrate.api.nvidia.com/v1" +_OPENROUTER_API_BASE = "https://openrouter.ai/api/v1" + + +# --- Branded LLM adapters ------------------------------------------------- + + +@pytest.mark.parametrize( + "adapter", + [NvidiaBuildLLMAdapter, OpenRouterLLMAdapter], +) +def test_branded_llm_adapter_is_registered(adapter: type) -> None: + adapter_id = adapter.get_id() + assert adapter_id in llm_adapters + assert llm_adapters[adapter_id][Common.MODULE] is adapter + + +@pytest.mark.parametrize( + ("params", "default_base"), + [ + (NvidiaBuildLLMParameters, _NVIDIA_BUILD_API_BASE), + (OpenRouterLLMParameters, _OPENROUTER_API_BASE), + ], +) +def test_branded_llm_prefixes_model_and_defaults_api_base( + params: type, default_base: str +) -> None: + validated = params.validate({"model": "some-model", "api_key": "k"}) + + assert validated["model"] == "custom_openai/some-model" + assert validated["api_base"] == default_base + + +@pytest.mark.parametrize( + ("params", "default_base"), + [ + (NvidiaBuildLLMParameters, _NVIDIA_BUILD_API_BASE), + (OpenRouterLLMParameters, _OPENROUTER_API_BASE), + ], +) +def test_branded_llm_blank_api_base_falls_back_to_default( + params: type, default_base: str +) -> None: + validated = params.validate({"model": "m", "api_key": "k", "api_base": " "}) + + assert validated["api_base"] == default_base + + +@pytest.mark.parametrize( + "params", + [NvidiaBuildLLMParameters, OpenRouterLLMParameters], +) +def test_branded_llm_honours_api_base_override(params: type) -> None: + validated = params.validate( + {"model": "m", "api_key": "k", "api_base": "https://proxy.internal/v1"} + ) + + assert validated["api_base"] == "https://proxy.internal/v1" + + +@pytest.mark.parametrize( + ("adapter", "default_base"), + [ + (NvidiaBuildLLMAdapter, _NVIDIA_BUILD_API_BASE), + (OpenRouterLLMAdapter, _OPENROUTER_API_BASE), + ], +) +def test_branded_llm_schema_exposes_api_base_with_default( + adapter: type, default_base: str +) -> None: + schema = json.loads(adapter.get_json_schema()) + + assert schema["properties"]["api_base"]["default"] == default_base + assert "api_base" not in schema["required"] + assert "model" in schema["required"] + + +# --- Branded / generic embedding adapters --------------------------------- + + +def test_nvidia_embedding_registered_and_routes_via_nvidia_nim() -> None: + adapter_id = NvidiaBuildEmbeddingAdapter.get_id() + assert adapter_id in embedding_adapters + + validated = NvidiaBuildEmbeddingParameters.validate( + {"model": "nvidia/nv-embedqa-e5-v5", "api_key": "k"} + ) + assert validated["model"] == "nvidia_nim/nvidia/nv-embedqa-e5-v5" + assert validated["api_base"] == _NVIDIA_BUILD_API_BASE + + +def test_nvidia_embedding_honours_api_base_override() -> None: + validated = NvidiaBuildEmbeddingParameters.validate( + {"model": "m", "api_key": "k", "api_base": "https://proxy.internal/v1"} + ) + assert validated["api_base"] == "https://proxy.internal/v1" + + +def test_nvidia_embedding_model_prefix_is_idempotent() -> None: + once = NvidiaBuildEmbeddingParameters.validate({"model": "m", "api_key": "k"}) + twice = NvidiaBuildEmbeddingParameters.validate(dict(once)) + assert twice["model"] == once["model"] == "nvidia_nim/m" + + +def test_compatible_embedding_registered_and_routes_via_openai() -> None: + adapter_id = OpenAICompatibleEmbeddingAdapter.get_id() + assert adapter_id in embedding_adapters + assert OpenAICompatibleEmbeddingAdapter.get_provider() == "custom_openai" + + validated = OpenAICompatibleEmbeddingParameters.validate( + {"model": "BAAI/bge-m3", "api_base": "https://gw.example/v1"} + ) + assert validated["model"] == "openai/BAAI/bge-m3" + assert validated["api_base"] == "https://gw.example/v1" + + +def test_compatible_embedding_blank_api_key_normalized_to_none() -> None: + validated = OpenAICompatibleEmbeddingParameters.validate( + {"model": "m", "api_base": "https://gw.example/v1", "api_key": " "} + ) + assert validated["api_key"] is None + + +def test_compatible_embedding_requires_api_base() -> None: + with pytest.raises(Exception): # noqa: B017 - pydantic ValidationError + OpenAICompatibleEmbeddingParameters.validate({"model": "m"}) + + +def test_compatible_embedding_schema_loadable() -> None: + schema = json.loads(OpenAICompatibleEmbeddingAdapter.get_json_schema()) + assert schema["title"] == "OpenAI Compatible Embedding" + assert "api_base" in schema["required"] + assert "model" in schema["required"] From 8fa9405e65e4540d1116b3d49941bfa85188f056 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 16:58:11 +0530 Subject: [PATCH 04/11] UN-3503 [FEAT] Address review: pin branded api_base field defaults - NvidiaBuild/OpenRouter LLM and NvidiaBuild embedding params now declare api_base as a required str defaulting to the provider URL (was str | None = None), so a directly-constructed instance is always valid even without going through validate() (greptile). - Clarify the NVIDIA embedding comment: nvidia_nim already defaults api_base to the same integrate.api.nvidia.com URL; we pin it as an overridable default, not to mitigate a runtime risk (coderabbit). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index d5103b83c8..322a8470b0 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -520,8 +520,10 @@ def _validate_branded_openai_compatible( class NvidiaBuildLLMParameters(OpenAICompatibleLLMParameters): """OpenAI-compatible adapter for NVIDIA's hosted models (build.nvidia.com).""" - # Endpoint is hard-coded; users never supply api_base. - api_base: str | None = None + # Endpoint defaults to NVIDIA Build; users may override it in the schema. + # Kept as a required `str` (not widened to None) so a directly-constructed + # instance is always valid even without going through validate(). + api_base: str = _NVIDIA_BUILD_API_BASE @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: @@ -533,7 +535,8 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: class OpenRouterLLMParameters(OpenAICompatibleLLMParameters): """OpenAI-compatible adapter for OpenRouter (openrouter.ai).""" - api_base: str | None = None + # Endpoint defaults to OpenRouter; users may override it in the schema. + api_base: str = _OPENROUTER_API_BASE @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: @@ -1359,17 +1362,18 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: # Branded NVIDIA Build embeddings. LiteLLM's generic `custom_openai` provider # (used by the branded LLM adapters) has no embedding support, so embeddings -# route through LiteLLM's native `nvidia_nim` provider instead — it reuses the -# OpenAI embedding handler and resolves the integrate.api.nvidia.com endpoint -# automatically. The endpoint is exposed with a sane default for overrides. +# route through LiteLLM's native `nvidia_nim` provider instead, which reuses the +# OpenAI embedding handler. `nvidia_nim` already defaults its api_base to +# `_NVIDIA_BUILD_API_BASE` (https://integrate.api.nvidia.com/v1); we pin the same +# value explicitly so it shows in the schema as an overridable default. _NVIDIA_NIM_PROVIDER_PREFIX = "nvidia_nim/" class NvidiaBuildEmbeddingParameters(OpenAIEmbeddingParameters): """OpenAI-compatible embeddings via NVIDIA's hosted endpoint (build.nvidia.com).""" - # Endpoint defaults to NVIDIA Build; users may override it in the schema. - api_base: str | None = _NVIDIA_BUILD_API_BASE + # Same value LiteLLM's nvidia_nim provider defaults to; users may override it. + api_base: str = _NVIDIA_BUILD_API_BASE @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: From 1f9669970f12c553e6b4d982b675ac49bb033957 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 17:16:35 +0530 Subject: [PATCH 05/11] UN-3503 [FEAT] Route OpenRouter natively, add doc links to help text - OpenRouter now routes through LiteLLM's native openrouter/ provider instead of the generic custom_openai path, so per-token cost resolves for the OpenRouter models LiteLLM prices (~95 popular models) and reasoning params map natively. Dedicated OpenRouterLLMParameters forwards reasoning_effort only when reasoning is enabled and drops temperature on that path (OpenAI o-series reject non-default temperature). NVIDIA Build stays on custom_openai (LiteLLM ships no nvidia pricing either way). - Add clickable markdown doc links in NVIDIA Build and OpenRouter help text (build.nvidia.com, openrouter.ai/keys, openrouter.ai/models) and correct the OpenRouter reasoning help text for native handling. - Update tests for native OpenRouter routing + reasoning passthrough. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 49 ++++++++++++--- .../embedding1/static/nvidia_build.json | 4 +- .../adapters/llm1/static/nvidia_build.json | 4 +- .../sdk1/adapters/llm1/static/openrouter.json | 8 +-- .../tests/test_branded_openai_adapters.py | 61 +++++++++++++++---- 5 files changed, 98 insertions(+), 28 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 322a8470b0..3cf4523f8d 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -494,11 +494,9 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: return f"{_CUSTOM_OPENAI_PROVIDER_PREFIX}{model}" -# Branded OpenAI-compatible providers. Wire protocol is identical to the -# generic OpenAI Compatible adapter; the only difference is a hard-coded -# endpoint so users pick a model + API key without typing a URL. Cost stays -# unresolved (same as any non-OpenAI gateway) since requests route through -# LiteLLM's generic custom_openai provider. +# NVIDIA Build reuses the generic OpenAI Compatible adapter wire protocol with a +# hard-coded endpoint. Cost stays unresolved (LiteLLM prices nothing under the +# generic custom_openai provider, and ships no nvidia_nim prices either). def _validate_branded_openai_compatible( adapter_metadata: dict[str, "Any"], default_api_base: str ) -> dict[str, "Any"]: @@ -515,6 +513,7 @@ def _validate_branded_openai_compatible( _NVIDIA_BUILD_API_BASE = "https://integrate.api.nvidia.com/v1" _OPENROUTER_API_BASE = "https://openrouter.ai/api/v1" +_OPENROUTER_PROVIDER_PREFIX = "openrouter/" class NvidiaBuildLLMParameters(OpenAICompatibleLLMParameters): @@ -532,15 +531,49 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: ) -class OpenRouterLLMParameters(OpenAICompatibleLLMParameters): - """OpenAI-compatible adapter for OpenRouter (openrouter.ai).""" +class OpenRouterLLMParameters(BaseChatCompletionParameters): + """Adapter for OpenRouter (openrouter.ai). + Routed through LiteLLM's native `openrouter/` provider (not the generic + `custom_openai` path) so that per-token costs resolve for the OpenRouter + models LiteLLM prices, and reasoning params map natively. LiteLLM's + openrouter handler applies the provider-specific transforms itself, so the + `custom_openai` extra_body workaround is intentionally not used here. + """ + + api_key: str # Endpoint defaults to OpenRouter; users may override it in the schema. api_base: str = _OPENROUTER_API_BASE + reasoning_effort: str | None = None @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: - return _validate_branded_openai_compatible(adapter_metadata, _OPENROUTER_API_BASE) + adapter_metadata = dict(adapter_metadata) + api_base = adapter_metadata.get("api_base") + if not (isinstance(api_base, str) and api_base.strip()): + adapter_metadata["api_base"] = _OPENROUTER_API_BASE + adapter_metadata["model"] = OpenRouterLLMParameters.validate_model( + adapter_metadata + ) + # Forward reasoning_effort natively only when reasoning is enabled; + # LiteLLM's openrouter handler maps it to OpenRouter's reasoning param. + # Drop temperature on the reasoning path — OpenAI o-series models reject + # a non-default temperature, and OpenRouter forwards it upstream as-is. + enable_reasoning = adapter_metadata.pop("enable_reasoning", False) + if enable_reasoning: + adapter_metadata["temperature"] = None + else: + adapter_metadata.pop("reasoning_effort", None) + return OpenRouterLLMParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + model = str(adapter_metadata.get("model", "")).strip() + if not model: + raise ValueError("model is required for the OpenRouter adapter.") + if model.startswith(_OPENROUTER_PROVIDER_PREFIX): + return model + return f"{_OPENROUTER_PROVIDER_PREFIX}{model}" class AzureOpenAILLMParameters(BaseChatCompletionParameters): diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json index 4def1d79c6..3edf601082 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json @@ -16,13 +16,13 @@ "model": { "type": "string", "title": "Model", - "description": "The embedding model name as listed on build.nvidia.com. Examples: nvidia/nv-embedqa-e5-v5, nvidia/llama-3.2-nv-embedqa-1b-v2" + "description": "The embedding model name as listed on [build.nvidia.com/models](https://build.nvidia.com/models). Examples: nvidia/nv-embedqa-e5-v5, nvidia/llama-3.2-nv-embedqa-1b-v2" }, "api_key": { "type": "string", "title": "API Key", "format": "password", - "description": "Your NVIDIA API key from build.nvidia.com." + "description": "Your NVIDIA API key from [build.nvidia.com](https://build.nvidia.com)." }, "api_base": { "type": "string", diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json index 691609e0a8..2277516f22 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/nvidia_build.json @@ -17,12 +17,12 @@ "type": "string", "title": "API Key", "format": "password", - "description": "Your NVIDIA API key from build.nvidia.com." + "description": "Your NVIDIA API key from [build.nvidia.com](https://build.nvidia.com)." }, "model": { "type": "string", "title": "Model", - "description": "The model name as listed on build.nvidia.com. Examples: nvidia/nemotron-mini-4b-instruct, meta/llama-3.1-70b-instruct" + "description": "The model name as listed on [build.nvidia.com/models](https://build.nvidia.com/models). Examples: nvidia/nemotron-mini-4b-instruct, meta/llama-3.1-70b-instruct" }, "api_base": { "type": "string", diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json index 99059b4298..a7eede115d 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openrouter.json @@ -17,12 +17,12 @@ "type": "string", "title": "API Key", "format": "password", - "description": "Your OpenRouter API key from openrouter.ai/keys." + "description": "Your OpenRouter API key from [openrouter.ai/keys](https://openrouter.ai/keys)." }, "model": { "type": "string", "title": "Model", - "description": "The model slug as listed on openrouter.ai/models. Examples: openai/gpt-4o, anthropic/claude-3.5-sonnet, meta-llama/llama-3.1-70b-instruct" + "description": "The model slug as listed on [openrouter.ai/models](https://openrouter.ai/models). Examples: openai/gpt-4o, anthropic/claude-3.5-sonnet, meta-llama/llama-3.1-70b-instruct" }, "api_base": { "type": "string", @@ -37,7 +37,7 @@ "multipleOf": 1, "title": "Maximum Output Tokens", "default": 4096, - "description": "Maximum number of output tokens to limit LLM replies. Leave it empty to use the provider default. Sent as `max_completion_tokens` when Enable Reasoning is on." + "description": "Maximum number of output tokens to limit LLM replies. Leave it empty to use the provider default." }, "max_retries": { "type": "number", @@ -59,7 +59,7 @@ "type": "boolean", "title": "Enable Reasoning", "default": false, - "description": "Toggle on for reasoning models to drop `temperature` and send `max_completion_tokens` and `reasoning_effort` via `extra_body` so the upstream API accepts them." + "description": "Toggle on for reasoning models to send `reasoning_effort` to OpenRouter. See [OpenRouter reasoning docs](https://openrouter.ai/docs/use-cases/reasoning-tokens)." } }, "allOf": [ diff --git a/unstract/sdk1/tests/test_branded_openai_adapters.py b/unstract/sdk1/tests/test_branded_openai_adapters.py index 8e658d8ad5..cce10374ab 100644 --- a/unstract/sdk1/tests/test_branded_openai_adapters.py +++ b/unstract/sdk1/tests/test_branded_openai_adapters.py @@ -34,20 +34,57 @@ def test_branded_llm_adapter_is_registered(adapter: type) -> None: assert llm_adapters[adapter_id][Common.MODULE] is adapter -@pytest.mark.parametrize( - ("params", "default_base"), - [ - (NvidiaBuildLLMParameters, _NVIDIA_BUILD_API_BASE), - (OpenRouterLLMParameters, _OPENROUTER_API_BASE), - ], -) -def test_branded_llm_prefixes_model_and_defaults_api_base( - params: type, default_base: str -) -> None: - validated = params.validate({"model": "some-model", "api_key": "k"}) +def test_nvidia_llm_prefixes_model_via_custom_openai() -> None: + validated = NvidiaBuildLLMParameters.validate({"model": "some-model", "api_key": "k"}) assert validated["model"] == "custom_openai/some-model" - assert validated["api_base"] == default_base + assert validated["api_base"] == _NVIDIA_BUILD_API_BASE + + +def test_openrouter_llm_routes_via_native_openrouter_provider() -> None: + from litellm import get_llm_provider + + validated = OpenRouterLLMParameters.validate( + {"model": "openai/gpt-4o", "api_key": "k"} + ) + + assert validated["model"] == "openrouter/openai/gpt-4o" + assert validated["api_base"] == _OPENROUTER_API_BASE + # Native routing is what lets LiteLLM resolve OpenRouter pricing. + assert get_llm_provider(validated["model"])[1] == "openrouter" + + +def test_openrouter_model_prefix_is_idempotent() -> None: + once = OpenRouterLLMParameters.validate({"model": "openai/gpt-4o", "api_key": "k"}) + twice = OpenRouterLLMParameters.validate(dict(once)) + + assert twice["model"] == once["model"] == "openrouter/openai/gpt-4o" + + +def test_openrouter_forwards_reasoning_effort_only_when_enabled() -> None: + on = OpenRouterLLMParameters.validate( + { + "model": "openai/gpt-5", + "api_key": "k", + "enable_reasoning": True, + "reasoning_effort": "high", + } + ) + assert on["reasoning_effort"] == "high" + # enable_reasoning is a UI-only toggle and must not leak to LiteLLM. + assert "enable_reasoning" not in on + # temperature dropped so OpenAI o-series (via OpenRouter) don't reject it. + assert on["temperature"] is None + + off = OpenRouterLLMParameters.validate( + { + "model": "openai/gpt-4o", + "api_key": "k", + "enable_reasoning": False, + "reasoning_effort": "high", + } + ) + assert off["reasoning_effort"] is None @pytest.mark.parametrize( From efaeb3c2c902cd81809158115b45b02dcd26f29a Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 18:09:47 +0530 Subject: [PATCH 06/11] UN-3503 [FIX] Default embedding encoding_format to float for NVIDIA Build LiteLLM sends encoding_format=null when unset; NVIDIA Build (and other strict OpenAI-compatible embedding gateways) reject null, demanding 'float' or 'base64'. Default it to 'float' in the NVIDIA Build and generic OpenAI-compatible embedding validators. Co-Authored-By: Claude Opus 4.8 (1M context) --- unstract/sdk1/src/unstract/sdk1/adapters/base1.py | 7 +++++++ .../sdk1/tests/test_branded_openai_adapters.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 3cf4523f8d..3e46839ac1 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -1378,6 +1378,9 @@ class OpenAIEmbeddingParameters(BaseEmbeddingParameters): api_base: str | None = None embed_batch_size: int | None = 10 dimensions: int | None = None # For text-embedding-3-* models + # LiteLLM sends encoding_format=null when unset; strict OpenAI-compatible + # endpoints (e.g. NVIDIA Build) reject null, so subclasses default it. + encoding_format: str | None = None @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: @@ -1416,6 +1419,8 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: api_base = adapter_metadata.get("api_base") if not (isinstance(api_base, str) and api_base.strip()): adapter_metadata["api_base"] = _NVIDIA_BUILD_API_BASE + # NVIDIA rejects the null LiteLLM sends by default; pin a real value. + adapter_metadata.setdefault("encoding_format", "float") adapter_metadata["model"] = NvidiaBuildEmbeddingParameters.validate_model( adapter_metadata ) @@ -1450,6 +1455,8 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: api_key = adapter_metadata.get("api_key") if isinstance(api_key, str) and not api_key.strip(): adapter_metadata["api_key"] = None + # Strict gateways reject the null encoding_format LiteLLM sends by default. + adapter_metadata.setdefault("encoding_format", "float") adapter_metadata["model"] = OpenAICompatibleEmbeddingParameters.validate_model( adapter_metadata ) diff --git a/unstract/sdk1/tests/test_branded_openai_adapters.py b/unstract/sdk1/tests/test_branded_openai_adapters.py index cce10374ab..0158a46178 100644 --- a/unstract/sdk1/tests/test_branded_openai_adapters.py +++ b/unstract/sdk1/tests/test_branded_openai_adapters.py @@ -145,6 +145,21 @@ def test_nvidia_embedding_registered_and_routes_via_nvidia_nim() -> None: assert validated["api_base"] == _NVIDIA_BUILD_API_BASE +def test_nvidia_embedding_defaults_encoding_format_to_float() -> None: + # NVIDIA rejects the null encoding_format LiteLLM sends when unset. + validated = NvidiaBuildEmbeddingParameters.validate( + {"model": "nvidia/nv-embedqa-e5-v5", "api_key": "k"} + ) + assert validated["encoding_format"] == "float" + + +def test_compatible_embedding_defaults_encoding_format_to_float() -> None: + validated = OpenAICompatibleEmbeddingParameters.validate( + {"model": "BAAI/bge-m3", "api_base": "https://gw.example/v1"} + ) + assert validated["encoding_format"] == "float" + + def test_nvidia_embedding_honours_api_base_override() -> None: validated = NvidiaBuildEmbeddingParameters.validate( {"model": "m", "api_key": "k", "api_base": "https://proxy.internal/v1"} From 301746686e0b4be63037f281f0979592a4e45296 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 19:03:10 +0530 Subject: [PATCH 07/11] UN-3503 [FIX] Stop forwarding embed_batch_size to litellm.embedding embed_batch_size is a llama-index client-side batching hint, not an API field, yet it was model-dumped into the litellm.embedding kwargs. LiteLLM's nvidia_nim handler dumps unknown kwargs into the request body, so NVIDIA Build rejected it with a 400 (extra_forbidden). It also never drove any batching: indexing uses llama-index's own embed_batch_size default. Strip it centrally in Embedding.__init__ (fixes every provider) and drop the now-inert field from the NVIDIA Build and generic OpenAI-compatible embedding schemas. Existing adapters are unaffected (Pydantic ignores the stale key). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../embedding1/static/custom_openai.json | 8 ---- .../embedding1/static/nvidia_build.json | 8 ---- unstract/sdk1/src/unstract/sdk1/embedding.py | 4 ++ .../tests/test_branded_openai_adapters.py | 41 +++++++++++++++++++ 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/custom_openai.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/custom_openai.json index d9d080cfbd..bde1665229 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/custom_openai.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/custom_openai.json @@ -33,14 +33,6 @@ "title": "API Base", "description": "Base URL for the OpenAI-compatible embeddings endpoint. Examples: https://gateway.example.com/v1, https://llm.example.net/openai/v1" }, - "embed_batch_size": { - "type": "number", - "minimum": 1, - "multipleOf": 1, - "title": "Embed Batch Size", - "default": 10, - "description": "Number of texts to embed in each batch." - }, "max_retries": { "type": "number", "minimum": 0, diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json index 3edf601082..fa2b14247b 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/nvidia_build.json @@ -31,14 +31,6 @@ "default": "https://integrate.api.nvidia.com/v1", "description": "NVIDIA Build endpoint. Pre-filled with the default; change only if NVIDIA moves the base URL." }, - "embed_batch_size": { - "type": "number", - "minimum": 1, - "multipleOf": 1, - "title": "Embed Batch Size", - "default": 10, - "description": "Number of texts to embed in each batch." - }, "max_retries": { "type": "number", "minimum": 0, diff --git a/unstract/sdk1/src/unstract/sdk1/embedding.py b/unstract/sdk1/src/unstract/sdk1/embedding.py index 109f2cd6a1..b3ffd2c37c 100644 --- a/unstract/sdk1/src/unstract/sdk1/embedding.py +++ b/unstract/sdk1/src/unstract/sdk1/embedding.py @@ -103,6 +103,10 @@ def __init__( self.platform_kwargs: dict[str, object] = kwargs self.kwargs: dict[str, object] = self.adapter.validate(self._adapter_metadata) self._cost_model: str | None = self.kwargs.pop("cost_model", None) + # embed_batch_size is a llama-index client-side batching hint, not an + # API field. Strip it so it isn't forwarded to litellm.embedding and + # rejected by strict gateways (e.g. NVIDIA Build returns 400). + self.kwargs.pop("embed_batch_size", None) except (ValidationError, ValueError) as e: raise SdkError("Invalid embedding adapter metadata: " + str(e)) from e diff --git a/unstract/sdk1/tests/test_branded_openai_adapters.py b/unstract/sdk1/tests/test_branded_openai_adapters.py index 0158a46178..c98386f69b 100644 --- a/unstract/sdk1/tests/test_branded_openai_adapters.py +++ b/unstract/sdk1/tests/test_branded_openai_adapters.py @@ -202,3 +202,44 @@ def test_compatible_embedding_schema_loadable() -> None: assert schema["title"] == "OpenAI Compatible Embedding" assert "api_base" in schema["required"] assert "model" in schema["required"] + + +@pytest.mark.parametrize( + "adapter", + [NvidiaBuildEmbeddingAdapter, OpenAICompatibleEmbeddingAdapter], +) +def test_embedding_schema_drops_embed_batch_size(adapter: type) -> None: + # embed_batch_size is an inert llama-index hint; it must not be shown. + schema = json.loads(adapter.get_json_schema()) + assert "embed_batch_size" not in schema["properties"] + + +def test_embedding_strips_embed_batch_size_before_litellm( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # embed_batch_size must never reach litellm.embedding (strict gateways + # like NVIDIA reject it). Also asserts encoding_format="float" is sent. + import unstract.sdk1.embedding as emb_mod + + captured: dict = {} + + def fake_embedding(model: str, input: list, **kwargs: object) -> dict: # noqa: A002 + captured["model"] = model + captured.update(kwargs) + return {"data": [{"embedding": [0.0, 1.0, 2.0]}]} + + monkeypatch.setattr(emb_mod.litellm, "embedding", fake_embedding) + + emb_mod.Embedding( + adapter_id=NvidiaBuildEmbeddingAdapter.get_id(), + adapter_metadata={ + "adapter_name": "n", + "model": "nvidia/nv-embedqa-e5-v5", + "api_key": "k", + "embed_batch_size": 10, + }, + ) + + assert "embed_batch_size" not in captured + assert captured["encoding_format"] == "float" + assert captured["model"] == "nvidia_nim/nvidia/nv-embedqa-e5-v5" From 0970d1e8cfaaa829bcd160d687afcfc06e538d9d Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 19:30:13 +0530 Subject: [PATCH 08/11] UN-3503 [FIX] Send input_type for NVIDIA Build asymmetric embeddings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NVIDIA's retrieval embedding models (nv-embedqa*, nv-embedcode) are asymmetric and require an input_type of "query" or "passage"; without it they return a 400. Thread input_type through the embedding call path — "query" for query embeddings (and the test-connection snippet), "passage" for documents — gated to nvidia_nim models so other providers, which reject the field, are unaffected. litellm forwards it via extra_body. Co-Authored-By: Claude Opus 4.8 (1M context) --- unstract/sdk1/src/unstract/sdk1/embedding.py | 58 ++++++++++--------- .../tests/test_branded_openai_adapters.py | 49 ++++++++++++++++ 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/embedding.py b/unstract/sdk1/src/unstract/sdk1/embedding.py index b3ffd2c37c..3b4de2cd65 100644 --- a/unstract/sdk1/src/unstract/sdk1/embedding.py +++ b/unstract/sdk1/src/unstract/sdk1/embedding.py @@ -29,6 +29,11 @@ litellm.drop_params = True +# NVIDIA's asymmetric retrieval embedding models require an input_type of +# "query" or "passage"; litellm forwards it via extra_body for nvidia_nim. +# Other providers reject the field, so it's only sent for nvidia_nim models. +_NVIDIA_NIM_MODEL_PREFIX = "nvidia_nim/" + class Embedding: """Unified embedding interface powered by LiteLLM. @@ -123,13 +128,19 @@ def _get_adapter_info(self) -> str: return f"{self._adapter_name} ({name})" return name - def get_embedding(self, text: str) -> list[float]: + def _prepare_call(self, input_type: str | None) -> tuple[str, dict, int | None]: + """Split model/retries out of kwargs and inject NVIDIA's input_type.""" + kwargs = self.kwargs.copy() + model = kwargs.pop("model") + max_retries = pop_litellm_retry_kwargs(kwargs, self._get_adapter_info()) + if input_type and str(model).startswith(_NVIDIA_NIM_MODEL_PREFIX): + kwargs["input_type"] = input_type + return model, kwargs, max_retries + + def get_embedding(self, text: str, input_type: str = "query") -> list[float]: """Return embedding vector for query string.""" try: - kwargs = self.kwargs.copy() - model = kwargs.pop("model") - max_retries = pop_litellm_retry_kwargs(kwargs, self._get_adapter_info()) - + model, kwargs, max_retries = self._prepare_call(input_type) resp = call_with_retry( lambda: litellm.embedding(model=model, input=[text], **kwargs), max_retries=max_retries, @@ -140,13 +151,12 @@ def get_embedding(self, text: str) -> list[float]: except Exception as e: raise parse_litellm_err(e, self._get_adapter_info()) from e - def get_embeddings(self, texts: list[str]) -> list[list[float]]: + def get_embeddings( + self, texts: list[str], input_type: str = "passage" + ) -> list[list[float]]: """Return embedding vectors for list of query strings.""" try: - kwargs = self.kwargs.copy() - model = kwargs.pop("model") - max_retries = pop_litellm_retry_kwargs(kwargs, self._get_adapter_info()) - + model, kwargs, max_retries = self._prepare_call(input_type) resp = call_with_retry( lambda: litellm.embedding(model=model, input=texts, **kwargs), max_retries=max_retries, @@ -157,13 +167,10 @@ def get_embeddings(self, texts: list[str]) -> list[list[float]]: except Exception as e: raise parse_litellm_err(e, self._get_adapter_info()) from e - async def get_aembedding(self, text: str) -> list[float]: + async def get_aembedding(self, text: str, input_type: str = "query") -> list[float]: """Return async embedding vector for query string.""" try: - kwargs = self.kwargs.copy() - model = kwargs.pop("model") - max_retries = pop_litellm_retry_kwargs(kwargs, self._get_adapter_info()) - + model, kwargs, max_retries = self._prepare_call(input_type) resp = await acall_with_retry( lambda: litellm.aembedding(model=model, input=[text], **kwargs), max_retries=max_retries, @@ -174,13 +181,12 @@ async def get_aembedding(self, text: str) -> list[float]: except Exception as e: raise parse_litellm_err(e, self._get_adapter_info()) from e - async def get_aembeddings(self, texts: list[str]) -> list[list[float]]: + async def get_aembeddings( + self, texts: list[str], input_type: str = "passage" + ) -> list[list[float]]: """Return async embedding vectors for list of query strings.""" try: - kwargs = self.kwargs.copy() - model = kwargs.pop("model") - max_retries = pop_litellm_retry_kwargs(kwargs, self._get_adapter_info()) - + model, kwargs, max_retries = self._prepare_call(input_type) resp = await acall_with_retry( lambda: litellm.aembedding(model=model, input=texts, **kwargs), max_retries=max_retries, @@ -260,25 +266,25 @@ def __init__( ) def _get_query_embedding(self, query: str) -> list[float]: - return self._embedding_instance.get_embedding(query) + return self._embedding_instance.get_embedding(query, input_type="query") def _get_text_embedding(self, text: str) -> list[float]: - return self._embedding_instance.get_embedding(text) + return self._embedding_instance.get_embedding(text, input_type="passage") def _get_text_embeddings(self, texts: list[str]) -> list[list[float]]: - return self._embedding_instance.get_embeddings(texts) + return self._embedding_instance.get_embeddings(texts, input_type="passage") def get_query_embedding(self, query: str) -> list[float]: return self._get_query_embedding(query) async def _aget_query_embedding(self, query: str) -> list[float]: - return await self._embedding_instance.get_aembedding(query) + return await self._embedding_instance.get_aembedding(query, input_type="query") async def _aget_text_embedding(self, text: str) -> list[float]: - return await self._embedding_instance.get_aembedding(text) + return await self._embedding_instance.get_aembedding(text, input_type="passage") async def _aget_text_embeddings(self, texts: list[str]) -> list[list[float]]: - return await self._embedding_instance.get_aembeddings(texts) + return await self._embedding_instance.get_aembeddings(texts, input_type="passage") async def get_aquery_embedding(self, query: str) -> list[float]: return await self._aget_query_embedding(query) diff --git a/unstract/sdk1/tests/test_branded_openai_adapters.py b/unstract/sdk1/tests/test_branded_openai_adapters.py index c98386f69b..fe36578b60 100644 --- a/unstract/sdk1/tests/test_branded_openai_adapters.py +++ b/unstract/sdk1/tests/test_branded_openai_adapters.py @@ -243,3 +243,52 @@ def fake_embedding(model: str, input: list, **kwargs: object) -> dict: # noqa: assert "embed_batch_size" not in captured assert captured["encoding_format"] == "float" assert captured["model"] == "nvidia_nim/nvidia/nv-embedqa-e5-v5" + # Test-connection embeds the snippet as a query; NVIDIA requires input_type. + assert captured["input_type"] == "query" + + +def _patch_capture_embedding(monkeypatch: pytest.MonkeyPatch) -> dict: + import unstract.sdk1.embedding as emb_mod + + captured: dict = {} + + def fake_embedding(model: str, input: list, **kwargs: object) -> dict: # noqa: A002 + captured["model"] = model + captured.update(kwargs) + return {"data": [{"embedding": [0.0, 1.0]}] * len(input)} + + monkeypatch.setattr(emb_mod.litellm, "embedding", fake_embedding) + return captured + + +def test_nvidia_embedding_batch_sends_passage_input_type( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import unstract.sdk1.embedding as emb_mod + + captured = _patch_capture_embedding(monkeypatch) + emb = emb_mod.Embedding( + adapter_id=NvidiaBuildEmbeddingAdapter.get_id(), + adapter_metadata={"model": "nvidia/nv-embedqa-e5-v5", "api_key": "k"}, + ) + emb.get_embeddings(["a", "b"]) + assert captured["input_type"] == "passage" + + +def test_compatible_embedding_omits_input_type( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # input_type is NVIDIA-only; non-nvidia_nim models must not receive it. + import unstract.sdk1.embedding as emb_mod + + captured = _patch_capture_embedding(monkeypatch) + emb_mod.Embedding( + adapter_id=OpenAICompatibleEmbeddingAdapter.get_id(), + adapter_metadata={ + "model": "BAAI/bge-m3", + "api_base": "https://gw.example/v1", + "api_key": "k", + }, + ) + assert "input_type" not in captured + assert captured["model"] == "openai/BAAI/bge-m3" From f95eb407c6bc10789629d761ff655c93387b3910 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 22:42:43 +0530 Subject: [PATCH 09/11] UN-3503 [MISC] Drop inert embed_batch_size from embedding schemas embed_batch_size is stripped before the litellm call, so the schema field was a control that did nothing. Remove it from the remaining embedding schemas (openai, azure, ollama, gemini, vertexai) for consistency with the branded adapters. Also tighten the related code comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- unstract/sdk1/src/unstract/sdk1/adapters/base1.py | 7 +++---- .../sdk1/adapters/embedding1/static/azure.json | 7 ------- .../sdk1/adapters/embedding1/static/gemini.json | 7 ------- .../sdk1/adapters/embedding1/static/ollama.json | 7 ------- .../sdk1/adapters/embedding1/static/openai.json | 7 ------- .../sdk1/adapters/embedding1/static/vertexai.json | 7 ------- unstract/sdk1/src/unstract/sdk1/embedding.py | 11 ++++------- unstract/sdk1/tests/test_gemini_embedding.py | 5 +++-- 8 files changed, 10 insertions(+), 48 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 3e46839ac1..7291f9d42a 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -1378,8 +1378,7 @@ class OpenAIEmbeddingParameters(BaseEmbeddingParameters): api_base: str | None = None embed_batch_size: int | None = 10 dimensions: int | None = None # For text-embedding-3-* models - # LiteLLM sends encoding_format=null when unset; strict OpenAI-compatible - # endpoints (e.g. NVIDIA Build) reject null, so subclasses default it. + # Strict endpoints reject the null LiteLLM sends when this is unset. encoding_format: str | None = None @staticmethod @@ -1419,7 +1418,7 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: api_base = adapter_metadata.get("api_base") if not (isinstance(api_base, str) and api_base.strip()): adapter_metadata["api_base"] = _NVIDIA_BUILD_API_BASE - # NVIDIA rejects the null LiteLLM sends by default; pin a real value. + # Strict endpoints reject the null LiteLLM sends; pin a real value. adapter_metadata.setdefault("encoding_format", "float") adapter_metadata["model"] = NvidiaBuildEmbeddingParameters.validate_model( adapter_metadata @@ -1455,7 +1454,7 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: api_key = adapter_metadata.get("api_key") if isinstance(api_key, str) and not api_key.strip(): adapter_metadata["api_key"] = None - # Strict gateways reject the null encoding_format LiteLLM sends by default. + # Strict endpoints reject the null LiteLLM sends; pin a real value. adapter_metadata.setdefault("encoding_format", "float") adapter_metadata["model"] = OpenAICompatibleEmbeddingParameters.validate_model( adapter_metadata diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/azure.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/azure.json index f1ca8d2230..b080a3d2f2 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/azure.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/azure.json @@ -54,13 +54,6 @@ "title": "Dimensions", "description": "Output embedding dimensions. Only supported by text-embedding-3-* models. Leave empty for default dimensions." }, - "embed_batch_size": { - "type": "number", - "minimum": 0, - "multipleOf": 1, - "title": "Embedding Batch Size", - "default": 5 - }, "max_retries": { "type": "number", "minimum": 0, diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/gemini.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/gemini.json index e614867eb7..5a5096716b 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/gemini.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/gemini.json @@ -25,13 +25,6 @@ "default": "", "format": "password" }, - "embed_batch_size": { - "type": "number", - "minimum": 1, - "multipleOf": 1, - "title": "Embed Batch Size", - "description": "Number of texts to embed in a single batch. Leave empty to use the system default." - }, "timeout": { "type": "number", "minimum": 0, diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/ollama.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/ollama.json index 8dd9bfa1c3..c241e5e711 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/ollama.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/ollama.json @@ -25,13 +25,6 @@ "default": "", "description": "Provide the base URL where Ollama server is running. Example: `http://docker.host.internal:11434` or `http://localhost:11434`" }, - "embed_batch_size": { - "type": "number", - "minimum": 0, - "multipleOf": 1, - "title": "Embed Batch Size", - "default": 10 - }, "max_retries": { "type": "number", "minimum": 0, diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/openai.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/openai.json index 3ad21d3564..30e2724032 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/openai.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/openai.json @@ -37,13 +37,6 @@ "title": "Dimensions", "description": "Output embedding dimensions. Only supported by text-embedding-3-* models. Leave empty for default dimensions (1536 for small, 3072 for large)." }, - "embed_batch_size": { - "type": "number", - "minimum": 0, - "multipleOf": 1, - "title": "Embed Batch Size", - "default": 10 - }, "max_retries": { "type": "number", "minimum": 0, diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/vertexai.json b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/vertexai.json index 6aa48e883f..5dec854624 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/vertexai.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/vertexai.json @@ -38,13 +38,6 @@ "default": "global", "description": "The Google Cloud region for the Vertex AI endpoint (e.g., us-central1, global). If left empty, defaults to us-central1." }, - "embed_batch_size": { - "type": "number", - "minimum": 0, - "multipleOf": 1, - "title": "Embedding Batch Size", - "default": 10 - }, "embed_mode": { "type": "string", "title": "Embed Mode", diff --git a/unstract/sdk1/src/unstract/sdk1/embedding.py b/unstract/sdk1/src/unstract/sdk1/embedding.py index 3b4de2cd65..1e02944f83 100644 --- a/unstract/sdk1/src/unstract/sdk1/embedding.py +++ b/unstract/sdk1/src/unstract/sdk1/embedding.py @@ -29,9 +29,8 @@ litellm.drop_params = True -# NVIDIA's asymmetric retrieval embedding models require an input_type of -# "query" or "passage"; litellm forwards it via extra_body for nvidia_nim. -# Other providers reject the field, so it's only sent for nvidia_nim models. +# Asymmetric embedding models need an input_type (query|passage); other +# providers reject the field, so it's sent only for this prefix. _NVIDIA_NIM_MODEL_PREFIX = "nvidia_nim/" @@ -108,9 +107,7 @@ def __init__( self.platform_kwargs: dict[str, object] = kwargs self.kwargs: dict[str, object] = self.adapter.validate(self._adapter_metadata) self._cost_model: str | None = self.kwargs.pop("cost_model", None) - # embed_batch_size is a llama-index client-side batching hint, not an - # API field. Strip it so it isn't forwarded to litellm.embedding and - # rejected by strict gateways (e.g. NVIDIA Build returns 400). + # Client-side batching hint, not an API field — keep it off the wire. self.kwargs.pop("embed_batch_size", None) except (ValidationError, ValueError) as e: raise SdkError("Invalid embedding adapter metadata: " + str(e)) from e @@ -129,7 +126,7 @@ def _get_adapter_info(self) -> str: return name def _prepare_call(self, input_type: str | None) -> tuple[str, dict, int | None]: - """Split model/retries out of kwargs and inject NVIDIA's input_type.""" + """Split model/retries out of kwargs and inject input_type when applicable.""" kwargs = self.kwargs.copy() model = kwargs.pop("model") max_retries = pop_litellm_retry_kwargs(kwargs, self._get_adapter_info()) diff --git a/unstract/sdk1/tests/test_gemini_embedding.py b/unstract/sdk1/tests/test_gemini_embedding.py index 2bf031e3ee..3bdc0f6d70 100644 --- a/unstract/sdk1/tests/test_gemini_embedding.py +++ b/unstract/sdk1/tests/test_gemini_embedding.py @@ -39,9 +39,10 @@ def test_json_schema_required_fields(self) -> None: schema = json.loads(GeminiEmbeddingAdapter.get_json_schema()) assert set(schema["required"]) == {"adapter_name", "api_key", "model"} - def test_json_schema_no_batch_size_default(self) -> None: + def test_json_schema_omits_batch_size(self) -> None: + # embed_batch_size is an inert client-side hint and is not exposed. schema = json.loads(GeminiEmbeddingAdapter.get_json_schema()) - assert "default" not in schema["properties"]["embed_batch_size"] + assert "embed_batch_size" not in schema["properties"] def test_json_schema_api_key_password_format(self) -> None: schema = json.loads(GeminiEmbeddingAdapter.get_json_schema()) From 69f72862eb295d740275c1c6cd10bdcb6e1790ff Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 2 Jun 2026 09:50:43 +0530 Subject: [PATCH 10/11] UN-3503 [MISC] Make code comments concise and generic Trim the comments and docstrings added in this PR to single-line WHY notes, dropping provider-specific and implementation-detail narration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 51 ++++++------------- .../tests/test_branded_openai_adapters.py | 5 +- 2 files changed, 18 insertions(+), 38 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 7291f9d42a..17d9f68602 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -494,16 +494,12 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: return f"{_CUSTOM_OPENAI_PROVIDER_PREFIX}{model}" -# NVIDIA Build reuses the generic OpenAI Compatible adapter wire protocol with a -# hard-coded endpoint. Cost stays unresolved (LiteLLM prices nothing under the -# generic custom_openai provider, and ships no nvidia_nim prices either). +# Shared validation for branded adapters that reuse the OpenAI-compatible wire +# protocol with a fixed default endpoint. def _validate_branded_openai_compatible( adapter_metadata: dict[str, "Any"], default_api_base: str ) -> dict[str, "Any"]: - # Endpoint is exposed in the schema with a sane default; honour a user - # override but fall back to the default when blank/absent (e.g. if the - # provider ever changes its base URL, users can point at the new one - # without an adapter release). + # Endpoint stays overridable so a provider URL change needs no release. api_base = adapter_metadata.get("api_base") if not (isinstance(api_base, str) and api_base.strip()): api_base = default_api_base @@ -519,9 +515,7 @@ def _validate_branded_openai_compatible( class NvidiaBuildLLMParameters(OpenAICompatibleLLMParameters): """OpenAI-compatible adapter for NVIDIA's hosted models (build.nvidia.com).""" - # Endpoint defaults to NVIDIA Build; users may override it in the schema. - # Kept as a required `str` (not widened to None) so a directly-constructed - # instance is always valid even without going through validate(). + # Required str so a directly-constructed instance stays valid. api_base: str = _NVIDIA_BUILD_API_BASE @staticmethod @@ -534,15 +528,11 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: class OpenRouterLLMParameters(BaseChatCompletionParameters): """Adapter for OpenRouter (openrouter.ai). - Routed through LiteLLM's native `openrouter/` provider (not the generic - `custom_openai` path) so that per-token costs resolve for the OpenRouter - models LiteLLM prices, and reasoning params map natively. LiteLLM's - openrouter handler applies the provider-specific transforms itself, so the - `custom_openai` extra_body workaround is intentionally not used here. + Routed through LiteLLM's native `openrouter/` provider so per-token costs + resolve and reasoning params map without provider-specific workarounds. """ api_key: str - # Endpoint defaults to OpenRouter; users may override it in the schema. api_base: str = _OPENROUTER_API_BASE reasoning_effort: str | None = None @@ -555,10 +545,8 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: adapter_metadata["model"] = OpenRouterLLMParameters.validate_model( adapter_metadata ) - # Forward reasoning_effort natively only when reasoning is enabled; - # LiteLLM's openrouter handler maps it to OpenRouter's reasoning param. - # Drop temperature on the reasoning path — OpenAI o-series models reject - # a non-default temperature, and OpenRouter forwards it upstream as-is. + # Reasoning models reject a non-default temperature; drop it and send + # reasoning_effort only when reasoning is enabled. enable_reasoning = adapter_metadata.pop("enable_reasoning", False) if enable_reasoning: adapter_metadata["temperature"] = None @@ -1395,26 +1383,21 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: return model -# Branded NVIDIA Build embeddings. LiteLLM's generic `custom_openai` provider -# (used by the branded LLM adapters) has no embedding support, so embeddings -# route through LiteLLM's native `nvidia_nim` provider instead, which reuses the -# OpenAI embedding handler. `nvidia_nim` already defaults its api_base to -# `_NVIDIA_BUILD_API_BASE` (https://integrate.api.nvidia.com/v1); we pin the same -# value explicitly so it shows in the schema as an overridable default. +# custom_openai has no embedding support, so these route through LiteLLM's +# native nvidia_nim provider; its default api_base is pinned for the schema. _NVIDIA_NIM_PROVIDER_PREFIX = "nvidia_nim/" class NvidiaBuildEmbeddingParameters(OpenAIEmbeddingParameters): """OpenAI-compatible embeddings via NVIDIA's hosted endpoint (build.nvidia.com).""" - # Same value LiteLLM's nvidia_nim provider defaults to; users may override it. + # Overridable default endpoint. api_base: str = _NVIDIA_BUILD_API_BASE @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: adapter_metadata = dict(adapter_metadata) - # Endpoint is exposed in the schema with a sane default; honour an - # override but fall back to the default when blank/absent. + # Endpoint stays overridable so a provider URL change needs no release. api_base = adapter_metadata.get("api_base") if not (isinstance(api_base, str) and api_base.strip()): adapter_metadata["api_base"] = _NVIDIA_BUILD_API_BASE @@ -1436,15 +1419,13 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: class OpenAICompatibleEmbeddingParameters(OpenAIEmbeddingParameters): - """Embeddings for any server implementing the OpenAI embeddings API. + """Embeddings for any OpenAI-compatible server (vLLM, self-hosted, etc.). - LiteLLM has no generic `custom_openai` embedding handler, so requests route - through the `openai/` provider with a user-supplied `api_base` (vLLM, - self-hosted gateways, third-party providers). Cost stays unresolved since - the endpoint is arbitrary. + Routes through the `openai/` provider with a user-supplied `api_base`; + cost stays unresolved since the endpoint is arbitrary. """ - # api_key is optional (some gateways are keyless); api_base is required. + # Some gateways are keyless; the endpoint is always required. api_key: str | None = None api_base: str diff --git a/unstract/sdk1/tests/test_branded_openai_adapters.py b/unstract/sdk1/tests/test_branded_openai_adapters.py index fe36578b60..8b4971b06d 100644 --- a/unstract/sdk1/tests/test_branded_openai_adapters.py +++ b/unstract/sdk1/tests/test_branded_openai_adapters.py @@ -217,8 +217,7 @@ def test_embedding_schema_drops_embed_batch_size(adapter: type) -> None: def test_embedding_strips_embed_batch_size_before_litellm( monkeypatch: pytest.MonkeyPatch, ) -> None: - # embed_batch_size must never reach litellm.embedding (strict gateways - # like NVIDIA reject it). Also asserts encoding_format="float" is sent. + # Non-API fields must not reach the provider; encoding_format must be sent. import unstract.sdk1.embedding as emb_mod captured: dict = {} @@ -243,7 +242,7 @@ def fake_embedding(model: str, input: list, **kwargs: object) -> dict: # noqa: assert "embed_batch_size" not in captured assert captured["encoding_format"] == "float" assert captured["model"] == "nvidia_nim/nvidia/nv-embedqa-e5-v5" - # Test-connection embeds the snippet as a query; NVIDIA requires input_type. + # Query path must send input_type for asymmetric models. assert captured["input_type"] == "query" From 78eddcb0356f11491672015c0e39ad98ed9ebb06 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 2 Jun 2026 10:55:50 +0530 Subject: [PATCH 11/11] UN-3503 [FIX] Address review: keyless compat embedding key, OpenRouter reasoning reload - OpenAI-compatible embedding: blank api_key normalized to a non-empty placeholder instead of None, since the OpenAI SDK rejects a null key before reaching keyless gateways (coderabbit). - OpenRouter: infer enable_reasoning from a surviving reasoning_effort on re-validation so reloading a saved reasoning config no longer silently disables it (greptile P1). Co-Authored-By: Claude Opus 4.8 --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 21 ++++++++++++++---- .../tests/test_branded_openai_adapters.py | 22 +++++++++++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 17d9f68602..ebd93e3dbd 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -353,6 +353,9 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: _OPENAI_PROVIDER_PREFIX = "openai/" _CUSTOM_OPENAI_PROVIDER_PREFIX = "custom_openai/" _OPENAI_REASONING_MODEL_PATTERN = re.compile(r"^(o1|o3|o4|gpt-5)(?:[-/]|$)") +# Keyless gateways still need a non-empty key; the OpenAI SDK rejects a +# null/blank one before any request reaches the endpoint. +_NO_AUTH_API_KEY = "no-auth" def _is_openai_reasoning_model(model: str) -> bool: @@ -546,8 +549,18 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: adapter_metadata ) # Reasoning models reject a non-default temperature; drop it and send - # reasoning_effort only when reasoning is enabled. - enable_reasoning = adapter_metadata.pop("enable_reasoning", False) + # reasoning_effort only when reasoning is enabled. On a re-validation + # pass `enable_reasoning` is absent (it isn't serialized), so recover the + # state from a surviving reasoning_effort to keep reload idempotent — + # unless the user explicitly opted out with `enable_reasoning: false`. + enable_reasoning = adapter_metadata.get("enable_reasoning", False) + if ( + not enable_reasoning + and "enable_reasoning" not in adapter_metadata + and adapter_metadata.get("reasoning_effort") is not None + ): + enable_reasoning = True + adapter_metadata.pop("enable_reasoning", None) if enable_reasoning: adapter_metadata["temperature"] = None else: @@ -1433,8 +1446,8 @@ class OpenAICompatibleEmbeddingParameters(OpenAIEmbeddingParameters): def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: adapter_metadata = dict(adapter_metadata) api_key = adapter_metadata.get("api_key") - if isinstance(api_key, str) and not api_key.strip(): - adapter_metadata["api_key"] = None + if not (isinstance(api_key, str) and api_key.strip()): + adapter_metadata["api_key"] = _NO_AUTH_API_KEY # Strict endpoints reject the null LiteLLM sends; pin a real value. adapter_metadata.setdefault("encoding_format", "float") adapter_metadata["model"] = OpenAICompatibleEmbeddingParameters.validate_model( diff --git a/unstract/sdk1/tests/test_branded_openai_adapters.py b/unstract/sdk1/tests/test_branded_openai_adapters.py index 8b4971b06d..c876698847 100644 --- a/unstract/sdk1/tests/test_branded_openai_adapters.py +++ b/unstract/sdk1/tests/test_branded_openai_adapters.py @@ -87,6 +87,22 @@ def test_openrouter_forwards_reasoning_effort_only_when_enabled() -> None: assert off["reasoning_effort"] is None +def test_openrouter_reasoning_survives_revalidation() -> None: + once = OpenRouterLLMParameters.validate( + { + "model": "openai/gpt-5", + "api_key": "k", + "enable_reasoning": True, + "reasoning_effort": "high", + } + ) + assert once["reasoning_effort"] == "high" + # Reloading a saved config (no enable_reasoning key) must keep reasoning on. + twice = OpenRouterLLMParameters.validate(dict(once)) + assert twice["reasoning_effort"] == "high" + assert twice["temperature"] is None + + @pytest.mark.parametrize( ("params", "default_base"), [ @@ -185,11 +201,13 @@ def test_compatible_embedding_registered_and_routes_via_openai() -> None: assert validated["api_base"] == "https://gw.example/v1" -def test_compatible_embedding_blank_api_key_normalized_to_none() -> None: +def test_compatible_embedding_blank_api_key_uses_placeholder() -> None: + # Keyless gateways still need a non-empty key or the OpenAI SDK rejects it. validated = OpenAICompatibleEmbeddingParameters.validate( {"model": "m", "api_base": "https://gw.example/v1", "api_key": " "} ) - assert validated["api_key"] is None + assert isinstance(validated["api_key"], str) + assert validated["api_key"].strip() def test_compatible_embedding_requires_api_base() -> None: