zai-cli 0.2.2__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.2 → zai_cli-0.2.3}/CHANGELOG.md +18 -0
  2. {zai_cli-0.2.2/zai_cli.egg-info → zai_cli-0.2.3}/PKG-INFO +7 -1
  3. {zai_cli-0.2.2 → zai_cli-0.2.3}/README.md +6 -0
  4. zai_cli-0.2.3/tests/test_fallback_diagnostics.py +177 -0
  5. zai_cli-0.2.3/zai/__init__.py +1 -0
  6. zai_cli-0.2.3/zai/core/fallback.py +257 -0
  7. {zai_cli-0.2.2 → zai_cli-0.2.3/zai_cli.egg-info}/PKG-INFO +7 -1
  8. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai_cli.egg-info/SOURCES.txt +1 -0
  9. zai_cli-0.2.2/zai/__init__.py +0 -1
  10. zai_cli-0.2.2/zai/core/fallback.py +0 -171
  11. {zai_cli-0.2.2 → zai_cli-0.2.3}/LICENSE +0 -0
  12. {zai_cli-0.2.2 → zai_cli-0.2.3}/MANIFEST.in +0 -0
  13. {zai_cli-0.2.2 → zai_cli-0.2.3}/pyproject.toml +0 -0
  14. {zai_cli-0.2.2 → zai_cli-0.2.3}/scripts/release_preflight.py +0 -0
  15. {zai_cli-0.2.2 → zai_cli-0.2.3}/setup.cfg +0 -0
  16. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_agent.py +0 -0
  17. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_auth.py +0 -0
  18. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_browser.py +0 -0
  19. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_code_runner.py +0 -0
  20. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_config_main.py +0 -0
  21. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_core.py +0 -0
  22. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_errors.py +0 -0
  23. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_file_mentions.py +0 -0
  24. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_hooks_skills_session.py +0 -0
  25. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_input.py +0 -0
  26. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_integrations_cli.py +0 -0
  27. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_interactive.py +0 -0
  28. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_mcp.py +0 -0
  29. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_plugins.py +0 -0
  30. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_process.py +0 -0
  31. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_project_memory.py +0 -0
  32. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_project_rules.py +0 -0
  33. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_providers.py +0 -0
  34. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_release_preflight.py +0 -0
  35. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_search.py +0 -0
  36. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_security.py +0 -0
  37. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_settings_cli.py +0 -0
  38. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_storage.py +0 -0
  39. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_streaming.py +0 -0
  40. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_tokens.py +0 -0
  41. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_tool_schema.py +0 -0
  42. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_tools.py +0 -0
  43. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_undo.py +0 -0
  44. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_utilities.py +0 -0
  45. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_vision.py +0 -0
  46. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_watch.py +0 -0
  47. {zai_cli-0.2.2 → zai_cli-0.2.3}/tests/test_workflows.py +0 -0
  48. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/__main__.py +0 -0
  49. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/cli/__init__.py +0 -0
  50. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/cli/common.py +0 -0
  51. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/cli/integrations.py +0 -0
  52. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/cli/interactive.py +0 -0
  53. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/cli/settings.py +0 -0
  54. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/cli/setup_wizard.py +0 -0
  55. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/cli/status.py +0 -0
  56. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/cli/utilities.py +0 -0
  57. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/cli/workflows.py +0 -0
  58. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/commands/commit.md +0 -0
  59. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/commands/explain.md +0 -0
  60. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/commands/feature.md +0 -0
  61. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/commands/fix.md +0 -0
  62. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/commands/review.md +0 -0
  63. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/config.py +0 -0
  64. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/__init__.py +0 -0
  65. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/agent.py +0 -0
  66. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/auth.py +0 -0
  67. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/cancellation.py +0 -0
  68. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/commands.py +0 -0
  69. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/compaction.py +0 -0
  70. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/context.py +0 -0
  71. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/errors.py +0 -0
  72. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/file_mentions.py +0 -0
  73. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/hooks.py +0 -0
  74. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/memory.py +0 -0
  75. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/process.py +0 -0
  76. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/project_memory.py +0 -0
  77. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/project_rules.py +0 -0
  78. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/repomap.py +0 -0
  79. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/runtime.py +0 -0
  80. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/security.py +0 -0
  81. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/session.py +0 -0
  82. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/storage.py +0 -0
  83. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/streaming.py +0 -0
  84. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/tokens.py +0 -0
  85. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/tool_schema.py +0 -0
  86. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/undo.py +0 -0
  87. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/core/watch.py +0 -0
  88. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/main.py +0 -0
  89. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/mcp/__init__.py +0 -0
  90. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/mcp/client.py +0 -0
  91. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/mcp/manager.py +0 -0
  92. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/plugins/__init__.py +0 -0
  93. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/plugins/base.py +0 -0
  94. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/plugins/loader.py +0 -0
  95. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/providers/__init__.py +0 -0
  96. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/providers/anthropic.py +0 -0
  97. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/providers/base.py +0 -0
  98. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/providers/cerebras.py +0 -0
  99. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/providers/gemini.py +0 -0
  100. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/providers/groq.py +0 -0
  101. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/providers/ollama.py +0 -0
  102. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/providers/openai.py +0 -0
  103. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/providers/openrouter.py +0 -0
  104. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/providers/qwen.py +0 -0
  105. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/skills/__init__.py +0 -0
  106. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/skills/registry.py +0 -0
  107. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/tools/__init__.py +0 -0
  108. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/tools/browser.py +0 -0
  109. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/tools/code_runner.py +0 -0
  110. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/tools/files.py +0 -0
  111. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/tools/git.py +0 -0
  112. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/tools/search.py +0 -0
  113. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/tools/vision.py +0 -0
  114. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/ui/__init__.py +0 -0
  115. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai/ui/input.py +0 -0
  116. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai_cli.egg-info/dependency_links.txt +0 -0
  117. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai_cli.egg-info/entry_points.txt +0 -0
  118. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai_cli.egg-info/requires.txt +0 -0
  119. {zai_cli-0.2.2 → zai_cli-0.2.3}/zai_cli.egg-info/top_level.txt +0 -0
