fix(llm): resolve context window via max_input_tokens with 128k fallback for unmapped models
- get_context_window() now reads litellm.get_model_info().max_input_tokens instead of get_max_tokens(), which returns the output cap and severely under-reported the window for every mapped model (e.g. deepseek 8k vs 1M) - models litellm cannot map fall back to 128000 with a single warning per model instead of warning on every call and returning None - raise LLMConfig.max_tokens default 8192 -> 32768 to match the template - README: configure the API key directly in llm_config.yaml; document max_tokens / context_window in the example Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -554,24 +554,25 @@ Run `opc init` once from the repo root. It creates `.opc/`, copies the template
|
|||||||
|
|
||||||
After `opc init`, edit `.opc/config/llm_config.yaml` in the repo-local OPC home. If you set `OPC_HOME`, edit `$OPC_HOME/config/llm_config.yaml` instead.
|
After `opc init`, edit `.opc/config/llm_config.yaml` in the repo-local OPC home. If you set `OPC_HOME`, edit `$OPC_HOME/config/llm_config.yaml` instead.
|
||||||
|
|
||||||
The template leaves secrets empty. Configure either a literal key or an env var:
|
The template leaves secrets empty. Write your key directly into the file:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
llm:
|
llm:
|
||||||
default_model: "openai/gpt-5.4"
|
default_model: "openai/gpt-5.4"
|
||||||
api_base: "https://openrouter.ai/api/v1"
|
api_base: "https://openrouter.ai/api/v1"
|
||||||
api_key: ""
|
api_key: "sk-or-v1-..." # your OpenRouter (or other provider) API key
|
||||||
api_key_env: "OPENROUTER_API_KEY"
|
|
||||||
|
max_tokens: 32768 # max output tokens per request; lower it if your
|
||||||
|
# model's output cap is smaller
|
||||||
|
# context_window: 128000 # total input window. Usually auto-detected via
|
||||||
|
# litellm; unmapped models fall back to 128000.
|
||||||
|
# Uncomment and set only when the fallback is
|
||||||
|
# wrong for your model.
|
||||||
```
|
```
|
||||||
|
|
||||||
Then:
|
Then verify with `opc status`.
|
||||||
|
|
||||||
```bash
|
If you prefer not to store the key in the file, leave `api_key` empty and set `api_key_env` to the name of an environment variable that holds it (e.g. `api_key_env: "OPENROUTER_API_KEY"`).
|
||||||
export OPENROUTER_API_KEY="..."
|
|
||||||
opc status
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also use provider-specific env vars such as `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` if your model/provider configuration expects them.
|
|
||||||
|
|
||||||
### External Agents
|
### External Agents
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ llm:
|
|||||||
fallback: {}
|
fallback: {}
|
||||||
|
|
||||||
# Optional: total input context window, used as the denominator for the
|
# Optional: total input context window, used as the denominator for the
|
||||||
# context-usage ring and to trigger auto-summarization. You normally do NOT
|
# context-usage ring and to trigger auto-summarization. Auto-detected via
|
||||||
# need this — for models litellm can map (most OpenAI / Anthropic models) the
|
# litellm for most OpenAI / Anthropic models; models litellm cannot map
|
||||||
# window is resolved automatically. Only set it when your model is served
|
# (e.g. deepseek/doubao/glm or proxy / self-hosted endpoints) fall back to
|
||||||
# through a proxy / self-hosted endpoint that litellm cannot map, so the ring
|
# 128000. Set this only when the fallback is wrong for your model.
|
||||||
# has no denominator. Scalar applies to the default model:
|
# Scalar applies to the default model:
|
||||||
# context_window: 200000
|
# context_window: 200000
|
||||||
# Or per-model (wins over the scalar); keys are matched by model name:
|
# Or per-model (wins over the scalar); keys are matched by model name:
|
||||||
# context_window_overrides:
|
# context_window_overrides:
|
||||||
|
|||||||
+4
-3
@@ -272,12 +272,13 @@ class LLMConfig(BaseModel):
|
|||||||
routing: dict[str, str] = Field(default_factory=dict)
|
routing: dict[str, str] = Field(default_factory=dict)
|
||||||
fallback: dict[str, Any] = Field(default_factory=dict)
|
fallback: dict[str, Any] = Field(default_factory=dict)
|
||||||
temperature: float = 0.3
|
temperature: float = 0.3
|
||||||
max_tokens: int = 8192
|
max_tokens: int = 32768
|
||||||
# Total input context window (tokens) for the active model. Set this when
|
# Total input context window (tokens) for the active model. Set this when
|
||||||
# the model is not mapped in litellm (e.g. proxy/self-hosted models like
|
# the model is not mapped in litellm (e.g. proxy/self-hosted models like
|
||||||
# doubao/minimax/glm), so the context-usage ring and compaction thresholds
|
# doubao/minimax/glm), so the context-usage ring and compaction thresholds
|
||||||
# have a real denominator. 0 = auto-detect via litellm. Optional per-model
|
# have a real denominator. 0 = auto-detect via litellm; unmapped models
|
||||||
# overrides keyed by model name take precedence over the scalar value.
|
# fall back to 128000. Optional per-model overrides keyed by model name
|
||||||
|
# take precedence over the scalar value.
|
||||||
context_window: int = 0
|
context_window: int = 0
|
||||||
context_window_overrides: dict[str, int] = Field(default_factory=dict)
|
context_window_overrides: dict[str, int] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|||||||
+26
-4
@@ -82,6 +82,12 @@ def _normalized_model_name(model: str) -> str:
|
|||||||
return model.strip().lower()
|
return model.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
# Used when neither user config nor litellm can supply a window. Conservative
|
||||||
|
# enough for modern models so compaction still has a real denominator.
|
||||||
|
_CONTEXT_WINDOW_FALLBACK = 128_000
|
||||||
|
_context_window_fallback_warned: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
_CONTEXT_WINDOW_OVERRIDES: tuple[tuple[str, int], ...] = (
|
_CONTEXT_WINDOW_OVERRIDES: tuple[tuple[str, int], ...] = (
|
||||||
("gpt-5.4-pro", 1_050_000),
|
("gpt-5.4-pro", 1_050_000),
|
||||||
("gpt-5.4-mini", 400_000),
|
("gpt-5.4-mini", 400_000),
|
||||||
@@ -269,11 +275,27 @@ class LLMProvider:
|
|||||||
if override is not None:
|
if override is not None:
|
||||||
return override
|
return override
|
||||||
try:
|
try:
|
||||||
limit = litellm.get_max_tokens(resolved_model)
|
# max_input_tokens is the context window; litellm.get_max_tokens()
|
||||||
return int(limit) if limit else None
|
# returns the "max_tokens" map entry, which for many models (e.g.
|
||||||
|
# deepseek) is the OUTPUT cap and would wildly under-report here.
|
||||||
|
info = litellm.get_model_info(resolved_model)
|
||||||
|
limit = info.get("max_input_tokens") or info.get("max_tokens")
|
||||||
|
if limit:
|
||||||
|
return int(limit)
|
||||||
|
reason = "model is not mapped in litellm"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Unable to resolve context window for model={resolved_model}: {e}")
|
reason = str(e)
|
||||||
return None
|
if resolved_model not in _context_window_fallback_warned:
|
||||||
|
_context_window_fallback_warned.add(resolved_model)
|
||||||
|
logger.warning(
|
||||||
|
"Unable to resolve context window for model={} ({}); assuming {} tokens. "
|
||||||
|
"Set llm.context_window or llm.context_window_overrides in llm_config.yaml "
|
||||||
|
"to use the model's real window.",
|
||||||
|
resolved_model,
|
||||||
|
reason,
|
||||||
|
_CONTEXT_WINDOW_FALLBACK,
|
||||||
|
)
|
||||||
|
return _CONTEXT_WINDOW_FALLBACK
|
||||||
|
|
||||||
def count_input_tokens(
|
def count_input_tokens(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class TestLLMProviderContextWindow(unittest.TestCase):
|
|||||||
def test_gpt_5_4_override_applies_on_official_openai_base(self) -> None:
|
def test_gpt_5_4_override_applies_on_official_openai_base(self) -> None:
|
||||||
provider = LLMProvider(LLMConfig(default_model="openai/gpt-5.4"))
|
provider = LLMProvider(LLMConfig(default_model="openai/gpt-5.4"))
|
||||||
|
|
||||||
with patch("opc.llm.provider.litellm.get_max_tokens", return_value=128000):
|
with patch("opc.llm.provider.litellm.get_model_info", return_value={"max_input_tokens": 128000}):
|
||||||
self.assertEqual(provider.get_context_window(), 1_050_000)
|
self.assertEqual(provider.get_context_window(), 1_050_000)
|
||||||
|
|
||||||
def test_gpt_5_4_override_does_not_apply_on_proxy_base(self) -> None:
|
def test_gpt_5_4_override_does_not_apply_on_proxy_base(self) -> None:
|
||||||
@@ -44,7 +44,7 @@ class TestLLMProviderContextWindow(unittest.TestCase):
|
|||||||
api_base="https://openrouter.ai/api/v1",
|
api_base="https://openrouter.ai/api/v1",
|
||||||
))
|
))
|
||||||
|
|
||||||
with patch("opc.llm.provider.litellm.get_max_tokens", return_value=128000):
|
with patch("opc.llm.provider.litellm.get_model_info", return_value={"max_input_tokens": 128000}):
|
||||||
self.assertEqual(provider.get_context_window(), 128000)
|
self.assertEqual(provider.get_context_window(), 128000)
|
||||||
|
|
||||||
def test_poe_claude_sonnet_4_5_model_uses_local_context_window(self) -> None:
|
def test_poe_claude_sonnet_4_5_model_uses_local_context_window(self) -> None:
|
||||||
@@ -53,9 +53,9 @@ class TestLLMProviderContextWindow(unittest.TestCase):
|
|||||||
api_base="https://api.poe.com/v1",
|
api_base="https://api.poe.com/v1",
|
||||||
))
|
))
|
||||||
|
|
||||||
with patch("opc.llm.provider.litellm.get_max_tokens") as get_max_tokens:
|
with patch("opc.llm.provider.litellm.get_model_info") as get_model_info:
|
||||||
self.assertEqual(provider.get_context_window(), 64_000)
|
self.assertEqual(provider.get_context_window(), 64_000)
|
||||||
get_max_tokens.assert_not_called()
|
get_model_info.assert_not_called()
|
||||||
|
|
||||||
def test_poe_openai_compatible_legacy_prefix_uses_same_context_window(self) -> None:
|
def test_poe_openai_compatible_legacy_prefix_uses_same_context_window(self) -> None:
|
||||||
provider = LLMProvider(LLMConfig(
|
provider = LLMProvider(LLMConfig(
|
||||||
@@ -63,16 +63,27 @@ class TestLLMProviderContextWindow(unittest.TestCase):
|
|||||||
api_base="https://api.poe.com/v1",
|
api_base="https://api.poe.com/v1",
|
||||||
))
|
))
|
||||||
|
|
||||||
with patch("opc.llm.provider.litellm.get_max_tokens") as get_max_tokens:
|
with patch("opc.llm.provider.litellm.get_model_info") as get_model_info:
|
||||||
self.assertEqual(provider.get_context_window(), 64_000)
|
self.assertEqual(provider.get_context_window(), 64_000)
|
||||||
get_max_tokens.assert_not_called()
|
get_model_info.assert_not_called()
|
||||||
|
|
||||||
def test_non_overridden_model_still_uses_litellm(self) -> None:
|
def test_non_overridden_model_still_uses_litellm(self) -> None:
|
||||||
provider = LLMProvider(LLMConfig(default_model="openai/gpt-4o"))
|
provider = LLMProvider(LLMConfig(default_model="openai/gpt-4o"))
|
||||||
|
|
||||||
with patch("opc.llm.provider.litellm.get_max_tokens", return_value=128000):
|
with patch("opc.llm.provider.litellm.get_model_info", return_value={"max_input_tokens": 128000}):
|
||||||
self.assertEqual(provider.get_context_window(), 128000)
|
self.assertEqual(provider.get_context_window(), 128000)
|
||||||
|
|
||||||
|
def test_context_window_uses_max_input_tokens_not_output_cap(self) -> None:
|
||||||
|
"""deepseek-style entries: max_tokens is the OUTPUT cap (8192), the
|
||||||
|
context window is max_input_tokens (1M). The window must not be 8192."""
|
||||||
|
provider = LLMProvider(LLMConfig(default_model="deepseek/deepseek-v4-pro"))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"opc.llm.provider.litellm.get_model_info",
|
||||||
|
return_value={"max_input_tokens": 1_000_000, "max_tokens": 8192, "max_output_tokens": 8192},
|
||||||
|
):
|
||||||
|
self.assertEqual(provider.get_context_window(), 1_000_000)
|
||||||
|
|
||||||
def test_config_scalar_override_supplies_window_for_unmapped_model(self) -> None:
|
def test_config_scalar_override_supplies_window_for_unmapped_model(self) -> None:
|
||||||
"""Unmapped proxy models (doubao/minimax/…) get a real window from config."""
|
"""Unmapped proxy models (doubao/minimax/…) get a real window from config."""
|
||||||
provider = LLMProvider(LLMConfig(
|
provider = LLMProvider(LLMConfig(
|
||||||
@@ -81,19 +92,28 @@ class TestLLMProviderContextWindow(unittest.TestCase):
|
|||||||
context_window=256000,
|
context_window=256000,
|
||||||
))
|
))
|
||||||
|
|
||||||
with patch("opc.llm.provider.litellm.get_max_tokens", return_value=None) as get_max_tokens:
|
with patch("opc.llm.provider.litellm.get_model_info", return_value={}) as get_model_info:
|
||||||
self.assertEqual(provider.get_context_window(), 256000)
|
self.assertEqual(provider.get_context_window(), 256000)
|
||||||
get_max_tokens.assert_not_called()
|
get_model_info.assert_not_called()
|
||||||
|
|
||||||
def test_unmapped_model_without_override_returns_none(self) -> None:
|
def test_unmapped_model_without_override_falls_back_to_default(self) -> None:
|
||||||
"""No override + litellm can't map → None (unchanged fallback)."""
|
"""No override + litellm can't map → 128000 fallback, not None."""
|
||||||
provider = LLMProvider(LLMConfig(
|
provider = LLMProvider(LLMConfig(
|
||||||
default_model="openai/doubao-seed-2.0-pro",
|
default_model="openai/doubao-seed-2.0-pro",
|
||||||
api_base="https://ark.cn-beijing.volces.com/api/coding/v3",
|
api_base="https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||||
))
|
))
|
||||||
|
|
||||||
with patch("opc.llm.provider.litellm.get_max_tokens", return_value=None):
|
with patch("opc.llm.provider.litellm.get_model_info", return_value={}):
|
||||||
self.assertIsNone(provider.get_context_window())
|
self.assertEqual(provider.get_context_window(), 128000)
|
||||||
|
|
||||||
|
def test_unmapped_model_litellm_error_falls_back_to_default(self) -> None:
|
||||||
|
provider = LLMProvider(LLMConfig(default_model="deepseek/deepseek-v4-pro"))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"opc.llm.provider.litellm.get_model_info",
|
||||||
|
side_effect=Exception("Model deepseek-v4-pro isn't mapped yet."),
|
||||||
|
):
|
||||||
|
self.assertEqual(provider.get_context_window(), 128000)
|
||||||
|
|
||||||
def test_config_per_model_override_takes_precedence(self) -> None:
|
def test_config_per_model_override_takes_precedence(self) -> None:
|
||||||
provider = LLMProvider(LLMConfig(
|
provider = LLMProvider(LLMConfig(
|
||||||
@@ -102,15 +122,15 @@ class TestLLMProviderContextWindow(unittest.TestCase):
|
|||||||
context_window_overrides={"doubao-seed-2.0-pro": 262144},
|
context_window_overrides={"doubao-seed-2.0-pro": 262144},
|
||||||
))
|
))
|
||||||
|
|
||||||
with patch("opc.llm.provider.litellm.get_max_tokens", return_value=None):
|
with patch("opc.llm.provider.litellm.get_model_info", return_value={}):
|
||||||
self.assertEqual(provider.get_context_window(), 262144)
|
self.assertEqual(provider.get_context_window(), 262144)
|
||||||
|
|
||||||
def test_config_override_wins_over_litellm_for_mapped_model(self) -> None:
|
def test_config_override_wins_over_litellm_for_mapped_model(self) -> None:
|
||||||
provider = LLMProvider(LLMConfig(default_model="openai/gpt-4o", context_window=50000))
|
provider = LLMProvider(LLMConfig(default_model="openai/gpt-4o", context_window=50000))
|
||||||
|
|
||||||
with patch("opc.llm.provider.litellm.get_max_tokens", return_value=128000) as get_max_tokens:
|
with patch("opc.llm.provider.litellm.get_model_info", return_value={"max_input_tokens": 128000}) as get_model_info:
|
||||||
self.assertEqual(provider.get_context_window(), 50000)
|
self.assertEqual(provider.get_context_window(), 50000)
|
||||||
get_max_tokens.assert_not_called()
|
get_model_info.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user