Skip to content

Commit b4ea70d

Browse files
fix(adapters): api_url custom-endpoint config (Slack/Discord/GitHub/Linear) + GitHub public get_installation_id (#150)
* fix(adapters): api_url custom-endpoint config (Slack/Discord/GitHub/Linear) + GitHub public get_installation_id Pre-existing parity gaps ported faithfully from upstream chat@4.30.0. (A) api_url custom-endpoint config (upstream 4.27.0, 6b17c60) — every high-level adapter reads `config.apiUrl ?? <ADAPTER>_API_URL` env and routes it into the underlying API client. Add the field (+ env fallback, default unchanged when unset) to the four adapters that were missing it: - Slack (index.ts:617,576-577,620-621): new `api_url` (+ SLACK_API_URL). Passed as `base_url` to BOTH the async AsyncWebClient cache and the synchronous web_client escape hatch. Omitted entirely when unset (slack_sdk rejects base_url=None and keeps its built-in default). - Discord (types.ts:18-19, index.ts:142): new `api_url` (+ DISCORD_API_URL) replacing the hardcoded DISCORD_API_BASE in `_discord_fetch` — the single funnel all Discord HTTP goes through. - GitHub (index.ts:201,217): new `api_url` (+ GITHUB_API_URL) for GitHub Enterprise Server. Threaded into `_github_api_request` and the installation-token exchange URL (the two hardcoded api.github.com sites). - Linear (index.ts:221,239,249, types.ts:51): new `api_url` (+ LINEAR_API_URL) overriding the GraphQL endpoint in `_graphql_query`. (B) GitHub public get_installation_id (index.ts:458-480) — add the public `get_installation_id(thread|str)` returning the fixed id (single-tenant app), the cached repo installation (multi-tenant), or None (PAT mode); raises when multi-tenant and uninitialized. Keeps the private `_get_installation_id(owner, repo)` helper it delegates to. Tests: tests/test_adapter_api_url_config.py (31) — per-adapter custom-url used / default-unchanged / env-fallback / config-wins-over-env, plus the six get_installation_id auth-mode branches. The usage-site assertions were verified to fail against the pre-fix sources. * fix(adapters): run api_url tests in CI + empty-string falls back to default endpoint The api_url config test module guarded every test behind a module-level pytest.importorskip("slack_sdk"). Since slack_sdk lives only in the optional slack/all extras (not the dev group CI installs), the whole file collected as a single skip -- silently disabling the Discord/GitHub/Linear api_url tests and the get_installation_id regression tests in CI (falsely green). Stub slack_sdk into sys.modules (mirroring tests/test_slack_client_cache.py) so the module imports without the real package and the Slack tests run too. The file now reports 34 passed, 1 skipped (the lone skip is the genuine-sdk end-to-end test, intentionally skipped when stubbed) instead of 1 skipped. Empty-string api_url now falls back to the default endpoint, matching upstream's truthy spread ...(this.apiUrl ? {...} : {}): - Slack: empty apiUrl/env resolves to None, so base_url is omitted and slack_sdk keeps https://slack.com/api/ (never passes base_url=""). - Linear: empty resolves to LINEAR_API_URL instead of POSTing to an empty URL. - GitHub: empty resolves to https://api.github.com; fixed the misleading inline comment that claimed the is-not-None check honored empty string. - Discord: empty resolves to DISCORD_API_BASE instead of a relative request URL. Added a per-adapter empty-string regression test (Slack/Discord/GitHub/Linear).
1 parent a7711ed commit b4ea70d

9 files changed

Lines changed: 641 additions & 6 deletions

File tree

src/chat_sdk/adapters/discord/adapter.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ def __init__(self, config: DiscordAdapterConfig | None = None) -> None:
121121
"application_id is required. Set DISCORD_APPLICATION_ID or provide it in config.",
122122
)
123123

124+
# Custom Discord API base URL (proxy / mock / self-host). Port of
125+
# upstream's coalescing chain ``config.apiUrl ?? process.env.DISCORD_API_URL
126+
# ?? DISCORD_API_BASE`` (index.ts:142). Unlike Slack/GitHub/Linear --
127+
# which feed clients via a truthy spread -- ``_discord_fetch`` joins
128+
# ``f"{base}{path}"`` directly, so an empty ``apiUrl`` would yield a
129+
# broken relative URL. We use a truthy fallback so an empty string (or
130+
# env) resolves to the ``DISCORD_API_BASE`` default, matching the
131+
# empty-string-is-default contract shared by the other adapters.
132+
self._api_base_url: str = config.api_url or os.environ.get("DISCORD_API_URL") or DISCORD_API_BASE
124133
self._name = "discord"
125134
self._bot_token = bot_token
126135
self._public_key = public_key.strip().lower()
@@ -1494,7 +1503,7 @@ async def _discord_fetch(
14941503
"""
14951504
import aiohttp # lazy import (needed for FormData)
14961505

1497-
url = f"{DISCORD_API_BASE}{path}"
1506+
url = f"{self._api_base_url}{path}"
14981507
headers: dict[str, str] = {
14991508
"Authorization": f"Bot {self._bot_token}",
15001509
}

src/chat_sdk/adapters/discord/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ class DiscordAdapterConfig:
2626
See: https://discord.com/developers/docs/getting-started
2727
"""
2828

29+
# Override the Discord API base URL. Defaults to the DISCORD_API_URL env
30+
# var, then to "https://discord.com/api/v10". Mirrors upstream
31+
# ``config.apiUrl ?? process.env.DISCORD_API_URL ?? DISCORD_API_BASE``
32+
# (vercel/chat adapter-discord index.ts:142).
33+
api_url: str | None = None
2934
# Discord application ID. Defaults to DISCORD_APPLICATION_ID env var.
3035
application_id: str | None = None
3136
# Discord bot token. Defaults to DISCORD_BOT_TOKEN env var.

src/chat_sdk/adapters/github/adapter.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,20 @@
5757
RawMessage,
5858
StreamChunk,
5959
StreamOptions,
60+
Thread,
6061
ThreadInfo,
6162
ThreadSummary,
6263
UserInfo,
6364
WebhookOptions,
6465
_parse_iso,
6566
)
6667

68+
# Default GitHub REST API base URL. Overridable per-adapter via
69+
# ``config["api_url"]`` / ``GITHUB_API_URL`` for GitHub Enterprise Server
70+
# (mirrors upstream Octokit ``baseUrl``). Stored without a trailing slash so
71+
# ``f"{base}{path}"`` joins cleanly with leading-slash paths.
72+
GITHUB_API_BASE_URL = "https://api.github.com"
73+
6774
REVIEW_COMMENT_THREAD_PATTERN = re.compile(r"^([^/]+)/([^:]+):(\d+):rc:(\d+)$")
6875
ISSUE_THREAD_PATTERN = re.compile(r"^([^/]+)/([^:]+):issue:(\d+)$")
6976
PR_THREAD_PATTERN = re.compile(r"^([^/]+)/([^:]+):(\d+)$")
@@ -114,6 +121,19 @@ def __init__(self, config: GitHubAdapterConfig | None = None) -> None:
114121
self._chat: ChatInstance | None = None
115122
self._format_converter = GitHubFormatConverter()
116123

124+
# Custom GitHub API base URL (e.g. GitHub Enterprise Server). Upstream
125+
# threads ``config.apiUrl ?? process.env.GITHUB_API_URL`` into every
126+
# Octokit ``baseUrl`` via the truthy spread ``...(this.apiUrl ? { baseUrl }
127+
# : {})`` (index.ts:201/217/...), so an empty string falls back to the
128+
# default. We have no Octokit -- our REST calls go through
129+
# ``_github_api_request`` and the installation-token exchange -- so we
130+
# normalize the override (strip a trailing slash so ``f"{base}{path}"``
131+
# joins cleanly) and substitute it for the hardcoded
132+
# ``https://api.github.com`` at both sites. The truthy fallback means an
133+
# empty ``apiUrl`` (or env) uses the default endpoint.
134+
api_url_raw = config.get("api_url") or os.environ.get("GITHUB_API_URL")
135+
self._api_url = api_url_raw.rstrip("/") if api_url_raw else GITHUB_API_BASE_URL
136+
117137
# Auth configuration
118138
self._auth_token: str | None = None
119139
self._app_credentials: dict[str, str] | None = None
@@ -1098,7 +1118,7 @@ async def _get_installation_token(self, installation_id: int) -> str:
10981118
return token
10991119

11001120
app_jwt = self._generate_app_jwt()
1101-
url = f"https://api.github.com/app/installations/{installation_id}/access_tokens"
1121+
url = f"{self._api_url}/app/installations/{installation_id}/access_tokens"
11021122
headers = {
11031123
"Accept": "application/vnd.github+json",
11041124
"Authorization": f"Bearer {app_jwt}",
@@ -1169,7 +1189,7 @@ async def _github_api_request(
11691189
if auth_token:
11701190
headers["Authorization"] = f"Bearer {auth_token}"
11711191

1172-
url = f"https://api.github.com{path}" if path.startswith("/") else path
1192+
url = f"{self._api_url}{path}" if path.startswith("/") else path
11731193

11741194
session = await self._get_http_session()
11751195
kwargs: dict[str, Any] = {"headers": headers}
@@ -1208,6 +1228,40 @@ async def _get_installation_id(self, owner: str, repo: str) -> int | None:
12081228
key = f"github:install:{owner}/{repo}"
12091229
return await self._chat.get_state().get(key)
12101230

1231+
async def get_installation_id(self, thread: Thread | str) -> int | None:
1232+
"""Get the GitHub App installation ID associated with a thread.
1233+
1234+
Returns the fixed installation ID in single-tenant app mode, the cached
1235+
repository installation in multi-tenant mode, or ``None`` in PAT mode.
1236+
1237+
Faithful port of upstream ``getInstallationId`` (index.ts:458-480).
1238+
``thread`` may be a :class:`Thread` or a raw thread-ID string. The
1239+
private :meth:`_get_installation_id` (owner/repo) is retained as the
1240+
storage-keyed helper this method delegates to in multi-tenant mode.
1241+
1242+
Raises:
1243+
ValidationError: In multi-tenant mode when the adapter has not been
1244+
initialized via ``chat.initialize()`` (no state store to read).
1245+
"""
1246+
# Single-tenant GitHub App mode: a fixed installation was configured.
1247+
if self._installation_id is not None:
1248+
return self._installation_id
1249+
1250+
# PAT mode (or any non-multi-tenant config): no installation concept.
1251+
if not self.is_multi_tenant:
1252+
return None
1253+
1254+
thread_id = thread if isinstance(thread, str) else thread.id
1255+
decoded = self.decode_thread_id(thread_id)
1256+
1257+
if not self._chat:
1258+
raise ValidationError(
1259+
"github",
1260+
"Adapter not initialized. Ensure chat.initialize() has been called first.",
1261+
)
1262+
1263+
return await self._get_installation_id(decoded.owner, decoded.repo)
1264+
12111265
@staticmethod
12121266
async def _get_request_body(request: Any) -> str:
12131267
"""Extract body text from a request object."""

src/chat_sdk/adapters/github/types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ class GitHubAdapterBaseConfig(TypedDict, total=False):
1616
"""Base configuration options shared by all auth methods.
1717
1818
Attributes:
19+
api_url: Override the GitHub API base URL (e.g. a GitHub Enterprise
20+
Server endpoint like "https://github.example.com/api/v3"). Mirrors
21+
upstream ``config.apiUrl`` → Octokit ``baseUrl`` (index.ts:201,217).
22+
Defaults to the GITHUB_API_URL env var, then to
23+
"https://api.github.com".
1924
bot_user_id: Bot's GitHub user ID (numeric). Used for self-message
2025
detection. If not provided, will be fetched on first API call.
2126
logger: Logger instance for error reporting. Defaults to ConsoleLogger.
@@ -27,6 +32,7 @@ class GitHubAdapterBaseConfig(TypedDict, total=False):
2732
Defaults to GITHUB_WEBHOOK_SECRET env var.
2833
"""
2934

35+
api_url: str
3036
bot_user_id: int
3137
logger: Logger
3238
user_name: str

src/chat_sdk/adapters/linear/adapter.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,17 @@ def __init__(self, config: LinearAdapterConfig | None = None) -> None:
119119
)
120120

121121
self._name = "linear"
122+
# Custom Linear GraphQL endpoint (proxy / mock / self-host). Faithful
123+
# port of upstream ``config.apiUrl ?? process.env.LINEAR_API_URL``
124+
# (index.ts:239), consumed via the truthy spread
125+
# ``...(this.apiUrl ? { apiUrl } : {})`` at every ``LinearClient``
126+
# construction — so an empty string falls back to the default endpoint.
127+
# We have no LinearClient -- ``_graphql_query`` POSTs raw GraphQL -- so
128+
# the override substitutes for the module-level ``LINEAR_API_URL``
129+
# default. The truthy check means an empty ``apiUrl`` (or env) uses the
130+
# default rather than POSTing to an empty/relative URL.
131+
config_api_url = getattr(config, "api_url", None)
132+
self._api_url: str = config_api_url or os.environ.get("LINEAR_API_URL") or LINEAR_API_URL
122133
self._webhook_secret = webhook_secret
123134
self._logger: Logger = getattr(config, "logger", None) or ConsoleLogger("info", prefix="linear")
124135
self._user_name = getattr(config, "user_name", None) or os.environ.get("LINEAR_BOT_USERNAME", "linear-bot")
@@ -1168,7 +1179,7 @@ async def _graphql_query(
11681179

11691180
session = await self._get_http_session()
11701181
async with session.post(
1171-
LINEAR_API_URL,
1182+
self._api_url,
11721183
headers=headers,
11731184
json=payload,
11741185
) as response:

src/chat_sdk/adapters/linear/types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
class LinearAdapterBaseConfig:
2121
"""Base configuration options shared by all auth methods."""
2222

23+
# Override the Linear GraphQL API base URL. Defaults to the LINEAR_API_URL
24+
# env var, then to "https://api.linear.app/graphql". Mirrors upstream
25+
# ``config.apiUrl ?? process.env.LINEAR_API_URL`` → ``LinearClient.apiUrl``
26+
# (vercel/chat adapter-linear index.ts:239, types.ts:51). Useful for
27+
# proxies, mocks, or self-hosted GraphQL gateways.
28+
api_url: str | None = None
2329
# Logger instance for error reporting. Defaults to ConsoleLogger.
2430
logger: Logger | None = None
2531
# Bot display name for @-mention detection.

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,18 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
540540
if encryption_key_raw:
541541
self._encryption_key = decode_key(encryption_key_raw)
542542

543+
# Custom Slack Web API base URL (e.g. proxy, mock, Enterprise routing).
544+
# ``config.apiUrl ?? process.env.SLACK_API_URL`` upstream (index.ts:617),
545+
# consumed via the truthy spread ``...(this.slackApiUrl ? {...} : {})``
546+
# at every client construction — so an empty string falls back to the
547+
# built-in default. We mirror that here: an empty ``apiUrl`` (or env)
548+
# resolves to ``None``. When ``None`` we omit ``base_url`` from the
549+
# client constructors entirely, so slack_sdk keeps its built-in
550+
# ``https://slack.com/api/`` default (it rejects ``base_url=None``).
551+
# Threaded into BOTH the async ``AsyncWebClient`` cache and the
552+
# synchronous ``web_client`` escape hatch.
553+
self._slack_api_url: str | None = (config.api_url or os.environ.get("SLACK_API_URL")) or None
554+
543555
# ------------------------------------------------------------------
544556
# Properties (Adapter protocol)
545557
# ------------------------------------------------------------------
@@ -852,7 +864,14 @@ def _get_client(self, token: str | None = None) -> Any:
852864

853865
from slack_sdk.web.async_client import AsyncWebClient
854866

855-
client = AsyncWebClient(token=resolved_token)
867+
# Only pass ``base_url`` when a truthy override is configured — slack_sdk
868+
# rejects ``base_url=None`` (it requires a string), and an empty string
869+
# must fall back to the built-in default, so mirroring upstream's truthy
870+
# spread keeps the default otherwise.
871+
client_kwargs: dict[str, Any] = {"token": resolved_token}
872+
if self._slack_api_url:
873+
client_kwargs["base_url"] = self._slack_api_url
874+
client = AsyncWebClient(**client_kwargs)
856875
self._client_cache[resolved_token] = client
857876
if len(self._client_cache) > self._client_cache_max:
858877
# Evict oldest (LRU). We intentionally do NOT close the evicted
@@ -895,7 +914,12 @@ def _get_web_client_for_token(self, token: str) -> Any:
895914
if client is None:
896915
from slack_sdk import WebClient
897916

898-
client = WebClient(token=token)
917+
# Same truthy ``base_url`` rule as ``_get_client`` — pass the
918+
# override only when truthy so slack_sdk keeps its default.
919+
web_client_kwargs: dict[str, Any] = {"token": token}
920+
if self._slack_api_url:
921+
web_client_kwargs["base_url"] = self._slack_api_url
922+
client = WebClient(**web_client_kwargs)
899923
self._web_client_cache[token] = client
900924
return client
901925

src/chat_sdk/adapters/slack/types.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@
4141
class SlackAdapterConfig:
4242
"""Configuration for the Slack adapter."""
4343

44+
# Override the Slack Web API base URL (passed as ``base_url`` to every
45+
# ``slack_sdk`` client the adapter builds — the default client, the
46+
# per-token async cache, and the synchronous ``web_client`` escape hatch).
47+
# Defaults to the ``SLACK_API_URL`` env var, then to slack_sdk's built-in
48+
# ``https://slack.com/api/``. Mirrors upstream ``config.apiUrl`` →
49+
# ``slackApiUrl`` (vercel/chat 6b17c60). Useful for proxies, Slack-API
50+
# mocks in tests, or Enterprise-routed deployments.
51+
api_url: str | None = None
4452
# App-level token (xapp-...). Required when ``mode == "socket"``.
4553
app_token: str | None = None
4654
# Bot token (xoxb-...). Required for single-workspace mode. Omit for multi-workspace.

0 commit comments

Comments
 (0)