@@ -5,6 +5,24 @@ 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
+
8
26
  ## [0.2.2] - 2026-07-13
9
27
 
10
28
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zai-cli
3
- Version: 0.2.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
@@ -618,6 +618,12 @@ Providers without an API key are skipped. Ollama is used only when its local
618
618
  service is available. Provider quotas and model availability are controlled by
619
619
  their respective services and can change independently of `zai`.
620
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
+
621
627
  Supported configuration keys:
622
628
 
623
629
  ```text
@@ -561,6 +561,12 @@ Providers without an API key are skipped. Ollama is used only when its local
561
561
  service is available. Provider quotas and model availability are controlled by
562
562
  their respective services and can change independently of `zai`.
563
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
+
564
570
  Supported configuration keys:
565
571
 
566
572
  ```text
@@ -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 @@
1
+ __version__ = "0.2.3"
@@ -0,0 +1,257 @@
1
+ from dataclasses import dataclass
2
+
3
+ from rich.console import Console
4
+ from ..providers import PROVIDERS
5
+ from ..providers.base import Message, Response
6
+ from ..config import get_model_config, load_config
7
+ from .errors import (
8
+ AllModelsFailedError,
9
+ AuthenticationError,
10
+ MalformedResponseError,
11
+ ModelNotFoundError,
12
+ NetworkError,
13
+ NoAPIKeyError,
14
+ ProviderError,
15
+ ProviderTimeoutError,
16
+ QuotaError,
17
+ RateLimitError,
18
+ classify_provider_error,
19
+ )
20
+ from .runtime import plain_enabled
21
+ from .cancellation import OperationCancelled, raise_if_cancelled
22
+ from .tokens import redact_secrets
23
+
24
+ console = Console()
25
+ diagnostic_console = Console(stderr=True)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class FallbackAttempt:
30
+ model: str
31
+ category: str
32
+ detail: str
33
+ next_action: str
34
+
35
+ def summary(self) -> str:
36
+ return f"{self.model} ({self.category})"
37
+
38
+
39
+ def _failure_attempt(model: str, provider: str, error: Exception) -> FallbackAttempt:
40
+ normalized = classify_provider_error(provider, error)
41
+ detail = redact_secrets(str(normalized))
42
+ if isinstance(normalized, NoAPIKeyError):
43
+ category = "missing credentials"
44
+ next_action = f"Add credentials with: zai auth login {provider}"
45
+ elif isinstance(normalized, AuthenticationError):
46
+ category = "invalid credentials"
47
+ next_action = f"Update credentials with: zai auth login {provider}"
48
+ elif isinstance(normalized, (RateLimitError, QuotaError)):
49
+ category = "quota/rate limit"
50
+ next_action = "Wait, check quota, or choose another model."
51
+ elif isinstance(normalized, ModelNotFoundError):
52
+ category = "model unavailable"
53
+ next_action = f"Check configuration with: zai model info {model}"
54
+ elif isinstance(normalized, ProviderTimeoutError):
55
+ category = "timeout"
56
+ next_action = f"Check or increase timeout with: zai model configure {model}"
57
+ elif isinstance(normalized, NetworkError):
58
+ category = "network error"
59
+ next_action = "Check internet, local service, or provider status."
60
+ elif isinstance(normalized, MalformedResponseError):
61
+ category = "malformed response"
62
+ next_action = "Retry once, then check provider status or switch models."
63
+ elif isinstance(normalized, ProviderError):
64
+ category = "provider error"
65
+ next_action = f"Inspect this model with: zai model test {model}"
66
+ else:
67
+ category = "unexpected error"
68
+ next_action = "Retry with --debug or run: zai model check"
69
+ return FallbackAttempt(model, category, detail, next_action)
70
+
71
+
72
+ def _unavailable_attempt(model: str) -> FallbackAttempt:
73
+ return FallbackAttempt(
74
+ model=model,
75
+ category="unavailable",
76
+ detail="Model is not configured, credentials are missing, or its local service is down.",
77
+ next_action=f"Inspect this model with: zai model test {model}",
78
+ )
79
+
80
+
81
+ def _report_attempt(attempt: FallbackAttempt, *, has_next: bool) -> None:
82
+ suffix = " Trying next model." if has_next else ""
83
+ output = diagnostic_console if plain_enabled() else console
84
+ output.print(
85
+ f"[yellow]{attempt.model} failed:[/yellow] {attempt.category}.{suffix}"
86
+ )
87
+ output.print(f"[dim]{attempt.detail}[/dim]")
88
+ output.print(f"[dim]{attempt.next_action}[/dim]")
89
+
90
+
91
+ def _raise_all_models_failed(attempts: list[FallbackAttempt]) -> None:
92
+ summary = "; ".join(attempt.summary() for attempt in attempts)
93
+ guidance = "Run `zai model check` and `zai auth doctor`."
94
+ detail = f"Attempts: {summary}. {guidance}" if summary else guidance
95
+ raise AllModelsFailedError(detail)
96
+
97
+
98
+ def format_model_selection(name: str) -> str:
99
+ try:
100
+ model = get_model_config(name)
101
+ except KeyError:
102
+ return name
103
+ return f"{name} -> {model['provider']}/{model['model_id']}"
104
+
105
+
106
+ def get_provider(name: str):
107
+ try:
108
+ model = get_model_config(name)
109
+ except KeyError:
110
+ return None
111
+ cls = PROVIDERS.get(model["provider"])
112
+ if not cls:
113
+ return None
114
+ provider = cls()
115
+ provider.model_id = model["model_id"]
116
+ provider.context_window = model["context_window"]
117
+ provider.timeout = model.get("timeout", 60)
118
+ provider.retries = model.get("retries", 2)
119
+ return provider
120
+
121
+
122
+ def _model_order(config: dict, preferred: str = None) -> list[str]:
123
+ first = preferred or config["default_model"]
124
+ if not config.get("auto_fallback", True):
125
+ return [first]
126
+ return [first] + [
127
+ model for model in config["fallback_order"]
128
+ if model != first
129
+ ]
130
+
131
+
132
+ def has_available_provider() -> bool:
133
+ """Return True when any configured API provider or local provider is usable."""
134
+ config = load_config()
135
+ for model_name in _model_order(config):
136
+ try:
137
+ provider = get_provider(model_name)
138
+ if provider and provider.is_available():
139
+ return True
140
+ except Exception:
141
+ continue
142
+ return False
143
+
144
+
145
+ def stream_with_fallback(messages: list[Message], system: str = "", preferred: str = None) -> tuple[str, str]:
146
+ """Stream response with auto fallback. Returns (content, model_name)."""
147
+ config = load_config()
148
+ order = _model_order(config, preferred)
149
+
150
+ attempts: list[FallbackAttempt] = []
151
+ for index, model_name in enumerate(order):
152
+ raise_if_cancelled()
153
+ try:
154
+ provider = get_provider(model_name)
155
+ available = bool(provider and provider.is_available())
156
+ except OperationCancelled:
157
+ raise
158
+ except Exception as error:
159
+ attempt = _failure_attempt(model_name, model_name, error)
160
+ attempts.append(attempt)
161
+ _report_attempt(attempt, has_next=index < len(order) - 1)
162
+ continue
163
+ if not available:
164
+ attempt = _unavailable_attempt(model_name)
165
+ attempts.append(attempt)
166
+ _report_attempt(attempt, has_next=index < len(order) - 1)
167
+ continue
168
+ try:
169
+ from .context import compact_messages, estimate_text_tokens
170
+
171
+ prepared_messages = compact_messages(
172
+ messages,
173
+ getattr(provider, "context_window", 128_000),
174
+ reserve_tokens=max(4096, estimate_text_tokens(system) + 4096),
175
+ )
176
+ if provider.supports_streaming and not plain_enabled():
177
+ content = provider.stream_chat(prepared_messages, system=system)
178
+ else:
179
+ response = provider.chat(prepared_messages, system=system)
180
+ content = response.content
181
+ console.print(content, markup=False)
182
+ raise_if_cancelled()
183
+ return content, model_name
184
+ except OperationCancelled:
185
+ raise
186
+ except Exception as error:
187
+ attempt = _failure_attempt(model_name, provider.name, error)
188
+ attempts.append(attempt)
189
+ _report_attempt(attempt, has_next=index < len(order) - 1)
190
+ continue
191
+
192
+ _raise_all_models_failed(attempts)
193
+
194
+
195
+ def chat_with_fallback(
196
+ messages: list[Message],
197
+ system: str = "",
198
+ preferred: str = None,
199
+ tools: list[dict] | None = None,
200
+ ) -> tuple[Response, str]:
201
+ config = load_config()
202
+ order = _model_order(config, preferred)
203
+
204
+ attempts: list[FallbackAttempt] = []
205
+ for index, model_name in enumerate(order):
206
+ raise_if_cancelled()
207
+ try:
208
+ provider = get_provider(model_name)
209
+ available = bool(provider and provider.is_available())
210
+ except OperationCancelled:
211
+ raise
212
+ except Exception as error:
213
+ attempt = _failure_attempt(model_name, model_name, error)
214
+ attempts.append(attempt)
215
+ _report_attempt(attempt, has_next=index < len(order) - 1)
216
+ continue
217
+ if not available:
218
+ attempt = _unavailable_attempt(model_name)
219
+ attempts.append(attempt)
220
+ _report_attempt(attempt, has_next=index < len(order) - 1)
221
+ continue
222
+ try:
223
+ from .context import compact_messages, estimate_text_tokens
224
+
225
+ provider_system = system
226
+ provider_tools = tools if provider.supports_native_tools else None
227
+ if tools and not provider.supports_native_tools:
228
+ from .tool_schema import legacy_tool_instructions
229
+
230
+ provider_system = (
231
+ f"{system}\n\n{legacy_tool_instructions()}"
232
+ )
233
+ tool_schema_tokens = estimate_text_tokens(str(provider_tools or ""))
234
+ prepared_messages = compact_messages(
235
+ messages,
236
+ getattr(provider, "context_window", 128_000),
237
+ reserve_tokens=max(
238
+ 4096,
239
+ estimate_text_tokens(provider_system) + tool_schema_tokens + 4096,
240
+ ),
241
+ )
242
+ response = provider.chat(
243
+ prepared_messages,
244
+ system=provider_system,
245
+ tools=provider_tools,
246
+ )
247
+ raise_if_cancelled()
248
+ return response, model_name
249
+ except OperationCancelled:
250
+ raise
251
+ except Exception as error:
252
+ attempt = _failure_attempt(model_name, provider.name, error)
253
+ attempts.append(attempt)
254
+ _report_attempt(attempt, has_next=index < len(order) - 1)
255
+ continue
256
+
257
+ _raise_all_models_failed(attempts)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zai-cli
3
- Version: 0.2.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
@@ -618,6 +618,12 @@ Providers without an API key are skipped. Ollama is used only when its local
618
618
  service is available. Provider quotas and model availability are controlled by
