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.
- {zai_cli-0.2.0 → zai_cli-0.2.3}/CHANGELOG.md +46 -0
- {zai_cli-0.2.0/zai_cli.egg-info → zai_cli-0.2.3}/PKG-INFO +24 -2
- {zai_cli-0.2.0 → zai_cli-0.2.3}/README.md +23 -1
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_auth.py +42 -1
- zai_cli-0.2.3/tests/test_fallback_diagnostics.py +177 -0
- zai_cli-0.2.3/tests/test_file_mentions.py +30 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_input.py +16 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_interactive.py +26 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_settings_cli.py +20 -0
- zai_cli-0.2.3/zai/__init__.py +1 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/interactive.py +16 -14
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/settings.py +49 -10
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/auth.py +89 -13
- zai_cli-0.2.3/zai/core/fallback.py +257 -0
- zai_cli-0.2.3/zai/core/file_mentions.py +99 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/main.py +3 -1
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/ui/input.py +14 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3/zai_cli.egg-info}/PKG-INFO +24 -2
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai_cli.egg-info/SOURCES.txt +3 -0
- zai_cli-0.2.0/zai/__init__.py +0 -1
- zai_cli-0.2.0/zai/core/fallback.py +0 -171
- {zai_cli-0.2.0 → zai_cli-0.2.3}/LICENSE +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/MANIFEST.in +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/pyproject.toml +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/scripts/release_preflight.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/setup.cfg +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_agent.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_browser.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_code_runner.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_config_main.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_core.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_errors.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_hooks_skills_session.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_integrations_cli.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_mcp.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_plugins.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_process.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_project_memory.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_project_rules.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_providers.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_release_preflight.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_search.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_security.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_storage.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_streaming.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_tokens.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_tool_schema.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_tools.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_undo.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_utilities.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_vision.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_watch.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/tests/test_workflows.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/__main__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/common.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/integrations.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/setup_wizard.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/status.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/utilities.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/cli/workflows.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/commands/commit.md +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/commands/explain.md +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/commands/feature.md +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/commands/fix.md +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/commands/review.md +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/config.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/agent.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/cancellation.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/commands.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/compaction.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/context.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/errors.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/hooks.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/memory.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/process.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/project_memory.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/project_rules.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/repomap.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/runtime.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/security.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/session.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/storage.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/streaming.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/tokens.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/tool_schema.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/undo.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/core/watch.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/mcp/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/mcp/client.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/mcp/manager.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/plugins/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/plugins/base.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/plugins/loader.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/anthropic.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/base.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/cerebras.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/gemini.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/groq.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/ollama.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/openai.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/openrouter.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/providers/qwen.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/skills/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/skills/registry.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/browser.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/code_runner.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/files.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/git.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/search.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/tools/vision.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai/ui/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai_cli.egg-info/dependency_links.txt +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai_cli.egg-info/entry_points.txt +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.3}/zai_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
style = "green" if status == "OK" else "yellow"
|
|
287
|
-
console.print(f"[{style}]Validation: {status}[/{style}]")
|
|
288
|
-
if
|
|
289
|
-
console.print(
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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(
|
|
755
|
+
result = run_agent(expanded_task, history, cwd, preferred)
|
|
755
756
|
if result:
|
|
756
|
-
remember_turn(
|
|
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
|
-
|
|
959
|
+
expanded = expand_file_mentions(stripped, cwd)
|
|
960
|
+
result = run_agent(expanded, history, cwd, preferred)
|
|
959
961
|
if result:
|
|
960
|
-
remember_turn(
|
|
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:
|