zai-cli 0.2.0__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. {zai_cli-0.2.0 → zai_cli-0.2.3}/CHANGELOG.md +46 -0
  2. {zai_cli-0.2.0/zai_cli.egg-info → zai_cli-0.2.3}/PKG-INFO +24 -2
  3. {zai_cli-0.2.0 → zai_cli-0.2.3}/README.md +23 -1
  4. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_auth.py +42 -1
  5. zai_cli-0.2.3/tests/test_fallback_diagnostics.py +177 -0
  6. zai_cli-0.2.3/tests/test_file_mentions.py +30 -0
  7. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_input.py +16 -0
  8. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_interactive.py +26 -0
  9. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_settings_cli.py +20 -0
  10. zai_cli-0.2.3/zai/__init__.py +1 -0
  11. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/interactive.py +16 -14
  12. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/settings.py +49 -10
  13. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/auth.py +89 -13
  14. zai_cli-0.2.3/zai/core/fallback.py +257 -0
  15. zai_cli-0.2.3/zai/core/file_mentions.py +99 -0
  16. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/main.py +3 -1
  17. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/ui/input.py +14 -0
  18. {zai_cli-0.2.0 → zai_cli-0.2.3/zai_cli.egg-info}/PKG-INFO +24 -2
  19. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai_cli.egg-info/SOURCES.txt +3 -0
  20. zai_cli-0.2.0/zai/__init__.py +0 -1
  21. zai_cli-0.2.0/zai/core/fallback.py +0 -171
  22. {zai_cli-0.2.0 → zai_cli-0.2.3}/LICENSE +0 -0
  23. {zai_cli-0.2.0 → zai_cli-0.2.3}/MANIFEST.in +0 -0
  24. {zai_cli-0.2.0 → zai_cli-0.2.3}/pyproject.toml +0 -0
  25. {zai_cli-0.2.0 → zai_cli-0.2.3}/scripts/release_preflight.py +0 -0
  26. {zai_cli-0.2.0 → zai_cli-0.2.3}/setup.cfg +0 -0
  27. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_agent.py +0 -0
  28. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_browser.py +0 -0
  29. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_code_runner.py +0 -0
  30. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_config_main.py +0 -0
  31. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_core.py +0 -0
  32. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_errors.py +0 -0
  33. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_hooks_skills_session.py +0 -0
  34. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_integrations_cli.py +0 -0
  35. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_mcp.py +0 -0
  36. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_plugins.py +0 -0
  37. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_process.py +0 -0
  38. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_project_memory.py +0 -0
  39. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_project_rules.py +0 -0
  40. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_providers.py +0 -0
  41. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_release_preflight.py +0 -0
  42. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_search.py +0 -0
  43. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_security.py +0 -0
  44. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_storage.py +0 -0
  45. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_streaming.py +0 -0
  46. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_tokens.py +0 -0
  47. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_tool_schema.py +0 -0
  48. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_tools.py +0 -0
  49. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_undo.py +0 -0
  50. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_utilities.py +0 -0
  51. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_vision.py +0 -0
  52. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_watch.py +0 -0
  53. {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_workflows.py +0 -0
  54. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/__main__.py +0 -0
  55. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/__init__.py +0 -0
  56. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/common.py +0 -0
  57. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/integrations.py +0 -0
  58. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/setup_wizard.py +0 -0
  59. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/status.py +0 -0
  60. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/utilities.py +0 -0
  61. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/workflows.py +0 -0
  62. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/commands/commit.md +0 -0
  63. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/commands/explain.md +0 -0
  64. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/commands/feature.md +0 -0
  65. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/commands/fix.md +0 -0
  66. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/commands/review.md +0 -0
  67. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/config.py +0 -0
  68. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/__init__.py +0 -0
  69. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/agent.py +0 -0
  70. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/cancellation.py +0 -0
  71. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/commands.py +0 -0
  72. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/compaction.py +0 -0
  73. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/context.py +0 -0
  74. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/errors.py +0 -0
  75. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/hooks.py +0 -0
  76. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/memory.py +0 -0
  77. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/process.py +0 -0
  78. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/project_memory.py +0 -0
  79. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/project_rules.py +0 -0
  80. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/repomap.py +0 -0
  81. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/runtime.py +0 -0
  82. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/security.py +0 -0
  83. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/session.py +0 -0
  84. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/storage.py +0 -0
  85. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/streaming.py +0 -0
  86. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/tokens.py +0 -0
  87. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/tool_schema.py +0 -0
  88. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/undo.py +0 -0
  89. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/watch.py +0 -0
  90. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/mcp/__init__.py +0 -0
  91. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/mcp/client.py +0 -0
  92. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/mcp/manager.py +0 -0
  93. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/plugins/__init__.py +0 -0
  94. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/plugins/base.py +0 -0
  95. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/plugins/loader.py +0 -0
  96. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/__init__.py +0 -0
  97. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/anthropic.py +0 -0
  98. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/base.py +0 -0
  99. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/cerebras.py +0 -0
  100. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/gemini.py +0 -0
  101. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/groq.py +0 -0
  102. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/ollama.py +0 -0
  103. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/openai.py +0 -0
  104. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/openrouter.py +0 -0
  105. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/qwen.py +0 -0
  106. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/skills/__init__.py +0 -0
  107. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/skills/registry.py +0 -0
  108. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/__init__.py +0 -0
  109. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/browser.py +0 -0
  110. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/code_runner.py +0 -0
  111. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/files.py +0 -0
  112. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/git.py +0 -0
  113. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/search.py +0 -0
  114. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/vision.py +0 -0
  115. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/ui/__init__.py +0 -0
  116. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai_cli.egg-info/dependency_links.txt +0 -0
  117. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai_cli.egg-info/entry_points.txt +0 -0
  118. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai_cli.egg-info/requires.txt +0 -0
  119. {zai_cli-0.2.0 → zai_cli-0.2.3}/zai_cli.egg-info/top_level.txt +0 -0
@@ -5,6 +5,52 @@ All notable changes to this project are documented in this file.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and the project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.2.3] - 2026-07-14
9
+
10
+ ### Added
11
+
12
+ - Added structured fallback-attempt diagnostics with model, failure category,
13
+ secret-redacted detail, and a recommended next action.
14
+ - Added final failure summaries that list every attempted model and its result.
15
+
16
+ ### Changed
17
+
18
+ - Chat fallback now distinguishes missing or invalid credentials, quota limits,
19
+ unavailable models, timeouts, network errors, malformed responses, and other
20
+ provider failures instead of describing them all as network issues.
21
+ - Provider initialization and readiness-check failures no longer stop the
22
+ remaining fallback chain.
23
+ - Plain mode sends fallback diagnostics to stderr so successful response output
24
+ on stdout remains script-friendly.
25
+
26
+ ## [0.2.2] - 2026-07-13
27
+
28
+ ### Added
29
+
30
+ - Added structured live-provider validation results with the resolved model,
31
+ token usage, failure detail, and a recommended next action.
32
+
33
+ ### Changed
34
+
35
+ - Authentication login validation now distinguishes missing or invalid
36
+ credentials, quota limits, model configuration errors, network failures, and
37
+ unexpected provider failures.
38
+ - `zai model test` now reports actionable recovery commands and shows the token
39
+ cost of a successful validation request when providers return usage data.
40
+
41
+ ## [0.2.1] - 2026-06-30
42
+
43
+ ### Added
44
+
45
+ - Added `@file` completion in normal interactive prompts.
46
+ - Added safe `@file` mention expansion so prompts like `explain @app.py`
47
+ include bounded referenced file content.
48
+
49
+ ### Changed
50
+
51
+ - Mention expansion skips missing, hidden, oversized, binary, and
52
+ sensitive-looking files without crashing the prompt.
53
+
8
54
  ## [0.2.0] - 2026-06-30