619
619
  their respective services and can change independently of `zai`.
620
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
+
621
627
  Supported configuration keys:
622
628
 
623
629
  ```text
@@ -11,6 +11,7 @@ tests/test_code_runner.py
11
11
  tests/test_config_main.py
12
12
  tests/test_core.py
13
13
  tests/test_errors.py
14
+ tests/test_fallback_diagnostics.py
14
15
  tests/test_file_mentions.py
15
16
  tests/test_hooks_skills_session.py
16
17
  tests/test_input.py
@@ -1 +0,0 @@
1
- __version__ = "0.2.2"
@@ -1,171 +0,0 @@
1
- from rich.console import Console
2
- from ..providers import PROVIDERS
3
- from ..providers.base import Message, Response
4
- from ..config import get_model_config, load_config
5
- from .errors import (
6
- AllModelsFailedError,
7
- NetworkError,
8
- NoAPIKeyError,
9
- ProviderError,
10
- RateLimitError,
11
- classify_provider_error,
12
- )
13
- from .runtime import plain_enabled
14
- from .cancellation import OperationCancelled, raise_if_cancelled
15
-
16
- console = Console()
17
-
18
-
19
- def format_model_selection(name: str) -> str:
20
- try:
21
- model = get_model_config(name)
22
- except KeyError:
23
- return name
24
- return f"{name} -> {model['provider']}/{model['model_id']}"
25
-
26
-
27
- def get_provider(name: str):
28
- try:
29
- model = get_model_config(name)
30
- except KeyError:
31
- return None
32
- cls = PROVIDERS.get(model["provider"])
33
- if not cls:
34
- return None
35
- provider = cls()
36
- provider.model_id = model["model_id"]
37
- provider.context_window = model["context_window"]
38
- provider.timeout = model.get("timeout", 60)
39
- provider.retries = model.get("retries", 2)
40
- return provider
41
-
42
-
43
- def _model_order(config: dict, preferred: str = None) -> list[str]:
44
- first = preferred or config["default_model"]
45
- if not config.get("auto_fallback", True):
46
- return [first]
47
- return [first] + [
48
- model for model in config["fallback_order"]
49
- if model != first
50
- ]
51
-
52
-
53
- def has_available_provider() -> bool:
54
- """Return True when any configured API provider or local provider is usable."""
55
- config = load_config()
56
- for model_name in _model_order(config):
57
- provider = get_provider(model_name)
58
- if provider and provider.is_available():
59
- return True
60
- return False
61
-
62
-
63
- def stream_with_fallback(messages: list[Message], system: str = "", preferred: str = None) -> tuple[str, str]:
64
- """Stream response with auto fallback. Returns (content, model_name)."""
65
- config = load_config()
66
- order = _model_order(config, preferred)
67
-
68
- last_error = None
69
- for model_name in order:
70
- raise_if_cancelled()
71
- provider = get_provider(model_name)
72
- if not provider or not provider.is_available():
73
- continue
74
- try:
75
- from .context import compact_messages, estimate_text_tokens
76
-
77
- prepared_messages = compact_messages(
78
- messages,
79
- getattr(provider, "context_window", 128_000),
80
- reserve_tokens=max(4096, estimate_text_tokens(system) + 4096),
81
- )
82
- if provider.supports_streaming and not plain_enabled():
83
- content = provider.stream_chat(prepared_messages, system=system)
84
- else:
85
- response = provider.chat(prepared_messages, system=system)
86
- content = response.content
87
- console.print(content, markup=False)
88
- raise_if_cancelled()
89
- return content, model_name
90
- except OperationCancelled:
91
- raise
92
- except RateLimitError as error:
93
- last_error = str(error)
94
- console.print(f"[yellow]⚡ {model_name} limit hit — switching...[/yellow]")
95
- continue
96
- except NoAPIKeyError:
97
- continue
98
- except (NetworkError, ProviderError) as error:
99
- last_error = str(error)
100
- console.print(f"[yellow]Network issue with {model_name} — switching...[/yellow]")
101
- continue
102
- except Exception as error:
103
- normalized = classify_provider_error(provider.name, error)
104
- console.print(f"[dim]Error with {model_name}: {normalized}[/dim]")
105
- last_error = str(normalized)
106
- continue
107
-
108
- raise AllModelsFailedError(last_error or "")
109
-
110
-
111
- def chat_with_fallback(
112
- messages: list[Message],
113
- system: str = "",
114
- preferred: str = None,
115
- tools: list[dict] | None = None,
116
- ) -> tuple[Response, str]:
117
- config = load_config()
118
- order = _model_order(config, preferred)
119
-
120
- last_error = None
121
- for model_name in order:
122
- raise_if_cancelled()
123
- provider = get_provider(model_name)
124
- if not provider or not provider.is_available():
125
- continue
126
- try:
127
- from .context import compact_messages, estimate_text_tokens
128
-
129
- provider_system = system
130
- provider_tools = tools if provider.supports_native_tools else None
131
- if tools and not provider.supports_native_tools:
132
- from .tool_schema import legacy_tool_instructions
133
-
134
- provider_system = (
135
- f"{system}\n\n{legacy_tool_instructions()}"
136
- )
137
- tool_schema_tokens = estimate_text_tokens(str(provider_tools or ""))
138
- prepared_messages = compact_messages(
139
- messages,
140
- getattr(provider, "context_window", 128_000),
141
- reserve_tokens=max(
142
- 4096,
143
- estimate_text_tokens(provider_system) + tool_schema_tokens + 4096,
144
- ),
145
- )
146
- response = provider.chat(
147
- prepared_messages,
148
- system=provider_system,
149
- tools=provider_tools,
150
- )
151
- raise_if_cancelled()
152
- return response, model_name
153
- except OperationCancelled:
154
- raise
155
- except RateLimitError as error:
156
- console.print(f"[yellow]⚡ {model_name} limit hit — switching...[/yellow]")
157
- last_error = str(error)
158
- continue
159
- except NoAPIKeyError:
160
- continue
161
- except (NetworkError, ProviderError) as error:
162
- console.print(f"[yellow]Network issue with {model_name} — switching...[/yellow]")
163
- last_error = str(error)
164
- continue
165
- except Exception as error:
166
- normalized = classify_provider_error(provider.name, error)
167
- console.print(f"[dim]Error with {model_name}: {normalized}[/dim]")
168
- last_error = str(normalized)
169
- continue
170
-
171
- raise AllModelsFailedError(last_error or "")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes