Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions agent/conversation_error_classifiers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Conversation-loop error classifiers."""

from typing import Optional

def _is_stale_copilot_credential_error(status_code: Optional[int], error_message: str) -> bool:
"""Detect a Copilot 400 that is really a STALE / DEGRADED credential.

Copilot surfaces a stale or degraded credential as an HTTP 400 rather than a
clean 401. Two body markers indicate this class:

- ``model_not_available_for_integrator`` — the request reached the
restricted ``copilot-language-server`` integrator (the server's fallback
when it receives a raw OAuth token instead of an exchanged API token),
whose model allowlist omits enterprise-only models.
- ``model_not_supported`` / "the requested model is not supported" — the
cached bearer's Copilot entitlement rotated out from under a long-lived
process.

Matched narrowly (status 400 AND a specific marker) so a genuinely wrong
model name — a real 400 — never triggers the single-shot re-exchange. The
caller enforces copilot-provider scoping and the single-shot guard.
"""
lowered = (error_message or "").lower()
is_400 = status_code == 400 or "error code: 400" in lowered
if not is_400:
return False
return (
"model_not_available_for_integrator" in lowered
or "not available for integrator" in lowered
or "model_not_supported" in lowered
or "the requested model is not supported" in lowered
)
29 changes: 1 addition & 28 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from typing import Any, Dict, List, Optional

from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.conversation_error_classifiers import _is_stale_copilot_credential_error
from agent.conversation_compression import (
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE,
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE,
Expand Down Expand Up @@ -298,34 +299,6 @@ def _is_copilot_provider(agent: Any) -> bool:
}


def _is_stale_copilot_credential_error(status_code: Optional[int], error_message: str) -> bool:
"""Detect a Copilot 400 that is really a STALE / DEGRADED credential.

Copilot surfaces a stale or degraded credential as an HTTP 400 rather than a
clean 401. Two body markers indicate this class:

- ``model_not_available_for_integrator`` — the request reached the
restricted ``copilot-language-server`` integrator (the server's fallback
when it receives a raw OAuth token instead of an exchanged API token),
whose model allowlist omits enterprise-only models.
- ``model_not_supported`` / "the requested model is not supported" — the
cached bearer's Copilot entitlement rotated out from under a long-lived
process.

Matched narrowly (status 400 AND a specific marker) so a genuinely wrong
model name — a real 400 — never triggers the single-shot re-exchange. The
caller enforces copilot-provider scoping and the single-shot guard.
"""
lowered = (error_message or "").lower()
is_400 = status_code == 400 or "error code: 400" in lowered
if not is_400:
return False
return (
"model_not_available_for_integrator" in lowered
or "not available for integrator" in lowered
or "model_not_supported" in lowered
or "the requested model is not supported" in lowered
)


def _image_error_max_dimension(error: Exception) -> Optional[int]:
Expand Down
1 change: 1 addition & 0 deletions contributors/emails/andrexibiza@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
andrexibiza
36 changes: 36 additions & 0 deletions tests/agent/test_conversation_error_classifiers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Runtime seams for the extracted conversation error classifier."""

from importlib import import_module
from unittest.mock import Mock

import pytest

classifiers = import_module("agent.conversation_error_classifiers")
conversation_loop = import_module("agent.conversation_loop")


@pytest.mark.parametrize(
("status_code", "message", "expected"),
[
(400, "model_not_available_for_integrator", True),
(400, "MODEL_NOT_SUPPORTED", True),
(None, "error code: 400; the requested model is not supported", True),
(401, "model_not_supported", False),
(400, "the model name is invalid", False),
(None, "temporary upstream failure", False),
],
)
def test_stale_copilot_credential_classifier_behavior(status_code, message, expected):
assert classifiers._is_stale_copilot_credential_error(status_code, message) is expected


def test_legacy_namespace_exports_the_same_callable():
assert conversation_loop._is_stale_copilot_credential_error is classifiers._is_stale_copilot_credential_error


def test_legacy_namespace_binding_remains_patchable(monkeypatch):
replacement = Mock(return_value="patched")
monkeypatch.setattr(conversation_loop, "_is_stale_copilot_credential_error", replacement)

assert conversation_loop._is_stale_copilot_credential_error(400, "anything") == "patched"
replacement.assert_called_once_with(400, "anything")
Loading