9
55
 
10
56
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zai-cli
3
- Version: 0.2.0
3
+ Version: 0.2.3
4
4
  Summary: Your personal AI CLI — free, fast, and smart
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/HumaizaNaz/zai_cli
@@ -198,6 +198,10 @@ zai auth login openai
198
198
  zai auth login anthropic
199
199
  ```
200
200
 
201
+ When prompted, authentication login can send one tiny live request to validate
202
+ the key. The result identifies the resolved model and gives a recovery command
203
+ for invalid credentials, quota limits, model errors, or network failures.
204
+
201
205
  Remove a saved API key from `zai`:
202
206
 
203
207
  ```bash
@@ -426,7 +430,7 @@ zai model add <name> Add a custom model alias
426
430
  zai model remove <name> Remove a custom model alias
427
431
  zai model configure <name> Set timeout and retry policy
428
432
  zai model info <name> Show effective model configuration
429
- zai model test <name> Verify credentials and model availability
433
+ zai model test <name> Verify credentials/model and show recovery guidance
430
434
  zai auth status Show API-key and external CLI auth status
431
435
  zai auth login <provider> Connect a provider API key
432
436
  zai auth logout <provider> Remove a provider API key from zai
@@ -538,6 +542,18 @@ Interactive input also provides:
538
542
  - `Ctrl+C` to cancel the current input or operation without ending the session;
