diff --git a/coworker/providers/openai_provider.py b/coworker/providers/openai_provider.py index fd733f93..48e6b6a0 100644 --- a/coworker/providers/openai_provider.py +++ b/coworker/providers/openai_provider.py @@ -82,6 +82,12 @@ def _strip_foreign_sidecars(messages: list[dict[str, Any]]) -> list[dict[str, An _MAX_TOKENS_ERROR = "'max_tokens' is not supported" +# Ceiling, not a spend target — same rationale as the Anthropic provider's default: a +# coworker writing a report ships the whole file inside one tool call's arguments, and +# compat servers left to their OWN defaults cap completions absurdly low (observed +# 2026-08-15: Together defaulted Kimi K3 to ~2k tokens — every ~5KB write truncated). +DEFAULT_MAX_TOKENS = 32000 + def _param_fix_retry(kwargs: dict[str, Any], exc: Exception) -> dict[str, Any]: """Kwargs for the one retry an unsupported-parameter error earns, or re-raise. @@ -103,6 +109,14 @@ def _param_fix_retry(kwargs: dict[str, Any], exc: Exception) -> dict[str, Any]: fixed = dict(kwargs) fixed.pop("stream_options") return fixed + if ("max_tokens" in msg or "max_new_tokens" in msg) and "max_tokens" in kwargs: + # Our 32k default exceeded this model's completion limit (each server words the + # 400 differently, so no number parsing) — drop the param and retry on the + # server's own default rather than surfacing the 400. Worst case is exactly + # yesterday's behavior; best case the server allows far more once asked. + fixed = dict(kwargs) + fixed.pop("max_tokens") + return fixed raise exc @@ -179,11 +193,13 @@ class OpenAIProvider(ProviderClient): } if tools: kwargs["tools"] = tools + kwargs.setdefault("max_tokens", DEFAULT_MAX_TOKENS) _pin_reasoning_effort(kwargs) client = self._ensure_client() - # Up to two param-fix retries: effort and max_tokens can BOTH need fixing. - for _ in range(2): + # Up to three param-fix retries: effort, the max_tokens rename, and the + # max_tokens over-limit drop can ALL need fixing on one call. + for _ in range(3): try: response = client.chat.completions.create(**kwargs) break @@ -227,6 +243,7 @@ class OpenAIProvider(ProviderClient): } if tools: kwargs["tools"] = tools + kwargs.setdefault("max_tokens", DEFAULT_MAX_TOKENS) _pin_reasoning_effort(kwargs) client = self._ensure_client() @@ -236,8 +253,9 @@ class OpenAIProvider(ProviderClient): finish_reason = None usage: Optional[TokenUsage] = None - # Up to two param-fix retries: effort and max_tokens can BOTH need fixing. - for _ in range(2): + # Up to three param-fix retries: effort, the max_tokens rename, and the + # max_tokens over-limit drop can ALL need fixing on one call. + for _ in range(3): try: chunks = client.chat.completions.create(**kwargs) break diff --git a/tests/test_providers.py b/tests/test_providers.py index 943c6e4c..cd068b67 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -456,3 +456,47 @@ def test_complete_picks_up_reasoning_content(): provider = OpenAIProvider(client=_FakeClient(SimpleNamespace(choices=[choice]))) turn = provider.complete(model="deepseek-v4-pro", messages=[{"role": "user", "content": "x"}]) assert turn.text == "Answer" and turn.reasoning == "deep thought" + + +def test_default_max_tokens_injected_and_caller_setting_wins(): + """Compat servers left to their OWN defaults cap completions absurdly low + (owner-hit 2026-08-15: Together defaulted Kimi K3 to ~2k tokens, so every report + write truncated mid-arguments). The request always names a ceiling now.""" + from coworker.providers.openai_provider import DEFAULT_MAX_TOKENS + + client = _FakeClient(_response(content="ok")) + provider = OpenAIProvider(client=client) + provider.complete(model="kimi-k3", messages=[]) + assert client.chat.completions.calls[0]["max_tokens"] == DEFAULT_MAX_TOKENS + + client2 = _FakeClient(_response(content="ok")) + provider2 = OpenAIProvider(client=client2) + provider2.complete(model="kimi-k3", messages=[], max_tokens=512) + assert client2.chat.completions.calls[0]["max_tokens"] == 512 + + +def test_over_limit_max_tokens_is_dropped_and_retried(): + """A model whose completion limit sits below our default must not surface the 400: + drop the param, retry on the server's own default (yesterday's behavior, at worst).""" + + class _LimitRejecting: + def __init__(self, response): + self._response = response + self.calls: list[dict] = [] + + def create(self, **kwargs): + self.calls.append(kwargs) + if "max_tokens" in kwargs: + raise RuntimeError( + "Error code: 400 - max_tokens must be at most 8193 for this model" + ) + return self._response + + client = _FakeClient(_response(content="ok")) + client.chat.completions = _LimitRejecting(_response(content="ok")) + provider = OpenAIProvider(client=client) + + turn = provider.complete(model="tiny-model", messages=[]) + calls = client.chat.completions.calls + assert turn.text == "ok" and len(calls) == 2 + assert "max_tokens" not in calls[1]