diff --git a/.claude/skills/adapter-ops/SKILL.md b/.claude/skills/adapter-ops/SKILL.md new file mode 100644 index 0000000000..755418469e --- /dev/null +++ b/.claude/skills/adapter-ops/SKILL.md @@ -0,0 +1,353 @@ +--- +name: adapter-ops +description: Extend LLM and embedding adapters in unstract/sdk1. Use when adding new adapters (LLM or embedding), removing adapters, adding/removing models to existing adapters, or editing adapter configurations. Supports OpenAI-compatible providers, cloud providers (AWS Bedrock, VertexAI, Azure), and self-hosted models (Ollama). +--- + +# Unstract Adapter Extension Skill + +This skill provides workflows and automation for extending LLM and embedding adapters in the `unstract/sdk1` module. + +## Supported Operations + +| Operation | Command | Description | +|-----------|---------|-------------| +| Add LLM Adapter | `scripts/init_llm_adapter.py` | Create new LLM provider adapter | +| Add Embedding Adapter | `scripts/init_embedding_adapter.py` | Create new embedding provider adapter | +| Remove Adapter | Manual deletion | Remove adapter files and parameter class | +| Add/Remove Models | `scripts/manage_models.py` | Modify available models in JSON schema | +| Edit Adapter | Manual edit | Modify existing adapter behavior | +| Check for Updates | `scripts/check_adapter_updates.py` | Compare adapters against LiteLLM features | + +## Quick Reference + +### File Locations +``` +unstract/sdk1/src/unstract/sdk1/adapters/ +├── base1.py # Parameter classes (add new ones here) +├── llm1/ # LLM adapters +│ ├── {provider}.py # Adapter implementation +│ └── static/{provider}.json # UI schema +└── embedding1/ # Embedding adapters + ├── {provider}.py # Adapter implementation + └── static/{provider}.json # UI schema +``` + +### ID Format +Adapter IDs follow the pattern: `{provider}|{uuid4}` +- Example: `openai|502ecf49-e47c-445c-9907-6d4b90c5cd17` +- Generate UUID: `python -c "import uuid; print(uuid.uuid4())"` + +### Model Prefix Convention +LiteLLM requires provider prefixes on model names: +| Provider | Prefix | Example | +|----------|--------|---------| +| OpenAI | `openai/` | `openai/gpt-4` | +| Azure | `azure/` | `azure/gpt-4-deployment` | +| Anthropic | `anthropic/` | `anthropic/claude-3-opus` | +| Bedrock | `bedrock/` | `bedrock/anthropic.claude-v2` | +| VertexAI | `vertex_ai/` | `vertex_ai/gemini-pro` | +| Ollama | `ollama_chat/` | `ollama_chat/llama2` | +| Mistral | `mistral/` | `mistral/mistral-large` | +| Anyscale | `anyscale/` | `anyscale/meta-llama/Llama-2-70b` | + +## Workflows + +### Adding a New LLM Adapter + +1. **Run initialization script**: + ```bash + python .claude/skills/unstract-adapter-extension/scripts/init_llm_adapter.py \ + --provider newprovider \ + --name "New Provider" \ + --description "New Provider LLM adapter" \ + --auto-logo + ``` + + **Logo options**: + - `--auto-logo`: Search for potential logo sources (Clearbit, GitHub) and display suggestions. Does NOT auto-download - you must verify and use `--logo-url` to download. + - `--logo-url URL`: Download logo from a verified URL (supports SVG and raster images) + - `--logo-file PATH`: Copy logo from local file (supports SVG and raster images) + + **Logo image settings** (optimized for sharp rendering): + - SVG conversion: 4800 DPI density, 8-bit depth, 512x512 pixels + - Raster images: Resized to 512x512 with LANCZOS resampling + - Requires ImageMagick for SVG conversion (`sudo pacman -S imagemagick`) + + **GitHub logo URL tip**: When downloading logos from GitHub, always use the raw URL: + - ❌ `https://github.com/user/repo/blob/main/logo.svg` + - ✅ `https://raw.githubusercontent.com/user/repo/main/logo.svg` + + Logos are saved to: `frontend/public/icons/adapter-icons/{ProviderName}.png` + +2. **Add parameter class to `base1.py`** (if provider has unique parameters): + ```python + class NewProviderLLMParameters(BaseChatCompletionParameters): + """See https://docs.litellm.ai/docs/providers/newprovider.""" + + api_key: str + # Add provider-specific fields + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata["model"] = NewProviderLLMParameters.validate_model(adapter_metadata) + return NewProviderLLMParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + model = adapter_metadata.get("model", "") + if model.startswith("newprovider/"): + return model + return f"newprovider/{model}" + ``` + +3. **Update adapter class** to inherit from new parameter class: + ```python + from unstract.sdk1.adapters.base1 import BaseAdapter, NewProviderLLMParameters + + class NewProviderLLMAdapter(NewProviderLLMParameters, BaseAdapter): + # ... implementation + ``` + +4. **Customize JSON schema** in `llm1/static/newprovider.json` for UI configuration + +5. **Test the adapter**: + ```python + from unstract.sdk1.adapters.adapterkit import Adapterkit + kit = Adapterkit() + adapters = kit.get_adapters_list() + # Verify new adapter appears + ``` + +### Adding a New Embedding Adapter + +1. **Run initialization script**: + ```bash + python .claude/skills/unstract-adapter-extension/scripts/init_embedding_adapter.py \ + --provider newprovider \ + --name "New Provider" \ + --description "New Provider embedding adapter" \ + --auto-logo + ``` + + Same logo options as LLM adapter: `--auto-logo` (search only), `--logo-url`, `--logo-file` + +2. **Add parameter class to `base1.py`** (if needed): + ```python + class NewProviderEmbeddingParameters(BaseEmbeddingParameters): + """See https://docs.litellm.ai/docs/providers/newprovider.""" + + api_key: str + embed_batch_size: int | None = 10 + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata["model"] = NewProviderEmbeddingParameters.validate_model(adapter_metadata) + return NewProviderEmbeddingParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + return adapter_metadata.get("model", "") + ``` + +3. **Update adapter class and JSON schema** + +### Removing an Adapter + +1. **Delete adapter file**: `llm1/{provider}.py` or `embedding1/{provider}.py` +2. **Delete JSON schema**: `llm1/static/{provider}.json` or `embedding1/static/{provider}.json` +3. **Remove parameter class** from `base1.py` (if dedicated class exists) +4. **Verify removal**: Run `Adapterkit().get_adapters_list()` to confirm + +### Adding/Removing Models from Existing Adapter + +1. **Edit JSON schema** (`static/{provider}.json`): + ```json + { + "properties": { + "model": { + "type": "string", + "title": "Model", + "default": "new-default-model", + "description": "Available models: model-1, model-2, model-3" + } + } + } + ``` + +2. **For dropdown selection**, use enum: + ```json + { + "properties": { + "model": { + "type": "string", + "title": "Model", + "enum": ["model-1", "model-2", "model-3"], + "default": "model-1" + } + } + } + ``` + +3. **Run management script** for automated updates: + ```bash + python .claude/skills/unstract-adapter-extension/scripts/manage_models.py \ + --adapter llm \ + --provider openai \ + --action add \ + --models "gpt-4-turbo,gpt-4o-mini" + ``` + +### Editing Adapter Behavior + +Common modifications: + +1. **Add reasoning/thinking support**: + - Add `enable_thinking` boolean field to JSON schema + - Add conditional `thinking` config in `validate()` method + - See `AnthropicLLMParameters` in `base1.py` for reference + +2. **Add custom field mapping**: + ```python + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + # Map custom field names to expected names + if "custom_field" in adapter_metadata: + adapter_metadata["expected_field"] = adapter_metadata["custom_field"] + # Continue validation... + ``` + +3. **Add conditional fields in JSON schema**: + ```json + { + "allOf": [ + { + "if": { "properties": { "feature_enabled": { "const": true } } }, + "then": { + "properties": { "feature_config": { "type": "string" } }, + "required": ["feature_config"] + } + } + ] + } + ``` + +### Checking for Adapter Updates + +Compare existing adapter schemas against known LiteLLM features to identify potential updates: + +1. **Run the update checker**: + ```bash + # Check all adapters + python .claude/skills/unstract-adapter-extension/scripts/check_adapter_updates.py + + # Check specific adapter type + python .claude/skills/unstract-adapter-extension/scripts/check_adapter_updates.py --adapter llm + python .claude/skills/unstract-adapter-extension/scripts/check_adapter_updates.py --adapter embedding + + # Check specific provider + python .claude/skills/unstract-adapter-extension/scripts/check_adapter_updates.py --provider openai + + # Output as JSON + python .claude/skills/unstract-adapter-extension/scripts/check_adapter_updates.py --json + ``` + +2. **Review the report**: + - 🟡 **NEEDS UPDATE**: Adapters with missing parameters or outdated features + - ✅ **UP TO DATE**: Adapters matching known LiteLLM features + - ❌ **ERRORS**: Adapters that couldn't be analyzed (missing schema, etc.) + +3. **Common update types identified**: + - **Missing parameters**: New configuration options (e.g., `dimensions` for embeddings) + - **Reasoning/Thinking support**: Enable reasoning for models like o1, o3, Claude 3.7+, Magistral + - **Outdated defaults**: Default models that have been superseded + +4. **After identifying updates**: + - Update JSON schema in `static/{provider}.json` + - Update parameter class in `base1.py` if validation logic changes + - Consult LiteLLM docs for implementation details (URLs provided in report) + +5. **Update the feature database** (`check_adapter_updates.py`): + - Edit `LITELLM_FEATURES` dict to add new providers or parameters + - Keep `known_params`, `reasoning_models`, `thinking_models`, `latest_models` current + - Add documentation URLs for reference + +## Validation Checklist + +Before submitting adapter changes: + +- [ ] Adapter class inherits from correct parameter class AND `BaseAdapter` +- [ ] `get_id()` returns unique `{provider}|{uuid}` format +- [ ] `get_metadata()` returns dict with `name`, `version`, `adapter`, `description`, `is_active` +- [ ] `get_provider()` returns lowercase provider identifier +- [ ] `get_adapter_type()` returns correct `AdapterTypes.LLM` or `AdapterTypes.EMBEDDING` +- [ ] JSON schema has `adapter_name` as required field +- [ ] `validate()` method adds correct model prefix +- [ ] `validate_model()` method handles prefix idempotently (doesn't double-prefix) +- [ ] All static methods decorated with `@staticmethod` +- [ ] Icon path follows pattern `/icons/adapter-icons/{Name}.png` + +## Maintenance Workflow + +Periodic maintenance to keep adapters current with LiteLLM features: + +### Monthly Update Check + +1. **Run the update checker**: + ```bash + python .claude/skills/unstract-adapter-extension/scripts/check_adapter_updates.py + ``` + +2. **Review LiteLLM changelog** for new provider features: + - https://github.com/BerriAI/litellm/releases + +3. **Update feature database** in `check_adapter_updates.py`: + - Add new `known_params` for each provider + - Update `reasoning_models` and `thinking_models` lists + - Update `latest_models` with current defaults + +4. **Apply updates** following priority: + - 🔴 **High**: Security or breaking changes + - 🟡 **Medium**: New capabilities (reasoning, dimensions) + - 🟢 **Low**: Model updates, documentation + +### After LiteLLM Upgrade + +When upgrading LiteLLM dependency: + +1. Check for **API changes** in provider parameters +2. Verify **model prefix** requirements haven't changed +3. Test **thinking/reasoning** features still work +4. Update **default models** if deprecated + +### Adding New Provider Support + +When LiteLLM adds a new provider: + +1. Check LiteLLM docs: `https://docs.litellm.ai/docs/providers/{provider}` +2. Add feature data to `check_adapter_updates.py` +3. Run init script to create adapter skeleton +4. Customize JSON schema and parameter class + +## Reference Files + +For detailed patterns and examples, see: +- `references/adapter_patterns.md` - Complete code patterns +- `references/json_schema_guide.md` - JSON schema patterns for UI +- `references/provider_capabilities.md` - Provider feature matrix +- `assets/templates/` - Ready-to-use templates + +## Troubleshooting + +### Adapter not appearing in list +- Verify class name ends with `LLMAdapter` or `EmbeddingAdapter` +- Check `is_active: True` in metadata +- Ensure file is in correct directory (`llm1/` or `embedding1/`) + +### Validation errors +- Check parameter class fields match JSON schema required fields +- Verify `validate()` returns properly validated dict +- Ensure model prefix logic is idempotent + +### Import errors +- Verify imports in adapter file match available classes in `base1.py` +- Check for circular imports (use `TYPE_CHECKING` guard) diff --git a/.claude/skills/adapter-ops/assets/templates/embedding_adapter.py.template b/.claude/skills/adapter-ops/assets/templates/embedding_adapter.py.template new file mode 100644 index 0000000000..9fbaa0307f --- /dev/null +++ b/.claude/skills/adapter-ops/assets/templates/embedding_adapter.py.template @@ -0,0 +1,59 @@ +"""Embedding Adapter Template for ${PROVIDER_NAME}. + +Replace placeholders: +- ${PROVIDER_NAME} -> Display name (e.g., "New Provider") +- ${PROVIDER_ID} -> Lowercase identifier (e.g., "newprovider") +- ${CLASS_NAME} -> PascalCase name (e.g., "NewProvider") +- ${UUID} -> Generate with: python -c "import uuid; print(uuid.uuid4())" +- ${PARAM_CLASS} -> Parameter class name (e.g., "NewProviderEmbeddingParameters") +""" + +from typing import Any + +from unstract.sdk1.adapters.base1 import BaseAdapter, ${PARAM_CLASS} +from unstract.sdk1.adapters.enums import AdapterTypes + + +class ${CLASS_NAME}EmbeddingAdapter(${PARAM_CLASS}, BaseAdapter): + """Embedding adapter for ${PROVIDER_NAME}.""" + + @staticmethod + def get_id() -> str: + """Return unique adapter ID.""" + return "${PROVIDER_ID}|${UUID}" + + @staticmethod + def get_metadata() -> dict[str, Any]: + """Return adapter metadata for registration.""" + return { + "name": "${PROVIDER_NAME}", + "version": "1.0.0", + "adapter": ${CLASS_NAME}EmbeddingAdapter, + "description": "${PROVIDER_NAME} embedding adapter", + "is_active": True, + } + + @staticmethod + def get_name() -> str: + """Return display name.""" + return "${PROVIDER_NAME}" + + @staticmethod + def get_description() -> str: + """Return description.""" + return "${PROVIDER_NAME} embedding adapter" + + @staticmethod + def get_provider() -> str: + """Return lowercase provider identifier.""" + return "${PROVIDER_ID}" + + @staticmethod + def get_icon() -> str: + """Return icon path.""" + return "/icons/adapter-icons/${PROVIDER_NAME}.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + """Return adapter type.""" + return AdapterTypes.EMBEDDING diff --git a/.claude/skills/adapter-ops/assets/templates/embedding_parameters.py.template b/.claude/skills/adapter-ops/assets/templates/embedding_parameters.py.template new file mode 100644 index 0000000000..466d723565 --- /dev/null +++ b/.claude/skills/adapter-ops/assets/templates/embedding_parameters.py.template @@ -0,0 +1,57 @@ +"""Embedding Parameter Class Template for ${PROVIDER_NAME}. + +Add this class to base1.py after the existing parameter classes. + +Replace placeholders: +- ${PROVIDER_NAME} -> Display name (e.g., "New Provider") +- ${PROVIDER_ID} -> Lowercase identifier (e.g., "newprovider") +- ${CLASS_NAME} -> PascalCase name (e.g., "NewProvider") +""" + + +class ${CLASS_NAME}EmbeddingParameters(BaseEmbeddingParameters): + """Parameters for ${PROVIDER_NAME} Embedding. + + See https://docs.litellm.ai/docs/providers/${PROVIDER_ID} + """ + + # Required fields + api_key: str + + # Optional fields with defaults + api_base: str | None = None + embed_batch_size: int | None = 10 + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + """Validate and transform adapter metadata. + + Args: + adapter_metadata: Raw configuration from UI form + + Returns: + Validated configuration dict for embedding provider + """ + # Set model (embedding models often don't need prefix) + adapter_metadata["model"] = ${CLASS_NAME}EmbeddingParameters.validate_model( + adapter_metadata + ) + + # Map any custom field names here + # Example: if "endpoint" in adapter_metadata: + # adapter_metadata["api_base"] = adapter_metadata["endpoint"] + + # Validate with pydantic and return + return ${CLASS_NAME}EmbeddingParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + """Get model name (embedding models typically don't need prefix). + + Args: + adapter_metadata: Configuration containing 'model' key + + Returns: + Model name (usually without prefix for embeddings) + """ + return adapter_metadata.get("model", "") diff --git a/.claude/skills/adapter-ops/assets/templates/embedding_schema.json.template b/.claude/skills/adapter-ops/assets/templates/embedding_schema.json.template new file mode 100644 index 0000000000..65e66a3323 --- /dev/null +++ b/.claude/skills/adapter-ops/assets/templates/embedding_schema.json.template @@ -0,0 +1,52 @@ +{ + "title": "${PROVIDER_NAME} Embedding", + "type": "object", + "required": [ + "adapter_name", + "api_key" + ], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: ${PROVIDER_ID}-emb-1" + }, + "model": { + "type": "string", + "title": "Model", + "default": "", + "description": "The embedding model to use." + }, + "api_key": { + "type": "string", + "title": "API Key", + "default": "", + "format": "password", + "description": "Your ${PROVIDER_NAME} API key." + }, + "api_base": { + "type": "string", + "title": "API Base", + "format": "uri", + "default": "", + "description": "API endpoint URL (optional)." + }, + "embed_batch_size": { + "type": "number", + "minimum": 1, + "multipleOf": 1, + "title": "Embed Batch Size", + "default": 10, + "description": "Number of texts to embed per batch." + }, + "timeout": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Timeout", + "default": 240, + "description": "Request timeout in seconds." + } + } +} diff --git a/.claude/skills/adapter-ops/assets/templates/llm_adapter.py.template b/.claude/skills/adapter-ops/assets/templates/llm_adapter.py.template new file mode 100644 index 0000000000..b042e12ffe --- /dev/null +++ b/.claude/skills/adapter-ops/assets/templates/llm_adapter.py.template @@ -0,0 +1,59 @@ +"""LLM Adapter Template for ${PROVIDER_NAME}. + +Replace placeholders: +- ${PROVIDER_NAME} -> Display name (e.g., "New Provider") +- ${PROVIDER_ID} -> Lowercase identifier (e.g., "newprovider") +- ${CLASS_NAME} -> PascalCase name (e.g., "NewProvider") +- ${UUID} -> Generate with: python -c "import uuid; print(uuid.uuid4())" +- ${PARAM_CLASS} -> Parameter class name (e.g., "NewProviderLLMParameters") +""" + +from typing import Any + +from unstract.sdk1.adapters.base1 import BaseAdapter, ${PARAM_CLASS} +from unstract.sdk1.adapters.enums import AdapterTypes + + +class ${CLASS_NAME}LLMAdapter(${PARAM_CLASS}, BaseAdapter): + """LLM adapter for ${PROVIDER_NAME}.""" + + @staticmethod + def get_id() -> str: + """Return unique adapter ID.""" + return "${PROVIDER_ID}|${UUID}" + + @staticmethod + def get_metadata() -> dict[str, Any]: + """Return adapter metadata for registration.""" + return { + "name": "${PROVIDER_NAME}", + "version": "1.0.0", + "adapter": ${CLASS_NAME}LLMAdapter, + "description": "${PROVIDER_NAME} LLM adapter", + "is_active": True, + } + + @staticmethod + def get_name() -> str: + """Return display name.""" + return "${PROVIDER_NAME}" + + @staticmethod + def get_description() -> str: + """Return description.""" + return "${PROVIDER_NAME} LLM adapter" + + @staticmethod + def get_provider() -> str: + """Return lowercase provider identifier.""" + return "${PROVIDER_ID}" + + @staticmethod + def get_icon() -> str: + """Return icon path.""" + return "/icons/adapter-icons/${PROVIDER_NAME}.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + """Return adapter type.""" + return AdapterTypes.LLM diff --git a/.claude/skills/adapter-ops/assets/templates/llm_parameters.py.template b/.claude/skills/adapter-ops/assets/templates/llm_parameters.py.template new file mode 100644 index 0000000000..ea26f69ae1 --- /dev/null +++ b/.claude/skills/adapter-ops/assets/templates/llm_parameters.py.template @@ -0,0 +1,61 @@ +"""LLM Parameter Class Template for ${PROVIDER_NAME}. + +Add this class to base1.py after the existing parameter classes. + +Replace placeholders: +- ${PROVIDER_NAME} -> Display name (e.g., "New Provider") +- ${PROVIDER_ID} -> Lowercase identifier (e.g., "newprovider") +- ${CLASS_NAME} -> PascalCase name (e.g., "NewProvider") +- ${MODEL_PREFIX} -> LiteLLM model prefix (e.g., "newprovider/") +""" + + +class ${CLASS_NAME}LLMParameters(BaseChatCompletionParameters): + """Parameters for ${PROVIDER_NAME} LLM. + + See https://docs.litellm.ai/docs/providers/${PROVIDER_ID} + """ + + # Required fields + api_key: str + + # Optional fields with defaults + api_base: str | None = None + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + """Validate and transform adapter metadata. + + Args: + adapter_metadata: Raw configuration from UI form + + Returns: + Validated configuration dict for LiteLLM + """ + # Add model prefix + adapter_metadata["model"] = ${CLASS_NAME}LLMParameters.validate_model( + adapter_metadata + ) + + # Map any custom field names here + # Example: if "endpoint" in adapter_metadata: + # adapter_metadata["api_base"] = adapter_metadata["endpoint"] + + # Validate with pydantic and return + return ${CLASS_NAME}LLMParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + """Add provider prefix to model name (idempotent). + + Args: + adapter_metadata: Configuration containing 'model' key + + Returns: + Model name with provider prefix + """ + model = adapter_metadata.get("model", "") + # Avoid double-prefixing + if model.startswith("${MODEL_PREFIX}"): + return model + return f"${MODEL_PREFIX}{model}" diff --git a/.claude/skills/adapter-ops/assets/templates/llm_schema.json.template b/.claude/skills/adapter-ops/assets/templates/llm_schema.json.template new file mode 100644 index 0000000000..b17f2ea1b7 --- /dev/null +++ b/.claude/skills/adapter-ops/assets/templates/llm_schema.json.template @@ -0,0 +1,58 @@ +{ + "title": "${PROVIDER_NAME} LLM", + "type": "object", + "required": [ + "adapter_name", + "api_key" + ], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: ${PROVIDER_ID}-llm-1" + }, + "api_key": { + "type": "string", + "title": "API Key", + "format": "password", + "description": "Your ${PROVIDER_NAME} API key." + }, + "model": { + "type": "string", + "title": "Model", + "default": "", + "description": "The model to use for API requests." + }, + "api_base": { + "type": "string", + "format": "url", + "title": "API Base", + "default": "", + "description": "API endpoint URL (optional, uses default if not specified)." + }, + "max_tokens": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Maximum Output Tokens", + "description": "Maximum number of output tokens to limit LLM replies." + }, + "max_retries": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Max Retries", + "default": 5, + "description": "Maximum retry attempts for failed requests." + }, + "timeout": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Timeout", + "default": 900, + "description": "Request timeout in seconds." + } + } +} diff --git a/.claude/skills/adapter-ops/references/adapter_patterns.md b/.claude/skills/adapter-ops/references/adapter_patterns.md new file mode 100644 index 0000000000..651d0944ba --- /dev/null +++ b/.claude/skills/adapter-ops/references/adapter_patterns.md @@ -0,0 +1,562 @@ +# Adapter Patterns Reference + +Complete code patterns for extending unstract/sdk1 adapters. + +## Architecture Overview + +``` +unstract/sdk1/src/unstract/sdk1/adapters/ +├── base1.py # Parameter classes & base adapter +│ ├── register_adapters() # Auto-discovery function +│ ├── BaseAdapter # Abstract base class +│ ├── BaseChatCompletionParameters # LLM base params +│ ├── BaseEmbeddingParameters # Embedding base params +│ └── {Provider}Parameters # Provider-specific params +├── adapterkit.py # Singleton registry (Adapterkit) +├── enums.py # AdapterTypes enum +├── llm1/ # LLM adapters +│ ├── __init__.py # Calls register_adapters() +│ ├── {provider}.py # Adapter implementations +│ └── static/ # JSON schemas +└── embedding1/ # Embedding adapters + ├── __init__.py # Calls register_adapters() + ├── {provider}.py # Adapter implementations + └── static/ # JSON schemas +``` + +## Registration Flow + +1. `llm1/__init__.py` imports and calls `register_adapters(adapters, "LLM")` +2. `register_adapters()` scans `llm1/*.py` files +3. For each file, inspects classes ending with `LLMAdapter` +4. Checks for `get_id()` and `get_metadata()` methods +5. Stores adapter in global dict: `adapters[adapter_id] = {module, metadata}` +6. `Adapterkit` singleton merges all adapter types on init + +## Complete LLM Adapter Example + +### Parameter Class (base1.py) + +```python +class NewProviderLLMParameters(BaseChatCompletionParameters): + """Provider-specific parameters. + + See https://docs.litellm.ai/docs/providers/newprovider + """ + + # Required fields (no defaults) + api_key: str + + # Optional fields (with defaults) + api_base: str | None = None + custom_header: str | None = None + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + """Transform and validate adapter metadata. + + This method: + 1. Applies model prefix via validate_model() + 2. Maps field names if needed (e.g., azure_endpoint -> api_base) + 3. Handles special features (reasoning, thinking) + 4. Validates with pydantic and returns clean dict + """ + # Always set model with proper prefix + adapter_metadata["model"] = NewProviderLLMParameters.validate_model( + adapter_metadata + ) + + # Map custom field names to expected names + if "endpoint" in adapter_metadata and not adapter_metadata.get("api_base"): + adapter_metadata["api_base"] = adapter_metadata["endpoint"] + + # Validate with pydantic and return + return NewProviderLLMParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + """Add provider prefix to model name (idempotent). + + IMPORTANT: Must handle already-prefixed models to avoid double-prefixing. + """ + model = adapter_metadata.get("model", "") + if model.startswith("newprovider/"): + return model + return f"newprovider/{model}" +``` + +### Adapter Class (llm1/newprovider.py) + +```python +from typing import Any + +from unstract.sdk1.adapters.base1 import BaseAdapter, NewProviderLLMParameters +from unstract.sdk1.adapters.enums import AdapterTypes + + +class NewProviderLLMAdapter(NewProviderLLMParameters, BaseAdapter): + """LLM adapter for New Provider. + + Multiple inheritance order matters: + 1. Parameter class first (provides validate methods) + 2. BaseAdapter second (provides abstract methods) + """ + + @staticmethod + def get_id() -> str: + """Return unique adapter ID in format: provider|uuid4.""" + return "newprovider|a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + @staticmethod + def get_metadata() -> dict[str, Any]: + """Return adapter metadata for registration.""" + return { + "name": "New Provider", + "version": "1.0.0", + "adapter": NewProviderLLMAdapter, # Reference to this class + "description": "New Provider LLM adapter", + "is_active": True, # Must be True for auto-registration + } + + @staticmethod + def get_name() -> str: + """Return display name.""" + return "New Provider" + + @staticmethod + def get_description() -> str: + """Return description.""" + return "New Provider LLM adapter" + + @staticmethod + def get_provider() -> str: + """Return lowercase provider identifier (used for schema path).""" + return "newprovider" + + @staticmethod + def get_icon() -> str: + """Return icon path (relative to frontend assets).""" + return "/icons/adapter-icons/NewProvider.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + """Return adapter type enum.""" + return AdapterTypes.LLM +``` + +### JSON Schema (llm1/static/newprovider.json) + +```json +{ + "title": "New Provider LLM", + "type": "object", + "required": ["adapter_name", "api_key"], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Unique name for this adapter instance" + }, + "api_key": { + "type": "string", + "title": "API Key", + "format": "password", + "description": "Your New Provider API key" + }, + "model": { + "type": "string", + "title": "Model", + "default": "default-model", + "description": "Model to use" + }, + "max_tokens": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Maximum Output Tokens" + }, + "timeout": { + "type": "number", + "minimum": 0, + "default": 900, + "title": "Timeout (seconds)" + } + } +} +``` + +## Reasoning Configuration Pattern (OpenAI o1/o3, Mistral Magistral) + +For providers supporting reasoning effort control: + +### JSON Schema Addition + +```json +{ + "properties": { + "enable_reasoning": { + "type": "boolean", + "title": "Enable Reasoning", + "default": false, + "description": "Enable reasoning capabilities for supported models" + } + }, + "allOf": [ + { + "if": { + "properties": { "enable_reasoning": { "const": true } } + }, + "then": { + "properties": { + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + "default": "medium", + "title": "Reasoning Effort", + "description": "Controls depth of reasoning" + } + }, + "required": ["reasoning_effort"] + } + }, + { + "if": { + "properties": { "enable_reasoning": { "const": false } } + }, + "then": { + "properties": {} + } + } + ] +} +``` + +### Parameter Class Pattern + +```python +@staticmethod +def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata["model"] = MyParameters.validate_model(adapter_metadata) + + # Handle reasoning configuration + enable_reasoning = adapter_metadata.get("enable_reasoning", False) + reasoning_effort = adapter_metadata.get("reasoning_effort") + + # Exclude control fields before validation + validation_metadata = { + k: v for k, v in adapter_metadata.items() + if k not in ("enable_reasoning", "reasoning_effort") + } + + validated = MyParameters(**validation_metadata).model_dump() + + # Add reasoning_effort back if enabled + if enable_reasoning and reasoning_effort: + validated["reasoning_effort"] = reasoning_effort + + return validated +``` + +## Thinking Configuration Pattern (Anthropic, VertexAI, Bedrock) + +For providers supporting extended thinking: + +### JSON Schema Addition + +```json +{ + "properties": { + "enable_thinking": { + "type": "boolean", + "title": "Enable Extended Thinking", + "default": false, + "description": "Allow extra reasoning for complex tasks" + } + }, + "allOf": [ + { + "if": { + "properties": { "enable_thinking": { "const": true } } + }, + "then": { + "properties": { + "budget_tokens": { + "type": "number", + "minimum": 1000, + "default": 10000, + "title": "Thinking Budget (tokens)" + } + } + } + } + ] +} +``` + +### Parameter Class Pattern + +```python +@staticmethod +def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata["model"] = MyParameters.validate_model(adapter_metadata) + + # Handle thinking configuration + enable_thinking = adapter_metadata.get("enable_thinking", False) + + # Check if thinking was previously enabled + has_thinking_config = ( + "thinking" in adapter_metadata + and adapter_metadata.get("thinking") is not None + ) + if not enable_thinking and has_thinking_config: + enable_thinking = True + + result_metadata = adapter_metadata.copy() + + if enable_thinking: + if has_thinking_config: + result_metadata["thinking"] = adapter_metadata["thinking"] + else: + thinking_config = {"type": "enabled"} + budget_tokens = adapter_metadata.get("budget_tokens") + if budget_tokens is not None: + thinking_config["budget_tokens"] = budget_tokens + result_metadata["thinking"] = thinking_config + result_metadata["temperature"] = 1 # Required for thinking + + # Exclude control fields from validation + validation_metadata = { + k: v for k, v in result_metadata.items() + if k not in ("enable_thinking", "budget_tokens", "thinking") + } + + validated = MyParameters(**validation_metadata).model_dump() + + # Add thinking config back if enabled + if enable_thinking and "thinking" in result_metadata: + validated["thinking"] = result_metadata["thinking"] + + return validated +``` + +## Conditional Fields Pattern + +For fields that appear based on other field values: + +```json +{ + "properties": { + "deployment_type": { + "type": "string", + "enum": ["cloud", "on-premise"], + "default": "cloud" + } + }, + "allOf": [ + { + "if": { + "properties": { "deployment_type": { "const": "cloud" } } + }, + "then": { + "properties": { + "region": { + "type": "string", + "enum": ["us-east-1", "us-west-2", "eu-west-1"] + } + }, + "required": ["region"] + } + }, + { + "if": { + "properties": { "deployment_type": { "const": "on-premise" } } + }, + "then": { + "properties": { + "server_url": { + "type": "string", + "format": "uri", + "title": "Server URL" + } + }, + "required": ["server_url"] + } + } + ] +} +``` + +## Optional Credentials Pattern (AWS Bedrock) + +For providers with multiple authentication methods (credentials, SSO profile, IAM role): + +### JSON Schema + +```json +{ + "required": ["adapter_name", "region_name", "model"], + "properties": { + "aws_access_key_id": { + "type": "string", + "title": "AWS Access Key ID", + "format": "password", + "description": "Leave empty if using AWS Profile or IAM role." + }, + "aws_secret_access_key": { + "type": "string", + "title": "AWS Secret Access Key", + "format": "password", + "description": "Leave empty if using AWS Profile or IAM role." + }, + "aws_profile_name": { + "type": "string", + "title": "AWS Profile Name", + "description": "AWS SSO profile name. Use instead of access keys." + } + } +} +``` + +### Parameter Class Pattern + +```python +class AWSBedrockLLMParameters(BaseChatCompletionParameters): + # All credential fields are optional + aws_access_key_id: str | None = None + aws_secret_access_key: str | None = None + aws_profile_name: str | None = None + region_name: str # Required +``` + +## JSON Mode Pattern (Ollama) + +For providers supporting structured JSON output: + +### JSON Schema + +```json +{ + "properties": { + "json_mode": { + "type": "boolean", + "title": "JSON Mode", + "default": false, + "description": "Enable JSON mode to constrain output to valid JSON." + } + } +} +``` + +### Parameter Class Pattern + +```python +@staticmethod +def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + # Convert json_mode to response_format + json_mode = adapter_metadata.pop("json_mode", False) + + validated = OllamaLLMParameters(**adapter_metadata).model_dump() + + if json_mode: + validated["response_format"] = {"type": "json_object"} + + return validated +``` + +## Embedding Adapter Pattern + +Embedding adapters follow the same structure but with different base classes: + +```python +class NewProviderEmbeddingParameters(BaseEmbeddingParameters): + """Embedding-specific parameters.""" + + api_key: str + embed_batch_size: int | None = 10 + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata["model"] = NewProviderEmbeddingParameters.validate_model( + adapter_metadata + ) + return NewProviderEmbeddingParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + # Embedding models often don't need prefix + return adapter_metadata.get("model", "") + + +class NewProviderEmbeddingAdapter(NewProviderEmbeddingParameters, BaseAdapter): + # Same structure as LLM adapter but with: + # - get_adapter_type() returns AdapterTypes.EMBEDDING + # - Different UUID in get_id() +``` + +## Embedding Dimensions Pattern (OpenAI, Azure) + +For embedding models supporting custom output dimensions (text-embedding-3-*): + +### JSON Schema + +```json +{ + "properties": { + "dimensions": { + "type": "number", + "minimum": 1, + "multipleOf": 1, + "title": "Dimensions", + "description": "Output embedding dimensions. Only supported by text-embedding-3-* models. Leave empty for default." + } + } +} +``` + +### Parameter Class Pattern + +```python +class OpenAIEmbeddingParameters(BaseEmbeddingParameters): + api_key: str + dimensions: int | None = None # Optional, model-dependent +``` + +## Testing Adapters + +```python +from unstract.sdk1.adapters.adapterkit import Adapterkit + +# Get singleton instance +kit = Adapterkit() + +# List all adapters +adapters = kit.get_adapters_list() +for adapter_id, info in adapters.items(): + print(f"{adapter_id}: {info['metadata']['name']}") + +# Get specific adapter class +adapter_class = kit.get_adapter_class_by_adapter_id( + "newprovider|a1b2c3d4-e5f6-7890-abcd-ef1234567890" +) + +# Validate metadata +validated = adapter_class.validate({ + "model": "my-model", + "api_key": "sk-xxx", +}) +print(validated) + +# Get JSON schema +schema = adapter_class.get_json_schema() +print(schema) +``` + +## Common Mistakes + +1. **Missing `@staticmethod` decorator** - All adapter methods must be static +2. **Wrong class name suffix** - Must end with `LLMAdapter` or `EmbeddingAdapter` +3. **Double prefix in validate_model** - Always check if prefix exists first +4. **Missing `is_active: True`** - Adapter won't be registered without it +5. **Wrong inheritance order** - Parameter class must come before BaseAdapter +6. **Incorrect provider in get_provider()** - Must match filename and schema path diff --git a/.claude/skills/adapter-ops/references/json_schema_guide.md b/.claude/skills/adapter-ops/references/json_schema_guide.md new file mode 100644 index 0000000000..7f3def2109 --- /dev/null +++ b/.claude/skills/adapter-ops/references/json_schema_guide.md @@ -0,0 +1,549 @@ +# JSON Schema Guide for Adapter UI + +Reference for creating JSON schemas that generate adapter configuration UIs. + +## Schema Structure + +```json +{ + "title": "Provider Name Type", + "type": "object", + "required": ["field1", "field2"], + "properties": { ... }, + "allOf": [ ... ] +} +``` + +## Field Types + +### String Field + +```json +{ + "field_name": { + "type": "string", + "title": "Display Label", + "default": "default value", + "description": "Help text shown to user" + } +} +``` + +### Password Field + +```json +{ + "api_key": { + "type": "string", + "title": "API Key", + "format": "password", + "description": "Your secret API key" + } +} +``` + +### URL Field + +```json +{ + "endpoint": { + "type": "string", + "title": "Endpoint URL", + "format": "uri", + "default": "https://api.example.com/v1" + } +} +``` + +### Number Field + +```json +{ + "timeout": { + "type": "number", + "title": "Timeout", + "default": 300, + "minimum": 0, + "maximum": 3600, + "multipleOf": 1, + "description": "Timeout in seconds" + } +} +``` + +### Integer Field + +```json +{ + "max_retries": { + "type": "integer", + "title": "Max Retries", + "default": 3, + "minimum": 0, + "maximum": 10 + } +} +``` + +### Boolean Field + +```json +{ + "enable_feature": { + "type": "boolean", + "title": "Enable Feature", + "default": false, + "description": "Toggle to enable this feature" + } +} +``` + +### Dropdown (Enum) Field + +```json +{ + "model": { + "type": "string", + "title": "Model", + "enum": ["model-a", "model-b", "model-c"], + "default": "model-a", + "description": "Select the model to use" + } +} +``` + +### Multi-line Text + +```json +{ + "json_credentials": { + "type": "string", + "title": "JSON Credentials", + "format": "textarea", + "description": "Paste your JSON credentials here" + } +} +``` + +## Required Fields + +Always include `adapter_name` as required: + +```json +{ + "required": ["adapter_name", "api_key"], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance" + } + } +} +``` + +## Conditional Fields + +Show/hide fields based on other field values using `allOf` with `if`/`then`: + +### Basic Conditional + +```json +{ + "properties": { + "auth_type": { + "type": "string", + "enum": ["api_key", "oauth"], + "default": "api_key" + } + }, + "allOf": [ + { + "if": { + "properties": { + "auth_type": { "const": "api_key" } + } + }, + "then": { + "properties": { + "api_key": { + "type": "string", + "format": "password", + "title": "API Key" + } + }, + "required": ["api_key"] + } + }, + { + "if": { + "properties": { + "auth_type": { "const": "oauth" } + } + }, + "then": { + "properties": { + "client_id": { "type": "string", "title": "Client ID" }, + "client_secret": { "type": "string", "format": "password" } + }, + "required": ["client_id", "client_secret"] + } + } + ] +} +``` + +### Boolean Toggle Conditional + +```json +{ + "properties": { + "enable_reasoning": { + "type": "boolean", + "default": false, + "title": "Enable Reasoning" + } + }, + "allOf": [ + { + "if": { + "properties": { + "enable_reasoning": { "const": true } + } + }, + "then": { + "properties": { + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + "default": "medium", + "title": "Reasoning Effort" + } + }, + "required": ["reasoning_effort"] + } + }, + { + "if": { + "properties": { + "enable_reasoning": { "const": false } + } + }, + "then": { + "properties": {} + } + } + ] +} +``` + +## Complete Examples + +### Simple LLM Adapter Schema + +```json +{ + "title": "Simple Provider LLM", + "type": "object", + "required": ["adapter_name", "api_key"], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Unique name for this adapter" + }, + "api_key": { + "type": "string", + "title": "API Key", + "format": "password" + }, + "model": { + "type": "string", + "title": "Model", + "default": "default-model" + }, + "max_tokens": { + "type": "number", + "minimum": 0, + "title": "Max Tokens" + }, + "timeout": { + "type": "number", + "minimum": 0, + "default": 900, + "title": "Timeout (seconds)" + } + } +} +``` + +### Cloud Provider with Regions + +```json +{ + "title": "Cloud Provider LLM", + "type": "object", + "required": ["adapter_name", "api_key", "region"], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name" + }, + "api_key": { + "type": "string", + "format": "password", + "title": "API Key" + }, + "region": { + "type": "string", + "title": "Region", + "enum": ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"], + "default": "us-east-1" + }, + "model": { + "type": "string", + "title": "Model", + "enum": ["model-small", "model-medium", "model-large"], + "default": "model-medium" + } + } +} +``` + +### Azure-Style with Deployment + +```json +{ + "title": "Azure-Style Provider", + "type": "object", + "required": ["adapter_name", "api_key", "azure_endpoint", "deployment_name"], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name" + }, + "azure_endpoint": { + "type": "string", + "format": "uri", + "title": "Endpoint", + "description": "Your Azure endpoint URL" + }, + "api_key": { + "type": "string", + "format": "password", + "title": "API Key" + }, + "deployment_name": { + "type": "string", + "title": "Deployment Name", + "description": "Name of your model deployment" + }, + "api_version": { + "type": "string", + "title": "API Version", + "default": "2024-02-01" + } + } +} +``` + +### Self-Hosted (Ollama-Style) + +```json +{ + "title": "Self-Hosted LLM", + "type": "object", + "required": ["adapter_name", "base_url"], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name" + }, + "base_url": { + "type": "string", + "format": "uri", + "title": "Server URL", + "default": "http://localhost:11434", + "description": "URL of your local server" + }, + "model": { + "type": "string", + "title": "Model", + "default": "llama2", + "description": "Model name (must be pulled on server)" + } + } +} +``` + +### Embedding Adapter Schema + +```json +{ + "title": "Provider Embedding", + "type": "object", + "required": ["adapter_name", "api_key"], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name" + }, + "model": { + "type": "string", + "title": "Model", + "default": "text-embedding-model" + }, + "api_key": { + "type": "string", + "format": "password", + "title": "API Key" + }, + "api_base": { + "type": "string", + "format": "uri", + "title": "API Base URL" + }, + "embed_batch_size": { + "type": "number", + "minimum": 1, + "default": 10, + "title": "Batch Size" + }, + "timeout": { + "type": "number", + "minimum": 0, + "default": 240, + "title": "Timeout (seconds)" + } + } +} +``` + +### Embedding with Dimensions + +```json +{ + "title": "OpenAI Embedding", + "type": "object", + "required": ["adapter_name", "api_key"], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name" + }, + "model": { + "type": "string", + "title": "Model", + "default": "text-embedding-3-small", + "description": "text-embedding-3-small/large support custom dimensions" + }, + "api_key": { + "type": "string", + "format": "password", + "title": "API Key" + }, + "dimensions": { + "type": "number", + "minimum": 1, + "multipleOf": 1, + "title": "Dimensions", + "description": "Output dimensions (only for text-embedding-3-* models)" + } + } +} +``` + +### Reasoning with Effort Control (Mistral Magistral, OpenAI o1/o3) + +```json +{ + "properties": { + "enable_reasoning": { + "type": "boolean", + "title": "Enable Reasoning", + "default": false, + "description": "Enable reasoning for Magistral models" + } + }, + "allOf": [ + { + "if": { + "properties": { "enable_reasoning": { "const": true } } + }, + "then": { + "properties": { + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + "default": "medium", + "title": "Reasoning Effort" + } + }, + "required": ["reasoning_effort"] + } + } + ] +} +``` + +### Optional Credentials (AWS Bedrock) + +```json +{ + "title": "Bedrock LLM", + "type": "object", + "required": ["adapter_name", "region_name", "model"], + "properties": { + "adapter_name": { "type": "string", "title": "Name" }, + "model": { "type": "string", "title": "Model" }, + "region_name": { "type": "string", "title": "AWS Region" }, + "aws_access_key_id": { + "type": "string", + "format": "password", + "title": "AWS Access Key ID", + "description": "Leave empty if using AWS Profile or IAM role." + }, + "aws_secret_access_key": { + "type": "string", + "format": "password", + "title": "AWS Secret Access Key", + "description": "Leave empty if using AWS Profile or IAM role." + }, + "aws_profile_name": { + "type": "string", + "title": "AWS Profile Name", + "description": "AWS SSO profile name for authentication." + } + } +} +``` + +### JSON Mode Toggle (Ollama) + +```json +{ + "properties": { + "json_mode": { + "type": "boolean", + "title": "JSON Mode", + "default": false, + "description": "Constrain output to valid JSON" + } + } +} +``` + +## Best Practices + +1. **Always include `adapter_name`** as required field +2. **Use `format: "password"`** for secrets and API keys +3. **Provide sensible defaults** for optional fields +4. **Add descriptions** for non-obvious fields +5. **Use enums** when choices are limited +6. **Keep titles short** - they become form labels +7. **Order properties** by importance/usage frequency +8. **Use conditional fields** to reduce clutter +9. **Validate with JSON Schema validator** before deploying +10. **Make credentials optional** when multiple auth methods exist diff --git a/.claude/skills/adapter-ops/references/provider_capabilities.md b/.claude/skills/adapter-ops/references/provider_capabilities.md new file mode 100644 index 0000000000..cebb66489d --- /dev/null +++ b/.claude/skills/adapter-ops/references/provider_capabilities.md @@ -0,0 +1,145 @@ +# Provider Capabilities Reference + +Quick reference for LLM and embedding provider features supported via LiteLLM. + +## LLM Provider Features + +| Provider | Reasoning | Thinking | JSON Mode | Tools | Streaming | +|----------|:---------:|:--------:|:---------:|:-----:|:---------:| +| OpenAI | ✅ o1/o3 | ❌ | ✅ | ✅ | ✅ | +| Anthropic | ❌ | ✅ Claude 3.7+ | ✅ | ✅ | ✅ | +| Azure OpenAI | ✅ o1/o3 | ❌ | ✅ | ✅ | ✅ | +| AWS Bedrock | ❌ | ✅ Claude 3.7+ | ✅ | ✅ | ✅ | +| VertexAI | ✅ Gemini 2.5 | ✅ Gemini 2.5 | ✅ | ✅ | ✅ | +| Mistral | ✅ Magistral | ❌ | ✅ | ✅ | ✅ | +| Ollama | ❌ | ❌ | ✅ | ✅ | ✅ | +| Anyscale | ❌ | ❌ | ❌ | ❌ | ✅ | + +### Feature Definitions + +- **Reasoning**: `enable_reasoning` + `reasoning_effort` (low/medium/high) for chain-of-thought +- **Thinking**: `enable_thinking` + `budget_tokens` for extended internal reasoning +- **JSON Mode**: `response_format` or `json_mode` for structured output +- **Tools**: Function calling / tool use capability +- **Streaming**: Token-by-token response streaming + +## Embedding Provider Features + +| Provider | Dimensions | Batch Size | Model Prefix | +|----------|:----------:|:----------:|:------------:| +| OpenAI | ✅ v3 only | ✅ | ❌ | +| Azure OpenAI | ✅ v3 only | ✅ | ❌ | +| AWS Bedrock | ❌ | ✅ | `bedrock/` | +| VertexAI | ✅ | ✅ | `vertex_ai/` | +| Ollama | ❌ | ✅ | ❌ | + +### Feature Definitions + +- **Dimensions**: Custom output embedding dimensions (only text-embedding-3-* models) +- **Batch Size**: `embed_batch_size` for controlling request batching +- **Model Prefix**: Whether LiteLLM requires provider prefix on model name + +## Provider-Specific Parameters + +### OpenAI LLM +``` +api_key, api_base, model, max_tokens, temperature, top_p +enable_reasoning, reasoning_effort (o1/o3 models) +``` + +### Anthropic LLM +``` +api_key, model, max_tokens, temperature +enable_thinking, budget_tokens (Claude 3.7+) +``` + +### Azure OpenAI LLM +``` +api_key, azure_endpoint, api_version, deployment_name, model +max_tokens, temperature, enable_reasoning, reasoning_effort +``` + +### AWS Bedrock LLM +``` +aws_access_key_id, aws_secret_access_key, region_name +aws_profile_name (SSO), model_id (inference profile ARN) +model, max_tokens, enable_thinking, budget_tokens +``` + +### VertexAI LLM +``` +json_credentials, project, model, max_tokens, temperature +enable_thinking, budget_tokens, reasoning_effort (Gemini 2.5) +``` + +### Mistral LLM +``` +api_key, model, max_tokens, max_retries, timeout +enable_reasoning, reasoning_effort (Magistral models) +``` + +### Ollama LLM +``` +base_url, model, max_tokens, temperature, context_window +request_timeout, json_mode +``` + +### Anyscale LLM +``` +api_key, api_base, model, max_tokens, max_retries, timeout +``` + +## Authentication Methods + +| Provider | API Key | OAuth | IAM Role | SSO Profile | +|----------|:-------:|:-----:|:--------:|:-----------:| +| OpenAI | ✅ | ❌ | ❌ | ❌ | +| Anthropic | ✅ | ❌ | ❌ | ❌ | +| Azure | ✅ | ✅ | ✅ | ❌ | +| AWS Bedrock | ✅ | ❌ | ✅ | ✅ | +| VertexAI | JSON | ❌ | ✅ | ❌ | +| Mistral | ✅ | ❌ | ❌ | ❌ | +| Ollama | ❌ | ❌ | ❌ | ❌ | + +## LiteLLM Model Prefixes + +| Provider | LLM Prefix | Embedding Prefix | +|----------|------------|------------------| +| OpenAI | `openai/` | (none) | +| Anthropic | `anthropic/` | N/A | +| Azure | `azure/` | `azure/` | +| AWS Bedrock | `bedrock/` | `bedrock/` | +| VertexAI | `vertex_ai/` | `vertex_ai/` | +| Mistral | `mistral/` | N/A | +| Ollama | `ollama_chat/` | `ollama/` | +| Anyscale | `anyscale/` | N/A | + +## Models Supporting Advanced Features + +### Reasoning Models (reasoning_effort) +- OpenAI: `o1-mini`, `o1-preview`, `o3-mini`, `o3`, `o4-mini` +- Azure: `o1-mini`, `o1-preview` (via deployment) +- Mistral: `magistral-medium-2506`, `magistral-small-2506` +- VertexAI: `gemini-2.5-flash-preview`, `gemini-2.5-pro` + +### Thinking Models (budget_tokens) +- Anthropic: `claude-3-7-sonnet`, `claude-sonnet-4`, `claude-opus-4` +- Bedrock: `anthropic.claude-3-7-sonnet-*` +- VertexAI: `gemini-2.5-flash-preview`, `gemini-2.5-pro` + +### Embedding Models with Dimensions +- OpenAI: `text-embedding-3-small`, `text-embedding-3-large` +- Azure: `text-embedding-3-small`, `text-embedding-3-large` (via deployment) + +## Documentation Links + +| Provider | LiteLLM Docs | +|----------|--------------| +| OpenAI | https://docs.litellm.ai/docs/providers/openai | +| Anthropic | https://docs.litellm.ai/docs/providers/anthropic | +| Azure | https://docs.litellm.ai/docs/providers/azure | +| Bedrock | https://docs.litellm.ai/docs/providers/bedrock | +| VertexAI | https://docs.litellm.ai/docs/providers/vertex | +| Mistral | https://docs.litellm.ai/docs/providers/mistral | +| Ollama | https://docs.litellm.ai/docs/providers/ollama | +| Embedding | https://docs.litellm.ai/docs/embedding/supported_embedding | diff --git a/.claude/skills/adapter-ops/scripts/check_adapter_updates.py b/.claude/skills/adapter-ops/scripts/check_adapter_updates.py new file mode 100755 index 0000000000..ec50ed7f76 --- /dev/null +++ b/.claude/skills/adapter-ops/scripts/check_adapter_updates.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +"""Check for updates to existing LLM and Embedding adapters. + +This script analyzes current adapter JSON schemas and compares them +against known LiteLLM features to identify potential updates. + +Usage: + python check_adapter_updates.py + python check_adapter_updates.py --adapter llm + python check_adapter_updates.py --adapter embedding + python check_adapter_updates.py --provider openai +""" + +import argparse +import json +import sys +from pathlib import Path + +# Resolve paths +SCRIPT_DIR = Path(__file__).parent +SKILL_DIR = SCRIPT_DIR.parent +REPO_ROOT = SKILL_DIR.parent.parent.parent +SDK1_ADAPTERS = REPO_ROOT / "unstract" / "sdk1" / "src" / "unstract" / "sdk1" / "adapters" + +# Known LiteLLM features by provider (update this periodically) +LITELLM_FEATURES = { + "llm": { + "openai": { + "known_params": [ + "api_key", + "api_base", + "api_version", + "model", + "max_tokens", + "max_retries", + "timeout", + "temperature", + "top_p", + "n", + "enable_reasoning", + "reasoning_effort", + "seed", + "response_format", + "tools", + "tool_choice", + "parallel_tool_calls", + "logprobs", + ], + "reasoning_models": ["o1-mini", "o1-preview", "o3-mini", "o3", "o4-mini"], + "latest_models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-5"], + "docs_url": "https://docs.litellm.ai/docs/providers/openai", + }, + "anthropic": { + "known_params": [ + "api_key", + "model", + "max_tokens", + "max_retries", + "timeout", + "temperature", + "enable_thinking", + "budget_tokens", + "thinking", + ], + "thinking_models": ["claude-3-7-sonnet", "claude-sonnet-4", "claude-opus-4"], + "latest_models": ["claude-sonnet-4-5-20250929", "claude-opus-4-1-20250805"], + "docs_url": "https://docs.litellm.ai/docs/providers/anthropic", + }, + "azure": { + "known_params": [ + "api_key", + "api_base", + "api_version", + "deployment_name", + "azure_endpoint", + "model", + "max_tokens", + "max_retries", + "timeout", + "temperature", + "enable_reasoning", + "reasoning_effort", + ], + "reasoning_models": ["o1-mini", "o1-preview"], + "docs_url": "https://docs.litellm.ai/docs/providers/azure", + }, + "bedrock": { + "known_params": [ + "aws_access_key_id", + "aws_secret_access_key", + "region_name", + "aws_region_name", + "aws_profile_name", + "model_id", + "model", + "max_tokens", + "max_retries", + "timeout", + "temperature", + "enable_thinking", + "budget_tokens", + "thinking", + "top_k", + ], + "thinking_models": ["anthropic.claude-3-7-sonnet"], + "docs_url": "https://docs.litellm.ai/docs/providers/bedrock", + }, + "vertex_ai": { + "known_params": [ + "json_credentials", + "vertex_credentials", + "project", + "vertex_project", + "model", + "max_tokens", + "max_retries", + "timeout", + "temperature", + "safety_settings", + "enable_thinking", + "budget_tokens", + "thinking", + "reasoning_effort", + "tools", + "googleSearch", + ], + "thinking_models": ["gemini-2.5-flash-preview", "gemini-2.5-pro"], + "docs_url": "https://docs.litellm.ai/docs/providers/vertex", + }, + "mistral": { + "known_params": [ + "api_key", + "model", + "max_tokens", + "max_retries", + "timeout", + "temperature", + "enable_reasoning", + "reasoning_effort", + "tools", + ], + "reasoning_models": ["magistral-medium-2506", "magistral-small-2506"], + "latest_models": ["mistral-large-latest", "mistral-small-latest"], + "docs_url": "https://docs.litellm.ai/docs/providers/mistral", + }, + "ollama": { + "known_params": [ + "base_url", + "api_base", + "model", + "max_tokens", + "temperature", + "context_window", + "request_timeout", + "json_mode", + "response_format", + "tools", + ], + "docs_url": "https://docs.litellm.ai/docs/providers/ollama", + }, + "anyscale": { + "known_params": [ + "api_key", + "api_base", + "model", + "max_tokens", + "max_retries", + "timeout", + "temperature", + "additional_kwargs", + ], + "docs_url": "https://docs.litellm.ai/docs/providers/anyscale", + }, + }, + "embedding": { + "openai": { + "known_params": [ + "api_key", + "api_base", + "model", + "embed_batch_size", + "timeout", + "dimensions", + ], + "latest_models": ["text-embedding-3-small", "text-embedding-3-large"], + "docs_url": "https://docs.litellm.ai/docs/embedding/supported_embedding", + }, + "azure": { + "known_params": [ + "api_key", + "api_base", + "api_version", + "deployment_name", + "azure_endpoint", + "model", + "embed_batch_size", + "timeout", + "dimensions", + ], + "docs_url": "https://docs.litellm.ai/docs/providers/azure", + }, + "bedrock": { + "known_params": [ + "aws_access_key_id", + "aws_secret_access_key", + "region_name", + "aws_region_name", + "model", + "max_retries", + "timeout", + ], + "docs_url": "https://docs.litellm.ai/docs/providers/bedrock_embedding", + }, + "vertexai": { + "known_params": [ + "json_credentials", + "vertex_credentials", + "project", + "vertex_project", + "model", + "embed_batch_size", + "embed_mode", + "dimensions", + "input_type", + ], + "docs_url": "https://docs.litellm.ai/docs/providers/vertex", + }, + "ollama": { + "known_params": [ + "base_url", + "api_base", + "model_name", + "model", + "embed_batch_size", + ], + "docs_url": "https://docs.litellm.ai/docs/providers/ollama", + }, + }, +} + + +def load_json_schema(adapter_type: str, provider: str) -> dict | None: + """Load JSON schema for an adapter.""" + schema_dir = SDK1_ADAPTERS / f"{adapter_type}1" / "static" + + # Try common filename patterns + for filename in [f"{provider}.json", f"{provider.replace('_', '')}.json"]: + schema_path = schema_dir / filename + if schema_path.exists(): + with open(schema_path) as f: + return json.load(f) + + return None + + +def get_schema_properties(schema: dict) -> set[str]: + """Extract property names from a JSON schema.""" + properties = set() + + if "properties" in schema: + properties.update(schema["properties"].keys()) + + # Check allOf conditional properties + if "allOf" in schema: + for item in schema["allOf"]: + if "then" in item and "properties" in item["then"]: + properties.update(item["then"]["properties"].keys()) + + return properties + + +def analyze_adapter(adapter_type: str, provider: str) -> dict: + """Analyze a single adapter for potential updates.""" + result = { + "provider": provider, + "adapter_type": adapter_type, + "status": "ok", + "current_properties": [], + "missing_properties": [], + "suggestions": [], + "docs_url": None, + } + + # Load schema + schema = load_json_schema(adapter_type, provider) + if not schema: + result["status"] = "error" + result["suggestions"].append(f"Schema not found for {provider}") + return result + + # Get current properties + current_props = get_schema_properties(schema) + result["current_properties"] = sorted(current_props) + + # Get known LiteLLM features + features = LITELLM_FEATURES.get(adapter_type, {}).get(provider, {}) + if not features: + result["suggestions"].append(f"No LiteLLM feature data for {provider}") + return result + + result["docs_url"] = features.get("docs_url") + + # Find missing parameters + known_params = set(features.get("known_params", [])) + missing = ( + known_params - current_props - {"adapter_name"} + ) # adapter_name is always present + + # Filter out params that might be named differently + common_aliases = { + "api_base": "base_url", + "base_url": "api_base", + "vertex_credentials": "json_credentials", + "vertex_project": "project", + "aws_region_name": "region_name", + } + + filtered_missing = set() + for param in missing: + alias = common_aliases.get(param) + if alias and alias in current_props: + continue + filtered_missing.add(param) + + result["missing_properties"] = sorted(filtered_missing) + + # Generate suggestions + if filtered_missing: + result["status"] = "needs_update" + result["suggestions"].append( + f"Consider adding: {', '.join(sorted(filtered_missing))}" + ) + + # Check for reasoning/thinking support + if adapter_type == "llm": + reasoning_models = features.get("reasoning_models", []) + thinking_models = features.get("thinking_models", []) + + if ( + reasoning_models + and "enable_reasoning" not in current_props + and "reasoning_effort" not in current_props + ): + result["suggestions"].append( + f"Consider adding reasoning support for models: {', '.join(reasoning_models)}" + ) + + if thinking_models and "enable_thinking" not in current_props: + result["suggestions"].append( + f"Consider adding thinking support for models: {', '.join(thinking_models)}" + ) + + # Check for model updates + if "model" in schema.get("properties", {}): + model_prop = schema["properties"]["model"] + default_model = model_prop.get("default", "") + latest_models = features.get("latest_models", []) + + if latest_models and default_model and default_model not in latest_models: + result["suggestions"].append( + f"Default model '{default_model}' may be outdated. Latest: {', '.join(latest_models[:3])}" + ) + + return result + + +def list_adapters(adapter_type: str) -> list[str]: + """List all adapters of a given type.""" + adapter_dir = SDK1_ADAPTERS / f"{adapter_type}1" + if not adapter_dir.exists(): + return [] + + adapters = [] + for py_file in adapter_dir.glob("*.py"): + if py_file.name.startswith("__"): + continue + adapters.append(py_file.stem) + + return adapters + + +def print_report(results: list[dict]) -> None: + """Print analysis report.""" + print("\n" + "=" * 70) + print("ADAPTER UPDATE CHECK REPORT") + print("=" * 70) + + needs_update = [r for r in results if r["status"] == "needs_update"] + ok = [r for r in results if r["status"] == "ok"] + errors = [r for r in results if r["status"] == "error"] + + if needs_update: + print(f"\n🟡 NEEDS UPDATE ({len(needs_update)}):") + print("-" * 40) + for r in needs_update: + print(f"\n {r['adapter_type'].upper()}: {r['provider']}") + if r["missing_properties"]: + print(f" Missing: {', '.join(r['missing_properties'])}") + for s in r["suggestions"]: + print(f" → {s}") + if r["docs_url"]: + print(f" Docs: {r['docs_url']}") + + if ok: + print(f"\n✅ UP TO DATE ({len(ok)}):") + print("-" * 40) + for r in ok: + print(f" {r['adapter_type'].upper()}: {r['provider']}") + + if errors: + print(f"\n❌ ERRORS ({len(errors)}):") + print("-" * 40) + for r in errors: + print(f" {r['adapter_type'].upper()}: {r['provider']}") + for s in r["suggestions"]: + print(f" → {s}") + + print("\n" + "=" * 70) + print(f"Total: {len(results)} adapters checked") + print(f" Needs update: {len(needs_update)}") + print(f" Up to date: {len(ok)}") + print(f" Errors: {len(errors)}") + print("=" * 70 + "\n") + + +def main(): + parser = argparse.ArgumentParser( + description="Check for updates to LLM and Embedding adapters" + ) + parser.add_argument( + "--adapter", + choices=["llm", "embedding", "all"], + default="all", + help="Type of adapter to check", + ) + parser.add_argument( + "--provider", help="Specific provider to check (e.g., openai, anthropic)" + ) + parser.add_argument("--json", action="store_true", help="Output results as JSON") + + args = parser.parse_args() + + print(f"SDK1 Adapters Path: {SDK1_ADAPTERS}") + + adapter_types = ["llm", "embedding"] if args.adapter == "all" else [args.adapter] + results = [] + + for adapter_type in adapter_types: + if args.provider: + providers = [args.provider] + else: + providers = list_adapters(adapter_type) + + for provider in providers: + result = analyze_adapter(adapter_type, provider) + results.append(result) + + if args.json: + print(json.dumps(results, indent=2)) + else: + print_report(results) + + # Return exit code based on results + needs_update = any(r["status"] == "needs_update" for r in results) + return 1 if needs_update else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/adapter-ops/scripts/init_embedding_adapter.py b/.claude/skills/adapter-ops/scripts/init_embedding_adapter.py new file mode 100755 index 0000000000..0122c39ed7 --- /dev/null +++ b/.claude/skills/adapter-ops/scripts/init_embedding_adapter.py @@ -0,0 +1,643 @@ +#!/usr/bin/env python3 +"""Initialize a new Embedding adapter for unstract/sdk1. + +Usage: + python init_embedding_adapter.py --provider newprovider --name "New Provider" --description "Description" + python init_embedding_adapter.py --provider newprovider --name "New Provider" --description "Description" --logo-url "https://example.com/logo.png" + python init_embedding_adapter.py --provider newprovider --name "New Provider" --description "Description" --auto-logo + +This script creates: + 1. Adapter Python file in embedding1/{provider}.py + 2. JSON schema in embedding1/static/{provider}.json + 3. Optionally adds parameter class stub to base1.py + 4. Optionally downloads and adds provider logo (from URL or auto-detected) +""" + +import argparse +import json +import sys +import uuid +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +# Resolve paths +SCRIPT_DIR = Path(__file__).parent +SKILL_DIR = SCRIPT_DIR.parent +# Find the sdk1 adapters directory relative to the repo root +REPO_ROOT = ( + SKILL_DIR.parent.parent.parent +) # .claude/skills/unstract-adapter-extension -> repo root +SDK1_ADAPTERS = REPO_ROOT / "unstract" / "sdk1" / "src" / "unstract" / "sdk1" / "adapters" +ICONS_DIR = REPO_ROOT / "frontend" / "public" / "icons" / "adapter-icons" + +EMBEDDING_ADAPTER_TEMPLATE = """from typing import Any + +from unstract.sdk1.adapters.base1 import BaseAdapter, {param_class} +from unstract.sdk1.adapters.enums import AdapterTypes + + +class {class_name}({param_class}, BaseAdapter): + @staticmethod + def get_id() -> str: + return "{provider}|{uuid}" + + @staticmethod + def get_metadata() -> dict[str, Any]: + return {{ + "name": "{display_name}", + "version": "1.0.0", + "adapter": {class_name}, + "description": "{description}", + "is_active": True, + }} + + @staticmethod + def get_name() -> str: + return "{display_name}" + + @staticmethod + def get_description() -> str: + return "{description}" + + @staticmethod + def get_provider() -> str: + return "{provider}" + + @staticmethod + def get_icon() -> str: + return "/icons/adapter-icons/{icon_name}.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + return AdapterTypes.EMBEDDING +""" + +EMBEDDING_SCHEMA_TEMPLATE = { + "title": "{display_name} Embedding", + "type": "object", + "required": ["adapter_name", "api_key"], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: {provider}-emb-1", + }, + "model": { + "type": "string", + "title": "Model", + "default": "", + "description": "Provide the name of the embedding model.", + }, + "api_key": { + "type": "string", + "title": "API Key", + "default": "", + "format": "password", + "description": "Your {display_name} API key.", + }, + "api_base": { + "type": "string", + "title": "API Base", + "format": "uri", + "default": "", + "description": "API endpoint URL (if different from default).", + }, + "embed_batch_size": { + "type": "number", + "minimum": 1, + "multipleOf": 1, + "title": "Embed Batch Size", + "default": 10, + "description": "Number of texts to embed in each batch.", + }, + "timeout": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Timeout", + "default": 240, + "description": "Timeout in seconds", + }, + }, +} + +PARAMETER_CLASS_TEMPLATE = ''' +class {param_class}(BaseEmbeddingParameters): + """See https://docs.litellm.ai/docs/providers/{provider}.""" + + api_key: str + api_base: str | None = None + embed_batch_size: int | None = 10 + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata["model"] = {param_class}.validate_model(adapter_metadata) + return {param_class}(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + model = adapter_metadata.get("model", "") + return model + +''' + + +def to_class_name(provider: str) -> str: + """Convert provider name to class name format.""" + special_cases = { + "openai": "OpenAI", + "azure_openai": "AzureOpenAI", + "azure_ai_foundry": "AzureAIFoundry", + "azure_ai": "AzureAI", + "vertexai": "VertexAI", + "aws_bedrock": "AWSBedrock", + "bedrock": "AWSBedrock", + } + if provider.lower() in special_cases: + return special_cases[provider.lower()] + + return "".join( + word.capitalize() for word in provider.replace("_", " ").replace("-", " ").split() + ) + + +def to_icon_name(display_name: str) -> str: + """Convert display name to icon filename (without extension).""" + return display_name.replace(" ", "") + + +def fetch_url(url: str, timeout: int = 10) -> bytes | None: + """Fetch content from URL with error handling.""" + try: + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + request = Request(url, headers=headers) + with urlopen(request, timeout=timeout) as response: + return response.read() + except (URLError, HTTPError, TimeoutError): + return None + + +def search_potential_logo_sources(provider: str, display_name: str) -> list[dict]: + """Search for potential logo sources for the given provider. + + This function only SEARCHES for potential sources and returns them. + It does NOT verify if the logos are correct - that's up to the user. + + Returns: + List of dicts with 'url' and 'source' keys for potential logos found + """ + found_sources = [] + provider_lower = provider.lower().replace("_", "").replace("-", "") + name_lower = display_name.lower().replace(" ", "") + + # Try Clearbit Logo API with common domain patterns + domains = [ + (f"{provider_lower}.com", "company domain"), + (f"{provider_lower}.ai", "AI domain"), + (f"{name_lower}.com", "name domain"), + (f"{name_lower}.ai", "name AI domain"), + ] + + for domain, source_type in domains: + url = f"https://logo.clearbit.com/{domain}" + try: + headers = {"User-Agent": "Mozilla/5.0"} + request = Request(url, headers=headers, method="HEAD") + with urlopen(request, timeout=5) as response: + if response.status == 200: + found_sources.append( + {"url": url, "source": f"Clearbit ({source_type}: {domain})"} + ) + except (URLError, HTTPError, TimeoutError): + continue + + # Try GitHub organization avatars + github_names = [ + provider_lower, + name_lower, + provider.lower().replace("_", "-"), + ] + + for name in github_names: + url = f"https://github.com/{name}.png?size=512" + try: + headers = {"User-Agent": "Mozilla/5.0"} + request = Request(url, headers=headers, method="HEAD") + with urlopen(request, timeout=5) as response: + if response.status == 200: + content_type = response.headers.get("Content-Type", "") + if "image" in content_type: + found_sources.append( + {"url": url, "source": f"GitHub avatar (@{name})"} + ) + except (URLError, HTTPError, TimeoutError): + continue + + return found_sources + + +def download_and_process_logo( + url: str, output_path: Path, target_size: int = 512 +) -> bool: + """Download logo from URL and process to standard format. + + Args: + url: URL to download the logo from + output_path: Path to save the logo + target_size: Target size for square logo (default 512x512) + + Returns: + True if successful, False otherwise + + Image settings: 4800 DPI density, 8-bit depth, 512x512 pixels + """ + image_data = fetch_url(url, timeout=30) + if not image_data: + return False + + # Check if SVG (by URL extension or content) + is_svg = ( + url.lower().endswith(".svg") + or image_data[:5] == b" bool: + """Copy and optionally resize a local logo file. + + Image settings: 4800 DPI density, 8-bit depth, 512x512 pixels + """ + if not source_path.exists(): + return False + + # Check if SVG + is_svg = source_path.suffix.lower() == ".svg" + + if is_svg: + # Use ImageMagick for SVG conversion with optimal settings + import subprocess + + try: + result = subprocess.run( + [ + "magick", + "-density", + "4800", + "-background", + "none", + str(source_path), + "-resize", + f"{target_size}x{target_size}", + "-depth", + "8", + str(output_path), + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f" ImageMagick error: {result.stderr}") + return False + return True + except FileNotFoundError: + print( + " Note: ImageMagick not found. Install with: sudo pacman -S imagemagick" + ) + return False + + # Handle raster images with PIL + try: + from PIL import Image + + img = Image.open(source_path) + if img.mode != "RGBA": + img = img.convert("RGBA") + + if img.width != target_size or img.height != target_size: + ratio = min(target_size / img.width, target_size / img.height) + new_size = (int(img.width * ratio), int(img.height * ratio)) + img = img.resize(new_size, Image.Resampling.LANCZOS) + + canvas = Image.new("RGBA", (target_size, target_size), (255, 255, 255, 0)) + offset = ((target_size - img.width) // 2, (target_size - img.height) // 2) + canvas.paste(img, offset, img if img.mode == "RGBA" else None) + img = canvas + + img.save(output_path, "PNG") + return True + + except ImportError: + import shutil + + shutil.copy2(source_path, output_path) + return True + except Exception: + return False + + +def create_embedding_adapter( + provider: str, + display_name: str, + description: str, + add_param_class: bool = False, + use_existing_param_class: str | None = None, + logo_url: str | None = None, + logo_file: str | None = None, + auto_logo: bool = False, +) -> dict: + """Create a new Embedding adapter. + + Returns: + dict with 'files_created' list and 'param_class_stub' if applicable + """ + result = {"files_created": [], "param_class_stub": None, "errors": [], "warnings": []} + + if not SDK1_ADAPTERS.exists(): + result["errors"].append(f"SDK1 adapters directory not found at: {SDK1_ADAPTERS}") + return result + + embedding_dir = SDK1_ADAPTERS / "embedding1" + static_dir = embedding_dir / "static" + + if not embedding_dir.exists(): + result["errors"].append( + f"Embedding adapters directory not found at: {embedding_dir}" + ) + return result + + static_dir.mkdir(exist_ok=True) + + class_base = to_class_name(provider) + class_name = f"{class_base}EmbeddingAdapter" + icon_name = to_icon_name(display_name) + adapter_uuid = str(uuid.uuid4()) + + if use_existing_param_class: + param_class = use_existing_param_class + elif add_param_class: + param_class = f"{class_base}EmbeddingParameters" + else: + param_class = "OpenAIEmbeddingParameters" + + adapter_content = EMBEDDING_ADAPTER_TEMPLATE.format( + class_name=class_name, + param_class=param_class, + provider=provider.lower(), + uuid=adapter_uuid, + display_name=display_name, + description=description, + icon_name=icon_name, + ) + + adapter_file = embedding_dir / f"{provider.lower()}.py" + if adapter_file.exists(): + result["errors"].append(f"Adapter file already exists: {adapter_file}") + else: + adapter_file.write_text(adapter_content) + result["files_created"].append(str(adapter_file)) + + schema = json.loads(json.dumps(EMBEDDING_SCHEMA_TEMPLATE)) + schema["title"] = f"{display_name} Embedding" + schema["properties"]["adapter_name"]["description"] = ( + f"Provide a unique name for this adapter instance. Example: {provider.lower()}-emb-1" + ) + schema["properties"]["api_key"]["description"] = f"Your {display_name} API key." + + schema_file = static_dir / f"{provider.lower()}.json" + if schema_file.exists(): + result["errors"].append(f"Schema file already exists: {schema_file}") + else: + schema_file.write_text(json.dumps(schema, indent=2) + "\n") + result["files_created"].append(str(schema_file)) + + # Handle logo + logo_path = ICONS_DIR / f"{icon_name}.png" + ICONS_DIR.mkdir(parents=True, exist_ok=True) + + if logo_path.exists(): + result["warnings"].append(f"Logo already exists: {logo_path}") + elif logo_url: + # User provided explicit URL - download it + if download_and_process_logo(logo_url, logo_path): + result["files_created"].append(str(logo_path)) + else: + result["warnings"].append(f"Failed to download logo from: {logo_url}") + elif logo_file: + # User provided local file - copy it + if copy_logo(Path(logo_file), logo_path): + result["files_created"].append(str(logo_path)) + else: + result["warnings"].append(f"Failed to copy logo from: {logo_file}") + elif auto_logo: + # Search for potential sources but DO NOT auto-download + # Just inform the user about what was found + print(f" Searching for potential logo sources for '{display_name}'...") + potential_sources = search_potential_logo_sources(provider, display_name) + if potential_sources: + result["logo_suggestions"] = potential_sources + else: + result["warnings"].append( + f"Could not find logo for '{display_name}'. " + f"Please add manually to: {logo_path}" + ) + + if add_param_class: + result["param_class_stub"] = PARAMETER_CLASS_TEMPLATE.format( + param_class=param_class, + provider=provider.lower(), + ) + + return result + + +def main(): + parser = argparse.ArgumentParser( + description="Initialize a new Embedding adapter for unstract/sdk1" + ) + parser.add_argument( + "--provider", + required=True, + help="Provider identifier (lowercase, e.g., 'newprovider')", + ) + parser.add_argument( + "--name", + required=True, + help="Display name for the provider (e.g., 'New Provider')", + ) + parser.add_argument("--description", required=True, help="Description of the adapter") + parser.add_argument( + "--add-param-class", + action="store_true", + help="Generate a parameter class stub for base1.py", + ) + parser.add_argument( + "--use-param-class", + help="Use an existing parameter class (e.g., 'OpenAIEmbeddingParameters')", + ) + parser.add_argument("--logo-url", help="URL to download the provider logo from") + parser.add_argument("--logo-file", help="Path to a local logo file to copy") + parser.add_argument( + "--auto-logo", + action="store_true", + help="Automatically search for and download provider logo", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be created without actually creating files", + ) + + args = parser.parse_args() + + icon_name = to_icon_name(args.name) + + print(f"Creating Embedding adapter for: {args.name}") + print(f"Provider: {args.provider}") + print(f"SDK1 Path: {SDK1_ADAPTERS}") + print() + + if args.dry_run: + print("[DRY RUN] Would create:") + print(f" - {SDK1_ADAPTERS}/embedding1/{args.provider.lower()}.py") + print(f" - {SDK1_ADAPTERS}/embedding1/static/{args.provider.lower()}.json") + if args.logo_url or args.logo_file or args.auto_logo: + print(f" - {ICONS_DIR}/{icon_name}.png (if logo found)") + if args.add_param_class: + print(" - Parameter class stub for base1.py") + return 0 + + result = create_embedding_adapter( + provider=args.provider, + display_name=args.name, + description=args.description, + add_param_class=args.add_param_class, + use_existing_param_class=args.use_param_class, + logo_url=args.logo_url, + logo_file=args.logo_file, + auto_logo=args.auto_logo, + ) + + if result["errors"]: + print("Errors:") + for error in result["errors"]: + print(f" - {error}") + return 1 + + if result["warnings"]: + print("Warnings:") + for warning in result["warnings"]: + print(f" - {warning}") + + print("Files created:") + for file in result["files_created"]: + print(f" - {file}") + + # Show logo suggestions if any were found + if result.get("logo_suggestions"): + print("\nPotential logo sources found (please verify before using):") + for i, suggestion in enumerate(result["logo_suggestions"], 1): + print(f" {i}. {suggestion['source']}") + print(f" URL: {suggestion['url']}") + print("\nTo use a logo, re-run with: --logo-url ") + print(f"Logo will be saved to: {ICONS_DIR}/{icon_name}.png") + + if result["param_class_stub"]: + print("\nParameter class stub (add to base1.py):") + print("-" * 60) + print(result["param_class_stub"]) + print("-" * 60) + + print("\nNext steps:") + print("1. Customize the JSON schema in static/{provider}.json") + print("2. If needed, add parameter class to base1.py") + print("3. Update the adapter to use the correct parameter class") + if not any("png" in f for f in result["files_created"]) and not result.get( + "logo_suggestions" + ): + print(f"4. Add logo manually to: {ICONS_DIR}/{icon_name}.png") + print("5. Test with: from unstract.sdk1.adapters.adapterkit import Adapterkit") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/adapter-ops/scripts/init_llm_adapter.py b/.claude/skills/adapter-ops/scripts/init_llm_adapter.py new file mode 100755 index 0000000000..202bb9a288 --- /dev/null +++ b/.claude/skills/adapter-ops/scripts/init_llm_adapter.py @@ -0,0 +1,653 @@ +#!/usr/bin/env python3 +"""Initialize a new LLM adapter for unstract/sdk1. + +Usage: + python init_llm_adapter.py --provider newprovider --name "New Provider" --description "Description" + python init_llm_adapter.py --provider newprovider --name "New Provider" --description "Description" --logo-url "https://example.com/logo.png" + python init_llm_adapter.py --provider newprovider --name "New Provider" --description "Description" --auto-logo + +This script creates: + 1. Adapter Python file in llm1/{provider}.py + 2. JSON schema in llm1/static/{provider}.json + 3. Optionally adds parameter class stub to base1.py + 4. Optionally downloads and adds provider logo (from URL or auto-detected) +""" + +import argparse +import json +import sys +import uuid +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +# Resolve paths +SCRIPT_DIR = Path(__file__).parent +SKILL_DIR = SCRIPT_DIR.parent +# Find the sdk1 adapters directory relative to the repo root +REPO_ROOT = ( + SKILL_DIR.parent.parent.parent +) # .claude/skills/unstract-adapter-extension -> repo root +SDK1_ADAPTERS = REPO_ROOT / "unstract" / "sdk1" / "src" / "unstract" / "sdk1" / "adapters" +ICONS_DIR = REPO_ROOT / "frontend" / "public" / "icons" / "adapter-icons" + +LLM_ADAPTER_TEMPLATE = """from typing import Any + +from unstract.sdk1.adapters.base1 import BaseAdapter, {param_class} +from unstract.sdk1.adapters.enums import AdapterTypes + + +class {class_name}({param_class}, BaseAdapter): + @staticmethod + def get_id() -> str: + return "{provider}|{uuid}" + + @staticmethod + def get_metadata() -> dict[str, Any]: + return {{ + "name": "{display_name}", + "version": "1.0.0", + "adapter": {class_name}, + "description": "{description}", + "is_active": True, + }} + + @staticmethod + def get_name() -> str: + return "{display_name}" + + @staticmethod + def get_description() -> str: + return "{description}" + + @staticmethod + def get_provider() -> str: + return "{provider}" + + @staticmethod + def get_icon() -> str: + return "/icons/adapter-icons/{icon_name}.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + return AdapterTypes.LLM +""" + +LLM_SCHEMA_TEMPLATE = { + "title": "{display_name} LLM", + "type": "object", + "required": ["adapter_name", "api_key"], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: {provider}-llm-1", + }, + "api_key": { + "type": "string", + "title": "API Key", + "format": "password", + "description": "Your {display_name} API key.", + }, + "model": { + "type": "string", + "title": "Model", + "default": "", + "description": "The model to use for the API request.", + }, + "max_tokens": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Maximum Output Tokens", + "description": "Maximum number of output tokens to limit LLM replies.", + }, + "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", + }, + }, +} + +PARAMETER_CLASS_TEMPLATE = ''' +class {param_class}(BaseChatCompletionParameters): + """See https://docs.litellm.ai/docs/providers/{provider}.""" + + api_key: str + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata["model"] = {param_class}.validate_model(adapter_metadata) + return {param_class}(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + model = adapter_metadata.get("model", "") + # Only add {provider}/ prefix if the model doesn't already have it + if model.startswith("{provider}/"): + return model + else: + return f"{provider}/{{model}}" + +''' + + +def to_class_name(provider: str) -> str: + """Convert provider name to class name format.""" + # Handle special cases + special_cases = { + "openai": "OpenAI", + "azure_openai": "AzureOpenAI", + "azure_ai_foundry": "AzureAIFoundry", + "azure_ai": "AzureAI", + "vertexai": "VertexAI", + "aws_bedrock": "AWSBedrock", + "bedrock": "AWSBedrock", + } + if provider.lower() in special_cases: + return special_cases[provider.lower()] + + # Default: capitalize each word + return "".join( + word.capitalize() for word in provider.replace("_", " ").replace("-", " ").split() + ) + + +def to_icon_name(display_name: str) -> str: + """Convert display name to icon filename (without extension). + + Removes spaces and special characters for cleaner filenames. + """ + return display_name.replace(" ", "") + + +def fetch_url(url: str, timeout: int = 10) -> bytes | None: + """Fetch content from URL with error handling.""" + try: + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + request = Request(url, headers=headers) + with urlopen(request, timeout=timeout) as response: + return response.read() + except (URLError, HTTPError, TimeoutError): + return None + + +def search_potential_logo_sources(provider: str, display_name: str) -> list[dict]: + """Search for potential logo sources for the given provider. + + This function only SEARCHES for potential sources and returns them. + It does NOT verify if the logos are correct - that's up to the user. + + Returns: + List of dicts with 'url' and 'source' keys for potential logos found + """ + found_sources = [] + provider_lower = provider.lower().replace("_", "").replace("-", "") + name_lower = display_name.lower().replace(" ", "") + + # Try Clearbit Logo API with common domain patterns + domains = [ + (f"{provider_lower}.com", "company domain"), + (f"{provider_lower}.ai", "AI domain"), + (f"{name_lower}.com", "name domain"), + (f"{name_lower}.ai", "name AI domain"), + ] + + for domain, source_type in domains: + url = f"https://logo.clearbit.com/{domain}" + try: + headers = {"User-Agent": "Mozilla/5.0"} + request = Request(url, headers=headers, method="HEAD") + with urlopen(request, timeout=5) as response: + if response.status == 200: + found_sources.append( + {"url": url, "source": f"Clearbit ({source_type}: {domain})"} + ) + except (URLError, HTTPError, TimeoutError): + continue + + # Try GitHub organization avatars + github_names = [ + provider_lower, + name_lower, + provider.lower().replace("_", "-"), + ] + + for name in github_names: + url = f"https://github.com/{name}.png?size=512" + try: + headers = {"User-Agent": "Mozilla/5.0"} + request = Request(url, headers=headers, method="HEAD") + with urlopen(request, timeout=5) as response: + if response.status == 200: + content_type = response.headers.get("Content-Type", "") + if "image" in content_type: + found_sources.append( + {"url": url, "source": f"GitHub avatar (@{name})"} + ) + except (URLError, HTTPError, TimeoutError): + continue + + return found_sources + + +def download_and_process_logo( + url: str, output_path: Path, target_size: int = 512 +) -> bool: + """Download logo from URL and process to standard format. + + Args: + url: URL to download the logo from + output_path: Path to save the logo + target_size: Target size for square logo (default 512x512) + + Returns: + True if successful, False otherwise + + Image settings: 4800 DPI density, 8-bit depth, 512x512 pixels + """ + image_data = fetch_url(url, timeout=30) + if not image_data: + return False + + # Check if SVG (by URL extension or content) + is_svg = ( + url.lower().endswith(".svg") + or image_data[:5] == b" bool: + """Copy and optionally resize a local logo file. + + Image settings: 4800 DPI density, 8-bit depth, 512x512 pixels + """ + if not source_path.exists(): + return False + + # Check if SVG + is_svg = source_path.suffix.lower() == ".svg" + + if is_svg: + # Use ImageMagick for SVG conversion with optimal settings + import subprocess + + try: + result = subprocess.run( + [ + "magick", + "-density", + "4800", + "-background", + "none", + str(source_path), + "-resize", + f"{target_size}x{target_size}", + "-depth", + "8", + str(output_path), + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f" ImageMagick error: {result.stderr}") + return False + return True + except FileNotFoundError: + print( + " Note: ImageMagick not found. Install with: sudo pacman -S imagemagick" + ) + return False + + # Handle raster images with PIL + try: + from PIL import Image + + img = Image.open(source_path) + if img.mode != "RGBA": + img = img.convert("RGBA") + + if img.width != target_size or img.height != target_size: + ratio = min(target_size / img.width, target_size / img.height) + new_size = (int(img.width * ratio), int(img.height * ratio)) + img = img.resize(new_size, Image.Resampling.LANCZOS) + + canvas = Image.new("RGBA", (target_size, target_size), (255, 255, 255, 0)) + offset = ((target_size - img.width) // 2, (target_size - img.height) // 2) + canvas.paste(img, offset, img if img.mode == "RGBA" else None) + img = canvas + + img.save(output_path, "PNG") + return True + + except ImportError: + import shutil + + shutil.copy2(source_path, output_path) + return True + except Exception: + return False + + +def create_llm_adapter( + provider: str, + display_name: str, + description: str, + add_param_class: bool = False, + use_existing_param_class: str | None = None, + logo_url: str | None = None, + logo_file: str | None = None, + auto_logo: bool = False, +) -> dict: + """Create a new LLM adapter. + + Returns: + dict with 'files_created' list and 'param_class_stub' if applicable + """ + result = {"files_created": [], "param_class_stub": None, "errors": [], "warnings": []} + + # Validate SDK1 path exists + if not SDK1_ADAPTERS.exists(): + result["errors"].append(f"SDK1 adapters directory not found at: {SDK1_ADAPTERS}") + return result + + llm_dir = SDK1_ADAPTERS / "llm1" + static_dir = llm_dir / "static" + + if not llm_dir.exists(): + result["errors"].append(f"LLM adapters directory not found at: {llm_dir}") + return result + + static_dir.mkdir(exist_ok=True) + + # Generate identifiers + class_base = to_class_name(provider) + class_name = f"{class_base}LLMAdapter" + icon_name = to_icon_name(display_name) + adapter_uuid = str(uuid.uuid4()) + + # Determine parameter class + if use_existing_param_class: + param_class = use_existing_param_class + elif add_param_class: + param_class = f"{class_base}LLMParameters" + else: + param_class = "AnyscaleLLMParameters" + + # Create adapter file + adapter_content = LLM_ADAPTER_TEMPLATE.format( + class_name=class_name, + param_class=param_class, + provider=provider.lower(), + uuid=adapter_uuid, + display_name=display_name, + description=description, + icon_name=icon_name, + ) + + adapter_file = llm_dir / f"{provider.lower()}.py" + if adapter_file.exists(): + result["errors"].append(f"Adapter file already exists: {adapter_file}") + else: + adapter_file.write_text(adapter_content) + result["files_created"].append(str(adapter_file)) + + # Create JSON schema + schema = json.loads(json.dumps(LLM_SCHEMA_TEMPLATE)) + schema["title"] = f"{display_name} LLM" + schema["properties"]["adapter_name"]["description"] = ( + f"Provide a unique name for this adapter instance. Example: {provider.lower()}-llm-1" + ) + schema["properties"]["api_key"]["description"] = f"Your {display_name} API key." + + schema_file = static_dir / f"{provider.lower()}.json" + if schema_file.exists(): + result["errors"].append(f"Schema file already exists: {schema_file}") + else: + schema_file.write_text(json.dumps(schema, indent=2) + "\n") + result["files_created"].append(str(schema_file)) + + # Handle logo + logo_path = ICONS_DIR / f"{icon_name}.png" + ICONS_DIR.mkdir(parents=True, exist_ok=True) + + if logo_path.exists(): + result["warnings"].append(f"Logo already exists: {logo_path}") + elif logo_url: + # User provided explicit URL - download it + if download_and_process_logo(logo_url, logo_path): + result["files_created"].append(str(logo_path)) + else: + result["warnings"].append(f"Failed to download logo from: {logo_url}") + elif logo_file: + # User provided local file - copy it + if copy_logo(Path(logo_file), logo_path): + result["files_created"].append(str(logo_path)) + else: + result["warnings"].append(f"Failed to copy logo from: {logo_file}") + elif auto_logo: + # Search for potential sources but DO NOT auto-download + # Just inform the user about what was found + print(f" Searching for potential logo sources for '{display_name}'...") + potential_sources = search_potential_logo_sources(provider, display_name) + if potential_sources: + result["logo_suggestions"] = potential_sources + else: + result["warnings"].append( + f"Could not find logo for '{display_name}'. " + f"Please add manually to: {logo_path}" + ) + + # Generate parameter class stub if requested + if add_param_class: + result["param_class_stub"] = PARAMETER_CLASS_TEMPLATE.format( + param_class=param_class, + provider=provider.lower(), + ) + + return result + + +def main(): + parser = argparse.ArgumentParser( + description="Initialize a new LLM adapter for unstract/sdk1" + ) + parser.add_argument( + "--provider", + required=True, + help="Provider identifier (lowercase, e.g., 'newprovider')", + ) + parser.add_argument( + "--name", + required=True, + help="Display name for the provider (e.g., 'New Provider')", + ) + parser.add_argument("--description", required=True, help="Description of the adapter") + parser.add_argument( + "--add-param-class", + action="store_true", + help="Generate a parameter class stub for base1.py", + ) + parser.add_argument( + "--use-param-class", + help="Use an existing parameter class (e.g., 'OpenAILLMParameters')", + ) + parser.add_argument("--logo-url", help="URL to download the provider logo from") + parser.add_argument("--logo-file", help="Path to a local logo file to copy") + parser.add_argument( + "--auto-logo", + action="store_true", + help="Automatically search for and download provider logo", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be created without actually creating files", + ) + + args = parser.parse_args() + + icon_name = to_icon_name(args.name) + + print(f"Creating LLM adapter for: {args.name}") + print(f"Provider: {args.provider}") + print(f"SDK1 Path: {SDK1_ADAPTERS}") + print() + + if args.dry_run: + print("[DRY RUN] Would create:") + print(f" - {SDK1_ADAPTERS}/llm1/{args.provider.lower()}.py") + print(f" - {SDK1_ADAPTERS}/llm1/static/{args.provider.lower()}.json") + if args.logo_url or args.logo_file or args.auto_logo: + print(f" - {ICONS_DIR}/{icon_name}.png (if logo found)") + if args.add_param_class: + print(" - Parameter class stub for base1.py") + return 0 + + result = create_llm_adapter( + provider=args.provider, + display_name=args.name, + description=args.description, + add_param_class=args.add_param_class, + use_existing_param_class=args.use_param_class, + logo_url=args.logo_url, + logo_file=args.logo_file, + auto_logo=args.auto_logo, + ) + + if result["errors"]: + print("Errors:") + for error in result["errors"]: + print(f" - {error}") + return 1 + + if result["warnings"]: + print("Warnings:") + for warning in result["warnings"]: + print(f" - {warning}") + + print("Files created:") + for file in result["files_created"]: + print(f" - {file}") + + # Show logo suggestions if any were found + if result.get("logo_suggestions"): + print("\nPotential logo sources found (please verify before using):") + for i, suggestion in enumerate(result["logo_suggestions"], 1): + print(f" {i}. {suggestion['source']}") + print(f" URL: {suggestion['url']}") + print("\nTo use a logo, re-run with: --logo-url ") + print(f"Logo will be saved to: {ICONS_DIR}/{icon_name}.png") + + if result["param_class_stub"]: + print("\nParameter class stub (add to base1.py):") + print("-" * 60) + print(result["param_class_stub"]) + print("-" * 60) + + print("\nNext steps:") + print("1. Customize the JSON schema in static/{provider}.json") + print("2. If needed, add parameter class to base1.py") + print("3. Update the adapter to use the correct parameter class") + if not any("png" in f for f in result["files_created"]) and not result.get( + "logo_suggestions" + ): + print(f"4. Add logo manually to: {ICONS_DIR}/{icon_name}.png") + print("5. Test with: from unstract.sdk1.adapters.adapterkit import Adapterkit") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/adapter-ops/scripts/manage_models.py b/.claude/skills/adapter-ops/scripts/manage_models.py new file mode 100755 index 0000000000..933954b25b --- /dev/null +++ b/.claude/skills/adapter-ops/scripts/manage_models.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Manage models in existing adapter JSON schemas. + +Usage: + # Add models to dropdown enum + python manage_models.py --adapter llm --provider openai --action add-enum --models "gpt-4-turbo,gpt-4o" + + # Remove models from dropdown enum + python manage_models.py --adapter llm --provider openai --action remove-enum --models "gpt-3.5-turbo" + + # Set default model + python manage_models.py --adapter llm --provider openai --action set-default --models "gpt-4o" + + # Update model description + python manage_models.py --adapter llm --provider openai --action update-description \ + --description "Available models: gpt-4o, gpt-4-turbo, gpt-3.5-turbo" + + # List current models + python manage_models.py --adapter llm --provider openai --action list +""" + +import argparse +import json +import sys +from pathlib import Path + +# Resolve paths +SCRIPT_DIR = Path(__file__).parent +SKILL_DIR = SCRIPT_DIR.parent +REPO_ROOT = SKILL_DIR.parent.parent.parent +SDK1_ADAPTERS = REPO_ROOT / "unstract" / "sdk1" / "src" / "unstract" / "sdk1" / "adapters" + + +def get_schema_path(adapter_type: str, provider: str) -> Path: + """Get the JSON schema path for an adapter.""" + adapter_dir = "llm1" if adapter_type == "llm" else "embedding1" + return SDK1_ADAPTERS / adapter_dir / "static" / f"{provider.lower()}.json" + + +def load_schema(schema_path: Path) -> dict: + """Load and parse a JSON schema file.""" + with open(schema_path) as f: + return json.load(f) + + +def save_schema(schema_path: Path, schema: dict) -> None: + """Save a JSON schema file with proper formatting.""" + with open(schema_path, "w") as f: + json.dump(schema, f, indent=2) + f.write("\n") + + +def list_models(schema: dict) -> dict: + """Extract model information from schema.""" + model_prop = schema.get("properties", {}).get("model", {}) + return { + "type": model_prop.get("type", "unknown"), + "default": model_prop.get("default"), + "enum": model_prop.get("enum"), + "description": model_prop.get("description"), + } + + +def add_enum_models(schema: dict, models: list[str]) -> dict: + """Add models to the enum list (creates enum if doesn't exist).""" + if "properties" not in schema: + schema["properties"] = {} + if "model" not in schema["properties"]: + schema["properties"]["model"] = {"type": "string", "title": "Model"} + + model_prop = schema["properties"]["model"] + + # Get existing enum or create new one + existing_enum = model_prop.get("enum", []) + if not isinstance(existing_enum, list): + existing_enum = [] + + # Add new models (avoiding duplicates) + for model in models: + if model not in existing_enum: + existing_enum.append(model) + + model_prop["enum"] = existing_enum + + # Set default if not set + if "default" not in model_prop and existing_enum: + model_prop["default"] = existing_enum[0] + + return schema + + +def remove_enum_models(schema: dict, models: list[str]) -> dict: + """Remove models from the enum list.""" + model_prop = schema.get("properties", {}).get("model", {}) + existing_enum = model_prop.get("enum", []) + + if not existing_enum: + return schema + + # Remove specified models + updated_enum = [m for m in existing_enum if m not in models] + model_prop["enum"] = updated_enum + + # Update default if it was removed + if model_prop.get("default") in models and updated_enum: + model_prop["default"] = updated_enum[0] + elif not updated_enum: + # Remove enum entirely if no models left + if "enum" in model_prop: + del model_prop["enum"] + + return schema + + +def set_default_model(schema: dict, model: str) -> dict: + """Set the default model.""" + if "properties" not in schema: + schema["properties"] = {} + if "model" not in schema["properties"]: + schema["properties"]["model"] = {"type": "string", "title": "Model"} + + schema["properties"]["model"]["default"] = model + return schema + + +def update_description(schema: dict, description: str) -> dict: + """Update the model field description.""" + if "properties" not in schema: + schema["properties"] = {} + if "model" not in schema["properties"]: + schema["properties"]["model"] = {"type": "string", "title": "Model"} + + schema["properties"]["model"]["description"] = description + return schema + + +def convert_to_enum(schema: dict) -> dict: + """Convert free-text model field to enum dropdown.""" + model_prop = schema.get("properties", {}).get("model", {}) + + if "enum" in model_prop: + print("Model field already has enum defined") + return schema + + # Get current default or prompt for models + current_default = model_prop.get("default", "") + + print(f"Current default: {current_default}") + print("To convert to enum, use --action add-enum with --models") + + return schema + + +def convert_to_freetext(schema: dict) -> dict: + """Convert enum dropdown to free-text model field.""" + model_prop = schema.get("properties", {}).get("model", {}) + + if "enum" in model_prop: + # Preserve default if it exists + default = model_prop.get( + "default", model_prop["enum"][0] if model_prop["enum"] else "" + ) + del model_prop["enum"] + model_prop["default"] = default + + return schema + + +def main(): + parser = argparse.ArgumentParser(description="Manage models in adapter JSON schemas") + parser.add_argument( + "--adapter", + required=True, + choices=["llm", "embedding"], + help="Adapter type (llm or embedding)", + ) + parser.add_argument( + "--provider", required=True, help="Provider name (e.g., 'openai', 'anthropic')" + ) + parser.add_argument( + "--action", + required=True, + choices=[ + "list", + "add-enum", + "remove-enum", + "set-default", + "update-description", + "to-enum", + "to-freetext", + ], + help="Action to perform", + ) + parser.add_argument( + "--models", help="Comma-separated list of models (for add/remove/set-default)" + ) + parser.add_argument("--description", help="New description for model field") + parser.add_argument( + "--dry-run", action="store_true", help="Show changes without applying them" + ) + + args = parser.parse_args() + + # Get schema path + schema_path = get_schema_path(args.adapter, args.provider) + + if not schema_path.exists(): + print(f"Error: Schema file not found: {schema_path}") + return 1 + + # Load schema + schema = load_schema(schema_path) + original_schema = json.dumps(schema, indent=2) + + # Perform action + if args.action == "list": + info = list_models(schema) + print(f"Model configuration for {args.provider} {args.adapter}:") + print(f" Type: {info['type']}") + print(f" Default: {info['default']}") + if info["enum"]: + print(f" Enum values: {', '.join(info['enum'])}") + else: + print(" Enum: (free text)") + if info["description"]: + print(f" Description: {info['description']}") + return 0 + + elif args.action == "add-enum": + if not args.models: + print("Error: --models required for add-enum action") + return 1 + models = [m.strip() for m in args.models.split(",")] + schema = add_enum_models(schema, models) + print(f"Added models: {', '.join(models)}") + + elif args.action == "remove-enum": + if not args.models: + print("Error: --models required for remove-enum action") + return 1 + models = [m.strip() for m in args.models.split(",")] + schema = remove_enum_models(schema, models) + print(f"Removed models: {', '.join(models)}") + + elif args.action == "set-default": + if not args.models: + print("Error: --models required for set-default action (single model)") + return 1 + model = args.models.split(",")[0].strip() + schema = set_default_model(schema, model) + print(f"Set default model: {model}") + + elif args.action == "update-description": + if not args.description: + print("Error: --description required for update-description action") + return 1 + schema = update_description(schema, args.description) + print("Updated description") + + elif args.action == "to-enum": + schema = convert_to_enum(schema) + + elif args.action == "to-freetext": + schema = convert_to_freetext(schema) + print("Converted to free-text field") + + # Show diff if changes were made + new_schema = json.dumps(schema, indent=2) + if original_schema != new_schema: + if args.dry_run: + print("\n[DRY RUN] Would update schema:") + print("-" * 40) + print(new_schema) + print("-" * 40) + else: + save_schema(schema_path, schema) + print(f"\nUpdated: {schema_path}") + else: + print("\nNo changes made") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitignore b/.gitignore index 80c568a0f4..7addcab6a8 100644 --- a/.gitignore +++ b/.gitignore @@ -686,8 +686,6 @@ prompting/ .prompting/ # Claude -CLAUDE.md -.claude/* CONTRIBUTION_GUIDE.md .mcp.json diff --git a/frontend/public/icons/adapter-icons/AzureAIFoundry.png b/frontend/public/icons/adapter-icons/AzureAIFoundry.png new file mode 100644 index 0000000000..8dd3d11e2d Binary files /dev/null and b/frontend/public/icons/adapter-icons/AzureAIFoundry.png differ diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index db112df69c..28eaff08cd 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -441,9 +441,11 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: class AWSBedrockLLMParameters(BaseChatCompletionParameters): """See https://docs.litellm.ai/docs/providers/bedrock.""" - aws_access_key_id: str | None - aws_secret_access_key: str | None - aws_region_name: str | None + aws_access_key_id: str | None = None + aws_secret_access_key: str | None = None + aws_region_name: str | None = None + aws_profile_name: str | None = None # For AWS SSO authentication + model_id: str | None = None # For Application Inference Profile (cost tracking) max_retries: int | None = None @staticmethod @@ -601,12 +603,51 @@ class MistralLLMParameters(BaseChatCompletionParameters): """See https://docs.litellm.ai/docs/providers/mistral.""" api_key: str + reasoning_effort: str | None = None # For Magistral models: low, medium, high @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: adapter_metadata["model"] = MistralLLMParameters.validate_model(adapter_metadata) - return MistralLLMParameters(**adapter_metadata).model_dump() + # Handle Mistral reasoning configuration (for Magistral models) + enable_reasoning = adapter_metadata.get("enable_reasoning", False) + + # If enable_reasoning is not explicitly provided but reasoning_effort is present, + # assume reasoning was enabled in a previous validation + has_reasoning_effort = ( + "reasoning_effort" in adapter_metadata + and adapter_metadata.get("reasoning_effort") is not None + ) + if not enable_reasoning and has_reasoning_effort: + enable_reasoning = True + + # Create a copy to avoid mutating the original metadata + result_metadata = adapter_metadata.copy() + + if enable_reasoning: + reasoning_effort = adapter_metadata.get("reasoning_effort", "medium") + result_metadata["reasoning_effort"] = reasoning_effort + + # Create validation metadata excluding control fields + exclude_fields = {"enable_reasoning"} + if not enable_reasoning: + exclude_fields.add("reasoning_effort") + + validation_metadata = { + k: v for k, v in result_metadata.items() if k not in exclude_fields + } + + validated = MistralLLMParameters(**validation_metadata).model_dump() + + # Clean up result based on reasoning state + if not enable_reasoning and "reasoning_effort" in validated: + validated.pop("reasoning_effort") + elif enable_reasoning: + validated["reasoning_effort"] = result_metadata.get( + "reasoning_effort", "medium" + ) + + return validated @staticmethod def validate_model(adapter_metadata: dict[str, "Any"]) -> str: @@ -622,13 +663,20 @@ class OllamaLLMParameters(BaseChatCompletionParameters): """See https://docs.litellm.ai/docs/providers/ollama.""" api_base: str + json_mode: bool | None = False # Enable JSON mode for structured output @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: adapter_metadata["model"] = OllamaLLMParameters.validate_model(adapter_metadata) adapter_metadata["api_base"] = adapter_metadata.get("base_url", "") - return OllamaLLMParameters(**adapter_metadata).model_dump() + # Handle JSON mode - convert to response_format + result_metadata = adapter_metadata.copy() + json_mode = result_metadata.pop("json_mode", False) + if json_mode: + result_metadata["response_format"] = {"type": "json_object"} + + return OllamaLLMParameters(**result_metadata).model_dump() @staticmethod def validate_model(adapter_metadata: dict[str, "Any"]) -> str: @@ -640,6 +688,33 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: return f"ollama_chat/{model}" +class AzureAIFoundryLLMParameters(BaseChatCompletionParameters): + """Azure AI Foundry LLM parameters. + + See https://docs.litellm.ai/docs/providers/azure_ai + """ + + api_key: str + api_base: str + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata["model"] = AzureAIFoundryLLMParameters.validate_model( + adapter_metadata + ) + + return AzureAIFoundryLLMParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + model = adapter_metadata.get("model", "") + # Only add azure_ai/ prefix if the model doesn't already have it + if model.startswith("azure_ai/"): + return model + else: + return f"azure_ai/{model}" + + # Embedding Parameter Classes @@ -649,6 +724,7 @@ class OpenAIEmbeddingParameters(BaseEmbeddingParameters): api_key: str api_base: str | None = None embed_batch_size: int | None = 10 + dimensions: int | None = None # For text-embedding-3-* models @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: @@ -672,6 +748,7 @@ class AzureOpenAIEmbeddingParameters(BaseEmbeddingParameters): api_version: str | None embed_batch_size: int | None = 5 num_retries: int | None = 3 + dimensions: int | None = None # For text-embedding-3-* models @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: 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 45e5c4d06c..7363a41aab 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/azure.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/azure.json @@ -20,7 +20,7 @@ "type": "string", "title": "Model", "default": "", - "description": "Provide the name of the model you defined in Azure console. Example text-embedding-ada-002" + "description": "Provide the name of the model you defined in Azure console. Example: text-embedding-3-small, text-embedding-ada-002" }, "deployment_name": { "type": "string", @@ -47,6 +47,13 @@ "format": "uri", "description": "Provide the Azure endpoint. Example: https://.openai.azure.com/" }, + "dimensions": { + "type": "number", + "minimum": 1, + "multipleOf": 1, + "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, 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 0227e05adc..9be724e41f 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/openai.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/embedding1/static/openai.json @@ -15,8 +15,8 @@ "model": { "type": "string", "title": "Model", - "default": "text-embedding-ada-002", - "description": "Provide the name of the model." + "default": "text-embedding-3-small", + "description": "Provide the name of the model. Recommended: text-embedding-3-small, text-embedding-3-large" }, "api_key": { "type": "string", @@ -30,6 +30,13 @@ "format": "uri", "default": "https://api.openai.com/v1/" }, + "dimensions": { + "type": "number", + "minimum": 1, + "multipleOf": 1, + "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, diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/azure_ai_foundry.py b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/azure_ai_foundry.py new file mode 100644 index 0000000000..4bb234077e --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/azure_ai_foundry.py @@ -0,0 +1,40 @@ +from typing import Any + +from unstract.sdk1.adapters.base1 import AzureAIFoundryLLMParameters, BaseAdapter +from unstract.sdk1.adapters.enums import AdapterTypes + + +class AzureAIFoundryLLMAdapter(AzureAIFoundryLLMParameters, BaseAdapter): + @staticmethod + def get_id() -> str: + return "azure_ai_foundry|1ee34560-ea2b-47ac-bfce-ecc4aa5a48cb" + + @staticmethod + def get_metadata() -> dict[str, Any]: + return { + "name": "Azure AI Foundry", + "version": "1.0.0", + "adapter": AzureAIFoundryLLMAdapter, + "description": "Azure AI Foundry LLM adapter", + "is_active": True, + } + + @staticmethod + def get_name() -> str: + return "Azure AI Foundry" + + @staticmethod + def get_description() -> str: + return "Azure AI Foundry LLM adapter" + + @staticmethod + def get_provider() -> str: + return "azure_ai_foundry" + + @staticmethod + def get_icon() -> str: + return "/icons/adapter-icons/AzureAIFoundry.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + return AdapterTypes.LLM diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/azure_ai_foundry.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/azure_ai_foundry.json new file mode 100644 index 0000000000..54eabf8adf --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/azure_ai_foundry.json @@ -0,0 +1,59 @@ +{ + "title": "Azure AI Foundry LLM", + "type": "object", + "required": [ + "adapter_name", + "api_key", + "api_base" + ], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: azure-ai-foundry-1" + }, + "api_key": { + "type": "string", + "title": "API Key", + "format": "password", + "description": "Your Azure AI Foundry API key from the Azure portal." + }, + "api_base": { + "type": "string", + "title": "Endpoint URL", + "format": "uri", + "default": "", + "description": "Azure AI Foundry endpoint URL. Example: https://..inference.ai.azure.com/ or https://-serverless..inference.ai.azure.com/" + }, + "model": { + "type": "string", + "title": "Model", + "default": "", + "description": "The model name deployed in Azure AI Foundry. Examples: command-r-plus, mistral-large-latest, gpt-4o" + }, + "max_tokens": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Maximum Output Tokens", + "description": "Maximum number of output tokens to limit LLM replies. Leave empty to use model default." + }, + "max_retries": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Max Retries", + "default": 5, + "description": "Maximum number of retries to attempt when a request fails." + }, + "timeout": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Timeout", + "default": 900, + "description": "Request timeout in seconds." + } + } +} diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/bedrock.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/bedrock.json index ec36f285ae..9adb8b8ceb 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/bedrock.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/bedrock.json @@ -2,9 +2,7 @@ "title": "Bedrock LLM", "type": "object", "required": [ - "aws_secret_access_key", "region_name", - "aws_access_key_id", "model", "adapter_name" ], @@ -24,20 +22,30 @@ "aws_access_key_id": { "type": "string", "title": "AWS Access Key ID", - "description": "Provide your AWS Access Key ID", + "description": "Provide your AWS Access Key ID. Leave empty if using AWS Profile or IAM role.", "format": "password" }, "aws_secret_access_key": { "type": "string", "title": "AWS Secret Access Key", - "description": "Provide your AWS Secret Access Key", + "description": "Provide your AWS Secret Access Key. Leave empty if using AWS Profile or IAM role.", "format": "password" }, "region_name": { "type": "string", - "title": "AWS Region name", + "title": "AWS Region Name", "description": "Provide the AWS Region name where the service is running. Eg. us-east-1" }, + "aws_profile_name": { + "type": "string", + "title": "AWS Profile Name", + "description": "AWS SSO profile name for authentication. Use this instead of access keys when using AWS SSO. Example: dev-profile" + }, + "model_id": { + "type": "string", + "title": "Application Inference Profile ARN", + "description": "Optional ARN for Application Inference Profile for cost tracking. Example: arn:aws:bedrock:us-east-1:000000000000:application-inference-profile/xxxx" + }, "max_tokens": { "type": "number", "minimum": 0, diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/mistral.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/mistral.json index 73739b48d8..78c0def9b3 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/mistral.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/mistral.json @@ -16,8 +16,8 @@ "model": { "type": "string", "title": "Model", - "default": "mistral-medium", - "description": "Provide the model name to be used. Example: mistral-tiny, mistral-small, mistral-medium" + "default": "mistral-large-latest", + "description": "Provide the model name. Examples: mistral-large-latest, mistral-small-latest, magistral-medium-2506, magistral-small-2506" }, "api_key": { "type": "string", @@ -48,6 +48,53 @@ "title": "Timeout", "default": 900, "description": "Timeout in seconds" + }, + "enable_reasoning": { + "type": "boolean", + "title": "Enable Reasoning", + "default": false, + "description": "Enable reasoning capabilities for Magistral models (magistral-medium-2506, magistral-small-2506). Provides step-by-step reasoning for complex tasks." + } + }, + "allOf": [ + { + "if": { + "properties": { + "enable_reasoning": { + "const": true + } + } + }, + "then": { + "properties": { + "reasoning_effort": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "default": "medium", + "title": "Reasoning Effort", + "description": "Controls the depth of reasoning. Higher values provide more thorough analysis but may increase latency and cost." + } + }, + "required": [ + "reasoning_effort" + ] + } + }, + { + "if": { + "properties": { + "enable_reasoning": { + "const": false + } + } + }, + "then": { + "properties": {} + } } - } + ] } diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/ollama.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/ollama.json index cefbed091f..3800814c77 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/ollama.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/ollama.json @@ -17,7 +17,7 @@ "type": "string", "title": "Model", "default": "", - "description": "Provide the model name to be used. Example:llama2, llama3, mistral" + "description": "Provide the model name to be used. Example: llama3.1, llama3, mistral, deepseek-r1" }, "base_url": { "type": "string", @@ -25,12 +25,27 @@ "default": "", "description": "Provide the base URL where Ollama server is running. Example: http://docker.host.internal:11434 or http://localhost:11434" }, + "max_tokens": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Maximum Output Tokens", + "description": "Maximum number of output tokens to generate. Maps to Ollama's num_predict parameter." + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2, + "title": "Temperature", + "default": 0.7, + "description": "Controls randomness. Lower values make output more focused and deterministic (0-2)." + }, "context_window": { "type": "number", "minimum": 0, "multipleOf": 1, - "title": "Context window", - "default":3900, + "title": "Context Window", + "default": 3900, "description": "The maximum number of context tokens for the model." }, "request_timeout": { @@ -40,6 +55,12 @@ "title": "Request Timeout", "default": 900, "description": "Request timeout in seconds" + }, + "json_mode": { + "type": "boolean", + "title": "JSON Mode", + "default": false, + "description": "Enable JSON mode to constrain output to valid JSON. Useful for structured data extraction." } } }