539
543
  - a basic Rich prompt fallback in redirected or unsupported terminals.
540
544
 
545
+ You can reference files in natural prompts with `@file`:
546
+
547
+ ```text
548
+ explain @app.py
549
+ fix @src/main.py
550
+ compare @README.md and @CHANGELOG.md
551
+ ```
552
+
553
+ `zai` loads bounded text from valid referenced files and appends it to the
554
+ prompt context. Hidden files, sensitive-looking paths, binary files, and very
555
+ large files are skipped with a note instead of crashing the session.
556
+
541
557
  ## How much of my project does zai see?
542
558
 
543
559
  `zai` starts from the folder where you run it. It does not load every file in a
@@ -602,6 +618,12 @@ Providers without an API key are skipped. Ollama is used only when its local
602
618
  service is available. Provider quotas and model availability are controlled by
603
619
  their respective services and can change independently of `zai`.
604
620
 
621
+ When an attempted model fails, `zai` reports the model, a stable failure
622
+ category, secret-redacted provider detail, and the recommended recovery action
623
+ before trying the next configured model. If every model fails, the final error
624
+ lists the complete attempt chain. In `--plain` mode these diagnostics go to
625
+ stderr, leaving successful response text on stdout.
626
+
605
627
  Supported configuration keys:
606
628
 
607
629
  ```text
@@ -141,6 +141,10 @@ zai auth login openai
141
141
  zai auth login anthropic
142
142
  ```
143
143
 
144
+ When prompted, authentication login can send one tiny live request to validate
145
+ the key. The result identifies the resolved model and gives a recovery command
146
+ for invalid credentials, quota limits, model errors, or network failures.
147
+
144
148
  Remove a saved API key from `zai`:
145
149
 
146
150
  ```bash
@@ -369,7 +373,7 @@ zai model add <name> Add a custom model alias
369
373
  zai model remove <name> Remove a custom model alias
370
374
  zai model configure <name> Set timeout and retry policy
371
375
  zai model info <name> Show effective model configuration
372
- zai model test <name> Verify credentials and model availability
376
+ zai model test <name> Verify credentials/model and show recovery guidance
373
377
  zai auth status Show API-key and external CLI auth status
374
378
  zai auth login <provider> Connect a provider API key
375
379
  zai auth logout <provider> Remove a provider API key from zai
@@ -481,6 +485,18 @@ Interactive input also provides:
481
485
  - `Ctrl+C` to cancel the current input or operation without ending the session;
482
486
  - a basic Rich prompt fallback in redirected or unsupported terminals.
483
487
 
488
+ You can reference files in natural prompts with `@file`:
489
+
490
+ ```text
491
+ explain @app.py
492
+ fix @src/main.py
493
+ compare @README.md and @CHANGELOG.md
494
+ ```
495
+
496
+ `zai` loads bounded text from valid referenced files and appends it to the
497
+ prompt context. Hidden files, sensitive-looking paths, binary files, and very
498
+ large files are skipped with a note instead of crashing the session.
499
+
484
500
  ## How much of my project does zai see?
485
501
 
486
502
  `zai` starts from the folder where you run it. It does not load every file in a
@@ -545,6 +561,12 @@ Providers without an API key are skipped. Ollama is used only when its local
545
561
  service is available. Provider quotas and model availability are controlled by
546
562
  their respective services and can change independently of `zai`.
547
563
 
564
+ When an attempted model fails, `zai` reports the model, a stable failure
565
+ category, secret-redacted provider detail, and the recommended recovery action
566
+ before trying the next configured model. If every model fails, the final error
567
+ lists the complete attempt chain. In `--plain` mode these diagnostics go to
568
+ stderr, leaving successful response text on stdout.
569
+
548
570
  Supported configuration keys:
549
571
 
550
572
  ```text
@@ -14,6 +14,7 @@ from zai.core.auth import (
14
14
  mask_secret,
15
15
  save_provider_key,
16
16
  validate_provider,
17
+ validate_provider_details,
17
18
  )
18
19
 
19
20
 
@@ -66,18 +67,58 @@ def test_external_login_command_for_codex_and_claude():
66
67
  def test_validate_provider_normalizes_results():
67
68
  provider = MagicMock()
68
69
  provider.is_available.return_value = True
69
- provider.chat.return_value = MagicMock()
70
+ provider.chat.return_value = MagicMock(model="provider/model", tokens_used=7)
70
71
  with patch("zai.core.auth.get_provider", return_value=provider):
71
72
  assert validate_provider("groq") == "OK"
73
+ details = validate_provider_details("groq")
74
+ assert details.status == "OK"
75
+ assert details.model == "provider/model"
76
+ assert details.tokens_used == 7
77
+ assert "zai model set groq" in details.next_action
72
78
 
73
79
  provider.chat.side_effect = RuntimeError("unauthorized api key")
74
80
  with patch("zai.core.auth.get_provider", return_value=provider):
75
81
  assert validate_provider("groq") == "invalid"
82
+ details = validate_provider_details("groq")
83
+ assert details.status == "invalid"
84
+ assert "zai auth login groq" in details.next_action
76
85
 
77
86
  provider.chat.side_effect = RuntimeError("rate limit")
78
87
  with patch("zai.core.auth.get_provider", return_value=provider):
79
88
  assert validate_provider("groq") == "quota/rate limit"
80
89
 
90
+ provider.chat.side_effect = RuntimeError("unknown model does not exist")
91
+ with patch("zai.core.auth.get_provider", return_value=provider):
92
+ details = validate_provider_details("groq")
93
+ assert details.status == "model error"
94
+ assert "zai model info groq" in details.next_action
95
+
96
+ provider.chat.side_effect = RuntimeError("unexpected provider failure")
97
+ with patch("zai.core.auth.get_provider", return_value=provider):
98
+ details = validate_provider_details("groq")
99
+ assert details.status == "provider error"
100
+ assert "zai auth doctor" in details.next_action
101
+
102
+ provider.chat.side_effect = RuntimeError("api_key=super-secret-provider-token")
103
+ with patch("zai.core.auth.get_provider", return_value=provider):
104
+ details = validate_provider_details("groq")
105
+ assert "super-secret-provider-token" not in details.detail
106
+ assert "[REDACTED_SECRET]" in details.detail
107
+
108
+
109
+ def test_validate_provider_reports_unknown_and_unavailable():
110
+ unknown = validate_provider_details("unknown")
111
+ assert unknown.status == "missing"
112
+ assert unknown.detail == "Unknown provider."
113
+
114
+ provider = MagicMock()
115
+ provider.is_available.return_value = False
116
+ with patch("zai.core.auth.get_provider", return_value=provider):
117
+ unavailable = validate_provider_details("groq")
118
+
119
+ assert unavailable.status == "missing"
120
+ assert "zai auth login groq" in unavailable.next_action
121
+
81
122
 
82
123
  def test_top_level_auth_status_does_not_print_secret(monkeypatch):
83
124
  monkeypatch.setenv("OPENAI_API_KEY", "sk-live-secret")
