Skip to content

Commit a034114

Browse files
lucia-sbclaude
andauthored
Add AI config block to ddev config model (DataDog#23894)
* Add ai block to ddev config model for Anthropic API key and flow dirs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add changelog for ddev AI config * Cover ddev AI config display * Fix changelog filename to match PR DataDog#23894. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Move get_anthropic_api_key to top of model.py and add precedence test Move get_anthropic_api_key() next to get_github_token() so all module-level env-var helpers are grouped before any class definitions. Add a test verifying config-file value takes precedence over DD_ANTHROPIC_API_KEY env var. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(ddev/config): replace hardcoded scrub_config with glob-based _scrub_path helper - Extract _scrub_path to scrub arbitrary nested config paths using dot-notation globs - Replace per-field scrub_config logic with a SCRUBBED_GLOBS-driven loop Rationale: makes adding new sensitive fields trivial without touching scrubbing logic This commit made by [/dd:git:commit:quick](https://github.com/DataDog/claude-marketplace/tree/main/dd/commands/git/commit/quick.md) * Add unit tests for scrub_config traversal semantics Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Use app.config.ai.anthropic_api_key in dynamicd command * Mention DD_ANTHROPIC_API_KEY in missing-key error message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 54edd6e commit a034114

8 files changed

Lines changed: 411 additions & 25 deletions

File tree

ddev/changelog.d/23894.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add an AI configuration block for ddev.

ddev/src/ddev/cli/meta/scripts/_dynamicd/cli.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from __future__ import annotations
77

8-
import os
98
from typing import TYPE_CHECKING
109

1110
import click
@@ -78,19 +77,15 @@ def _get_api_keys(app: Application) -> tuple[str, str]:
7877
7978
Returns (llm_api_key, dd_api_key) or aborts if not configured.
8079
"""
81-
# Get LLM API key from config or environment variable
82-
llm_api_key = app.config.raw_data.get("dynamicd", {}).get("llm_api_key")
83-
if not llm_api_key:
84-
llm_api_key = os.environ.get("ANTHROPIC_API_KEY")
80+
llm_api_key = app.config.ai.anthropic_api_key
8581
if not llm_api_key:
8682
app.display_error(
87-
"LLM API key not configured. Either:\n"
88-
" 1. Set env var: export ANTHROPIC_API_KEY=<your-key>\n"
89-
" 2. Or run: ddev config set dynamicd.llm_api_key <your-key>"
83+
"Anthropic API key not configured. Either:\n"
84+
" 1. Set env var: export ANTHROPIC_API_KEY=<your-key> (or DD_ANTHROPIC_API_KEY)\n"
85+
" 2. Or run: ddev config set ai.anthropic_api_key <your-key>"
9086
)
9187
app.abort()
9288

93-
# Get Datadog API key from org config
9489
dd_api_key = app.config.org.config.get("api_key")
9590
if not dd_api_key:
9691
app.display_error(

ddev/src/ddev/config/model.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ def get_github_token():
1919
return os.environ.get('DD_GITHUB_TOKEN', '') or os.environ.get('GH_TOKEN', '') or os.environ.get('GITHUB_TOKEN', '')
2020

2121

22+
def get_anthropic_api_key():
23+
return os.environ.get('DD_ANTHROPIC_API_KEY', '') or os.environ.get('ANTHROPIC_API_KEY', '')
24+
25+
2226
class ConfigurationError(Exception):
2327
def __init__(self, *args, location):
2428
self.location = location
@@ -71,6 +75,7 @@ def __init__(self, *args, **kwargs):
7175
self._field_pypi = FIELD_TO_PARSE
7276
self._field_trello = FIELD_TO_PARSE
7377
self._field_terminal = FIELD_TO_PARSE
78+
self._field_ai = FIELD_TO_PARSE
7479
self._field_upgrade_check = FIELD_TO_PARSE
7580

7681
@property
@@ -331,6 +336,27 @@ def terminal(self, value):
331336
self.raw_data['terminal'] = value
332337
self._field_terminal = FIELD_TO_PARSE
333338

339+
@property
340+
def ai(self):
341+
if self._field_ai is FIELD_TO_PARSE:
342+
if 'ai' in self.raw_data:
343+
ai = self.raw_data['ai']
344+
if not isinstance(ai, dict):
345+
self.raise_error('must be a table')
346+
347+
self._field_ai = AIConfig(ai, ('ai',))
348+
else:
349+
ai = {}
350+
self.raw_data['ai'] = ai
351+
self._field_ai = AIConfig(ai, ('ai',))
352+
353+
return self._field_ai
354+
355+
@ai.setter
356+
def ai(self, value):
357+
self.raw_data['ai'] = value
358+
self._field_ai = FIELD_TO_PARSE
359+
334360

335361
class RepoConfig(LazilyParsedConfig):
336362
def __init__(self, *args, **kwargs):
@@ -780,3 +806,53 @@ def spinner(self):
780806
def spinner(self, value):
781807
self.raw_data['spinner'] = value
782808
self._field_spinner = FIELD_TO_PARSE
809+
810+
811+
class AIConfig(LazilyParsedConfig):
812+
def __init__(self, *args, **kwargs):
813+
super().__init__(*args, **kwargs)
814+
815+
self._field_anthropic_api_key = FIELD_TO_PARSE
816+
self._field_flow_dirs = FIELD_TO_PARSE
817+
818+
@property
819+
def anthropic_api_key(self):
820+
if self._field_anthropic_api_key is FIELD_TO_PARSE:
821+
if 'anthropic_api_key' in self.raw_data:
822+
key = self.raw_data['anthropic_api_key']
823+
if not isinstance(key, str):
824+
self.raise_error('must be a string')
825+
826+
self._field_anthropic_api_key = key
827+
else:
828+
self._field_anthropic_api_key = get_anthropic_api_key()
829+
830+
return self._field_anthropic_api_key
831+
832+
@anthropic_api_key.setter
833+
def anthropic_api_key(self, value):
834+
self.raw_data['anthropic_api_key'] = value
835+
self._field_anthropic_api_key = FIELD_TO_PARSE
836+
837+
@property
838+
def flow_dirs(self):
839+
if self._field_flow_dirs is FIELD_TO_PARSE:
840+
if 'flow_dirs' in self.raw_data:
841+
flow_dirs = self.raw_data['flow_dirs']
842+
if not isinstance(flow_dirs, list):
843+
self.raise_error('must be an array')
844+
845+
for i, entry in enumerate(flow_dirs):
846+
if not isinstance(entry, str):
847+
self.raise_error('must be a string', extra_steps=(str(i),))
848+
849+
self._field_flow_dirs = flow_dirs
850+
else:
851+
self._field_flow_dirs = self.raw_data['flow_dirs'] = []
852+
853+
return self._field_flow_dirs
854+
855+
@flow_dirs.setter
856+
def flow_dirs(self, value):
857+
self.raw_data['flow_dirs'] = value
858+
self._field_flow_dirs = FIELD_TO_PARSE

ddev/src/ddev/config/utils.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77
from ddev.utils.fs import Path
88

99
SCRUBBED_VALUE = '*****'
10-
SCRUBBED_GLOBS = ('github.token', 'pypi.auth', 'trello.token', 'orgs.*.api_key', 'orgs.*.app_key')
10+
SCRUBBED_GLOBS = (
11+
'github.token',
12+
'pypi.auth',
13+
'trello.token',
14+
'orgs.*.api_key',
15+
'orgs.*.app_key',
16+
'ai.anthropic_api_key',
17+
)
1118

1219

1320
def save_toml_document(document: TOMLDocument, path: Path):
@@ -23,17 +30,25 @@ def load_toml_data(path: Path) -> dict:
2330
return tomlkit.loads(path.read_text())
2431

2532

26-
def scrub_config(config: dict):
27-
if 'token' in config.get('github', {}):
28-
config['github']['token'] = SCRUBBED_VALUE
29-
30-
if 'auth' in config.get('pypi', {}):
31-
config['pypi']['auth'] = SCRUBBED_VALUE
32-
33-
if 'token' in config.get('trello', {}):
34-
config['trello']['token'] = SCRUBBED_VALUE
35-
36-
for data in config.get('orgs', {}).values():
37-
for key in ('api_key', 'app_key'):
38-
if key in data:
39-
data[key] = SCRUBBED_VALUE
33+
def _scrub_path(config: dict, path: str) -> None:
34+
parts = path.split('.')
35+
nodes = [config]
36+
for part in parts[:-1]:
37+
next_nodes: list[dict] = []
38+
for node in nodes:
39+
if not isinstance(node, dict):
40+
continue
41+
if part == '*':
42+
next_nodes.extend(node.values())
43+
elif part in node:
44+
next_nodes.append(node[part])
45+
nodes = next_nodes
46+
leaf = parts[-1]
47+
for node in nodes:
48+
if isinstance(node, dict) and leaf in node:
49+
node[leaf] = SCRUBBED_VALUE
50+
51+
52+
def scrub_config(config: dict) -> None:
53+
for glob in SCRUBBED_GLOBS:
54+
_scrub_path(config, glob)

ddev/tests/cli/config/test_show.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@
5757
waiting = "bold magenta"
5858
debug = "bold"
5959
spinner = "simpleDotsScrolling"
60+
61+
[ai]
62+
flow_dirs = []
63+
anthropic_api_key = "*****"
6064
"""
6165

6266
EXPECTED_NON_SCRUBBED_OUTPUT = f"""
@@ -105,6 +109,10 @@
105109
waiting = "bold magenta"
106110
debug = "bold"
107111
spinner = "simpleDotsScrolling"
112+
113+
[ai]
114+
flow_dirs = []
115+
anthropic_api_key = "sk-test"
108116
"""
109117

110118

@@ -114,6 +122,7 @@ def valid_config_file(config_file):
114122
config_file.model.orgs['default']['api_key'] = 'foo'
115123
config_file.model.orgs['default']['app_key'] = 'bar'
116124
config_file.model.github = {'user': '', 'token': ''}
125+
config_file.model.ai.anthropic_api_key = 'sk-test'
117126
config_file.save()
118127

119128

@@ -182,6 +191,10 @@ def build_expected_output_with_line_sources(expected: str, config_file: ConfigFi
182191
42: 'GlobalConfig:43',
183192
43: 'GlobalConfig:44',
184193
44: 'GlobalConfig:45',
194+
# Blank line
195+
46: 'GlobalConfig:47',
196+
47: 'GlobalConfig:48',
197+
48: 'GlobalConfig:49',
185198
}
186199

187200
# Add a blank line at the end to match the expected output

ddev/tests/cli/meta/scripts/test_dynamicd.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
and tag values from real integration dashboards.
88
"""
99

10+
from unittest.mock import MagicMock
11+
1012
import pytest
1113

1214
from ddev.repo.core import Repository
@@ -141,6 +143,41 @@ def test_to_prompt_context_generates_string(self, real_repo):
141143
assert 'redis' in prompt.lower()
142144

143145

146+
class TestGetApiKeys:
147+
"""Tests for _get_api_keys helper."""
148+
149+
def _make_app(self, anthropic_api_key: str, dd_api_key: str) -> MagicMock:
150+
app = MagicMock()
151+
app.config.ai.anthropic_api_key = anthropic_api_key
152+
app.config.org.config = {"api_key": dd_api_key}
153+
return app
154+
155+
def test_returns_both_keys_when_configured(self):
156+
from ddev.cli.meta.scripts._dynamicd.cli import _get_api_keys
157+
158+
app = self._make_app("sk-ant-test", "dd-key-123")
159+
llm_key, dd_key = _get_api_keys(app)
160+
161+
assert llm_key == "sk-ant-test"
162+
assert dd_key == "dd-key-123"
163+
164+
def test_aborts_when_llm_key_missing(self):
165+
from ddev.cli.meta.scripts._dynamicd.cli import _get_api_keys
166+
167+
app = self._make_app("", "dd-key-123")
168+
_get_api_keys(app)
169+
170+
app.abort.assert_called_once()
171+
172+
def test_aborts_when_dd_key_missing(self):
173+
from ddev.cli.meta.scripts._dynamicd.cli import _get_api_keys
174+
175+
app = self._make_app("sk-ant-test", "")
176+
_get_api_keys(app)
177+
178+
app.abort.assert_called_once()
179+
180+
144181
class TestReadMetrics:
145182
"""Tests for _read_metrics function."""
146183

0 commit comments

Comments
 (0)