litellm/tests/e2e/claude_code/structured_outputs/test_azure.py
mubashir1osmani 3f5ed5a9c8
fix(e2e/claude_code): unblock stage collection, align proxy env names, register compat models (#33433)
* refactor(e2e/claude_code): align proxy env names with the rest of tests/e2e

Every claude_code compat cell used to read its own `LITELLM_PROXY_BASE_URL` and `LITELLM_PROXY_API_KEY` and duplicate the same 12-line "missing env, hard fail" block. The rest of `tests/e2e/` reads `LITELLM_PROXY_URL` and `LITELLM_MASTER_KEY` from `e2e_config.py`, so anyone standing up a live proxy for one suite had to export a second spelling for claude_code, and every cell repeated the same boilerplate.

Centralize the resolution in `claude_code/_env.py`. `resolve_proxy()` prefers the suite-wide `LITELLM_PROXY_URL` / `LITELLM_MASTER_KEY` names and falls back to the legacy pair so existing CI wiring on stage keeps working during the roll-out. `require_proxy(compat_result)` is the one-liner cells call to bind `(base_url, api_key)` or hard-fail with a message that names both spellings.

55 cell files, `_basic_messaging.py`, and the driver's own unit-test fixture now go through the helper. `run_compat.sh` accepts either spelling and normalizes to the primary names before invoking pytest. `cron_vm/run_daily.sh` exports the primary names when launching pytest.

`_pr_gate_unit_tests/test_env_resolution.py` pins the resolution rules so a future edit cannot silently reintroduce the drift: primary names win on tie, legacy names still resolve when primary is unset, mixed URL-primary key-legacy still resolves, empty-string exports are treated as unset, `require_proxy` names both spellings in its error message.

Net diff: 71 files, +370/-1240.

* fix(e2e): anchor claude_code Bash pin at parents[1] so container run collects

`test_bash_tool_restrictions.py` derived `REPO_ROOT = Path(__file__).resolve().parents[4]` and then joined `tests/e2e/claude_code/<feature>`. That works locally, but the stage container mounts tests/e2e/ at /app/e2e/, so parents[4] resolves to filesystem root and the `_bash_cells()` assertion looks for `/tests/e2e/claude_code/tool_use` — a path that doesn't exist. Collection interrupts before any test runs, so the entire e2e suite appears broken.

Fix: `CLAUDE_CODE_DIR = Path(__file__).resolve().parents[1]` resolves to the sibling `claude_code/` dir in either layout, and the `relative_to(REPO_ROOT)` calls become `relative_to(CLAUDE_CODE_DIR)` so test IDs and error messages read the same.

Adds `test_claude_code_dir_anchor_is_layout_independent` as a regression pin: it checks the anchor lands on a directory named `claude_code` that contains this test file, which would fail under the old parents[4] anchor when run from /app/e2e/.

* feat(e2e/claude_code): register compat deployments via /model/new from a session fixture

Every compat cell hardcodes a virtual model name like `claude-sonnet-4-6` or `claude-sonnet-4-6-bedrock-invoke` and hits the proxy expecting it to be routable. On stage those live in the deployed model_list; locally the `docker-config.yaml` under tests/e2e/ only declares one of them, so anything past haiku 400s with `Invalid model name`.

`claude_code/test_config.yaml` is the ground-truth compat matrix config the deployment already uses. `_compat_models.py` loads it, normalizes the yaml keys pydantic would silently drop (vertex_ai_* → vertex_*), and selects the subset whose provider credentials are present in the environment. An autouse session fixture in `conftest.py` POSTs each selected deployment to `/model/new`, blocks until it is servable on the data plane, and tears them all down on session exit. Skips silently when the proxy env is unset so pure-unit runs stay hermetic.

`test_compat_models.py` pins the invariants that keep this safe. Every cell-referenced name must have a yaml entry (drift check catches a cell probing a name the fixture never registered); the yaml has no unused declarations; the fixture registers exactly 15 deployments (3 tiers × 5 provider surfaces); vertex_ai_* yaml keys populate the pydantic body's vertex_* fields (they got silently dropped historically); Azure needs both AZURE_FOUNDRY_* env vars; Bedrock lifts creds from the ambient AWS chain; Vertex needs both the yaml refs AND ambient GCP credentials.

* refactor(e2e/claude_code): inject env + runner instead of monkeypatching

`require_proxy` and `_basic_messaging.run_basic_messaging_cell` now take the env mapping (and the CLI runner) as constructor-style arguments with `os.environ` and `run_claude_models_parallel` as defaults. Tests exercise the branching by passing dicts and callables directly, so `monkeypatch.setenv` and `monkeypatch.setattr(_basic_messaging, "run_claude_models_parallel", ...)` are gone from every unit test in this refactor's blast radius.

`test_env_resolution.py` drops the `monkeypatch.setenv`/`delenv` fixtures and passes `env={...}` dicts to `require_proxy`. Added a new pinned check that a successful resolution leaves `compat_result` untouched, and split the "unset env" test into three explicit shapes (empty, primary-only, legacy-only) so a regression that swaps the precedence rule can no longer hide behind a single monkeypatched fixture.

`test_basic_messaging.py` (driver) replaces the `_install_fake_runner(monkeypatch, ...)` helper with `_make_fake_runner(...)` that returns a `(callable, captured_dict)` pair the test passes in via the helper's new `runner=` kwarg. Also drops the autouse `_proxy_env` fixture in favor of a module-level `_PROXY_ENV` dict each test wires through the helper's new `env=` kwarg. Added a regression pin that a missing-env call hard-fails without ever invoking the runner (so the guard order stays correct).

`test_run_daily_pytest_scrubs_env.py` updates its pin to assert the new suite-wide env spellings (`LITELLM_PROXY_URL` / `LITELLM_MASTER_KEY`) instead of the legacy `LITELLM_PROXY_BASE_URL` / `LITELLM_PROXY_API_KEY` that `run_daily.sh` used to export.

* handwrote rules
2026-07-16 11:05:31 -07:00

204 lines
7.8 KiB
Python

"""structured_outputs x Azure (Microsoft Foundry).
Drive the real `claude` CLI in headless mode with the `--json-schema`
flag, route through a LiteLLM proxy aimed at Azure (Microsoft Foundry), and assert that
the final stream-json `result` event surfaces a `structured_output`
object whose shape matches the schema.
The (feature, provider) for this cell is inferred from the file path by
`tests/e2e/claude_code/conftest.py`:
tests/e2e/claude_code/structured_outputs/test_azure.py
^^^^^^^^^^^^^^^^^^ ^^^^^
feature_id provider
What this row actually exercises (and what it does not):
`--json-schema` is implemented client-side by Claude Code: the CLI
synthesizes a synthetic `StructuredOutput` tool whose `input_schema`
equals the user-supplied JSON Schema, forces the model toward it, and
finally extracts the tool_use input on the trailing `result` event as
`structured_output: {...}`. The proxy never sees `output_config.schema`
in this flow -- it sees a normal `tools` array with one synthetic
tool.
This makes the row a tool-use feature test in disguise. It's still a
distinct row from `tool_use` because:
- The synthetic tool is generated per request from a user schema, not
a developer-declared one. Provider-side bugs that special-case
`Claude Code`-generated tool names (e.g. case-folding `tool_use`
blocks back to lowercase, or stripping the StructuredOutput-only
`additionalProperties: false`) only surface here.
- The success signal lives on the *final* `result` event, not the
intermediate `assistant` events the `tool_use` row checks. A proxy
that drops trailing events (seen in early Bedrock Converse SSE
plumbing) breaks this cell while leaving `tool_use` green.
It is NOT a test of Anthropic's server-side `output_config.schema`
parameter -- that's a different feature used internally by Claude Code
for session-title generation and is not reachable from any CLI flag.
LiteLLM's `output_config`-stripping fixes (2.1.122, 2.1.81) surface in
the `count_tokens` and other HTTP-probe rows, not here.
Three Claude tiers run in parallel; one `compat_result.add(...)` per
tier so the matrix's "all three must pass" rule applies.
"""
from __future__ import annotations
import json
import re
from typing import Any, Mapping, Optional, Sequence, Tuple
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
AZURE_MODELS = [
"claude-haiku-4-5-azure",
"claude-sonnet-4-5-azure",
"claude-opus-4-7-azure",
]
# Minimal schema with one required integer field. Kept intentionally
# small -- the matrix tests the *plumbing*, not the model's ability to
# satisfy a complex schema. A trivial arithmetic prompt + a one-field
# integer schema gives every tier (including Haiku) enough headroom
# that schema satisfaction is essentially deterministic, isolating
# failures to the proxy / transport.
SCHEMA = {
"type": "object",
"properties": {"answer": {"type": "integer"}},
"required": ["answer"],
"additionalProperties": False,
}
SCHEMA_JSON = json.dumps(SCHEMA, separators=(",", ":"))
# A prompt the model has no reason to misanswer; we don't check the
# value, but a wrong answer would suggest the structured-output
# pathway is silently degrading reasoning, which is itself worth
# noticing.
PROMPT = "What is 2 + 2? Reply only via the structured output."
def _extract_structured_output(
events: Sequence[Mapping[str, Any]],
) -> Optional[Mapping[str, Any]]:
"""Return the `structured_output` payload from the last `result` event.
Claude Code emits its terminal stream-json line as
`{"type":"result","structured_output":{...},...}` when a request
used `--json-schema` and the model actually produced a valid tool
call. If the model bailed or the proxy ate the trailing events,
`structured_output` is missing -- which is exactly the failure
mode we want this row to surface, so the caller treats `None` as
"feature did not work end-to-end".
"""
for event in reversed(list(events)):
if event.get("type") != "result":
continue
so = event.get("structured_output")
if isinstance(so, Mapping):
return so
return None
def _validate_against_schema(
payload: Mapping[str, Any], schema: Mapping[str, Any]
) -> Optional[str]:
"""Tiny shape validator covering the subset we actually need.
We deliberately do not pull in `jsonschema` as a test dep: the
matrix's success signal is "does the proxy let the synthetic
StructuredOutput tool round-trip end-to-end", and that's
answerable with a presence + type check over `required` keys.
Any malformed schema beyond that would be a Claude Code bug,
not a LiteLLM-proxy bug, so a deeper check would only add false
failures on the wrong axis.
"""
type_map = {
"integer": int,
"number": (int, float),
"string": str,
"boolean": bool,
"array": list,
"object": Mapping,
}
required = schema.get("required") or []
properties = schema.get("properties") or {}
for key in required:
if key not in payload:
return f"missing required key {key!r}"
expected = (properties.get(key) or {}).get("type")
if expected and expected in type_map:
if not isinstance(payload[key], type_map[expected]):
return (
f"key {key!r} has wrong type: "
f"expected {expected}, got {type(payload[key]).__name__}"
)
# bool is a subclass of int in Python; reject `True`/`False`
# when the schema asked for an integer/number.
if expected in ("integer", "number") and isinstance(payload[key], bool):
return f"key {key!r} is a bool but schema asked for {expected}"
return None
@pytest.mark.covers("llm.messages.azure_foundry.structured_output.nonstream.works")
def test_structured_outputs_azure(compat_result):
"""Drive `claude --json-schema ...` against the LiteLLM proxy and
assert the trailing `result` event contains a schema-conforming
`structured_output`."""
base_url, api_key = require_proxy(compat_result)
outcomes = run_claude_models_parallel(
models=AZURE_MODELS,
prompt=PROMPT,
base_url=base_url,
api_key=api_key,
extra_args=["--json-schema", SCHEMA_JSON],
)
failures = []
for model in AZURE_MODELS:
outcome = outcomes[model]
if isinstance(outcome, ClaudeCLIError):
error = f"[{model}] {outcome}"
compat_result.add({"status": "fail", "error": error})
failures.append(error)
continue
if outcome.exit_code != 0:
error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}"
compat_result.add({"status": "fail", "error": error})
failures.append(error)
continue
payload = _extract_structured_output(outcome.events)
if payload is None:
error = (
f"[{model}] no `structured_output` in trailing result event; "
"Claude Code's StructuredOutput tool round-trip did not "
"complete end-to-end through the proxy"
)
compat_result.add({"status": "fail", "error": error})
failures.append(error)
continue
shape_error = _validate_against_schema(payload, SCHEMA)
if shape_error is not None:
error = f"[{model}] structured_output shape error: {shape_error}; payload={payload}"
compat_result.add({"status": "fail", "error": error})
failures.append(error)
continue
compat_result.add({"status": "pass"})
if failures:
pytest.fail("; ".join(failures), pytrace=False)