@@ -0,0 +1,177 @@
1
+ from unittest.mock import MagicMock
2
+
3
+ import pytest
4
+
5
+ from zai.core import fallback
6
+ from zai.core.errors import (
7
+ AllModelsFailedError,
8
+ AuthenticationError,
9
+ MalformedResponseError,
10
+ ModelNotFoundError,
11
+ NetworkError,
12
+ NoAPIKeyError,
13
+ ProviderError,
14
+ ProviderTimeoutError,
15
+ QuotaError,
16
+ )
17
+ from zai.providers.base import Message, Response
18
+
19
+
20
+ @pytest.mark.parametrize(
21
+ ("error", "category", "guidance"),
22
+ [
23
+ (NoAPIKeyError("groq"), "missing credentials", "zai auth login groq"),
24
+ (AuthenticationError("groq", "bad key"), "invalid credentials", "zai auth login groq"),
25
+ (QuotaError("groq", "quota"), "quota/rate limit", "check quota"),
26
+ (ModelNotFoundError("groq", "gone"), "model unavailable", "zai model info fast"),
27
+ (ProviderTimeoutError("groq", "slow"), "timeout", "zai model configure fast"),
28
+ (NetworkError("offline"), "network error", "Check internet"),
29
+ (MalformedResponseError("groq", "choices"), "malformed response", "Retry once"),
30
+ (ProviderError("groq", "broken"), "provider error", "zai model test fast"),
31
+ ],
32
+ )
33
+ def test_failure_attempt_has_accurate_category_and_guidance(error, category, guidance):
34
+ attempt = fallback._failure_attempt("fast", "groq", error)
35
+
36
+ assert attempt.category == category
37
+ assert guidance in attempt.next_action
38
+
39
+
40
+ def test_failure_attempt_redacts_provider_secrets():
41
+ attempt = fallback._failure_attempt(
42
+ "groq",
43
+ "groq",
44
+ AuthenticationError("groq", "api_key=super-secret-provider-token"),
45
+ )
46
+
47
+ assert "super-secret-provider-token" not in attempt.detail
48
+ assert "[REDACTED_SECRET]" in attempt.detail
49
+
50
+
51
+ def _config(*names):
52
+ return {
53
+ "default_model": names[0],
54
+ "fallback_order": list(names),
55
+ "auto_fallback": True,
56
+ }
57
+
58
+
59
+ def _provider(name, *, error=None, response="ok", available=True):
60
+ provider = MagicMock()
61
+ provider.name = name
62
+ provider.model_id = f"provider/{name}"
63
+ provider.context_window = 128_000
64
+ provider.supports_native_tools = True
65
+ provider.supports_streaming = False
66
+ provider.is_available.return_value = available
67
+ if error:
68
+ provider.chat.side_effect = error
69
+ else:
70
+ provider.chat.return_value = Response(content=response, model=provider.model_id)
71
+ return provider
72
+
73
+
74
+ def test_chat_fallback_reports_auth_failure_and_uses_next_model(monkeypatch, capsys):
75
+ broken = _provider("groq", error=AuthenticationError("groq", "bad key"))
76
+ working = _provider("gemini", response="fallback worked")
77
+ providers = {"groq": broken, "gemini": working}
78
+ monkeypatch.setattr(fallback, "load_config", lambda: _config("groq", "gemini"))
79
+ monkeypatch.setattr(fallback, "get_provider", providers.get)
80
+
81
+ response, used = fallback.chat_with_fallback([Message(role="user", content="hi")])
82
+
83
+ output = capsys.readouterr().out
84
+ assert response.content == "fallback worked"
85
+ assert used == "gemini"
86
+ assert "groq failed" in output
87
+ assert "invalid credentials" in output
88
+ assert "Trying next model" in output
89
+ assert "Network issue" not in output
90
+
91
+
92
+ def test_fallback_survives_provider_initialization_failure(monkeypatch, capsys):
93
+ working = _provider("gemini")
94
+
95
+ def get_provider(name):
96
+ if name == "groq":
97
+ raise RuntimeError("provider SDK failed to initialize")
98
+ return working
99
+
100
+ monkeypatch.setattr(fallback, "load_config", lambda: _config("groq", "gemini"))
101
+ monkeypatch.setattr(fallback, "get_provider", get_provider)
102
+
103
+ response, used = fallback.chat_with_fallback([Message(role="user", content="hi")])
104
+
105
+ assert response.content == "ok"
106
+ assert used == "gemini"
107
+ assert "provider error" in capsys.readouterr().out
108
+
109
+
110
+ @pytest.mark.parametrize("runner", [fallback.chat_with_fallback, fallback.stream_with_fallback])
111
+ def test_fallback_does_not_swallow_cancellation_during_readiness(monkeypatch, runner):
112
+ from zai.core.cancellation import OperationCancelled
113
+
114
+ provider = _provider("groq")
115
+ provider.is_available.side_effect = OperationCancelled("stop now")
116
+ monkeypatch.setattr(fallback, "load_config", lambda: _config("groq", "gemini"))
117
+ monkeypatch.setattr(fallback, "get_provider", lambda name: provider)
118
+
119
+ with pytest.raises(OperationCancelled, match="stop now"):
120
+ runner([Message(role="user", content="hi")])
121
+
122
+
123
+ def test_all_models_failed_lists_every_attempt(monkeypatch):
124
+ missing = _provider("groq", available=False)
125
+ limited = _provider("gemini", error=QuotaError("gemini", "quota exhausted"))
126
+ providers = {"groq": missing, "gemini": limited}
127
+ monkeypatch.setattr(fallback, "load_config", lambda: _config("groq", "gemini"))
128
+ monkeypatch.setattr(fallback, "get_provider", providers.get)
129
+
130
+ with pytest.raises(AllModelsFailedError) as caught:
131
+ fallback.chat_with_fallback([Message(role="user", content="hi")])
132
+
133
+ message = str(caught.value)
134
+ assert "groq (unavailable)" in message
135
+ assert "gemini (quota/rate limit)" in message
136
+ assert "zai model check" in message
137
+
138
+
139
+ def test_stream_fallback_uses_same_diagnostics(monkeypatch, capsys):
140
+ broken = _provider("groq", error=NetworkError("offline"))
141
+ working = _provider("gemini", response="stream fallback")
142
+ providers = {"groq": broken, "gemini": working}
143
+ monkeypatch.setattr(fallback, "load_config", lambda: _config("groq", "gemini"))
144
+ monkeypatch.setattr(fallback, "get_provider", providers.get)
145
+
146
+ content, used = fallback.stream_with_fallback([Message(role="user", content="hi")])
147
+
148
+ output = capsys.readouterr().out
149
+ assert content == "stream fallback"
150
+ assert used == "gemini"
151
+ assert "network error" in output
152
+ assert "Check internet" in output
153
+
154
+
155
+ def test_plain_mode_keeps_fallback_diagnostics_off_stdout(monkeypatch, capsys):
156
+ from zai.core.runtime import configure
157
+
158
+ broken = _provider("groq", error=NetworkError("offline"))
159
+ working = _provider("gemini", response="plain result")
160
+ providers = {"groq": broken, "gemini": working}
161
+ monkeypatch.setattr(fallback, "load_config", lambda: _config("groq", "gemini"))
162
+ monkeypatch.setattr(fallback, "get_provider", providers.get)
163
+
164
+ configure(plain=True)
165
+ try:
166
+ content, used = fallback.stream_with_fallback([
167
+ Message(role="user", content="hi")
168
+ ])
169
+ finally:
170
+ configure()
171
+
172
+ captured = capsys.readouterr()
173
+ assert content == "plain result"
174
+ assert used == "gemini"
175
+ assert "plain result" in captured.out
176
+ assert "groq failed" not in captured.out
177
+ assert "groq failed" in captured.err
@@ -0,0 +1,30 @@
1
+ from zai.core.file_mentions import expand_file_mentions, extract_file_mentions
2
+
3
+
4
+ def test_extract_file_mentions_keeps_order_and_uniqueness():
5
+ text = "explain @app.py then compare @README.md and @app.py."
6
+
7
+ assert extract_file_mentions(text) == ["app.py", "README.md"]
8
+
9
+
10
+ def test_expand_file_mentions_loads_bounded_file_context(tmp_path):
11
+ (tmp_path / "app.py").write_text("print('hi')\n", encoding="utf-8")
12
+
13
+ expanded = expand_file_mentions("explain @app.py", tmp_path)
14
+
15
+ assert "explain @app.py" in expanded
16
+ assert "Referenced @file context" in expanded
17
+ assert "--- @app.py ---" in expanded
18
+ assert "print('hi')" in expanded
19
+
20
+
21
+ def test_expand_file_mentions_skips_hidden_and_sensitive_files(tmp_path):
22
+ (tmp_path / ".env").write_text("API_KEY=secret\n", encoding="utf-8")
23
+ (tmp_path / "token.txt").write_text("secret\n", encoding="utf-8")
24
+
25
+ expanded = expand_file_mentions("read @.env and @token.txt", tmp_path)
26
+
27
+ assert "API_KEY=secret" not in expanded
28
+ assert "Mention notes" in expanded
29
+ assert "Skipped @.env" in expanded
30
+ assert "Skipped @token.txt" in expanded
@@ -97,6 +97,22 @@ def test_path_completion(tmp_path):
97
97
  assert not any(".secret" in candidate[0] for candidate in candidates)
