python-agent-harness 1.5.3__tar.gz → 1.5.4.1__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 (88) hide show
  1. {python_agent_harness-1.5.3/python_agent_harness.egg-info → python_agent_harness-1.5.4.1}/PKG-INFO +8 -1
  2. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/README.md +5 -0
  3. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/pyproject.toml +6 -1
  4. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/cli.py +8 -0
  6. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/client.py +23 -2
  7. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/commands.py +1 -2
  8. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/config.py +108 -20
  9. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/context_manager.py +11 -2
  10. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/mcp/client.py +2 -4
  11. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/mcp/manager.py +0 -2
  12. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/persistence.py +1 -2
  13. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/token_estimator.py +12 -8
  14. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1/python_agent_harness.egg-info}/PKG-INFO +8 -1
  15. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_client.py +81 -1
  16. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_config.py +112 -0
  17. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_token_estimator.py +31 -0
  18. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/LICENSE +0 -0
  19. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/__main__.py +0 -0
  20. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/agent.py +0 -0
  21. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/diffrender.py +0 -0
  22. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/mcp/__init__.py +0 -0
  23. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/mcp/config.py +0 -0
  24. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/models.py +0 -0
  25. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/planmode.py +0 -0
  26. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/agent.md +0 -0
  27. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/build-switch.md +0 -0
  28. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/commands/explain.md +0 -0
  29. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/compact.md +0 -0
  30. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/initialize.md +0 -0
  31. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/plan-mode.md +0 -0
  32. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/plan.md +0 -0
  33. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/review.md +0 -0
  34. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/subagent.md +0 -0
  35. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/summary.md +0 -0
  36. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  37. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts/title.md +0 -0
  38. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/prompts.py +0 -0
  39. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/session.py +0 -0
  40. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/subagent.py +0 -0
  41. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tool_runner.py +0 -0
  42. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/__init__.py +0 -0
  43. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/agent_tool.py +0 -0
  44. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/base.py +0 -0
  45. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/bash.py +0 -0
  46. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/edit.py +0 -0
  47. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/filesystem.py +0 -0
  48. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/glob.py +0 -0
  49. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/grep.py +0 -0
  50. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/insert.py +0 -0
  51. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/mcp.py +0 -0
  52. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/mkdir.py +0 -0
  53. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/planexit.py +0 -0
  54. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/question.py +0 -0
  55. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/read.py +0 -0
  56. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/skill.py +0 -0
  57. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/todo.py +0 -0
  58. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tools/write.py +0 -0
  59. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tui/__init__.py +0 -0
  60. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tui/commands.py +0 -0
  61. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tui/core.py +0 -0
  62. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tui/input.py +0 -0
  63. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness/tui/render.py +0 -0
  64. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  65. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  66. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness.egg-info/entry_points.txt +0 -0
  67. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness.egg-info/requires.txt +0 -0
  68. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/python_agent_harness.egg-info/top_level.txt +0 -0
  69. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/setup.cfg +0 -0
  70. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_agents_md.py +0 -0
  71. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_cli.py +0 -0
  72. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_commands.py +0 -0
  73. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_concurrent_subagents.py +0 -0
  74. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_context_rules.py +0 -0
  75. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_diffrender.py +0 -0
  76. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_filesystem.py +0 -0
  77. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_invariants.py +0 -0
  78. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_mcp.py +0 -0
  79. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_models.py +0 -0
  80. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_persistence.py +0 -0
  81. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_planmode.py +0 -0
  82. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_prompts.py +0 -0
  83. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_scenarios.py +0 -0
  84. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_session.py +0 -0
  85. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_subagent.py +0 -0
  86. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_subagent_isolation.py +0 -0
  87. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_todos_scope.py +0 -0
  88. {python_agent_harness-1.5.3 → python_agent_harness-1.5.4.1}/tests/test_tools_misc.py +0 -0
@@ -1,7 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.3
3
+ Version: 1.5.4.1
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
+ Author: huming
6
+ License-Expression: MIT
5
7
  Requires-Python: >=3.11
6
8
  Description-Content-Type: text/markdown
7
9
  License-File: LICENSE
@@ -143,6 +145,10 @@ All LLM settings live in a single JSON configuration file. Environment variables
143
145
  "model": "qwen3.5-coder"
144
146
  }
145
147
  },
148
+ "context_windows": {
149
+ "_comment": "Optional per-model context-window overrides (tokens). Keys are model names or fnmatch patterns (e.g. deepseek-v4* = 1000000); matched in file order, first match wins. Overrides the built-in CONTEXT_WINDOWS table in config.py.",
150
+ "deepseek-v4*": 1000000
151
+ },
146
152
  "subagent_llm": {
147
153
  "profile": null,
148
154
  "base_url": null,
@@ -182,6 +188,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
182
188
 
183
189
  - **`llm`** — main LLM configuration. Optional keys include `backend`, `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, and `stream`. Values such as `reasoning_effort` are passed to the API as-is when set. `run --no-stream` overrides `stream`.
184
190
  - **`models`** — named LLM profiles for runtime switching with `/model`. A profile is a partial settings dictionary; unset keys inherit from the main `llm`. `default` restores the main LLM configuration.
191
+ - **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or fnmatch patterns (e.g., `deepseek-v4*`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
185
192
  - **`subagent_llm`** — LLM configuration for `Agent` tool requests. Unset values inherit from the main `llm`. Set `profile` to reuse a profile from `models`. Precedence is: profile settings > explicit `subagent_llm` settings > main `llm` > environment variables.
186
193
  - **`paths.context_path` / `paths.skill_path`** — locations from which to load context files and skills. When unset, the project-local `<project>/contexts` and `<project>/skills` directories are used.
187
194
  - **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
@@ -122,6 +122,10 @@ All LLM settings live in a single JSON configuration file. Environment variables
122
122
  "model": "qwen3.5-coder"
123
123
  }
124
124
  },
125
+ "context_windows": {
126
+ "_comment": "Optional per-model context-window overrides (tokens). Keys are model names or fnmatch patterns (e.g. deepseek-v4* = 1000000); matched in file order, first match wins. Overrides the built-in CONTEXT_WINDOWS table in config.py.",
127
+ "deepseek-v4*": 1000000
128
+ },
125
129
  "subagent_llm": {
126
130
  "profile": null,
127
131
  "base_url": null,
@@ -161,6 +165,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
161
165
 
162
166
  - **`llm`** — main LLM configuration. Optional keys include `backend`, `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, and `stream`. Values such as `reasoning_effort` are passed to the API as-is when set. `run --no-stream` overrides `stream`.
163
167
  - **`models`** — named LLM profiles for runtime switching with `/model`. A profile is a partial settings dictionary; unset keys inherit from the main `llm`. `default` restores the main LLM configuration.
168
+ - **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or fnmatch patterns (e.g., `deepseek-v4*`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
164
169
  - **`subagent_llm`** — LLM configuration for `Agent` tool requests. Unset values inherit from the main `llm`. Set `profile` to reuse a profile from `models`. Precedence is: profile settings > explicit `subagent_llm` settings > main `llm` > environment variables.
165
170
  - **`paths.context_path` / `paths.skill_path`** — locations from which to load context files and skills. When unset, the project-local `<project>/contexts` and `<project>/skills` directories are used.
166
171
  - **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
@@ -4,10 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.3"
7
+ version = "1.5.4.1"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
+ authors = [
12
+ { name = "huming" }
13
+ ]
14
+ license = 'MIT'
15
+ license-files = ["LICENSE"]
11
16
  dependencies = [
12
17
  "rich>=13.0",
13
18
  "httpx>=0.27",
@@ -5,7 +5,7 @@ from .mcp.manager import MCPManager
5
5
  from .models import AgentMode, Message, ToolCall, ToolSpec
6
6
  from .session import Session
7
7
 
8
- __version__ = "1.5.3"
8
+ __version__ = "1.5.4.1"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -221,6 +221,14 @@ def cmd_config(args: argparse.Namespace) -> int:
221
221
  print(f" {name}: model={model_name}, base_url={base_url}")
222
222
  else:
223
223
  print("models: (none configured — add a 'models' section to use /model)")
224
+ # Show context-window overrides
225
+ context_windows = config.load_context_windows_config(args.path)
226
+ if context_windows:
227
+ print("context_windows:")
228
+ for pattern, size in context_windows:
229
+ print(f" {pattern}: {size}")
230
+ else:
231
+ print("context_windows: (none configured — built-in table in config.py applies)")
224
232
  return 0
225
233
 
226
234
 
@@ -11,6 +11,7 @@ import contextlib
11
11
  import json
12
12
  import os
13
13
  import random
14
+ import socket as _socket
14
15
  import threading
15
16
  import time
16
17
  import uuid
@@ -211,6 +212,8 @@ class Client:
211
212
  )
212
213
  self._config_path = config_path
213
214
  self._http = httpx.Client(timeout=timeout, verify=self.verify)
215
+ # Resolve context window for this model (API discovery + fallbacks)
216
+ self._context_window = None # lazy-loaded
214
217
  # True while the in-flight request was aborted (Ctrl-C): a
215
218
  # connection error on an aborted request must NOT be retried —
216
219
  # the user asked to stop. Cleared at the start of each chat()
@@ -224,6 +227,26 @@ class Client:
224
227
  else (_llm_log_path() if config.LLM_LOG_ENABLED else None)
225
228
  )
226
229
 
230
+ @property
231
+ def context_window(self) -> int:
232
+ """Get the context window for this model.
233
+
234
+ Resolution order: config-file ``context_windows`` overrides
235
+ (via ``config.get_context_window_for_model``) -> CONTEXT_WINDOWS
236
+ pattern match -> DEFAULT_CONTEXT_WINDOW. The resolved value is
237
+ cached for the life of the client.
238
+ """
239
+ if self._context_window is None:
240
+ try:
241
+ self._context_window = config.get_context_window_for_model(
242
+ self.model, config_path=self._config_path
243
+ )
244
+ except Exception:
245
+ # a malformed context_windows section must not break
246
+ # the loop: cache the safe default
247
+ self._context_window = config.DEFAULT_CONTEXT_WINDOW
248
+ return self._context_window
249
+
227
250
  def close(self) -> None:
228
251
  self._http.close()
229
252
 
@@ -810,8 +833,6 @@ def _abort_inflight_sockets(client: httpx.Client) -> None:
810
833
  down an idle socket is harmless (the pool is closed right after
811
834
  anyway); any failure is ignored (best effort).
812
835
  """
813
- import socket as _socket
814
-
815
836
  pool = getattr(getattr(client, "_transport", None), "_pool", None)
816
837
  for conn in getattr(pool, "_connections", None) or ():
817
838
  try:
@@ -17,6 +17,7 @@ Tool availability per command:
17
17
  from __future__ import annotations
18
18
 
19
19
  import re
20
+ import subprocess
20
21
  from collections.abc import Callable
21
22
  from pathlib import Path
22
23
  from typing import Any
@@ -35,8 +36,6 @@ def _substitute(text: str, path: str, extra: str | None) -> str:
35
36
 
36
37
  def _project_root(cwd: str) -> str:
37
38
  """Best-effort project root (git dir or parent with AGENTS.md)."""
38
- import subprocess
39
-
40
39
  d = Path(cwd).resolve()
41
40
  try:
42
41
  out = subprocess.run(
@@ -5,34 +5,43 @@ Mirrors the defcustom defaults of the Emacs gptel-agent-harness.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import fnmatch
8
9
  import json
9
10
  import os
10
11
  from pathlib import Path
11
- from typing import TYPE_CHECKING
12
12
 
13
- if TYPE_CHECKING:
14
- from .mcp.config import MCPConfig
13
+ from .mcp.config import MCPConfig
15
14
 
16
15
  # ---- context management -------------------------------------------------
17
16
  CONTEXT_TRIGGER = 0.70
18
17
 
19
18
  # Entries are matched in order (first match wins): put more specific
20
- # patterns before general ones.
19
+ # patterns before general ones. Supports wildcards (*). Trailing `*`
20
+ # on explicit entries preserves prefix matching for suffixed model
21
+ # names (e.g. "deepseek-v4-flash" -> deepseek-v4*), matching the
22
+ # legacy substring behavior.
21
23
  CONTEXT_WINDOWS: list[tuple[str, int]] = [
22
- ("gpt-5-mini", 128_000),
23
- ("gpt-5", 400_000),
24
- ("gpt-oss-120b", 128_000),
25
- ("claude", 200_000),
26
- ("deepseek-v3", 128_000),
27
- ("deepseek-v4", 1_000_000),
28
- ("qwen3.5", 131_072),
29
- ("qwen3.6", 262_144),
30
- ("qwen3.8", 262_144),
31
- ("qwen3", 131_072),
32
- ("glm-5.2", 1_000_000),
33
- ("glm-5.1", 128_000),
34
- ("kimi-k2.7", 256_000),
35
- ("kimi", 128_000),
24
+ ("gpt-5-mini*", 128_000),
25
+ ("gpt-5*", 400_000),
26
+ ("gpt-oss-120b*", 128_000),
27
+ ("claude*", 200_000),
28
+ ("deepseek-v3*", 128_000),
29
+ ("deepseek-v4*", 1_000_000),
30
+ ("qwen3.5*", 131_072),
31
+ ("qwen3.6*", 262_144),
32
+ ("qwen3.8*", 262_144),
33
+ ("qwen3*", 131_072),
34
+ ("glm-5.2*", 1_000_000),
35
+ ("glm-5.1*", 128_000),
36
+ ("kimi-k2.7*", 256_000),
37
+ ("kimi*", 128_000),
38
+ # Wildcard fallbacks for unknown models
39
+ ("gpt-*", 128_000),
40
+ ("claude-*", 200_000),
41
+ ("deepseek-*", 128_000),
42
+ ("qwen-*", 128_000),
43
+ ("glm-*", 128_000),
44
+ ("kimi-*", 128_000),
36
45
  ]
37
46
  DEFAULT_CONTEXT_WINDOW = 128_000
38
47
 
@@ -223,6 +232,10 @@ CONFIG_TEMPLATE = """\
223
232
  "model": "gpt-5-mini"
224
233
  }}
