Skip to content

Commit 77b9ba6

Browse files
yjwyjw
authored andcommitted
fix: persist llm config across terminal restarts
1 parent 5249089 commit 77b9ba6

5 files changed

Lines changed: 158 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Fixed
1616
- Fixed chat search flows that previously surfaced raw `/reach` output or still nudged users to run `/reach` manually instead of returning a summarized answer directly.
1717
- Hardened LLM replay prompts so `young daily --llm` stays on the stock-analysis six-module framework and does not drift into persona-style investment templates.
18+
- Fixed LLM config persistence so `young config llm --api-key-env ...` also stores a local fallback key, which keeps `young chat` working across fresh terminal sessions without forcing users to re-export the secret every time.
1819

1920
## [0.2.8] - 2026-06-19
2021

src/young_stock/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ def save_config(data: dict[str, Any]) -> dict[str, Any]:
8787
def update_llm_config(**values: Any) -> dict[str, Any]:
8888
config = load_config(strict=False)
8989
llm = config.setdefault("llm", {})
90+
env_name = str(values.get("api_key_env") or "").strip()
91+
if env_name and values.get("api_key") is None:
92+
resolved = os.environ.get(env_name)
93+
if resolved:
94+
values["api_key"] = resolved
9095
for key, value in values.items():
9196
if value is not None:
9297
llm[key] = value

tests/test_cli.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,106 @@ def test_cli_config_llm_saves_and_masks_secret(monkeypatch, tmp_path):
659659
assert "deepseek-chat" in shown.output
660660

661661

662+
def test_cli_config_llm_persists_all_core_fields(monkeypatch, tmp_path):
663+
from click.testing import CliRunner
664+
665+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
666+
monkeypatch.setenv("MODEL_KEY", "env-secret")
667+
runner = CliRunner()
668+
669+
result = runner.invoke(
670+
cli,
671+
[
672+
"config",
673+
"llm",
674+
"--provider",
675+
"deepseek",
676+
"--model",
677+
"deepseek-chat",
678+
"--api-key-env",
679+
"MODEL_KEY",
680+
"--api-base",
681+
"https://api.deepseek.com",
682+
"--timeout",
683+
"45",
684+
"--max-tokens",
685+
"8192",
686+
],
687+
)
688+
689+
assert result.exit_code == 0
690+
config = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
691+
llm = config["llm"]
692+
assert llm["provider"] == "deepseek"
693+
assert llm["model"] == "deepseek-chat"
694+
assert llm["api_key_env"] == "MODEL_KEY"
695+
assert llm["api_key"] == "env-secret"
696+
assert llm["api_base"] == "https://api.deepseek.com"
697+
assert llm["timeout"] == 45
698+
assert llm["max_tokens"] == 8192
699+
700+
701+
def test_cli_config_llm_with_api_key_env_persists_fallback(monkeypatch, tmp_path):
702+
from click.testing import CliRunner
703+
704+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
705+
monkeypatch.setenv("MODEL_KEY", "env-secret")
706+
runner = CliRunner()
707+
708+
saved = runner.invoke(
709+
cli,
710+
[
711+
"config",
712+
"llm",
713+
"--provider",
714+
"deepseek",
715+
"--model",
716+
"deepseek-chat",
717+
"--api-key-env",
718+
"MODEL_KEY",
719+
],
720+
)
721+
722+
assert saved.exit_code == 0
723+
config = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
724+
assert config["llm"]["api_key_env"] == "MODEL_KEY"
725+
assert config["llm"]["api_key"] == "env-secret"
726+
727+
728+
def test_cli_config_channel_add_persists_app_delivery_fields(monkeypatch, tmp_path):
729+
from click.testing import CliRunner
730+
731+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
732+
runner = CliRunner()
733+
734+
result = runner.invoke(
735+
cli,
736+
[
737+
"config",
738+
"channel",
739+
"add",
740+
"feishu",
741+
"work",
742+
"--app-id",
743+
"cli_a1",
744+
"--app-secret",
745+
"secret-123",
746+
"--receive-id",
747+
"oc_test_chat",
748+
"--receive-id-type",
749+
"chat_id",
750+
],
751+
)
752+
753+
assert result.exit_code == 0
754+
config = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
755+
channel = config["channels"]["feishu"]["work"]
756+
assert channel["app_id"] == "cli_a1"
757+
assert channel["app_secret"] == "secret-123"
758+
assert channel["receive_id"] == "oc_test_chat"
759+
assert channel["receive_id_type"] == "chat_id"
760+
761+
662762
def test_cli_daily_llm_uses_enhanced_path(monkeypatch, tmp_path):
663763
from click.testing import CliRunner
664764

tests/test_config.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ def test_config_round_trip_uses_young_home(monkeypatch, tmp_path):
2828
assert stat.S_IMODE((tmp_path / "config.json").stat().st_mode) & 0o077 == 0
2929

3030

31+
def test_config_persists_api_key_env_fallback(monkeypatch, tmp_path):
32+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
33+
monkeypatch.setenv("MODEL_KEY", "env-secret")
34+
35+
config = update_llm_config(
36+
provider="deepseek",
37+
model="deepseek-chat",
38+
api_key_env="MODEL_KEY",
39+
)
40+
41+
assert config["llm"]["api_key_env"] == "MODEL_KEY"
42+
assert config["llm"]["api_key"] == "env-secret"
43+
assert load_config()["llm"]["api_key"] == "env-secret"
44+
45+
3146
def test_config_masks_secrets_and_webhook_tokens():
3247
masked = mask_config(
3348
{
@@ -97,3 +112,22 @@ def test_feishu_channel_accepts_preissued_tenant_token(monkeypatch, tmp_path):
97112
)
98113

99114
assert config["channels"]["feishu"]["token-app"]["tenant_access_token"] == "token"
115+
116+
117+
def test_feishu_channel_round_trips_app_credentials(monkeypatch, tmp_path):
118+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
119+
120+
config = add_feishu_channel(
121+
"work",
122+
{
123+
"app_id": "cli_a1",
124+
"app_secret": "secret-123",
125+
"receive_id": "oc_test_chat",
126+
"receive_id_type": "chat_id",
127+
},
128+
)
129+
130+
channel = config["channels"]["feishu"]["work"]
131+
assert channel["app_id"] == "cli_a1"
132+
assert channel["app_secret"] == "secret-123"
133+
assert load_config()["channels"]["feishu"]["work"]["receive_id"] == "oc_test_chat"

tests/test_llm.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,24 @@ def test_api_key_env_takes_precedence(monkeypatch):
106106
assert session.calls[0][1]["headers"]["Authorization"] == "Bearer env-secret"
107107

108108

109+
def test_api_key_falls_back_to_saved_secret_when_env_missing(monkeypatch):
110+
monkeypatch.delenv("MODEL_KEY", raising=False)
111+
session = FakeSession([response(200, {"choices": [{"message": {"content": "ok"}}]})])
112+
client = LLMClient(
113+
{
114+
"provider": "openai",
115+
"model": "gpt-test",
116+
"api_key": "saved-secret",
117+
"api_key_env": "MODEL_KEY",
118+
},
119+
session=session,
120+
)
121+
122+
client.chat([{"role": "user", "content": "hi"}])
123+
124+
assert session.calls[0][1]["headers"]["Authorization"] == "Bearer saved-secret"
125+
126+
109127
def test_openai_compatible_model_discovery():
110128
session = FakeSession(
111129
[

0 commit comments

Comments
 (0)