98
98
 
99
99
 
100
+ def test_file_mention_completion_in_natural_prompt(tmp_path):
101
+ (tmp_path / "src").mkdir()
102
+ (tmp_path / "sample.py").write_text("")
103
+ (tmp_path / ".secret").write_text("")
104
+
105
+ candidates = completion_candidates(
106
+ "explain @s",
107
+ str(tmp_path),
108
+ {"fix"},
109
+ )
110
+
111
+ assert ("@src/", "@src/", "file mention") in candidates
112
+ assert ("@sample.py", "@sample.py", "file mention") in candidates
113
+ assert not any(".secret" in candidate[0] for candidate in candidates)
114
+
115
+
100
116
  def test_prompt_falls_back_when_prompt_toolkit_missing(tmp_path):
101
117
  real_import = __import__
102
118
 
@@ -305,6 +305,32 @@ def test_interactive_pins_project_rules_before_agent_call(tmp_path, monkeypatch)
305
305
  )
306
306
 
307
307
 
308
+ def test_interactive_expands_file_mentions_before_agent_call(tmp_path, monkeypatch):
309
+ monkeypatch.chdir(tmp_path)
310
+ (tmp_path / "app.py").write_text("print('hello')\n")
311
+ with patch("zai.cli.interactive.has_available_provider", return_value=True):
312
+ with patch("zai.cli.interactive.fire_hook", return_value=True):
313
+ with patch("zai.cli.interactive.plugin_loader.load_all", return_value={}):
314
+ with patch("zai.cli.interactive.plugin_loader.get_errors", return_value={}):
315
+ with patch("zai.cli.interactive._connect_mcp_servers"):
316
+ with patch(
317
+ "zai.cli.interactive.InteractiveInput.prompt",
318
+ side_effect=["explain @app.py", EOFError()],
319
+ ):
320
+ with (
321
+ patch(
322
+ "zai.cli.interactive.run_agent",
323
+ return_value="done",
324
+ ) as run_agent,
325
+ patch("zai.cli.interactive.Confirm.ask", return_value=False),
326
+ ):
327
+ run_interactive("groq")
328
+
329
+ prompt = run_agent.call_args.args[0]
330
+ assert "Referenced @file context" in prompt
331
+ assert "print('hello')" in prompt
332
+
333
+
308
334
  def test_interactive_manual_compact_summarizes_history(tmp_path, monkeypatch, capsys):
