diff --git a/examples/available_tools.py b/examples/available_tools.py index c6cccf5..83c5663 100644 --- a/examples/available_tools.py +++ b/examples/available_tools.py @@ -8,8 +8,7 @@ 3. Using multiple patterns for cross-vertical functionality 4. Filtering by specific operations 5. Combining multiple operation patterns - -TODO: experimental - get_available_tools(account_id="your_account_id") +6. TODO: get_account_tools(account_id="your_account_id") ```bash uv run examples/available_tools.py diff --git a/examples/custom_base_url.py b/examples/custom_base_url.py new file mode 100644 index 0000000..6257c18 --- /dev/null +++ b/examples/custom_base_url.py @@ -0,0 +1,42 @@ +""" +Example demonstrating how to use a custom base URL with StackOne tools. + +This is useful for: +1. Testing against development APIs +2. Working with self-hosted StackOne instances + +Usage: + +```bash +uv run examples/custom_base_url.py +``` +""" + +from stackone_ai.toolset import StackOneToolSet + + +def custom_base_url(): + """ + Default base URL + """ + default_toolset = StackOneToolSet() + hris_tools = default_toolset.get_tools(filter_pattern="hris_*") + + assert len(hris_tools) > 0 + assert hris_tools[0]._execute_config.url.startswith("https://api.stackone.com") + + """ + Custom base URL + """ + dev_toolset = StackOneToolSet(base_url="https://api.example-dev.com") + dev_hris_tools = dev_toolset.get_tools(filter_pattern="hris_*") + + """ + Note this uses the same tools but substitutes the base URL + """ + assert len(dev_hris_tools) > 0 + assert dev_hris_tools[0]._execute_config.url.startswith("https://api.example-dev.com") + + +if __name__ == "__main__": + custom_base_url() diff --git a/examples/file_uploads.py b/examples/file_uploads.py index eda5d8e..c4c8420 100644 --- a/examples/file_uploads.py +++ b/examples/file_uploads.py @@ -24,8 +24,7 @@ """ # Resume content -This is a sample resume content that will be uploaded to StackOne. - +This is a sample resume content that will be uploaded using the `hris_upload_employee_document` tool. """ resume_content = """ diff --git a/examples/index.py b/examples/index.py index 96650dd..1c17662 100644 --- a/examples/index.py +++ b/examples/index.py @@ -1,25 +1,38 @@ """ -StackOne AI provides a unified interface for accessing various SaaS tools through AI-friendly APIs. +StackOne AI SDK provides an AI-friendly interface for accessing various SaaS tools through the StackOne Unified API. + +This SDK is available on [PyPI](https://pypi.org/project/stackone-ai/) for python projects. There is a node version in the works. # Installation ```bash -# Using pip -pip install stackone-ai - # Using uv uv add stackone-ai + +# Using pip +pip install stackone-ai ``` # How to use these docs All examples are complete and runnable. -We use [uv](https://docs.astral.sh/uv/getting-started/installation/) for python dependency management. +We use [uv](https://docs.astral.sh/uv/getting-started/installation/) for easy python dependency management. + +Install uv: -To run this example, install the dependencies (one-time setup) and run the script: +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` +To run this example, clone the repo, install the dependencies (one-time setup) and run the script: ```bash +git clone https://github.com/stackoneHQ/stackone-ai-python.git +cd stackone-ai-python + +# Install dependencies uv sync --all-extras + +# Run the example uv run examples/index.py ``` diff --git a/examples/langchain_integration.py b/examples/langchain_integration.py index af5c28c..a845cb6 100644 --- a/examples/langchain_integration.py +++ b/examples/langchain_integration.py @@ -38,13 +38,13 @@ def langchain_integration() -> None: result = model_with_tools.invoke(f"Can you get me information about employee with ID: {employee_id}?") - if result.tool_calls: - for tool_call in result.tool_calls: - tool = tools.get_tool(tool_call["name"]) - if tool: - result = tool.execute(tool_call["args"]) - assert result is not None - assert result.get("data") is not None + assert result.tool_calls is not None + for tool_call in result.tool_calls: + tool = tools.get_tool(tool_call["name"]) + if tool: + result = tool.execute(tool_call["args"]) + assert result is not None + assert result.get("data") is not None if __name__ == "__main__": diff --git a/stackone_ai/specs/parser.py b/stackone_ai/specs/parser.py index 1ac2ed0..a5b9eeb 100644 --- a/stackone_ai/specs/parser.py +++ b/stackone_ai/specs/parser.py @@ -6,13 +6,15 @@ class OpenAPIParser: - def __init__(self, spec_path: Path): + def __init__(self, spec_path: Path, base_url: str | None = None): self.spec_path = spec_path with open(spec_path) as f: self.spec = json.load(f) # Get base URL from servers array or default to stackone API servers = self.spec.get("servers", [{"url": "https://api.stackone.com"}]) - self.base_url = servers[0]["url"] if isinstance(servers, list) else "https://api.stackone.com" + default_url = servers[0]["url"] if isinstance(servers, list) else "https://api.stackone.com" + # Use provided base_url if available, otherwise use the default from the spec + self.base_url = base_url or default_url def _is_file_type(self, schema: dict[str, Any]) -> bool: """Check if a schema represents a file upload.""" diff --git a/stackone_ai/toolset.py b/stackone_ai/toolset.py index 0e5cb2d..6520070 100644 --- a/stackone_ai/toolset.py +++ b/stackone_ai/toolset.py @@ -36,12 +36,14 @@ def __init__( self, api_key: str | None = None, account_id: str | None = None, + base_url: str | None = None, ) -> None: """Initialize StackOne tools with authentication Args: api_key: Optional API key. If not provided, will try to get from STACKONE_API_KEY env var account_id: Optional account ID. If not provided, will try to get from STACKONE_ACCOUNT_ID env var + base_url: Optional base URL override for API requests. If not provided, uses the URL from the OAS Raises: ToolsetConfigError: If no API key is provided or found in environment @@ -54,6 +56,7 @@ def __init__( ) self.api_key: str = api_key_value self.account_id = account_id or os.getenv("STACKONE_ACCOUNT_ID") + self.base_url = base_url def _parse_parameters(self, parameters: list[dict[str, Any]]) -> dict[str, dict[str, str]]: """Parse OpenAPI parameters into tool properties @@ -133,7 +136,7 @@ def get_tools( # Load all available specs for spec_file in OAS_DIR.glob("*.json"): - parser = OpenAPIParser(spec_file) + parser = OpenAPIParser(spec_file, base_url=self.base_url) tool_definitions = parser.parse_tools() # Create tools and filter if pattern is provided diff --git a/tests/test_parser.py b/tests/test_parser.py index 14290a7..a5417f6 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -699,3 +699,33 @@ def test_form_data_without_files(temp_spec_file: Path) -> None: # Check body type assert tool.execute.body_type == "form" + + +def test_parser_with_base_url_override(tmp_path: Path, sample_openapi_spec: dict[str, Any]) -> None: + """Test that the parser uses the provided base_url instead of the one from the spec.""" + # Write the spec to a temporary file + spec_file = tmp_path / "test_spec.json" + with open(spec_file, "w") as f: + json.dump(sample_openapi_spec, f) + + # Create parser with default base_url + default_parser = OpenAPIParser(spec_file) + assert default_parser.base_url == "https://api.test.com" + + # Create parser with development base_url + dev_parser = OpenAPIParser(spec_file, base_url="https://api.example-dev.com") + assert dev_parser.base_url == "https://api.example-dev.com" + + # Create parser with experimental base_url + exp_parser = OpenAPIParser(spec_file, base_url="https://api.example-exp.com") + assert exp_parser.base_url == "https://api.example-exp.com" + + # Verify the base_url is used in the tool definitions for development environment + dev_tools = dev_parser.parse_tools() + assert dev_tools["get_employee"].execute.url.startswith("https://api.example-dev.com") + assert not dev_tools["get_employee"].execute.url.startswith("https://api.test.com") + + # Verify the base_url is used in the tool definitions for experimental environment + exp_tools = exp_parser.parse_tools() + assert exp_tools["get_employee"].execute.url.startswith("https://api.example-exp.com") + assert not exp_tools["get_employee"].execute.url.startswith("https://api.test.com") diff --git a/tests/test_toolset.py b/tests/test_toolset.py index 8e5a1e3..4be7888 100644 --- a/tests/test_toolset.py +++ b/tests/test_toolset.py @@ -85,3 +85,145 @@ def test_empty_filter_result(): toolset = StackOneToolSet(api_key="test_key") tools = toolset.get_tools(filter_pattern="unknown_*") assert len(tools) == 0 + + +def test_toolset_with_base_url(): + """Test StackOneToolSet with a custom base_url""" + mock_spec_content = { + "paths": { + "/employee/{id}": { + "get": { + "operationId": "hris_get_employee", + "summary": "Get employee details", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": {"type": "string"}, + "description": "Employee ID", + } + ], + } + } + } + } + + # Create mock tool definition with default URL + mock_tool_def = ToolDefinition( + description="Get employee details", + parameters=ToolParameters( + type="object", + properties={ + "id": { + "type": "string", + "description": "Employee ID", + } + }, + ), + execute=ExecuteConfig( + method="GET", + url="https://api.stackone.com/employee/{id}", + name="hris_get_employee", + headers={}, + parameter_locations={"id": "path"}, + ), + ) + + # Create mock tool definition with development URL + mock_tool_def_dev = ToolDefinition( + description="Get employee details", + parameters=ToolParameters( + type="object", + properties={ + "id": { + "type": "string", + "description": "Employee ID", + } + }, + ), + execute=ExecuteConfig( + method="GET", + url="https://api.example-dev.com/employee/{id}", + name="hris_get_employee", + headers={}, + parameter_locations={"id": "path"}, + ), + ) + + # Create mock tool definition with experimental URL + mock_tool_def_exp = ToolDefinition( + description="Get employee details", + parameters=ToolParameters( + type="object", + properties={ + "id": { + "type": "string", + "description": "Employee ID", + } + }, + ), + execute=ExecuteConfig( + method="GET", + url="https://api.example-exp.com/employee/{id}", + name="hris_get_employee", + headers={}, + parameter_locations={"id": "path"}, + ), + ) + + # Mock the OpenAPIParser and file operations + with ( + patch("stackone_ai.toolset.OAS_DIR") as mock_dir, + patch("stackone_ai.toolset.OpenAPIParser") as mock_parser_class, + ): + # Setup mocks + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_dir.__truediv__.return_value = mock_path + mock_dir.glob.return_value = [mock_path] + + # Setup parser mock for default URL + mock_parser = MagicMock() + mock_parser.spec = mock_spec_content + mock_parser.parse_tools.return_value = {"hris_get_employee": mock_tool_def} + + # Setup parser mock for development URL + mock_parser_dev = MagicMock() + mock_parser_dev.spec = mock_spec_content + mock_parser_dev.parse_tools.return_value = {"hris_get_employee": mock_tool_def_dev} + + # Setup parser mock for experimental URL + mock_parser_exp = MagicMock() + mock_parser_exp.spec = mock_spec_content + mock_parser_exp.parse_tools.return_value = {"hris_get_employee": mock_tool_def_exp} + + # Configure the mock parser class to return different instances based on base_url + def get_parser(spec_path, base_url=None): + if base_url == "https://api.example-dev.com": + return mock_parser_dev + elif base_url == "https://api.example-exp.com": + return mock_parser_exp + return mock_parser + + mock_parser_class.side_effect = get_parser + + # Test with default URL + toolset = StackOneToolSet(api_key="test_key") + tools = toolset.get_tools(filter_pattern="hris_*") + tool = tools.get_tool("hris_get_employee") + assert tool is not None + assert tool._execute_config.url == "https://api.stackone.com/employee/{id}" + + # Test with development URL + toolset_dev = StackOneToolSet(api_key="test_key", base_url="https://api.example-dev.com") + tools_dev = toolset_dev.get_tools(filter_pattern="hris_*") + tool_dev = tools_dev.get_tool("hris_get_employee") + assert tool_dev is not None + assert tool_dev._execute_config.url == "https://api.example-dev.com/employee/{id}" + + # Test with experimental URL + toolset_exp = StackOneToolSet(api_key="test_key", base_url="https://api.example-exp.com") + tools_exp = toolset_exp.get_tools(filter_pattern="hris_*") + tool_exp = tools_exp.get_tool("hris_get_employee") + assert tool_exp is not None + assert tool_exp._execute_config.url == "https://api.example-exp.com/employee/{id}"