Skip to content

Commit ae97e60

Browse files
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.
1 parent 17aa41a commit ae97e60

9 files changed

Lines changed: 553 additions & 6 deletions

File tree

src/chat_sdk/adapters/discord/adapter.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,17 @@ 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). Faithful port
125+
# of upstream's nullish-coalescing chain ``config.apiUrl ??
126+
# process.env.DISCORD_API_URL ?? DISCORD_API_BASE`` (index.ts:142):
127+
# each rung falls through only on absence (``None``), so an explicit
128+
# value -- including an empty string -- is honored rather than silently
129+
# re-defaulted (CLAUDE.md truthiness-trap hazard).
130+
if config.api_url is not None:
131+
self._api_base_url: str = config.api_url
132+
else:
133+
env_api_url = os.environ.get("DISCORD_API_URL")
134+
self._api_base_url = env_api_url if env_api_url is not None else DISCORD_API_BASE
124135
self._name = "discord"
125136
self._bot_token = bot_token
126137
self._public_key = public_key.strip().lower()
@@ -1494,7 +1505,7 @@ async def _discord_fetch(
14941505
"""
14951506
import aiohttp # lazy import (needed for FormData)
14961507

1497-
url = f"{DISCORD_API_BASE}{path}"
1508+
url = f"{self._api_base_url}{path}"
14981509
headers: dict[str, str] = {
14991510
"Authorization": f"Bot {self._bot_token}",
15001511
}

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: 55 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,18 @@ 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`` (index.ts:201). We have no Octokit -- our REST
127+
# calls go through ``_github_api_request`` and the installation-token
128+
# exchange -- so we normalize the override (strip a trailing slash so
129+
# ``f"{base}{path}"`` joins cleanly) and substitute it for the hardcoded
130+
# ``https://api.github.com`` at both sites. ``is not None`` honors an
131+
# explicit empty string (CLAUDE.md truthiness-trap hazard).
132+
config_api_url = config.get("api_url")
133+
api_url_raw = config_api_url if config_api_url is not None else os.environ.get("GITHUB_API_URL")
134+
self._api_url = api_url_raw.rstrip("/") if api_url_raw else GITHUB_API_BASE_URL
135+
117136
# Auth configuration
118137
self._auth_token: str | None = None
119138
self._app_credentials: dict[str, str] | None = None
@@ -1098,7 +1117,7 @@ async def _get_installation_token(self, installation_id: int) -> str:
10981117
return token
10991118

11001119
app_jwt = self._generate_app_jwt()
1101-
url = f"https://api.github.com/app/installations/{installation_id}/access_tokens"
1120+
url = f"{self._api_url}/app/installations/{installation_id}/access_tokens"
11021121
headers = {
11031122
"Accept": "application/vnd.github+json",
11041123
"Authorization": f"Bearer {app_jwt}",
@@ -1169,7 +1188,7 @@ async def _github_api_request(
11691188
if auth_token:
11701189
headers["Authorization"] = f"Bearer {auth_token}"
11711190

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

11741193
session = await self._get_http_session()
11751194
kwargs: dict[str, Any] = {"headers": headers}
@@ -1208,6 +1227,40 @@ async def _get_installation_id(self, owner: str, repo: str) -> int | None:
12081227
key = f"github:install:{owner}/{repo}"
12091228
return await self._chat.get_state().get(key)
12101229

1230+
async def get_installation_id(self, thread: Thread | str) -> int | None:
1231+
"""Get the GitHub App installation ID associated with a thread.
1232+
1233+
Returns the fixed installation ID in single-tenant app mode, the cached
1234+
repository installation in multi-tenant mode, or ``None`` in PAT mode.
1235+
1236+
Faithful port of upstream ``getInstallationId`` (index.ts:458-480).
1237+
``thread`` may be a :class:`Thread` or a raw thread-ID string. The
1238+
private :meth:`_get_installation_id` (owner/repo) is retained as the
1239+
storage-keyed helper this method delegates to in multi-tenant mode.
1240+
1241+
Raises:
1242+
ValidationError: In multi-tenant mode when the adapter has not been
1243+
initialized via ``chat.initialize()`` (no state store to read).
1244+
"""
1245+
# Single-tenant GitHub App mode: a fixed installation was configured.
1246+
if self._installation_id is not None:
1247+
return self._installation_id
1248+
1249+
# PAT mode (or any non-multi-tenant config): no installation concept.
1250+
if not self.is_multi_tenant:
1251+
return None
1252+
1253+
thread_id = thread if isinstance(thread, str) else thread.id
1254+
decoded = self.decode_thread_id(thread_id)
1255+
1256+
if not self._chat:
1257+
raise ValidationError(
1258+
"github",
1259+
"Adapter not initialized. Ensure chat.initialize() has been called first.",
1260+
)
1261+
1262+
return await self._get_installation_id(decoded.owner, decoded.repo)
1263+
12111264
@staticmethod
12121265
async def _get_request_body(request: Any) -> str:
12131266
"""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: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,19 @@ 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) which overrides every ``LinearClient``'s ``apiUrl``.
125+
# We have no LinearClient -- ``_graphql_query`` POSTs raw GraphQL -- so
126+
# the override substitutes for the module-level ``LINEAR_API_URL``
127+
# default. ``is not None`` honors an explicit empty string (CLAUDE.md
128+
# truthiness-trap hazard).
129+
config_api_url = getattr(config, "api_url", None)
130+
if config_api_url is not None:
131+
self._api_url: str = config_api_url
132+
else:
133+
env_api_url = os.environ.get("LINEAR_API_URL")
134+
self._api_url = env_api_url if env_api_url is not None else LINEAR_API_URL
122135
self._webhook_secret = webhook_secret
123136
self._logger: Logger = getattr(config, "logger", None) or ConsoleLogger("info", prefix="linear")
124137
self._user_name = getattr(config, "user_name", None) or os.environ.get("LINEAR_BOT_USERNAME", "linear-bot")
@@ -1168,7 +1181,7 @@ async def _graphql_query(
11681181

11691182
session = await self._get_http_session()
11701183
async with session.post(
1171-
LINEAR_API_URL,
1184+
self._api_url,
11721185
headers=headers,
11731186
json=payload,
11741187
) 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: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,17 @@ 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+
# When ``None`` we omit ``base_url`` from the client constructors
546+
# entirely, so slack_sdk keeps its built-in ``https://slack.com/api/``
547+
# default (it rejects ``base_url=None``). Threaded into BOTH the async
548+
# ``AsyncWebClient`` cache and the synchronous ``web_client`` escape
549+
# hatch, mirroring upstream's default + per-token ``slackApiUrl``.
550+
self._slack_api_url: str | None = (
551+
config.api_url if config.api_url is not None else os.environ.get("SLACK_API_URL")
552+
)
553+
543554
# ------------------------------------------------------------------
544555
# Properties (Adapter protocol)
545556
# ------------------------------------------------------------------
@@ -852,7 +863,13 @@ def _get_client(self, token: str | None = None) -> Any:
852863

853864
from slack_sdk.web.async_client import AsyncWebClient
854865

855-
client = AsyncWebClient(token=resolved_token)
866+
# Only pass ``base_url`` when an override is configured — slack_sdk
867+
# rejects ``base_url=None`` (it requires a string), so mirroring
868+
# upstream's conditional spread keeps the built-in default otherwise.
869+
client_kwargs: dict[str, Any] = {"token": resolved_token}
870+
if self._slack_api_url is not None:
871+
client_kwargs["base_url"] = self._slack_api_url
872+
client = AsyncWebClient(**client_kwargs)
856873
self._client_cache[resolved_token] = client
857874
if len(self._client_cache) > self._client_cache_max:
858875
# Evict oldest (LRU). We intentionally do NOT close the evicted
@@ -895,7 +912,12 @@ def _get_web_client_for_token(self, token: str) -> Any:
895912
if client is None:
896913
from slack_sdk import WebClient
897914

898-
client = WebClient(token=token)
915+
# Same conditional-``base_url`` rule as ``_get_client`` — pass the
916+
# override only when configured so slack_sdk keeps its default.
917+
web_client_kwargs: dict[str, Any] = {"token": token}
918+
if self._slack_api_url is not None:
919+
web_client_kwargs["base_url"] = self._slack_api_url
920+
client = WebClient(**web_client_kwargs)
899921
self._web_client_cache[token] = client
900922
return client
901923

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)