309
335
  monkeypatch.chdir(tmp_path)
310
336
  prompts = [
@@ -159,6 +159,7 @@ def test_context_command_is_honest_about_unloaded_files(tmp_path, monkeypatch):
159
159
  def test_rules_command_shows_loaded_project_rules(tmp_path, monkeypatch):
160
160
  app = settings_app()
161
161
  monkeypatch.chdir(tmp_path)
162
+ (tmp_path / ".git").mkdir()
162
163
  (tmp_path / ".zai").mkdir()
163
164
  (tmp_path / ".zai" / "rules.md").write_text("Never commit secrets.\n")
164
165
 
@@ -271,6 +272,25 @@ def test_model_test_passes_and_reports_unavailable():
271
272
  assert "Model unavailable" in unavailable.output
272
273
 
273
274
 
275
+ def test_model_test_reports_actionable_auth_failure():
276
+ from zai.core.errors import AuthenticationError
277
+
278
+ app = settings_app()
279
+ runner = CliRunner()
280
+ provider = MagicMock()
281
+ provider.name = "groq"
282
+ provider.is_available.return_value = True
283
+ provider.chat.side_effect = AuthenticationError("groq", "bad key")
284
+
285
+ with patch("zai.core.fallback.get_provider", return_value=provider):
286
+ result = runner.invoke(app, ["model", "test", "groq"])
287
+
288
+ assert result.exit_code == 0
289
+ assert "invalid credentials" in result.output
290
+ assert "zai auth login groq" in result.output
291
+ assert "bad key" in result.output
292
+
293
+
274
294
  def test_model_configure_updates_builtin_runtime_settings():
275
295
  app = settings_app()
276
296
  config = {
@@ -0,0 +1 @@
1
+ __version__ = "0.2.3"
@@ -15,13 +15,14 @@ from ..core.auth import (
15
15
  get_auth_provider,
16
16
  run_external_login,
17
17
  save_provider_key,
18
- validate_provider,
18
+ validate_provider_details,
19
19
  )
20
20
  from ..core.agent import plan_agent, run_agent, undo_last
21
21
  from ..core.commands import get_command_prompt, list_commands, load_commands
22
22
  from ..core.compaction import manual_compact_messages
23
23
  from ..core.hooks import fire as fire_hook
24
24
  from ..core.fallback import has_available_provider
25
+ from ..core.file_mentions import expand_file_mentions
25
26
  from ..core.context import ContextManager
26
27
  from ..core.memory import add_project, get_last_session, save_session
27
28
  from ..core.project_memory import (
@@ -282,14 +283,13 @@ def _run_auth_login(provider: str, *, device: bool = False) -> None:
282
283
  f"Test {info.display_name} now? This sends one tiny request.",
283
284
  default=False,
284
285
  ):
285
- status = validate_provider(info.key)
286
- style = "green" if status == "OK" else "yellow"
287
- console.print(f"[{style}]Validation: {status}[/{style}]")
288
- if status != "OK":
289
- console.print(
290
- f"[dim]Update it with `/auth login {info.key}` "
291
- f"or remove it with `/auth logout {info.key}`.[/dim]"
292
- )
286
+ validation = validate_provider_details(info.key)
287
+ style = "green" if validation.status == "OK" else "yellow"
288
+ console.print(f"[{style}]Validation: {validation.status}[/{style}]")
289
+ if validation.model:
290
+ console.print(f"[dim]Model: {validation.model}[/dim]")
291
+ if validation.next_action:
292
+ console.print(f"[dim]{validation.next_action.replace('zai ', '/')}[/dim]")
293
293
  command = external_login_command(info.key, device=device)
294
294
  if command:
295
295
  command_text = " ".join(command)
@@ -748,12 +748,13 @@ def run_interactive(model: str = None) -> None:
748
748
  console.print("[red]Usage: /plan <what you want to build>[/red]")
749
749
  else:
750
750
  console.print("[dim]Planning...[/dim]")
751
- plan = plan_agent(task, history, cwd, preferred)
751
+ expanded_task = expand_file_mentions(task, cwd)
752
+ plan = plan_agent(expanded_task, history, cwd, preferred)
752
753
  if plan and Prompt.ask("[cyan]Proceed?[/cyan]", default="no").lower() in ("yes", "y"):
753
754
  console.print("[dim]Executing...[/dim]")
754
- result = run_agent(task, history, cwd, preferred)
755
+ result = run_agent(expanded_task, history, cwd, preferred)
755
756
  if result:
756
- remember_turn(task, result)
757
+ remember_turn(expanded_task, result)
757
758
  save_session(task=task[:80], model=preferred)
758
759
  maybe_offer_project_memory(task)
759
760
  elif stripped == "/test":
@@ -955,9 +956,10 @@ def run_interactive(model: str = None) -> None:
955
956
  console.print("[dim]Type /commands to see available commands[/dim]")
956
957
  else:
957
958
  fire_hook("UserPromptSubmit", {"message": stripped[:200]})
958
- result = run_agent(stripped, history, cwd, preferred)
959
+ expanded = expand_file_mentions(stripped, cwd)
960
+ result = run_agent(expanded, history, cwd, preferred)
959
961
  if result:
960
- remember_turn(stripped, result)
962
+ remember_turn(expanded, result)
961
963
  save_session(task=stripped[:80], model=preferred)
962
964
  maybe_offer_project_memory(stripped)
963
965
  except KeyboardInterrupt: