Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions examples/available_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions examples/custom_base_url.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 1 addition & 2 deletions examples/file_uploads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down
25 changes: 19 additions & 6 deletions examples/index.py
Original file line number Diff line number Diff line change
@@ -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
```

Expand Down
14 changes: 7 additions & 7 deletions examples/langchain_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
6 changes: 4 additions & 2 deletions stackone_ai/specs/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
5 changes: 4 additions & 1 deletion stackone_ai/toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
142 changes: 142 additions & 0 deletions tests/test_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Loading