Files
Alberto Schiabel 820abb9072 fix(sdk): resolve toolkit versions case-insensitively in both SDKs (#3762)
## Summary

Toolkit version maps are keyed by **normalized (lowercase) slugs** on
the *write* side — env vars (`COMPOSIO_TOOLKIT_VERSION_*`) and
user-supplied dicts both get their keys lowercased when the map is
built. But the *lookup* read the map with the **raw slug**. A pin
configured under a different casing (e.g. `{ "GitHub": "20250101_00" }`
or `COMPOSIO_TOOLKIT_VERSION_GITHUB` looked up as `GitHub`) therefore
missed the map and silently fell back to `"latest"`, which
`Tools.execute` then rejects with a version-required error — discarding
the user's explicit pin.

Rather than patch only the read side, this **centralizes the
normalization rule** into a single helper per SDK and routes **both**
the map-building (write) and lookup (read) paths through it, so the two
sides can never drift apart again:

- **Python** — `normalize_toolkit_slug()` in
`python/composio/utils/toolkit_version.py`
- **TypeScript** — `normalizeToolkitSlug()` in
`ts/packages/core/src/utils/toolkitVersion.ts`, imported by
`getToolkitVersionsFromEnv` in `sdk.ts`

Behavior is now identical across the two SDKs. This **supersedes
#3759**, which patched only the Python read side and left TS diverging.

## Backend verification

Confirmed against the backend (staging, **1403 toolkits across 15
pages**): every toolkit `slug` is already lowercase (the mixed-case
`name` field is display-only). So the *primary* execute path — which
resolves via `tool.toolkit.slug` — was never broken in practice. This
change:

- **hardens** the direct-util and mixed-case-config paths (a user
passing `{ "GitHub": "…" }` now resolves as expected), and
- **removes a latent cross-SDK divergence**: the TS suite previously
asserted `getToolkitVersion` was *case-sensitive* (`versions.test.ts`),
locking in the bug for an input the production code path can't actually
produce. That test is flipped to assert case-insensitive resolution.

## Tests

- **Python** (`python/tests/test_toolkit_version.py`): case-insensitive
lookup via `get_toolkit_versions` round-trip, and against a raw
pre-normalized map. `18 passed`.
- **TypeScript** (`ts/packages/core/test/core/versions.test.ts`):
replaced the case-sensitivity assertion with case-insensitive resolution
+ a write→read round-trip of a mixed-case user pin. Full core suite `997
passed`.
- Typecheck (`tsc --noEmit`) clean; `ruff` clean.

## Notes

- Changeset added: `@composio/core` patch.
- No public API change; the fix is behavioral (case-insensitive) plus an
internal shared helper.
2026-07-06 15:30:06 +04:00

188 lines
7.9 KiB
Python

"""Test toolkit version utilities."""
import pytest
from composio.core.types import ToolkitVersionParam
from composio.utils.toolkit_version import get_toolkit_version, get_toolkit_versions
class TestToolkitVersion:
"""Test cases for toolkit version utilities."""
def test_get_toolkit_version_with_string(self):
"""Test get_toolkit_version with string parameter."""
result = get_toolkit_version("github", "v1.0.0")
assert result == "v1.0.0"
def test_get_toolkit_version_with_dict(self):
"""Test get_toolkit_version with dict parameter."""
versions = {"github": "v1.0.0", "slack": "v2.0.0"}
result = get_toolkit_version("github", versions)
assert result == "v1.0.0"
def test_get_toolkit_version_with_dict_missing_key(self):
"""Test get_toolkit_version with dict parameter missing key."""
versions = {"slack": "v2.0.0"}
result = get_toolkit_version("github", versions)
assert result == "latest"
def test_get_toolkit_version_with_none(self):
"""Test get_toolkit_version with None parameter."""
result = get_toolkit_version("github", None)
assert result == "latest"
def test_get_toolkit_version_with_env_var(self):
"""Test get_toolkit_version with environment variable."""
with pytest.MonkeyPatch().context():
# The function doesn't directly use COMPOSIO_TOOLKIT_VERSION env var
# It only looks at the toolkit_versions parameter
result = get_toolkit_version("github", None)
assert result == "latest"
def test_get_toolkit_versions_with_string(self):
"""Test get_toolkit_versions with string parameter."""
result = get_toolkit_versions("v1.0.0")
assert result == "v1.0.0"
def test_get_toolkit_versions_with_dict(self):
"""Test get_toolkit_versions with dict parameter."""
versions = {"github": "v1.0.0", "slack": "v2.0.0"}
result = get_toolkit_versions(versions)
assert result == versions
def test_get_toolkit_versions_with_none(self):
"""Test get_toolkit_versions with None parameter."""
result = get_toolkit_versions(None)
assert result == "latest"
def test_get_toolkit_versions_with_env_toolkit_specific(self):
"""Test get_toolkit_versions with toolkit-specific environment variables."""
with pytest.MonkeyPatch().context() as m:
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "v2.0.0")
m.setenv("COMPOSIO_TOOLKIT_VERSION_SLACK", "v1.5.0")
result = get_toolkit_versions(None)
expected = {"github": "v2.0.0", "slack": "v1.5.0"}
assert result == expected
def test_get_toolkit_versions_env_and_user_override(self):
"""Test that user-provided versions override environment variables."""
with pytest.MonkeyPatch().context() as m:
# Set environment variables
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "v1.0.0")
m.setenv("COMPOSIO_TOOLKIT_VERSION_SLACK", "v2.0.0")
# User overrides should take precedence
user_versions = {"github": "v3.0.0", "jira": "v4.0.0"}
result = get_toolkit_versions(user_versions)
expected = {
"github": "v3.0.0", # User override
"slack": "v2.0.0", # From env
"jira": "v4.0.0", # User provided
}
assert result == expected
def test_get_toolkit_version_with_env_specific_toolkit(self):
"""Test get_toolkit_version works with environment-configured versions."""
with pytest.MonkeyPatch().context() as m:
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "v2.5.0")
m.setenv("COMPOSIO_TOOLKIT_VERSION_SLACK", "v1.8.0")
# Get versions from environment
env_versions = get_toolkit_versions(None)
# Test specific toolkit version retrieval
github_version = get_toolkit_version("github", env_versions)
slack_version = get_toolkit_version("slack", env_versions)
unknown_version = get_toolkit_version("unknown", env_versions)
assert github_version == "v2.5.0"
assert slack_version == "v1.8.0"
assert unknown_version == "latest"
def test_mixed_case_env_vars_normalized(self):
"""Test that mixed case environment variable names are normalized."""
with pytest.MonkeyPatch().context() as m:
# Environment variables are typically uppercase
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "v1.0.0")
m.setenv("COMPOSIO_TOOLKIT_VERSION_OPENAI", "v2.0.0")
result = get_toolkit_versions(None)
# Should be normalized to lowercase
assert "github" in result
assert "openai" in result
assert result["github"] == "v1.0.0"
assert result["openai"] == "v2.0.0"
def test_user_dict_case_normalization(self):
"""Test that user-provided dictionary keys are normalized to lowercase."""
user_versions = {"GitHub": "v1.0.0", "SLACK": "v2.0.0", "OpenAI": "v3.0.0"}
result = get_toolkit_versions(user_versions)
expected = {"github": "v1.0.0", "slack": "v2.0.0", "openai": "v3.0.0"}
assert result == expected
def test_get_toolkit_version_lookup_is_case_insensitive(self):
"""The lookup slug is matched case-insensitively.
Version maps are keyed by normalized (lowercase) slugs (write side
covered by test_user_dict_case_normalization), so the read side must
resolve any-cased slugs instead of silently returning 'latest'.
"""
versions = {"github": "v1.0.0"}
assert get_toolkit_version("GITHUB", versions) == "v1.0.0"
assert get_toolkit_version("GitHub", versions) == "v1.0.0"
assert get_toolkit_version("github", versions) == "v1.0.0"
def test_priority_order_matches_typescript(self):
"""Test that priority order matches TypeScript implementation.
Priority order should be:
1. String global version (overrides everything)
2. User-provided dict overrides env vars
3. Environment variables
4. Fallback to 'latest'
"""
with pytest.MonkeyPatch().context() as m:
# Test 1: String global version overrides everything
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "env_version")
result = get_toolkit_version("github", "global_version")
assert result == "global_version"
# Test 2: User dict overrides env vars
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "env_version")
m.setenv("COMPOSIO_TOOLKIT_VERSION_SLACK", "env_slack")
user_dict = {"github": "user_override"}
result = get_toolkit_versions(user_dict)
expected = {
"github": "user_override", # User override
"slack": "env_slack", # From env
}
assert result == expected
# Test 3: Environment variables when no user input
result = get_toolkit_versions(None)
expected = {"github": "env_version", "slack": "env_slack"}
assert result == expected
def test_empty_user_dict_uses_env_vars(self):
"""Test that empty user dict still uses environment variables."""
with pytest.MonkeyPatch().context() as m:
m.setenv("COMPOSIO_TOOLKIT_VERSION_GITHUB", "v1.0.0")
# Empty dict should still use env vars
result = get_toolkit_versions({})
expected = {"github": "v1.0.0"}
assert result == expected
def test_toolkit_version_param_type_annotation(self):
"""Test ToolkitVersionParam type annotation."""
import typing
# Test that ToolkitVersionParam is a Union type
assert hasattr(ToolkitVersionParam, "__origin__")
assert ToolkitVersionParam.__origin__ is typing.Union