Skip to content

Commit 89e9204

Browse files
committed
Make vercel dep optional
1 parent 80f3b01 commit 89e9204

5 files changed

Lines changed: 106 additions & 40 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ A toolkit for building LLM-powered applications and agent loops.
88
uv add ai
99
```
1010

11-
AI Gateway usage works with the base package. Direct providers that use an
12-
OpenAI-compatible or Anthropic-compatible adapter load the corresponding
13-
official SDK lazily and require optional extras:
11+
AI Gateway API-key usage works with the base package. Direct providers that
12+
use an OpenAI-compatible or Anthropic-compatible adapter load the corresponding
13+
official SDK lazily. Vercel OIDC for AI Gateway also uses an optional extra:
1414

1515
```bash
1616
uv add "ai[openai]" # OpenAI-compatible providers
1717
uv add "ai[anthropic]" # Anthropic-compatible providers
18+
uv add "ai[vercel]" # Vercel OIDC for AI Gateway
1819
```
1920

2021
```python

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ dependencies = [
3232
"modelsdotdev==0.*",
3333
"pydantic>=2.12.5",
3434
"typing-extensions>=4.15.0",
35-
"vercel>=0.5.9",
3635
]
3736

3837
[project.optional-dependencies]
3938
anthropic = ["anthropic>=0.83.0"]
4039
mcp = ["mcp>=1.18.0"]
4140
openai = ["openai>=2.14.0"]
41+
vercel = [
42+
"vercel>=0.5.9",
43+
]
4244

4345
[build-system]
4446
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]

src/ai/providers/ai_gateway/provider.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@
55

66
from __future__ import annotations
77

8-
from typing import TYPE_CHECKING, Any, ClassVar
9-
10-
from vercel import oidc
8+
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast
119

1210
from ... import errors as ai_errors
13-
from .. import base
11+
from .. import _optional, base
1412
from . import client as gateway_client
1513
from . import errors
1614
from . import protocol as protocol_module
@@ -36,6 +34,30 @@
3634
_OIDC_TOKEN_ENV = "VERCEL_OIDC_TOKEN"
3735

3836

37+
class _VercelOidc(Protocol):
38+
def get_vercel_oidc_token(self) -> str: ...
39+
40+
41+
def _get_vercel_oidc_token() -> str:
42+
try:
43+
oidc = cast(
44+
"_VercelOidc",
45+
_optional.import_optional_sdk(
46+
"vercel.oidc",
47+
provider="AI Gateway OIDC",
48+
extra="vercel",
49+
),
50+
)
51+
except ai_errors.InstallationError as exc:
52+
raise ai_errors.InstallationError(
53+
"AI Gateway OIDC authentication requires the optional `vercel` "
54+
'package. Install it with `pip install "ai[vercel]"` or '
55+
'`uv add "ai[vercel]"`, or set `AI_GATEWAY_API_KEY` to use '
56+
"API key authentication."
57+
) from exc
58+
return oidc.get_vercel_oidc_token()
59+
60+
3961
class GatewayProvider(base.Provider[gateway_client.GatewayClient]):
4062
"""Provider configuration for the Vercel AI Gateway."""
4163

@@ -87,7 +109,7 @@ def _gateway_auth(
87109
if self._config_value(_VERCEL_ENV) == "1" or self._config_value(
88110
_OIDC_TOKEN_ENV
89111
):
90-
return oidc.get_vercel_oidc_token(), "oidc"
112+
return _get_vercel_oidc_token(), "oidc"
91113
return None, None
92114

93115
def is_configured(self) -> bool:

tests/providers/ai_gateway/test_provider.py

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,43 @@
11
from __future__ import annotations
22

3+
import importlib
4+
from collections.abc import Callable
5+
from types import ModuleType
6+
37
import httpx
48
import pytest
5-
from vercel import oidc as vercel_oidc
69

710
import ai
811
from ai.providers.ai_gateway.client import errors
912

1013

14+
def _set_oidc_token(
15+
monkeypatch: pytest.MonkeyPatch,
16+
get_token: Callable[[], str],
17+
) -> None:
18+
real_import_module = importlib.import_module
19+
oidc = ModuleType("vercel.oidc")
20+
oidc.__dict__["get_vercel_oidc_token"] = get_token
21+
22+
def _import_module(name: str, package: str | None = None) -> ModuleType:
23+
if name == "vercel.oidc":
24+
return oidc
25+
return real_import_module(name, package)
26+
27+
monkeypatch.setattr(importlib, "import_module", _import_module)
28+
29+
30+
def _fail_oidc_import(monkeypatch: pytest.MonkeyPatch) -> None:
31+
real_import_module = importlib.import_module
32+
33+
def _import_module(name: str, package: str | None = None) -> ModuleType:
34+
if name == "vercel.oidc":
35+
pytest.fail("OIDC should not be imported when an API key is set")
36+
return real_import_module(name, package)
37+
38+
monkeypatch.setattr(importlib, "import_module", _import_module)
39+
40+
1141
async def test_list_models_gets_config_with_gateway_headers_and_sorts_ids() -> (
1242
None
1343
):
@@ -79,11 +109,7 @@ async def test_list_models_uses_oidc_on_vercel_when_no_api_key(
79109
) -> None:
80110
monkeypatch.delenv("AI_GATEWAY_API_KEY", raising=False)
81111
monkeypatch.setenv("VERCEL", "1")
82-
monkeypatch.setattr(
83-
vercel_oidc,
84-
"get_vercel_oidc_token",
85-
lambda: "oidc-test-token",
86-
)
112+
_set_oidc_token(monkeypatch, lambda: "oidc-test-token")
87113
captured_headers: dict[str, str] = {}
88114

89115
def _handler(request: httpx.Request) -> httpx.Response:
@@ -115,11 +141,7 @@ async def test_list_models_uses_oidc_token_env_without_vercel_flag(
115141
monkeypatch.delenv("AI_GATEWAY_API_KEY", raising=False)
116142
monkeypatch.delenv("VERCEL", raising=False)
117143
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "pulled-oidc-token")
118-
monkeypatch.setattr(
119-
vercel_oidc,
120-
"get_vercel_oidc_token",
121-
lambda: "pulled-oidc-token",
122-
)
144+
_set_oidc_token(monkeypatch, lambda: "pulled-oidc-token")
123145
captured_headers: dict[str, str] = {}
124146

125147
def _handler(request: httpx.Request) -> httpx.Response:
@@ -141,20 +163,45 @@ def _handler(request: httpx.Request) -> httpx.Response:
141163
assert captured_headers["ai-gateway-auth-method"] == "oidc"
142164

143165

144-
async def test_api_key_env_takes_precedence_over_oidc(
166+
async def test_oidc_expected_without_vercel_extra_raises_installation_error(
145167
monkeypatch: pytest.MonkeyPatch,
146168
) -> None:
147-
monkeypatch.setenv("AI_GATEWAY_API_KEY", "env-test-key")
169+
monkeypatch.delenv("AI_GATEWAY_API_KEY", raising=False)
148170
monkeypatch.setenv("VERCEL", "1")
171+
real_import_module = importlib.import_module
172+
173+
def _import_module(name: str, package: str | None = None) -> ModuleType:
174+
if name == "vercel.oidc":
175+
raise ModuleNotFoundError(name="vercel")
176+
return real_import_module(name, package)
149177

150-
def _fail_oidc() -> str:
151-
pytest.fail("OIDC should not be fetched when an API key is set")
178+
def _handler(request: httpx.Request) -> httpx.Response:
179+
pytest.fail("Gateway should not be called without the OIDC helper")
152180

153-
monkeypatch.setattr(
154-
vercel_oidc,
155-
"get_vercel_oidc_token",
156-
_fail_oidc,
181+
monkeypatch.setattr(importlib, "import_module", _import_module)
182+
provider = ai.get_provider(
183+
"vercel",
184+
base_url="https://gateway.test/v3/ai",
185+
client=httpx.AsyncClient(transport=httpx.MockTransport(_handler)),
157186
)
187+
188+
try:
189+
with pytest.raises(ai.InstallationError) as exc_info:
190+
await provider.list_models()
191+
finally:
192+
await provider.aclose()
193+
194+
assert "AI Gateway OIDC authentication requires" in str(exc_info.value)
195+
assert "ai[vercel]" in str(exc_info.value)
196+
assert "AI_GATEWAY_API_KEY" in str(exc_info.value)
197+
198+
199+
async def test_api_key_env_takes_precedence_over_oidc(
200+
monkeypatch: pytest.MonkeyPatch,
201+
) -> None:
202+
monkeypatch.setenv("AI_GATEWAY_API_KEY", "env-test-key")
203+
monkeypatch.setenv("VERCEL", "1")
204+
_fail_oidc_import(monkeypatch)
158205
captured_headers: dict[str, str] = {}
159206

160207
def _handler(request: httpx.Request) -> httpx.Response:
@@ -181,15 +228,7 @@ async def test_explicit_api_key_takes_precedence_over_oidc(
181228
) -> None:
182229
monkeypatch.setenv("AI_GATEWAY_API_KEY", "env-test-key")
183230
monkeypatch.setenv("VERCEL", "1")
184-
185-
def _fail_oidc() -> str:
186-
pytest.fail("OIDC should not be fetched when an API key is set")
187-
188-
monkeypatch.setattr(
189-
vercel_oidc,
190-
"get_vercel_oidc_token",
191-
_fail_oidc,
192-
)
231+
_fail_oidc_import(monkeypatch)
193232
captured_headers: dict[str, str] = {}
194233

195234
def _handler(request: httpx.Request) -> httpx.Response:

uv.lock

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)