Skip to content

Commit d18e039

Browse files
Honor AI_AGENT and pass raw values through (#1454)
## Why The Python SDK detects AI coding agents and surfaces them as `agent/<name>` in the User-Agent. Today the generic fallback (when no proprietary env var fires) only honors the agents.md `AGENT=<name>` standard. Vercel's `@vercel/detect-agent` library uses a parallel `AI_AGENT=<name>` convention that tools in the Vercel ecosystem set instead; we currently miss those. Separately, the existing fallback coerces any unrecognized value to the literal string `"unknown"`. That buries useful signal: a tool setting `AI_AGENT=claude-code_2-1-141_agent` ends up as `agent/unknown`, discarding the very signal (tool name plus version variant) we want to see. Bucketing arbitrary names is an ETL concern, not the SDK's. This mirrors the Go SDK change in databricks/databricks-sdk-go#1683. ## Changes Two behavior changes in `databricks/sdk/useragent.py`: 1. **`AI_AGENT` fallback.** Add `AI_AGENT=<name>` as a secondary fallback after `AGENT=<name>`. `AGENT` wins when both are set to non-empty values; empty is treated as unset for both. Explicit product matchers (e.g. `CLAUDECODE`) still always win over both. 2. **Raw passthrough instead of `"unknown"`.** Drop the known-product lookup in the fallback. The value is sanitized (disallowed chars become `-`, satisfying the User-Agent allowlist `[0-9A-Za-z_.+-]+`) and capped at 64 chars to keep the header bounded. Known products like `cursor` or `claude-code` pass through unchanged because they already satisfy the allowlist. Same change is landing in `databricks-sdk-java` as a sibling PR. ## Test plan - [x] `pytest tests/test_user_agent.py` passes (54 tests) - [x] `ruff format` / `ruff check` clean - [x] `AI_AGENT=<known product>` returns the product name - [x] `AI_AGENT=<unrecognized>` returns the raw sanitized value (no longer `"unknown"`) - [x] `AGENT` wins over `AI_AGENT` when both are non-empty - [x] Empty `AGENT` falls through to `AI_AGENT` - [x] Disallowed chars in `AGENT` / `AI_AGENT` are sanitized to `-` - [x] Values longer than 64 chars are truncated - [x] Explicit matcher (e.g. `CLAUDECODE`) still wins over both fallbacks
1 parent caecb8b commit d18e039

3 files changed

Lines changed: 145 additions & 21 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### New Features and Improvements
66

7+
* Honor the Vercel `AI_AGENT=<name>` env var as a secondary fallback for AI agent detection in the User-Agent header (after the agents.md `AGENT=<name>` standard). Unrecognized fallback values now pass through the User-Agent sanitized and length-capped at 64 chars instead of being coerced to `agent/unknown`, so versioned variants such as `claude-code_2-1-141_agent` surface as-is.
8+
79
### Security
810

911
### Bug Fixes

databricks/sdk/useragent.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
# Precompiled regex patterns
2424
alphanum_pattern = re.compile(r"^[a-zA-Z0-9_.+-]+$")
2525

26+
# Matches any single character not allowed in a User-Agent token. Used to
27+
# sanitize free-form values (e.g. the AGENT/AI_AGENT fallback) by replacing
28+
# disallowed characters with a hyphen.
29+
alphanum_inverse_pattern = re.compile(r"[^a-zA-Z0-9_.+-]")
30+
2631
# official https://semver.org/ recommendation: https://regex101.com/r/Ly7O1x/
2732
# with addition of "x" wildcards for minor/patch versions. Also, patch version may be omitted.
2833
semver_pattern = re.compile(
@@ -133,6 +138,11 @@ def _sanitize_header_value(value: str) -> str:
133138
return value
134139

135140

141+
def _sanitize_agent_value(value: str) -> str:
142+
"""Replace any character not allowed in a User-Agent token with a hyphen."""
143+
return alphanum_inverse_pattern.sub("-", value)
144+
145+
136146
def to_string(
137147
alternate_product_info: Optional[Tuple[str, str]] = None,
138148
other_info: Optional[List[Tuple[str, str]]] = None,
@@ -226,7 +236,8 @@ def cicd_provider() -> str:
226236

227237

228238
# Canonical list of known AI coding agents. Alphabetical by product name.
229-
# Keep this list in sync with databricks-sdk-go and databricks-sdk-java.
239+
# Keep this list, and the AGENT / AI_AGENT fallback handling in
240+
# _agent_env_fallback, in sync with databricks-sdk-go and databricks-sdk-java.
230241
#
231242
# Each record has a single env var that identifies the product by presence
232243
# (the env var just needs to be set, even to an empty string).
@@ -236,6 +247,12 @@ class _AgentRecord:
236247
product: str
237248

238249

250+
# Caps fallback values to keep the User-Agent bounded. Explicit-matcher
251+
# products are short by construction; only the fallback path can carry
252+
# arbitrary lengths.
253+
_MAX_AGENT_FALLBACK_LEN = 64
254+
255+
239256
_KNOWN_AGENTS: List[_AgentRecord] = [
240257
_AgentRecord("AMP_CURRENT_THREAD_ID", "amp"), # https://ampcode.com/ (also sets AGENT=amp, handled centrally)
241258
_AgentRecord("ANTIGRAVITY_AGENT", "antigravity"), # Closed source (Google)
@@ -274,13 +291,12 @@ def agent_provider() -> str:
274291
every enclosing layer).
275292
276293
Explicit agent env vars (e.g. CLAUDECODE, GOOSE_TERMINAL) always take
277-
precedence. The agents.md-standard AGENT=<name> env var is only consulted
278-
as a fallback when no explicit matcher fired:
279-
- If AGENT matches a known product name, return that product.
280-
- Otherwise return "unknown".
294+
precedence. The agents.md-standard AGENT=<name> env var and the Vercel
295+
AI_AGENT=<name> convention are only consulted as a fallback when no
296+
explicit matcher fired (see _agent_env_fallback).
281297
282-
This means AGENT=<name> never contributes to the multi-agent signal: if
283-
any explicit matcher fires, AGENT is ignored entirely, even when it names
298+
This means AGENT/AI_AGENT never contribute to the multi-agent signal: if
299+
any explicit matcher fires, they are ignored entirely, even when they name
284300
a different known product.
285301
286302
Result is cached after first call.
@@ -301,14 +317,15 @@ def agent_provider() -> str:
301317

302318

303319
def _agent_env_fallback() -> str:
304-
"""Honor the agents.md AGENT=<name> standard.
320+
"""Return a sanitized, length-capped name from AGENT or AI_AGENT.
305321
306-
Returns the value if it matches a known product name, "unknown" if AGENT
307-
is set to any other non-empty value, and "" if AGENT is unset or empty.
322+
AGENT (the agents.md standard) is preferred; AI_AGENT (the Vercel
323+
@vercel/detect-agent convention) is consulted only when AGENT is unset or
324+
empty. The value is passed through rather than categorized so that new
325+
names are propagated without updating the list of known agents. Returns ""
326+
if both are unset or empty.
308327
"""
309-
v = os.environ.get("AGENT", "")
328+
v = os.environ.get("AGENT") or os.environ.get("AI_AGENT")
310329
if not v:
311330
return ""
312-
if v in {a.product for a in _KNOWN_AGENTS}:
313-
return v
314-
return "unknown"
331+
return _sanitize_agent_value(v)[:_MAX_AGENT_FALLBACK_LEN]

tests/test_user_agent.py

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -256,25 +256,50 @@ def test_agent_provider_windsurf(clean_useragent_env):
256256
assert useragent.agent_provider() == "windsurf"
257257

258258

259-
def test_agent_provider_unknown_agent_fallback(clean_useragent_env):
260-
# AGENT set to a value that doesn't match any known agent
261-
# should fall back to "unknown".
259+
def test_agent_provider_unknown_agent_passthrough(clean_useragent_env):
260+
# AGENT set to a value that doesn't match any known agent now passes
261+
# through (sanitized) rather than being coerced to "unknown".
262262
os.environ["AGENT"] = "someweirdthing"
263263
from databricks.sdk import useragent
264264

265-
assert useragent.agent_provider() == "unknown"
265+
assert useragent.agent_provider() == "someweirdthing"
266266

267267

268268
def test_agent_provider_agent_known_product_name_fallback(clean_useragent_env):
269-
# AGENT=<known product name> with no other matchers set should resolve
270-
# to the matching product (e.g. cursor is only identified by CURSOR_AGENT;
271-
# AGENT=cursor is a reasonable implicit signal to attribute it).
269+
# AGENT=<known product name> with no other matchers set passes through
270+
# unchanged (cursor is only identified by CURSOR_AGENT; AGENT=cursor is a
271+
# reasonable implicit signal to attribute it).
272272
os.environ["AGENT"] = "cursor"
273273
from databricks.sdk import useragent
274274

275275
assert useragent.agent_provider() == "cursor"
276276

277277

278+
def test_agent_provider_agent_versioned_variant_passthrough(clean_useragent_env):
279+
# A versioned variant passes through unchanged since every character is
280+
# already in the allowlist.
281+
os.environ["AGENT"] = "claude-code_2-1-141_agent"
282+
from databricks.sdk import useragent
283+
284+
assert useragent.agent_provider() == "claude-code_2-1-141_agent"
285+
286+
287+
def test_agent_provider_agent_disallowed_chars_sanitized(clean_useragent_env):
288+
# Characters outside the User-Agent allowlist are replaced with hyphens.
289+
os.environ["AGENT"] = "claude code/agent"
290+
from databricks.sdk import useragent
291+
292+
assert useragent.agent_provider() == "claude-code-agent"
293+
294+
295+
def test_agent_provider_agent_over_cap_truncated(clean_useragent_env):
296+
# Values longer than the cap are truncated to 64 characters.
297+
os.environ["AGENT"] = "a" * 100
298+
from databricks.sdk import useragent
299+
300+
assert useragent.agent_provider() == "a" * 64
301+
302+
278303
def test_agent_provider_known_matcher_wins_over_agent_fallback(clean_useragent_env):
279304
# When a known matcher fires, it wins even if AGENT is set to an
280305
# unrelated value. The AGENT fallback only applies when nothing else hits.
@@ -293,6 +318,86 @@ def test_agent_provider_agent_empty_string(clean_useragent_env):
293318
assert useragent.agent_provider() == ""
294319

295320

321+
def test_agent_provider_ai_agent_fallback(clean_useragent_env):
322+
# AI_AGENT (Vercel @vercel/detect-agent convention) is consulted as a
323+
# secondary fallback when AGENT is unset.
324+
os.environ["AI_AGENT"] = "cursor"
325+
from databricks.sdk import useragent
326+
327+
assert useragent.agent_provider() == "cursor"
328+
329+
330+
def test_agent_provider_ai_agent_empty_string(clean_useragent_env):
331+
# AI_AGENT="" (empty) should NOT trigger the fallback.
332+
os.environ["AI_AGENT"] = ""
333+
from databricks.sdk import useragent
334+
335+
assert useragent.agent_provider() == ""
336+
337+
338+
def test_agent_provider_known_matcher_wins_over_ai_agent_fallback(clean_useragent_env):
339+
# An explicit matcher wins over the AI_AGENT fallback.
340+
os.environ["AI_AGENT"] = "somethingunknown"
341+
os.environ["CLAUDECODE"] = "1"
342+
from databricks.sdk import useragent
343+
344+
assert useragent.agent_provider() == "claude-code"
345+
346+
347+
def test_agent_provider_agent_wins_over_ai_agent(clean_useragent_env):
348+
# AGENT takes precedence over AI_AGENT when both are non-empty.
349+
os.environ["AGENT"] = "claude-code"
350+
os.environ["AI_AGENT"] = "cursor"
351+
from databricks.sdk import useragent
352+
353+
assert useragent.agent_provider() == "claude-code"
354+
355+
356+
def test_agent_provider_agent_unrecognized_wins_over_ai_agent(clean_useragent_env):
357+
# A non-empty AGENT wins over AI_AGENT even when it is unrecognized.
358+
os.environ["AGENT"] = "somethingunknown"
359+
os.environ["AI_AGENT"] = "cursor"
360+
from databricks.sdk import useragent
361+
362+
assert useragent.agent_provider() == "somethingunknown"
363+
364+
365+
def test_agent_provider_agent_set_ai_agent_empty(clean_useragent_env):
366+
# AGENT set, AI_AGENT empty: AGENT value is used.
367+
os.environ["AGENT"] = "cursor"
368+
os.environ["AI_AGENT"] = ""
369+
from databricks.sdk import useragent
370+
371+
assert useragent.agent_provider() == "cursor"
372+
373+
374+
def test_agent_provider_empty_agent_falls_through_to_ai_agent(clean_useragent_env):
375+
# An empty AGENT falls through to AI_AGENT.
376+
os.environ["AGENT"] = ""
377+
os.environ["AI_AGENT"] = "cursor"
378+
from databricks.sdk import useragent
379+
380+
assert useragent.agent_provider() == "cursor"
381+
382+
383+
def test_agent_provider_both_agent_and_ai_agent_empty(clean_useragent_env):
384+
# Both AGENT and AI_AGENT empty returns no agent.
385+
os.environ["AGENT"] = ""
386+
os.environ["AI_AGENT"] = ""
387+
from databricks.sdk import useragent
388+
389+
assert useragent.agent_provider() == ""
390+
391+
392+
def test_agent_provider_explicit_wins_over_ai_agent(clean_useragent_env):
393+
# An explicit env var wins over AI_AGENT naming a different product.
394+
os.environ["AI_AGENT"] = "cursor"
395+
os.environ["CLAUDECODE"] = "1"
396+
from databricks.sdk import useragent
397+
398+
assert useragent.agent_provider() == "claude-code"
399+
400+
296401
def test_agent_provider_multiple_agents(clean_useragent_env):
297402
# Nested agents (e.g. Claude Code spawning a Cursor CLI subagent) set
298403
# multiple explicit matchers on the same process.

0 commit comments

Comments
 (0)