ouroboros/tests/test_llm_extraction.py
Anton Razzhigaev f6711d3369 feat: integrate subscription accounts into model setup and execution
Add caller-owned Claudexor model calls, unified subscription/API setup, explicit role accounts and live quota/auth continuation. Preserve prompt/tool ownership, physical-attempt custody and manual assignments. Keep the existing task process through waits and post-work.

The reviewed source checkpoint retains the current dependency pin. Published signed Claudexor bytes, cross-platform CI and the agreed merge ordering remain delivery prerequisites. No Ouroboros version, tag or release is created.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-07 10:54:51 +00:00

284 lines
13 KiB
Python

"""Structural contracts for the semantic-no-op llm.py extraction."""
from __future__ import annotations
import ast
import hashlib
import json
import pathlib
import sys
from ouroboros import (
llm,
llm_anthropic,
llm_attempt,
llm_capability_policy,
llm_claudexor,
llm_fallback,
llm_gigachat,
llm_local,
llm_messages,
llm_openai_compatible,
llm_pricing,
llm_probe,
llm_routing,
)
from ouroboros.llm import LLMClient
REPO = pathlib.Path(__file__).parents[1]
PKG = REPO / "ouroboros"
_LEAVES = (
llm_attempt,
llm_capability_policy,
llm_claudexor,
llm_routing,
llm_messages,
llm_fallback,
llm_anthropic,
llm_gigachat,
llm_local,
llm_openai_compatible,
llm_pricing,
# Not a mixin and not an extraction: the probe transport arrived whole from
# upstream. It is an llm_* leaf all the same, so the leaf rules bind it —
# never import the parent, no cycles, real weight.
llm_probe,
)
# Module-level names that moved. llm.py re-exports every one of them, so its
# import surface (and every existing importer) is unchanged.
_MODULE_OWNERS = {
llm_attempt: (
"_CACHE_TTL_SECONDS _VALID_CACHE_TTLS _applied_payload_cache_ttl _attempt_request "
"_candidate_before_dispatch _canonical_candidate_bytes _execute_candidate "
"_execute_candidate_async _is_structured_context_overflow_body "
"_is_structured_context_overflow_exception _physical_candidate "
"_route_normalizes_cache_breakpoints _structured_error_values cache_ttl_seconds "
"supports_message_cache_control"
),
llm_capability_policy: (
"_MANDATORY_VALUE_MARKERS _OPTIONAL_DROPPABLE_PARAMS _OPTIONAL_SAMPLING_PARAMS "
"normalize_reasoning_effort"
),
llm_routing: "_OR_PROVIDER_PRESETS _resolve_or_provider",
llm_local: (
"LocalContextTooLargeError _LOCAL_COMPACTION_MODES _compact_local_text "
"_compact_markdown_sections _estimate_message_chars _split_markdown_sections"
),
llm_openai_compatible: "_FALSE_LIKE_ENV_VALUES _RESPONSE_METADATA_LABEL_MAX_CHARS _bounded_response_metadata_label",
llm_pricing: "add_usage fetch_cloudru_pricing fetch_openrouter_pricing",
}
# LLMClient members that moved into an owner mixin. LLMClient inherits the exact
# same function objects, so name, signature and body are unchanged.
_MIXIN_OWNERS = {
(llm_attempt, "_PayloadCachePolicyMixin"): (
"_MAX_CACHE_BREAKPOINTS _normalize_payload_cache_ttl _payload_cache_breakpoints "
"_stage_reasoning_pin_disclosure"
),
(llm_capability_policy, "_CapabilityPolicyMixin"): (
"_CAPABILITIES_FETCH_OK _CONTEXT_LENGTH_CACHE _EFFORT_CEILING_CACHE _EFFORT_CEILING_LOADED "
"_EFFORT_FLOOR_CACHE _EFFORT_FLOOR_LOADED _EFFORT_FLOOR_RELOAD_SEC _NESTED_REASONING_PARAM "
"_REJECTED_PARAMS_CACHE _REJECTED_PARAMS_LOADED _REJECTED_PARAMS_RELOAD_SEC "
"_SUPPORTED_PARAMS_CACHE _SUPPORTED_PARAMS_FETCHED _apply_rejected_param_cache "
"_clamp_effort_for_model _effort_ceiling_for _effort_floor_for "
"_fetch_openrouter_capabilities _get_supported_parameters _known_rejected_params "
"_mandatory_value_rejection _parameter_rejection_error _payload_effort "
"_pop_effort_clamp_disclosure _pop_thread_disclosure _record_effort_ceiling _record_effort_floor "
"_remember_rejected_params _retry_without_optional_sampling _set_payload_effort "
"clamp_effort_for_route metadata_fetch_attempted_and_failed openrouter_context_length"
),
(llm_routing, "_ProviderRoutingMixin"): (
"_chat_remote claudexor_model_sources claudexor_model_catalog supports_response_format "
"_explicit_cache_affinity_identity _get_async_remote_client _get_client _get_local_client "
"_get_remote_client _make_no_proxy_async_client _make_no_proxy_client _new_remote_client "
"_no_proxy_timeout _openrouter_session_identity _parse_provider_model "
"_prompt_cache_identity _qualified_model_name _resolve_remote_target "
"probe_oversized_context probe_provider_readiness"
),
(llm_messages, "_MessageShapingMixin"): (
"_REASONING_CONTENT_BLOCK_TYPES _content_with_system_notice_marker "
"_copy_messages_with_cache_policy "
"_has_replayed_reasoning_metadata _is_deferrable_image_user_turn _model_family "
"_normalize_system_message_placement _replace_image_blocks_with_placeholder "
"_strip_openrouter_roundtrip_metadata sanitize_reasoning_on_model_switch"
),
(llm_fallback, "_RecoveryLadderMixin"): (
"_create_chat_completion_with_retries _create_chat_completion_with_retries_async "
"_is_http_status _is_transient_body_error _openrouter_signature_retry_kwargs "
"_param_retry_kwargs_for_body_error _provider_body_error _reroute_kwargs_for_body_error "
"_reroute_same_model_kwargs _retry_without_prompt_cache_parameter "
"_rotate_openrouter_session_affinity _strip_kwargs_for_encrypted_body_error"
),
(llm_anthropic, "_AnthropicLaneMixin"): (
"_anthropic_blocks_from_content _anthropic_image_block _build_anthropic_messages "
"_build_anthropic_tool_choice _build_remote_candidate _cache_write_split _chat_anthropic "
"_coalesce_anthropic_message _normalize_anthropic_response "
"_sanitize_anthropic_tool_result_content _stringify_anthropic_content"
),
(llm_gigachat, "_GigaChatLaneMixin"): (
"_chat_gigachat _get_gigachat_client _gigachat_function_result _gigachat_messages "
"_gigachat_text _new_gigachat_client _normalize_gigachat_response"
),
(llm_local, "_LocalLaneMixin"): "_chat_local _prepare_messages_for_local_context",
(llm_openai_compatible, "_OpenAICompatibleLaneMixin"): (
"_build_remote_kwargs _normalize_remote_response _openrouter_main_web_search_tool "
"extract_display_reasoning"
),
(llm_pricing, "_GenerationCostMixin"): "_fetch_generation_cost",
}
# Members llm.py keeps: the composition itself, the caller-facing chat surface,
# and the tool-schema/tool-call translators every lane reaches by class name.
_PARENT_MEMBERS = frozenset({
"__init__", "chat", "chat_async", "vision_query", "default_model",
"available_models", "_strip_reasoning_wrappers", "_parse_tool_calls_from_content",
"_stringify_tool_description", "_sanitize_chat_completion_tools", "_build_anthropic_tools",
"_gigachat_sanitize_schema", "_gigachat_functions",
})
def test_llm_leaves_never_import_their_parent():
for module in _LEAVES:
tree = ast.parse(pathlib.Path(module.__file__).read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
assert node.module != "ouroboros.llm", module.__name__
if isinstance(node, ast.Import):
assert not any(a.name == "ouroboros.llm" for a in node.names), module.__name__
def test_llm_facade_reexports_every_moved_module_identity():
"""``ouroboros.llm`` keeps the exact objects, so existing importers and
monkeypatch targets of the module surface see no identity change."""
for owner, names in _MODULE_OWNERS.items():
for name in names.split():
assert hasattr(llm, name), name
assert getattr(llm, name) is getattr(owner, name), name
# The shared context-budget seam stays reachable through llm.py as before.
from ouroboros import context_budget
assert llm.context_overflow_message is context_budget.context_overflow_message
assert llm.CONTEXT_OVERFLOW_CODES is context_budget.CONTEXT_OVERFLOW_CODES
def _defined_members(path: pathlib.Path, class_name: str) -> set[str]:
"""Members a class DEFINES in source — immune to monkeypatch residue that an
earlier test in the same process may have left on the class object."""
tree = ast.parse(path.read_text(encoding="utf-8"))
node = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name)
out: set[str] = set()
for sub in node.body:
if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
out.add(sub.name)
elif isinstance(sub, ast.Assign):
out.update(t.id for t in sub.targets if isinstance(t, ast.Name))
elif isinstance(sub, ast.AnnAssign) and isinstance(sub.target, ast.Name):
out.add(sub.target.id)
return out
def test_llm_client_members_resolve_to_their_mixin_owners():
parent_defined = _defined_members(pathlib.Path(llm.__file__), "LLMClient")
for (owner, mixin_name), names in _MIXIN_OWNERS.items():
mixin = getattr(owner, mixin_name)
defined = _defined_members(pathlib.Path(owner.__file__), mixin_name)
for name in names.split():
assert name in defined, f"{mixin_name}.{name}"
assert name not in parent_defined, f"{name} is defined twice"
assert name in mixin.__dict__, name
assert hasattr(LLMClient, name), name
def test_llm_client_member_inventory_is_unchanged():
"""The composed class exposes exactly the member set of the tree it was split from.
The digest has moved twice, each time with named provenance. (1) The final upstream
cutoff (PR #257) added three methods to ``LLMClient`` in the base —
``_new_remote_client``, ``probe_provider_readiness`` and ``_new_gigachat_client`` —
and the adopting merge re-homed them into the leaves that own their siblings.
(2) The F6 sync #2 (upstream f3fbfdbb, issue #468) replaced
``_pop_cache_breakpoint_disclosure`` with the generalized
``_pop_thread_disclosure`` (one slot reader for every thread-local disclosure),
retired ``_has_openrouter_reasoning_details`` (the shape-first classifier in
``reasoning_artifacts`` answers the sealed question the broad presence predicate
could not), and added ``_stage_reasoning_pin_disclosure`` beside the payload it
describes. (3) The F2 upstream absorption (upstream b7a73355) added
``_build_remote_candidate`` — one provider-aware candidate builder serving both
the direct-Anthropic send and ``task_pacing``'s prospective wrap-up estimate —
homed on the Anthropic lane mixin. A member appearing or vanishing without that
kind of provenance is what this pin exists to catch. (4) The subscription
model integration adds three explicit route capability/catalog methods and
places complete remote dispatch with the existing provider/client owner.
Every prior member remains inherited; no dispatch semantics changed.
"""
assert _defined_members(pathlib.Path(llm.__file__), "LLMClient") == _PARENT_MEMBERS
moved = {name for names in _MIXIN_OWNERS.values() for name in names.split()}
composed = sorted(moved | _PARENT_MEMBERS)
assert hashlib.sha256(
json.dumps(composed, separators=(",", ":")).encode()
).hexdigest() == "025221e4a003803c39da72098b267397c614ec744cb44530e3d8870f4cde5826"
for name in composed:
assert hasattr(LLMClient, name), name
def test_llm_mixin_composition_order_is_pinned():
assert [base.__name__ for base in LLMClient.__mro__] == [
"LLMClient",
"_PayloadCachePolicyMixin",
"_CapabilityPolicyMixin",
"_ProviderRoutingMixin",
"_MessageShapingMixin",
"_RecoveryLadderMixin",
"_AnthropicLaneMixin",
"_GigaChatLaneMixin",
"_LocalLaneMixin",
"_OpenAICompatibleLaneMixin",
"_GenerationCostMixin",
"object",
]
# No mixin shadows another: every member has exactly one owner.
owners: dict[str, str] = {}
for base in LLMClient.__mro__[1:-1]:
for name in _defined_members(pathlib.Path(sys.modules[base.__module__].__file__), base.__name__):
assert name not in owners, f"{name} owned by both {owners.get(name)} and {base.__name__}"
owners[name] = base.__name__
def test_llm_extraction_size_bounds_have_meaningful_headroom():
counts = {
module.__name__: len(
pathlib.Path(module.__file__).read_text(encoding="utf-8").splitlines()
)
for module in (llm, *_LEAVES)
}
assert counts["ouroboros.llm"] <= 750
assert all(count <= 1000 for count in counts.values()), counts
# Every leaf carries real weight; a 40-line leaf would be a seam, not an owner.
assert all(count >= 200 for count in counts.values()), counts
def test_llm_leaf_import_graph_is_acyclic_and_shallow():
"""Leaves may depend on siblings, never in a cycle."""
edges: dict[str, set[str]] = {}
for module in _LEAVES:
name = module.__name__.rsplit(".", 1)[-1]
tree = ast.parse(pathlib.Path(module.__file__).read_text(encoding="utf-8"))
edges[name] = {
node.module.rsplit(".", 1)[-1]
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom)
and str(node.module or "").startswith("ouroboros.llm_")
}
seen: set[str] = set()
def walk(node: str, stack: tuple[str, ...]) -> None:
assert node not in stack, f"import cycle: {stack + (node,)}"
for child in sorted(edges.get(node, ())):
walk(child, stack + (node,))
seen.add(node)
for name in sorted(edges):
walk(name, ())
assert seen == set(edges)