Skip to content

Commit c0f7406

Browse files
Add AI coding agent detection to User-Agent header
Detect when the Go SQL driver is invoked by an AI coding agent (e.g. Claude Code, Cursor, Gemini CLI) by checking well-known environment variables, and append `agent/<product>` to the User-Agent string. This enables Databricks to understand how much driver usage originates from AI coding agents. Detection only succeeds when exactly one agent is detected to avoid ambiguous attribution. Mirrors the approach in databricks/cli#4287. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 98148d9 commit c0f7406

File tree

3 files changed

+141
-0
lines changed

3 files changed

+141
-0
lines changed

internal/agent/agent.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Package agent detects whether the Go SQL driver is being invoked by an AI
2+
// coding agent by checking for well-known environment variables that agents set
3+
// in their spawned shell processes.
4+
//
5+
// Detection only succeeds when exactly one agent environment variable is
6+
// present, to avoid ambiguous attribution when multiple agent environments
7+
// overlap.
8+
//
9+
// Adding a new agent requires only a new constant and a new entry in
10+
// knownAgents.
11+
//
12+
// References for each environment variable:
13+
// - ANTIGRAVITY_AGENT: Closed source. Google Antigravity sets this variable.
14+
// - CLAUDECODE: https://github.com/anthropics/claude-code (sets CLAUDECODE=1)
15+
// - CLINE_ACTIVE: https://github.com/cline/cline (shipped in v3.24.0)
16+
// - CODEX_CI: https://github.com/openai/codex (part of UNIFIED_EXEC_ENV array in codex-rs)
17+
// - CURSOR_AGENT: Closed source. Referenced in a gist by johnlindquist.
18+
// - GEMINI_CLI: https://google-gemini.github.io/gemini-cli/docs/tools/shell.html (sets GEMINI_CLI=1)
19+
// - OPENCODE: https://github.com/opencode-ai/opencode (sets OPENCODE=1)
20+
package agent
21+
22+
import "os"
23+
24+
const (
25+
Antigravity = "antigravity"
26+
ClaudeCode = "claude-code"
27+
Cline = "cline"
28+
Codex = "codex"
29+
Cursor = "cursor"
30+
GeminiCLI = "gemini-cli"
31+
OpenCode = "opencode"
32+
)
33+
34+
var knownAgents = []struct {
35+
envVar string
36+
product string
37+
}{
38+
{"ANTIGRAVITY_AGENT", Antigravity},
39+
{"CLAUDECODE", ClaudeCode},
40+
{"CLINE_ACTIVE", Cline},
41+
{"CODEX_CI", Codex},
42+
{"CURSOR_AGENT", Cursor},
43+
{"GEMINI_CLI", GeminiCLI},
44+
{"OPENCODE", OpenCode},
45+
}
46+
47+
// Detect returns the product string of the AI coding agent driving the current
48+
// process, or an empty string if no agent (or multiple agents) are detected.
49+
func Detect() string {
50+
return detect(os.Getenv)
51+
}
52+
53+
// detect is the internal implementation that accepts an env lookup function
54+
// for testability.
55+
func detect(getenv func(string) string) string {
56+
var detected []string
57+
for _, a := range knownAgents {
58+
if getenv(a.envVar) != "" {
59+
detected = append(detected, a.product)
60+
}
61+
}
62+
if len(detected) == 1 {
63+
return detected[0]
64+
}
65+
return ""
66+
}

internal/agent/agent_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package agent
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func envWith(vars map[string]string) func(string) string {
8+
return func(key string) string {
9+
return vars[key]
10+
}
11+
}
12+
13+
func TestDetectsSingleAgent(t *testing.T) {
14+
cases := []struct {
15+
envVar string
16+
product string
17+
}{
18+
{"ANTIGRAVITY_AGENT", Antigravity},
19+
{"CLAUDECODE", ClaudeCode},
20+
{"CLINE_ACTIVE", Cline},
21+
{"CODEX_CI", Codex},
22+
{"CURSOR_AGENT", Cursor},
23+
{"GEMINI_CLI", GeminiCLI},
24+
{"OPENCODE", OpenCode},
25+
}
26+
for _, tc := range cases {
27+
t.Run(tc.product, func(t *testing.T) {
28+
got := detect(envWith(map[string]string{tc.envVar: "1"}))
29+
if got != tc.product {
30+
t.Errorf("detect() = %q, want %q", got, tc.product)
31+
}
32+
})
33+
}
34+
}
35+
36+
func TestReturnsEmptyWhenNoAgent(t *testing.T) {
37+
got := detect(envWith(map[string]string{}))
38+
if got != "" {
39+
t.Errorf("detect() = %q, want empty", got)
40+
}
41+
}
42+
43+
func TestReturnsEmptyWhenMultipleAgents(t *testing.T) {
44+
got := detect(envWith(map[string]string{
45+
"CLAUDECODE": "1",
46+
"CURSOR_AGENT": "1",
47+
}))
48+
if got != "" {
49+
t.Errorf("detect() = %q, want empty", got)
50+
}
51+
}
52+
53+
func TestIgnoresEmptyValues(t *testing.T) {
54+
got := detect(envWith(map[string]string{"CLAUDECODE": ""}))
55+
if got != "" {
56+
t.Errorf("detect() = %q, want empty", got)
57+
}
58+
}
59+
60+
func TestDetectUsesOsGetenv(t *testing.T) {
61+
// Clear all known agent env vars, then set one
62+
for _, a := range knownAgents {
63+
t.Setenv(a.envVar, "")
64+
}
65+
t.Setenv("CLAUDECODE", "1")
66+
67+
got := Detect()
68+
if got != ClaudeCode {
69+
t.Errorf("Detect() = %q, want %q", got, ClaudeCode)
70+
}
71+
}

internal/client/client.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"time"
2121

2222
dbsqlerr "github.com/databricks/databricks-sql-go/errors"
23+
"github.com/databricks/databricks-sql-go/internal/agent"
2324
dbsqlerrint "github.com/databricks/databricks-sql-go/internal/errors"
2425

2526
"github.com/apache/thrift/lib/go/thrift"
@@ -295,6 +296,9 @@ func InitThriftClient(cfg *config.Config, httpclient *http.Client) (*ThriftServi
295296
if cfg.UserAgentEntry != "" {
296297
userAgent = fmt.Sprintf("%s/%s (%s)", cfg.DriverName, cfg.DriverVersion, cfg.UserAgentEntry)
297298
}
299+
if agentProduct := agent.Detect(); agentProduct != "" {
300+
userAgent = fmt.Sprintf("%s agent/%s", userAgent, agentProduct)
301+
}
298302
thriftHttpClient.SetHeader("User-Agent", userAgent)
299303

300304
default:

0 commit comments

Comments
 (0)