225
234
  }},
235
+ "context_windows": {{
236
+ "_comment": "Optional per-model context-window overrides (tokens). Keys are model names or fnmatch patterns (e.g. deepseek-v4* = 1000000); matched in file order, first match wins. Overrides the built-in CONTEXT_WINDOWS table in config.py. Remove this section to use the built-in table.",
237
+ "deepseek-v4*": 1000000
238
+ }},
226
239
  "subagent_llm": {{
227
240
  "_comment": "Optional overrides for sub-agent (Agent tool) requests, e.g. a cheaper model. Every key is optional; unset keys inherit the main llm settings above. Set 'profile' to a name from the 'models' section to reuse a model profile (profile settings win over explicit keys below).",
228
241
  "profile": null,
@@ -428,8 +441,6 @@ def load_mcp_config(path: str | os.PathLike | None = None) -> MCPConfig:
428
441
  ``mcp`` SDK is only needed when servers are actually configured and
429
442
  connected — reading the config never requires it.
430
443
  """
431
- from .mcp.config import MCPConfig
432
-
433
444
  data = _read_config(path)
434
445
  section = data.get("mcp") or {}
435
446
  if not isinstance(section, dict):
@@ -462,3 +473,80 @@ def load_models_config(path: str | os.PathLike | None = None) -> dict[str, dict]
462
473
 
463
474
  def mask_secret(value: str | None) -> str:
464
475
  return "****" if value else "(unset)"
476
+
477
+
478
+ def _match_context_window(model: str) -> int | None:
479
+ """Match model name against CONTEXT_WINDOWS patterns (supports wildcards).
480
+
481
+ Args:
482
+ model: The model ID to match (e.g., "gpt-5-mini", "claude-3-opus")
483
+
484
+ Returns:
485
+ The context window size if matched, None otherwise
486
+ """
487
+ lowered = model.lower()
488
+ for pattern, size in CONTEXT_WINDOWS:
489
+ if fnmatch.fnmatch(lowered, pattern.lower()):
490
+ return size
491
+ return None
492
+
493
+
494
+ def load_context_windows_config(
495
+ path: str | os.PathLike | None = None,
496
+ ) -> list[tuple[str, int]]:
497
+ """Load per-model context-window overrides from the config file.
498
+
499
+ Reads the ``context_windows`` object: a mapping of model names or
500
+ fnmatch patterns (matched in file order, first match wins) to token
501
+ counts. Keys starting with ``_`` are comments and skipped. A
502
+ missing file, missing section, or unreadable JSON yields ``[]``
503
+ (callers fall back to the built-in table); a malformed section or
504
+ non-integer size raises ValueError so config errors surface.
505
+ """
506
+ try:
507
+ data = _read_config(path)
508
+ except ValueError:
509
+ return []
510
+ section = data.get("context_windows") or {}
511
+ if not isinstance(section, dict):
512
+ raise ValueError(f"config file {_config_path(path)}: context_windows must be an object")
513
+ entries: list[tuple[str, int]] = []
514
+ for pattern, size in section.items():
515
+ if pattern.startswith("_"):
516
+ continue
517
+ if isinstance(size, bool) or not isinstance(size, int):
518
+ raise ValueError(
519
+ f"config file {_config_path(path)}: context_windows.{pattern} must be an integer"
520
+ )
521
+ entries.append((pattern, size))
522
+ return entries
523
+
524
+
525
+ def get_context_window_for_model(
526
+ model: str,
527
+ config_path: str | os.PathLike | None = None,
528
+ ) -> int:
529
+ """Get the context window for MODEL: config-file overrides, then
530
+ the built-in table, then the default.
531
+
532
+ The config file's ``context_windows`` object (user overrides) is
533
+ consulted first (fnmatch over its patterns, first match wins,
534
+ case-insensitive); then the CONTEXT_WINDOWS table in config.py;
535
+ then DEFAULT_CONTEXT_WINDOW.
536
+
537
+ Args:
538
+ model: The model ID to look up (e.g. "deepseek-v4-flash")
539
+ config_path: Optional path to the config file; defaults to the
540
+ standard config location (config.json).
541
+
542
+ Returns:
543
+ The context window size as an integer.
544
+ """
545
+ lowered = model.lower()
546
+ for pattern, size in load_context_windows_config(config_path):
547
+ if fnmatch.fnmatch(lowered, pattern.lower()):
548
+ return size
549
+ matched = _match_context_window(model)
550
+ if matched is not None:
551
+ return matched
552
+ return DEFAULT_CONTEXT_WINDOW
@@ -20,7 +20,10 @@ class ContextManager:
20
20
  ``update_context_ratio`` receives the two token-estimator functions
21
21
  from the loop's delegate so the call site keeps resolving them
22
22
  through the ``agent`` module namespace (tests patch
23
- ``python_agent_harness.agent.estimate_payload_tokens``).
23
+ ``python_agent_harness.agent.estimate_payload_tokens``). The
24
+ context window comes from the session's client (cached, config-
25
+ file aware); ``context_window_for`` is only the fallback for
26
+ clients without the property.
24
27
  """
25
28
 
26
29
  def __init__(self, loop: Any) -> None:
@@ -39,7 +42,13 @@ class ContextManager:
39
42
  )
40
43
  loop.session.calibrator.last_raw_estimate = raw
41
44
  calibrated = loop.session.calibrator.calibrate(raw)
42
- window = context_window_for(loop.session.model)
45
+ # Prefer the client's cached window (config overrides ->
46
+ # patterns -> default); fall back to the static resolver for
47
+ # clients without the property (test doubles).
48
+ client = loop.session.client
49
+ window = getattr(client, "context_window", None)
50
+ if window is None:
51
+ window = context_window_for(loop.session.model)
43
52
  loop.session.context_ratio = calibrated / float(window)
44
53
  loop.session.notify("context")
45
54
 
@@ -18,10 +18,12 @@ content-block dicts), ``structured_content`` and ``is_error``.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import contextlib
21
22
  import os
22
23
  from typing import Any
23
24
 
24
25
  try:
26
+ import httpx2 # shipped as part of the mcp SDK
25
27
  from mcp import Client
26
28
  from mcp.client.sse import sse_client
27
29
  from mcp.client.stdio import StdioServerParameters, stdio_client
@@ -93,8 +95,6 @@ class MCPClient:
93
95
  assert cfg.url is not None
94
96
  if cfg.transport == "streamable-http":
95
97
  if cfg.headers:
96
- import httpx2 # shipped as part of the mcp SDK
97
-
98
98
  self._http_client = httpx2.AsyncClient(headers=cfg.headers)
99
99
  return streamable_http_client(cfg.url, http_client=self._http_client)
100
100
  return streamable_http_client(cfg.url)
@@ -117,8 +117,6 @@ class MCPClient:
117
117
 
118
118
  async def close(self) -> None:
119
119
  """Tear down the connection (best effort, never raises)."""
120
- import contextlib
121
-
122
120
  client, self._client = self._client, None
123
121
  if client is not None:
124
122
  with contextlib.suppress(Exception): # teardown noise is not an error
@@ -200,8 +200,6 @@ class MCPManager:
200
200
 
201
201
  def disconnect(self, name: str) -> None:
202
202
  """Disconnect one server and drop its discovered tools."""
203
- import contextlib
204
-
205
203
  client = self._clients.pop(name, None)
206
204
  if client is None:
207
205
  return
@@ -12,6 +12,7 @@ Ported from gptel-agent-harness-session.el.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import ast
15
16
  import os
16
17
  import re
17
18
  import threading
@@ -284,8 +285,6 @@ class SessionPersistence:
284
285
 
285
286
  def _parse_metadata_value(value: str) -> str:
286
287
  """Parse a repr()-style metadata value, mirroring elisp read-from-string."""
287
- import ast
288
-
289
288
  try:
290
289
  parsed = ast.literal_eval(value)
291
290
  if isinstance(parsed, str):
@@ -8,6 +8,7 @@ to reduce drift.
8
8
  from __future__ import annotations
9
9
 
10
10
  import json
11
+ import os
11
12
  import re
12
13
 
13
14
  from . import config
@@ -39,17 +40,20 @@ def estimate_tokens(text: str) -> int:
39
40
  return round(latin / 4.0 + cjk / 2.0)
40
41
 
41
42
 
42
- def context_window_for(model: str) -> int:
43
+ def context_window_for(model: str, config_path: str | os.PathLike | None = None) -> int:
43
44
  """Return the context window for MODEL, or a safe fallback.
44
45
 
45
- Entries are matched in order using substring matching, so more
46
- specific patterns must come before general ones (see config).
46
+ Delegates to ``config.get_context_window_for_model``: config-file
47
+ ``context_windows`` overrides (config.json) first, then fnmatch
48
+ over CONTEXT_WINDOWS (first match wins, case-insensitive), then
49
+ DEFAULT_CONTEXT_WINDOW.
50
+
51
+ Args:
52
+ model: The model ID to look up.
53
+ config_path: Optional config file path; defaults to the
54
+ standard config location (config.json).
47
55
  """
48
- lowered = model.lower()
49
- for pattern, size in config.CONTEXT_WINDOWS:
50
- if pattern in lowered:
51
- return size
52
- return config.DEFAULT_CONTEXT_WINDOW
56
+ return config.get_context_window_for_model(model, config_path=config_path)
53
57
 
54
58
 
55
59
  class TokenCalibrator:
@@ -1,7 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.3
3
+ Version: 1.5.4.1
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
+ Author: huming
6
+ License-Expression: MIT
5
7
  Requires-Python: >=3.11
6
8
  Description-Content-Type: text/markdown
7
9
  License-File: LICENSE
@@ -143,6 +145,10 @@ All LLM settings live in a single JSON configuration file. Environment variables
143
145
  "model": "qwen3.5-coder"
144
146
  }
145
147
  },
148
+ "context_windows": {
149
+ "_comment": "Optional per-model context-window overrides (tokens). Keys are model names or fnmatch patterns (e.g. deepseek-v4* = 1000000); matched in file order, first match wins. Overrides the built-in CONTEXT_WINDOWS table in config.py.",
150
+ "deepseek-v4*": 1000000
151
+ },
146
152
  "subagent_llm": {
147
153
  "profile": null,
148
154
  "base_url": null,
@@ -182,6 +188,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
182
188
 
183
189
  - **`llm`** — main LLM configuration. Optional keys include `backend`, `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, and `stream`. Values such as `reasoning_effort` are passed to the API as-is when set. `run --no-stream` overrides `stream`.
184
190
  - **`models`** — named LLM profiles for runtime switching with `/model`. A profile is a partial settings dictionary; unset keys inherit from the main `llm`. `default` restores the main LLM configuration.
191
+ - **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or fnmatch patterns (e.g., `deepseek-v4*`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
185
192
  - **`subagent_llm`** — LLM configuration for `Agent` tool requests. Unset values inherit from the main `llm`. Set `profile` to reuse a profile from `models`. Precedence is: profile settings > explicit `subagent_llm` settings > main `llm` > environment variables.
186
193
  - **`paths.context_path` / `paths.skill_path`** — locations from which to load context files and skills. When unset, the project-local `<project>/contexts` and `<project>/skills` directories are used.
187
194
  - **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
@@ -6,6 +6,7 @@ import sys
6
6
  import tempfile
7
7
  import time
8
8
  import unittest
9
+ from pathlib import Path
9
10
  from unittest import mock
10
11
 
11
12
  import httpx
@@ -27,13 +28,14 @@ def make_client(
27
28
  retry_max: int | None = None,
28
29
  retry_base_delay: float | None = None,
29
30
  retry_max_delay: float | None = None,
31
+ model: str = "fake",
30
32
  ) -> Client:
31
33
  srv = serve()
32
34
  host, port = srv.server_address
33
35
  c = Client(
34
36
  base_url=f"http://{host}:{port}/v1",
35
37
  api_key="test",
36
- model="fake",
38
+ model=model,
37
39
  retry_max=retry_max,
38
40
  retry_base_delay=retry_base_delay,
39
41
  retry_max_delay=retry_max_delay,
@@ -1690,5 +1692,83 @@ class TestAuthRefreshOn401(unittest.TestCase):
1690
1692
  c.close()
1691
1693
 
1692
1694
 
1695
+ class TestContextWindow(unittest.TestCase):
1696
+ """Client.context_window: config-file overrides -> CONTEXT_WINDOWS
1697
+ patterns -> DEFAULT_CONTEXT_WINDOW. The resolved value is cached
1698
+ for the life of the client."""
1699
+
1700
+ def _client(self, model: str, config_path: str | None = None) -> Client:
1701
+ c = Client(base_url="http://x/v1", api_key="k", model=model, config_path=config_path)
1702
+ self.addCleanup(c.close)
1703
+ return c
1704
+
1705
+ def test_config_file_override_wins(self):
1706
+ """A context_windows entry in the config file beats the
1707
+ built-in table, and the resolved value is cached."""
1708
+ with tempfile.TemporaryDirectory() as d:
1709
+ p = Path(d) / "config.json"
1710
+ p.write_text('{"context_windows": {"fake*": 999999}}', encoding="utf-8")
1711
+ c = self._client(model="fake", config_path=str(p))
1712
+ self.assertEqual(c.context_window, 999_999)
1713
+ # cached: a later config change must not affect the window
1714
+ p.write_text('{"context_windows": {"fake*": 111111}}', encoding="utf-8")
1715
+ self.assertEqual(c.context_window, 999_999)
1716
+
1717
+ def test_config_file_wildcard_matching(self):
1718
+ """Config-file patterns support fnmatch wildcards, first match
1719
+ wins in file order."""
1720
+ with tempfile.TemporaryDirectory() as d:
1721
+ p = Path(d) / "config.json"
1722
+ p.write_text(
1723
+ '{"context_windows": {"gpt-4*": 500000, "gpt-4-turbo": 300000}}',
1724
+ encoding="utf-8",
1725
+ )
1726
+ # "gpt-4-turbo" hits the FIRST entry ("gpt-4*")
1727
+ self.assertEqual(self._client("gpt-4-turbo", str(p)).context_window, 500_000)
1728
+ self.assertEqual(self._client("gpt-4o", str(p)).context_window, 500_000)
1729
+
1730
+ def test_unknown_model_in_config_file_falls_to_table(self):
1731
+ """Config-file overrides that don't match fall through to the
1732
+ built-in CONTEXT_WINDOWS table."""
1733
+ with tempfile.TemporaryDirectory() as d:
1734
+ p = Path(d) / "config.json"
1735
+ p.write_text('{"context_windows": {"deepseek-v4*": 1000000}}', encoding="utf-8")
1736
+ self.assertEqual(self._client("gpt-5-mini", str(p)).context_window, 128_000)
1737
+ self.assertEqual(self._client("deepseek-v4-flash", str(p)).context_window, 1_000_000)
1738
+
1739
+ def test_fallback_to_pattern_match(self):
1740
+ """No config-file overrides -> CONTEXT_WINDOWS wildcard."""
1741
+ with tempfile.TemporaryDirectory() as d:
1742
+ p = Path(d) / "config.json"
1743
+ p.write_text('{"llm": {"model": "x"}}', encoding="utf-8")
1744
+ self.assertEqual(self._client("gpt-4-turbo", str(p)).context_window, 128_000)
1745
+
1746
+ def test_fallback_to_default(self):
1747
+ """No config-file overrides and no pattern match ->
1748
+ DEFAULT_CONTEXT_WINDOW."""
1749
+ with tempfile.TemporaryDirectory() as d:
1750
+ p = Path(d) / "config.json"
1751
+ p.write_text('{"llm": {"model": "x"}}', encoding="utf-8")
1752
+ self.assertEqual(
1753
+ self._client("totally-unknown-model", str(p)).context_window,
1754
+ config.DEFAULT_CONTEXT_WINDOW,
1755
+ )
1756
+
1757
+ def test_missing_config_file_uses_table(self):
1758
+ """No config file at all -> CONTEXT_WINDOWS patterns."""
1759
+ self.assertEqual(
1760
+ self._client("deepseek-v4-flash", "/no/such/config.json").context_window,
1761
+ 1_000_000,
1762
+ )
1763
+
1764
+ def test_malformed_config_file_falls_back_to_default(self):
1765
+ """A broken context_windows section must not break the loop:
1766
+ the client caches DEFAULT_CONTEXT_WINDOW."""
1767
+ with tempfile.TemporaryDirectory() as d:
1768
+ p = Path(d) / "config.json"
1769
+ p.write_text('{"context_windows": {"fake": "not-a-number"}}', encoding="utf-8")
1770
+ self.assertEqual(self._client("fake", str(p)).context_window, 128_000)
1771
+
1772
+
1693
1773
  if __name__ == "__main__":
1694
1774
  unittest.main()
@@ -180,6 +180,118 @@ class TestConfigFile(unittest.TestCase):
180
180
  self.assertIn("reasoning_effort", config.CONFIG_TEMPLATE)
181
181
  self.assertIn('"stream"', config.CONFIG_TEMPLATE)
182
182
  self.assertIn('"subagent_llm"', config.CONFIG_TEMPLATE)
183
+ self.assertIn('"context_windows"', config.CONFIG_TEMPLATE)
184
+
185
+
186
+ class TestContextWindowsConfig(unittest.TestCase):
187
+ """Config-file context-window overrides: loaded from the
188
+ ``context_windows`` object, matched before the built-in table."""
189
+
190
+ def setUp(self):
191
+ self._saved = {k: os.environ.get(k) for k in ENV_KEYS}
192
+ for k in ENV_KEYS:
193
+ os.environ.pop(k, None)
194
+
195
+ def tearDown(self):
196
+ for k, v in self._saved.items():
197
+ if v is None:
198
+ os.environ.pop(k, None)
199
+ else:
200
+ os.environ[k] = v
201
+
202
+ def test_missing_file_returns_empty(self):
203
+ self.assertEqual(config.load_context_windows_config("/no/such/file.json"), [])
204
+
205
+ def test_empty_section_returns_empty(self):
206
+ with tempfile.TemporaryDirectory() as d:
207
+ p = Path(d) / "config.json"
208
+ p.write_text('{"llm": {"model": "m"}}', encoding="utf-8")
209
+ self.assertEqual(config.load_context_windows_config(p), [])
210
+
211
+ def test_bad_json_returns_empty(self):
212
+ with tempfile.TemporaryDirectory() as d:
213
+ p = Path(d) / "config.json"
214
+ p.write_text("not {valid json", encoding="utf-8")
215
+ self.assertEqual(config.load_context_windows_config(p), [])
216
+
217
+ def test_overrides_loaded_in_order(self):
218
+ with tempfile.TemporaryDirectory() as d:
219
+ p = Path(d) / "config.json"
220
+ p.write_text(
221
+ '{"context_windows": {"deepseek-v4*": 1000000, "gpt-5*": 400000}}',
222
+ encoding="utf-8",
223
+ )
224
+ self.assertEqual(
225
+ config.load_context_windows_config(p),
226
+ [("deepseek-v4*", 1000000), ("gpt-5*", 400000)],
227
+ )
228
+
229
+ def test_comment_keys_skipped(self):
230
+ with tempfile.TemporaryDirectory() as d:
231
+ p = Path(d) / "config.json"
232
+ p.write_text(
233
+ '{"context_windows": {"_comment": "hi", "kimi*": 256000}}',
234
+ encoding="utf-8",
235
+ )
236
+ self.assertEqual(config.load_context_windows_config(p), [("kimi*", 256000)])
237
+
238
+ def test_section_must_be_object(self):
239
+ with tempfile.TemporaryDirectory() as d:
240
+ p = Path(d) / "config.json"
241
+ p.write_text('{"context_windows": "nope"}', encoding="utf-8")
242
+ with self.assertRaises(ValueError):
243
+ config.load_context_windows_config(p)
244
+
245
+ def test_size_must_be_integer(self):
246
+ with tempfile.TemporaryDirectory() as d:
247
+ p = Path(d) / "config.json"
248
+ p.write_text('{"context_windows": {"m*": "big"}}', encoding="utf-8")
249
+ with self.assertRaises(ValueError):
250
+ config.load_context_windows_config(p)
251
+
252
+ def test_bool_size_rejected(self):
253
+ """True is an int subclass but not a valid token count."""
254
+ with tempfile.TemporaryDirectory() as d:
255
+ p = Path(d) / "config.json"
256
+ p.write_text('{"context_windows": {"m*": true}}', encoding="utf-8")
257
+ with self.assertRaises(ValueError):
258
+ config.load_context_windows_config(p)
259
+
260
+ def test_get_context_window_precedence(self):
261
+ """Config-file override -> CONTEXT_WINDOWS -> default."""
262
+ with tempfile.TemporaryDirectory() as d:
263
+ p = Path(d) / "config.json"
264
+ p.write_text(
265
+ '{"context_windows": {"deepseek-v4*": 1000000, "gpt-4-turbo": 300000}}',
266
+ encoding="utf-8",
267
+ )
268
+ self.assertEqual(config.get_context_window_for_model("deepseek-v4-flash", p), 1000000)
269
+ # config-file match beats the built-in table
270
+ self.assertEqual(config.get_context_window_for_model("gpt-4-turbo", p), 300000)
271
+ # built-in table still applies when no override matches
272
+ self.assertEqual(config.get_context_window_for_model("gpt-5-mini", p), 128000)
273
+ self.assertEqual(config.get_context_window_for_model("kimi-k2.7-0613", p), 256000)
274
+ # unknown model -> default
275
+ self.assertEqual(
276
+ config.get_context_window_for_model("unknown-model", p),
277
+ config.DEFAULT_CONTEXT_WINDOW,
278
+ )
279
+
280
+ def test_get_context_window_case_insensitive(self):
281
+ with tempfile.TemporaryDirectory() as d:
282
+ p = Path(d) / "config.json"
283
+ p.write_text('{"context_windows": {"DeepSeek-V4*": 1000000}}', encoding="utf-8")
284
+ self.assertEqual(config.get_context_window_for_model("deepseek-v4-flash", p), 1000000)
285
+
286
+ def test_get_context_window_no_file(self):
287
+ self.assertEqual(
288
+ config.get_context_window_for_model("deepseek-v4", "/no/such/file.json"),
289
+ 1_000_000,
290
+ )
291
+ self.assertEqual(
292
+ config.get_context_window_for_model("totally-unknown", "/no/such/file.json"),
293
+ config.DEFAULT_CONTEXT_WINDOW,
294
+ )
183
295
 
184
296
 
185
297
  class TestSubagentLlmConfig(unittest.TestCase):
@@ -1,4 +1,7 @@
1
+ import os
2
+ import tempfile
1
3
  import unittest
4
+ from pathlib import Path
2
5
 
3
6
  from python_agent_harness.token_estimator import (
4
7
  TokenCalibrator,
@@ -11,6 +14,18 @@ from python_agent_harness.token_estimator import (
11
14
 
12
15
 
13
16
  class TestTokenizer(unittest.TestCase):
17
+ def setUp(self):
18
+ # pin the default config path to a nonexistent file so the
19
+ # tests never read the user's real config.json
20
+ self._saved_cfg = os.environ.get("PYTHON_AGENT_HARNESS_CONFIG")
21
+ os.environ["PYTHON_AGENT_HARNESS_CONFIG"] = "/no/such/harness-config.json"
22
+
23
+ def tearDown(self):
24
+ if self._saved_cfg is None:
25
+ os.environ.pop("PYTHON_AGENT_HARNESS_CONFIG", None)
26
+ else:
27
+ os.environ["PYTHON_AGENT_HARNESS_CONFIG"] = self._saved_cfg
28
+
14
29
  def test_empty_text_zero_tokens(self):
15
30
  self.assertEqual(estimate_tokens(""), 0)
16
31
 
@@ -26,10 +41,26 @@ class TestTokenizer(unittest.TestCase):
26
41
 
27
42
  def test_context_window_matching(self):
28
43
  self.assertEqual(context_window_for("deepseek-v4"), 1_000_000)
44
+ self.assertEqual(context_window_for("deepseek-v4-flash"), 1_000_000)
45
+ self.assertEqual(context_window_for("deepseek-v4-pro"), 1_000_000)
29
46
  self.assertEqual(context_window_for("gpt-5-mini"), 128_000)
47
+ self.assertEqual(context_window_for("gpt-5-pro"), 400_000)
30
48
  self.assertEqual(context_window_for("claude-sonnet"), 200_000)
49
+ self.assertEqual(context_window_for("qwen3.5-32b"), 131_072)
50
+ self.assertEqual(context_window_for("kimi-k2.7-0613"), 256_000)
31
51
  self.assertEqual(context_window_for("unknown-model"), 128_000)
32
52
 
53
+ def test_context_window_config_file_override(self):
54
+ """A context_windows section in the config file overrides the
55
+ built-in table (matched before CONTEXT_WINDOWS)."""
56
+ with tempfile.TemporaryDirectory() as d:
57
+ p = Path(d) / "config.json"
58
+ p.write_text('{"context_windows": {"deepseek-v4*": 2000000}}', encoding="utf-8")
59
+ self.assertEqual(context_window_for("deepseek-v4-flash", str(p)), 2_000_000)
60
+ # models the override doesn't cover still use the table
61
+ self.assertEqual(context_window_for("gpt-5-mini", str(p)), 128_000)
62
+ self.assertEqual(context_window_for("unknown-model", str(p)), 128_000)
63
+
33
64
  def test_calibrator(self):
34
65
  c = TokenCalibrator()
35
66
  c.last_raw_estimate = 1000